From 923cb0b5bb6c160c592058c0e1eb1748645622b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 15:56:08 +0000 Subject: [PATCH 01/11] feat(data): analyst records for satellites and infrastructure Expose getAnalystRecords() on the CelesTrak satellites layer and on createLocalGeoJsonLayer (datacenters/dams) so analyst_query / voice can answer counts and nearest queries from in-memory client data, matching the existing flights/ships/fires/earthquakes seam. Co-authored-by: Matt Van Horn --- CHANGELOG.md | 3 + docs/CURRENT-STATE.md | 1 + docs/INFRASTRUCTURE-LAYERS.md | 3 +- server/providers/local.js | 10 +- src/data/analystEngine.js | 3 + src/data/analystEngine.test.mjs | 47 +++++++ src/data/localGeojson.analyst.test.mjs | 182 +++++++++++++++++++++++++ src/data/localGeojsonCore.js | 108 ++++++++++++++- src/data/satellites.analyst.test.mjs | 134 ++++++++++++++++++ src/data/satellites.js | 107 +++++++++++++++ src/firstRunExperience.test.mjs | 13 +- src/radioMarkup.test.mjs | 15 +- src/voice/gevActions.js | 2 +- src/voice/gevActions.test.mjs | 18 +++ 14 files changed, 624 insertions(+), 22 deletions(-) create mode 100644 src/data/localGeojson.analyst.test.mjs create mode 100644 src/data/satellites.analyst.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ce2aff3..ce5bf30bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Analyst queries can count and rank CelesTrak satellites and local + datacenter/dam infrastructure from in-memory layer records. + - Separate terrain, traffic, FIRMS and GBFS middleware into focused provider modules, preserving local configuration, routes and cache/error behavior. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 250a2f106..c24fa9c83 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2402,6 +2402,7 @@ silently demoting every later lookup for the session. - **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame. - **Context-mode vocabulary is symmetric** (2026-08-21). `set_context_mode` accepts `contacts`; the internal id is `flights`. Every model-readable field (`mode`, `entering`, `priorMode`, nested `context`/`contextRollback`, and the transition diagnostic text) is reported in the accepted vocabulary, with the internal id preserved as `Internal`; an absent secondary mode stays `null` rather than claiming to be `off`. Mapping lives in `src/contextModePolicy.js` so UI text and voice payloads cannot drift. Reporting the internal id made the model read `mode:'flights'` as "Contacts is off" and refuse to answer from the `contactsWindow` counts in the same payload. - **Analyst → track handoff carries a key, not just a label** (2026-08-21). `analyst_query` items include `icao24`/`mmsi` alongside the display `id`, and contact lookup uses the shared tiered ranking in `src/data/contactMatch.js`: hex exact → callsign exact → registration exact → callsign prefix → registration prefix → callsign substring → registration substring. The tiers keep an exact callsign ahead of a colliding registration regardless of feed order, and registrations compare separator-insensitively (`G-ABCD`/`GABCD`, `05-8152`/`058152`, `N123AB`/`N-123AB`). + - **Satellites and local infrastructure join the analyst record seam** (2026-09-12). `getAnalystRecords()` on the CelesTrak satellites layer and on `createLocalGeoJsonLayer` (datacenters/dams) snapshots in-memory rows for `analyst_query`, so voice can answer counts and nearest queries the same way it already does for flights, ships, fires, and earthquakes. Core catalog rows are emitted before dense Starlink extras when the 2,000-record cap truncates. Compact items carry `noradId`/`satelliteClass` for satellites and `operator`/`river`/`output`/`capacity` for infrastructure. On-demand only — zero per-frame cost. - **A typed command supersedes the turn it interrupts** (2026-08-21). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items. - **Subject reconciliation across satellite catalog rebuilds** (2026-08-21). A dense↔core toggle or TLE refresh clears and repopulates the catalog, so the published subject is re-resolved against the new satrec; a subject that did NOT survive releases the slot. The per-frame refresh cannot do this itself — `_getTrackedFramePosition` returns early once the satellite has no catalog entry — so the reconcile runs at rebuild completion. An empty catalog is a rebuild in flight, not a disappearance. - **One aircraft-proximity engine** (2026-08-22). `collectAircraftProximityWindow` (`src/data/militaryAwareness.js`) is the single computation behind both the Contacts panel window and the voice analyst's entity-centred "how many nearby", so the panel readout and the spoken count for one centre are identical by construction. **Invariant: do not re-derive a proximity count anywhere else.** They diverged before because the panel read live billboard positions (20,000 cap) while the analyst used last-fix coordinates over a 2,000-record slice — 111 on screen, 15 spoken. Explicit regions and arbitrary points deliberately keep the general record engine. Entity-centred results carry `window: {engine:'contacts-window', centeredOn, radiusKm, flights, military, aircraft}`. diff --git a/docs/INFRASTRUCTURE-LAYERS.md b/docs/INFRASTRUCTURE-LAYERS.md index d29e356f9..76567e605 100644 --- a/docs/INFRASTRUCTURE-LAYERS.md +++ b/docs/INFRASTRUCTURE-LAYERS.md @@ -36,7 +36,8 @@ const layers = createInfrastructureLayers({ | `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler | Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`, -`update(viewer)`, `destroy(viewer)`, `getStats()` and `getLodDiagnostics()`. +`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and +`getAnalystRecords()`. Use the same viewer for the instance's entire lifetime. Each viewer/context/overlay host permits one live instance per layer ID; destroy an old instance before replacing it. Separate applications may use identical IDs with separate hosts. diff --git a/server/providers/local.js b/server/providers/local.js index 934e7c442..d1ec9304a 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -2905,7 +2905,7 @@ export function openAiRealtimeProxy() { 'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").', 'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.', 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.', - 'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, or earthquakes ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.', + 'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, earthquakes, satellites, datacenters, or dams ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "how many satellites are in view", "nearest datacenter", "how many dams near Austin") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.', 'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near " means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.', 'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.', 'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.', @@ -3608,15 +3608,15 @@ const GEV_REALTIME_TOOLS = [ { type: 'function', name: 'analyst_query', - description: 'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.', + description: 'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes, satellites, datacenters, dams). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view", "how many satellites", "nearest datacenter", "how many dams near Austin". Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.', parameters: { type: 'object', additionalProperties: false, properties: { layers: { type: 'array', - items: { type: 'string', enum: ['flights', 'military', 'ais-live-vessels', 'local-firms', 'earthquakes'] }, - description: 'Layers to query. fires/wildfires → local-firms; ships/vessels → ais-live-vessels.', + items: { type: 'string', enum: ['flights', 'military', 'ais-live-vessels', 'local-firms', 'earthquakes', 'satellites', 'local-datacenters', 'local-dams'] }, + description: 'Layers to query. fires/wildfires → local-firms; ships/vessels → ais-live-vessels; satellites/ISS/GPS → satellites; datacenters/data centres → local-datacenters; dams → local-dams.', }, scope: { type: 'object', @@ -3631,7 +3631,7 @@ const GEV_REALTIME_TOOLS = [ }, filters: { type: 'array', - description: 'Attribute predicates, ANDed. ALTITUDE IS METERS (40,000 ft = 12192). Fields: altitudeM, speedMps, military, onGround, aircraftClass, callsign, operator, routeOrigin, routeDestination, originCountry (flights); speedKts, shipType, destination (ships); frp, confidence (fires); magnitude, depthKm, place (earthquakes).', + description: 'Attribute predicates, ANDed. ALTITUDE IS METERS (40,000 ft = 12192). Fields: altitudeM, speedMps, military, onGround, aircraftClass, callsign, operator, routeOrigin, routeDestination, originCountry (flights); speedKts, shipType, destination (ships); frp, confidence (fires); magnitude, depthKm, place (earthquakes); altitudeM, speedMps, name, noradId, satelliteClass, group (satellites); name, operator, capacity (datacenters); name, operator, river, output (dams).', items: { type: 'object', additionalProperties: false, diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index 58c28c7d8..04f86685f 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -33,6 +33,9 @@ export const ANALYST_LAYERS = { 'ais-live-vessels': { numeric: ['speedKts', 'courseDeg'], text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'], flags: [] }, 'local-firms': { numeric: ['frp'], text: ['confidence', 'satellite'], flags: [] }, earthquakes: { numeric: ['magnitude', 'depthKm'], text: ['place'], flags: [] }, + satellites: { numeric: ['altitudeM', 'speedMps'], text: ['name', 'noradId', 'satelliteClass', 'group'], flags: [] }, + 'local-datacenters': { numeric: [], text: ['name', 'operator', 'capacity'], flags: [] }, + 'local-dams': { numeric: [], text: ['name', 'operator', 'river', 'output'], flags: [] }, }; const EARTH_R_KM = 6371; diff --git a/src/data/analystEngine.test.mjs b/src/data/analystEngine.test.mjs index 6521a5464..4456c0a12 100644 --- a/src/data/analystEngine.test.mjs +++ b/src/data/analystEngine.test.mjs @@ -195,6 +195,53 @@ test('analyst: route fields queryable from cached enrichment only', async () => assert.deepEqual(r.items.map((i) => i.id), ['SWA1'], 'null route fields never match'); }); +test('analyst: satellites and local infrastructure are queryable layers', async () => { + const SATS = [ + { id: 'ISS (ZARYA)', noradId: '25544', name: 'ISS (ZARYA)', lat: 30.3, lon: -97.7, altitudeM: 410000, satelliteClass: 'STATION · ISS', group: 'stations' }, + { id: 'GPS BIIR-2', noradId: '24876', name: 'GPS BIIR-2', lat: 51.0, lon: 0.0, altitudeM: 20200000, satelliteClass: 'NAV · GPS', group: 'gps-ops' }, + ]; + const DAMS = [ + { id: 'Austin Dam', name: 'Austin Dam', lat: 30.27, lon: -97.74, operator: 'LCRA', river: 'Colorado', output: '2 MW' }, + { id: 'Far Dam', name: 'Far Dam', lat: 45.0, lon: -122.0, operator: 'USACE', river: 'Columbia', output: '1000 MW' }, + ]; + const DCS = [ + { id: 'AUS-1', name: 'AUS-1', lat: 30.28, lon: -97.75, operator: 'Example Cloud', capacity: '27 MW' }, + ]; + const eng = createAnalystEngine({ + getRecords: (key) => ({ satellites: SATS, 'local-dams': DAMS, 'local-datacenters': DCS }[key] || []), + resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null), + getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }), + }); + + const sats = await eng.query({ + layers: ['satellites'], scope: { kind: 'view' }, sortBy: 'distance', limit: 2, + }); + assert.equal(sats.ok, true); + assert.equal(sats.count, 1, 'GPS sat is out of the Austin view radius'); + assert.equal(sats.items[0].noradId, '25544'); + assert.ok(Number.isFinite(sats.items[0].distanceKm)); + + const nav = await eng.query({ + layers: ['satellites'], scope: { kind: 'anywhere' }, + filters: [{ field: 'satelliteClass', op: 'contains', value: 'NAV' }], + }); + assert.deepEqual(nav.items.map((i) => i.id), ['GPS BIIR-2']); + + const dams = await eng.query({ + layers: ['local-dams'], scope: { kind: 'view' }, sortBy: 'distance', limit: 5, + }); + assert.equal(dams.count, 1); + assert.equal(dams.items[0].id, 'Austin Dam'); + assert.equal(dams.items[0].river, 'Colorado'); + + const dcs = await eng.query({ + layers: ['local-datacenters'], scope: { kind: 'region', name: 'Texland' }, + filters: [{ field: 'operator', op: 'contains', value: 'cloud' }], + }); + assert.equal(dcs.count, 1); + assert.equal(dcs.items[0].id, 'AUS-1'); +}); + test('helpers: haversine sanity + scope radius', () => { const km = haversineKm(30.2672, -97.7431, 29.7604, -95.3698); // Austin→Houston assert.ok(km > 200 && km < 280, `Austin-Houston ~235km, got ${km}`); diff --git a/src/data/localGeojson.analyst.test.mjs b/src/data/localGeojson.analyst.test.mjs new file mode 100644 index 000000000..c695004f6 --- /dev/null +++ b/src/data/localGeojson.analyst.test.mjs @@ -0,0 +1,182 @@ +// Focused tests for the local-infrastructure analyst-record mapper and snapshot seam. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + createLocalGeoJsonLayer, + mapAnalystRecord, +} from './localGeojson.js'; + +const DC_RAW = { + id: '1176042553', + lat: -52.942, + lon: -70.85, + properties: { + tags: { + name: 'AWS', + operator: 'Amazon Web Services', + 'operator:short': 'AWS', + 'capacity:it_load': '27 MW', + }, + }, +}; + +const DAM_RAW = { + id: '-19685440', + lat: -25.41, + lon: -54.59, + properties: { + name: 'Usina Hidrelétrica de Itaipu', + output: '14000 MW', + tags: { + name: 'Usina Hidrelétrica de Itaipu', + 'name:en': 'Itaipu Dam', + operator: 'Itaipú Binacional', + associated_river: 'Paraná', + 'plant:output:electricity': '14000 MW', + }, + }, +}; + +test('infra analyst record: datacenter maps name, operator, capacity', () => { + const r = mapAnalystRecord(DC_RAW, 'local-datacenters'); + assert.deepEqual(r, { + id: 'AWS', + name: 'AWS', + lat: -52.942, + lon: -70.85, + operator: 'Amazon Web Services', + capacity: '27 MW', + river: null, + output: null, + }); +}); + +test('infra analyst record: dam maps name, operator, river, output; names stay unclamped', () => { + const r = mapAnalystRecord(DAM_RAW, 'local-dams'); + assert.equal(r.id, 'Usina Hidrelétrica de Itaipu'); + assert.equal(r.name, 'Usina Hidrelétrica de Itaipu'); + assert.equal(r.operator, 'Itaipú Binacional'); + assert.equal(r.river, 'Paraná'); + assert.equal(r.output, '14000 MW'); + assert.equal(r.capacity, null); +}); + +test('infra analyst record: unnamed feature falls back to source id', () => { + const r = mapAnalystRecord({ + id: 'dc-42', + lat: 30.2, + lon: -97.7, + properties: { tags: { operator: 'Example Cloud' } }, + }, 'local-datacenters'); + assert.equal(r.id, 'dc-42'); + assert.equal(r.name, null); + assert.equal(r.operator, 'Example Cloud'); +}); + +test('infra analyst record: empty record yields nulls, never NaN/undefined', () => { + const r = mapAnalystRecord(undefined, 'local-dams'); + assert.equal(r.id, 'Dam'); + for (const [key, value] of Object.entries(r)) { + assert.notEqual(value, undefined, `${key} must not be undefined`); + if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`); + } +}); + +test('infra analyst record: output is JSON-safe (no Cesium types leak)', () => { + const r = mapAnalystRecord({ + ...DAM_RAW, + entity: {}, + position: { x: 1 }, + }, 'local-dams'); + assert.deepEqual(JSON.parse(JSON.stringify(r)), r); + assert.equal('entity' in r, false); + assert.equal('position' in r, false); +}); + +class MockLayerEvent { + constructor() { + this.listeners = new Set(); + } + + addEventListener(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } +} + +test('infra getAnalystRecords: enabled layer snapshots loaded stems; disable returns []', async () => { + const originalFetch = globalThis.fetch; + const originalWindow = globalThis.window; + globalThis.window = { dispatchEvent() {} }; + globalThis.fetch = async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + type: 'Feature', + id: 'runtime-dam', + properties: { + name: 'Runtime Dam', + output: '12 MW', + tags: { associated_river: 'Test River', operator: 'Test Hydro' }, + }, + geometry: { + type: 'Polygon', + coordinates: [[[-97.70, 30.20], [-97.69, 30.20], [-97.69, 30.21], [-97.70, 30.20]]], + }, + }), + }); + const viewer = { + selectedEntity: undefined, + dataSources: { + add(dataSource) { return dataSource; }, + remove() { return true; }, + }, + camera: { + positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000), + frustum: { fov: Math.PI / 3 }, + moveEnd: new MockLayerEvent(), + flyTo() {}, + }, + scene: { + canvas: { clientWidth: 800, clientHeight: 600 }, + preRender: new MockLayerEvent(), + sampleHeightSupported: false, + sampleHeight() { return undefined; }, + screenSpaceCameraController: { enableInputs: true }, + pick() { return null; }, + requestRender() {}, + }, + }; + const layer = createLocalGeoJsonLayer({ + id: 'local-dams', + url: '/runtime-dam.geojsonl', + name: 'Runtime Dams', + color: '#0088ff', + overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, + projectToWindow: () => ({ x: 400, y: 300 }), + screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }), + }); + try { + assert.deepEqual(layer.getAnalystRecords(), [], 'unenabled layer has no analyst records'); + await layer.enable(viewer); + const rows = layer.getAnalystRecords(); + assert.equal(rows.length, 1); + assert.equal(rows[0].id, 'Runtime Dam'); + assert.equal(rows[0].name, 'Runtime Dam'); + assert.equal(rows[0].operator, 'Test Hydro'); + assert.equal(rows[0].river, 'Test River'); + assert.equal(rows[0].output, '12 MW'); + assert.ok(Number.isFinite(rows[0].lat) && Number.isFinite(rows[0].lon)); + assert.deepEqual(JSON.parse(JSON.stringify(rows[0])), rows[0]); + assert.equal(layer.getAnalystRecords(0).length, 1, 'non-finite/zero cap still returns at least one'); + + layer.disable(viewer); + assert.deepEqual(layer.getAnalystRecords(), [], 'disable keeps stems but analyst snapshot is empty'); + } finally { + layer.destroy(viewer); + globalThis.fetch = originalFetch; + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + } +}); diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index b90d1b66b..8cb8ef2ff 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -88,6 +88,78 @@ export function localInfrastructureOverlayCopy(properties, layerId) { return { title, details }; } +/** + * Map one local infrastructure feature to a JSON-safe analyst record + * (analyst query engine seam). Pure — no Cesium types. Missing/unknown + * fields are null, never NaN/undefined. Layer-specific fields that do not + * apply (river/output on datacenters, capacity on dams) stay null so a + * shared compact payload can copy them without inventing values. + * Names are unclamped — overlay cards shorten for paint; queries need the + * full source string ("Usina Hidrelétrica de Itaipu"). + * @param {Object|null|undefined} raw - {id, lat, lon, properties}. + * @param {string} [layerId] Local layer id (`local-datacenters` / `local-dams`). + * @returns {{id: string, name: string|null, lat: number|null, lon: number|null, + * operator: string|null, capacity: string|null, river: string|null, + * output: string|null}} + */ +export function mapAnalystRecord(raw, layerId = '') { + const num = (v) => (Number.isFinite(v) ? v : null); + const text = (v) => { + const t = String(v ?? '').trim(); + return t && t !== 'undefined' && t !== 'null' ? t : null; + }; + const props = + raw?.properties && + typeof raw.properties === 'object' && + !Array.isArray(raw.properties) + ? raw.properties + : {}; + const tags = + props.tags && typeof props.tags === 'object' && !Array.isArray(props.tags) + ? props.tags + : {}; + const name = + text(props.name) || + text(tags.name) || + text(tags['name:en']) || + text(tags.official_name) || + null; + const operator = + text(tags.operator) || + text(props.operator) || + text(tags['operator:short']) || + null; + const capacity = + layerId === 'local-datacenters' + ? text(tags['capacity:it_load']) || + text(tags.it_load) || + text(tags.capacity) || + text(props.capacity) + : null; + const river = + layerId === 'local-dams' + ? text(tags.associated_river) || + text(props.associated_river) || + text(tags.river) || + text(props.river) || + text(tags['river:name']) + : null; + const output = + layerId === 'local-dams' + ? text(props.output) || text(tags['plant:output:electricity']) + : null; + return { + id: name || text(raw?.id) || layerTitle(layerId), + name, + lat: num(raw?.lat), + lon: num(raw?.lon), + operator, + capacity, + river, + output, + }; +} + /** * Produce one normalized-contract input owned by a local infrastructure layer. * The host revalidates the authoritative `source` value while normalizing it. @@ -309,7 +381,7 @@ export function localDatasetError(error) { * standard scene.pick natively clicks them. * @param {object} options Dataset URL, identity, appearance and optional Cesium adapters. * @param {object} services Caller-owned operations; see docs/INFRASTRUCTURE-LAYERS.md. - * @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats. + * @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats/getAnalystRecords. * One live instance per layer id is allowed in a given viewer/context/overlay host. * Destroy the previous instance before replacing it. Importing creates no layers. */ @@ -497,6 +569,40 @@ export function createLocalGeoJsonLayer( computed: _lodComputed, }), + /** + * Snapshot in-memory infrastructure features as plain JSON-safe objects + * for the analyst query engine. On-demand only (called at most once per + * spoken query) — zero per-frame cost, no listeners, no caching. Returns + * [] while the layer is disabled or empty. Disable keeps the loaded + * stems for reuse; this method still returns [] until the next enable. + * @param {number} [maxCount=2000] Maximum records to return (truncation). + * @returns {Array} See mapAnalystRecord for the record shape. + */ + getAnalystRecords(maxCount = 2000) { + if (!_enabled || !_stemRecords.length) return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; + const result = []; + for (let i = 0; i < _stemRecords.length; i++) { + if (result.length >= limit) break; + const record = _stemRecords[i]; + const carto = record.carto; + result.push( + mapAnalystRecord( + { + id: record.id, + lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null, + lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null, + properties: propertyObject(record.entity), + }, + id, + ), + ); + } + return result; + }, + enable: async (viewer) => { if (_destroyed) return; _enabled = true; diff --git a/src/data/satellites.analyst.test.mjs b/src/data/satellites.analyst.test.mjs new file mode 100644 index 000000000..708781422 --- /dev/null +++ b/src/data/satellites.analyst.test.mjs @@ -0,0 +1,134 @@ +// Focused tests for the satellite analyst-record mapper and snapshot seam. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { twoline2satrec } from 'satellite.js'; +import satellitesLayer, { + mapAnalystRecord, + _clearDenseCatalogStateForTest, + _setTrackedSatelliteRefreshStateForTest, +} from './satellites.js'; + +const ISS_NORAD = 25544; + +const ISS_L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927'; +const ISS_L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537'; + +const FULL_RAW = { + noradId: 25544, + name: 'ISS (ZARYA)', + group: 'stations', + lat: 51.64, + lon: -97.4, + altitudeM: 410_000, + speedMps: 7660, +}; + +test('satellite analyst record: full record maps every contract field', () => { + const r = mapAnalystRecord(FULL_RAW); + assert.deepEqual(r, { + id: 'ISS (ZARYA)', + noradId: '25544', + name: 'ISS (ZARYA)', + lat: 51.64, + lon: -97.4, + altitudeM: 410_000, + speedMps: 7660, + satelliteClass: 'STATION · ISS', + group: 'stations', + }); +}); + +test('satellite analyst record: nameless row falls back to SAT-norad id', () => { + const r = mapAnalystRecord({ ...FULL_RAW, name: ' ', noradId: 25544 }); + assert.equal(r.id, 'SAT-25544'); + assert.equal(r.name, null); +}); + +test('satellite analyst record: ISS class wins even when ingested as visual', () => { + const r = mapAnalystRecord({ ...FULL_RAW, group: 'visual' }); + assert.equal(r.satelliteClass, 'STATION · ISS'); +}); + +test('satellite analyst record: GPS group maps to NAV · GPS', () => { + const r = mapAnalystRecord({ + noradId: 24876, + name: 'GPS BIIR-2 (PRN 13)', + group: 'gps-ops', + lat: 0, + lon: 0, + altitudeM: 20_200_000, + speedMps: 3870, + }); + assert.equal(r.satelliteClass, 'NAV · GPS'); + assert.equal(r.group, 'gps-ops'); +}); + +test('satellite analyst record: empty record yields nulls, never NaN/undefined', () => { + const r = mapAnalystRecord(undefined); + assert.equal(r.id, 'SAT-00000'); + assert.equal(r.satelliteClass, null); + for (const [key, value] of Object.entries(r)) { + assert.notEqual(value, undefined, `${key} must not be undefined`); + if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`); + } +}); + +test('satellite analyst record: output is JSON-safe (no Cesium types leak)', () => { + const r = mapAnalystRecord({ ...FULL_RAW, position: { x: 1 }, satrec: { dummy: true } }); + assert.deepEqual(JSON.parse(JSON.stringify(r)), r); + assert.equal('position' in r, false); + assert.equal('satrec' in r, false); +}); + +test('satellite getAnalystRecords: disabled or empty catalog returns []', () => { + _clearDenseCatalogStateForTest(); + assert.deepEqual(satellitesLayer.getAnalystRecords(), []); +}); + +test('satellite getAnalystRecords: core rows beat dense extras under the cap', () => { + const satrec = twoline2satrec(ISS_L1, ISS_L2); + const point = { position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 420_000) }; + _setTrackedSatelliteRefreshStateForTest({ + noradId: ISS_NORAD, + name: 'ISS (ZARYA)', + satrec, + entity: {}, + point, + viewer: {}, + now: () => Date.now(), + neighbours: [ + { + noradId: 99999, + name: 'STARLINK-TEST', + satrec, + group: 'dense', + point: { position: Cesium.Cartesian3.fromDegrees(-90, 20, 550_000) }, + }, + { + noradId: 24876, + name: 'GPS BIIR-2', + satrec, + group: 'gps-ops', + point: { position: Cesium.Cartesian3.fromDegrees(-100, 10, 20_200_000) }, + }, + ], + }); + try { + const capped = satellitesLayer.getAnalystRecords(2); + assert.equal(capped.length, 2); + assert.deepEqual(capped.map((r) => r.noradId), ['25544', '24876']); + assert.equal(capped.some((r) => r.group === 'dense'), false); + + const all = satellitesLayer.getAnalystRecords(10); + assert.equal(all.length, 3); + assert.equal(all[2].noradId, '99999'); + assert.equal(all[2].satelliteClass, 'COMMS · STARLINK'); + for (const row of all) { + assert.ok(Number.isFinite(row.lat) && Number.isFinite(row.lon), `${row.id} needs a geodetic position`); + assert.deepEqual(JSON.parse(JSON.stringify(row)), row); + } + } finally { + _clearDenseCatalogStateForTest(); + } +}); diff --git a/src/data/satellites.js b/src/data/satellites.js index 0fe8b6d02..14b6358d5 100644 --- a/src/data/satellites.js +++ b/src/data/satellites.js @@ -499,6 +499,70 @@ function propagatePosition(satrec, date) { } } +/** + * Map one CelesTrak catalog satellite to a JSON-safe analyst record + * (analyst query engine seam). Pure — no Cesium types. Missing/unknown + * fields are null, never NaN/undefined. `id` is the display name (same + * convention as vessels); `noradId` is the track_entity key. + * @param {Object|null|undefined} raw - {noradId, name, group, lat, lon, altitudeM, speedMps}. + * @returns {{id: string, noradId: string|null, name: string|null, + * lat: number|null, lon: number|null, altitudeM: number|null, + * speedMps: number|null, satelliteClass: string|null, group: string|null}} + */ +export function mapAnalystRecord(raw) { + const num = (v) => (Number.isFinite(v) ? v : null); + const text = (v) => { const t = String(v ?? '').trim(); return t || null; }; + const noradNum = Number(raw?.noradId); + const noradId = Number.isFinite(noradNum) ? String(Math.trunc(noradNum)) : text(raw?.noradId); + const name = text(raw?.name); + const group = text(raw?.group); + const isIss = noradNum === ISS_NORAD; + return { + id: name || (noradId ? `SAT-${noradId}` : 'SAT-00000'), + noradId, + name, + lat: num(raw?.lat), + lon: num(raw?.lon), + altitudeM: num(raw?.altitudeM), + speedMps: num(raw?.speedMps), + // Empty records stay class-less; the class table's fallback is VISUAL and + // must not leak onto a row that has no satellite identity. + satelliteClass: (noradId || group) ? satelliteClassLabel(group, { isIss }) : null, + group, + }; +} + +/** + * Geodetic snapshot for one catalog row: prefer a fresh SGP4 sample so the + * analyst answer matches "where is it now", then fall back to the last + * rendered point (no satrec in tests that only planted a Cartesian). + * @param {number} noradId + * @param {{satrec?: object}|null|undefined} sat + * @param {Date} now + * @returns {{lat: number, lon: number, altitudeM: number, speedMps: number|null}|null} + */ +function analystGeo(noradId, sat, now) { + const pos = sat?.satrec ? propagatePosition(sat.satrec, now) : null; + if (pos && Number.isFinite(pos.latitude) && Number.isFinite(pos.longitude)) { + return { + lat: pos.latitude, + lon: pos.longitude, + altitudeM: pos.altitude, + speedMps: Number.isFinite(pos.speedMps) ? pos.speedMps : null, + }; + } + const point = _points.get(noradId); + if (!point?.position) return null; + const carto = Cesium.Cartographic.fromCartesian(point.position); + if (!carto) return null; + return { + lat: Cesium.Math.toDegrees(carto.latitude), + lon: Cesium.Math.toDegrees(carto.longitude), + altitudeM: carto.height, + speedMps: null, + }; +} + function orbitalPeriodSeconds(satrec) { const meanMotion = satrec.no * (1440 / (2 * Math.PI)); return 86400 / Math.max(meanMotion, 0.1); @@ -2174,6 +2238,49 @@ const satellitesLayer = { _rowControlsListener = typeof listener === 'function' ? listener : null; }, + /** + * Snapshot the layer's in-memory CelesTrak catalog as plain JSON-safe + * objects for the analyst query engine. On-demand only (called at most + * once per spoken query) — zero per-frame cost, no listeners, no caching. + * Core catalog rows are emitted before dense Starlink extras so the 2,000 + * cap keeps named constellations (ISS, GPS, GEO, …) ahead of the shell. + * Returns [] while the layer is disabled or empty. + * @param {number} [maxCount=2000] - Maximum records to return (truncation). + * @returns {Array} See mapAnalystRecord for the record shape. + */ + getAnalystRecords(maxCount = 2000) { + if (!_enabled || !_catalog.size) return []; + const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; + const now = new Date(); + const result = []; + const emit = (noradId, sat) => { + if (result.length >= limit) return false; + const pos = analystGeo(noradId, sat, now); + if (!pos) return true; + result.push(mapAnalystRecord({ + noradId, + name: sat?.name, + group: sat?.group, + lat: pos.lat, + lon: pos.lon, + altitudeM: pos.altitudeM, + speedMps: pos.speedMps, + })); + return result.length < limit; + }; + for (const [noradId, sat] of _catalog) { + if (sat?.group === 'dense') continue; + if (!emit(noradId, sat)) break; + } + if (result.length < limit) { + for (const [noradId, sat] of _catalog) { + if (sat?.group !== 'dense') continue; + if (!emit(noradId, sat)) break; + } + } + return result; + }, + getStats() { return { count: _count, diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 6d887eb58..0c9c9c909 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -654,15 +654,14 @@ test('the voice TOOL SCHEMA is byte-identical to main — the mission mapping is const end = src.indexOf('\n];\n', start); const block = src.slice(start, end + 4); - // Re-pinned 2026-08-28: the Provider Settings / Esri release DELIBERATELY - // extends set_map_stack's enum with 'esri-imagery' (a real new basemap — - // exactly the kind of schema change this pin exists to make loud). The - // guarded claim is unchanged: first-run missions ride existing tools, and - // any NEW drift from this recorded schema still fails here. - assert.equal(block.length, 31189, 'tool schema byte length drifted from the pinned release schema'); + // Re-pinned 2026-09-12: analyst_query's layer enum grows satellites + + // local-datacenters + local-dams so voice can count/nearest those layers. + // The guarded claim is unchanged: first-run missions ride existing tools, + // and any NEW drift from this recorded schema still fails here. + assert.equal(block.length, 31591, 'tool schema byte length drifted from the pinned release schema'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '73aaabdb169a5478893d28688f327a21edd32ed3ec16fc6287bd944ed77beecf', + 'f78d80ddf564f7bef6d8c548d110d83f863a7b49a70ead4dbd7581a7996a193f', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 8d59e6787..2a9035d04 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -157,11 +157,11 @@ test('the edited existing tools changed exactly as intended', () => { }); test('no unchanged Realtime tool definition drifts silently', () => { - // Context/Cockpit parity, the dependent-location wait edit, and the retired - // `bing-road` stack leaving `set_map_stack`'s enum are the known schema - // changes. Everything else must be byte-identical: an unnoticed edit - // to a shipped tool changes - // model behavior in production with nothing in review to catch it. + // Context/Cockpit parity, the dependent-location wait edit, the retired + // `bing-road` stack leaving `set_map_stack`'s enum, and analyst_query's + // satellite/infrastructure layer enum are the known schema changes. + // Everything else must be byte-identical: an unnoticed edit to a shipped + // tool changes model behavior in production with nothing in review to catch it. // // If this fails and the change was deliberate, re-derive the digest and say // in the mic-test brief which tools moved — the session cache busts on any @@ -174,16 +174,17 @@ test('no unchanged Realtime tool definition drifts silently', () => { 'fly_to_location', 'select_nearest_aircraft', 'set_map_stack', + 'analyst_query', ]); const unchanged = realtimeTools() .filter((tool) => !TOUCHED.has(tool.name)) .sort((a, b) => a.name.localeCompare(b.name)); - assert.equal(unchanged.length, 21); + assert.equal(unchanged.length, 20); const digest = createHash('sha256') .update(JSON.stringify(unchanged)) .digest('hex') .slice(0, 16); - assert.equal(digest, '802ed694b8887b88', 'an unchanged Realtime tool definition drifted'); + assert.equal(digest, '5db68d7c29f6ed4f', 'an unchanged Realtime tool definition drifted'); }); test('Radio volume and mission speed share the Sharpen slider visual language', () => { diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index a56c840e7..f2db3cd8e 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -3378,7 +3378,7 @@ async function runAnalystQuery(viewer, dataManager, args = {}) { // model burned the turn on retries (owner field session 2026-08-21, 23:48). const items = result.items.map((r) => { const compact = { layerKey: r.layerKey, id: r.id }; - for (const k of ['icao24', 'mmsi', 'registration', 'label', 'callsign', 'name', 'altitudeM', 'speedMps', 'speedKts', 'frp', 'magnitude', 'shipType', 'destination', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass', 'military', 'onGround', 'distanceKm', 'confidence', 'place']) { + for (const k of ['icao24', 'mmsi', 'registration', 'label', 'callsign', 'name', 'altitudeM', 'speedMps', 'speedKts', 'frp', 'magnitude', 'shipType', 'destination', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass', 'military', 'onGround', 'distanceKm', 'confidence', 'place', 'noradId', 'satelliteClass', 'group', 'river', 'output', 'capacity']) { if (r[k] !== null && r[k] !== undefined) compact[k] = r[k]; } return compact; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 0f6baa651..cf2e8d9f8 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3020,3 +3020,21 @@ test('front5: 0.99 km due EAST is the subject, though a degree box rejects it', assert.equal(result.window.centeredOn, 'N546PC'); }); }); + +test('analyst_query tool enum includes satellites and local infrastructure', () => { + const config = readFileSync(new URL('../../server/providers/local.js', import.meta.url), 'utf8'); + const match = config.match(/name: 'analyst_query'[\s\S]*?items: \{ type: 'string', enum: \[([^\]]+)\]/); + assert.ok(match, 'analyst_query layers enum must still be findable'); + for (const layer of ["'satellites'", "'local-datacenters'", "'local-dams'"]) { + assert.ok(match[1].includes(layer), `${layer} must be queryable via analyst_query`); + } +}); + +test('analyst_query compact items keep satellite and infrastructure identity fields', () => { + const src = readFileSync(new URL('./gevActions.js', import.meta.url), 'utf8'); + const match = src.match(/for \(const k of \['icao24', 'mmsi'[^\]]+\]\)/); + assert.ok(match, 'analyst compact-field list must still be findable'); + for (const field of ["'noradId'", "'satelliteClass'", "'group'", "'river'", "'output'", "'capacity'"]) { + assert.ok(match[0].includes(field), `${field} must ride on compact analyst items`); + } +}); From cfb0d6e2479eaae99b31649220bd9267b9bd0ab5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 16:00:43 +0000 Subject: [PATCH 02/11] feat(voice): attach feed-state provenance to analyst and HUD answers Share one layer snapshot envelope so analyst_query, get_current_view_state, and HUD summaries narrate the same chip feed-state instead of treating stale or degraded feeds as live. Co-authored-by: Matt Van Horn --- CHANGELOG.md | 5 + docs/CURRENT-STATE.md | 20 ++- server/providers/local.js | 17 +- src/data/analystEngine.js | 35 +++- src/data/analystEngine.test.mjs | 52 +++++- src/data/layerSnapshot.js | 278 ++++++++++++++++++++++++++++++++ src/data/layerSnapshot.test.mjs | 182 +++++++++++++++++++++ src/data/manager.js | 66 +------- src/firstRunExperience.test.mjs | 12 +- src/hud.js | 20 ++- src/hudSummaryResponse.js | 62 +++++++ src/hudSummaryResponse.test.mjs | 42 +++++ src/radioMarkup.test.mjs | 35 +++- src/voice/gevActions.js | 107 ++++++++++-- src/voice/gevActions.test.mjs | 66 ++++++++ 15 files changed, 881 insertions(+), 118 deletions(-) create mode 100644 src/data/layerSnapshot.js create mode 100644 src/data/layerSnapshot.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ce2aff3..2d8f85f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Attach honest layer feed-state provenance (nominal/loading/degraded/stale/ + fallback/unavailable) to `analyst_query`, `get_current_view_state`, and HUD + summary context so voice and HUD answers cannot narrate a stale or degraded + feed as live. + - Separate terrain, traffic, FIRMS and GBFS middleware into focused provider modules, preserving local configuration, routes and cache/error behavior. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 250a2f106..c01b06465 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,20 @@ # God's Eye View Current State +## Voice and HUD feed-state provenance + +Voice and HUD answers share one layer snapshot envelope (`src/data/layerSnapshot.js`) +so they cannot narrate a stale, degraded, fallback, loading, or unavailable feed as +live. `layerFeedState` — the same classifier the Data Layers chips use — stamps +each queried or enabled layer with `nominal` / `loading` / `degraded` / `stale` / +`fallback` / `unavailable`, or `off` when the layer is disabled. `analyst_query` +and `get_current_view_state` carry `feedProvenance` (`overall`, per-layer rows, +and a mechanical `note`). The HUD summary context sends the same snapshots; the +five-word AI line must include a non-nominal feedState token, and the +deterministic telemetry fallback appends e.g. `STALE LIVE FLIGHTS`. Confirmations +still report only what the tool result returned. + +## Terrain, traffic, fire and bike-share provider modules + ## Terrain, traffic, fire and bike-share provider modules Local composition now imports separate Node modules for Re:Earth heights, @@ -2426,13 +2441,14 @@ silently demoting every later lookup for the session. 2. **Contacts OFF** → "nearby" means **in view**; "near \" means a radius around that place. A radius query with Contacts active and no explicit centre is centred on the **active contact**, not the camera. 3. **Every count names its scope in words** — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number. `analyst_query` returns `scopeLabel` so this is mechanical. Two different numbers with named scopes are not a contradiction. 4. **The loaded-data caveat is stated once when relevant**: counts cover loaded data, and the flights layer loads where you look (appended to `coverage.note` for radius/view scopes over viewport-loaded layers). + 5. **Feed-state provenance rides with every count**: `feedProvenance` is the same `layerFeedState` the chips show. A non-nominal `overall` must be spoken with the count ("12 flights in view, STALE"); an unavailable feed is not a confident zero; fallback is valid data and must be named as fallback. Mechanism: `src/data/layerSnapshot.js`, attached by `analystEngine` / `get_current_view_state` / HUD summary context. - **Degradation**: without `OPENAI_API_KEY`, `/api/realtime/token` returns 503 and the mic button surfaces the error; the rest of the app is unaffected. ### AI HUD Summary (June 2026) - HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning). -- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates. -- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update. +- Input is the live basemap label context (place/street/nearby-place labels + enabled layers with `feedState`) — the model is instructed not to infer from coordinates or invent a feed-state. A non-nominal enabled layer requires that feedState token in the five words. +- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort), which itself appends a provenance tag (`STALE LIVE FLIGHTS`) when an enabled feed is not nominal; typewriter animation on update. ### Map Stack Switcher (June 2026) diff --git a/server/providers/local.js b/server/providers/local.js index 934e7c442..b513a46d8 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -61,7 +61,7 @@ import { normalizeRegionalPlace, normalizeRegionalWeather, } from '../../src/data/regionalBrief.js'; -import { keylessHudSummaryResponse } from '../../src/hudSummaryResponse.js'; +import { HUD_SUMMARY_INSTRUCTIONS, keylessHudSummaryResponse } from '../../src/hudSummaryResponse.js'; import { parseEnv as parseDotenvText } from 'node:util'; import { readEnvironmentSource as readPinokioEnvironmentSource } from '../../scripts/pinokio-environment.mjs'; import { @@ -2767,13 +2767,7 @@ export function openAiRealtimeProxy() { }, body: JSON.stringify({ model: process.env.OPENAI_HUD_SUMMARY_MODEL || OPENAI_HUD_SUMMARY_MODEL_DEFAULT, - instructions: [ - "Write one concise intelligence-HUD summary for God's Eye View.", - 'Use only the supplied place, street, nearby-place, and enabled-layer text labels.', - 'Prefer the clearest named place and include a relevant enabled layer only when useful.', - 'Do not infer from coordinates or invent a place.', - 'Output exactly five words with no title, punctuation, markdown, or introductory phrase.', - ].join(' '), + instructions: HUD_SUMMARY_INSTRUCTIONS, input: JSON.stringify(context), reasoning: { effort: 'minimal' }, max_output_tokens: 100, @@ -2905,8 +2899,9 @@ export function openAiRealtimeProxy() { 'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").', 'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.', 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.', - 'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, or earthquakes ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.', + 'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, or earthquakes ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note and feedProvenance honestly: the answer covers data loaded by enabled layers, not the whole world, and a non-nominal feedState is not live. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.', 'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near " means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.', + 'FEED STATE CONTRACT — analyst_query and get_current_view_state carry feedProvenance from the same layerFeedState the Data Layers chips use (nominal/loading/degraded/stale/fallback/unavailable, or off). NEVER narrate a count as live or current when feedProvenance.overall is not nominal. Say the feedState in the same breath as the count ("12 flights in view, STALE"). If unavailable, do not invent a count; say the feed is unavailable. Fallback is valid data — name the fallback source, never call it an enable failure. Confirm only what the tool result returned.', 'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.', 'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.', 'If get_entity_context has no selected object or overlay entities, use its basemap context: Google Photorealistic 3D Tiles/Cesium source, center target coordinates, reverse-geocoded place, camera altitude, active style, and enabled layers. Do not say there is nothing unless the basemap target is also unavailable.', @@ -3314,7 +3309,7 @@ const GEV_REALTIME_TOOLS = [ { type: 'function', name: 'get_current_view_state', - description: 'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action.', + description: 'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action. Each layer includes feedState (nominal/loading/degraded/stale/fallback/unavailable/off) plus feedProvenance; narrate layer counts only with that provenance.', parameters: { type: 'object', additionalProperties: false, @@ -3608,7 +3603,7 @@ const GEV_REALTIME_TOOLS = [ { type: 'function', name: 'analyst_query', - description: 'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.', + description: 'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. Results include feedProvenance (the same feedState the Data Layers chips show). Narrate counts only with that provenance; never describe a stale, degraded, loading, fallback, or unavailable feed as live. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.', parameters: { type: 'object', additionalProperties: false, diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index 58c28c7d8..2b2de873b 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -18,14 +18,21 @@ * * Providers (injected — keeps the engine pure and node-testable): * getRecords(layerKey) → Array (layer accessor snapshot) + * getLayerSnapshot?(layerKey) → snapshot|null (honest feed-state envelope) * resolveRegionRing(name) → Promise<{ring, name}|null> (NE pack / admin boundary) * getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived) * * @module data/analystEngine */ +import { feedProvenanceEnvelope } from './layerSnapshot.js'; import { pointInRing } from './naturalEarthRegions.js'; +function withFeedProvenance(coverage, snapshots) { + if (!Array.isArray(snapshots) || snapshots.length === 0) return coverage; + return { ...coverage, feedProvenance: feedProvenanceEnvelope(snapshots) }; +} + /** Layers the engine understands, with the fields queries may reference. */ export const ANALYST_LAYERS = { flights: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass'], flags: ['military', 'onGround'] }, @@ -115,12 +122,15 @@ export function createAnalystEngine(providers) { // 1) Source records let records; let layersQueried; + let queriedSnapshots; if (layers === null) { records = lastResult.items.slice(); layersQueried = lastResult.coverage.layersQueried; + queriedSnapshots = lastResult.coverage.feedProvenance?.layers || []; } else { records = []; layersQueried = []; + queriedSnapshots = []; const unknown = layers.filter((k) => !ANALYST_LAYERS[k]); if (unknown.length) { return { @@ -132,7 +142,21 @@ export function createAnalystEngine(providers) { for (const key of layers) { if (!ANALYST_LAYERS[key]) continue; const rows = providers.getRecords(key) || []; - layersQueried.push({ layerKey: key, records: rows.length }); + const snapshot = typeof providers.getLayerSnapshot === 'function' + ? providers.getLayerSnapshot(key) + : null; + if (snapshot) queriedSnapshots.push(snapshot); + layersQueried.push({ + layerKey: key, + records: rows.length, + ...(snapshot ? { + feedState: snapshot.feedState, + source: snapshot.source, + lastUpdate: snapshot.lastUpdate, + enabled: snapshot.enabled, + error: snapshot.error, + } : {}), + }); for (const row of rows) records.push({ layerKey: key, ...row }); } } @@ -151,7 +175,10 @@ export function createAnalystEngine(providers) { return { ok: false, error: `I couldn't resolve a boundary for "${scope.name}" — try a state, country, or a named natural region.`, - coverage: { layersQueried, scope: `region:${scope.name}:unresolved` }, + coverage: withFeedProvenance({ + layersQueried, + scope: `region:${scope.name}:unresolved`, + }, queriedSnapshots), }; } resolvedScope = region; @@ -225,12 +252,12 @@ export function createAnalystEngine(providers) { truncated: items.length > top.length, summary: summarize(items, sortBy && sortBy !== 'distance' ? sortBy : null), scopeLabel, - coverage: { + coverage: withFeedProvenance({ layersQueried, scope: scopeNote, followUp: Boolean(spec.followUp && lastResult), note: 'client-side data only — answers cover what the enabled layers currently hold', - }, + }, queriedSnapshots), // Surfaced so the narration can name the centre it measured from rather // than implying a view-centred answer. ...(resolvedScope?.centeredOn ? { centeredOn: resolvedScope.centeredOn } : {}), diff --git a/src/data/analystEngine.test.mjs b/src/data/analystEngine.test.mjs index 6521a5464..94964fa4b 100644 --- a/src/data/analystEngine.test.mjs +++ b/src/data/analystEngine.test.mjs @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js'; +import { layerSnapshot } from './layerSnapshot.js'; // Stub world: a square "Texland" region, flights + ships + fires around it. const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] }; @@ -20,18 +21,31 @@ const FIRES = [ { id: 'FIRE-3', lat: 51.9, lon: -121.9, frp: 2400 }, ]; -function makeEngine() { +function snapshotFor(key, rows, extraStats = {}) { + return layerSnapshot({ + id: key, + name: key, + enabled: true, + stats: { count: rows.length, lastUpdate: 1, ...extraStats }, + }); +} + +function makeEngine(statOverrides = {}) { + const records = { flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES }; return createAnalystEngine({ - getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES }[key] || []), + getRecords: (key) => records[key] || [], + getLayerSnapshot: (key) => snapshotFor(key, records[key] || [], statOverrides[key]), resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null), getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }), }); } /** Same world, but Contacts is up with a subject far from the parked camera. */ -function makeContactsEngine(subject) { +function makeContactsEngine(subject, statOverrides = {}) { + const records = { flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES }; return createAnalystEngine({ - getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES }[key] || []), + getRecords: (key) => records[key] || [], + getLayerSnapshot: (key) => snapshotFor(key, records[key] || [], statOverrides[key]), resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null), // Parked far away, as a high-altitude camera often is. getViewContext: () => ({ lat: 45.0, lon: -122.0, viewRadiusKm: 150 }), @@ -201,3 +215,33 @@ test('helpers: haversine sanity + scope radius', () => { const scoped = applyScope(FLIGHTS, { kind: 'radius' }, { center: { lat: 30.27, lon: -97.74 }, km: 50 }); assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']); }); + +test('analyst: a stale feed stamps provenance so the count cannot be narrated as live', async () => { + const r = await makeEngine({ flights: { stale: true } }).query({ + layers: ['flights'], scope: { kind: 'view' }, limit: 50, + }); + assert.equal(r.ok, true); + assert.equal(r.coverage.layersQueried[0].feedState, 'stale'); + assert.equal(r.coverage.feedProvenance.overall, 'stale'); + assert.match(r.coverage.feedProvenance.note, /STALE/); + assert.match(r.coverage.feedProvenance.note, /do not describe this as live/i); +}); + +test('analyst: a degraded feed outranks a nominal neighbour in overall provenance', async () => { + const r = await makeEngine({ + 'local-firms': { error: 'FIRMS timeout', count: 2 }, + }).query({ + layers: ['flights', 'local-firms'], scope: { kind: 'anywhere' }, limit: 50, + }); + assert.equal(r.coverage.feedProvenance.overall, 'degraded'); + const fires = r.coverage.layersQueried.find((row) => row.layerKey === 'local-firms'); + assert.equal(fires.feedState, 'degraded'); +}); + +test('analyst: follow-up keeps the original feed-state snapshot', async () => { + const eng = makeEngine({ flights: { stale: true } }); + await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } }); + const r = await eng.query({ followUp: true, filters: [{ field: 'onGround', op: 'eq', value: true }] }); + assert.equal(r.coverage.followUp, true); + assert.equal(r.coverage.feedProvenance.overall, 'stale'); +}); diff --git a/src/data/layerSnapshot.js b/src/data/layerSnapshot.js new file mode 100644 index 000000000..767f8f6ba --- /dev/null +++ b/src/data/layerSnapshot.js @@ -0,0 +1,278 @@ +/** + * Shared layer snapshot envelope for voice and HUD answers. + * + * Data Layers chips, `analyst_query`, `get_current_view_state`, and the HUD + * summary must all name the same feed-state. This module is the one envelope: + * it classifies `getStats()` through `layerFeedState` and stamps the result + * so a stale or degraded feed cannot be narrated as live. Cesium-free so the + * HUD summary proxy can import it from Node. + * + * @module data/layerSnapshot + */ + +/** Feed states the Data Layers chips and voice/HUD provenance share. */ +export const LAYER_FEED_STATES = Object.freeze([ + 'nominal', + 'loading', + 'degraded', + 'stale', + 'fallback', + 'unavailable', +]); + +/** Chip labels for enabled layers. Disabled layers read OFF, not a feed-state. */ +export const LAYER_FEED_STATE_LABELS = Object.freeze({ + nominal: 'ON', + loading: 'LOADING', + degraded: 'DEGRADED', + stale: 'STALE', + fallback: 'FALLBACK', + unavailable: 'UNAVAILABLE', +}); + +/** Snapshot feed-state including a disabled layer. */ +export const SNAPSHOT_FEED_STATES = Object.freeze([...LAYER_FEED_STATES, 'off']); + +/** + * Lower rank is more severe. Voice/HUD `overall` is the worst queried state + * so a mixed roster cannot hide an unavailable or stale feed behind a nominal + * neighbour. + */ +export const FEED_STATE_SEVERITY = Object.freeze({ + unavailable: 0, + loading: 1, + degraded: 2, + stale: 3, + fallback: 4, + nominal: 5, + off: 6, +}); + +/** + * Normalize heterogeneous layer stats into one honest control-chip state. + * @param {object|null} stats Layer getStats() result. + * @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state. + */ +export function layerFeedState(stats = {}) { + const state = stats || {}; + const status = typeof state.status === 'string' ? state.status.toLowerCase() : ''; + const source = `${state.source || ''} ${state.coverage || ''}`; + const hasExplicitFallback = typeof state.fallback === 'boolean'; + const hasPriorData = Number(state.count) > 0 || Boolean(state.lastUpdate); + const presentedError = state.error || state.lastError || state.managerRefreshError; + if (['unavailable', 'offline', 'down', 'error'].includes(status)) return 'unavailable'; + if ( + (presentedError || state.unavailable === true || state.available === false) + && !hasPriorData + && !['zoom-in', 'empty', 'idle'].includes(status) + ) { + return 'unavailable'; + } + if (state.loading) return 'loading'; + // Guidance states ask the user to act (zoom in, run a search) — normal + // operation, not feed faults. One honesty carve-out: layers keep their + // rendered records through the guidance state, so a genuinely stale cache + // still reads STALE; a guidance prompt alone never reads DEGRADED. + if (['zoom-in', 'empty', 'idle'].includes(status)) { + return state.stale ? 'stale' : 'nominal'; + } + if ( + state.fallback === true + || status === 'fallback' + || state.mode === 'sim' + || /\bfallback\b/i.test(source) + || (!hasExplicitFallback && /\badsb\.lol\b/i.test(source)) + ) { + return 'fallback'; + } + if (state.stale || status === 'stale') return 'stale'; + if ( + state.degraded + || presentedError + || state.unavailable === true + || state.available === false + ) return 'degraded'; + return 'nominal'; +} + +function snapshotError(stats) { + const err = stats?.error || stats?.lastError || stats?.managerRefreshError; + if (err == null || err === '') return null; + return err instanceof Error ? err.message : String(err); +} + +/** + * Age of a lastUpdate timestamp in whole seconds. + * @param {number|null|undefined} lastUpdate Epoch ms. + * @param {number} [now=Date.now()] Clock for tests. + * @returns {number|null} Non-negative seconds, or null when unknown. + */ +export function snapshotAgeSec(lastUpdate, now = Date.now()) { + const stamp = Number(lastUpdate); + if (!Number.isFinite(stamp) || stamp <= 0) return null; + return Math.max(0, Math.floor((now - stamp) / 1000)); +} + +/** + * Compact age label matching the Data Layers meta line. + * @param {number|null|undefined} lastUpdate Epoch ms. + * @param {number} [now=Date.now()] Clock for tests. + * @returns {string|null} `just now` / `12s ago` / `4m ago` / `2h ago`. + */ +export function snapshotAgeLabel(lastUpdate, now = Date.now()) { + const sec = snapshotAgeSec(lastUpdate, now); + if (sec === null) return null; + if (sec < 5) return 'just now'; + if (sec < 60) return `${sec}s ago`; + if (sec < 3600) return `${Math.floor(sec / 60)}m ago`; + return `${Math.floor(sec / 3600)}h ago`; +} + +/** + * Pick the most severe feed-state from a list. + * @param {Iterable} states Feed-state strings. + * @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'|'off'|null} + */ +export function worstFeedState(states) { + let worst = null; + let worstRank = Number.POSITIVE_INFINITY; + for (const state of states || []) { + if (!Object.hasOwn(FEED_STATE_SEVERITY, state)) continue; + const rank = FEED_STATE_SEVERITY[state]; + if (rank < worstRank) { + worst = state; + worstRank = rank; + } + } + return worst; +} + +/** + * One honest layer snapshot for voice/HUD narration. + * + * Disabled layers report `feedState: 'off'` — the same reading the chips use — + * even if leftover stats would otherwise classify as stale. Counts and errors + * still travel so a disabled layer with a last-known error is inspectable. + * + * @param {object} [layer] `DataLayerManager.getAll()` projection, or a layer-like + * object with `id`, `enabled`, `stats`, and optional `source`/`name`. + * @param {{ now?: number }} [options] + * @returns {{ + * id: string|null, + * name: string|null, + * enabled: boolean, + * feedState: string, + * source: string|null, + * count: number, + * lastUpdate: number|null, + * ageSec: number|null, + * ageLabel: string|null, + * error: string|null, + * }} + */ +export function layerSnapshot(layer = {}, { now = Date.now() } = {}) { + const stats = (layer && typeof layer.stats === 'object' && layer.stats) || {}; + const source = String(stats.source || layer.source || '').trim() || null; + const enabled = Boolean(layer.enabled); + const countRaw = Number(stats.count); + const count = Number.isFinite(countRaw) ? countRaw : 0; + const lastUpdate = Number.isFinite(Number(stats.lastUpdate)) ? Number(stats.lastUpdate) : null; + const feedState = enabled ? layerFeedState({ ...stats, source }) : 'off'; + return { + id: layer.id || layer.layerKey || null, + name: layer.name || layer.label || layer.id || layer.layerKey || null, + enabled, + feedState, + source, + count, + lastUpdate, + ageSec: snapshotAgeSec(lastUpdate, now), + ageLabel: snapshotAgeLabel(lastUpdate, now), + error: snapshotError(stats), + }; +} + +/** + * Snapshot every layer in a roster. + * @param {Array} [layers] + * @param {{ now?: number }} [options] + * @returns {Array} + */ +export function layerSnapshots(layers = [], options) { + if (!Array.isArray(layers)) return []; + return layers.map((layer) => layerSnapshot(layer, options)); +} + +function asSnapshot(entry, now) { + if (!entry || typeof entry !== 'object') return layerSnapshot({}, { now }); + if (typeof entry.feedState === 'string') return entry; + return layerSnapshot(entry, { now }); +} + +/** + * Mechanical narration note so the model does not have to infer honesty. + * @param {Array} layers Snapshots. + * @param {string|null} overall Worst considered feed-state. + * @returns {string} + */ +export function feedProvenanceNote(layers = [], overall = null) { + if (!overall) { + return 'No layer snapshot is attached. Do not invent a feed-state or a count.'; + } + if (overall === 'nominal') { + return 'Queried enabled layers are nominal. Still name the count scope; do not claim whole-world coverage.'; + } + if (overall === 'off') { + const names = layers.map((s) => s.name || s.id).filter(Boolean).join(', ') || 'the requested layer'; + return `${names} is off. Do not invent a count; say the layer is off and offer to enable it.`; + } + const flagged = layers.filter((s) => ( + s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off' + )); + const bits = flagged.map((s) => { + const extras = [s.source, s.ageLabel || snapshotAgeLabel(s.lastUpdate), s.error] + .filter(Boolean) + .join(', '); + return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`; + }); + const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`; + return `${body}. Narrate that provenance with any count; do not describe this as live.`; +} + +/** + * Shared envelope stamped onto analyst, view-state, and HUD payloads. + * @param {Array} [snapshots] Snapshots or raw `getAll()` rows. + * @param {{ now?: number }} [options] + * @returns {{ overall: string|null, layers: Array, note: string }} + */ +export function feedProvenanceEnvelope(snapshots = [], { now = Date.now() } = {}) { + const layers = (Array.isArray(snapshots) ? snapshots : []).map((entry) => asSnapshot(entry, now)); + const active = layers.filter((s) => s.feedState && s.feedState !== 'off'); + const overall = worstFeedState(active.map((s) => s.feedState)) + || (layers.length ? 'off' : null); + return { + overall, + layers, + note: feedProvenanceNote(layers, overall), + }; +} + +/** + * Compact voice-tool layer row: identity + the fields a model must read. + * @param {object} layer Raw `getAll()` row or snapshot. + * @param {{ now?: number }} [options] + * @returns {object} + */ +export function viewStateLayerRow(layer, options) { + const snap = layerSnapshot(layer, options); + return { + id: snap.id, + name: snap.name, + enabled: snap.enabled, + count: snap.count, + error: snap.error, + feedState: snap.feedState, + source: snap.source, + lastUpdate: snap.lastUpdate, + }; +} diff --git a/src/data/layerSnapshot.test.mjs b/src/data/layerSnapshot.test.mjs new file mode 100644 index 000000000..a7c1a1346 --- /dev/null +++ b/src/data/layerSnapshot.test.mjs @@ -0,0 +1,182 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + FEED_STATE_SEVERITY, + LAYER_FEED_STATE_LABELS, + LAYER_FEED_STATES, + SNAPSHOT_FEED_STATES, + feedProvenanceEnvelope, + feedProvenanceNote, + layerFeedState, + layerSnapshot, + layerSnapshots, + snapshotAgeLabel, + snapshotAgeSec, + viewStateLayerRow, + worstFeedState, +} from './layerSnapshot.js'; + +const NOW = 1_700_000_000_000; + +test('layerSnapshot stays Cesium-free so the HUD proxy can import it', () => { + const src = readFileSync(new URL('./layerSnapshot.js', import.meta.url), 'utf8'); + assert.equal(src.includes("from './manager.js'"), false); + assert.doesNotMatch(src, /from ['"]cesium['"]/i); +}); + +test('layerFeedState matches the chip classifier for every honesty class', () => { + assert.equal(layerFeedState({ error: 'feed down', count: 0, lastUpdate: null }), 'unavailable'); + assert.equal(layerFeedState({ + status: 'unavailable', + error: 'feed down', + count: 50, + lastUpdate: 1, + }), 'unavailable'); + assert.equal(layerFeedState({ mode: 'sim', count: 100, lastUpdate: 1 }), 'fallback'); + assert.equal(layerFeedState({ source: 'adsb.lol', count: 10, lastUpdate: 1 }), 'fallback'); + assert.equal(layerFeedState({ + source: 'adsb.lol', + fallback: false, + count: 10, + lastUpdate: 1, + }), 'nominal'); + assert.equal(layerFeedState({ stale: true, count: 0, lastUpdate: 1 }), 'stale'); + assert.equal(layerFeedState({ error: 'partial group failure', count: 50, lastUpdate: 1 }), 'degraded'); + assert.equal(layerFeedState({ loading: true }), 'loading'); + assert.equal(layerFeedState({ count: 5, lastUpdate: 1 }), 'nominal'); + assert.equal(layerFeedState({ status: 'zoom-in', error: 'zoom in to search', count: 12 }), 'nominal'); + assert.equal(layerFeedState({ status: 'zoom-in', stale: true, count: 12 }), 'stale'); +}); + +test('chip labels and severity cover every feed-state the envelope can emit', () => { + assert.deepEqual([...LAYER_FEED_STATES], [ + 'nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable', + ]); + assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']); + for (const state of LAYER_FEED_STATES) { + assert.equal(typeof LAYER_FEED_STATE_LABELS[state], 'string'); + assert.equal(typeof FEED_STATE_SEVERITY[state], 'number'); + } + assert.equal(FEED_STATE_SEVERITY.unavailable < FEED_STATE_SEVERITY.stale, true); + assert.equal(FEED_STATE_SEVERITY.stale < FEED_STATE_SEVERITY.fallback, true); + assert.equal(worstFeedState(['nominal', 'stale', 'fallback']), 'stale'); + assert.equal(worstFeedState(['degraded', 'unavailable']), 'unavailable'); + assert.equal(worstFeedState([]), null); +}); + +test('a disabled layer is off even when leftover stats would read stale', () => { + const snap = layerSnapshot({ + id: 'flights', + name: 'Live Flights', + enabled: false, + source: 'OpenSky Network', + stats: { stale: true, count: 12, lastUpdate: NOW - 240_000, source: 'OpenSky Network' }, + }, { now: NOW }); + assert.equal(snap.feedState, 'off'); + assert.equal(snap.enabled, false); + assert.equal(snap.count, 12); + assert.equal(snap.ageLabel, '4m ago'); +}); + +test('an enabled stale layer carries age, source, and a mechanical note', () => { + const snap = layerSnapshot({ + id: 'flights', + name: 'Live Flights', + enabled: true, + stats: { + stale: true, + count: 12, + lastUpdate: NOW - 240_000, + source: 'OpenSky Network', + }, + }, { now: NOW }); + assert.equal(snap.feedState, 'stale'); + assert.equal(snap.source, 'OpenSky Network'); + assert.equal(snap.ageSec, 240); + assert.equal(snap.ageLabel, '4m ago'); + const envelope = feedProvenanceEnvelope([snap], { now: NOW }); + assert.equal(envelope.overall, 'stale'); + assert.match(envelope.note, /Live Flights is STALE/); + assert.match(envelope.note, /OpenSky Network/); + assert.match(envelope.note, /4m ago/); + assert.match(envelope.note, /do not describe this as live/i); +}); + +test('degraded and unavailable envelopes outrank leftover nominal neighbours', () => { + const layers = layerSnapshots([ + { + id: 'earthquakes', + name: 'Earthquakes', + enabled: true, + stats: { count: 3, lastUpdate: NOW - 10_000 }, + }, + { + id: 'local-firms', + name: 'Active Fires', + enabled: true, + stats: { error: 'FIRMS key missing', count: 8, lastUpdate: NOW - 30_000 }, + }, + ], { now: NOW }); + const envelope = feedProvenanceEnvelope(layers, { now: NOW }); + assert.equal(envelope.overall, 'degraded'); + assert.match(envelope.note, /Active Fires is DEGRADED/); + assert.match(envelope.note, /FIRMS key missing/); +}); + +test('an unavailable feed with no prior data does not become a confident zero', () => { + const snap = layerSnapshot({ + id: 'ais-live-vessels', + name: 'Live Vessels', + enabled: true, + stats: { error: 'AISStream down', count: 0, lastUpdate: null }, + }, { now: NOW }); + assert.equal(snap.feedState, 'unavailable'); + const envelope = feedProvenanceEnvelope([snap]); + assert.equal(envelope.overall, 'unavailable'); + assert.match(envelope.note, /UNAVAILABLE/); +}); + +test('fallback provenance names the fallback source instead of calling it live', () => { + const snap = layerSnapshot({ + id: 'flights', + name: 'Live Flights', + enabled: true, + stats: { source: 'adsb.lol fallback', count: 40, lastUpdate: NOW - 8_000 }, + }, { now: NOW }); + assert.equal(snap.feedState, 'fallback'); + assert.match(feedProvenanceNote([snap], 'fallback'), /adsb\.lol fallback/); +}); + +test('view-state rows keep the existing identity fields and add feedState', () => { + const row = viewStateLayerRow({ + id: 'flights', + name: 'Live Flights', + enabled: true, + stats: { count: 9, lastUpdate: 42, error: null, source: 'OpenSky Network' }, + }, { now: NOW }); + assert.deepEqual(row, { + id: 'flights', + name: 'Live Flights', + enabled: true, + count: 9, + error: null, + feedState: 'nominal', + source: 'OpenSky Network', + lastUpdate: 42, + }); +}); + +test('empty roster envelope does not pretend to be nominal', () => { + const envelope = feedProvenanceEnvelope([]); + assert.equal(envelope.overall, null); + assert.match(envelope.note, /Do not invent/); +}); + +test('age labels match the Data Layers meta clock', () => { + assert.equal(snapshotAgeSec(null, NOW), null); + assert.equal(snapshotAgeLabel(NOW - 3_000, NOW), 'just now'); + assert.equal(snapshotAgeLabel(NOW - 12_000, NOW), '12s ago'); + assert.equal(snapshotAgeLabel(NOW - 240_000, NOW), '4m ago'); + assert.equal(snapshotAgeLabel(NOW - 7_200_000, NOW), '2h ago'); +}); diff --git a/src/data/manager.js b/src/data/manager.js index 7db25def4..d567678ee 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -1,5 +1,9 @@ import { governorRequestRender } from '../renderGovernor.js'; import { markDetectionSourcesChanged } from './detection.js'; +import { LAYER_FEED_STATE_LABELS, layerFeedState } from './layerSnapshot.js'; + +export { layerFeedState }; + function cloneLayerParams(value) { if (Array.isArray(value)) return value.map(cloneLayerParams); if (value && typeof value === 'object') { @@ -10,15 +14,6 @@ function cloneLayerParams(value) { return value; } -const FEED_STATE_LABELS = Object.freeze({ - nominal: 'ON', - loading: 'LOADING', - degraded: 'DEGRADED', - stale: 'STALE', - fallback: 'FALLBACK', - unavailable: 'UNAVAILABLE', -}); - const SUPERSEDED_VISIBILITY_INTENT = Symbol('superseded-visibility-intent'); const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([ 'enabled-only', @@ -64,53 +59,6 @@ function refreshFailureFromStats(stats, label) { return null; } -/** - * Normalize heterogeneous layer stats into one honest control-chip state. - * @param {object|null} stats Layer getStats() result. - * @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state. - */ -export function layerFeedState(stats = {}) { - const state = stats || {}; - const status = typeof state.status === 'string' ? state.status.toLowerCase() : ''; - const source = `${state.source || ''} ${state.coverage || ''}`; - const hasExplicitFallback = typeof state.fallback === 'boolean'; - const hasPriorData = Number(state.count) > 0 || Boolean(state.lastUpdate); - const presentedError = state.error || state.lastError || state.managerRefreshError; - if (['unavailable', 'offline', 'down', 'error'].includes(status)) return 'unavailable'; - if ( - (presentedError || state.unavailable === true || state.available === false) - && !hasPriorData - && !['zoom-in', 'empty', 'idle'].includes(status) - ) { - return 'unavailable'; - } - if (state.loading) return 'loading'; - // Guidance states ask the user to act (zoom in, run a search) — normal - // operation, not feed faults. One honesty carve-out: layers keep their - // rendered records through the guidance state, so a genuinely stale cache - // still reads STALE; a guidance prompt alone never reads DEGRADED. - if (['zoom-in', 'empty', 'idle'].includes(status)) { - return state.stale ? 'stale' : 'nominal'; - } - if ( - state.fallback === true - || status === 'fallback' - || state.mode === 'sim' - || /\bfallback\b/i.test(source) - || (!hasExplicitFallback && /\badsb\.lol\b/i.test(source)) - ) { - return 'fallback'; - } - if (state.stale || status === 'stale') return 'stale'; - if ( - state.degraded - || presentedError - || state.unavailable === true - || state.available === false - ) return 'degraded'; - return 'nominal'; -} - /** * DataLayerManager — Manages registration, toggling, and update loops * for real-time data overlays on the CesiumJS globe. @@ -2217,7 +2165,7 @@ export class DataLayerManager { _buildMetaText(layer) { const stats = layer.stats || {}; const feedState = layerFeedState(stats); - const stateLabel = FEED_STATE_LABELS[feedState]; + const stateLabel = LAYER_FEED_STATE_LABELS[feedState]; const source = stats.source || layer.source; const lifecycleState = layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled'); if (lifecycleState === 'enabling' || lifecycleState === 'disabling') { @@ -2267,7 +2215,7 @@ export class DataLayerManager { button.classList.toggle('enabling', layer.lifecycleState === 'enabling'); button.classList.toggle('disabling', layer.lifecycleState === 'disabling'); button.classList.toggle('lifecycle-uncertain', uncertain); - for (const state of Object.keys(FEED_STATE_LABELS)) { + for (const state of Object.keys(LAYER_FEED_STATE_LABELS)) { button.classList.toggle(`feed-${state}`, layer.enabled && !uncertain && feedState === state); } button.dataset.feedState = transitioning @@ -2281,7 +2229,7 @@ export class DataLayerManager { button.setAttribute('aria-busy', String(transitioning)); button.textContent = transitioning ? layer.lifecycleState.toUpperCase() - : (uncertain ? 'UNCERTAIN' : (layer.enabled ? FEED_STATE_LABELS[feedState] : 'OFF')); + : (uncertain ? 'UNCERTAIN' : (layer.enabled ? LAYER_FEED_STATE_LABELS[feedState] : 'OFF')); button.setAttribute('aria-label', `${layer.name}: ${button.textContent}`); } diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 6d887eb58..8c54f6357 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -654,15 +654,13 @@ test('the voice TOOL SCHEMA is byte-identical to main — the mission mapping is const end = src.indexOf('\n];\n', start); const block = src.slice(start, end + 4); - // Re-pinned 2026-08-28: the Provider Settings / Esri release DELIBERATELY - // extends set_map_stack's enum with 'esri-imagery' (a real new basemap — - // exactly the kind of schema change this pin exists to make loud). The - // guarded claim is unchanged: first-run missions ride existing tools, and - // any NEW drift from this recorded schema still fails here. - assert.equal(block.length, 31189, 'tool schema byte length drifted from the pinned release schema'); + // Re-pinned 2026-09-12: analyst_query and get_current_view_state descriptions + // now require feedProvenance narration. First-run missions still ride + // existing tools; any NEW drift from this recorded schema still fails here. + assert.equal(block.length, 31550, 'tool schema byte length drifted from the pinned release schema'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '73aaabdb169a5478893d28688f327a21edd32ed3ec16fc6287bd944ed77beecf', + '350a7678155a97d0ded399dc2368db8596d520bed65c5b81bebce0ee3d9a76d4', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); diff --git a/src/hud.js b/src/hud.js index 74b9c044e..6f68cfbdc 100644 --- a/src/hud.js +++ b/src/hud.js @@ -19,7 +19,7 @@ import { CITY_POIS } from './locations.js'; import { composeLocalityTag } from './hudLocality.js'; import { ellipsoidalToMslDisplayM, ensureGeoidReady, geoidHeight } from './data/geoid.js'; import { getBasemapLabelContext } from './voice/gevActions.js'; -import { isHudSummaryUnconfigured } from './hudSummaryResponse.js'; +import { isHudSummaryUnconfigured, hudSummaryLayerContext, hudTelemetryProvenanceTag } from './hudSummaryResponse.js'; /** Color palettes keyed by shader mode; applied as CSS custom properties. */ const HUD_COLORS = { @@ -587,8 +587,9 @@ export class IntelHUD { : 'N/A'; // NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector. const localityTag = composeLocalityTag(nearest, m.latDeg, m.lonDeg); - - return `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`; + const provenance = hudTelemetryProvenanceTag(this._dataManager?.getAll?.() || []); + const line = `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`; + return provenance ? `${line} | ${provenance}` : line; } /** @@ -701,14 +702,11 @@ export class IntelHUD { async _summaryContext() { const labels = await getBasemapLabelContext(this.viewer); - const enabledLayers = this._dataManager?.getAll?.() - ?.filter((layer) => layer.enabled) - .map((layer) => layer.name) || []; return { placeLabels: labels.placeLabels, streetLabels: labels.streetLabels, nearbyPlaceLabels: labels.nearbyPlaceLabels, - enabledLayerLabels: enabledLayers, + ...hudSummaryLayerContext(this._dataManager?.getAll?.() || []), }; } @@ -838,7 +836,13 @@ export class IntelHUD { this._dataManager = dataManager || null; if (typeof this._dataManager?.subscribe === 'function') { this._dataManagerUnsubscribe = this._dataManager.subscribe((change) => { - if (change?.type === 'visibility') this._markSummaryDirty(); + if ( + change?.type === 'visibility' + || change?.type === 'refresh-transition' + || change?.type === 'refresh-cancelled' + ) { + this._markSummaryDirty(); + } }); } this._markSummaryDirty(); diff --git a/src/hudSummaryResponse.js b/src/hudSummaryResponse.js index 9c516eed1..12c50c1b6 100644 --- a/src/hudSummaryResponse.js +++ b/src/hudSummaryResponse.js @@ -1,3 +1,8 @@ +import { + feedProvenanceEnvelope, + layerSnapshots, +} from './data/layerSnapshot.js'; + export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED'; /** @@ -33,3 +38,60 @@ export function isHudSummaryUnconfigured(status, data) { && data?.error === null && data?.summary === null; } + +/** + * HUD summary model instructions. The five-word line may only describe + * supplied labels, and must carry feed-state when any enabled layer is not + * nominal. + */ +export const HUD_SUMMARY_INSTRUCTIONS = [ + "Write one concise intelligence-HUD summary for God's Eye View.", + 'Use only the supplied place, street, nearby-place, enabled-layer labels, and feedProvenance.', + 'Prefer the clearest named place and include a relevant enabled layer only when useful.', + 'If any enabled layer is not nominal, the five words MUST include that feedState token (STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE) and must not present the view as live.', + 'Do not infer from coordinates or invent a place or feed state.', + 'Output exactly five words with no title, punctuation, markdown, or introductory phrase.', +].join(' '); + +/** + * Stamp HUD summary context with the same layer snapshots voice tools use. + * @param {Array} [layers] `DataLayerManager.getAll()` rows. + * @param {{ now?: number }} [options] + * @returns {{ + * enabledLayerLabels: string[], + * enabledLayers: Array<{id: string|null, name: string|null, feedState: string, source: string|null, count: number}>, + * feedProvenance: object, + * }} + */ +export function hudSummaryLayerContext(layers = [], options) { + const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled); + const envelope = feedProvenanceEnvelope(snapshots, options); + return { + enabledLayerLabels: snapshots.map((s) => s.name).filter(Boolean), + enabledLayers: snapshots.map((s) => ({ + id: s.id, + name: s.name, + feedState: s.feedState, + source: s.source, + count: s.count, + })), + feedProvenance: envelope, + }; +} + +/** + * Deterministic telemetry suffix when an enabled feed is not nominal. + * @param {Array} [layers] + * @param {{ now?: number }} [options] + * @returns {string|null} + */ +export function hudTelemetryProvenanceTag(layers = [], options) { + const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled); + const envelope = feedProvenanceEnvelope(snapshots, options); + if (!envelope.overall || envelope.overall === 'nominal') return null; + const names = envelope.layers + .filter((s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off') + .map((s) => String(s.name || s.id || 'LAYER').toUpperCase()) + .slice(0, 2); + return `${envelope.overall.toUpperCase()}${names.length ? ` ${names.join('/')}` : ''}`; +} diff --git a/src/hudSummaryResponse.test.mjs b/src/hudSummaryResponse.test.mjs index bbdc9d01a..305920828 100644 --- a/src/hudSummaryResponse.test.mjs +++ b/src/hudSummaryResponse.test.mjs @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { readFileSync } from 'node:fs'; import { + HUD_SUMMARY_INSTRUCTIONS, HUD_SUMMARY_UNCONFIGURED_CODE, + hudSummaryLayerContext, + hudTelemetryProvenanceTag, isHudSummaryUnconfigured, keylessHudSummaryResponse, } from './hudSummaryResponse.js'; @@ -128,3 +132,41 @@ test('the installed keyless HUD route stays successful after the voice quota is else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit; } }); + +test('HUD summary context and telemetry tag carry chip-identical feed-state', () => { + const now = 1_000_000; + const layers = [ + { + id: 'flights', + name: 'Live Flights', + enabled: true, + stats: { stale: true, count: 12, lastUpdate: now - 240_000, source: 'OpenSky Network' }, + }, + { + id: 'earthquakes', + name: 'Earthquakes', + enabled: false, + stats: { count: 3, lastUpdate: now }, + }, + ]; + const context = hudSummaryLayerContext(layers, { now }); + assert.deepEqual(context.enabledLayerLabels, ['Live Flights']); + assert.equal(context.enabledLayers[0].feedState, 'stale'); + assert.equal(context.feedProvenance.overall, 'stale'); + assert.equal(hudTelemetryProvenanceTag(layers, { now }), 'STALE LIVE FLIGHTS'); + assert.equal(hudTelemetryProvenanceTag([{ + id: 'flights', name: 'Live Flights', enabled: true, stats: { count: 4, lastUpdate: now }, + }], { now }), null); +}); + +test('HUD summary instructions require non-nominal feedState in the five words', () => { + assert.match(HUD_SUMMARY_INSTRUCTIONS, /five words MUST include that feedState token/); + assert.match(HUD_SUMMARY_INSTRUCTIONS, /STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/); + assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/); +}); + +test('the HUD proxy uses the shared provenance instructions', () => { + const local = readFileSync(new URL('../server/providers/local.js', import.meta.url), 'utf8'); + assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/); + assert.doesNotMatch(local, /enabled-layer text labels/); +}); diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 8d59e6787..bd09ca583 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -64,6 +64,17 @@ test('the counting contract is stated in the Realtime instructions', () => { assert.match(text, /flights layer loads where you look/, 'rule 5: the loaded-data caveat'); }); +test('the feed-state contract is stated in the Realtime instructions', () => { + const start = voice.indexOf("'FEED STATE CONTRACT"); + assert.ok(start >= 0, 'the feed-state contract instruction is missing'); + const text = voice.slice(start, voice.indexOf('\n', start)); + assert.match(text, /feedProvenance/, 'names the envelope'); + assert.match(text, /layerFeedState/, 'same classifier as the chips'); + assert.match(text, /nominal\/loading\/degraded\/stale\/fallback\/unavailable/); + assert.match(text, /NEVER narrate a count as live/); + assert.match(text, /Confirm only what the tool result returned/); +}); + test('Context panel opening stays distinct from Contacts activation', () => { const start = voice.indexOf("'For requests to open, show, reveal, or focus a menu/panel"); assert.ok(start >= 0, 'panel-routing instruction is missing'); @@ -106,6 +117,10 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { assert.match(nearest.description, /exclude on-ground records/); assert.match(nearest.description, /fallback feeds remain usable/); assert.match(nearest.description, /does not open Contacts or Cockpit/); + + const analyst = byName.get('analyst_query'); + assert.match(analyst.description, /feedProvenance/); + assert.match(analyst.description, /never describe a stale/); }); test('the two Context/Cockpit tools pin their enums and required arguments', () => { @@ -145,9 +160,12 @@ test('the edited existing tools changed exactly as intended', () => { ); assert.deepEqual(panel.parameters.required, ['panelId', 'open']); - // Edit 2: description only — the view state now reports Context and Cockpit. + // Edit 2: description only — the view state now reports Context and Cockpit + // plus honest per-layer feedState / feedProvenance. const viewState = byName.get('get_current_view_state'); assert.match(viewState.description, /Context, Cockpit/); + assert.match(viewState.description, /feedState/); + assert.match(viewState.description, /feedProvenance/); assert.deepEqual(viewState.parameters.properties, {}); // Edit 3: dependent multi-tool navigation can wait for the destination view. @@ -157,11 +175,11 @@ test('the edited existing tools changed exactly as intended', () => { }); test('no unchanged Realtime tool definition drifts silently', () => { - // Context/Cockpit parity, the dependent-location wait edit, and the retired - // `bing-road` stack leaving `set_map_stack`'s enum are the known schema - // changes. Everything else must be byte-identical: an unnoticed edit - // to a shipped tool changes - // model behavior in production with nothing in review to catch it. + // Context/Cockpit parity, the dependent-location wait edit, the retired + // `bing-road` stack leaving `set_map_stack`'s enum, and analyst_query + // feedProvenance copy are the known schema changes. Everything else must be + // byte-identical: an unnoticed edit to a shipped tool changes model behavior + // in production with nothing in review to catch it. // // If this fails and the change was deliberate, re-derive the digest and say // in the mic-test brief which tools moved — the session cache busts on any @@ -174,16 +192,17 @@ test('no unchanged Realtime tool definition drifts silently', () => { 'fly_to_location', 'select_nearest_aircraft', 'set_map_stack', + 'analyst_query', ]); const unchanged = realtimeTools() .filter((tool) => !TOUCHED.has(tool.name)) .sort((a, b) => a.name.localeCompare(b.name)); - assert.equal(unchanged.length, 21); + assert.equal(unchanged.length, 20); const digest = createHash('sha256') .update(JSON.stringify(unchanged)) .digest('hex') .slice(0, 16); - assert.equal(digest, '802ed694b8887b88', 'an unchanged Realtime tool definition drifted'); + assert.equal(digest, '5db68d7c29f6ed4f', 'an unchanged Realtime tool definition drifted'); }); test('Radio volume and mission speed share the Sharpen slider visual language', () => { diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index a56c840e7..6fe42be6e 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -9,7 +9,12 @@ import { getNextIssPass } from '../data/satellites.js'; import { CCTV_FOCUS_RESULT } from '../data/cctv.js'; import { contextModeWord } from '../contextModePolicy.js'; import { createAnalystEngine } from '../data/analystEngine.js'; -import { layerFeedState } from '../data/manager.js'; +import { + feedProvenanceEnvelope, + layerSnapshot, + layerSnapshots, + viewStateLayerRow, +} from '../data/layerSnapshot.js'; import militaryAwarenessLayer, { collectAircraftProximityWindow, contactsWindowFromSnapshot, @@ -503,10 +508,17 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene } const stats = layerModule?.getStats?.() || {}; const source = String(stats.source || layerModule?.source || '').trim() || null; - const feed = { - state: layerFeedState({ ...stats, source }), + const snap = layerSnapshot({ + id: layerId, + name: layer.label || layerId, + enabled: true, source, - count: Number.isFinite(Number(stats.count)) ? Number(stats.count) : null, + stats: { ...stats, source }, + }); + const feed = { + state: snap.feedState, + source: snap.source, + count: snap.count, }; const nearest = await createAnalystEngine(analystProviders(viewer, dataManager, { @@ -2356,6 +2368,8 @@ function normalizeLocationId(value) { function getCurrentViewState(viewer, styleManager, dataManager, sceneDirector = null) { const cartographic = Cesium.Cartographic.fromCartesian(viewer.camera.positionWC); + const layerRows = (dataManager?.getAll?.() || []).map((layer) => viewStateLayerRow(layer)); + const enabledSnapshots = layerSnapshots(dataManager?.getAll?.() || []).filter((s) => s.enabled); return { ok: true, action: 'get_current_view_state', @@ -2379,13 +2393,8 @@ function getCurrentViewState(viewer, styleManager, dataManager, sceneDirector = controls: typeof styleManager.getControlState === 'function' ? styleManager.getControlState() : null, scenePlayback: sceneDirector?.getPlaybackStatus?.() || null, tracked: collectTrackedEntities(dataManager), - layers: dataManager.getAll().map((layer) => ({ - id: layer.id, - name: layer.name, - enabled: layer.enabled, - count: layer.stats?.count || 0, - error: layer.stats?.error || null, - })), + layers: layerRows, + feedProvenance: feedProvenanceEnvelope(enabledSnapshots), }; } @@ -2482,6 +2491,7 @@ function aircraftProximityWindowForQuery(args, result) { scope: `window:${radiusKm}km@${label}`, followUp: false, note: 'Contacts window engine — the same computation and cohort the Contacts panel displays, so this count matches the panel exactly — counts cover loaded data; the flights layer loads by viewport.', + ...(result?.coverage?.feedProvenance ? { feedProvenance: result.coverage.feedProvenance } : {}), }, // (D) The answer always says whose window it is and which engine produced it. window: { @@ -2493,6 +2503,12 @@ function aircraftProximityWindowForQuery(args, result) { aircraft: window.aircraft, }, ...(activeContactsWindow() ? { contactsWindow: activeContactsWindow() } : {}), + ...(result?.coverage?.feedProvenance + ? { + feedProvenance: result.coverage.feedProvenance, + feedState: result.coverage.feedProvenance.overall, + } + : {}), }; } @@ -3310,6 +3326,47 @@ function activeContactsWindow() { } } +function provenanceForQuery(dataManager, layerKeys) { + const keys = Array.isArray(layerKeys) && layerKeys.length ? layerKeys : ['flights']; + const all = dataManager?.getAll?.() || []; + const snapshots = keys.map((key) => { + const row = all.find((layer) => layer.id === key); + if (row) return layerSnapshot(row); + const enabled = Boolean(dataManager?.isEnabled?.(key)); + return layerSnapshot({ id: key, enabled }); + }); + return feedProvenanceEnvelope(snapshots); +} + +function attachQueryProvenance(result, dataManager, args = {}) { + if (!result || typeof result !== 'object') return result; + const keys = Array.isArray(args.layers) && args.layers.length + ? args.layers + : (result.coverage?.layersQueried || []).map((row) => row.layerKey); + const envelope = provenanceForQuery(dataManager, keys); + if (result.coverage) { + result.coverage.feedProvenance = envelope; + if (Array.isArray(result.coverage.layersQueried)) { + const byId = new Map(envelope.layers.map((snap) => [snap.id, snap])); + result.coverage.layersQueried = result.coverage.layersQueried.map((row) => { + const snap = byId.get(row.layerKey); + if (!snap) return row; + return { + ...row, + feedState: snap.feedState, + source: snap.source, + lastUpdate: snap.lastUpdate, + enabled: snap.enabled, + error: snap.error, + }; + }); + } + } + result.feedProvenance = envelope; + result.feedState = envelope.overall; + return result; +} + function analystProviders(viewer, dataManager, { recordLimitByLayer = null } = {}) { return { getRecords(layerKey) { @@ -3322,6 +3379,20 @@ function analystProviders(viewer, dataManager, { recordLimitByLayer = null } = { ? (mod.getAnalystRecords(requestedLimit) || []) : (mod.getAnalystRecords() || []); }, + getLayerSnapshot(layerKey) { + const row = (dataManager.getAll?.() || []).find((layer) => layer.id === layerKey); + if (row) return layerSnapshot(row); + const enabled = Boolean(dataManager.isEnabled?.(layerKey)); + const entry = dataManager.layers?.get?.(layerKey); + const stats = entry?.module?.getStats?.() || {}; + return layerSnapshot({ + id: layerKey, + name: entry?.module?.name || layerKey, + enabled, + source: stats.source || entry?.module?.source || null, + stats, + }); + }, resolveRegionRing: (name) => resolveRegionRingForQuery(name), /** * The active Contacts subject, when there is one — the centre the operator @@ -3367,7 +3438,13 @@ async function runAnalystQuery(viewer, dataManager, args = {}) { limit: args.limit, followUp: Boolean(args.followUp), }); - if (!result.ok) return { ok: false, action: 'analyst_query', error: result.error, coverage: result.coverage }; + if (!result.ok) { + return attachQueryProvenance( + { ok: false, action: 'analyst_query', error: result.error, coverage: result.coverage }, + dataManager, + args, + ); + } // Compact payload for the voice model: identity + the fields queries sort/ // filter on. The full record set stays engine-side for follow-ups. // @@ -3411,7 +3488,7 @@ async function runAnalystQuery(viewer, dataManager, args = {}) { // arbitrary points — only "how many aircraft around " is // unified, because that is the question the panel is already answering. const entityWindow = aircraftProximityWindowForQuery(args, result); - if (entityWindow) return entityWindow; + if (entityWindow) return attachQueryProvenance(entityWindow, dataManager, args); const contactsWindow = activeContactsWindow(); const aircraftQueried = (result.coverage?.layersQueried || []) @@ -3432,7 +3509,7 @@ async function runAnalystQuery(viewer, dataManager, args = {}) { + `"how many aircraft are nearby". This query measured something else — ${result.count} ${result.scopeLabel}. ` + 'Give this one only if the operator asked about that specific area, and name both scopes if you give both.' : null; - return { + return attachQueryProvenance({ ok: true, action: 'analyst_query', count: result.count, @@ -3455,5 +3532,5 @@ async function runAnalystQuery(viewer, dataManager, args = {}) { } : {}), ...(countsReconciliation ? { countsReconciliation } : {}), - }; + }, dataManager, args); } diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 0f6baa651..bb1c0abbd 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -732,6 +732,9 @@ test('Data Layers voice inventory hides the Context coordinator while current-vi assert.deepEqual(menu.layers.map(({ id }) => id), ['flights']); const current = await runner('get_current_view_state'); assert.deepEqual(current.layers.map(({ id }) => id), ['flights', 'military-awareness']); + assert.equal(current.layers.find(({ id }) => id === 'flights').feedState, 'off'); + assert.equal(current.layers.find(({ id }) => id === 'military-awareness').feedState, 'nominal'); + assert.equal(current.feedProvenance.overall, 'nominal'); // The Contacts mode's internal id is 'flights'; the tools accept 'contacts'. // State output reports the accepted word so the model cannot read its own // active context as "off", with the internal id kept for layer reasoning. @@ -3020,3 +3023,66 @@ test('front5: 0.99 km due EAST is the subject, though a degree box rejects it', assert.equal(result.window.centeredOn, 'N546PC'); }); }); + +test('analyst_query and get_current_view_state carry stale feed provenance', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const now = Date.now(); + const flights = { + id: 'flights', + source: 'OpenSky Network', + getStats: () => ({ + source: 'OpenSky Network', + stale: true, + count: 12, + lastUpdate: now - 240_000, + }), + getAnalystRecords: () => ([ + { id: 'SWA1', icao24: 'aaa001', lat: 30.27, lon: -97.74, altitudeM: 11000, onGround: false }, + ]), + }; + const viewer = { + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, + camera: { + moveEnd: { addEventListener() {} }, + positionWC: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 1000), + positionCartographic: { height: 300_000, latitude: 0.52, longitude: -1.71 }, + }, + }; + const dataManager = { + layers: new Map([['flights', { module: flights }]]), + isEnabled: (id) => id === 'flights', + getAll: () => [{ + id: 'flights', + name: 'Live Flights', + enabled: true, + source: 'OpenSky Network', + stats: flights.getStats(), + }], + }; + const runner = createGevActionRunner({ + viewer, + styleManager: { + activeStyle: 'normal', + getContextModeState: () => ({ mode: null, active: false }), + getCockpitState: () => ({ active: false }), + getControlState: () => null, + }, + dataManager, + }); + const view = await runner('get_current_view_state'); + assert.equal(view.layers[0].feedState, 'stale'); + assert.equal(view.feedProvenance.overall, 'stale'); + assert.match(view.feedProvenance.note, /STALE/); + + const query = await runner('analyst_query', { + layers: ['flights'], + scope: { kind: 'view' }, + limit: 5, + }); + assert.equal(query.ok, true); + assert.equal(query.feedState, 'stale'); + assert.equal(query.feedProvenance.overall, 'stale'); + assert.equal(query.coverage.layersQueried[0].feedState, 'stale'); + assert.match(query.feedProvenance.note, /do not describe this as live/i); +}); From 096f533efec73e52e42869ee2c0d9abcd1dfd0c1 Mon Sep 17 00:00:00 2001 From: Rehaan Delmotra <208644523+rDelmotra@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:49:48 +0530 Subject: [PATCH 03/11] feat(satellites): precise pass rise/set and peak times - Add satellitePass.js: bisected rise/set, parabolic peak fit - Add getNextSatellitePass for any loaded catalog satellite - Keep findNextIssPass as a wrapper --- CHANGELOG.md | 5 + docs/CURRENT-STATE.md | 2 +- scripts/package-boundaries.json | 2 + src/data/issPass.js | 93 ++++------ src/data/issPass.test.mjs | 8 + src/data/satellitePass.js | 237 ++++++++++++++++++++++++++ src/data/satellitePass.test.mjs | 106 ++++++++++++ src/data/satellites.js | 1 + src/layers/satellites/index.js | 1 + src/layers/satellites/orbits.js | 45 ++++- src/layers/satellites/source.test.mjs | 90 ++++++++++ 11 files changed, 526 insertions(+), 64 deletions(-) create mode 100644 src/data/satellitePass.js create mode 100644 src/data/satellitePass.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a30caf1..ba56328a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ - Split application scene, controls, catalog, tools and HTML into reusable components; configure application request services and sources without changing global fetch. Preserve standalone markup and voice behavior. Explicit annotation navigation may resolve a distant named target. +## Satellite pass prediction + +- Bisect pass rise/set to ~0.2 s and fit peak elevation with a parabola. +- Add `getNextSatellitePass(noradId, options)` for any loaded catalog satellite. + ## Voice component boundaries - Separate voice controls, Realtime connection requests and the action runner. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7bc10963a..9f044af4a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2714,7 +2714,7 @@ silently demoting every later lookup for the session. `activeCameraCardEnabled` presentation option can publish the retained protected-card path. Disable tears the tier down completely. Coverage/viewshed semantics unchanged. -- **Satellites**: orbit rings rotate about Earth's Z by ΔGMST every ~1s (exact inertial→ECEF compensation; no SGP4 re-runs); the tracked satellite propagates per frame with one shared epoch for dot/label/camera. Verified: ISS holds <1km perpendicular to its ring while tracked. +- **Satellites**: orbit rings rotate about Earth's Z by ΔGMST every ~1s (exact inertial→ECEF compensation; no SGP4 re-runs); the tracked satellite propagates per frame with one shared epoch for dot/label/camera. Verified: ISS holds <1km perpendicular to its ring while tracked. Pass prediction (`src/data/satellitePass.js`; `getNextSatellitePass`, `getNextIssPass`) scans at 20s, bisects rise/set to ~0.2s, and fits the peak with a parabola. - **Space Missions (30d)**: recent launches render as bounded shared-host, horizon-occluded mission markers using Launch Library 2 v2.3 detailed records. Enabling the layer selects the unified right-side Context panel's Space Missions mode and enables the required satellite layer. Before applying its temporary dense/hidden Satellite mode, Space Missions snapshots the complete standalone Satellite parameter set and exact enabled-layer set. Disabling Space Missions from either Context or the left Data Layers rail restores those parameters and the exact prior enabled state, so a Satellite layer that was already on stays on while a mode-owned dependency returns off; enabling Satellites by itself remains independent. Selecting a mission isolates its launch-to-orbit transfer and dashed satellite orbit, fills that same panel with navigation/details, and animates a small phase-colored marker along the exact displayed Cartesian samples. Marker hit testing drill-picks through photorealistic tiles so the depth-test-free tactical dot remains reliably selectable; its text is non-interactive shared-host presentation. The selected pad is the camera's zoom pivot: the overview remains centered on its launch site, wheel zoom approaches that site instead of drifting elsewhere, and camera pitch progressively changes from global nadir to an oblique close 3D view. Its protected shared-host callout remains visible and expands to include both mission and launch-site names. `FOCUS` flies directly to a 12 km oblique frame around the selected launch site and retains the same anchored zoom/orbit behavior. `REPLAY ASCENT` resets the selected marker at the pad, frames it from an oblique third-person angle, and follows it through the mission-specific compressed ascent directly into one orbital lap at the default `1×` rate. A live `0.25×`–`4×` slider changes ascent and orbit playback speed; adjusting it mid-replay preserves the current path position and historical mission timestamp. Re-entry/recovery cannot be inserted into replay. At orbit insertion the camera smoothly pulls back over the first fifth of the orbital replay and pitches to a globe-scale nadir view while continuing to target the moving replay point. Replay Cancel, mission navigation, deselection, layer disable, and data refresh all release camera ownership. Reconstructed paths are one continuous 128-sample geodetic curve: horizontal departure begins near zero while altitude rises quickly, then the climb progressively bends toward insertion without the former hard 120 km corner. Unmatched orbit fallbacks are smooth planar inclined rings rather than longitude/latitude ground-track curves. The panel lists disclosed payload names, types, operators/manufacturers, mass, multiplicity, and destination when supplied; an empty LL2 payload collection is shown as `CLASSIFIED / MULTI-PAYLOAD`. Launcher, spacecraft, and recoverable payload stages appear in a compact stage table with serial/flight/reuse details, recovery outcome/type, destination, and final coordinates when those records exist; an empty recovery collection omits the section. Stage recoveries with confirmed coordinates use those coordinates; return-to-launch-site records use the pad; downrange-only records receive an explicitly labeled estimated endpoint along the ascent azimuth. Available endpoints render as static 2 px dashed descent/recovery paths with a fixed final-position dot and an estimated atmospheric-interface segment when applicable. The ascent is geodetically densified above the ellipsoid toward the orbit's nearest insertion point, then rendered with `ArcType.NONE`, so it neither cuts through Earth nor separates from the marker. Because LL2 does not normally supply continuous ascent telemetry, pad-to-insertion paths without upstream trajectory samples are labeled `RECONSTRUCTED ESTIMATE` / `ASCENT ESTIMATE`; only supplied trajectory samples receive the replay wording. Selected-orbit framing fits the complete ring, rear-side linework uses normal scene depth occlusion, and current distance, speed, and callout data come only from a launch-year-validated satellite match. Speed is the magnitude of the SGP4 inertial velocity vector at the same propagation epoch as position, displayed in km/s with km/h available as hover detail. Unavailable operator, site, launch-time, orbit, current-altitude, and speed values omit their detail rows instead of reserving panel space with placeholders. Newly launched payloads absent from the core operational groups use CelesTrak's cached active TLE feed as a lookup-only fallback; weak constellation-name matches are rejected. The replay callout maps compressed animation progress onto Launch Library's mission-relative timeline, showing the historical UTC date/time at the marker's current path position; unavailable timelines remain explicit. Matched live satellite positions propagate at one-second cadence and use a distinct green dot/callout with the current UTC date/time. When no mission is selected, the Context panel presents a scrollable newest-first roster of every launch in the rolling window, including the smaller 5 px operator-colored marker, provider, and launch date. Hovering or keyboard-focusing a row shows four compact cyan corner brackets on both that roster row and its corresponding globe dot, rotates the globe at the current zoom to center it, and gives its label declutter priority without selecting it; the globe label remains unbracketed. Selecting a roster row invokes the same mission isolation and full-orbit framing as clicking its globe marker. The replay vehicle is one screen-space SVG/CSS HUD overlay rather than separate Cesium billboard, label, and reticle graphics. It is hidden during ordinary Focus and manual close views, where the standard selected launch-site label remains visible, and exists only while ascent replay is active. Its fixed pixel scale is shared by ascent and insertion, so camera range never resizes the rocket on screen before the phase boundary. Generic Launch Library pad names are reduced to their identifying suffix, and replay timestamps use a cyan state title over unprefixed white UTC date/time values. `REPLAY ASCENT` holds the unframed cyan/white rocket at the pad for a real-time `T−10` countdown, transitions through `LIFTOFF`, and attaches six tapered cyan/white ellipse waves directly below it from liftoff through insertion to convey thrust without adding scene geometry. While replay is active, the single start button is replaced by compact Play, Pause, and Cancel icon controls. Pause freezes countdown or mission time, vehicle/stage positions, camera target, labels, and thrust-wave animation; Play resumes from that exact frame, and Cancel releases replay camera ownership. The rocket and thrust group rotates from the path's live screen-space tangent, so its nose follows the visible ascent curve while the adjacent text remains upright. The camera begins as a close oblique launch chase, then smoothly widens between roughly 120 and 420 km vehicle altitude into a higher oblique context view that keeps the moving rocket targeted while exposing the ascent bend and orbit connection. At insertion the rocket/thrust glyph is replaced by the fixed-size cyan orbit dot, which the camera follows through the existing globe-scale orbit pullback. The chase camera limits per-frame yaw changes across heading wraps so it cannot abruptly cross in front of the vehicle and make ascent read in reverse; the replay-speed slider affects mission playback but not countdown duration. Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index fb9469e52..808260be4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -729,6 +729,7 @@ "modules": [ "src/data/issPass.js", "src/data/satelliteClass.js", + "src/data/satellitePass.js", "src/layers/satellites/catalog.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", @@ -1143,6 +1144,7 @@ "src/data/rocketLaunches.js", "src/data/routePlausible.js", "src/data/satelliteClass.js", + "src/data/satellitePass.js", "src/data/satellites.js", "src/data/scenePick.js", "src/data/spriteOrder.js", diff --git a/src/data/issPass.js b/src/data/issPass.js index 188e85835..416355a0c 100644 --- a/src/data/issPass.js +++ b/src/data/issPass.js @@ -1,66 +1,43 @@ // src/data/issPass.js /** - * Next-ISS-pass prediction from a satrec: coarse 30 s scan to find a window - * where elevation ≥ minElevDeg, refined to ~5 s at the edges, tracking peak - * elevation. ~2880 SGP4 propagations for a 24 h horizon — tens of ms, fine for - * an on-demand voice call. Pattern from skylight (MIT) shared/src/celestial.ts - * nextISSPass, extended with set-time + peak tracking. + * Next-ISS-pass prediction. Kept as a thin wrapper over satellitePass.js so the + * original findNextIssPass signature and defaults still work. */ -import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js'; +import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js'; -const R2D = 180 / Math.PI; -const D2R = Math.PI / 180; - -/** Observer look angles at an instant, or null when propagation fails. */ -export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) { - const date = new Date(dateMs); - const pv = propagate(satrec, date); - const pos = pv && pv.position; - if (!pos || typeof pos === 'boolean') return null; - const ecf = eciToEcf(pos, gstime(date)); - const look = ecfToLookAngles( - { latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 }, - ecf, - ); - return { - elevDeg: look.elevation * R2D, - azDeg: (((look.azimuth * R2D) % 360) + 360) % 360, - }; -} +export { lookAnglesAt }; +/** + * Predict the next ISS pass over an observer location. + * @param {Object} options + * @param {Object} options.satrec SGP4 satellite record + * @param {number} options.latDeg Observer latitude in degrees [-90, 90] + * @param {number} options.lonDeg Observer longitude in degrees [-180, 180] + * @param {number} options.fromMs UTC start time in milliseconds + * @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees + * @param {number} [options.horizonHours=24] Maximum search window in hours + * @param {number} [options.coarseStepSec=30] Coarse search step in seconds + * @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking + * @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number } | null} + */ export function findNextIssPass({ - satrec, latDeg, lonDeg, fromMs, - minElevDeg = 10, horizonHours = 24, coarseStepSec = 30, fineStepSec = 5, + satrec, + latDeg, + lonDeg, + fromMs, + minElevDeg = 10, + horizonHours = 24, + coarseStepSec = 30, + fineStepSec = 5, }) { - const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90; - const horizonMs = fromMs + horizonHours * 3600_000; - const coarse = coarseStepSec * 1000; - const fine = fineStepSec * 1000; - - // Coarse scan for the first sample above threshold. If we START inside a - // pass, that's still "the next pass" for a voice answer — accept it. - let hit = null; - for (let t = fromMs; t <= horizonMs; t += coarse) { - if (elev(t) >= minElevDeg) { hit = t; break; } - } - if (hit == null) return null; - - // Refine rise: walk back in fine steps to the first sample ≥ threshold. - let riseMs = hit; - while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg) riseMs -= fine; - - // Walk forward through the pass tracking the peak until we drop below. - let maxElevDeg = -90; - let maxElevMs = riseMs; - let t = riseMs; - while (t <= horizonMs) { - const e = elev(t); - if (e < minElevDeg && t > riseMs) break; - if (e > maxElevDeg) { maxElevDeg = e; maxElevMs = t; } - t += fine; - } - const setMs = t; - - const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg); - return { riseMs, setMs, maxElevDeg, maxElevMs, riseAzDeg: rise ? rise.azDeg : 0 }; + return findNextSatellitePass({ + satrec, + latDeg, + lonDeg, + fromMs, + minElevDeg, + horizonHours, + coarseStepSec, + fineStepSec, + }); } diff --git a/src/data/issPass.test.mjs b/src/data/issPass.test.mjs index 345f8c4a0..aaaa87a55 100644 --- a/src/data/issPass.test.mjs +++ b/src/data/issPass.test.mjs @@ -3,6 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { twoline2satrec } from 'satellite.js'; import { findNextIssPass, lookAnglesAt } from './issPass.js'; +import { findNextSatellitePass } from './satellitePass.js'; // Canonical archived ISS TLE (valid checksums; epoch 2008-09-20 ~12:25 UTC). const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927'; @@ -27,6 +28,13 @@ test('finds a structurally-consistent ISS pass within 24h of the TLE epoch', () assert.ok(before && before.elevDeg < 10); }); +test('forwards its original 30 s coarse step and fineStepSec to the pass engine', () => { + const satrec = twoline2satrec(L1, L2); + const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10, fineStepSec: 30 }; + assert.deepEqual(findNextIssPass(opts), findNextSatellitePass({ ...opts, coarseStepSec: 30 })); + assert.notDeepEqual(findNextIssPass(opts), findNextIssPass({ ...opts, fineStepSec: 5 })); +}); + test('returns null when no pass clears an absurd threshold', () => { const satrec = twoline2satrec(L1, L2); const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 89.9, horizonHours: 2 }); diff --git a/src/data/satellitePass.js b/src/data/satellitePass.js new file mode 100644 index 000000000..c7fa54cde --- /dev/null +++ b/src/data/satellitePass.js @@ -0,0 +1,237 @@ +// src/data/satellitePass.js +/** + * Satellite pass prediction. + * Look angles come from SGP4; rise/set are bisected to sub-second precision + * and the peak is refined with a parabola. Scan pattern from skylight (MIT) + * shared/src/celestial.ts nextISSPass. + */ +import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js'; + +const R2D = 180 / Math.PI; +const D2R = Math.PI / 180; + +/** + * Observer look angles at an instant, or null when propagation fails. + * @param {Object} satrec SGP4 satellite record + * @param {number} dateMs UTC epoch timestamp in milliseconds + * @param {number} latDeg Observer latitude in degrees [-90, 90] + * @param {number} lonDeg Observer longitude in degrees [-180, 180] + * @returns {{ elevDeg: number, azDeg: number } | null} + */ +export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) { + const date = new Date(dateMs); + const pv = propagate(satrec, date); + const pos = pv && pv.position; + if (!pos || typeof pos === 'boolean') return null; + const ecf = eciToEcf(pos, gstime(date)); + const look = ecfToLookAngles( + { latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 }, + ecf, + ); + return { + elevDeg: look.elevation * R2D, + azDeg: (((look.azimuth * R2D) % 360) + 360) % 360, + }; +} + +/** + * Bisect the elevation threshold boundary f(t) = elev(t) - minElevDeg = 0. + * Assumes one threshold crossing inside the bracket. 7 iterations resolve 20s to ~0.16s. + * @private + */ +function _bisectBoundary( + satrec, + latDeg, + lonDeg, + tLow, + tHigh, + minElevDeg, + isRising, + steps = 7, +) { + let low = tLow; + let high = tHigh; + for (let i = 0; i < steps; i++) { + const mid = (low + high) / 2; + const look = lookAnglesAt(satrec, mid, latDeg, lonDeg); + const elev = look ? look.elevDeg : -90; + if (isRising) { + if (elev >= minElevDeg) { + high = mid; + } else { + low = mid; + } + } else { + if (elev >= minElevDeg) { + low = mid; + } else { + high = mid; + } + } + } + return Math.round(isRising ? high : low); +} + +/** + * Find the next pass of a satellite over an observer location. + * Uses 20s coarse scanning, bisection for sub-second rise/set times, + * and parabolic interpolation for the peak. + * + * @param {Object} options + * @param {Object} options.satrec SGP4 satellite record + * @param {number} options.latDeg Observer latitude [-90, 90] + * @param {number} options.lonDeg Observer longitude [-180, 180] + * @param {number} options.fromMs UTC start time in milliseconds + * @param {number} [options.minElevDeg=10] Elevation that defines rise and set (default 10°) + * @param {number} [options.horizonHours=24] Maximum search horizon in hours (default 24h) + * @param {number} [options.coarseStepSec=20] Coarse scan step in seconds (default 20s) + * @param {number} [options.fineStepSec=5] Fine transit step in seconds (default 5s) + * @returns {{ + * riseMs: number, + * setMs: number, + * maxElevDeg: number, + * maxElevMs: number, + * riseAzDeg: number + * } | null} + */ +export function findNextSatellitePass({ + satrec, + latDeg, + lonDeg, + fromMs, + minElevDeg = 10, + horizonHours = 24, + coarseStepSec = 20, + fineStepSec = 5, +}) { + const horizonMs = fromMs + horizonHours * 3600_000; + const coarseMs = coarseStepSec * 1000; + const fineMs = Math.max(1000, (fineStepSec || 5) * 1000); + + let searchCursor = fromMs; + + while (searchCursor <= horizonMs) { + let tPrev = searchCursor; + let tHit = null; + let elevPrev = lookAnglesAt(satrec, tPrev, latDeg, lonDeg)?.elevDeg ?? -90; + + // Check if we start already inside a pass + if (elevPrev >= minElevDeg) { + tHit = searchCursor; + } else { + // Coarse forward scan + for (let t = searchCursor + coarseMs; t <= horizonMs; t += coarseMs) { + const look = lookAnglesAt(satrec, t, latDeg, lonDeg); + const elev = look ? look.elevDeg : -90; + if (elev >= minElevDeg) { + tHit = t; + break; + } + tPrev = t; + elevPrev = elev; + } + } + + if (tHit == null) return null; // No pass found in remaining horizon + + // Resolve rise + let riseMs; + if (tHit === searchCursor && elevPrev >= minElevDeg) { + riseMs = searchCursor; + } else { + riseMs = _bisectBoundary( + satrec, + latDeg, + lonDeg, + tPrev, + tHit, + minElevDeg, + true, + ); + } + + // Transit the pass to track peak and bracket set between tStepPrev and tCurr + let tCurr = riseMs; + let maxElevDeg = -90; + let maxElevMs = riseMs; + let tStepPrev = riseMs; + let setMs = null; + let elevPrevSample = null; + let peakLeftElev = null; + let peakRightElev = null; + let peakStepMs = fineMs; + + // Transit forward in fine steps + while (tCurr <= horizonMs) { + const look = lookAnglesAt(satrec, tCurr, latDeg, lonDeg); + const elev = look ? look.elevDeg : -90; + + if (elev < minElevDeg && tCurr > riseMs) { + if (peakRightElev == null && tCurr > maxElevMs) { + peakRightElev = elev; + peakStepMs = tCurr - maxElevMs; + } + // Below threshold: bisect set between tStepPrev and tCurr + setMs = _bisectBoundary( + satrec, + latDeg, + lonDeg, + tStepPrev, + tCurr, + minElevDeg, + false, + ); + break; + } + + if (elev > maxElevDeg) { + maxElevDeg = elev; + maxElevMs = tCurr; + peakLeftElev = elevPrevSample; + peakRightElev = null; + } else if (peakRightElev == null && tCurr > maxElevMs) { + peakRightElev = elev; + peakStepMs = tCurr - maxElevMs; + } + + elevPrevSample = elev; + tStepPrev = tCurr; + tCurr += fineMs; + } + + if (setMs == null) { + setMs = Math.min(horizonMs, tCurr); + } + + // Refine peak culmination via 3-point parabolic interpolation around maxElevMs + // using adjacent transit samples (zero additional SGP4 calls) + let refinedPeakMs = maxElevMs; + let refinedPeakElevDeg = maxElevDeg; + if ( + Number.isFinite(peakLeftElev) && + Number.isFinite(peakRightElev) && + peakLeftElev - 2 * maxElevDeg + peakRightElev < 0 // concave down + ) { + const denom = 2 * (peakLeftElev - 2 * maxElevDeg + peakRightElev); + const shift = ((peakLeftElev - peakRightElev) / denom) * peakStepMs; + if (Math.abs(shift) < peakStepMs) { + refinedPeakMs = Math.round(maxElevMs + shift); + refinedPeakElevDeg = + maxElevDeg - + Math.pow(peakLeftElev - peakRightElev, 2) / + (8 * (peakLeftElev - 2 * maxElevDeg + peakRightElev)); + } + } + + const riseLook = lookAnglesAt(satrec, riseMs, latDeg, lonDeg); + return { + riseMs, + setMs, + maxElevDeg: refinedPeakElevDeg, + maxElevMs: refinedPeakMs, + riseAzDeg: riseLook ? riseLook.azDeg : 0, + }; + } + + return null; +} diff --git a/src/data/satellitePass.test.mjs b/src/data/satellitePass.test.mjs new file mode 100644 index 000000000..1739c0570 --- /dev/null +++ b/src/data/satellitePass.test.mjs @@ -0,0 +1,106 @@ +// src/data/satellitePass.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { twoline2satrec } from 'satellite.js'; +import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js'; + +// Canonical archived ISS TLE (epoch 2008-09-20 ~12:25 UTC). +const L1 = + '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927'; +const L2 = + '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537'; +const AUSTIN = { latDeg: 30.2672, lonDeg: -97.7431 }; +const FROM_MS = Date.UTC(2008, 8, 20, 12, 30, 0); + +test('findNextSatellitePass resolves pass boundaries with sub-second precision', () => { + const satrec = twoline2satrec(L1, L2); + const pass = findNextSatellitePass({ + satrec, + ...AUSTIN, + fromMs: FROM_MS, + minElevDeg: 10, + }); + assert.ok(pass, 'expected a pass within 24h at Austin for ISS'); + assert.ok(pass.riseMs > FROM_MS); + assert.ok(pass.riseMs < pass.maxElevMs && pass.maxElevMs < pass.setMs); + assert.ok(pass.maxElevDeg >= 10); + assert.ok(pass.riseAzDeg >= 0 && pass.riseAzDeg < 360); + + // Verify bisection accuracy at rise time: + const beforeRise = lookAnglesAt( + satrec, + pass.riseMs - 1000, + AUSTIN.latDeg, + AUSTIN.lonDeg, + ); + const atRise = lookAnglesAt( + satrec, + pass.riseMs, + AUSTIN.latDeg, + AUSTIN.lonDeg, + ); + const afterRise = lookAnglesAt( + satrec, + pass.riseMs + 1000, + AUSTIN.latDeg, + AUSTIN.lonDeg, + ); + assert.ok( + beforeRise && beforeRise.elevDeg < 10.05, + 'elevation before riseMs must be below threshold', + ); + assert.ok( + atRise && Math.abs(atRise.elevDeg - 10) < 0.2, + 'elevation at riseMs must be within 0.2° of 10°', + ); + assert.ok( + afterRise && afterRise.elevDeg > 9.95, + 'elevation after riseMs must be rising above threshold', + ); + + // Verify bisection accuracy at set time: + const beforeSet = lookAnglesAt( + satrec, + pass.setMs - 1000, + AUSTIN.latDeg, + AUSTIN.lonDeg, + ); + const atSet = lookAnglesAt(satrec, pass.setMs, AUSTIN.latDeg, AUSTIN.lonDeg); + const afterSet = lookAnglesAt( + satrec, + pass.setMs + 1000, + AUSTIN.latDeg, + AUSTIN.lonDeg, + ); + assert.ok( + beforeSet && beforeSet.elevDeg > 9.95, + 'elevation before setMs must be above threshold', + ); + assert.ok( + atSet && Math.abs(atSet.elevDeg - 10) < 0.2, + 'elevation at setMs must be within 0.2° of 10°', + ); + assert.ok( + afterSet && afterSet.elevDeg < 10.05, + 'elevation after setMs must be below threshold', + ); + + // Parabolic culmination accuracy: max elevation must be peak + assert.ok(pass.maxElevDeg >= atRise.elevDeg); + assert.ok(pass.maxElevDeg >= atSet.elevDeg); +}); + +test('findNextSatellitePass samples the peak at fineStepSec', () => { + const satrec = twoline2satrec(L1, L2); + const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10 }; + const fine = findNextSatellitePass({ ...opts, fineStepSec: 1 }); + const coarse = findNextSatellitePass({ ...opts, fineStepSec: 10 }); + assert.ok(fine && coarse); + // The step is used: peak samples differ. + assert.notEqual(fine.maxElevMs, coarse.maxElevMs); + // Rise comes from bisection, so it does not depend on the step. + assert.ok(Math.abs(fine.riseMs - coarse.riseMs) < 1000); + // Parabolic refinement keeps 10s sampling close to the 1s peak. + assert.ok(Math.abs(fine.maxElevMs - coarse.maxElevMs) < 1000); + assert.ok(Math.abs(fine.maxElevDeg - coarse.maxElevDeg) < 0.01); +}); diff --git a/src/data/satellites.js b/src/data/satellites.js index 11093ec13..9b8c6234d 100644 --- a/src/data/satellites.js +++ b/src/data/satellites.js @@ -51,6 +51,7 @@ export const _clearSatelliteLabelLifecycleForTest = export const applySatellitePointFocusDeemphasis = layer.applySatellitePointFocusDeemphasis; export const getNextIssPass = layer.getNextIssPass; +export const getNextSatellitePass = layer.getNextSatellitePass; export const scoreSatelliteNameMatch = layer.scoreSatelliteNameMatch; export const findSatelliteOrbitTrackInTle = layer.findSatelliteOrbitTrackInTle; export const getSatelliteOrbitTrack = layer.getSatelliteOrbitTrack; diff --git a/src/layers/satellites/index.js b/src/layers/satellites/index.js index 7bec672c0..1d613b014 100644 --- a/src/layers/satellites/index.js +++ b/src/layers/satellites/index.js @@ -63,6 +63,7 @@ export function createSatellitesLayer({ services, source }) { applySatellitePointFocusDeemphasis: parts.rendering.applySatellitePointFocusDeemphasis, getNextIssPass: parts.orbits.getNextIssPass, + getNextSatellitePass: parts.orbits.getNextSatellitePass, scoreSatelliteNameMatch: parts.orbits.scoreSatelliteNameMatch, findSatelliteOrbitTrackInTle: parts.orbits.findSatelliteOrbitTrackInTle, getSatelliteOrbitTrack: parts.orbits.getSatelliteOrbitTrack, diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js index 943e5af14..306497937 100644 --- a/src/layers/satellites/orbits.js +++ b/src/layers/satellites/orbits.js @@ -7,7 +7,7 @@ import { degreesLat, twoline2satrec, } from 'satellite.js'; -import { findNextIssPass } from '../../data/issPass.js'; +import { findNextSatellitePass } from '../../data/satellitePass.js'; import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js'; export function createOrbits({ state: layerState, services, parts, source }) { @@ -136,17 +136,51 @@ export function createOrbits({ state: layerState, services, parts, source }) { /** * Next ISS pass for an observer. Requires the catalog to have loaded (the * satellites layer enabled at least once this session). + * @param {Object} options + * @param {number} options.latDeg Observer latitude + * @param {number} options.lonDeg Observer longitude + * @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees * @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number}}} */ - function getNextIssPass({ latDeg, lonDeg, minElevDeg = 10 }) { - const sat = layerState._catalog.get(ISS_NORAD); + function getNextIssPass({ + latDeg, + lonDeg, + minElevDeg = 10, + fromMs = Date.now(), + }) { + return getNextSatellitePass(ISS_NORAD, { + latDeg, + lonDeg, + minElevDeg, + fromMs, + }); + } + + /** + * Next pass of any catalog satellite for an observer. + * @param {number} noradId NORAD satellite catalog ID + * @param {Object} options + * @param {number} options.latDeg Observer latitude + * @param {number} options.lonDeg Observer longitude + * @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees + * @param {number} [options.fromMs=Date.now()] Search start time in UTC milliseconds + * @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:Object}} + */ + + function getNextSatellitePass( + noradId, + { latDeg, lonDeg, minElevDeg = 10, fromMs = Date.now() }, + ) { + const id = Number(noradId); + if (!Number.isFinite(id)) return { status: 'no-tle' }; + const sat = layerState._catalog.get(id); if (!sat || !sat.satrec) return { status: 'no-tle' }; - const pass = findNextIssPass({ + const pass = findNextSatellitePass({ satrec: sat.satrec, latDeg, lonDeg, - fromMs: Date.now(), + fromMs, minElevDeg, }); return pass ? { status: 'ok', pass } : { status: 'none' }; @@ -316,6 +350,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { orbitalPeriodSeconds, computeOrbitPath, getNextIssPass, + getNextSatellitePass, scoreSatelliteNameMatch, internationalDesignatorYear, lookupTleEntries, diff --git a/src/layers/satellites/source.test.mjs b/src/layers/satellites/source.test.mjs index a44ad565b..61913263c 100644 --- a/src/layers/satellites/source.test.mjs +++ b/src/layers/satellites/source.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { twoline2satrec } from 'satellite.js'; import { createSatelliteSource } from './source.js'; import { createSatellitesLayer } from './index.js'; @@ -56,3 +57,92 @@ test('satellite factories keep control state separate and construct without requ assert.equal(second.getParams().showPoints, true); assert.notEqual(first.getStats(), second.getStats()); }); + +test('satellite pass prediction methods report no-tle on cold catalog and coerce IDs', () => { + const source = { readGroup() {} }; + const services = Object.fromEntries( + [ + 'picking', + 'focus', + 'readout', + 'overlays', + 'context', + 'render', + 'layerState', + ].map((key) => [key, {}]), + ); + services.layerState.isExplicitLayerStateOrigin = () => false; + const layer = createSatellitesLayer({ source, services }); + + // Cold catalog (no satellites loaded yet) + assert.deepEqual(layer.getNextIssPass({ latDeg: 30, lonDeg: -97 }), { + status: 'no-tle', + }); + assert.deepEqual( + layer.getNextSatellitePass(25544, { latDeg: 30, lonDeg: -97 }), + { status: 'no-tle' }, + ); + + // String NORAD ID coercion + assert.deepEqual( + layer.getNextSatellitePass('25544', { latDeg: 30, lonDeg: -97 }), + { status: 'no-tle' }, + ); + + // Invalid / non-finite ID handling + assert.deepEqual( + layer.getNextSatellitePass(NaN, { latDeg: 30, lonDeg: -97 }), + { status: 'no-tle' }, + ); + assert.deepEqual( + layer.getNextSatellitePass(null, { latDeg: 30, lonDeg: -97 }), + { status: 'no-tle' }, + ); + + // Populated catalog: verify numeric and string coercion succeed + const L1 = + '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927'; + const L2 = + '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537'; + const satrec = twoline2satrec(L1, L2); + layer._setTrackedSatelliteRefreshStateForTest({ + noradId: 25544, + name: 'ISS (ZARYA)', + satrec, + }); + + const FROM_MS = 1221868800000; + const numericResult = layer.getNextSatellitePass(25544, { + latDeg: 30.2672, + lonDeg: -97.7431, + fromMs: FROM_MS, + }); + assert.equal(numericResult.status, 'ok'); + assert.ok(numericResult.pass); + + const stringResult = layer.getNextSatellitePass('25544', { + latDeg: 30.2672, + lonDeg: -97.7431, + fromMs: FROM_MS, + }); + assert.equal(stringResult.status, 'ok'); + assert.deepEqual(stringResult, numericResult); + + const issResult = layer.getNextIssPass({ + latDeg: 30.2672, + lonDeg: -97.7431, + fromMs: FROM_MS, + }); + assert.equal(issResult.status, 'ok'); + assert.deepEqual(issResult, numericResult); + + // Non-existent satellite ID in populated catalog + assert.deepEqual( + layer.getNextSatellitePass(99999, { + latDeg: 30.2672, + lonDeg: -97.7431, + fromMs: FROM_MS, + }), + { status: 'no-tle' }, + ); +}); From ac4a17bebe35618a410478ddbbf0371be1aa8172 Mon Sep 17 00:00:00 2001 From: Rehaan Delmotra <208644523+rDelmotra@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:50:15 +0530 Subject: [PATCH 04/11] feat(satellites): flag naked-eye visible passes - Add Sun position and Earth-shadow checks - Add visible to pass results - next_iss_pass prefers a visible pass, falls back with visible: false --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 2 +- src/data/issPass.js | 7 +- src/data/satellitePass.js | 159 ++++++++++++++++++++++++++++++-- src/data/satellitePass.test.mjs | 137 ++++++++++++++++++++++++++- src/layers/satellites/orbits.js | 15 ++- src/voice/gevActions.js | 8 +- 7 files changed, 314 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba56328a9..4d391f98a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ ## Satellite pass prediction - Bisect pass rise/set to ~0.2 s and fit peak elevation with a parabola. +- Mark passes visible from Earth-shadow and civil-twilight checks. - Add `getNextSatellitePass(noradId, options)` for any loaded catalog satellite. +- `next_iss_pass` now prefers the next visible pass and falls back to the next pass with `visible: false`. ## Voice component boundaries diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9f044af4a..b21fe4e6d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2714,7 +2714,7 @@ silently demoting every later lookup for the session. `activeCameraCardEnabled` presentation option can publish the retained protected-card path. Disable tears the tier down completely. Coverage/viewshed semantics unchanged. -- **Satellites**: orbit rings rotate about Earth's Z by ΔGMST every ~1s (exact inertial→ECEF compensation; no SGP4 re-runs); the tracked satellite propagates per frame with one shared epoch for dot/label/camera. Verified: ISS holds <1km perpendicular to its ring while tracked. Pass prediction (`src/data/satellitePass.js`; `getNextSatellitePass`, `getNextIssPass`) scans at 20s, bisects rise/set to ~0.2s, and fits the peak with a parabola. +- **Satellites**: orbit rings rotate about Earth's Z by ΔGMST every ~1s (exact inertial→ECEF compensation; no SGP4 re-runs); the tracked satellite propagates per frame with one shared epoch for dot/label/camera. Verified: ISS holds <1km perpendicular to its ring while tracked. Pass prediction (`src/data/satellitePass.js`; `getNextSatellitePass`, `getNextIssPass`) scans at 20s, bisects rise/set to ~0.2s, fits the peak with a parabola, and marks a pass visible when the satellite is outside a cylindrical Earth shadow while the observer's Sun is at or below −6°. `next_iss_pass` returns the next visible pass, falling back to the next pass with `visible: false`. - **Space Missions (30d)**: recent launches render as bounded shared-host, horizon-occluded mission markers using Launch Library 2 v2.3 detailed records. Enabling the layer selects the unified right-side Context panel's Space Missions mode and enables the required satellite layer. Before applying its temporary dense/hidden Satellite mode, Space Missions snapshots the complete standalone Satellite parameter set and exact enabled-layer set. Disabling Space Missions from either Context or the left Data Layers rail restores those parameters and the exact prior enabled state, so a Satellite layer that was already on stays on while a mode-owned dependency returns off; enabling Satellites by itself remains independent. Selecting a mission isolates its launch-to-orbit transfer and dashed satellite orbit, fills that same panel with navigation/details, and animates a small phase-colored marker along the exact displayed Cartesian samples. Marker hit testing drill-picks through photorealistic tiles so the depth-test-free tactical dot remains reliably selectable; its text is non-interactive shared-host presentation. The selected pad is the camera's zoom pivot: the overview remains centered on its launch site, wheel zoom approaches that site instead of drifting elsewhere, and camera pitch progressively changes from global nadir to an oblique close 3D view. Its protected shared-host callout remains visible and expands to include both mission and launch-site names. `FOCUS` flies directly to a 12 km oblique frame around the selected launch site and retains the same anchored zoom/orbit behavior. `REPLAY ASCENT` resets the selected marker at the pad, frames it from an oblique third-person angle, and follows it through the mission-specific compressed ascent directly into one orbital lap at the default `1×` rate. A live `0.25×`–`4×` slider changes ascent and orbit playback speed; adjusting it mid-replay preserves the current path position and historical mission timestamp. Re-entry/recovery cannot be inserted into replay. At orbit insertion the camera smoothly pulls back over the first fifth of the orbital replay and pitches to a globe-scale nadir view while continuing to target the moving replay point. Replay Cancel, mission navigation, deselection, layer disable, and data refresh all release camera ownership. Reconstructed paths are one continuous 128-sample geodetic curve: horizontal departure begins near zero while altitude rises quickly, then the climb progressively bends toward insertion without the former hard 120 km corner. Unmatched orbit fallbacks are smooth planar inclined rings rather than longitude/latitude ground-track curves. The panel lists disclosed payload names, types, operators/manufacturers, mass, multiplicity, and destination when supplied; an empty LL2 payload collection is shown as `CLASSIFIED / MULTI-PAYLOAD`. Launcher, spacecraft, and recoverable payload stages appear in a compact stage table with serial/flight/reuse details, recovery outcome/type, destination, and final coordinates when those records exist; an empty recovery collection omits the section. Stage recoveries with confirmed coordinates use those coordinates; return-to-launch-site records use the pad; downrange-only records receive an explicitly labeled estimated endpoint along the ascent azimuth. Available endpoints render as static 2 px dashed descent/recovery paths with a fixed final-position dot and an estimated atmospheric-interface segment when applicable. The ascent is geodetically densified above the ellipsoid toward the orbit's nearest insertion point, then rendered with `ArcType.NONE`, so it neither cuts through Earth nor separates from the marker. Because LL2 does not normally supply continuous ascent telemetry, pad-to-insertion paths without upstream trajectory samples are labeled `RECONSTRUCTED ESTIMATE` / `ASCENT ESTIMATE`; only supplied trajectory samples receive the replay wording. Selected-orbit framing fits the complete ring, rear-side linework uses normal scene depth occlusion, and current distance, speed, and callout data come only from a launch-year-validated satellite match. Speed is the magnitude of the SGP4 inertial velocity vector at the same propagation epoch as position, displayed in km/s with km/h available as hover detail. Unavailable operator, site, launch-time, orbit, current-altitude, and speed values omit their detail rows instead of reserving panel space with placeholders. Newly launched payloads absent from the core operational groups use CelesTrak's cached active TLE feed as a lookup-only fallback; weak constellation-name matches are rejected. The replay callout maps compressed animation progress onto Launch Library's mission-relative timeline, showing the historical UTC date/time at the marker's current path position; unavailable timelines remain explicit. Matched live satellite positions propagate at one-second cadence and use a distinct green dot/callout with the current UTC date/time. When no mission is selected, the Context panel presents a scrollable newest-first roster of every launch in the rolling window, including the smaller 5 px operator-colored marker, provider, and launch date. Hovering or keyboard-focusing a row shows four compact cyan corner brackets on both that roster row and its corresponding globe dot, rotates the globe at the current zoom to center it, and gives its label declutter priority without selecting it; the globe label remains unbracketed. Selecting a roster row invokes the same mission isolation and full-orbit framing as clicking its globe marker. The replay vehicle is one screen-space SVG/CSS HUD overlay rather than separate Cesium billboard, label, and reticle graphics. It is hidden during ordinary Focus and manual close views, where the standard selected launch-site label remains visible, and exists only while ascent replay is active. Its fixed pixel scale is shared by ascent and insertion, so camera range never resizes the rocket on screen before the phase boundary. Generic Launch Library pad names are reduced to their identifying suffix, and replay timestamps use a cyan state title over unprefixed white UTC date/time values. `REPLAY ASCENT` holds the unframed cyan/white rocket at the pad for a real-time `T−10` countdown, transitions through `LIFTOFF`, and attaches six tapered cyan/white ellipse waves directly below it from liftoff through insertion to convey thrust without adding scene geometry. While replay is active, the single start button is replaced by compact Play, Pause, and Cancel icon controls. Pause freezes countdown or mission time, vehicle/stage positions, camera target, labels, and thrust-wave animation; Play resumes from that exact frame, and Cancel releases replay camera ownership. The rocket and thrust group rotates from the path's live screen-space tangent, so its nose follows the visible ascent curve while the adjacent text remains upright. The camera begins as a close oblique launch chase, then smoothly widens between roughly 120 and 420 km vehicle altitude into a higher oblique context view that keeps the moving rocket targeted while exposing the ascent bend and orbit connection. At insertion the rocket/thrust glyph is replaced by the fixed-size cyan orbit dot, which the camera follows through the existing globe-scale orbit pullback. The chase camera limits per-frame yaw changes across heading wraps so it cannot abruptly cross in front of the vehicle and make ascent read in reverse; the replay-speed slider affects mission playback but not countdown duration. Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources. diff --git a/src/data/issPass.js b/src/data/issPass.js index 416355a0c..c3807d59f 100644 --- a/src/data/issPass.js +++ b/src/data/issPass.js @@ -17,8 +17,9 @@ export { lookAnglesAt }; * @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees * @param {number} [options.horizonHours=24] Maximum search window in hours * @param {number} [options.coarseStepSec=30] Coarse search step in seconds - * @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking - * @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number } | null} + * @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking and visibility + * @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass + * @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number, visible: boolean, sunlit: boolean, observerDark: boolean } | null} */ export function findNextIssPass({ satrec, @@ -29,6 +30,7 @@ export function findNextIssPass({ horizonHours = 24, coarseStepSec = 30, fineStepSec = 5, + requireVisible = false, }) { return findNextSatellitePass({ satrec, @@ -39,5 +41,6 @@ export function findNextIssPass({ horizonHours, coarseStepSec, fineStepSec, + requireVisible, }); } diff --git a/src/data/satellitePass.js b/src/data/satellitePass.js index c7fa54cde..2ce7b35fb 100644 --- a/src/data/satellitePass.js +++ b/src/data/satellitePass.js @@ -1,14 +1,17 @@ // src/data/satellitePass.js /** - * Satellite pass prediction. - * Look angles come from SGP4; rise/set are bisected to sub-second precision - * and the peak is refined with a parabola. Scan pattern from skylight (MIT) - * shared/src/celestial.ts nextISSPass. + * Satellite pass prediction with naked-eye visibility. + * Look angles come from SGP4; rise/set are bisected to sub-second precision, + * the peak is refined with a parabola, and visibility uses the USNO + * low-precision Sun position with a cylindrical Earth shadow. Scan pattern from + * skylight (MIT) shared/src/celestial.ts nextISSPass. */ import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js'; const R2D = 180 / Math.PI; const D2R = Math.PI / 180; +const EARTH_RADIUS_KM = 6371.0; +const AU_KM = 149597870.7; /** * Observer look angles at an instant, or null when propagation fails. @@ -16,7 +19,7 @@ const D2R = Math.PI / 180; * @param {number} dateMs UTC epoch timestamp in milliseconds * @param {number} latDeg Observer latitude in degrees [-90, 90] * @param {number} lonDeg Observer longitude in degrees [-180, 180] - * @returns {{ elevDeg: number, azDeg: number } | null} + * @returns {{ elevDeg: number, azDeg: number, satECI: {x:number, y:number, z:number} } | null} */ export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) { const date = new Date(dateMs); @@ -31,9 +34,92 @@ export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) { return { elevDeg: look.elevation * R2D, azDeg: (((look.azimuth * R2D) % 360) + 360) % 360, + satECI: pos, }; } +/** + * Compute Sun position in geocentric ECI coordinates using USNO low-precision solar coordinates. + * Accurate to within ~1 arcminute (~0.017°) within centuries of J2000.0. + * @param {number} dateMs UTC epoch timestamp in milliseconds + * @returns {{ x: number, y: number, z: number, raDeg: number, decDeg: number }} + */ +export function solarPositionECI(dateMs) { + const JD = dateMs / 86400000 + 2440587.5; + const D = JD - 2451545.0; // days since J2000.0 + const g = ((((357.529 + 0.98560028 * D) % 360) + 360) % 360) * D2R; + const q = (((280.459 + 0.98564736 * D) % 360) + 360) % 360; + const L = + ((((q + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) % 360) + 360) % 360) * + D2R; + const e = (23.439 - 0.00000036 * D) * D2R; + const ra = Math.atan2(Math.cos(e) * Math.sin(L), Math.cos(L)); + const dec = Math.asin(Math.sin(e) * Math.sin(L)); + return { + x: AU_KM * Math.cos(dec) * Math.cos(ra), + y: AU_KM * Math.cos(dec) * Math.sin(ra), + z: AU_KM * Math.sin(dec), + raDeg: (((ra * R2D) % 360) + 360) % 360, + decDeg: dec * R2D, + }; +} + +/** + * Check whether a satellite in ECI is illuminated by the Sun (outside Earth shadow). + * Uses a cylindrical Earth shadow model with mean radius 6,371 km. + * @param {{ x: number, y: number, z: number }} satECI Satellite position in km + * @param {number} dateMs UTC epoch timestamp in milliseconds + * @returns {boolean} True if sunlit, false if in eclipse (Earth shadow) + */ +export function isSatelliteSunlit(satECI, dateMs) { + if (!satECI || typeof satECI === 'boolean') return false; + const sun = solarPositionECI(dateMs); + const sunLen = Math.hypot(sun.x, sun.y, sun.z); + const sx = sun.x / sunLen; + const sy = sun.y / sunLen; + const sz = sun.z / sunLen; + const proj = satECI.x * sx + satECI.y * sy + satECI.z * sz; + if (proj > 0) return true; // Sunward side of Earth + const dx = satECI.x - proj * sx; + const dy = satECI.y - proj * sy; + const dz = satECI.z - proj * sz; + return Math.hypot(dx, dy, dz) > EARTH_RADIUS_KM; +} + +/** + * Compute the observer local solar elevation angle in degrees. + * @param {number} latDeg Observer latitude in degrees + * @param {number} lonDeg Observer longitude in degrees + * @param {number} dateMs UTC epoch timestamp in milliseconds + * @returns {number} Solar elevation in degrees [-90, 90] + */ +export function observerSolarElevation(latDeg, lonDeg, dateMs) { + const sun = solarPositionECI(dateMs); + const gmst = gstime(new Date(dateMs)); + const gmstDeg = (((gmst * R2D) % 360) + 360) % 360; + const localHourAngleDeg = + (((gmstDeg + lonDeg - sun.raDeg) % 360) + 360) % 360; + const H = localHourAngleDeg * D2R; + const phi = latDeg * D2R; + const dec = sun.decDeg * D2R; + const sinElev = + Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(H); + return Math.asin(Math.max(-1, Math.min(1, sinElev))) * R2D; +} + +/** + * Check whether the observer sky is dark (civil, nautical, or astronomical twilight / night). + * Default threshold is -6° (civil twilight). + * @param {number} latDeg Observer latitude + * @param {number} lonDeg Observer longitude + * @param {number} dateMs UTC timestamp + * @param {number} [maxSunElevDeg=-6] Sun elevation ceiling (default -6° for civil twilight) + * @returns {boolean} True if sun is at or below the threshold + */ +export function isObserverDark(latDeg, lonDeg, dateMs, maxSunElevDeg = -6) { + return observerSolarElevation(latDeg, lonDeg, dateMs) <= maxSunElevDeg; +} + /** * Bisect the elevation threshold boundary f(t) = elev(t) - minElevDeg = 0. * Assumes one threshold crossing inside the bracket. 7 iterations resolve 20s to ~0.16s. @@ -75,7 +161,7 @@ function _bisectBoundary( /** * Find the next pass of a satellite over an observer location. * Uses 20s coarse scanning, bisection for sub-second rise/set times, - * and parabolic interpolation for the peak. + * parabolic interpolation for the peak, and optional naked-eye visibility filtering. * * @param {Object} options * @param {Object} options.satrec SGP4 satellite record @@ -86,13 +172,19 @@ function _bisectBoundary( * @param {number} [options.horizonHours=24] Maximum search horizon in hours (default 24h) * @param {number} [options.coarseStepSec=20] Coarse scan step in seconds (default 20s) * @param {number} [options.fineStepSec=5] Fine transit step in seconds (default 5s) + * @param {boolean} [options.requireVisible=false] Skip passes that are not naked-eye visible + * @param {number} [options.maxSunElevDeg=-6] Ceiling for observer darkness (default -6° for civil twilight) * @returns {{ * riseMs: number, * setMs: number, * maxElevDeg: number, * maxElevMs: number, - * riseAzDeg: number - * } | null} + * riseAzDeg: number, + * visible: boolean, + * sunlit: boolean, + * observerDark: boolean + * } | null} `visible` is true when any sampled part of the pass is sunlit under a + * dark sky; `sunlit` and `observerDark` describe the peak only. */ export function findNextSatellitePass({ satrec, @@ -103,6 +195,8 @@ export function findNextSatellitePass({ horizonHours = 24, coarseStepSec = 20, fineStepSec = 5, + requireVisible = false, + maxSunElevDeg = -6, }) { const horizonMs = fromMs + horizonHours * 3600_000; const coarseMs = coarseStepSec * 1000; @@ -156,16 +250,40 @@ export function findNextSatellitePass({ let maxElevMs = riseMs; let tStepPrev = riseMs; let setMs = null; + let hasVisibleSegment = false; let elevPrevSample = null; let peakLeftElev = null; let peakRightElev = null; let peakStepMs = fineMs; + // Visibility at an instant inside [riseMs, setMs], so no elevation gate + const checkVisibility = (tMs) => { + if (hasVisibleSegment) return; + const look = lookAnglesAt(satrec, tMs, latDeg, lonDeg); + if (!look) return; + const sunlit = isSatelliteSunlit(look.satECI, tMs); + const dark = isObserverDark(latDeg, lonDeg, tMs, maxSunElevDeg); + if (sunlit && dark) { + hasVisibleSegment = true; + } + }; + + // Check visibility at rise + checkVisibility(riseMs); + // Transit forward in fine steps while (tCurr <= horizonMs) { const look = lookAnglesAt(satrec, tCurr, latDeg, lonDeg); const elev = look ? look.elevDeg : -90; + if (look) { + const sunlit = isSatelliteSunlit(look.satECI, tCurr); + const dark = isObserverDark(latDeg, lonDeg, tCurr, maxSunElevDeg); + if (sunlit && dark && elev >= minElevDeg) { + hasVisibleSegment = true; + } + } + if (elev < minElevDeg && tCurr > riseMs) { if (peakRightElev == null && tCurr > maxElevMs) { peakRightElev = elev; @@ -223,6 +341,28 @@ export function findNextSatellitePass({ } } + // Sun and sky state at the peak + const peakLook = lookAnglesAt(satrec, refinedPeakMs, latDeg, lonDeg); + const peakSunlit = peakLook + ? isSatelliteSunlit(peakLook.satECI, refinedPeakMs) + : false; + const peakDark = isObserverDark( + latDeg, + lonDeg, + refinedPeakMs, + maxSunElevDeg, + ); + + // Check visibility at the refined peak and at set + checkVisibility(refinedPeakMs); + checkVisibility(setMs); + + // Skip passes with no visible part when requireVisible is set + if (requireVisible && !hasVisibleSegment) { + searchCursor = Math.max(setMs + 1000, searchCursor + coarseMs); + continue; + } + const riseLook = lookAnglesAt(satrec, riseMs, latDeg, lonDeg); return { riseMs, @@ -230,6 +370,9 @@ export function findNextSatellitePass({ maxElevDeg: refinedPeakElevDeg, maxElevMs: refinedPeakMs, riseAzDeg: riseLook ? riseLook.azDeg : 0, + visible: hasVisibleSegment, + sunlit: peakSunlit, + observerDark: peakDark, }; } diff --git a/src/data/satellitePass.test.mjs b/src/data/satellitePass.test.mjs index 1739c0570..64d2efec2 100644 --- a/src/data/satellitePass.test.mjs +++ b/src/data/satellitePass.test.mjs @@ -1,8 +1,15 @@ // src/data/satellitePass.test.mjs import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { twoline2satrec } from 'satellite.js'; -import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js'; +import { twoline2satrec, propagate } from 'satellite.js'; +import { + findNextSatellitePass, + lookAnglesAt, + solarPositionECI, + isSatelliteSunlit, + observerSolarElevation, + isObserverDark, +} from './satellitePass.js'; // Canonical archived ISS TLE (epoch 2008-09-20 ~12:25 UTC). const L1 = @@ -12,6 +19,107 @@ const L2 = const AUSTIN = { latDeg: 30.2672, lonDeg: -97.7431 }; const FROM_MS = Date.UTC(2008, 8, 20, 12, 30, 0); +test('solarPositionECI matches the Sun at J2000.0', () => { + // Reference: Cesium Simon1994PlanetaryPositions at 2000-01-01 12:00 UTC + // gives RA 281.289°, Dec -23.033°. + const j2000 = solarPositionECI(Date.UTC(2000, 0, 1, 12, 0, 0)); + assert.ok( + Math.abs(j2000.raDeg - 281.289) < 0.05, + `RA ${j2000.raDeg}° within 0.05°`, + ); + assert.ok( + Math.abs(j2000.decDeg - -23.033) < 0.05, + `Dec ${j2000.decDeg}° within 0.05°`, + ); +}); + +test('solarPositionECI calculates reasonable solar coordinates across seasons', () => { + // Near Autumnal Equinox (Sept 22, 2020): declination ≈ 0° + const equinox = solarPositionECI(Date.UTC(2020, 8, 22, 12, 0, 0)); + assert.ok( + Math.abs(equinox.decDeg) < 1.0, + 'declination near equinox must be close to 0°', + ); + + // Near Summer Solstice (June 21, 2020): declination ≈ +23.44° + const solsticeSummer = solarPositionECI(Date.UTC(2020, 5, 21, 12, 0, 0)); + assert.ok( + Math.abs(solsticeSummer.decDeg - 23.44) < 0.5, + 'summer declination ≈ 23.44°', + ); + + // Near Winter Solstice (Dec 21, 2020): declination ≈ -23.44° + const solsticeWinter = solarPositionECI(Date.UTC(2020, 11, 21, 12, 0, 0)); + assert.ok( + Math.abs(solsticeWinter.decDeg - -23.44) < 0.5, + 'winter declination ≈ -23.44°', + ); +}); + +test('observerSolarElevation distinguishes local noon from local midnight', () => { + // Greenwich noon on equinox: solar elevation should be close to 90 - lat + const greenwichNoon = observerSolarElevation( + 51.5, + 0, + Date.UTC(2020, 8, 22, 12, 0, 0), + ); + assert.ok(greenwichNoon > 30, 'sun must be well above horizon at local noon'); + + // Greenwich midnight: solar elevation must be deeply negative + const greenwichMidnight = observerSolarElevation( + 51.5, + 0, + Date.UTC(2020, 8, 22, 0, 0, 0), + ); + assert.ok( + greenwichMidnight < -20, + 'sun must be well below horizon at midnight', + ); + assert.ok(isObserverDark(51.5, 0, Date.UTC(2020, 8, 22, 0, 0, 0), -6)); +}); + +test('isSatelliteSunlit accurately computes cylindrical umbra shadow', () => { + const summerDate = Date.UTC(2020, 5, 21, 12, 0, 0); + const sun = solarPositionECI(summerDate); + const sunLen = Math.hypot(sun.x, sun.y, sun.z); + const sx = sun.x / sunLen; + const sy = sun.y / sunLen; + const sz = sun.z / sunLen; + + // Point toward the Sun: must be sunlit + const daysideSat = { x: 7000 * sx, y: 7000 * sy, z: 7000 * sz }; + assert.equal(isSatelliteSunlit(daysideSat, summerDate), true); + + // Point directly in Earth shadow (opposite Sun, r = 6700 km, < Earth radius offset): must be eclipsed + const shadowedSat = { x: -6700 * sx, y: -6700 * sy, z: -6700 * sz }; + assert.equal(isSatelliteSunlit(shadowedSat, summerDate), false); + + // Point on night side but high altitude / far perpendicular to shadow axis (> 6371 km): must be sunlit + const highAltSat = { x: -6700 * sx + 10000, y: -6700 * sy, z: -6700 * sz }; + assert.equal(isSatelliteSunlit(highAltSat, summerDate), true); +}); + +test('isSatelliteSunlit accurately identifies SGP4 orbital shadow entry and exit', () => { + const satrec = twoline2satrec(L1, L2); + // Minute 17 after FROM_MS (1221914820000): ISS is inside Earth umbra shadow cone + const tShadowed = FROM_MS + 17 * 60_000; + const pvShadowed = propagate(satrec, new Date(tShadowed)); + assert.equal( + isSatelliteSunlit(pvShadowed.position, tShadowed), + false, + 'ISS must be eclipsed in Earth umbra during orbital night', + ); + + // Minute 18 after FROM_MS (1221914880000): ISS emerges from umbra into sunlight + const tSunlit = FROM_MS + 18 * 60_000; + const pvSunlit = propagate(satrec, new Date(tSunlit)); + assert.equal( + isSatelliteSunlit(pvSunlit.position, tSunlit), + true, + 'ISS must be illuminated upon orbital sunrise', + ); +}); + test('findNextSatellitePass resolves pass boundaries with sub-second precision', () => { const satrec = twoline2satrec(L1, L2); const pass = findNextSatellitePass({ @@ -90,6 +198,31 @@ test('findNextSatellitePass resolves pass boundaries with sub-second precision', assert.ok(pass.maxElevDeg >= atSet.elevDeg); }); +test('findNextSatellitePass skips non-visible passes when requireVisible is true', () => { + const satrec = twoline2satrec(L1, L2); + const geometricPass = findNextSatellitePass({ + satrec, + ...AUSTIN, + fromMs: FROM_MS, + minElevDeg: 10, + requireVisible: false, + }); + assert.ok(geometricPass); + + const visiblePass = findNextSatellitePass({ + satrec, + ...AUSTIN, + fromMs: FROM_MS, + minElevDeg: 10, + horizonHours: 72, + requireVisible: true, + }); + + assert.ok(visiblePass, 'expected at least one visible pass within horizon'); + assert.equal(visiblePass.visible, true); + assert.ok(visiblePass.riseMs >= geometricPass.riseMs); +}); + test('findNextSatellitePass samples the peak at fineStepSec', () => { const satrec = twoline2satrec(L1, L2); const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10 }; diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js index 306497937..95063497b 100644 --- a/src/layers/satellites/orbits.js +++ b/src/layers/satellites/orbits.js @@ -140,19 +140,22 @@ export function createOrbits({ state: layerState, services, parts, source }) { * @param {number} options.latDeg Observer latitude * @param {number} options.lonDeg Observer longitude * @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees - * @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number}}} + * @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass + * @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number,visible?:boolean,sunlit?:boolean,observerDark?:boolean}}} */ function getNextIssPass({ latDeg, lonDeg, minElevDeg = 10, + requireVisible = false, fromMs = Date.now(), }) { return getNextSatellitePass(ISS_NORAD, { latDeg, lonDeg, minElevDeg, + requireVisible, fromMs, }); } @@ -164,13 +167,20 @@ export function createOrbits({ state: layerState, services, parts, source }) { * @param {number} options.latDeg Observer latitude * @param {number} options.lonDeg Observer longitude * @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees + * @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass * @param {number} [options.fromMs=Date.now()] Search start time in UTC milliseconds * @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:Object}} */ function getNextSatellitePass( noradId, - { latDeg, lonDeg, minElevDeg = 10, fromMs = Date.now() }, + { + latDeg, + lonDeg, + minElevDeg = 10, + requireVisible = false, + fromMs = Date.now(), + }, ) { const id = Number(noradId); if (!Number.isFinite(id)) return { status: 'no-tle' }; @@ -182,6 +192,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { lonDeg, fromMs, minElevDeg, + requireVisible, }); return pass ? { status: 'ok', pass } : { status: 'none' }; } diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index a607e27de..4b821fa03 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -2017,7 +2017,12 @@ function nextIssPass(viewer, args) { lonDeg = Cesium.Math.toDegrees(carto.longitude); } const minElevDeg = Number.isFinite(args.minElevationDeg) ? args.minElevationDeg : 10; - const result = getNextIssPass({ latDeg, lonDeg, minElevDeg }); + // Prefer a naked-eye-visible pass (the tool contract); otherwise report the + // next pass with visible: false. + let result = getNextIssPass({ latDeg, lonDeg, minElevDeg, requireVisible: true }); + if (result.status === 'none') { + result = getNextIssPass({ latDeg, lonDeg, minElevDeg }); + } if (result.status === 'no-tle') { return { ok: false, @@ -2042,6 +2047,7 @@ function nextIssPass(viewer, args) { durationMin: Math.max(1, Math.round((pass.setMs - pass.riseMs) / 60000)), peakElevationDeg: Math.round(pass.maxElevDeg), riseDirection: compassDir(pass.riseAzDeg), + visible: Boolean(pass.visible), }; } From 18869a82f0ee214644f9897a76c8ff2feea80b57 Mon Sep 17 00:00:00 2001 From: Rehaan Delmotra <208644523+rDelmotra@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:50:57 +0530 Subject: [PATCH 05/11] chore: adopt satellitePass formatting --- scripts/format-scope.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 1a5a2a54c..60ed4e114 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -361,6 +361,8 @@ "src/layers/launches/model.js", "src/layers/launches/selection.js", "src/layers/launches/camera.js", + "src/data/satellitePass.js", + "src/data/satellitePass.test.mjs", "src/data/satellites.js", "src/data/rocketLaunches.js", "src/layers/satellites/source.test.mjs", From 5c3f558766fe45a92ef7e2042c30ac6169fc25bc Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Tue, 15 Sep 2026 02:18:42 -0500 Subject: [PATCH 06/11] fix: verify bounded analyst scope and empty satellite identity --- src/data/analystEngine.test.mjs | 14 ++++++++++++++ src/data/localGeojson.analyst.test.mjs | 1 + src/layers/satellites/records.js | 2 +- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/data/analystEngine.test.mjs b/src/data/analystEngine.test.mjs index 4456c0a12..4f56c834b 100644 --- a/src/data/analystEngine.test.mjs +++ b/src/data/analystEngine.test.mjs @@ -248,3 +248,17 @@ test('helpers: haversine sanity + scope radius', () => { const scoped = applyScope(FLIGHTS, { kind: 'radius' }, { center: { lat: 30.27, lon: -97.74 }, km: 50 }); assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']); }); + +test('bounded loaded cohorts disclose truncation before filtering and retain it on follow-up', async () => { + const engine = createAnalystEngine({ + getRecords: () => [{ id: 'sample dam', lat: 0, lon: 0, name: 'sample' }], + getRecordCoverage: () => ({ basis: 'bounded-loaded-records', recordsExamined: 1, loadedCount: 3000, sourceTruncated: true }), + getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }), + }); + const result = await engine.query({ layers: ['local-dams'], scope: { kind: 'anywhere' }, sortBy: 'distance' }); + assert.equal(result.count, 1); + assert.equal(result.coverage.layersQueried[0].sourceTruncated, true); + assert.match(result.coverage.note, /omitted records may change the nearest item or count/); + const followUp = await engine.query({ followUp: true }); + assert.equal(followUp.coverage.layersQueried[0].loadedCount, 3000); +}); diff --git a/src/data/localGeojson.analyst.test.mjs b/src/data/localGeojson.analyst.test.mjs index c695004f6..d1cc74343 100644 --- a/src/data/localGeojson.analyst.test.mjs +++ b/src/data/localGeojson.analyst.test.mjs @@ -175,6 +175,7 @@ test('infra getAnalystRecords: enabled layer snapshots loaded stems; disable ret assert.deepEqual(layer.getAnalystRecords(), [], 'disable keeps stems but analyst snapshot is empty'); } finally { layer.destroy(viewer); + assert.deepEqual(layer.getAnalystRecords(), [], 'destroy releases analyst records'); globalThis.fetch = originalFetch; if (originalWindow === undefined) delete globalThis.window; else globalThis.window = originalWindow; diff --git a/src/layers/satellites/records.js b/src/layers/satellites/records.js index 962e52f86..7b4e6603a 100644 --- a/src/layers/satellites/records.js +++ b/src/layers/satellites/records.js @@ -15,7 +15,7 @@ import { ISS_NORAD } from './policy.js'; export function mapAnalystRecord(raw) { const num = (v) => (Number.isFinite(v) ? v : null); const text = (v) => { const t = String(v ?? '').trim(); return t || null; }; - const noradNum = Number(raw?.noradId); + const noradNum = raw?.noradId == null || raw.noradId === '' ? NaN : Number(raw.noradId); const noradId = Number.isFinite(noradNum) ? String(Math.trunc(noradNum)) : text(raw?.noradId); const name = text(raw?.name); const group = text(raw?.group); From 6d6d43c36c4d414c129edd024088a25349493715 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Tue, 15 Sep 2026 02:25:56 -0500 Subject: [PATCH 07/11] test: cover pass ambiguity and retain legacy ISS response contract --- scripts/format-scope.json | 3 +++ src/layers/satellites/source.test.mjs | 12 ++++++++++++ src/voice/gevActions.js | 5 ++++- src/voice/gevActions.test.mjs | 25 +++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index e2cd43d52..01d96c28b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -382,6 +382,9 @@ "src/layers/launches/model.js", "src/layers/launches/selection.js", "src/layers/launches/camera.js", + "src/layers/satellites/records.js", + "src/data/layerSnapshot.js", + "src/data/layerSnapshot.test.mjs", "src/data/satellitePass.js", "src/data/satellitePass.test.mjs", "src/data/satellites.js", diff --git a/src/layers/satellites/source.test.mjs b/src/layers/satellites/source.test.mjs index 61913263c..ba0f376ec 100644 --- a/src/layers/satellites/source.test.mjs +++ b/src/layers/satellites/source.test.mjs @@ -136,6 +136,18 @@ test('satellite pass prediction methods report no-tle on cold catalog and coerce assert.equal(issResult.status, 'ok'); assert.deepEqual(issResult, numericResult); + layer._setTrackedSatelliteRefreshStateForTest({ noradId: 25544, name: 'ISS (ZARYA)', satrec, + neighbours: [{ noradId: 10, name: 'STARLINK ONE', satrec }, { noradId: 11, name: 'STARLINK TWO', satrec }], + }); + assert.equal(layer.resolveSatelliteForPass('25544').noradId, 25544); + assert.equal(layer.resolveSatelliteForPass('iss (zarya)').noradId, 25544); + assert.equal(layer.resolveSatelliteForPass('starlink').status, 'ambiguous'); + assert.equal(layer.resolveSatelliteForPass('starlink').totalMatches, 2); + assert.equal(layer.resolveSatelliteForPass('STARLINK ONE').noradId, 10); + assert.equal(layer.resolveSatelliteForPass('missing').status, 'not-found'); + layer._clearDenseCatalogStateForTest(); + assert.equal(layer.resolveSatelliteForPass('25544').status, 'not-found'); + // Non-existent satellite ID in populated catalog assert.deepEqual( layer.getNextSatellitePass(99999, { diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 3bf5000e0..0639ca610 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -2067,7 +2067,10 @@ function nextSatellitePass(viewer, dataManager, args) { getNextIssPass: (options) => layer.getNextSatellitePass(identity.noradId, { ...options, requireVisible: args.visibleOnly === true }), } }]]) }; const result = nextIssPass(viewer, adapter, args); - if (result.error) result.error = result.error.replace(/ISS/g, identity.name || String(identity.noradId)); + if (result.error) { + result.error = result.error.replace(/ISS/g, identity.name || String(identity.noradId)); + if (args.visibleOnly === true) result.error += ' Search required estimated illumination under a dark sky.'; + } return { ...result, action: 'next_satellite_pass', noradId: identity.noradId, name: identity.name, visibleOnly: args.visibleOnly === true, horizonHours: 24 }; } diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 0ced3f9be..3adb086ce 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3245,3 +3245,28 @@ test('analyst_query and get_current_view_state carry stale feed provenance', asy assert.equal(query.coverage.layersQueried[0].feedState, 'stale'); assert.match(query.feedProvenance.note, /do not describe this as live/i); }); + + +test('general satellite pass resolves once, refuses ambiguity, and preserves ISS call semantics', async () => { + const calls = []; + const pass = { riseMs: Date.now() + 60000, setMs: Date.now() + 360000, maxElevMs: Date.now() + 180000, maxElevDeg: 30, riseAzDeg: 90, visible: false }; + const layer = { + resolveSatelliteForPass(target) { return target === 'starlink' ? { status: 'ambiguous', candidates: [{ noradId: 1 }, { noradId: 2 }] } : { status: 'ok', noradId: 25544, name: 'ISS' }; }, + getNextSatellitePass(id, options) { calls.push({ id, ...options }); return { status: 'ok', pass }; }, + getNextIssPass(options) { calls.push(options); return { status: 'ok', pass }; }, + }; + const viewer = { clock: { onTick: { addEventListener: () => () => {} } }, scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, camera: { moveEnd: { addEventListener() {} } } }; + const runner = createGevActionRunner({ viewer, styleManager: {}, dataManager: { layers: new Map([['satellites', { module: layer }]]) } }); + const ambiguous = await runner('next_satellite_pass', { target: 'starlink' }); + assert.equal(ambiguous.status, 'ambiguous'); + assert.equal(calls.length, 0); + const args = { latitude: 30, longitude: -97, minElevationDeg: 15 }; + const general = await runner('next_satellite_pass', { target: '25544', visibleOnly: true, ...args }); + assert.equal(general.action, 'next_satellite_pass'); + assert.deepEqual(calls[0], { id: 25544, latDeg: 30, lonDeg: -97, minElevDeg: 15, requireVisible: true }); + const iss = await runner('next_iss_pass', args); + assert.deepEqual(calls[1], { latDeg: 30, lonDeg: -97, minElevDeg: 15 }); + for (const key of ['observer', 'riseIso', 'minutesFromNow', 'durationMin', 'peakElevationDeg', 'riseDirection']) assert.deepEqual(iss[key], general[key]); + assert.equal(iss.visible, false); + assert.equal(iss.action, 'next_iss_pass'); +}); From 1e8f919cf3379824490c972fbf2af410d327682a Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Tue, 15 Sep 2026 02:28:41 -0500 Subject: [PATCH 08/11] fix: pin additive voice contracts and require HUD provenance wording --- server/providers/openai/hud-summary.js | 5 +- server/providers/openai/instructions.js | 2 +- server/providers/openai/toolDescriptions.js | 3 +- src/data/layerSnapshot.js | 41 +++- src/data/layerSnapshot.test.mjs | 224 +++++++++++++------- src/data/satellitePass.js | 63 ++++-- src/data/satellitePass.test.mjs | 31 ++- src/firstRunExperience.test.mjs | 6 +- src/hud.js | 4 +- src/hudSummaryResponse.js | 7 + src/hudSummaryResponse.test.mjs | 7 + src/layers/satellites/orbits.js | 21 +- src/layers/satellites/records.js | 82 ++++--- src/radioMarkup.test.mjs | 9 +- src/voice/actionSchemas.js | 4 +- src/voice/actionSchemas.test.mjs | 13 +- 16 files changed, 368 insertions(+), 154 deletions(-) diff --git a/server/providers/openai/hud-summary.js b/server/providers/openai/hud-summary.js index d57f7ead0..ca3778ba0 100644 --- a/server/providers/openai/hud-summary.js +++ b/server/providers/openai/hud-summary.js @@ -1,4 +1,7 @@ -import { HUD_SUMMARY_INSTRUCTIONS, keylessHudSummaryResponse } from '../../../src/hudSummaryResponse.js'; +import { + HUD_SUMMARY_INSTRUCTIONS, + keylessHudSummaryResponse, +} from '../../../src/hudSummaryResponse.js'; import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js'; import { readRequestBody } from '../common/request.js'; import { OPENAI_HUD_SUMMARY_MODEL_DEFAULT } from './constants.js'; diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 2bcd93d93..52c8c0b95 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -19,7 +19,7 @@ function realtimeInstructions( 'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.', 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.', 'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, earthquakes, satellites, datacenters, or dams ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.', -'FEED STATE CONTRACT — analyst_query and get_current_view_state carry feedProvenance from the same layerFeedState the Data Layers chips use (nominal/loading/degraded/stale/fallback/unavailable, or off). NEVER narrate a count as live or current when feedProvenance.overall is not nominal. Say the feedState in the same breath as the count ("12 flights in view, STALE"). If unavailable, do not invent a count; say the feed is unavailable. Fallback is valid data — name the fallback source, never call it an enable failure. Confirm only what the tool result returned.', + 'FEED STATE CONTRACT — analyst_query and get_current_view_state carry feedProvenance from the same layerFeedState the Data Layers chips use (nominal/loading/degraded/stale/fallback/unavailable, or off). NEVER narrate a count as live or current when feedProvenance.overall is not nominal. Say the feedState in the same breath as the count ("12 flights in view, STALE"). If unavailable, do not invent a count; say the feed is unavailable. Fallback is valid data — name the fallback source, never call it an enable failure. Confirm only what the tool result returned.', 'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near " means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.', 'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.', 'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.', diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 00045aff9..f4e328b7f 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -572,7 +572,8 @@ export const ACTION_DESCRIPTIONS = { }, }, next_satellite_pass: { - description: 'Predict the next pass in 24 hours for one satellite in the loaded catalog, identified by exact NORAD ID or name. Ambiguous names return candidates: ask for a choice rather than selecting one. Defaults to geometric passes; visibleOnly requires estimated illumination and a dark observer sky, which does not guarantee naked-eye visibility. Uses camera location unless coordinates are supplied.', + description: + 'Predict the next pass in 24 hours for one satellite in the loaded catalog, identified by exact NORAD ID or name. Ambiguous names return candidates: ask for a choice rather than selecting one. Defaults to geometric passes; visibleOnly requires estimated illumination and a dark observer sky, which does not guarantee naked-eye visibility. Uses camera location unless coordinates are supplied.', $position: 1, }, }; diff --git a/src/data/layerSnapshot.js b/src/data/layerSnapshot.js index f2b12da9c..13fd3afe2 100644 --- a/src/data/layerSnapshot.js +++ b/src/data/layerSnapshot.js @@ -32,7 +32,10 @@ export const LAYER_FEED_STATE_LABELS = Object.freeze({ }); /** Snapshot feed-state including a disabled layer. */ -export const SNAPSHOT_FEED_STATES = Object.freeze([...LAYER_FEED_STATES, 'off']); +export const SNAPSHOT_FEED_STATES = Object.freeze([ + ...LAYER_FEED_STATES, + 'off', +]); /** * Lower rank is more severe. Voice/HUD `overall` is the worst queried state @@ -132,7 +135,9 @@ export function layerSnapshot(layer = {}, { now = Date.now() } = {}) { const enabled = Boolean(layer.enabled); const countRaw = Number(stats.count); const count = Number.isFinite(countRaw) ? countRaw : 0; - const lastUpdate = Number.isFinite(Number(stats.lastUpdate)) ? Number(stats.lastUpdate) : null; + const lastUpdate = Number.isFinite(Number(stats.lastUpdate)) + ? Number(stats.lastUpdate) + : null; const feedState = enabled ? layerFeedState({ ...stats, source }) : 'off'; return { id: layer.id || layer.layerKey || null, @@ -179,14 +184,22 @@ export function feedProvenanceNote(layers = [], overall = null) { return 'Queried enabled layers are nominal. Still name the count scope; do not claim whole-world coverage.'; } if (overall === 'off') { - const names = layers.map((s) => s.name || s.id).filter(Boolean).join(', ') || 'the requested layer'; + const names = + layers + .map((s) => s.name || s.id) + .filter(Boolean) + .join(', ') || 'the requested layer'; return `${names} is off. Do not invent a count; say the layer is off and offer to enable it.`; } - const flagged = layers.filter((s) => ( - s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off' - )); + const flagged = layers.filter( + (s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off', + ); const bits = flagged.map((s) => { - const extras = [s.source, s.ageLabel || snapshotAgeLabel(s.lastUpdate), s.error] + const extras = [ + s.source, + s.ageLabel || snapshotAgeLabel(s.lastUpdate), + s.error, + ] .filter(Boolean) .join(', '); return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`; @@ -201,11 +214,17 @@ export function feedProvenanceNote(layers = [], overall = null) { * @param {{ now?: number }} [options] * @returns {{ overall: string|null, layers: Array, note: string }} */ -export function feedProvenanceEnvelope(snapshots = [], { now = Date.now() } = {}) { - const layers = (Array.isArray(snapshots) ? snapshots : []).map((entry) => asSnapshot(entry, now)); +export function feedProvenanceEnvelope( + snapshots = [], + { now = Date.now() } = {}, +) { + const layers = (Array.isArray(snapshots) ? snapshots : []).map((entry) => + asSnapshot(entry, now), + ); const active = layers.filter((s) => s.feedState && s.feedState !== 'off'); - const overall = worstFeedState(active.map((s) => s.feedState)) - || (layers.length ? 'off' : null); + const overall = + worstFeedState(active.map((s) => s.feedState)) || + (layers.length ? 'off' : null); return { overall, layers, diff --git a/src/data/layerSnapshot.test.mjs b/src/data/layerSnapshot.test.mjs index a7c1a1346..9e3b08c80 100644 --- a/src/data/layerSnapshot.test.mjs +++ b/src/data/layerSnapshot.test.mjs @@ -20,45 +20,87 @@ import { const NOW = 1_700_000_000_000; test('layerSnapshot stays Cesium-free so the HUD proxy can import it', () => { - const src = readFileSync(new URL('./layerSnapshot.js', import.meta.url), 'utf8'); + const src = readFileSync( + new URL('./layerSnapshot.js', import.meta.url), + 'utf8', + ); assert.equal(src.includes("from './manager.js'"), false); assert.doesNotMatch(src, /from ['"]cesium['"]/i); }); test('layerFeedState matches the chip classifier for every honesty class', () => { - assert.equal(layerFeedState({ error: 'feed down', count: 0, lastUpdate: null }), 'unavailable'); - assert.equal(layerFeedState({ - status: 'unavailable', - error: 'feed down', - count: 50, - lastUpdate: 1, - }), 'unavailable'); - assert.equal(layerFeedState({ mode: 'sim', count: 100, lastUpdate: 1 }), 'fallback'); - assert.equal(layerFeedState({ source: 'adsb.lol', count: 10, lastUpdate: 1 }), 'fallback'); - assert.equal(layerFeedState({ - source: 'adsb.lol', - fallback: false, - count: 10, - lastUpdate: 1, - }), 'nominal'); - assert.equal(layerFeedState({ stale: true, count: 0, lastUpdate: 1 }), 'stale'); - assert.equal(layerFeedState({ error: 'partial group failure', count: 50, lastUpdate: 1 }), 'degraded'); + assert.equal( + layerFeedState({ error: 'feed down', count: 0, lastUpdate: null }), + 'unavailable', + ); + assert.equal( + layerFeedState({ + status: 'unavailable', + error: 'feed down', + count: 50, + lastUpdate: 1, + }), + 'unavailable', + ); + assert.equal( + layerFeedState({ mode: 'sim', count: 100, lastUpdate: 1 }), + 'fallback', + ); + assert.equal( + layerFeedState({ source: 'adsb.lol', count: 10, lastUpdate: 1 }), + 'fallback', + ); + assert.equal( + layerFeedState({ + source: 'adsb.lol', + fallback: false, + count: 10, + lastUpdate: 1, + }), + 'nominal', + ); + assert.equal( + layerFeedState({ stale: true, count: 0, lastUpdate: 1 }), + 'stale', + ); + assert.equal( + layerFeedState({ + error: 'partial group failure', + count: 50, + lastUpdate: 1, + }), + 'degraded', + ); assert.equal(layerFeedState({ loading: true }), 'loading'); assert.equal(layerFeedState({ count: 5, lastUpdate: 1 }), 'nominal'); - assert.equal(layerFeedState({ status: 'zoom-in', error: 'zoom in to search', count: 12 }), 'nominal'); - assert.equal(layerFeedState({ status: 'zoom-in', stale: true, count: 12 }), 'stale'); + assert.equal( + layerFeedState({ + status: 'zoom-in', + error: 'zoom in to search', + count: 12, + }), + 'nominal', + ); + assert.equal( + layerFeedState({ status: 'zoom-in', stale: true, count: 12 }), + 'stale', + ); }); test('chip labels and severity cover every feed-state the envelope can emit', () => { - assert.deepEqual([...LAYER_FEED_STATES], [ - 'nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable', - ]); + assert.deepEqual( + [...LAYER_FEED_STATES], + ['nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable'], + ); assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']); for (const state of LAYER_FEED_STATES) { assert.equal(typeof LAYER_FEED_STATE_LABELS[state], 'string'); assert.equal(typeof FEED_STATE_SEVERITY[state], 'number'); } - assert.equal(FEED_STATE_SEVERITY.unavailable < FEED_STATE_SEVERITY.stale, true); + assert.equal( + FEED_STATE_SEVERITY.unavailable < FEED_STATE_SEVERITY.stale, + true, + ); assert.equal(FEED_STATE_SEVERITY.stale < FEED_STATE_SEVERITY.fallback, true); assert.equal(worstFeedState(['nominal', 'stale', 'fallback']), 'stale'); assert.equal(worstFeedState(['degraded', 'unavailable']), 'unavailable'); @@ -66,13 +108,21 @@ test('chip labels and severity cover every feed-state the envelope can emit', () }); test('a disabled layer is off even when leftover stats would read stale', () => { - const snap = layerSnapshot({ - id: 'flights', - name: 'Live Flights', - enabled: false, - source: 'OpenSky Network', - stats: { stale: true, count: 12, lastUpdate: NOW - 240_000, source: 'OpenSky Network' }, - }, { now: NOW }); + const snap = layerSnapshot( + { + id: 'flights', + name: 'Live Flights', + enabled: false, + source: 'OpenSky Network', + stats: { + stale: true, + count: 12, + lastUpdate: NOW - 240_000, + source: 'OpenSky Network', + }, + }, + { now: NOW }, + ); assert.equal(snap.feedState, 'off'); assert.equal(snap.enabled, false); assert.equal(snap.count, 12); @@ -80,17 +130,20 @@ test('a disabled layer is off even when leftover stats would read stale', () => }); test('an enabled stale layer carries age, source, and a mechanical note', () => { - const snap = layerSnapshot({ - id: 'flights', - name: 'Live Flights', - enabled: true, - stats: { - stale: true, - count: 12, - lastUpdate: NOW - 240_000, - source: 'OpenSky Network', + const snap = layerSnapshot( + { + id: 'flights', + name: 'Live Flights', + enabled: true, + stats: { + stale: true, + count: 12, + lastUpdate: NOW - 240_000, + source: 'OpenSky Network', + }, }, - }, { now: NOW }); + { now: NOW }, + ); assert.equal(snap.feedState, 'stale'); assert.equal(snap.source, 'OpenSky Network'); assert.equal(snap.ageSec, 240); @@ -104,20 +157,27 @@ test('an enabled stale layer carries age, source, and a mechanical note', () => }); test('degraded and unavailable envelopes outrank leftover nominal neighbours', () => { - const layers = layerSnapshots([ - { - id: 'earthquakes', - name: 'Earthquakes', - enabled: true, - stats: { count: 3, lastUpdate: NOW - 10_000 }, - }, - { - id: 'local-firms', - name: 'Active Fires', - enabled: true, - stats: { error: 'FIRMS key missing', count: 8, lastUpdate: NOW - 30_000 }, - }, - ], { now: NOW }); + const layers = layerSnapshots( + [ + { + id: 'earthquakes', + name: 'Earthquakes', + enabled: true, + stats: { count: 3, lastUpdate: NOW - 10_000 }, + }, + { + id: 'local-firms', + name: 'Active Fires', + enabled: true, + stats: { + error: 'FIRMS key missing', + count: 8, + lastUpdate: NOW - 30_000, + }, + }, + ], + { now: NOW }, + ); const envelope = feedProvenanceEnvelope(layers, { now: NOW }); assert.equal(envelope.overall, 'degraded'); assert.match(envelope.note, /Active Fires is DEGRADED/); @@ -125,12 +185,15 @@ test('degraded and unavailable envelopes outrank leftover nominal neighbours', ( }); test('an unavailable feed with no prior data does not become a confident zero', () => { - const snap = layerSnapshot({ - id: 'ais-live-vessels', - name: 'Live Vessels', - enabled: true, - stats: { error: 'AISStream down', count: 0, lastUpdate: null }, - }, { now: NOW }); + const snap = layerSnapshot( + { + id: 'ais-live-vessels', + name: 'Live Vessels', + enabled: true, + stats: { error: 'AISStream down', count: 0, lastUpdate: null }, + }, + { now: NOW }, + ); assert.equal(snap.feedState, 'unavailable'); const envelope = feedProvenanceEnvelope([snap]); assert.equal(envelope.overall, 'unavailable'); @@ -138,23 +201,38 @@ test('an unavailable feed with no prior data does not become a confident zero', }); test('fallback provenance names the fallback source instead of calling it live', () => { - const snap = layerSnapshot({ - id: 'flights', - name: 'Live Flights', - enabled: true, - stats: { source: 'adsb.lol fallback', count: 40, lastUpdate: NOW - 8_000 }, - }, { now: NOW }); + const snap = layerSnapshot( + { + id: 'flights', + name: 'Live Flights', + enabled: true, + stats: { + source: 'adsb.lol fallback', + count: 40, + lastUpdate: NOW - 8_000, + }, + }, + { now: NOW }, + ); assert.equal(snap.feedState, 'fallback'); assert.match(feedProvenanceNote([snap], 'fallback'), /adsb\.lol fallback/); }); test('view-state rows keep the existing identity fields and add feedState', () => { - const row = viewStateLayerRow({ - id: 'flights', - name: 'Live Flights', - enabled: true, - stats: { count: 9, lastUpdate: 42, error: null, source: 'OpenSky Network' }, - }, { now: NOW }); + const row = viewStateLayerRow( + { + id: 'flights', + name: 'Live Flights', + enabled: true, + stats: { + count: 9, + lastUpdate: 42, + error: null, + source: 'OpenSky Network', + }, + }, + { now: NOW }, + ); assert.deepEqual(row, { id: 'flights', name: 'Live Flights', diff --git a/src/data/satellitePass.js b/src/data/satellitePass.js index 49433ee1b..7f8f75337 100644 --- a/src/data/satellitePass.js +++ b/src/data/satellitePass.js @@ -24,9 +24,18 @@ const AU_KM = 149597870.7; export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) { const date = new Date(dateMs); let pv; - try { pv = propagate(satrec, date); } catch { return null; } + try { + pv = propagate(satrec, date); + } catch { + return null; + } const pos = pv && pv.position; - if (!pos || typeof pos === 'boolean' || ![pos.x, pos.y, pos.z].every(Number.isFinite)) return null; + if ( + !pos || + typeof pos === 'boolean' || + ![pos.x, pos.y, pos.z].every(Number.isFinite) + ) + return null; const ecf = eciToEcf(pos, gstime(date)); const look = ecfToLookAngles( { latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 }, @@ -199,10 +208,31 @@ export function findNextSatellitePass({ requireVisible = false, maxSunElevDeg = -6, }) { - if (!satrec || ![latDeg, lonDeg, fromMs, minElevDeg, horizonHours, coarseStepSec, fineStepSec, maxSunElevDeg].every(Number.isFinite) - || Math.abs(latDeg) > 90 || Math.abs(lonDeg) > 180 || Math.abs(fromMs) > 8.63e15 - || minElevDeg < 0 || minElevDeg > 90 || horizonHours <= 0 || horizonHours > 72 - || coarseStepSec < 1 || coarseStepSec > 120 || fineStepSec < 0.1 || fineStepSec > 60) return null; + if ( + !satrec || + ![ + latDeg, + lonDeg, + fromMs, + minElevDeg, + horizonHours, + coarseStepSec, + fineStepSec, + maxSunElevDeg, + ].every(Number.isFinite) || + Math.abs(latDeg) > 90 || + Math.abs(lonDeg) > 180 || + Math.abs(fromMs) > 8.63e15 || + minElevDeg < 0 || + minElevDeg > 90 || + horizonHours <= 0 || + horizonHours > 72 || + coarseStepSec < 1 || + coarseStepSec > 120 || + fineStepSec < 0.1 || + fineStepSec > 60 + ) + return null; const horizonMs = fromMs + horizonHours * 3600_000; const coarseMs = coarseStepSec * 1000; const fineMs = Math.max(1000, (fineStepSec || 5) * 1000); @@ -338,11 +368,16 @@ export function findNextSatellitePass({ const denom = 2 * (peakLeftElev - 2 * maxElevDeg + peakRightElev); const shift = ((peakLeftElev - peakRightElev) / denom) * peakStepMs; if (Math.abs(shift) < peakStepMs) { - refinedPeakMs = Math.max(riseMs, Math.min(setMs, Math.round(maxElevMs + shift))); - refinedPeakElevDeg = - Math.min(90, maxElevDeg - - Math.pow(peakLeftElev - peakRightElev, 2) / - (8 * (peakLeftElev - 2 * maxElevDeg + peakRightElev))); + refinedPeakMs = Math.max( + riseMs, + Math.min(setMs, Math.round(maxElevMs + shift)), + ); + refinedPeakElevDeg = Math.min( + 90, + maxElevDeg - + Math.pow(peakLeftElev - peakRightElev, 2) / + (8 * (peakLeftElev - 2 * maxElevDeg + peakRightElev)), + ); } } @@ -365,7 +400,10 @@ export function findNextSatellitePass({ if (!hasVisibleSegment) { hasVisibleSegment = hasVisibleInterval(riseMs, setMs, (time) => { const look = lookAnglesAt(satrec, time, latDeg, lonDeg); - return [Boolean(look && isSatelliteSunlit(look.satECI, time)), isObserverDark(latDeg, lonDeg, time, maxSunElevDeg)]; + return [ + Boolean(look && isSatelliteSunlit(look.satECI, time)), + isObserverDark(latDeg, lonDeg, time, maxSunElevDeg), + ]; }); } @@ -391,7 +429,6 @@ export function findNextSatellitePass({ return null; } - /** Resolve shadow/twilight crossings to 20ms so short overlap is not lost between samples. */ export function hasVisibleInterval(start, end, conditionsAt) { const visible = (states) => states[0] && states[1]; diff --git a/src/data/satellitePass.test.mjs b/src/data/satellitePass.test.mjs index 66db1a327..f195dff99 100644 --- a/src/data/satellitePass.test.mjs +++ b/src/data/satellitePass.test.mjs @@ -239,25 +239,44 @@ test('findNextSatellitePass samples the peak at fineStepSec', () => { assert.ok(Math.abs(fine.maxElevDeg - coarse.maxElevDeg) < 0.01); }); - test('invalid numerical search options return promptly without unbounded scans', () => { const satrec = twoline2satrec(L1, L2); const options = { satrec, ...AUSTIN, fromMs: FROM_MS }; - for (const change of [{ coarseStepSec: 0 }, { coarseStepSec: -1 }, { horizonHours: Infinity }, { horizonHours: 73 }, { fineStepSec: NaN }, { latDeg: 91 }, { fromMs: NaN }]) { + for (const change of [ + { coarseStepSec: 0 }, + { coarseStepSec: -1 }, + { horizonHours: Infinity }, + { horizonHours: 73 }, + { fineStepSec: NaN }, + { latDeg: 91 }, + { fromMs: NaN }, + ]) { assert.equal(findNextSatellitePass({ ...options, ...change }), null); } }); test('visibility catches a short overlap between shadow exit and dawn', () => { - assert.equal(hasVisibleInterval(0, 5000, (t) => [t >= 2400, t <= 2500]), true); - assert.equal(hasVisibleInterval(0, 5000, (t) => [t >= 2500, t <= 2400]), false); + assert.equal( + hasVisibleInterval(0, 5000, (t) => [t >= 2400, t <= 2500]), + true, + ); + assert.equal( + hasVisibleInterval(0, 5000, (t) => [t >= 2500, t <= 2400]), + false, + ); }); test('a pass already underway clips rise to the requested start', () => { const satrec = twoline2satrec(L1, L2); const first = findNextSatellitePass({ satrec, ...AUSTIN, fromMs: FROM_MS }); - const clipped = findNextSatellitePass({ satrec, ...AUSTIN, fromMs: first.maxElevMs }); + const clipped = findNextSatellitePass({ + satrec, + ...AUSTIN, + fromMs: first.maxElevMs, + }); assert.equal(clipped.riseMs, first.maxElevMs); assert.ok(clipped.setMs >= clipped.riseMs); - assert.ok(clipped.maxElevMs >= clipped.riseMs && clipped.maxElevMs <= clipped.setMs); + assert.ok( + clipped.maxElevMs >= clipped.riseMs && clipped.maxElevMs <= clipped.setMs, + ); }); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index ccaacf7e4..bad66f8fc 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -657,15 +657,15 @@ test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins // ── Voice: instruction-only, tool schema unchanged ───────────────────── test('the voice TOOL SCHEMA matches the pinned release — the mission mapping is instructions only', () => { - // ALPR deliberately adds its ID to the two layer menus and visibility aliases. + // Analyst layers and the separate satellite-pass tool deliberately extend the schema. // Canonical serialization pins every tool name, description, property and // ordering while allowing source formatting. Derived from the unchanged // release schema before formatting (the previous source-byte pin passed). const block = JSON.stringify(GEV_REALTIME_TOOLS); - assert.equal(block.length, 26208, 'serialized tool schema length drifted'); + assert.equal(block.length, 27271, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '135d4ec66239777da34a8476cdf8348574421d7afd2e981cc3490909a5bc8686', + 'da5f1bb334d1e882d63098cb35b29afde273947c0807ef6af2ef3d25a0df4918', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8'); diff --git a/src/hud.js b/src/hud.js index 16288fcd3..f40a32da5 100644 --- a/src/hud.js +++ b/src/hud.js @@ -20,7 +20,7 @@ import { CITY_POIS } from './locations.js'; import { composeLocalityTag } from './hudLocality.js'; import { ellipsoidalToMslDisplayM, ensureGeoidReady, geoidHeight } from './data/geoid.js'; import { getBasemapLabelContext } from './voice/gevActions.js'; -import { hudSummaryLayerContext, hudTelemetryProvenanceTag, isHudSummaryUnconfigured } from './hudSummaryResponse.js'; +import { hudSummaryMatchesProvenance, hudSummaryLayerContext, hudTelemetryProvenanceTag, isHudSummaryUnconfigured } from './hudSummaryResponse.js'; /** Color palettes keyed by shader mode; applied as CSS custom properties. */ const HUD_COLORS = { @@ -677,7 +677,7 @@ export class IntelHUD { if (!response.ok || !data?.summary) { throw new Error(data?.error || `HTTP ${response.status}`); } - this._setSummaryText(data.summary, animate); + this._setSummaryText(hudSummaryMatchesProvenance(data.summary, context.feedProvenance) ? data.summary : fallbackText, animate); } catch (error) { if (error?.name !== 'AbortError') { console.warn('[HUD] AI summary unavailable:', error); diff --git a/src/hudSummaryResponse.js b/src/hudSummaryResponse.js index 12c50c1b6..65ec252bb 100644 --- a/src/hudSummaryResponse.js +++ b/src/hudSummaryResponse.js @@ -95,3 +95,10 @@ export function hudTelemetryProvenanceTag(layers = [], options) { .slice(0, 2); return `${envelope.overall.toUpperCase()}${names.length ? ` ${names.join('/')}` : ''}`; } + +/** Require the supplied non-nominal state before showing an AI summary. */ +export function hudSummaryMatchesProvenance(summary, provenance) { + const state = provenance?.overall; + if (!state || state === 'nominal' || state === 'off') return true; + return String(summary || '').toUpperCase().split(/\W+/).includes(String(state).toUpperCase()); +} diff --git a/src/hudSummaryResponse.test.mjs b/src/hudSummaryResponse.test.mjs index 31d474e08..194d66106 100644 --- a/src/hudSummaryResponse.test.mjs +++ b/src/hudSummaryResponse.test.mjs @@ -170,3 +170,10 @@ test('the HUD proxy uses the shared provenance instructions', () => { assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/); assert.doesNotMatch(local, /enabled-layer text labels/); }); + +test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => { + const { hudSummaryMatchesProvenance } = await import('./hudSummaryResponse.js'); + assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'stale' }), false); + assert.equal(hudSummaryMatchesProvenance('Austin stale flights over downtown', { overall: 'stale' }), true); + assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'nominal' }), true); +}); diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js index bafde7ac4..ed26a8883 100644 --- a/src/layers/satellites/orbits.js +++ b/src/layers/satellites/orbits.js @@ -204,14 +204,27 @@ export function createOrbits({ state: layerState, services, parts, source }) { const exactId = /^\d+$/.test(text) ? Number(text) : null; if (exactId !== null) { const sat = layerState._catalog.get(exactId); - return sat?.satrec ? { status: 'ok', noradId: exactId, name: sat.name } : { status: 'not-found' }; + return sat?.satrec + ? { status: 'ok', noradId: exactId, name: sat.name } + : { status: 'not-found' }; } const q = text.toLowerCase(); const rows = [...layerState._catalog].filter(([, sat]) => sat.satrec); - const exact = rows.filter(([, sat]) => String(sat.name).toLowerCase() === q); - const matches = exact.length ? exact : rows.filter(([, sat]) => String(sat.name).toLowerCase().includes(q)); + const exact = rows.filter( + ([, sat]) => String(sat.name).toLowerCase() === q, + ); + const matches = exact.length + ? exact + : rows.filter(([, sat]) => String(sat.name).toLowerCase().includes(q)); if (!matches.length) return { status: 'not-found' }; - if (matches.length > 1) return { status: 'ambiguous', candidates: matches.slice(0, 8).map(([noradId, sat]) => ({ noradId, name: sat.name })), totalMatches: matches.length }; + if (matches.length > 1) + return { + status: 'ambiguous', + candidates: matches + .slice(0, 8) + .map(([noradId, sat]) => ({ noradId, name: sat.name })), + totalMatches: matches.length, + }; return { status: 'ok', noradId: matches[0][0], name: matches[0][1].name }; } diff --git a/src/layers/satellites/records.js b/src/layers/satellites/records.js index 7b4e6603a..eddae75b6 100644 --- a/src/layers/satellites/records.js +++ b/src/layers/satellites/records.js @@ -14,9 +14,15 @@ import { ISS_NORAD } from './policy.js'; */ export function mapAnalystRecord(raw) { const num = (v) => (Number.isFinite(v) ? v : null); - const text = (v) => { const t = String(v ?? '').trim(); return t || null; }; - const noradNum = raw?.noradId == null || raw.noradId === '' ? NaN : Number(raw.noradId); - const noradId = Number.isFinite(noradNum) ? String(Math.trunc(noradNum)) : text(raw?.noradId); + const text = (v) => { + const t = String(v ?? '').trim(); + return t || null; + }; + const noradNum = + raw?.noradId == null || raw.noradId === '' ? NaN : Number(raw.noradId); + const noradId = Number.isFinite(noradNum) + ? String(Math.trunc(noradNum)) + : text(raw?.noradId); const name = text(raw?.name); const group = text(raw?.group); const isIss = noradNum === ISS_NORAD; @@ -30,54 +36,64 @@ export function mapAnalystRecord(raw) { speedMps: num(raw?.speedMps), // Empty records stay class-less; the class table's fallback is VISUAL and // must not leak onto a row that has no satellite identity. - satelliteClass: (noradId || group) ? satelliteClassLabel(group, { isIss }) : null, + satelliteClass: + noradId || group ? satelliteClassLabel(group, { isIss }) : null, group, }; } - /** Create on-demand analyst snapshots owned by one layer instance. */ export function createRecords({ state, parts }) { -function analystGeo(noradId, sat, now) { - const pos = sat?.satrec ? parts.orbits.propagatePosition(sat.satrec, now) : null; - if (pos && Number.isFinite(pos.latitude) && Number.isFinite(pos.longitude)) { + function analystGeo(noradId, sat, now) { + const pos = sat?.satrec + ? parts.orbits.propagatePosition(sat.satrec, now) + : null; + if ( + pos && + Number.isFinite(pos.latitude) && + Number.isFinite(pos.longitude) + ) { + return { + lat: pos.latitude, + lon: pos.longitude, + altitudeM: pos.altitude, + speedMps: Number.isFinite(pos.speedMps) ? pos.speedMps : null, + }; + } + const point = state._points.get(noradId); + if (!point?.position) return null; + const carto = Cesium.Cartographic.fromCartesian(point.position); + if (!carto) return null; return { - lat: pos.latitude, - lon: pos.longitude, - altitudeM: pos.altitude, - speedMps: Number.isFinite(pos.speedMps) ? pos.speedMps : null, + lat: Cesium.Math.toDegrees(carto.latitude), + lon: Cesium.Math.toDegrees(carto.longitude), + altitudeM: carto.height, + speedMps: null, }; } - const point = state._points.get(noradId); - if (!point?.position) return null; - const carto = Cesium.Cartographic.fromCartesian(point.position); - if (!carto) return null; - return { - lat: Cesium.Math.toDegrees(carto.latitude), - lon: Cesium.Math.toDegrees(carto.longitude), - altitudeM: carto.height, - speedMps: null, - }; -} function getAnalystRecords(maxCount = 2000) { if (!state._enabled || !state._catalog.size) return []; - const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; const now = new Date(); const result = []; const emit = (noradId, sat) => { if (result.length >= limit) return false; const pos = analystGeo(noradId, sat, now); if (!pos) return true; - result.push(mapAnalystRecord({ - noradId, - name: sat?.name, - group: sat?.group, - lat: pos.lat, - lon: pos.lon, - altitudeM: pos.altitudeM, - speedMps: pos.speedMps, - })); + result.push( + mapAnalystRecord({ + noradId, + name: sat?.name, + group: sat?.group, + lat: pos.lat, + lon: pos.lon, + altitudeM: pos.altitudeM, + speedMps: pos.speedMps, + }), + ); return result.length < limit; }; for (const [noradId, sat] of state._catalog) { diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 778f89a8c..ea8c7ca1c 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -177,17 +177,20 @@ test('no unchanged Realtime tool definition drifts silently', () => { 'fly_to_location', 'select_nearest_aircraft', 'set_map_stack', + 'analyst_query', + 'next_iss_pass', + 'next_satellite_pass', ]); const unchanged = realtimeTools() .filter((tool) => !TOUCHED.has(tool.name)) .sort((a, b) => a.name.localeCompare(b.name)); - assert.equal(unchanged.length, 21); + assert.equal(unchanged.length, 19); const digest = createHash('sha256') .update(JSON.stringify(unchanged)) .digest('hex') .slice(0, 16); - // ALPR intentionally extends the two layer enums; retain the complete pin. - assert.equal(digest, '6963175a0c9a76de', 'an unchanged Realtime tool definition drifted'); + // Analyst additions and ISS wording correction are explicitly excluded above; all other tool definitions retain their pin. + assert.equal(digest, '7411ea585e534795', 'an unchanged Realtime tool definition drifted'); }); test('Radio volume and mission speed share the Sharpen slider visual language', () => { diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index 9160b85cf..ff1a8dcf9 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -866,7 +866,9 @@ const schemas = [ { name: 'next_satellite_pass', parameters: { - type: 'object', additionalProperties: false, required: ['target'], + type: 'object', + additionalProperties: false, + required: ['target'], properties: { target: { type: 'string', minLength: 1, maxLength: 120 }, latitude: { type: 'number', minimum: -90, maximum: 90 }, diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index e3107f0b3..121bd9de3 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -15,13 +15,13 @@ const stable = (value) => ) : value; -test('the complete Realtime tool payload retains its pre-extraction contract and wording', () => { +test('the complete Realtime tool payload pins the additive analyst and satellite release', () => { const digest = createHash('sha256') .update(JSON.stringify(stable(GEV_REALTIME_TOOLS))) .digest('hex'); assert.equal( digest, - '956381c3456d3644ed7c9cda72910dc68a34d9191e0b3e414ee200c348245214', + '9e33ac0fa5a860a17bb6d79f2c43646b6c36d0c932590bf114814e891a1c96de', ); }); @@ -76,3 +76,12 @@ test('metadata cannot add tools, fields, types or enum values', () => { ]) assert.throws(() => createActionTools(descriptions), TypeError); }); + + +test('all legacy action arguments are byte-identical after removing the deliberate additions', () => { + const legacy = structuredClone(GEV_ACTION_SCHEMAS).filter((tool) => tool.name !== 'next_satellite_pass'); + const layers = legacy.find((tool) => tool.name === 'analyst_query').parameters.properties.layers.items; + layers.enum = layers.enum.filter((key) => !['satellites', 'local-datacenters', 'local-dams'].includes(key)); + // Independently derived by executing trusted c9f9896 actionSchemas in the restricted container. + assert.equal(createHash('sha256').update(JSON.stringify(legacy)).digest('hex'), '820fff21658f6907e1010b2b79c5431a77f4e34afd2277d62d8de46c368b6f8c'); +}); From c96a250253e2bfcbddffc455103f869b9ac0396c Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Tue, 15 Sep 2026 02:30:59 -0500 Subject: [PATCH 09/11] chore: register provenance dependency closure and format regression tests --- docs/CURRENT-STATE.md | 4 ++-- scripts/package-boundaries.json | 4 ++++ src/layers/satellites/source.test.mjs | 10 ++++++++-- src/voice/actionSchemas.test.mjs | 17 ++++++++++++----- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4facc9192..f846936ba 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2,7 +2,7 @@ Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. -Satellite and local infrastructure layers expose on-demand analyst records through their current factory owners. Analyst counts and ranks explicitly cover only bounded examined loaded records; omitted records can change nearest/count and satellite distance is ground distance. Existing tools and result fields remain available. +Satellite and local infrastructure layers expose on-demand analyst records through their current factory owners. Analyst counts and ranks explicitly cover only bounded examined loaded records (default 2,000 per new layer, core satellite rows before dense extras); omitted records can change nearest/count and satellite distance is ground distance. Existing tools and result fields remain available. Place search accepts an explicit Nominatim provider with independently configured search and reverse endpoints. The default offline/Google/Photon/local-fallback order is unchanged when no provider is selected. Provider adapters share normalized coordinates, viewport framing and reverse labels; roads and boundary geometry remain separate services. Portable capped-response and Overpass lexical helpers are exported independently of the Node server. @@ -12,7 +12,7 @@ Source factories have dedicated `layers//source` exports. ALPR and earth Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/result, interruption and completion events; stopping or replacing a session cancels pending actions. Alternate adapters can use the same controls and action runner. -Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds the same 28 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields. +Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 29 tools, retaining the existing 28 names and argument contracts apart from the three additive analyst-layer enum values using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields. Portable source exports provide Radio Browser station normalization, CCTV feed types and regional records independently of HTTP middleware. Radio normalization accepts an explicit URL policy; the standalone directory retains its existing HTTPS rules. Tile coordinate validation accepts explicit zoom bounds with unchanged traffic defaults. Cache, request and rendering owners remain unchanged. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 681791712..30fbc6971 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -264,6 +264,10 @@ "server/providers/openai/realtime.js", "server/providers/openai/tools.js", "src/hudSummaryResponse.js", + "src/data/layerSnapshot.js", + "src/data/feedState.js", + "src/loadingFeedback.js", + "src/data/installationFeedback.js", "src/voice/voiceCost.js", "src/voice/actionSchemas.js", "server/providers/openai/toolDescriptions.js" diff --git a/src/layers/satellites/source.test.mjs b/src/layers/satellites/source.test.mjs index ba0f376ec..810b0d111 100644 --- a/src/layers/satellites/source.test.mjs +++ b/src/layers/satellites/source.test.mjs @@ -136,8 +136,14 @@ test('satellite pass prediction methods report no-tle on cold catalog and coerce assert.equal(issResult.status, 'ok'); assert.deepEqual(issResult, numericResult); - layer._setTrackedSatelliteRefreshStateForTest({ noradId: 25544, name: 'ISS (ZARYA)', satrec, - neighbours: [{ noradId: 10, name: 'STARLINK ONE', satrec }, { noradId: 11, name: 'STARLINK TWO', satrec }], + layer._setTrackedSatelliteRefreshStateForTest({ + noradId: 25544, + name: 'ISS (ZARYA)', + satrec, + neighbours: [ + { noradId: 10, name: 'STARLINK ONE', satrec }, + { noradId: 11, name: 'STARLINK TWO', satrec }, + ], }); assert.equal(layer.resolveSatelliteForPass('25544').noradId, 25544); assert.equal(layer.resolveSatelliteForPass('iss (zarya)').noradId, 25544); diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 121bd9de3..76e7892a2 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -77,11 +77,18 @@ test('metadata cannot add tools, fields, types or enum values', () => { assert.throws(() => createActionTools(descriptions), TypeError); }); - test('all legacy action arguments are byte-identical after removing the deliberate additions', () => { - const legacy = structuredClone(GEV_ACTION_SCHEMAS).filter((tool) => tool.name !== 'next_satellite_pass'); - const layers = legacy.find((tool) => tool.name === 'analyst_query').parameters.properties.layers.items; - layers.enum = layers.enum.filter((key) => !['satellites', 'local-datacenters', 'local-dams'].includes(key)); + const legacy = structuredClone(GEV_ACTION_SCHEMAS).filter( + (tool) => tool.name !== 'next_satellite_pass', + ); + const layers = legacy.find((tool) => tool.name === 'analyst_query').parameters + .properties.layers.items; + layers.enum = layers.enum.filter( + (key) => !['satellites', 'local-datacenters', 'local-dams'].includes(key), + ); // Independently derived by executing trusted c9f9896 actionSchemas in the restricted container. - assert.equal(createHash('sha256').update(JSON.stringify(legacy)).digest('hex'), '820fff21658f6907e1010b2b79c5431a77f4e34afd2277d62d8de46c368b6f8c'); + assert.equal( + createHash('sha256').update(JSON.stringify(legacy)).digest('hex'), + '820fff21658f6907e1010b2b79c5431a77f4e34afd2277d62d8de46c368b6f8c', + ); }); From cfe573c6e646002cff713f1557809a8d777d1a4d Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Tue, 15 Sep 2026 02:32:49 -0500 Subject: [PATCH 10/11] test: update additive tool inventory and satellite boundary closure --- scripts/package-boundaries.json | 1 + src/radioMarkup.test.mjs | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 30fbc6971..2de39fa85 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1719,6 +1719,7 @@ "src/data/infrastructure.js", "src/data/installationFeedback.js", "src/data/issPass.js", + "src/data/satellitePass.js", "src/data/labelArbiter.js", "src/data/layerState.js", "src/data/localGeojson.js", diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index ea8c7ca1c..85ae2cae1 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -24,15 +24,17 @@ const css = readStylesheet(new URL('../style.css', import.meta.url)); function realtimeTools() { return GEV_REALTIME_TOOLS; } -test('Realtime schema exposes the authoritative 28-tool inventory', () => { +test('Realtime schema exposes the authoritative 29-tool inventory', () => { const tools = realtimeTools(); - assert.equal(tools.length, 28); + assert.equal(tools.length, 29); const names = tools.map((tool) => tool.name); - assert.equal(new Set(names).size, 28, 'tool names are unique'); + assert.equal(new Set(names).size, 29, 'tool names are unique'); assert.ok(names.includes('set_context_mode')); assert.ok(names.includes('control_cockpit')); assert.ok(names.includes('select_nearest_aircraft')); assert.ok(names.includes('control_radio')); + assert.ok(names.includes('next_satellite_pass')); + assert.ok(names.includes('next_iss_pass')); // Every tool closes its parameter object: an open schema lets the model // invent arguments the runner silently drops. for (const tool of tools) { From d1a72a35af9897b11e1d0d3fccda96e26222cff1 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Tue, 15 Sep 2026 10:54:32 -0500 Subject: [PATCH 11/11] style: apply current runtime formatter to voice integration --- src/data/analystEngine.js | 85 +++++- src/hud.js | 33 ++- src/hudSummaryResponse.js | 13 +- src/voice/gevActions.js | 532 ++++++++++++++++++++++---------------- 4 files changed, 414 insertions(+), 249 deletions(-) diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index ba49bb0d4..e2a288fc8 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -29,14 +29,54 @@ import { pointInRing } from './naturalEarthRegions.js'; /** Layers the engine understands, with the fields queries may reference. */ export const ANALYST_LAYERS = { - flights: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass'], flags: ['military', 'onGround'] }, - military: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'], flags: ['military', 'onGround'] }, - 'ais-live-vessels': { numeric: ['speedKts', 'courseDeg'], text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'], flags: [] }, - 'local-firms': { numeric: ['frp'], text: ['confidence', 'satellite'], flags: [] }, - earthquakes: { numeric: ['magnitude', 'depthKm'], text: ['place'], flags: [] }, - satellites: { numeric: ['altitudeM', 'speedMps'], text: ['name', 'noradId', 'satelliteClass', 'group'], flags: [] }, - 'local-datacenters': { numeric: [], text: ['name', 'operator', 'capacity'], flags: [] }, - 'local-dams': { numeric: [], text: ['name', 'operator', 'river', 'output'], flags: [] }, + flights: { + numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], + text: [ + 'callsign', + 'icao24', + 'originCountry', + 'operator', + 'routeOrigin', + 'routeDestination', + 'aircraftClass', + ], + flags: ['military', 'onGround'], + }, + military: { + numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], + text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'], + flags: ['military', 'onGround'], + }, + 'ais-live-vessels': { + numeric: ['speedKts', 'courseDeg'], + text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'], + flags: [], + }, + 'local-firms': { + numeric: ['frp'], + text: ['confidence', 'satellite'], + flags: [], + }, + earthquakes: { + numeric: ['magnitude', 'depthKm'], + text: ['place'], + flags: [], + }, + satellites: { + numeric: ['altitudeM', 'speedMps'], + text: ['name', 'noradId', 'satelliteClass', 'group'], + flags: [], + }, + 'local-datacenters': { + numeric: [], + text: ['name', 'operator', 'capacity'], + flags: [], + }, + 'local-dams': { + numeric: [], + text: ['name', 'operator', 'river', 'output'], + flags: [], + }, }; const EARTH_R_KM = 6371; @@ -131,7 +171,7 @@ export function createAnalystEngine(providers) { async function query(spec = {}) { const layers = spec.followUp && lastResult - ? null // follow-up: re-filter the remembered set, no re-snapshot + ? null // follow-up: re-filter the remembered set, no re-snapshot : Array.isArray(spec.layers) && spec.layers.length ? spec.layers : ['flights']; @@ -161,7 +201,20 @@ export function createAnalystEngine(providers) { const rows = providers.getRecords(key) || []; const snapshot = providers.getLayerSnapshot?.(key); if (snapshot) queriedSnapshots.push(snapshot); - layersQueried.push({ layerKey: key, records: rows.length, ...(snapshot ? { feedState: snapshot.feedState, source: snapshot.source, lastUpdate: snapshot.lastUpdate, enabled: snapshot.enabled, error: snapshot.error } : {}), ...providers.getRecordCoverage?.(key, rows) }); + layersQueried.push({ + layerKey: key, + records: rows.length, + ...(snapshot + ? { + feedState: snapshot.feedState, + source: snapshot.source, + lastUpdate: snapshot.lastUpdate, + enabled: snapshot.enabled, + error: snapshot.error, + } + : {}), + ...providers.getRecordCoverage?.(key, rows), + }); for (const row of rows) records.push({ layerKey: key, ...row }); } } @@ -206,8 +259,8 @@ export function createAnalystEngine(providers) { // nothing in the payload to show which. const subjectCenter = Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon) - ? { lat: subject.lat, lon: subject.lon } - : null; + ? { lat: subject.lat, lon: subject.lon } + : null; const center = explicitCenter || subjectCenter || { lat: view.lat, lon: view.lon }; resolvedScope = { center, km: Number(scope.km) || 100 }; @@ -273,9 +326,13 @@ export function createAnalystEngine(providers) { coverage: { layersQueried, scope: scopeNote, - ...(queriedSnapshots.length ? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) } : {}), + ...(queriedSnapshots.length + ? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) } + : {}), followUp: Boolean(spec.followUp && lastResult), - note: layersQueried.some((layer) => layer.basis === 'bounded-loaded-records') + note: layersQueried.some( + (layer) => layer.basis === 'bounded-loaded-records', + ) ? 'Counts and ranks cover the bounded examined loaded records only; omitted records may change the nearest item or count. Satellite distance is ground distance, not slant range.' : 'client-side data only — answers cover what the enabled layers currently hold', }, diff --git a/src/hud.js b/src/hud.js index 5bebd46f2..236df7013 100644 --- a/src/hud.js +++ b/src/hud.js @@ -24,7 +24,12 @@ import { geoidHeight, } from './data/geoid.js'; import { getBasemapLabelContext } from './voice/gevActions.js'; -import { hudSummaryMatchesProvenance, hudSummaryLayerContext, hudTelemetryProvenanceTag, isHudSummaryUnconfigured } from './hudSummaryResponse.js'; +import { + hudSummaryMatchesProvenance, + hudSummaryLayerContext, + hudTelemetryProvenanceTag, + isHudSummaryUnconfigured, +} from './hudSummaryResponse.js'; /** Color palettes keyed by shader mode; applied as CSS custom properties. */ const HUD_COLORS = { @@ -641,15 +646,17 @@ export class IntelHUD { const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM; const altTag = altDisplayM >= 1000 - ? `${(altDisplayM / 1000).toFixed(1)}KM` - : `${Math.round(altDisplayM)}M`; + ? `${(altDisplayM / 1000).toFixed(1)}KM` + : `${Math.round(altDisplayM)}M`; const winTag = window ? `${Math.max(1, Math.round(window.widthKm))}x${Math.max(1, Math.round(window.heightKm))}KM` : 'N/A'; // NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector. const localityTag = composeLocalityTag(nearest, m.latDeg, m.lonDeg); - const provenance = hudTelemetryProvenanceTag(this._dataManager?.getAll?.() || []); + const provenance = hudTelemetryProvenanceTag( + this._dataManager?.getAll?.() || [], + ); const line = `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`; return provenance ? `${line} | ${provenance}` : line; } @@ -736,7 +743,12 @@ export class IntelHUD { if (!response.ok || !data?.summary) { throw new Error(data?.error || `HTTP ${response.status}`); } - this._setSummaryText(hudSummaryMatchesProvenance(data.summary, context.feedProvenance) ? data.summary : fallbackText, animate); + this._setSummaryText( + hudSummaryMatchesProvenance(data.summary, context.feedProvenance) + ? data.summary + : fallbackText, + animate, + ); } catch (error) { if (error?.name !== 'AbortError') { console.warn('[HUD] AI summary unavailable:', error); @@ -770,8 +782,8 @@ export class IntelHUD { const enabledLayers = this._dataManager ?.getAll?.() - ?.filter((layer) => layer.enabled) - .map((layer) => layer.name) || []; + ?.filter((layer) => layer.enabled) + .map((layer) => layer.name) || []; return { placeLabels: labels.placeLabels, streetLabels: labels.streetLabels, @@ -907,7 +919,12 @@ export class IntelHUD { this._dataManager = dataManager || null; if (typeof this._dataManager?.subscribe === 'function') { this._dataManagerUnsubscribe = this._dataManager.subscribe((change) => { - if (['visibility', 'refresh-transition', 'refresh-cancelled'].includes(change?.type)) this._markSummaryDirty(); + if ( + ['visibility', 'refresh-transition', 'refresh-cancelled'].includes( + change?.type, + ) + ) + this._markSummaryDirty(); }); } this._markSummaryDirty(); diff --git a/src/hudSummaryResponse.js b/src/hudSummaryResponse.js index 7d2374a6b..82b12e55f 100644 --- a/src/hudSummaryResponse.js +++ b/src/hudSummaryResponse.js @@ -30,8 +30,8 @@ export function keylessHudSummaryResponse(apiKey) { export function isHudSummaryUnconfigured(status, data) { const keys = data !== null && typeof data === 'object' && !Array.isArray(data) - ? Object.keys(data) - : []; + ? Object.keys(data) + : []; return ( keys.length === 4 && status === 200 && @@ -93,7 +93,9 @@ export function hudTelemetryProvenanceTag(layers = [], options) { const envelope = feedProvenanceEnvelope(snapshots, options); if (!envelope.overall || envelope.overall === 'nominal') return null; const names = envelope.layers - .filter((s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off') + .filter( + (s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off', + ) .map((s) => String(s.name || s.id || 'LAYER').toUpperCase()) .slice(0, 2); return `${envelope.overall.toUpperCase()}${names.length ? ` ${names.join('/')}` : ''}`; @@ -103,5 +105,8 @@ export function hudTelemetryProvenanceTag(layers = [], options) { export function hudSummaryMatchesProvenance(summary, provenance) { const state = provenance?.overall; if (!state || state === 'nominal' || state === 'off') return true; - return String(summary || '').toUpperCase().split(/\W+/).includes(String(state).toUpperCase()); + return String(summary || '') + .toUpperCase() + .split(/\W+/) + .includes(String(state).toUpperCase()); } diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index fd1787d97..3831f9665 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1,4 +1,8 @@ -import { layerSnapshot, layerSnapshots, feedProvenanceEnvelope } from '../data/layerSnapshot.js'; +import { + layerSnapshot, + layerSnapshots, + feedProvenanceEnvelope, +} from '../data/layerSnapshot.js'; import { readLayerLifecycleSummary } from './layerSummary.js'; export { readLayerLifecycleSummary } from './layerSummary.js'; import { defaultGeospatial } from '../search/defaults.js'; @@ -300,7 +304,7 @@ function cachesFor(service) { service.signal?.addEventListener( 'abort', () => { - for (const cache of Object.values(caches)) cache.clear(); + for (const cache of Object.values(caches)) cache.clear(); }, { once: true }, ); @@ -519,16 +523,16 @@ export function createGevActionRunner({ ...(args.locationQuery ? { query: args.locationQuery } : {}), ...(hasCoordinates ? { - latitude: Number(args.latitude), - longitude: Number(args.longitude), + latitude: Number(args.latitude), + longitude: Number(args.longitude), } : {}), }; const layer = await runGevAction( 'set_layer_visibility', { - layerId, - enabled: true, + layerId, + enabled: true, }, runOptions, ); @@ -712,10 +716,10 @@ export function createGevActionRunner({ } const cancellationState = () => withContextModeVocabulary( - typeof styleManager.getContextModeState === 'function' - ? styleManager.getContextModeState() - : {}, - ); + typeof styleManager.getContextModeState === 'function' + ? styleManager.getContextModeState() + : {}, + ); if (!current()) { return { ok: false, @@ -731,8 +735,8 @@ export function createGevActionRunner({ const result = await styleManager.setContextMode( mode === 'off' ? null : mode, { - signal: runOptions.signal, - isCurrent: runOptions.isCurrent, + signal: runOptions.signal, + isCurrent: runOptions.isCurrent, }, ); if (!current() && result?.ok !== true) { @@ -803,8 +807,8 @@ export function createGevActionRunner({ rollbackTarget = styleManager.getAircraftTrackingTarget?.() || null; const contextState = typeof styleManager.getContextModeState === 'function' - ? styleManager.getContextModeState() - : {}; + ? styleManager.getContextModeState() + : {}; priorContextMode = contextState?.mode || null; const contactsReady = contextState?.mode === 'flights' && @@ -824,8 +828,8 @@ export function createGevActionRunner({ if (contextResult?.ok !== true || !current()) { const contextRollback = contextChangedForEntry ? await styleManager.setContextMode(priorContextMode, { - claimVisualAuthority: false, - }) + claimVisualAuthority: false, + }) : null; return { ok: false, @@ -873,12 +877,12 @@ export function createGevActionRunner({ const contextRollback = await styleManager.setContextMode( priorContextMode, { - // Undoing this action's own precondition — still choreography. - claimVisualAuthority: false, + // Undoing this action's own precondition — still choreography. + claimVisualAuthority: false, ...(current() ? { - signal: runOptions.signal, - isCurrent: runOptions.isCurrent, + signal: runOptions.signal, + isCurrent: runOptions.isCurrent, } : {}), }, @@ -896,8 +900,8 @@ export function createGevActionRunner({ setPanelOpen(styleManager, 'data-panel', true); const focusedLayer = layerId && dataManager.layers.has(layerId) - ? focusDataLayerRow(layerId) - : null; + ? focusDataLayerRow(layerId) + : null; return { ok: true, action: 'show_data_layers_menu', @@ -907,10 +911,10 @@ export function createGevActionRunner({ .getAll() .filter((layer) => layer.showInTogglePanel !== false) .map((layer) => ({ - id: layer.id, - name: layer.name, - enabled: layer.enabled, - count: layer.stats?.count || 0, + id: layer.id, + name: layer.name, + enabled: layer.enabled, + count: layer.stats?.count || 0, })), }; } @@ -924,16 +928,16 @@ export function createGevActionRunner({ typeof styleManager?.runImmediateLocationNavigation === 'function' ? (navigate) => styleManager.runImmediateLocationNavigation(navigate) - : null, + : null, beginDeferred: typeof styleManager?.beginDeferredLocationNavigation === 'function' - ? () => styleManager.beginDeferredLocationNavigation() - : null, + ? () => styleManager.beginDeferredLocationNavigation() + : null, reassertDeferred: typeof styleManager?.reassertDeferredLocationNavigation === 'function' ? (generation) => styleManager.reassertDeferredLocationNavigation(generation) - : null, + : null, onStart: () => { if (typeof styleManager?.beginLocationNavigation === 'function') { styleManager.beginLocationNavigation(); @@ -1180,8 +1184,8 @@ function sanitizeAnnotationSpec(spec) { out.points = out.points .slice(0, MAX_ROUTE_POINTS) .map((p) => - p && typeof p === 'object' && typeof p.target === 'string' - ? { ...p, target: clampStr(p.target, MAX_TARGET_LEN) } + p && typeof p === 'object' && typeof p.target === 'string' + ? { ...p, target: clampStr(p.target, MAX_TARGET_LEN) } : p, ); } @@ -1644,8 +1648,8 @@ export async function controlRadio( // viewport's nearest station. const action = requestedAction === 'play' && hasSelectionCriteria - ? 'select' - : requestedAction; + ? 'select' + : requestedAction; const readRadioLifecycle = () => readLayerLifecycleSummary(dataManager, 'radio'); @@ -1666,11 +1670,11 @@ export async function controlRadio( const intentSummary = () => lastIntentOutcome ? { - phase: lastIntentOutcome.phase, - cancellationReason: lastIntentOutcome.cancellationReason || null, - successorIntentEpoch: lastIntentOutcome.successorIntentEpoch ?? null, - successorEnabled: lastIntentOutcome.successorEnabled ?? null, - successorOrigin: lastIntentOutcome.successorOrigin ?? null, + phase: lastIntentOutcome.phase, + cancellationReason: lastIntentOutcome.cancellationReason || null, + successorIntentEpoch: lastIntentOutcome.successorIntentEpoch ?? null, + successorEnabled: lastIntentOutcome.successorEnabled ?? null, + successorOrigin: lastIntentOutcome.successorOrigin ?? null, } : {}; @@ -1832,7 +1836,7 @@ export async function controlRadio( { volume: volumePct / 100 }, { origin: 'voice' }, ) - : radio.setVolume(volumePct / 100); + : radio.setVolume(volumePct / 100); if (volumeApplied === false) { return { ok: false, @@ -1970,12 +1974,12 @@ export async function controlRadio( if (!radioActionIsCurrent(options)) return cancelled(summarize); const station = radio.selectRequestedStation?.( { - categoryId: String(args.category || 'all'), - anchor: location ? { lat: location.lat, lon: location.lon } : null, - country: normalizedCountry.empty - ? String(location?.country || '') - : normalizedCountry.code, - stationQuery: String(args.stationQuery || ''), + categoryId: String(args.category || 'all'), + anchor: location ? { lat: location.lat, lon: location.lon } : null, + country: normalizedCountry.empty + ? String(location?.country || '') + : normalizedCountry.code, + stationQuery: String(args.stationQuery || ''), }, { autoplay: false }, ); @@ -2097,23 +2101,23 @@ async function trackEntity(viewer, dataManager, styleManager, args = {}) { 'fire', 'track_entity', () => { - flyToLandmark(viewer, strongest.latitude, strongest.longitude, { + flyToLandmark(viewer, strongest.latitude, strongest.longitude, { range: 14000, pitch: -50, heading: 0, buildingHeight: 0, duration: 2.2, - }); - return { + }); + return { ok: true, action: 'track_entity', kind: 'fire', layerId: 'local-firms', - label: strongest.label || 'Strongest fire', + label: strongest.label || 'Strongest fire', latitude: strongest.latitude, longitude: strongest.longitude, - frp: strongest.frp ?? null, - }; + frp: strongest.frp ?? null, + }; }, ); } @@ -2152,39 +2156,39 @@ async function trackEntity(viewer, dataManager, styleManager, args = {}) { family.kind, 'track_entity', () => { - let trackedOk = false; - if (family.kind === 'vessel') { - trackedOk = !!module.selectById?.(found.mmsi); - flyToLandmark(viewer, found.latitude, found.longitude, { + let trackedOk = false; + if (family.kind === 'vessel') { + trackedOk = !!module.selectById?.(found.mmsi); + flyToLandmark(viewer, found.latitude, found.longitude, { range: 6000, pitch: -45, heading: 0, buildingHeight: 0, duration: 2.0, - }); - } else if (family.kind === 'satellite') { - trackedOk = !!module.trackById?.(found.noradId, { origin: 'voice' }); - } else { - trackedOk = !!module.trackById?.(found.icao24, { origin: 'voice' }); - } + }); + } else if (family.kind === 'satellite') { + trackedOk = !!module.trackById?.(found.noradId, { origin: 'voice' }); + } else { + trackedOk = !!module.trackById?.(found.icao24, { origin: 'voice' }); + } - return { - ok: trackedOk, - action: 'track_entity', - layerId: family.layerId, - kind: family.kind, - // Aircraft follow the flight layers' label convention (callsign → - // registration → icao24) so the spoken name matches what the UI shows; - // `registration` is absent on vessels/satellites and simply falls - // through to their own name/id links. - label: formatTrackedEntityLabel(found, query), - latitude: found.latitude ?? null, - longitude: found.longitude ?? null, + return { + ok: trackedOk, + action: 'track_entity', + layerId: family.layerId, + kind: family.kind, + // Aircraft follow the flight layers' label convention (callsign → + // registration → icao24) so the spoken name matches what the UI shows; + // `registration` is absent on vessels/satellites and simply falls + // through to their own name/id links. + label: formatTrackedEntityLabel(found, query), + latitude: found.latitude ?? null, + longitude: found.longitude ?? null, altitudeM: Number.isFinite(found.altitudeM) ? Math.round(found.altitudeM) : null, - error: trackedOk ? null : 'Match found but tracking failed', - }; + error: trackedOk ? null : 'Match found but tracking failed', + }; }, ); } @@ -2337,40 +2341,40 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) { 'frame', 'frame_overhead', () => { - viewer.camera.flyToBoundingSphere(sphere, { - duration: 2.0, + viewer.camera.flyToBoundingSphere(sphere, { + duration: 2.0, offset: new Cesium.HeadingPitchRange( viewer.camera.heading, pitch, sphere.radius * 2.4, ), - }); + }); - let detectionEnabled = false; - try { - const detectionState = styleManager?.getDetectionState?.(); - if (detectionState?.detectionMode === 'OFF') { - const detectionResult = styleManager.setDetection({ mode: 'dense' }); - detectionEnabled = detectionResult?.ok === true; - } else if (detectionState) { - detectionEnabled = true; + let detectionEnabled = false; + try { + const detectionState = styleManager?.getDetectionState?.(); + if (detectionState?.detectionMode === 'OFF') { + const detectionResult = styleManager.setDetection({ mode: 'dense' }); + detectionEnabled = detectionResult?.ok === true; + } else if (detectionState) { + detectionEnabled = true; + } + } catch { + // detection facade unavailable; framing still succeeded } - } catch { - // detection facade unavailable; framing still succeeded - } - return { - ok: true, - action: 'frame_overhead', - layerId, - radiusKm: Math.round(radiusKm), - count: entries.length, - detectionEnabled, - nearest: entries.slice(0, 5).map((entry) => ({ - id: entry.id || entry.icao24 || entry.mmsi || null, - label: entry.label || entry.callsign || entry.name || null, - })), - }; + return { + ok: true, + action: 'frame_overhead', + layerId, + radiusKm: Math.round(radiusKm), + count: entries.length, + detectionEnabled, + nearest: entries.slice(0, 5).map((entry) => ({ + id: entry.id || entry.icao24 || entry.mmsi || null, + label: entry.label || entry.callsign || entry.name || null, + })), + }; }, ); } @@ -2491,8 +2495,8 @@ export async function getBasemapLabelContext( ]).slice(0, 16), nearbyPlaceLabels: uniqueStrings( (nearbyPlaces || []).flatMap((nearbyPlace) => [ - nearbyPlace.name, - nearbyPlace.address, + nearbyPlace.name, + nearbyPlace.address, ]), ).slice(0, 24), }; @@ -2661,7 +2665,8 @@ function nextIssPass(viewer, dataManager, args) { peakElevationDeg: Math.round(pass.maxElevDeg), riseDirection: compassDir(pass.riseAzDeg), visible: typeof pass.visible === 'boolean' ? pass.visible : null, - visibilityNote: 'Geometric illumination estimate only; weather, brightness and orbital-element age affect actual visibility.', + visibilityNote: + 'Geometric illumination estimate only; weather, brightness and orbital-element age affect actual visibility.', setIso: new Date(pass.setMs).toISOString(), peakIso: new Date(pass.maxElevMs).toISOString(), }; @@ -2669,22 +2674,55 @@ function nextIssPass(viewer, dataManager, args) { function nextSatellitePass(viewer, dataManager, args) { const layer = dataManager?.layers?.get('satellites')?.module; - const identity = layer?.resolveSatelliteForPass?.(args.target) || { status: 'not-found' }; - if (identity.status !== 'ok') return { - ok: false, action: 'next_satellite_pass', ...identity, - error: identity.status === 'ambiguous' ? 'Several loaded satellites match. Choose a NORAD ID from candidates.' : 'No loaded satellite matches. Enable satellites and use an exact name or NORAD ID.', + const identity = layer?.resolveSatelliteForPass?.(args.target) || { + status: 'not-found', }; + if (identity.status !== 'ok') + return { + ok: false, + action: 'next_satellite_pass', + ...identity, + error: + identity.status === 'ambiguous' + ? 'Several loaded satellites match. Choose a NORAD ID from candidates.' + : 'No loaded satellite matches. Enable satellites and use an exact name or NORAD ID.', + }; // Reuse the legacy location fallback and result formatting, substituting only // this explicitly resolved catalog identity and the optional visibility filter. - const adapter = { layers: new Map([['satellites', { module: { - getNextIssPass: (options) => layer.getNextSatellitePass(identity.noradId, { ...options, requireVisible: args.visibleOnly === true }), - } }]]) }; + const adapter = { + layers: new Map([ + [ + 'satellites', + { + module: { + getNextIssPass: (options) => + layer.getNextSatellitePass(identity.noradId, { + ...options, + requireVisible: args.visibleOnly === true, + }), + }, + }, + ], + ]), + }; const result = nextIssPass(viewer, adapter, args); if (result.error) { - result.error = result.error.replace(/ISS/g, identity.name || String(identity.noradId)); - if (args.visibleOnly === true) result.error += ' Search required estimated illumination under a dark sky.'; + result.error = result.error.replace( + /ISS/g, + identity.name || String(identity.noradId), + ); + if (args.visibleOnly === true) + result.error += + ' Search required estimated illumination under a dark sky.'; } - return { ...result, action: 'next_satellite_pass', noradId: identity.noradId, name: identity.name, visibleOnly: args.visibleOnly === true, horizonHours: 24 }; + return { + ...result, + action: 'next_satellite_pass', + noradId: identity.noradId, + name: identity.name, + visibleOnly: args.visibleOnly === true, + horizonHours: 24, + }; } function normalizePanelId(value) { @@ -2716,21 +2754,21 @@ function normalizeCockpitNavigationHints(rawAction) { const targetLayer = raw.includes('vessel') || raw.includes('ship') || raw.includes('ais') - ? 'ais-live-vessels' + ? 'ais-live-vessels' : raw.includes('installation') || raw.includes('facility') || raw.includes('base') - ? 'military-installations' - : raw.includes('military') - ? 'military' - : null; + ? 'military-installations' + : raw.includes('military') + ? 'military' + : null; const aircraftClass = raw.includes('helicopter') || raw.includes('helo') || raw.includes('chopper') - ? 'helicopter' - : null; + ? 'helicopter' + : null; return { targetLayer, @@ -2839,10 +2877,10 @@ async function flyToRequestedLocation( placeSearch = unavailablePlaceSearch, searchNavigation = searchAndFlyTo, signal, - onStart = null, - runImmediate = null, - beginDeferred = null, - reassertDeferred = null, + onStart = null, + runImmediate = null, + beginDeferred = null, + reassertDeferred = null, } = {}, ) { const requestedRangeM = Number(args.rangeM); @@ -2865,11 +2903,11 @@ async function flyToRequestedLocation( ? new Promise((resolve) => { settleArrival = resolve; }) - : null; + : null; const arrivalHooks = arrival ? { - onComplete: () => settleArrival?.('arrived'), - onCancel: () => settleArrival?.('cancelled'), + onComplete: () => settleArrival?.('arrived'), + onCancel: () => settleArrival?.('cancelled'), } : {}; const afterArrival = async (result, label) => { @@ -2884,12 +2922,12 @@ async function flyToRequestedLocation( if (locationId) { const result = immediate(() => flyToPresetLocation(viewer, locationId, { - ...(rangeM || args.viewMode === 'close' - ? { range: rangeM || 250 } - : { viewMode: 'overview' }), - duration: 2.2, - onStart: immediateOnStart, - ...arrivalHooks, + ...(rangeM || args.viewMode === 'close' + ? { range: rangeM || 250 } + : { viewMode: 'overview' }), + duration: 2.2, + onStart: immediateOnStart, + ...arrivalHooks, }), ); if (result === false) @@ -2914,13 +2952,13 @@ async function flyToRequestedLocation( if (Number.isFinite(latitude) && Number.isFinite(longitude)) { const result = immediate(() => flyToLandmark(viewer, latitude, longitude, { - range: rangeM || 250, - pitch: -35, - heading: 0, - buildingHeight: 0, - duration: 2.2, - onStart: immediateOnStart, - ...arrivalHooks, + range: rangeM || 250, + pitch: -35, + heading: 0, + buildingHeight: 0, + duration: 2.2, + onStart: immediateOnStart, + ...arrivalHooks, }), ); if (result === false) @@ -2946,10 +2984,10 @@ async function flyToRequestedLocation( if (poiMatch) { const result = immediate(() => flyToPOI(viewer, poiMatch.cityId, poiMatch.index, { - duration: 2.2, - onStart: immediateOnStart, - ...arrivalHooks, - ...(rangeM ? { range: rangeM } : {}), + duration: 2.2, + onStart: immediateOnStart, + ...arrivalHooks, + ...(rangeM ? { range: rangeM } : {}), }), ); const poi = CITY_POIS[poiMatch.cityId]?.pois?.[poiMatch.index]; @@ -3029,19 +3067,19 @@ function getCurrentViewState( style: styleManager.activeStyle || 'normal', context: typeof styleManager.getContextModeState === 'function' - ? { - ...withContextModeVocabulary(styleManager.getContextModeState()), - // The numbers on the operator's Contacts panel, so a window/count - // question can be answered from what they are looking at. + ? { + ...withContextModeVocabulary(styleManager.getContextModeState()), + // The numbers on the operator's Contacts panel, so a window/count + // question can be answered from what they are looking at. ...(activeContactsWindow(dataManager) ? { contactsWindow: activeContactsWindow(dataManager) } : {}), - } - : null, + } + : null, cockpit: typeof styleManager.getCockpitState === 'function' - ? styleManager.getCockpitState() - : null, + ? styleManager.getCockpitState() + : null, controls: typeof styleManager.getControlState === 'function' ? styleManager.getControlState() @@ -3058,7 +3096,9 @@ function getCurrentViewState( source: layerSnapshot(layer).source, lastUpdate: layerSnapshot(layer).lastUpdate, })), - feedProvenance: feedProvenanceEnvelope(layerSnapshots(dataManager.getAll()).filter((layer) => layer.enabled)), + feedProvenance: feedProvenanceEnvelope( + layerSnapshots(dataManager.getAll()).filter((layer) => layer.enabled), + ), }; } @@ -3092,7 +3132,7 @@ async function getEntityContext( limit, target: viewTarget, }) - : []; + : []; const scene = await scenePromise; if ((scope === 'selected' || scope === 'auto') && selected) { @@ -3178,14 +3218,14 @@ function aircraftProximityWindowForQuery(dataManager, args, result) { items: items .slice(0, Math.round(clampNumber(args.limit, 1, 50, 12))) .map((item) => ({ - layerKey: item.layerKey, - id: item.id, - ...(item.icao24 ? { icao24: item.icao24 } : {}), - ...(item.callsign ? { callsign: item.callsign } : {}), + layerKey: item.layerKey, + id: item.id, + ...(item.icao24 ? { icao24: item.icao24 } : {}), + ...(item.callsign ? { callsign: item.callsign } : {}), ...(Number.isFinite(item.distance) ? { distanceKm: Math.round(item.distance / 100) / 10 } : {}), - })), + })), summary: { count }, coverage: { layersQueried: result?.coverage?.layersQueried || [], @@ -3283,15 +3323,15 @@ function visibleEntityContexts( insertNearestRecord( nearbyRecords, { - record, - distanceScore: target + record, + distanceScore: target ? approximateCoordinateDistanceSq( targetLat, targetLon, record.latitude, record.longitude, ) - : 0, + : 0, }, VISIBLE_ENTITY_SHORTLIST, ); @@ -3448,16 +3488,16 @@ async function getBasemapContext( const cachedNearbyPlaces = shouldFetchNearbyPlaces(cameraHeightM) && nearbyPlacesCache.has(nearbyCacheKey) - ? nearbyPlacesCache.get(nearbyCacheKey) - : null; + ? nearbyPlacesCache.get(nearbyCacheKey) + : null; const placePromise = shouldReverseGeocode(cameraHeightM) && !cachedPlace - ? reverseGeocode(latitude, longitude, service) - : Promise.resolve(cachedPlace); + ? reverseGeocode(latitude, longitude, service) + : Promise.resolve(cachedPlace); const nearbyPlacesPromise = shouldFetchNearbyPlaces(cameraHeightM) && !cachedNearbyPlaces - ? fetchNearbyPlaces(latitude, longitude, cameraHeightM, service) - : Promise.resolve(cachedNearbyPlaces); + ? fetchNearbyPlaces(latitude, longitude, cameraHeightM, service) + : Promise.resolve(cachedNearbyPlaces); const [viewportPlaces, resolvedPlace, resolvedNearbyPlaces] = await Promise.all([ resolveWithin( @@ -3465,13 +3505,13 @@ async function getBasemapContext( BASEMAP_CONTEXT_WAIT_MS, cachedViewportPlaces, ), - resolveWithin(placePromise, BASEMAP_CONTEXT_WAIT_MS, cachedPlace), + resolveWithin(placePromise, BASEMAP_CONTEXT_WAIT_MS, cachedPlace), resolveWithin( nearbyPlacesPromise, BASEMAP_CONTEXT_WAIT_MS, cachedNearbyPlaces, ), - ]); + ]); const place = resolvedPlace || fallbackPlace; const nearbyPlaces = resolvedNearbyPlaces || []; return { @@ -3516,12 +3556,12 @@ function nearbyKnownLandmarks(latitude, longitude, cameraHeightM) { if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return []; const maxDistanceKm = cameraHeightM <= 5000 - ? 2 - : cameraHeightM <= 50000 - ? 10 - : cameraHeightM <= 250000 - ? 35 - : 0; + ? 2 + : cameraHeightM <= 50000 + ? 10 + : cameraHeightM <= 250000 + ? 35 + : 0; if (maxDistanceKm <= 0) return []; const matches = []; @@ -3752,9 +3792,9 @@ function inferCountry(latitude, longitude) { ]; const region = regions.find( (item) => - latitude >= item.south && - latitude <= item.north && - longitude >= item.west && + latitude >= item.south && + latitude <= item.north && + longitude >= item.west && longitude <= item.east, ); return region?.name || null; @@ -3802,18 +3842,18 @@ async function reverseGeocodeViewportSamples(samples, cameraHeightM, service) { sample.longitude, service, ); - if (!place) return null; - return { - latitude: sample.latitude, - longitude: sample.longitude, - formattedAddress: place.formattedAddress, - locality: place.locality, - region: place.region, - country: place.country, - types: place.types, - labels: place.labels, - streetLabels: place.streetLabels, - }; + if (!place) return null; + return { + latitude: sample.latitude, + longitude: sample.longitude, + formattedAddress: place.formattedAddress, + locality: place.locality, + region: place.region, + country: place.country, + types: place.types, + labels: place.labels, + streetLabels: place.streetLabels, + }; }), ) ).filter(Boolean); @@ -3834,21 +3874,21 @@ function viewportPlacesFromCache(samples, cameraHeightM, service) { const place = reverseGeocodeCache.get( reverseGeocodeKey(sample.latitude, sample.longitude, service), ); - if (!place) return []; + if (!place) return []; return [ { - latitude: sample.latitude, - longitude: sample.longitude, - formattedAddress: place.formattedAddress, - locality: place.locality, - region: place.region, - country: place.country, - types: place.types, - labels: place.labels, - streetLabels: place.streetLabels, + latitude: sample.latitude, + longitude: sample.longitude, + formattedAddress: place.formattedAddress, + locality: place.locality, + region: place.region, + country: place.country, + types: place.types, + labels: place.labels, + streetLabels: place.streetLabels, }, ]; - }); + }); return summarizeViewportPlaces(places); } @@ -4008,12 +4048,12 @@ function summarizeEntity(viewer, entity, { includeProperties = false } = {}) { const tags = props.tags || {}; const label = cleanText( props.name || - tags.name || - tags['name:en'] || - tags.official_name || - tags.operator || - props.operator || - entity.name || + tags.name || + tags['name:en'] || + tags.official_name || + tags.operator || + props.operator || + entity.name || layerTitle(layerId), ); const position = @@ -4073,8 +4113,8 @@ function unwrapProperties(value) { for (const [key, entry] of Object.entries(value)) { out[key] = entry && typeof entry.getValue === 'function' - ? unwrapProperties(entry.getValue(Cesium.JulianDate.now())) - : unwrapProperties(entry); + ? unwrapProperties(entry.getValue(Cesium.JulianDate.now())) + : unwrapProperties(entry); } return out; } @@ -4189,10 +4229,15 @@ function analystProviders( const row = dataManager.getAll?.().find((layer) => layer.id === layerKey); if (row) return layerSnapshot(row); const module = dataManager.layers?.get(layerKey)?.module; - return layerSnapshot({ id: layerKey, enabled: dataManager.isEnabled?.(layerKey), stats: module?.getStats?.() || {} }); + return layerSnapshot({ + id: layerKey, + enabled: dataManager.isEnabled?.(layerKey), + stats: module?.getStats?.() || {}, + }); }, getRecordCoverage(layerKey, rows) { - if (!['satellites', 'local-datacenters', 'local-dams'].includes(layerKey)) return null; + if (!['satellites', 'local-datacenters', 'local-dams'].includes(layerKey)) + return null; const module = dataManager.layers.get(layerKey)?.module; const loaded = module?.getStats?.().count; return { @@ -4271,7 +4316,36 @@ async function runAnalystQuery( // model burned the turn on retries (owner field session 2026-08-21, 23:48). const items = result.items.map((r) => { const compact = { layerKey: r.layerKey, id: r.id }; - for (const k of ['icao24', 'mmsi', 'registration', 'label', 'callsign', 'name', 'altitudeM', 'speedMps', 'speedKts', 'frp', 'magnitude', 'shipType', 'destination', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass', 'military', 'onGround', 'distanceKm', 'confidence', 'place', 'noradId', 'satelliteClass', 'group', 'river', 'output', 'capacity']) { + for (const k of [ + 'icao24', + 'mmsi', + 'registration', + 'label', + 'callsign', + 'name', + 'altitudeM', + 'speedMps', + 'speedKts', + 'frp', + 'magnitude', + 'shipType', + 'destination', + 'operator', + 'routeOrigin', + 'routeDestination', + 'aircraftClass', + 'military', + 'onGround', + 'distanceKm', + 'confidence', + 'place', + 'noradId', + 'satelliteClass', + 'group', + 'river', + 'output', + 'capacity', + ]) { if (r[k] !== null && r[k] !== undefined) compact[k] = r[k]; } return compact; @@ -4305,10 +4379,22 @@ async function runAnalystQuery( // differ. The generic record/scope engine still owns explicit regions and // arbitrary points — only "how many aircraft around " is // unified, because that is the question the panel is already answering. - const entityWindow = aircraftProximityWindowForQuery(dataManager, args, result); + const entityWindow = aircraftProximityWindowForQuery( + dataManager, + args, + result, + ); if (entityWindow) { - const provenance = feedProvenanceEnvelope(layerSnapshots(dataManager.getAll?.() || []).filter((layer) => layer.enabled && ['flights', 'military'].includes(layer.id))); - return { ...entityWindow, feedProvenance: provenance, feedState: provenance.overall }; + const provenance = feedProvenanceEnvelope( + layerSnapshots(dataManager.getAll?.() || []).filter( + (layer) => layer.enabled && ['flights', 'military'].includes(layer.id), + ), + ); + return { + ...entityWindow, + feedProvenance: provenance, + feedState: provenance.overall, + }; } const contactsWindow = activeContactsWindow(dataManager); @@ -4336,7 +4422,7 @@ async function runAnalystQuery( `${contactsWindow.radiusKm} km of ${contactsWindow.centeredOn}, and that is the answer to a bare ` + `"how many aircraft are nearby". This query measured something else — ${result.count} ${result.scopeLabel}. ` + 'Give this one only if the operator asked about that specific area, and name both scopes if you give both.' - : null; + : null; return { ok: true, action: 'analyst_query', @@ -4356,10 +4442,10 @@ async function runAnalystQuery( // missed inside a nested shape. ...(contactsWindow && aircraftQueried ? { - contactsWindow, - contactsWindowCount: windowAircraft, - contactsWindowSubject: contactsWindow.centeredOn || null, - } + contactsWindow, + contactsWindowCount: windowAircraft, + contactsWindowSubject: contactsWindow.centeredOn || null, + } : {}), ...(countsReconciliation ? { countsReconciliation } : {}), };