fix(local-adsb): track altitude freshness and held-value provenance

This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-10-20 17:12:24 +02:00
1 parent 68d87aa107
commit 2ab7c0d8e6
18 files changed
+1561 -71

No files matched your search

+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`.
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 () => {
+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.
// 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(
+73
View File
@@ -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 = [
+15 -1
View File
@@ -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,
+198 -1
View File
@@ -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) => {
+24
View File
@@ -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<string, object>} 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.
+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.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');
});
+235 -48
View File
@@ -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<typeof createLocalAdsbAltitudeGate>} 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<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) {
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),
+444 -1
View File
@@ -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');
});
+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.
// 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',
);
});
+45 -2
View File
@@ -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]);
+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.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: {
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'],
+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);
}
});
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'));
});