diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index c51ef65d..5158f240 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -105,6 +105,35 @@ class PatrolConfig: ) +@dataclass(frozen=True) +class FuelConsumption: + #: The estimated taxi fuel requirement, in pounds. + taxi: int + + #: The estimated fuel consumption for a takeoff climb, in pounds per nautical mile. + climb: float + + #: The estimated fuel consumption for cruising, in pounds per nautical mile. + cruise: float + + #: The estimated fuel consumption for combat speeds, in pounds per nautical mile. + combat: float + + #: The minimum amount of fuel that the aircraft should land with, in pounds. This is + #: a reserve amount for landing delays or emergencies. + min_safe: int + + @classmethod + def from_data(cls, data: dict[str, Any]) -> FuelConsumption: + return FuelConsumption( + int(data["taxi"]), + float(data["climb_ppm"]), + float(data["cruise_ppm"]), + float(data["combat_ppm"]), + int(data["min_safe"]), + ) + + # TODO: Split into PlaneType and HelicopterType? @dataclass(frozen=True) class AircraftType(UnitType[Type[FlyingType]]): @@ -124,6 +153,8 @@ class AircraftType(UnitType[Type[FlyingType]]): #: planner will consider this aircraft usable for a mission. max_mission_range: Distance + fuel_consumption: Optional[FuelConsumption] + intra_flight_radio: Optional[Radio] channel_allocator: Optional[RadioChannelAllocator] channel_namer: Type[ChannelNamer] @@ -246,6 +277,14 @@ class AircraftType(UnitType[Type[FlyingType]]): f"{mission_range.nautical_miles}NM" ) + fuel_data = data.get("fuel") + if fuel_data is not None: + fuel_consumption: Optional[FuelConsumption] = FuelConsumption.from_data( + fuel_data + ) + else: + fuel_consumption = None + try: introduction = data["introduced"] if introduction is None: @@ -274,6 +313,7 @@ class AircraftType(UnitType[Type[FlyingType]]): patrol_altitude=patrol_config.altitude, patrol_speed=patrol_config.speed, max_mission_range=mission_range, + fuel_consumption=fuel_consumption, intra_flight_radio=radio_config.intra_flight, channel_allocator=radio_config.channel_allocator, channel_namer=radio_config.channel_namer, diff --git a/game/utils.py b/game/utils.py index 39daa058..b21b11df 100644 --- a/game/utils.py +++ b/game/utils.py @@ -4,7 +4,7 @@ import itertools import math from collections import Iterable from dataclasses import dataclass -from typing import Union, Any +from typing import Union, Any, TypeVar METERS_TO_FEET = 3.28084 FEET_TO_METERS = 1 / METERS_TO_FEET @@ -205,7 +205,10 @@ def inches_hg(value: float) -> Pressure: return Pressure(value) -def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]: +PairwiseT = TypeVar("PairwiseT") + + +def pairwise(iterable: Iterable[PairwiseT]) -> Iterable[tuple[PairwiseT, PairwiseT]]: """ itertools recipe s -> (s0,s1), (s1,s2), (s2, s3), ... diff --git a/gen/aircraft.py b/gen/aircraft.py index 392cd70c..144344df 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -1,5 +1,6 @@ from __future__ import annotations +import itertools import logging import random from dataclasses import dataclass @@ -80,7 +81,7 @@ from game.theater.missiontarget import MissionTarget from game.theater.theatergroundobject import TheaterGroundObject from game.transfers import MultiGroupTransport from game.unitmap import UnitMap -from game.utils import Distance, meters, nautical_miles +from game.utils import Distance, meters, nautical_miles, pairwise from gen.ato import AirTaskingOrder, Package from gen.callsigns import create_group_callsign_from_unit from gen.flights.flight import ( @@ -1194,8 +1195,57 @@ class AircraftConflictGenerator: ).build() # Set here rather than when the FlightData is created so they waypoints - # have their TOTs set. - self.flights[-1].waypoints = [takeoff_point] + flight.points + # have their TOTs and fuel minimums set. Once we're more confident in our fuel + # estimation ability the minimum fuel amounts will be calculated during flight + # plan construction, but for now it's only used by the kneeboard so is generated + # late. + waypoints = [takeoff_point] + flight.points + self._estimate_min_fuel_for(flight, waypoints) + self.flights[-1].waypoints = waypoints + + @staticmethod + def _estimate_min_fuel_for(flight: Flight, waypoints: list[FlightWaypoint]) -> None: + if flight.unit_type.fuel_consumption is None: + return + + combat_speed_types = { + FlightWaypointType.INGRESS_BAI, + FlightWaypointType.INGRESS_CAS, + FlightWaypointType.INGRESS_DEAD, + FlightWaypointType.INGRESS_ESCORT, + FlightWaypointType.INGRESS_OCA_AIRCRAFT, + FlightWaypointType.INGRESS_OCA_RUNWAY, + FlightWaypointType.INGRESS_SEAD, + FlightWaypointType.INGRESS_STRIKE, + FlightWaypointType.INGRESS_SWEEP, + FlightWaypointType.SPLIT, + } | set(TARGET_WAYPOINTS) + + consumption = flight.unit_type.fuel_consumption + min_fuel: float = consumption.min_safe + + # The flight plan (in reverse) up to and including the arrival point. + main_flight_plan = reversed(waypoints) + try: + while waypoint := next(main_flight_plan): + if waypoint.waypoint_type is FlightWaypointType.LANDING_POINT: + waypoint.min_fuel = min_fuel + main_flight_plan = itertools.chain([waypoint], main_flight_plan) + break + except StopIteration: + # Some custom flight plan without a landing point. Skip it. + return + + for b, a in pairwise(main_flight_plan): + distance = meters(a.position.distance_to_point(b.position)) + if a.waypoint_type is FlightWaypointType.TAKEOFF: + ppm = consumption.climb + elif b.waypoint_type in combat_speed_types: + ppm = consumption.combat + else: + ppm = consumption.cruise + min_fuel += distance.nautical_miles * ppm + a.min_fuel = min_fuel def should_delay_flight(self, flight: Flight, start_time: timedelta) -> bool: if start_time.total_seconds() <= 0: diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 05ee4c19..5c3241f7 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -2,13 +2,14 @@ from __future__ import annotations from datetime import timedelta from enum import Enum -from typing import List, Optional, TYPE_CHECKING, Union, Sequence +from typing import List, Optional, TYPE_CHECKING, Union, Sequence, Any from dcs.mapping import Point from dcs.point import MovingPoint, PointAction from dcs.unit import Unit from game.dcs.aircrafttype import AircraftType +from game.savecompat import has_save_compat_for from game.squadrons import Pilot, Squadron from game.theater.controlpoint import ControlPoint, MissionTarget from game.utils import Distance, meters @@ -138,7 +139,7 @@ class FlightWaypoint: Args: waypoint_type: The waypoint type. - x: X cooidinate of the waypoint. + x: X coordinate of the waypoint. y: Y coordinate of the waypoint. alt: Altitude of the waypoint. By default this is AGL, but it can be changed to MSL by setting alt_type to "RADIO". @@ -158,6 +159,8 @@ class FlightWaypoint: self.pretty_name = "" self.only_for_player = False self.flyover = False + # The minimum amount of fuel remaining at this waypoint in pounds. + self.min_fuel: Optional[float] = None # These are set very late by the air conflict generator (part of mission # generation). We do it late so that we don't need to propagate changes @@ -166,6 +169,12 @@ class FlightWaypoint: self.tot: Optional[timedelta] = None self.departure_time: Optional[timedelta] = None + @has_save_compat_for(5) + def __setstate__(self, state: dict[str, Any]) -> None: + if "min_fuel" not in state: + state["min_fuel"] = None + self.__dict__.update(state) + @property def position(self) -> Point: return Point(self.x, self.y) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 4f28e4cf..6c25babd 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -20,6 +20,7 @@ from dcs.unit import Unit from shapely.geometry import Point as ShapelyPoint from game.data.doctrine import Doctrine +from game.dcs.aircrafttype import FuelConsumption from game.flightplan import IpZoneGeometry, JoinZoneGeometry, HoldZoneGeometry from game.theater import ( Airfield, @@ -138,6 +139,17 @@ class FlightPlan: @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 diff --git a/gen/kneeboard.py b/gen/kneeboard.py index 20fb8ca1..1b0f09b1 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -23,6 +23,7 @@ only be added per airframe, so PvP missions where each side have the same aircraft will be able to see the enemy's kneeboard for the same airframe. """ import datetime +import math import textwrap from collections import defaultdict from dataclasses import dataclass @@ -39,8 +40,8 @@ from game.db import unit_type_from_name from game.dcs.aircrafttype import AircraftType from game.theater import ConflictTheater, TheaterGroundObject, LatLon from game.theater.bullseye import Bullseye -from game.weather import Weather from game.utils import meters +from game.weather import Weather from .aircraft import FlightData from .airsupportgen import AwacsInfo, TankerInfo from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator @@ -111,12 +112,17 @@ class KneeboardPageWriter: self.text(text, font=self.heading_font, fill=self.foreground_fill) def table( - self, cells: List[List[str]], headers: Optional[List[str]] = None + self, + cells: List[List[str]], + headers: Optional[List[str]] = None, + font: Optional[ImageFont.FreeTypeFont] = None, ) -> None: if headers is None: headers = [] + if font is None: + font = self.table_font table = tabulate(cells, headers=headers, numalign="right") - self.text(table, font=self.table_font, fill=self.foreground_fill) + self.text(table, font, fill=self.foreground_fill) def write(self, path: Path) -> None: self.image.save(path) @@ -199,6 +205,7 @@ class FlightPlanBuilder: self._ground_speed(self.target_points[0].waypoint), self._format_time(self.target_points[0].waypoint.tot), self._format_time(self.target_points[0].waypoint.departure_time), + self._format_min_fuel(self.target_points[0].waypoint.min_fuel), ] ) self.last_waypoint = self.target_points[-1].waypoint @@ -216,6 +223,7 @@ class FlightPlanBuilder: self._ground_speed(waypoint.waypoint), self._format_time(waypoint.waypoint.tot), self._format_time(waypoint.waypoint.departure_time), + self._format_min_fuel(waypoint.waypoint.min_fuel), ] ) @@ -254,6 +262,12 @@ class FlightPlanBuilder: duration = (waypoint.tot - last_time).total_seconds() / 3600 return f"{int(distance.nautical_miles / duration)} kt" + @staticmethod + def _format_min_fuel(min_fuel: Optional[float]) -> str: + if min_fuel is None: + return "" + return str(math.ceil(min_fuel / 100) * 100) + def build(self) -> List[List[str]]: return self.rows @@ -276,6 +290,11 @@ class BriefingPage(KneeboardPage): self.weather = weather self.start_time = start_time self.dark_kneeboard = dark_kneeboard + self.flight_plan_font = ImageFont.truetype( + "resources/fonts/Inconsolata.otf", + 16, + layout_engine=ImageFont.LAYOUT_BASIC, + ) def write(self, path: Path) -> None: writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard) @@ -302,7 +321,17 @@ class BriefingPage(KneeboardPage): flight_plan_builder.add_waypoint(num, waypoint) writer.table( flight_plan_builder.build(), - headers=["#", "Action", "Alt", "Dist", "GSPD", "Time", "Departure"], + headers=[ + "#", + "Action", + "Alt", + "Dist", + "GSPD", + "Time", + "Departure", + "Min fuel", + ], + font=self.flight_plan_font, ) writer.text(f"Bullseye: {self.bullseye.to_lat_lon(self.theater).format_dms()}")