Spaces:
Configuration error
Configuration error
| import { JulianDate, TimeInterval, TimeIntervalCollection } from "@cesium/engine"; | |
| import dayjs from "dayjs"; | |
| import utc from "dayjs/plugin/utc"; | |
| import type { SwathExtents } from "../config/satelliteMetadata"; | |
| import type Orbit from "./Orbit"; | |
| import type { GroundStationPosition } from "./Orbit"; | |
| import type { PassPredictorSource, WorkerPass } from "./util/passSource"; | |
| dayjs.extend(utc); | |
| /** Defined off-thread, where the prediction runs, and re-exported here for its callers. */ | |
| export type Pass = WorkerPass; | |
| export interface GroundStation { | |
| name: string; | |
| position: GroundStationPosition; | |
| } | |
| /** One rendered row of the passes table in the entity info panel. */ | |
| export interface PassRow { | |
| key: string; | |
| name: string; | |
| countdown: string; | |
| startLabel: string; | |
| endLabel: string; | |
| /** Max elevation (elevation mode) or min distance (swath mode). */ | |
| primary: string; | |
| /** Azimuth at apex (elevation mode) or swath width (swath mode). */ | |
| secondary: string; | |
| /** | |
| * Pass start in epoch milliseconds. Both a time jump on click and the row's | |
| * identity. The panel highlights the next pass, and the one picked off the | |
| * timeline, by start time rather than by index. So nothing has to assume this | |
| * list and the `Pass[]` it came from stay aligned. | |
| */ | |
| startMs: number; | |
| } | |
| interface PassWindow { | |
| start: JulianDate; | |
| stop: JulianDate; | |
| stopPrediction: JulianDate; | |
| } | |
| /** | |
| * The single owner of pass prediction for one satellite: holds the ground | |
| * stations, the overpass mode, the window guard that decides when passes are | |
| * recomputed, the computed pass list, and the Cesium intervals derived from | |
| * it (used to gate pass-dependent visuals like the ground-station link). | |
| * | |
| * Setting `groundStations` or `mode` invalidates the window; the next | |
| * `passes(time)` call requests a recompute. | |
| * | |
| * `passes(time)` stays synchronous and never blocks. The prediction itself runs | |
| * off-thread — 8 ms of SGP4 per satellite per station, which is 40 s across five | |
| * thousand satellites and used to be 40 s of frozen page — so a read while a | |
| * window is stale returns the previous list, or nothing on the first read, and the | |
| * answer lands a moment later. Callers that need to know when that happens | |
| * subscribe with `onChanged`; the info panel re-reads once a second anyway. | |
| */ | |
| export class PassPredictor { | |
| #orbit: Orbit; | |
| // Per-side swath extents, read lazily per recompute so a satellite whose | |
| // record arrives later still predicts against its own footprint. | |
| #swath: () => SwathExtents; | |
| #groundStations: GroundStation[] = []; | |
| #mode = "elevation"; | |
| #passes: Pass[] = []; | |
| #window: PassWindow | undefined; | |
| /** Where prediction happens. Injected, so nothing here knows about workers. */ | |
| readonly #source: PassPredictorSource; | |
| /** True while a request is out, so a per-frame read does not queue another. */ | |
| #requesting = false; | |
| /** The time a read asked about while a request was already out. */ | |
| #pendingTime: JulianDate | undefined; | |
| /** The request in flight, so a coalesced caller can await the real answer. */ | |
| #inFlight: Promise<void> | undefined; | |
| /** | |
| * Bumped by `clear()`. A reply carrying an older generation was computed against | |
| * ground stations or a mode that have since changed, so it is dropped rather | |
| * than shown — the request that replaces it is already out. | |
| */ | |
| #generation = 0; | |
| #listeners = new Set<() => void>(); | |
| /** Pass time ranges as Cesium intervals, kept in sync with the pass list. */ | |
| passIntervals = new TimeIntervalCollection(); | |
| constructor(orbit: Orbit, swath: () => SwathExtents, source: PassPredictorSource) { | |
| this.#orbit = orbit; | |
| this.#swath = swath; | |
| this.#source = source; | |
| } | |
| /** | |
| * Called whenever the pass list changes. Returns an unsubscribe. | |
| * | |
| * A set rather than one callback because two things want it for the same | |
| * satellite: the timeline highlight ranges and the ground-station link. | |
| */ | |
| onChanged(listener: () => void): () => void { | |
| this.#listeners.add(listener); | |
| return () => this.#listeners.delete(listener); | |
| } | |
| get groundStations(): GroundStation[] { | |
| return this.#groundStations; | |
| } | |
| set groundStations(groundStations: GroundStation[]) { | |
| this.#groundStations = groundStations; | |
| this.clear(); | |
| } | |
| get groundStationAvailable(): boolean { | |
| return this.#groundStations.length > 0; | |
| } | |
| get mode(): string { | |
| return this.#mode; | |
| } | |
| /** Overpass mode: "elevation" (line-of-sight) or "swath" (sensor footprint). */ | |
| set mode(mode: string) { | |
| if (mode === this.#mode) { | |
| return; | |
| } | |
| this.#mode = mode; | |
| this.clear(); | |
| } | |
| /** | |
| * The pass list valid around `time`, requesting a recompute if it is not. | |
| * | |
| * Recomputes only when `time` leaves the current window (±1 day around the last | |
| * compute, predicting 4 days ahead), which keeps the list valid after large time | |
| * jumps without asking on every read. | |
| */ | |
| passes(time: JulianDate): Pass[] { | |
| if (!this.groundStationAvailable) { | |
| return this.#passes; | |
| } | |
| if (this.#covers(time)) { | |
| return this.#passes; | |
| } | |
| void this.#request(time); | |
| return this.#passes; | |
| } | |
| /** | |
| * Whether `passes(time)` is answering from a window that covers `time` rather | |
| * than from a stale list while a recompute is in flight. | |
| * | |
| * The panel needs this to tell "nothing to show" from "nothing yet": prediction | |
| * runs off-thread, so the first read after a selection returns an empty list that | |
| * looks exactly like a satellite with no passes. Answering `true` with no ground | |
| * station is deliberate — there is nothing to wait for, and the caller has its own | |
| * word for that case. | |
| */ | |
| settled(time: JulianDate): boolean { | |
| return !this.groundStationAvailable || this.#covers(time); | |
| } | |
| /** Resolves once the list covers `time`. For callers that cannot use a stale one. */ | |
| ensurePasses(time: JulianDate): Promise<Pass[]> { | |
| if (!this.groundStationAvailable) { | |
| return Promise.resolve(this.#passes); | |
| } | |
| if (this.#covers(time)) { | |
| return Promise.resolve(this.#passes); | |
| } | |
| return this.#request(time).then(() => this.#passes); | |
| } | |
| clear(): void { | |
| this.#window = undefined; | |
| this.#passes = []; | |
| this.#generation += 1; | |
| this.passIntervals = new TimeIntervalCollection(); | |
| } | |
| /** | |
| * Ask for the window around `time`, at most one request at a time. | |
| * | |
| * Coalesced the way the sample window is: a read arriving while a request is out | |
| * neither queues another — at a fast clock the reads outrun the replies — nor is | |
| * dropped, which would lose a clock that jumped mid-request. The latest time is | |
| * remembered and re-asked once. | |
| */ | |
| #request(time: JulianDate): Promise<void> { | |
| if (this.#requesting) { | |
| this.#pendingTime = JulianDate.clone(time); | |
| // The one in flight, not a resolved promise: `ensurePasses` promises a list | |
| // that covers the time asked about, and resolving now would hand back the | |
| // stale one — empty, on a first read. | |
| return this.#inFlight ?? Promise.resolve(); | |
| } | |
| this.#requesting = true; | |
| const generation = this.#generation; | |
| const window: PassWindow = { | |
| start: JulianDate.addDays(time, -1, JulianDate.clone(time)), | |
| stop: JulianDate.addDays(time, 1, JulianDate.clone(time)), | |
| stopPrediction: JulianDate.addDays(time, 4, JulianDate.clone(time)), | |
| }; | |
| this.#inFlight = this.#source | |
| .passes({ | |
| mode: this.#mode, | |
| stations: this.#groundStations.map((station) => ({ name: station.name, position: station.position })), | |
| startEpochMs: JulianDate.toDate(window.start).getTime(), | |
| endEpochMs: JulianDate.toDate(window.stopPrediction).getTime(), | |
| swath: this.#swath(), | |
| }) | |
| .then((passes) => { | |
| if (generation !== this.#generation || passes === undefined) { | |
| return; | |
| } | |
| this.#apply(window, passes); | |
| }) | |
| .finally(() => { | |
| this.#requesting = false; | |
| this.#inFlight = undefined; | |
| const pending = this.#pendingTime; | |
| this.#pendingTime = undefined; | |
| // Only if the window that just landed does not already cover it. Without | |
| // the check every reply spawned another full prediction for a time the | |
| // answer already included, which at a ground station over thousands of | |
| // satellites kept the worker recomputing the same windows forever. | |
| if (pending && !this.#covers(pending)) { | |
| void this.#request(pending); | |
| } | |
| }); | |
| return this.#inFlight; | |
| } | |
| #covers(time: JulianDate): boolean { | |
| return this.#window !== undefined && TimeInterval.contains(new TimeInterval({ start: this.#window.start, stop: this.#window.stop }), time); | |
| } | |
| #apply(window: PassWindow, passes: Pass[]): void { | |
| // The name is stamped here rather than in the worker, which is keyed on satnum | |
| // and holds no name at all. See OrbitCache. | |
| for (const pass of passes) { | |
| pass.name = this.#orbit.name; | |
| } | |
| this.#window = window; | |
| this.#passes = passes; | |
| this.passIntervals = new TimeIntervalCollection( | |
| passes.map( | |
| (pass) => | |
| new TimeInterval({ | |
| start: JulianDate.fromDate(new Date(pass.start)), | |
| stop: JulianDate.fromDate(new Date(pass.end)), | |
| }), | |
| ), | |
| ); | |
| this.#listeners.forEach((listener) => listener()); | |
| } | |
| } | |
| /** | |
| * Aggregate the passes of many satellites over one ground station: recompute | |
| * each predictor as needed, keep passes over the named station starting within | |
| * `deltaHours`, sorted by start time. | |
| */ | |
| /** Whether every predictor feeding a station's list has settled — see `settled`. */ | |
| export function stationPassesSettled(predictors: readonly PassPredictor[], time: JulianDate): boolean { | |
| return predictors.every((predictor) => predictor.settled(time)); | |
| } | |
| export function stationPasses(predictors: PassPredictor[], time: JulianDate, stationName: string, deltaHours = 48): Pass[] { | |
| const timeDate = JulianDate.toDate(time); | |
| return predictors | |
| .flatMap((predictor) => predictor.passes(time)) | |
| .filter((pass) => dayjs(pass.start).diff(timeDate, "hours") < deltaHours && pass.groundStationName === stationName) | |
| .toSorted((a, b) => a.start - b.start); | |
| } | |
| /** | |
| * Filter passes for display: by default only ongoing and upcoming passes are | |
| * kept; with showPast the full list (including finished passes) is returned. | |
| */ | |
| export function filterPasses(passes: Pass[], time: JulianDate, showPast: boolean): Pass[] { | |
| if (showPast) { | |
| return passes; | |
| } | |
| const start = dayjs(JulianDate.toDate(time)); | |
| return passes.filter((pass) => dayjs(pass.end).isAfter(start)); | |
| } | |
| /** | |
| * How long until a pass, at the precision it is read at: "3 h 27 m", "42 s", | |
| * "ongoing", "ended". | |
| * | |
| * Two units at most, and seconds only inside the last minute. A countdown hours | |
| * away then stops changing every second: a column of thirty of them re-rendering | |
| * every tick was mostly noise. | |
| */ | |
| export function formatCountdown(nowMs: number, pass: Pass): string { | |
| if (pass.end < nowMs) { | |
| return "ended"; | |
| } | |
| if (pass.start <= nowMs) { | |
| return "ongoing"; | |
| } | |
| const seconds = Math.floor((pass.start - nowMs) / 1000); | |
| if (seconds < 60) { | |
| return `${seconds} s`; | |
| } | |
| const minutes = Math.floor(seconds / 60); | |
| if (minutes < 60) { | |
| return `${minutes} m ${seconds % 60} s`; | |
| } | |
| const hours = Math.floor(minutes / 60); | |
| if (hours < 24) { | |
| return `${hours} h ${minutes % 60} m`; | |
| } | |
| return `${Math.floor(hours / 24)} d ${hours % 24} h`; | |
| } | |
| /** Whole minutes of a pass. `Pass.duration` is `end - start`, so milliseconds. */ | |
| export function passMinutes(pass: Pass): number { | |
| return Math.round(pass.duration / 60_000); | |
| } | |
| /** `HH:mm` UTC — the precision a pass window is quoted at. */ | |
| export function hhmmUtc(epochMs: number): string { | |
| return dayjs.utc(epochMs).format("HH:mm"); | |
| } | |
| /** | |
| * How good a pass is, as 0..1, for anything that draws rather than tabulates. | |
| * | |
| * Max elevation over 90° in elevation mode; in swath mode how close to the centre | |
| * of the footprint the station falls, which is the same question the mode asks. | |
| */ | |
| export function passQuality(pass: Pass): number { | |
| if ("maxElevation" in pass) { | |
| return Math.min(1, Math.max(0, pass.maxElevation / 90)); | |
| } | |
| return Math.min(1, Math.max(0, 1 - pass.minDistance / Math.max(1, pass.swathWidth / 2))); | |
| } | |
| /** The pass in one line: window, length, and what the current mode measures. */ | |
| export function passSummary(pass: Pass): string { | |
| const window = `${hhmmUtc(pass.start)}–${hhmmUtc(pass.end)} UTC · ${passMinutes(pass)} min`; | |
| if ("maxElevation" in pass) { | |
| return `${window} · ${pass.maxElevation.toFixed(0)}° max, apex ${compassPoint(pass.azimuthApex)}`; | |
| } | |
| return `${window} · ${pass.minDistance.toFixed(0)} km off track, swath ${pass.swathWidth.toFixed(0)} km`; | |
| } | |
| /** A bearing as a 16-point compass abbreviation, which reads faster than degrees. */ | |
| export function compassPoint(azimuthDeg: number): string { | |
| const points = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]; | |
| return points[Math.round((((azimuthDeg % 360) + 360) % 360) / 22.5) % 16]!; | |
| } | |
| export function toPassRows(passes: Pass[], time: JulianDate, nameField: "name" | "groundStationName", mode: string): PassRow[] { | |
| const nowMs = JulianDate.toDate(time).getTime(); | |
| return passes.map((pass) => { | |
| let primary: string; | |
| let secondary: string; | |
| if (mode === "swath" && "minDistance" in pass) { | |
| primary = `${pass.minDistance.toFixed(1)}km`; | |
| secondary = `${pass.swathWidth.toFixed(0)}km`; | |
| } else if ("maxElevation" in pass) { | |
| primary = `${pass.maxElevation.toFixed(0)}°`; | |
| secondary = `${pass.azimuthApex.toFixed(2)}°`; | |
| } else { | |
| primary = ""; | |
| secondary = ""; | |
| } | |
| const name = pass[nameField] ?? ""; | |
| return { | |
| key: `${name}-${pass.start}-${pass.end}`, | |
| name, | |
| countdown: formatCountdown(nowMs, pass), | |
| startLabel: dayjs.utc(pass.start).format("DD.MM HH:mm:ss"), | |
| endLabel: dayjs.utc(pass.end).format("HH:mm:ss"), | |
| primary, | |
| secondary, | |
| startMs: pass.start, | |
| }; | |
| }); | |
| } | |