feat(layers): GNSS interference layer from ADS-B NIC/NACp integrity

Bins airborne ADS-B v1/v2 aircraft that report degraded navigation
integrity (NIC < 7, NACp < 8, or readsb's GPS-loss flag) into 0.5 degree
cells over a rolling 30-minute window, coloured with the gpsjam.org
bands. Keyless: reads the capped adsb.lol point snapshot around the view
through /api/gnss-integrity, which returns integrity fields only, caches
60 s per whole-degree anchor, backs off on upstream 429 and serves the
last snapshot on failure. Adds scripts/qa-gnss.mjs.

Built by the Hex project (Crew Leo Agile dev team).

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
Leo PechnickiandClaude authored and Mazeyar Moeini Feizabadi committed 2026-10-13 17:19:23 +02:00
1 parent f71da95e91
commit eefcb338b3
26 files changed
+1389 -4

No files matched your search

+2
View File
@@ -25,6 +25,7 @@ import { createApplicationAwareness } from './layers/militaryAwareness.js';
import { createApplicationFirms } from './layers/firms.js';
import { createApplicationEarthquakes } from './layers/earthquakes.js';
import { createApplicationFirePerimeters } from './layers/perimeters.js';
import { createApplicationGnssInterference } from './layers/gnss.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
@@ -120,6 +121,7 @@ export function createApplicationCatalog({
createApplicationFirePerimeters({
source: sources['fire-perimeters'],
}),
createApplicationGnssInterference(),
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationStreetLevel({
surface,
+1
View File
@@ -60,6 +60,7 @@ test('catalogs construct distinct layers and classification from their supplied
'2026-09-21T12:00:00.000Z',
);
assert.ok(first.get('fire-perimeters'));
assert.ok(first.get('gnss-interference'));
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
+12
View File
@@ -0,0 +1,12 @@
import {
createAdsbGnssSource,
createGnssInterferenceLayer,
} from '../../layers/gnss/index.js';
/** Wire the adsb.lol GNSS interference layer into the application catalog. */
export function createApplicationGnssInterference(options = {}) {
return createGnssInterferenceLayer({
source: createAdsbGnssSource(),
...options,
});
}
+1 -1
View File
@@ -104,7 +104,7 @@ export const DATA_CREDITS = [
{
key: 'adsblol',
html:
'Military flights, aircraft traces &amp; bounded regional flight fallback: ' +
'Military flights, aircraft traces, bounded regional flight fallback &amp; GNSS interference: ' +
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol</a> ' +
'(ODbL 1.0)',
},
+183
View File
@@ -0,0 +1,183 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
gnssIntegrityAnchor,
gnssIntegrityProxy,
} from '../../server/providers/gnssIntegrity.js';
const snapshot = {
now: 1,
ac: [
{
hex: '4ca7b5',
type: 'adsb_icao',
version: 2,
alt_baro: 36000,
lat: 50.1,
lon: 19.9,
nic: 0,
nac_p: 0,
seen_pos: 1,
flight: 'RYR1AB ',
r: 'EI-ABC',
},
{ hex: '000001', type: 'mlat', lat: 50, lon: 20, nic: 0, nac_p: 0 },
],
};
function install(options = {}, hook = 'configureServer') {
let handler;
const plugin = gnssIntegrityProxy(options);
assert.equal(plugin.name, 'gnss-integrity');
plugin[hook]({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/gnss-integrity');
handler = callback;
},
},
});
return async (url = '/?lat=50&lon=20', method = 'GET', peer = 'local') => {
const res = {
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end(body) {
this.body = JSON.parse(body);
},
};
await handler({ url, method, socket: { remoteAddress: peer } }, res);
return res;
};
}
test('anchors snap to whole degrees and reject missing or out-of-range values', () => {
assert.deepEqual(gnssIntegrityAnchor('/?lat=50.44&lon=19.51'), {
lat: 50,
lon: 20,
});
assert.equal(gnssIntegrityAnchor('/?lat=91&lon=0'), null);
assert.equal(gnssIntegrityAnchor('/?lat=0&lon=-181'), null);
assert.equal(gnssIntegrityAnchor('/?lat=0'), null);
assert.equal(gnssIntegrityAnchor('/?lat=abc&lon=1'), null);
});
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: fetches the fixed adsb.lol origin and returns only integrity facts`, async () => {
const calls = [];
const request = install(
{
now: () => 1234,
fetchImpl: async (url, options) => {
calls.push(url);
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.redirect, 'error');
return Response.json(snapshot);
},
},
hook,
);
const res = await request(
'/?lat=50.2&lon=19.8&url=https://invalid.example',
);
assert.equal(res.status, 200);
assert.deepEqual(calls, ['https://api.adsb.lol/v2/lat/50/lon/20/dist/250']);
assert.deepEqual(res.body, {
fetchedAt: 1234,
anchor: { lat: 50, lon: 20 },
radiusNm: 250,
rows: [
{
hex: '4ca7b5',
lat: 50.1,
lon: 19.9,
nic: 0,
nacp: 0,
gpsLost: false,
degraded: true,
},
],
});
assert.equal(res.headers['Cache-Control'], 'no-store');
});
}
test('snapshots are cached per anchor and concurrent reads share one upstream call', async () => {
let clock = 0;
let calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
calls += 1;
return Response.json(snapshot);
},
});
await Promise.all([request(), request('/?lat=50.3&lon=20.2')]);
assert.equal(calls, 1);
clock = 59_000;
await request();
assert.equal(calls, 1);
clock = 61_000;
await request();
assert.equal(calls, 2);
await request('/?lat=10&lon=10');
assert.equal(calls, 3);
});
test('an upstream failure serves the last snapshot as stale, else 502', async () => {
let clock = 0;
let fail = false;
const request = install({
now: () => clock,
fetchImpl: async () =>
fail ? new Response('down', { status: 503 }) : Response.json(snapshot),
});
await request();
fail = true;
clock = 120_000;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.headers['X-Data-Stale'], 'true');
const missing = await request('/?lat=-10&lon=-10');
assert.equal(missing.status, 502);
assert.deepEqual(missing.body, { error: 'gnss_integrity_unavailable' });
});
test('an upstream 429 starts a cooldown with no further upstream calls', async () => {
let clock = 0;
let calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
calls += 1;
return new Response('slow down', {
status: 429,
headers: { 'retry-after': '120' },
});
},
});
assert.equal((await request()).status, 502);
const cooling = await request('/?lat=1&lon=1');
assert.equal(cooling.status, 429);
assert.equal(calls, 1);
clock = 121_000;
await request('/?lat=2&lon=2');
assert.equal(calls, 2);
});
test('the route rejects other methods, paths, bad anchors, a malformed feed and floods', async () => {
const request = install({
fetchImpl: async () => Response.json({ nope: true }),
});
assert.equal((await request('/', 'POST')).status, 405);
assert.equal((await request('/other?lat=1&lon=1')).status, 404);
assert.equal((await request('/?lat=1')).status, 400);
assert.equal((await request('/?lat=1&lon=1')).status, 502);
let limited = 0;
for (let i = 0; i < 40; i += 1)
if ((await request('/?lat=1&lon=1', 'GET', 'flood')).status === 429)
limited += 1;
assert.ok(limited > 0);
});
+5
View File
@@ -607,6 +607,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options',
optionOwner: 'flights',
}),
Object.freeze({
id: 'gnss-interference',
token: '0',
disposition: 'enabled-only',
}),
Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }),
Object.freeze({
id: 'local-datacenters',
+17
View File
@@ -2639,6 +2639,23 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
);
});
test('GNSS interference uses digit 0 without colliding with other digit tokens', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=0.2.1'));
assert.deepEqual(decoded.enabledLayerIds, [
'fire-perimeters',
'gnss-interference',
'recent-imagery',
]);
assert.equal(
LAYER_STATE_REGISTRY.find(({ id }) => id === 'gnss-interference').token,
'0',
);
assert.deepEqual(
decodeLayerStateParams(new URLSearchParams(encode(decoded))),
decoded,
);
});
test('fire perimeters uses digit 2 without colliding with wind or recent imagery', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=2.k.1'));
assert.deepEqual(decoded.enabledLayerIds, [
+1 -1
View File
@@ -131,7 +131,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{ ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0' },
{ ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0', 'gnss-interference': '0' },
);
assert.throws(
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
@@ -9,6 +9,7 @@
["earthquakes", "e"],
["fire-perimeters", "2"],
["flights", "f"],
["gnss-interference", "0"],
["local-dams", "q"],
["local-datacenters", "d"],
["local-firms", "w"],
+200
View File
@@ -0,0 +1,200 @@
import * as Cesium from 'cesium';
import {
GNSS_LEVEL_COLORS,
GNSS_WINDOW_MS,
accumulateGnssObservations,
binGnssCells,
} from './records.js';
export * from './records.js';
export { createAdsbGnssSource } from './source.js';
const FILL_ALPHA = Object.freeze({ low: 0.14, medium: 0.32, high: 0.45 });
const LEVEL_LABELS = Object.freeze({
low: 'Under 2% degraded',
medium: '2–10% degraded',
high: 'Over 10% degraded',
});
/** Degrees at the centre of the current view, or null before the camera settles. */
export function defaultGnssViewAnchor(viewer) {
const rectangle = viewer?.camera?.computeViewRectangle?.(
viewer.scene?.globe?.ellipsoid,
);
const carto = rectangle
? Cesium.Rectangle.center(rectangle)
: viewer?.camera?.positionCartographic;
if (!carto) return null;
const latitude = Cesium.Math.toDegrees(carto.latitude);
const longitude = Cesium.Math.toDegrees(carto.longitude);
return Number.isFinite(latitude) && Number.isFinite(longitude)
? { latitude, longitude }
: null;
}
/**
* 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.
*/
export function createGnssInterferenceLayer({
source,
viewAnchor = defaultGnssViewAnchor,
now = () => Date.now(),
windowMs = GNSS_WINDOW_MS,
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('GNSS interference requires a snapshot source');
let _viewer = null;
let _dataSource = null;
let _request = null;
let _enabled = false;
let _cells = [];
let _signature = null;
let _lastUpdate = null;
let _lastError = null;
let _stale = false;
const _observations = new Map();
function render() {
const signature = JSON.stringify(
_cells.map(({ id, level, aircraft, degraded }) => [
id,
level,
aircraft,
degraded,
]),
);
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,
},
}),
);
}
}
const layer = {
id: 'gnss-interference',
name: 'GNSS Interference',
icon: '📡',
source: 'adsb.lol',
updateInterval: 60000,
init(viewer) {
if (_viewer)
throw new Error('GNSS interference layer is already initialized');
_viewer = viewer;
_dataSource = new Cesium.CustomDataSource('gnss-interference');
_dataSource.show = false;
viewer.dataSources.add(_dataSource);
},
enable() {
_enabled = true;
if (_dataSource) _dataSource.show = true;
},
disable() {
_request?.abort();
_request = null;
_enabled = false;
if (_dataSource) _dataSource.show = false;
},
async update() {
if (!_enabled || !_dataSource) return false;
const anchor = viewAnchor(_viewer);
if (!anchor) return false;
_request?.abort();
const request = new AbortController();
_request = request;
try {
const snapshot = await source.getSnapshot(anchor, {
signal: request.signal,
});
if (request.signal.aborted || _request !== request || !_enabled)
return false;
accumulateGnssObservations(_observations, snapshot.rows, now(), {
windowMs,
});
_cells = binGnssCells(_observations);
render();
_stale = snapshot.stale;
_lastUpdate = now();
_lastError = null;
return true;
} catch (e) {
if (request.signal.aborted || _request !== request || !_enabled)
return false;
console.warn('[Data:GNSS] Fetch error:', e);
_lastError = e?.message || 'GNSS integrity source unavailable';
return false;
} finally {
if (_request === request) _request = null;
}
},
destroy(viewer = _viewer) {
_request?.abort();
_request = null;
_enabled = false;
_observations.clear();
_cells = [];
_signature = null;
if (_dataSource) {
viewer.dataSources.remove(_dataSource, true);
_dataSource = null;
}
_viewer = null;
_lastUpdate = null;
_lastError = null;
_stale = false;
},
getRowControls() {
return {
chips: [],
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 integrity (NIC/NACp) over the last 30 minutes, around the views you visit. An inference of jamming or spoofing, not a detection.',
}
: {}),
})),
};
},
getStats() {
return {
count: _cells.length,
lastUpdate: _lastUpdate,
error: _lastError,
stale: _stale,
fallback: false,
};
},
};
return layer;
}
+161
View File
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGnssInterferenceLayer } from './index.js';
const row = (hex, lat, degraded) => ({
hex,
lat,
lon: 30.2,
nic: degraded ? 0 : 8,
nacp: degraded ? 0 : 10,
gpsLost: false,
degraded,
});
function harness(
getSnapshot,
{ anchor = { latitude: 50, longitude: 30 } } = {},
) {
const sources = [];
const viewer = {
dataSources: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
},
},
};
let clock = 1_000;
const anchors = [];
const layer = createGnssInterferenceLayer({
source: { getSnapshot },
viewAnchor: () => {
anchors.push(anchor);
return anchor;
},
now: () => clock,
});
layer.init(viewer);
return {
layer,
sources,
anchors,
advance(ms) {
clock += ms;
},
};
}
test('the layer requires a snapshot source and cannot be initialized twice', () => {
assert.throws(() => createGnssInterferenceLayer({}), TypeError);
const { layer } = harness(async () => ({ rows: [] }));
assert.throws(() => layer.init({ dataSources: { add() {} } }), /already/);
});
test('an enabled refresh bins the snapshot into coloured ground cells', async () => {
const { layer, sources, anchors } = harness(async (anchor, { signal }) => {
assert.deepEqual(anchor, { latitude: 50, longitude: 30 });
assert.ok(signal instanceof AbortSignal);
return {
rows: [
row('a1', 50.1, true),
row('a2', 50.1, true),
row('a3', 50.2, false),
row('a4', 50.3, false),
row('b1', 55.1, false),
],
stale: false,
};
});
assert.equal(await layer.update(), false, 'disabled layers do not fetch');
assert.equal(anchors.length, 0);
layer.enable();
assert.equal(sources[0].show, true);
assert.equal(await layer.update(), true);
const entities = sources[0].entities.values;
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);
const stats = layer.getStats();
assert.equal(stats.count, 1);
assert.equal(stats.error, null);
assert.equal(stats.fallback, false);
const { legend } = layer.getRowControls();
assert.deepEqual(
legend.map(({ count }) => count),
[1, 0, 0],
);
assert.match(legend[0].blurb, /not a detection/);
});
test('a failed refresh keeps the last cells and reports the error', async () => {
let fail = false;
const { layer, sources } = harness(async () => {
if (fail) throw new Error('adsb.lol HTTP 502');
return {
rows: [
row('a1', 1.1, false),
row('a2', 1.2, false),
row('a3', 1.3, false),
],
stale: true,
};
});
layer.enable();
await layer.update();
assert.equal(layer.getStats().stale, true);
fail = true;
assert.equal(await layer.update(), false);
assert.equal(sources[0].entities.values.length, 1);
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
assert.equal(layer.getStats().count, 1);
});
test('observations expire with the window, and disable aborts the request in flight', async () => {
let pending;
const { layer, sources, advance } = harness(
(anchor, { signal }) =>
new Promise((resolve, reject) => {
pending = { resolve };
signal.addEventListener('abort', () => reject(signal.reason));
}),
);
layer.enable();
let result = layer.update();
pending.resolve({
rows: [row('a1', 1.1, false), row('a2', 1.2, false), row('a3', 1.3, false)],
});
assert.equal(await result, true);
assert.equal(layer.getStats().count, 1);
advance(31 * 60_000);
result = layer.update();
pending.resolve({ rows: [] });
assert.equal(await result, true);
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].entities.values.length, 0);
result = layer.update();
layer.disable();
assert.equal(await result, false);
assert.equal(sources[0].show, false);
});
test('no view anchor means no request, and destroy releases the data source', async () => {
let calls = 0;
const { layer, sources } = harness(
async () => {
calls += 1;
return { rows: [] };
},
{ anchor: null },
);
layer.enable();
assert.equal(await layer.update(), false);
assert.equal(calls, 0);
layer.destroy();
assert.equal(sources.length, 0);
assert.equal(layer.getStats().count, 0);
});
+171
View File
@@ -0,0 +1,171 @@
/**
* GNSS interference inferred from ADS-B navigation integrity.
*
* 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 (the method popularized by gpsjam.org and the
* readsb/tar1090 `gpsOkBefore` flag). 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.
*/
/** 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 above 0.2 NM (RNP 0.3 and worse). */
export const GNSS_NIC_THRESHOLD = 7;
/** NACp below 8 means an estimated position uncertainty of 93 m or more. */
export const GNSS_NACP_THRESHOLD = 8;
/** Positions older than this are not current evidence of the local sky. */
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. */
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;
const HEX_PATTERN = /^~?[0-9a-f]{6}$/;
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
/**
* Reduce one adsb.lol / readsb `aircraft.json`-style payload to the integrity
* 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}
*/
export function normalizeGnssAircraft(payload) {
if (!Array.isArray(payload?.ac)) return null;
const rows = [];
const seen = new Set();
for (const aircraft of payload.ac) {
if (!aircraft || typeof aircraft !== 'object') continue;
const hex = typeof aircraft.hex === 'string' ? aircraft.hex : '';
if (!HEX_PATTERN.test(hex) || seen.has(hex)) continue;
if (!QUALIFYING_TYPES.has(aircraft.type)) continue;
if (!finite(aircraft.version) || aircraft.version < 1) continue;
if (aircraft.alt_baro === 'ground' || !finite(aircraft.alt_baro)) continue;
const { lat, lon, nic, nac_p: nacp } = aircraft;
if (!finite(lat) || Math.abs(lat) > 90) continue;
if (!finite(lon) || Math.abs(lon) > 180) continue;
if (!finite(nic) || !finite(nacp)) continue;
if (finite(aircraft.seen_pos) && aircraft.seen_pos > MAX_POSITION_AGE_S)
continue;
const gpsLost = aircraft.gpsOkBefore != null;
seen.add(hex);
rows.push({
hex,
lat,
lon,
nic,
nacp,
gpsLost,
degraded:
gpsLost || nic < GNSS_NIC_THRESHOLD || nacp < GNSS_NACP_THRESHOLD,
});
}
return rows;
}
/** Stable key of the cell containing a position. */
export function gnssCellKey(lat, lon, cellDeg = GNSS_CELL_DEG) {
const row = Math.floor((Math.min(lat, 89.999999) + 90) / cellDeg);
const col = Math.floor((Math.min(lon, 179.999999) + 180) / cellDeg);
return `${row}:${col}`;
}
/**
* Fold one snapshot into a rolling observation store. Each aircraft counts
* once per cell per window; a degraded report in the window keeps it degraded
* there, so an aircraft that loses GNSS mid-cell is not averaged away.
*
* @param {Map<string, {cell: string, degraded: boolean, seenAt: number}>} store
* @param {ReturnType<typeof normalizeGnssAircraft>} rows
* @param {number} nowMs
* @param {{windowMs?: number, cellDeg?: number}} [options]
* @returns {Map} The same store, pruned to the window.
*/
export function accumulateGnssObservations(
store,
rows,
nowMs,
{ windowMs = GNSS_WINDOW_MS, cellDeg = GNSS_CELL_DEG } = {},
) {
for (const row of rows || []) {
const cell = gnssCellKey(row.lat, row.lon, cellDeg);
const key = `${row.hex}|${cell}`;
const previous = store.get(key);
store.set(key, {
cell,
degraded: row.degraded || (previous?.degraded ?? false),
seenAt: nowMs,
});
}
for (const [key, entry] of store) {
if (nowMs - entry.seenAt > windowMs) store.delete(key);
}
return store;
}
/**
* Interference level of a cell, using gpsjam.org's published 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.
*/
export function gnssInterferenceLevel(total, degraded) {
const percent = total > 0 ? (100 * Math.max(0, degraded - 1)) / total : 0;
return {
percent,
level: percent > 10 ? 'high' : percent >= 2 ? 'medium' : 'low',
};
}
/**
* 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'}[]}
*/
export function binGnssCells(
store,
{ cellDeg = GNSS_CELL_DEG, minAircraft = GNSS_MIN_AIRCRAFT } = {},
) {
const tallies = new Map();
for (const { cell, degraded } of store.values()) {
const tally = tallies.get(cell) || { aircraft: 0, degraded: 0 };
tally.aircraft += 1;
if (degraded) tally.degraded += 1;
tallies.set(cell, tally);
}
const cells = [];
for (const [id, { aircraft, degraded }] of tallies) {
if (aircraft < minAircraft) continue;
const [row, col] = id.split(':').map(Number);
const south = row * cellDeg - 90;
const west = col * cellDeg - 180;
const { percent, level } = gnssInterferenceLevel(aircraft, degraded);
cells.push({
id,
south,
west,
north: south + cellDeg,
east: west + cellDeg,
lat: south + cellDeg / 2,
lon: west + cellDeg / 2,
aircraft,
degraded,
percentDegraded: Math.round(percent * 10) / 10,
level,
});
}
return cells.sort((a, b) => a.id.localeCompare(b.id));
}
/** Legend colors: gpsjam's green / amber / red convention. */
export const GNSS_LEVEL_COLORS = Object.freeze({
low: '#3fb950',
medium: '#e3b341',
high: '#f85149',
});
+133
View File
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
accumulateGnssObservations,
binGnssCells,
gnssCellKey,
gnssInterferenceLevel,
normalizeGnssAircraft,
} from './records.js';
const aircraft = (overrides = {}) => ({
hex: '4ca7b5',
type: 'adsb_icao',
version: 2,
alt_baro: 36000,
lat: 50.1,
lon: 19.9,
nic: 8,
nac_p: 10,
seen_pos: 1.2,
...overrides,
});
test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft(),
aircraft({ hex: '000001', type: 'mlat', nic: 0 }),
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: '000007', lat: 95 }),
aircraft({ hex: 'not-hex' }),
aircraft(),
null,
],
});
assert.deepEqual(rows, [
{
hex: '4ca7b5',
lat: 50.1,
lon: 19.9,
nic: 8,
nacp: 10,
gpsLost: false,
degraded: false,
},
]);
assert.equal(normalizeGnssAircraft({}), null);
assert.equal(normalizeGnssAircraft(null), null);
});
test('low NIC, low NACp or a readsb GPS-loss flag each mark an aircraft degraded', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft({ hex: 'a00001', nic: 6 }),
aircraft({ hex: 'a00002', nac_p: 7 }),
aircraft({ hex: 'a00003', gpsOkBefore: 1790690000.1 }),
aircraft({ hex: 'a00004', nic: 7, nac_p: 8 }),
],
});
assert.deepEqual(
rows.map(({ hex, degraded, gpsLost }) => [hex, degraded, gpsLost]),
[
['a00001', true, false],
['a00002', true, false],
['a00003', true, true],
['a00004', false, false],
],
);
});
test('cell keys are stable half-degree bins, clamped at the poles and antimeridian', () => {
assert.equal(gnssCellKey(50.1, 19.9), gnssCellKey(50.4, 19.6));
assert.notEqual(gnssCellKey(50.1, 19.9), gnssCellKey(50.6, 19.9));
assert.equal(gnssCellKey(90, 180), gnssCellKey(89.9, 179.9));
assert.equal(gnssCellKey(-90, -180), '0:0');
});
test('interference level follows the published gpsjam formula and bands', () => {
assert.deepEqual(gnssInterferenceLevel(10, 0), { percent: 0, level: 'low' });
// 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');
});
test('observations count each aircraft once per cell and expire after the window', () => {
const store = new Map();
const at = (lat, hex, degraded) => ({ hex, lat, lon: 20.1, degraded });
accumulateGnssObservations(
store,
[at(50.1, 'a1', true), at(50.2, 'a2', false), at(50.3, 'a3', false)],
0,
{ windowMs: 1000 },
);
// a1 recovers inside the same cell: it stays degraded for the window.
accumulateGnssObservations(store, [at(50.2, 'a1', false)], 500, {
windowMs: 1000,
});
let [cell] = binGnssCells(store);
assert.equal(cell.aircraft, 3);
assert.equal(cell.degraded, 1);
assert.equal(cell.level, 'low');
assert.deepEqual(
[cell.south, cell.west, cell.north, cell.east],
[50, 20, 50.5, 20.5],
);
accumulateGnssObservations(store, [], 1200, { windowMs: 1000 });
assert.equal(store.size, 1);
assert.deepEqual(binGnssCells(store), []);
});
test('cells below the minimum aircraft count are withheld', () => {
const store = new Map();
accumulateGnssObservations(
store,
[
{ hex: 'b1', lat: 10.1, lon: 10.1, degraded: true },
{ hex: 'b2', lat: 10.2, lon: 10.2, degraded: true },
],
0,
);
assert.deepEqual(binGnssCells(store), []);
const [cell] = binGnssCells(store, { minAircraft: 2 });
assert.equal(cell.level, 'high');
assert.equal(cell.percentDegraded, 50);
});
+37
View File
@@ -0,0 +1,37 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
/** Request integrity facts around a view anchor through the same-origin adsb.lol proxy. */
export function createAdsbGnssSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getSnapshot({ latitude, longitude } = {}, { signal } = {}) {
signal?.throwIfAborted();
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),
});
const response = await fetchImpl(`/api/gnss-integrity?${params}`, {
signal,
});
if (!response.ok) throw new Error(`adsb.lol HTTP ${response.status}`);
const payload = await readResponseJsonCapped(
response,
4 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error('Malformed GNSS integrity snapshot');
return {
rows: payload.rows,
fetchedAt: Number.isFinite(payload.fetchedAt)
? payload.fetchedAt
: null,
stale: payload.stale === true,
};
},
};
}
+59
View File
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAdsbGnssSource } from './source.js';
test('the source asks the same-origin proxy for the view anchor', async () => {
const calls = [];
const source = createAdsbGnssSource({
fetchImpl: async (url, options) => {
calls.push(url);
assert.ok(options.signal instanceof AbortSignal);
return Response.json({ fetchedAt: 5, rows: [{ hex: 'abc123' }] });
},
});
const snapshot = await source.getSnapshot(
{ latitude: 50.06143, longitude: 19.93658 },
{ signal: new AbortController().signal },
);
assert.deepEqual(calls, ['/api/gnss-integrity?lat=50.06&lon=19.94']);
assert.deepEqual(snapshot, {
rows: [{ hex: 'abc123' }],
fetchedAt: 5,
stale: false,
});
});
test('the source reports stale proxy data and rejects bad input or responses', async () => {
const reply = (body, init) => async () => Response.json(body, init);
const stale = await createAdsbGnssSource({
fetchImpl: reply({ rows: [], stale: true }),
}).getSnapshot({ latitude: 1, longitude: 2 });
assert.equal(stale.stale, true);
assert.equal(stale.fetchedAt, null);
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}),
/view anchor/,
);
await assert.rejects(
createAdsbGnssSource({
fetchImpl: reply({ error: 'x' }, { status: 502 }),
}).getSnapshot({ latitude: 1, longitude: 2 }),
/HTTP 502/,
);
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({ rows: 'no' }) }).getSnapshot({
latitude: 1,
longitude: 2,
}),
/Malformed/,
);
const controller = new AbortController();
controller.abort();
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({ rows: [] }) }).getSnapshot(
{ latitude: 1, longitude: 2 },
{ signal: controller.signal },
),
);
});
+2
View File
@@ -10,6 +10,7 @@ import { makeFixtureRoot } from './fixtureRoot.mjs';
test('data providers have both hooks; credential editing stays development-only', () => {
const plugins = localProviderPlugins();
assert.ok(plugins.some(({ name }) => name === 'fire-perimeters'));
assert.ok(plugins.some(({ name }) => name === 'gnss-integrity'));
for (const plugin of plugins) {
if (plugin.name === 'gev-key-setup') {
assert.equal(plugin.configurePreviewServer, undefined);
@@ -103,6 +104,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
['/api/adsbdb/type/invalid', 400],
['/api/firms/status', 200],
['/api/fire-perimeters/inciweb/publication/invalid', 400],
['/api/gnss-integrity?lat=invalid', 400],
['/api/terrain/heights?points=invalid', 400],
['/api/overpass', 405],
['/api/overpass/status', 200],
+7 -1
View File
@@ -49,7 +49,13 @@ const PANEL_GROUPS = [
},
{
label: 'Events',
ids: ['rocket-launches', 'earthquakes', 'local-firms', 'fire-perimeters'],
ids: [
'rocket-launches',
'earthquakes',
'local-firms',
'fire-perimeters',
'gnss-interference',
],
},
{
label: 'Weather',