Preserve positionless aircraft identities for classification

This commit is contained in:
Sameh Khamis committed 2026-09-13 10:28:10 -07:00
1 parent 1edebcb6f8
commit 22138212b9
8 files changed
+99 -4

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
- Split military flights into instance-owned state, ingestion, motion, rendering, tracking and query components. Share the existing aircraft calculations and give military classification an explicit source and cleanup lifecycle.
- Split military flights into instance-owned state, ingestion, motion, rendering, tracking and query components. Share the existing aircraft calculations and give military classification an explicit source and cleanup lifecycle. Preserve known military identities even when the source has no position for them.
- Split civil flights into instance-owned state, ingestion, motion, rendering, tracking and query components. Cancel enrichment on teardown and resolve model assets through the application.
+4 -1
View File
@@ -15,7 +15,10 @@ behavior remains unchanged.
metadata, motion, altitude, model-anchor, proximity and selection calculations.
It also exports `createMilitaryRegistry`, an explicitly constructed owner for
known military identities and active-layer transitions. Its optional background
poll consumes normalized records from the supplied source. Source replacement
poll consumes the optional `getIdentities` capability, falling back to normalized
positioned records when a source has no identity-only capability. The adsb.lol
adapter preserves known identities without requiring positions; those entries
still cannot enter the renderer. Source replacement
and disposal abort pending work and clear retained identities; construction
starts no network request. Both standalone aircraft layers use one registry.
+9 -2
View File
@@ -129,10 +129,17 @@ export function createMilitaryRegistry({ source, now = Date.now } = {}) {
const signal = AbortSignal.any([owner.signal, AbortSignal.timeout(10000)]);
_polling = true;
try {
const snapshot = await requestSource.getSnapshot({}, { signal });
const ids =
typeof requestSource.getIdentities === 'function'
? await requestSource.getIdentities({}, { signal })
: (await requestSource.getSnapshot({}, { signal })).records.map(
(record) => record.id,
);
signal.throwIfAborted();
if (lifetime !== owner) return;
registerMilitaryIcaos(snapshot.records.map((record) => record.id));
if (!Array.isArray(ids))
throw new TypeError('Expected aircraft identities');
registerMilitaryIcaos(ids);
} catch {
// Keep the existing classification through source outages.
} finally {
@@ -121,3 +121,21 @@ test('disposing a registry fed directly by its layer also clears classification'
registry.dispose();
assert.equal(registry.isMilitaryIcao('abc123'), false);
});
test('classification prefers an explicit identity capability over positioned observations', async () => {
const registry = createMilitaryRegistry({
source: {
getSnapshot() {
assert.fail(
'position snapshots must not replace an available identity capability',
);
},
async getIdentities() {
return ['abc123'];
},
},
});
await registry.refreshMilitaryRegistryIfStale();
assert.equal(registry.isMilitaryIcao('abc123'), true);
registry.dispose();
});
+13
View File
@@ -165,3 +165,16 @@ export function normalizeAircraftTrack(
];
});
}
/** Known source identities are useful for classification even without positions. */
export function readsbIdentities(payload) {
const admitted = admitRecords(
payload?.ac,
(row) => {
const id = cleanText(row?.hex).toLowerCase();
return id ? { id } : null;
},
'aircraft identities',
);
return admitted.records.map((record) => record.id);
}
+42
View File
@@ -271,3 +271,45 @@ test('out-of-range source epochs do not reach Date or globe time constructors',
assert.equal(snapshot.records[0].positionTimeMs, null);
assert.equal(snapshot.records[0].contactTimeMs, null);
});
test('classification identities include positionless aircraft without admitting them to rendering', async () => {
const source = createAdsbLolSource({
fetchImpl: async () =>
response({
ac: [
{ hex: ' ABC123 ' },
{ hex: 'abc123' },
{ hex: 'def456', lat: 30, lon: -97 },
],
}),
});
assert.deepEqual(await source.getIdentities(), ['abc123', 'def456']);
const snapshot = await source.getSnapshot();
assert.deepEqual(
snapshot.records.map((record) => record.id),
['def456'],
);
assert.equal(snapshot.complete, false);
});
test('identity lookup validates its response and honors body-parse cancellation', async () => {
const malformed = createAdsbLolSource({
fetchImpl: async () => response({ ac: [{}] }),
});
await assert.rejects(malformed.getIdentities(), /Malformed/);
const abort = new AbortController();
const source = createAdsbLolSource({
fetchImpl: async () => ({
ok: true,
status: 200,
headers: new Headers(),
async json() {
abort.abort();
return { ac: [{ hex: 'abc123' }] };
},
}),
});
await assert.rejects(source.getIdentities({}, { signal: abort.signal }), {
name: 'AbortError',
});
});
+1
View File
@@ -5,6 +5,7 @@ export {
normalizeAircraftTrack,
openSkySnapshot,
readsbSnapshot,
readsbIdentities,
} from './aircraft.js';
export {
normalizeVesselObservation,
+11
View File
@@ -9,6 +9,7 @@ import {
normalizeAircraftTrack,
openSkySnapshot,
readsbSnapshot,
readsbIdentities,
} from './aircraft.js';
import { normalizeVesselTrack, vesselSnapshot } from './vessels.js';
@@ -113,6 +114,16 @@ export function createAdsbLolSource({
} = {}) {
return {
label: 'adsb.lol',
async getIdentities(_query = {}, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
'/api/adsblol/mil',
{ signal },
'adsb.lol',
);
if (!response.ok) throw httpError(response, 'adsb.lol');
return readsbIdentities(payload);
},
async getSnapshot(_query = {}, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,