Ability to plan tanker recovery for strike like flights (#1729)

* Initial refueling definitions.

* Adding refuel definitions.

* Initial functionality changes

* Regenerate package when adding refueling flight.

* Recursively change package waypoint.

* Fix mypy errors.

* Regenerate flight plans when tanker is added to package.

* Give tanker better starting position on package recovery.

* Add TOT calculation for refueling waypoint.

* Timing changes to Strike split point and Refueling start time.

* Add correct waypoint builder for refuel in tarcap and sweep.  Remove restrict afterburner on refuel point.

* Always generate a refuel point for a package.

* Less arbitrary altitude in Refuel track start time calculation.

* Refueling waypoint no longer optional.

* Fix mypy gen error.

* Better discrimination of which tanker flight plan to make.

* Remove refuel tot calculations.

* Remove package regeneration on tanker flight addition.
This commit is contained in:
SnappyComebacks 2021-11-12 21:37:34 -07:00 committed by GitHub
parent 94f65d8f70
commit 532ac261ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 251 additions and 10 deletions

View File

@ -5,6 +5,7 @@ Saves from 5.x are not compatible with 6.0.
## Features/Improvements ## Features/Improvements
* **[Mission Generation]** Added an option to fast-forward mission generation until the point of first contact (WIP). * **[Mission Generation]** Added an option to fast-forward mission generation until the point of first contact (WIP).
* **[Flight Planning]** Added the ability to plan tankers for recovery on package flights. AI does not plan.
## Fixes ## Fixes

View File

@ -45,3 +45,4 @@ class FlightWaypointType(Enum):
PICKUP = 26 PICKUP = 26
DROP_OFF = 27 DROP_OFF = 27
BULLSEYE = 28 BULLSEYE = 28
REFUEL = 29 # Should look for nearby tanker to refuel from.

View File

@ -48,7 +48,7 @@ class Package:
"""The speed of the package when in formation. """The speed of the package when in formation.
If none of the flights in the package will join a formation, this If none of the flights in the package will join a formation, this
returns None. This is nto uncommon, since only strike-like (strike, returns None. This is not uncommon, since only strike-like (strike,
DEAD, anti-ship, BAI, etc.) flights and their escorts fly in formation. DEAD, anti-ship, BAI, etc.) flights and their escorts fly in formation.
Others (CAP and CAS, currently) will coordinate in target timing but Others (CAP and CAS, currently) will coordinate in target timing but
fly their own path to the target. fly their own path to the target.

View File

@ -1,4 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional
from dcs import Point from dcs import Point
@ -8,3 +9,4 @@ class PackageWaypoints:
join: Point join: Point
ingress: Point ingress: Point
split: Point split: Point
refuel: Point

View File

@ -0,0 +1,27 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from dcs import Point
if TYPE_CHECKING:
from game.coalition import Coalition
class RefuelZoneGeometry:
def __init__(
self,
package_home: Point,
join: Point,
coalition: Coalition,
) -> None:
self.package_home = package_home
self.join = join
self.coalition = coalition
def find_best_refuel_point(self) -> Point:
# Do simple at first.
# TODO: Consider threats.
distance = 0.75 * self.package_home.distance_to_point(self.join)
heading = self.package_home.heading_between_point(self.join)
return self.package_home.point_from_heading(heading, distance)

View File

@ -0,0 +1,9 @@
from dcs.point import MovingPoint
from dcs.task import RefuelingTaskAction
from .pydcswaypointbuilder import PydcsWaypointBuilder
class RefuelPointBuilder(PydcsWaypointBuilder):
def add_tasks(self, waypoint: MovingPoint) -> None:
waypoint.add_task(RefuelingTaskAction())
return super().add_tasks(waypoint)

View File

@ -32,6 +32,7 @@ from .ocarunwayingress import OcaRunwayIngressBuilder
from .pydcswaypointbuilder import PydcsWaypointBuilder, TARGET_WAYPOINTS from .pydcswaypointbuilder import PydcsWaypointBuilder, TARGET_WAYPOINTS
from .racetrack import RaceTrackBuilder from .racetrack import RaceTrackBuilder
from .racetrackend import RaceTrackEndBuilder from .racetrackend import RaceTrackEndBuilder
from .refuel import RefuelPointBuilder
from .seadingress import SeadIngressBuilder from .seadingress import SeadIngressBuilder
from .strikeingress import StrikeIngressBuilder from .strikeingress import StrikeIngressBuilder
from .sweepingress import SweepIngressBuilder from .sweepingress import SweepIngressBuilder
@ -130,6 +131,7 @@ class WaypointGenerator:
FlightWaypointType.PATROL: RaceTrackEndBuilder, FlightWaypointType.PATROL: RaceTrackEndBuilder,
FlightWaypointType.PATROL_TRACK: RaceTrackBuilder, FlightWaypointType.PATROL_TRACK: RaceTrackBuilder,
FlightWaypointType.PICKUP: CargoStopBuilder, FlightWaypointType.PICKUP: CargoStopBuilder,
FlightWaypointType.REFUEL: RefuelPointBuilder,
} }
builder = builders.get(waypoint.waypoint_type, DefaultWaypointBuilder) builder = builders.get(waypoint.waypoint_type, DefaultWaypointBuilder)
return builder( return builder(

View File

@ -906,11 +906,12 @@ class Airfield(ControlPoint):
if self.is_friendly(for_player): if self.is_friendly(for_player):
yield from [ yield from [
FlightType.AEWC, FlightType.AEWC,
FlightType.REFUELING,
# TODO: FlightType.INTERCEPTION # TODO: FlightType.INTERCEPTION
# TODO: FlightType.LOGISTICS # TODO: FlightType.LOGISTICS
] ]
yield FlightType.REFUELING
@property @property
def total_aircraft_parking(self) -> int: def total_aircraft_parking(self) -> int:
""" """

View File

@ -132,6 +132,7 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]):
yield from [ yield from [
FlightType.STRIKE, FlightType.STRIKE,
FlightType.BAI, FlightType.BAI,
FlightType.REFUELING,
] ]
yield from super().mission_types(for_player) yield from super().mission_types(for_player)

View File

@ -26,6 +26,7 @@ from game.ato.starttype import StartType
from game.data.doctrine import Doctrine from game.data.doctrine import Doctrine
from game.dcs.aircrafttype import FuelConsumption from game.dcs.aircrafttype import FuelConsumption
from game.flightplan import HoldZoneGeometry, IpZoneGeometry, JoinZoneGeometry from game.flightplan import HoldZoneGeometry, IpZoneGeometry, JoinZoneGeometry
from game.flightplan.refuelzonegeometry import RefuelZoneGeometry
from game.theater import ( from game.theater import (
Airfield, Airfield,
ConflictTheater, ConflictTheater,
@ -51,7 +52,6 @@ if TYPE_CHECKING:
from game.ato.package import Package from game.ato.package import Package
from game.coalition import Coalition from game.coalition import Coalition
from game.threatzones import ThreatZones from game.threatzones import ThreatZones
from game.transfers import Convoy
INGRESS_TYPES = { INGRESS_TYPES = {
@ -362,6 +362,7 @@ class LoiterFlightPlan(FlightPlan):
class FormationFlightPlan(LoiterFlightPlan): class FormationFlightPlan(LoiterFlightPlan):
join: FlightWaypoint join: FlightWaypoint
split: FlightWaypoint split: FlightWaypoint
refuel: FlightWaypoint
def iter_waypoints(self) -> Iterator[FlightWaypoint]: def iter_waypoints(self) -> Iterator[FlightWaypoint]:
raise NotImplementedError raise NotImplementedError
@ -555,6 +556,7 @@ class CasFlightPlan(PatrollingFlightPlan):
@dataclass(frozen=True) @dataclass(frozen=True)
class TarCapFlightPlan(PatrollingFlightPlan): class TarCapFlightPlan(PatrollingFlightPlan):
takeoff: FlightWaypoint takeoff: FlightWaypoint
refuel: Optional[FlightWaypoint]
land: FlightWaypoint land: FlightWaypoint
divert: Optional[FlightWaypoint] divert: Optional[FlightWaypoint]
bullseye: FlightWaypoint bullseye: FlightWaypoint
@ -567,6 +569,8 @@ class TarCapFlightPlan(PatrollingFlightPlan):
self.patrol_start, self.patrol_start,
self.patrol_end, self.patrol_end,
] ]
if self.refuel is not None:
yield self.refuel
yield from self.nav_from yield from self.nav_from
yield self.land yield self.land
if self.divert is not None: if self.divert is not None:
@ -624,6 +628,8 @@ class StrikeFlightPlan(FormationFlightPlan):
yield self.ingress yield self.ingress
yield from self.targets yield from self.targets
yield self.split yield self.split
if self.refuel is not None:
yield self.refuel
yield from self.nav_from yield from self.nav_from
yield self.land yield self.land
if self.divert is not None: if self.divert is not None:
@ -697,8 +703,20 @@ class StrikeFlightPlan(FormationFlightPlan):
@property @property
def split_time(self) -> timedelta: def split_time(self) -> timedelta:
travel_time = self.travel_time_between_waypoints(self.ingress, self.split) travel_time_ingress = self.travel_time_between_waypoints(
return self.ingress_time + travel_time self.ingress, self.target_area_waypoint
)
travel_time_egress = self.travel_time_between_waypoints(
self.target_area_waypoint, self.split
)
minutes_at_target = 0.75 * len(self.targets)
timedelta_at_target = timedelta(minutes=minutes_at_target)
return (
self.ingress_time
+ travel_time_ingress
+ timedelta_at_target
+ travel_time_egress
)
@property @property
def ingress_time(self) -> timedelta: def ingress_time(self) -> timedelta:
@ -722,6 +740,7 @@ class SweepFlightPlan(LoiterFlightPlan):
nav_to: List[FlightWaypoint] nav_to: List[FlightWaypoint]
sweep_start: FlightWaypoint sweep_start: FlightWaypoint
sweep_end: FlightWaypoint sweep_end: FlightWaypoint
refuel: FlightWaypoint
nav_from: List[FlightWaypoint] nav_from: List[FlightWaypoint]
land: FlightWaypoint land: FlightWaypoint
divert: Optional[FlightWaypoint] divert: Optional[FlightWaypoint]
@ -734,6 +753,8 @@ class SweepFlightPlan(LoiterFlightPlan):
yield from self.nav_to yield from self.nav_to
yield self.sweep_start yield self.sweep_start
yield self.sweep_end yield self.sweep_end
if self.refuel is not None:
yield self.refuel
yield from self.nav_from yield from self.nav_from
yield self.land yield self.land
if self.divert is not None: if self.divert is not None:
@ -835,6 +856,10 @@ class RefuelingFlightPlan(PatrollingFlightPlan):
divert: Optional[FlightWaypoint] divert: Optional[FlightWaypoint]
bullseye: FlightWaypoint bullseye: FlightWaypoint
@property
def patrol_start_time(self) -> timedelta:
return self.package.time_over_target
def iter_waypoints(self) -> Iterator[FlightWaypoint]: def iter_waypoints(self) -> Iterator[FlightWaypoint]:
yield self.takeoff yield self.takeoff
yield from self.nav_to yield from self.nav_to
@ -847,6 +872,52 @@ class RefuelingFlightPlan(PatrollingFlightPlan):
yield self.bullseye yield self.bullseye
@dataclass(frozen=True)
class PackageRefuelingFlightPlan(RefuelingFlightPlan):
def target_area_waypoint(self) -> FlightWaypoint:
return FlightWaypoint(
FlightWaypointType.TARGET_GROUP_LOC,
self.package.target.position.x,
self.package.target.position.y,
meters(0),
)
@property
def patrol_start_time(self) -> timedelta:
altitude: Optional[Distance] = self.flight.unit_type.patrol_altitude
if altitude is None:
altitude = Distance.from_feet(20000)
assert self.package.waypoints is not None
# Cheat in a FlightWaypoint for the split point.
split: Point = self.package.waypoints.split
split_waypoint: FlightWaypoint = FlightWaypoint(
FlightWaypointType.SPLIT, split.x, split.y, altitude
)
# Cheat in a FlightWaypoint for the refuel point.
refuel: Point = self.package.waypoints.refuel
refuel_waypoint: FlightWaypoint = FlightWaypoint(
FlightWaypointType.REFUEL, refuel.x, refuel.y, altitude
)
delay_target_to_split: timedelta = self.travel_time_between_waypoints(
self.target_area_waypoint(), split_waypoint
)
delay_split_to_refuel: timedelta = self.travel_time_between_waypoints(
split_waypoint, refuel_waypoint
)
return (
self.package.time_over_target
+ delay_target_to_split
+ delay_split_to_refuel
- timedelta(minutes=1.5)
)
@dataclass(frozen=True) @dataclass(frozen=True)
class AirliftFlightPlan(FlightPlan): class AirliftFlightPlan(FlightPlan):
takeoff: FlightWaypoint takeoff: FlightWaypoint
@ -1043,11 +1114,24 @@ class FlightPlanBuilder:
elif task == FlightType.TRANSPORT: elif task == FlightType.TRANSPORT:
return self.generate_transport(flight) return self.generate_transport(flight)
elif task == FlightType.REFUELING: elif task == FlightType.REFUELING:
return self.generate_refueling_racetrack(flight) if self.package.target.is_friendly(self.is_player) or isinstance(
self.package.target, FrontLine
):
return self.generate_refueling_racetrack(flight)
else:
return self.generate_refueling_package_support(flight)
elif task == FlightType.FERRY: elif task == FlightType.FERRY:
return self.generate_ferry(flight) return self.generate_ferry(flight)
raise PlanningError(f"{task} flight plan generation not implemented") raise PlanningError(f"{task} flight plan generation not implemented")
def regenerate_flight_plans(self) -> None:
new_flights: list[Flight] = []
for old_flight in self.package.flights:
# TODO: Don't lose custom targets here.
old_flight.flight_plan = self.generate_flight_plan(old_flight, None)
new_flights.append(old_flight)
self.package.flights = new_flights
def regenerate_package_waypoints(self) -> None: def regenerate_package_waypoints(self) -> None:
from game.ato.packagewaypoints import PackageWaypoints from game.ato.packagewaypoints import PackageWaypoints
@ -1067,6 +1151,12 @@ class FlightPlanBuilder:
self.coalition, self.coalition,
).find_best_join_point() ).find_best_join_point()
refuel_point = RefuelZoneGeometry(
package_airfield.position,
join_point,
self.coalition,
).find_best_refuel_point()
# And the split point based on the best route from the IP. Since that's no # And the split point based on the best route from the IP. Since that's no
# different than the best route *to* the IP, this is the same as the join point. # different than the best route *to* the IP, this is the same as the join point.
# TODO: Estimate attack completion point based on the IP and split from there? # TODO: Estimate attack completion point based on the IP and split from there?
@ -1074,6 +1164,7 @@ class FlightPlanBuilder:
WaypointBuilder.perturb(join_point), WaypointBuilder.perturb(join_point),
ingress_point, ingress_point,
WaypointBuilder.perturb(join_point), WaypointBuilder.perturb(join_point),
refuel_point,
) )
def generate_strike(self, flight: Flight) -> StrikeFlightPlan: def generate_strike(self, flight: Flight) -> StrikeFlightPlan:
@ -1241,7 +1332,7 @@ class FlightPlanBuilder:
) )
def generate_sweep(self, flight: Flight) -> SweepFlightPlan: def generate_sweep(self, flight: Flight) -> SweepFlightPlan:
"""Generate a BARCAP flight at a given location. """Generate a FighterSweep flight at a given location.
Args: Args:
flight: The flight to generate the flight plan for. flight: The flight to generate the flight plan for.
@ -1260,6 +1351,11 @@ class FlightPlanBuilder:
hold = builder.hold(self._hold_point(flight)) hold = builder.hold(self._hold_point(flight))
refuel = None
if self.package.waypoints is not None:
refuel = builder.refuel(self.package.waypoints.refuel)
return SweepFlightPlan( return SweepFlightPlan(
package=self.package, package=self.package,
flight=flight, flight=flight,
@ -1275,6 +1371,7 @@ class FlightPlanBuilder:
), ),
sweep_start=start, sweep_start=start,
sweep_end=end, sweep_end=end,
refuel=refuel,
land=builder.land(flight.arrival), land=builder.land(flight.arrival),
divert=builder.divert(flight.divert), divert=builder.divert(flight.divert),
bullseye=builder.bullseye(), bullseye=builder.bullseye(),
@ -1482,6 +1579,12 @@ class FlightPlanBuilder:
orbit0p, orbit1p = self.cap_racetrack_for_objective(location, barcap=False) orbit0p, orbit1p = self.cap_racetrack_for_objective(location, barcap=False)
start, end = builder.race_track(orbit0p, orbit1p, patrol_alt) start, end = builder.race_track(orbit0p, orbit1p, patrol_alt)
refuel = None
if self.package.waypoints is not None:
refuel = builder.refuel(self.package.waypoints.refuel)
return TarCapFlightPlan( return TarCapFlightPlan(
package=self.package, package=self.package,
flight=flight, flight=flight,
@ -1498,6 +1601,7 @@ class FlightPlanBuilder:
nav_from=builder.nav_path(orbit1p, flight.arrival.position, patrol_alt), nav_from=builder.nav_path(orbit1p, flight.arrival.position, patrol_alt),
patrol_start=start, patrol_start=start,
patrol_end=end, patrol_end=end,
refuel=refuel,
land=builder.land(flight.arrival), land=builder.land(flight.arrival),
divert=builder.divert(flight.divert), divert=builder.divert(flight.divert),
bullseye=builder.bullseye(), bullseye=builder.bullseye(),
@ -1611,6 +1715,9 @@ class FlightPlanBuilder:
hold = builder.hold(self._hold_point(flight)) hold = builder.hold(self._hold_point(flight))
join = builder.join(self.package.waypoints.join) join = builder.join(self.package.waypoints.join)
split = builder.split(self.package.waypoints.split) split = builder.split(self.package.waypoints.split)
refuel = None
if self.package.waypoints.refuel is not None:
refuel = builder.refuel(self.package.waypoints.refuel)
return StrikeFlightPlan( return StrikeFlightPlan(
package=self.package, package=self.package,
@ -1625,6 +1732,7 @@ class FlightPlanBuilder:
ingress=ingress, ingress=ingress,
targets=[target], targets=[target],
split=split, split=split,
refuel=refuel,
nav_from=builder.nav_path( nav_from=builder.nav_path(
split.position, flight.arrival.position, self.doctrine.ingress_altitude split.position, flight.arrival.position, self.doctrine.ingress_altitude
), ),
@ -1703,6 +1811,11 @@ class FlightPlanBuilder:
) )
def generate_refueling_racetrack(self, flight: Flight) -> RefuelingFlightPlan: def generate_refueling_racetrack(self, flight: Flight) -> RefuelingFlightPlan:
racetrack_half_distance = Distance.from_nautical_miles(20).meters
patrol_duration = timedelta(hours=1)
location = self.package.target location = self.package.target
closest_boundary = self.threat_zones.closest_boundary(location.position) closest_boundary = self.threat_zones.closest_boundary(location.position)
@ -1725,11 +1838,10 @@ class FlightPlanBuilder:
orbit_heading.degrees, orbit_distance.meters orbit_heading.degrees, orbit_distance.meters
) )
racetrack_half_distance = Distance.from_nautical_miles(20).meters
racetrack_start = racetrack_center.point_from_heading( racetrack_start = racetrack_center.point_from_heading(
orbit_heading.right.degrees, racetrack_half_distance orbit_heading.right.degrees, racetrack_half_distance
) )
racetrack_end = racetrack_center.point_from_heading( racetrack_end = racetrack_center.point_from_heading(
orbit_heading.left.degrees, racetrack_half_distance orbit_heading.left.degrees, racetrack_half_distance
) )
@ -1764,7 +1876,74 @@ class FlightPlanBuilder:
land=builder.land(flight.arrival), land=builder.land(flight.arrival),
divert=builder.divert(flight.divert), divert=builder.divert(flight.divert),
bullseye=builder.bullseye(), bullseye=builder.bullseye(),
patrol_duration=timedelta(hours=1), patrol_duration=patrol_duration,
patrol_speed=speed,
# TODO: Factor out a common base of the combat and non-combat race-tracks.
# No harm in setting this, but we ought to clean up a bit.
engagement_distance=meters(0),
)
def generate_refueling_package_support(
self, flight: Flight
) -> PackageRefuelingFlightPlan:
package_waypoints = self.package.waypoints
assert package_waypoints is not None
racetrack_half_distance = Distance.from_nautical_miles(20).meters
# TODO: Only consider aircraft that can refuel with this tanker type.
refuel_time_minutes = 5
for flight in self.package.flights:
flight_size = flight.roster.max_size
refuel_time_minutes = refuel_time_minutes + 4 * flight_size + 1
patrol_duration = timedelta(minutes=refuel_time_minutes)
racetrack_center = package_waypoints.refuel
split_heading = Heading.from_degrees(
racetrack_center.heading_between_point(package_waypoints.split)
)
home_heading = split_heading.opposite
racetrack_start = racetrack_center.point_from_heading(
split_heading.degrees, racetrack_half_distance
)
racetrack_end = racetrack_center.point_from_heading(
home_heading.degrees, racetrack_half_distance
)
builder = WaypointBuilder(flight, self.coalition)
tanker_type = flight.unit_type
if tanker_type.patrol_altitude is not None:
altitude = tanker_type.patrol_altitude
else:
altitude = feet(21000)
# TODO: Could use flight.unit_type.preferred_patrol_speed(altitude) instead.
if tanker_type.patrol_speed is not None:
speed = tanker_type.patrol_speed
else:
# ~280 knots IAS at 21000.
speed = knots(400)
racetrack = builder.race_track(racetrack_start, racetrack_end, altitude)
return PackageRefuelingFlightPlan(
package=self.package,
flight=flight,
takeoff=builder.takeoff(flight.departure),
nav_to=builder.nav_path(
flight.departure.position, racetrack_start, altitude
),
nav_from=builder.nav_path(racetrack_end, flight.arrival.position, altitude),
patrol_start=racetrack[0],
patrol_end=racetrack[1],
land=builder.land(flight.arrival),
divert=builder.divert(flight.divert),
bullseye=builder.bullseye(),
patrol_duration=patrol_duration,
patrol_speed=speed, patrol_speed=speed,
# TODO: Factor out a common base of the combat and non-combat race-tracks. # TODO: Factor out a common base of the combat and non-combat race-tracks.
# No harm in setting this, but we ought to clean up a bit. # No harm in setting this, but we ought to clean up a bit.
@ -1843,6 +2022,9 @@ class FlightPlanBuilder:
hold = builder.hold(self._hold_point(flight)) hold = builder.hold(self._hold_point(flight))
join = builder.join(self.package.waypoints.join) join = builder.join(self.package.waypoints.join)
split = builder.split(self.package.waypoints.split) split = builder.split(self.package.waypoints.split)
refuel = None
if self.package.waypoints.refuel is not None:
refuel = builder.refuel(self.package.waypoints.refuel)
return StrikeFlightPlan( return StrikeFlightPlan(
package=self.package, package=self.package,
@ -1859,6 +2041,7 @@ class FlightPlanBuilder:
), ),
targets=target_waypoints, targets=target_waypoints,
split=split, split=split,
refuel=refuel,
nav_from=builder.nav_path( nav_from=builder.nav_path(
split.position, flight.arrival.position, self.doctrine.ingress_altitude split.position, flight.arrival.position, self.doctrine.ingress_altitude
), ),

View File

@ -199,6 +199,20 @@ class WaypointBuilder:
waypoint.name = "JOIN" waypoint.name = "JOIN"
return waypoint return waypoint
def refuel(self, position: Point) -> FlightWaypoint:
waypoint = FlightWaypoint(
FlightWaypointType.REFUEL,
position.x,
position.y,
meters(80) if self.is_helo else self.doctrine.ingress_altitude,
)
if self.is_helo:
waypoint.alt_type = "RADIO"
waypoint.pretty_name = "Refuel"
waypoint.description = "Refuel from tanker"
waypoint.name = "REFUEL"
return waypoint
def split(self, position: Point) -> FlightWaypoint: def split(self, position: Point) -> FlightWaypoint:
waypoint = FlightWaypoint( waypoint = FlightWaypoint(
FlightWaypointType.SPLIT, FlightWaypointType.SPLIT,