// Reference data for turning aircraft traces into airport movements. All of it is open, cached in RW_ADSB_WORK and // refreshed weekly: // // OurAirports airports.csv (public domain) airports: ICAO, IATA, name, city, country, position, elevation // OpenFlights airports.dat (ODbL 1.0) IANA time zones (see timeZones below for airports it lacks) // VRS standing data airlines.csv (CC0 1.0) airline names and IATA codes; current and maintained // OpenFlights airlines.dat (ODbL 1.0) airline names and IATA codes VRS does not have // VRS standing data routes.csv.gz (CC0 1.0) the other end of a flight when only one end was seen, via adsb.lol import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; import { createInterface } from "node:readline"; import { createGunzip } from "node:zlib"; import { USER_AGENT } from "./download.mjs"; import { AirportIndex } from "./geo.mjs"; export { AirportIndex, distanceKm, move } from "./geo.mjs"; export const SOURCES = { airports: { url: "https://davidmegginson.github.io/ourairports-data/airports.csv", file: "ourairports-airports.csv", days: 7, required: true }, timeZones: { url: "https://raw.githubusercontent.com/jpatokal/openflights/master/data/airports.dat", file: "openflights-airports.dat", days: 30 }, airlines: { url: "https://raw.githubusercontent.com/vradarserver/standing-data/main/airlines/schema-01/airlines.csv", file: "vrs-airlines.csv", days: 7 }, airlinesFallback: { url: "https://raw.githubusercontent.com/jpatokal/openflights/master/data/airlines.dat", file: "openflights-airlines.dat", days: 30 }, routes: { url: "https://vrs-standing-data.adsb.lol/routes.csv.gz", file: "vrs-routes.csv.gz", days: 7 }, }; // ── Files ───────────────────────────────────────────────────────────────────────────────────────────────────────── // The cached copy when it is recent enough, else a fresh download (written beside it, then renamed). A failed refresh // keeps the old copy, so one bad night upstream does not stop the ingest. export async function ensureFile(dir, source, { log = () => {}, fetchImpl = fetch, now = Date.now() } = {}) { mkdirSync(dir, { recursive: true }); const file = path.join(dir, source.file); const age = existsSync(file) ? (now - statSync(file).mtimeMs) / 86400000 : Infinity; if (age < source.days) return file; try { const response = await fetchImpl(source.url, { headers: { "User-Agent": USER_AGENT }, signal: AbortSignal.timeout(120000) }); if (!response.ok) throw new Error(`${response.status}`); const body = Buffer.from(await response.arrayBuffer()); if (body.length < 1000) throw new Error(`only ${body.length} bytes`); writeFileSync(`${file}.new`, body); renameSync(`${file}.new`, file); log(`reference: ${source.file} refreshed (${(body.length / 1e6).toFixed(1)} MB).`); } catch (error) { try { unlinkSync(`${file}.new`); } catch { // nothing half-written } if (!existsSync(file)) { if (source.required) throw new Error(`Could not download ${source.url}: ${error?.message || error}`); log(`reference: ${source.file} unavailable (${error?.message || error}); going on without it.`); return null; } log(`reference: ${source.file} not refreshed (${error?.message || error}); using the copy from ${Math.round(age)} days ago.`); } return file; } // RFC 4180 CSV (quoted fields, doubled quotes, commas and newlines inside quotes), as arrays of strings. export function parseCsv(text) { const rows = []; let row = []; let field = ""; let quoted = false; let i = text.charCodeAt(0) === 0xfeff ? 1 : 0; for (; i < text.length; i++) { const c = text[i]; if (quoted) { if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else quoted = false; } else field += c; } else if (c === '"') quoted = true; else if (c === ",") { row.push(field); field = ""; } else if (c === "\n" || c === "\r") { if (c === "\r" && text[i + 1] === "\n") i++; row.push(field); rows.push(row); row = []; field = ""; } else field += c; } if (field || row.length) { row.push(field); rows.push(row); } return rows; } function records(text) { const [head, ...rows] = parseCsv(text); return rows.filter((r) => r.length >= head.length - 1).map((r) => Object.fromEntries(head.map((h, i) => [h, r[i] ?? ""]))); } // ── Airports and the spatial index ─────────────────────────────────────────────────────────────────────────────── const ICAO = /^[A-Z]{4}$/; const KIND = { large_airport: "L", medium_airport: "M", small_airport: "S" }; const KIND_RANK = { L: 3, M: 2, S: 1 }; // OurAirports rows -> one airport per ICAO code. The code is icao_code, else an ICAO-shaped ident or gps_code; when // two airports claim a code, the one whose official icao_code it is wins, then the larger, then the scheduled one. export function airportsFromOurAirports(csvText) { const byIcao = new Map(); for (const r of records(csvText)) { const kind = KIND[r.type]; if (!kind) continue; const icao = [r.icao_code, r.ident, r.gps_code].find((c) => ICAO.test(c || "")); if (!icao) continue; const lat = Number(r.latitude_deg); const lon = Number(r.longitude_deg); if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; const a = { icao, iata: /^[A-Z0-9]{3}$/.test(r.iata_code) ? r.iata_code : "", name: r.name || "", city: r.municipality || "", country: r.iso_country || "", lat, lon, elev: Number.isFinite(Number(r.elevation_ft)) && r.elevation_ft !== "" ? Number(r.elevation_ft) : 0, kind, scheduled: r.scheduled_service === "yes", official: r.icao_code === icao, tz: "", }; const old = byIcao.get(icao); const better = (x, y) => x.official !== y.official ? x.official : KIND_RANK[x.kind] !== KIND_RANK[y.kind] ? KIND_RANK[x.kind] > KIND_RANK[y.kind] : x.scheduled && !y.scheduled; if (!old || better(a, old)) byIcao.set(icao, a); } return byIcao; } // ── Time zones ──────────────────────────────────────────────────────────────────────────────────────────────────── export function validZone(tz) { if (!tz) return false; try { new Intl.DateTimeFormat("en", { timeZone: tz }); return true; } catch { return false; } } // The lightest source that is right: OpenFlights airports.dat names the IANA zone of ~6,500 airports by ICAO. For the // rest, the zone is (in order) the country's own when OpenFlights' airports in that country all but unanimously share // one (95%), else that of the nearest OpenFlights airport in the same country within 150 km, else unknown (served in // UTC). Checked against OpenFlights itself (holding each airport out), this is right for ~97% within 150 km, and most // misses are equivalent zones (America/Toronto for America/New_York). export function assignTimeZones(airports, openFlightsText) { const known = []; const zoneOf = new Map(); for (const r of parseCsv(openFlightsText)) { const icao = r[5]; const tz = r[11]; if (!ICAO.test(icao || "") || !tz || tz === "\\N" || !validZone(tz)) continue; zoneOf.set(icao, tz); } const index = new Map(); // country -> [{lat, lon, tz}] for (const a of airports.values()) { const tz = zoneOf.get(a.icao); if (!tz) continue; a.tz = tz; known.push(a); let list = index.get(a.country); if (!list) index.set(a.country, (list = [])); list.push(a); } const countryZone = new Map(); for (const [country, list] of index) { const counts = new Map(); for (const a of list) counts.set(a.tz, (counts.get(a.tz) || 0) + 1); const [top, n] = [...counts.entries()].sort((x, y) => y[1] - x[1])[0]; if (n / list.length >= 0.95) countryZone.set(country, top); } const grid = new AirportIndex(known, 1); let filled = 0; let unknown = 0; for (const a of airports.values()) { if (a.tz) continue; if (countryZone.has(a.country)) { a.tz = countryZone.get(a.country); filled++; continue; } const near = grid.near(a.lat, a.lon, 150).find((x) => x.airport.country === a.country); if (near) { a.tz = near.airport.tz; filled++; } else unknown++; } return { fromOpenFlights: known.length, filled, unknown }; } // ── Airlines ────────────────────────────────────────────────────────────────────────────────────────────────────── const IATA_AIRLINE = /^[A-Z0-9]{2}$/; // ICAO code -> { iata, name }. VRS standing data first (CC0, maintained by its users); OpenFlights (ODbL, last // maintained years ago: no ITA Airways, no Endeavor Air) only for codes VRS lacks, preferring its active entries. export function airlinesFrom(vrsText, openFlightsText) { const airlines = new Map(); if (vrsText) { for (const r of records(vrsText)) { if (!/^[A-Z]{3}$/.test(r.ICAO)) continue; const old = airlines.get(r.ICAO); const iata = IATA_AIRLINE.test(r.IATA) ? r.IATA : ""; if (!old || (!old.iata && iata)) airlines.set(r.ICAO, { iata, name: r.Name || "" }); } } if (openFlightsText) { const fallback = new Map(); for (const r of parseCsv(openFlightsText)) { const icao = r[4]; if (!/^[A-Z]{3}$/.test(icao || "") || airlines.has(icao)) continue; const entry = { iata: IATA_AIRLINE.test(r[3] || "") ? r[3] : "", name: r[1] && r[1] !== "\\N" ? r[1] : "", active: r[7] === "Y" }; const old = fallback.get(icao); if (!old || (entry.active && !old.active)) fallback.set(icao, entry); } for (const [icao, e] of fallback) airlines.set(icao, { iata: e.iata, name: e.name }); } return airlines; } // ── Routes ──────────────────────────────────────────────────────────────────────────────────────────────────────── // Reads the route file once and keeps only the callsigns asked for: "BAW117" -> ["EGLL", "KJFK"]. Multi-leg routes // keep every stop ("EGLL-OMDB-YSSY"). export async function routesFor(file, callsigns) { const routes = new Map(); if (!file || !callsigns.size) return routes; const input = createReadStream(file).pipe(createGunzip()); const lines = createInterface({ input, crlfDelay: Infinity }); let header = true; for await (const line of lines) { if (header) { header = false; continue; } const comma = line.indexOf(","); const callsign = line.slice(0, comma); if (!callsigns.has(callsign)) continue; const codes = line.slice(line.lastIndexOf(",") + 1).trim().toUpperCase(); const stops = codes.split("-"); if (stops.length >= 2 && stops.every((s) => ICAO.test(s))) routes.set(callsign, stops); } return routes; } // The other end of a one-ended movement from its route: the next stop after a departure, the one before an arrival. // Only when the observed airport is on the route; a route that does not pass through it is out of date. export function otherFromRoute(stops, airport, kind) { if (!stops) return ""; const at = stops.indexOf(airport); if (at < 0) return ""; if (kind === "D") return at + 1 < stops.length ? stops[at + 1] : ""; return at > 0 ? stops[at - 1] : ""; } // ── Everything at once ──────────────────────────────────────────────────────────────────────────────────────────── export async function loadReference(workDir, { log = () => {}, fetchImpl = fetch } = {}) { const dir = path.join(workDir, "reference"); const files = {}; for (const [key, source] of Object.entries(SOURCES)) files[key] = await ensureFile(dir, source, { log, fetchImpl }); const read = (f) => (f ? readFileSync(f, "utf8") : ""); const airports = airportsFromOurAirports(read(files.airports)); const zones = files.timeZones ? assignTimeZones(airports, read(files.timeZones)) : { fromOpenFlights: 0, filled: 0, unknown: airports.size }; const airlines = airlinesFrom(read(files.airlines), read(files.airlinesFallback)); log( `reference: ${airports.size} airports (time zones: ${zones.fromOpenFlights} from OpenFlights, ${zones.filled} by country or neighbour, ${zones.unknown} unknown), ${airlines.size} airlines.`, ); return { index: new AirportIndex(airports.values()), airlines, routesFile: files.routes, zones }; }