diff --git a/changelog.d/801-local-adsb-altitude-filter.md b/changelog.d/801-local-adsb-altitude-filter.md new file mode 100644 index 000000000..cb8940b3d --- /dev/null +++ b/changelog.d/801-local-adsb-altitude-filter.md @@ -0,0 +1,8 @@ +- Local ADS-B no longer draws an aircraft at an altitude it cannot have + reached. A corrupt frame can still pass CRC, and one reached the globe as a + DC-9 at 108,800 ft. Each altitude report must now be reachable from the last + accepted one at a plausible vertical rate, whether it came from the browser + SDR or a decoder feed. A refused report keeps the previous altitude for the + marker, the card and the 3D model alike. Three refusals in a row start over + from the new value, and a surface report is never compared with the last + airborne altitude. The layer stats count refusals as `rejectedAltitudes` (Pedro Lobato, #801). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7d3ff3ac7..cc6a9387a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5689,6 +5689,22 @@ on purpose: Refused fixes are counted (`positionsRejected` in the SDR state; the layer applies the same check to merged feed records and reports the total as `rejectedPositions` in its stats). Three refusals in a row re-anchor. +- **Altitude filter.** A corrupt frame can pass CRC, and a decoder feed can + relay one: a DC-9 was logged at 108,800 ft. The browser decoder cannot + produce that value, since it decodes only 25 ft (Q=1) altitudes and those + stop at 50,175 ft, so the check runs in the layer where both paths merge + (`gateLocalAdsbAltitude` in `src/sources/adsbRecords.js`). Each new report + must be reachable from the last accepted altitude at 1.5 × the larger + reported vertical rate + 2,000 ft/min (12,500 ft/min without a rate) over + the elapsed time + 1 s, plus 300 ft. A refused report holds the last + accepted altitude for the marker height, the card, the model ceiling and + route plausibility alike, and is counted as `rejectedAltitudes` in the + layer stats. Only a newer message is judged, so the layer's repeated + re-reads of one record neither count twice nor let a refused value + through. Three refusals in a row re-anchor, so a bad first altitude clears + itself. A surface report passes unjudged, because a feed's `ground` is 0 + and not a barometric reading. There is no absolute ceiling: balloons really + do fly above 100,000 ft. - **Trail.** A selected aircraft draws a magenta trail of the positions the receiver heard (up to 10 minutes / 600 fixes, dropped with the aircraft), with the tracked-flight trail look and a live head segment. No network diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index fcf54b5d5..0cf3a33b7 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -10,6 +10,8 @@ import { isPointerFree } from '../../data/inputOwnership.js'; import { pickRenderAltitudeM } from '../../data/renderAltitude.js'; import { routePlausible } from '../../data/routePlausible.js'; import { + createLocalAdsbAltitudeGate, + gateLocalAdsbAltitude, localAdsbPositionIsFresh, mergeLocalAdsbRecords, summarizeLocalAdsb, @@ -145,6 +147,7 @@ export function createLocalAdsbLayer({ let models = null; let geoidReady = false; let rejectedFixes = 0; + let rejectedAltitudes = 0; let state = receiver.getState(); const markers = new Map(); // Per-ICAO receptions (band|source -> last message) across merges. @@ -408,30 +411,36 @@ export function createLocalAdsbLayer({ // ── Markers ───────────────────────────────────────────────────────────── - function upsertMarker(record, at) { - const id = `${ENTITY_PREFIX}${record.icao}`; + function upsertMarker(heard, at) { + const id = `${ENTITY_PREFIX}${heard.icao}`; let marker = markers.get(id); if (!marker) { marker = { id, entity: null, - record, + record: heard, motion: new LocalAdsbMotion(), + altitude: createLocalAdsbAltitudeGate(), position: null, lat: null, lon: null, - courseDeg: Number.isFinite(record.trackDeg) ? record.trackDeg : null, + courseDeg: Number.isFinite(heard.trackDeg) ? heard.trackDeg : null, lastRotation: 0, klass: null, evidence: false, iconKind: null, iconScale: null, - onGround: Boolean(record.onGround), + onGround: Boolean(heard.onGround), geoidN: null, modelOwnsVisual: false, }; markers.set(id, marker); } + // Gated before anything reads the record, so the marker height, the card, + // the 3D-model ceiling and route plausibility agree on one altitude. + const altitudesRejectedBefore = marker.altitude.rejected; + const record = gateLocalAdsbAltitude(heard, marker.altitude); + rejectedAltitudes += marker.altitude.rejected - altitudesRejectedBefore; marker.record = record; marker.onGround = Boolean(record.onGround); const rejectedBefore = marker.motion.rejectedFixes; @@ -810,6 +819,8 @@ export function createLocalAdsbLayer({ // plus the layer's check on every merged record. rejectedPositions: Math.max(0, Number(current.positionsRejected) || 0) + rejectedFixes, + // Altitude reports refused by the vertical-rate check. + rejectedAltitudes, ...localAdsbStatus({ receiver: current, feedState: feeds?.getState?.() || null, diff --git a/src/layers/localAdsb/index.test.mjs b/src/layers/localAdsb/index.test.mjs index 263c21210..15694eff8 100644 --- a/src/layers/localAdsb/index.test.mjs +++ b/src/layers/localAdsb/index.test.mjs @@ -399,6 +399,55 @@ test('an implausible jump in a feed record is ignored by the display', async (t) assert.equal(layer.getStats().rejectedPositions, 1); }); +test('a corrupt altitude reaches neither the marker height nor the card', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const { layer, sources } = await enabledLayer(receiver, clock); + t.after(() => layer.destroy()); + const entity = () => sources[0].entities.getById('local-adsb:abc123'); + const heightM = () => + Cesium.Cartographic.fromCartesian(entity().position.getValue()).height; + const altitudeLine = () => + entity().gevLabelModel.details.find((line) => line.startsWith('ALT ')); + receiver.set({ aircraft: [record()] }); + await layer.update(); + const cruiseM = heightM(); + assert.match(altitudeLine(), /^ALT 10,000 FT/); + + clock.now = 101_000; + receiver.set({ + aircraft: [ + record({ + altitudeFt: 108_800, + lastPositionAt: 101_000, + lastMessageAt: 101_000, + }), + ], + }); + await layer.update(); + await layer.update(); + assert.ok( + Math.abs(heightM() - cruiseM) < 1, + `held at cruise (${heightM()} m vs ${cruiseM} m)`, + ); + assert.match(altitudeLine(), /^ALT 10,000 FT/); + assert.equal(layer.getStats().rejectedAltitudes, 1, 'counted once'); + + clock.now = 102_000; + receiver.set({ + aircraft: [ + record({ + altitudeFt: 10_100, + lastPositionAt: 102_000, + lastMessageAt: 102_000, + }), + ], + }); + await layer.update(); + assert.match(altitudeLine(), /^ALT 10,100 FT/); + assert.equal(layer.getStats().rejectedAltitudes, 1); +}); + test('selecting an aircraft draws a magenta trail of the fixes the receiver heard', async (t) => { const receiver = fakeReceiver({ mode: 'adsb', connected: true }); const clock = { now: 100_000 }; diff --git a/src/sources/adsbRecords.js b/src/sources/adsbRecords.js index 9c0082700..12e3d3963 100644 --- a/src/sources/adsbRecords.js +++ b/src/sources/adsbRecords.js @@ -54,6 +54,16 @@ export const LOCAL_ADSB_POSITION_MARGIN_M = 500; * reference: the previously accepted fix was the outlier, not the stream. */ export const LOCAL_ADSB_REANCHOR_AFTER = 3; +/** + * Altitude change always allowed between two reports: Gillham altitudes step + * in 100 ft and barometric readings jitter. + */ +export const LOCAL_ADSB_ALTITUDE_MARGIN_FT = 300; +/** Vertical-rate limit (ft/min) for an aircraft that has not reported one. */ +export const LOCAL_ADSB_UNKNOWN_VERTICAL_RATE_FPM = 12_500; +/** Reported vertical rate is multiplied by this, plus the margin below. */ +export const LOCAL_ADSB_VERTICAL_RATE_FACTOR = 1.5; +export const LOCAL_ADSB_VERTICAL_RATE_MARGIN_FPM = 2_000; const SLOW_CATEGORIES = new Set(['A1', 'A7', 'B1', 'B4']); const KT_TO_MPS = 1852 / 3600; const EARTH_RADIUS_M = 6_371_008.8; @@ -164,6 +174,115 @@ export function localAdsbFixIsPlausible( ); } +/** + * The fastest an aircraft is assumed to climb or descend, in ft/min: 1.5 × the + * larger reported vertical rate + 2,000 ft/min, or 12,500 ft/min when neither + * report carries one. + * @param {...(number|null|undefined)} rates Reported vertical rates (ft/min). + * @returns {number} + */ +export function localAdsbVerticalRateLimitFpm(...rates) { + const known = rates.filter(Number.isFinite).map(Math.abs); + if (!known.length) return LOCAL_ADSB_UNKNOWN_VERTICAL_RATE_FPM; + return ( + Math.max(...known) * LOCAL_ADSB_VERTICAL_RATE_FACTOR + + LOCAL_ADSB_VERTICAL_RATE_MARGIN_FPM + ); +} + +/** + * The altitude counterpart of {@link localAdsbFixIsPlausible}: whether `next` + * is reachable from the previous accepted altitude at a plausible vertical + * rate. A corrupt frame can still pass CRC, and a decoder feed can relay one, + * so a single report of a DC-9 at 108,800 ft has to be refused here. The + * allowance is a fixed margin plus the climb covered in the elapsed time + 1 s + * at {@link localAdsbVerticalRateLimitFpm}. Without a reference every altitude + * is plausible. There is no reference age limit, unlike positions: the + * allowance grows with the gap anyway, 125,000 ft over 10 minutes at the + * unknown rate. + * @param {{altitudeFt:number, at:number, verticalRateFpm?:number|null}|null} + * previous Last accepted altitude. + * @param {{altitudeFt:number, at:number, verticalRateFpm?:number|null}} next + * Candidate altitude. + * @returns {boolean} + */ +export function localAdsbAltitudeIsPlausible(previous, next) { + if ( + !Number.isFinite(previous?.altitudeFt) || + !Number.isFinite(previous?.at) || + !Number.isFinite(next?.altitudeFt) || + !Number.isFinite(next?.at) + ) + return true; + const elapsedMs = Math.max(0, next.at - previous.at); + const limitFpm = localAdsbVerticalRateLimitFpm( + previous.verticalRateFpm, + next.verticalRateFpm, + ); + const allowedFt = + LOCAL_ADSB_ALTITUDE_MARGIN_FT + ((elapsedMs + 1_000) / 60_000) * limitFpm; + return Math.abs(next.altitudeFt - previous.altitudeFt) <= allowedFt; +} + +/** + * State for {@link gateLocalAdsbAltitude}, one per aircraft, owned by the + * caller and dropped with the aircraft. + * @returns {{accepted:object|null, judgedAt:number|null, rejectStreak:number, + * rejected:number}} + */ +export function createLocalAdsbAltitudeGate() { + return { accepted: null, judgedAt: null, rejectStreak: 0, rejected: 0 }; +} + +/** + * Hold a record's barometric altitude at its last plausible value. + * + * Returns `record` itself, or a copy whose `altitudeFt` is the last accepted + * altitude, so the marker height, the card, the 3D-model ceiling and route + * plausibility all read the same value. A refused report is counted in + * `gate.rejected`. From the third consecutive refusal on, the candidate + * becomes the new reference, as positions do: a bad first altitude cannot + * freeze the aircraft at it. + * + * Only a report with a newer `lastMessageAt` is judged. The layer re-reads the + * same record several times a second, and a re-read must neither count toward + * a re-anchor nor slip a refused value through. A surface report passes + * unjudged: a feed reports `alt_baro: "ground"` as 0, which is not a + * barometric reading, so a landing at a high field is not a 5,000 ft drop. + * @param {object} record Local ADS-B record. + * @param {ReturnType} gate Mutated. + * @returns {object} + */ +export function gateLocalAdsbAltitude(record, gate) { + if (record?.onGround) return record; + const altitudeFt = finiteOrNull(record?.altitudeFt); + const at = finiteOrNull(record?.lastMessageAt); + if (altitudeFt === null || at === null) return record; + if (gate.judgedAt === null || at > gate.judgedAt) { + gate.judgedAt = at; + const candidate = { + altitudeFt, + at, + verticalRateFpm: finiteOrNull(record.verticalRateFpm), + }; + if (localAdsbAltitudeIsPlausible(gate.accepted, candidate)) { + gate.accepted = candidate; + gate.rejectStreak = 0; + } else { + gate.rejected += 1; + gate.rejectStreak += 1; + if (gate.rejectStreak >= LOCAL_ADSB_REANCHOR_AFTER) { + gate.accepted = candidate; + gate.rejectStreak = 0; + } + } + } + const held = gate.accepted?.altitudeFt; + return Number.isFinite(held) && held !== altitudeFt + ? { ...record, altitudeFt: held } + : record; +} + function normalizePosition(lat, lon) { const latitude = finiteOrNull(lat); const longitude = finiteOrNull(lon); diff --git a/src/sources/adsbRecords.test.mjs b/src/sources/adsbRecords.test.mjs index 12070d47b..8106eb5cd 100644 --- a/src/sources/adsbRecords.test.mjs +++ b/src/sources/adsbRecords.test.mjs @@ -6,6 +6,9 @@ import { LOCAL_ADSB_MESSAGE_STALE_MS, LOCAL_ADSB_POSITION_STALE_MS, LOCAL_ADSB_REFERENCE_MAX_AGE_MS, + createLocalAdsbAltitudeGate, + gateLocalAdsbAltitude, + localAdsbAltitudeIsPlausible, localAdsbFixIsPlausible, localAdsbPositionIsFresh, localAdsbRecordIsLive, @@ -324,3 +327,105 @@ test('the position sanity check follows dump1090: speed × 1.5 + margin over ela 'the fixed 500 m margin absorbs CPR and reception error', ); }); + +test('the altitude check allows the vertical rate × 1.5 + margin over elapsed + 1 s', () => { + const from = { altitudeFt: 30_000, at: 0, verticalRateFpm: null }; + // 9 s later, so the allowance covers 10 s: a sixth of a minute. + const at = 9_000; + const check = (altitudeFt, rates = {}) => + localAdsbAltitudeIsPlausible( + { ...from, verticalRateFpm: rates.previous ?? null }, + { altitudeFt, at, verticalRateFpm: rates.next ?? null }, + ); + assert.equal( + localAdsbAltitudeIsPlausible(null, { altitudeFt: 108_800, at }), + true, + 'no reference', + ); + // Unknown rate: 12,500 ft/min → 2,083 ft + 300 ft. + assert.equal(check(32_380), true); + assert.equal(check(32_390), false); + assert.equal(check(27_620), true, 'the limit is symmetric'); + assert.equal(check(27_610), false); + // Level flight reported: 2,000 ft/min → 333 ft + 300 ft. + assert.equal(check(30_630, { previous: 0 }), true); + assert.equal(check(30_640, { previous: 0 }), false); + // The larger of the two reported rates counts: 6,000 × 1.5 + 2,000 + // = 11,000 ft/min → 1,833 ft + 300 ft. + assert.equal(check(27_870, { previous: 0, next: -6_000 }), true); + assert.equal(check(27_860, { previous: 0, next: -6_000 }), false); + assert.equal( + localAdsbAltitudeIsPlausible( + { altitudeFt: 30_000, at: 9_000 }, + { altitudeFt: 30_400, at: 0 }, + ), + true, + 'an out-of-order report gets the zero-gap allowance, not a negative one', + ); +}); + +/** Feed one aircraft's reports, `[seconds, altitudeFt, extra]`, through a gate. */ +function gateReports(reports) { + const gate = createLocalAdsbAltitudeGate(); + const altitudes = reports.map( + ([seconds, altitudeFt, extra = {}]) => + gateLocalAdsbAltitude( + { + icao: 'abc123', + lastMessageAt: seconds * 1_000, + altitudeFt, + ...extra, + }, + gate, + ).altitudeFt, + ); + return { altitudes, rejected: gate.rejected }; +} + +test('a corrupt altitude is held at the last accepted one and judged once', () => { + // The logged case: a DC-9 cruising at 35,000 ft reported once at 108,800 ft. + // The layer re-reads the same record several times a second; a re-read of + // the refused report is neither let through nor counted again. + assert.deepEqual( + gateReports([ + [0, 35_000], + [0.5, 35_000], + [1, 108_800], + [1, 108_800], + [1, 108_800], + [1.5, 35_025], + ]), + { + altitudes: [35_000, 35_000, 35_000, 35_000, 35_000, 35_025], + rejected: 1, + }, + ); +}); + +test('a bad first altitude clears itself on the third refusal', () => { + assert.deepEqual( + gateReports([ + [0, 108_800], + [0.5, 35_000], + [1, 35_000], + [1.5, 35_000], + [2, 35_000], + ]), + { altitudes: [108_800, 108_800, 108_800, 35_000, 35_000], rejected: 3 }, + ); +}); + +test('a surface report is not compared with the last airborne altitude', () => { + // Denver: the last airborne report is 5,600 ft barometric, and a feed + // reports `alt_baro: "ground"` as 0. Neither the landing nor the next + // takeoff is a jump. + assert.deepEqual( + gateReports([ + [0, 5_600], + [20, 0, { onGround: true }], + [300, 5_700], + [301, 5_720], + ]), + { altitudes: [5_600, 0, 5_700, 5_720], rejected: 0 }, + ); +});