mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(gnss): date replayed snapshots by the proxy-reported age, not cross-clock fetchedAt
The layer compared the server's fetchedAt with the browser's clock, so a skewed client clock could shift or void the 30-minute window. The proxy now returns ageMs (its own now minus fetchedAt, at serve time, on fresh, cached and stale answers); the source refuses a snapshot without a finite, non-negative ageMs, and the layer dates rows as its now() - ageMs. fetchedAt stays in the payload for display. Tests: proxy cached/stale answers report ageMs on the server clock; the source rejects missing/invalid/negative ageMs; the layer replay test drives ageMs. qa-gnss stubs send ageMs. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7e570a79ad
commit
783c5b16f4
9 files changed
+55
-25
No files matched your search
+2
-1
@@ -169,7 +169,8 @@
|
|||||||
Keyless, worldwide wherever adsb.lol has receivers; reads
|
Keyless, worldwide wherever adsb.lol has receivers; reads
|
||||||
a capped 250 nm snapshot around the camera through `/api/gnss-integrity`,
|
a capped 250 nm snapshot around the camera through `/api/gnss-integrity`,
|
||||||
which returns integrity fields only and serves the last snapshot (with its
|
which returns integrity fields only and serves the last snapshot (with its
|
||||||
original observation time, for at most 30 minutes) when the upstream fails.
|
original observation time and `ageMs`, for at most 30 minutes) when the
|
||||||
|
upstream fails.
|
||||||
Only ADS-B versions 1 and 2 with a known position age of 0-60 s count. A
|
Only ADS-B versions 1 and 2 with a known position age of 0-60 s count. A
|
||||||
degraded report keeps an aircraft degraded for 30 minutes after that report,
|
degraded report keeps an aircraft degraded for 30 minutes after that report,
|
||||||
not after its latest healthy one; replayed snapshots keep their age, and
|
not after its latest healthy one; replayed snapshots keep their age, and
|
||||||
|
|||||||
@@ -3072,7 +3072,7 @@ its criteria cannot be silently ignored.
|
|||||||
| GNSS Integrity 📡 (`gnss-interference`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | 60s (server cache 60 s; 30 min rolling window) |
|
| GNSS Integrity 📡 (`gnss-interference`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | 60s (server cache 60 s; 30 min rolling window) |
|
||||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||||
|
|
||||||
GNSS Integrity fetches a capped adsb.lol snapshot around the view anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. Only DO-260A/B (ADS-B versions 1 and 2) direct 1090ES positions with a known `seen_pos` of 0–60 s count. Presence and degraded evidence age separately: a degraded report keeps the aircraft degraded in its cell for 30 minutes after that report, however many healthy reports follow, and an aircraft unheard for 30 minutes starts fresh. Rows are dated by the snapshot's `fetchedAt`, which the proxy keeps on cached and stale answers, so a replayed snapshot never restarts the window; the proxy stops serving a stale snapshot once it is older than 30 minutes, the source refuses an undated one, and cells age out on schedule during fetch failures or while there is no view anchor.
|
GNSS Integrity fetches a capped adsb.lol snapshot around the view anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. Only DO-260A/B (ADS-B versions 1 and 2) direct 1090ES positions with a known `seen_pos` of 0–60 s count. Presence and degraded evidence age separately: a degraded report keeps the aircraft degraded in its cell for 30 minutes after that report, however many healthy reports follow, and an aircraft unheard for 30 minutes starts fresh. Rows are dated by the snapshot's observation time, which the proxy keeps on cached and stale answers and reports as `ageMs` on its own clock (so browser clock skew does not move the window); a replayed snapshot never restarts the window; the proxy stops serving a stale snapshot once it is older than 30 minutes, the source refuses an undated one, and cells age out on schedule during fetch failures or while there is no view anchor.
|
||||||
|
|
||||||
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -250,7 +250,11 @@ async function stubRoute(page, { ageMs = 0 } = {}) {
|
|||||||
if (!url.includes('/api/gnss-integrity')) return realFetch(input, init);
|
if (!url.includes('/api/gnss-integrity')) return realFetch(input, init);
|
||||||
const ok = window.__gnssQaMode === 'fixture';
|
const ok = window.__gnssQaMode === 'fixture';
|
||||||
const body = ok
|
const body = ok
|
||||||
? { ...payload, fetchedAt: Date.now() - (window.__gnssQaAgeMs || 0) }
|
? {
|
||||||
|
...payload,
|
||||||
|
fetchedAt: Date.now() - (window.__gnssQaAgeMs || 0),
|
||||||
|
ageMs: window.__gnssQaAgeMs || 0,
|
||||||
|
}
|
||||||
: { error: 'gnss_integrity_unavailable' };
|
: { error: 'gnss_integrity_unavailable' };
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
new Response(JSON.stringify(body), {
|
new Response(JSON.stringify(body), {
|
||||||
|
|||||||
@@ -145,7 +145,10 @@ export function gnssIntegrityProxy({
|
|||||||
if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' });
|
if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' });
|
||||||
try {
|
try {
|
||||||
const { value, stale } = await acquire(anchor);
|
const { value, stale } = await acquire(anchor);
|
||||||
json(200, stale ? { ...value, stale: true } : value, stale);
|
// ageMs is measured on this server's clock, so the browser can date
|
||||||
|
// the rows without comparing its clock to ours.
|
||||||
|
const ageMs = Math.max(0, now() - value.fetchedAt);
|
||||||
|
json(200, { ...value, ageMs, ...(stale ? { stale: true } : {}) }, stale);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status === 429) {
|
if (error.status === 429) {
|
||||||
// Tell the browser how long the shared upstream cooldown has left.
|
// Tell the browser how long the shared upstream cooldown has left.
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
|
|||||||
assert.deepEqual(calls, ['https://api.adsb.lol/v2/lat/50/lon/20/dist/250']);
|
assert.deepEqual(calls, ['https://api.adsb.lol/v2/lat/50/lon/20/dist/250']);
|
||||||
assert.deepEqual(res.body, {
|
assert.deepEqual(res.body, {
|
||||||
fetchedAt: 1234,
|
fetchedAt: 1234,
|
||||||
|
ageMs: 0,
|
||||||
anchor: { lat: 50, lon: 20 },
|
anchor: { lat: 50, lon: 20 },
|
||||||
radiusNm: 250,
|
radiusNm: 250,
|
||||||
classifier: 'gev-nic-nacp-v1',
|
classifier: 'gev-nic-nacp-v1',
|
||||||
@@ -118,8 +119,14 @@ test('snapshots are cached per anchor and concurrent reads share one upstream ca
|
|||||||
await Promise.all([request(), request('/?lat=50.3&lon=20.2')]);
|
await Promise.all([request(), request('/?lat=50.3&lon=20.2')]);
|
||||||
assert.equal(calls, 1);
|
assert.equal(calls, 1);
|
||||||
clock = 59_000;
|
clock = 59_000;
|
||||||
await request();
|
const cached = await request();
|
||||||
assert.equal(calls, 1);
|
assert.equal(calls, 1);
|
||||||
|
assert.equal(cached.body.fetchedAt, 0);
|
||||||
|
assert.equal(
|
||||||
|
cached.body.ageMs,
|
||||||
|
59_000,
|
||||||
|
'a cached answer reports its age on the server clock',
|
||||||
|
);
|
||||||
clock = 61_000;
|
clock = 61_000;
|
||||||
await request();
|
await request();
|
||||||
assert.equal(calls, 2);
|
assert.equal(calls, 2);
|
||||||
@@ -146,6 +153,7 @@ test('an upstream failure serves the last snapshot as stale, else 502', async ()
|
|||||||
0,
|
0,
|
||||||
'a stale answer keeps the time it was observed',
|
'a stale answer keeps the time it was observed',
|
||||||
);
|
);
|
||||||
|
assert.equal(stale.body.ageMs, 120_000);
|
||||||
assert.equal(stale.headers['X-Data-Stale'], 'true');
|
assert.equal(stale.headers['X-Data-Stale'], 'true');
|
||||||
const missing = await request('/?lat=-10&lon=-10');
|
const missing = await request('/?lat=-10&lon=-10');
|
||||||
assert.equal(missing.status, 502);
|
assert.equal(missing.status, 502);
|
||||||
|
|||||||
@@ -157,9 +157,11 @@ export function createGnssIntegrityLayer({
|
|||||||
return false;
|
return false;
|
||||||
// Date the rows by when the proxy observed them: a cached or stale
|
// Date the rows by when the proxy observed them: a cached or stale
|
||||||
// replay keeps its original age instead of restarting the window.
|
// replay keeps its original age instead of restarting the window.
|
||||||
accumulateGnssObservations(_observations, snapshot.rows, now(), {
|
// The proxy reports that age, so client and server clocks never mix.
|
||||||
|
const at = now();
|
||||||
|
accumulateGnssObservations(_observations, snapshot.rows, at, {
|
||||||
windowMs,
|
windowMs,
|
||||||
observedAt: snapshot.fetchedAt,
|
observedAt: at - snapshot.ageMs,
|
||||||
});
|
});
|
||||||
_cells = binGnssCells(_observations);
|
_cells = binGnssCells(_observations);
|
||||||
render();
|
render();
|
||||||
|
|||||||
@@ -31,7 +31,13 @@ function harness(
|
|||||||
let clock = 1_000;
|
let clock = 1_000;
|
||||||
const anchors = [];
|
const anchors = [];
|
||||||
const layer = createGnssIntegrityLayer({
|
const layer = createGnssIntegrityLayer({
|
||||||
source: { getSnapshot },
|
// The real source refuses an undated snapshot; stubs default to a fresh one.
|
||||||
|
source: {
|
||||||
|
async getSnapshot(...args) {
|
||||||
|
const snapshot = await getSnapshot(...args);
|
||||||
|
return { ageMs: 0, ...snapshot };
|
||||||
|
},
|
||||||
|
},
|
||||||
viewAnchor: () => {
|
viewAnchor: () => {
|
||||||
anchors.push(anchor);
|
anchors.push(anchor);
|
||||||
return anchor;
|
return anchor;
|
||||||
@@ -220,9 +226,9 @@ test('no view anchor means no request, and destroy releases the data source', as
|
|||||||
|
|
||||||
test('a replayed stale snapshot keeps its observation age and expires on time', async () => {
|
test('a replayed stale snapshot keeps its observation age and expires on time', async () => {
|
||||||
// The proxy keeps answering with the snapshot it observed at t=1000.
|
// The proxy keeps answering with the snapshot it observed at t=1000.
|
||||||
const { layer, sources, advance } = harness(async () => ({
|
const { layer, sources, advance, now } = harness(async () => ({
|
||||||
rows: threeHealthy(),
|
rows: threeHealthy(),
|
||||||
fetchedAt: 1_000,
|
ageMs: now() - 1_000,
|
||||||
stale: true,
|
stale: true,
|
||||||
}));
|
}));
|
||||||
layer.enable();
|
layer.enable();
|
||||||
@@ -247,9 +253,9 @@ test('a replayed stale snapshot keeps its observation age and expires on time',
|
|||||||
|
|
||||||
test('cells expire during prolonged fetch failures', async () => {
|
test('cells expire during prolonged fetch failures', async () => {
|
||||||
let fail = false;
|
let fail = false;
|
||||||
const { layer, sources, advance, now } = harness(async () => {
|
const { layer, sources, advance } = harness(async () => {
|
||||||
if (fail) throw new Error('adsb.lol HTTP 502');
|
if (fail) throw new Error('adsb.lol HTTP 502');
|
||||||
return { rows: threeHealthy(), fetchedAt: now() };
|
return { rows: threeHealthy() };
|
||||||
});
|
});
|
||||||
layer.enable();
|
layer.enable();
|
||||||
await layer.update();
|
await layer.update();
|
||||||
@@ -266,9 +272,9 @@ test('cells expire during prolonged fetch failures', async () => {
|
|||||||
|
|
||||||
test('cells expire while no view anchor is available', async () => {
|
test('cells expire while no view anchor is available', async () => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const { layer, sources, advance, now, setAnchor } = harness(async () => {
|
const { layer, sources, advance, setAnchor } = harness(async () => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
return { rows: threeHealthy(), fetchedAt: now() };
|
return { rows: threeHealthy() };
|
||||||
});
|
});
|
||||||
layer.enable();
|
layer.enable();
|
||||||
await layer.update();
|
await layer.update();
|
||||||
@@ -285,9 +291,8 @@ test('cells expire while no view anchor is available', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('re-enabling after a long disable does not show cells past the window', async () => {
|
test('re-enabling after a long disable does not show cells past the window', async () => {
|
||||||
const { layer, sources, advance, now } = harness(async () => ({
|
const { layer, sources, advance } = harness(async () => ({
|
||||||
rows: threeHealthy(),
|
rows: threeHealthy(),
|
||||||
fetchedAt: now(),
|
|
||||||
}));
|
}));
|
||||||
layer.enable();
|
layer.enable();
|
||||||
await layer.update();
|
await layer.update();
|
||||||
|
|||||||
@@ -25,13 +25,17 @@ export function createAdsbGnssSource({
|
|||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
if (!Array.isArray(payload?.rows))
|
if (!Array.isArray(payload?.rows))
|
||||||
throw new Error('Malformed GNSS integrity snapshot');
|
throw new Error('Malformed GNSS integrity snapshot');
|
||||||
// The observation time dates every row in the rolling window; an
|
// The snapshot's age (measured on the proxy's clock) dates every row in
|
||||||
// undated snapshot could only be treated as new, so it is refused.
|
// the rolling window; an undated snapshot could only be treated as new,
|
||||||
if (!Number.isFinite(payload.fetchedAt))
|
// so it is refused.
|
||||||
|
if (!Number.isFinite(payload.ageMs) || payload.ageMs < 0)
|
||||||
throw new Error('GNSS integrity snapshot has no observation time');
|
throw new Error('GNSS integrity snapshot has no observation time');
|
||||||
return {
|
return {
|
||||||
rows: payload.rows,
|
rows: payload.rows,
|
||||||
fetchedAt: payload.fetchedAt,
|
ageMs: payload.ageMs,
|
||||||
|
fetchedAt: Number.isFinite(payload.fetchedAt)
|
||||||
|
? payload.fetchedAt
|
||||||
|
: null,
|
||||||
stale: payload.stale === true,
|
stale: payload.stale === true,
|
||||||
classifier:
|
classifier:
|
||||||
typeof payload.classifier === 'string' ? payload.classifier : null,
|
typeof payload.classifier === 'string' ? payload.classifier : null,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ test('the source asks the same-origin proxy for the view anchor', async () => {
|
|||||||
assert.ok(options.signal instanceof AbortSignal);
|
assert.ok(options.signal instanceof AbortSignal);
|
||||||
return Response.json({
|
return Response.json({
|
||||||
fetchedAt: 5,
|
fetchedAt: 5,
|
||||||
|
ageMs: 0,
|
||||||
classifier: 'gev-nic-nacp-v1',
|
classifier: 'gev-nic-nacp-v1',
|
||||||
rows: [{ hex: 'abc123' }],
|
rows: [{ hex: 'abc123' }],
|
||||||
});
|
});
|
||||||
@@ -22,6 +23,7 @@ test('the source asks the same-origin proxy for the view anchor', async () => {
|
|||||||
assert.deepEqual(calls, ['/api/gnss-integrity?lat=50.06&lon=19.94']);
|
assert.deepEqual(calls, ['/api/gnss-integrity?lat=50.06&lon=19.94']);
|
||||||
assert.deepEqual(snapshot, {
|
assert.deepEqual(snapshot, {
|
||||||
rows: [{ hex: 'abc123' }],
|
rows: [{ hex: 'abc123' }],
|
||||||
|
ageMs: 0,
|
||||||
fetchedAt: 5,
|
fetchedAt: 5,
|
||||||
stale: false,
|
stale: false,
|
||||||
classifier: 'gev-nic-nacp-v1',
|
classifier: 'gev-nic-nacp-v1',
|
||||||
@@ -31,16 +33,17 @@ test('the source asks the same-origin proxy for the view anchor', async () => {
|
|||||||
test('the source reports stale proxy data and rejects bad input or responses', async () => {
|
test('the source reports stale proxy data and rejects bad input or responses', async () => {
|
||||||
const reply = (body, init) => async () => Response.json(body, init);
|
const reply = (body, init) => async () => Response.json(body, init);
|
||||||
const stale = await createAdsbGnssSource({
|
const stale = await createAdsbGnssSource({
|
||||||
fetchImpl: reply({ rows: [], fetchedAt: 7, stale: true }),
|
fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs: 90_000, stale: true }),
|
||||||
}).getSnapshot({ latitude: 1, longitude: 2 });
|
}).getSnapshot({ latitude: 1, longitude: 2 });
|
||||||
assert.equal(stale.stale, true);
|
assert.equal(stale.stale, true);
|
||||||
assert.equal(stale.fetchedAt, 7, 'a stale reply keeps its observation time');
|
assert.equal(stale.ageMs, 90_000, 'a stale reply keeps its observation age');
|
||||||
|
assert.equal(stale.fetchedAt, 7);
|
||||||
assert.equal(stale.classifier, null);
|
assert.equal(stale.classifier, null);
|
||||||
// Without an observation time the rows could only be dated as new.
|
// Without an observation age the rows could only be dated as new.
|
||||||
for (const fetchedAt of [undefined, null, '7'])
|
for (const ageMs of [undefined, null, '7', -1, Number.NaN])
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
createAdsbGnssSource({
|
createAdsbGnssSource({
|
||||||
fetchImpl: reply({ rows: [], fetchedAt }),
|
fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs }),
|
||||||
}).getSnapshot({ latitude: 1, longitude: 2 }),
|
}).getSnapshot({ latitude: 1, longitude: 2 }),
|
||||||
/no observation time/,
|
/no observation time/,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user