From 6d4a2a02fe7e78cec819a5300141323bebd80a80 Mon Sep 17 00:00:00 2001 From: Leo Pechnicki Date: Thu, 1 Oct 2026 12:22:05 +0200 Subject: [PATCH] fix(gnss): separate GEV integrity classifier from GPSJAM aggregation; machine-readable provenance Review on #855: the per-aircraft rule (NIC < 7 || NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima) is GEV-defined, not gpsjam's, and gpsjam's map shows low navigation accuracy, not necessarily jamming. - Primary cell state is now `navigation-integrity-anomaly` (medium/high) or `nominal`; `suspected-gnss-interference` is a secondary `interpretation` marked validated: false. - GNSS_PROVENANCE / gnssProvenance({ windowMs }) records the classifier (gev-nic-nacp-v1, GEV, unvalidated), the gpsjam aggregation (formula, bands [0.02, 0.1], FAQ source) and the GEV 30-minute visited-view window, built from the constants the code applies. Exposed as layer.provenance and getStats().provenance; the proxy tags responses with `classifier`. - Visible name "GNSS Integrity", legend "... low accuracy", blurb leads with low navigation accuracy and states the threshold is a GEV choice. - Layer id `gnss-interference` and share token `0` unchanged. - Voice aliases add "gnss integrity", "navigation integrity", "low navigation accuracy"; the interference aliases stay. - Docs describe the method as GEV threshold + gpsjam aggregation. - Tests: provenance object, renamed state/label, proxy classifier tag; qa-gnss asserts provenance and wording in the real app. Hex project - Crew Leo Agile dev team. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 17 ++-- DATA_SOURCES.md | 4 +- docs/CURRENT-STATE.md | 4 +- scripts/qa-gnss.mjs | 45 +++++++++-- server/providers/gnssIntegrity.js | 7 +- src/app/constructCatalog.js | 4 +- src/app/layers/gnss.js | 8 +- src/data/dataCredits.js | 2 +- src/data/gnssIntegrityProxy.test.mjs | 1 + src/layers/gnss/index.js | 30 ++++--- src/layers/gnss/ownership.test.mjs | 46 ++++++++++- src/layers/gnss/records.js | 117 ++++++++++++++++++++++----- src/layers/gnss/records.test.mjs | 91 +++++++++++++++++++-- src/layers/gnss/source.js | 2 + src/layers/gnss/source.test.mjs | 8 +- src/voice/gevActions.test.mjs | 6 +- 16 files changed, 325 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e9c4aca..de7b1eb56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -155,11 +155,18 @@ largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed under NLOD 2.0. -- GNSS Interference layer (Events group): ADS-B aircraft that report degraded - navigation accuracy (below the 14 CFR 91.227 ADS-B Out minima, NIC < 7 or - NACp < 8, or readsb's GPS-loss flag) are binned into 0.5° cells over a - rolling 30-minute window and scored with gpsjam.org's published formula and - bands. Keyless, worldwide wherever adsb.lol has receivers; reads +- GNSS Integrity layer (Events group, share id `gnss-interference`): cells of + low navigation accuracy reported by ADS-B aircraft. The method is a GEV + threshold plus gpsjam.org aggregation, not "the gpsjam method": an aircraft + counts as low when it reports NIC < 7 or NACp < 8 (borrowed from the US + 14 CFR 91.227 ADS-B Out minima, not a published interference classifier) or + readsb's GPS-loss flag; aircraft are binned into 0.5° cells over a + GEV-defined rolling 30-minute window around visited views and scored with + gpsjam.org's published formula and 2% / 10% bands. Medium and high cells + are navigation-integrity anomalies; suspected GNSS interference is a + secondary, unvalidated interpretation. The layer and the proxy expose + 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. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 217c18486..5bea2456d 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -25,7 +25,7 @@ How to read this: | **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors | | **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required | | **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org | -| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Interference layer (per-aircraft NIC/NACp integrity only, via `/api/gnss-integrity`, 60 s server cache) | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` | +| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Integrity layer (per-aircraft NIC/NACp integrity only, via `/api/gnss-integrity`, 60 s server cache) | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` | | **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) | | **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow | | **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) | @@ -78,7 +78,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. -- **GNSS interference (adsb.lol integrity fields).** The GNSS Interference layer reads the same keyless 250 nm point snapshot around the camera (anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. An aircraft is degraded when it reports below the 14 CFR 91.227(c) ADS-B Out minima (NIC < 7 or NACp < 8) or readsb flags GPS loss; cells use gpsjam.org's published formula and bands (`100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %; at least 3 aircraft per 0.5° cell over a rolling 30-minute window). Degraded navigation accuracy indicates probable jamming or spoofing, not a confirmed source or location. Verified 2026-09-29. +- **GNSS integrity (adsb.lol integrity fields).** The GNSS Integrity layer (id `gnss-interference`) reads the same keyless 250 nm point snapshot around the camera (anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. The method has three separately sourced parts, exposed as machine-readable `provenance` on the layer and its stats: (1) a GEV-defined per-aircraft classifier (`gev-nic-nacp-v1`): an aircraft has low navigation accuracy when it reports NIC < 7 or NACp < 8 or readsb flags GPS loss. The numbers are borrowed from the 14 CFR 91.227(c) ADS-B Out performance minima, a US regulation, not a published interference classifier; gpsjam.org does not publish its per-aircraft threshold ([FAQ](https://gpsjam.org/faq)), and this one is not yet validated against known events. (2) gpsjam.org's published cell formula and bands (`100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %). (3) A GEV-defined window: at least 3 aircraft per 0.5° cell over a rolling 30 minutes around the views the user visits. Medium and high cells are navigation-integrity anomalies. Suspected jamming or spoofing is a secondary interpretation, not a confirmed source or location: avionics faults also report low values. Verified 2026-09-29. - **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it. - **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state. - **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f9f3e6391..356e232b2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3069,10 +3069,10 @@ its criteria cannot be silently ignored. | FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | | Street Level 📷 | Street-level imagery; Mapillary is the first provider (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, cached 24 h) | | Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | -| GNSS Interference 📡 | adsb.lol point snapshot, ADS-B NIC/NACp integrity binned per 0.5° cell (gpsjam.org formula and bands, 91.227 NIC/NACp minima, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | 60s (server cache 60 s; 30 min rolling window) | +| 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 Interference 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. It shows probable interference, not 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. 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 08e9123b6..33e3675e3 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node /** - * qa-gnss.mjs — headless proof for the GNSS Interference layer. + * qa-gnss.mjs — headless proof for the GNSS Integrity layer + * (id `gnss-interference`): low navigation accuracy reported by ADS-B. * * Drives the REAL app in headless Chromium against a dev server (keyless). * The live section reads adsb.lol through `/api/gnss-integrity` (60 s server @@ -16,6 +17,8 @@ * with 1 degraded is low (first bad discounted), a clean cell at sea * is low, a 2-aircraft cell is withheld; `stale: true` reaches * getStats. Every band is drawn and low is shown over land and sea. + * The row reads as low navigation accuracy, and stats carry the + * machine-readable provenance (GEV classifier, gpsjam bands, window). * (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. @@ -108,13 +111,19 @@ async function enableAndSettle(page, { timeoutS = 30, refresh = false } = {}) { if (stats.count > 0 || stats.error) break; await new Promise((r) => setTimeout(r, 1000)); } - const legend = layer - .getRowControls() - .legend.map(({ label, count }) => ({ label, count })); + const rowControls = layer.getRowControls().legend; + const legend = rowControls.map(({ label, count }) => ({ label, count })); + const blurb = rowControls[0]?.blurb ?? ''; + const rowName = + document.querySelector(`[data-layer-id="${id}"] .data-name`) + ?.textContent ?? ''; const ds = window.__godsEyeView.viewer.dataSources.getByName(id)[0]; return { stats, legend, + blurb, + rowName, + name: layer.name, entities: ds?.entities.values.length ?? -1, shown: ds?.show ?? null, }; @@ -217,7 +226,7 @@ function fixturePayload() { } async function main() { - console.log('\nGNSS Interference proof (qa-gnss)'); + console.log('\nGNSS Integrity proof (qa-gnss)'); console.log(` App URL : ${APP_URL}\n`); try { const res = await fetch(APP_URL); @@ -346,6 +355,32 @@ async function main() { band('Over') === 1 && band('2') === 1 && band('Under') === 2, JSON.stringify(f.legend), ); + const prov = f.stats.provenance; + record( + 'PROVENANCE: GEV classifier, gpsjam bands and visited-view window are machine-readable', + prov?.classifier?.id === 'gev-nic-nacp-v1' && + prov.classifier.definedBy === 'GEV' && + prov.classifier.validated === false && + prov.aggregation?.source === 'https://gpsjam.org/faq' && + JSON.stringify(prov.aggregation.bands) === '[0.02,0.1]' && + prov.window?.minutes === 30 && + prov.window.scope === 'visited-view' && + prov.interpretation?.validated === false, + JSON.stringify(prov?.classifier), + ); + record( + 'WORDING: the row leads with low navigation accuracy, interference only as the hedged reading', + f.name === 'GNSS Integrity' && + f.rowName.includes('GNSS Integrity') && + !/interference/i.test(f.rowName) && + f.legend.every(({ label }) => label.endsWith('low accuracy')) && + f.blurb.startsWith( + 'Share of ADS-B aircraft reporting low navigation accuracy', + ) && + /GEV threshold/.test(f.blurb) && + /Suspected jamming or spoofing is one possible cause/.test(f.blurb), + `name=${JSON.stringify(f.name)}`, + ); record( 'BANDS: stale snapshot is reported', f.stats.stale === true, diff --git a/server/providers/gnssIntegrity.js b/server/providers/gnssIntegrity.js index d0a96a98b..7cb79c68f 100644 --- a/server/providers/gnssIntegrity.js +++ b/server/providers/gnssIntegrity.js @@ -1,4 +1,7 @@ -import { normalizeGnssAircraft } from '../../src/layers/gnss/records.js'; +import { + GNSS_CLASSIFIER, + normalizeGnssAircraft, +} from '../../src/layers/gnss/records.js'; import { adsbLolFallbackAnchor } from './aircraft/opensky.js'; import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js'; import { makeRateLimiter, clientKey } from './common/rate-limit.js'; @@ -91,6 +94,8 @@ export function gnssIntegrityProxy({ fetchedAt: now(), anchor: { lat, lon }, radiusNm: RADIUS_NM, + // `degraded` on each row is this classifier's verdict, not gpsjam's. + classifier: GNSS_CLASSIFIER.id, rows, }; } diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index a9c9ca3d1..f78d4d5eb 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -25,7 +25,7 @@ import { createApplicationAwareness } from './layers/militaryAwareness.js'; import { createApplicationFirms } from './layers/firms.js'; import { createApplicationEarthquakes } from './layers/earthquakes.js'; import { createApplicationFirePerimeters } from './layers/perimeters.js'; -import { createApplicationGnssInterference } from './layers/gnss.js'; +import { createApplicationGnssIntegrity } from './layers/gnss.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; @@ -121,7 +121,7 @@ export function createApplicationCatalog({ createApplicationFirePerimeters({ source: sources['fire-perimeters'], }), - createApplicationGnssInterference({ + createApplicationGnssIntegrity({ source: sources['gnss-interference'], }), createApplicationAlpr({ surface, source: sources.alpr }), diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js index 5c1b38916..46983953b 100644 --- a/src/app/layers/gnss.js +++ b/src/app/layers/gnss.js @@ -1,6 +1,6 @@ -import { createGnssInterferenceLayer } from '../../layers/gnss/index.js'; +import { createGnssIntegrityLayer } from '../../layers/gnss/index.js'; -/** Wire the adsb.lol GNSS interference layer into the application catalog. */ -export function createApplicationGnssInterference(options) { - return createGnssInterferenceLayer(options); +/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */ +export function createApplicationGnssIntegrity(options) { + return createGnssIntegrityLayer(options); } diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index bdfbb4cde..8c8ea480e 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -104,7 +104,7 @@ export const DATA_CREDITS = [ { key: 'adsblol', html: - 'Military flights, aircraft traces, bounded regional flight fallback & GNSS interference: ' + + 'Military flights, aircraft traces, bounded regional flight fallback & GNSS navigation integrity: ' + 'adsb.lol ' + '(ODbL 1.0)', }, diff --git a/src/data/gnssIntegrityProxy.test.mjs b/src/data/gnssIntegrityProxy.test.mjs index 5575e64ce..6639af088 100644 --- a/src/data/gnssIntegrityProxy.test.mjs +++ b/src/data/gnssIntegrityProxy.test.mjs @@ -88,6 +88,7 @@ for (const hook of ['configureServer', 'configurePreviewServer']) { fetchedAt: 1234, anchor: { lat: 50, lon: 20 }, radiusNm: 250, + classifier: 'gev-nic-nacp-v1', rows: [ { hex: '4ca7b5', diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index 2308b0aae..1f5840e3f 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -2,9 +2,12 @@ import * as Cesium from 'cesium'; import { GNSS_LEVEL_COLORS, GNSS_MIN_AIRCRAFT, + GNSS_NACP_THRESHOLD, + GNSS_NIC_THRESHOLD, GNSS_WINDOW_MS, accumulateGnssObservations, binGnssCells, + gnssProvenance, } from './records.js'; export * from './records.js'; export { createAdsbGnssSource } from './source.js'; @@ -12,9 +15,9 @@ export { createAdsbGnssSource } from './source.js'; /** Low stays visible over land so coverage reads apart from no data. */ const FILL_ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 }); const LEVEL_LABELS = Object.freeze({ - low: 'Under 2% degraded', - medium: '2–10% degraded', - high: 'Over 10% degraded', + low: 'Under 2% low accuracy', + medium: '2–10% low accuracy', + high: 'Over 10% low accuracy', }); /** Degrees at the centre of the current view, or null before the camera settles. */ @@ -34,18 +37,19 @@ export function defaultGnssViewAnchor(viewer) { } /** - * Own one GNSS-interference display: rolling integrity observations around - * the views the user visits, binned into cells coloured by the share of - * aircraft reporting degraded navigation integrity. + * Own one GNSS navigation-integrity display: rolling integrity observations + * around the views the user visits, binned into cells coloured by the share + * of aircraft reporting low navigation accuracy. The layer id keeps its + * original `gnss-interference` spelling because share links depend on it. */ -export function createGnssInterferenceLayer({ +export function createGnssIntegrityLayer({ source, viewAnchor = defaultGnssViewAnchor, now = () => Date.now(), windowMs = GNSS_WINDOW_MS, } = {}) { if (typeof source?.getSnapshot !== 'function') - throw new TypeError('GNSS interference requires a snapshot source'); + throw new TypeError('GNSS integrity layer requires a snapshot source'); let _viewer = null; let _dataSource = null; let _request = null; @@ -56,6 +60,7 @@ export function createGnssInterferenceLayer({ let _lastError = null; let _stale = false; const _observations = new Map(); + const provenance = gnssProvenance({ windowMs }); function render() { // Only the id and band change what is drawn; counts alone do not. @@ -91,14 +96,16 @@ export function createGnssInterferenceLayer({ const layer = { id: 'gnss-interference', - name: 'GNSS Interference', + name: 'GNSS Integrity', icon: '📡', source: 'adsb.lol', + /** Where each part of the method comes from; see records.js. */ + provenance, updateInterval: 60000, init(viewer) { if (_viewer) - throw new Error('GNSS interference layer is already initialized'); + throw new Error('GNSS integrity layer is already initialized'); _viewer = viewer; _dataSource = new Cesium.CustomDataSource('gnss-interference'); _dataSource.show = false; @@ -179,7 +186,7 @@ export function createGnssInterferenceLayer({ count: _cells.filter((cell) => cell.level === level).length, ...(index === 0 ? { - blurb: `Share of ADS-B aircraft reporting low navigation integrity (NIC/NACp) over the last 30 minutes, around the views you visit. Cells with fewer than ${GNSS_MIN_AIRCRAFT} aircraft are not drawn, so blank areas mean no data, not clean GNSS. An inference of jamming or spoofing, not a detection.`, + blurb: `Share of ADS-B aircraft reporting low navigation accuracy over the last ${provenance.window.minutes} minutes, around the views you visit. An aircraft counts when it reports NIC < ${GNSS_NIC_THRESHOLD} or NACp < ${GNSS_NACP_THRESHOLD} or GPS loss: a GEV threshold borrowed from US ADS-B Out minima, not a published interference test. Cells use gpsjam.org's formula and 2% / 10% bands. Cells with fewer than ${GNSS_MIN_AIRCRAFT} aircraft are not drawn, so blank areas mean no data, not clean GNSS. Medium and high cells are navigation-integrity anomalies. Suspected jamming or spoofing is one possible cause, not a detection: avionics faults also report low values.`, } : {}), })), @@ -192,6 +199,7 @@ export function createGnssInterferenceLayer({ lastUpdate: _lastUpdate, error: _lastError, stale: _stale, + provenance, // Load-bearing: without an explicit boolean, layerFeedState reads the // adsb.lol source name as a flights fallback (src/data/feedState.js). fallback: false, diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs index 099a470f4..339a0b603 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createGnssInterferenceLayer } from './index.js'; +import { createGnssIntegrityLayer } from './index.js'; const row = (hex, lat, degraded) => ({ hex, @@ -29,7 +29,7 @@ function harness( }; let clock = 1_000; const anchors = []; - const layer = createGnssInterferenceLayer({ + const layer = createGnssIntegrityLayer({ source: { getSnapshot }, viewAnchor: () => { anchors.push(anchor); @@ -49,7 +49,7 @@ function harness( } test('the layer requires a snapshot source and cannot be initialized twice', () => { - assert.throws(() => createGnssInterferenceLayer({}), TypeError); + assert.throws(() => createGnssIntegrityLayer({}), TypeError); const { layer } = harness(async () => ({ rows: [] })); assert.throws(() => layer.init({ dataSources: { add() {} } }), /already/); }); @@ -91,6 +91,46 @@ test('an enabled refresh bins the snapshot into coloured ground cells', async () assert.match(legend[0].blurb, /fewer than 3 aircraft are not drawn/); }); +test('the visible layer leads with low navigation accuracy and exposes its provenance', async () => { + const { layer } = harness(async () => ({ rows: [], stale: false })); + assert.equal(layer.id, 'gnss-interference', 'the share-link id is unchanged'); + assert.equal(layer.name, 'GNSS Integrity'); + assert.doesNotMatch(layer.name, /interference|jamming/i); + const { legend } = layer.getRowControls(); + assert.deepEqual( + legend.map(({ label }) => label), + ['Over 10% low accuracy', '2–10% low accuracy', 'Under 2% low accuracy'], + ); + const { blurb } = legend[0]; + assert.match( + blurb, + /^Share of ADS-B aircraft reporting low navigation accuracy/, + ); + assert.match(blurb, /NIC < 7 or NACp < 8/); + assert.match(blurb, /GEV threshold/); + assert.match(blurb, /not a published interference test/); + assert.match(blurb, /gpsjam\.org's formula and 2% \/ 10% bands/); + assert.match(blurb, /navigation-integrity anomalies/); + // Interference appears only as the secondary, hedged reading. + assert.ok( + blurb.indexOf('navigation-integrity anomalies') < + blurb.indexOf('Suspected jamming or spoofing'), + ); + assert.equal(layer.provenance.classifier.id, 'gev-nic-nacp-v1'); + assert.equal(layer.provenance.classifier.definedBy, 'GEV'); + assert.equal(layer.provenance.classifier.validated, false); + assert.equal(layer.provenance.aggregation.source, 'https://gpsjam.org/faq'); + assert.deepEqual(layer.provenance.window, { + minutes: 30, + scope: 'visited-view', + cellDeg: 0.5, + minAircraft: 3, + definedBy: 'GEV', + }); + assert.equal(layer.provenance.interpretation.validated, false); + assert.equal(layer.getStats().provenance, layer.provenance); +}); + test('a failed refresh keeps the last cells and reports the error', async () => { let fail = false; const { layer, sources } = harness(async () => { diff --git a/src/layers/gnss/records.js b/src/layers/gnss/records.js index 25353fd28..8e57b2f9a 100644 --- a/src/layers/gnss/records.js +++ b/src/layers/gnss/records.js @@ -1,24 +1,29 @@ /** - * GNSS interference inferred from ADS-B navigation integrity. + * Low navigation accuracy reported by ADS-B aircraft, binned into cells. * * Aircraft broadcasting DO-260 version 1/2 ADS-B report how much they trust * their own position: NIC (integrity containment radius) and NACp (position - * accuracy). Both collapse when the GNSS receiver is jammed or spoofed, so a - * cluster of aircraft reporting low values in one area is a well-known public - * proxy for interference (popularized by gpsjam.org, whose published cell - * formula and bands this module uses, and by the readsb/tar1090 `gpsOkBefore` - * flag). gpsjam does not publish its per-aircraft thresholds; here an aircraft - * is degraded when it reports below the 14 CFR 91.227(c) ADS-B Out minima - * (NIC < 7 or NACp < 8). It is an inference, not a detection: - * avionics faults and old transponders also report low values, which is why - * cells need several aircraft and the first degraded aircraft is discounted. + * accuracy). A cluster of aircraft reporting low values in one area is a + * navigation-integrity anomaly. Jamming or spoofing is one common cause, so + * such clusters are a well-known public proxy for GNSS interference, but + * avionics faults and old transponders also report low values: suspected + * interference is a second, unvalidated interpretation, never the primary + * state. + * + * Three separately sourced parts make up the method (see `gnssProvenance`): + * - Per-aircraft classifier, defined by GEV: NIC < 7 or NACp < 8 (borrowed + * from the US 14 CFR 91.227(c) ADS-B Out performance minima, which are not + * a published interference classifier) or the readsb/tar1090 `gpsOkBefore` + * flag. gpsjam.org does not publish its per-aircraft threshold. + * - Cell aggregation: gpsjam.org's published formula and 2% / 10% bands. + * - Window: a GEV-defined rolling 30 minutes over the views the user visits. */ /** Direct 1090ES ADS-B only: MLAT, TIS-B and ADS-R positions are not self-reported. */ const QUALIFYING_TYPES = new Set(['adsb_icao']); -/** NIC below 7 means a containment radius of 0.2 NM or more (below the 91.227 ADS-B Out minimum). */ +/** 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 means an estimated position uncertainty of 93 m or more (below the 91.227 minimum). */ +/** NACp below 8: position uncertainty of 93 m or more (below the US 91.227 minimum). GEV threshold. */ export const GNSS_NACP_THRESHOLD = 8; /** Positions older than this are not current evidence of the local sky. */ const MAX_POSITION_AGE_S = 60; @@ -28,6 +33,65 @@ export const GNSS_CELL_DEG = 0.5; export const GNSS_MIN_AIRCRAFT = 3; /** Rolling window over which aircraft seen in a cell are counted. */ export const GNSS_WINDOW_MS = 30 * 60_000; +/** gpsjam.org band edges, in percent of aircraft (after discounting one). */ +export const GNSS_MEDIUM_PERCENT = 2; +export const GNSS_HIGH_PERCENT = 10; + +/** Primary cell state: what the data shows (low navigation accuracy). */ +export const GNSS_ANOMALY_STATE = 'navigation-integrity-anomaly'; +export const GNSS_NOMINAL_STATE = 'nominal'; +/** Secondary, unvalidated reading of an anomaly cell. */ +export const GNSS_INTERPRETATION = 'suspected-gnss-interference'; + +/** The per-aircraft rule; GEV-defined and not validated against known events. */ +export const GNSS_CLASSIFIER = Object.freeze({ + id: 'gev-nic-nacp-v1', + rule: `gpsOkBefore set || NIC < ${GNSS_NIC_THRESHOLD} || NACp < ${GNSS_NACP_THRESHOLD}`, + basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)', + definedBy: 'GEV', + validated: false, +}); + +/** + * Machine-readable provenance of the cell output: which part of the method + * comes from where, and which parts are unvalidated. + * + * @param {{windowMs?: number}} [options] The window the layer actually uses. + */ +export function gnssProvenance({ windowMs = GNSS_WINDOW_MS } = {}) { + return Object.freeze({ + classifier: GNSS_CLASSIFIER, + aggregation: Object.freeze({ + id: 'gpsjam-cell-bands', + formula: '100 * max(0, bad - 1) / total', + bands: Object.freeze([ + GNSS_MEDIUM_PERCENT / 100, + GNSS_HIGH_PERCENT / 100, + ]), + source: 'https://gpsjam.org/faq', + definedBy: 'gpsjam.org', + }), + window: Object.freeze({ + minutes: windowMs / 60_000, + scope: 'visited-view', + cellDeg: GNSS_CELL_DEG, + minAircraft: GNSS_MIN_AIRCRAFT, + definedBy: 'GEV', + }), + state: Object.freeze({ + id: GNSS_ANOMALY_STATE, + meaning: 'low navigation accuracy reported by ADS-B aircraft', + }), + interpretation: Object.freeze({ + id: GNSS_INTERPRETATION, + validated: false, + note: 'one possible cause of an anomaly cell; avionics faults also report low values', + }), + }); +} + +/** Provenance at the default window. */ +export const GNSS_PROVENANCE = gnssProvenance(); const HEX_PATTERN = /^~?[0-9a-f]{6}$/; @@ -118,22 +182,32 @@ export function accumulateGnssObservations( } /** - * Interference level of a cell, using gpsjam.org's published cell formula and - * bands: the first degraded aircraft is discounted to limit false positives, - * then under 2% is low, 2–10% medium and over 10% high. + * Navigation-integrity level of a cell, using gpsjam.org's published cell + * formula and bands: the first degraded aircraft is discounted to limit false + * positives, then under 2% is low, 2–10% medium and over 10% high. Medium and + * high cells are navigation-integrity anomalies. */ -export function gnssInterferenceLevel(total, degraded) { +export function gnssIntegrityLevel(total, degraded) { const percent = total > 0 ? (100 * Math.max(0, degraded - 1)) / total : 0; + const level = + percent > GNSS_HIGH_PERCENT + ? 'high' + : percent >= GNSS_MEDIUM_PERCENT + ? 'medium' + : 'low'; + const anomaly = level !== 'low'; return { percent, - level: percent > 10 ? 'high' : percent >= 2 ? 'medium' : 'low', + level, + state: anomaly ? GNSS_ANOMALY_STATE : GNSS_NOMINAL_STATE, + interpretation: anomaly ? GNSS_INTERPRETATION : null, }; } /** * Aggregate the observation store into displayable cells. * - * @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'}[]} + * @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}[]} */ export function binGnssCells( store, @@ -152,7 +226,10 @@ export function binGnssCells( const [row, col] = id.split(':').map(Number); const south = row * cellDeg - 90; const west = col * cellDeg - 180; - const { percent, level } = gnssInterferenceLevel(aircraft, degraded); + const { percent, level, state, interpretation } = gnssIntegrityLevel( + aircraft, + degraded, + ); cells.push({ id, south, @@ -165,6 +242,8 @@ export function binGnssCells( degraded, percentDegraded: Math.round(percent * 10) / 10, level, + state, + interpretation, }); } return cells.sort((a, b) => a.id.localeCompare(b.id)); diff --git a/src/layers/gnss/records.test.mjs b/src/layers/gnss/records.test.mjs index 9188fd839..20546abb7 100644 --- a/src/layers/gnss/records.test.mjs +++ b/src/layers/gnss/records.test.mjs @@ -3,8 +3,11 @@ import test from 'node:test'; import { accumulateGnssObservations, binGnssCells, + GNSS_CLASSIFIER, + GNSS_PROVENANCE, gnssCellKey, - gnssInterferenceLevel, + gnssIntegrityLevel, + gnssProvenance, normalizeGnssAircraft, } from './records.js'; @@ -93,14 +96,86 @@ test('cell keys are stable half-degree bins, clamped at the poles and antimeridi assert.equal(gnssCellKey(-0.1, -0.1), '179:359'); }); -test('interference level follows the gpsjam.org published cell formula and bands', () => { - assert.deepEqual(gnssInterferenceLevel(10, 0), { percent: 0, level: 'low' }); +test('integrity level follows the gpsjam.org published cell formula and bands', () => { + const nominal = { + percent: 0, + level: 'low', + state: 'nominal', + interpretation: null, + }; + assert.deepEqual(gnssIntegrityLevel(10, 0), nominal); // One degraded aircraft is always discounted. - assert.deepEqual(gnssInterferenceLevel(10, 1), { percent: 0, level: 'low' }); - assert.equal(gnssInterferenceLevel(50, 2).level, 'medium'); - assert.equal(gnssInterferenceLevel(10, 2).level, 'medium'); - assert.equal(gnssInterferenceLevel(10, 3).level, 'high'); - assert.equal(gnssInterferenceLevel(0, 0).level, 'low'); + assert.deepEqual(gnssIntegrityLevel(10, 1), nominal); + assert.equal(gnssIntegrityLevel(50, 2).level, 'medium'); + assert.equal(gnssIntegrityLevel(10, 2).level, 'medium'); + assert.equal(gnssIntegrityLevel(10, 3).level, 'high'); + assert.equal(gnssIntegrityLevel(0, 0).level, 'low'); +}); + +test('the primary state is a navigation-integrity anomaly; interference is only the secondary reading', () => { + for (const [total, bad] of [ + [50, 2], + [10, 3], + ]) { + const { state, interpretation } = gnssIntegrityLevel(total, bad); + assert.equal(state, 'navigation-integrity-anomaly'); + assert.equal(interpretation, 'suspected-gnss-interference'); + } + const store = new Map(); + accumulateGnssObservations( + store, + [ + { hex: 'h1', lat: 10.1, lon: 10.1, degraded: true }, + { hex: 'h2', lat: 10.2, lon: 10.2, degraded: true }, + { hex: 'h3', lat: 10.3, lon: 10.3, degraded: false }, + ], + 0, + ); + const [cell] = binGnssCells(store); + assert.equal(cell.level, 'high'); + assert.equal(cell.state, 'navigation-integrity-anomaly'); + assert.equal(cell.interpretation, 'suspected-gnss-interference'); + for (const key of Object.keys(cell)) + assert.doesNotMatch(key, /interference|jamming|spoof/i); +}); + +test('provenance separates the GEV classifier from the gpsjam aggregation and the GEV window', () => { + assert.deepEqual(GNSS_PROVENANCE.classifier, { + id: 'gev-nic-nacp-v1', + rule: 'gpsOkBefore set || NIC < 7 || NACp < 8', + basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)', + definedBy: 'GEV', + validated: false, + }); + assert.equal(GNSS_PROVENANCE.classifier, GNSS_CLASSIFIER); + assert.deepEqual(GNSS_PROVENANCE.aggregation, { + id: 'gpsjam-cell-bands', + formula: '100 * max(0, bad - 1) / total', + bands: [0.02, 0.1], + source: 'https://gpsjam.org/faq', + definedBy: 'gpsjam.org', + }); + assert.deepEqual(GNSS_PROVENANCE.window, { + minutes: 30, + scope: 'visited-view', + cellDeg: 0.5, + minAircraft: 3, + definedBy: 'GEV', + }); + assert.equal(GNSS_PROVENANCE.state.id, 'navigation-integrity-anomaly'); + assert.equal( + GNSS_PROVENANCE.interpretation.id, + 'suspected-gnss-interference', + ); + assert.equal(GNSS_PROVENANCE.interpretation.validated, false); + assert.ok(Object.isFrozen(GNSS_PROVENANCE)); + assert.ok(Object.isFrozen(GNSS_PROVENANCE.aggregation.bands)); + // The bands in provenance are the ones the level function applies. + const [medium, high] = GNSS_PROVENANCE.aggregation.bands; + assert.equal(gnssIntegrityLevel(100, 1 + 100 * medium).level, 'medium'); + assert.equal(gnssIntegrityLevel(100, 1 + 100 * high).level, 'medium'); + assert.equal(gnssIntegrityLevel(100, 2 + 100 * high).level, 'high'); + assert.equal(gnssProvenance({ windowMs: 10 * 60_000 }).window.minutes, 10); }); test('observations count each aircraft once per cell and expire after the window', () => { diff --git a/src/layers/gnss/source.js b/src/layers/gnss/source.js index 4feba891e..7d1fb0aa8 100644 --- a/src/layers/gnss/source.js +++ b/src/layers/gnss/source.js @@ -31,6 +31,8 @@ export function createAdsbGnssSource({ ? payload.fetchedAt : null, 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 8aad9b988..cb8fe372f 100644 --- a/src/layers/gnss/source.test.mjs +++ b/src/layers/gnss/source.test.mjs @@ -8,7 +8,11 @@ test('the source asks the same-origin proxy for the view anchor', async () => { fetchImpl: async (url, options) => { calls.push(url); assert.ok(options.signal instanceof AbortSignal); - return Response.json({ fetchedAt: 5, rows: [{ hex: 'abc123' }] }); + return Response.json({ + fetchedAt: 5, + classifier: 'gev-nic-nacp-v1', + rows: [{ hex: 'abc123' }], + }); }, }); const snapshot = await source.getSnapshot( @@ -20,6 +24,7 @@ test('the source asks the same-origin proxy for the view anchor', async () => { rows: [{ hex: 'abc123' }], fetchedAt: 5, stale: false, + classifier: 'gev-nic-nacp-v1', }); }); @@ -30,6 +35,7 @@ test('the source reports stale proxy data and rejects bad input or responses', a }).getSnapshot({ latitude: 1, longitude: 2 }); assert.equal(stale.stale, true); assert.equal(stale.fetchedAt, null); + assert.equal(stale.classifier, null); await assert.rejects( createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}), diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index b8d911767..55daa80e9 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3263,7 +3263,7 @@ test('ALPR common names toggle only the registered camera layer through the norm } }); -test('GNSS interference common names toggle only the GNSS layer through the normal voice action', async () => { +test('GNSS integrity and interference common names toggle only the GNSS layer through the normal voice action', async () => { globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; const viewer = { clock: { onTick: { addEventListener: () => () => {} } }, scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, @@ -3272,12 +3272,12 @@ test('GNSS interference common names toggle only the GNSS layer through the norm let enabled = false; const dataManager = { layers: new Map([['gnss-interference', { module: {} }]]), - getAll: () => [{ id: 'gnss-interference', name: 'GNSS Interference' }], + getAll: () => [{ id: 'gnss-interference', name: 'GNSS Integrity' }], isEnabled: () => enabled, setEnabled: async (id, value) => { calls.push([id, value]); enabled = value; return true; }, }; const runner = createGevActionRunner({ viewer, styleManager: {}, dataManager }); - for (const alias of ['gnss-interference', 'gnss interference', 'gps interference', 'gps jamming', 'gnss']) { + for (const alias of ['gnss-interference', 'gnss integrity', 'navigation integrity', 'low navigation accuracy', 'gnss interference', 'gps interference', 'gps jamming', 'gnss']) { for (const value of [true, false]) { const result = await runner('set_layer_visibility', { layerId: alias, enabled: value }); assert.equal(result.ok, true);