swadhinbiswas Load the real reference data, and count each flight once · 9/13/2026
a18d02b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
"""Build the aircraft/operator reference data used by the classifier.
Downloads two authoritative open datasets and writes compact JSON under
``services/data/``:
* **ICAO DOC 8643** aircraft type designators (mirrored by ``tar1090-db``):
type code → name, physical type (landplane/helicopter/…), engine count/type,
wake category.
* **OpenFlights airlines.dat**: ICAO airline designator → name, country, callsign
and a derived operator category (passenger / cargo / military / government /
other).
Run whenever the upstream datasets change::
python -m scripts.build_reference_data
The generated files are committed so the collector and CI need no network.
"""
from __future__ import annotations
import csv
import gzip
import io
import json
import re
from pathlib import Path
import requests
from config.logging import logger, setup_logging
DATA_DIR = Path(__file__).resolve().parent.parent / "services" / "data"
TYPES_URL = (
"https://raw.githubusercontent.com/wiedehopf/tar1090-db/master/db/icao_aircraft_types2.js"
)
AIRLINES_URL = "https://raw.githubusercontent.com/jpatokal/openflights/master/data/airlines.dat"
PLANES_URL = "https://raw.githubusercontent.com/jpatokal/openflights/master/data/planes.dat"
ROUTES_URL = "https://raw.githubusercontent.com/jpatokal/openflights/master/data/routes.dat"
EU_AIRPORTS_FILE = (
Path(__file__).resolve().parent.parent / "ingestion" / "airports" / "europe_airports.json"
)
# ICAO 8643 description: first letter = physical airframe type.
AIRFRAME = {
"L": "landplane",
"H": "helicopter",
"G": "gyrocopter",
"S": "seaplane",
"A": "amphibian",
"T": "tiltrotor",
}
ENGINE = {"P": "piston", "T": "turboprop", "J": "jet", "E": "electric"}
MILITARY_NAME_HINTS = (
"air force",
"airforce",
"military",
"army",
"navy",
"naval",
"marine",
"national guard",
"government",
"state",
"police",
"coast guard",
"border",
"customs",
"ministry of defence",
"ministry of defense",
"gendarmerie",
"luftwaffe",
"aerobatic",
"squadron",
"u.s.",
"united states air",
)
CARGO_NAME_HINTS = (
"cargo",
"freight",
"courier",
"logistics",
"parcel",
"air transport international",
"asl airlines",
"west atlantic",
"swift air",
"amerijet",
"kalitta",
"polar air",
"atlas air",
"cargolux",
"aerologic",
"bluebird cargo",
"fedex",
"ups airlines",
"dhl",
"tnt airways",
)
# Operator category → military/government detection for aircraft type names.
MILITARY_TYPE_HINTS = (
"hercules",
"globemaster",
"galaxy",
"osprey",
"apache",
"black hawk",
"chinook",
"eurofighter",
"typhoon",
"rafale",
"gripen",
"tornado",
"hornet",
"falcon (dassault)",
"c-130",
"c-17",
"c-5",
"a400",
"a-400",
"predator",
"reaper",
"global hawk",
"sentinel",
"awacs",
"kawasaki p-1",
)
BUSINESS_HINTS = (
"gulfstream",
"bombardier global",
"bombardier challenger",
"learjet",
"dassault falcon",
"citation",
"phenom",
"legacy",
"praetor",
"hawker",
"piaggio",
"pilatus pc-12",
"king air",
"beechcraft 350",
"cessna citation",
"embraer praetor",
"global 5000",
"global 6000",
"global 7500",
)
GLIDER_HINTS = ("glider", "sailplane", "discus", "ventus", "duo discus", "ask-", "dg-", "ls-")
DRONE_HINTS = ("unmanned", "drone", "uav", "mq-", "rq-", "surveyor", "aerosonde")
def _fetch_bytes(url: str) -> bytes:
response = requests.get(url, timeout=60)
response.raise_for_status()
return response.content
def _decode_json_maybe_gzip(raw: bytes) -> dict:
"""tar1090 files are committed gzip-compressed; accept plain JSON too."""
try:
return json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return json.loads(gzip.decompress(raw).decode("utf-8"))
def _parse_designator(desc: str) -> dict[str, object]:
"""Decode an ICAO 8643 designator like ``L2J`` / ``H2T`` / ``L1P``."""
match = re.match(r"^([LHGSAT])(\d*)([PTJE]?)$", (desc or "").strip())
if not match:
return {"airframe": "unknown", "engines": None, "engine_type": None}
airframe_letter, engines, engine_type = match.groups()
return {
"airframe": AIRFRAME.get(airframe_letter, "unknown"),
"engines": int(engines) if engines else None,
"engine_type": ENGINE.get(engine_type),
}
def _type_category(type_code: str, name: str) -> str:
"""Derive an operational hint for an aircraft type from its name/code."""
low = name.lower()
if any(h in low for h in MILITARY_TYPE_HINTS):
return "military"
if any(h in low for h in DRONE_HINTS):
return "drone"
if any(h in low for h in GLIDER_HINTS):
return "glider"
if re.match(r"^[A-Z]{1,2}\d{2,3}F$", type_code.upper()):
return "cargo" # ICAO freighter suffix convention (B77F, A32F, MD11F)
if any(h in low for h in BUSINESS_HINTS):
return "business"
return ""
def build_aircraft_types() -> int:
raw = _fetch_bytes(TYPES_URL)
payload = _decode_json_maybe_gzip(raw)
out: dict[str, list[object]] = {}
for type_code, value in payload.items():
if not isinstance(value, list) or len(value) < 3:
continue
name, desc, wtc = value[0], value[1], value[2]
parsed = _parse_designator(str(desc))
out[str(type_code).upper()] = [
str(name),
parsed["airframe"],
parsed["engines"],
parsed["engine_type"],
str(wtc),
_type_category(str(type_code), str(name)),
]
DATA_DIR.mkdir(parents=True, exist_ok=True)
(DATA_DIR / "aircraft_types.json").write_text(
json.dumps(out, separators=(",", ":"), sort_keys=True), encoding="utf-8"
)
logger.info("[reference] %s aircraft types → %s", len(out), DATA_DIR / "aircraft_types.json")
return len(out)
def _operator_category(name: str, callsign: str) -> str:
haystack = f"{name} {callsign}".lower()
if any(h in haystack for h in MILITARY_NAME_HINTS):
return "military"
if any(h in haystack for h in CARGO_NAME_HINTS):
return "cargo"
if any(k in haystack for k in ("government", "ministry", "authority", "administration")):
return "government"
return "passenger"
def build_airlines() -> int:
raw = _fetch_bytes(AIRLINES_URL).decode("utf-8", errors="replace")
reader = csv.reader(io.StringIO(raw))
out: dict[str, list[str]] = {}
for row in reader:
if len(row) < 7:
continue
name, icao, callsign, country = row[1], row[4], row[5], row[6]
if not icao or icao in {"-", "N/A", "\\N", "NA"}:
continue
clean = lambda value: "" if value in {"\\N", "-"} else value # noqa: E731
out[icao.upper()] = [
clean(name),
clean(country),
clean(callsign),
_operator_category(clean(name), clean(callsign)),
]
DATA_DIR.mkdir(parents=True, exist_ok=True)
(DATA_DIR / "airlines.json").write_text(
json.dumps(out, separators=(",", ":"), sort_keys=True), encoding="utf-8"
)
logger.info("[reference] %s airlines → %s", len(out), DATA_DIR / "airlines.json")
return len(out)
def _europe_codes() -> tuple[set[str], dict[str, str]]:
"""Return (ICAO set, IATA→ICAO map) for the EU airport reference."""
try:
rows = json.loads(EU_AIRPORTS_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError):
return set(), {}
icao = {str(r["icao"]).upper() for r in rows if r.get("icao")}
iata_to_icao = {
str(r["iata"]).upper(): str(r["icao"]).upper()
for r in rows
if r.get("iata") and r.get("icao")
}
return icao, iata_to_icao
def _europe_points() -> dict[str, tuple[float, float]]:
"""ICAO → (lat, lon) for the EU airport reference."""
try:
rows = json.loads(EU_AIRPORTS_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
points: dict[str, tuple[float, float]] = {}
for row in rows:
icao = str(row.get("icao") or "").upper()
lat, lon = row.get("latitude"), row.get("longitude")
if icao and isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
points.setdefault(icao, (float(lat), float(lon)))
return points
def _haversine_km(a: tuple[float, float], b: tuple[float, float]) -> float:
from math import asin, cos, radians, sin, sqrt
lat1, lon1 = radians(a[0]), radians(a[1])
lat2, lon2 = radians(b[0]), radians(b[1])
dlat, dlon = lat2 - lat1, lon2 - lon1
h = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
return 2 * asin(sqrt(h)) * 6371.0
def _fleet_specs() -> dict[str, dict[str, object]]:
"""Capacity/range specs from the curated fleet table (best effort)."""
from ingestion.aircraft.collector import AIRCRAFT_TYPES
return AIRCRAFT_TYPES
def build_fleet() -> int:
"""OpenFlights fleet reference (planes.dat) with curated specs merged in."""
specs = _fleet_specs()
raw = _fetch_bytes(PLANES_URL).decode("utf-8", errors="replace")
reader = csv.reader(io.StringIO(raw))
out: list[dict[str, object]] = []
seen: set[str] = set()
for row in reader:
if len(row) < 3:
continue
name, iata, icao = row[0].strip(), row[1].strip(), row[2].strip()
code = icao.upper()
if not code or code in {"\\N", "-"} or code in seen:
continue
seen.add(code)
spec = specs.get(code, {})
out.append(
{
"type_icao": code,
"type_iata": "" if iata in {"\\N", "-"} else iata,
"name": name,
"manufacturer": spec.get("manufacturer"),
"family": spec.get("family"),
"engine": spec.get("engine"),
"capacity": spec.get("capacity"),
"range_km": spec.get("range_km"),
}
)
DATA_DIR.mkdir(parents=True, exist_ok=True)
(DATA_DIR / "fleet.json").write_text(json.dumps(out, separators=(",", ":")), encoding="utf-8")
logger.info("[reference] %s aircraft types → %s", len(out), DATA_DIR / "fleet.json")
return len(out)
def build_routes() -> int:
"""OpenFlights routes.dat filtered to routes touching an EU airport.
``routes.dat`` identifies endpoints by IATA code; both endpoints are mapped
to ICAO so the route network joins cleanly to ``dim_airport``.
"""
europe_icao, iata_to_icao = _europe_codes()
points = _europe_points()
raw = _fetch_bytes(ROUTES_URL).decode("utf-8", errors="replace")
reader = csv.reader(io.StringIO(raw))
out: list[dict[str, object]] = []
for row in reader:
if len(row) < 9:
continue
airline = row[0].strip()
origin = iata_to_icao.get(row[2].strip().upper(), row[2].strip().upper())
dest = iata_to_icao.get(row[4].strip().upper(), row[4].strip().upper())
if origin not in europe_icao and dest not in europe_icao:
continue
stops = row[7].strip()
origin_point, dest_point = points.get(origin), points.get(dest)
distance = (
round(_haversine_km(origin_point, dest_point), 1)
if origin_point and dest_point
else None
)
out.append(
{
"airline": airline,
"origin": origin,
"destination": dest,
"stops": int(stops) if stops.isdigit() else 0,
"equipment": row[8].strip(),
"distance_km": distance,
}
)
DATA_DIR.mkdir(parents=True, exist_ok=True)
(DATA_DIR / "routes.json").write_text(json.dumps(out, separators=(",", ":")), encoding="utf-8")
logger.info("[reference] %s EU routes → %s", len(out), DATA_DIR / "routes.json")
return len(out)
def main() -> int:
setup_logging()
build_aircraft_types()
build_airlines()
build_fleet()
build_routes()
return 0
if __name__ == "__main__":
raise SystemExit(main())