mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(satellites): precise pass rise/set and peak times
- Add satellitePass.js: bisected rise/set, parabolic peak fit - Add getNextSatellitePass for any loaded catalog satellite - Keep findNextIssPass as a wrapper
This commit is contained in:
1 parent
844c25212c
commit
096f533efe
11 files changed
+526
-64
No files matched your search
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
- Split application scene, controls, catalog, tools and HTML into reusable components; configure application request services and sources without changing global fetch. Preserve standalone markup and voice behavior. Explicit annotation navigation may resolve a distant named target.
|
- Split application scene, controls, catalog, tools and HTML into reusable components; configure application request services and sources without changing global fetch. Preserve standalone markup and voice behavior. Explicit annotation navigation may resolve a distant named target.
|
||||||
|
|
||||||
|
## Satellite pass prediction
|
||||||
|
|
||||||
|
- Bisect pass rise/set to ~0.2 s and fit peak elevation with a parabola.
|
||||||
|
- Add `getNextSatellitePass(noradId, options)` for any loaded catalog satellite.
|
||||||
|
|
||||||
## Voice component boundaries
|
## Voice component boundaries
|
||||||
|
|
||||||
- Separate voice controls, Realtime connection requests and the action runner.
|
- Separate voice controls, Realtime connection requests and the action runner.
|
||||||
|
|||||||
@@ -2714,7 +2714,7 @@ silently demoting every later lookup for the session.
|
|||||||
`activeCameraCardEnabled` presentation option can publish the retained
|
`activeCameraCardEnabled` presentation option can publish the retained
|
||||||
protected-card path. Disable tears the tier down completely.
|
protected-card path. Disable tears the tier down completely.
|
||||||
Coverage/viewshed semantics unchanged.
|
Coverage/viewshed semantics unchanged.
|
||||||
- **Satellites**: orbit rings rotate about Earth's Z by ΔGMST every ~1s (exact inertial→ECEF compensation; no SGP4 re-runs); the tracked satellite propagates per frame with one shared epoch for dot/label/camera. Verified: ISS holds <1km perpendicular to its ring while tracked.
|
- **Satellites**: orbit rings rotate about Earth's Z by ΔGMST every ~1s (exact inertial→ECEF compensation; no SGP4 re-runs); the tracked satellite propagates per frame with one shared epoch for dot/label/camera. Verified: ISS holds <1km perpendicular to its ring while tracked. Pass prediction (`src/data/satellitePass.js`; `getNextSatellitePass`, `getNextIssPass`) scans at 20s, bisects rise/set to ~0.2s, and fits the peak with a parabola.
|
||||||
- **Space Missions (30d)**: recent launches render as bounded shared-host, horizon-occluded mission markers using Launch Library 2 v2.3 detailed records. Enabling the layer selects the unified right-side Context panel's Space Missions mode and enables the required satellite layer. Before applying its temporary dense/hidden Satellite mode, Space Missions snapshots the complete standalone Satellite parameter set and exact enabled-layer set. Disabling Space Missions from either Context or the left Data Layers rail restores those parameters and the exact prior enabled state, so a Satellite layer that was already on stays on while a mode-owned dependency returns off; enabling Satellites by itself remains independent. Selecting a mission isolates its launch-to-orbit transfer and dashed satellite orbit, fills that same panel with navigation/details, and animates a small phase-colored marker along the exact displayed Cartesian samples. Marker hit testing drill-picks through photorealistic tiles so the depth-test-free tactical dot remains reliably selectable; its text is non-interactive shared-host presentation. The selected pad is the camera's zoom pivot: the overview remains centered on its launch site, wheel zoom approaches that site instead of drifting elsewhere, and camera pitch progressively changes from global nadir to an oblique close 3D view. Its protected shared-host callout remains visible and expands to include both mission and launch-site names. `FOCUS` flies directly to a 12 km oblique frame around the selected launch site and retains the same anchored zoom/orbit behavior. `REPLAY ASCENT` resets the selected marker at the pad, frames it from an oblique third-person angle, and follows it through the mission-specific compressed ascent directly into one orbital lap at the default `1×` rate. A live `0.25×`–`4×` slider changes ascent and orbit playback speed; adjusting it mid-replay preserves the current path position and historical mission timestamp. Re-entry/recovery cannot be inserted into replay. At orbit insertion the camera smoothly pulls back over the first fifth of the orbital replay and pitches to a globe-scale nadir view while continuing to target the moving replay point. Replay Cancel, mission navigation, deselection, layer disable, and data refresh all release camera ownership. Reconstructed paths are one continuous 128-sample geodetic curve: horizontal departure begins near zero while altitude rises quickly, then the climb progressively bends toward insertion without the former hard 120 km corner. Unmatched orbit fallbacks are smooth planar inclined rings rather than longitude/latitude ground-track curves. The panel lists disclosed payload names, types, operators/manufacturers, mass, multiplicity, and destination when supplied; an empty LL2 payload collection is shown as `CLASSIFIED / MULTI-PAYLOAD`. Launcher, spacecraft, and recoverable payload stages appear in a compact stage table with serial/flight/reuse details, recovery outcome/type, destination, and final coordinates when those records exist; an empty recovery collection omits the section. Stage recoveries with confirmed coordinates use those coordinates; return-to-launch-site records use the pad; downrange-only records receive an explicitly labeled estimated endpoint along the ascent azimuth. Available endpoints render as static 2 px dashed descent/recovery paths with a fixed final-position dot and an estimated atmospheric-interface segment when applicable. The ascent is geodetically densified above the ellipsoid toward the orbit's nearest insertion point, then rendered with `ArcType.NONE`, so it neither cuts through Earth nor separates from the marker. Because LL2 does not normally supply continuous ascent telemetry, pad-to-insertion paths without upstream trajectory samples are labeled `RECONSTRUCTED ESTIMATE` / `ASCENT ESTIMATE`; only supplied trajectory samples receive the replay wording. Selected-orbit framing fits the complete ring, rear-side linework uses normal scene depth occlusion, and current distance, speed, and callout data come only from a launch-year-validated satellite match. Speed is the magnitude of the SGP4 inertial velocity vector at the same propagation epoch as position, displayed in km/s with km/h available as hover detail. Unavailable operator, site, launch-time, orbit, current-altitude, and speed values omit their detail rows instead of reserving panel space with placeholders. Newly launched payloads absent from the core operational groups use CelesTrak's cached active TLE feed as a lookup-only fallback; weak constellation-name matches are rejected. The replay callout maps compressed animation progress onto Launch Library's mission-relative timeline, showing the historical UTC date/time at the marker's current path position; unavailable timelines remain explicit. Matched live satellite positions propagate at one-second cadence and use a distinct green dot/callout with the current UTC date/time.
|
- **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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -729,6 +729,7 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
"src/data/issPass.js",
|
"src/data/issPass.js",
|
||||||
"src/data/satelliteClass.js",
|
"src/data/satelliteClass.js",
|
||||||
|
"src/data/satellitePass.js",
|
||||||
"src/layers/satellites/catalog.js",
|
"src/layers/satellites/catalog.js",
|
||||||
"src/layers/satellites/controls.js",
|
"src/layers/satellites/controls.js",
|
||||||
"src/layers/satellites/index.js",
|
"src/layers/satellites/index.js",
|
||||||
@@ -1143,6 +1144,7 @@
|
|||||||
"src/data/rocketLaunches.js",
|
"src/data/rocketLaunches.js",
|
||||||
"src/data/routePlausible.js",
|
"src/data/routePlausible.js",
|
||||||
"src/data/satelliteClass.js",
|
"src/data/satelliteClass.js",
|
||||||
|
"src/data/satellitePass.js",
|
||||||
"src/data/satellites.js",
|
"src/data/satellites.js",
|
||||||
"src/data/scenePick.js",
|
"src/data/scenePick.js",
|
||||||
"src/data/spriteOrder.js",
|
"src/data/spriteOrder.js",
|
||||||
|
|||||||
+35
-58
@@ -1,66 +1,43 @@
|
|||||||
// src/data/issPass.js
|
// src/data/issPass.js
|
||||||
/**
|
/**
|
||||||
* Next-ISS-pass prediction from a satrec: coarse 30 s scan to find a window
|
* Next-ISS-pass prediction. Kept as a thin wrapper over satellitePass.js so the
|
||||||
* where elevation ≥ minElevDeg, refined to ~5 s at the edges, tracking peak
|
* original findNextIssPass signature and defaults still work.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js';
|
import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js';
|
||||||
|
|
||||||
const R2D = 180 / Math.PI;
|
export { lookAnglesAt };
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predict the next ISS pass over an observer location.
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {Object} options.satrec SGP4 satellite record
|
||||||
|
* @param {number} options.latDeg Observer latitude in degrees [-90, 90]
|
||||||
|
* @param {number} options.lonDeg Observer longitude in degrees [-180, 180]
|
||||||
|
* @param {number} options.fromMs UTC start time in milliseconds
|
||||||
|
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
|
||||||
|
* @param {number} [options.horizonHours=24] Maximum search window in hours
|
||||||
|
* @param {number} [options.coarseStepSec=30] Coarse search step in seconds
|
||||||
|
* @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking
|
||||||
|
* @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number } | null}
|
||||||
|
*/
|
||||||
export function findNextIssPass({
|
export function findNextIssPass({
|
||||||
satrec, latDeg, lonDeg, fromMs,
|
satrec,
|
||||||
minElevDeg = 10, horizonHours = 24, coarseStepSec = 30, fineStepSec = 5,
|
latDeg,
|
||||||
|
lonDeg,
|
||||||
|
fromMs,
|
||||||
|
minElevDeg = 10,
|
||||||
|
horizonHours = 24,
|
||||||
|
coarseStepSec = 30,
|
||||||
|
fineStepSec = 5,
|
||||||
}) {
|
}) {
|
||||||
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
|
return findNextSatellitePass({
|
||||||
const horizonMs = fromMs + horizonHours * 3600_000;
|
satrec,
|
||||||
const coarse = coarseStepSec * 1000;
|
latDeg,
|
||||||
const fine = fineStepSec * 1000;
|
lonDeg,
|
||||||
|
fromMs,
|
||||||
// Coarse scan for the first sample above threshold. If we START inside a
|
minElevDeg,
|
||||||
// pass, that's still "the next pass" for a voice answer — accept it.
|
horizonHours,
|
||||||
let hit = null;
|
coarseStepSec,
|
||||||
for (let t = fromMs; t <= horizonMs; t += coarse) {
|
fineStepSec,
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { twoline2satrec } from 'satellite.js';
|
import { twoline2satrec } from 'satellite.js';
|
||||||
import { findNextIssPass, lookAnglesAt } from './issPass.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).
|
// 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';
|
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);
|
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', () => {
|
test('returns null when no pass clears an absurd threshold', () => {
|
||||||
const satrec = twoline2satrec(L1, L2);
|
const satrec = twoline2satrec(L1, L2);
|
||||||
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 89.9, horizonHours: 2 });
|
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 89.9, horizonHours: 2 });
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// src/data/satellitePass.js
|
||||||
|
/**
|
||||||
|
* Satellite pass prediction.
|
||||||
|
* Look angles come from SGP4; rise/set are bisected to sub-second precision
|
||||||
|
* and the peak is refined with a parabola. Scan pattern from skylight (MIT)
|
||||||
|
* shared/src/celestial.ts nextISSPass.
|
||||||
|
*/
|
||||||
|
import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js';
|
||||||
|
|
||||||
|
const R2D = 180 / Math.PI;
|
||||||
|
const D2R = Math.PI / 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observer look angles at an instant, or null when propagation fails.
|
||||||
|
* @param {Object} satrec SGP4 satellite record
|
||||||
|
* @param {number} dateMs UTC epoch timestamp in milliseconds
|
||||||
|
* @param {number} latDeg Observer latitude in degrees [-90, 90]
|
||||||
|
* @param {number} lonDeg Observer longitude in degrees [-180, 180]
|
||||||
|
* @returns {{ elevDeg: number, azDeg: number } | null}
|
||||||
|
*/
|
||||||
|
export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
|
||||||
|
const date = new Date(dateMs);
|
||||||
|
const pv = propagate(satrec, date);
|
||||||
|
const pos = pv && pv.position;
|
||||||
|
if (!pos || typeof pos === 'boolean') return null;
|
||||||
|
const ecf = eciToEcf(pos, gstime(date));
|
||||||
|
const look = ecfToLookAngles(
|
||||||
|
{ latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 },
|
||||||
|
ecf,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
elevDeg: look.elevation * R2D,
|
||||||
|
azDeg: (((look.azimuth * R2D) % 360) + 360) % 360,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bisect the elevation threshold boundary f(t) = elev(t) - minElevDeg = 0.
|
||||||
|
* Assumes one threshold crossing inside the bracket. 7 iterations resolve 20s to ~0.16s.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function _bisectBoundary(
|
||||||
|
satrec,
|
||||||
|
latDeg,
|
||||||
|
lonDeg,
|
||||||
|
tLow,
|
||||||
|
tHigh,
|
||||||
|
minElevDeg,
|
||||||
|
isRising,
|
||||||
|
steps = 7,
|
||||||
|
) {
|
||||||
|
let low = tLow;
|
||||||
|
let high = tHigh;
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
const mid = (low + high) / 2;
|
||||||
|
const look = lookAnglesAt(satrec, mid, latDeg, lonDeg);
|
||||||
|
const elev = look ? look.elevDeg : -90;
|
||||||
|
if (isRising) {
|
||||||
|
if (elev >= minElevDeg) {
|
||||||
|
high = mid;
|
||||||
|
} else {
|
||||||
|
low = mid;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (elev >= minElevDeg) {
|
||||||
|
low = mid;
|
||||||
|
} else {
|
||||||
|
high = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.round(isRising ? high : low);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the next pass of a satellite over an observer location.
|
||||||
|
* Uses 20s coarse scanning, bisection for sub-second rise/set times,
|
||||||
|
* and parabolic interpolation for the peak.
|
||||||
|
*
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {Object} options.satrec SGP4 satellite record
|
||||||
|
* @param {number} options.latDeg Observer latitude [-90, 90]
|
||||||
|
* @param {number} options.lonDeg Observer longitude [-180, 180]
|
||||||
|
* @param {number} options.fromMs UTC start time in milliseconds
|
||||||
|
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set (default 10°)
|
||||||
|
* @param {number} [options.horizonHours=24] Maximum search horizon in hours (default 24h)
|
||||||
|
* @param {number} [options.coarseStepSec=20] Coarse scan step in seconds (default 20s)
|
||||||
|
* @param {number} [options.fineStepSec=5] Fine transit step in seconds (default 5s)
|
||||||
|
* @returns {{
|
||||||
|
* riseMs: number,
|
||||||
|
* setMs: number,
|
||||||
|
* maxElevDeg: number,
|
||||||
|
* maxElevMs: number,
|
||||||
|
* riseAzDeg: number
|
||||||
|
* } | null}
|
||||||
|
*/
|
||||||
|
export function findNextSatellitePass({
|
||||||
|
satrec,
|
||||||
|
latDeg,
|
||||||
|
lonDeg,
|
||||||
|
fromMs,
|
||||||
|
minElevDeg = 10,
|
||||||
|
horizonHours = 24,
|
||||||
|
coarseStepSec = 20,
|
||||||
|
fineStepSec = 5,
|
||||||
|
}) {
|
||||||
|
const horizonMs = fromMs + horizonHours * 3600_000;
|
||||||
|
const coarseMs = coarseStepSec * 1000;
|
||||||
|
const fineMs = Math.max(1000, (fineStepSec || 5) * 1000);
|
||||||
|
|
||||||
|
let searchCursor = fromMs;
|
||||||
|
|
||||||
|
while (searchCursor <= horizonMs) {
|
||||||
|
let tPrev = searchCursor;
|
||||||
|
let tHit = null;
|
||||||
|
let elevPrev = lookAnglesAt(satrec, tPrev, latDeg, lonDeg)?.elevDeg ?? -90;
|
||||||
|
|
||||||
|
// Check if we start already inside a pass
|
||||||
|
if (elevPrev >= minElevDeg) {
|
||||||
|
tHit = searchCursor;
|
||||||
|
} else {
|
||||||
|
// Coarse forward scan
|
||||||
|
for (let t = searchCursor + coarseMs; t <= horizonMs; t += coarseMs) {
|
||||||
|
const look = lookAnglesAt(satrec, t, latDeg, lonDeg);
|
||||||
|
const elev = look ? look.elevDeg : -90;
|
||||||
|
if (elev >= minElevDeg) {
|
||||||
|
tHit = t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tPrev = t;
|
||||||
|
elevPrev = elev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tHit == null) return null; // No pass found in remaining horizon
|
||||||
|
|
||||||
|
// Resolve rise
|
||||||
|
let riseMs;
|
||||||
|
if (tHit === searchCursor && elevPrev >= minElevDeg) {
|
||||||
|
riseMs = searchCursor;
|
||||||
|
} else {
|
||||||
|
riseMs = _bisectBoundary(
|
||||||
|
satrec,
|
||||||
|
latDeg,
|
||||||
|
lonDeg,
|
||||||
|
tPrev,
|
||||||
|
tHit,
|
||||||
|
minElevDeg,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transit the pass to track peak and bracket set between tStepPrev and tCurr
|
||||||
|
let tCurr = riseMs;
|
||||||
|
let maxElevDeg = -90;
|
||||||
|
let maxElevMs = riseMs;
|
||||||
|
let tStepPrev = riseMs;
|
||||||
|
let setMs = null;
|
||||||
|
let elevPrevSample = null;
|
||||||
|
let peakLeftElev = null;
|
||||||
|
let peakRightElev = null;
|
||||||
|
let peakStepMs = fineMs;
|
||||||
|
|
||||||
|
// Transit forward in fine steps
|
||||||
|
while (tCurr <= horizonMs) {
|
||||||
|
const look = lookAnglesAt(satrec, tCurr, latDeg, lonDeg);
|
||||||
|
const elev = look ? look.elevDeg : -90;
|
||||||
|
|
||||||
|
if (elev < minElevDeg && tCurr > riseMs) {
|
||||||
|
if (peakRightElev == null && tCurr > maxElevMs) {
|
||||||
|
peakRightElev = elev;
|
||||||
|
peakStepMs = tCurr - maxElevMs;
|
||||||
|
}
|
||||||
|
// Below threshold: bisect set between tStepPrev and tCurr
|
||||||
|
setMs = _bisectBoundary(
|
||||||
|
satrec,
|
||||||
|
latDeg,
|
||||||
|
lonDeg,
|
||||||
|
tStepPrev,
|
||||||
|
tCurr,
|
||||||
|
minElevDeg,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elev > maxElevDeg) {
|
||||||
|
maxElevDeg = elev;
|
||||||
|
maxElevMs = tCurr;
|
||||||
|
peakLeftElev = elevPrevSample;
|
||||||
|
peakRightElev = null;
|
||||||
|
} else if (peakRightElev == null && tCurr > maxElevMs) {
|
||||||
|
peakRightElev = elev;
|
||||||
|
peakStepMs = tCurr - maxElevMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
elevPrevSample = elev;
|
||||||
|
tStepPrev = tCurr;
|
||||||
|
tCurr += fineMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setMs == null) {
|
||||||
|
setMs = Math.min(horizonMs, tCurr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refine peak culmination via 3-point parabolic interpolation around maxElevMs
|
||||||
|
// using adjacent transit samples (zero additional SGP4 calls)
|
||||||
|
let refinedPeakMs = maxElevMs;
|
||||||
|
let refinedPeakElevDeg = maxElevDeg;
|
||||||
|
if (
|
||||||
|
Number.isFinite(peakLeftElev) &&
|
||||||
|
Number.isFinite(peakRightElev) &&
|
||||||
|
peakLeftElev - 2 * maxElevDeg + peakRightElev < 0 // concave down
|
||||||
|
) {
|
||||||
|
const denom = 2 * (peakLeftElev - 2 * maxElevDeg + peakRightElev);
|
||||||
|
const shift = ((peakLeftElev - peakRightElev) / denom) * peakStepMs;
|
||||||
|
if (Math.abs(shift) < peakStepMs) {
|
||||||
|
refinedPeakMs = Math.round(maxElevMs + shift);
|
||||||
|
refinedPeakElevDeg =
|
||||||
|
maxElevDeg -
|
||||||
|
Math.pow(peakLeftElev - peakRightElev, 2) /
|
||||||
|
(8 * (peakLeftElev - 2 * maxElevDeg + peakRightElev));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const riseLook = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
|
||||||
|
return {
|
||||||
|
riseMs,
|
||||||
|
setMs,
|
||||||
|
maxElevDeg: refinedPeakElevDeg,
|
||||||
|
maxElevMs: refinedPeakMs,
|
||||||
|
riseAzDeg: riseLook ? riseLook.azDeg : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// src/data/satellitePass.test.mjs
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { twoline2satrec } from 'satellite.js';
|
||||||
|
import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js';
|
||||||
|
|
||||||
|
// Canonical archived ISS TLE (epoch 2008-09-20 ~12:25 UTC).
|
||||||
|
const L1 =
|
||||||
|
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||||
|
const L2 =
|
||||||
|
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
||||||
|
const AUSTIN = { latDeg: 30.2672, lonDeg: -97.7431 };
|
||||||
|
const FROM_MS = Date.UTC(2008, 8, 20, 12, 30, 0);
|
||||||
|
|
||||||
|
test('findNextSatellitePass resolves pass boundaries with sub-second precision', () => {
|
||||||
|
const satrec = twoline2satrec(L1, L2);
|
||||||
|
const pass = findNextSatellitePass({
|
||||||
|
satrec,
|
||||||
|
...AUSTIN,
|
||||||
|
fromMs: FROM_MS,
|
||||||
|
minElevDeg: 10,
|
||||||
|
});
|
||||||
|
assert.ok(pass, 'expected a pass within 24h at Austin for ISS');
|
||||||
|
assert.ok(pass.riseMs > FROM_MS);
|
||||||
|
assert.ok(pass.riseMs < pass.maxElevMs && pass.maxElevMs < pass.setMs);
|
||||||
|
assert.ok(pass.maxElevDeg >= 10);
|
||||||
|
assert.ok(pass.riseAzDeg >= 0 && pass.riseAzDeg < 360);
|
||||||
|
|
||||||
|
// Verify bisection accuracy at rise time:
|
||||||
|
const beforeRise = lookAnglesAt(
|
||||||
|
satrec,
|
||||||
|
pass.riseMs - 1000,
|
||||||
|
AUSTIN.latDeg,
|
||||||
|
AUSTIN.lonDeg,
|
||||||
|
);
|
||||||
|
const atRise = lookAnglesAt(
|
||||||
|
satrec,
|
||||||
|
pass.riseMs,
|
||||||
|
AUSTIN.latDeg,
|
||||||
|
AUSTIN.lonDeg,
|
||||||
|
);
|
||||||
|
const afterRise = lookAnglesAt(
|
||||||
|
satrec,
|
||||||
|
pass.riseMs + 1000,
|
||||||
|
AUSTIN.latDeg,
|
||||||
|
AUSTIN.lonDeg,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
beforeRise && beforeRise.elevDeg < 10.05,
|
||||||
|
'elevation before riseMs must be below threshold',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
atRise && Math.abs(atRise.elevDeg - 10) < 0.2,
|
||||||
|
'elevation at riseMs must be within 0.2° of 10°',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
afterRise && afterRise.elevDeg > 9.95,
|
||||||
|
'elevation after riseMs must be rising above threshold',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify bisection accuracy at set time:
|
||||||
|
const beforeSet = lookAnglesAt(
|
||||||
|
satrec,
|
||||||
|
pass.setMs - 1000,
|
||||||
|
AUSTIN.latDeg,
|
||||||
|
AUSTIN.lonDeg,
|
||||||
|
);
|
||||||
|
const atSet = lookAnglesAt(satrec, pass.setMs, AUSTIN.latDeg, AUSTIN.lonDeg);
|
||||||
|
const afterSet = lookAnglesAt(
|
||||||
|
satrec,
|
||||||
|
pass.setMs + 1000,
|
||||||
|
AUSTIN.latDeg,
|
||||||
|
AUSTIN.lonDeg,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
beforeSet && beforeSet.elevDeg > 9.95,
|
||||||
|
'elevation before setMs must be above threshold',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
atSet && Math.abs(atSet.elevDeg - 10) < 0.2,
|
||||||
|
'elevation at setMs must be within 0.2° of 10°',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
afterSet && afterSet.elevDeg < 10.05,
|
||||||
|
'elevation after setMs must be below threshold',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parabolic culmination accuracy: max elevation must be peak
|
||||||
|
assert.ok(pass.maxElevDeg >= atRise.elevDeg);
|
||||||
|
assert.ok(pass.maxElevDeg >= atSet.elevDeg);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findNextSatellitePass samples the peak at fineStepSec', () => {
|
||||||
|
const satrec = twoline2satrec(L1, L2);
|
||||||
|
const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10 };
|
||||||
|
const fine = findNextSatellitePass({ ...opts, fineStepSec: 1 });
|
||||||
|
const coarse = findNextSatellitePass({ ...opts, fineStepSec: 10 });
|
||||||
|
assert.ok(fine && coarse);
|
||||||
|
// The step is used: peak samples differ.
|
||||||
|
assert.notEqual(fine.maxElevMs, coarse.maxElevMs);
|
||||||
|
// Rise comes from bisection, so it does not depend on the step.
|
||||||
|
assert.ok(Math.abs(fine.riseMs - coarse.riseMs) < 1000);
|
||||||
|
// Parabolic refinement keeps 10s sampling close to the 1s peak.
|
||||||
|
assert.ok(Math.abs(fine.maxElevMs - coarse.maxElevMs) < 1000);
|
||||||
|
assert.ok(Math.abs(fine.maxElevDeg - coarse.maxElevDeg) < 0.01);
|
||||||
|
});
|
||||||
@@ -51,6 +51,7 @@ export const _clearSatelliteLabelLifecycleForTest =
|
|||||||
export const applySatellitePointFocusDeemphasis =
|
export const applySatellitePointFocusDeemphasis =
|
||||||
layer.applySatellitePointFocusDeemphasis;
|
layer.applySatellitePointFocusDeemphasis;
|
||||||
export const getNextIssPass = layer.getNextIssPass;
|
export const getNextIssPass = layer.getNextIssPass;
|
||||||
|
export const getNextSatellitePass = layer.getNextSatellitePass;
|
||||||
export const scoreSatelliteNameMatch = layer.scoreSatelliteNameMatch;
|
export const scoreSatelliteNameMatch = layer.scoreSatelliteNameMatch;
|
||||||
export const findSatelliteOrbitTrackInTle = layer.findSatelliteOrbitTrackInTle;
|
export const findSatelliteOrbitTrackInTle = layer.findSatelliteOrbitTrackInTle;
|
||||||
export const getSatelliteOrbitTrack = layer.getSatelliteOrbitTrack;
|
export const getSatelliteOrbitTrack = layer.getSatelliteOrbitTrack;
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export function createSatellitesLayer({ services, source }) {
|
|||||||
applySatellitePointFocusDeemphasis:
|
applySatellitePointFocusDeemphasis:
|
||||||
parts.rendering.applySatellitePointFocusDeemphasis,
|
parts.rendering.applySatellitePointFocusDeemphasis,
|
||||||
getNextIssPass: parts.orbits.getNextIssPass,
|
getNextIssPass: parts.orbits.getNextIssPass,
|
||||||
|
getNextSatellitePass: parts.orbits.getNextSatellitePass,
|
||||||
scoreSatelliteNameMatch: parts.orbits.scoreSatelliteNameMatch,
|
scoreSatelliteNameMatch: parts.orbits.scoreSatelliteNameMatch,
|
||||||
findSatelliteOrbitTrackInTle: parts.orbits.findSatelliteOrbitTrackInTle,
|
findSatelliteOrbitTrackInTle: parts.orbits.findSatelliteOrbitTrackInTle,
|
||||||
getSatelliteOrbitTrack: parts.orbits.getSatelliteOrbitTrack,
|
getSatelliteOrbitTrack: parts.orbits.getSatelliteOrbitTrack,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
degreesLat,
|
degreesLat,
|
||||||
twoline2satrec,
|
twoline2satrec,
|
||||||
} from 'satellite.js';
|
} from 'satellite.js';
|
||||||
import { findNextIssPass } from '../../data/issPass.js';
|
import { findNextSatellitePass } from '../../data/satellitePass.js';
|
||||||
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
|
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
|
||||||
|
|
||||||
export function createOrbits({ state: layerState, services, parts, source }) {
|
export function createOrbits({ state: layerState, services, parts, source }) {
|
||||||
@@ -136,17 +136,51 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
|||||||
/**
|
/**
|
||||||
* Next ISS pass for an observer. Requires the catalog to have loaded (the
|
* Next ISS pass for an observer. Requires the catalog to have loaded (the
|
||||||
* satellites layer enabled at least once this session).
|
* satellites layer enabled at least once this session).
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {number} options.latDeg Observer latitude
|
||||||
|
* @param {number} options.lonDeg Observer longitude
|
||||||
|
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
|
||||||
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number}}}
|
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number}}}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function getNextIssPass({ latDeg, lonDeg, minElevDeg = 10 }) {
|
function getNextIssPass({
|
||||||
const sat = layerState._catalog.get(ISS_NORAD);
|
latDeg,
|
||||||
|
lonDeg,
|
||||||
|
minElevDeg = 10,
|
||||||
|
fromMs = Date.now(),
|
||||||
|
}) {
|
||||||
|
return getNextSatellitePass(ISS_NORAD, {
|
||||||
|
latDeg,
|
||||||
|
lonDeg,
|
||||||
|
minElevDeg,
|
||||||
|
fromMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next pass of any catalog satellite for an observer.
|
||||||
|
* @param {number} noradId NORAD satellite catalog ID
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {number} options.latDeg Observer latitude
|
||||||
|
* @param {number} options.lonDeg Observer longitude
|
||||||
|
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
|
||||||
|
* @param {number} [options.fromMs=Date.now()] Search start time in UTC milliseconds
|
||||||
|
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:Object}}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function getNextSatellitePass(
|
||||||
|
noradId,
|
||||||
|
{ latDeg, lonDeg, minElevDeg = 10, fromMs = Date.now() },
|
||||||
|
) {
|
||||||
|
const id = Number(noradId);
|
||||||
|
if (!Number.isFinite(id)) return { status: 'no-tle' };
|
||||||
|
const sat = layerState._catalog.get(id);
|
||||||
if (!sat || !sat.satrec) return { status: 'no-tle' };
|
if (!sat || !sat.satrec) return { status: 'no-tle' };
|
||||||
const pass = findNextIssPass({
|
const pass = findNextSatellitePass({
|
||||||
satrec: sat.satrec,
|
satrec: sat.satrec,
|
||||||
latDeg,
|
latDeg,
|
||||||
lonDeg,
|
lonDeg,
|
||||||
fromMs: Date.now(),
|
fromMs,
|
||||||
minElevDeg,
|
minElevDeg,
|
||||||
});
|
});
|
||||||
return pass ? { status: 'ok', pass } : { status: 'none' };
|
return pass ? { status: 'ok', pass } : { status: 'none' };
|
||||||
@@ -316,6 +350,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
|||||||
orbitalPeriodSeconds,
|
orbitalPeriodSeconds,
|
||||||
computeOrbitPath,
|
computeOrbitPath,
|
||||||
getNextIssPass,
|
getNextIssPass,
|
||||||
|
getNextSatellitePass,
|
||||||
scoreSatelliteNameMatch,
|
scoreSatelliteNameMatch,
|
||||||
internationalDesignatorYear,
|
internationalDesignatorYear,
|
||||||
lookupTleEntries,
|
lookupTleEntries,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { twoline2satrec } from 'satellite.js';
|
||||||
import { createSatelliteSource } from './source.js';
|
import { createSatelliteSource } from './source.js';
|
||||||
import { createSatellitesLayer } from './index.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.equal(second.getParams().showPoints, true);
|
||||||
assert.notEqual(first.getStats(), second.getStats());
|
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' },
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user