mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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) <[email protected]>
This commit is contained in:
1 parent
40b06a24d4
commit
6d4a2a02fe
16 files changed
+325
-67
No files matched your search
+12
-5
@@ -155,11 +155,18 @@
|
|||||||
largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed
|
largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed
|
||||||
under NLOD 2.0.
|
under NLOD 2.0.
|
||||||
|
|
||||||
- GNSS Interference layer (Events group): ADS-B aircraft that report degraded
|
- GNSS Integrity layer (Events group, share id `gnss-interference`): cells of
|
||||||
navigation accuracy (below the 14 CFR 91.227 ADS-B Out minima, NIC < 7 or
|
low navigation accuracy reported by ADS-B aircraft. The method is a GEV
|
||||||
NACp < 8, or readsb's GPS-loss flag) are binned into 0.5° cells over a
|
threshold plus gpsjam.org aggregation, not "the gpsjam method": an aircraft
|
||||||
rolling 30-minute window and scored with gpsjam.org's published formula and
|
counts as low when it reports NIC < 7 or NACp < 8 (borrowed from the US
|
||||||
bands. Keyless, worldwide wherever adsb.lol has receivers; reads
|
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`,
|
a capped 250 nm snapshot around the camera through `/api/gnss-integrity`,
|
||||||
which returns integrity fields only and serves the last snapshot when the
|
which returns integrity fields only and serves the last snapshot when the
|
||||||
upstream fails.
|
upstream fails.
|
||||||
|
|||||||
+2
-2
@@ -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 |
|
| **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 |
|
| **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 |
|
| **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) |
|
| **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 |
|
| **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) |
|
| **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)).
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+40
-5
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/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).
|
* 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
|
* 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
|
* with 1 degraded is low (first bad discounted), a clean cell at sea
|
||||||
* is low, a 2-aircraft cell is withheld; `stale: true` reaches
|
* is low, a 2-aircraft cell is withheld; `stale: true` reaches
|
||||||
* getStats. Every band is drawn and low is shown over land and sea.
|
* 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
|
* (iii) FAILURE — the route stubbed to 502: stats.error is set and the
|
||||||
* cells already on the globe are kept.
|
* cells already on the globe are kept.
|
||||||
* (iv) DISABLE — the layer's data source is hidden.
|
* (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;
|
if (stats.count > 0 || stats.error) break;
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
const legend = layer
|
const rowControls = layer.getRowControls().legend;
|
||||||
.getRowControls()
|
const legend = rowControls.map(({ label, count }) => ({ label, count }));
|
||||||
.legend.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];
|
const ds = window.__godsEyeView.viewer.dataSources.getByName(id)[0];
|
||||||
return {
|
return {
|
||||||
stats,
|
stats,
|
||||||
legend,
|
legend,
|
||||||
|
blurb,
|
||||||
|
rowName,
|
||||||
|
name: layer.name,
|
||||||
entities: ds?.entities.values.length ?? -1,
|
entities: ds?.entities.values.length ?? -1,
|
||||||
shown: ds?.show ?? null,
|
shown: ds?.show ?? null,
|
||||||
};
|
};
|
||||||
@@ -217,7 +226,7 @@ function fixturePayload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('\nGNSS Interference proof (qa-gnss)');
|
console.log('\nGNSS Integrity proof (qa-gnss)');
|
||||||
console.log(` App URL : ${APP_URL}\n`);
|
console.log(` App URL : ${APP_URL}\n`);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(APP_URL);
|
const res = await fetch(APP_URL);
|
||||||
@@ -346,6 +355,32 @@ async function main() {
|
|||||||
band('Over') === 1 && band('2') === 1 && band('Under') === 2,
|
band('Over') === 1 && band('2') === 1 && band('Under') === 2,
|
||||||
JSON.stringify(f.legend),
|
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(
|
record(
|
||||||
'BANDS: stale snapshot is reported',
|
'BANDS: stale snapshot is reported',
|
||||||
f.stats.stale === true,
|
f.stats.stale === true,
|
||||||
|
|||||||
@@ -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 { adsbLolFallbackAnchor } from './aircraft/opensky.js';
|
||||||
import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js';
|
import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js';
|
||||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||||
@@ -91,6 +94,8 @@ export function gnssIntegrityProxy({
|
|||||||
fetchedAt: now(),
|
fetchedAt: now(),
|
||||||
anchor: { lat, lon },
|
anchor: { lat, lon },
|
||||||
radiusNm: RADIUS_NM,
|
radiusNm: RADIUS_NM,
|
||||||
|
// `degraded` on each row is this classifier's verdict, not gpsjam's.
|
||||||
|
classifier: GNSS_CLASSIFIER.id,
|
||||||
rows,
|
rows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { createApplicationAwareness } from './layers/militaryAwareness.js';
|
|||||||
import { createApplicationFirms } from './layers/firms.js';
|
import { createApplicationFirms } from './layers/firms.js';
|
||||||
import { createApplicationEarthquakes } from './layers/earthquakes.js';
|
import { createApplicationEarthquakes } from './layers/earthquakes.js';
|
||||||
import { createApplicationFirePerimeters } from './layers/perimeters.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 { createApplicationCables } from './layers/submarineCables.js';
|
||||||
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
||||||
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
||||||
@@ -121,7 +121,7 @@ export function createApplicationCatalog({
|
|||||||
createApplicationFirePerimeters({
|
createApplicationFirePerimeters({
|
||||||
source: sources['fire-perimeters'],
|
source: sources['fire-perimeters'],
|
||||||
}),
|
}),
|
||||||
createApplicationGnssInterference({
|
createApplicationGnssIntegrity({
|
||||||
source: sources['gnss-interference'],
|
source: sources['gnss-interference'],
|
||||||
}),
|
}),
|
||||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||||
|
|||||||
@@ -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. */
|
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
|
||||||
export function createApplicationGnssInterference(options) {
|
export function createApplicationGnssIntegrity(options) {
|
||||||
return createGnssInterferenceLayer(options);
|
return createGnssIntegrityLayer(options);
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ export const DATA_CREDITS = [
|
|||||||
{
|
{
|
||||||
key: 'adsblol',
|
key: 'adsblol',
|
||||||
html:
|
html:
|
||||||
'Military flights, aircraft traces, bounded regional flight fallback & GNSS interference: ' +
|
'Military flights, aircraft traces, bounded regional flight fallback & GNSS navigation integrity: ' +
|
||||||
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol</a> ' +
|
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol</a> ' +
|
||||||
'(ODbL 1.0)',
|
'(ODbL 1.0)',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
|
|||||||
fetchedAt: 1234,
|
fetchedAt: 1234,
|
||||||
anchor: { lat: 50, lon: 20 },
|
anchor: { lat: 50, lon: 20 },
|
||||||
radiusNm: 250,
|
radiusNm: 250,
|
||||||
|
classifier: 'gev-nic-nacp-v1',
|
||||||
rows: [
|
rows: [
|
||||||
{
|
{
|
||||||
hex: '4ca7b5',
|
hex: '4ca7b5',
|
||||||
|
|||||||
+19
-11
@@ -2,9 +2,12 @@ import * as Cesium from 'cesium';
|
|||||||
import {
|
import {
|
||||||
GNSS_LEVEL_COLORS,
|
GNSS_LEVEL_COLORS,
|
||||||
GNSS_MIN_AIRCRAFT,
|
GNSS_MIN_AIRCRAFT,
|
||||||
|
GNSS_NACP_THRESHOLD,
|
||||||
|
GNSS_NIC_THRESHOLD,
|
||||||
GNSS_WINDOW_MS,
|
GNSS_WINDOW_MS,
|
||||||
accumulateGnssObservations,
|
accumulateGnssObservations,
|
||||||
binGnssCells,
|
binGnssCells,
|
||||||
|
gnssProvenance,
|
||||||
} from './records.js';
|
} from './records.js';
|
||||||
export * from './records.js';
|
export * from './records.js';
|
||||||
export { createAdsbGnssSource } from './source.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. */
|
/** 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 FILL_ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 });
|
||||||
const LEVEL_LABELS = Object.freeze({
|
const LEVEL_LABELS = Object.freeze({
|
||||||
low: 'Under 2% degraded',
|
low: 'Under 2% low accuracy',
|
||||||
medium: '2–10% degraded',
|
medium: '2–10% low accuracy',
|
||||||
high: 'Over 10% degraded',
|
high: 'Over 10% low accuracy',
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Degrees at the centre of the current view, or null before the camera settles. */
|
/** 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
|
* Own one GNSS navigation-integrity display: rolling integrity observations
|
||||||
* the views the user visits, binned into cells coloured by the share of
|
* around the views the user visits, binned into cells coloured by the share
|
||||||
* aircraft reporting degraded navigation integrity.
|
* 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,
|
source,
|
||||||
viewAnchor = defaultGnssViewAnchor,
|
viewAnchor = defaultGnssViewAnchor,
|
||||||
now = () => Date.now(),
|
now = () => Date.now(),
|
||||||
windowMs = GNSS_WINDOW_MS,
|
windowMs = GNSS_WINDOW_MS,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (typeof source?.getSnapshot !== 'function')
|
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 _viewer = null;
|
||||||
let _dataSource = null;
|
let _dataSource = null;
|
||||||
let _request = null;
|
let _request = null;
|
||||||
@@ -56,6 +60,7 @@ export function createGnssInterferenceLayer({
|
|||||||
let _lastError = null;
|
let _lastError = null;
|
||||||
let _stale = false;
|
let _stale = false;
|
||||||
const _observations = new Map();
|
const _observations = new Map();
|
||||||
|
const provenance = gnssProvenance({ windowMs });
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
// Only the id and band change what is drawn; counts alone do not.
|
// Only the id and band change what is drawn; counts alone do not.
|
||||||
@@ -91,14 +96,16 @@ export function createGnssInterferenceLayer({
|
|||||||
|
|
||||||
const layer = {
|
const layer = {
|
||||||
id: 'gnss-interference',
|
id: 'gnss-interference',
|
||||||
name: 'GNSS Interference',
|
name: 'GNSS Integrity',
|
||||||
icon: '📡',
|
icon: '📡',
|
||||||
source: 'adsb.lol',
|
source: 'adsb.lol',
|
||||||
|
/** Where each part of the method comes from; see records.js. */
|
||||||
|
provenance,
|
||||||
updateInterval: 60000,
|
updateInterval: 60000,
|
||||||
|
|
||||||
init(viewer) {
|
init(viewer) {
|
||||||
if (_viewer)
|
if (_viewer)
|
||||||
throw new Error('GNSS interference layer is already initialized');
|
throw new Error('GNSS integrity layer is already initialized');
|
||||||
_viewer = viewer;
|
_viewer = viewer;
|
||||||
_dataSource = new Cesium.CustomDataSource('gnss-interference');
|
_dataSource = new Cesium.CustomDataSource('gnss-interference');
|
||||||
_dataSource.show = false;
|
_dataSource.show = false;
|
||||||
@@ -179,7 +186,7 @@ export function createGnssInterferenceLayer({
|
|||||||
count: _cells.filter((cell) => cell.level === level).length,
|
count: _cells.filter((cell) => cell.level === level).length,
|
||||||
...(index === 0
|
...(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,
|
lastUpdate: _lastUpdate,
|
||||||
error: _lastError,
|
error: _lastError,
|
||||||
stale: _stale,
|
stale: _stale,
|
||||||
|
provenance,
|
||||||
// Load-bearing: without an explicit boolean, layerFeedState reads the
|
// Load-bearing: without an explicit boolean, layerFeedState reads the
|
||||||
// adsb.lol source name as a flights fallback (src/data/feedState.js).
|
// adsb.lol source name as a flights fallback (src/data/feedState.js).
|
||||||
fallback: false,
|
fallback: false,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { createGnssInterferenceLayer } from './index.js';
|
import { createGnssIntegrityLayer } from './index.js';
|
||||||
|
|
||||||
const row = (hex, lat, degraded) => ({
|
const row = (hex, lat, degraded) => ({
|
||||||
hex,
|
hex,
|
||||||
@@ -29,7 +29,7 @@ function harness(
|
|||||||
};
|
};
|
||||||
let clock = 1_000;
|
let clock = 1_000;
|
||||||
const anchors = [];
|
const anchors = [];
|
||||||
const layer = createGnssInterferenceLayer({
|
const layer = createGnssIntegrityLayer({
|
||||||
source: { getSnapshot },
|
source: { getSnapshot },
|
||||||
viewAnchor: () => {
|
viewAnchor: () => {
|
||||||
anchors.push(anchor);
|
anchors.push(anchor);
|
||||||
@@ -49,7 +49,7 @@ function harness(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('the layer requires a snapshot source and cannot be initialized twice', () => {
|
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: [] }));
|
const { layer } = harness(async () => ({ rows: [] }));
|
||||||
assert.throws(() => layer.init({ dataSources: { add() {} } }), /already/);
|
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/);
|
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 () => {
|
test('a failed refresh keeps the last cells and reports the error', async () => {
|
||||||
let fail = false;
|
let fail = false;
|
||||||
const { layer, sources } = harness(async () => {
|
const { layer, sources } = harness(async () => {
|
||||||
|
|||||||
+98
-19
@@ -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
|
* Aircraft broadcasting DO-260 version 1/2 ADS-B report how much they trust
|
||||||
* their own position: NIC (integrity containment radius) and NACp (position
|
* their own position: NIC (integrity containment radius) and NACp (position
|
||||||
* accuracy). Both collapse when the GNSS receiver is jammed or spoofed, so a
|
* accuracy). A cluster of aircraft reporting low values in one area is a
|
||||||
* cluster of aircraft reporting low values in one area is a well-known public
|
* navigation-integrity anomaly. Jamming or spoofing is one common cause, so
|
||||||
* proxy for interference (popularized by gpsjam.org, whose published cell
|
* such clusters are a well-known public proxy for GNSS interference, but
|
||||||
* formula and bands this module uses, and by the readsb/tar1090 `gpsOkBefore`
|
* avionics faults and old transponders also report low values: suspected
|
||||||
* flag). gpsjam does not publish its per-aircraft thresholds; here an aircraft
|
* interference is a second, unvalidated interpretation, never the primary
|
||||||
* is degraded when it reports below the 14 CFR 91.227(c) ADS-B Out minima
|
* state.
|
||||||
* (NIC < 7 or NACp < 8). It is an inference, not a detection:
|
*
|
||||||
* avionics faults and old transponders also report low values, which is why
|
* Three separately sourced parts make up the method (see `gnssProvenance`):
|
||||||
* cells need several aircraft and the first degraded aircraft is discounted.
|
* - 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. */
|
/** Direct 1090ES ADS-B only: MLAT, TIS-B and ADS-R positions are not self-reported. */
|
||||||
const QUALIFYING_TYPES = new Set(['adsb_icao']);
|
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;
|
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;
|
export const GNSS_NACP_THRESHOLD = 8;
|
||||||
/** Positions older than this are not current evidence of the local sky. */
|
/** Positions older than this are not current evidence of the local sky. */
|
||||||
const MAX_POSITION_AGE_S = 60;
|
const MAX_POSITION_AGE_S = 60;
|
||||||
@@ -28,6 +33,65 @@ export const GNSS_CELL_DEG = 0.5;
|
|||||||
export const GNSS_MIN_AIRCRAFT = 3;
|
export const GNSS_MIN_AIRCRAFT = 3;
|
||||||
/** Rolling window over which aircraft seen in a cell are counted. */
|
/** Rolling window over which aircraft seen in a cell are counted. */
|
||||||
export const GNSS_WINDOW_MS = 30 * 60_000;
|
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}$/;
|
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
|
* Navigation-integrity level of a cell, using gpsjam.org's published cell
|
||||||
* bands: the first degraded aircraft is discounted to limit false positives,
|
* formula and bands: the first degraded aircraft is discounted to limit false
|
||||||
* then under 2% is low, 2–10% medium and over 10% high.
|
* 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 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 {
|
return {
|
||||||
percent,
|
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.
|
* 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(
|
export function binGnssCells(
|
||||||
store,
|
store,
|
||||||
@@ -152,7 +226,10 @@ export function binGnssCells(
|
|||||||
const [row, col] = id.split(':').map(Number);
|
const [row, col] = id.split(':').map(Number);
|
||||||
const south = row * cellDeg - 90;
|
const south = row * cellDeg - 90;
|
||||||
const west = col * cellDeg - 180;
|
const west = col * cellDeg - 180;
|
||||||
const { percent, level } = gnssInterferenceLevel(aircraft, degraded);
|
const { percent, level, state, interpretation } = gnssIntegrityLevel(
|
||||||
|
aircraft,
|
||||||
|
degraded,
|
||||||
|
);
|
||||||
cells.push({
|
cells.push({
|
||||||
id,
|
id,
|
||||||
south,
|
south,
|
||||||
@@ -165,6 +242,8 @@ export function binGnssCells(
|
|||||||
degraded,
|
degraded,
|
||||||
percentDegraded: Math.round(percent * 10) / 10,
|
percentDegraded: Math.round(percent * 10) / 10,
|
||||||
level,
|
level,
|
||||||
|
state,
|
||||||
|
interpretation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return cells.sort((a, b) => a.id.localeCompare(b.id));
|
return cells.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
accumulateGnssObservations,
|
accumulateGnssObservations,
|
||||||
binGnssCells,
|
binGnssCells,
|
||||||
|
GNSS_CLASSIFIER,
|
||||||
|
GNSS_PROVENANCE,
|
||||||
gnssCellKey,
|
gnssCellKey,
|
||||||
gnssInterferenceLevel,
|
gnssIntegrityLevel,
|
||||||
|
gnssProvenance,
|
||||||
normalizeGnssAircraft,
|
normalizeGnssAircraft,
|
||||||
} from './records.js';
|
} 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');
|
assert.equal(gnssCellKey(-0.1, -0.1), '179:359');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('interference level follows the gpsjam.org published cell formula and bands', () => {
|
test('integrity level follows the gpsjam.org published cell formula and bands', () => {
|
||||||
assert.deepEqual(gnssInterferenceLevel(10, 0), { percent: 0, level: 'low' });
|
const nominal = {
|
||||||
|
percent: 0,
|
||||||
|
level: 'low',
|
||||||
|
state: 'nominal',
|
||||||
|
interpretation: null,
|
||||||
|
};
|
||||||
|
assert.deepEqual(gnssIntegrityLevel(10, 0), nominal);
|
||||||
// One degraded aircraft is always discounted.
|
// One degraded aircraft is always discounted.
|
||||||
assert.deepEqual(gnssInterferenceLevel(10, 1), { percent: 0, level: 'low' });
|
assert.deepEqual(gnssIntegrityLevel(10, 1), nominal);
|
||||||
assert.equal(gnssInterferenceLevel(50, 2).level, 'medium');
|
assert.equal(gnssIntegrityLevel(50, 2).level, 'medium');
|
||||||
assert.equal(gnssInterferenceLevel(10, 2).level, 'medium');
|
assert.equal(gnssIntegrityLevel(10, 2).level, 'medium');
|
||||||
assert.equal(gnssInterferenceLevel(10, 3).level, 'high');
|
assert.equal(gnssIntegrityLevel(10, 3).level, 'high');
|
||||||
assert.equal(gnssInterferenceLevel(0, 0).level, 'low');
|
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', () => {
|
test('observations count each aircraft once per cell and expire after the window', () => {
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export function createAdsbGnssSource({
|
|||||||
? payload.fetchedAt
|
? payload.fetchedAt
|
||||||
: null,
|
: null,
|
||||||
stale: payload.stale === true,
|
stale: payload.stale === true,
|
||||||
|
classifier:
|
||||||
|
typeof payload.classifier === 'string' ? payload.classifier : null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ test('the source asks the same-origin proxy for the view anchor', async () => {
|
|||||||
fetchImpl: async (url, options) => {
|
fetchImpl: async (url, options) => {
|
||||||
calls.push(url);
|
calls.push(url);
|
||||||
assert.ok(options.signal instanceof AbortSignal);
|
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(
|
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' }],
|
rows: [{ hex: 'abc123' }],
|
||||||
fetchedAt: 5,
|
fetchedAt: 5,
|
||||||
stale: false,
|
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 });
|
}).getSnapshot({ latitude: 1, longitude: 2 });
|
||||||
assert.equal(stale.stale, true);
|
assert.equal(stale.stale, true);
|
||||||
assert.equal(stale.fetchedAt, null);
|
assert.equal(stale.fetchedAt, null);
|
||||||
|
assert.equal(stale.classifier, null);
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}),
|
createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}),
|
||||||
|
|||||||
@@ -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 };
|
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||||
const viewer = { clock: { onTick: { addEventListener: () => () => {} } },
|
const viewer = { clock: { onTick: { addEventListener: () => () => {} } },
|
||||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||||
@@ -3272,12 +3272,12 @@ test('GNSS interference common names toggle only the GNSS layer through the norm
|
|||||||
let enabled = false;
|
let enabled = false;
|
||||||
const dataManager = {
|
const dataManager = {
|
||||||
layers: new Map([['gnss-interference', { module: {} }]]),
|
layers: new Map([['gnss-interference', { module: {} }]]),
|
||||||
getAll: () => [{ id: 'gnss-interference', name: 'GNSS Interference' }],
|
getAll: () => [{ id: 'gnss-interference', name: 'GNSS Integrity' }],
|
||||||
isEnabled: () => enabled,
|
isEnabled: () => enabled,
|
||||||
setEnabled: async (id, value) => { calls.push([id, value]); enabled = value; return true; },
|
setEnabled: async (id, value) => { calls.push([id, value]); enabled = value; return true; },
|
||||||
};
|
};
|
||||||
const runner = createGevActionRunner({ viewer, styleManager: {}, dataManager });
|
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]) {
|
for (const value of [true, false]) {
|
||||||
const result = await runner('set_layer_visibility', { layerId: alias, enabled: value });
|
const result = await runner('set_layer_visibility', { layerId: alias, enabled: value });
|
||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
|
|||||||
Reference in new issue
Block a user