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

+10 -8
View File
@@ -1,8 +1,10 @@
- Local ADS-B no longer draws an aircraft at an altitude it cannot have
reached. A corrupt frame can still pass CRC, and one reached the globe as a
DC-9 at 108,800 ft. Each altitude report must now be reachable from the last
accepted one at a plausible vertical rate, whether it came from the browser
SDR or a decoder feed. A refused report keeps the previous altitude for the
marker, the card and the 3D model alike. Three refusals in a row start over
from the new value, and a surface report is never compared with the last
airborne altitude. The layer stats count refusals as `rejectedAltitudes` (Pedro Lobato, #801).
- Local ADS-B filters implausible altitude changes and holds the accepted
value consistently for markers, cards and models. Browser refusals count
actual altitude frames rather than unrelated aircraft messages. Decoder
feeds use a conservative five-second recovery with three advancing
snapshots and aircraft message activity, explicitly marked as inferred.
Held values retain their altitude source and measurement time through
context and analyst results; feeds report an unknown measurement time.
Cached snapshots and transport jitter cannot recount refusals. Ground
reports reset the airborne reference, and no absolute altitude ceiling is
imposed (Pedro Lobato's original filter, #801, with maintainer corrections).
+35 -7
View File
@@ -5699,12 +5699,36 @@ on purpose:
the elapsed time + 1 s, plus 300 ft. A refused report holds the last
accepted altitude for the marker height, the card, the model ceiling and
route plausibility alike, and is counted as `rejectedAltitudes` in the
layer stats. Only a newer message is judged, so the layer's repeated
re-reads of one record neither count twice nor let a refused value
through. Three refusals in a row re-anchor, so a bad first altitude clears
itself. A surface report passes unjudged, because a feed's `ground` is 0
and not a barometric reading. There is no absolute ceiling: balloons really
do fly above 100,000 ft.
layer stats. Browser tracks carry the altitude frame's own receive time,
sequence and track start: identification/velocity messages and renderer
re-reads never refresh an accepted altitude or recount a refusal. Equal
altitudes in distinct frames are real observations, including frames from
one USB block with the same timestamp. Three observed refusals in a row
re-anchor; a sequence gap counts as only one observed report.
Decoder feeds expose no altitude-specific measurement age. A changed value
is judged once and keeps its receipt-time reference through unrelated
messages. Feed allowance is capped at a 60 s relative gap, since fresh
positions can keep an old altitude alive indefinitely. Recovery from an
implausible feed value requires a coherent candidate stream for at least
5 s and three advancing source snapshots with increasing aircraft message
counts from one receiver. Cached snapshots and transport jitter cannot
supply that progress. Each receiver keeps separate recovery progress when
the freshest position alternates between inputs; a new accepted altitude
resets pending recovery against the old reference. This is
an inference: a stable corrupt feed can also satisfy it. Without source
clock/count/identity metadata, automatic feed recovery is disabled, so a
held altitude can persist while positions remain fresh. A surface report
clears the airborne reference, because a feed's `ground` is 0 and not a
barometric reading. The next airborne value seeds a new reference, just
like the first observation of a new aircraft. There is no absolute ceiling:
balloons really do fly above 100,000 ft.
Accepted/held records carry `altitudeQuality` (`accepted`,
`held-after-rejection`, `feed-estimate` or `inferred-reanchor`),
`altitudeObservedAt` (the actual browser measurement time, null for feeds),
and `altitudeSource` (the accepted value's source, which can differ from the
position source). These accompany altitude through context metadata and
final analyst items, including altitude-only queries. Missing/surface
altitudes carry no airborne quality claim.
- **Trail.** A selected aircraft draws a magenta trail of the positions the
receiver heard (up to 10 minutes / 600 fixes, dropped with the aircraft),
with the tracked-flight trail look and a live head segment. No network
@@ -5723,10 +5747,12 @@ aircraft join detection boxes, labelled only with a decoded callsign.
Receivers publish one record shape from `src/sources/adsbRecords.js`
(`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`,
`altitudeObservedAt`, browser `altitudeSequence`/`altitudeSessionAt`, feed
`feedId`/`feedSnapshotAt`,
`groundSpeedKt`, `trackDeg`, `verticalRateFpm`, `lastPositionAt`,
`lastMessageAt`, `messageCount`, `rssiDbfs`, `band`, `source`). `band` is `1090` or `978`; `source` is
`webusb` (browser SDR) or `feed` (decoder feed).
`normalizeDump1090Aircraft(json, nowMs, { band })` maps a dump1090/readsb/
`normalizeDump1090Aircraft(json, nowMs, { band, feedId })` maps a dump1090/readsb/
skyaware978 `aircraft.json` document into the same records. Voice
`set_layer_visibility` accepts `local-adsb` ("local ADS-B", "my receiver",
"my antenna").
@@ -5751,6 +5777,8 @@ second only while the layer is enabled and only while the route reports
`configured`; the Local RTL-SDR card makes one probe request at startup to show
its read-only "Decoder feeds: 1090 live · 978 live" line. Feed records are
rebased to the browser clock and kept up to 60 s after their last message.
The opaque configured `feedId` and raw `feedSnapshotAt` source clock are
preserved without rebasing; neither exposes the receiver URL.
`mergeLocalAdsbRecords` merges both inputs by ICAO, keeping the newest position
(tie: newest message) and the bands/sources heard in the last 60 s; the card's
receiver line names them ("Heard by your receiver · 1090 MHz + 978 MHz UAT ·
+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
of the public one; both markers are drawn on purpose.
Airborne altitude changes are checked against the last accepted value and a
vertical-rate allowance. A refused value holds the accepted altitude for the
marker, card and model. Browser SDR recovery counts actual altitude frames;
identification and velocity messages cannot make a held altitude look new.
Decoder feeds have no altitude measurement timestamp. Their changed values
are estimates; recovery after a refusal requires at least five seconds and
three advancing source snapshots with increasing aircraft message counts
from the same receiver, with coherent altitude changes. Repeated cached
snapshots and transport delays do not count. This recovery is inferred, so a
stable corrupt feed can still qualify. Feeds missing progression metadata
cannot automatically recover a held value, and fresh positions can keep that
held altitude alive. The rate check caps its elapsed allowance at 60 seconds
for feeds; there is no absolute altitude ceiling.
Context and analyst records include `altitudeQuality`, `altitudeObservedAt`
and `altitudeSource`. Held values retain the accepted value's source and
measurement time; feed measurement times are null. Ground reports clear the
airborne reference and its quality metadata.
## Which to use
- **One plain RTL-SDR dongle, desktop Chrome or Edge:** use the browser SDR.
+1
View File
@@ -384,6 +384,7 @@ async function readFeed(
// naturally, without trusting a receiver clock that runs ahead of ours.
const records = normalizeDump1090Aircraft(json, receivedAt - ageMs, {
band: feed.band,
feedId: feed.id,
});
return {
status: { ...summary, status, aircraft: records.length, ageMs },
+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'));
});