Add minimum fuel per waypoint on the kneeboard.

This commit is contained in:
Dan Albert 2021-07-17 19:51:55 -07:00
parent 3c90a92641
commit c11c6f40d5
6 changed files with 154 additions and 11 deletions

View File

@ -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? # TODO: Split into PlaneType and HelicopterType?
@dataclass(frozen=True) @dataclass(frozen=True)
class AircraftType(UnitType[Type[FlyingType]]): class AircraftType(UnitType[Type[FlyingType]]):
@ -124,6 +153,8 @@ class AircraftType(UnitType[Type[FlyingType]]):
#: planner will consider this aircraft usable for a mission. #: planner will consider this aircraft usable for a mission.
max_mission_range: Distance max_mission_range: Distance
fuel_consumption: Optional[FuelConsumption]
intra_flight_radio: Optional[Radio] intra_flight_radio: Optional[Radio]
channel_allocator: Optional[RadioChannelAllocator] channel_allocator: Optional[RadioChannelAllocator]
channel_namer: Type[ChannelNamer] channel_namer: Type[ChannelNamer]
@ -246,6 +277,14 @@ class AircraftType(UnitType[Type[FlyingType]]):
f"{mission_range.nautical_miles}NM" 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: try:
introduction = data["introduced"] introduction = data["introduced"]
if introduction is None: if introduction is None:
@ -274,6 +313,7 @@ class AircraftType(UnitType[Type[FlyingType]]):
patrol_altitude=patrol_config.altitude, patrol_altitude=patrol_config.altitude,
patrol_speed=patrol_config.speed, patrol_speed=patrol_config.speed,
max_mission_range=mission_range, max_mission_range=mission_range,
fuel_consumption=fuel_consumption,
intra_flight_radio=radio_config.intra_flight, intra_flight_radio=radio_config.intra_flight,
channel_allocator=radio_config.channel_allocator, channel_allocator=radio_config.channel_allocator,
channel_namer=radio_config.channel_namer, channel_namer=radio_config.channel_namer,

View File

@ -4,7 +4,7 @@ import itertools
import math import math
from collections import Iterable from collections import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Union, Any from typing import Union, Any, TypeVar
METERS_TO_FEET = 3.28084 METERS_TO_FEET = 3.28084
FEET_TO_METERS = 1 / METERS_TO_FEET FEET_TO_METERS = 1 / METERS_TO_FEET
@ -205,7 +205,10 @@ def inches_hg(value: float) -> Pressure:
return Pressure(value) 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 itertools recipe
s -> (s0,s1), (s1,s2), (s2, s3), ... s -> (s0,s1), (s1,s2), (s2, s3), ...

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import itertools
import logging import logging
import random import random
from dataclasses import dataclass from dataclasses import dataclass
@ -80,7 +81,7 @@ from game.theater.missiontarget import MissionTarget
from game.theater.theatergroundobject import TheaterGroundObject from game.theater.theatergroundobject import TheaterGroundObject
from game.transfers import MultiGroupTransport from game.transfers import MultiGroupTransport
from game.unitmap import UnitMap 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.ato import AirTaskingOrder, Package
from gen.callsigns import create_group_callsign_from_unit from gen.callsigns import create_group_callsign_from_unit
from gen.flights.flight import ( from gen.flights.flight import (
@ -1194,8 +1195,57 @@ class AircraftConflictGenerator:
).build() ).build()
# Set here rather than when the FlightData is created so they waypoints # Set here rather than when the FlightData is created so they waypoints
# have their TOTs set. # have their TOTs and fuel minimums set. Once we're more confident in our fuel
self.flights[-1].waypoints = [takeoff_point] + flight.points # 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: def should_delay_flight(self, flight: Flight, start_time: timedelta) -> bool:
if start_time.total_seconds() <= 0: if start_time.total_seconds() <= 0:

View File

@ -2,13 +2,14 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from enum import Enum 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.mapping import Point
from dcs.point import MovingPoint, PointAction from dcs.point import MovingPoint, PointAction
from dcs.unit import Unit from dcs.unit import Unit
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.savecompat import has_save_compat_for
from game.squadrons import Pilot, Squadron from game.squadrons import Pilot, Squadron
from game.theater.controlpoint import ControlPoint, MissionTarget from game.theater.controlpoint import ControlPoint, MissionTarget
from game.utils import Distance, meters from game.utils import Distance, meters
@ -138,7 +139,7 @@ class FlightWaypoint:
Args: Args:
waypoint_type: The waypoint type. waypoint_type: The waypoint type.
x: X cooidinate of the waypoint. x: X coordinate of the waypoint.
y: Y coordinate of the waypoint. y: Y coordinate of the waypoint.
alt: Altitude of the waypoint. By default this is AGL, but it can be alt: Altitude of the waypoint. By default this is AGL, but it can be
changed to MSL by setting alt_type to "RADIO". changed to MSL by setting alt_type to "RADIO".
@ -158,6 +159,8 @@ class FlightWaypoint:
self.pretty_name = "" self.pretty_name = ""
self.only_for_player = False self.only_for_player = False
self.flyover = 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 # 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 # 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.tot: Optional[timedelta] = None
self.departure_time: 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 @property
def position(self) -> Point: def position(self) -> Point:
return Point(self.x, self.y) return Point(self.x, self.y)

View File

@ -20,6 +20,7 @@ from dcs.unit import Unit
from shapely.geometry import Point as ShapelyPoint from shapely.geometry import Point as ShapelyPoint
from game.data.doctrine import Doctrine from game.data.doctrine import Doctrine
from game.dcs.aircrafttype import FuelConsumption
from game.flightplan import IpZoneGeometry, JoinZoneGeometry, HoldZoneGeometry from game.flightplan import IpZoneGeometry, JoinZoneGeometry, HoldZoneGeometry
from game.theater import ( from game.theater import (
Airfield, Airfield,
@ -138,6 +139,17 @@ class FlightPlan:
@cached_property @cached_property
def bingo_fuel(self) -> int: def bingo_fuel(self) -> int:
"""Bingo fuel value for the FlightPlan""" """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) distance_to_arrival = self.max_distance_from(self.flight.arrival)
bingo = 1000.0 # Minimum Emergency Fuel bingo = 1000.0 # Minimum Emergency Fuel

View File

@ -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. aircraft will be able to see the enemy's kneeboard for the same airframe.
""" """
import datetime import datetime
import math
import textwrap import textwrap
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
@ -39,8 +40,8 @@ from game.db import unit_type_from_name
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.theater import ConflictTheater, TheaterGroundObject, LatLon from game.theater import ConflictTheater, TheaterGroundObject, LatLon
from game.theater.bullseye import Bullseye from game.theater.bullseye import Bullseye
from game.weather import Weather
from game.utils import meters from game.utils import meters
from game.weather import Weather
from .aircraft import FlightData from .aircraft import FlightData
from .airsupportgen import AwacsInfo, TankerInfo from .airsupportgen import AwacsInfo, TankerInfo
from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator
@ -111,12 +112,17 @@ class KneeboardPageWriter:
self.text(text, font=self.heading_font, fill=self.foreground_fill) self.text(text, font=self.heading_font, fill=self.foreground_fill)
def table( 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: ) -> None:
if headers is None: if headers is None:
headers = [] headers = []
if font is None:
font = self.table_font
table = tabulate(cells, headers=headers, numalign="right") 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: def write(self, path: Path) -> None:
self.image.save(path) self.image.save(path)
@ -199,6 +205,7 @@ class FlightPlanBuilder:
self._ground_speed(self.target_points[0].waypoint), 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.tot),
self._format_time(self.target_points[0].waypoint.departure_time), 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 self.last_waypoint = self.target_points[-1].waypoint
@ -216,6 +223,7 @@ class FlightPlanBuilder:
self._ground_speed(waypoint.waypoint), self._ground_speed(waypoint.waypoint),
self._format_time(waypoint.waypoint.tot), self._format_time(waypoint.waypoint.tot),
self._format_time(waypoint.waypoint.departure_time), 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 duration = (waypoint.tot - last_time).total_seconds() / 3600
return f"{int(distance.nautical_miles / duration)} kt" 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]]: def build(self) -> List[List[str]]:
return self.rows return self.rows
@ -276,6 +290,11 @@ class BriefingPage(KneeboardPage):
self.weather = weather self.weather = weather
self.start_time = start_time self.start_time = start_time
self.dark_kneeboard = dark_kneeboard 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: def write(self, path: Path) -> None:
writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard) writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard)
@ -302,7 +321,17 @@ class BriefingPage(KneeboardPage):
flight_plan_builder.add_waypoint(num, waypoint) flight_plan_builder.add_waypoint(num, waypoint)
writer.table( writer.table(
flight_plan_builder.build(), 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()}") writer.text(f"Bullseye: {self.bullseye.to_lat_lon(self.theater).format_dms()}")