diff --git a/game/ato/flightplans/flightplan.py b/game/ato/flightplans/flightplan.py index af03e78f..819aa858 100644 --- a/game/ato/flightplans/flightplan.py +++ b/game/ato/flightplans/flightplan.py @@ -12,7 +12,6 @@ from abc import ABC, abstractmethod from collections.abc import Iterator from dataclasses import dataclass from datetime import datetime, timedelta -from functools import cached_property from typing import Any, Generic, TYPE_CHECKING, TypeGuard, TypeVar from game.typeguard import self_type_guard @@ -23,7 +22,6 @@ from ..starttype import StartType from ..traveltime import GroundSpeed if TYPE_CHECKING: - from game.dcs.aircrafttype import FuelConsumption from game.theater import ControlPoint from ..flight import Flight from ..flightwaypoint import FlightWaypoint @@ -162,39 +160,6 @@ class FlightPlan(ABC, Generic[LayoutT]): def tot(self) -> datetime: return self.package.time_over_target + self.tot_offset - @cached_property - def bingo_fuel(self) -> int: - """Bingo fuel value for the FlightPlan""" - if (fuel := self.flight.unit_type.fuel_consumption) is not None: - return self._bingo_estimate(fuel) - return self._legacy_bingo_estimate() - - def _bingo_estimate(self, fuel: FuelConsumption) -> int: - distance_to_arrival = self.max_distance_from(self.flight.arrival) - fuel_consumed = fuel.cruise * distance_to_arrival.nautical_miles - bingo = fuel_consumed + fuel.min_safe - return math.ceil(bingo / 100) * 100 - - def _legacy_bingo_estimate(self) -> int: - distance_to_arrival = self.max_distance_from(self.flight.arrival) - - bingo = 1000.0 # Minimum Emergency Fuel - bingo += 500 # Visual Traffic - bingo += 15 * distance_to_arrival.nautical_miles - - # TODO: Per aircraft tweaks. - - if self.flight.divert is not None: - max_divert_distance = self.max_distance_from(self.flight.divert) - bingo += 10 * max_divert_distance.nautical_miles - - return round(bingo / 100) * 100 - - @cached_property - def joker_fuel(self) -> int: - """Joker fuel value for the FlightPlan""" - return self.bingo_fuel + 1000 - def max_distance_from(self, cp: ControlPoint) -> Distance: """Returns the farthest waypoint of the flight plan from a ControlPoint. :arg cp The ControlPoint to measure distance from. diff --git a/game/missiongenerator/aircraft/bingoestimator.py b/game/missiongenerator/aircraft/bingoestimator.py new file mode 100644 index 00000000..a5abee11 --- /dev/null +++ b/game/missiongenerator/aircraft/bingoestimator.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from dcs import Point + +from game.utils import Distance, meters + +if TYPE_CHECKING: + from game.ato.flightwaypoint import FlightWaypoint + from game.dcs.aircrafttype import FuelConsumption + + +class BingoEstimator: + """Estimates bingo/joker fuel values for a flight plan. + + The results returned by this class are bogus for most airframes. Only the few + airframes which have fuel consumption data available can provide even moderately + reliable estimates. **Do not use this for flight planning.** This should only be + used in briefing context where it's okay to be wrong. + """ + + def __init__( + self, + fuel_consumption: FuelConsumption | None, + arrival: Point, + divert: Point | None, + waypoints: list[FlightWaypoint], + ) -> None: + self.fuel_consumption = fuel_consumption + self.arrival = arrival + self.divert = divert + self.waypoints = waypoints + + def estimate_bingo(self) -> int: + """Bingo fuel value for the FlightPlan""" + if (fuel := self.fuel_consumption) is not None: + return self._fuel_consumption_based_estimate(fuel) + return self._legacy_bingo_estimate() + + def estimate_joker(self) -> int: + """Joker fuel value for the FlightPlan""" + return self.estimate_bingo() + 1000 + + def _fuel_consumption_based_estimate(self, fuel: FuelConsumption) -> int: + distance_to_arrival = self._max_distance_from(self.arrival) + fuel_consumed = fuel.cruise * distance_to_arrival.nautical_miles + bingo = fuel_consumed + fuel.min_safe + return math.ceil(bingo / 100) * 100 + + def _legacy_bingo_estimate(self) -> int: + distance_to_arrival = self._max_distance_from(self.arrival) + + bingo = 1000.0 # Minimum Emergency Fuel + bingo += 500 # Visual Traffic + bingo += 15 * distance_to_arrival.nautical_miles + + if self.divert is not None: + max_divert_distance = self._max_distance_from(self.divert) + bingo += 10 * max_divert_distance.nautical_miles + + return round(bingo / 100) * 100 + + def _max_distance_from(self, point: Point) -> Distance: + return max(meters(point.distance_to_point(w.position)) for w in self.waypoints) diff --git a/game/missiongenerator/aircraft/flightgroupconfigurator.py b/game/missiongenerator/aircraft/flightgroupconfigurator.py index 9fb18513..ec42092c 100644 --- a/game/missiongenerator/aircraft/flightgroupconfigurator.py +++ b/game/missiongenerator/aircraft/flightgroupconfigurator.py @@ -4,7 +4,7 @@ import logging from datetime import datetime from typing import Any, Optional, TYPE_CHECKING -from dcs import Mission +from dcs import Mission, Point from dcs.action import DoScript from dcs.flyingunit import FlyingUnit from dcs.task import OptReactOnThreat @@ -24,6 +24,7 @@ from game.runways import RunwayData from game.squadrons import Pilot from .aircraftbehavior import AircraftBehavior from .aircraftpainter import AircraftPainter +from .bingoestimator import BingoEstimator from .flightdata import FlightData from .waypoints import WaypointGenerator from ...ato.flightmember import FlightMember @@ -115,6 +116,16 @@ class FlightGroupConfigurator: # Need to set uncontrolled to false, otherwise the AI will skip the mission and just land self.group.uncontrolled = False + divert_position: Point | None = None + if self.flight.divert is not None: + divert_position = self.flight.divert.position + bingo_estimator = BingoEstimator( + self.flight.unit_type.fuel_consumption, + self.flight.arrival.position, + divert_position, + self.flight.flight_plan.waypoints, + ) + return FlightData( package=self.flight.package, aircraft_type=self.flight.unit_type, @@ -133,8 +144,8 @@ class FlightGroupConfigurator: divert=divert, waypoints=waypoints, intra_flight_channel=flight_channel, - bingo_fuel=self.flight.flight_plan.bingo_fuel, - joker_fuel=self.flight.flight_plan.joker_fuel, + bingo_fuel=bingo_estimator.estimate_bingo(), + joker_fuel=bingo_estimator.estimate_joker(), custom_name=self.flight.custom_name, laser_codes=laser_codes, ) diff --git a/tests/missiongenerator/aircraft/test_bingoestimator.py b/tests/missiongenerator/aircraft/test_bingoestimator.py new file mode 100644 index 00000000..338f7bfd --- /dev/null +++ b/tests/missiongenerator/aircraft/test_bingoestimator.py @@ -0,0 +1,51 @@ +import pytest +from dcs import Point +from dcs.terrain import Terrain, Caucasus + +from game.ato import FlightWaypoint +from game.ato.flightwaypointtype import FlightWaypointType +from game.dcs.aircrafttype import FuelConsumption +from game.missiongenerator.aircraft.bingoestimator import BingoEstimator +from game.utils import nautical_miles + + +@pytest.fixture(name="terrain") +def terrain_fixture() -> Terrain: + return Caucasus() + + +@pytest.fixture(name="waypoints") +def waypoints_fixture(terrain: Terrain) -> list[FlightWaypoint]: + return [ + FlightWaypoint( + "", FlightWaypointType.NAV, Point(0, nautical_miles(d).meters, terrain) + ) + for d in range(101) + ] + + +def test_legacy_bingo_estimator( + waypoints: list[FlightWaypoint], terrain: Terrain +) -> None: + estimator = BingoEstimator(None, Point(0, 0, terrain), None, waypoints) + assert estimator.estimate_bingo() == 3000 + assert estimator.estimate_joker() == estimator.estimate_bingo() + 1000 + estimator = BingoEstimator( + None, Point(0, 0, terrain), Point(0, 5, terrain), waypoints + ) + assert estimator.estimate_bingo() == 4000 + assert estimator.estimate_joker() == estimator.estimate_bingo() + 1000 + + +def test_fuel_consumption_based_bingo_estimator( + waypoints: list[FlightWaypoint], terrain: Terrain +) -> None: + consumption = FuelConsumption(100, 50, 10, 25, 1000) + estimator = BingoEstimator(consumption, Point(0, 0, terrain), None, waypoints) + assert estimator.estimate_bingo() == 2000 + assert estimator.estimate_joker() == estimator.estimate_bingo() + 1000 + estimator = BingoEstimator( + consumption, Point(0, 0, terrain), Point(0, 5, terrain), waypoints + ) + assert estimator.estimate_bingo() == 2000 + assert estimator.estimate_joker() == estimator.estimate_bingo() + 1000