mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
492 lines
16 KiB
JavaScript
492 lines
16 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
accumulateGnssObservations,
|
|
binGnssCells,
|
|
GNSS_CLASSIFIER,
|
|
GNSS_PROVENANCE,
|
|
GNSS_WINDOW_MS,
|
|
gnssCellKey,
|
|
gnssIntegrityLevel,
|
|
gnssProvenance,
|
|
normalizeGnssAircraft,
|
|
pruneGnssObservations,
|
|
} 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 airborne decoded ADS-B v1/v2 quality with current positions', () => {
|
|
const rows = normalizeGnssAircraft({
|
|
ac: [
|
|
aircraft(),
|
|
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, 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.
|
|
aircraft({ hex: '000009', version: 3 }),
|
|
aircraft({ hex: '00000a', version: '2' }),
|
|
aircraft({ hex: '00000b', version: 1.5 }),
|
|
// A position is current only when its age is known, finite and 0..60 s.
|
|
aircraft({ hex: '00000c', seen_pos: undefined }),
|
|
aircraft({ hex: '00000d', seen_pos: null }),
|
|
aircraft({ hex: '00000e', seen_pos: -0.5 }),
|
|
aircraft({ hex: '00000f', seen_pos: '1.2' }),
|
|
aircraft({ hex: '000010', seen_pos: Number.NaN }),
|
|
aircraft({ hex: '000011', seen_pos: 60.1 }),
|
|
aircraft({ hex: 'not-hex' }),
|
|
aircraft(),
|
|
// A non-ICAO (`~`) address still self-reports over 1090ES ADS-B.
|
|
aircraft({ hex: '~a1b2c3' }),
|
|
// Version 1, and both ends of the position-age range, qualify.
|
|
aircraft({ hex: 'b00001', version: 1, seen_pos: 0 }),
|
|
aircraft({ hex: 'b00002', seen_pos: 60 }),
|
|
null,
|
|
],
|
|
});
|
|
assert.deepEqual(rows, [
|
|
{
|
|
hex: '4ca7b5',
|
|
lat: 50.1,
|
|
lon: 19.9,
|
|
nic: 8,
|
|
nacp: 10,
|
|
gpsLost: false,
|
|
degraded: false,
|
|
},
|
|
{
|
|
hex: '~a1b2c3',
|
|
lat: 50.1,
|
|
lon: 19.9,
|
|
nic: 8,
|
|
nacp: 10,
|
|
gpsLost: false,
|
|
degraded: false,
|
|
},
|
|
...['b00001', 'b00002'].map((hex) => ({
|
|
hex,
|
|
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');
|
|
// Just south-west of the origin falls in the last cell before 0°, not cell 0.
|
|
assert.equal(gnssCellKey(-0.1, -0.1), '179:359');
|
|
});
|
|
|
|
test('integrity level follows the gpsjam.org published cell formula and bands', () => {
|
|
const nominal = {
|
|
percent: 0,
|
|
level: 'low',
|
|
state: 'nominal',
|
|
interpretation: null,
|
|
};
|
|
assert.deepEqual(gnssIntegrityLevel(10, 0), nominal);
|
|
// One degraded aircraft is always discounted.
|
|
assert.deepEqual(gnssIntegrityLevel(10, 1), nominal);
|
|
assert.equal(gnssIntegrityLevel(50, 2).level, 'medium');
|
|
assert.equal(gnssIntegrityLevel(10, 2).level, 'medium');
|
|
assert.equal(gnssIntegrityLevel(10, 3).level, 'high');
|
|
assert.equal(gnssIntegrityLevel(0, 0).level, 'low');
|
|
});
|
|
|
|
test('the primary state is a navigation-integrity anomaly; interference is only the secondary reading', () => {
|
|
for (const [total, bad] of [
|
|
[50, 2],
|
|
[10, 3],
|
|
]) {
|
|
const { state, interpretation } = gnssIntegrityLevel(total, bad);
|
|
assert.equal(state, 'navigation-integrity-anomaly');
|
|
assert.equal(interpretation, 'suspected-gnss-interference');
|
|
}
|
|
const store = new Map();
|
|
accumulateGnssObservations(
|
|
store,
|
|
[
|
|
{ hex: 'h1', lat: 10.1, lon: 10.1, degraded: true },
|
|
{ hex: 'h2', lat: 10.2, lon: 10.2, degraded: true },
|
|
{ hex: 'h3', lat: 10.3, lon: 10.3, degraded: false },
|
|
],
|
|
0,
|
|
);
|
|
const [cell] = binGnssCells(store);
|
|
assert.equal(cell.level, 'high');
|
|
assert.equal(cell.state, 'navigation-integrity-anomaly');
|
|
assert.equal(cell.interpretation, 'suspected-gnss-interference');
|
|
for (const key of Object.keys(cell))
|
|
assert.doesNotMatch(key, /interference|jamming|spoof/i);
|
|
});
|
|
|
|
test('provenance separates the GEV classifier from the gpsjam aggregation and the GEV window', () => {
|
|
assert.deepEqual(GNSS_PROVENANCE.classifier, {
|
|
id: 'gev-nic-nacp-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,
|
|
});
|
|
assert.equal(GNSS_PROVENANCE.classifier, GNSS_CLASSIFIER);
|
|
assert.deepEqual(GNSS_PROVENANCE.aggregation, {
|
|
id: 'gpsjam-cell-bands',
|
|
formula: '100 * max(0, bad - 1) / total',
|
|
bands: [2, 10],
|
|
source: 'https://gpsjam.org/faq',
|
|
definedBy: 'gpsjam.org',
|
|
});
|
|
assert.deepEqual(GNSS_PROVENANCE.window, {
|
|
minutes: 30,
|
|
scope: 'visited-view',
|
|
cellDeg: 0.5,
|
|
minAircraft: 3,
|
|
definedBy: 'GEV',
|
|
});
|
|
assert.equal(GNSS_PROVENANCE.state.id, 'navigation-integrity-anomaly');
|
|
assert.equal(
|
|
GNSS_PROVENANCE.interpretation.id,
|
|
'suspected-gnss-interference',
|
|
);
|
|
assert.equal(GNSS_PROVENANCE.interpretation.validated, false);
|
|
assert.ok(Object.isFrozen(GNSS_PROVENANCE));
|
|
assert.ok(Object.isFrozen(GNSS_PROVENANCE.aggregation.bands));
|
|
// The bands in provenance are the ones the level function applies.
|
|
const [medium, high] = GNSS_PROVENANCE.aggregation.bands;
|
|
assert.equal(gnssIntegrityLevel(100, 1 + 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);
|
|
});
|
|
|
|
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), []);
|
|
});
|
|
|
|
const MIN = 60_000;
|
|
const report = (hex, degraded, lat = 50.1) => ({
|
|
hex,
|
|
lat,
|
|
lon: 20.1,
|
|
degraded,
|
|
});
|
|
const entry = (store, hex) =>
|
|
[...store].find(([key]) => key.startsWith(`${hex}|`))?.[1];
|
|
|
|
test('degraded evidence expires one window after the degraded report, despite continuing healthy reports', () => {
|
|
const store = new Map();
|
|
accumulateGnssObservations(store, [report('a1', true)], 0);
|
|
// a1 keeps reporting healthy every minute in the same cell.
|
|
for (let minute = 1; minute <= 30; minute += 1)
|
|
accumulateGnssObservations(store, [report('a1', false)], minute * MIN);
|
|
assert.equal(entry(store, 'a1').seenAt, 30 * MIN);
|
|
assert.equal(
|
|
entry(store, 'a1').degradedAt,
|
|
0,
|
|
'healthy reports do not refresh the degraded evidence',
|
|
);
|
|
accumulateGnssObservations(store, [report('a1', false)], 31 * MIN);
|
|
assert.deepEqual(entry(store, 'a1'), {
|
|
cell: gnssCellKey(50.1, 20.1),
|
|
seenAt: 31 * MIN,
|
|
degradedAt: null,
|
|
});
|
|
// A new degraded report starts its own window.
|
|
accumulateGnssObservations(store, [report('a1', true)], 32 * MIN);
|
|
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
|
|
accumulateGnssObservations(store, [report('a1', false)], 40 * MIN);
|
|
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
|
|
});
|
|
|
|
test('a degraded cell turns healthy once its degraded evidence is older than the window', () => {
|
|
const store = new Map();
|
|
const healthy = [report('a2', false), report('a3', false)];
|
|
accumulateGnssObservations(
|
|
store,
|
|
[report('a1', true), report('b1', true), ...healthy],
|
|
0,
|
|
);
|
|
assert.equal(binGnssCells(store)[0].level, 'high');
|
|
for (let minute = 10; minute <= 30; minute += 10)
|
|
accumulateGnssObservations(
|
|
store,
|
|
[report('a1', false), report('b1', false), ...healthy],
|
|
minute * MIN,
|
|
);
|
|
assert.equal(binGnssCells(store)[0].degraded, 2);
|
|
accumulateGnssObservations(
|
|
store,
|
|
[report('a1', false), report('b1', false), ...healthy],
|
|
31 * MIN,
|
|
);
|
|
const [cell] = binGnssCells(store);
|
|
assert.equal(cell.aircraft, 4);
|
|
assert.equal(cell.degraded, 0);
|
|
assert.equal(cell.level, 'low');
|
|
});
|
|
|
|
test('an aircraft returning after the window starts fresh', () => {
|
|
const store = new Map();
|
|
accumulateGnssObservations(store, [report('a1', true)], 0);
|
|
// a1 leaves; nothing is heard from it for longer than the window.
|
|
accumulateGnssObservations(store, [], 31 * MIN);
|
|
assert.equal(store.size, 0);
|
|
accumulateGnssObservations(store, [report('a1', false)], 32 * MIN);
|
|
assert.deepEqual(entry(store, 'a1'), {
|
|
cell: gnssCellKey(50.1, 20.1),
|
|
seenAt: 32 * MIN,
|
|
degradedAt: null,
|
|
});
|
|
// Returning degraded dates the evidence from the new report.
|
|
const again = new Map();
|
|
accumulateGnssObservations(again, [report('a9', true)], 0);
|
|
accumulateGnssObservations(again, [], 31 * MIN);
|
|
accumulateGnssObservations(again, [report('a9', true)], 33 * MIN);
|
|
assert.equal(entry(again, 'a9').degradedAt, 33 * MIN);
|
|
});
|
|
|
|
test('an aircraft heard healthy right after the window drops its stale degraded evidence', () => {
|
|
// No prune runs in between: the old entry is still in the store when the
|
|
// healthy report arrives, so the new report alone must not carry it over.
|
|
const store = new Map();
|
|
accumulateGnssObservations(store, [report('a1', true)], 0);
|
|
accumulateGnssObservations(store, [report('a1', false)], 31 * MIN);
|
|
assert.deepEqual(entry(store, 'a1'), {
|
|
cell: gnssCellKey(50.1, 20.1),
|
|
seenAt: 31 * MIN,
|
|
degradedAt: null,
|
|
});
|
|
// Exactly one window after the degraded report it still counts.
|
|
const edge = new Map();
|
|
accumulateGnssObservations(edge, [report('a1', true)], 0);
|
|
accumulateGnssObservations(edge, [report('a1', false)], 30 * MIN);
|
|
assert.equal(entry(edge, 'a1').degradedAt, 0);
|
|
});
|
|
|
|
test('rows are dated by the snapshot observation time, so a replay never restarts the window', () => {
|
|
const store = new Map();
|
|
const snapshot = [report('a1', true), report('a2', false)];
|
|
accumulateGnssObservations(store, snapshot, 0, { observedAt: 0 });
|
|
// The same snapshot replayed (cached or stale) 29 minutes later.
|
|
accumulateGnssObservations(store, snapshot, 29 * MIN, { observedAt: 0 });
|
|
assert.equal(entry(store, 'a1').seenAt, 0);
|
|
assert.equal(entry(store, 'a1').degradedAt, 0);
|
|
accumulateGnssObservations(store, snapshot, 31 * MIN, { observedAt: 0 });
|
|
assert.equal(store.size, 0, 'a replay older than the window adds nothing');
|
|
|
|
// An older replay never moves an entry's age backwards either.
|
|
accumulateGnssObservations(store, snapshot, 40 * MIN);
|
|
accumulateGnssObservations(store, [report('a2', true)], 41 * MIN, {
|
|
observedAt: 35 * MIN,
|
|
});
|
|
assert.equal(entry(store, 'a2').seenAt, 40 * MIN);
|
|
assert.equal(entry(store, 'a2').degradedAt, 35 * MIN);
|
|
|
|
// A future time is clamped to now; an undated snapshot adds nothing.
|
|
accumulateGnssObservations(store, [report('f1', true)], 50 * MIN, {
|
|
observedAt: 99 * MIN,
|
|
});
|
|
assert.equal(entry(store, 'f1').seenAt, 50 * MIN);
|
|
for (const observedAt of [null, Number.NaN])
|
|
accumulateGnssObservations(store, [report('u1', true)], 50 * MIN, {
|
|
observedAt,
|
|
});
|
|
assert.equal(entry(store, 'u1'), undefined);
|
|
});
|
|
|
|
test('pruning without new rows drops expired observations and expired degraded evidence', () => {
|
|
const store = new Map();
|
|
accumulateGnssObservations(store, [report('a1', true)], 0);
|
|
accumulateGnssObservations(store, [report('a1', false)], 20 * MIN);
|
|
accumulateGnssObservations(store, [report('a2', false)], 0);
|
|
assert.equal(pruneGnssObservations(store, 31 * MIN), store);
|
|
assert.deepEqual(
|
|
[...store.keys()].map((key) => key.split('|')[0]),
|
|
['a1'],
|
|
);
|
|
assert.equal(entry(store, 'a1').degradedAt, null);
|
|
pruneGnssObservations(store, 51 * MIN);
|
|
assert.equal(store.size, 0);
|
|
assert.equal(GNSS_WINDOW_MS, 30 * MIN);
|
|
});
|
|
|
|
test('rows without finite coordinates never key a cell', () => {
|
|
const store = new Map();
|
|
accumulateGnssObservations(
|
|
store,
|
|
[
|
|
{ hex: 'n1', lat: Number.NaN, lon: 10.1, degraded: true },
|
|
{ hex: 'n2', lat: 10.1, lon: '10.1', degraded: true },
|
|
null,
|
|
],
|
|
0,
|
|
);
|
|
assert.equal(store.size, 0);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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,
|
|
);
|
|
});
|