mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat: integrate PR #451 pass timing with additive catalog voice queries
This commit is contained in:
commit
901d66adf3
15 files changed
+957
-68
No files matched your search
@@ -29,6 +29,13 @@ Analyst records for loaded satellites, datacenters and dams, with explicit bound
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -2900,7 +2900,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.
|
||||
|
||||
@@ -382,6 +382,8 @@
|
||||
"src/layers/launches/model.js",
|
||||
"src/layers/launches/selection.js",
|
||||
"src/layers/launches/camera.js",
|
||||
"src/data/satellitePass.js",
|
||||
"src/data/satellitePass.test.mjs",
|
||||
"src/data/satellites.js",
|
||||
"src/data/rocketLaunches.js",
|
||||
"src/layers/satellites/source.test.mjs",
|
||||
|
||||
@@ -374,7 +374,8 @@
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/data/feedState.js"
|
||||
"src/data/feedState.js",
|
||||
"src/data/layerSnapshot.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -569,7 +570,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"]
|
||||
},
|
||||
@@ -785,6 +787,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",
|
||||
@@ -793,6 +796,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",
|
||||
@@ -1240,6 +1244,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",
|
||||
@@ -1427,6 +1432,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",
|
||||
@@ -1631,6 +1637,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",
|
||||
@@ -1904,6 +1911,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",
|
||||
|
||||
@@ -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,8 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
},
|
||||
},
|
||||
next_satellite_pass: {
|
||||
description: 'Predict the next pass in 24 hours for one satellite in the loaded catalog, identified by exact NORAD ID or name. Ambiguous names return candidates: ask for a choice rather than selecting one. Defaults to geometric passes; visibleOnly requires estimated illumination and a dark observer sky, which does not guarantee naked-eye visibility. Uses camera location unless coordinates are supplied.',
|
||||
$position: 1,
|
||||
},
|
||||
};
|
||||
+38
-58
@@ -1,66 +1,46 @@
|
||||
// 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, lonDeg, fromMs,
|
||||
minElevDeg = 10, horizonHours = 24, coarseStepSec = 30, fineStepSec = 5,
|
||||
satrec,
|
||||
latDeg,
|
||||
lonDeg,
|
||||
fromMs,
|
||||
minElevDeg = 10,
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -65,6 +65,8 @@ 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,
|
||||
|
||||
@@ -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,85 @@ 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 +379,8 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
orbitalPeriodSeconds,
|
||||
computeOrbitPath,
|
||||
getNextIssPass,
|
||||
getNextSatellitePass,
|
||||
resolveSatelliteForPass,
|
||||
scoreSatelliteNameMatch,
|
||||
internationalDesignatorYear,
|
||||
lookupTleEntries,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { createSatelliteSource } from './source.js';
|
||||
import { createSatellitesLayer } from './index.js';
|
||||
|
||||
@@ -56,3 +57,92 @@ test('satellite factories keep control state separate and construct without requ
|
||||
assert.equal(second.getParams().showPoints, true);
|
||||
assert.notEqual(first.getStats(), second.getStats());
|
||||
});
|
||||
|
||||
test('satellite pass prediction methods report no-tle on cold catalog and coerce IDs', () => {
|
||||
const source = { readGroup() {} };
|
||||
const services = Object.fromEntries(
|
||||
[
|
||||
'picking',
|
||||
'focus',
|
||||
'readout',
|
||||
'overlays',
|
||||
'context',
|
||||
'render',
|
||||
'layerState',
|
||||
].map((key) => [key, {}]),
|
||||
);
|
||||
services.layerState.isExplicitLayerStateOrigin = () => false;
|
||||
const layer = createSatellitesLayer({ source, services });
|
||||
|
||||
// Cold catalog (no satellites loaded yet)
|
||||
assert.deepEqual(layer.getNextIssPass({ latDeg: 30, lonDeg: -97 }), {
|
||||
status: 'no-tle',
|
||||
});
|
||||
assert.deepEqual(
|
||||
layer.getNextSatellitePass(25544, { latDeg: 30, lonDeg: -97 }),
|
||||
{ status: 'no-tle' },
|
||||
);
|
||||
|
||||
// String NORAD ID coercion
|
||||
assert.deepEqual(
|
||||
layer.getNextSatellitePass('25544', { latDeg: 30, lonDeg: -97 }),
|
||||
{ status: 'no-tle' },
|
||||
);
|
||||
|
||||
// Invalid / non-finite ID handling
|
||||
assert.deepEqual(
|
||||
layer.getNextSatellitePass(NaN, { latDeg: 30, lonDeg: -97 }),
|
||||
{ status: 'no-tle' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
layer.getNextSatellitePass(null, { latDeg: 30, lonDeg: -97 }),
|
||||
{ status: 'no-tle' },
|
||||
);
|
||||
|
||||
// Populated catalog: verify numeric and string coercion succeed
|
||||
const L1 =
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
const L2 =
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
||||
const satrec = twoline2satrec(L1, L2);
|
||||
layer._setTrackedSatelliteRefreshStateForTest({
|
||||
noradId: 25544,
|
||||
name: 'ISS (ZARYA)',
|
||||
satrec,
|
||||
});
|
||||
|
||||
const FROM_MS = 1221868800000;
|
||||
const numericResult = layer.getNextSatellitePass(25544, {
|
||||
latDeg: 30.2672,
|
||||
lonDeg: -97.7431,
|
||||
fromMs: FROM_MS,
|
||||
});
|
||||
assert.equal(numericResult.status, 'ok');
|
||||
assert.ok(numericResult.pass);
|
||||
|
||||
const stringResult = layer.getNextSatellitePass('25544', {
|
||||
latDeg: 30.2672,
|
||||
lonDeg: -97.7431,
|
||||
fromMs: FROM_MS,
|
||||
});
|
||||
assert.equal(stringResult.status, 'ok');
|
||||
assert.deepEqual(stringResult, numericResult);
|
||||
|
||||
const issResult = layer.getNextIssPass({
|
||||
latDeg: 30.2672,
|
||||
lonDeg: -97.7431,
|
||||
fromMs: FROM_MS,
|
||||
});
|
||||
assert.equal(issResult.status, 'ok');
|
||||
assert.deepEqual(issResult, numericResult);
|
||||
|
||||
// Non-existent satellite ID in populated catalog
|
||||
assert.deepEqual(
|
||||
layer.getNextSatellitePass(99999, {
|
||||
latDeg: 30.2672,
|
||||
lonDeg: -97.7431,
|
||||
fromMs: FROM_MS,
|
||||
}),
|
||||
{ status: 'no-tle' },
|
||||
);
|
||||
});
|
||||
@@ -863,6 +863,19 @@ 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) {
|
||||
|
||||
@@ -791,6 +791,10 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'next_satellite_pass') {
|
||||
return nextSatellitePass(viewer, dataManager, args);
|
||||
}
|
||||
|
||||
if (name === 'next_iss_pass') {
|
||||
return nextIssPass(viewer, dataManager, args);
|
||||
}
|
||||
@@ -2043,9 +2047,30 @@ 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));
|
||||
return { ...result, action: 'next_satellite_pass', noradId: identity.noradId, name: identity.name, visibleOnly: args.visibleOnly === true, horizonHours: 24 };
|
||||
}
|
||||
|
||||
function normalizePanelId(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return null;
|
||||
|
||||
Reference in new issue
Block a user