1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/test/satellites.test.ts
T
karti a229fb2721 The sky gets the things above the aeroplanes
Satellites, end to end: CelesTrak element sets behind the same TTL cache
the weather and the flights use, served as TLEs rather than as positions,
and propagated in the browser with SGP4.

Sending elements is the same trick `flights/plan.ts` plays and it has a
better excuse here — a TLE *is* the closed form, valid for days either
side of its epoch, so one cacheable fetch every six hours replaces a poll
and every viewer agrees about where everything is.

Two things are worth knowing about the shape of it:

  - There is no region parameter. An aeroplane at 10,000 m is local and
    a satellite at 550 km is above the horizon for a circle two thousand
    kilometres across, so one catalogue serves both boards and the client
    decides what is above its own horizon. Only the observer is per-city,
    which is why `main.ts` shares the elements and rebuilds the catalogue.
  - The layer draws on a dome, because it cannot draw anywhere else.
    `world.metres(550_000)` is 21,000 scene units against a far plane at
    3,000. Azimuth and elevation are real; the radius carries nothing.

Off by default: a clone that started pulling CelesTrak on `npm run dev`
would have volunteered somebody else's bandwidth for its onboarding.

Godmode gets the two dials that point at the sky rather than at the
light — fabricated traffic, which composes with a live ADS-B feed instead
of replacing it, and a switch for the satellite layer with a count beside
it. Both are god-only lies about the inputs, in the manner of the weather
override.

`satellite.js` is the second runtime dependency this package has taken.
Its entry point star-exports an Emscripten build that cannot be shaken
out, so `noWasmPropagator` in the Vite config cuts it: 308 kB of WASM
loader for a bulk propagator nothing calls, against 26 kB for the SGP4
that does the work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:57:14 -07:00

201 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `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 4002,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");
});
});