diff --git a/CHANGELOG.md b/CHANGELOG.md index de7b1eb56..ced1d2d32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -168,8 +168,12 @@ machine-readable provenance (`provenance`, `classifier: gev-nic-nacp-v1`). Keyless, worldwide wherever adsb.lol has receivers; reads a capped 250 nm snapshot around the camera through `/api/gnss-integrity`, - which returns integrity fields only and serves the last snapshot when the - upstream fails. + which returns integrity fields only and serves the last snapshot (with its + original observation time, for at most 30 minutes) when the upstream fails. + Only ADS-B versions 1 and 2 with a known position age of 0-60 s count. A + degraded report keeps an aircraft degraded for 30 minutes after that report, + not after its latest healthy one; replayed snapshots keep their age, and + cells expire on schedule during fetch failures or without a view anchor. ## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 356e232b2..cb1582ce8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3072,7 +3072,7 @@ its criteria cannot be silently ignored. | GNSS Integrity 📡 (`gnss-interference`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | 60s (server cache 60 s; 30 min rolling window) | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | -GNSS Integrity fetches a capped adsb.lol snapshot around the view anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. +GNSS Integrity fetches a capped adsb.lol snapshot around the view anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. Only DO-260A/B (ADS-B versions 1 and 2) direct 1090ES positions with a known `seen_pos` of 0–60 s count. Presence and degraded evidence age separately: a degraded report keeps the aircraft degraded in its cell for 30 minutes after that report, however many healthy reports follow, and an aircraft unheard for 30 minutes starts fresh. Rows are dated by the snapshot's `fetchedAt`, which the proxy keeps on cached and stale answers, so a replayed snapshot never restarts the window; the proxy stops serving a stale snapshot once it is older than 30 minutes, the source refuses an undated one, and cells age out on schedule during fetch failures or while there is no view anchor. Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index 33e3675e3..ae7e47a54 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -10,8 +10,9 @@ * * (i) LIVE — over Poland and the southern Baltic the layer settles with no error, * one entity per cell and legend band counts that sum to the cell - * count; cells are required only when adsb.lol actually returned - * enough aircraft (live traffic varies by hour). + * count; cells are required only when the returned rows, binned + * with the layer's own rules, reach the per-cell minimum (live + * traffic varies by hour). * (ii) BANDS (synthetic) — a 10-aircraft cell with 5 degraded is high, * a 30-aircraft cell with 2 degraded is medium, a 3-aircraft cell * with 1 degraded is low (first bad discounted), a clean cell at sea @@ -22,12 +23,22 @@ * (iii) FAILURE — the route stubbed to 502: stats.error is set and the * cells already on the globe are kept. * (iv) DISABLE — the layer's data source is hidden. + * (v) KEYBOARD — the row's toggle button, focused, turns the layer on + * with Enter and off with Space, keeps focus and names its state. + * (vi) VIEWPORT — at a 390x844 phone viewport the open row and its legend + * fit the panel width with no horizontal overflow. + * (vii) AGE (synthetic) — a fresh page whose stubbed proxy replays the same + * stale snapshot observed 31 minutes ago draws nothing and reports no + * error; observed 29 minutes ago it draws its cells. The replay keeps + * its observation time instead of restarting the 30-minute window. * * Screenshots wait until every cell's ground geometry is built and show the * open Data Layers row with its legend. Visual proof saved to * qa-shots/gnss-*.png (gitignored). * * Run: node scripts/qa-gnss.mjs --url http://localhost:4173 + * (also against a built preview: npm run build && npm run preview, + * then pass the preview URL) * Exits non-zero on any FAIL. Does not commit anything. */ @@ -35,6 +46,10 @@ import puppeteer from 'puppeteer'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + accumulateGnssObservations, + binGnssCells, +} from '../src/layers/gnss/records.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, '..'); @@ -49,7 +64,6 @@ const APP_URL = getOpt('--url', 'http://localhost:4173'); const HEADFUL = argv.includes('--headful'); const LAYER_ID = 'gnss-interference'; /** Live rows below this cannot reliably fill a 3-aircraft cell. */ -const LIVE_MIN_ROWS = 30; const results = []; function record(name, ok, detail) { @@ -225,6 +239,70 @@ function fixturePayload() { }; } +/** Answer /api/gnss-integrity in-page from `fixturePayload()`. */ +async function stubRoute(page, { ageMs = 0 } = {}) { + await page.evaluateOnNewDocument( + (payload, age) => { + window.__gnssQaAgeMs = age; + const realFetch = window.fetch.bind(window); + window.__gnssQaMode = 'fixture'; + window.fetch = (input, init) => { + const url = typeof input === 'string' ? input : input?.url || ''; + if (!url.includes('/api/gnss-integrity')) return realFetch(input, init); + const ok = window.__gnssQaMode === 'fixture'; + const body = ok + ? { ...payload, fetchedAt: Date.now() - (window.__gnssQaAgeMs || 0) } + : { error: 'gnss_integrity_unavailable' }; + return Promise.resolve( + new Response(JSON.stringify(body), { + status: ok ? 200 : 502, + headers: { 'Content-Type': 'application/json' }, + }), + ); + }; + }, + fixturePayload(), + ageMs, + ); +} + +/** Toggle button of the GNSS row, as the panel draws it. */ +function toggleState(id) { + const button = document.querySelector( + `[data-layer-id="${id}"] .data-toggle-btn`, + ); + const layer = window.__godsEyeView.dataManager.layers.get(id); + return { + focused: document.activeElement === button, + label: button?.getAttribute('aria-label') ?? '', + enabled: Boolean(layer?.enabled), + count: layer?.module.getStats().count ?? -1, + }; +} + +/** Press a key on the focused GNSS toggle and wait for the lifecycle to settle. */ +async function pressToggle(page, key, wantEnabled) { + await page.evaluate((id) => { + document.querySelector(`[data-layer-id="${id}"] .data-toggle-btn`)?.focus(); + }, LAYER_ID); + await page.keyboard.press(key); + await page + .waitForFunction( + (id, want) => { + const layer = window.__godsEyeView.dataManager.layers.get(id); + return ( + Boolean(layer?.enabled) === want && + (!want || layer.module.getStats().count > 0) + ); + }, + { timeout: 30000, polling: 250 }, + LAYER_ID, + wantEnabled, + ) + .catch(() => {}); + return page.evaluate(`(${toggleState})(${JSON.stringify(LAYER_ID)})`); +} + async function main() { console.log('\nGNSS Integrity proof (qa-gnss)'); console.log(` App URL : ${APP_URL}\n`); @@ -272,31 +350,36 @@ async function main() { const liveBodies = []; live.on('response', (res) => { if (res.url().includes('/api/gnss-integrity') && res.ok()) - liveBodies.push(res.json().then((body) => body.rows?.length ?? 0)); + liveBodies.push(res.json().then((body) => body.rows ?? [])); }); await boot(live, { lon: 19.5, lat: 52.5, height: 1_100_000 }); const l = await enableAndSettle(live); const legendTotal = l.legend.reduce((sum, { count }) => sum + count, 0); - const liveRows = Math.max( - 0, - ...(await Promise.allSettled(liveBodies)) - .filter((r) => r.status === 'fulfilled') - .map((r) => r.value), - ); + // Bin what adsb.lol actually returned with the layer's own rules: live + // traffic varies by hour, so cells are required only when some cell + // really reached the minimum aircraft count. + const liveSnapshots = (await Promise.allSettled(liveBodies)) + .filter((r) => r.status === 'fulfilled') + .map((r) => r.value); + const liveRows = liveSnapshots.reduce((sum, rows) => sum + rows.length, 0); + const expectedStore = new Map(); + for (const rows of liveSnapshots) + accumulateGnssObservations(expectedStore, rows, Date.now()); + const expectedCells = binGnssCells(expectedStore).length; record( 'LIVE: no error, one entity per cell', !l.stats.error && l.entities === l.stats.count, `cells=${l.stats.count} entities=${l.entities} error=${JSON.stringify(l.stats.error)}`, ); - if (liveRows >= LIVE_MIN_ROWS) { + if (expectedCells > 0) { record( - 'LIVE: a busy snapshot yields cells', + 'LIVE: a snapshot with a qualifying cell yields cells', l.stats.count > 0, - `rows=${liveRows} cells=${l.stats.count}`, + `rows=${liveRows} expected=${expectedCells} cells=${l.stats.count}`, ); } else { console.log( - ` [info] only ${liveRows} live rows — cell count not asserted`, + ` [info] ${liveRows} live rows, no cell reaches the minimum — cell count not asserted`, ); } record( @@ -323,24 +406,7 @@ async function main() { // Stub the route in-page rather than with request interception: an // intercepted page stalls Cesium's geometry workers, so ground rectangles // never finish building and the screenshot would not show real behaviour. - await page.evaluateOnNewDocument((payload) => { - const realFetch = window.fetch.bind(window); - window.__gnssQaMode = 'fixture'; - window.fetch = (input, init) => { - const url = typeof input === 'string' ? input : input?.url || ''; - if (!url.includes('/api/gnss-integrity')) return realFetch(input, init); - const ok = window.__gnssQaMode === 'fixture'; - const body = ok - ? { ...payload, fetchedAt: Date.now() } - : { error: 'gnss_integrity_unavailable' }; - return Promise.resolve( - new Response(JSON.stringify(body), { - status: ok ? 200 : 502, - headers: { 'Content-Type': 'application/json' }, - }), - ); - }; - }, fixturePayload()); + await stubRoute(page); await boot(page, { lon: 18.6, lat: 54.6, height: 450_000 }); const f = await enableAndSettle(page); const band = (prefix) => @@ -415,6 +481,100 @@ async function main() { }, LAYER_ID); record('DISABLE: data source hidden', shown === false, `show=${shown}`); await shoot(page, 'disabled'); + + // ── (v) KEYBOARD ────────────────────────────────────────────────────── + console.log('\n(v) KEYBOARD — the row toggle, from the keyboard...'); + await page.evaluate(() => { + window.__gnssQaMode = 'fixture'; + }); + await showLayerRow(page); + const on = await pressToggle(page, 'Enter', true); + record( + 'KEYBOARD: Enter on the focused toggle enables the layer and keeps focus', + on.enabled && + on.focused && + on.count === 4 && + /^GNSS Integrity: /.test(on.label) && + !/OFF$/.test(on.label), + JSON.stringify(on), + ); + const off = await pressToggle(page, ' ', false); + record( + 'KEYBOARD: Space on the focused toggle disables it and the label says OFF', + !off.enabled && off.focused && /^GNSS Integrity: OFF$/.test(off.label), + JSON.stringify(off), + ); + + // ── (vi) VIEWPORT ───────────────────────────────────────────────────── + console.log( + '\n(vi) VIEWPORT — the open row on a 390x844 phone viewport...', + ); + await page.close(); + // A fresh page: switching an open page to isMobile reloads it, which + // would measure a half-booted app instead of the open row. + const phone = await browser.newPage(); + await phone.setViewport({ width: 390, height: 844, isMobile: true }); + phone.on('pageerror', (e) => errors.push(e.message)); + await stubRoute(phone); + await boot(phone, { lon: 18.6, lat: 54.6, height: 450_000 }); + await showLayerRow(phone); + await pressToggle(phone, 'Enter', true); + await showLayerRow(phone); + const fit = await phone.evaluate((id) => { + const row = document.querySelector(`[data-layer-id="${id}"]`); + const legend = [...row.querySelectorAll('.data-toggle-legend-item')]; + const rect = row.getBoundingClientRect(); + return { + width: window.innerWidth, + rowLeft: Math.round(rect.left), + rowRight: Math.round(rect.right), + overflow: row.scrollWidth - row.clientWidth, + legendItems: legend.length, + legendInside: legend.every((item) => { + const r = item.getBoundingClientRect(); + return ( + r.width > 0 && r.left >= rect.left - 1 && r.right <= rect.right + 1 + ); + }), + }; + }, LAYER_ID); + record( + 'VIEWPORT: row and legend fit a 390 px viewport without horizontal overflow', + fit.rowLeft >= 0 && + fit.rowRight <= fit.width && + fit.overflow <= 0 && + fit.legendItems === 3 && + fit.legendInside, + JSON.stringify(fit), + ); + await phone.screenshot({ path: path.join(SHOTS_DIR, 'gnss-phone.png') }); + await phone.close(); + + // ── (vii) AGE ───────────────────────────────────────────────────────── + console.log( + '\n(vii) AGE — a replayed stale snapshot keeps its observation time...', + ); + const aged = await browser.newPage(); + await aged.setViewport({ width: 1440, height: 900 }); + aged.on('pageerror', (e) => errors.push(e.message)); + await stubRoute(aged, { ageMs: 31 * 60_000 }); + await boot(aged, { lon: 18.6, lat: 54.6, height: 450_000 }); + const old = await enableAndSettle(aged, { timeoutS: 5 }); + record( + 'AGE: a snapshot observed 31 min ago adds no cells and is not an error', + old.stats.count === 0 && old.entities === 0 && !old.stats.error, + `cells=${old.stats.count} entities=${old.entities} error=${JSON.stringify(old.stats.error)}`, + ); + await aged.evaluate(() => { + window.__gnssQaAgeMs = 29 * 60_000; + }); + const recent = await enableAndSettle(aged, { refresh: true }); + record( + 'AGE: the same snapshot observed 29 min ago is still inside the window', + recent.stats.count === 4 && recent.entities === 4, + `cells=${recent.stats.count} entities=${recent.entities}`, + ); + await aged.close(); record( 'no uncaught browser errors', errors.length === 0, diff --git a/server/providers/gnssIntegrity.js b/server/providers/gnssIntegrity.js index 7cb79c68f..77f674a50 100644 --- a/server/providers/gnssIntegrity.js +++ b/server/providers/gnssIntegrity.js @@ -1,5 +1,6 @@ import { GNSS_CLASSIFIER, + GNSS_WINDOW_MS, normalizeGnssAircraft, } from '../../src/layers/gnss/records.js'; import { adsbLolFallbackAnchor } from './aircraft/opensky.js'; @@ -115,7 +116,10 @@ export function gnssIntegrityProxy({ }); return { value: await promise, stale: false }; } catch (error) { - if (previous) return { value: previous, stale: true }; + // A stale answer keeps its original fetchedAt so the layer can age it; + // past the layer's window it is no evidence at all, so it is not served. + if (previous && now() - previous.fetchedAt <= GNSS_WINDOW_MS) + return { value: previous, stale: true }; throw error; } } diff --git a/src/data/gnssIntegrityProxy.test.mjs b/src/data/gnssIntegrityProxy.test.mjs index 6639af088..1437e3218 100644 --- a/src/data/gnssIntegrityProxy.test.mjs +++ b/src/data/gnssIntegrityProxy.test.mjs @@ -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; diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index 1f5840e3f..52922af4d 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -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; diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs index 339a0b603..ba4940b10 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -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)], }); diff --git a/src/layers/gnss/records.js b/src/layers/gnss/records.js index 8e57b2f9a..b76f10e5a 100644 --- a/src/layers/gnss/records.js +++ b/src/layers/gnss/records.js @@ -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} 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} store * @param {ReturnType} 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} 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 = []; diff --git a/src/layers/gnss/records.test.mjs b/src/layers/gnss/records.test.mjs index 20546abb7..273fde10e 100644 --- a/src/layers/gnss/records.test.mjs +++ b/src/layers/gnss/records.test.mjs @@ -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( diff --git a/src/layers/gnss/source.js b/src/layers/gnss/source.js index 7d1fb0aa8..3f129d13e 100644 --- a/src/layers/gnss/source.js +++ b/src/layers/gnss/source.js @@ -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, diff --git a/src/layers/gnss/source.test.mjs b/src/layers/gnss/source.test.mjs index cb8fe372f..b906dce1c 100644 --- a/src/layers/gnss/source.test.mjs +++ b/src/layers/gnss/source.test.mjs @@ -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({}),