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`); + } +});