From 2ab7c0d8e689d0c83638e8ac506a20074ffd229a Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Tue, 20 Oct 2026 17:12:24 +0200 Subject: [PATCH] fix(local-adsb): track altitude freshness and held-value provenance --- changelog.d/801-local-adsb-altitude-filter.md | 18 +- docs/CURRENT-STATE.md | 42 +- docs/LOCAL-RECEIVERS.md | 20 + server/providers/local-receivers.js | 1 + src/data/localReceiversProxy.test.mjs | 36 ++ src/firstRunExperience.test.mjs | 5 +- src/layers/localAdsb/feeds.test.mjs | 73 +++ src/layers/localAdsb/index.js | 16 +- src/layers/localAdsb/index.test.mjs | 199 +++++++- src/sdr/adsbDecoder.js | 24 + src/sdr/adsbDecoder.test.mjs | 181 +++++++ src/sources/adsbRecords.js | 283 +++++++++-- src/sources/adsbRecords.test.mjs | 445 +++++++++++++++++- src/voice/actionSchemas.test.mjs | 3 +- src/voice/gevActions.js | 47 +- src/voice/gevActions.test.mjs | 224 +++++++++ src/voice/layerManifest.js | 4 + src/voice/layerManifest.test.mjs | 11 + 18 files changed, 1561 insertions(+), 71 deletions(-) diff --git a/changelog.d/801-local-adsb-altitude-filter.md b/changelog.d/801-local-adsb-altitude-filter.md index cb8940b3d..d513923f3 100644 --- a/changelog.d/801-local-adsb-altitude-filter.md +++ b/changelog.d/801-local-adsb-altitude-filter.md @@ -1,8 +1,10 @@ -- 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). +- Local ADS-B filters implausible altitude changes and holds the accepted + value consistently for markers, cards and models. Browser refusals count + actual altitude frames rather than unrelated aircraft messages. Decoder + feeds use a conservative five-second recovery with three advancing + snapshots and aircraft message activity, explicitly marked as inferred. + Held values retain their altitude source and measurement time through + context and analyst results; feeds report an unknown measurement time. + Cached snapshots and transport jitter cannot recount refusals. Ground + reports reset the airborne reference, and no absolute altitude ceiling is + imposed (Pedro Lobato's original filter, #801, with maintainer corrections). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cc6a9387a..d6ea580ab 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5699,12 +5699,36 @@ on purpose: 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. + layer stats. Browser tracks carry the altitude frame's own receive time, + sequence and track start: identification/velocity messages and renderer + re-reads never refresh an accepted altitude or recount a refusal. Equal + altitudes in distinct frames are real observations, including frames from + one USB block with the same timestamp. Three observed refusals in a row + re-anchor; a sequence gap counts as only one observed report. + Decoder feeds expose no altitude-specific measurement age. A changed value + is judged once and keeps its receipt-time reference through unrelated + messages. Feed allowance is capped at a 60 s relative gap, since fresh + positions can keep an old altitude alive indefinitely. Recovery from an + implausible feed value requires a coherent candidate stream for at least + 5 s and three advancing source snapshots with increasing aircraft message + counts from one receiver. Cached snapshots and transport jitter cannot + supply that progress. Each receiver keeps separate recovery progress when + the freshest position alternates between inputs; a new accepted altitude + resets pending recovery against the old reference. This is + an inference: a stable corrupt feed can also satisfy it. Without source + clock/count/identity metadata, automatic feed recovery is disabled, so a + held altitude can persist while positions remain fresh. A surface report + clears the airborne reference, because a feed's `ground` is 0 and not a + barometric reading. The next airborne value seeds a new reference, just + like the first observation of a new aircraft. There is no absolute ceiling: + balloons really do fly above 100,000 ft. + Accepted/held records carry `altitudeQuality` (`accepted`, + `held-after-rejection`, `feed-estimate` or `inferred-reanchor`), + `altitudeObservedAt` (the actual browser measurement time, null for feeds), + and `altitudeSource` (the accepted value's source, which can differ from the + position source). These accompany altitude through context metadata and + final analyst items, including altitude-only queries. Missing/surface + altitudes carry no airborne quality claim. - **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 @@ -5723,10 +5747,12 @@ aircraft join detection boxes, labelled only with a decoded callsign. Receivers publish one record shape from `src/sources/adsbRecords.js` (`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`, +`altitudeObservedAt`, browser `altitudeSequence`/`altitudeSessionAt`, feed +`feedId`/`feedSnapshotAt`, `groundSpeedKt`, `trackDeg`, `verticalRateFpm`, `lastPositionAt`, `lastMessageAt`, `messageCount`, `rssiDbfs`, `band`, `source`). `band` is `1090` or `978`; `source` is `webusb` (browser SDR) or `feed` (decoder feed). -`normalizeDump1090Aircraft(json, nowMs, { band })` maps a dump1090/readsb/ +`normalizeDump1090Aircraft(json, nowMs, { band, feedId })` maps a dump1090/readsb/ skyaware978 `aircraft.json` document into the same records. Voice `set_layer_visibility` accepts `local-adsb` ("local ADS-B", "my receiver", "my antenna"). @@ -5751,6 +5777,8 @@ second only while the layer is enabled and only while the route reports `configured`; the Local RTL-SDR card makes one probe request at startup to show its read-only "Decoder feeds: 1090 live · 978 live" line. Feed records are rebased to the browser clock and kept up to 60 s after their last message. +The opaque configured `feedId` and raw `feedSnapshotAt` source clock are +preserved without rebasing; neither exposes the receiver URL. `mergeLocalAdsbRecords` merges both inputs by ICAO, keeping the newest position (tie: newest message) and the bands/sources heard in the last 60 s; the card's receiver line names them ("Heard by your receiver · 1090 MHz + 978 MHz UAT · diff --git a/docs/LOCAL-RECEIVERS.md b/docs/LOCAL-RECEIVERS.md index ee75d9a70..aedda886e 100644 --- a/docs/LOCAL-RECEIVERS.md +++ b/docs/LOCAL-RECEIVERS.md @@ -18,6 +18,26 @@ smoothly between fixes, while local receiver aircraft are shown as heard. An aircraft in both layers therefore draws twice, the magenta local marker ahead of the public one; both markers are drawn on purpose. +Airborne altitude changes are checked against the last accepted value and a +vertical-rate allowance. A refused value holds the accepted altitude for the +marker, card and model. Browser SDR recovery counts actual altitude frames; +identification and velocity messages cannot make a held altitude look new. + +Decoder feeds have no altitude measurement timestamp. Their changed values +are estimates; recovery after a refusal requires at least five seconds and +three advancing source snapshots with increasing aircraft message counts +from the same receiver, with coherent altitude changes. Repeated cached +snapshots and transport delays do not count. This recovery is inferred, so a +stable corrupt feed can still qualify. Feeds missing progression metadata +cannot automatically recover a held value, and fresh positions can keep that +held altitude alive. The rate check caps its elapsed allowance at 60 seconds +for feeds; there is no absolute altitude ceiling. + +Context and analyst records include `altitudeQuality`, `altitudeObservedAt` +and `altitudeSource`. Held values retain the accepted value's source and +measurement time; feed measurement times are null. Ground reports clear the +airborne reference and its quality metadata. + ## Which to use - **One plain RTL-SDR dongle, desktop Chrome or Edge:** use the browser SDR. diff --git a/server/providers/local-receivers.js b/server/providers/local-receivers.js index 994da7bd8..9a263af8b 100644 --- a/server/providers/local-receivers.js +++ b/server/providers/local-receivers.js @@ -384,6 +384,7 @@ async function readFeed( // naturally, without trusting a receiver clock that runs ahead of ours. const records = normalizeDump1090Aircraft(json, receivedAt - ageMs, { band: feed.band, + feedId: feed.id, }); return { status: { ...summary, status, aircraft: records.length, ageMs }, diff --git a/src/data/localReceiversProxy.test.mjs b/src/data/localReceiversProxy.test.mjs index 79ea9a558..9e7605a29 100644 --- a/src/data/localReceiversProxy.test.mjs +++ b/src/data/localReceiversProxy.test.mjs @@ -180,6 +180,42 @@ test('live feeds are read in parallel and labelled with their band', async () => // Ages are anchored at the feed's own `now`. assert.equal(shinr[0].lastPositionAt, FIXTURE_NOW_MS - 34_600); assert.equal(shinr[0].lat, 30.269662); + assert.equal( + shinr[0].altitudeObservedAt, + null, + 'feed has no altitude measurement time', + ); + assert.equal( + shinr[0].feedSnapshotAt, + FIXTURE_NOW_MS, + 'source clock is not rebased', + ); + assert.equal(typeof shinr[0].feedId, 'string'); + assert.notEqual( + shinr[0].feedId, + shinr[1].feedId, + 'each receiver has a distinct opaque id', + ); + assert.ok(!JSON.stringify(json).includes(FEED_1090)); + assert.ok(!JSON.stringify(json).includes(FEED_978)); +}); + +test('a decoder without a source clock cannot supply inferred altitude progress', async () => { + const handler = createLocalReceiversHandler({ + feedsValue: `1090=${FEED_1090}`, + logger: quietLogger(), + now: () => 1_000, + fetchImpl: async () => + respond( + 200, + JSON.stringify({ + aircraft: [{ hex: 'abc123', alt_baro: 35_000, messages: 5 }], + }), + ), + }); + const { json } = await call(handler); + assert.equal(json.records[0].feedSnapshotAt, null); + assert.equal(json.records[0].altitudeObservedAt, null); }); test('a feed whose own now is over 10 s old is stale; failures are unreachable', async () => { diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 749123acd..25be511c2 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -995,10 +995,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // list descriptions; the missions still ride existing tools. // Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft. // Air quality (ECCC AQHI) extends the manifest. - assert.equal(block.length, 30655, 'serialized tool schema length drifted'); + // Local ADS-B adds altitude quality, observation time and source field hints. + assert.equal(block.length, 30705, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e', + 'f3835c8f5b1732cbd5b45e3eb46baa9a979830be2d23dd1c9888a3e98a35f082', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/layers/localAdsb/feeds.test.mjs b/src/layers/localAdsb/feeds.test.mjs index e1ad540d4..1e5dec86a 100644 --- a/src/layers/localAdsb/feeds.test.mjs +++ b/src/layers/localAdsb/feeds.test.mjs @@ -2,6 +2,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { LOCAL_RECEIVERS_URL, createLocalReceiverFeeds } from './feeds.js'; +import { + createLocalAdsbAltitudeGate, + gateLocalAdsbAltitude, +} from '../../sources/adsbRecords.js'; const flush = () => new Promise((resolve) => setImmediate(resolve)); @@ -100,6 +104,75 @@ test('configured feeds poll every second between start and stop, on the browser assert.equal(seen.at(-1), false); }); +test('transport jitter rebases liveness without inventing altitude observations', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const base = 1_000_000; + const report = (altitudeFt, at, messages) => + aircraft({ + altitudeFt, + altitudeObservedAt: null, + feedId: 'feed-1', + feedSnapshotAt: at, + messageCount: messages, + lastPositionAt: at, + lastMessageAt: at, + }); + const cachedBad = payload([report(108_800, base + 1_000, 2)], base + 1_000); + const { fetchImpl } = route([ + payload([report(35_000, base, 1)], base), + cachedBad, + cachedBad, + cachedBad, + payload([report(108_800, base + 8_000, 3)], base + 8_000), + payload([report(108_800, base + 9_000, 4)], base + 9_000), + ]); + let clock = base; + const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => clock }); + t.after(() => feeds.destroy()); + const gate = createLocalAdsbAltitudeGate(); + feeds.start(); + await flush(); + gateLocalAdsbAltitude(feeds.getState().records[0], gate, clock); + for (const delay of [20, 740, 5_500]) { + clock = base + 1_000 + delay; + t.mock.timers.tick(1_000); + await flush(); + const record = feeds.getState().records[0]; + assert.equal( + record.lastMessageAt, + clock, + 'transport clock changes on every receipt', + ); + assert.equal( + record.feedSnapshotAt, + base + 1_000, + 'raw source clock survives rebasing', + ); + assert.equal(record.altitudeObservedAt, null); + const held = gateLocalAdsbAltitude(record, gate, clock); + assert.equal(held.altitudeFt, 35_000); + assert.equal(held.altitudeQuality, 'held-after-rejection'); + } + assert.equal( + gate.rejected, + 1, + 'cached value produces one refusal despite >500 ms latency swings', + ); + for (const at of [base + 8_000, base + 9_000]) { + clock = at; + t.mock.timers.tick(1_000); + await flush(); + const result = gateLocalAdsbAltitude( + feeds.getState().records[0], + gate, + clock, + ); + assert.equal(result.altitudeFt, at === base + 8_000 ? 35_000 : 108_800); + if (at === base + 9_000) + assert.equal(result.altitudeQuality, 'inferred-reanchor'); + } +}); + test('an aircraft a feed drops is kept until 60 s after its last message', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); const bodies = [ diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index 0cf3a33b7..780a31cb0 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -325,6 +325,13 @@ export function createLocalAdsbLayer({ aircraftClass: marker.evidence ? marker.klass : null, typeCode: meta?.typeCode ?? null, altitudeFt: record.altitudeFt, + ...(Number.isFinite(record.altitudeFt) + ? { + altitudeQuality: record.altitudeQuality, + altitudeObservedAt: record.altitudeObservedAt, + altitudeSource: record.altitudeSource, + } + : {}), groundSpeedKt: record.groundSpeedKt, trackDeg: record.trackDeg, verticalRateFpm: record.verticalRateFpm, @@ -439,7 +446,7 @@ export function createLocalAdsbLayer({ // 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); + const record = gateLocalAdsbAltitude(heard, marker.altitude, at); rejectedAltitudes += marker.altitude.rejected - altitudesRejectedBefore; marker.record = record; marker.onGround = Boolean(record.onGround); @@ -865,6 +872,13 @@ export function createLocalAdsbLayer({ altitudeM: Number.isFinite(record.altitudeFt) ? Math.round(record.altitudeFt * 0.3048) : null, + ...(Number.isFinite(record.altitudeFt) + ? { + altitudeQuality: record.altitudeQuality, + altitudeObservedAt: record.altitudeObservedAt, + altitudeSource: record.altitudeSource, + } + : {}), speedKts: num(record.groundSpeedKt), heading: num(record.trackDeg), onGround: record.onGround === true, diff --git a/src/layers/localAdsb/index.test.mjs b/src/layers/localAdsb/index.test.mjs index 15694eff8..4def46f9a 100644 --- a/src/layers/localAdsb/index.test.mjs +++ b/src/layers/localAdsb/index.test.mjs @@ -120,6 +120,13 @@ function record(overrides = {}) { rssiDbfs: null, band: '1090', source: 'webusb', + ...(overrides.source !== 'feed' && overrides.altitudeFt !== null + ? { + altitudeObservedAt: 100_000, + altitudeSequence: 1, + altitudeSessionAt: 100_000, + } + : {}), ...overrides, }; } @@ -402,23 +409,35 @@ test('an implausible jump in a feed record is ignored by the display', async (t) 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); + const { layer, sources, calls } = 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 ')); + const provenance = () => { + const { altitudeQuality, altitudeObservedAt, altitudeSource } = + calls.registered.at(-1).properties; + return { altitudeQuality, altitudeObservedAt, altitudeSource }; + }; receiver.set({ aircraft: [record()] }); await layer.update(); const cruiseM = heightM(); assert.match(altitudeLine(), /^ALT 10,000 FT/); + assert.deepEqual(provenance(), { + altitudeQuality: 'accepted', + altitudeObservedAt: 100_000, + altitudeSource: 'webusb', + }); clock.now = 101_000; receiver.set({ aircraft: [ record({ altitudeFt: 108_800, + altitudeObservedAt: 101_000, + altitudeSequence: 2, lastPositionAt: 101_000, lastMessageAt: 101_000, }), @@ -432,12 +451,40 @@ test('a corrupt altitude reaches neither the marker height nor the card', async ); assert.match(altitudeLine(), /^ALT 10,000 FT/); assert.equal(layer.getStats().rejectedAltitudes, 1, 'counted once'); + assert.deepEqual(provenance(), { + altitudeQuality: 'held-after-rejection', + altitudeObservedAt: 100_000, + altitudeSource: 'webusb', + }); + + // New callsign/velocity messages retain the refused altitude observation. + for (const messageAt of [101_250, 101_500]) { + clock.now = messageAt; + receiver.set({ + aircraft: [ + record({ + altitudeFt: 108_800, + altitudeObservedAt: 101_000, + altitudeSequence: 2, + lastPositionAt: 101_000, + lastMessageAt: messageAt, + messageCount: 13, + }), + ], + }); + await layer.update(); + assert.match(altitudeLine(), /^ALT 10,000 FT/); + assert.ok(Math.abs(heightM() - cruiseM) < 1); + assert.equal(layer.getStats().rejectedAltitudes, 1); + } clock.now = 102_000; receiver.set({ aircraft: [ record({ altitudeFt: 10_100, + altitudeObservedAt: 102_000, + altitudeSequence: 3, lastPositionAt: 102_000, lastMessageAt: 102_000, }), @@ -446,6 +493,156 @@ test('a corrupt altitude reaches neither the marker height nor the card', async await layer.update(); assert.match(altitudeLine(), /^ALT 10,100 FT/); assert.equal(layer.getStats().rejectedAltitudes, 1); + assert.deepEqual(provenance(), { + altitudeQuality: 'accepted', + altitudeObservedAt: 102_000, + altitudeSource: 'webusb', + }); + assert.equal(layer.getAnalystRecords()[0].altitudeQuality, 'accepted'); + assert.equal(layer.getAnalystRecords()[0].altitudeObservedAt, 102_000); +}); + +test('held altitude keeps its accepted feed provenance until a browser correction', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const { layer, calls } = await enabledLayer(receiver, clock); + t.after(() => layer.destroy()); + const provenance = (value) => ({ + altitudeQuality: value.altitudeQuality, + altitudeObservedAt: value.altitudeObservedAt, + altitudeSource: value.altitudeSource, + }); + receiver.set({ aircraft: [record({ source: 'feed' })] }); + await layer.update(); + const estimate = { + altitudeQuality: 'feed-estimate', + altitudeObservedAt: null, + altitudeSource: 'feed', + }; + assert.deepEqual(provenance(calls.registered.at(-1).properties), estimate); + assert.deepEqual(provenance(layer.getAnalystRecords()[0]), estimate); + + clock.now = 101_000; + receiver.set({ + aircraft: [ + record({ + altitudeFt: 108_800, + altitudeObservedAt: 101_000, + altitudeSequence: 2, + lastPositionAt: clock.now, + lastMessageAt: clock.now, + }), + ], + }); + await layer.update(); + const held = { ...estimate, altitudeQuality: 'held-after-rejection' }; + assert.deepEqual(provenance(calls.registered.at(-1).properties), held); + const [row] = layer.getAnalystRecords(); + assert.equal(row.altitudeM, 3_048); + assert.deepEqual(provenance(row), held); + + clock.now = 102_000; + receiver.set({ + aircraft: [ + record({ + altitudeFt: 10_100, + altitudeObservedAt: clock.now, + altitudeSequence: 3, + lastPositionAt: clock.now, + lastMessageAt: clock.now, + }), + ], + }); + await layer.update(); + const accepted = { + altitudeQuality: 'accepted', + altitudeObservedAt: clock.now, + altitudeSource: 'webusb', + }; + assert.deepEqual(provenance(calls.registered.at(-1).properties), accepted); + assert.deepEqual(provenance(layer.getAnalystRecords()[0]), accepted); +}); + +test('missing altitude publishes no quality claim in context or analyst records', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const { layer, calls } = await enabledLayer(receiver, clock); + t.after(() => layer.destroy()); + receiver.set({ aircraft: [record({ altitudeFt: null })] }); + await layer.update(); + const [row] = layer.getAnalystRecords(); + assert.equal(row.altitudeM, null); + for (const value of [row, calls.registered.at(-1).properties]) { + for (const key of [ + 'altitudeQuality', + 'altitudeObservedAt', + 'altitudeSource', + ]) + assert.equal(Object.hasOwn(value, key), false, key); + } +}); + +test('feed recovery uses the layer clock and publishes an inferred altitude', async (t) => { + const receiver = fakeReceiver({ mode: 'adsb', connected: true }); + const clock = { now: 100_000 }; + const feeds = fakeFeeds({ configured: true }); + const { layer, calls, sources } = await enabledLayer(receiver, clock, feeds); + t.after(() => layer.destroy()); + const heard = (altitudeFt, snapshotAt, messageAt, messageCount) => + record({ + source: 'feed', + altitudeFt, + altitudeObservedAt: null, + feedId: 'feed-1', + feedSnapshotAt: snapshotAt, + lastPositionAt: snapshotAt, + lastMessageAt: messageAt, + messageCount, + }); + feeds.set({ records: [heard(108_800, clock.now, 99_000, 12)] }); + await layer.update(); + assert.equal(layer.getAnalystRecords()[0].altitudeQuality, 'feed-estimate'); + + // Source message times advance slowly; the five-second recovery uses the + // injected layer clock, not those message times or real wall-clock time. + for (const [at, messageAt, messageCount] of [ + [101_000, 100_000, 13], + [103_000, 100_500, 14], + ]) { + clock.now = at; + feeds.set({ records: [heard(10_000, at, messageAt, messageCount)] }); + await layer.update(); + assert.equal(layer.getAnalystRecords()[0].altitudeM, 33_162); + assert.equal( + layer.getAnalystRecords()[0].altitudeQuality, + 'held-after-rejection', + ); + } + clock.now = 106_000; + feeds.set({ records: [heard(10_000, clock.now, 101_000, 15)] }); + await layer.update(); + const [row] = layer.getAnalystRecords(); + assert.equal(row.altitudeM, 3_048); + assert.equal(row.altitudeQuality, 'inferred-reanchor'); + assert.equal(row.altitudeObservedAt, null); + assert.equal(row.altitudeSource, 'feed'); + const properties = calls.registered.at(-1).properties; + assert.equal(properties.altitudeQuality, row.altitudeQuality); + assert.equal(properties.altitudeObservedAt, null); + assert.equal(properties.altitudeSource, 'feed'); + assert.equal(layer.getStats().rejectedAltitudes, 1); + + clock.now = 107_000; + await layer.update(); + const entity = sources[0].entities.getById('local-adsb:abc123'); + assert.match( + entity.gevLabelModel.details.find((line) => line.startsWith('ALT ')), + /^ALT 10,000 FT/, + ); + const heightM = Cesium.Cartographic.fromCartesian( + entity.position.getValue(), + ).height; + assert.ok(Math.abs(heightM - 3_048) < 1, `recovered height ${heightM}`); }); test('selecting an aircraft draws a magenta trail of the fixes the receiver heard', async (t) => { diff --git a/src/sdr/adsbDecoder.js b/src/sdr/adsbDecoder.js index f6172d354..9a4701326 100644 --- a/src/sdr/adsbDecoder.js +++ b/src/sdr/adsbDecoder.js @@ -314,6 +314,10 @@ export function decodeAdsbMessage(bytes, { receivedAt = Date.now() } = {}) { return message; } +function finiteTime(value) { + return Number.isFinite(value) ? value : null; +} + function recentReference(track, receivedAt) { if ( !Number.isFinite(track.latitude) || @@ -371,6 +375,16 @@ function surfaceReference(track, message) { * `positionsRejected` on the optional `stats`) and not applied. From the * third consecutive rejection on, a globally decoded fix is accepted as the * new reference, so one bad accepted fix cannot freeze a track. + * + * `lastSeen` advances on every message, so it says nothing about how fresh + * `altitudeFt` is: a velocity or identification frame leaves the altitude + * from the last airborne position. Only a frame that carries a decoded + * altitude (TC 9–18 with Q=1) sets `lastAltitudeAt` to its receive time and + * increments `altitudeSequence`, an equal value included. The sequence tells + * apart frames of one USB block, which share a receive time. A surface frame + * clears the altitude and `lastAltitudeAt` and keeps the sequence, so the + * next airborne altitude still counts as new. `firstSeenAt` is when this + * track started; a pruned and re-heard aircraft starts a new track. * @param {Map} tracks Tracks keyed by uppercase ICAO. * @param {object} message Output of `decodeAdsbMessage`. * @param {{latitude:number, longitude:number}|null} [receiverLocation] @@ -396,7 +410,10 @@ export function updateAircraftTrack( verticalRateFpm: null, messages: 0, lastSeen: 0, + firstSeenAt: finiteTime(message.receivedAt), lastPositionAt: null, + lastAltitudeAt: null, + altitudeSequence: 0, onGround: false, cprEven: null, cprOdd: null, @@ -428,8 +445,15 @@ export function updateAircraftTrack( if (message.onGround) { next.altitudeFt = null; next.verticalRateFpm = null; + next.lastAltitudeAt = null; } } + // An altitude observation, not merely a message. A receive time that is not + // a number is unknown rather than the previous altitude's time. + if (Number.isFinite(message.altitudeFt) && message.onGround !== true) { + next.lastAltitudeAt = finiteTime(message.receivedAt); + next.altitudeSequence = (prior.altitudeSequence || 0) + 1; + } if (message.cpr) { const surface = Boolean(message.cpr.surface); // Airborne and surface frames use different zone sizes: never pair them. diff --git a/src/sdr/adsbDecoder.test.mjs b/src/sdr/adsbDecoder.test.mjs index 3b963f942..23e164a05 100644 --- a/src/sdr/adsbDecoder.test.mjs +++ b/src/sdr/adsbDecoder.test.mjs @@ -622,3 +622,184 @@ test('a far receiver never places a lone surface frame; the next valid pair seed assert.ok(Math.abs(track.longitude - 4.7347) < 0.002, `${track.longitude}`); assert.equal(track.onGround, true); }); + +// Altitude freshness. Frames are real DF17 layouts with recomputed parity: +// the 40621D airborne position pair (38,000 ft) with its 12-bit altitude +// field rewritten, and the KLM1023 identification and 485020 velocity ME +// fields under another address. +const IDENT_ME = '202CC371C32CE0'; +const VELOCITY_ME = '99440994083817'; + +/** A CRC-valid DF17 frame from a 24-bit address and a 56-bit ME, in hex. */ +function frame(icao, me) { + return withParity(`8D${icao}${me}000000`); +} + +/** Airborne position ME carrying `altitudeFt` in 25 ft (Q=1) steps, or Q=0. */ +function airborneMe(altitudeFt, { odd = false, gillham = false } = {}) { + const me = fromHex(odd ? '58C386435CC412' : '58C382D690C8AC'); + const n = (altitudeFt + 1_000) / 25; + const field = ((n >> 4) << 5) | (gillham ? 0 : 0x10) | (n & 0xf); + me[1] = field >> 4; + me[2] = ((field & 0xf) << 4) | (me[2] & 0x0f); + return [...me].map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +function altitudeObservation(track) { + return { + altitudeFt: track.altitudeFt, + lastAltitudeAt: track.lastAltitudeAt, + altitudeSequence: track.altitudeSequence, + }; +} + +function trackHeard(tracks) { + return (bytes, receivedAt) => + updateAircraftTrack(tracks, decodeAdsbMessage(bytes, { receivedAt })); +} + +test('the altitude frame builder reproduces the published 38,000 ft position', () => { + assert.equal( + decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7')).altitudeFt, + 38_000, + ); + assert.deepEqual( + frame('40621D', airborneMe(38_000)), + fromHex('8D40621D58C382D690C8AC2863A7'), + ); + assert.equal( + decodeAdsbMessage(frame('40621D', airborneMe(36_025))).altitudeFt, + 36_025, + ); +}); + +test('only a frame that carries an altitude advances the altitude observation', () => { + const tracks = new Map(); + const heard = trackHeard(tracks); + let track = heard(frame('40621D', IDENT_ME), 1_000); + assert.equal(track.callsign, 'KLM1023'); + assert.equal(track.firstSeenAt, 1_000); + assert.deepEqual(altitudeObservation(track), { + altitudeFt: null, + lastAltitudeAt: null, + altitudeSequence: 0, + }); + heard(frame('40621D', VELOCITY_ME), 1_200); + track = heard(frame('40621D', airborneMe(38_000)), 1_500); + assert.deepEqual(altitudeObservation(track), { + altitudeFt: 38_000, + lastAltitudeAt: 1_500, + altitudeSequence: 1, + }); + const cruising = track; + + // The next position is missed: velocity and identification frames keep the + // aircraft heard, and say nothing about its altitude. + track = heard(frame('40621D', VELOCITY_ME), 1_700); + assert.equal(track.verticalRateFpm, -832); + track = heard(frame('40621D', IDENT_ME), 1_800); + assert.equal(track.lastSeen, 1_800); + assert.deepEqual(altitudeObservation(track), { + altitudeFt: 38_000, + lastAltitudeAt: 1_500, + altitudeSequence: 1, + }); + + // Level flight repeats the altitude: still a new observation. + track = heard(frame('40621D', airborneMe(38_000, { odd: true })), 2_000); + assert.deepEqual(altitudeObservation(track), { + altitudeFt: 38_000, + lastAltitudeAt: 2_000, + altitudeSequence: 2, + }); + assert.equal(track.firstSeenAt, 1_000, 'the track start survives every copy'); + assert.equal(cruising.altitudeSequence, 1, 'an earlier track is not mutated'); +}); + +test('altitude frames of one USB block share a receive time and each count', () => { + // The worker stamps every frame of a block with one Date.now(). + const tracks = new Map(); + const heard = trackHeard(tracks); + heard(frame('40621D', airborneMe(38_000)), 5_000); + heard(frame('40621D', VELOCITY_ME), 5_000); + heard(frame('40621D', airborneMe(38_000, { odd: true })), 5_000); + const track = heard(frame('40621D', airborneMe(38_025)), 5_000); + assert.deepEqual(altitudeObservation(track), { + altitudeFt: 38_025, + lastAltitudeAt: 5_000, + altitudeSequence: 3, + }); + assert.equal(track.messages, 4); +}); + +test('Q=0 and surface frames never advance the altitude; the next airborne one does', () => { + const tracks = new Map(); + const heard = trackHeard(tracks); + heard(frame('C8200A', airborneMe(3_000)), 0); + const gillham = frame('C8200A', airborneMe(3_000, { gillham: true })); + assert.equal(decodeAdsbMessage(gillham).altitudeFt, null, 'Q=0 not decoded'); + let track = heard(gillham, 500); + assert.equal(track.onGround, false); + assert.deepEqual( + altitudeObservation(track), + { altitudeFt: 3_000, lastAltitudeAt: 0, altitudeSequence: 1 }, + 'the altitude shown is still the one heard at 0', + ); + + heard(SURFACE_EVEN, 1_000); + track = heard(SURFACE_ODD, 2_000); + assert.equal(track.onGround, true); + assert.deepEqual(altitudeObservation(track), { + altitudeFt: null, + lastAltitudeAt: null, + altitudeSequence: 1, + }); + + track = heard(frame('C8200A', airborneMe(3_100)), 3_000); + assert.equal(track.onGround, false); + assert.deepEqual(altitudeObservation(track), { + altitudeFt: 3_100, + lastAltitudeAt: 3_000, + altitudeSequence: 2, + }); + assert.equal(track.firstSeenAt, 0); +}); + +test('a pruned aircraft heard again starts a new altitude session', () => { + const tracks = new Map(); + const heard = trackHeard(tracks); + heard(frame('40621D', airborneMe(38_000)), 1_000); + assert.equal(pruneAircraftTracks(tracks, 61_000), 1); + let track = heard(frame('40621D', VELOCITY_ME), 70_000); + assert.equal(track.firstSeenAt, 70_000); + assert.deepEqual(altitudeObservation(track), { + altitudeFt: null, + lastAltitudeAt: null, + altitudeSequence: 0, + }); + track = heard(frame('40621D', airborneMe(38_000)), 70_500); + assert.deepEqual(altitudeObservation(track), { + altitudeFt: 38_000, + lastAltitudeAt: 70_500, + altitudeSequence: 1, + }); +}); + +test('an altitude with an unusable receive time is counted without a time', () => { + const tracks = new Map(); + const heard = trackHeard(tracks); + let track = heard(frame('40621D', airborneMe(38_000)), Number.NaN); + assert.equal(track.firstSeenAt, null); + assert.deepEqual(altitudeObservation(track), { + altitudeFt: 38_000, + lastAltitudeAt: null, + altitudeSequence: 1, + }); + track = heard(frame('40621D', airborneMe(38_025, { odd: true })), 2_000); + assert.deepEqual(altitudeObservation(track), { + altitudeFt: 38_025, + lastAltitudeAt: 2_000, + altitudeSequence: 2, + }); + assert.equal(track.firstSeenAt, null, 'the track start stays unknown'); +}); diff --git a/src/sources/adsbRecords.js b/src/sources/adsbRecords.js index 12e3d3963..b5b321e39 100644 --- a/src/sources/adsbRecords.js +++ b/src/sources/adsbRecords.js @@ -12,6 +12,11 @@ * lat: number|null, // degrees, null until a position decodes * lon: number|null, * altitudeFt: number|null, // barometric altitude + * altitudeObservedAt: number|null, // browser altitude measurement time; unknown for feeds + * altitudeSequence: number|null, // browser altitude-frame counter + * altitudeSessionAt: number|null, // browser track's first message time + * feedId: string|null, // opaque configured receiver id, never its URL + * feedSnapshotAt: number|null, // feed's own clock; never browser-rebased * groundSpeedKt: number|null, * trackDeg: number|null, // true track over ground, [0, 360) * verticalRateFpm: number|null, @@ -25,6 +30,7 @@ * rssiDbfs: number|null, * band: '1090'|'978', // 1090 MHz Mode S/ES or 978 MHz UAT * source: 'webusb'|'feed', // browser SDR or decoder feed + * // The layer's altitude gate adds altitudeQuality and altitudeSource. * } * * This module is portable: no Cesium, DOM, network or storage access. @@ -64,6 +70,12 @@ 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; +/** Minimum coherent feed recovery window; this is an inference, not a measurement. */ +export const LOCAL_ADSB_FEED_RECOVERY_MS = 5_000; +/** Advancing feed snapshots with aircraft activity required for inferred recovery. */ +export const LOCAL_ADSB_FEED_RECOVERY_SNAPSHOTS = 3; +/** Bound an unknown feed measurement age without imposing an altitude ceiling. */ +export const LOCAL_ADSB_FEED_ALTITUDE_GAP_MS = 60_000; const SLOW_CATEGORIES = new Set(['A1', 'A7', 'B1', 'B4']); const KT_TO_MPS = 1852 / 3600; const EARTH_RADIUS_M = 6_371_008.8; @@ -227,60 +239,223 @@ export function localAdsbAltitudeIsPlausible(previous, next) { /** * 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}} + * @returns {{accepted:object|null, sources:Map, + * rejectStreak:number, rejected:number}} */ export function createLocalAdsbAltitudeGate() { - return { accepted: null, judgedAt: null, rejectStreak: 0, rejected: 0 }; + return { + accepted: null, + sources: new Map(), + rejectStreak: 0, + rejected: 0, + }; +} + +function altitudeResult(record, accepted = null) { + const held = accepted && accepted.altitudeFt !== record.altitudeFt; + return { + ...record, + ...(accepted ? { altitudeFt: accepted.altitudeFt } : {}), + altitudeQuality: held + ? 'held-after-rejection' + : (accepted?.quality ?? null), + altitudeObservedAt: accepted?.observedAt ?? null, + altitudeSource: accepted?.source ?? null, + }; +} + +function feedAltitudeIsPlausible(previous, next) { + // A retained value can outlive its original measurement indefinitely while + // positions stay fresh. Do not let that accumulate an unlimited allowance. + const bounded = previous + ? { + ...previous, + at: Math.max(previous.at, next.at - LOCAL_ADSB_FEED_ALTITUDE_GAP_MS), + } + : null; + return localAdsbAltitudeIsPlausible(bounded, next); +} + +function altitudeSourceState(gate, key, at) { + for (const [sourceKey, state] of gate.sources) { + if (at - state.lastSeenAt > LOCAL_ADSB_MESSAGE_STALE_MS) + gate.sources.delete(sourceKey); + } + // Each receiver keeps its own votes and watermarks even when the freshest + // position alternates between inputs. Votes from different receivers never mix. + let state = gate.sources.get(key); + if (!state) { + state = { + value: null, + observedAt: null, + sequence: null, + snapshotAt: null, + messageCount: null, + rejectStreak: 0, + pending: null, + }; + gate.sources.set(key, state); + } + state.lastSeenAt = at; + gate.rejectStreak = state.rejectStreak; + return state; +} + +function acceptAltitude(gate, candidate) { + gate.accepted = candidate; + // Every pending refusal was measured against the previous reference. + for (const state of gate.sources.values()) { + state.pending = null; + state.rejectStreak = 0; + } + gate.rejectStreak = 0; +} + +function judgeBrowserAltitude(record, gate, state, candidate) { + const observedAt = finiteOrNull(record.altitudeObservedAt); + const sequence = Number.isSafeInteger(record.altitudeSequence) + ? record.altitudeSequence + : null; + if (observedAt === null) { + if (!gate.accepted) acceptAltitude(gate, candidate); + return; + } + if (sequence !== null && state.sequence !== null) { + if (sequence === state.sequence) return; + if (sequence < state.sequence) { + if (observedAt <= state.observedAt) return; + state.rejectStreak = 0; + } + } else if (state.observedAt !== null && observedAt <= state.observedAt) + return; + state.observedAt = observedAt; + state.sequence = sequence; + candidate.at = observedAt; + candidate.observedAt = observedAt; + if (localAdsbAltitudeIsPlausible(gate.accepted, candidate)) { + acceptAltitude(gate, candidate); + } else { + gate.rejected += 1; + state.rejectStreak += 1; + // A sequence gap is still only one observed report, not multiple votes. + if (state.rejectStreak >= LOCAL_ADSB_REANCHOR_AFTER) { + acceptAltitude(gate, candidate); + } + } + gate.rejectStreak = state.rejectStreak; +} + +function judgeFeedAltitude(record, gate, state, candidate) { + const snapshotAt = finiteOrNull(record.feedSnapshotAt); + const messageCount = Number.isSafeInteger(record.messageCount) + ? record.messageCount + : null; + const hasProgress = + typeof record.feedId === 'string' && + record.feedId.length > 0 && + snapshotAt !== null && + messageCount !== null; + if ( + hasProgress && + ((state.snapshotAt !== null && snapshotAt < state.snapshotAt) || + (state.messageCount !== null && messageCount < state.messageCount)) + ) { + // A decoder restart or wall-clock adjustment begins a new progress window. + // Its first value is still checked against the accepted altitude. + state.pending = null; + state.value = null; + state.snapshotAt = null; + state.messageCount = null; + } + const progressed = + hasProgress && + (state.snapshotAt === null || + (snapshotAt > state.snapshotAt && messageCount > state.messageCount)); + if (hasProgress) { + state.snapshotAt = Math.max(state.snapshotAt ?? snapshotAt, snapshotAt); + state.messageCount = Math.max( + state.messageCount ?? messageCount, + messageCount, + ); + } else state.pending = null; + + const changed = state.value !== candidate.altitudeFt; + state.value = candidate.altitudeFt; + if (changed) { + if (feedAltitudeIsPlausible(gate.accepted, candidate)) { + acceptAltitude(gate, candidate); + return; + } + gate.rejected += 1; + } + if (!gate.accepted || candidate.altitudeFt === gate.accepted.altitudeFt) + return; + // Feed JSON has no altitude-specific age. Repeated snapshots are not new + // refusals. Activity can only support the explicitly inferred recovery below. + if (!hasProgress) return; + if ( + !state.pending || + (changed && !feedAltitudeIsPlausible(state.pending.last, candidate)) + ) { + state.pending = { startedAt: candidate.at, snapshots: 0, last: candidate }; + } + if (changed) state.pending.last = candidate; + if (progressed) state.pending.snapshots += 1; + if ( + progressed && + state.pending.snapshots >= LOCAL_ADSB_FEED_RECOVERY_SNAPSHOTS && + candidate.at - state.pending.startedAt >= LOCAL_ADSB_FEED_RECOVERY_MS + ) { + acceptAltitude(gate, { ...candidate, quality: 'inferred-reanchor' }); + } } /** - * 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} + * Hold rejected altitudes and retain the accepted value's provenance. + * Browser votes come only from actual altitude frames. Feeds cannot identify + * an altitude measurement: changed values are judged once, and coherent source + * activity can recover after 5 s / 3 advancing snapshots, explicitly inferred. + * A stable corrupt feed can also satisfy that policy. Missing progression + * metadata disables automatic feed recovery; there is no absolute ceiling. + * @param {object} record Local ADS-B record (never mutated). + * @param {ReturnType} gate Caller-owned state. + * @param {number} [nowMs=record.lastMessageAt] Browser clock, injected by the layer. + * @returns {object} Record with altitudeQuality, altitudeObservedAt and altitudeSource. */ -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; - } - } +export function gateLocalAdsbAltitude( + record, + gate, + nowMs = record?.lastMessageAt, +) { + if (record?.onGround) { + gate.accepted = null; + gate.sources.clear(); + gate.rejectStreak = 0; + return altitudeResult(record); } - const held = gate.accepted?.altitudeFt; - return Number.isFinite(held) && held !== altitudeFt - ? { ...record, altitudeFt: held } - : record; + const altitudeFt = finiteOrNull(record?.altitudeFt); + const at = finiteOrNull(nowMs); + if (altitudeFt === null || at === null) { + for (const state of gate.sources.values()) state.pending = null; + return altitudeResult(record); + } + const browser = record.source === 'webusb'; + const source = browser ? 'webusb' : 'feed'; + const key = browser + ? `webusb:${record.altitudeSessionAt ?? 'unknown'}` + : `feed:${record.feedId ?? 'unknown'}:${record.band ?? '1090'}`; + const state = altitudeSourceState(gate, key, at); + const candidate = { + altitudeFt, + at, + verticalRateFpm: finiteOrNull(record.verticalRateFpm), + observedAt: null, + source, + quality: browser ? 'accepted' : 'feed-estimate', + }; + if (browser) judgeBrowserAltitude(record, gate, state, candidate); + else judgeFeedAltitude(record, gate, state, candidate); + return altitudeResult(record, gate.accepted); } function normalizePosition(lat, lon) { @@ -315,6 +490,9 @@ export function recordFromDecoderTrack(track) { lat, lon, altitudeFt: finiteOrNull(track.altitudeFt), + altitudeObservedAt: finiteOrNull(track.lastAltitudeAt), + altitudeSequence: finiteOrNull(track.altitudeSequence), + altitudeSessionAt: finiteOrNull(track.firstSeenAt), groundSpeedKt: finiteOrNull(track.speedKt), trackDeg: normalizeTrack(track.headingDeg), verticalRateFpm: finiteOrNull(track.verticalRateFpm), @@ -340,9 +518,14 @@ export function recordFromDecoderTrack(track) { * @param {number} nowMs Local epoch ms at which the document was received. * @param {object} [options] * @param {'1090'|'978'} [options.band='1090'] Band the feed decodes. + * @param {string|null} [options.feedId=null] Opaque configured receiver identity. * @returns {object[]} Normalized records. */ -export function normalizeDump1090Aircraft(json, nowMs, { band = '1090' } = {}) { +export function normalizeDump1090Aircraft( + json, + nowMs, + { band = '1090', feedId = null } = {}, +) { const recordBand = band === '978' ? '978' : '1090'; const receivedAt = finiteOrNull(nowMs); if (receivedAt === null || !Array.isArray(json?.aircraft)) return []; @@ -364,6 +547,10 @@ export function normalizeDump1090Aircraft(json, nowMs, { band = '1090' } = {}) { lat: hasPosition ? position.lat : null, lon: hasPosition ? position.lon : null, altitudeFt: altitude, + altitudeObservedAt: null, + feedId: typeof feedId === 'string' ? feedId : null, + feedSnapshotAt: + finiteOrNull(json.now) === null ? null : finiteOrNull(json.now * 1000), groundSpeedKt: finiteOrNull(entry.gs), trackDeg: normalizeTrack(entry.track), verticalRateFpm: finiteOrNull(entry.baro_rate), diff --git a/src/sources/adsbRecords.test.mjs b/src/sources/adsbRecords.test.mjs index 8106eb5cd..9c3c8a309 100644 --- a/src/sources/adsbRecords.test.mjs +++ b/src/sources/adsbRecords.test.mjs @@ -1,6 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +import { + decodeAdsbMessage, + modeSChecksum, + updateAircraftTrack, +} from '../sdr/adsbDecoder.js'; import { LOCAL_ADSB_MESSAGE_STALE_MS, @@ -43,6 +48,9 @@ test('dump1090 aircraft.json maps to the shared local ADS-B records', () => { lat: 30.269662, lon: -97.793262, altitudeFt: 1600, + altitudeObservedAt: null, + feedId: null, + feedSnapshotAt: dump1090.now * 1000, groundSpeedKt: 129.9, trackDeg: 330.5, verticalRateFpm: 64, @@ -117,6 +125,9 @@ test('decoder tracks map to the same record shape', () => { verticalRateFpm: 64, messages: 48, lastSeen: 5_000, + firstSeenAt: 1_000, + lastAltitudeAt: 3_000, + altitudeSequence: 2, lastPositionAt: 4_000, cprEven: {}, }); @@ -128,6 +139,9 @@ test('decoder tracks map to the same record shape', () => { lat: 30.27, lon: -97.79, altitudeFt: 1600, + altitudeObservedAt: 3_000, + altitudeSequence: 2, + altitudeSessionAt: 1_000, groundSpeedKt: 129.9, trackDeg: 330.5, verticalRateFpm: 64, @@ -364,7 +378,7 @@ test('the altitude check allows the vertical rate × 1.5 + margin over elapsed + ); }); -/** Feed one aircraft's reports, `[seconds, altitudeFt, extra]`, through a gate. */ +/** Feed actual browser altitude reports, `[seconds, altitudeFt, extra]`. */ function gateReports(reports) { const gate = createLocalAdsbAltitudeGate(); const altitudes = reports.map( @@ -373,6 +387,10 @@ function gateReports(reports) { { icao: 'abc123', lastMessageAt: seconds * 1_000, + source: 'webusb', + altitudeObservedAt: seconds * 1_000, + altitudeSequence: seconds * 1_000 + 1, + altitudeSessionAt: 0, altitudeFt, ...extra, }, @@ -429,3 +447,428 @@ test('a surface report is not compared with the last airborne altitude', () => { { altitudes: [5_600, 0, 5_700, 5_720], rejected: 0 }, ); }); + +function browserAltitude(altitudeFt, at, sequence, extra = {}) { + return { + icao: 'abc123', + source: 'webusb', + altitudeFt, + altitudeObservedAt: at, + altitudeSequence: sequence, + altitudeSessionAt: 0, + lastMessageAt: at, + ...extra, + }; +} + +function feedAltitude(altitudeFt, at, messages, extra = {}) { + return { + icao: 'abc123', + source: 'feed', + band: '1090', + feedId: 'feed-1', + feedSnapshotAt: at, + messageCount: messages, + altitudeFt, + altitudeObservedAt: null, + lastMessageAt: at, + ...extra, + }; +} + +test('non-altitude messages never recount a refusal or refresh its reference', () => { + const gate = createLocalAdsbAltitudeGate(); + const first = browserAltitude(35_000, 0, 1); + const bad = browserAltitude(108_800, 1_000, 2); + gateLocalAdsbAltitude(first, gate, 0); + let result = gateLocalAdsbAltitude(bad, gate, 1_000); + for (const at of [2_000, 3_000, 9_000]) { + result = gateLocalAdsbAltitude({ ...bad, lastMessageAt: at }, gate, at); + assert.equal(result.altitudeFt, 35_000); + assert.equal(result.altitudeObservedAt, 0); + assert.equal(result.altitudeQuality, 'held-after-rejection'); + } + assert.equal(gate.rejected, 1); + // The true report is compared with the real 0 ms observation, not the + // most recent identification/velocity message at 9 seconds. + result = gateLocalAdsbAltitude( + browserAltitude(37_000, 10_000, 3), + gate, + 10_000, + ); + assert.equal(result.altitudeFt, 37_000); + assert.equal(result.altitudeObservedAt, 10_000); + assert.equal(result.altitudeQuality, 'accepted'); + assert.equal(gate.rejected, 1); + assert.equal(bad.altitudeFt, 108_800, 'input records are not rewritten'); +}); + +test('same-valued real frames refresh time; same-ms frames each get one vote', () => { + const gate = createLocalAdsbAltitudeGate(); + gateLocalAdsbAltitude(browserAltitude(35_000, 0, 1), gate, 0); + let result = gateLocalAdsbAltitude( + browserAltitude(35_000, 10_000, 2), + gate, + 10_000, + ); + assert.equal(result.altitudeObservedAt, 10_000); + result = gateLocalAdsbAltitude( + browserAltitude(37_000, 11_000, 3), + gate, + 11_000, + ); + assert.equal(result.altitudeFt, 35_000, 'recent level report limits the gap'); + for (const sequence of [4, 5]) + result = gateLocalAdsbAltitude( + browserAltitude(37_000, 11_000, sequence), + gate, + 11_000, + ); + assert.equal(result.altitudeFt, 37_000); + assert.equal( + gate.rejected, + 3, + 'different frames in one USB block count individually', + ); +}); + +test('a browser sequence gap is one observation, and old/repeated tokens do not vote', () => { + const gate = createLocalAdsbAltitudeGate(); + gateLocalAdsbAltitude(browserAltitude(35_000, 0, 1), gate, 0); + const bad = browserAltitude(108_800, 1_000, 15); + for (let n = 0; n < 4; n++) gateLocalAdsbAltitude(bad, gate, 1_000 + n); + gateLocalAdsbAltitude(browserAltitude(108_800, 500, 3), gate, 2_000); + assert.equal(gate.rejected, 1); + assert.equal(gate.accepted.altitudeFt, 35_000); +}); + +test('sources and a new browser track cannot combine recovery streaks', () => { + const gate = createLocalAdsbAltitudeGate(); + gateLocalAdsbAltitude(browserAltitude(35_000, 0, 1), gate, 0); + gateLocalAdsbAltitude(browserAltitude(108_800, 1_000, 2), gate, 1_000); + gateLocalAdsbAltitude(feedAltitude(108_800, 2_000, 2), gate, 2_000); + let result = gateLocalAdsbAltitude( + browserAltitude(108_800, 3_000, 3), + gate, + 3_000, + ); + assert.equal( + result.altitudeFt, + 35_000, + 'two browser votes plus a feed refusal are not three browser votes', + ); + result = gateLocalAdsbAltitude( + browserAltitude(108_800, 4_000, 1, { altitudeSessionAt: 4_000 }), + gate, + 4_000, + ); + assert.equal(result.altitudeFt, 35_000, 'new track starts its own streak'); +}); + +test("alternating receivers recover using each receiver's own observations", () => { + for (const secondSource of ['browser', 'feed']) { + const gate = createLocalAdsbAltitudeGate(); + gateLocalAdsbAltitude(feedAltitude(108_800, 0, 1), gate, 0); + let result; + for (let at = 1_000; at <= 7_000; at += 1_000) { + const alternate = at % 2_000 === 0; + const record = + alternate && secondSource === 'browser' + ? browserAltitude(35_000, at, at / 1_000) + : feedAltitude( + 35_000, + at, + at / 1_000 + 1, + alternate ? { feedId: 'feed-2' } : {}, + ); + result = gateLocalAdsbAltitude(record, gate, at); + } + assert.equal(result.altitudeFt, 35_000, secondSource); + } +}); + +test("a newly accepted reference clears every receiver's pending recovery", () => { + const gate = createLocalAdsbAltitudeGate(); + gateLocalAdsbAltitude(feedAltitude(108_800, 0, 1), gate, 0); + gateLocalAdsbAltitude(feedAltitude(35_000, 1_000, 2), gate, 1_000); + gateLocalAdsbAltitude(feedAltitude(35_000, 3_000, 3), gate, 3_000); + for (const at of [4_000, 4_500, 5_000]) + gateLocalAdsbAltitude(browserAltitude(80_000, at, at), gate, at); + const result = gateLocalAdsbAltitude( + feedAltitude(35_000, 6_000, 4), + gate, + 6_000, + ); + assert.equal( + result.altitudeFt, + 80_000, + 'old votes cannot re-anchor against the new reference', + ); +}); + +test('a feed wall-clock rollback judges values and starts a fresh recovery window', () => { + const gate = createLocalAdsbAltitudeGate(); + gateLocalAdsbAltitude(feedAltitude(35_000, 60_000, 100), gate, 60_000); + const descent = gateLocalAdsbAltitude( + feedAltitude(33_750, 40_000, 101), + gate, + 70_000, + ); + assert.equal( + descent.altitudeFt, + 33_750, + 'a clock step cannot freeze a plausible descent', + ); + assert.equal(descent.altitudeQuality, 'feed-estimate'); + for (const [raw, at, count] of [ + [41_000, 71_000, 102], + [43_000, 73_000, 103], + ]) + gateLocalAdsbAltitude(feedAltitude(108_800, raw, count), gate, at); + let result = gateLocalAdsbAltitude( + feedAltitude(108_800, 20_000, 104), + gate, + 76_000, + ); + assert.equal( + result.altitudeFt, + 33_750, + 'rollback does not reuse the old recovery window', + ); + result = gateLocalAdsbAltitude( + feedAltitude(108_800, 22_000, 105), + gate, + 78_000, + ); + assert.equal(result.altitudeFt, 33_750); + result = gateLocalAdsbAltitude( + feedAltitude(108_800, 25_000, 106), + gate, + 81_000, + ); + assert.equal(result.altitudeQuality, 'inferred-reanchor'); +}); + +test('held values keep the accepted measurement time and source; ground/missing clears claims', () => { + const gate = createLocalAdsbAltitudeGate(); + gateLocalAdsbAltitude(browserAltitude(35_000, 100, 1), gate, 100); + const held = gateLocalAdsbAltitude( + feedAltitude(108_800, 1_000, 1), + gate, + 1_000, + ); + assert.equal(held.source, 'feed', 'position source remains the feed'); + assert.equal(held.altitudeSource, 'webusb'); + assert.equal(held.altitudeObservedAt, 100); + assert.equal(held.altitudeQuality, 'held-after-rejection'); + for (const extra of [ + { altitudeFt: null }, + { altitudeFt: 0, onGround: true }, + ]) { + const result = gateLocalAdsbAltitude( + feedAltitude(108_800, 2_000, 2, extra), + gate, + 2_000, + ); + assert.equal(result.altitudeQuality, null); + assert.equal(result.altitudeSource, null); + assert.equal(result.altitudeObservedAt, null); + } + assert.equal(gate.accepted, null, 'ground resets the airborne reference'); + const takeoff = gateLocalAdsbAltitude( + browserAltitude(5_700, 3_000, 2), + gate, + 3_000, + ); + assert.equal(takeoff.altitudeFt, 5_700); +}); + +test('feed recovery needs five seconds, three advancing snapshots and aircraft activity', () => { + const gate = createLocalAdsbAltitudeGate(); + let result = gateLocalAdsbAltitude(feedAltitude(108_800, 0, 1), gate, 0); + assert.equal(result.altitudeQuality, 'feed-estimate'); + for (const [at, count] of [ + [1_000, 2], + [2_000, 3], + [3_000, 4], + [5_999, 5], + ]) { + result = gateLocalAdsbAltitude(feedAltitude(35_000, at, count), gate, at); + assert.equal( + result.altitudeFt, + 108_800, + 'minimum recovery duration has not elapsed', + ); + } + result = gateLocalAdsbAltitude(feedAltitude(35_000, 6_000, 6), gate, 6_000); + assert.equal(result.altitudeFt, 35_000); + assert.equal(result.altitudeQuality, 'inferred-reanchor'); + assert.equal( + result.altitudeObservedAt, + null, + 'feed has no measurement timestamp', + ); + assert.equal(result.altitudeSource, 'feed'); + assert.equal(gate.rejected, 1, 'retained altitude was refused once'); + result = gateLocalAdsbAltitude(feedAltitude(35_025, 7_000, 7), gate, 7_000); + assert.equal( + result.altitudeQuality, + 'feed-estimate', + 'a changed plausible value is an estimate', + ); + + const sparse = createLocalAdsbAltitudeGate(); + gateLocalAdsbAltitude(feedAltitude(108_800, 0, 1), sparse, 0); + gateLocalAdsbAltitude(feedAltitude(35_000, 1_000, 2), sparse, 1_000); + result = gateLocalAdsbAltitude(feedAltitude(35_000, 7_000, 3), sparse, 7_000); + assert.equal( + result.altitudeFt, + 108_800, + 'two snapshots do not suffice even after five seconds', + ); + result = gateLocalAdsbAltitude(feedAltitude(35_000, 8_000, 4), sparse, 8_000); + assert.equal(result.altitudeFt, 35_000); +}); + +test('cached feed snapshots, message-free snapshots and missing metadata cannot infer recovery', () => { + for (const mode of ['cached', 'no-messages', 'no-snapshot', 'no-id']) { + const gate = createLocalAdsbAltitudeGate(); + gateLocalAdsbAltitude(feedAltitude(35_000, 0, 1), gate, 0); + for (const at of [1_000, 2_000, 4_000, 7_000, 20_000]) { + const extra = + mode === 'no-snapshot' + ? { feedSnapshotAt: null } + : mode === 'no-id' + ? { feedId: null } + : {}; + const record = feedAltitude( + 108_800, + mode === 'cached' ? 1_000 : at, + mode === 'cached' || mode === 'no-messages' ? 2 : at, + extra, + ); + const result = gateLocalAdsbAltitude( + { ...record, lastMessageAt: at }, + gate, + at, + ); + assert.equal(result.altitudeFt, 35_000, mode); + } + assert.equal(gate.rejected, 1, mode); + } +}); + +test('incoherent feed candidates, counter reset and clock regression restart recovery', () => { + for (const mode of ['incoherent', 'counter-reset', 'clock-regression']) { + const gate = createLocalAdsbAltitudeGate(); + gateLocalAdsbAltitude(feedAltitude(35_000, 0, 1), gate, 0); + gateLocalAdsbAltitude(feedAltitude(108_800, 1_000, 2), gate, 1_000); + gateLocalAdsbAltitude(feedAltitude(108_800, 3_000, 3), gate, 3_000); + const broken = + mode === 'incoherent' + ? feedAltitude(80_000, 6_000, 4) + : mode === 'counter-reset' + ? feedAltitude(108_800, 6_000, 1) + : feedAltitude(108_800, 2_000, 4); + let result = gateLocalAdsbAltitude(broken, gate, 6_000); + assert.equal(result.altitudeFt, 35_000, mode); + result = gateLocalAdsbAltitude( + feedAltitude(broken.altitudeFt, 7_000, 5), + gate, + 7_000, + ); + assert.equal( + result.altitudeFt, + 35_000, + `${mode}: no old recovery duration/votes`, + ); + } +}); + +test('feed altitude changes keep a real gap allowance but retained values cannot age it without bound', () => { + const gate = createLocalAdsbAltitudeGate(); + gateLocalAdsbAltitude(feedAltitude(30_000, 0, 1), gate, 0); + for (const at of [1_000, 5_000, 9_000]) + gateLocalAdsbAltitude(feedAltitude(30_000, at, at), gate, at); + const climb = gateLocalAdsbAltitude( + feedAltitude(32_000, 10_000, 20), + gate, + 10_000, + ); + assert.equal( + climb.altitudeFt, + 32_000, + 'non-altitude activity did not shrink the altitude gap', + ); + for (let at = 20_000; at <= 1_800_000; at += 20_000) + gateLocalAdsbAltitude(feedAltitude(32_000, at, at), gate, at); + const spike = gateLocalAdsbAltitude( + feedAltitude(108_800, 1_801_000, 1_801_000), + gate, + 1_801_000, + ); + assert.equal( + spike.altitudeFt, + 32_000, + 'fresh positions cannot grow an unlimited altitude allowance', + ); + assert.equal(spike.altitudeQuality, 'held-after-rejection'); + const balloon = gateLocalAdsbAltitude( + feedAltitude(120_000, 0, 1), + createLocalAdsbAltitudeGate(), + 0, + ); + assert.equal( + balloon.altitudeFt, + 120_000, + 'this is a relative filter with no absolute ceiling', + ); +}); + +test('CRC-valid decoder frames retain one refused altitude through interleaved message types', () => { + const tracks = new Map(); + const gate = createLocalAdsbAltitudeGate(); + const hex = (s) => Uint8Array.from(s.match(/../g), (p) => parseInt(p, 16)); + const parity = (bytes) => { + bytes.fill(0, 11); + const crc = modeSChecksum(bytes.slice(0, 11)); + bytes[11] = crc >>> 16; + bytes[12] = (crc >>> 8) & 0xff; + bytes[13] = crc & 0xff; + assert.equal(modeSChecksum(bytes), 0); + return bytes; + }; + const position = hex('8D40621D58C382D690C8AC2863A7'); + const low = position.slice(); + const n = (2_000 + 1_000) / 25; + const field = ((n >> 4) << 5) | 0x10 | (n & 0xf); + low[5] = field >> 4; + low[6] = ((field & 0xf) << 4) | (low[6] & 0xf); + const id = parity(hex('8D40621D202CC371C32CE0000000')); + const velocity = parity(hex('8D40621D99440994083817000000')); + const apply = (bytes, at) => + gateLocalAdsbAltitude( + recordFromDecoderTrack( + updateAircraftTrack( + tracks, + decodeAdsbMessage(bytes, { receivedAt: at }), + ), + ), + gate, + at, + ); + apply(position, 0); + let result = apply(parity(low), 1_000); + for (const [bytes, at] of [ + [id, 2_000], + [velocity, 3_000], + [id, 4_000], + ]) { + result = apply(bytes, at); + assert.equal(result.altitudeFt, 38_000); + assert.equal(result.altitudeObservedAt, 0); + } + assert.equal(gate.rejected, 1); + assert.equal(result.altitudeQuality, 'held-after-rejection'); + assert.equal(apply(position, 5_000).altitudeQuality, 'accepted'); +}); diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 550d725e4..bcc90df67 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -38,7 +38,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // requested radius and center authority in descriptions only. // Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts. // Air quality (ECCC AQHI) extends the manifest. - '775ba323384b4c1b62fb8415bac52aecc3fc668e20bedcfa57e426dcd1da3969', + // Local ADS-B adds altitude quality, observation time and source field hints. + '13595e14cd3837b48ff5a3aa52b287cf98dcf8a47e2aac7ccf9d57bd4d48e51c', ); }); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index bf3e5b8b8..9cf8438c0 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -4443,6 +4443,34 @@ function unwrapProperties(value) { return out; } +const LOCAL_ADSB_ALTITUDE_METADATA_KEYS = new Set([ + 'altitudeQuality', + 'altitudeObservedAt', + 'altitudeSource', +]); +const LOCAL_ADSB_ALTITUDE_QUALITIES = new Set([ + 'accepted', + 'held-after-rejection', + 'feed-estimate', + 'inferred-reanchor', +]); + +/** Keep the value's provenance, including an explicitly unknown measurement time. */ +function localAdsbAltitudeMetadata(record) { + if ( + !LOCAL_ADSB_ALTITUDE_QUALITIES.has(record.altitudeQuality) || + (record.altitudeSource !== 'webusb' && record.altitudeSource !== 'feed') || + (record.altitudeObservedAt !== null && + !Number.isFinite(record.altitudeObservedAt)) + ) + return {}; + return { + altitudeQuality: record.altitudeQuality, + altitudeObservedAt: record.altitudeObservedAt, + altitudeSource: record.altitudeSource, + }; +} + function compactProperties(props) { const preferredKeys = [ 'name', @@ -4461,14 +4489,22 @@ function compactProperties(props) { ...props, ...(props.tags && typeof props.tags === 'object' ? props.tags : {}), }; - const result = {}; + const result = Number.isFinite(flat.altitudeFt) + ? localAdsbAltitudeMetadata(flat) + : {}; for (const key of preferredKeys) { + if (Object.keys(result).length >= 12) break; const value = cleanText(flat[key]); if (value) result[key] = value; } for (const [key, value] of Object.entries(flat)) { if (Object.keys(result).length >= 12) break; - if (key === 'tags' || result[key] !== undefined) continue; + if ( + key === 'tags' || + LOCAL_ADSB_ALTITUDE_METADATA_KEYS.has(key) || + result[key] !== undefined + ) + continue; const text = cleanText(value); if (text) result[key] = text; } @@ -4656,8 +4692,15 @@ function compactAnalystItem(r) { compact.lon = Math.round(r.lon * 1e4) / 1e4; } if (present(r.distanceKm)) compact.distanceKm = r.distanceKm; + if (r.layerKey === 'local-adsb' && Number.isFinite(r.altitudeM)) + Object.assign(compact, localAdsbAltitudeMetadata(r)); const fields = voiceLayer(r.layerKey)?.query?.fields || {}; for (const [key, spec] of Object.entries(fields)) { + if ( + r.layerKey === 'local-adsb' && + LOCAL_ADSB_ALTITUDE_METADATA_KEYS.has(key) + ) + continue; if (!present(r[key]) || key in compact) continue; if (spec.type === 'time') { const ms = Number(r[key]); diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 4a3a8ec72..3eb384f07 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3931,3 +3931,227 @@ test('analyst-only lookup distinguishes duplicate names and uses canonical aircr assert.equal(plane.ok, true); assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates'); }); + +test('altitude-focused analyst queries retain local ADS-B quality and nullable measurement time', async (t) => { + const previousWindow = globalThis.window; + globalThis.window = { clearTimeout, setTimeout, requestIdleCallback: null }; + t.after(() => { + globalThis.window = previousWindow; + }); + const provenance = [ + { + altitudeQuality: 'accepted', + altitudeObservedAt: 100_000, + altitudeSource: 'webusb', + }, + { + altitudeQuality: 'held-after-rejection', + altitudeObservedAt: 99_000, + altitudeSource: 'webusb', + }, + { + altitudeQuality: 'feed-estimate', + altitudeObservedAt: null, + altitudeSource: 'feed', + }, + { + altitudeQuality: 'held-after-rejection', + altitudeObservedAt: null, + altitudeSource: 'feed', + }, + { + altitudeQuality: 'inferred-reanchor', + altitudeObservedAt: null, + altitudeSource: 'feed', + }, + ]; + const rows = provenance.map((metadata, index) => ({ + id: `LOCAL${index}`, + icao24: `abc12${index}`, + lat: 30, + lon: -97, + altitudeM: 3_048 + index, + ...metadata, + altitudeSequence: index, + })); + const module = { + getStats: () => ({ count: rows.length, lastUpdate: Date.now() }), + getAnalystRecords: () => rows, + }; + const { runner } = manifestRunner({ 'local-adsb': module }); + const result = await runner('analyst_query', { + layers: ['local-adsb'], + scope: { kind: 'anywhere' }, + filters: [{ field: 'altitudeM', op: 'gte', value: 3_000 }], + sortBy: 'altitudeM', + sortDir: 'asc', + limit: 5, + }); + assert.equal(result.ok, true); + assert.equal(result.count, rows.length); + assert.deepEqual( + result.items.map((item) => item.id), + rows.map((row) => row.id), + ); + result.items.forEach((item, index) => { + const { altitudeQuality, altitudeObservedAt, altitudeSource } = item; + assert.deepEqual( + { altitudeQuality, altitudeObservedAt, altitudeSource }, + provenance[index], + ); + assert.equal( + Object.hasOwn(item, 'altitudeObservedAt'), + true, + 'unknown measurement time remains explicit', + ); + assert.equal( + Object.hasOwn(item, 'time'), + false, + 'measurement time keeps its own name and epoch-ms unit', + ); + assert.equal( + Object.hasOwn(item, 'altitudeSequence'), + false, + 'internal observation tokens stay out of the tool payload', + ); + }); + const heldOnly = await runner('analyst_query', { + layers: ['local-adsb'], + scope: { kind: 'anywhere' }, + filters: [ + { field: 'altitudeQuality', op: 'eq', value: 'held-after-rejection' }, + ], + }); + assert.equal(heldOnly.count, 2); + assert.ok( + heldOnly.items.every( + (item) => item.altitudeQuality === 'held-after-rejection', + ), + ); + const beforeFirstMeasurement = await runner('analyst_query', { + layers: ['local-adsb'], + scope: { kind: 'anywhere' }, + filters: [{ field: 'altitudeObservedAt', op: 'lte', value: 1 }], + }); + assert.equal( + beforeFirstMeasurement.count, + 0, + 'unknown feed measurement times do not become epoch zero', + ); + + rows.splice(0, rows.length, { + id: 'MISSING', + lat: 30, + lon: -97, + altitudeM: null, + ...provenance[0], + }); + const missing = await runner('analyst_query', { + layers: ['local-adsb'], + scope: { kind: 'anywhere' }, + }); + assert.equal(missing.items[0].altitudeM, undefined); + for (const key of ['altitudeQuality', 'altitudeObservedAt', 'altitudeSource']) + assert.equal( + Object.hasOwn(missing.items[0], key), + false, + `${key} needs a finite altitude`, + ); +}); + +test('selected local ADS-B context preserves altitude provenance within its 12-property budget', async (t) => { + const previousWindow = globalThis.window; + globalThis.window = { clearTimeout, setTimeout, requestIdleCallback: null }; + t.after(() => { + globalThis.window = previousWindow; + }); + const { viewer, styleManager } = createVoiceNavigationHarness(); + // A global view needs no geocoding or nearby-place requests. + viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 40_000_000); + viewer.camera.positionCartographic = Cesium.Cartographic.fromCartesian( + viewer.camera.positionWC, + ); + viewer.camera.pickEllipsoid = () => undefined; + const dataManager = { + layers: new Map([['local-adsb', { module: {} }]]), + isEnabled: (id) => id === 'local-adsb', + getAll: () => [{ id: 'local-adsb', enabled: true, stats: { count: 1 } }], + }; + const runner = createGevActionRunner({ viewer, styleManager, dataManager }); + const store = getContextStore(); + const provenance = [ + { + altitudeQuality: 'accepted', + altitudeObservedAt: 100_000, + altitudeSource: 'webusb', + }, + { + altitudeQuality: 'held-after-rejection', + altitudeObservedAt: 99_000, + altitudeSource: 'webusb', + }, + { + altitudeQuality: 'feed-estimate', + altitudeObservedAt: null, + altitudeSource: 'feed', + }, + { + altitudeQuality: 'held-after-rejection', + altitudeObservedAt: null, + altitudeSource: 'feed', + }, + { + altitudeQuality: 'inferred-reanchor', + altitudeObservedAt: null, + altitudeSource: 'feed', + }, + ]; + for (const metadata of provenance) { + const record = registerEntityContext( + {}, + { + id: 'local-adsb:abc123', + layerId: 'local-adsb', + label: 'LOCAL1', + latitude: 30, + longitude: -97, + properties: { + icao: 'abc123', + callsign: 'LOCAL1', + category: 'A3', + aircraftClass: 'airliner', + typeCode: 'DC9', + altitudeFt: 10_000, + ...metadata, + groundSpeedKt: 180, + trackDeg: 0, + verticalRateFpm: 0, + lastPositionAt: 101_000, + messageCount: 13, + receiverSource: 'webusb', + bands: ['1090'], + sources: ['webusb', 'feed'], + }, + }, + ); + store.selectedEntityId = record.id; + const result = await runner('get_entity_context', { scope: 'selected' }); + assert.equal(result.ok, true); + const properties = result.selected.properties; + const { altitudeQuality, altitudeObservedAt, altitudeSource } = properties; + assert.deepEqual( + { altitudeQuality, altitudeObservedAt, altitudeSource }, + metadata, + ); + assert.equal(properties.altitudeFt, '10000'); + assert.ok(Object.keys(properties).length <= 12); + } + store.entities.get(store.selectedEntityId).properties.altitudeFt = null; + const missing = await runner('get_entity_context', { scope: 'selected' }); + for (const key of ['altitudeQuality', 'altitudeObservedAt', 'altitudeSource']) + assert.equal( + Object.hasOwn(missing.selected.properties, key), + false, + `${key} needs a finite altitude`, + ); +}); diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index eb0f09909..998a9f35d 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -91,6 +91,10 @@ const layers = [ query: { fields: { altitudeM: ['n', 'm'], + // Quality describes the altitude value; feeds have no measurement time. + altitudeQuality: ['t'], + altitudeObservedAt: ['ms'], + altitudeSource: ['t'], speedKts: ['n', 'kn'], heading: ['n', 'deg'], callsign: ['t'], diff --git a/src/voice/layerManifest.test.mjs b/src/voice/layerManifest.test.mjs index d0222b934..c6f72b77a 100644 --- a/src/voice/layerManifest.test.mjs +++ b/src/voice/layerManifest.test.mjs @@ -112,3 +112,14 @@ test('spoken aliases resolve to real layers, keep every legacy phrase and never claimed.set(alias, entry.id); } }); + +test('local ADS-B analyst fields distinguish altitude quality, time and source', () => { + const fields = VOICE_LAYER_MANIFEST.find((entry) => entry.id === 'local-adsb') + .query.fields; + assert.deepEqual(fields.altitudeQuality, { type: 'text' }); + assert.deepEqual(fields.altitudeObservedAt, { type: 'time' }); + assert.deepEqual(fields.altitudeSource, { type: 'text' }); + assert.ok(ANALYST_LAYERS['local-adsb'].text.includes('altitudeQuality')); + assert.ok(ANALYST_LAYERS['local-adsb'].text.includes('altitudeSource')); + assert.ok(ANALYST_LAYERS['local-adsb'].time.includes('altitudeObservedAt')); +});