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

+1 -1
View File
@@ -122,7 +122,7 @@ export function createApplicationCatalog({
source: sources['fire-perimeters'],
}),
createApplicationGnssIntegrity({
source: sources['gnss-interference'],
source: sources['gnss-integrity'],
}),
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationStreetLevel({
+1 -1
View File
@@ -60,7 +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('gnss-integrity'));
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
+1 -1
View File
@@ -46,7 +46,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
cyclones: contract(['weather-cyclones'], ['getSnapshot']),
earthquakes: contract(['earthquakes'], ['getSnapshot']),
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
'gnss-interference': contract(['gnss-interference'], ['getSnapshot']),
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
});
+5 -3
View File
@@ -54,7 +54,7 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
const seenPosition = Math.max(
0,
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? 0,
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? NaN,
);
const seen = Math.max(0, finiteNumber(aircraft?.seen) ?? seenPosition);
const onGround = aircraft?.alt_baro === 'ground';
@@ -69,8 +69,10 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
hex,
String(aircraft?.flight || aircraft?.r || '').trim() || null,
null,
Math.max(0, nowSeconds - seenPosition),
Math.max(0, nowSeconds - seen),
Number.isFinite(seenPosition)
? Math.max(0, nowSeconds - seenPosition)
: null,
Number.isFinite(seen) ? Math.max(0, nowSeconds - seen) : null,
longitude,
latitude,
barometricFeet === null ? null : barometricFeet * FOOT_TO_M,
+9 -5
View File
@@ -1,4 +1,3 @@
// src/data/aircraftEmergency.js
/**
* Transponder emergency status for one aircraft, from the Mode A squawk and
* the ADS-B emergency/priority field.
@@ -10,8 +9,8 @@
* squawk only.
*
* The status is what the transponder broadcast in THIS poll, never a finding:
* crews pass through 7x00 codes while dialling, and a real emergency clears
* when the code does. Callers keep it per poll (not sticky).
* crews pass through 7x00 codes while dialling. Highlighting follows the latest
* broadcast, which does not confirm an incident or its resolution.
*/
/** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */
@@ -39,7 +38,8 @@ export function normalizeSquawk(value) {
}
/**
* Emergency status from one observation. An explicit ADS-B status wins; a
* Emergency status from one observation. Higher severity wins; an ADS-B
* emergency wins a tie. A
* 7x00 squawk counts even when the ADS-B field says `none`.
* @param {{squawk?: unknown, emergency?: unknown}} [observation]
* @returns {{kind: string, label: string, severity: 'emergency'|'priority',
@@ -53,7 +53,11 @@ export function aircraftEmergency({ squawk, emergency } = {}) {
.trim()
.toLowerCase();
const fromAdsb = Object.hasOwn(KINDS, reported);
const kind = fromAdsb ? reported : (SQUAWK_KINDS[code] ?? null);
const squawkKind = SQUAWK_KINDS[code] ?? null;
const kind =
fromAdsb && (KINDS[reported].severity === 'emergency' || !squawkKind)
? reported
: squawkKind;
if (!kind) return null;
return {
kind,
+11
View File
@@ -82,3 +82,14 @@ test('the broadcast phrase quotes a naming code, else attributes ADS-B', () => {
assert.equal(emergencyBroadcastPhrase(null), null);
assert.equal(emergencyBroadcastPhrase({ kind: 'reserved' }), null);
});
test('an emergency squawk outranks an ADS-B priority', () => {
assert.equal(
aircraftEmergency({ squawk: '7700', emergency: 'lifeguard' }).kind,
'general',
);
assert.equal(
aircraftEmergency({ squawk: '7500', emergency: 'minfuel' }).kind,
'unlawful',
);
});
+174 -6
View File
@@ -1,8 +1,11 @@
// src/data/aircraftEmergencyAnalyst.test.mjs
// Source-to-analyst fixture: an OpenSky state vector squawking 7700 and an
// adsb.lol (readsb) aircraft whose ADS-B `emergency` field says `general`
// on an ordinary code must reach the analyst with the same kind but distinct
// provenance, so a summary never flattens one into the other.
import * as Cesium from 'cesium';
import { TRACKED_ICON_COLOR } from '../layers/military/policy.js';
import { createRendering as civilRendering } from '../layers/flights/rendering.js';
import { createRendering as militaryRendering } from '../layers/military/rendering.js';
import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js';
import { aircraftEmergency } from './aircraftEmergency.js';
// Source-to-voice provenance, status retention/clearing, contact-time contracts,
// and civil/military billboard, Cockpit and model tint regressions.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { openSkySnapshot, readsbSnapshot } from '../sources/live/index.js';
@@ -30,7 +33,7 @@ test('fallback status belongs to the first valid admitted aircraft row', () => {
});
const snapshot = openSkySnapshot(payload, { now: NOW_MS });
assert.equal(snapshot.records.length, 1);
assert.equal(snapshot.records[0].emergency, status ?? null);
assert.equal(snapshot.records[0].emergency, status);
assert.equal(snapshot.records[0].latitude, 30);
}
});
@@ -354,3 +357,168 @@ test('military status changes retain only the latest known general contact', ()
assert.equal(changed.emergencyKind, 'nordo');
assert.equal(changed.lastContactAtMs, NOW_MS - 1500);
});
test('fallback missing contact age retains known contact through the real converter', () => {
const store = civilStore();
const row = {
hex: 'a1b2c3',
lat: 30,
lon: -97,
squawk: '1200',
emergency: 'general',
seen: 3,
seen_pos: 1,
};
const receive = (row) =>
store.receive(
openSkySnapshot(
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
{ now: NOW_MS },
).records[0],
receiveContext,
).meta;
const first = receive(row);
const second = receive({ ...row, seen: undefined, seen_pos: undefined });
assert.equal(second.lastContactEpochMs, first.lastContactEpochMs);
});
test('one omitted ADS-B status poll is retained; explicit none and changed squawk clear immediately', () => {
const store = civilStore();
const row = {
hex: 'a1b2c3',
lat: 30,
lon: -97,
squawk: '1200',
emergency: 'general',
seen: 3,
};
const receive = (row) =>
store.receive(
openSkySnapshot(
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
{ now: NOW_MS },
).records[0],
receiveContext,
).meta;
assert.equal(receive(row).emergency.kind, 'general');
assert.equal(
receive({ ...row, emergency: undefined }).emergency?.kind,
'general',
);
assert.equal(receive({ ...row, emergency: undefined }).emergency, null);
receive(row);
assert.equal(receive({ ...row, emergency: 'none' }).emergency, null);
receive(row);
assert.equal(
receive({ ...row, emergency: undefined, squawk: '2000' }).emergency,
null,
);
});
function renderHarness(factory) {
const flightState = { records: { data: new Map() }, _trackedIcao: null };
const rendering = factory({
flightState,
services: {
militaryRegistry: { isMilitaryIcao: (id) => id === 'mil' },
aircraftPresentation: {},
camera: {},
focus: {},
recession: {},
},
parts: {},
});
return { flightState, rendering };
}
test('tint priorities cover normal, priority, emergency, tracking and Cockpit dots', () => {
for (const factory of [civilRendering, militaryRendering]) {
const { flightState, rendering } = renderHarness(factory);
const normal = rendering._fleetBillboardColor('civil', {});
assert.equal(
rendering._fleetBillboardColor('civil', {
emergency: aircraftEmergency({ emergency: 'minfuel' }),
}),
normal,
);
const red = rendering._fleetBillboardColor('civil', {
emergency: aircraftEmergency({ squawk: '7700' }),
});
assert.ok(red.red > 0.9 && red.green < 0.5);
flightState.records.data.set('civil', {
emergency: aircraftEmergency({ squawk: '7700' }),
});
assert.equal(rendering._modelColor('civil'), red);
if (rendering._cockpitContactColor)
assert.equal(rendering._cockpitContactColor('civil'), red);
flightState._trackedIcao = 'civil';
assert.equal(
rendering._modelColor('civil'),
factory === civilRendering ? Cesium.Color.CYAN : TRACKED_ICON_COLOR,
);
flightState._trackedIcao = null;
flightState.records.data.set('civil', { emergency: null });
assert.equal(rendering._modelColor('civil'), normal);
}
});
test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => {
const { flightState, rendering } = renderHarness(militaryRendering);
const bb = {
color: rendering._fleetBillboardColor('abc123').withAlpha(0.65),
};
Object.assign(flightState, {
_billboards: new Map([['abc123', bb]]),
_models: new Map(),
_positionHistory: new Map(),
_cullPositions: new Map(),
_groundSnap: { forget() {} },
_cockpitContactMode: false,
});
const records = new MilitaryFlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (value) => value,
});
flightState.records = records;
const apply = createMilitarySnapshotRenderer({
flightState,
records,
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
meshFloor: { sampleMeshFloorCells() {} },
militaryRegistry: { registerMilitaryIcaos() {} },
rendering: {
...rendering,
_syncModelToClass() {},
_applyFleetBillboardPresentation() {},
},
motion: {},
tracking: {},
queries: {},
});
for (const [emergency, red] of [
['general', true],
['none', false],
['general', true],
]) {
const snapshot = readsbSnapshot(
{
ac: [
{
hex: 'abc123',
lat: 30,
lon: -97,
alt_baro: 10000,
squawk: '1200',
emergency,
seen_pos: 1,
},
],
},
{ observedAtMs: NOW_MS },
);
apply(snapshot, {});
assert.equal(bb.color.green < 0.5, red);
assert.equal(bb.color.alpha, 0.65);
}
});
+5 -2
View File
@@ -242,7 +242,7 @@ export function applyAircraftBillboardTreatment({
}
/**
* Apply the already-composed aircraft alpha to an ambient glTF model without
* Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without
* disturbing Cesium's existing MIX/colorBlendAmount presentation semantics.
* @param {object} input
* @param {object} input.model
@@ -261,7 +261,10 @@ export function applyAircraftModelTreatment({
if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
if (
Number.isFinite(model.color?.alpha) &&
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon &&
model.color.red === baseColor.red &&
model.color.green === baseColor.green &&
model.color.blue === baseColor.blue
)
return 0;
model.color = baseColor.withAlpha(alpha);
+12
View File
@@ -142,3 +142,15 @@ test('ambient model presentation receives composed alpha without changing blend
assert.equal(model.colorBlendAmount, 0.9);
assert.equal(applyAircraftModelTreatment({ model, baseColor: color, alpha: 0.2, params }), 0);
});
test('model hue changes at constant alpha, then clears without redundant writes', () => {
const color = (red,green,blue) => ({red,green,blue,withAlpha(alpha){return {red,green,blue,alpha};}});
const model = {color:color(1,1,1).withAlpha(0.65)};
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),1);
assert.equal(model.color.green,0.2);
assert.equal(model.color.alpha,0.65);
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),0);
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,1,1),alpha:0.65}),1);
assert.equal(model.color.green,1);
});
+117 -5
View File
@@ -7,7 +7,7 @@ import {
} from '../../server/providers/gnssIntegrity.js';
const snapshot = {
now: 1,
now: 4_000_000_000_000,
ac: [
{
hex: '4ca7b5',
@@ -28,7 +28,7 @@ const snapshot = {
function install(options = {}, hook = 'configureServer') {
let handler;
const plugin = gnssIntegrityProxy(options);
const plugin = gnssIntegrityProxy({ monotonicNow: options.now, ...options });
assert.equal(plugin.name, 'gnss-integrity');
plugin[hook]({
middlewares: {
@@ -89,7 +89,7 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
ageMs: 0,
anchor: { lat: 50, lon: 20 },
radiusNm: 250,
classifier: 'gev-nic-nacp-v1',
classifier: 'gev-nic-nacp-v2',
rows: [
{
hex: '4ca7b5',
@@ -205,7 +205,7 @@ test('an upstream 429 starts a cooldown with no further upstream calls', async (
assert.equal(calls, 2);
});
test('Retry-After accepts seconds or an HTTP date, with a 5 s minimum and no truncated deadline', () => {
test('Retry-After accepts sane seconds or HTTP dates up to one hour', () => {
const nowMs = Date.parse('2026-09-29T12:00:00Z');
assert.equal(gnssRetryCooldownMs('30', nowMs), 30_000);
assert.equal(gnssRetryCooldownMs('1', nowMs), 5_000);
@@ -309,9 +309,121 @@ test('the route rejects other methods, paths, bad anchors, a malformed feed and
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);
const flood = install({ fetchImpl: async () => Response.json(snapshot) });
let limited = 0;
for (let i = 0; i < 40; i += 1)
if ((await request('/?lat=1&lon=1', 'GET', 'flood')).status === 429)
if ((await flood(`/?lat=${i}&lon=1`, 'GET', 'flood')).status === 429)
limited += 1;
assert.ok(limited > 0);
});
test('GERMAN: pathological retry deadlines are bounded', () => {
const at = Date.parse('2026-10-14T12:00:00Z');
assert.ok(gnssRetryCooldownMs('1e305', at) <= 3600000);
assert.equal(gnssRetryCooldownMs('9999999999999999999999', at), 3600000);
assert.equal(
gnssRetryCooldownMs('Wed, 14 Oct 2099 12:00:00 GMT', at),
3600000,
);
assert.equal(gnssRetryCooldownMs('0x10', at), 60000);
});
test('GERMAN: 5xx, network timeouts and malformed JSON share a short cooldown', async () => {
for (const fail of [
async () => new Response('', { status: 503 }),
async () => {
throw new DOMException('timeout', 'TimeoutError');
},
async () => new Response('bad JSON'),
]) {
let clock = 0,
calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
calls++;
return fail();
},
});
assert.equal((await request()).status, 502);
clock = 14000;
assert.equal((await request('/?lat=1&lon=1')).status, 502);
assert.equal(calls, 1);
clock = 15001;
await request('/?lat=2&lon=2');
assert.equal(calls, 2);
}
});
test('GERMAN: source timestamp dates evidence; frozen old snapshots are rejected', async () => {
const now = 1800000000000;
const request = install({
now: () => now,
fetchImpl: async () =>
Response.json({ ...snapshot, now: (now - 120000) / 1000 }),
});
const res = await request();
assert.equal(res.body.ageMs, 120000);
assert.equal(res.body.stale, true);
for (const sourceNow of [(now - 1800001) / 1000, undefined, NaN]) {
const missing = install({
now: () => now,
fetchImpl: async () => Response.json({ ...snapshot, now: sourceNow }),
});
assert.equal((await missing()).status, 502);
}
});
test('GERMAN: cache age and cooldown remain stable across a wall-clock rollback', async () => {
let wall = 1800000000000,
mono = 0,
calls = 0;
const request = install({
now: () => wall,
monotonicNow: () => mono,
fetchImpl: async () => {
calls++;
return calls === 1
? Response.json({ ...snapshot, now: wall / 1000 })
: new Response('', { status: 429, headers: { 'Retry-After': '60' } });
},
});
await request();
wall -= 3600000;
mono = 61000;
const stale = await request();
assert.equal(stale.body.ageMs, 61000);
assert.equal(stale.body.stale, true);
mono += 1000;
assert.equal((await request('/?lat=1&lon=1')).headers['Retry-After'], '59');
assert.equal(calls, 2);
});
test('GERMAN: cache hits and coalesced reads do not consume upstream admission', async () => {
let calls = 0;
const request = install({
now: () => 1800000000000,
fetchImpl: async () => {
calls++;
return Response.json(snapshot);
},
});
for (let i = 0; i < 100; i++) assert.equal((await request()).status, 200);
assert.equal((await request('/?lat=1&lon=1')).status, 200);
assert.equal(calls, 2);
});
test('GERMAN: distinct concurrent upstream reads are bounded', async () => {
const pending = [];
const request = install({
fetchImpl: () => new Promise((resolve) => pending.push(resolve)),
});
const jobs = Array.from({ length: 30 }, (_, i) =>
request(`/?lat=${i}&lon=1`),
);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(pending.length <= 8);
pending.forEach((resolve) => resolve(Response.json(snapshot)));
const results = await Promise.all(jobs);
assert.ok(results.some((result) => result.status === 429));
});
+1 -1
View File
@@ -608,7 +608,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
optionOwner: 'flights',
}),
Object.freeze({
id: 'gnss-interference',
id: 'gnss-integrity',
token: '3',
disposition: 'enabled-only',
}),
+5 -5
View File
@@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3', competing: '3' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4', competing: '4' },
),
/next free token 4/,
/next free token 5/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
@@ -2643,11 +2643,11 @@ test('GNSS interference uses digit 3 without colliding with other digit tokens',
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2.1'));
assert.deepEqual(decoded.enabledLayerIds, [
'fire-perimeters',
'gnss-interference',
'gnss-integrity',
'recent-imagery',
]);
assert.equal(
LAYER_STATE_REGISTRY.find(({ id }) => id === 'gnss-interference').token,
LAYER_STATE_REGISTRY.find(({ id }) => id === 'gnss-integrity').token,
'3',
);
assert.deepEqual(
+1 -1
View File
@@ -134,7 +134,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{
...LEGACY_LAYER_STATE_TOKENS,
'street-level': '0',
'gnss-interference': '3',
'gnss-integrity': '3',
},
);
assert.throws(
+1 -1
View File
@@ -9,7 +9,7 @@
["earthquakes", "e"],
["fire-perimeters", "2"],
["flights", "f"],
["gnss-interference", "3"],
["gnss-integrity", "3"],
["local-dams", "q"],
["local-datacenters", "d"],
["local-firms", "w"],
+3 -3
View File
@@ -673,11 +673,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// referent-only track_entity alternative (kept out of the model-facing
// schema, which may not carry top-level anyOf), and Contacts requested-radius
// list descriptions; the missions still ride existing tools.
// GNSS and emergency/contact fields extend the manifest-generated contract.
assert.equal(block.length, 30208, 'serialized tool schema length drifted');
// Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft.
assert.equal(block.length, 30511, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'f226ed9a1d1867b107b39073f527add7f9cb083ef013dbb2111842fcbd343c35',
'62d5df9cedf413403e4fd0a7cbe94bb2cd13736c3d8fedc84bcccd63ad253eb6',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
+1
View File
@@ -171,6 +171,7 @@ export function createQueries({
// This poll's transponder broadcast (not sticky; see records.js).
squawk: info?.squawk ?? null,
emergency: info?.emergency ? { ...info.emergency } : null,
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
airline: info?.airline ?? null,
// CLASS label follows the TR-3B conversion so every downstream card
// (cockpit, Contacts, analyst) agrees with the triangle on screen.
+13 -1
View File
@@ -230,7 +230,19 @@ export class FlightRecords {
// Per poll, NOT sticky: an emergency code clears when the crew resets
// it, so a blank squawk must not hold a stale 7700 on screen.
squawk: observation.squawk ?? null,
emergency: aircraftEmergency(observation),
// One source-switch omission may retain an ADS-B status, while an
// explicit clear or changed squawk always takes effect immediately.
emergency:
observation.emergency === undefined &&
observation.squawk === prevMeta?.squawk &&
prevMeta?.emergency?.source === 'ads-b' &&
(prevMeta?.missingEmergencyPolls ?? 0) < 1
? prevMeta.emergency
: aircraftEmergency(observation),
missingEmergencyPolls:
observation.emergency === undefined
? (prevMeta?.missingEmergencyPolls ?? 0) + 1
: 0,
// OpenSky distinguishes the last position epoch from the last
// transponder message. The fleet coast horizon uses this actual
// contact time so a temporarily old position does not hard-freeze
+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',
});
});
+1
View File
@@ -137,6 +137,7 @@ export function createQueries({
// This poll's transponder broadcast (not sticky; see records.js).
squawk: info?.squawk ?? null,
emergency: info?.emergency ? { ...info.emergency } : null,
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
};
}
+6
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import {
turnRateFromFixHistory,
@@ -120,6 +121,11 @@ export function createMilitarySnapshotRenderer({
if (flightState._billboards.has(icao24)) {
const bb = flightState._billboards.get(icao24);
if (!isTracked) {
bb.color = rendering
._fleetBillboardColor(icao24, meta)
.withAlpha(cyberSonarBaseAlpha(bb));
}
// Position AND rotation are owned by the fleet pass (_fleetTick);
// course changes land on the next rotation pass (forced below).
// Ground flips (landing/takeoff) restyle this SAME billboard in
+4 -3
View File
@@ -1,3 +1,4 @@
import { normalizeSquawk } from '../../data/aircraftEmergency.js';
import {
admitRecords,
cleanText,
@@ -30,9 +31,9 @@ export function normalizeOpenSkyAircraft(row) {
typeCode: null,
registration: null,
operator: null,
squawk: cleanText(row[14]) || null,
squawk: normalizeSquawk(row[14]),
// State vectors carry no ADS-B emergency field; the squawk stands alone.
emergency: null,
emergency: undefined,
};
}
@@ -71,7 +72,7 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) {
typeCode: cleanText(row.t),
registration: cleanText(row.r),
operator: cleanText(row.ownOp),
squawk: cleanText(row.squawk) || null,
squawk: normalizeSquawk(row.squawk),
emergency: cleanText(row.emergency).toLowerCase() || null,
};
}
+2 -2
View File
@@ -314,11 +314,11 @@ test('identity lookup validates its response and honors body-parse cancellation'
});
});
test('transponder squawk and ADS-B emergency status are carried, blanks stay null', () => {
test('squawks are validated and omitted OpenSky status differs from an explicit ADS-B clear', () => {
const squawking = [...aircraft, '7700'];
const civil = openSkySnapshot({ states: [squawking] }, { now }).records[0];
assert.equal(civil.squawk, '7700');
assert.equal(civil.emergency, null);
assert.equal(civil.emergency, undefined);
assert.equal(openSkySnapshot({ states: [aircraft] }).records[0].squawk, null);
const mil = normalizeReadsbAircraft(
{ hex: 'abc123', lat: 30, lon: -97, squawk: '7600', emergency: 'NORDO' },
+1 -1
View File
@@ -9,6 +9,6 @@ export function createReferenceSources() {
earthquakes: createUsgsEarthquakeSource(),
'fire-perimeters': createWfigsPerimeterSource(),
cables: createBundledCableSource(),
'gnss-interference': createAdsbGnssSource(),
'gnss-integrity': createAdsbGnssSource(),
};
}
+3 -3
View File
@@ -16,15 +16,15 @@ test('reference factories retain compatibility without starting acquisition or s
'earthquakes',
'fire-perimeters',
'cables',
'gnss-interference',
'gnss-integrity',
]);
assert.notEqual(first.earthquakes, second.earthquakes);
assert.notEqual(first['fire-perimeters'], second['fire-perimeters']);
assert.notEqual(first.cables, second.cables);
assert.notEqual(first['gnss-interference'], second['gnss-interference']);
assert.notEqual(first['gnss-integrity'], second['gnss-integrity']);
assert.equal(typeof first.earthquakes.getSnapshot, 'function');
assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function');
assert.equal(typeof first.cables.fetch, 'function');
assert.equal(typeof first['gnss-interference'].getSnapshot, 'function');
assert.equal(typeof first['gnss-integrity'].getSnapshot, 'function');
assert.equal(requests, 0);
});
+1 -1
View File
@@ -54,7 +54,7 @@ const PANEL_GROUPS = [
'earthquakes',
'local-firms',
'fire-perimeters',
'gnss-interference',
'gnss-integrity',
],
},
{
+2 -2
View File
@@ -36,8 +36,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// consolidated tool wording (each policy stated once); model-facing tools
// omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves
// requested radius and center authority in descriptions only.
// GNSS and emergency/contact fields extend the manifest-generated contract.
'3f18ec8718981cfa42e1462a0f1a9f0102cd90f1f86e0cc25a9c0d0f3b291a53',
// Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts.
'9398815a573ab29ecc3ce527f6e1a2ad883804e7696e0444aebb11f973700d4b',
);
});
+5 -5
View File
@@ -3271,18 +3271,18 @@ test('GNSS integrity and interference common names toggle only the GNSS layer th
const calls = [];
let enabled = false;
const dataManager = {
layers: new Map([['gnss-interference', { module: {} }]]),
getAll: () => [{ id: 'gnss-interference', name: 'GNSS Integrity' }],
layers: new Map([['gnss-integrity', { module: {} }]]),
getAll: () => [{ id: 'gnss-integrity', name: 'GNSS Integrity' }],
isEnabled: () => enabled,
setEnabled: async (id, value) => { calls.push([id, value]); enabled = value; return true; },
};
const runner = createGevActionRunner({ viewer, styleManager: {}, dataManager });
for (const alias of ['gnss-interference', 'gnss integrity', 'navigation integrity', 'low navigation accuracy', 'gnss interference', 'gps interference', 'gps jamming', 'gnss']) {
for (const alias of ['gnss-integrity', 'gnss integrity', 'navigation integrity', 'low navigation accuracy', 'gnss interference', 'gps interference', 'gps jamming', 'gnss']) {
for (const value of [true, false]) {
const result = await runner('set_layer_visibility', { layerId: alias, enabled: value });
assert.equal(result.ok, true);
assert.equal(result.layerId, 'gnss-interference');
assert.deepEqual(calls.at(-1), ['gnss-interference', value]);
assert.equal(result.layerId, 'gnss-integrity');
assert.deepEqual(calls.at(-1), ['gnss-integrity', value]);
}
}
});
+1 -1
View File
@@ -44,7 +44,7 @@ const AIRCRAFT_FIELDS = {
const layers = [
{
id: 'gnss-interference',
id: 'gnss-integrity',
aliases: [
'gnss integrity',
'navigation integrity',