fix: address GNSS integrity and aircraft emergency review findings

This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-10-14 17:43:13 +02:00
1 parent 4240d04642
commit 01a8ad7aa7
44 files changed
+1282 -388

No files matched your search

+80 -52
View File
@@ -2,8 +2,6 @@ import * as Cesium from 'cesium';
import {
GNSS_LEVEL_COLORS,
GNSS_MIN_AIRCRAFT,
GNSS_NACP_THRESHOLD,
GNSS_NIC_THRESHOLD,
GNSS_WINDOW_MS,
accumulateGnssObservations,
binGnssCells,
@@ -13,12 +11,23 @@ import {
export * from './records.js';
export { createAdsbGnssSource } from './source.js';
/**
* GroundPrimitive allowPicking:false suppresses ids but still submits stencil
* commands in Cesium's pick pass, erasing picks of overlapping ground geometry.
* This fill participates only in rendering, so sibling polygons remain pickable.
*/
class GnssGroundFill extends Cesium.GroundPrimitive {
update(frameState) {
if (!frameState.passes.pick) super.update(frameState);
}
}
/** 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% low accuracy',
medium: '2–10% low accuracy',
high: 'Over 10% low accuracy',
low: 'Low-accuracy score < 2%',
medium: 'Low-accuracy score 2–10%',
high: 'Low-accuracy score > 10%',
});
/** Degrees at the centre of the current view, or null before the camera settles. */
@@ -34,7 +43,8 @@ export function defaultGnssViewAnchor(viewer) {
new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
ellipsoid,
);
const rectangle = viewer?.camera?.computeViewRectangle?.(ellipsoid);
const rectangle =
!centre && viewer?.camera?.computeViewRectangle?.(ellipsoid);
const carto = centre
? (ellipsoid || Cesium.Ellipsoid.WGS84).cartesianToCartographic(centre)
: rectangle
@@ -50,20 +60,20 @@ export function defaultGnssViewAnchor(viewer) {
/**
* 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.
* around the views the user visits, binned into cells coloured by the
* gpsjam low-accuracy score. Share links retain token 3.
*/
export function createGnssIntegrityLayer({
source,
viewAnchor = defaultGnssViewAnchor,
now = () => Date.now(),
monotonicNow = () => performance.now(),
windowMs = GNSS_WINDOW_MS,
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('GNSS integrity layer requires a snapshot source');
let _viewer = null;
let _dataSource = null;
let _primitives = null;
let _request = null;
let _enabled = false;
let _cells = [];
@@ -91,7 +101,7 @@ export function createGnssIntegrityLayer({
: null;
function scheduleCameraRefresh() {
if (!_enabled || !_dataSource) return;
if (!_enabled || !_primitives) return;
if (now() < _retryAt) return;
const key = queryKey(viewAnchor(_viewer));
if (!key || key === _lastQueryKey) return;
@@ -101,12 +111,12 @@ export function createGnssIntegrityLayer({
_cameraTimer = setTimeout(
async () => {
_cameraTimer = null;
if (!_enabled || _backgroundRefresh) return;
if (!_enabled || _backgroundRefresh || _request) return;
const key = queryKey(viewAnchor(_viewer));
if (!key || key === _lastQueryKey) return;
await refreshInBackground();
},
Math.max(600, 2500 - (now() - _lastRequestAt)),
Math.max(600, 2500 - (monotonicNow() - _lastRequestAt)),
);
}
@@ -157,26 +167,40 @@ export function createGnssIntegrityLayer({
);
if (signature === _signature) return;
_signature = signature;
_dataSource.entities.removeAll();
for (const cell of _cells) {
const color = Cesium.Color.fromCssColorString(
GNSS_LEVEL_COLORS[cell.level],
);
_dataSource.entities.add(
new Cesium.Entity({
id: `gnss-interference:${cell.id}`,
rectangle: {
coordinates: Cesium.Rectangle.fromDegrees(
cell.west,
cell.south,
cell.east,
cell.north,
),
material: new Cesium.ColorMaterialProperty(
color.withAlpha(FILL_ALPHA[cell.level]),
),
classificationType: Cesium.ClassificationType.BOTH,
},
_primitives.removeAll();
if (_cells.length) {
_primitives.add(
new GnssGroundFill({
geometryInstances: _cells.map(
(cell) =>
new Cesium.GeometryInstance({
id: `${layer.id}:${cell.id}`,
geometry: new Cesium.RectangleGeometry({
rectangle: Cesium.Rectangle.fromDegrees(
cell.west,
cell.south,
cell.east,
cell.north,
),
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
Cesium.Color.fromCssColorString(
GNSS_LEVEL_COLORS[cell.level],
).withAlpha(FILL_ALPHA[cell.level]),
),
},
}),
),
appearance: new Cesium.PerInstanceColorAppearance({
flat: true,
translucent: true,
}),
classificationType: Cesium.ClassificationType.BOTH,
allowPicking: false,
// Retain the compact rectangle descriptors for inspection, not Entity visualizers.
releaseGeometryInstances: false,
}),
);
}
@@ -184,7 +208,7 @@ export function createGnssIntegrityLayer({
}
const layer = {
id: 'gnss-interference',
id: 'gnss-integrity',
name: 'GNSS Integrity',
icon: '📡',
source: 'adsb.lol',
@@ -200,9 +224,10 @@ export function createGnssIntegrityLayer({
if (_viewer)
throw new Error('GNSS integrity layer is already initialized');
_viewer = viewer;
_dataSource = new Cesium.CustomDataSource('gnss-interference');
_dataSource.show = false;
viewer.dataSources.add(_dataSource);
_primitives = new Cesium.PrimitiveCollection();
_primitives.id = layer.id;
_primitives.show = false;
viewer.scene.groundPrimitives.add(_primitives);
_removeCameraEnd = viewer.camera?.moveEnd?.addEventListener(
scheduleCameraRefresh,
);
@@ -214,9 +239,9 @@ export function createGnssIntegrityLayer({
enable() {
_enabled = true;
// Cells left from before a long disable must not reappear past the window.
if (_dataSource) {
if (_primitives) {
expire();
_dataSource.show = true;
_primitives.show = true;
}
},
@@ -230,11 +255,11 @@ export function createGnssIntegrityLayer({
_request?.abort();
_request = null;
_enabled = false;
if (_dataSource) _dataSource.show = false;
if (_primitives) _primitives.show = false;
},
async update(_viewerArg, { signal } = {}) {
if (!_enabled || !_dataSource || signal?.aborted) return false;
if (!_enabled || !_primitives || signal?.aborted) return false;
if (now() < _retryAt) {
expire();
scheduleRetry();
@@ -257,7 +282,7 @@ export function createGnssIntegrityLayer({
const onAbort = () => request.abort(signal.reason);
signal?.addEventListener('abort', onAbort, { once: true });
_lastQueryKey = queryKey(anchor);
_lastRequestAt = now();
_lastRequestAt = monotonicNow();
_coverage = {
kind: 'regional',
latitude: Math.round(anchor.latitude),
@@ -283,7 +308,7 @@ export function createGnssIntegrityLayer({
_cells = binGnssCells(_observations);
render();
_stale = snapshot.stale === true;
_lastUpdate = now();
_lastUpdate = at - snapshot.ageMs;
_lastError = null;
_retryAt = 0;
_failureCount = 0;
@@ -325,15 +350,12 @@ export function createGnssIntegrityLayer({
_removeCameraChanged?.();
_removeCameraEnd = null;
_removeCameraChanged = null;
_request?.abort();
_request = null;
_enabled = false;
_observations.clear();
_cells = [];
_signature = null;
if (_dataSource) {
viewer.dataSources.remove(_dataSource, true);
_dataSource = null;
if (_primitives) {
viewer.scene.groundPrimitives.remove(_primitives);
_primitives = null;
}
_viewer = null;
_lastUpdate = null;
@@ -350,7 +372,7 @@ export function createGnssIntegrityLayer({
return {
chips: [],
info:
'Regional sample · 250 nm radius · pan to sample another area' +
'Regional sample · 250 nm radius · cells from visited views over 30 min' +
(_lastError && _retryAt
? ` · ${_lastError} · Retrying automatically${
_retryAt <= 8640000000000000
@@ -359,14 +381,14 @@ export function createGnssIntegrityLayer({
}`
: ''),
infoTitle:
'Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells have insufficient observations.',
'Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.',
legend: ['high', 'medium', 'low'].map((level, index) => ({
label: LEVEL_LABELS[level],
color: GNSS_LEVEL_COLORS[level],
count: _cells.filter((cell) => cell.level === level).length,
...(index === 0
? {
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.`,
blurb: `Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need ${GNSS_MIN_AIRCRAFT} aircraft over ${provenance.window.minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.`,
}
: {}),
})),
@@ -376,6 +398,12 @@ export function createGnssIntegrityLayer({
getStats() {
return {
count: _cells.length,
levels: Object.fromEntries(
['low', 'medium', 'high'].map((level) => [
level,
_cells.filter((cell) => cell.level === level).length,
]),
),
lastUpdate: _lastUpdate,
error: _lastError,
retryInSec: _retryAt
+181 -38
View File
@@ -26,12 +26,16 @@ function harness(
const sources = [];
const viewer = {
camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() },
dataSources: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
scene: {
globe: { show: true },
groundPrimitives: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
value.destroy();
},
},
},
};
@@ -50,6 +54,7 @@ function harness(
return anchor;
},
now: () => clock,
monotonicNow: () => clock,
});
if (initialize) layer.init(viewer);
return {
@@ -87,7 +92,9 @@ test('a globe-wide rectangle uses the visible screen centre as the query anchor'
assert.deepEqual(pixel, new Cesium.Cartesian2(500, 400));
return Cesium.Cartesian3.fromDegrees(-97, 30);
},
computeViewRectangle: () => Cesium.Rectangle.MAX_VALUE,
computeViewRectangle: () => {
throw new Error('centre hit must not compute a rectangle');
},
},
});
assert.ok(Math.abs(anchor.latitude - 30) < 1e-8);
@@ -208,10 +215,11 @@ test('an enabled refresh bins the snapshot into coloured ground cells', async ()
layer.enable();
assert.equal(sources[0].show, true);
assert.equal(await layer.update(), true);
const entities = sources[0].entities.values;
const entities = sources[0].get(0).geometryInstances;
assert.equal(entities.length, 1, 'the one-aircraft cell is withheld');
assert.equal(entities[0].id, 'gnss-interference:280:420');
assert.ok(entities[0].rectangle);
assert.equal(entities[0].id, 'gnss-integrity:280:420');
assert.ok(entities[0].geometry instanceof Cesium.RectangleGeometry);
assert.equal(sources[0].get(0).allowPicking, false);
const stats = layer.getStats();
assert.equal(stats.count, 1);
assert.equal(stats.error, null);
@@ -222,35 +230,32 @@ test('an enabled refresh bins the snapshot into coloured ground cells', async ()
[1, 0, 0],
);
assert.match(legend[0].blurb, /not a detection/);
assert.match(legend[0].blurb, /fewer than 3 aircraft are not drawn/);
assert.match(legend[0].blurb, /need 3 aircraft/);
});
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.id,
'gnss-integrity',
'the unpublished id describes the measured state',
);
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'],
[
'Low-accuracy score > 10%',
'Low-accuracy score 2–10%',
'Low-accuracy score < 2%',
],
);
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.match(blurb, /one aircraft is discounted/);
assert.match(blurb, /not the raw share/);
assert.match(blurb, /not a detection/);
assert.equal(layer.provenance.classifier.id, 'gev-nic-nacp-v2');
assert.equal(layer.provenance.classifier.definedBy, 'GEV');
assert.equal(layer.provenance.classifier.validated, false);
assert.equal(layer.provenance.aggregation.source, 'https://gpsjam.org/faq');
@@ -283,7 +288,7 @@ test('a failed refresh keeps the last cells and reports the error', async () =>
assert.equal(layer.getStats().stale, true);
fail = true;
assert.equal(await layer.update(), true);
assert.equal(sources[0].entities.values.length, 1);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
assert.equal(layer.getStats().count, 1);
});
@@ -310,7 +315,7 @@ test('observations expire with the window, and disable aborts the request in fli
pending.resolve({ rows: [] });
assert.equal(await result, true);
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].entities.values.length, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
result = layer.update();
layer.disable();
@@ -364,7 +369,7 @@ test('a replayed stale snapshot keeps its observation age and expires on time',
0,
'the replay did not restart the window',
);
assert.equal(sources[0].entities.values.length, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
});
test('cells expire during prolonged fetch failures', async () => {
@@ -382,7 +387,7 @@ test('cells expire during prolonged fetch failures', async () => {
advance(11 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].entities.values.length, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
});
@@ -402,7 +407,7 @@ test('cells expire while no view anchor is available', async () => {
assert.equal(await layer.update(), true);
assert.equal(calls, 1, 'no anchor means no request');
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].entities.values.length, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
assert.equal(layer.getStats().error, null);
});
@@ -412,12 +417,12 @@ test('re-enabling after a long disable does not show cells past the window', asy
}));
layer.enable();
await layer.update();
assert.equal(sources[0].entities.values.length, 1);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
layer.disable();
advance(31 * MIN);
layer.enable();
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].entities.values.length, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
});
const threeCells = () => ({
@@ -428,10 +433,10 @@ test('an identical snapshot leaves the drawn entities untouched', async () => {
const { layer, sources } = harness(async () => threeCells());
layer.enable();
await layer.update();
const [first] = sources[0].entities.values;
const first = sources[0].get(0);
await layer.update();
assert.equal(sources[0].entities.values.length, 1);
assert.equal(sources[0].entities.values[0], first);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
assert.equal(sources[0].get(0), first);
});
test('an initial feed failure stays enabled, reports unavailable, and recovers automatically', async (t) => {
@@ -555,3 +560,141 @@ test('repeated acquisition failures back off, while genuine non-retryable failur
assert.equal(fatal.layer.getStats().retryInSec, null);
fatal.layer.destroy();
});
test('GERMAN: stale snapshot keeps the source observation age in row stats', async () => {
const h = harness(async () => ({
rows: threeHealthy(),
ageMs: 120000,
stale: true,
}));
h.layer.enable();
await h.layer.update();
assert.equal(h.layer.getStats().lastUpdate, h.now() - 120000);
h.layer.destroy();
});
test('GERMAN: camera debounce and minimum request gap are pinned', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const h = harness(async () => {
calls++;
return { rows: [] };
});
h.layer.enable();
await h.layer.update();
h.setAnchor({ latitude: 51, longitude: 31 });
h.viewer.camera.changed.raiseEvent();
h.advance(2499);
t.mock.timers.tick(2499);
await flush();
assert.equal(calls, 1);
h.advance(1);
t.mock.timers.tick(1);
await flush();
assert.equal(calls, 2);
h.advance(3000);
h.setAnchor({ latitude: 52, longitude: 32 });
h.viewer.camera.changed.raiseEvent();
h.advance(599);
t.mock.timers.tick(599);
await flush();
assert.equal(calls, 2);
h.advance(1);
t.mock.timers.tick(1);
await flush();
assert.equal(calls, 3);
h.layer.destroy();
});
test('camera movement during initial activation waits for acquisition before following up', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const calls = [];
let resolve;
const h = harness((anchor) => {
calls.push(anchor);
return calls.length === 1
? new Promise((r) => (resolve = r))
: Promise.resolve({ rows: threeHealthy() });
});
h.layer.enable();
const initial = h.layer.update();
h.setAnchor({ latitude: 51, longitude: 31 });
h.viewer.camera.changed.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
assert.equal(calls.length, 1);
resolve({ rows: threeHealthy() });
assert.equal(await initial, true);
h.advance(600);
t.mock.timers.tick(600);
await flush();
assert.equal(calls.length, 2);
h.layer.destroy();
});
test('monotonic camera scheduling is unaffected by wall-clock rollback', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const h = harness(async () => ({ rows: [] }));
h.layer.destroy();
let wall = 1800000000000,
mono = 0,
anchor = { latitude: 50, longitude: 30 },
calls = 0;
const layer = createGnssIntegrityLayer({
source: {
getSnapshot: async () => {
calls++;
return { rows: [], ageMs: 0 };
},
},
viewAnchor: () => anchor,
now: () => wall,
monotonicNow: () => mono,
});
layer.init(h.viewer);
layer.enable();
await layer.update();
wall -= 3600000;
mono = 3000;
anchor = { latitude: 51, longitude: 31 };
h.viewer.camera.changed.raiseEvent();
mono += 600;
t.mock.timers.tick(600);
await flush();
assert.equal(calls, 2);
layer.destroy();
});
test('GNSS fill emits no pick-pass commands but keeps normal rendering', async (t) => {
const h = harness(async () => ({ rows: threeHealthy() }));
h.layer.enable();
await h.layer.update();
const fill = h.sources[0].get(0);
let updates = 0;
t.mock.method(Cesium.GroundPrimitive.prototype, 'update', () => updates++);
fill.update({ passes: { pick: true } });
assert.equal(updates, 0);
fill.update({ passes: { render: true, pick: false } });
assert.equal(updates, 1);
h.layer.destroy();
assert.equal(fill.isDestroyed(), true);
});
test('view anchor falls back to rectangle, then nadir, then no camera', () => {
assert.equal(defaultGnssViewAnchor({}), null);
const viewer = {
camera: {
computeViewRectangle: () => Cesium.Rectangle.fromDegrees(20, 40, 22, 42),
},
};
const rectangleAnchor = defaultGnssViewAnchor(viewer);
assert.ok(Math.abs(rectangleAnchor.latitude - 41) < 1e-10);
assert.ok(Math.abs(rectangleAnchor.longitude - 21) < 1e-10);
viewer.camera = {
positionCartographic: Cesium.Cartographic.fromDegrees(21, 41),
};
const nadirAnchor = defaultGnssViewAnchor(viewer);
assert.ok(Math.abs(nadirAnchor.latitude - 41) < 1e-10);
assert.ok(Math.abs(nadirAnchor.longitude - 21) < 1e-10);
});
+36 -19
View File
@@ -11,7 +11,8 @@
* state.
*
* Three separately sourced parts make up the method (see `gnssProvenance`):
* - Per-aircraft classifier, defined by GEV: NIC < 7 or NACp < 8 (borrowed
* - Per-aircraft classifier, defined by GEV: aircraft-reported NIC < 7
* (except unknown NIC 0 with NACp >= 10) 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.
@@ -19,9 +20,9 @@
* - 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']);
/** DO-260A (1) and DO-260B (2) define NIC and NACp; version 0 and unknown versions do not. */
/** Position source can switch to MLAT after GPS loss; quality must remain aircraft-reported. */
const QUALIFYING_TYPES = new Set(['adsb_icao', 'mlat']);
/** Restrict to decoded DO-260A/B quality fields; readsb does not decode v3 quality as v3. */
const QUALIFYING_VERSIONS = new Set([1, 2]);
/** NIC below 7: containment radius of 0.2 NM or more (below the US 91.227 ADS-B Out minimum). GEV threshold. */
export const GNSS_NIC_THRESHOLD = 7;
@@ -31,7 +32,7 @@ export const GNSS_NACP_THRESHOLD = 8;
const MAX_POSITION_AGE_S = 60;
/** Cell edge in degrees: roughly 55 km north-south, similar in area to an H3 res-4 cell. */
export const GNSS_CELL_DEG = 0.5;
/** Fewer aircraft than this in a cell cannot distinguish one faulty transponder from interference. */
/** Hide sparse one- and two-aircraft cells; the aggregation separately discounts one bad report. */
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;
@@ -47,8 +48,8 @@ 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}`,
id: 'gev-nic-nacp-v2',
rule: `gpsOkBefore set || aircraft-reported NACp < ${GNSS_NACP_THRESHOLD} || (aircraft-reported NIC < ${GNSS_NIC_THRESHOLD} && !(NIC === 0 && NACp >= 10))`,
basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)',
definedBy: 'GEV',
validated: false,
@@ -66,10 +67,7 @@ export function gnssProvenance({ windowMs = GNSS_WINDOW_MS } = {}) {
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,
]),
bands: Object.freeze([GNSS_MEDIUM_PERCENT, GNSS_HIGH_PERCENT]),
source: 'https://gpsjam.org/faq',
definedBy: 'gpsjam.org',
}),
@@ -104,7 +102,7 @@ const finite = (value) => typeof value === 'number' && Number.isFinite(value);
* facts the layer needs. Returns null when the payload has no aircraft array.
*
* @param {{ac?: object[], now?: number}} payload - readsb-style snapshot.
* @returns {{hex: string, lat: number, lon: number, nic: number, nacp: number, gpsLost: boolean, degraded: boolean}[]|null}
* @returns {{hex: string, lat: number, lon: number, nic: number|null, nacp: number|null, gpsLost: boolean, degraded: boolean}[]|null}
*/
export function normalizeGnssAircraft(payload) {
if (!Array.isArray(payload?.ac)) return null;
@@ -117,18 +115,33 @@ export function normalizeGnssAircraft(payload) {
if (!QUALIFYING_TYPES.has(aircraft.type)) continue;
if (!QUALIFYING_VERSIONS.has(aircraft.version)) continue;
if (aircraft.alt_baro === 'ground' || !finite(aircraft.alt_baro)) continue;
const { lat, lon, nic, nac_p: nacp } = aircraft;
let { lat, lon } = aircraft;
const derived = (field) =>
aircraft.tisb?.includes(field) || aircraft.mlat?.includes(field);
const nic = finite(aircraft.nic) && !derived('nic') ? aircraft.nic : null;
const nacp =
finite(aircraft.nac_p) && !derived('nac_p') ? aircraft.nac_p : null;
if (!finite(lat) || Math.abs(lat) > 90) continue;
if (!finite(lon) || Math.abs(lon) > 180) continue;
if (!finite(nic) || !finite(nacp)) continue;
// Only a position confirmed within the last minute is current evidence; a
// missing, invalid or negative age confirms nothing.
const age = aircraft.seen_pos;
if (!finite(age) || age < 0 || age > MAX_POSITION_AGE_S) continue;
// readsb sets gpsOkBefore when an aircraft's integrity collapses and keeps
// it for roughly 15 minutes after GPS recovers, so it can outlast the
// current NIC/NACp; the rolling window treats that as recent evidence.
const gpsLost = aircraft.gpsOkBefore != null;
// readsb exposes the flag while GPS is lost (15 s–15 min after the
// last good position), and removes it on recovery. Locate that evidence
// at the last good GNSS position, rather than a later MLAT estimate.
const gpsLost = finite(aircraft.gpsOkBefore) && aircraft.gpsOkBefore > 0;
if (nic === null && nacp === null && !gpsLost) continue;
if (
gpsLost &&
finite(aircraft.gpsOkLat) &&
Math.abs(aircraft.gpsOkLat) <= 90 &&
finite(aircraft.gpsOkLon) &&
Math.abs(aircraft.gpsOkLon) <= 180
) {
lat = aircraft.gpsOkLat;
lon = aircraft.gpsOkLon;
}
seen.add(hex);
rows.push({
hex,
@@ -138,7 +151,11 @@ export function normalizeGnssAircraft(payload) {
nacp,
gpsLost,
degraded:
gpsLost || nic < GNSS_NIC_THRESHOLD || nacp < GNSS_NACP_THRESHOLD,
gpsLost ||
(nacp !== null && nacp < GNSS_NACP_THRESHOLD) ||
(nic !== null &&
nic < GNSS_NIC_THRESHOLD &&
!(nic === 0 && nacp !== null && nacp >= 10)),
});
}
return rows;
+92 -9
View File
@@ -26,16 +26,21 @@ const aircraft = (overrides = {}) => ({
...overrides,
});
test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', () => {
test('normalization keeps airborne decoded ADS-B v1/v2 quality with current positions', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft(),
aircraft({ hex: '000001', type: 'mlat', nic: 0 }),
aircraft({
hex: '000001',
type: 'mlat',
nic: undefined,
nac_p: undefined,
}),
aircraft({ hex: '000002', type: 'tisb_icao' }),
aircraft({ hex: '000003', version: 0 }),
aircraft({ hex: '000004', alt_baro: 'ground' }),
aircraft({ hex: '000005', seen_pos: 120 }),
aircraft({ hex: '000006', nic: undefined }),
aircraft({ hex: '000006', nic: undefined, nac_p: undefined }),
aircraft({ hex: '000007', lat: 95 }),
aircraft({ hex: '000008', version: undefined }),
// Only DO-260A/B (versions 1 and 2) define NIC and NACp.
@@ -166,8 +171,8 @@ test('the primary state is a navigation-integrity anomaly; interference is only
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',
id: 'gev-nic-nacp-v2',
rule: 'gpsOkBefore set || aircraft-reported NACp < 8 || (aircraft-reported NIC < 7 && !(NIC === 0 && NACp >= 10))',
basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)',
definedBy: 'GEV',
validated: false,
@@ -176,7 +181,7 @@ test('provenance separates the GEV classifier from the gpsjam aggregation and th
assert.deepEqual(GNSS_PROVENANCE.aggregation, {
id: 'gpsjam-cell-bands',
formula: '100 * max(0, bad - 1) / total',
bands: [0.02, 0.1],
bands: [2, 10],
source: 'https://gpsjam.org/faq',
definedBy: 'gpsjam.org',
});
@@ -197,9 +202,9 @@ test('provenance separates the GEV classifier from the gpsjam aggregation and th
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(gnssIntegrityLevel(100, 1 + medium).level, 'medium');
assert.equal(gnssIntegrityLevel(100, 1 + high).level, 'medium');
assert.equal(gnssIntegrityLevel(100, 2 + high).level, 'high');
assert.equal(gnssProvenance({ windowMs: 10 * 60_000 }).window.minutes, 10);
});
@@ -406,3 +411,81 @@ test('cells below the minimum aircraft count are withheld', () => {
assert.equal(cell.level, 'high');
assert.equal(cell.percentDegraded, 50);
});
test('MLAT GPS-loss evidence uses the last good GNSS location and ignores MLAT NIC', () => {
const [row] = normalizeGnssAircraft({
ac: [
aircraft({
type: 'mlat',
nac_p: 0,
nic: 0,
mlat: ['lat', 'lon', 'nic'],
gpsOkBefore: 1800000000,
gpsOkLat: 54.2,
gpsOkLon: 19.3,
}),
],
});
assert.ok(row);
assert.equal(row.lat, 54.2);
assert.equal(row.lon, 19.3);
assert.equal(row.nic, null);
assert.equal(row.degraded, true);
const [healthy] = normalizeGnssAircraft({
ac: [aircraft({ type: 'mlat', nic: 0, mlat: ['nic'] })],
});
assert.equal(healthy.degraded, false);
});
test('TIS-B accuracy fields and unknown containment do not manufacture anomalies', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft({ hex: 'a00001', nac_p: 0, tisb: ['nac_p'] }),
aircraft({ hex: 'a00002', nic: 0, tisb: ['nic'] }),
aircraft({ hex: 'a00003', nic: 0, nac_p: 10 }),
aircraft({ hex: 'a00004', nic: 0, nac_p: 8 }),
aircraft({ hex: 'a00005', nic: 6, nac_p: 10 }),
],
});
assert.deepEqual(
rows.map((r) => r.degraded),
[false, false, false, true, true],
);
});
// Quality-field subsets captured from public adsb.lol US point responses on
// 2026-10-14 (34,-118 and 41,-73, radius 250 nm), ODbL 1.0 / adsb.lol contributors.
// Only field provenance is reused; the airborne ADS-B envelopes are synthetic.
test('public US quality-field fixtures do not turn rebroadcast accuracy into own accuracy', () => {
const tisbQuality = {
nic: 0,
nac_p: 0,
tisb: [
'altitude',
'alt_baro',
'gs',
'track',
'geom_rate',
'lat',
'lon',
'nic',
'rc',
'nac_p',
'nac_v',
'sil',
'sil_type',
],
mlat: [],
};
assert.deepEqual(normalizeGnssAircraft({ ac: [aircraft(tisbQuality)] }), []);
const unknownContainment = {
nic: 0,
nac_p: 10,
tisb: ['callsign'],
mlat: [],
};
assert.equal(
normalizeGnssAircraft({ ac: [aircraft(unknownContainment)] })[0].degraded,
false,
);
});
+9 -6
View File
@@ -4,9 +4,12 @@ function retryDelay(raw, nowMs) {
if (!raw) return null;
const seconds = Number(raw);
const ms = seconds * 1000;
if (Number.isFinite(ms) && seconds > 0) return Math.max(5000, ms);
if (Number.isFinite(ms) && seconds > 0)
return Math.min(3600000, Math.max(5000, ms));
const at = Date.parse(raw);
return Number.isFinite(at) && at > nowMs ? Math.max(5000, at - nowMs) : null;
return Number.isFinite(at) && at > nowMs
? Math.min(3600000, Math.max(5000, at - nowMs))
: null;
}
/** Request integrity facts around a view anchor through the same-origin adsb.lol proxy. */
@@ -20,8 +23,8 @@ export function createAdsbGnssSource({
if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
throw new Error('GNSS snapshot needs a view anchor');
const params = new URLSearchParams({
lat: latitude.toFixed(2),
lon: longitude.toFixed(2),
lat: String(Math.round(latitude)),
lon: String(Math.round(longitude)),
});
const response = await fetchImpl(`/api/gnss-integrity?${params}`, {
signal,
@@ -35,8 +38,8 @@ export function createAdsbGnssSource({
throw Object.assign(
new Error(
limited
? 'adsb.lol rate limited (HTTP 429)'
: `adsb.lol feed unavailable (HTTP ${response.status})`,
? 'GNSS feed rate limited (HTTP 429)'
: `GNSS feed unavailable (HTTP ${response.status})`,
),
{
status: response.status,
+3 -3
View File
@@ -11,7 +11,7 @@ test('the source asks the same-origin proxy for the view anchor', async () => {
return Response.json({
fetchedAt: 5,
ageMs: 0,
classifier: 'gev-nic-nacp-v1',
classifier: 'gev-nic-nacp-v2',
rows: [{ hex: 'abc123' }],
});
},
@@ -20,13 +20,13 @@ test('the source asks the same-origin proxy for the view anchor', async () => {
{ latitude: 50.06143, longitude: 19.93658 },
{ signal: new AbortController().signal },
);
assert.deepEqual(calls, ['/api/gnss-integrity?lat=50.06&lon=19.94']);
assert.deepEqual(calls, ['/api/gnss-integrity?lat=50&lon=20']);
assert.deepEqual(snapshot, {
rows: [{ hex: 'abc123' }],
ageMs: 0,
fetchedAt: 5,
stale: false,
classifier: 'gev-nic-nacp-v1',
classifier: 'gev-nic-nacp-v2',
});
});