"""ACARS message translator — label lookup, classification, and field parsers.""" from __future__ import annotations import re # Common ACARS label codes → human-readable descriptions # Sources: ARINC 618, ARINC 620, airline implementations ACARS_LABELS: dict[str, str] = { # Position & navigation "H1": "Position report (HF data link)", "H2": "Weather report", "5Z": "OOOI (gate times)", "15": "Departure report", "16": "Arrival report", "20": "Position report", "22": "Fuel report", "2Z": "Off-gate report", "30": "Progress report", "44": "Weather request", "80": "Free text (3-char header)", "83": "Free text", "8E": "ATIS request", # Engine & performance "DF": "Engine data / DFDR", "D3": "Engine exceedance", "D6": "Engine trend data", # ATS / air traffic services "B1": "ATC request", "B2": "ATC clearance", "B3": "ATC comm test", "B6": "ATC departure clearance", "B9": "ATC message", "BA": "ATC advisory", "BB": "ATC response", # CPDLC (Controller-Pilot Data Link Communications) "AA": "CPDLC message", "AB": "CPDLC response", "A0": "CPDLC uplink", "A1": "CPDLC downlink", "A2": "CPDLC connection request", "A3": "CPDLC logon/logoff", "A6": "CPDLC message", "A7": "CPDLC response", "AT": "CPDLC transfer", # Handshake & link management "_d": "Demand mode (link test)", "Q0": "Link test", "QA": "Link test reply", "QB": "Acknowledgement", "QC": "Link request", "QD": "Link accept", "QE": "Link reject", "QF": "Squitter / heartbeat", "QG": "Abort", "QH": "Version request", "QK": "Mode change", "QM": "Link verification", "QN": "Media advisory", "QP": "Polling", "QQ": "Status", "QR": "General response", "QS": "System table request", "QT": "System table", "QX": "Frequency change", # Squawk & surveillance "SQ": "Squawk assignment", "SA": "Surveillance data", "S1": "ADS-C report", # Airline operations "C1": "Crew scheduling", "C2": "Crew response", "C3": "Crew message", "C4": "Crew query", "10": "Delay message", "12": "Clearance request", "17": "Cargo/load data", "4T": "TWIP (terminal weather)", "4X": "Connectivity test", "50": "Weather observation", "51": "METAR/TAF request", "52": "METAR/TAF response", "54": "SIGMET / AIRMET", "70": "Maintenance report", "7A": "Fault message", "7B": "Fault clear", "F3": "Flight plan", "F5": "Flight plan amendment", "F6": "Route request", "F7": "Route clearance", "RA": "ATIS report", "RB": "ATIS request", } # Message type classification for UI colour coding MESSAGE_TYPES = { "position", "engine_data", "weather", "ats", "handshake", "oooi", "squawk", "link_test", "cpdlc", "other", } def translate_label(label: str | None) -> str: """Return human-readable description for an ACARS label code.""" if not label: return "Unknown label" label = label.strip() if label in ACARS_LABELS: return ACARS_LABELS[label] # Check for Q-prefix group if len(label) == 2 and label.startswith("Q"): return f"Link management ({label})" return f"Label {label}" def classify_message_type(label: str | None, text: str | None = None) -> str: """Classify an ACARS message into a canonical type for UI display.""" if not label: return "other" label = label.strip() # Position reports if label in ("H1", "20", "15", "16", "30", "S1"): return "position" if text and "#M1BPOS" in text: return "position" # Engine / DFDR data if label in ("DF", "D3", "D6"): return "engine_data" # Weather if label in ("H2", "44", "50", "51", "52", "54", "4T"): return "weather" # ATS / ATC if label.startswith("B") and len(label) == 2: return "ats" # CPDLC if label in ("AA", "AB", "A0", "A1", "A2", "A3", "A6", "A7", "AT"): return "cpdlc" # OOOI (Out/Off/On/In gate times) if label in ("5Z", "2Z"): return "oooi" # Squawk if label in ("SQ", "SA"): return "squawk" # Link test / handshake if label in ( "Q0", "QA", "QB", "QC", "QD", "QE", "QF", "QG", "QH", "QK", "QM", "QN", "QP", "QQ", "QR", "QS", "QT", "QX", "4X", ): return "link_test" # Handshake (_d is demand mode) if label == "_d": return "handshake" return "other" def parse_position_report(text: str | None) -> dict | None: """Parse H1 / #M1BPOS position report fields. Example format: #M1BPOSN42411W086034,CSG,070852,340,N42441W087074,DTW,0757,224A8C Lat/Lon: N42411W086034 (N42.411 W086.034) Waypoint: CSG Time: 070852Z FL: 340 Next waypoint coords, destination, ETA """ if not text: return None result: dict = {} # Look for BPOS block bpos_match = re.search( r"#M\d[A-Z]*POS" r"([NS])(\d{2,5})([EW])(\d{3,6})" r",([^,]*),(\d{4,6})" r",(\d{2,3})" r"(?:,([NS]\d{2,5}[EW]\d{3,6}))?" r"(?:,([A-Z]{3,4}))?", text, ) if bpos_match: lat_dir, lat_val, lon_dir, lon_val = bpos_match.group(1, 2, 3, 4) # Convert to decimal degrees if len(lat_val) >= 4: lat_deg = int(lat_val[:2]) lat_min = int(lat_val[2:]) / (10 ** (len(lat_val) - 2)) * 60 lat = lat_deg + lat_min / 60 else: lat = float(lat_val) if lat_dir == "S": lat = -lat if len(lon_val) >= 5: lon_deg = int(lon_val[:3]) lon_min = int(lon_val[3:]) / (10 ** (len(lon_val) - 3)) * 60 lon = lon_deg + lon_min / 60 else: lon = float(lon_val) if lon_dir == "W": lon = -lon result["lat"] = round(lat, 4) result["lon"] = round(lon, 4) result["waypoint"] = bpos_match.group(5).strip() if bpos_match.group(5) else None result["time"] = bpos_match.group(6) result["flight_level"] = f"FL{bpos_match.group(7)}" if bpos_match.group(9): result["destination"] = bpos_match.group(9) # Look for temperature (e.g., /TS-045 or M045) temp_match = re.search(r"/TS([MP]?)(\d{2,3})", text) if temp_match: sign = "-" if temp_match.group(1) == "M" else "" result["temperature"] = f"{sign}{temp_match.group(2)} C" return result if result else None def parse_engine_data(text: str | None) -> dict | None: """Parse DF (engine/DFDR) messages. Common format: #DFB followed by KEY/VALUE pairs. Keys: SM (source mode), AC0/AC1 (engine 1/2 N2), FL (flight level), FU (fuel used), ES (EGT spread), BA (bleed air), CO (config), AO (auto) """ if not text: return None result: dict = {} engine_keys = { "SM": "Source mode", "AC0": "Eng 1 N2 (%)", "AC1": "Eng 2 N2 (%)", "FL": "Flight level", "FU": "Fuel used (lbs)", "ES": "EGT spread", "BA": "Bleed air", "CO": "Config", "AO": "Auto", "EGT": "Exhaust gas temp", "OIT": "Oil temp", "OIP": "Oil pressure", "N1": "N1 (%)", "N2": "N2 (%)", "FF": "Fuel flow", "VIB": "Vibration", } # Match KEY/VALUE or KEY VALUE patterns for key, desc in engine_keys.items(): pattern = rf"\b{re.escape(key)}[/: ]?\s*([+-]?\d+\.?\d*)" m = re.search(pattern, text) if m: result[key] = {"value": m.group(1), "description": desc} return result if result else None def parse_weather_data(text: str | None) -> dict | None: """Parse weather report fields (/WX blocks, METAR-like data).""" if not text: return None result: dict = {} # Wind: direction/speed (e.g., 270/15 or WND270015) wind_match = re.search(r"(?:WND|WIND)\s*(\d{3})[/ ]?(\d{2,3})", text) if wind_match: result["wind_dir"] = f"{wind_match.group(1)} deg" result["wind_speed"] = f"{wind_match.group(2)} kts" # Airport codes (3-4 letter ICAO) airports = re.findall(r"\b([A-Z]{3,4})\b", text) if airports: result["airports"] = list(dict.fromkeys(airports))[:4] # Temperature (e.g., T24/D18, TMP24, TEMP -5) temp_match = re.search(r"(?:TMP|TEMP|T)\s*([MP+-]?\d{1,3})", text) if temp_match: val = temp_match.group(1).replace("M", "-").replace("P", "") result["temperature"] = f"{val} C" # Visibility vis_match = re.search(r"VIS\s*(\d+(?:\.\d+)?)", text) if vis_match: result["visibility"] = f"{vis_match.group(1)} SM" return result if result else None def parse_oooi(text: str | None) -> dict | None: """Parse 5Z OOOI (Out/Off/On/In) gate time messages. Typical format: origin destination OUT OFF ON IN e.g., KJFK KLAX 1423 1435 1812 1824 """ if not text: return None result: dict = {} # Try to find airport pair + 4 time blocks oooi_match = re.search(r"([A-Z]{3,4})\s+([A-Z]{3,4})\s+(\d{4})\s+(\d{4})\s+(\d{4})\s+(\d{4})", text) if oooi_match: result["origin"] = oooi_match.group(1) result["destination"] = oooi_match.group(2) result["out"] = oooi_match.group(3) result["off"] = oooi_match.group(4) result["on"] = oooi_match.group(5) result["in"] = oooi_match.group(6) return result # Try partial (just origin/destination and some times) partial = re.search(r"([A-Z]{3,4})\s+([A-Z]{3,4})", text) if partial: result["origin"] = partial.group(1) result["destination"] = partial.group(2) times = re.findall(r"\b(\d{4})\b", text) labels = ["out", "off", "on", "in"] for i, t in enumerate(times[:4]): result[labels[i]] = t return result if result else None def translate_message(msg: dict) -> dict: """Translate an ACARS message dict, returning enrichment fields. Args: msg: Raw ACARS message dict with 'label', 'text'/'msg' fields. Returns: Dict with 'label_description', 'message_type', 'parsed'. """ label = msg.get("label") text = msg.get("text") or msg.get("msg") or "" label_description = translate_label(label) message_type = classify_message_type(label, text) parsed: dict | None = None if message_type == "position" or (label == "H1" and "POS" in text.upper()): parsed = parse_position_report(text) elif message_type == "engine_data": parsed = parse_engine_data(text) elif message_type == "weather": parsed = parse_weather_data(text) elif message_type == "oooi": parsed = parse_oooi(text) return { "label_description": label_description, "message_type": message_type, "parsed": parsed, }