From a2e98f485c62ad950ba6a5ea63f8e633e2a52b4c Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 23 Aug 2023 20:05:17 -0700 Subject: [PATCH] Remove bingo estimates from FlightPlan. This doesn't need to be a part of FlightPlan, and it's easier to test if it isn't. Move it out and add the tests. It's pretty misleading to allow this in the core of the flight plan code anything. This is an extremely unreliable estimate for most aircraft so it should be more clearly just for briefing purposes. --- game/ato/flightplans/flightplan.py | 35 ---------- .../aircraft/bingoestimator.py | 66 +++++++++++++++++++ .../aircraft/flightgroupconfigurator.py | 17 ++++- .../aircraft/test_bingoestimator.py | 51 ++++++++++++++ 4 files changed, 131 insertions(+), 38 deletions(-) create mode 100644 game/missiongenerator/aircraft/bingoestimator.py create mode 100644 tests/missiongenerator/aircraft/test_bingoestimator.py 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