/** * `SatelliteCatalogue`: the propagation, and the units it is easy to get wrong. * * SGP4 itself is `satellite.js`'s problem and is not re-tested here — it is a * direct translation of Vallado's reference implementation and has its own * conformance suite. What *is* tested is the chain around it, which is where * every bug in a satellite layer actually lives: ECI to ECF needs the sidereal * angle for the right instant, `ecfToLookAngles` wants the observer in **radians** * and kilometres, and getting either wrong produces angles that are plausible to * look at and completely false. * * The check that catches all of it is the **slant range**. It is a physical * consequence of the geometry rather than a number copied from somewhere: an * object above the horizon can be no closer than its own altitude (straight * overhead) and no further than the horizon-grazing chord, and that is a tight * window — roughly 400–2,400 km for the ISS. Feed the observer degrees instead * of radians and the ranges leave it immediately. * * The layer itself is not tested. It is three.js buffer writes with no branch * worth pinning, and testing it would mean standing up a GL context to assert * that a float landed in an array. */ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { SatelliteCatalogue, type SatelliteElements } from "../engine/satellites.ts"; /** San Francisco, which is `SAN_FRANCISCO.center` and is the default board. */ const SF = { lat: 37.7749, lng: -122.4194 }; /** * A real ISS element set. Chosen because the ISS is the one object whose orbit * everybody can check independently — 51.6° inclination, ~420 km, ~92 minutes — * and because at that inclination it genuinely passes over San Francisco * several times a day, which is what makes the visibility test below meaningful * rather than vacuous. */ const ISS: SatelliteElements = { noradId: 25544, name: "ISS (ZARYA)", group: "station", line1: "1 25544U 98067A 26037.51782528 -.00002182 00000-0 -11606-4 0 2927", line2: "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", }; /** * Near the element set's own epoch — day 37 of 2026. A TLE is good for days * either side of its epoch and degrades after that, so a test that propagated * one six months forward would be measuring the decay of the model rather than * the correctness of this module. */ const NEAR_EPOCH = new Date(Date.UTC(2026, 1, 6, 12, 0, 0)); /** * Bounds on how far away something above the horizon can be, in kilometres. * * The lower bound is the orbit's own altitude, less a margin for the ellipsoid * and for the object being a little low. The upper bound is the slant range to * an object on the horizon at this altitude, which is about 2,340 km for the * ISS; 2,600 leaves room without admitting anything absurd. */ const MIN_RANGE_KM = 350; const MAX_RANGE_KM = 2600; /** Walks the whole catalogue, however many budgeted calls that takes. */ function sweep(catalogue: SatelliteCatalogue, when: Date) { // The budget is two milliseconds a call and this catalogue holds one object, // so one call is a full pass — but looping to `size` keeps the helper honest // if a test ever hands it a larger set. let fixes = catalogue.fixes(when); for (let i = 0; i < catalogue.size; i += 1) fixes = catalogue.fixes(when); return fixes; } describe("reading element sets", () => { it("keeps the ones it can read", () => { assert.equal(new SatelliteCatalogue([ISS], SF).size, 1); }); it("skips a malformed set rather than throwing", () => { const broken: SatelliteElements = { ...ISS, line1: "1 nonsense", line2: "2 nonsense" }; const catalogue = new SatelliteCatalogue([broken, ISS], SF); assert.equal(catalogue.size, 1, "the good set should survive its neighbour"); }); it("reports nothing at all for an empty catalogue, and does not divide by zero", () => { const catalogue = new SatelliteCatalogue([], SF); assert.equal(catalogue.size, 0); assert.deepEqual(catalogue.fixes(NEAR_EPOCH), []); }); }); describe("the look angles", () => { /** * A day of the ISS over San Francisco, five minutes at a time. * * Sampled rather than asserted at one instant because a single sample proves * nothing: the ISS is below the horizon from any one place about ninety-five * per cent of the time, so a test pinned to one moment would almost certainly * be asserting on an empty array and would pass with the propagation deleted. */ function passesOverADay() { const catalogue = new SatelliteCatalogue([ISS], SF); const seen: { elevation: number; azimuth: number; rangeKm: number; shadow: number }[] = []; for (let minute = 0; minute < 24 * 60; minute += 5) { const when = new Date(NEAR_EPOCH.getTime() + minute * 60_000); for (const fix of sweep(catalogue, when)) seen.push(fix); } return seen; } it("puts the ISS over San Francisco several times a day", () => { const seen = passesOverADay(); // At 51.6° inclination and ~92 minutes, several passes a day is arithmetic, // not luck. Zero would mean the propagation or the observer is wrong. assert.ok(seen.length > 5, `only ${seen.length} five-minute samples were above the horizon`); }); it("never reports something below the horizon", () => { for (const fix of passesOverADay()) { assert.ok(fix.elevation >= 0, `elevation ${fix.elevation} rad is under the horizon`); } }); it("keeps elevation inside a quarter turn and azimuth inside a full one", () => { for (const fix of passesOverADay()) { assert.ok(fix.elevation <= Math.PI / 2 + 1e-6, `elevation ${fix.elevation} is past zenith`); assert.ok(Math.abs(fix.azimuth) <= 2 * Math.PI, `azimuth ${fix.azimuth} is off the compass`); } }); /** The one that catches degrees-for-radians. See the note at the top. */ it("reports a slant range the geometry actually permits", () => { const seen = passesOverADay(); assert.ok(seen.length > 0); for (const fix of seen) { assert.ok( fix.rangeKm >= MIN_RANGE_KM && fix.rangeKm <= MAX_RANGE_KM, `range ${Math.round(fix.rangeKm)} km is outside ${MIN_RANGE_KM}–${MAX_RANGE_KM} km, ` + `which is not a range a 420 km orbit can be seen at`, ); } }); it("reports a shadow fraction, not a boolean and not a stray number", () => { for (const fix of passesOverADay()) { assert.ok(fix.shadow >= 0 && fix.shadow <= 1, `shadow ${fix.shadow} is not a fraction`); } }); it("is a pure function of the instant it is given", () => { const a = new SatelliteCatalogue([ISS], SF); const b = new SatelliteCatalogue([ISS], SF); // Two viewers on two machines must agree, which is the whole reason the // server sends elements rather than positions. assert.deepEqual(sweep(a, NEAR_EPOCH), sweep(b, NEAR_EPOCH)); }); it("moves when the clock does", () => { const catalogue = new SatelliteCatalogue([ISS], SF); // A minute apart, so any instant where it is up at both ends has visibly // moved: the ISS crosses the sky in about ten. let differed = false; for (let minute = 0; minute < 24 * 60 && !differed; minute += 5) { const at = new Date(NEAR_EPOCH.getTime() + minute * 60_000); const later = new Date(at.getTime() + 60_000); const [before] = sweep(catalogue, at); const [after] = sweep(catalogue, later); if (before && after) differed = before.azimuth !== after.azimuth; } assert.ok(differed, "the sky never changed across a minute"); }); }); describe("the observer", () => { /** * The bug this exists for is a real one and it is invisible on screen: reusing * one catalogue across a city switch computes the second board's sky from the * first board's coordinates. Everything still renders, and every angle is * wrong. `main.ts` rebuilds per city because of this. */ it("is where the catalogue was told it is", () => { const sf = new SatelliteCatalogue([ISS], SF); const antipode = new SatelliteCatalogue([ISS], { lat: -37.7749, lng: 57.5806 }); let disagreed = false; for (let minute = 0; minute < 24 * 60 && !disagreed; minute += 5) { const when = new Date(NEAR_EPOCH.getTime() + minute * 60_000); const here = sweep(sf, when); const there = sweep(antipode, when); // Two observers on opposite sides of the earth cannot both be looking at // the same low-orbit object. if (here.length > 0 && there.length > 0) { disagreed = here[0]?.azimuth !== there[0]?.azimuth; } if (here.length !== there.length) disagreed = true; } assert.ok(disagreed, "the observer coordinate made no difference to the answer"); }); });