From 60dad0c35e6772ffd12503ab2ed81b448fdc6a66 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:44:36 +0530 Subject: [PATCH 1/2] fix(military): don't treat adsb.lol's position-less rows as a partial snapshot readsb omits lat/lon for a contact heard without a current position; about a third of every /v2/mil response is such rows. They made every military snapshot partial, so a contact that left the feed was retained at full opacity for five minutes instead of fading and leaving on the missed-poll limits. A row with a hex and no lat/lon now leaves the snapshot complete and is listed in retainIds, as OpenSky's null-position rows already are. --- docs/CURRENT-STATE.md | 12 ++-- src/layers/military/ownership.test.mjs | 93 +++++++++++++++++++------ src/layers/military/records.js | 7 +- src/layers/military/records.test.mjs | 24 +++++++ src/layers/military/snapshotRenderer.js | 1 + src/sources/live/aircraft.js | 13 +++- src/sources/live/contract.test.mjs | 38 +++++++--- 7 files changed, 151 insertions(+), 37 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0cb46940c..3e4439e61 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1096,12 +1096,12 @@ addition to the locally accumulated trail, never a promise of complete coverage. Snapshot coverage, completeness and freshness are separate fields. A partially admitted aircraft snapshot retains absent contacts for up to five minutes before the usual missed-poll eviction. An OpenSky row with an ICAO24 address but null -longitude and latitude is a contact without a position, not a malformed row: it -leaves the snapshot complete and is listed in `retainIds`, so civil flights -retain that aircraft under the same five-minute bound, unless it is likely -landed, while other absent aircraft fade and leave on the missed-poll limits. A -nonempty snapshot with no positioned rows is still malformed. Military readsb -admission is unchanged. Unknown snapshot times remain unknown in stats. An invalid nonempty +longitude and latitude, or a readsb row with a hex but no `lat`/`lon`, is a +contact without a position, not a malformed row: it leaves the snapshot complete +and is listed in `retainIds`, so civil and military flights retain that aircraft +under the same five-minute bound, unless it is likely landed, while other absent +aircraft fade and leave on the missed-poll limits. A nonempty snapshot with no +positioned rows is still malformed. Unknown snapshot times remain unknown in stats. An invalid nonempty snapshot retains the previous display. Empty vessel refreshes retain the existing first-connect grace and warm-data behavior. Known source failures have bounded messages; arbitrary HTTP response bodies are not surfaced as diagnostics. diff --git a/src/layers/military/ownership.test.mjs b/src/layers/military/ownership.test.mjs index 41081c137..64844128c 100644 --- a/src/layers/military/ownership.test.mjs +++ b/src/layers/military/ownership.test.mjs @@ -27,6 +27,29 @@ function services() { }; } +// The application's own scene services, as src/app/layers/militaryFlights.js +// composes them; only the surface samplers and registry are stubbed. +async function sceneServices() { + return { + picking: await import('../../data/pickRegistry.js'), + sprites: await import('../../data/spriteOrder.js'), + trails: await import('../../data/trailRenderer.js'), + aircraftPresentation: await import('../../data/tr3bRegistry.js'), + camera: await import('../../data/trackedCamera.js'), + labels: await import('../../data/detectionDraw.js'), + geoid: await import('../../data/geoid.js'), + focus: await import('../../data/focusDeemphasis.js'), + readout: await import('../../data/trackedReadout.js'), + context: await import('../../data/contextStore.js'), + render: await import('../../renderGovernor.js'), + recession: await import('../../data/aircraftRecession.js'), + groundFloor: await import('../../data/groundFloor.js'), + groundSnap: await import('../../data/groundSnap.js'), + meshFloor: { sampleMeshFloorCells: () => {} }, + militaryRegistry: { registerMilitaryIcaos: () => {} }, + }; +} + test('military layer instances isolate policy and restoration state without requesting a source', async () => { let requests = 0; const source = { @@ -121,26 +144,7 @@ test('a newly seen military contact appears at its delayed position, not ahead o // the next fleet tick jumped it back. const Cesium = await import('cesium'); const { RENDER_DELAY_SEC } = await import('./policy.js'); - // The application's own scene services, as src/app/layers/militaryFlights.js - // composes them; only the surface samplers and registry are stubbed. - const supplied = { - picking: await import('../../data/pickRegistry.js'), - sprites: await import('../../data/spriteOrder.js'), - trails: await import('../../data/trailRenderer.js'), - aircraftPresentation: await import('../../data/tr3bRegistry.js'), - camera: await import('../../data/trackedCamera.js'), - labels: await import('../../data/detectionDraw.js'), - geoid: await import('../../data/geoid.js'), - focus: await import('../../data/focusDeemphasis.js'), - readout: await import('../../data/trackedReadout.js'), - context: await import('../../data/contextStore.js'), - render: await import('../../renderGovernor.js'), - recession: await import('../../data/aircraftRecession.js'), - groundFloor: await import('../../data/groundFloor.js'), - groundSnap: await import('../../data/groundSnap.js'), - meshFloor: { sampleMeshFloorCells: () => {} }, - militaryRegistry: { registerMilitaryIcaos: () => {} }, - }; + const supplied = await sceneServices(); const fixLon = -97.6; const fixLat = 30.3; const speedMps = 200; @@ -211,3 +215,52 @@ test('a newly seen military contact appears at its delayed position, not ahead o `expected about ${Math.round(expectedM)} m behind the fix, got ${Math.round(behindM)} m`, ); }); + +test('a vanished military contact fades while position-less rows keep theirs', async () => { + // About a third of a live adsb.lol /v2/mil fetch is contacts without a + // position. Those rows marked every snapshot partial, so an aircraft that + // left the feed stayed at full opacity for five minutes. + const Cesium = await import('cesium'); + const { readsbSnapshot } = await import('../../sources/live/aircraft.js'); + const nowMs = Date.now(); + const row = (hex, lon) => ({ + hex, + lat: 30.3, + lon, + alt_baro: 20_000, + gs: 300, + track: 90, + seen_pos: 1, + }); + const polls = [ + [row('ae0001', -97.6), row('ae0002', -96.6), row('ae0003', -95.6)], + [row('ae0001', -97.5), { hex: 'ae0003' }], + ]; + // Every poll also lists a contact that never has a position. + for (const poll of polls) poll.push({ hex: 'ae0009', type: 'mode_s' }); + const layer = createMilitaryFlightLayer({ + services: await sceneServices(), + source: { + label: 'Fixture aircraft', + getSnapshot: async () => + readsbSnapshot( + { ac: polls.shift() }, + { observedAtMs: nowMs, now: nowMs }, + ), + }, + }); + const viewer = { camera: { positionCartographic: null }, scene: {} }; + layer.testing._setTrackedMilitaryRefreshStateForTest({ + icao24: 'seed00', + entity: null, + meta: { rawLat: 0, rawLon: 0, onGround: false }, + billboard: { show: false, position: Cesium.Cartesian3.fromDegrees(0, 0) }, + billboardCollection: { show: true, add: (options) => ({ ...options }) }, + viewer, + tracked: false, + }); + await layer.update(viewer); + await layer.update(viewer); + assert.equal(layer.findByQuery('ae0002')?.stale, true, 'vanished: fading'); + assert.equal(layer.findByQuery('ae0003')?.stale, false, 'still listed'); +}); diff --git a/src/layers/military/records.js b/src/layers/military/records.js index 05d91ccf0..7198d62da 100644 --- a/src/layers/military/records.js +++ b/src/layers/military/records.js @@ -187,9 +187,12 @@ export class MilitaryFlightRecords { fixEpochMs: aircraft.positionTimeMs ?? observedAtMs, }; } - absence(id, { complete, likelyLanded }) { + absence(id, { complete, retainIds, likelyLanded }) { + // Partial admissions and position-less rows do not prove absence, but + // retention remains bounded; a likely-landed position-less contact + // still takes the landed limit. if ( - !complete && + (!complete || (retainIds?.has(id) && !likelyLanded)) && Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000 ) return 'retain'; diff --git a/src/layers/military/records.test.mjs b/src/layers/military/records.test.mjs index ce00c7761..65a3b76c9 100644 --- a/src/layers/military/records.test.mjs +++ b/src/layers/military/records.test.mjs @@ -99,6 +99,30 @@ test('military records keep bounded partial retention and release metadata only ); }); +test('complete military snapshots age out vanished aircraft and retain position-less ones unless landed', () => { + const records = store(); + for (const id of ['aaa001', 'aaa002', 'aaa003']) + records.receive(observation({ id, reference: id }), context()); + // aaa001 and aaa003 are still listed without a position; aaa002 vanished. + const absent = (id, likelyLanded = false) => + records.absence(id, { + complete: true, + retainIds: new Set(['aaa001', 'aaa003']), + likelyLanded, + }); + assert.deepEqual( + [absent('aaa002'), absent('aaa002'), absent('aaa002')], + ['stale', 'stale', 'remove'], + ); + assert.equal(absent('aaa001'), 'retain'); + assert.equal(records.missingPolls.has('aaa001'), false); + // A landed contact that lost its position takes the landed fast-cull. + assert.equal(absent('aaa003', true), 'remove'); + // Position-less retention stays bounded like partial admission. + records.data.get('aaa001').observedReceiptMs = Date.now() - 300001; + assert.equal(absent('aaa001'), 'stale'); +}); + test('military emergency status follows the latest poll instead of sticking', () => { const records = store(); const first = records.receive( diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 9de25db1d..7ed2b1c1f 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -259,6 +259,7 @@ export function createMilitarySnapshotRenderer({ if (currentIcaos.has(icao24)) continue; const absence = records.absence(icao24, { complete: snapshot.complete, + retainIds: snapshot.retainIds, likelyLanded: queries._likelyLanded(icao24), }); if (absence === 'retain') continue; diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js index 1af1f6b47..7df66ba83 100644 --- a/src/sources/live/aircraft.js +++ b/src/sources/live/aircraft.js @@ -39,9 +39,11 @@ export function normalizeOpenSkyAircraft(row) { }; } +const readsbId = (row) => cleanText(row?.hex).toLowerCase(); + /** Normalize readsb observations. The caller supplies the source snapshot epoch. */ export function normalizeReadsbAircraft(row, snapshotTimeMs) { - const id = cleanText(row?.hex).toLowerCase(); + const id = readsbId(row); const latitude = finite(row?.lat), longitude = finite(row?.lon); if (!id || !coordinates(latitude, longitude)) return null; @@ -128,6 +130,14 @@ export function openSkySnapshot( }; } +/** + * readsb omits lat/lon for a contact heard without a current position. Both + * must be absent; a half or unreadable position still marks the row malformed. + */ +function readsbUnpositionedId(row) { + return row?.lat == null && row?.lon == null ? readsbId(row) : ''; +} + export function readsbSnapshot( payload, { @@ -142,6 +152,7 @@ export function readsbSnapshot( payload?.ac, (row) => normalizeReadsbAircraft(row, observedAtMs), 'adsb.lol', + readsbUnpositionedId, ); const ageMs = observedAtMs == null ? null : Math.max(0, now - observedAtMs); return { diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index b8b1787da..0818d8118 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -102,13 +102,34 @@ test('position-less OpenSky rows are seen, not missing; malformed rows stay inco assert.throws(() => openSkySnapshot({ states: [unpositioned] }), { code: 'malformed', }); - // Military readsb admission keeps its existing semantics. - const military = readsbSnapshot( - { ac: [{ hex: 'abc123', lat: 30, lon: -97 }, { hex: 'def456' }] }, - { observedAtMs: null }, - ); - assert.equal(military.complete, false); - assert.equal(military.retainIds.size, 0); +}); + +test('position-less readsb rows are seen, not missing; malformed rows stay incomplete', () => { + // readsb omits lat/lon for a contact heard without a current position; a + // live adsb.lol /v2/mil fetch had 82 such rows of 239. + const positioned = { hex: 'ae638d', lat: 20.3, lon: -158.5, seen_pos: 0.2 }; + const heard = { hex: 'ADFAC3', type: 'mode_s', rr_lat: 19.9, seen: 24.2 }; + const lastPosition = { + hex: '3b752d', + lastPosition: { lat: 45.3, lon: 5.98, seen_pos: 61.56 }, + }; + const read = (ac) => readsbSnapshot({ ac }, { observedAtMs: null }); + const snapshot = read([positioned, heard, lastPosition]); + assert.equal(snapshot.records.length, 1); + assert.equal(snapshot.complete, true); + assert.deepEqual([...snapshot.retainIds], ['adfac3', '3b752d']); + assert.equal(snapshot.rejectedCount, 2); + // No usable id, only half a position, an unreadable one, or no row at all. + for (const row of [ + { ...heard, hex: ' ' }, + { ...heard, lat: 30 }, + { ...heard, lon: -97 }, + { ...heard, lat: 'x', lon: 'y' }, + null, + ]) + assert.equal(read([positioned, heard, row]).complete, false); + // A feed with no positions at all still cannot prove absence. + assert.throws(() => read([heard]), { code: 'malformed' }); }); test('readsb units and position/contact ages are normalized once against the source epoch', () => { @@ -332,7 +353,8 @@ test('classification identities include positionless aircraft without admitting snapshot.records.map((record) => record.id), ['def456'], ); - assert.equal(snapshot.complete, false); + assert.equal(snapshot.complete, true); + assert.deepEqual([...snapshot.retainIds], ['abc123']); }); test('identity lookup validates its response and honors body-parse cancellation', async () => { From dc56ada3a55ee55049ebcd3ab74428e880f576ed Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:57:34 +0530 Subject: [PATCH 2/2] docs(changelog): add #1435 fragment --- changelog.d/1435-military-positionless-rows.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1435-military-positionless-rows.md diff --git a/changelog.d/1435-military-positionless-rows.md b/changelog.d/1435-military-positionless-rows.md new file mode 100644 index 000000000..215ca26b0 --- /dev/null +++ b/changelog.d/1435-military-positionless-rows.md @@ -0,0 +1,5 @@ +- Military aircraft that leave the adsb.lol feed fade and are removed again. + The feed's routine position-less rows no longer mark every snapshot partial, + which kept vanished aircraft at full opacity for up to five minutes; those + rows keep only their own aircraft, and a likely-landed one still leaves on + the landed limit (Aditya, @Rohilalala, #1435).