diff --git a/changelog.md b/changelog.md index 6d1e647d..7a78e88f 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,7 @@ * **[Units]** Support for newly added BTR-82A, T-72B3 * **[Units]** Added ZSU-57 AAA sites * **[Culling]** BARCAP missions no longer create culling exclusion zones. +* **[Flight Planner]** Improved TOT planning. Negative start times no longer occur with TARCAPs and hold times no longer affect planning for flight plans without hold points. ## Fixes: * **[Mission Generator]** Fix mission generation error when there are too many radio frequency to setup for the Mig-21 diff --git a/gen/aircraft.py b/gen/aircraft.py index e51b0f2e..87233d35 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -20,20 +20,20 @@ from dcs.planes import ( B_17G, B_52H, Bf_109K_4, - C_101EB, C_101CC, + C_101EB, FW_190A8, FW_190D9, F_14B, I_16, JF_17, Ju_88A4, - PlaneType, P_47D_30, P_47D_30bl1, P_47D_40, P_51D, P_51D_30_NA, + PlaneType, SpitfireLFMkIX, SpitfireLFMkIXCW, Su_33, @@ -59,16 +59,15 @@ from dcs.task import ( OptReactOnThreat, OptRestrictJettison, OrbitAction, + PinpointStrike, RunwayAttack, SEAD, StartCommand, Targets, Task, WeaponType, - PinpointStrike, ) from dcs.terrain.terrain import Airport, NoParkingSlotError -from dcs.translation import String from dcs.triggers import Event, TriggerOnce, TriggerRule from dcs.unitgroup import FlyingGroup, ShipGroup, StaticGroup from dcs.unittype import FlyingType, UnitType @@ -99,7 +98,6 @@ from gen.flights.flight import ( ) from gen.radios import MHz, Radio, RadioFrequency, RadioRegistry, get_radio from gen.runways import RunwayData -from .conflictgen import Conflict from .flights.flightplan import ( CasFlightPlan, LoiterFlightPlan, @@ -108,7 +106,6 @@ from .flights.flightplan import ( ) from .flights.traveltime import GroundSpeed, TotEstimator from .naming import namegen -from .runways import RunwayAssigner if TYPE_CHECKING: from game import Game @@ -1349,7 +1346,7 @@ class AircraftConflictGenerator: # 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) + waypoint.tot = flight.flight_plan.takeoff_time() # And finally assign it to the FlightData info so it shows correctly in # the briefing. self.flights[-1].departure_delay = start_time diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 8d4e532f..478275cc 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -73,10 +73,17 @@ class FlightPlan: """Iterates over all waypoints in the flight plan, in order.""" raise NotImplementedError - @property - def edges(self) -> Iterator[Tuple[FlightWaypoint, FlightWaypoint]]: + def edges( + self, until: Optional[FlightWaypoint] = None + ) -> Iterator[Tuple[FlightWaypoint, FlightWaypoint]]: """A list of all paths between waypoints, in order.""" - return zip(self.waypoints, self.waypoints[1:]) + waypoints = self.waypoints + if until is None: + last_index = len(waypoints) + else: + last_index = waypoints.index(until) + 1 + + return zip(self.waypoints[:last_index], self.waypoints[1:last_index]) def best_speed_between_waypoints(self, a: FlightWaypoint, b: FlightWaypoint) -> int: @@ -137,7 +144,6 @@ class FlightPlan: """Joker fuel value for the FlightPlan """ return self.bingo_fuel + 1000 - def max_distance_from(self, cp: ControlPoint) -> int: """Returns the farthest waypoint of the flight plan from a ControlPoint. @@ -156,26 +162,18 @@ class FlightPlan: """ return timedelta() - # Not cached because changes to the package might alter the formation speed. - @property - def travel_time_to_target(self) -> Optional[timedelta]: - """The estimated time between the first waypoint and the target.""" - if self.tot_waypoint is None: - return None - return self._travel_time_to_waypoint(self.tot_waypoint) - def _travel_time_to_waypoint( self, destination: FlightWaypoint) -> timedelta: total = timedelta() - for previous_waypoint, waypoint in self.edges: - total += self.travel_time_between_waypoints(previous_waypoint, - waypoint) - if waypoint == destination: - break - else: + + if destination not in self.waypoints: raise PlanningError( f"Did not find destination waypoint {destination} in " f"waypoints for {self.flight}") + + for previous_waypoint, waypoint in self.edges(until=destination): + total += self.travel_time_between_waypoints(previous_waypoint, + waypoint) return total def travel_time_between_waypoints(self, a: FlightWaypoint, @@ -196,10 +194,59 @@ class FlightPlan: def dismiss_escort_at(self) -> Optional[FlightWaypoint]: return None + def takeoff_time(self) -> Optional[timedelta]: + tot_waypoint = self.tot_waypoint + if tot_waypoint is None: + return None + + time = self.tot_for_waypoint(tot_waypoint) + if time is None: + return None + time += self.tot_offset + return time - self._travel_time_to_waypoint(tot_waypoint) + + def startup_time(self) -> Optional[timedelta]: + takeoff_time = self.takeoff_time() + if takeoff_time is None: + return None + + start_time = (takeoff_time - self.estimate_startup() - + self.estimate_ground_ops()) + + # In case FP math has given us some barely below zero time, round to + # zero. + if math.isclose(start_time.total_seconds(), 0): + return timedelta() + + # Trim microseconds. DCS doesn't handle sub-second resolution for tasks, + # and they're not interesting from a mission planning perspective so we + # don't want them in the UI. + # + # Round down so *barely* above zero start times are just zero. + return timedelta(seconds=math.floor(start_time.total_seconds())) + + def estimate_startup(self) -> timedelta: + if self.flight.start_type == "Cold": + if self.flight.client_count: + return timedelta(minutes=10) + else: + # The AI doesn't seem to have a real startup procedure. + return timedelta(minutes=2) + return timedelta() + + def estimate_ground_ops(self) -> timedelta: + if self.flight.start_type in ("Runway", "In Flight"): + return timedelta() + if self.flight.from_cp.is_fleet: + return timedelta(minutes=2) + else: + return timedelta(minutes=5) + @dataclass(frozen=True) class LoiterFlightPlan(FlightPlan): hold: FlightWaypoint + hold_duration: timedelta def iter_waypoints(self) -> Iterator[FlightWaypoint]: raise NotImplementedError @@ -221,6 +268,17 @@ class LoiterFlightPlan(FlightPlan): return self.push_time return None + def travel_time_between_waypoints(self, a: FlightWaypoint, + b: FlightWaypoint) -> timedelta: + travel_time = super().travel_time_between_waypoints(a, b) + if a != self.hold: + return travel_time + try: + return travel_time + self.hold_duration + except AttributeError: + # Save compat for 2.3. + return travel_time + timedelta(minutes=5) + @dataclass(frozen=True) class FormationFlightPlan(LoiterFlightPlan): @@ -254,7 +312,7 @@ class FormationFlightPlan(LoiterFlightPlan): all of its formation waypoints. """ speeds = [] - for previous_waypoint, waypoint in self.edges: + for previous_waypoint, waypoint in self.edges(): if waypoint in self.package_speed_waypoints: speeds.append(self.best_speed_between_waypoints( previous_waypoint, waypoint)) @@ -486,7 +544,7 @@ class StrikeFlightPlan(FormationFlightPlan): """The estimated time between the first waypoint and the target.""" destination = self.tot_waypoint total = timedelta() - for previous_waypoint, waypoint in self.edges: + for previous_waypoint, waypoint in self.edges(): if waypoint == self.tot_waypoint: # For anything strike-like the TOT waypoint is the *flight's* # mission target, but to synchronize with the rest of the @@ -846,6 +904,7 @@ class FlightPlanBuilder: lead_time=timedelta(minutes=5), takeoff=builder.takeoff(flight.departure), hold=builder.hold(self._hold_point(flight)), + hold_duration=timedelta(minutes=5), sweep_start=start, sweep_end=end, land=builder.land(flight.arrival), @@ -1050,6 +1109,7 @@ class FlightPlanBuilder: flight=flight, takeoff=builder.takeoff(flight.departure), hold=builder.hold(self._hold_point(flight)), + hold_duration=timedelta(minutes=5), join=builder.join(self.package.waypoints.join), ingress=ingress, targets=[target], @@ -1196,6 +1256,7 @@ class FlightPlanBuilder: flight=flight, takeoff=builder.takeoff(flight.departure), hold=builder.hold(self._hold_point(flight)), + hold_duration=timedelta(minutes=5), join=builder.join(self.package.waypoints.join), ingress=builder.ingress(ingress_type, self.package.waypoints.ingress, location), diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py index 714fbd25..078dabc7 100644 --- a/gen/flights/traveltime.py +++ b/gen/flights/traveltime.py @@ -89,68 +89,23 @@ class TravelTime: # TODO: Most if not all of this should move into FlightPlan. 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 = timedelta(minutes=5) def __init__(self, package: Package) -> None: self.package = package - def mission_start_time(self, flight: Flight) -> timedelta: - takeoff_time = self.takeoff_time_for_flight(flight) - if takeoff_time is None: + @staticmethod + def mission_start_time(flight: Flight) -> timedelta: + startup_time = flight.flight_plan.startup_time() + if startup_time is None: # Could not determine takeoff time, probably due to a custom flight # plan. Start immediately. return timedelta() - - startup_time = self.estimate_startup(flight) - ground_ops_time = self.estimate_ground_ops(flight) - start_time = takeoff_time - startup_time - ground_ops_time - # In case FP math has given us some barely below zero time, round to - # zero. - if math.isclose(start_time.total_seconds(), 0): - return timedelta() - # Trim microseconds. DCS doesn't handle sub-second resolution for tasks, - # and they're not interesting from a mission planning perspective so we - # don't want them in the UI. - # - # Round down so *barely* above zero start times are just zero. - return timedelta(seconds=math.floor(start_time.total_seconds())) - - def takeoff_time_for_flight(self, flight: Flight) -> Optional[timedelta]: - travel_time = self.travel_time_to_rendezvous_or_target(flight) - if travel_time is None: - from gen.flights.flightplan import CustomFlightPlan - if not isinstance(flight.flight_plan, CustomFlightPlan): - logging.warning( - "Found no rendezvous or target point. Cannot estimate " - f"takeoff time takeoff time for {flight}.") - return None - - from gen.flights.flightplan import FormationFlightPlan - if isinstance(flight.flight_plan, FormationFlightPlan): - tot = flight.flight_plan.tot_for_waypoint( - flight.flight_plan.join) - if tot is None: - logging.warning( - "Could not determine the TOT of the join point. Takeoff " - f"time for {flight} will be immediate.") - return None - else: - tot_waypoint = flight.flight_plan.tot_waypoint - if tot_waypoint is None: - tot = self.package.time_over_target - else: - tot = flight.flight_plan.tot_for_waypoint(tot_waypoint) - if tot is None: - logging.error(f"TOT waypoint for {flight} has no TOT") - tot = self.package.time_over_target - return tot - travel_time - self.HOLD_TIME + return startup_time def earliest_tot(self) -> timedelta: earliest_tot = max(( self.earliest_tot_for_flight(f) for f in self.package.flights - )) + self.HOLD_TIME + )) # Trim microseconds. DCS doesn't handle sub-second resolution for tasks, # and they're not interesting from a mission planning perspective so we @@ -159,7 +114,8 @@ class TotEstimator: # Round up so we don't get negative start times. return timedelta(seconds=math.ceil(earliest_tot.total_seconds())) - def earliest_tot_for_flight(self, flight: Flight) -> timedelta: + @staticmethod + def earliest_tot_for_flight(flight: Flight) -> timedelta: """Estimate fastest time from mission start to the target position. For BARCAP flights, this is time to race track start. This ensures that @@ -175,51 +131,18 @@ class TotEstimator: The earliest possible TOT for the given flight in seconds. Returns 0 if an ingress point cannot be found. """ - time_to_target = self.travel_time_to_target(flight) - if time_to_target is None: + # Clear the TOT, calculate the startup time. Negating the result gives + # the earliest possible start time. + orig_tot = flight.package.time_over_target + try: + flight.package.time_over_target = timedelta() + time = flight.flight_plan.startup_time() + finally: + flight.package.time_over_target = orig_tot + + if time is None: logging.warning(f"Cannot estimate TOT for {flight}") # Return 0 so this flight's travel time does not affect the rest # of the package. return timedelta() - # Account for TOT offsets for the flight plan. An offset of -2 minutes - # means the flight's TOT is 2 minutes ahead of the package's so it needs - # an extra two minutes. - offset = -flight.flight_plan.tot_offset - startup = self.estimate_startup(flight) - ground_ops = self.estimate_ground_ops(flight) - return startup + ground_ops + time_to_target + offset - - @staticmethod - def estimate_startup(flight: Flight) -> timedelta: - if flight.start_type == "Cold": - if flight.client_count: - return timedelta(minutes=10) - else: - # The AI doesn't seem to have a real startup procedure. - return timedelta(minutes=2) - return timedelta() - - @staticmethod - def estimate_ground_ops(flight: Flight) -> timedelta: - if flight.start_type in ("Runway", "In Flight"): - return timedelta() - if flight.from_cp.is_fleet: - return timedelta(minutes=2) - else: - return timedelta(minutes=5) - - @staticmethod - def travel_time_to_target(flight: Flight) -> Optional[timedelta]: - if flight.flight_plan is None: - return None - return flight.flight_plan.travel_time_to_target - - @staticmethod - def travel_time_to_rendezvous_or_target( - flight: Flight) -> Optional[timedelta]: - if flight.flight_plan is None: - return None - from gen.flights.flightplan import FormationFlightPlan - if isinstance(flight.flight_plan, FormationFlightPlan): - return flight.flight_plan.travel_time_to_rendezvous - return flight.flight_plan.travel_time_to_target + return -time diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py index 9f5df938..4aa3683c 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py @@ -7,7 +7,6 @@ from PySide2.QtWidgets import QHeaderView, QTableView from game.utils import meter_to_feet from gen.ato import Package from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType -from gen.flights.traveltime import TotEstimator from qt_ui.windows.mission.flight.waypoints.QFlightWaypointItem import \ QWaypointItem @@ -74,9 +73,9 @@ class QFlightWaypointList(QTableView): time = timedelta(seconds=int(time.total_seconds())) return f"{prefix}T+{time}" - def takeoff_text(self, flight: Flight) -> str: - estimator = TotEstimator(self.package) - takeoff_time = estimator.takeoff_time_for_flight(flight) + @staticmethod + def takeoff_text(flight: Flight) -> str: + takeoff_time = flight.flight_plan.takeoff_time() # Handle custom flight plans where we can't estimate the takeoff time. if takeoff_time is None: takeoff_time = timedelta()