Files
Flight-Ops/scripts/build-world-airports.mjs
kamilkrzywda 03731b938e
CI / build + unit + e2e (pull_request) Successful in 15m31s
Special remote airports + isRemote curation (ISS-097, v0.61.0)
2026-09-23 07:14:35 +00:00

167 lines
8.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Build data/airports_world.csv from the full OurAirports world dump (ISS-079, v0.44.0).
//
// Pipeline: download → keep `large_airport` rows only (same density curation as the EU file,
// v0.2.8) → greedy 40 km haversine clustering → one airport per cluster ("the biggest").
// v0.61.0 (ISS-097): additionally keeps every non-large fixed-wing row that is REMOTE — no other
// airport within 1000 km of the kept set (lib/airports.ts isRemote) — so Antarctica, the high
// Arctic, Alaska, northern Canada and far islands are represented even though they carry no
// large_airport; same-station twins collapse via the same 40 km clustering.
// The output keeps the exact OurAirports header so lib/airports.ts parses it unchanged.
//
// Usage: node scripts/build-world-airports.mjs (writes data/airports_world.csv, prints stats)
// Aborts without writing anything if the download fails after retries — no fabricated data.
import { promises as fs } from 'node:fs';
import path from 'node:path';
const SOURCE_URL = 'https://davidmegginson.github.io/ourairports-data/airports.csv'; // legacy ourairports.com/data URL is dead (see CHANGELOG v0.1.0)
const OUT_FILE = path.join(import.meta.dirname, '..', 'data', 'airports_world.csv');
const CLUSTER_RADIUS_KM = 40; // mid of the issue's 30–50 km range — n≈1k rows makes O(n²) trivial
// v0.61.0 (ISS-097): mirrors REMOTE_RADIUS_KM in lib/airports.ts — "no other airport within this
// radius" is what makes a small airport worth keeping (Antarctica, high Arctic, far islands).
const REMOTE_RADIUS_KM = 1000;
// Same fixed-wing type set as INCLUDED_TYPES in lib/airports.ts (standalone copy — the script must
// stay dependency-free): all fixed-wing airports plus seaplane bases.
const FIXED_WING_TYPES = new Set(['airport', 'small_airport', 'medium_airport', 'large_airport', 'seaplane_base']);
// Standalone copy of isRemote in lib/airports.ts, for CSV rows: true when `row` has no member of
// `others` within REMOTE_RADIUS_KM. Callers pass disjoint sets (the row itself is not in `others`).
function isRemoteRow(row, others) {
for (const o of others) {
if (haversineKm(row, o) <= REMOTE_RADIUS_KM) return false;
}
return true;
}
// --- Minimal RFC-4180 parser (same semantics as lib/airports.ts parseCsv, standalone copy) ---
function parseCsv(text) {
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') { field += '"'; i++; } else inQuotes = false;
} else field += c;
} else if (c === '"') inQuotes = true;
else if (c === ',') { row.push(field); field = ''; }
else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
else if (c !== '\r') field += c;
}
if (field.length > 0 || row.length > 0) { row.push(field); rows.push(row); }
const [header, ...rest] = rows;
return rest
.filter((r) => r.length === header.length && !(r.length === 1 && r[0] === ''))
.map((r) => Object.fromEntries(header.map((h, i) => [h, r[i]])));
}
function haversineKm(a, b) {
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(b.latitude_deg - a.latitude_deg);
const dLon = toRad(b.longitude_deg - a.longitude_deg);
const x =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a.latitude_deg)) * Math.cos(toRad(b.latitude_deg)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(x)));
}
// "Biggest" proxy — the CSV carries no passenger/traffic data, so rank by service signals:
// scheduled_service first, then an IATA code (commercially served), then a Wikipedia page,
// then file order. ponytail: good-enough proxy for hub-vs-satellite; swap in pax data if it ever exists.
function biggestOf(members) {
const rank = (r) => [r.scheduled_service === 'yes' ? 1 : 0, r.iata_code ? 1 : 0, r.wikipedia_link ? 1 : 0];
let best = members[0];
for (const m of members.slice(1)) {
const rb = rank(m);
const rk = rank(best);
if (rb[0] > rk[0] || (rb[0] === rk[0] && rb[1] > rk[1]) || (rb[0] === rk[0] && rb[1] === rk[1] && rb[2] > rk[2])) best = m;
}
return best; // ties keep the lowest file order (members are in file order)
}
async function download(url, attempts = 3) {
for (let i = 1; i <= attempts; i++) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
if (!(text.startsWith('"id"') || text.startsWith('id,')) || text.split('\n').length < 10_000) throw new Error('response does not look like the OurAirports CSV');
return text;
} catch (err) {
console.error(`download attempt ${i}/${attempts} failed: ${err.message}`);
if (i === attempts) throw err;
await new Promise((r) => setTimeout(r, 2000 * i));
}
}
throw new Error('unreachable');
}
const text = await download(SOURCE_URL);
const [headerLine] = text.split('\n');
const header = headerLine.replace(/\r$/, '').split(',').map((h) => h.replace(/^"|"$/g, ''));
if (header.length !== 19) { console.error(`unexpected header (${header.length} columns): ${headerLine}`); process.exit(1); }
const allRows = parseCsv(text);
// v0.61.0 (ISS-097): curation runs over ALL fixed-wing rows, not just large_airport — the remote
// pass below needs the full pool to test against.
const fixed = allRows.filter((r) => FIXED_WING_TYPES.has(r.type));
const large = fixed.filter((r) => r.type === 'large_airport');
console.log(`downloaded ${allRows.length} rows; ${fixed.length} are fixed-wing, ${large.length} of them large_airport`);
// Greedy clustering in file order: a row joins the first cluster whose anchor is within the
// radius, otherwise it starts a new one. Deterministic — same input, same output. Returns the
// "biggest" member (one airport per cluster) plus the clusters themselves for reporting.
function clusterWinners(rows) {
const clusters = []; // { anchorIdx (into rows), members }
for (let i = 0; i < rows.length; i++) {
let joined = false;
for (const c of clusters) {
if (haversineKm(rows[i], rows[c.anchorIdx]) <= CLUSTER_RADIUS_KM) { c.members.push(i); joined = true; break; }
}
if (!joined) clusters.push({ anchorIdx: i, members: [i] });
}
const winners = clusters.map((c) => biggestOf(c.members.map((i) => rows[i])));
return { winners, clusters };
}
const { winners: kept, clusters: largeClusters } = clusterWinners(large);
const multiClusters = [];
for (const c of largeClusters) {
if (c.members.length > 1) {
const winner = biggestOf(c.members.map((i) => large[i]));
multiClusters.push({
city: winner.municipality || winner.name,
keptIcao: winner.icao_code || winner.ident,
dropped: c.members.map((i) => large[i]).filter((r) => r !== winner).map((r) => `${r.icao_code || r.ident} (${r.name})`),
});
}
}
// v0.61.0 (ISS-097): "special" remote airports — every non-large fixed-wing row with no other
// airport within REMOTE_RADIUS_KM of the kept set joins the dataset, so empty regions get their
// real (small) airports instead of nothing. Same-station twins (two strips <40 km apart at one
// base) collapse via the same greedy clustering as above.
const remote = fixed.filter((r) => r.type !== 'large_airport' && isRemoteRow(r, kept));
const { winners: remoteKept } = clusterWinners(remote);
// --- Guards (fail loud before writing anything) ---
const finalRows = [...kept, ...remoteKept];
const key = (r) => r.icao_code || r.ident; // the effective ICAO consumers key by (toGeoJson fallback)
const keys = new Set(finalRows.map(key));
if (keys.size !== finalRows.length) { console.error('duplicate ICAO in curated set — aborting'); process.exit(1); }
for (const must of ['EDDF', 'UNNT', 'LPPD']) { // EDDF is the seed hub; UNNT (Novosibirsk) + LPPD (Ponta Delgada) pin the 409-backstop test pair
if (!keys.has(must)) { console.error(`required airport ${must} missing from curated set — aborting`); process.exit(1); }
}
if (remoteKept.length === 0) { console.error('no remote airports found in the dump — curation rule broken, aborting'); process.exit(1); }
if (finalRows.length < 300 || finalRows.length > 2500) { console.error(`unexpected row count ${finalRows.length}`); process.exit(1); }
await fs.writeFile(OUT_FILE, [headerLine.replace(/\r$/, ''), ...finalRows.map((r) => header.map((h) => `"${String(r[h] ?? '').replace(/"/g, '""')}"`).join(','))].join('\n') + '\n', 'utf8');
console.log(`wrote ${OUT_FILE}: ${kept.length} large + ${remoteKept.length} remote = ${finalRows.length} rows (${large.length - kept.length} large dropped into ${multiClusters.length} multi-airport clusters)`);
console.log('sample merged cities (kept ← dropped):');
for (const c of multiClusters.slice(0, 15)) console.log(` ${c.city}: kept ${c.keptIcao} ← ${c.dropped.join(', ')}`);