From dd4c37cde331931cf77cee47b21e63abf9a4c9ef Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Oct 2020 16:36:49 -0700 Subject: [PATCH] Pick runways and ascent/descent based on headwind. --- gen/aircraft.py | 26 ++---- gen/airfields.py | 66 +--------------- gen/briefinggen.py | 7 +- gen/flights/flightplan.py | 18 ++--- gen/flights/waypointbuilder.py | 14 ++-- gen/groundobjectsgen.py | 6 +- gen/kneeboard.py | 6 +- gen/runways.py | 139 +++++++++++++++++++++++++++++++++ 8 files changed, 177 insertions(+), 105 deletions(-) create mode 100644 gen/runways.py diff --git a/gen/aircraft.py b/gen/aircraft.py index 0a8c1cf7..7e2ceb1a 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -64,7 +64,6 @@ from game import db from game.data.cap_capabilities_db import GUNFIGHTERS from game.settings import Settings from game.utils import nm_to_meter -from gen.airfields import RunwayData from gen.airsupportgen import AirSupport from gen.ato import AirTaskingOrder, Package from gen.callsigns import create_group_callsign_from_unit @@ -75,11 +74,13 @@ from gen.flights.flight import ( FlightWaypointType, ) from gen.radios import MHz, Radio, RadioFrequency, RadioRegistry, get_radio +from gen.runways import RunwayData from theater import TheaterGroundObject from theater.controlpoint import ControlPoint, ControlPointType from .conflictgen import Conflict from .flights.traveltime import PackageWaypointTiming, TotEstimator from .naming import namegen +from .runways import RunwayAssigner WARM_START_HELI_AIRSPEED = 120 WARM_START_HELI_ALT = 500 @@ -621,9 +622,12 @@ class AircraftConflictGenerator: # TODO: Support for different departure/arrival airfields. cp = flight.from_cp - fallback_runway = RunwayData(cp.full_name, runway_name="") + fallback_runway = RunwayData(cp.full_name, runway_heading=0, + runway_name="") if cp.cptype == ControlPointType.AIRBASE: - departure_runway = self.get_preferred_runway(flight.from_cp.airport) + assigner = RunwayAssigner(self.game.conditions) + departure_runway = assigner.get_preferred_runway( + flight.from_cp.airport) elif cp.is_fleet: departure_runway = dynamic_runways.get(cp.name, fallback_runway) else: @@ -655,22 +659,6 @@ class AircraftConflictGenerator: for unit in group.units: unit.fuel = Su_33.fuel_max * 0.8 - def get_preferred_runway(self, airport: Airport) -> RunwayData: - """Returns the preferred runway for the given airport. - - Right now we're only selecting runways based on whether or not they have - ILS, but we could also choose based on wind conditions, or which - direction flight plans should follow. - """ - runways = list(RunwayData.for_pydcs_airport(airport)) - for runway in runways: - # Prefer any runway with ILS. - if runway.ils is not None: - return runway - # Otherwise we lack the mission information to pick more usefully, - # so just use the first runway. - return runways[0] - def _generate_at_airport(self, name: str, side: Country, unit_type: FlyingType, count: int, start_type: str, airport: Optional[Airport] = None) -> FlyingGroup: diff --git a/gen/airfields.py b/gen/airfields.py index b3185158..5ea5c57c 100644 --- a/gen/airfields.py +++ b/gen/airfields.py @@ -3,11 +3,11 @@ Remove once https://github.com/pydcs/dcs/issues/69 tracks getting the missing data added to pydcs. Until then, missing data can be manually filled in here. """ -from dataclasses import dataclass, field -import logging -from typing import Dict, Iterator, Optional, Tuple +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Optional, Tuple -from dcs.terrain.terrain import Airport from .radios import MHz, RadioFrequency from .tacan import TacanBand, TacanChannel @@ -1503,61 +1503,3 @@ AIRFIELD_DATA = { atc=AtcData(MHz(3, 775), MHz(118, 50), MHz(38, 450), MHz(250, 50)), ), } - - -@dataclass(frozen=True) -class RunwayData: - airfield_name: str - runway_name: str - atc: Optional[RadioFrequency] = None - tacan: Optional[TacanChannel] = None - tacan_callsign: Optional[str] = None - ils: Optional[RadioFrequency] = None - icls: Optional[int] = None - - @classmethod - def for_airfield(cls, airport: Airport, runway: str) -> "RunwayData": - """Creates RunwayData for the given runway of an airfield. - - Args: - airport: The airfield the runway belongs to. - runway: Identifier of the runway to use. e.g. "03" or "20L". - """ - atc: Optional[RadioFrequency] = None - tacan: Optional[TacanChannel] = None - tacan_callsign: Optional[str] = None - ils: Optional[RadioFrequency] = None - try: - airfield = AIRFIELD_DATA[airport.name] - if airfield.atc is not None: - atc = airfield.atc.uhf - else: - atc = None - tacan = airfield.tacan - tacan_callsign = airfield.tacan_callsign - ils = airfield.ils_freq(runway) - except KeyError: - logging.warning(f"No airfield data for {airport.name}") - return cls( - airfield_name=airport.name, - runway_name=runway, - atc=atc, - tacan=tacan, - tacan_callsign=tacan_callsign, - ils=ils - ) - - @classmethod - def for_pydcs_airport(cls, airport: Airport) -> Iterator["RunwayData"]: - for runway in airport.runways: - runway_number = runway.heading // 10 - runway_side = ["", "L", "R"][runway.leftright] - runway_name = f"{runway_number:02}{runway_side}" - yield cls.for_airfield(airport, runway_name) - - # pydcs only exposes one runway per physical runway, so to expose - # both sides of the runway we need to generate the other. - runway_number = ((runway.heading + 180) % 360) // 10 - runway_side = ["", "R", "L"][runway.leftright] - runway_name = f"{runway_number:02}{runway_side}" - yield cls.for_airfield(airport, runway_name) diff --git a/gen/briefinggen.py b/gen/briefinggen.py index 1eef67a7..63f29396 100644 --- a/gen/briefinggen.py +++ b/gen/briefinggen.py @@ -1,19 +1,20 @@ import datetime import os +import random from collections import defaultdict from dataclasses import dataclass -import random from typing import List -from game import db from dcs.mission import Mission + +from game import db from .aircraft import FlightData -from .airfields import RunwayData from .airsupportgen import AwacsInfo, TankerInfo from .armor import JtacInfo from .conflictgen import Conflict from .ground_forces.combat_stance import CombatStance from .radios import RadioFrequency +from .runways import RunwayData @dataclass diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index e89d2f53..7c9f26f1 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -132,7 +132,7 @@ class FlightPlanBuilder: if not isinstance(location, TheaterGroundObject): raise InvalidObjectiveLocation(flight.flight_type, location) - builder = WaypointBuilder(flight, self.doctrine) + builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) builder.ascent(flight.from_cp) builder.hold(self._hold_point(flight)) builder.join(self.package.waypoints.join) @@ -222,7 +222,7 @@ class FlightPlanBuilder: ) start = end.point_from_heading(heading - 180, diameter) - builder = WaypointBuilder(flight, self.doctrine) + builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) builder.ascent(flight.from_cp) builder.race_track(start, end, patrol_alt) builder.rtb(flight.from_cp) @@ -264,7 +264,7 @@ class FlightPlanBuilder: orbit1p = orbit_center.point_from_heading(heading + 180, radius) # Create points - builder = WaypointBuilder(flight, self.doctrine) + builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) builder.ascent(flight.from_cp) builder.hold(self._hold_point(flight)) builder.join(self.package.waypoints.join) @@ -290,7 +290,7 @@ class FlightPlanBuilder: if custom_targets is None: custom_targets = [] - builder = WaypointBuilder(flight, self.doctrine) + builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) builder.ascent(flight.from_cp) builder.hold(self._hold_point(flight)) builder.join(self.package.waypoints.join) @@ -328,7 +328,7 @@ class FlightPlanBuilder: def generate_escort(self, flight: Flight) -> None: assert self.package.waypoints is not None - builder = WaypointBuilder(flight, self.doctrine) + builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) builder.ascent(flight.from_cp) builder.hold(self._hold_point(flight)) builder.join(self.package.waypoints.join) @@ -361,7 +361,7 @@ class FlightPlanBuilder: center = ingress.point_from_heading(heading, distance / 2) egress = ingress.point_from_heading(heading, distance) - builder = WaypointBuilder(flight, self.doctrine) + builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) builder.ascent(flight.from_cp, is_helo) builder.hold(self._hold_point(flight)) builder.join(self.package.waypoints.join) @@ -382,7 +382,7 @@ class FlightPlanBuilder: flight: The flight to generate the descend point for. departure: Departure airfield or carrier. """ - builder = WaypointBuilder(flight, self.doctrine) + builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) builder.ascent(departure) return builder.build()[0] @@ -394,7 +394,7 @@ class FlightPlanBuilder: flight: The flight to generate the descend point for. arrival: Arrival airfield or carrier. """ - builder = WaypointBuilder(flight, self.doctrine) + builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) builder.descent(arrival) return builder.build()[0] @@ -406,7 +406,7 @@ class FlightPlanBuilder: flight: The flight to generate the landing waypoint for. arrival: Arrival airfield or carrier. """ - builder = WaypointBuilder(flight, self.doctrine) + builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) builder.land(arrival) return builder.build()[0] diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index 5ee8820c..cdaefd0b 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -7,12 +7,16 @@ from dcs.unit import Unit from game.data.doctrine import Doctrine from game.utils import nm_to_meter +from game.weather import Conditions from theater import ControlPoint, MissionTarget, TheaterGroundObject from .flight import Flight, FlightWaypoint, FlightWaypointType +from ..runways import RunwayAssigner class WaypointBuilder: - def __init__(self, flight: Flight, doctrine: Doctrine) -> None: + def __init__(self, conditions: Conditions, flight: Flight, + doctrine: Doctrine) -> None: + self.conditions = conditions self.flight = flight self.doctrine = doctrine self.waypoints: List[FlightWaypoint] = [] @@ -28,8 +32,7 @@ class WaypointBuilder: departure: Departure airfield or carrier. is_helo: True if the flight is a helicopter. """ - # TODO: Pick runway based on wind direction. - heading = departure.heading + heading = RunwayAssigner(self.conditions).takeoff_heading(departure) position = departure.position.point_from_heading( heading, nm_to_meter(5) ) @@ -52,9 +55,8 @@ class WaypointBuilder: arrival: Arrival airfield or carrier. is_helo: True if the flight is a helicopter. """ - # TODO: Pick runway based on wind direction. - # ControlPoint.heading is the departure heading. - heading = (arrival.heading + 180) % 360 + landing_heading = RunwayAssigner(self.conditions).landing_heading(arrival) + heading = (landing_heading + 180) % 360 position = arrival.position.point_from_heading( heading, nm_to_meter(5) ) diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index 2d1894e8..779492e0 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -16,9 +16,9 @@ from dcs.unitgroup import StaticGroup from game import db from game.data.building_data import FORTIFICATION_UNITS, FORTIFICATION_UNITS_ID from game.db import unit_type_from_name -from .airfields import RunwayData from .conflictgen import Conflict from .radios import RadioRegistry +from .runways import RunwayData from .tacan import TacanBand, TacanRegistry FARP_FRONTLINE_DISTANCE = 10000 @@ -141,8 +141,9 @@ class GroundObjectsGenerator: # Find carrier direction (In the wind) found_carrier_destination = False attempt = 0 + brc = self.m.weather.wind_at_ground.direction + 180 while not found_carrier_destination and attempt < 5: - point = sg.points[0].position.point_from_heading(self.m.weather.wind_at_ground.direction + 180, 100000-attempt*20000) + point = sg.points[0].position.point_from_heading(brc, 100000-attempt*20000) if self.game.theater.is_in_sea(point): found_carrier_destination = True sg.add_waypoint(point) @@ -196,6 +197,7 @@ class GroundObjectsGenerator: # unit name since it's an arbitrary ID. self.runways[cp.name] = RunwayData( cp.name, + brc, "N/A", atc=atc_channel, tacan=tacan, diff --git a/gen/kneeboard.py b/gen/kneeboard.py index a0c4c7a5..d2782188 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -22,14 +22,13 @@ https://forums.eagle.ru/showthread.php?t=206360 claims that kneeboard pages can 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 from collections import defaultdict from dataclasses import dataclass -import datetime from pathlib import Path from typing import Dict, List, Optional, Tuple from PIL import Image, ImageDraw, ImageFont -from dcs.mapping import Point from dcs.mission import Mission from dcs.unittype import FlyingType from tabulate import tabulate @@ -37,12 +36,11 @@ from tabulate import tabulate from game.utils import meter_to_nm from . import units from .aircraft import AIRCRAFT_DATA, FlightData -from .airfields import RunwayData from .airsupportgen import AwacsInfo, TankerInfo from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator from .flights.flight import FlightWaypoint, FlightWaypointType -from .flights.traveltime import TravelTime from .radios import RadioFrequency +from .runways import RunwayData class KneeboardPageWriter: diff --git a/gen/runways.py b/gen/runways.py new file mode 100644 index 00000000..5323c37b --- /dev/null +++ b/gen/runways.py @@ -0,0 +1,139 @@ +"""Runway information and selection.""" +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Iterator, Optional + +from dcs.terrain.terrain import Airport + +from game.weather import Conditions +from theater import ControlPoint, ControlPointType +from .airfields import AIRFIELD_DATA +from .radios import RadioFrequency +from .tacan import TacanChannel + + +@dataclass(frozen=True) +class RunwayData: + airfield_name: str + runway_heading: int + runway_name: str + atc: Optional[RadioFrequency] = None + tacan: Optional[TacanChannel] = None + tacan_callsign: Optional[str] = None + ils: Optional[RadioFrequency] = None + icls: Optional[int] = None + + @classmethod + def for_airfield(cls, airport: Airport, runway_heading: int, + runway_name: str) -> RunwayData: + """Creates RunwayData for the given runway of an airfield. + + Args: + airport: The airfield the runway belongs to. + runway_heading: Heading of the runway. + runway_name: Identifier of the runway to use. e.g. "03" or "20L". + """ + atc: Optional[RadioFrequency] = None + tacan: Optional[TacanChannel] = None + tacan_callsign: Optional[str] = None + ils: Optional[RadioFrequency] = None + try: + airfield = AIRFIELD_DATA[airport.name] + if airfield.atc is not None: + atc = airfield.atc.uhf + else: + atc = None + tacan = airfield.tacan + tacan_callsign = airfield.tacan_callsign + ils = airfield.ils_freq(runway_name) + except KeyError: + logging.warning(f"No airfield data for {airport.name}") + return cls( + airfield_name=airport.name, + runway_heading=runway_heading, + runway_name=runway_name, + atc=atc, + tacan=tacan, + tacan_callsign=tacan_callsign, + ils=ils + ) + + @classmethod + def for_pydcs_airport(cls, airport: Airport) -> Iterator[RunwayData]: + for runway in airport.runways: + runway_number = runway.heading // 10 + runway_side = ["", "L", "R"][runway.leftright] + runway_name = f"{runway_number:02}{runway_side}" + yield cls.for_airfield(airport, runway.heading, runway_name) + + # pydcs only exposes one runway per physical runway, so to expose + # both sides of the runway we need to generate the other. + heading = (runway.heading + 180) % 360 + runway_number = heading // 10 + runway_side = ["", "R", "L"][runway.leftright] + runway_name = f"{runway_number:02}{runway_side}" + yield cls.for_airfield(airport, heading, runway_name) + + +class RunwayAssigner: + def __init__(self, conditions: Conditions): + self.conditions = conditions + + def angle_off_headwind(self, runway: RunwayData) -> int: + wind = self.conditions.weather.wind.at_0m.direction + ideal_heading = (wind + 180) % 360 + return abs(runway.runway_heading - ideal_heading) + + def get_preferred_runway(self, airport: Airport) -> RunwayData: + """Returns the preferred runway for the given airport. + + Right now we're only selecting runways based on whether or not + they have + ILS, but we could also choose based on wind conditions, or which + direction flight plans should follow. + """ + runways = list(RunwayData.for_pydcs_airport(airport)) + + # Find the runway with the best headwind first. + best_runways = [runways[0]] + best_angle_off_headwind = self.angle_off_headwind(best_runways[0]) + for runway in runways[1:]: + angle_off_headwind = self.angle_off_headwind(runway) + if angle_off_headwind == best_angle_off_headwind: + best_runways.append(runway) + elif angle_off_headwind < best_angle_off_headwind: + best_runways = [runway] + best_angle_off_headwind = angle_off_headwind + + for runway in best_runways: + # But if there are multiple runways with the same heading, + # prefer + # and ILS capable runway. + if runway.ils is not None: + return runway + + # Otherwise the only difference between the two is the distance from + # parking, which we don't know, so just pick the first one. + return best_runways[0] + + def takeoff_heading(self, departure: ControlPoint) -> int: + if departure.cptype == ControlPointType.AIRBASE: + return self.get_preferred_runway(departure.airport).runway_heading + elif departure.is_fleet: + # The carrier will be angled into the wind automatically. + return (self.conditions.weather.wind.at_0m.direction + 180) % 360 + logging.warning( + f"Unhandled departure control point: {departure.cptype}") + return 0 + + def landing_heading(self, arrival: ControlPoint) -> int: + if arrival.cptype == ControlPointType.AIRBASE: + return self.get_preferred_runway(arrival.airport).runway_heading + elif arrival.is_fleet: + # The carrier will be angled into the wind automatically. + return (self.conditions.weather.wind.at_0m.direction + 180) % 360 + logging.warning( + f"Unhandled departure control point: {arrival.cptype}") + return 0