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;