diff --git a/game/operation/operation.py b/game/operation/operation.py index 5f7a9503..09242628 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -4,8 +4,9 @@ from dcs.terrain import Terrain from gen import * from userdata.debriefing import * - -TANKER_CALLSIGNS = ["Texaco", "Arco", "Shell"] +from gen.airfields import AIRFIELD_DATA +from gen.radios import RadioRegistry +from gen.tacan import TacanRegistry class Operation: @@ -25,6 +26,8 @@ class Operation: groundobjectgen = None # type: GroundObjectsGenerator briefinggen = None # type: BriefingGenerator forcedoptionsgen = None # type: ForcedOptionsGenerator + radio_registry: Optional[RadioRegistry] = None + tacan_registry: Optional[TacanRegistry] = None environment_settings = None trigger_radius = TRIGGER_RADIUS_MEDIUM @@ -63,8 +66,14 @@ class Operation: def initialize(self, mission: Mission, conflict: Conflict): self.current_mission = mission self.conflict = conflict - self.airgen = AircraftConflictGenerator(mission, conflict, self.game.settings, self.game) - self.airsupportgen = AirSupportConflictGenerator(mission, conflict, self.game) + self.radio_registry = RadioRegistry() + self.tacan_registry = TacanRegistry() + self.airgen = AircraftConflictGenerator( + mission, conflict, self.game.settings, self.game, + self.radio_registry) + self.airsupportgen = AirSupportConflictGenerator( + mission, conflict, self.game, self.radio_registry, + self.tacan_registry) self.triggersgen = TriggersGenerator(mission, conflict, self.game) self.visualgen = VisualGenerator(mission, conflict, self.game) self.envgen = EnviromentGenerator(mission, conflict, self.game) @@ -120,6 +129,17 @@ class Operation: # Generate ground object first self.groundobjectgen.generate() + for airfield, data in AIRFIELD_DATA.items(): + if data.theater == self.game.theater.terrain.name: + self.radio_registry.reserve(data.atc.hf) + self.radio_registry.reserve(data.atc.vhf_fm) + self.radio_registry.reserve(data.atc.vhf_am) + self.radio_registry.reserve(data.atc.uhf) + for ils in data.ils.values(): + self.radio_registry.reserve(ils) + if data.tacan is not None: + self.tacan_registry.reserve(data.tacan) + # Generate destroyed units for d in self.game.get_destroyed_units(): try: @@ -224,21 +244,16 @@ class Operation: kneeboard_generator = KneeboardGenerator(self.current_mission, self.game) # Briefing Generation - for i, tanker_type in enumerate(self.airsupportgen.generated_tankers): - callsign = TANKER_CALLSIGNS[i] - tacan = f"{60 + i}X" - freq = f"{130 + i} MHz AM" - self.briefinggen.append_frequency(f"Tanker {callsign} ({tanker_type})", f"{tacan}/{freq}") - kneeboard_generator.add_tanker(callsign, tanker_type, freq, tacan) + for tanker in self.airsupportgen.air_support.tankers: + self.briefinggen.append_frequency( + f"Tanker {tanker.callsign} ({tanker.variant})", + f"{tanker.tacan}/{tanker.freq}") + kneeboard_generator.add_tanker(tanker) if self.is_awacs_enabled: - callsign = "AWACS" - freq = "233 MHz AM" - self.briefinggen.append_frequency(callsign, freq) - kneeboard_generator.add_awacs(callsign, freq) - - self.briefinggen.append_frequency("Flight", "251 MHz AM") - kneeboard_generator.add_comm("Flight", "251 MHz AM") + for awacs in self.airsupportgen.air_support.awacs: + self.briefinggen.append_frequency(awacs.callsign, awacs.freq) + kneeboard_generator.add_awacs(awacs) # Generate the briefing self.briefinggen.generate() diff --git a/gen/aircraft.py b/gen/aircraft.py index c5eb8ae8..aa5a62e4 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -1,14 +1,32 @@ -from dcs.action import ActivateGroup, AITaskPush, MessageToCoalition, MessageToAll -from dcs.condition import TimeAfter, CoalitionHasAirdrome, PartOfCoalitionInZone -from dcs.helicopters import UH_1H -from dcs.terrain.terrain import NoParkingSlotError -from dcs.triggers import TriggerOnce, Event +from dataclasses import dataclass +from typing import Dict from game.data.cap_capabilities_db import GUNFIGHTERS from game.settings import Settings from game.utils import nm_to_meter from gen.flights.ai_flight_planner import FlightPlanner from gen.flights.flight import Flight, FlightType, FlightWaypointType +from gen.radios import get_radio, MHz, Radio, RadioFrequency, RadioRegistry +from pydcs.dcs import helicopters +from pydcs.dcs.action import ActivateGroup, AITaskPush, MessageToAll +from pydcs.dcs.condition import TimeAfter, CoalitionHasAirdrome, PartOfCoalitionInZone +from pydcs.dcs.helicopters import helicopter_map, UH_1H +from pydcs.dcs.mission import Mission, StartType +from pydcs.dcs.planes import ( + Bf_109K_4, + FW_190A8, + FW_190D9, + I_16, + Ju_88A4, + P_47D_30, + P_51D, + P_51D_30_NA, + SpitfireLFMkIX, + SpitfireLFMkIXCW, +) +from pydcs.dcs.terrain.terrain import NoParkingSlotError +from pydcs.dcs.triggers import TriggerOnce, Event +from pydcs.dcs.unittype import UnitType from .conflictgen import * from .naming import * @@ -23,17 +41,83 @@ RTB_ALTITUDE = 800 RTB_DISTANCE = 5000 HELI_ALT = 500 +# Note that fallback radio channels will *not* be reserved. It's possible that +# flights using these will overlap with other channels. This is because we would +# need to make sure we fell back to a frequency that is not used by any beacon +# or ATC, which we don't have the information to predict. Deal with the minor +# annoyance for now since we'll be fleshing out radio info soon enough. +ALLIES_WW2_CHANNEL = MHz(124) +GERMAN_WW2_CHANNEL = MHz(40) +HELICOPTER_CHANNEL = MHz(127) +UHF_FALLBACK_CHANNEL = MHz(251) + + +@dataclass(frozen=True) +class AircraftData: + """Additional aircraft data not exposed by pydcs.""" + + #: The type of radio used for intra-flight communications. + intra_flight_radio: Radio + + +# Indexed by the id field of the pydcs PlaneType. +AIRCRAFT_DATA: Dict[str, AircraftData] = { + "A-10C": AircraftData(get_radio("AN/ARC-186(V) AM")), + "F-16C_50": AircraftData(get_radio("AN/ARC-222")), + "F/A-18C": AircraftData(get_radio("AN/ARC-210")), +} + + +# TODO: Get radio information for all the special cases. +def get_fallback_channel(unit_type: UnitType) -> RadioFrequency: + if unit_type in helicopter_map.values() and unit_type != UH_1H: + return HELICOPTER_CHANNEL + + german_ww2_aircraft = [ + Bf_109K_4, + FW_190A8, + FW_190D9, + Ju_88A4, + ] + + if unit_type in german_ww2_aircraft: + return GERMAN_WW2_CHANNEL + + allied_ww2_aircraft = [ + I_16, + P_47D_30, + P_51D, + P_51D_30_NA, + SpitfireLFMkIX, + SpitfireLFMkIXCW, + ] + + if unit_type in allied_ww2_aircraft: + return ALLIES_WW2_CHANNEL + + return UHF_FALLBACK_CHANNEL + class AircraftConflictGenerator: escort_targets = [] # type: typing.List[typing.Tuple[FlyingGroup, int]] - def __init__(self, mission: Mission, conflict: Conflict, settings: Settings, game): + def __init__(self, mission: Mission, conflict: Conflict, settings: Settings, + game, radio_registry: RadioRegistry): self.m = mission self.game = game self.settings = settings self.conflict = conflict + self.radio_registry = radio_registry self.escort_targets = [] + def get_intra_flight_channel(self, airframe: UnitType) -> RadioFrequency: + try: + aircraft_data = AIRCRAFT_DATA[airframe.id] + return self.radio_registry.alloc_for_radio( + aircraft_data.intra_flight_radio) + except KeyError: + return get_fallback_channel(airframe) + def _start_type(self) -> StartType: return self.settings.cold_start and StartType.Cold or StartType.Warm @@ -90,19 +174,9 @@ class AircraftConflictGenerator: group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire)) - # TODO : refactor this following bad specific special case code :( - - if unit_type in helicopters.helicopter_map.values() and unit_type not in [UH_1H]: - group.set_frequency(127.5) - else: - if unit_type not in [P_51D_30_NA, P_51D, SpitfireLFMkIX, SpitfireLFMkIXCW, P_47D_30, I_16, FW_190A8, FW_190D9, Bf_109K_4]: - group.set_frequency(251.0) - else: - # WW2 - if unit_type in [FW_190A8, FW_190D9, Bf_109K_4, Ju_88A4]: - group.set_frequency(40) - else: - group.set_frequency(124.0) + channel = self.get_intra_flight_channel(unit_type) + group.set_frequency(channel.mhz) + flight.intra_flight_channel = channel # Special case so Su 33 carrier take off if unit_type is Su_33: diff --git a/gen/airfields.py b/gen/airfields.py index 2b51995a..38d4ccdb 100644 --- a/gen/airfields.py +++ b/gen/airfields.py @@ -4,10 +4,10 @@ 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 -from typing import Dict, List, Optional +from typing import Dict, Optional, Tuple - -RadioFrequency = str +from .radios import MHz, RadioFrequency +from .tacan import TacanBand, TacanChannel @dataclass @@ -21,6 +21,8 @@ class AtcData: @dataclass class AirfieldData: """Additional airfield data not included in pydcs.""" + #: Name of the theater the airport is in. + theater: str #: ICAO airport code icao: Optional[str] = None @@ -31,614 +33,615 @@ class AirfieldData: #: Runway length (in ft). runway_length: int = 0 - #: TACAN channel as a string, i.e. "74X". - tacan: Optional[str] = None + #: TACAN channel for the airfield. + tacan: Optional[TacanChannel] = None #: TACAN callsign tacan_callsign: Optional[str] = None - #: VOR channel as a string, i.e. "114.90 (MA)". - vor: Optional[str] = None + #: VOR as a tuple of (callsign, frequency). + vor: Optional[Tuple[str, RadioFrequency]] = None - #: RSBN channel as a string, i.e. "ch 28 (KW)". - rsbn: Optional[str] = None + #: RSBN channel as a tuple of (callsign, channel). + rsbn: Optional[Tuple[str, int]] = None - #: Radio channels used by the airfield's ATC. - atc: AtcData = AtcData("", "", "", "") + #: Radio channels used by the airfield's ATC. Note that not all airfields + #: have ATCs. + atc: Optional[AtcData] = None - #: Dict of runway heading -> ILS frequency. - ils: Dict[str, RadioFrequency] = field(default_factory=dict) + #: Dict of runway heading -> ILS tuple of (callsign, frequency). + ils: Dict[str, Tuple[str, RadioFrequency]] = field(default_factory=dict) - #: Dict of runway heading -> PRMG info, i.e "ch 26 (KW)" - prmg: Dict[str, str] = field(default_factory=dict) + #: Dict of runway heading -> PRMG tuple of (callsign, channel). + prmg: Dict[str, Tuple[str, int]] = field(default_factory=dict) - #: Dict of runway heading -> outer ndb, i.e "408.00 (KW)" - outer_ndb: Dict[str, str] = field(default_factory=dict) + #: Dict of runway heading -> outer NDB tuple of (callsign, frequency). + outer_ndb: Dict[str, Tuple[str, RadioFrequency]] = field(default_factory=dict) - #: Dict of runway heading -> inner ndb, i.e "803.00 (K) - inner_ndb: Dict[str, str] = field(default_factory=dict) + #: Dict of runway heading -> inner NDB tuple of (callsign, frequency). + inner_ndb: Dict[str, Tuple[str, RadioFrequency]] = field(default_factory=dict) def ils_freq(self, runway: str) -> Optional[RadioFrequency]: - return self.ils.get(runway) + ils = self.ils.get(runway) + if ils is not None: + return ils[1] + return None # TODO: Add more airfields. AIRFIELD_DATA = { + # Caucasus - # TODO : CAUCASUS MAP "Batumi": AirfieldData( - "UGSB", - 32, 6792, - "16X", "BTM", - "", "", - AtcData("4.250", "131.000", "40.400", "260.000"), - {"13": "110.30 (ILU)"}, - {}, - {}, - {} + theater="Caucasus", + icao="UGSB", + elevation=32, + runway_length=6792, + tacan=TacanChannel(16, TacanBand.X), + tacan_callsign="BTM", + atc=AtcData(MHz(4, 250), MHz(131, 0), MHz(40, 400), MHz(260, 0)), + ils={ + "13": ("ILU", MHz(110, 30)), + }, ), "Kobuleti": AirfieldData( - "UG5X", - 59, 7406, - "67X", "KBL", - "", "", - AtcData("4.350", "133.000", "40.800", "262.000"), - {"7": "111.50 (IKB)"}, - {}, - {"7": "870.00 (KT)"}, - {"7": "490.00 (T)"}, + theater="Caucasus", + icao="UG5X", + elevation=59, + runway_length=7406, + tacan=TacanChannel(67, TacanBand.X), + tacan_callsign="KBL", + atc=AtcData(MHz(4, 350), MHz(133, 0), MHz(40, 800), MHz(262, 0)), + ils={ + "7": ("IKB", MHz(111, 50)), + }, + outer_ndb={ + "7": ("KT", MHz(870, 0)), + }, + inner_ndb={ + "7": ("T", MHz(490, 0)), + }, ), "Senaki-Kolkhi": AirfieldData( - "UGKS", - 43, 7256, - "31X", "TSK", - "", "", - AtcData("4.300", "132.000", "40.600", "261.000"), - {"9": "108.90 (ITS)"}, - {}, - {"9": "335.00 (BI)"}, - {"9": "688.00 (I)"}, + theater="Caucasus", + icao="UGKS", + elevation=43, + runway_length=7256, + tacan=TacanChannel(31, TacanBand.X), + tacan_callsign="TSK", + atc=AtcData(MHz(4, 300), MHz(132, 0), MHz(40, 600), MHz(261, 0)), + ils={ + "9": ("ITS", MHz(108, 90)), + }, + outer_ndb={ + "9": ("BI", MHz(335, 0)), + }, + inner_ndb={ + "9": ("I", MHz(688, 0)), + }, ), "Kutaisi": AirfieldData( - "UGKO", - 147, 7937, - "44X", "KTS", - "113.60 (KT)", "", - AtcData("4.400", "134.000", "41.000", "263.000"), - {"8": "109.75 (IKS)"}, - {}, - {}, - {}, + theater="Caucasus", + icao="UGKO", + elevation=147, + runway_length=7937, + tacan=TacanChannel(44, TacanBand.X), + tacan_callsign="KTS", + atc=AtcData(MHz(4, 400), MHz(134, 0), MHz(41, 0), MHz(263, 0)), + ils={ + "8": ("IKS", MHz(109, 75)), + }, ), "Sukhumi-Babushara": AirfieldData( - "UGSS", - 43, 11217, - "", "", - "", "", - AtcData("4.150", "129.000", "40.000", "258.000"), - {}, - {}, - {"30": "489.00 (AV)"}, - {"30": "995.00 (A)"}, + theater="Caucasus", + icao="UGSS", + elevation=43, + runway_length=11217, + atc=AtcData(MHz(4, 150), MHz(129, 0), MHz(40, 0), MHz(258, 0)), + outer_ndb={ + "30": ("AV", MHz(489, 0)), + }, + inner_ndb={ + "30": ("A", MHz(995, 0)), + }, ), "Gudauta": AirfieldData( - "UG23", - 68, 7839, - "", "", - "", "", - AtcData("4.200", "120.000", "40.200", "259.000"), - {}, - {}, - {}, - {}, + theater="Caucasus", + icao="UG23", + elevation=68, + runway_length=7839, + atc=AtcData(MHz(4, 200), MHz(120, 0), MHz(40, 200), MHz(259, 0)), ), "Sochi-Adler": AirfieldData( - "URSS", - 98, 9686, - "", "", - "", "", - AtcData("4.050", "127.000", "39.600", "256.000"), - {"6": "111.10 (ISO)"}, - {}, - {}, - {}, + theater="Caucasus", + icao="URSS", + elevation=98, + runway_length=9686, + atc=AtcData(MHz(4, 50), MHz(127, 0), MHz(39, 600), MHz(256, 0)), + ils={ + "6": ("ISO", MHz(111, 10)), + }, ), "Gelendzhik": AirfieldData( - "URKG", - 72, 5452, - "", "", - "114.30 (GN)", "", - AtcData("4.000", "126.000", "39.400", "255.000"), - {}, - {}, - {}, - {}, + theater="Caucasus", + icao="URKG", + elevation=72, + runway_length=5452, + vor=("GN", MHz(114, 30)), + atc=AtcData(MHz(4, 0), MHz(126, 0), MHz(39, 400), MHz(255, 0)), ), "Novorossiysk": AirfieldData( - "URKN", - 131, 5639, - "", "", - "", "", - AtcData("3.850", "123.000", "38.800", "252.000"), - {}, - {}, - {}, - {}, + theater="Caucasus", + icao="URKN", + elevation=131, + runway_length=5639, + atc=AtcData(MHz(3, 850), MHz(123, 0), MHz(38, 800), MHz(252, 0)), ), "Anapa-Vityazevo": AirfieldData( - "URKA", - 141, 8623, - "", "", - "", "", - AtcData("3.750", "121.000", "38.400", "250.000"), - {}, - {}, - {"22": "443.00 (AP)", "4": "443.00 (AN)"}, - {"22": "215.00 (P)", "4": "215.00 (N)"}, + theater="Caucasus", + icao="URKA", + elevation=141, + runway_length=8623, + atc=AtcData(MHz(3, 750), MHz(121, 0), MHz(38, 400), MHz(250, 0)), + outer_ndb={ + "22": ("AP", MHz(443, 0)), "4": "443.00 (AN)" + }, + inner_ndb={ + "22": ("P", MHz(215, 0)), "4": "215.00 (N)" + }, ), "Krymsk": AirfieldData( - "URKW", - 65, 6733, - "", "", - "", "ch 28 (KW)", - AtcData("3.900", "124.000", "39.000", "253.000"), - {}, - {"4": "ch 26 (OX)", "22": "ch 26 (KW)"}, - {"4": "408.00 (OX)", "22": "408.00 (KW)"}, - {"4": "803.00 (O)", "22": "803.00 (K)"}, + theater="Caucasus", + icao="URKW", + elevation=65, + runway_length=6733, + rsbn=("KW", 28), + atc=AtcData(MHz(3, 900), MHz(124, 0), MHz(39, 0), MHz(253, 0)), + prmg={ + "4": ("OX", 26), + "22": ("KW", 26), + }, + outer_ndb={ + "4": ("OX", MHz(408, 0)), + "22": ("KW", MHz(408, 0)), + }, + inner_ndb={ + "4": ("O", MHz(803, 0)), + "22": ("K", MHz(803, 0)), + }, ), "Krasnodar-Center": AirfieldData( - "URKL", - 98, 7659, - "", "", - "", "ch 40 (MB)", - AtcData("3.800", "122.000", "38.600", "251.000"), - {}, - {"9": "ch 38 (MB)"}, - {"9": "625.00 (MB)", "27": "625.00 (OC)"}, - {"9": "303.00 (M)", "27": "303.00 (C)"}, + theater="Caucasus", + icao="URKL", + elevation=98, + runway_length=7659, + rsbn=("MB", 40), + atc=AtcData(MHz(3, 800), MHz(122, 0), MHz(38, 600), MHz(251, 0)), + prmg={ + "9": ("MB", 38), + }, + outer_ndb={ + "9": ("MB", MHz(625, 0)), + "27": ("OC", MHz(625, 0)), + }, + inner_ndb={ + "9": ("M", MHz(303, 0)), + "27": ("C", MHz(303, 0)), + }, ), "Krasnodar-Pashkovsky": AirfieldData( - "URKK", - 111, 9738, - "", "", - "115.80 (KN)", "", - AtcData("4.100", "128.000", "39.800", "257.000"), - {}, - {}, - {"23": "493.00 (LD)", "5": "493.00 (KR)"}, - {"23": "240.00 (L)", "5": "240.00 (K)"}, + theater="Caucasus", + icao="URKK", + elevation=111, + runway_length=9738, + vor=("KN", MHz(115, 80)), + atc=AtcData(MHz(4, 100), MHz(128, 0), MHz(39, 800), MHz(257, 0)), + outer_ndb={ + "23": ("LD", MHz(493, 0)), + "5": ("KR", MHz(493, 0)), + }, + inner_ndb={ + "23": ("L", MHz(240, 0)), + "5": ("K", MHz(240, 0)), + }, ), "Maykop-Khanskaya": AirfieldData( - "URKH", - 590, 10195, - "", "", - "", "ch 34 (DG)", - AtcData("3.950", "125.000", "39.200", "254.000"), - {}, - {"4": "ch 36 (DG)"}, - {"4": "289.00 (DG)", "22": "289.00 (RK)"}, - {"4": "591.00 (D)", "22": "591.00 (R)"}, + theater="Caucasus", + icao="URKH", + elevation=590, + runway_length=10195, + rsbn=("DG", 34), + atc=AtcData(MHz(3, 950), MHz(125, 0), MHz(39, 200), MHz(254, 0)), + prmg={ + "4": ("DG", 36), + }, + outer_ndb={ + "4": ("DG", MHz(289, 0)), + "22": ("RK", MHz(289, 0)), + }, + inner_ndb={ + "4": ("D", MHz(591, 0)), + "22": ("R", MHz(591, 0)), + }, ), "Mineralnye Vody": AirfieldData( - "URMM", - 1049, 12316, - "", "", - "117.10 (MN)", "", - AtcData("4.450", "135.000", "41.200", "264.000"), - {"30": "109.30 (IMW)", "12": "111.70 (IMD)"}, - {}, - {"30": "583.00 (NR)", "12": "583.00 (MD)"}, - {"30": "283.00 (N)", "12": "283.00 (D)"}, + theater="Caucasus", + icao="URMM", + elevation=1049, + runway_length=12316, + vor=("MN", MHz(117, 10)), + atc=AtcData(MHz(4, 450), MHz(135, 0), MHz(41, 200), MHz(264, 0)), + ils={ + "30": ("IMW", MHz(109, 30)), + "12": ("IMD", MHz(111, 70)), + }, + outer_ndb={ + "30": ("NR", MHz(583, 0)), + "12": ("MD", MHz(583, 0)), + }, + inner_ndb={ + "30": ("N", MHz(283, 0)), + "12": ("D", MHz(283, 0)), + }, ), "Nalchik": AirfieldData( - "URMN", - 1410, 7082, - "", "", - "", "", - AtcData("4.500", "136.000", "41.400", "265.000"), - {"24": "110.50 (INL)"}, - {}, - {"24": "718.00 (NL)"}, - {"24": "350.00 (N)"}, + theater="Caucasus", + icao="URMN", + elevation=1410, + runway_length=7082, + atc=AtcData(MHz(4, 500), MHz(136, 0), MHz(41, 400), MHz(265, 0)), + ils={ + "24": ("INL", MHz(110, 50)), + }, + outer_ndb={ + "24": ("NL", MHz(718, 0)), + }, + inner_ndb={ + "24": ("N", MHz(350, 0)), + }, ), "Mozdok": AirfieldData( - "XRMF", - 507, 7734, - "", "", - "", "ch 20 (MZ)", - AtcData("4.550", "137.000", "41.600", "266.000"), - {}, - {"26": "ch 22 (MZ)", "8": "ch 22 (MZ)"}, - {"26": "525.00 (RM)", "8": "525.00 (DO)"}, - {"26": "1.06 (R)", "8": "1.06 (D)"} + theater="Caucasus", + icao="XRMF", + elevation=507, + runway_length=7734, + rsbn=("MZ", 20), + atc=AtcData(MHz(4, 550), MHz(137, 0), MHz(41, 600), MHz(266, 0)), + prmg={ + "26": ("MZ", 22), + "8": ("MZ", 22), + }, + outer_ndb={ + "26": ("RM", MHz(525, 0)), + "8": ("DO", MHz(525, 0)), + }, + inner_ndb={ + "26": ("R", MHz(1, 6)), + "8": ("D", MHz(1, 6)), + } ), "Beslan": AirfieldData( - "URMO", - 1719, 9327, - "", "", - "", "", - AtcData("4.750", "141.000", "42.400", "270.000"), - {"10": "110.50 (ICH)"}, - {}, - {"10": "1.05 (CX)"}, - {"10": "250.00 (C)"} + theater="Caucasus", + icao="URMO", + elevation=1719, + runway_length=9327, + atc=AtcData(MHz(4, 750), MHz(141, 0), MHz(42, 400), MHz(270, 0)), + ils={ + "10": ("ICH", MHz(110, 50)), + }, + outer_ndb={ + "10": ("CX", MHz(1, 5)), + }, + inner_ndb={ + "10": ("C", MHz(250, 0)), + } ), "Tbilisi-Lochini": AirfieldData( - "UGTB", - 1573, 7692, - "25X", "GTB", - "113.70 (INA)", "", - AtcData("4.600", "138.000", "41.800", "267.000"), - {"13": "110.30 (INA)", "30": "108.90 (INA)"}, - {}, - {"13": "342.00 (BP)", "30": "211.00 (NA)"}, - {"13": "923.00 (B)", "30": "435.00 (N)"}, + theater="Caucasus", + icao="UGTB", + elevation=1573, + runway_length=7692, + tacan=TacanChannel(25, TacanBand.X), + tacan_callsign="GTB", + atc=AtcData(MHz(4, 600), MHz(138, 0), MHz(41, 800), MHz(267, 0)), + ils={ + "13": ("INA", MHz(110, 30)), + "30": ("INA", MHz(108, 90)), + }, + outer_ndb={ + "13": ("BP", MHz(342, 0)), + "30": ("NA", MHz(211, 0)), + }, + inner_ndb={ + "13": ("B", MHz(923, 0)), + "30": ("N", MHz(435, 0)), + }, ), "Soganlung": AirfieldData( - "UG24", - 1474, 7871, - "25X", "GTB", - "113.70 (INA)", "", - AtcData("4.650", "139.000", "42.000", "268.000"), - {}, - {}, - {}, - {}, + theater="Caucasus", + icao="UG24", + elevation=1474, + runway_length=7871, + tacan=TacanChannel(25, TacanBand.X), + tacan_callsign="GTB", + atc=AtcData(MHz(4, 650), MHz(139, 0), MHz(42, 0), MHz(268, 0)), ), "Vaziani": AirfieldData( - "UG27", - 1523, 7842, - "22X", "VAS", - "", "", - AtcData("4.700", "140.000", "42.200", "269.000"), - {"13": "108.75 (IVZ)", "31": "108.75 (IVZ)"}, - {}, - {}, - {}, + theater="Caucasus", + icao="UG27", + elevation=1523, + runway_length=7842, + tacan=TacanChannel(22, TacanBand.X), + tacan_callsign="VAS", + atc=AtcData(MHz(4, 700), MHz(140, 0), MHz(42, 200), MHz(269, 0)), + ils={ + "13": ("IVZ", MHz(108, 75)), + "31": ("IVZ", MHz(108, 75)), + }, ), # TODO : PERSIAN GULF MAP # TODO : SYRIA MAP - # "Incirlik": AirfieldData( - # AtcData("3.85", "38.6", "129.4", "360.1"), - # "21X", - # {"050": "109.3", "230": "111.7"} - # ), - # TODO : NEVADA MAP + + "Incirlik": AirfieldData( + theater="Syria", + icao="LTAG", + elevation=156, + runway_length=9662, + tacan=TacanChannel(21, TacanBand.X), + tacan_callsign="DAN", + vor=("DAN", MHz(108, 400)), + atc=AtcData(MHz(3, 850), MHz(38, 600), MHz(129, 400), MHz(360, 100)), + ils={ + "050": ("IDAN", MHz(109, 300)), + "230": ("DANM", MHz(111, 700)), + }, + ), + + # NTTR "Mina Airport 3Q0": AirfieldData( - "", - 4562, 4222, - "", "", - "", "", - AtcData("", "", "", ""), - {}, - {}, - {}, - {}, + theater="NTTR", + elevation=4562, + runway_length=4222, ), "Tonopah Airport": AirfieldData( - "KTPH", - 5394, 6715, - "", "", - "", "", - AtcData("", "", "", ""), - {}, - {}, - {}, - {}, + theater="NTTR", + icao="KTPH", + elevation=5394, + runway_length=6715, ), "Tonopah Test Range Airfield": AirfieldData( - "KTNX", - 5534, 11633, - "77X", "TQQ", - "113.00 (TQQ)", "", - AtcData("3.800", "124.750", "38.500", "257.950"), - {"32": "111.70 (I-UVV)", "14": "108.30 (I-RVP)"}, - {}, - {}, - {}, + theater="NTTR", + icao="KTNX", + elevation=5534, + runway_length=11633, + tacan=TacanChannel(77, TacanBand.X), + tacan_callsign="TQQ", + atc=AtcData(MHz(3, 800), MHz(124, 750), MHz(38, 500), MHz(257, 950)), + ils={ + "32": ("I-UVV", MHz(111, 70)), + "14": ("I-RVP", MHz(108, 30)), + }, ), "Beatty Airport": AirfieldData( - "KBTY", - 3173, 5380, - "", "", - "", "", - AtcData("", "", "", ""), - {}, - {}, - {}, - {}, + theater="NTTR", + icao="KBTY", + elevation=3173, + runway_length=5380, ), "Pahute Mesa Airstrip": AirfieldData( - "", - 5056, 5420, - "", "", - "", "", - AtcData("", "", "", ""), - {}, - {}, - {}, - {}, + theater="NTTR", + elevation=5056, + runway_length=5420, ), "Groom Lake AFB": AirfieldData( - "KXTA", - 4494, 11008, - "18X", "GRL", - "", "", - AtcData("3.850", "118.000", "38.600", "250.050"), - {"32": "109.30 (GLRI)"}, - {}, - {}, - {}, + theater="NTTR", + icao="KXTA", + elevation=4494, + runway_length=11008, + tacan=TacanChannel(18, TacanBand.X), + tacan_callsign="GRL", + atc=AtcData(MHz(3, 850), MHz(118, 0), MHz(38, 600), MHz(250, 50)), + ils={ + "32": ("GLRI", MHz(109, 30)), + }, ), "Lincoln County": AirfieldData( - "", - 4815, 4408, - "", "", - "", "", - AtcData("", "", "", ""), - {}, - {}, - {}, - {}, + theater="NTTR", + elevation=4815, + runway_length=4408, ), "Mesquite": AirfieldData( - "67L", - 1858, 4937, - "", "", - "", "", - AtcData("", "", "", ""), - {}, - {}, - {}, - {}, + theater="NTTR", + icao="67L", + elevation=1858, + runway_length=4937, ), "Creech AFB": AirfieldData( - "KINS", - 3126, 6100, - "87X", "INS", - "", "", - AtcData("3.825", "118.300", "38.550", "360.600"), - {"8": "108.70 (ICRR)"}, - {}, - {}, - {}, + theater="NTTR", + icao="KINS", + elevation=3126, + runway_length=6100, + tacan=TacanChannel(87, TacanBand.X), + tacan_callsign="INS", + atc=AtcData(MHz(3, 825), MHz(118, 300), MHz(38, 550), MHz(360, 600)), + ils={ + "8": ("ICRR", MHz(108, 70)), + }, ), "Echo Bay": AirfieldData( - "OL9", - 3126, 6100, - "87X", "INS", - "", "", - AtcData("3.825", "118.300", "38.550", "360.600"), - {}, - {}, - {}, - {}, + theater="NTTR", + icao="OL9", + elevation=3126, + runway_length=6100, + tacan=TacanChannel(87, TacanBand.X), + tacan_callsign="INS", + atc=AtcData(MHz(3, 825), MHz(118, 300), MHz(38, 550), MHz(360, 600)), ), "Nellis AFB": AirfieldData( - "KLSV", - 1841, 9454, - "12X", "LSV", - "", "", - AtcData("3.900", "132.550", "38.700", "327.000"), - {"21": "109.10 (IDIQ)"}, - {}, - {}, - {}, + theater="NTTR", + icao="KLSV", + elevation=1841, + runway_length=9454, + tacan=TacanChannel(12, TacanBand.X), + tacan_callsign="LSV", + atc=AtcData(MHz(3, 900), MHz(132, 550), MHz(38, 700), MHz(327, 0)), + ils={ + "21": ("IDIQ", MHz(109, 10)), + }, ), "North Las Vegas": AirfieldData( - "KVGT", - 2228, 4734, - "", "", - "", "", - AtcData("3.775", "125.700", "38.450", "360.750"), - {}, - {}, - {}, - {}, + theater="NTTR", + icao="KVGT", + elevation=2228, + runway_length=4734, + atc=AtcData(MHz(3, 775), MHz(125, 700), MHz(38, 450), MHz(360, 750)), ), "McCarran International Airport": AirfieldData( - "KLAS", - 2169, 10377, - "116X", "LAS", - "116.90 (LAS)", "", - AtcData("3.875", "119.900", "38.650", "257.800"), - {"25": "110.30 (I-LAS)"}, - {}, - {}, - {}, + theater="NTTR", + icao="KLAS", + elevation=2169, + runway_length=10377, + tacan=TacanChannel(116, TacanBand.X), + tacan_callsign="LAS", + atc=AtcData(MHz(3, 875), MHz(119, 900), MHz(38, 650), MHz(257, 800)), + ils={ + "25": ("I-LAS", MHz(110, 30)), + }, ), "Henderson Executive Airport": AirfieldData( - "KHND", - 2491, 5999, - "", "", - "", "", - AtcData("3.925", "125.100", "38.750", "250.100"), - {}, - {}, - {}, - {}, + theater="NTTR", + icao="KHND", + elevation=2491, + runway_length=5999, + atc=AtcData(MHz(3, 925), MHz(125, 100), MHz(38, 750), MHz(250, 100)), ), "Boulder City Airport": AirfieldData( - "KBVU", - 2121, 4612, - "", "", - "", "", - AtcData("", "", "", ""), - {}, - {}, - {}, - {}, + theater="NTTR", + icao="KBVU", + elevation=2121, + runway_length=4612, ), "Jean Airport": AirfieldData( - "", - 2824, 4053, - "", "", - "", "", - AtcData("", "", "", ""), - {}, - {}, - {}, - {}, + theater="NTTR", + elevation=2824, + runway_length=4053, ), "Laughlin Airport": AirfieldData( - "KIFP", - 656, 7139, - "", "", - "", "", - AtcData("3.750", "123.900", "38.400", "250.000"), - {}, - {}, - {}, - {}, + theater="NTTR", + icao="KIFP", + elevation=656, + runway_length=7139, + atc=AtcData(MHz(3, 750), MHz(123, 900), MHz(38, 400), MHz(250, 0)), ), # TODO : NORMANDY MAP # Channel Map "Detling": AirfieldData( - "", - 623, 2557, - "", "", - "", "", - AtcData("3.950", "118.400", "38.800", "250.400"), - {}, - {}, - {}, - {}, + theater="Channel", + elevation=623, + runway_length=2557, + atc=AtcData(MHz(3, 950), MHz(118, 400), MHz(38, 800), MHz(250, 400)), ), "High Halden": AirfieldData( - "", - 104, 3296, - "", "", - "", "", - AtcData("3.750", "118.800", "38.400", "250.000"), - {}, - {}, - {}, - {}, + theater="Channel", + elevation=104, + runway_length=3296, + atc=AtcData(MHz(3, 750), MHz(118, 800), MHz(38, 400), MHz(250, 0)), ), "Lympne": AirfieldData( - "", - 351, 2548, - "", "", - "", "", - AtcData("3.925", "118.350", "38.750", "250.350"), - {}, - {}, - {}, - {}, + theater="Channel", + elevation=351, + runway_length=2548, + atc=AtcData(MHz(3, 925), MHz(118, 350), MHz(38, 750), MHz(250, 350)), ), "Hawkinge": AirfieldData( - "", - 524, 3013, - "", "", - "", "", - AtcData("3.900", "118.300", "38.700", "250.300"), - {}, - {}, - {}, - {}, + theater="Channel", + elevation=524, + runway_length=3013, + atc=AtcData(MHz(3, 900), MHz(118, 300), MHz(38, 700), MHz(250, 300)), ), "Manston": AirfieldData( - "", - 160, 8626, - "", "", - "", "", - AtcData("3.875", "118.250", "38.650", "250.250"), - {}, - {}, - {}, - {}, + theater="Channel", + elevation=160, + runway_length=8626, + atc=AtcData(MHz(3, 875), MHz(118, 250), MHz(38, 650), MHz(250, 250)), ), "Dunkirk Mardyck": AirfieldData( - "", - 16, 1737, - "", "", - "", "", - AtcData("3.850", "118.200", "38.600", "250.200"), - {}, - {}, - {}, - {}, + theater="Channel", + elevation=16, + runway_length=1737, + atc=AtcData(MHz(3, 850), MHz(118, 200), MHz(38, 600), MHz(250, 200)), ), "Saint Omer Longuenesse": AirfieldData( - "", - 219, 1929, - "", "", - "", "", - AtcData("3.825", "118.150", "38.550" "250.150"), - {}, - {}, - {}, - {}, + theater="Channel", + elevation=219, + runway_length=1929, + atc=AtcData(MHz(3, 825), MHz(118, 150), MHz(38, 550), MHz(250, 150)), ), "Merville Calonne": AirfieldData( - "", - 52, 7580, - "", "", - "", "", - AtcData("3.800", "118.100", "38.500", "250.100"), - {}, - {}, - {}, - {}, + theater="Channel", + elevation=52, + runway_length=7580, + atc=AtcData(MHz(3, 800), MHz(118, 100), MHz(38, 500), MHz(250, 100)), ), "Abbeville Drucat": AirfieldData( - "", - 183, 4726, - "", "", - "", "", - AtcData("3.775", "118.050", "38.450", "250.050"), - {}, - {}, - {}, - {}, - ) - + theater="Channel", + elevation=183, + runway_length=4726, + atc=AtcData(MHz(3, 775), MHz(118, 50), MHz(38, 450), MHz(250, 50)), + ), } + +# TODO: Add list of all beacons on the map so we can reserve those frequencies. +# This list could be generated from the beasons.lua file in the terrain mod +# directory. As-is, we're allocating channels that might include VOR beacons, +# and those will broadcast their callsign consistently (probably with a lot of +# static, depending on how far away the beacon is. The F-16's VHF radio starts +# at 116 MHz, which happens to be the Damascus VOR beacon, so this is more or +# less guaranteed to happen. diff --git a/gen/airsupportgen.py b/gen/airsupportgen.py index 2e0ef249..e65b5a7a 100644 --- a/gen/airsupportgen.py +++ b/gen/airsupportgen.py @@ -1,12 +1,15 @@ -from game import db +from typing import List +from dataclasses import dataclass, field + from .conflictgen import * from .naming import * +from .radios import RadioFrequency, RadioRegistry +from .tacan import TacanBand, TacanChannel, TacanRegistry from dcs.mission import * from dcs.unitgroup import * from dcs.unittype import * from dcs.task import * -from dcs.terrain.terrain import NoParkingSlotError TANKER_DISTANCE = 15000 TANKER_ALT = 4572 @@ -15,15 +18,61 @@ TANKER_HEADING_OFFSET = 45 AWACS_DISTANCE = 150000 AWACS_ALT = 13000 +AWACS_CALLSIGNS = [ + "Overlord", + "Magic", + "Wizard", + "Focus", + "Darkstar", +] + + +@dataclass +class TankerCallsign: + full: str + short: str + + +TANKER_CALLSIGNS = [ + TankerCallsign("Texaco", "TEX"), + TankerCallsign("Arco", "ARC"), + TankerCallsign("Shell", "SHL"), +] + + +@dataclass +class AwacsInfo: + """AWACS information for the kneeboard.""" + callsign: str + freq: RadioFrequency + + +@dataclass +class TankerInfo: + """Tanker information for the kneeboard.""" + callsign: str + variant: str + freq: RadioFrequency + tacan: TacanChannel + + +@dataclass +class AirSupport: + awacs: List[AwacsInfo] = field(default_factory=list) + tankers: List[TankerInfo] = field(default_factory=list) + class AirSupportConflictGenerator: - generated_tankers = None # type: typing.List[str] - def __init__(self, mission: Mission, conflict: Conflict, game): + def __init__(self, mission: Mission, conflict: Conflict, game, + radio_registry: RadioRegistry, + tacan_registry: TacanRegistry) -> None: self.mission = mission self.conflict = conflict self.game = game - self.generated_tankers = [] + self.air_support = AirSupport() + self.radio_registry = radio_registry + self.tacan_registry = tacan_registry @classmethod def support_tasks(cls) -> typing.Collection[typing.Type[MainTask]]: @@ -32,9 +81,11 @@ class AirSupportConflictGenerator: def generate(self, is_awacs_enabled): player_cp = self.conflict.from_cp if self.conflict.from_cp.captured else self.conflict.to_cp - CALLSIGNS = ["TKR", "TEX", "FUL", "FUE", ""] for i, tanker_unit_type in enumerate(db.find_unittype(Refueling, self.conflict.attackers_side)): - self.generated_tankers.append(db.unit_type_name(tanker_unit_type)) + callsign = TANKER_CALLSIGNS[i] + variant = db.unit_type_name(tanker_unit_type) + freq = self.radio_registry.alloc_uhf() + tacan = self.tacan_registry.alloc_for_band(TacanBand.Y) tanker_heading = self.conflict.to_cp.position.heading_between_point(self.conflict.from_cp.position) + TANKER_HEADING_OFFSET * i tanker_position = player_cp.position.point_from_heading(tanker_heading, TANKER_DISTANCE) tanker_group = self.mission.refuel_flight( @@ -45,21 +96,26 @@ class AirSupportConflictGenerator: position=tanker_position, altitude=TANKER_ALT, race_distance=58000, - frequency=130 + i, + frequency=freq.mhz, start_type=StartType.Warm, speed=574, - tacanchannel="{}X".format(60 + i), + tacanchannel=str(tacan), ) if tanker_unit_type != IL_78M: tanker_group.points[0].tasks.pop() # Override PyDCS tacan channel - tanker_group.points[0].tasks.append(ActivateBeaconCommand(60 + i, "X", CALLSIGNS[i], True, tanker_group.units[0].id, True)) + tanker_group.points[0].tasks.append(ActivateBeaconCommand( + tacan.number, tacan.band.value, callsign.short, True, tanker_group.units[0].id, True)) tanker_group.points[0].tasks.append(SetInvisibleCommand(True)) tanker_group.points[0].tasks.append(SetImmortalCommand(True)) + self.air_support.tankers.append(TankerInfo(callsign.full, variant, freq, tacan)) + if is_awacs_enabled: try: + callsign = AWACS_CALLSIGNS[0] + freq = self.radio_registry.alloc_uhf() awacs_unit = db.find_unittype(AWACS, self.conflict.attackers_side)[0] awacs_flight = self.mission.awacs_flight( country=self.mission.country(self.game.player_country), @@ -68,11 +124,12 @@ class AirSupportConflictGenerator: altitude=AWACS_ALT, airport=None, position=self.conflict.position.random_point_within(AWACS_DISTANCE, AWACS_DISTANCE), - frequency=233, + frequency=freq.mhz, start_type=StartType.Warm, ) awacs_flight.points[0].tasks.append(SetInvisibleCommand(True)) awacs_flight.points[0].tasks.append(SetImmortalCommand(True)) + self.air_support.awacs.append(AwacsInfo(callsign, freq)) except: print("No AWACS for faction") diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 57c7097f..912946e4 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -1,10 +1,10 @@ from enum import Enum -from typing import List +from typing import List, Optional -from dcs.mission import StartType -from dcs.unittype import UnitType +from pydcs.dcs.unittype import UnitType from game import db +from gen.radios import RadioFrequency class FlightType(Enum): @@ -96,6 +96,13 @@ class Flight: # How long before this flight should take off scheduled_in = 0 + # Populated during mission generation time by AircraftConflictGenerator. + # TODO: Decouple radio planning from the Flight. + # Make AircraftConflictGenerator generate a FlightData object that is + # returned to the Operation rather than relying on the Flight object, which + # represents a game UI flight rather than a fully planned flight. + intra_flight_channel: Optional[RadioFrequency] + def __init__(self, unit_type: UnitType, count: int, from_cp, flight_type: FlightType): self.unit_type = unit_type self.count = count @@ -105,6 +112,7 @@ class Flight: self.targets = [] self.loadout = {} self.start_type = "Runway" + self.intra_flight_channel = None def __repr__(self): return self.flight_type.name + " | " + str(self.count) + "x" + db.unit_type_name(self.unit_type) \ @@ -113,10 +121,10 @@ class Flight: # Test if __name__ == '__main__': - from dcs.planes import A_10C + from pydcs.dcs.planes import A_10C from theater import ControlPoint, Point, List from_cp = ControlPoint(0, "AA", Point(0, 0), None, [], 0, 0) - f = Flight(A_10C, 4, from_cp, FlightType.CAS) + f = Flight(A_10C(), 4, from_cp, FlightType.CAS) f.scheduled_in = 50 print(f) diff --git a/gen/kneeboard.py b/gen/kneeboard.py index 1d679e4a..1241078e 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -33,9 +33,11 @@ from tabulate import tabulate from pydcs.dcs.mission import Mission from pydcs.dcs.terrain.terrain import Airport from pydcs.dcs.unittype import FlyingType -from .airfields import AIRFIELD_DATA -from .flights.flight import Flight from . import units +from .airfields import AIRFIELD_DATA +from .airsupportgen import AwacsInfo, TankerInfo +from .flights.flight import Flight +from .radios import RadioFrequency class KneeboardPageWriter: @@ -116,23 +118,7 @@ class AirfieldInfo: class CommInfo: """Communications information for the kneeboard.""" name: str - freq: str - - -@dataclass -class AwacsInfo: - """AWACS information for the kneeboard.""" - callsign: str - freq: str - - -@dataclass -class TankerInfo: - """Tanker information for the kneeboard.""" - callsign: str - variant: str - freq: str - tacan: str + freq: RadioFrequency @dataclass @@ -153,6 +139,10 @@ class BriefingPage(KneeboardPage): self.awacs = awacs self.tankers = tankers self.jtacs = jtacs + if self.flight.intra_flight_channel is not None: + self.comms.append( + CommInfo("Flight", self.flight.intra_flight_channel) + ) self.departure = flight.from_cp.airport self.arrival = flight.from_cp.airport self.divert: Optional[Airport] = None @@ -245,7 +235,7 @@ class KneeboardGenerator: self.tankers: List[TankerInfo] = [] self.jtacs: List[JtacInfo] = [] - def add_comm(self, name: str, freq: str) -> None: + def add_comm(self, name: str, freq: RadioFrequency) -> None: """Adds communications info to the kneeboard. Args: @@ -254,26 +244,21 @@ class KneeboardGenerator: """ self.comms.append(CommInfo(name, freq)) - def add_awacs(self, callsign: str, freq: str) -> None: + def add_awacs(self, awacs: AwacsInfo) -> None: """Adds an AWACS/GCI to the kneeboard. Args: - callsign: Callsign of the AWACS/GCI. - freq: Radio frequency used by the AWACS/GCI. + awacs: AWACS information. """ - self.awacs.append(AwacsInfo(callsign, freq)) + self.awacs.append(awacs) - def add_tanker(self, callsign: str, variant: str, freq: str, - tacan: str) -> None: + def add_tanker(self, tanker: TankerInfo) -> None: """Adds a tanker to the kneeboard. Args: - callsign: Callsign of the tanker. - variant: Aircraft type. - freq: Radio frequency used by the tanker. - tacan: TACAN channel of the tanker. + tanker: Tanker information. """ - self.tankers.append(TankerInfo(callsign, variant, freq, tacan)) + self.tankers.append(tanker) def add_jtac(self, callsign: str, region: str, code: str) -> None: """Adds a JTAC to the kneeboard. diff --git a/gen/radios.py b/gen/radios.py new file mode 100644 index 00000000..2cfeac8a --- /dev/null +++ b/gen/radios.py @@ -0,0 +1,200 @@ +"""Radio frequency types and allocators.""" +import itertools +from dataclasses import dataclass +from typing import Dict, Iterator, List, Set + + +@dataclass(frozen=True) +class RadioFrequency: + """A radio frequency. + + Not currently concerned with tracking modulation, just the frequency. + """ + + #: The frequency in kilohertz. + hertz: int + + def __str__(self): + if self.hertz >= 1000000: + return self.format("MHz", 1000000) + return self.format("kHz", 1000) + + def format(self, units: str, divisor: int) -> str: + converted = self.hertz / divisor + if converted.is_integer(): + return f"{int(converted)} {units}" + return f"{converted:0.3f} {units}" + + @property + def mhz(self) -> float: + """Returns the frequency in megahertz. + + Returns: + The frequency in megahertz. + """ + return self.hertz / 1000000 + + +def MHz(num: int, khz: int = 0) -> RadioFrequency: + return RadioFrequency(num * 1000000 + khz * 1000) + + +def kHz(num: int) -> RadioFrequency: + return RadioFrequency(num * 1000) + + +@dataclass(frozen=True) +class Radio: + """A radio. + + Defines the minimum (inclusive) and maximum (exclusive) range of the radio. + """ + + #: The name of the radio. + name: str + + #: The minimum (inclusive) frequency tunable by this radio. + minimum: RadioFrequency + + #: The maximum (exclusive) frequency tunable by this radio. + maximum: RadioFrequency + + #: The spacing between adjacent frequencies. + step: RadioFrequency + + def __str__(self) -> str: + return self.name + + def range(self) -> Iterator[RadioFrequency]: + """Returns an iterator over the usable frequencies of this radio.""" + return (RadioFrequency(x) for x in range( + self.minimum.hertz, self.maximum.hertz, self.step.hertz + )) + + +class OutOfChannelsError(RuntimeError): + """Raised when all channels usable by this radio have been allocated.""" + + def __init__(self, radio: Radio) -> None: + super().__init__(f"No available channels for {radio}") + + +class ChannelInUseError(RuntimeError): + """Raised when attempting to reserve an in-use frequency.""" + + def __init__(self, frequency: RadioFrequency) -> None: + super().__init__(f"{frequency} is already in use") + + +# TODO: Figure out appropriate steps for each radio. These are just guesses. +#: List of all known radios used by aircraft in the game. +RADIOS: List[Radio] = [ + Radio("AN/ARC-164", MHz(225), MHz(400), step=MHz(1)), + Radio("AN/ARC-186(V) AM", MHz(116), MHz(152), step=MHz(1)), + Radio("AN/ARC-186(V) FM", MHz(30), MHz(76), step=MHz(1)), + # The AN/ARC-210 can also use [30, 88) and [108, 118), but the current + # implementation can't implement the gap and the radio can't transmit on the + # latter. There's still plenty of channels between 118 MHz and 400 MHz, so + # not worth worrying about. + Radio("AN/ARC-210", MHz(118), MHz(400), step=MHz(1)), + Radio("AN/ARC-222", MHz(116), MHz(174), step=MHz(1)), + Radio("SCR-522", MHz(100), MHz(156), step=MHz(1)), + Radio("A.R.I. 1063", MHz(100), MHz(156), step=MHz(1)), + Radio("BC-1206", kHz(200), kHz(400), step=kHz(10)), +] + + +def get_radio(name: str) -> Radio: + """Returns the radio with the given name. + + Args: + name: Name of the radio to return. + + Returns: + The radio matching name. + + Raises: + KeyError: No matching radio was found. + """ + for radio in RADIOS: + if radio.name == name: + return radio + raise KeyError + + +class RadioRegistry: + """Manages allocation of radio channels. + + There's some room for improvement here. We could prefer to allocate + frequencies that are available to the fewest number of radios first, so + radios with wide bands like the AN/ARC-210 don't exhaust all the channels + available to narrower radios like the AN/ARC-186(V). In practice there are + probably plenty of channels, so we can deal with that later if we need to. + + We could also allocate using a larger increment, returning to smaller + increments each time the range is exhausted. This would help with the + previous problem, as the AN/ARC-186(V) would still have plenty of 25 kHz + increment channels left after the AN/ARC-210 moved on to the higher + frequencies. This would also look a little nicer than having every flight + allocated in the 30 MHz range. + """ + + # Not a real radio, but useful for allocating a channel usable for + # inter-flight communications. + BLUFOR_UHF = Radio("BLUFOR UHF", MHz(225), MHz(400), step=MHz(1)) + + def __init__(self) -> None: + self.allocated_channels: Set[RadioFrequency] = set() + self.radio_allocators: Dict[Radio, Iterator[RadioFrequency]] = {} + + radios = itertools.chain(RADIOS, [self.BLUFOR_UHF]) + for radio in radios: + self.radio_allocators[radio] = radio.range() + + def alloc_for_radio(self, radio: Radio) -> RadioFrequency: + """Allocates a radio channel tunable by the given radio. + + Args: + radio: The radio to allocate a channel for. + + Returns: + A radio channel compatible with the given radio. + + Raises: + OutOfChannelsError: All channels compatible with the given radio are + already allocated. + """ + allocator = self.radio_allocators[radio] + try: + while (channel := next(allocator)) in self.allocated_channels: + pass + return channel + except StopIteration: + raise OutOfChannelsError(radio) + + def alloc_uhf(self) -> RadioFrequency: + """Allocates a UHF radio channel suitable for inter-flight comms. + + Returns: + A UHF radio channel suitable for inter-flight comms. + + Raises: + OutOfChannelsError: All channels compatible with the given radio are + already allocated. + """ + return self.alloc_for_radio(self.BLUFOR_UHF) + + def reserve(self, frequency: RadioFrequency) -> None: + """Reserves the given channel. + + Reserving a channel ensures that it will not be allocated in the future. + + Args: + frequency: The channel to reserve. + + Raises: + ChannelInUseError: The given frequency is already in use. + """ + if frequency in self.allocated_channels: + raise ChannelInUseError(frequency) + self.allocated_channels.add(frequency) diff --git a/gen/tacan.py b/gen/tacan.py new file mode 100644 index 00000000..5e43202a --- /dev/null +++ b/gen/tacan.py @@ -0,0 +1,83 @@ +"""TACAN channel handling.""" +from dataclasses import dataclass +from enum import Enum +from typing import Dict, Iterator, Set + + +class TacanBand(Enum): + X = "X" + Y = "Y" + + def range(self) -> Iterator["TacanChannel"]: + """Returns an iterator over the channels in this band.""" + return (TacanChannel(x, self) for x in range(1, 100)) + + +@dataclass(frozen=True) +class TacanChannel: + number: int + band: TacanBand + + def __str__(self) -> str: + return f"{self.number}{self.band.value}" + + +class OutOfTacanChannelsError(RuntimeError): + """Raised when all channels in this band have been allocated.""" + + def __init__(self, band: TacanBand) -> None: + super().__init__(f"No available channels in TACAN {band.value} band") + + +class TacanChannelInUseError(RuntimeError): + """Raised when attempting to reserve an in-use channel.""" + + def __init__(self, channel: TacanChannel) -> None: + super().__init__(f"{channel} is already in use") + + +class TacanRegistry: + """Manages allocation of TACAN channels.""" + + def __init__(self) -> None: + self.allocated_channels: Set[TacanChannel] = set() + self.band_allocators: Dict[TacanBand, Iterator[TacanChannel]] = {} + + for band in TacanBand: + self.band_allocators[band] = band.range() + + def alloc_for_band(self, band: TacanBand) -> TacanChannel: + """Allocates a TACAN channel in the given band. + + Args: + band: The TACAN band to allocate a channel for. + + Returns: + A TACAN channel in the given band. + + Raises: + OutOfChannelsError: All channels compatible with the given radio are + already allocated. + """ + allocator = self.band_allocators[band] + try: + while (channel := next(allocator)) in self.allocated_channels: + pass + return channel + except StopIteration: + raise OutOfTacanChannelsError(band) + + def reserve(self, channel: TacanChannel) -> None: + """Reserves the given channel. + + Reserving a channel ensures that it will not be allocated in the future. + + Args: + channel: The channel to reserve. + + Raises: + ChannelInUseError: The given frequency is already in use. + """ + if channel in self.allocated_channels: + raise TacanChannelInUseError(channel) + self.allocated_channels.add(channel)