mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix(local-adsb): track altitude freshness and held-value provenance
This commit is contained in:
1 parent
68d87aa107
commit
2ab7c0d8e6
18 files changed
+1561
-71
No files matched your search
@@ -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
@@ -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 ·
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
Reference in new issue
Block a user