Files
Flight-Ops/scripts/build-world-airports.mjs
T
kamilkrzywda 49dfd2ca6e
CI / build + unit + e2e (pull_request) Successful in 13m18s
v0.68.0: re-curate special remote airports — one per ~1000 km region (ISS-144)
Greedy sparse selection replaces the blanket 1000km-from-large filter that
over-filled central Australia / Canada edges with dozens of tiny strips:
candidates sorted by scheduled service, type rank (medium < small < seaplane),
numeric id; accepted only if >= REMOTE_RADIUS_KM from every already-accepted
special. CSV: 1483 -> 1161 rows (1119 large unchanged, specials 364 -> 42).
New unit test pins the pairwise-sparsity invariant on the committed dataset.
2026-09-23 20:33:25 +00:00

180 lines
9.4 KiB
JavaScript
Raw 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.68.0 (ISS-144): additionally keeps a sparse set of "special" remote airports — non-large
// fixed-wing rows with no large airport within 1000 km, greedily thinned to one per ~1000 km
// region (scheduled service + larger types preferred) so Antarctica, the high Arctic, Alaska,
// northern Canada and far islands are represented without over-filling empty regions.
// 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.68.0 (ISS-144): "special" remote airports — one per ~REMOTE_RADIUS_KM region. Candidates are
// the non-large fixed-wing rows with no large airport within REMOTE_RADIUS_KM (unchanged), sorted
// by priority (scheduled service, then type rank medium < small < seaplane, then numeric id) and
// accepted greedily: a candidate joins only if it is at least REMOTE_RADIUS_KM from EVERY already
// accepted special — no special airport within 1000 km of another. One per ~1000 km region, so
// central Australia / Canada edges get a handful of well-chosen strips instead of dozens; this
// subsumes the old 40 km remote clustering (deleted).
const TYPE_RANK = { medium_airport: 0, small_airport: 1, seaplane_base: 2 }; // legacy 'airport' ranks last
const candidates = fixed.filter((r) => r.type !== 'large_airport' && isRemoteRow(r, kept));
candidates.sort(
(a, b) =>
Number(b.scheduled_service === 'yes') - Number(a.scheduled_service === 'yes') ||
(TYPE_RANK[a.type] ?? 3) - (TYPE_RANK[b.type] ?? 3) ||
Number(a.id) - Number(b.id),
);
const remoteKept = [];
for (const c of candidates) {
if (remoteKept.every((k) => haversineKm(c, k) >= REMOTE_RADIUS_KM)) remoteKept.push(c);
}
// --- 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(', ')}`);