From 7d6f31e54d397690dc8cfa3d3dfa3415a9a5602a Mon Sep 17 00:00:00 2001 From: Leo Pechnicki Date: Thu, 1 Oct 2026 11:53:33 +0200 Subject: [PATCH] feat(squawk): carry emergencySource + observation time into analyst records Review on #856 (kvnloo): the analyst projection flattened a 7700 squawk and the readsb ADS-B emergency field into the same emergency/emergencyKind pair. - mapAnalystRecord (flights, military) adds emergencySource ('squawk' | 'ads-b' | null, from aircraftEmergency().source), emergencyBroadcast (new pure emergencyBroadcastPhrase: "broadcasting squawk 7700 (general emergency)" vs "ADS-B reports general emergency") and observedAtMs (the record's lastContactEpochMs: last transponder message, epoch ms). - emergencySource is a queryable analyst text field; the voice payload passes it and emergencyBroadcast through, and observedAtMs only for aircraft with a status. Tool description asks the model to narrate with the phrase. - Source-to-analyst fixture test: OpenSky 7700 vs adsb.lol emergency=general on squawk 1200 differ in provenance and timestamp; filterable by source. - Voice tool-payload pins re-derived for the additive description change. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 5 +- docs/CURRENT-STATE.md | 7 +- scripts/format-scope.json | 1 + src/data/aircraftEmergency.js | 16 +++ src/data/aircraftEmergency.test.mjs | 20 +++ src/data/aircraftEmergencyAnalyst.test.mjs | 152 +++++++++++++++++++++ src/data/flights.test.mjs | 3 + src/data/militaryFlights.test.mjs | 3 + src/layers/flights/queries.js | 12 +- src/layers/military/queries.js | 12 +- 10 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 src/data/aircraftEmergencyAnalyst.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 50e731268..cab1f1f72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,7 +103,10 @@ Minimum-fuel and lifeguard statuses are reported but not tinted. The status follows the latest poll instead of sticking, so a cleared code stops highlighting on the next refresh. The analyst and voice query engine can - filter by `squawk`, `emergency` and `emergencyKind`. No new source or + filter by `squawk`, `emergency`, `emergencyKind` and `emergencySource` + (whether the squawk or the ADS-B field named the status), and each result + says when the transponder was last heard, so answers read "broadcasting + squawk 7700" or "ADS-B reports general emergency". No new source or request: OpenSky already carries the squawk and adsb.lol carries both. - Street Level: Google Street View is the second provider. On the Google 3D diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dea8a5bec..6244311a5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -831,7 +831,12 @@ the same tint when a billboard is first created and when a tracked contact is released. While a broadcasting aircraft is tracked, the Cockpit aircraft-meta line reads `SQUAWK 7700 · GENERAL EMERGENCY` when the code names the status, else `ADS-B EMERGENCY · …` or `ADS-B PRIORITY · …`, in red. Analyst records and -the voice query fields expose `squawk`, `emergency` (flag) and `emergencyKind`. +the voice query fields expose `squawk`, `emergency` (flag), `emergencyKind` and +`emergencySource` (`squawk` when the Mode A code names the status, `ads-b` for +the readsb field), plus `emergencyBroadcast` (`broadcasting squawk 7700 (general +emergency)` vs `ADS-B reports general emergency`) and `observedAtMs`, the epoch +ms of the last transponder message. Voice results carry `observedAtMs` only for +aircraft with a status. `scripts/qa-emergency-squawk.mjs` proves the tint, HUD line and clearing. ## Browser live-source observations diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ea1531793..a2ec00096 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -20,6 +20,7 @@ "src/data/infrastructure.js", "src/data/aircraftEmergency.js", "src/data/aircraftEmergency.test.mjs", + "src/data/aircraftEmergencyAnalyst.test.mjs", "scripts/qa-emergency-squawk.mjs", "src/data/infrastructure.test.mjs", "src/data/localGeojsonCore.js", diff --git a/src/data/aircraftEmergency.js b/src/data/aircraftEmergency.js index 3fd6ab688..3a555399e 100644 --- a/src/data/aircraftEmergency.js +++ b/src/data/aircraftEmergency.js @@ -63,6 +63,22 @@ export function aircraftEmergency({ squawk, emergency } = {}) { }; } +/** + * How a summary may phrase the status without flattening it into an incident: + * quote the code when the Mode A squawk itself names the status, otherwise + * attribute it to the ADS-B emergency field. + * @param {{kind?: string, squawk?: string|null, source?: string}|null|undefined} status + * @returns {string|null} e.g. 'broadcasting squawk 7700 (general emergency)' + * or 'ADS-B reports general emergency'; null when there is no status. + */ +export function emergencyBroadcastPhrase(status) { + if (!status || !Object.hasOwn(KINDS, status.kind)) return null; + const label = KINDS[status.kind].label.toLowerCase(); + return status.source === 'squawk' && status.squawk + ? `broadcasting squawk ${status.squawk} (${label})` + : `ADS-B reports ${label}`; +} + /** * True when the aircraft is broadcasting an emergency (not a priority status). * @param {{severity?: string}|null|undefined} status diff --git a/src/data/aircraftEmergency.test.mjs b/src/data/aircraftEmergency.test.mjs index 2cff800b9..4304dcb14 100644 --- a/src/data/aircraftEmergency.test.mjs +++ b/src/data/aircraftEmergency.test.mjs @@ -3,6 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { aircraftEmergency, + emergencyBroadcastPhrase, isEmergency, normalizeSquawk, } from './aircraftEmergency.js'; @@ -62,3 +63,22 @@ test('priority statuses are reported but are not emergencies', () => { assert.equal(isEmergency(aircraftEmergency({ emergency: 'minfuel' })), false); assert.equal(isEmergency(null), false); }); + +test('the broadcast phrase quotes a naming code, else attributes ADS-B', () => { + assert.equal( + emergencyBroadcastPhrase(aircraftEmergency({ squawk: '7700' })), + 'broadcasting squawk 7700 (general emergency)', + ); + assert.equal( + emergencyBroadcastPhrase( + aircraftEmergency({ squawk: '1200', emergency: 'general' }), + ), + 'ADS-B reports general emergency', + ); + assert.equal( + emergencyBroadcastPhrase(aircraftEmergency({ emergency: 'lifeguard' })), + 'ADS-B reports lifeguard (medical priority)', + ); + assert.equal(emergencyBroadcastPhrase(null), null); + assert.equal(emergencyBroadcastPhrase({ kind: 'reserved' }), null); +}); diff --git a/src/data/aircraftEmergencyAnalyst.test.mjs b/src/data/aircraftEmergencyAnalyst.test.mjs new file mode 100644 index 000000000..8df428162 --- /dev/null +++ b/src/data/aircraftEmergencyAnalyst.test.mjs @@ -0,0 +1,152 @@ +// 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 { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { openSkySnapshot, readsbSnapshot } from '../sources/live/index.js'; +import { FlightRecords } from '../layers/flights/records.js'; +import { MilitaryFlightRecords } from '../layers/military/records.js'; +import { mapAnalystRecord as mapFlight } from './flights.js'; +import { mapAnalystRecord as mapMilitary } from './militaryFlights.js'; +import { createAnalystEngine } from './analystEngine.js'; + +const NOW_MS = 1800000000000; + +// OpenSky /states/all: [icao24, callsign, origin_country, time_position, +// last_contact, lon, lat, baro_alt, on_ground, velocity, true_track, +// vertical_rate, sensors, geo_alt, squawk, spi, position_source, category]. +const OPENSKY = { + time: NOW_MS / 1000, + states: [ + [ + 'a1b2c3', + 'SWA696 ', + 'United States', + NOW_MS / 1000 - 6, + NOW_MS / 1000 - 3, + -97.67, + 30.19, + 3000, + false, + 150, + 180, + -5, + null, + 3040, + '7700', + false, + 0, + 4, + ], + ], +}; + +// adsb.lol /v2/mil: readsb aircraft with `seen`/`seen_pos` ages in seconds. +const ADSB_LOL = { + ac: [ + { + hex: 'ae01ce', + flight: 'RCH123 ', + t: 'C17', + lat: 31.1, + lon: -97.4, + alt_baro: 18000, + gs: 300, + track: 90, + squawk: '1200', + emergency: 'general', + seen: 1.5, + seen_pos: 2, + }, + ], +}; + +function civilAnalystRecord() { + const [row] = openSkySnapshot(OPENSKY, { now: NOW_MS }).records; + const store = new FlightRecords({ + geoidHeight: () => 0, + cachedGroundFloor: () => null, + floorAltitudeM: (altitude) => altitude, + approxDistanceKm: () => 0, + }); + const { meta } = store.receive(row, { + viewerLatDeg: null, + viewerLonDeg: null, + trackedId: null, + floorWarmPoints: [], + }); + return mapFlight(row.id, meta); +} + +function militaryAnalystRecord() { + const [row] = readsbSnapshot(ADSB_LOL, { + observedAtMs: NOW_MS, + now: NOW_MS, + }).records; + const store = new MilitaryFlightRecords({ + geoidHeight: () => 0, + cachedGroundFloor: () => null, + floorAltitudeM: (altitude) => altitude, + }); + const { meta } = store.receive(row, { + observedAtMs: NOW_MS, + floorWarmPoints: [], + modelOwnsVisual: false, + }); + return mapMilitary(row.id, meta); +} + +test('OpenSky 7700 and adsb.lol emergency=general differ only in provenance', () => { + const civil = civilAnalystRecord(); + const military = militaryAnalystRecord(); + + assert.equal(civil.emergency, true); + assert.equal(civil.emergencyKind, 'general'); + assert.equal(civil.squawk, '7700'); + assert.equal(civil.emergencySource, 'squawk'); + assert.equal( + civil.emergencyBroadcast, + 'broadcasting squawk 7700 (general emergency)', + ); + assert.equal(civil.observedAtMs, NOW_MS - 3000, 'OpenSky last_contact'); + + assert.equal(military.emergency, true); + assert.equal(military.emergencyKind, 'general'); + assert.equal(military.squawk, '1200', 'the code is not an emergency code'); + assert.equal(military.emergencySource, 'ads-b'); + assert.equal(military.emergencyBroadcast, 'ADS-B reports general emergency'); + assert.equal(military.observedAtMs, NOW_MS - 1500, 'snapshot time - seen'); +}); + +test('the analyst can filter emergencies by source', async () => { + const records = { + flights: [civilAnalystRecord()], + military: [militaryAnalystRecord()], + }; + const engine = createAnalystEngine({ + getRecords: (key) => records[key] || [], + resolveRegionRing: async () => null, + getViewContext: () => ({ lat: 30.5, lon: -97.5, viewRadiusKm: 500 }), + }); + const query = (value) => + engine.query({ + layers: ['flights', 'military'], + scope: { kind: 'anywhere' }, + filters: [ + { field: 'emergency', op: 'eq', value: true }, + { field: 'emergencySource', op: 'eq', value }, + ], + }); + const bySquawk = await query('squawk'); + assert.deepEqual( + bySquawk.items.map((item) => [item.icao24, item.emergencyBroadcast]), + [['a1b2c3', 'broadcasting squawk 7700 (general emergency)']], + ); + const byAdsb = await query('ads-b'); + assert.deepEqual( + byAdsb.items.map((item) => [item.icao24, item.emergencyBroadcast]), + [['ae01ce', 'ADS-B reports general emergency']], + ); +}); diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index 0e7021251..6c6a586d6 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -74,6 +74,9 @@ test('flights analyst record: full record maps every contract field', () => { squawk: null, emergency: false, emergencyKind: null, + emergencySource: null, + emergencyBroadcast: null, + observedAtMs: null, routeOrigin: 'AUS', routeDestination: 'LAX', }); diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index 9e6e4aea8..b6441aa52 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -70,6 +70,9 @@ test('military analyst record: full record maps every contract field', () => { squawk: null, emergency: false, emergencyKind: null, + emergencySource: null, + emergencyBroadcast: null, + observedAtMs: null, routeOrigin: null, routeDestination: null, }); diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 89ea44214..0714acfc5 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -15,6 +15,7 @@ import { FOCUS_EVIDENCE_DEV, FLEET_DR_INTERVAL_MS, } from './policy.js'; +import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js'; export function createQueries({ flightState, @@ -223,7 +224,8 @@ export function createQueries({ * heading: number|null, verticalRateMps: number|null, onGround: boolean, * military: boolean, aircraftClass: string|null, originCountry: string|null, * operator: string|null, squawk: string|null, emergency: boolean, - * emergencyKind: string|null, routeOrigin: string|null, + * emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null, + * emergencyBroadcast: string|null, observedAtMs: number|null, routeOrigin: string|null, * routeDestination: string|null}} */ @@ -264,6 +266,14 @@ export function createQueries({ // without the flag. emergency: info?.emergency?.severity === 'emergency', emergencyKind: text(info?.emergency?.kind), + // Provenance, so a summary can say "broadcasting squawk 7700" rather + // than "ADS-B reports general emergency" (or flatten both): 'squawk' + // when the Mode A code names the status, 'ads-b' for the readsb field. + emergencySource: text(info?.emergency?.source), + emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency), + // Epoch ms of the last transponder message — when this squawk/status + // was heard (OpenSky time_contact; readsb snapshot time minus `seen`). + observedAtMs: num(info?.lastContactEpochMs), routeOrigin: routeOk ? text(info?.route?.origin?.code) : null, routeDestination: routeOk ? text(info?.route?.destination?.code) : null, }; diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index eab06458c..0d418ddc1 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -9,6 +9,7 @@ import { CONTACT_MATCH_TIER, } from '../../data/contactMatch.js'; import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js'; +import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js'; export function createQueries({ flightState, @@ -160,7 +161,8 @@ export function createQueries({ * heading: number|null, verticalRateMps: number|null, onGround: boolean, * military: boolean, aircraftClass: string|null, originCountry: null, * operator: string|null, squawk: string|null, emergency: boolean, - * emergencyKind: string|null, routeOrigin: null, routeDestination: null}} + * emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null, + * emergencyBroadcast: string|null, observedAtMs: number|null, routeOrigin: null, routeDestination: null}} */ function mapAnalystRecord(icao24, info) { @@ -197,6 +199,14 @@ export function createQueries({ // without the flag. emergency: info?.emergency?.severity === 'emergency', emergencyKind: text(info?.emergency?.kind), + // Provenance, so a summary can say "broadcasting squawk 7700" rather + // than "ADS-B reports general emergency" (or flatten both): 'squawk' + // when the Mode A code names the status, 'ads-b' for the readsb field. + emergencySource: text(info?.emergency?.source), + emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency), + // Epoch ms of the last transponder message — when this squawk/status + // was heard (OpenSky time_contact; readsb snapshot time minus `seen`). + observedAtMs: num(info?.lastContactEpochMs), routeOrigin: null, routeDestination: null, };