mirror of
https://github.com/dcs-liberation/dcs_liberation.git
synced 2025-11-10 14:22:26 +00:00
Add minimum fuel per waypoint on the kneeboard.
This commit is contained in:
parent
3c90a92641
commit
c11c6f40d5
@ -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,
|
||||
|
||||
@ -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), ...
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()}")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user