fix(gnss): age degraded evidence and replayed snapshots on their own clocks

Maintainer review on #855 (mazy1998, 2026-10-06):

- records: presence (seenAt) and degraded evidence (degradedAt) age
  separately, so continuing healthy reports no longer keep an older
  degraded flag alive past 30 minutes; an aircraft returning after the
  window starts fresh. pruneGnssObservations ages the store with no new
  rows.
- Rows are dated by the snapshot's fetchedAt, which the proxy keeps on
  cached and stale answers: a replay never restarts the window, the
  proxy stops serving a stale snapshot older than the window, the source
  refuses an undated one, and cells expire during fetch failures and
  while there is no view anchor.
- Normalization accepts ADS-B versions 1 and 2 only; a missing, invalid
  or negative seen_pos is not a confirmed position from the last minute.
- qa-gnss: keyboard, 390x844 viewport (fresh page: isMobile reloads) and
  replay-age sections; the live cell assertion bins the returned rows
  with the layer's own rules instead of a raw row count.
This commit is contained in:
Leo Pechnicki authored and Mazeyar Moeini Feizabadi committed 2026-10-13 17:20:43 +02:00
1 parent c8d3f44bb7
commit fde8b2a64f
11 files changed
+557 -65

No files matched your search

+23
View File
@@ -141,12 +141,35 @@ test('an upstream failure serves the last snapshot as stale, else 502', async ()
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(
stale.body.fetchedAt,
0,
'a stale answer keeps the time it was observed',
);
assert.equal(stale.headers['X-Data-Stale'], 'true');
const missing = await request('/?lat=-10&lon=-10');
assert.equal(missing.status, 502);
assert.deepEqual(missing.body, { error: 'gnss_integrity_unavailable' });
});
test('a stale snapshot older than the layer window is no longer served', async () => {
let clock = 0;
let fail = false;
const request = install({
now: () => clock,
fetchImpl: async () =>
fail ? new Response('down', { status: 503 }) : Response.json(snapshot),
});
await request();
fail = true;
clock = 30 * 60_000;
assert.equal((await request()).status, 200);
clock = 30 * 60_000 + 1;
const expired = await request();
assert.equal(expired.status, 502);
assert.deepEqual(expired.body, { error: 'gnss_integrity_unavailable' });
});
test('an upstream 429 starts a cooldown with no further upstream calls', async () => {
let clock = 0;
let calls = 0;
+18 -2
View File
@@ -8,6 +8,7 @@ import {
accumulateGnssObservations,
binGnssCells,
gnssProvenance,
pruneGnssObservations,
} from './records.js';
export * from './records.js';
export { createAdsbGnssSource } from './source.js';
@@ -62,6 +63,13 @@ export function createGnssIntegrityLayer({
const _observations = new Map();
const provenance = gnssProvenance({ windowMs });
/** Age the store without new evidence, so expired cells leave the globe. */
function expire() {
pruneGnssObservations(_observations, now(), { windowMs });
_cells = binGnssCells(_observations);
render();
}
function render() {
// Only the id and band change what is drawn; counts alone do not.
const signature = JSON.stringify(
@@ -129,8 +137,11 @@ export function createGnssIntegrityLayer({
const anchor = viewAnchor(_viewer);
// No anchor yet (camera still settling) is not a failure: returning
// false would make the manager reject the enable. The next interval
// retries.
if (!anchor) return true;
// retries; cells already drawn still age out of the window.
if (!anchor) {
expire();
return true;
}
_request?.abort();
const request = new AbortController();
_request = request;
@@ -140,8 +151,11 @@ export function createGnssIntegrityLayer({
});
if (request.signal.aborted || _request !== request || !_enabled)
return false;
// Date the rows by when the proxy observed them: a cached or stale
// replay keeps its original age instead of restarting the window.
accumulateGnssObservations(_observations, snapshot.rows, now(), {
windowMs,
observedAt: snapshot.fetchedAt,
});
_cells = binGnssCells(_observations);
render();
@@ -154,6 +168,8 @@ export function createGnssIntegrityLayer({
return false;
console.warn('[Data:GNSS] Fetch error:', e);
_lastError = e?.message || 'GNSS integrity source unavailable';
// Keep the last cells only while their evidence is inside the window.
expire();
return false;
} finally {
if (_request === request) _request = null;
+79 -1
View File
@@ -14,8 +14,9 @@ const row = (hex, lat, degraded) => ({
function harness(
getSnapshot,
{ anchor = { latitude: 50, longitude: 30 } } = {},
{ anchor: initialAnchor = { latitude: 50, longitude: 30 } } = {},
) {
let anchor = initialAnchor;
const sources = [];
const viewer = {
dataSources: {
@@ -45,9 +46,20 @@ function harness(
advance(ms) {
clock += ms;
},
now: () => clock,
setAnchor(value) {
anchor = value;
},
};
}
const MIN = 60_000;
const threeHealthy = () => [
row('a1', 1.1, false),
row('a2', 1.2, false),
row('a3', 1.3, false),
];
test('the layer requires a snapshot source and cannot be initialized twice', () => {
assert.throws(() => createGnssIntegrityLayer({}), TypeError);
const { layer } = harness(async () => ({ rows: [] }));
@@ -206,6 +218,72 @@ test('no view anchor means no request, and destroy releases the data source', as
assert.equal(layer.getStats().count, 0);
});
test('a replayed stale snapshot keeps its observation age and expires on time', async () => {
// The proxy keeps answering with the snapshot it observed at t=1000.
const { layer, sources, advance } = harness(async () => ({
rows: threeHealthy(),
fetchedAt: 1_000,
stale: true,
}));
layer.enable();
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1);
advance(29 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1);
advance(2 * MIN);
assert.equal(
await layer.update(),
true,
'the replay is not an error, only no evidence',
);
assert.equal(
layer.getStats().count,
0,
'the replay did not restart the window',
);
assert.equal(sources[0].entities.values.length, 0);
});
test('cells expire during prolonged fetch failures', async () => {
let fail = false;
const { layer, sources, advance, now } = harness(async () => {
if (fail) throw new Error('adsb.lol HTTP 502');
return { rows: threeHealthy(), fetchedAt: now() };
});
layer.enable();
await layer.update();
fail = true;
advance(20 * MIN);
assert.equal(await layer.update(), false);
assert.equal(layer.getStats().count, 1, 'still inside the window');
advance(11 * MIN);
assert.equal(await layer.update(), false);
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].entities.values.length, 0);
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
});
test('cells expire while no view anchor is available', async () => {
let calls = 0;
const { layer, sources, advance, now, setAnchor } = harness(async () => {
calls += 1;
return { rows: threeHealthy(), fetchedAt: now() };
});
layer.enable();
await layer.update();
setAnchor(null);
advance(20 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1);
advance(11 * MIN);
assert.equal(await layer.update(), true);
assert.equal(calls, 1, 'no anchor means no request');
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].entities.values.length, 0);
assert.equal(layer.getStats().error, null);
});
const threeCells = () => ({
rows: [row('a1', 50.1, true), row('a2', 50.1, true), row('a3', 50.2, false)],
});
+65 -21
View File
@@ -21,6 +21,8 @@
/** Direct 1090ES ADS-B only: MLAT, TIS-B and ADS-R positions are not self-reported. */
const QUALIFYING_TYPES = new Set(['adsb_icao']);
/** DO-260A (1) and DO-260B (2) define NIC and NACp; version 0 and unknown versions do not. */
const QUALIFYING_VERSIONS = new Set([1, 2]);
/** NIC below 7: containment radius of 0.2 NM or more (below the US 91.227 ADS-B Out minimum). GEV threshold. */
export const GNSS_NIC_THRESHOLD = 7;
/** NACp below 8: position uncertainty of 93 m or more (below the US 91.227 minimum). GEV threshold. */
@@ -113,14 +115,16 @@ export function normalizeGnssAircraft(payload) {
const hex = typeof aircraft.hex === 'string' ? aircraft.hex : '';
if (!HEX_PATTERN.test(hex) || seen.has(hex)) continue;
if (!QUALIFYING_TYPES.has(aircraft.type)) continue;
if (!finite(aircraft.version) || aircraft.version < 1) continue;
if (!QUALIFYING_VERSIONS.has(aircraft.version)) continue;
if (aircraft.alt_baro === 'ground' || !finite(aircraft.alt_baro)) continue;
const { lat, lon, nic, nac_p: nacp } = aircraft;
if (!finite(lat) || Math.abs(lat) > 90) continue;
if (!finite(lon) || Math.abs(lon) > 180) continue;
if (!finite(nic) || !finite(nacp)) continue;
if (finite(aircraft.seen_pos) && aircraft.seen_pos > MAX_POSITION_AGE_S)
continue;
// Only a position confirmed within the last minute is current evidence; a
// missing, invalid or negative age confirms nothing.
const age = aircraft.seen_pos;
if (!finite(age) || age < 0 || age > MAX_POSITION_AGE_S) continue;
// readsb sets gpsOkBefore when an aircraft's integrity collapses and keeps
// it for roughly 15 minutes after GPS recovers, so it can outlast the
// current NIC/NACp; the rolling window treats that as recent evidence.
@@ -149,34 +153,72 @@ export function gnssCellKey(lat, lon, cellDeg = GNSS_CELL_DEG) {
/**
* Fold one snapshot into a rolling observation store. Each aircraft counts
* once per cell per window; a degraded report in the window keeps it degraded
* there, so an aircraft that loses GNSS mid-cell is not averaged away.
* once per cell per window. Presence and degraded evidence age separately: a
* degraded report keeps the aircraft degraded in that cell for one window
* after the report itself, however many healthy reports follow, so an
* aircraft that loses GNSS mid-cell is not averaged away and a recovered one
* is not held degraded forever.
*
* @param {Map<string, {cell: string, degraded: boolean, seenAt: number}>} store
* Every row is dated by when the snapshot was observed, not when it arrived:
* replaying a cached or stale snapshot never makes its evidence younger, and
* a snapshot older than the window adds nothing.
*
* @param {Map<string, {cell: string, seenAt: number, degradedAt: number|null}>} store
* @param {ReturnType<typeof normalizeGnssAircraft>} rows
* @param {number} nowMs
* @param {{windowMs?: number, cellDeg?: number}} [options]
* @param {{windowMs?: number, cellDeg?: number, observedAt?: number}} [options]
* `observedAt` is the snapshot's observation time (defaults to `nowMs`;
* a future time is clamped to `nowMs`, a non-finite one adds nothing).
* @returns {Map} The same store, pruned to the window.
*/
export function accumulateGnssObservations(
store,
rows,
nowMs,
{ windowMs = GNSS_WINDOW_MS, cellDeg = GNSS_CELL_DEG } = {},
{
windowMs = GNSS_WINDOW_MS,
cellDeg = GNSS_CELL_DEG,
observedAt = nowMs,
} = {},
) {
for (const row of rows || []) {
if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) continue;
const cell = gnssCellKey(row.lat, row.lon, cellDeg);
const key = `${row.hex}|${cell}`;
const previous = store.get(key);
store.set(key, {
cell,
degraded: row.degraded || (previous?.degraded ?? false),
seenAt: nowMs,
});
const at = Number.isFinite(observedAt) ? Math.min(observedAt, nowMs) : NaN;
if (Number.isFinite(at) && nowMs - at <= windowMs) {
for (const row of rows || []) {
if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) continue;
const cell = gnssCellKey(row.lat, row.lon, cellDeg);
const key = `${row.hex}|${cell}`;
const previous = store.get(key);
const previousDegradedAt = previous?.degradedAt ?? null;
store.set(key, {
cell,
seenAt: Math.max(previous?.seenAt ?? at, at),
degradedAt: row.degraded
? Math.max(previousDegradedAt ?? at, at)
: previousDegradedAt,
});
}
}
return pruneGnssObservations(store, nowMs, { windowMs });
}
/**
* Drop observations older than the window and degraded evidence older than
* the window, without adding anything. Used when no fresh snapshot arrives.
*
* @param {Map<string, {cell: string, seenAt: number, degradedAt: number|null}>} store
* @param {number} nowMs
* @param {{windowMs?: number}} [options]
* @returns {Map} The same store.
*/
export function pruneGnssObservations(
store,
nowMs,
{ windowMs = GNSS_WINDOW_MS } = {},
) {
for (const [key, entry] of store) {
if (nowMs - entry.seenAt > windowMs) store.delete(key);
else if (entry.degradedAt != null && nowMs - entry.degradedAt > windowMs)
store.set(key, { ...entry, degradedAt: null });
}
return store;
}
@@ -205,7 +247,9 @@ export function gnssIntegrityLevel(total, degraded) {
}
/**
* Aggregate the observation store into displayable cells.
* Aggregate the observation store into displayable cells. Expects a store
* already pruned to the window by `accumulateGnssObservations` or
* `pruneGnssObservations`.
*
* @returns {{id: string, south: number, west: number, north: number, east: number, lat: number, lon: number, aircraft: number, degraded: number, percentDegraded: number, level: 'low'|'medium'|'high', state: string, interpretation: string|null}[]}
*/
@@ -214,10 +258,10 @@ export function binGnssCells(
{ cellDeg = GNSS_CELL_DEG, minAircraft = GNSS_MIN_AIRCRAFT } = {},
) {
const tallies = new Map();
for (const { cell, degraded } of store.values()) {
for (const { cell, degradedAt } of store.values()) {
const tally = tallies.get(cell) || { aircraft: 0, degraded: 0 };
tally.aircraft += 1;
if (degraded) tally.degraded += 1;
if (degradedAt != null) tally.degraded += 1;
tallies.set(cell, tally);
}
const cells = [];
+153
View File
@@ -5,10 +5,12 @@ import {
binGnssCells,
GNSS_CLASSIFIER,
GNSS_PROVENANCE,
GNSS_WINDOW_MS,
gnssCellKey,
gnssIntegrityLevel,
gnssProvenance,
normalizeGnssAircraft,
pruneGnssObservations,
} from './records.js';
const aircraft = (overrides = {}) => ({
@@ -36,10 +38,24 @@ test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', ()
aircraft({ hex: '000006', nic: undefined }),
aircraft({ hex: '000007', lat: 95 }),
aircraft({ hex: '000008', version: undefined }),
// Only DO-260A/B (versions 1 and 2) define NIC and NACp.
aircraft({ hex: '000009', version: 3 }),
aircraft({ hex: '00000a', version: '2' }),
aircraft({ hex: '00000b', version: 1.5 }),
// A position is current only when its age is known, finite and 0..60 s.
aircraft({ hex: '00000c', seen_pos: undefined }),
aircraft({ hex: '00000d', seen_pos: null }),
aircraft({ hex: '00000e', seen_pos: -0.5 }),
aircraft({ hex: '00000f', seen_pos: '1.2' }),
aircraft({ hex: '000010', seen_pos: Number.NaN }),
aircraft({ hex: '000011', seen_pos: 60.1 }),
aircraft({ hex: 'not-hex' }),
aircraft(),
// A non-ICAO (`~`) address still self-reports over 1090ES ADS-B.
aircraft({ hex: '~a1b2c3' }),
// Version 1, and both ends of the position-age range, qualify.
aircraft({ hex: 'b00001', version: 1, seen_pos: 0 }),
aircraft({ hex: 'b00002', seen_pos: 60 }),
null,
],
});
@@ -62,6 +78,15 @@ test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', ()
gpsLost: false,
degraded: false,
},
...['b00001', 'b00002'].map((hex) => ({
hex,
lat: 50.1,
lon: 19.9,
nic: 8,
nacp: 10,
gpsLost: false,
degraded: false,
})),
]);
assert.equal(normalizeGnssAircraft({}), null);
assert.equal(normalizeGnssAircraft(null), null);
@@ -205,6 +230,134 @@ test('observations count each aircraft once per cell and expire after the window
assert.deepEqual(binGnssCells(store), []);
});
const MIN = 60_000;
const report = (hex, degraded, lat = 50.1) => ({
hex,
lat,
lon: 20.1,
degraded,
});
const entry = (store, hex) =>
[...store].find(([key]) => key.startsWith(`${hex}|`))?.[1];
test('degraded evidence expires one window after the degraded report, despite continuing healthy reports', () => {
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
// a1 keeps reporting healthy every minute in the same cell.
for (let minute = 1; minute <= 30; minute += 1)
accumulateGnssObservations(store, [report('a1', false)], minute * MIN);
assert.equal(entry(store, 'a1').seenAt, 30 * MIN);
assert.equal(
entry(store, 'a1').degradedAt,
0,
'healthy reports do not refresh the degraded evidence',
);
accumulateGnssObservations(store, [report('a1', false)], 31 * MIN);
assert.deepEqual(entry(store, 'a1'), {
cell: gnssCellKey(50.1, 20.1),
seenAt: 31 * MIN,
degradedAt: null,
});
// A new degraded report starts its own window.
accumulateGnssObservations(store, [report('a1', true)], 32 * MIN);
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
accumulateGnssObservations(store, [report('a1', false)], 40 * MIN);
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
});
test('a degraded cell turns healthy once its degraded evidence is older than the window', () => {
const store = new Map();
const healthy = [report('a2', false), report('a3', false)];
accumulateGnssObservations(
store,
[report('a1', true), report('b1', true), ...healthy],
0,
);
assert.equal(binGnssCells(store)[0].level, 'high');
for (let minute = 10; minute <= 30; minute += 10)
accumulateGnssObservations(
store,
[report('a1', false), report('b1', false), ...healthy],
minute * MIN,
);
assert.equal(binGnssCells(store)[0].degraded, 2);
accumulateGnssObservations(
store,
[report('a1', false), report('b1', false), ...healthy],
31 * MIN,
);
const [cell] = binGnssCells(store);
assert.equal(cell.aircraft, 4);
assert.equal(cell.degraded, 0);
assert.equal(cell.level, 'low');
});
test('an aircraft returning after the window starts fresh', () => {
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
// a1 leaves; nothing is heard from it for longer than the window.
accumulateGnssObservations(store, [], 31 * MIN);
assert.equal(store.size, 0);
accumulateGnssObservations(store, [report('a1', false)], 32 * MIN);
assert.deepEqual(entry(store, 'a1'), {
cell: gnssCellKey(50.1, 20.1),
seenAt: 32 * MIN,
degradedAt: null,
});
// Returning degraded dates the evidence from the new report.
accumulateGnssObservations(store, [report('a9', true)], 0);
accumulateGnssObservations(store, [], 31 * MIN);
accumulateGnssObservations(store, [report('a9', true)], 33 * MIN);
assert.equal(entry(store, 'a9').degradedAt, 33 * MIN);
});
test('rows are dated by the snapshot observation time, so a replay never restarts the window', () => {
const store = new Map();
const snapshot = [report('a1', true), report('a2', false)];
accumulateGnssObservations(store, snapshot, 0, { observedAt: 0 });
// The same snapshot replayed (cached or stale) 29 minutes later.
accumulateGnssObservations(store, snapshot, 29 * MIN, { observedAt: 0 });
assert.equal(entry(store, 'a1').seenAt, 0);
assert.equal(entry(store, 'a1').degradedAt, 0);
accumulateGnssObservations(store, snapshot, 31 * MIN, { observedAt: 0 });
assert.equal(store.size, 0, 'a replay older than the window adds nothing');
// An older replay never moves an entry's age backwards either.
accumulateGnssObservations(store, snapshot, 40 * MIN);
accumulateGnssObservations(store, [report('a2', true)], 41 * MIN, {
observedAt: 35 * MIN,
});
assert.equal(entry(store, 'a2').seenAt, 40 * MIN);
assert.equal(entry(store, 'a2').degradedAt, 35 * MIN);
// A future time is clamped to now; an undated snapshot adds nothing.
accumulateGnssObservations(store, [report('f1', true)], 50 * MIN, {
observedAt: 99 * MIN,
});
assert.equal(entry(store, 'f1').seenAt, 50 * MIN);
for (const observedAt of [null, Number.NaN])
accumulateGnssObservations(store, [report('u1', true)], 50 * MIN, {
observedAt,
});
assert.equal(entry(store, 'u1'), undefined);
});
test('pruning without new rows drops expired observations and expired degraded evidence', () => {
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
accumulateGnssObservations(store, [report('a1', false)], 20 * MIN);
accumulateGnssObservations(store, [report('a2', false)], 0);
assert.equal(pruneGnssObservations(store, 31 * MIN), store);
assert.deepEqual(
[...store.keys()].map((key) => key.split('|')[0]),
['a1'],
);
assert.equal(entry(store, 'a1').degradedAt, null);
pruneGnssObservations(store, 51 * MIN);
assert.equal(store.size, 0);
assert.equal(GNSS_WINDOW_MS, 30 * MIN);
});
test('rows without finite coordinates never key a cell', () => {
const store = new Map();
accumulateGnssObservations(
+5 -3
View File
@@ -25,11 +25,13 @@ export function createAdsbGnssSource({
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error('Malformed GNSS integrity snapshot');
// The observation time dates every row in the rolling window; an
// undated snapshot could only be treated as new, so it is refused.
if (!Number.isFinite(payload.fetchedAt))
throw new Error('GNSS integrity snapshot has no observation time');
return {
rows: payload.rows,
fetchedAt: Number.isFinite(payload.fetchedAt)
? payload.fetchedAt
: null,
fetchedAt: payload.fetchedAt,
stale: payload.stale === true,
classifier:
typeof payload.classifier === 'string' ? payload.classifier : null,
+10 -2
View File
@@ -31,11 +31,19 @@ test('the source asks the same-origin proxy for the view anchor', async () => {
test('the source reports stale proxy data and rejects bad input or responses', async () => {
const reply = (body, init) => async () => Response.json(body, init);
const stale = await createAdsbGnssSource({
fetchImpl: reply({ rows: [], stale: true }),
fetchImpl: reply({ rows: [], fetchedAt: 7, stale: true }),
}).getSnapshot({ latitude: 1, longitude: 2 });
assert.equal(stale.stale, true);
assert.equal(stale.fetchedAt, null);
assert.equal(stale.fetchedAt, 7, 'a stale reply keeps its observation time');
assert.equal(stale.classifier, null);
// Without an observation time the rows could only be dated as new.
for (const fetchedAt of [undefined, null, '7'])
await assert.rejects(
createAdsbGnssSource({
fetchImpl: reply({ rows: [], fetchedAt }),
}).getSnapshot({ latitude: 1, longitude: 2 }),
/no observation time/,
);
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}),