// From one aircraft's day of ADS-B positions (a readsb trace_full file) to the airport movements it made: which // airport it took off from or landed at, and when. // // A trace point is [seconds after `timestamp`, lat, lon, altitude ft | "ground" | null, ground speed kt, track, // flags, vertical rate fpm, details | null, source, geometric altitude, geometric vertical rate, ias, roll] // (github.com/wiedehopf/readsb/blob/dev/README-json.md#trace-jsons). flags & 2 marks the start of a new leg. // // The method, in short: // 1. Split the day into blocks at gaps of 30 minutes or more and at readsb's new-leg flag. // 2. In each block, every airborne stretch between ground stretches is a leg (a point is on the ground when readsb // says "ground", or below 25 kt). Isolated glitches are smoothed away: a "ground" report at cruise height, or a // few airborne points while taxiing. // 3. Each end of a leg is matched to an airport: // - beside a ground stretch: the airport the aircraft is standing at (within a few km, by airport size); // - otherwise the first/last airborne point, when it is low and near an airport: below 2,500 ft above the // airport and within 8 km, or extrapolated (by vertical rate, speed and track) onto it; // - higher up (to 12,000 ft), the airports it may be climbing away from or descending to are candidates, // settled with the flight's route data (see settle). // Takeoff and landing times are back-/forward-extrapolated from the first/last airborne point with the climb or // descent rate, never beyond the neighbouring ground report. // 4. Two pieces of one flight split by a coverage gap (an ocean crossing) are joined when it is still flying on // both sides (high, or climbing before and descending after), carries the same callsign and the gap matches // the distance at a plausible speed. // 5. Only airline-style callsigns (three-letter ICAO prefix, then a digit and up to three more characters) are // kept; military, rotorcraft and private flights are dropped and counted. import { angleBetween, bearing, distanceKm, move } from "./geo.mjs"; const GAP_S = 30 * 60; const GROUND_KT = 25; const HIGH_FT = 10000; const MAX_EXTRAPOLATE_MIN = 6; const FAR_KM = 75; const AIRLINE_CALLSIGN = /^[A-Z]{3}[0-9][0-9A-Z]{0,3}$/; // Prefixes of military and state flights that look like airline callsigns (readsb's military flag catches most). const STATE_PREFIXES = new Set(["RCH", "RRR", "CNV", "PAT", "GAF", "IAM", "FAF", "CTM", "BAF", "ASY", "CFC", "SAM", "NAF", "HAF", "PLF", "SUI", "SHF", "AME", "MMF", "IFC", "NOH", "KAF", "QID", "CEF", "HRZ", "RFR", "SVF", "LAF", "MBU", "TUAF", "UNO"]); // Helicopter type designators (ICAO Doc 8643), for traces whose transponder does not send the rotorcraft category. const ROTORCRAFT = new Set( ("R22 R44 R66 B06 B06T B204 B205 B206 B212 B214 B222 B230 B407 B412 B427 B429 B430 B505 B47G EC20 EC25 EC30 EC35 EC45 EC55 " + "EC75 H125 H130 H135 H145 H155 H160 H175 H215 H225 AS32 AS50 AS55 AS65 AS3B A109 A119 A129 A139 A149 A169 A189 AW09 " + "BK17 MD52 MD60 MD90 EXPL S61 S64 S70 S76 S92 H47 H60 H64 UH1 UH60 MI8 MI17 MI24 MI26 KA32 K32 CH47 NH90 EH10 G2CA S300 " + "S330 HUCO EN28 EN48 GAZL ALO2 ALO3 LAMA PUMA SUCO TIGR V22").split(" "), ); // Tolerances by airport size (L large, M medium, S small), in km: an aircraft on the ground at a large airport can be // several km from its reference point; a small strip is small. They also rank candidates, so a big airport's runway // end is not given to a small field next to it. const GROUND_KM = { L: 7, M: 4.5, S: 3 }; const AIR_KM = { L: 8, M: 5, S: 3.5 }; export function callsignProblem(callsign) { if (!callsign) return "no-callsign"; if (!AIRLINE_CALLSIGN.test(callsign)) return "not-airline-callsign"; if (STATE_PREFIXES.has(callsign.slice(0, 3))) return "military"; return null; } // Cheap test on the raw JSON before parsing it: does any airline-style callsign occur at all? Most private and // military traces have none and are counted without being parsed. const ANY_AIRLINE = /"flight":"[A-Z]{3}[0-9]/; export function mayHaveAirline(json) { return ANY_AIRLINE.test(json); } const isGround = (p) => p[3] === "ground" || (typeof p[4] === "number" && p[4] < GROUND_KT); function altitude(p) { if (typeof p[10] === "number") return p[10]; return typeof p[3] === "number" ? p[3] : null; } function verticalRate(p) { if (typeof p[7] === "number") return p[7]; return typeof p[11] === "number" ? p[11] : null; } // The airport an aircraft standing (or rolling) at this point is at. function groundAirport(p, ref) { let best = null; for (const { airport, km } of ref.index.near(p[1], p[2], 8)) { const score = km / GROUND_KM[airport.kind]; if (score <= 1 && (!best || score < best.score)) best = { airport, score }; } return best?.airport || null; } function completePoint(trace, at, step) { const t = trace[at][0]; for (let i = at; i >= 0 && i < trace.length && Math.abs(trace[i][0] - t) <= 90; i += step) { const q = trace[i]; if (typeof q[5] === "number" && verticalRate(q) !== null && typeof q[3] === "number") return q; } return null; } // The airport a leg starts at (dir -1) or ends at (dir +1), from its first or last airborne point, with the minutes // between that point and the takeoff or landing. Low and near (2,500 ft above the airport or less, within 8 km or // projected onto it), the airport is certain. Higher up several airports may fit, and an aircraft on downwind or // being vectored is not heading for its airport: those are returned as candidates, settled later with the flight's // route (see settle), or left unknown. export function airborneEnd(trace, at, dir, ref) { const p = trace[at]; const alt = altitude(p); if (alt === null) return null; // A first fix often has a position but no track or vertical rate yet (MLAT, Mode S): take those from the next // point that has them, within 90 seconds. const full = completePoint(trace, at, -dir) || p; const vr = verticalRate(full); const gs = typeof full[4] === "number" ? full[4] : 140; const track = typeof full[5] === "number" ? full[5] : null; // Speed since takeoff (or until landing) averages between the current speed and ~150 kt at the runway. const average = (gs + (dir < 0 ? 150 : 140)) / 2; const rate = vr === null ? null : dir < 0 ? vr : -vr; // positive when climbing away / descending towards let best = null; const candidates = []; for (const { airport, km } of ref.index.near(p[1], p[2], FAR_KM)) { const agl = Math.max(0, alt - airport.elev); if (agl > 12000) continue; const tolerance = AIR_KM[airport.kind]; // Near: extrapolate onto the runway by the vertical rate, speed and track. if (km <= 30 && agl <= 6000) { let minutes = null; if (rate !== null && rate >= 200) minutes = agl / rate; else if (agl <= 1500 && (rate === null || rate > -300)) minutes = agl / (dir < 0 ? 2000 : 800); // else too high to be sure, or going the wrong way (climbing away is not a landing) if (minutes !== null && minutes <= MAX_EXTRAPOLATE_MIN) { let projected = km; if (track !== null && minutes > 0) { const [lat, lon] = move(p[1], p[2], dir < 0 ? track + 180 : track, (average * minutes * 1.852) / 60); projected = distanceKm(lat, lon, airport.lat, airport.lon); } if (agl <= 2500 && (km <= 8 || projected <= tolerance)) { const score = Math.min(km, projected) / tolerance; if (!best || score < best.score) best = { airport, score, minutes }; continue; } if (projected <= tolerance) { candidates.push({ airport, minutes }); continue; } } } // Farther out: an airliner climbing away from (descending towards) an airport with airline service, within the // distance that height allows (4 nm per 1,000 ft, plus 3), heading away from (towards) it. if (best || track === null || rate === null || rate < 500) continue; if (airport.kind === "S" && !airport.scheduled) continue; const nm = km / 1.852; if (nm > (agl / 1000) * 4 + 3) continue; if (km > 5) { const toward = dir < 0 ? bearing(airport.lat, airport.lon, p[1], p[2]) : bearing(p[1], p[2], airport.lat, airport.lon); if (angleBetween(track, toward) > 100) continue; } candidates.push({ airport, minutes: Math.min(25, Math.max(agl / rate, (nm / average) * 60)) }); } if (best) return best; return candidates.length ? { candidates } : null; } function mostCommon(values) { const counts = new Map(); let top = ""; let n = 0; for (const v of values) { const c = (counts.get(v) || 0) + 1; counts.set(v, c); if (c > n) { n = c; top = v; } } return top; } // Runs of ground / airborne points in one block, with glitches smoothed away. function runsOf(trace, s, e) { let runs = []; for (let i = s; i <= e; i++) { const g = isGround(trace[i]); const last = runs[runs.length - 1]; if (last && last.ground === g) last.e = i; else runs.push({ ground: g, s: i, e: i }); } const merge = (list) => { const out = []; for (const r of list) { const last = out[out.length - 1]; if (last && last.ground === r.ground) last.e = r.e; else out.push({ ...r }); } return out; }; // A ground report next to an aircraft at cruise height is a glitch. runs = merge( runs.map((r, i) => { if (!r.ground) return r; const before = i > 0 ? altitude(trace[runs[i - 1].e]) : null; const after = i + 1 < runs.length ? altitude(trace[runs[i + 1].s]) : null; return (before ?? 0) >= HIGH_FT || (after ?? 0) >= HIGH_FT ? { ...r, ground: false } : r; }), ); // A couple of airborne points between ground reports (a bad altitude while taxiing) are not a flight. runs = merge( runs.map((r, i) => { if (r.ground || i === 0 || i === runs.length - 1) return r; const short = r.e - r.s + 1 < 3 || trace[r.e][0] - trace[r.s][0] < 45; return short ? { ...r, ground: true } : r; }), ); return runs; } // Everything about one leg that does not need the airports yet. function describeLeg(trace, runs, i, block) { const air = runs[i]; const before = i > 0 && runs[i - 1].ground ? runs[i - 1] : null; const after = i + 1 < runs.length && runs[i + 1].ground ? runs[i + 1] : null; const from = before ? before.s : air.s; const to = after ? after.e : air.e; const flights = []; const categories = []; for (let k = from; k <= to; k++) { const d = trace[k][8]; if (!d) continue; if (d.flight) { const f = d.flight.trim(); if (f) flights.push(f); } if (d.category) categories.push(d.category); } return { block, air, before, after, callsign: mostCommon(flights), category: mostCommon(categories) }; } function departureOf(leg, trace, ref, t0) { const first = trace[leg.air.s]; if (leg.before) { const g = trace[leg.before.e]; const airport = groundAirport(g, ref); if (!airport) return null; const agl = Math.max(0, (altitude(first) ?? airport.elev) - airport.elev); const vr = verticalRate(first); const back = vr !== null && vr >= 200 ? Math.min(MAX_EXTRAPOLATE_MIN, agl / vr) * 60 : 0; return { airport, t: t0 + Math.max(g[0], first[0] - back) }; } const end = airborneEnd(trace, leg.air.s, -1, ref); if (!end) return null; if (end.candidates) return { candidates: end.candidates.map((c) => ({ airport: c.airport, t: t0 + first[0] - c.minutes * 60 })) }; return { airport: end.airport, t: t0 + first[0] - end.minutes * 60 }; } function arrivalOf(leg, trace, ref, t0) { const last = trace[leg.air.e]; if (leg.after) { const g = trace[leg.after.s]; const airport = groundAirport(g, ref); if (!airport) return null; const agl = Math.max(0, (altitude(last) ?? airport.elev) - airport.elev); const vr = verticalRate(last); const ahead = vr !== null && vr <= -200 ? Math.min(MAX_EXTRAPOLATE_MIN, agl / -vr) * 60 : 0; return { airport, t: t0 + Math.min(g[0], last[0] + ahead) }; } const end = airborneEnd(trace, leg.air.e, 1, ref); if (!end) return null; if (end.candidates) return { candidates: end.candidates.map((c) => ({ airport: c.airport, t: t0 + last[0] + c.minutes * 60 })) }; return { airport: end.airport, t: t0 + last[0] + end.minutes * 60 }; } // Two legs that are one flight broken by a coverage gap: same callsign, still flying on both sides of the gap (high, // or climbing before it and descending after it, so not just landed or just taken off), at a consistent speed. function continues(a, b, trace) { if (a.arr || b.dep || !a.callsign || a.callsign !== b.callsign) return false; const p = trace[a.air.e]; const q = trace[b.air.s]; const stillFlying = (alt, vr, sign) => (alt ?? 0) >= HIGH_FT || (vr !== null && vr * sign >= 300 && (alt ?? 0) >= 2000); if (!stillFlying(altitude(p), verticalRate(p), 1) || !stillFlying(altitude(q), verticalRate(q), -1)) return false; const hours = (q[0] - p[0]) / 3600; if (hours <= 0 || hours > 14) return false; const kt = distanceKm(p[1], p[2], q[1], q[2]) / 1.852 / hours; const gs = Math.max(typeof p[4] === "number" ? p[4] : 0, typeof q[4] === "number" ? q[4] : 0) || 450; return kt >= gs * 0.5 && kt <= gs * 1.5 + 100; } export function newStats() { return { traces: 0, tracesParsed: 0, tracesNoAirlineCallsign: 0, tracesGroundOnly: 0, tracesUnreadable: 0, legs: 0, legsJoined: 0, legsKept: 0, fullLegs: 0, dropped: { "no-callsign": 0, "not-airline-callsign": 0, military: 0, rotorcraft: 0, "no-airport": 0, "same-airport": 0, }, pairsBroken: 0, }; } const DAY_S = 86400; // The movements in one parsed trace. `day0` is the UTC midnight the file is for: times are kept inside that day. export function movementsOf(doc, ref, stats, day0 = doc.timestamp) { const trace = doc.trace || []; const t0 = doc.timestamp || 0; const hex = String(doc.icao || "").toLowerCase(); const military = Boolean((doc.dbFlags || 0) & 1); const helicopterType = ROTORCRAFT.has(doc.t || ""); // 1. blocks const legs = []; let start = 0; for (let i = 1; i <= trace.length; i++) { if (i === trace.length || trace[i][0] - trace[i - 1][0] >= GAP_S || (trace[i][6] & 2) !== 0) { // 2. legs in this block const runs = runsOf(trace, start, i - 1); for (let r = 0; r < runs.length; r++) if (!runs[r].ground) legs.push(describeLeg(trace, runs, r, legs.length)); start = i; } } if (!legs.length) { if (trace.length) stats.tracesGroundOnly++; return []; } // 3. the ends of legs that might be kept stats.legs += legs.length; for (const leg of legs) { const rotorcraft = helicopterType || leg.category === "A7"; leg.problem = military ? "military" : callsignProblem(leg.callsign) || (rotorcraft ? "rotorcraft" : null); if (leg.problem) continue; leg.dep = departureOf(leg, trace, ref, t0); leg.arr = arrivalOf(leg, trace, ref, t0); } // 4. join pieces of one flight across coverage gaps const joined = []; for (const leg of legs) { const prev = joined[joined.length - 1]; if (prev && !prev.problem && !leg.problem && continues(prev, leg, trace)) { prev.arr = leg.arr; prev.air = { s: prev.air.s, e: leg.air.e }; stats.legsJoined++; continue; } joined.push(leg); } // 5. keep, count, shape. An end known only as candidates is settled later (see settle). const out = []; const clamp = (t) => Math.round(Math.min(day0 + DAY_S - 1, Math.max(day0, t))); for (const leg of joined) { stats.onLeg?.(doc, leg); const drop = (reason) => { stats.dropped[reason]++; stats.onDrop?.(reason, doc, leg); }; if (leg.problem) { drop(leg.problem); continue; } let { dep, arr } = leg; if (!dep && !arr) { drop("no-airport"); continue; } if (dep?.airport && arr?.airport && dep.airport === arr.airport) { // A short leg seen only at one end can look like it starts and ends at the same airport (a landing seen from // 150 ft, a takeoff lost at 400 ft): keep the end a ground report confirms. Otherwise it is a local flight // (circuits, a training or sightseeing flight, an air return) and no part of a schedule. const short = arr.t - dep.t < 600; if (short && leg.after && !leg.before) dep = null; else if (short && leg.before && !leg.after) arr = null; else { drop("same-airport"); continue; } } stats.legsKept++; const base = { callsign: leg.callsign, airline: leg.callsign.slice(0, 3), registration: doc.r || "", type: doc.t || "", hex, }; const shape = (end, kind) => { const m = { ...base, airport: end.airport ? end.airport.icao : "", utc: end.airport ? clamp(end.t) : 0, kind, other: "", via: "" }; if (end.candidates) m.candidates = end.candidates.map((c) => ({ airport: c.airport, utc: clamp(c.t) })); out.push(m); return m; }; const d = dep ? shape(dep, "D") : null; const a = arr ? shape(arr, "A") : null; if (d && a) { if (d.airport && a.airport) pair(d, a, ref, stats); else { d.partner = a; a.partner = d; } } } return out; } // Both ends of one leg: each gets the other's airport, unless the times say it cannot have been one flight. function pair(d, a, ref, stats) { if (d.airport === a.airport) return false; const p = ref.index.get(d.airport); const q = ref.index.get(a.airport); const hours = (a.utc - d.utc) / 3600; const kt = p && q ? distanceKm(p.lat, p.lon, q.lat, q.lon) / 1.852 / Math.max(hours, 1 / 60) : 0; if (hours <= 0 || kt > 750) { stats.pairsBroken++; return false; } d.other = a.airport; a.other = d.airport; d.via = a.via = "O"; stats.fullLegs++; return true; } // Settles the ends known only as candidates, once the day's route data is at hand: the candidate on the flight's // route (a departure from a stop that is not its last, an arrival at one that is not its first). Without a route a // departure with a single candidate is taken (climb-outs point away from their airport; checked against ground- // confirmed takeoffs, 93% right), an arrival is not (aircraft on downwind or vectors point at the neighbours: 74%). // Anything else leaves the end unknown and it is dropped. Then the legs whose ends are both known are paired. export function settle(movements, routes, ref, stats) { const out = []; for (const m of movements) { if (m.candidates) { const stops = routes.get(m.callsign); let chosen = null; if (stops) { const eligible = m.kind === "D" ? stops.slice(0, -1) : stops.slice(1); const fits = m.candidates.filter((c) => eligible.includes(c.airport.icao)); if (fits.length === 1) chosen = fits[0]; } else if (m.kind === "D" && m.candidates.length === 1) chosen = m.candidates[0]; if (!chosen) { stats.farEndsUnresolved = (stats.farEndsUnresolved || 0) + 1; m.dropped = true; continue; } stats.farEnds = (stats.farEnds || 0) + 1; m.airport = chosen.airport.icao; m.utc = chosen.utc; delete m.candidates; } } for (const m of movements) { if (m.dropped) continue; if (m.partner) { const p = m.partner; if (m.kind === "D" && !p.dropped) { if (m.airport === p.airport) { // Settled onto the same airport as the other end: no flight to keep. p.dropped = true; stats.dropped["same-airport"]++; continue; } pair(m, p, ref, stats); } delete m.partner; } out.push(m); } return out.filter((m) => !m.dropped); }