diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b8282ef0..7d583a7a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347). + +Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). + - Separate map-feature acquisition from annotation/search selection. Move road and mapped-installation decoding into source adapters while preserving geometry policy, cancellation, retry outcomes and compatibility exports. - Cancel hidden Nepal provider preloads on Stop, event disable and replacement, and respect drawing-tool pointer ownership for fallback evidence cards. Preserve @manjunath22466’s Nepal scene contribution and source attribution. @@ -72,6 +76,13 @@ - 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. +- Mark passes visible from Earth-shadow and civil-twilight checks. +- Add `getNextSatellitePass(noradId, options)` for any loaded catalog satellite. +- `next_iss_pass` retains the next geometric pass and adds visibility metadata. `next_satellite_pass` adds bounded loaded-catalog name/NORAD lookup and optional visible-only filtering (Rehaan Delmotra, #451; maintainer adaptation). + ## 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 3eebcbb9d..f64ed2cb9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2,6 +2,10 @@ Wind is an optional GFS 10 m forecast overlay. Source acquisition has deadline and body budgets, disconnect cancellation and one-minute failure backoff. The row distinguishes model issue time from forecast valid time. Animation starts only with a field, stops on disable/destruction, preserves trails between unchanged canvas dimensions and scales from 200 to 4,000 particles with viewport area. Application catalog construction owns each instance. +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 (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. + Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, transient and throttled outcomes remain distinct. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Default providers, footprints, road directions, exact-viewport retries and source attribution are unchanged. Nepal media preloads survive repeated camera-flight updates, but Stop, event disable and replacement remove abandoned frames and revoke pending Facebook sessions. Fallback evidence-card clicks respect the shared drawing-tool pointer lease. @@ -40,7 +44,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. @@ -3087,7 +3091,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, 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` preserves its geometric-pass ordering and appends visibility metadata. General `next_satellite_pass` accepts a loaded NORAD ID or unambiguous name, searches 24 hours and optionally requires estimated visibility. These estimates do not account for weather or satellite brightness. - **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/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/scripts/format-scope.json b/scripts/format-scope.json index 3eb34eab5..1e8a2c12d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -382,6 +382,11 @@ "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", "src/data/rocketLaunches.js", "src/layers/satellites/source.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 03e654264..81364cd4a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -269,6 +269,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", @@ -380,7 +384,8 @@ "src/loadingFeedback.js", "src/data/installationFeedback.js", "src/keySetupCore.mjs", - "src/data/feedState.js" + "src/data/feedState.js", + "src/data/layerSnapshot.js" ], "external": [] }, @@ -577,7 +582,8 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js", "src/worldFocus.js", - "src/data/feedState.js" + "src/data/feedState.js", + "src/data/layerSnapshot.js" ], "external": ["cesium"] }, @@ -799,6 +805,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", @@ -807,6 +814,7 @@ "src/layers/satellites/labels.js", "src/layers/satellites/lifecycle.js", "src/layers/satellites/orbits.js", + "src/layers/satellites/records.js", "src/layers/satellites/policy.js", "src/layers/satellites/rendering.js", "src/layers/satellites/source.js", @@ -1271,6 +1279,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", @@ -1459,6 +1468,7 @@ "src/layers/satellites/labels.js", "src/layers/satellites/lifecycle.js", "src/layers/satellites/orbits.js", + "src/layers/satellites/records.js", "src/layers/satellites/policy.js", "src/layers/satellites/rendering.js", "src/layers/satellites/source.js", @@ -1664,6 +1674,7 @@ "src/voice/realtimeEvents.js", "src/app/localGeojsonServices.js", "src/data/feedState.js", + "src/data/layerSnapshot.js", "src/layers/alpr/records.js", "src/layers/earthquakes/records.js", "src/layers/cctv/sourcePolicy.js", @@ -1783,6 +1794,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", @@ -1980,6 +1992,7 @@ "src/layers/satellites/labels.js", "src/layers/satellites/lifecycle.js", "src/layers/satellites/orbits.js", + "src/layers/satellites/records.js", "src/layers/satellites/policy.js", "src/layers/satellites/rendering.js", "src/layers/satellites/source.js", diff --git a/server/providers/openai/hud-summary.js b/server/providers/openai/hud-summary.js index 54c065412..ca3778ba0 100644 --- a/server/providers/openai/hud-summary.js +++ b/server/providers/openai/hud-summary.js @@ -1,4 +1,7 @@ -import { 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'; @@ -61,13 +64,7 @@ async function handleHudSummary(req, res) { 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, diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index ef291b9f4..52c8c0b95 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -18,7 +18,8 @@ function realtimeInstructions( '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") — 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.', '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 9a4d1ec9e..f4e328b7f 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -505,7 +505,7 @@ export const ACTION_DESCRIPTIONS = { }, 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.', + '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". For satellites and infrastructure, counts and ranks cover only bounded examined loaded records; omitted records can change nearest/count. 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.', $position: 1, parameters: { properties: { @@ -549,7 +549,7 @@ export const ACTION_DESCRIPTIONS = { }, next_iss_pass: { description: - "When the user asks when the ISS / the space station will next fly over: returns the next visible ISS pass for the current camera location (or an explicit lat/lon) — rise time (ISO + minutes from now), rise compass direction, peak elevation, and duration. Requires the satellites layer to have loaded its catalog at least once this session; if it hasn't, tell the user to enable the satellites layer and try again.", + "When the user asks when the ISS / the space station will next fly over: returns the next geometric ISS pass with estimated visibility for the current camera location (or an explicit lat/lon) — rise time (ISO + minutes from now), rise compass direction, peak elevation, and duration. Requires the satellites layer to have loaded its catalog at least once this session; if it hasn't, tell the user to enable the satellites layer and try again.", $position: 1, parameters: { properties: { @@ -571,4 +571,9 @@ 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.', + $position: 1, + }, }; diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index a84d6b855..e2a288fc8 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -24,6 +24,7 @@ * @module data/analystEngine */ +import { feedProvenanceEnvelope } from './layerSnapshot.js'; import { pointInRing } from './naturalEarthRegions.js'; /** Layers the engine understands, with the fields queries may reference. */ @@ -61,6 +62,21 @@ export const ANALYST_LAYERS = { 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; @@ -163,12 +179,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 { @@ -180,7 +199,22 @@ 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 = 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), + }); for (const row of rows) records.push({ layerKey: key, ...row }); } } @@ -292,8 +326,15 @@ export function createAnalystEngine(providers) { coverage: { layersQueried, scope: scopeNote, + ...(queriedSnapshots.length + ? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) } + : {}), followUp: Boolean(spec.followUp && lastResult), - note: 'client-side data only — answers cover what the enabled layers currently hold', + 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', }, // Surfaced so the narration can name the centre it measured from rather // than implying a view-centred answer. diff --git a/src/data/analystEngine.test.mjs b/src/data/analystEngine.test.mjs index 6521a5464..72a0d0f5c 100644 --- a/src/data/analystEngine.test.mjs +++ b/src/data/analystEngine.test.mjs @@ -195,9 +195,88 @@ 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}`); 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); +}); + + +test('follow-up provenance stays attached to old rows after a feed recovers', async () => { + let state = 'stale'; + const engine = createAnalystEngine({ + getRecords: () => [{ id: 'A', lat: 0, lon: 0 }], + getLayerSnapshot: () => ({ id: 'flights', enabled: true, feedState: state }), + getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }), + }); + const first = await engine.query(); + state = 'nominal'; + const followUp = await engine.query({ followUp: true }); + assert.equal(first.coverage.feedProvenance.overall, 'stale'); + assert.equal(followUp.coverage.feedProvenance.overall, 'stale'); + engine.reset(); + assert.equal(engine.hasMemory(), false); + assert.equal((await engine.query()).coverage.feedProvenance.overall, 'nominal'); +}); diff --git a/src/data/issPass.js b/src/data/issPass.js index 2a075438b..c3807d59f 100644 --- a/src/data/issPass.js +++ b/src/data/issPass.js @@ -1,33 +1,26 @@ // 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 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, latDeg, @@ -37,49 +30,17 @@ export function findNextIssPass({ horizonHours = 24, coarseStepSec = 30, fineStepSec = 5, + requireVisible = false, }) { - 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, + requireVisible, + }); } 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/layerSnapshot.js b/src/data/layerSnapshot.js new file mode 100644 index 000000000..13fd3afe2 --- /dev/null +++ b/src/data/layerSnapshot.js @@ -0,0 +1,253 @@ +import { layerFeedState } from './feedState.js'; +/** + * 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, +}); + +export { layerFeedState } from './feedState.js'; + +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..9e3b08c80 --- /dev/null +++ b/src/data/layerSnapshot.test.mjs @@ -0,0 +1,260 @@ +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/localGeojson.analyst.test.mjs b/src/data/localGeojson.analyst.test.mjs new file mode 100644 index 000000000..d1cc74343 --- /dev/null +++ b/src/data/localGeojson.analyst.test.mjs @@ -0,0 +1,183 @@ +// 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); + 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/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index edb246fdf..0f4681247 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -89,6 +89,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. @@ -310,7 +382,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. */ @@ -530,6 +602,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/satellitePass.js b/src/data/satellitePass.js new file mode 100644 index 000000000..7f8f75337 --- /dev/null +++ b/src/data/satellitePass.js @@ -0,0 +1,458 @@ +// src/data/satellitePass.js +/** + * 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. + * @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, satECI: {x:number, y:number, z:number} } | null} + */ +export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) { + const date = new Date(dateMs); + let pv; + 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; + 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, + 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. + * @private + */ +function _bisectBoundary( + satrec, + latDeg, + lonDeg, + tLow, + tHigh, + minElevDeg, + isRising, + steps = 10, +) { + 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 isRising ? Math.ceil(high) : Math.floor(low); +} + +/** + * Find the next pass of a satellite over an observer location. + * Uses 20s coarse scanning, bisection for sub-second rise/set times, + * parabolic interpolation for the peak, and optional naked-eye visibility filtering. + * + * @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) + * @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, + * 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, + latDeg, + lonDeg, + fromMs, + minElevDeg = 10, + horizonHours = 24, + coarseStepSec = 20, + fineStepSec = 5, + 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; + 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 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; + 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.max( + riseMs, + Math.min(setMs, Math.round(maxElevMs + shift)), + ); + refinedPeakElevDeg = Math.min( + 90, + maxElevDeg - + Math.pow(peakLeftElev - peakRightElev, 2) / + (8 * (peakLeftElev - 2 * maxElevDeg + peakRightElev)), + ); + } + } + + // 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); + + 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), + ]; + }); + } + + // 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, + setMs, + maxElevDeg: refinedPeakElevDeg, + maxElevMs: refinedPeakMs, + riseAzDeg: riseLook ? riseLook.azDeg : 0, + visible: hasVisibleSegment, + sunlit: peakSunlit, + observerDark: peakDark, + }; + } + + 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]; + for (let left = start; left < end; left += 5000) { + const right = Math.min(end, left + 5000); + const a = conditionsAt(left); + const b = conditionsAt(right); + if (visible(a) || visible(b)) return true; + const cuts = [left, right]; + for (let condition = 0; condition < 2; condition++) { + if (a[condition] === b[condition]) continue; + let low = left; + let high = right; + while (high - low > 20) { + const mid = (low + high) / 2; + if (conditionsAt(mid)[condition] === a[condition]) low = mid; + else high = mid; + } + cuts.push((low + high) / 2); + } + cuts.sort((a, b) => a - b); + for (let i = 1; i < cuts.length; i++) { + if (visible(conditionsAt((cuts[i - 1] + cuts[i]) / 2))) return true; + } + } + return false; +} diff --git a/src/data/satellitePass.test.mjs b/src/data/satellitePass.test.mjs new file mode 100644 index 000000000..f195dff99 --- /dev/null +++ b/src/data/satellitePass.test.mjs @@ -0,0 +1,282 @@ +// src/data/satellitePass.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { twoline2satrec, propagate } from 'satellite.js'; +import { + findNextSatellitePass, + hasVisibleInterval, + lookAnglesAt, + solarPositionECI, + isSatelliteSunlit, + observerSolarElevation, + isObserverDark, +} 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('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({ + 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 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 }; + 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); +}); + +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 }, + ]) { + 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, + ); +}); + +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, + }); + assert.equal(clipped.riseMs, first.maxElevMs); + assert.ok(clipped.setMs >= clipped.riseMs); + assert.ok( + clipped.maxElevMs >= clipped.riseMs && clipped.maxElevMs <= clipped.setMs, + ); +}); 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 383c583ef..969102076 100644 --- a/src/data/satellites.js +++ b/src/data/satellites.js @@ -41,6 +41,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; @@ -49,3 +50,5 @@ export { ISS_OVERLAY_SOURCE_OPTIONS, } from '../layers/satellites/index.js'; export default layer; + +export { mapAnalystRecord } from '../layers/satellites/records.js'; diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 5a501a677..99026d10a 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 c3177d808..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 { 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 = { @@ -649,7 +654,11 @@ export class IntelHUD { // 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; } /** @@ -734,7 +743,12 @@ 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); @@ -775,6 +789,7 @@ export class IntelHUD { streetLabels: labels.streetLabels, nearbyPlaceLabels: labels.nearbyPlaceLabels, enabledLayerLabels: enabledLayers, + ...hudSummaryLayerContext(this._dataManager?.getAll?.() || []), }; } @@ -904,7 +919,12 @@ 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 ( + ['visibility', 'refresh-transition', 'refresh-cancelled'].includes( + change?.type, + ) + ) + this._markSummaryDirty(); }); } this._markSummaryDirty(); diff --git a/src/hudSummaryResponse.js b/src/hudSummaryResponse.js index c4b6c20c1..82b12e55f 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'; /** @@ -36,3 +41,72 @@ export function isHudSummaryUnconfigured(status, data) { 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('/')}` : ''}`; +} + +/** 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 bbdc9d01a..194d66106 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,48 @@ 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/openai/hud-summary.js', import.meta.url), 'utf8'); + 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/index.js b/src/layers/satellites/index.js index 7bec672c0..16e1de512 100644 --- a/src/layers/satellites/index.js +++ b/src/layers/satellites/index.js @@ -8,6 +8,7 @@ import { createTesting } from './testing.js'; import { createInteraction } from './interaction.js'; import { createLifecycle } from './lifecycle.js'; import { createIngestion } from './ingestion.js'; +import { createRecords } from './records.js'; import { createState } from './state.js'; /** Construct one layer with its own scene state and supplied application services. */ @@ -21,6 +22,7 @@ export function createSatellitesLayer({ services, source }) { parts.catalog = createCatalog(context); parts.labels = createLabels(context); parts.orbits = createOrbits(context); + parts.records = createRecords(context); parts.rendering = createRendering(context); parts.tracking = createTracking(context); parts.testing = createTesting(context); @@ -63,6 +65,9 @@ export function createSatellitesLayer({ services, source }) { applySatellitePointFocusDeemphasis: parts.rendering.applySatellitePointFocusDeemphasis, getNextIssPass: parts.orbits.getNextIssPass, + getNextSatellitePass: parts.orbits.getNextSatellitePass, + resolveSatelliteForPass: parts.orbits.resolveSatelliteForPass, + getAnalystRecords: parts.records.getAnalystRecords, 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..ed26a8883 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,22 +136,98 @@ 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). - * @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number}}} + * @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 {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 }) { - const sat = layerState._catalog.get(ISS_NORAD); + function getNextIssPass({ + latDeg, + lonDeg, + minElevDeg = 10, + requireVisible = false, + fromMs = Date.now(), + }) { + return getNextSatellitePass(ISS_NORAD, { + latDeg, + lonDeg, + minElevDeg, + requireVisible, + 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 {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, + requireVisible = false, + fromMs = Date.now(), + }, + ) { + const id = Number(noradId); + if (!Number.isSafeInteger(id) || id <= 0) 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, + requireVisible, }); return pass ? { status: 'ok', pass } : { status: 'none' }; } + /** Resolve only loaded catalog identities; ambiguous names never pick a first row. */ + function resolveSatelliteForPass(query) { + const text = String(query ?? '').trim(); + if (!text || text.length > 120) return { status: 'not-found' }; + 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' }; + } + 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)); + 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, + }; + return { status: 'ok', noradId: matches[0][0], name: matches[0][1].name }; + } + /** * Score a mission-to-catalog name match. Compact identifier containment handles * names such as "Sirius SXM-11" → "SXM-11", while weak generic matches such as @@ -316,6 +392,8 @@ export function createOrbits({ state: layerState, services, parts, source }) { orbitalPeriodSeconds, computeOrbitPath, getNextIssPass, + getNextSatellitePass, + resolveSatelliteForPass, scoreSatelliteNameMatch, internationalDesignatorYear, lookupTleEntries, diff --git a/src/layers/satellites/records.js b/src/layers/satellites/records.js new file mode 100644 index 000000000..eddae75b6 --- /dev/null +++ b/src/layers/satellites/records.js @@ -0,0 +1,112 @@ +import * as Cesium from 'cesium'; +import { satelliteClassLabel } from '../../data/satelliteClass.js'; +import { ISS_NORAD } from './policy.js'; + +/** + * 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 = + 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; + 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, + }; +} + +/** 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) + ) { + 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: 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 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 state._catalog) { + if (sat?.group === 'dense') continue; + if (!emit(noradId, sat)) break; + } + if (result.length < limit) { + for (const [noradId, sat] of state._catalog) { + if (sat?.group !== 'dense') continue; + if (!emit(noradId, sat)) break; + } + } + return result; + } + return { getAnalystRecords }; +} diff --git a/src/layers/satellites/source.test.mjs b/src/layers/satellites/source.test.mjs index a44ad565b..810b0d111 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,110 @@ 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); + + 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, { + latDeg: 30.2672, + lonDeg: -97.7431, + fromMs: FROM_MS, + }), + { status: 'no-tle' }, + ); +}); diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 1a0c27b2c..48cd7021f 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) { @@ -177,17 +179,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 81d1147d1..ff1a8dcf9 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -771,6 +771,9 @@ const schemas = [ 'ais-live-vessels', 'local-firms', 'earthquakes', + 'satellites', + 'local-datacenters', + 'local-dams', ], }, }, @@ -860,6 +863,21 @@ const schemas = [ }, }, }, + { + name: 'next_satellite_pass', + parameters: { + type: 'object', + additionalProperties: false, + required: ['target'], + properties: { + target: { type: 'string', minLength: 1, maxLength: 120 }, + latitude: { type: 'number', minimum: -90, maximum: 90 }, + longitude: { type: 'number', minimum: -180, maximum: 180 }, + minElevationDeg: { type: 'number', minimum: 5, maximum: 60 }, + visibleOnly: { type: 'boolean' }, + }, + }, + }, ]; function freeze(value) { diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index e3107f0b3..76e7892a2 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,19 @@ 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', + ); +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index d607322c8..3831f9665 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1,3 +1,8 @@ +import { + layerSnapshot, + layerSnapshots, + feedProvenanceEnvelope, +} from '../data/layerSnapshot.js'; import { readLayerLifecycleSummary } from './layerSummary.js'; export { readLayerLifecycleSummary } from './layerSummary.js'; import { defaultGeospatial } from '../search/defaults.js'; @@ -964,6 +969,10 @@ export function createGevActionRunner({ }; } + if (name === 'next_satellite_pass') { + return nextSatellitePass(viewer, dataManager, args); + } + if (name === 'next_iss_pass') { return nextIssPass(viewer, dataManager, args); } @@ -2655,6 +2664,64 @@ function nextIssPass(viewer, dataManager, args) { durationMin: Math.max(1, Math.round((pass.setMs - pass.riseMs) / 60000)), 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.', + setIso: new Date(pass.setMs).toISOString(), + peakIso: new Date(pass.maxElevMs).toISOString(), + }; +} + +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.', + }; + // 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 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.'; + } + return { + ...result, + action: 'next_satellite_pass', + noradId: identity.noradId, + name: identity.name, + visibleOnly: args.visibleOnly === true, + horizonHours: 24, }; } @@ -3025,7 +3092,13 @@ function getCurrentViewState( enabled: layer.enabled, count: layer.stats?.count || 0, error: layer.stats?.error || null, + feedState: layerSnapshot(layer).feedState, + source: layerSnapshot(layer).source, + lastUpdate: layerSnapshot(layer).lastUpdate, })), + feedProvenance: feedProvenanceEnvelope( + layerSnapshots(dataManager.getAll()).filter((layer) => layer.enabled), + ), }; } @@ -4152,6 +4225,29 @@ function analystProviders( ? mod.getAnalystRecords(requestedLimit) || [] : mod.getAnalystRecords() || []; }, + getLayerSnapshot(layerKey) { + 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?.() || {}, + }); + }, + getRecordCoverage(layerKey, rows) { + if (!['satellites', 'local-datacenters', 'local-dams'].includes(layerKey)) + return null; + const module = dataManager.layers.get(layerKey)?.module; + const loaded = module?.getStats?.().count; + return { + basis: 'bounded-loaded-records', + recordsExamined: rows.length, + loadedCount: Number.isFinite(loaded) ? loaded : null, + sourceTruncated: Number.isFinite(loaded) ? loaded > rows.length : null, + note: 'Counts and ranks apply only to these examined loaded records, not all satellites or infrastructure; distance is ground great-circle distance.', + }; + }, resolveRegionRing, /** * The active Contacts subject, when there is one — the centre the operator @@ -4243,6 +4339,12 @@ async function runAnalystQuery( 'distanceKm', 'confidence', 'place', + 'noradId', + 'satelliteClass', + 'group', + 'river', + 'output', + 'capacity', ]) { if (r[k] !== null && r[k] !== undefined) compact[k] = r[k]; } @@ -4282,7 +4384,18 @@ async function runAnalystQuery( args, result, ); - if (entityWindow) return entityWindow; + 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 contactsWindow = activeContactsWindow(dataManager); const aircraftQueried = (result.coverage?.layersQueried || []).some( @@ -4321,6 +4434,8 @@ async function runAnalystQuery( items, summary: result.summary, coverage: result.coverage, + feedProvenance: result.coverage?.feedProvenance || null, + feedState: result.coverage?.feedProvenance?.overall || null, // The panel's own numbers, carried so the answer can match what the // operator is looking at regardless of how the model reads the note. // Flattened alongside the object so the count and its subject cannot be diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index ba0ed9cf7..3adb086ce 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3182,3 +3182,91 @@ test('ISS voice lookup uses the registered satellite instance', async () => { assert.deepEqual(calls, [{ latDeg: 30, lonDeg: -97, minElevDeg: 15 }]); assert.match(result.error, /No ISS pass above 15/); }); + +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); +}); + + +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'); +});