From f105a372bda141c293419abeb007569d5f8c3d60 Mon Sep 17 00:00:00 2001 From: benweru <138494503+benweru@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:30:27 +0300 Subject: [PATCH] feat(web): predict Nairobi contact windows from look angles Co-authored-by: Cursor --- web/README.md | 4 +- web/src/App.tsx | 81 ++++++++++++++++--- web/src/index.css | 39 +++++++++ web/src/orbit/countdown.test.ts | 26 ++++++ web/src/orbit/countdown.ts | 16 ++++ web/src/orbit/look.ts | 57 +++++++++++++ web/src/orbit/pass.test.ts | 68 ++++++++++++++++ web/src/orbit/pass.ts | 137 ++++++++++++++++++++++++++++++++ web/src/orbit/station.ts | 24 ++++++ 9 files changed, 440 insertions(+), 12 deletions(-) create mode 100644 web/src/orbit/countdown.test.ts create mode 100644 web/src/orbit/countdown.ts create mode 100644 web/src/orbit/look.ts create mode 100644 web/src/orbit/pass.test.ts create mode 100644 web/src/orbit/pass.ts create mode 100644 web/src/orbit/station.ts diff --git a/web/README.md b/web/README.md index b26a4dc..ea6fb72 100644 --- a/web/README.md +++ b/web/README.md @@ -21,8 +21,8 @@ From the repo root, `make test-web` runs `npm test` here. It is not part of ## Sprints - 12: scaffold -- 13: CelesTrak TLEs + SGP4 (this branch) -- 14: Nairobi contact windows +- 13: CelesTrak TLEs + SGP4 +- 14: Nairobi contact windows (this branch) - 15: Cesium globe, picker, credits, Heavens-Above/N2YO check `npm run dev` proxies `/api/celestrak` to CelesTrak so the browser can fetch diff --git a/web/src/App.tsx b/web/src/App.tsx index e558d2b..7ff96d4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,13 +1,22 @@ -import { useEffect, useState } from "react"; -import { SATELLITES } from "./orbit/catalog"; +import { useEffect, useMemo, useState } from "react"; import { tleCache, type CachedTle } from "./orbit/cache"; +import { SATELLITES } from "./orbit/catalog"; +import { countdownSeconds, formatCountdown } from "./orbit/countdown"; import { fetchAndCacheTle } from "./orbit/fetchTle"; +import { nextPass, type Pass } from "./orbit/pass"; +import { DEFAULT_ELEVATION_MASK_DEG, NAIROBI } from "./orbit/station"; import { V2_STATUS } from "./v2"; +function utc(d: Date): string { + return d.toISOString().replace(/\.\d{3}Z$/, "Z"); +} + export default function App() { const [cached, setCached] = useState>({}); + const [selected, setSelected] = useState(SATELLITES[0].catnr); const [error, setError] = useState(null); const [busy, setBusy] = useState(null); + const [now, setNow] = useState(() => new Date()); useEffect(() => { const cache = tleCache(); @@ -21,12 +30,18 @@ export default function App() { setCached(next); }, []); + useEffect(() => { + const id = window.setInterval(() => setNow(new Date()), 1000); + return () => window.clearInterval(id); + }, []); + async function load(catnr: number) { setBusy(catnr); setError(null); try { const entry = await fetchAndCacheTle(catnr); setCached((prev) => ({ ...prev, [catnr]: entry })); + setSelected(catnr); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -34,24 +49,43 @@ export default function App() { } } + const tle = cached[selected]; + const passBucket = Math.floor(now.getTime() / 15_000); + const pass: Pass | null = useMemo(() => { + if (!tle) { + return null; + } + return nextPass(tle, NAIROBI, new Date(), DEFAULT_ELEVATION_MASK_DEG); + }, [tle, passBucket]); + + const inPass = + pass !== null && + now.getTime() >= pass.rise.getTime() && + now.getTime() < pass.set.getTime(); + return (

OrbitLog

Ride along with a real satellite and watch it think.

- A TLE is a snapshot of an orbit. SGP4 is the standard model that - turns it into a position. Fetch times below are when this browser - last pulled CelesTrak; the globe comes in a later sprint. + A contact window is the few minutes a satellite is + above {DEFAULT_ELEVATION_MASK_DEG}° elevation at {NAIROBI.name}.{" "} + Look angles are azimuth (compass direction) and + elevation (angle above the horizon). The globe comes next.

    {SATELLITES.map((sat) => { const hit = cached[sat.catnr]; + const active = sat.catnr === selected; return ( -
  • - - {sat.name}{" "} - CATNR {sat.catnr} - +
  • + {hit ? `TLE fetched ${hit.fetchedAt}` : "TLE not fetched"} @@ -67,6 +101,33 @@ export default function App() { })}
{error ?

{error}

: null} +
+

+ Next pass over {NAIROBI.name} (mask {DEFAULT_ELEVATION_MASK_DEG}°) +

+ {!tle ? ( +

Fetch a TLE to predict the next window.

+ ) : !pass ? ( +

No pass in the next 36 hours for this TLE.

+ ) : ( +
+
Countdown
+
+ {inPass + ? `In contact · sets in ${formatCountdown(countdownSeconds(pass.set, now))}` + : formatCountdown(countdownSeconds(pass.rise, now))} +
+
Rise
+
{utc(pass.rise)}
+
Max elevation
+
+ {pass.maxElDeg.toFixed(1)}° at {utc(pass.maxElAt)} +
+
Set
+
{utc(pass.set)}
+
+ )} +

v2, the onboard diagnosis story, is {V2_STATUS} until the benchmark writes files under results/. This page will diff --git a/web/src/index.css b/web/src/index.css index 85f32a7..e0e1a4e 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -54,6 +54,45 @@ p { border-top: 1px solid #2a2e35; } +.sats li.active { + background: #1a1d22; +} + +.pick { + text-align: left; + background: none; + border: 0; + color: inherit; + padding: 0; + cursor: pointer; +} + +dl { + display: grid; + grid-template-columns: 10rem 1fr; + gap: 0.35rem 1rem; + margin: 0; +} + +dt { + color: #9aa0a6; +} + +dd { + margin: 0; +} + +.pass h2 { + font-size: 1rem; + font-weight: 600; + margin: 0 0 0.75rem; +} + +.pass { + margin: 0 0 1.5rem; + padding-top: 0.5rem; +} + .sats li .meta { grid-column: 1; font-size: 0.85rem; diff --git a/web/src/orbit/countdown.test.ts b/web/src/orbit/countdown.test.ts new file mode 100644 index 0000000..4a624b4 --- /dev/null +++ b/web/src/orbit/countdown.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { countdownSeconds, formatCountdown } from "./countdown"; + +describe("countdownSeconds", () => { + it("clamps at zero", () => { + const to = new Date("2026-10-19T12:00:00.000Z"); + const now = new Date("2026-10-19T12:00:10.000Z"); + expect(countdownSeconds(to, now)).toBe(0); + }); + + it("counts whole seconds remaining", () => { + const to = new Date("2026-10-19T12:01:30.000Z"); + const now = new Date("2026-10-19T12:00:00.000Z"); + expect(countdownSeconds(to, now)).toBe(90); + }); +}); + +describe("formatCountdown", () => { + it("formats mm:ss under an hour", () => { + expect(formatCountdown(90)).toBe("01:30"); + }); + + it("formats h:mm:ss over an hour", () => { + expect(formatCountdown(3661)).toBe("1:01:01"); + }); +}); diff --git a/web/src/orbit/countdown.ts b/web/src/orbit/countdown.ts new file mode 100644 index 0000000..26340c5 --- /dev/null +++ b/web/src/orbit/countdown.ts @@ -0,0 +1,16 @@ +/** Seconds remaining until `to`, never negative. */ +export function countdownSeconds(to: Date, now: Date): number { + return Math.max(0, Math.floor((to.getTime() - now.getTime()) / 1000)); +} + +export function formatCountdown(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(totalSeconds)); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + const two = (n: number) => String(n).padStart(2, "0"); + if (h > 0) { + return `${h}:${two(m)}:${two(s)}`; + } + return `${two(m)}:${two(s)}`; +} diff --git a/web/src/orbit/look.ts b/web/src/orbit/look.ts new file mode 100644 index 0000000..6ae835f --- /dev/null +++ b/web/src/orbit/look.ts @@ -0,0 +1,57 @@ +/** + * Look angles are how a ground station sees a satellite: azimuth (compass + * direction, 0° = north), elevation (angle above the horizon), and range. + * + * A contact window is the stretch of time elevation stays above the mask. + */ +import * as satellite from "satellite.js"; +import type { SatRec } from "satellite.js"; +import type { Station } from "./station"; +import type { ParsedTle } from "./tle"; + +export type LookAngles = { + azimuthDeg: number; + elevationDeg: number; + rangeKm: number; +}; + +export function satrecFromTle(tle: ParsedTle): SatRec { + return satellite.twoline2satrec(tle.line1, tle.line2); +} + +export function lookAnglesAt( + satrec: SatRec, + station: Station, + when: Date, +): LookAngles { + const result = satellite.propagate(satrec, when); + const position = result?.position; + if ( + position === undefined || + position === null || + typeof position.x !== "number" + ) { + throw new Error("SGP4 did not return a position"); + } + const gmst = satellite.gstime(when); + const ecf = satellite.eciToEcf(position, gmst); + const observer = { + latitude: satellite.degreesToRadians(station.latitudeDeg), + longitude: satellite.degreesToRadians(station.longitudeDeg), + height: station.heightKm, + }; + const look = satellite.ecfToLookAngles(observer, ecf); + return { + azimuthDeg: satellite.radiansToDegrees(look.azimuth), + elevationDeg: satellite.radiansToDegrees(look.elevation), + rangeKm: look.rangeSat, + }; +} + +export function lookAngles( + tle: ParsedTle, + station: Station, + when: Date, +): LookAngles { + return lookAnglesAt(satrecFromTle(tle), station, when); +} diff --git a/web/src/orbit/pass.test.ts b/web/src/orbit/pass.test.ts new file mode 100644 index 0000000..4ef3a2b --- /dev/null +++ b/web/src/orbit/pass.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import issTle from "./fixtures/iss.tle?raw"; +import { lookAngles } from "./look"; +import { nextPass } from "./pass"; +import { DEFAULT_ELEVATION_MASK_DEG, NAIROBI } from "./station"; +import { parseTle } from "./tle"; + +const tle = parseTle(issTle); +const from = new Date(Date.UTC(2026, 9, 19, 10, 26, 22)); + +describe("lookAngles", () => { + it("returns finite azimuth and elevation from Nairobi", () => { + const look = lookAngles(tle, NAIROBI, from); + expect(Number.isFinite(look.azimuthDeg)).toBe(true); + expect(Number.isFinite(look.elevationDeg)).toBe(true); + expect(look.azimuthDeg).toBeGreaterThanOrEqual(0); + expect(look.azimuthDeg).toBeLessThan(360); + expect(look.elevationDeg).toBeGreaterThan(-90); + expect(look.elevationDeg).toBeLessThan(90); + }); +}); + +describe("nextPass", () => { + it("finds a Nairobi ISS pass with rise, max elevation and set", () => { + const pass = nextPass(tle, NAIROBI, from, DEFAULT_ELEVATION_MASK_DEG); + expect(pass).not.toBeNull(); + if (!pass) { + return; + } + expect(pass.rise.getTime()).toBeLessThanOrEqual(pass.maxElAt.getTime()); + expect(pass.maxElAt.getTime()).toBeLessThanOrEqual(pass.set.getTime()); + expect(pass.set.getTime()).toBeGreaterThan(pass.rise.getTime()); + expect(pass.maxElDeg).toBeGreaterThanOrEqual(DEFAULT_ELEVATION_MASK_DEG); + const minutes = (pass.set.getTime() - pass.rise.getTime()) / 60_000; + expect(minutes).toBeGreaterThan(1); + expect(minutes).toBeLessThan(20); + expect(lookAngles(tle, NAIROBI, pass.rise).elevationDeg).toBeGreaterThanOrEqual( + DEFAULT_ELEVATION_MASK_DEG - 0.5, + ); + expect( + lookAngles(tle, NAIROBI, new Date(pass.rise.getTime() - 2000)).elevationDeg, + ).toBeLessThan(DEFAULT_ELEVATION_MASK_DEG); + expect( + lookAngles(tle, NAIROBI, new Date(pass.set.getTime() + 2000)).elevationDeg, + ).toBeLessThan(DEFAULT_ELEVATION_MASK_DEG); + }); + + it("treats 0° as a lower mask than 10° at the same 10° pass edges", () => { + const high = nextPass(tle, NAIROBI, from, 10); + expect(high).not.toBeNull(); + if (!high) { + return; + } + const before = lookAngles( + tle, + NAIROBI, + new Date(high.rise.getTime() - 2000), + ).elevationDeg; + const after = lookAngles( + tle, + NAIROBI, + new Date(high.set.getTime() + 2000), + ).elevationDeg; + expect(before).toBeLessThan(10); + expect(after).toBeLessThan(10); + expect(before).toBeGreaterThan(-90); + }); +}); diff --git a/web/src/orbit/pass.ts b/web/src/orbit/pass.ts new file mode 100644 index 0000000..69f3010 --- /dev/null +++ b/web/src/orbit/pass.ts @@ -0,0 +1,137 @@ +import type { SatRec } from "satellite.js"; +import { lookAnglesAt, satrecFromTle } from "./look"; +import type { Station } from "./station"; +import type { ParsedTle } from "./tle"; + +export type Pass = { + rise: Date; + set: Date; + maxElDeg: number; + maxElAt: Date; +}; + +const STEP_MS = 15_000; +const REFINE_MS = 1_000; +const SEARCH_MS = 36 * 60 * 60 * 1000; +const BACK_MS = 20 * 60 * 1000; + +function elevation(satrec: SatRec, station: Station, ms: number): number | null { + try { + return lookAnglesAt(satrec, station, new Date(ms)).elevationDeg; + } catch { + return null; + } +} + +function above( + satrec: SatRec, + station: Station, + ms: number, + maskDeg: number, +): boolean { + const el = elevation(satrec, station, ms); + return el !== null && el >= maskDeg; +} + +function refineEdge( + satrec: SatRec, + station: Station, + lo: number, + hi: number, + maskDeg: number, + findRise: boolean, +): number { + while (hi - lo > REFINE_MS) { + const mid = Math.floor((lo + hi) / 2); + if (above(satrec, station, mid, maskDeg)) { + if (findRise) { + hi = mid; + } else { + lo = mid; + } + } else if (findRise) { + lo = mid; + } else { + hi = mid; + } + } + return findRise ? hi : lo; +} + +function peak( + satrec: SatRec, + station: Station, + riseMs: number, + setMs: number, +): { maxElDeg: number; maxElAt: Date } { + let maxElDeg = -90; + let maxElAtMs = riseMs; + for (let t = riseMs; t <= setMs; t += REFINE_MS) { + const el = elevation(satrec, station, t); + if (el !== null && el > maxElDeg) { + maxElDeg = el; + maxElAtMs = t; + } + } + return { maxElDeg, maxElAt: new Date(maxElAtMs) }; +} + +function completePass( + satrec: SatRec, + station: Station, + insideMs: number, + maskDeg: number, +): Pass | null { + let riseLo = Math.max(insideMs - BACK_MS, insideMs - SEARCH_MS); + while (riseLo < insideMs && above(satrec, station, riseLo, maskDeg)) { + riseLo = Math.max(insideMs - SEARCH_MS, riseLo - BACK_MS); + } + if (above(satrec, station, riseLo, maskDeg)) { + return null; + } + const rise = refineEdge(satrec, station, riseLo, insideMs, maskDeg, true); + + let setHi = insideMs + STEP_MS; + const limit = insideMs + SEARCH_MS; + while (setHi < limit && above(satrec, station, setHi, maskDeg)) { + setHi += STEP_MS; + } + if (above(satrec, station, setHi, maskDeg)) { + return null; + } + const set = refineEdge(satrec, station, insideMs, setHi, maskDeg, false); + const { maxElDeg, maxElAt } = peak(satrec, station, rise, set); + return { + rise: new Date(rise), + set: new Date(set), + maxElDeg, + maxElAt, + }; +} + +/** + * Next contact window: time the satellite stays above `maskDeg` at the station. + * If a pass is already in progress at `from`, that pass is returned. + */ +export function nextPass( + tle: ParsedTle, + station: Station, + from: Date, + maskDeg: number, +): Pass | null { + const satrec = satrecFromTle(tle); + const start = from.getTime(); + if (above(satrec, station, start, maskDeg)) { + return completePass(satrec, station, start, maskDeg); + } + const end = start + SEARCH_MS; + let prev = start; + for (let t = start + STEP_MS; t <= end; t += STEP_MS) { + if (above(satrec, station, t, maskDeg)) { + const rise = refineEdge(satrec, station, prev, t, maskDeg, true); + return completePass(satrec, station, rise + REFINE_MS, maskDeg); + } + prev = t; + } + return null; +} diff --git a/web/src/orbit/station.ts b/web/src/orbit/station.ts new file mode 100644 index 0000000..106feed --- /dev/null +++ b/web/src/orbit/station.ts @@ -0,0 +1,24 @@ +/** + * Ground station: where we stand on Earth to wait for a contact window. + * Coordinates are WGS-84 geodetic (the usual lat/lon/height). + * + * Nairobi city, not a specific dish: + * 1°17′11″S, 36°49′02″E, elevation 1,795 m + * https://en.wikipedia.org/wiki/Nairobi + */ +export type Station = { + name: string; + latitudeDeg: number; + longitudeDeg: number; + heightKm: number; +}; + +export const NAIROBI: Station = { + name: "Nairobi", + latitudeDeg: -1.286389, + longitudeDeg: 36.817223, + heightKm: 1.795, +}; + +/** Elevation mask: ignore the satellite until it is this many degrees up. */ +export const DEFAULT_ELEVATION_MASK_DEG = 10;