Compare commits

...
Author SHA1 Message Date
Mazeyar Moeini Feizabadi 35aef1cdea docs: assign altitude filter release note to PR #1290 2026-10-20 17:14:32 +02:00
Mazeyar Moeini Feizabadi 2ab7c0d8e6 fix(local-adsb): track altitude freshness and held-value provenance 2026-10-20 17:12:24 +02:00
Mazeyar Moeini Feizabadi 68d87aa107 Merge PR #801 altitude plausibility filter from Lob26
Preserve Pedro Lobato’s original commits. Move his credited release note into a changelog fragment when resolving the current-main conflict.
2026-10-20 16:48:28 +02:00
Pedro Lobato 2be50ae41c docs(local-adsb): credit the altitude filter to its PR in CHANGELOG 2026-09-28 10:57:32 -05:00
Pedro Lobato 96d8000620 fix(local-adsb): hold an altitude no aircraft could have reached
A corrupt frame can still pass CRC, and one reached the globe as a DC-9 at
108,800 ft (#714). The browser decoder cannot produce that value: it decodes
only 25 ft (Q=1) altitudes, which stop at 50,175 ft. It came from a 1090
decoder feed, so the check sits in the layer's per-aircraft ingest, where the
browser SDR and the feeds already meet.

Each altitude must be reachable from the last accepted one at 1.5 x the
larger reported vertical rate + 2,000 ft/min (12,500 ft/min with none) over
the elapsed time + 1 s, plus 300 ft, which is the position speed check's
shape. A refused report holds the last accepted altitude on the record
itself, so the marker height, the card, the model ceiling and route
plausibility read the same value. Only a newer message is judged, because the
layer re-reads one record several times a second and a re-read must not count
toward the three-refusal re-anchor or let the refused value through. Surface
reports pass unjudged, since a feed's "ground" is 0 and not a barometric
reading. Refusals are counted as rejectedAltitudes in the layer stats.
2026-09-28 10:56:54 -05:00
18 changed files with 1810 additions and 12 deletions

No files matched your search

@@ -0,0 +1,10 @@
- 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).
+45 -1
View File
@@ -5689,6 +5689,46 @@ on purpose:
Refused fixes are counted (`positionsRejected` in the SDR state; the layer Refused fixes are counted (`positionsRejected` in the SDR state; the layer
applies the same check to merged feed records and reports the total as applies the same check to merged feed records and reports the total as
`rejectedPositions` in its stats). Three refusals in a row re-anchor. `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. 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 - **Trail.** A selected aircraft draws a magenta trail of the positions the
receiver heard (up to 10 minutes / 600 fixes, dropped with the aircraft), 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 with the tracked-flight trail look and a live head segment. No network
@@ -5707,10 +5747,12 @@ aircraft join detection boxes, labelled only with a decoded callsign.
Receivers publish one record shape from `src/sources/adsbRecords.js` Receivers publish one record shape from `src/sources/adsbRecords.js`
(`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`, (`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`,
`altitudeObservedAt`, browser `altitudeSequence`/`altitudeSessionAt`, feed
`feedId`/`feedSnapshotAt`,
`groundSpeedKt`, `trackDeg`, `verticalRateFpm`, `lastPositionAt`, `groundSpeedKt`, `trackDeg`, `verticalRateFpm`, `lastPositionAt`,
`lastMessageAt`, `messageCount`, `rssiDbfs`, `band`, `source`). `band` is `1090` or `978`; `source` is `lastMessageAt`, `messageCount`, `rssiDbfs`, `band`, `source`). `band` is `1090` or `978`; `source` is
`webusb` (browser SDR) or `feed` (decoder feed). `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 skyaware978 `aircraft.json` document into the same records. Voice
`set_layer_visibility` accepts `local-adsb` ("local ADS-B", "my receiver", `set_layer_visibility` accepts `local-adsb` ("local ADS-B", "my receiver",
"my antenna"). "my antenna").
@@ -5735,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 `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 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. 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 `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 (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 · receiver line names them ("Heard by your receiver · 1090 MHz + 978 MHz UAT ·
+20
View File
@@ -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 aircraft in both layers therefore draws twice, the magenta local marker ahead
of the public one; both markers are drawn on purpose. 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 ## Which to use
- **One plain RTL-SDR dongle, desktop Chrome or Edge:** use the browser SDR. - **One plain RTL-SDR dongle, desktop Chrome or Edge:** use the browser SDR.
+1
View File
@@ -384,6 +384,7 @@ async function readFeed(
// naturally, without trusting a receiver clock that runs ahead of ours. // naturally, without trusting a receiver clock that runs ahead of ours.
const records = normalizeDump1090Aircraft(json, receivedAt - ageMs, { const records = normalizeDump1090Aircraft(json, receivedAt - ageMs, {
band: feed.band, band: feed.band,
feedId: feed.id,
}); });
return { return {
status: { ...summary, status, aircraft: records.length, ageMs }, status: { ...summary, status, aircraft: records.length, ageMs },
+36
View File
@@ -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`. // Ages are anchored at the feed's own `now`.
assert.equal(shinr[0].lastPositionAt, FIXTURE_NOW_MS - 34_600); assert.equal(shinr[0].lastPositionAt, FIXTURE_NOW_MS - 34_600);
assert.equal(shinr[0].lat, 30.269662); 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 () => { test('a feed whose own now is over 10 s old is stale; failures are unreachable', async () => {
+3 -2
View File
@@ -995,10 +995,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// list descriptions; the missions still ride existing tools. // list descriptions; the missions still ride existing tools.
// Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft. // Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft.
// Air quality (ECCC AQHI) extends the manifest. // 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( assert.equal(
crypto.createHash('sha256').update(block).digest('hex'), crypto.createHash('sha256').update(block).digest('hex'),
'3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e', 'f3835c8f5b1732cbd5b45e3eb46baa9a979830be2d23dd1c9888a3e98a35f082',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
); );
const instructions = fs.readFileSync( const instructions = fs.readFileSync(
+73
View File
@@ -2,6 +2,10 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { LOCAL_RECEIVERS_URL, createLocalReceiverFeeds } from './feeds.js'; import { LOCAL_RECEIVERS_URL, createLocalReceiverFeeds } from './feeds.js';
import {
createLocalAdsbAltitudeGate,
gateLocalAdsbAltitude,
} from '../../sources/adsbRecords.js';
const flush = () => new Promise((resolve) => setImmediate(resolve)); 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); 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) => { test('an aircraft a feed drops is kept until 60 s after its last message', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
const bodies = [ const bodies = [
+30 -5
View File
@@ -10,6 +10,8 @@ import { isPointerFree } from '../../data/inputOwnership.js';
import { pickRenderAltitudeM } from '../../data/renderAltitude.js'; import { pickRenderAltitudeM } from '../../data/renderAltitude.js';
import { routePlausible } from '../../data/routePlausible.js'; import { routePlausible } from '../../data/routePlausible.js';
import { import {
createLocalAdsbAltitudeGate,
gateLocalAdsbAltitude,
localAdsbPositionIsFresh, localAdsbPositionIsFresh,
mergeLocalAdsbRecords, mergeLocalAdsbRecords,
summarizeLocalAdsb, summarizeLocalAdsb,
@@ -145,6 +147,7 @@ export function createLocalAdsbLayer({
let models = null; let models = null;
let geoidReady = false; let geoidReady = false;
let rejectedFixes = 0; let rejectedFixes = 0;
let rejectedAltitudes = 0;
let state = receiver.getState(); let state = receiver.getState();
const markers = new Map(); const markers = new Map();
// Per-ICAO receptions (band|source -> last message) across merges. // Per-ICAO receptions (band|source -> last message) across merges.
@@ -322,6 +325,13 @@ export function createLocalAdsbLayer({
aircraftClass: marker.evidence ? marker.klass : null, aircraftClass: marker.evidence ? marker.klass : null,
typeCode: meta?.typeCode ?? null, typeCode: meta?.typeCode ?? null,
altitudeFt: record.altitudeFt, altitudeFt: record.altitudeFt,
...(Number.isFinite(record.altitudeFt)
? {
altitudeQuality: record.altitudeQuality,
altitudeObservedAt: record.altitudeObservedAt,
altitudeSource: record.altitudeSource,
}
: {}),
groundSpeedKt: record.groundSpeedKt, groundSpeedKt: record.groundSpeedKt,
trackDeg: record.trackDeg, trackDeg: record.trackDeg,
verticalRateFpm: record.verticalRateFpm, verticalRateFpm: record.verticalRateFpm,
@@ -408,30 +418,36 @@ export function createLocalAdsbLayer({
// ── Markers ───────────────────────────────────────────────────────────── // ── Markers ─────────────────────────────────────────────────────────────
function upsertMarker(record, at) { function upsertMarker(heard, at) {
const id = `${ENTITY_PREFIX}${record.icao}`; const id = `${ENTITY_PREFIX}${heard.icao}`;
let marker = markers.get(id); let marker = markers.get(id);
if (!marker) { if (!marker) {
marker = { marker = {
id, id,
entity: null, entity: null,
record, record: heard,
motion: new LocalAdsbMotion(), motion: new LocalAdsbMotion(),
altitude: createLocalAdsbAltitudeGate(),
position: null, position: null,
lat: null, lat: null,
lon: null, lon: null,
courseDeg: Number.isFinite(record.trackDeg) ? record.trackDeg : null, courseDeg: Number.isFinite(heard.trackDeg) ? heard.trackDeg : null,
lastRotation: 0, lastRotation: 0,
klass: null, klass: null,
evidence: false, evidence: false,
iconKind: null, iconKind: null,
iconScale: null, iconScale: null,
onGround: Boolean(record.onGround), onGround: Boolean(heard.onGround),
geoidN: null, geoidN: null,
modelOwnsVisual: false, modelOwnsVisual: false,
}; };
markers.set(id, marker); 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, at);
rejectedAltitudes += marker.altitude.rejected - altitudesRejectedBefore;
marker.record = record; marker.record = record;
marker.onGround = Boolean(record.onGround); marker.onGround = Boolean(record.onGround);
const rejectedBefore = marker.motion.rejectedFixes; const rejectedBefore = marker.motion.rejectedFixes;
@@ -810,6 +826,8 @@ export function createLocalAdsbLayer({
// plus the layer's check on every merged record. // plus the layer's check on every merged record.
rejectedPositions: rejectedPositions:
Math.max(0, Number(current.positionsRejected) || 0) + rejectedFixes, Math.max(0, Number(current.positionsRejected) || 0) + rejectedFixes,
// Altitude reports refused by the vertical-rate check.
rejectedAltitudes,
...localAdsbStatus({ ...localAdsbStatus({
receiver: current, receiver: current,
feedState: feeds?.getState?.() || null, feedState: feeds?.getState?.() || null,
@@ -854,6 +872,13 @@ export function createLocalAdsbLayer({
altitudeM: Number.isFinite(record.altitudeFt) altitudeM: Number.isFinite(record.altitudeFt)
? Math.round(record.altitudeFt * 0.3048) ? Math.round(record.altitudeFt * 0.3048)
: null, : null,
...(Number.isFinite(record.altitudeFt)
? {
altitudeQuality: record.altitudeQuality,
altitudeObservedAt: record.altitudeObservedAt,
altitudeSource: record.altitudeSource,
}
: {}),
speedKts: num(record.groundSpeedKt), speedKts: num(record.groundSpeedKt),
heading: num(record.trackDeg), heading: num(record.trackDeg),
onGround: record.onGround === true, onGround: record.onGround === true,
+246
View File
@@ -120,6 +120,13 @@ function record(overrides = {}) {
rssiDbfs: null, rssiDbfs: null,
band: '1090', band: '1090',
source: 'webusb', source: 'webusb',
...(overrides.source !== 'feed' && overrides.altitudeFt !== null
? {
altitudeObservedAt: 100_000,
altitudeSequence: 1,
altitudeSessionAt: 100_000,
}
: {}),
...overrides, ...overrides,
}; };
} }
@@ -399,6 +406,245 @@ test('an implausible jump in a feed record is ignored by the display', async (t)
assert.equal(layer.getStats().rejectedPositions, 1); 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, 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,
}),
],
});
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');
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,
}),
],
});
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) => { test('selecting an aircraft draws a magenta trail of the fixes the receiver heard', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true }); const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 }; const clock = { now: 100_000 };
+24
View File
@@ -314,6 +314,10 @@ export function decodeAdsbMessage(bytes, { receivedAt = Date.now() } = {}) {
return message; return message;
} }
function finiteTime(value) {
return Number.isFinite(value) ? value : null;
}
function recentReference(track, receivedAt) { function recentReference(track, receivedAt) {
if ( if (
!Number.isFinite(track.latitude) || !Number.isFinite(track.latitude) ||
@@ -371,6 +375,16 @@ function surfaceReference(track, message) {
* `positionsRejected` on the optional `stats`) and not applied. From the * `positionsRejected` on the optional `stats`) and not applied. From the
* third consecutive rejection on, a globally decoded fix is accepted as the * third consecutive rejection on, a globally decoded fix is accepted as the
* new reference, so one bad accepted fix cannot freeze a track. * 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<string, object>} tracks Tracks keyed by uppercase ICAO. * @param {Map<string, object>} tracks Tracks keyed by uppercase ICAO.
* @param {object} message Output of `decodeAdsbMessage`. * @param {object} message Output of `decodeAdsbMessage`.
* @param {{latitude:number, longitude:number}|null} [receiverLocation] * @param {{latitude:number, longitude:number}|null} [receiverLocation]
@@ -396,7 +410,10 @@ export function updateAircraftTrack(
verticalRateFpm: null, verticalRateFpm: null,
messages: 0, messages: 0,
lastSeen: 0, lastSeen: 0,
firstSeenAt: finiteTime(message.receivedAt),
lastPositionAt: null, lastPositionAt: null,
lastAltitudeAt: null,
altitudeSequence: 0,
onGround: false, onGround: false,
cprEven: null, cprEven: null,
cprOdd: null, cprOdd: null,
@@ -428,8 +445,15 @@ export function updateAircraftTrack(
if (message.onGround) { if (message.onGround) {
next.altitudeFt = null; next.altitudeFt = null;
next.verticalRateFpm = 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) { if (message.cpr) {
const surface = Boolean(message.cpr.surface); const surface = Boolean(message.cpr.surface);
// Airborne and surface frames use different zone sizes: never pair them. // Airborne and surface frames use different zone sizes: never pair them.
+181
View File
@@ -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.ok(Math.abs(track.longitude - 4.7347) < 0.002, `${track.longitude}`);
assert.equal(track.onGround, true); 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');
});
+307 -1
View File
@@ -12,6 +12,11 @@
* lat: number|null, // degrees, null until a position decodes * lat: number|null, // degrees, null until a position decodes
* lon: number|null, * lon: number|null,
* altitudeFt: number|null, // barometric altitude * 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, * groundSpeedKt: number|null,
* trackDeg: number|null, // true track over ground, [0, 360) * trackDeg: number|null, // true track over ground, [0, 360)
* verticalRateFpm: number|null, * verticalRateFpm: number|null,
@@ -25,6 +30,7 @@
* rssiDbfs: number|null, * rssiDbfs: number|null,
* band: '1090'|'978', // 1090 MHz Mode S/ES or 978 MHz UAT * band: '1090'|'978', // 1090 MHz Mode S/ES or 978 MHz UAT
* source: 'webusb'|'feed', // browser SDR or decoder feed * 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. * This module is portable: no Cesium, DOM, network or storage access.
@@ -54,6 +60,22 @@ export const LOCAL_ADSB_POSITION_MARGIN_M = 500;
* reference: the previously accepted fix was the outlier, not the stream. * reference: the previously accepted fix was the outlier, not the stream.
*/ */
export const LOCAL_ADSB_REANCHOR_AFTER = 3; 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;
/** 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 SLOW_CATEGORIES = new Set(['A1', 'A7', 'B1', 'B4']);
const KT_TO_MPS = 1852 / 3600; const KT_TO_MPS = 1852 / 3600;
const EARTH_RADIUS_M = 6_371_008.8; const EARTH_RADIUS_M = 6_371_008.8;
@@ -164,6 +186,278 @@ 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, sources:Map,
* rejectStreak:number, rejected:number}}
*/
export function createLocalAdsbAltitudeGate() {
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 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<typeof createLocalAdsbAltitudeGate>} 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,
nowMs = record?.lastMessageAt,
) {
if (record?.onGround) {
gate.accepted = null;
gate.sources.clear();
gate.rejectStreak = 0;
return altitudeResult(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) { function normalizePosition(lat, lon) {
const latitude = finiteOrNull(lat); const latitude = finiteOrNull(lat);
const longitude = finiteOrNull(lon); const longitude = finiteOrNull(lon);
@@ -196,6 +490,9 @@ export function recordFromDecoderTrack(track) {
lat, lat,
lon, lon,
altitudeFt: finiteOrNull(track.altitudeFt), altitudeFt: finiteOrNull(track.altitudeFt),
altitudeObservedAt: finiteOrNull(track.lastAltitudeAt),
altitudeSequence: finiteOrNull(track.altitudeSequence),
altitudeSessionAt: finiteOrNull(track.firstSeenAt),
groundSpeedKt: finiteOrNull(track.speedKt), groundSpeedKt: finiteOrNull(track.speedKt),
trackDeg: normalizeTrack(track.headingDeg), trackDeg: normalizeTrack(track.headingDeg),
verticalRateFpm: finiteOrNull(track.verticalRateFpm), verticalRateFpm: finiteOrNull(track.verticalRateFpm),
@@ -221,9 +518,14 @@ export function recordFromDecoderTrack(track) {
* @param {number} nowMs Local epoch ms at which the document was received. * @param {number} nowMs Local epoch ms at which the document was received.
* @param {object} [options] * @param {object} [options]
* @param {'1090'|'978'} [options.band='1090'] Band the feed decodes. * @param {'1090'|'978'} [options.band='1090'] Band the feed decodes.
* @param {string|null} [options.feedId=null] Opaque configured receiver identity.
* @returns {object[]} Normalized records. * @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 recordBand = band === '978' ? '978' : '1090';
const receivedAt = finiteOrNull(nowMs); const receivedAt = finiteOrNull(nowMs);
if (receivedAt === null || !Array.isArray(json?.aircraft)) return []; if (receivedAt === null || !Array.isArray(json?.aircraft)) return [];
@@ -245,6 +547,10 @@ export function normalizeDump1090Aircraft(json, nowMs, { band = '1090' } = {}) {
lat: hasPosition ? position.lat : null, lat: hasPosition ? position.lat : null,
lon: hasPosition ? position.lon : null, lon: hasPosition ? position.lon : null,
altitudeFt: altitude, altitudeFt: altitude,
altitudeObservedAt: null,
feedId: typeof feedId === 'string' ? feedId : null,
feedSnapshotAt:
finiteOrNull(json.now) === null ? null : finiteOrNull(json.now * 1000),
groundSpeedKt: finiteOrNull(entry.gs), groundSpeedKt: finiteOrNull(entry.gs),
trackDeg: normalizeTrack(entry.track), trackDeg: normalizeTrack(entry.track),
verticalRateFpm: finiteOrNull(entry.baro_rate), verticalRateFpm: finiteOrNull(entry.baro_rate),
+548
View File
@@ -1,11 +1,19 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import {
decodeAdsbMessage,
modeSChecksum,
updateAircraftTrack,
} from '../sdr/adsbDecoder.js';
import { import {
LOCAL_ADSB_MESSAGE_STALE_MS, LOCAL_ADSB_MESSAGE_STALE_MS,
LOCAL_ADSB_POSITION_STALE_MS, LOCAL_ADSB_POSITION_STALE_MS,
LOCAL_ADSB_REFERENCE_MAX_AGE_MS, LOCAL_ADSB_REFERENCE_MAX_AGE_MS,
createLocalAdsbAltitudeGate,
gateLocalAdsbAltitude,
localAdsbAltitudeIsPlausible,
localAdsbFixIsPlausible, localAdsbFixIsPlausible,
localAdsbPositionIsFresh, localAdsbPositionIsFresh,
localAdsbRecordIsLive, localAdsbRecordIsLive,
@@ -40,6 +48,9 @@ test('dump1090 aircraft.json maps to the shared local ADS-B records', () => {
lat: 30.269662, lat: 30.269662,
lon: -97.793262, lon: -97.793262,
altitudeFt: 1600, altitudeFt: 1600,
altitudeObservedAt: null,
feedId: null,
feedSnapshotAt: dump1090.now * 1000,
groundSpeedKt: 129.9, groundSpeedKt: 129.9,
trackDeg: 330.5, trackDeg: 330.5,
verticalRateFpm: 64, verticalRateFpm: 64,
@@ -114,6 +125,9 @@ test('decoder tracks map to the same record shape', () => {
verticalRateFpm: 64, verticalRateFpm: 64,
messages: 48, messages: 48,
lastSeen: 5_000, lastSeen: 5_000,
firstSeenAt: 1_000,
lastAltitudeAt: 3_000,
altitudeSequence: 2,
lastPositionAt: 4_000, lastPositionAt: 4_000,
cprEven: {}, cprEven: {},
}); });
@@ -125,6 +139,9 @@ test('decoder tracks map to the same record shape', () => {
lat: 30.27, lat: 30.27,
lon: -97.79, lon: -97.79,
altitudeFt: 1600, altitudeFt: 1600,
altitudeObservedAt: 3_000,
altitudeSequence: 2,
altitudeSessionAt: 1_000,
groundSpeedKt: 129.9, groundSpeedKt: 129.9,
trackDeg: 330.5, trackDeg: 330.5,
verticalRateFpm: 64, verticalRateFpm: 64,
@@ -324,3 +341,534 @@ test('the position sanity check follows dump1090: speed × 1.5 + margin over ela
'the fixed 500 m margin absorbs CPR and reception error', '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 actual browser altitude reports, `[seconds, altitudeFt, extra]`. */
function gateReports(reports) {
const gate = createLocalAdsbAltitudeGate();
const altitudes = reports.map(
([seconds, altitudeFt, extra = {}]) =>
gateLocalAdsbAltitude(
{
icao: 'abc123',
lastMessageAt: seconds * 1_000,
source: 'webusb',
altitudeObservedAt: seconds * 1_000,
altitudeSequence: seconds * 1_000 + 1,
altitudeSessionAt: 0,
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 },
);
});
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');
});
+2 -1
View File
@@ -38,7 +38,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// requested radius and center authority in descriptions only. // requested radius and center authority in descriptions only.
// Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts. // Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts.
// Air quality (ECCC AQHI) extends the manifest. // Air quality (ECCC AQHI) extends the manifest.
'775ba323384b4c1b62fb8415bac52aecc3fc668e20bedcfa57e426dcd1da3969', // Local ADS-B adds altitude quality, observation time and source field hints.
'13595e14cd3837b48ff5a3aa52b287cf98dcf8a47e2aac7ccf9d57bd4d48e51c',
); );
}); });
+45 -2
View File
@@ -4443,6 +4443,34 @@ function unwrapProperties(value) {
return out; 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) { function compactProperties(props) {
const preferredKeys = [ const preferredKeys = [
'name', 'name',
@@ -4461,14 +4489,22 @@ function compactProperties(props) {
...props, ...props,
...(props.tags && typeof props.tags === 'object' ? props.tags : {}), ...(props.tags && typeof props.tags === 'object' ? props.tags : {}),
}; };
const result = {}; const result = Number.isFinite(flat.altitudeFt)
? localAdsbAltitudeMetadata(flat)
: {};
for (const key of preferredKeys) { for (const key of preferredKeys) {
if (Object.keys(result).length >= 12) break;
const value = cleanText(flat[key]); const value = cleanText(flat[key]);
if (value) result[key] = value; if (value) result[key] = value;
} }
for (const [key, value] of Object.entries(flat)) { for (const [key, value] of Object.entries(flat)) {
if (Object.keys(result).length >= 12) break; 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); const text = cleanText(value);
if (text) result[key] = text; if (text) result[key] = text;
} }
@@ -4656,8 +4692,15 @@ function compactAnalystItem(r) {
compact.lon = Math.round(r.lon * 1e4) / 1e4; compact.lon = Math.round(r.lon * 1e4) / 1e4;
} }
if (present(r.distanceKm)) compact.distanceKm = r.distanceKm; 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 || {}; const fields = voiceLayer(r.layerKey)?.query?.fields || {};
for (const [key, spec] of Object.entries(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 (!present(r[key]) || key in compact) continue;
if (spec.type === 'time') { if (spec.type === 'time') {
const ms = Number(r[key]); const ms = Number(r[key]);
+224
View File
@@ -3931,3 +3931,227 @@ test('analyst-only lookup distinguishes duplicate names and uses canonical aircr
assert.equal(plane.ok, true); assert.equal(plane.ok, true);
assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates'); 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`,
);
});
+4
View File
@@ -91,6 +91,10 @@ const layers = [
query: { query: {
fields: { fields: {
altitudeM: ['n', 'm'], altitudeM: ['n', 'm'],
// Quality describes the altitude value; feeds have no measurement time.
altitudeQuality: ['t'],
altitudeObservedAt: ['ms'],
altitudeSource: ['t'],
speedKts: ['n', 'kn'], speedKts: ['n', 'kn'],
heading: ['n', 'deg'], heading: ['n', 'deg'],
callsign: ['t'], callsign: ['t'],
+11
View File
@@ -112,3 +112,14 @@ test('spoken aliases resolve to real layers, keep every legacy phrase and never
claimed.set(alias, entry.id); 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'));
});