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:
Leo PechnickiandClaude Opus 5.5 authored and Mazeyar Moeini Feizabadi committed 2026-10-13 17:20:43 +02:00
1 parent 40b06a24d4
commit 6d4a2a02fe
16 files changed
+325 -67

No files matched your search

+19 -11
View File
@@ -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,
+43 -3
View File
@@ -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 () => {
+98 -19
View File
@@ -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));
+83 -8
View File
@@ -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', () => {
+2
View File
@@ -31,6 +31,8 @@ export function createAdsbGnssSource({
? payload.fetchedAt
: null,
stale: payload.stale === true,
classifier:
typeof payload.classifier === 'string' ? payload.classifier : null,
};
},
};
+7 -1
View File
@@ -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({}),