Estimate TOTs for packages.

We estimate the longest possible time from mission start to TOT for
all flights in a package and use that to set the TOT (plus any delay
used to stagger flights). This both cuts down on loiter time for
shorter flights and ensures that long flights will make it to the
target in time.

This is also used to compute the start time for the AI, so the
explicit delay option is no longer needed.
This commit is contained in:
Dan Albert 2020-10-09 21:25:56 -07:00
parent d414c00b74
commit 974b6590d8
14 changed files with 497 additions and 322 deletions

View File

@ -57,14 +57,14 @@ from dcs.task import (
)
from dcs.terrain.terrain import Airport, NoParkingSlotError
from dcs.translation import String
from dcs.triggers import Event, TriggerOnce
from dcs.triggers import Event, TriggerOnce, TriggerRule
from dcs.unitgroup import FlyingGroup, Group, ShipGroup, StaticGroup
from dcs.unittype import FlyingType, UnitType
from game import db
from game.data.cap_capabilities_db import GUNFIGHTERS
from game.settings import Settings
from game.utils import meter_to_nm, nm_to_meter
from game.utils import nm_to_meter
from gen.airfields import RunwayData
from gen.airsupportgen import AirSupport
from gen.ato import AirTaskingOrder, Package
@ -76,9 +76,10 @@ from gen.flights.flight import (
FlightWaypointType,
)
from gen.radios import MHz, Radio, RadioFrequency, RadioRegistry, get_radio
from theater import MissionTarget, TheaterGroundObject
from theater import TheaterGroundObject
from theater.controlpoint import ControlPoint, ControlPointType
from .conflictgen import Conflict
from .flights.traveltime import PackageWaypointTiming, TotEstimator
from .naming import namegen
WARM_START_HELI_AIRSPEED = 120
@ -86,8 +87,6 @@ WARM_START_HELI_ALT = 500
WARM_START_ALTITUDE = 3000
WARM_START_AIRSPEED = 550
CAP_DURATION = 30 # minutes
RTB_ALTITUDE = 800
RTB_DISTANCE = 5000
HELI_ALT = 500
@ -217,7 +216,7 @@ class FlightData:
#: True if this flight belongs to the player's coalition.
friendly: bool
#: Number of minutes after mission start the flight is set to depart.
#: Number of seconds after mission start the flight is set to depart.
departure_delay: int
#: Arrival airport.
@ -533,148 +532,6 @@ AIRCRAFT_DATA["P-51D-30-NA"] = AIRCRAFT_DATA["P-51D"]
AIRCRAFT_DATA["P-47D-30"] = AIRCRAFT_DATA["P-51D"]
@dataclass(frozen=True)
class PackageWaypointTiming:
#: The package being scheduled.
package: Package
#: The package join time.
join: int
#: The ingress waypoint TOT.
ingress: int
#: The egress waypoint TOT.
egress: int
#: The package split time.
split: int
@property
def target(self) -> int:
"""The package time over target."""
assert self.package.time_over_target is not None
return self.package.time_over_target
@property
def race_track_start(self) -> Optional[int]:
cap_types = (FlightType.BARCAP, FlightType.CAP)
if self.package.primary_task in cap_types:
# CAP flights don't have hold points, and we don't calculate takeoff
# times yet or adjust the TOT based on when the flight can arrive,
# so if we set a TOT that gives the flight a lot of extra time it
# will just fly to the start point slowly, possibly slowly enough to
# stall and crash. Just don't set a TOT for these points and let the
# CAP get on station ASAP.
return None
else:
return self.ingress
@property
def race_track_end(self) -> int:
cap_types = (FlightType.BARCAP, FlightType.CAP)
if self.package.primary_task in cap_types:
return self.target + CAP_DURATION * 60
else:
return self.egress
def push_time(self, flight: Flight, hold_point: Point) -> int:
assert self.package.waypoints is not None
return self.join - self.travel_time(
hold_point,
self.package.waypoints.join,
self.flight_ground_speed(flight)
)
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[int]:
target_types = (
FlightWaypointType.TARGET_GROUP_LOC,
FlightWaypointType.TARGET_POINT,
FlightWaypointType.TARGET_SHIP,
)
ingress_types = (
FlightWaypointType.INGRESS_CAS,
FlightWaypointType.INGRESS_SEAD,
FlightWaypointType.INGRESS_STRIKE,
)
if waypoint.waypoint_type == FlightWaypointType.JOIN:
return self.join
elif waypoint.waypoint_type in ingress_types:
return self.ingress
elif waypoint.waypoint_type in target_types:
return self.target
elif waypoint.waypoint_type == FlightWaypointType.EGRESS:
return self.egress
elif waypoint.waypoint_type == FlightWaypointType.SPLIT:
return self.split
elif waypoint.waypoint_type == FlightWaypointType.PATROL_TRACK:
return self.race_track_start
return None
def depart_time_for_waypoint(self, waypoint: FlightWaypoint,
flight: Flight) -> Optional[int]:
if waypoint.waypoint_type == FlightWaypointType.LOITER:
return self.push_time(flight, Point(waypoint.x, waypoint.y))
elif waypoint.waypoint_type == FlightWaypointType.PATROL:
return self.race_track_end
return None
@classmethod
def for_package(cls, package: Package) -> PackageWaypointTiming:
assert package.time_over_target is not None
assert package.waypoints is not None
group_ground_speed = cls.package_ground_speed(package)
ingress = package.time_over_target - cls.travel_time(
package.waypoints.ingress,
package.target.position,
group_ground_speed
)
join = ingress - cls.travel_time(
package.waypoints.join,
package.waypoints.ingress,
group_ground_speed
)
egress = package.time_over_target + cls.travel_time(
package.target.position,
package.waypoints.egress,
group_ground_speed
)
split = egress + cls.travel_time(
package.waypoints.egress,
package.waypoints.split,
group_ground_speed
)
return cls(package, join, ingress, egress, split)
@classmethod
def package_ground_speed(cls, package: Package) -> int:
speeds = []
for flight in package.flights:
speeds.append(cls.flight_ground_speed(flight))
return min(speeds) # knots
@staticmethod
def flight_ground_speed(_flight: Flight) -> int:
# TODO: Gather data so this is useful.
return 400 # knots
@staticmethod
def travel_time(a: Point, b: Point, speed: float) -> int:
error_factor = 1.1
distance = meter_to_nm(a.distance_to_point(b))
hours = distance / speed
seconds = hours * 3600
return int(seconds * error_factor)
class AircraftConflictGenerator:
def __init__(self, mission: Mission, conflict: Conflict, settings: Settings,
game, radio_registry: RadioRegistry):
@ -702,8 +559,13 @@ class AircraftConflictGenerator:
except KeyError:
return get_fallback_channel(airframe)
def _start_type(self) -> StartType:
return self.settings.cold_start and StartType.Cold or StartType.Warm
@staticmethod
def _start_type(start_type: str) -> StartType:
if start_type == "Runway":
return StartType.Runway
elif start_type == "Cold":
return StartType.Cold
return StartType.Warm
def _setup_group(self, group: FlyingGroup, for_task: Type[Task],
flight: Flight, dynamic_runways: Dict[str, RunwayData]):
@ -808,15 +670,10 @@ class AircraftConflictGenerator:
return runways[0]
def _generate_at_airport(self, name: str, side: Country,
unit_type: FlyingType, count: int,
client_count: int,
airport: Optional[Airport] = None,
start_type=None) -> FlyingGroup:
unit_type: FlyingType, count: int, start_type: str,
airport: Optional[Airport] = None) -> FlyingGroup:
assert count > 0
if start_type is None:
start_type = self._start_type()
logging.info("airgen: {} for {} at {}".format(unit_type, side.id, airport))
return self.m.flight_group_from_airport(
country=side,
@ -824,11 +681,11 @@ class AircraftConflictGenerator:
aircraft_type=unit_type,
airport=airport,
maintask=None,
start_type=start_type,
start_type=self._start_type(start_type),
group_size=count,
parking_slots=None)
def _generate_inflight(self, name: str, side: Country, unit_type: FlyingType, count: int, client_count: int, at: Point) -> FlyingGroup:
def _generate_inflight(self, name: str, side: Country, unit_type: FlyingType, count: int, at: Point) -> FlyingGroup:
assert count > 0
if unit_type in helicopters.helicopter_map.values():
@ -850,21 +707,16 @@ class AircraftConflictGenerator:
altitude=alt,
speed=speed,
maintask=None,
start_type=self._start_type(),
group_size=count)
group.points[0].alt_type = "RADIO"
return group
def _generate_at_group(self, name: str, side: Country,
unit_type: FlyingType, count: int, client_count: int,
at: Union[ShipGroup, StaticGroup],
start_type=None) -> FlyingGroup:
unit_type: FlyingType, count: int, start_type: str,
at: Union[ShipGroup, StaticGroup]) -> FlyingGroup:
assert count > 0
if start_type is None:
start_type = self._start_type()
logging.info("airgen: {} for {} at unit {}".format(unit_type, side.id, at))
return self.m.flight_group_from_unit(
country=side,
@ -872,34 +724,9 @@ class AircraftConflictGenerator:
aircraft_type=unit_type,
pad_group=at,
maintask=None,
start_type=start_type,
start_type=self._start_type(start_type),
group_size=count)
def _generate_group(self, name: str, side: Country, unit_type: FlyingType, count: int, client_count: int, at: db.StartingPosition):
if isinstance(at, Point):
return self._generate_inflight(name, side, unit_type, count, client_count, at)
elif isinstance(at, Group):
takeoff_ban = unit_type in db.CARRIER_TAKEOFF_BAN
ai_ban = client_count == 0 and self.settings.only_player_takeoff
if not takeoff_ban and not ai_ban:
return self._generate_at_group(name, side, unit_type, count, client_count, at)
else:
return self._generate_inflight(name, side, unit_type, count, client_count, at.position)
elif isinstance(at, Airport):
takeoff_ban = unit_type in db.TAKEOFF_BAN
ai_ban = client_count == 0 and self.settings.only_player_takeoff
if not takeoff_ban and not ai_ban:
try:
return self._generate_at_airport(name, side, unit_type, count, client_count, at)
except NoParkingSlotError:
logging.info("No parking slot found at " + at.name + ", switching to air start.")
pass
return self._generate_inflight(name, side, unit_type, count, client_count, at.position)
else:
assert False
def _add_radio_waypoint(self, group: FlyingGroup, position, altitude: int, airspeed: int = 600):
point = group.add_waypoint(position, altitude, airspeed)
point.alt_type = "RADIO"
@ -970,110 +797,91 @@ class AircraftConflictGenerator:
logging.info(f"Generating flight: {flight.unit_type}")
group = self.generate_planned_flight(flight.from_cp, country,
flight)
self.setup_flight_group(group, flight, timing, dynamic_runways)
self.setup_group_activation_trigger(flight, group)
self.setup_flight_group(group, package, flight, timing,
dynamic_runways)
def setup_group_activation_trigger(self, flight, group):
if flight.scheduled_in > 0 and flight.client_count == 0:
def set_activation_time(self, flight: Flight, group: FlyingGroup,
delay: int) -> None:
# Note: Late activation causes the waypoint TOTs to look *weird* in the
# mission editor. Waypoint times will be relative to the group
# activation time rather than in absolute local time. A flight delayed
# until 09:10 when the overall mission start time is 09:00, with a join
# time of 09:30 will show the join time as 00:30, not 09:30.
group.late_activation = True
if flight.start_type != "In Flight" and flight.from_cp.cptype not in [ControlPointType.AIRCRAFT_CARRIER_GROUP, ControlPointType.LHA_GROUP]:
group.late_activation = False
group.uncontrolled = True
activation_trigger = TriggerOnce(
Event.NoEvent, f"FlightLateActivationTrigger{group.id}")
activation_trigger.add_condition(TimeAfter(seconds=delay))
activation_trigger = TriggerOnce(Event.NoEvent, "FlightStartTrigger" + str(group.id))
activation_trigger.add_condition(TimeAfter(seconds=flight.scheduled_in * 60))
if (flight.from_cp.cptype == ControlPointType.AIRBASE):
if flight.from_cp.captured:
activation_trigger.add_condition(
CoalitionHasAirdrome(self.game.get_player_coalition_id(), flight.from_cp.id))
else:
activation_trigger.add_condition(
CoalitionHasAirdrome(self.game.get_enemy_coalition_id(), flight.from_cp.id))
self.prevent_spawn_at_hostile_airbase(flight, activation_trigger)
activation_trigger.add_action(ActivateGroup(group.id))
self.m.triggerrules.triggers.append(activation_trigger)
if flight.flight_type == FlightType.INTERCEPTION:
self.setup_interceptor_triggers(group, flight, activation_trigger)
def set_startup_time(self, flight: Flight, group: FlyingGroup,
delay: int) -> None:
# Uncontrolled causes the AI unit to spawn, but not begin startup.
group.uncontrolled = True
group.add_trigger_action(StartCommand())
activation_trigger.add_action(AITaskPush(group.id, len(group.tasks)))
activation_trigger = TriggerOnce(Event.NoEvent,
f"FlightStartTrigger{group.id}")
activation_trigger.add_condition(TimeAfter(seconds=delay))
self.m.triggerrules.triggers.append(activation_trigger)
else:
group.late_activation = True
activation_trigger = TriggerOnce(Event.NoEvent, "FlightLateActivationTrigger" + str(group.id))
activation_trigger.add_condition(TimeAfter(seconds=flight.scheduled_in*60))
self.prevent_spawn_at_hostile_airbase(flight, activation_trigger)
group.add_trigger_action(StartCommand())
activation_trigger.add_action(AITaskPush(group.id, len(group.tasks)))
self.m.triggerrules.triggers.append(activation_trigger)
if(flight.from_cp.cptype == ControlPointType.AIRBASE):
if flight.from_cp.captured:
activation_trigger.add_condition(CoalitionHasAirdrome(self.game.get_player_coalition_id(), flight.from_cp.id))
else:
activation_trigger.add_condition(CoalitionHasAirdrome(self.game.get_enemy_coalition_id(), flight.from_cp.id))
def prevent_spawn_at_hostile_airbase(self, flight: Flight,
trigger: TriggerRule) -> None:
# Prevent delayed flights from spawning at airbases if they were
# captured before they've spawned.
if flight.from_cp.cptype != ControlPointType.AIRBASE:
return
if flight.flight_type == FlightType.INTERCEPTION:
self.setup_interceptor_triggers(group, flight, activation_trigger)
activation_trigger.add_action(ActivateGroup(group.id))
self.m.triggerrules.triggers.append(activation_trigger)
def setup_interceptor_triggers(self, group, flight, activation_trigger):
detection_zone = self.m.triggers.add_triggerzone(flight.from_cp.position, radius=25000, hidden=False, name="ITZ")
if flight.from_cp.captured:
activation_trigger.add_condition(PartOfCoalitionInZone(self.game.get_enemy_color(), detection_zone.id)) # TODO : support unit type in part of coalition
activation_trigger.add_action(MessageToAll(String("WARNING : Enemy aircraft have been detected in the vicinity of " + flight.from_cp.name + ". Interceptors are taking off."), 20))
coalition = self.game.get_player_coalition_id()
else:
activation_trigger.add_condition(PartOfCoalitionInZone(self.game.get_player_color(), detection_zone.id))
activation_trigger.add_action(MessageToAll(String("WARNING : We have detected that enemy aircraft are scrambling for an interception on " + flight.from_cp.name + " airbase."), 20))
coalition = self.game.get_enemy_coalition_id()
trigger.add_condition(
CoalitionHasAirdrome(coalition, flight.from_cp.id))
def generate_planned_flight(self, cp, country, flight:Flight):
try:
if flight.client_count == 0 and self.game.settings.perf_ai_parking_start:
flight.start_type = "Cold"
if flight.start_type == "In Flight":
group = self._generate_group(
group = self._generate_inflight(
name=namegen.next_unit_name(country, cp.id, flight.unit_type),
side=country,
unit_type=flight.unit_type,
count=flight.count,
client_count=0,
at=cp.position)
elif cp.is_fleet:
group_name = cp.get_carrier_group_name()
group = self._generate_at_group(
name=namegen.next_unit_name(country, cp.id, flight.unit_type),
side=country,
unit_type=flight.unit_type,
count=flight.count,
start_type=flight.start_type,
at=self.m.find_group(group_name))
else:
st = StartType.Runway
if flight.start_type == "Cold":
st = StartType.Cold
elif flight.start_type == "Warm":
st = StartType.Warm
if cp.cptype in [ControlPointType.AIRCRAFT_CARRIER_GROUP, ControlPointType.LHA_GROUP]:
group_name = cp.get_carrier_group_name()
group = self._generate_at_group(
name=namegen.next_unit_name(country, cp.id, flight.unit_type),
side=country,
unit_type=flight.unit_type,
count=flight.count,
client_count=0,
at=self.m.find_group(group_name),
start_type=st)
else:
group = self._generate_at_airport(
name=namegen.next_unit_name(country, cp.id, flight.unit_type),
side=country,
unit_type=flight.unit_type,
count=flight.count,
client_count=0,
airport=cp.airport,
start_type=st)
group = self._generate_at_airport(
name=namegen.next_unit_name(country, cp.id, flight.unit_type),
side=country,
unit_type=flight.unit_type,
count=flight.count,
start_type=flight.start_type,
airport=cp.airport)
except Exception as e:
# Generated when there is no place on Runway or on Parking Slots
logging.error(e)
logging.warning("No room on runway or parking slots. Starting from the air.")
flight.start_type = "In Flight"
group = self._generate_group(
group = self._generate_inflight(
name=namegen.next_unit_name(country, cp.id, flight.unit_type),
side=country,
unit_type=flight.unit_type,
count=flight.count,
client_count=0,
at=cp.position)
group.points[0].alt = 1500
@ -1179,8 +987,8 @@ class AircraftConflictGenerator:
logging.error(f"Unhandled flight type: {flight.flight_type.name}")
self.configure_behavior(group)
def setup_flight_group(self, group: FlyingGroup, flight: Flight,
timing: PackageWaypointTiming,
def setup_flight_group(self, group: FlyingGroup, package: Package,
flight: Flight, timing: PackageWaypointTiming,
dynamic_runways: Dict[str, RunwayData]) -> None:
flight_type = flight.flight_type
if flight_type in [FlightType.CAP, FlightType.BARCAP, FlightType.TARCAP,
@ -1204,6 +1012,9 @@ class AircraftConflictGenerator:
for waypoint in flight.points:
waypoint.tot = None
takeoff_point = FlightWaypoint.from_pydcs(group.points[0],
flight.from_cp)
self.set_takeoff_time(takeoff_point, package, flight, group)
for point in flight.points:
if point.only_for_player and not flight.client_count:
continue
@ -1214,9 +1025,53 @@ class AircraftConflictGenerator:
# Set here rather than when the FlightData is created so they waypoints
# have their TOTs set.
self.flights[-1].waypoints = flight.points
self.flights[-1].waypoints = [takeoff_point] + flight.points
self._setup_custom_payload(flight, group)
def set_takeoff_time(self, waypoint: FlightWaypoint, package: Package,
flight: Flight, group: FlyingGroup) -> None:
estimator = TotEstimator(package)
start_time = estimator.mission_start_time(flight)
if start_time > 0:
if self.should_activate_late(flight):
# Late activation causes the aircraft to not be spawned until
# triggered.
self.set_activation_time(flight, group, start_time)
elif flight.start_type == "Cold":
# Setting the start time causes the AI to wait until the
# specified time to begin their startup sequence.
self.set_startup_time(flight, group, start_time)
# And setting *our* waypoint TOT causes the takeoff time to show up in
# the player's kneeboard.
waypoint.tot = estimator.takeoff_time_for_flight(flight)
@staticmethod
def should_activate_late(flight: Flight) -> bool:
if flight.client_count:
# Never delay players. Note that cold start player flights with
# AI members will still be marked as uncontrolled until the start
# trigger fires to postpone engine start.
#
# Player flights that start on the runway or in the air will start
# immediately, and AI flight members will not be delayed.
return False
if flight.start_type != "Cold":
# Avoid spawning aircraft in the air or on the runway until it's
# time for their mission. Also avoid burning through gas spawning
# hot aircraft hours before their takeoff time.
return True
if flight.from_cp.is_fleet:
# Carrier spawns will crowd the carrier deck, especially without
# super carrier.
# TODO: Is there enough parking on the supercarrier?
return True
return False
class PydcsWaypointBuilder:
def __init__(self, waypoint: FlightWaypoint, group: FlyingGroup,
@ -1243,10 +1098,8 @@ class PydcsWaypointBuilder:
waypoint.speed_locked = False
@classmethod
def for_waypoint(cls, waypoint: FlightWaypoint,
group: FlyingGroup,
flight: Flight,
timing: PackageWaypointTiming,
def for_waypoint(cls, waypoint: FlightWaypoint, group: FlyingGroup,
flight: Flight, timing: PackageWaypointTiming,
mission: Mission) -> PydcsWaypointBuilder:
builders = {
FlightWaypointType.EGRESS: EgressPointBuilder,
@ -1391,9 +1244,7 @@ class RaceTrackBuilder(PydcsWaypointBuilder):
pattern=OrbitAction.OrbitPattern.RaceTrack
))
start = self.timing.race_track_start
if start is not None:
self.set_waypoint_tot(waypoint, start)
self.set_waypoint_tot(waypoint, self.timing.race_track_start)
racetrack.stop_after_time(self.timing.race_track_end)
waypoint.add_task(racetrack)
return waypoint

View File

@ -52,7 +52,7 @@ class Package:
delay: int = field(default=0)
#: Desired TOT measured in seconds from mission start.
time_over_target: Optional[int] = field(default=None)
time_over_target: int = field(default=0)
waypoints: Optional[PackageWaypoints] = field(default=None)

View File

@ -1,3 +1,4 @@
import datetime
import os
from collections import defaultdict
from dataclasses import dataclass
@ -116,9 +117,10 @@ class BriefingGenerator(MissionInfoGenerator):
assert not flight.client_units
aircraft = flight.aircraft_type
flight_unit_name = db.unit_type_name(aircraft)
delay = datetime.timedelta(seconds=flight.departure_delay)
self.description += (
f"{flight.flight_type.name} {flight_unit_name} x {flight.size}, "
f"departing in {flight.departure_delay} minutes\n"
f"departing in {delay}\n"
)
def generate(self):

View File

@ -33,6 +33,7 @@ from gen.flights.flight import (
FlightType,
)
from gen.flights.flightplan import FlightPlanBuilder
from gen.flights.traveltime import TotEstimator
from theater import (
ControlPoint,
FrontLine,
@ -185,11 +186,13 @@ class PackageBuilder:
def __init__(self, location: MissionTarget,
closest_airfields: ClosestAirfields,
global_inventory: GlobalAircraftInventory,
is_player: bool) -> None:
is_player: bool,
start_type: str) -> None:
self.package = Package(location)
self.allocator = AircraftAllocator(closest_airfields, global_inventory,
is_player)
self.global_inventory = global_inventory
self.start_type = start_type
def plan_flight(self, plan: ProposedFlight) -> bool:
"""Allocates aircraft for the given flight and adds them to the package.
@ -203,7 +206,8 @@ class PackageBuilder:
if assignment is None:
return False
airfield, aircraft = assignment
flight = Flight(aircraft, plan.num_aircraft, airfield, plan.task)
flight = Flight(aircraft, plan.num_aircraft, airfield, plan.task,
self.start_type)
self.package.add_flight(flight)
return True
@ -444,11 +448,18 @@ class CoalitionMissionPlanner:
def plan_mission(self, mission: ProposedMission) -> None:
"""Allocates aircraft for a proposed mission and adds it to the ATO."""
if self.game.settings.perf_ai_parking_start:
start_type = "Cold"
else:
start_type = "Warm"
builder = PackageBuilder(
mission.location,
self.objective_finder.closest_airfields_to(mission.location),
self.game.aircraft_inventory,
self.is_player
self.is_player,
start_type
)
missing_types: Set[FlightType] = set()
@ -497,16 +508,18 @@ class CoalitionMissionPlanner:
margin=5
)
for package in self.ato.packages:
tot = TotEstimator(package).earliest_tot()
if package.primary_task in dca_types:
# All CAP missions should be on station in 15-25 minutes.
package.time_over_target = (20 + random.randint(-5, 5)) * 60
# All CAP missions should be on station ASAP.
package.time_over_target = tot
else:
# But other packages should be spread out a bit.
package.delay = next(start_time)
# TODO: Compute TOT based on package.
package.time_over_target = (package.delay + 40) * 60
for flight in package.flights:
flight.scheduled_in = package.delay
# But other packages should be spread out a bit. Note that take
# times are delayed, but all aircraft will become active at
# mission start. This makes it more worthwhile to attack enemy
# airfields to hit grounded aircraft, since they're more likely
# to be present. Runway and air started aircraft will be
# delayed until their takeoff time by AirConflictGenerator.
package.time_over_target = next(start_time) * 60 + tot
def message(self, title, text) -> None:
"""Emits a planning message to the player.

View File

@ -97,7 +97,6 @@ class FlightWaypoint:
# flight's offset in the UI.
self.tot: Optional[int] = None
@classmethod
def from_pydcs(cls, point: MovingPoint,
from_cp: ControlPoint) -> "FlightWaypoint":
@ -130,13 +129,10 @@ class Flight:
client_count: int = 0
use_custom_loadout = False
preset_loadout_name = ""
start_type = "Runway"
group = False # Contains DCS Mission group data after mission has been generated
# How long before this flight should take off
scheduled_in = 0
def __init__(self, unit_type: UnitType, count: int, from_cp: ControlPoint, flight_type: FlightType):
def __init__(self, unit_type: UnitType, count: int, from_cp: ControlPoint,
flight_type: FlightType, start_type: str) -> None:
self.unit_type = unit_type
self.count = count
self.from_cp = from_cp
@ -144,11 +140,16 @@ class Flight:
self.points: List[FlightWaypoint] = []
self.targets: List[MissionTarget] = []
self.loadout: Dict[str, str] = {}
self.start_type = "Runway"
self.start_type = start_type
# Late activation delay in seconds from mission start. This is not
# the same as the flight's takeoff time. Takeoff time depends on the
# mission's TOT and the other flights in the package. Takeoff time is
# determined by AirConflictGenerator.
self.scheduled_in = 0
def __repr__(self):
return self.flight_type.name + " | " + str(self.count) + "x" + db.unit_type_name(self.unit_type) \
+ " in " + str(self.scheduled_in) + " minutes (" + str(len(self.points)) + " wpt)"
+ " (" + str(len(self.points)) + " wpt)"
# Test
@ -157,6 +158,6 @@ if __name__ == '__main__':
from theater import ControlPoint, Point, List
from_cp = ControlPoint(0, "AA", Point(0, 0), Point(0, 0), [], 0, 0)
f = Flight(A_10C(), 4, from_cp, FlightType.CAS)
f = Flight(A_10C(), 4, from_cp, FlightType.CAS, "Cold")
f.scheduled_in = 50
print(f)

285
gen/flights/traveltime.py Normal file
View File

@ -0,0 +1,285 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Iterable, Optional
from dcs.mapping import Point
from game.utils import meter_to_nm
from gen.ato import Package
from gen.flights.flight import (
Flight,
FlightType,
FlightWaypoint,
FlightWaypointType,
)
CAP_DURATION = 30 # Minutes
CAP_TYPES = (FlightType.BARCAP, FlightType.CAP)
class GroundSpeed:
@classmethod
def for_package(cls, package: Package) -> int:
speeds = []
for flight in package.flights:
speeds.append(cls.for_flight(flight))
return min(speeds) # knots
@staticmethod
def for_flight(_flight: Flight) -> int:
# TODO: Gather data so this is useful.
# TODO: Expose both a cruise speed and target speed.
# The cruise speed can be used for ascent, hold, join, and RTB to save
# on fuel, but mission speed will be fast enough to keep the flight
# safer.
return 400 # knots
class TravelTime:
@staticmethod
def between_points(a: Point, b: Point, speed: float) -> int:
error_factor = 1.1
distance = meter_to_nm(a.distance_to_point(b))
hours = distance / speed
seconds = hours * 3600
return int(seconds * error_factor)
class TotEstimator:
# An extra five minutes given as wiggle room. Expected to be spent at the
# hold point performing any last minute configuration.
HOLD_TIME = 5 * 60
def __init__(self, package: Package) -> None:
self.package = package
self.timing = PackageWaypointTiming.for_package(package)
def mission_start_time(self, flight: Flight) -> int:
takeoff_time = self.takeoff_time_for_flight(flight)
startup_time = self.estimate_startup(flight)
ground_ops_time = self.estimate_ground_ops(flight)
return takeoff_time - startup_time - ground_ops_time
def takeoff_time_for_flight(self, flight: Flight) -> int:
stop_types = {FlightWaypointType.JOIN, FlightWaypointType.PATROL_TRACK}
travel_time = self.estimate_waypoints_to_target(flight, stop_types)
if travel_time is None:
logging.warning("Found no join point or patrol point. Cannot "
f"estimate takeoff time takeoff time for {flight}")
# Takeoff immediately.
return 0
if self.package.primary_task in CAP_TYPES:
start_time = self.timing.race_track_start
else:
start_time = self.timing.join
return start_time - travel_time - self.HOLD_TIME
def earliest_tot(self) -> int:
return max((
self.earliest_tot_for_flight(f) for f in self.package.flights
)) + self.HOLD_TIME
def earliest_tot_for_flight(self, flight: Flight) -> int:
"""Estimate fastest time from mission start to the target position.
For CAP missions, this is time to race track start.
For other mission types this is the time to the mission target.
Args:
flight: The flight to get the earliest TOT time for.
Returns:
The earliest possible TOT for the given flight in seconds. Returns 0
if an ingress point cannot be found.
"""
stop_types = {
FlightWaypointType.PATROL_TRACK,
FlightWaypointType.INGRESS_CAS,
FlightWaypointType.INGRESS_SEAD,
FlightWaypointType.INGRESS_STRIKE,
}
time_to_ingress = self.estimate_waypoints_to_target(flight, stop_types)
if time_to_ingress is None:
logging.warning(
f"Found no ingress types. Cannot estimate TOT for {flight}")
# Return 0 so this flight's travel time does not affect the rest of
# the package.
return 0
if self.package.primary_task in CAP_TYPES:
# The racetrack start *is* the target. The package target is the
# protected objective.
time_to_target = 0
else:
assert self.package.waypoints is not None
time_to_target = TravelTime.between_points(
self.package.waypoints.ingress, self.package.target.position,
GroundSpeed.for_package(self.package))
return sum([
self.estimate_startup(flight),
self.estimate_ground_ops(flight),
time_to_ingress,
time_to_target,
])
@staticmethod
def estimate_startup(flight: Flight) -> int:
if flight.start_type == "Cold":
return 10 * 60
return 0
@staticmethod
def estimate_ground_ops(flight: Flight) -> int:
if flight.start_type in ("Runway", "In Flight"):
return 0
if flight.from_cp.is_fleet:
return 2 * 60
else:
return 5 * 60
def estimate_waypoints_to_target(
self, flight: Flight,
stop_types: Iterable[FlightWaypointType]) -> Optional[int]:
total = 0
previous_position = flight.from_cp.position
for waypoint in flight.points:
position = Point(waypoint.x, waypoint.y)
total += TravelTime.between_points(
previous_position, position,
self.speed_to_waypoint(flight, waypoint)
)
previous_position = position
if waypoint.waypoint_type in stop_types:
return total
return None
def speed_to_waypoint(self, flight: Flight,
waypoint: FlightWaypoint) -> int:
pre_join = (FlightWaypointType.LOITER, FlightWaypointType.JOIN)
if waypoint.waypoint_type == FlightWaypointType.ASCEND_POINT:
# Flights that start airborne already have some altitude and a good
# amount of speed.
factor = 1.0 if flight.start_type == "In Flight" else 0.5
return int(GroundSpeed.for_flight(flight) * factor)
elif waypoint.waypoint_type in pre_join:
return GroundSpeed.for_flight(flight)
return GroundSpeed.for_package(self.package)
@dataclass(frozen=True)
class PackageWaypointTiming:
#: The package being scheduled.
package: Package
#: The package join time.
join: int
#: The ingress waypoint TOT.
ingress: int
#: The egress waypoint TOT.
egress: int
#: The package split time.
split: int
@property
def target(self) -> int:
"""The package time over target."""
assert self.package.time_over_target is not None
return self.package.time_over_target
@property
def race_track_start(self) -> int:
if self.package.primary_task in CAP_TYPES:
return self.package.time_over_target
else:
return self.ingress
@property
def race_track_end(self) -> int:
if self.package.primary_task in CAP_TYPES:
return self.target + CAP_DURATION * 60
else:
return self.egress
def push_time(self, flight: Flight, hold_point: Point) -> int:
assert self.package.waypoints is not None
return self.join - TravelTime.between_points(
hold_point,
self.package.waypoints.join,
GroundSpeed.for_flight(flight)
)
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[int]:
target_types = (
FlightWaypointType.TARGET_GROUP_LOC,
FlightWaypointType.TARGET_POINT,
FlightWaypointType.TARGET_SHIP,
)
ingress_types = (
FlightWaypointType.INGRESS_CAS,
FlightWaypointType.INGRESS_SEAD,
FlightWaypointType.INGRESS_STRIKE,
)
if waypoint.waypoint_type == FlightWaypointType.JOIN:
return self.join
elif waypoint.waypoint_type in ingress_types:
return self.ingress
elif waypoint.waypoint_type in target_types:
return self.target
elif waypoint.waypoint_type == FlightWaypointType.EGRESS:
return self.egress
elif waypoint.waypoint_type == FlightWaypointType.SPLIT:
return self.split
elif waypoint.waypoint_type == FlightWaypointType.PATROL_TRACK:
return self.race_track_start
return None
def depart_time_for_waypoint(self, waypoint: FlightWaypoint,
flight: Flight) -> Optional[int]:
if waypoint.waypoint_type == FlightWaypointType.LOITER:
return self.push_time(flight, Point(waypoint.x, waypoint.y))
elif waypoint.waypoint_type == FlightWaypointType.PATROL:
return self.race_track_end
return None
@classmethod
def for_package(cls, package: Package) -> PackageWaypointTiming:
assert package.waypoints is not None
group_ground_speed = GroundSpeed.for_package(package)
ingress = package.time_over_target - TravelTime.between_points(
package.waypoints.ingress,
package.target.position,
group_ground_speed
)
join = ingress - TravelTime.between_points(
package.waypoints.join,
package.waypoints.ingress,
group_ground_speed
)
egress = package.time_over_target + TravelTime.between_points(
package.target.position,
package.waypoints.egress,
group_ground_speed
)
split = egress + TravelTime.between_points(
package.waypoints.egress,
package.waypoints.split,
group_ground_speed
)
return cls(package, join, ingress, egress, split)

View File

@ -1,7 +1,6 @@
"""Qt data models for game objects."""
import datetime
from enum import auto, IntEnum
from typing import Any, Callable, Dict, Iterator, TypeVar, Optional
from typing import Any, Callable, Dict, Iterator, Optional, TypeVar
from PySide2.QtCore import (
QAbstractListModel,
@ -15,6 +14,7 @@ from game import db
from game.game import Game
from gen.ato import AirTaskingOrder, Package
from gen.flights.flight import Flight
from gen.flights.traveltime import TotEstimator
from qt_ui.uiconstants import AIRCRAFT_ICONS
from theater.missiontarget import MissionTarget
@ -119,15 +119,15 @@ class PackageModel(QAbstractListModel):
return flight
return None
@staticmethod
def text_for_flight(flight: Flight) -> str:
def text_for_flight(self, flight: Flight) -> str:
"""Returns the text that should be displayed for the flight."""
task = flight.flight_type.name
count = flight.count
name = db.unit_type_name(flight.unit_type)
delay = flight.scheduled_in
estimator = TotEstimator(self.package)
delay = datetime.timedelta(seconds=estimator.mission_start_time(flight))
origin = flight.from_cp.name
return f"[{task}] {count} x {name} from {origin} in {delay} minutes"
return f"[{task}] {count} x {name} from {origin} in {delay}"
@staticmethod
def icon_for_flight(flight: Flight) -> Optional[QIcon]:

View File

@ -147,6 +147,8 @@ class QTopPanel(QFrame):
if not self.ato_has_clients() and not self.confirm_no_client_launch():
return
# TODO: Verify no negative start times.
# TODO: Refactor this nonsense.
game_event = None
for event in self.game.events:

View File

@ -24,6 +24,7 @@ from PySide2.QtWidgets import (
from game import db
from gen.ato import Package
from gen.flights.flight import Flight
from gen.flights.traveltime import TotEstimator
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from ..models import AtoModel, GameModel, NullListModel, PackageModel
@ -33,6 +34,10 @@ class FlightDelegate(QStyledItemDelegate):
HMARGIN = 4
VMARGIN = 4
def __init__(self, package: Package) -> None:
super().__init__()
self.package = package
def get_font(self, option: QStyleOptionViewItem) -> QFont:
font = QFont(option.font)
font.setPointSize(self.FONT_SIZE)
@ -47,8 +52,9 @@ class FlightDelegate(QStyledItemDelegate):
task = flight.flight_type.name
count = flight.count
name = db.unit_type_name(flight.unit_type)
delay = flight.scheduled_in
return f"[{task}] {count} x {name} in {delay} minutes"
estimator = TotEstimator(self.package)
delay = datetime.timedelta(seconds=estimator.mission_start_time(flight))
return f"[{task}] {count} x {name} in {delay}"
def second_row_text(self, index: QModelIndex) -> str:
flight = self.flight(index)
@ -128,7 +134,8 @@ class QFlightList(QListView):
super().__init__()
self.package_model = model
self.set_package(model)
self.setItemDelegate(FlightDelegate())
if model is not None:
self.setItemDelegate(FlightDelegate(model.package))
self.setIconSize(QSize(91, 24))
self.setSelectionBehavior(QAbstractItemView.SelectItems)
@ -138,6 +145,7 @@ class QFlightList(QListView):
self.disconnect_model()
else:
self.package_model = model
self.setItemDelegate(FlightDelegate(model.package))
self.setModel(model)
# noinspection PyUnresolvedReferences
model.deleted.connect(self.disconnect_model)

View File

@ -21,7 +21,8 @@ from game import Game, db
from game.data.aaa_db import AAA_UNITS
from game.data.radar_db import UNITS_WITH_RADAR
from game.utils import meter_to_feet
from gen import Conflict, Package, PackageWaypointTiming
from gen import Conflict, PackageWaypointTiming
from gen.ato import Package
from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType
from qt_ui.displayoptions import DisplayOptions
from qt_ui.models import GameModel

View File

@ -1,26 +1,28 @@
import datetime
from PySide2.QtGui import QStandardItem, QIcon
from game import db
from gen.ato import Package
from gen.flights.flight import Flight
from gen.flights.traveltime import TotEstimator
from qt_ui.uiconstants import AIRCRAFT_ICONS
# TODO: Replace with QFlightList.
class QFlightItem(QStandardItem):
def __init__(self, flight:Flight):
def __init__(self, package: Package, flight: Flight):
super(QFlightItem, self).__init__()
self.package = package
self.flight = flight
if db.unit_type_name(self.flight.unit_type).replace("/", " ") in AIRCRAFT_ICONS.keys():
icon = QIcon((AIRCRAFT_ICONS[db.unit_type_name(self.flight.unit_type)]))
self.setIcon(icon)
self.setEditable(False)
estimator = TotEstimator(self.package)
delay = datetime.timedelta(seconds=estimator.mission_start_time(flight))
self.setText("["+str(self.flight.flight_type.name[:6])+"] "
+ str(self.flight.count) + " x " + db.unit_type_name(self.flight.unit_type)
+ " in " + str(self.flight.scheduled_in) + " minutes")
def update(self, flight):
self.flight = flight
self.setText("[" + str(self.flight.flight_type.name[:6]) + "] "
+ str(self.flight.count) + " x " + db.unit_type_name(self.flight.unit_type)
+ " in " + str(self.flight.scheduled_in) + " minutes")
+ " in " + str(delay))

View File

@ -116,11 +116,14 @@ class QPackageDialog(QDialog):
self.finished.connect(self.on_close)
def on_close(self, _result) -> None:
@staticmethod
def on_close(_result) -> None:
GameUpdateSignal.get_instance().redraw_flight_paths()
def save_tot(self) -> None:
time = self.tot_spinner.time()
seconds = time.hour() * 3600 + time.minute() * 60 + time.second()
self.package_model.update_tot(seconds)
GameUpdateSignal.get_instance().redraw_flight_paths()
def on_selection_changed(self, selected: QItemSelection,
_deselected: QItemSelection) -> None:
@ -182,6 +185,7 @@ class QNewPackageDialog(QPackageDialog):
Empty packages may be created. They can be modified later, and will have
no effect if empty when the mission is generated.
"""
self.save_tot()
self.ato_model.add_package(self.package_model.package)
for flight in self.package_model.package.flights:
self.game.aircraft_inventory.claim_for_flight(flight)
@ -227,6 +231,7 @@ class QEditPackageDialog(QPackageDialog):
def on_done(self) -> None:
"""Closes the window."""
self.save_tot()
self.close()
def on_delete(self) -> None:

View File

@ -90,7 +90,11 @@ class QFlightCreator(QDialog):
origin = self.airfield_selector.currentData()
size = self.flight_size_spinner.value()
flight = Flight(aircraft, size, origin, task)
if self.game.settings.perf_ai_parking_start:
start_type = "Cold"
else:
start_type = "Warm"
flight = Flight(aircraft, size, origin, task, start_type)
flight.scheduled_in = self.package.delay
# noinspection PyUnresolvedReferences

View File

@ -1,6 +1,7 @@
from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox, QSpinBox
# TODO: Remove?
class QFlightDepartureEditor(QGroupBox):
def __init__(self, flight):
@ -15,7 +16,7 @@ class QFlightDepartureEditor(QGroupBox):
self.departure_delta = QSpinBox(self)
self.departure_delta.setMinimum(0)
self.departure_delta.setMaximum(120)
self.departure_delta.setValue(self.flight.scheduled_in)
self.departure_delta.setValue(self.flight.scheduled_in // 60)
self.departure_delta.valueChanged.connect(self.change_scheduled)
layout.addWidget(self.depart_from)
@ -27,4 +28,4 @@ class QFlightDepartureEditor(QGroupBox):
self.changed = self.departure_delta.valueChanged
def change_scheduled(self):
self.flight.scheduled_in = int(self.departure_delta.value())
self.flight.scheduled_in = int(self.departure_delta.value() * 60)