From 4b9765791f2cca46e4b666ddd54499cce036b8a6 Mon Sep 17 00:00:00 2001 From: adust09 Date: Mon, 16 Mar 2026 01:05:44 +0900 Subject: [PATCH] feat: enrich military aircraft with ICAO country/force and East Asia model classification Infer country and military force (PLA, JSDF, ROK, ROC) from ICAO hex address blocks when the flag field is Unknown. Extract and extend aircraft model classification to cover East Asian fighters, cargo, recon, and tanker types with hyphen-normalized matching. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/services/fetchers/military.py | 78 +++++++++++++++++++++------ backend/tests/test_icao_military.py | 57 ++++++++++++++++++++ frontend/src/components/NewsFeed.tsx | 7 ++- frontend/src/types/dashboard.ts | 2 + 4 files changed, 127 insertions(+), 17 deletions(-) create mode 100644 backend/tests/test_icao_military.py diff --git a/backend/services/fetchers/military.py b/backend/services/fetchers/military.py index 6780e2c..374bf8d 100644 --- a/backend/services/fetchers/military.py +++ b/backend/services/fetchers/military.py @@ -33,6 +33,56 @@ _UAV_WIKI = { } +_ICAO_COUNTRY_RANGES = [ + (0x780000, 0x7BFFFF, "China", "PLA"), + (0x840000, 0x87FFFF, "Japan", "JSDF"), + (0x700000, 0x71FFFF, "South Korea", "ROK"), + (0xE80000, 0xE80FFF, "Taiwan", "ROC"), +] + + +def _enrich_country(icao_hex: str, flag: str) -> tuple[str, str]: + """If flag is Unknown/empty, infer country and force from ICAO range.""" + if flag and flag not in ("Unknown", "Military Asset", ""): + return flag, "" + try: + addr = int(icao_hex, 16) + except (ValueError, TypeError): + return flag or "Military Asset", "" + for start, end, country, force in _ICAO_COUNTRY_RANGES: + if start <= addr <= end: + return country, force + return flag or "Military Asset", "" + + +def _classify_military_type(raw_model: str) -> str: + model = raw_model.upper().replace("-", "").replace(" ", "") + if "H" in model and any(c.isdigit() for c in model): + return "heli" + if any(k in model for k in [ + "K35", "K46", "A33", "YY20", + ]): + return "tanker" + if any(k in model for k in [ + "F16", "F35", "F22", "F15", "F18", "T38", "T6", "A10", + "J10", "J11", "J15", "J16", "J20", "JF17", + "SU27", "SU30", "SU35", + "F15J", "F2", "IDF", "FA50", "KF21", + ]): + return "fighter" + if any(k in model for k in [ + "C17", "C5", "C130", "C30", "A400", "V22", + "Y20", "Y9", "Y8", "C2", + ]): + return "cargo" + if any(k in model for k in [ + "P8", "E3", "E8", "U2", + "KJ500", "KJ200", "GX11", "P1", "E767", "E2K", "E2C", + ]): + return "recon" + return "default" + + def _classify_uav(model: str, callsign: str): """Check if an aircraft is a UAV based on type code, callsign prefix, or model keywords. Returns (is_uav, uav_type, wiki_url) or (False, None, None).""" @@ -106,10 +156,13 @@ def fetch_military_flights(): gs_knots = f.get("gs") speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None + icao_hex = f.get("hex", "") + is_uav, uav_type, wiki_url = _classify_uav(model, callsign) if is_uav: + uav_country, uav_force = _enrich_country(icao_hex, f.get("flag", "")) detected_uavs.append({ - "id": f"uav-{f.get('hex', '')}", + "id": f"uav-{icao_hex}", "callsign": callsign, "aircraft_model": f.get("t", "Unknown"), "lat": float(lat), @@ -117,31 +170,24 @@ def fetch_military_flights(): "alt": alt_value, "heading": heading, "speed_knots": speed_knots, - "country": f.get("flag", "Unknown"), + "country": uav_country, + "force": uav_force, "uav_type": uav_type, "wiki": wiki_url or "", "type": "uav", "registration": f.get("r", "N/A"), - "icao24": f.get("hex", ""), + "icao24": icao_hex, "squawk": f.get("squawk", ""), }) continue - mil_cat = "default" - if "H" in model and any(c.isdigit() for c in model): - mil_cat = "heli" - elif any(k in model for k in ["K35", "K46", "A33"]): - mil_cat = "tanker" - elif any(k in model for k in ["F16", "F35", "F22", "F15", "F18", "T38", "T6", "A10"]): - mil_cat = "fighter" - elif any(k in model for k in ["C17", "C5", "C130", "C30", "A400", "V22"]): - mil_cat = "cargo" - elif any(k in model for k in ["P8", "E3", "E8", "U2"]): - mil_cat = "recon" + mil_country, mil_force = _enrich_country(icao_hex, f.get("flag", "")) + mil_cat = _classify_military_type(f.get("t", "UNKNOWN")) military_flights.append({ "callsign": callsign, - "country": f.get("flag", "Military Asset"), + "country": mil_country, + "force": mil_force, "lng": float(lng), "lat": float(lat), "alt": alt_value, @@ -154,7 +200,7 @@ def fetch_military_flights(): "dest_name": "UNKNOWN", "registration": f.get("r", "N/A"), "model": f.get("t", "Unknown"), - "icao24": f.get("hex", ""), + "icao24": icao_hex, "speed_knots": speed_knots, "squawk": f.get("squawk", "") }) diff --git a/backend/tests/test_icao_military.py b/backend/tests/test_icao_military.py new file mode 100644 index 0000000..829b7fe --- /dev/null +++ b/backend/tests/test_icao_military.py @@ -0,0 +1,57 @@ +"""Tests for ICAO country enrichment and military type classification.""" +import pytest +from services.fetchers.military import _enrich_country, _classify_military_type + + +class TestEnrichCountry: + def test_china_range(self): + assert _enrich_country("780000", "Unknown") == ("China", "PLA") + + def test_japan_range(self): + assert _enrich_country("840000", "Unknown") == ("Japan", "JSDF") + + def test_taiwan_range(self): + assert _enrich_country("E80000", "Unknown") == ("Taiwan", "ROC") + + def test_south_korea_range(self): + assert _enrich_country("700000", "Unknown") == ("South Korea", "ROK") + + def test_out_of_range_unknown_flag(self): + assert _enrich_country("A00000", "Unknown") == ("Unknown", "") + + def test_valid_flag_preserved(self): + country, force = _enrich_country("780000", "United States") + assert country == "United States" + assert force == "" + + def test_empty_flag_uses_icao(self): + assert _enrich_country("840000", "") == ("Japan", "JSDF") + + def test_military_asset_flag_uses_icao(self): + assert _enrich_country("E80000", "Military Asset") == ("Taiwan", "ROC") + + def test_invalid_hex_with_unknown(self): + assert _enrich_country("ZZZZ", "Unknown") == ("Unknown", "") + + def test_invalid_hex_with_empty(self): + assert _enrich_country("ZZZZ", "") == ("Military Asset", "") + + +class TestClassifyMilitaryType: + @pytest.mark.parametrize("model,expected", [ + ("J-20", "fighter"), + ("Y-20", "cargo"), + ("KJ-500", "recon"), + ("YY-20", "tanker"), + ("F-15J", "fighter"), + ("FA-50", "fighter"), + ("E-2K", "recon"), + ("F16", "fighter"), + ("C17", "cargo"), + ("P8", "recon"), + ("H60", "heli"), + ("K35", "tanker"), + ("Boeing 737", "default"), + ]) + def test_classification(self, model: str, expected: str): + assert _classify_military_type(model) == expected diff --git a/frontend/src/components/NewsFeed.tsx b/frontend/src/components/NewsFeed.tsx index cf8f3cf..c9dfeff 100644 --- a/frontend/src/components/NewsFeed.tsx +++ b/frontend/src/components/NewsFeed.tsx @@ -427,7 +427,12 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi let airline = "UNKNOWN"; if (selectedEntity.type === 'military_flight') { - airline = "MILITARY ASSET"; + const mil = flight as import('@/types/dashboard').MilitaryFlight; + const milCountry = mil.country; + airline = mil.force + ? `${milCountry} ${mil.force}`.trim() + : (milCountry && milCountry !== 'Military Asset' && milCountry !== 'Unknown' + ? milCountry : "MILITARY ASSET"); } else if (selectedEntity.type === 'private_jet') { airline = "PRIVATE JET"; } else if (selectedEntity.type === 'private_flight') { diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index 7596735..6dae3df 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -44,6 +44,7 @@ export interface PrivateJet extends FlightBase { export interface MilitaryFlight extends FlightBase { type: "military_flight"; military_type?: "heli" | "fighter" | "tanker" | "cargo" | "recon" | "default"; + force?: string; } export interface TrackedFlight extends FlightBase { @@ -68,6 +69,7 @@ export interface UAV extends FlightBase { uav_type?: string; aircraft_model?: string; wiki?: string; + force?: string; } export type Flight = CommercialFlight | PrivateFlight | PrivateJet | MilitaryFlight | TrackedFlight | UAV;