Special remote airports + isRemote curation (ISS-097, v0.61.0)
CI / build + unit + e2e (pull_request) Successful in 15m31s
CI / build + unit + e2e (pull_request) Successful in 15m31s
This commit is contained in:
@@ -3,6 +3,10 @@
|
||||
//
|
||||
// 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)
|
||||
@@ -14,6 +18,22 @@ 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) {
|
||||
@@ -86,26 +106,33 @@ const header = headerLine.replace(/\r$/, '').split(',').map((h) => h.replace(/^"
|
||||
if (header.length !== 19) { console.error(`unexpected header (${header.length} columns): ${headerLine}`); process.exit(1); }
|
||||
|
||||
const allRows = parseCsv(text);
|
||||
const large = allRows.filter((r) => r.type === 'large_airport');
|
||||
console.log(`downloaded ${allRows.length} rows; ${large.length} are large_airport`);
|
||||
// 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.
|
||||
const clusters = []; // { anchorIdx (into `large`), members }
|
||||
for (let i = 0; i < large.length; i++) {
|
||||
let joined = false;
|
||||
for (const c of clusters) {
|
||||
if (haversineKm(large[i], large[c.anchorIdx]) <= CLUSTER_RADIUS_KM) { c.members.push(i); joined = true; break; }
|
||||
// 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] });
|
||||
}
|
||||
if (!joined) clusters.push({ anchorIdx: i, members: [i] });
|
||||
const winners = clusters.map((c) => biggestOf(c.members.map((i) => rows[i])));
|
||||
return { winners, clusters };
|
||||
}
|
||||
|
||||
const kept = [];
|
||||
const { winners: kept, clusters: largeClusters } = clusterWinners(large);
|
||||
const multiClusters = [];
|
||||
for (const c of clusters) {
|
||||
const winner = biggestOf(c.members.map((i) => large[i]));
|
||||
kept.push(winner);
|
||||
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,
|
||||
@@ -114,17 +141,26 @@ for (const c of clusters) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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(kept.map(key));
|
||||
if (keys.size !== kept.length) { console.error('duplicate ICAO in curated set — aborting'); process.exit(1); }
|
||||
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 (kept.length < 300 || kept.length > 2500) { console.error(`unexpected row count ${kept.length}`); 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$/, ''), ...kept.map((r) => header.map((h) => `"${String(r[h] ?? '').replace(/"/g, '""')}"`).join(','))].join('\n') + '\n', 'utf8');
|
||||
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} rows kept, ${large.length - kept.length} dropped into ${multiClusters.length} multi-airport clusters`);
|
||||
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(', ')}`);
|
||||
|
||||
Reference in New Issue
Block a user