krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

server/test/celestrak.test.ts

6c4b28384d65e09486e0ad62f2ac9d10401cc80c
moonrise/server/test/celestrak.test.ts history · blame · raw

137 lines · 8752 bytes

  1import assert from "node:assert/strict";
  2import { test } from "node:test";
  3import { byDesignator, compose, fitsLaunch, parseSupGP, pick, starlinkTrains, supgpFile, supgpLaunches } from "../src/celestrak.ts";
  4
  5test("keeps only the SGP4 fields", () => {
  6  // CelesTrak GP JSON for the ISS, 2026-10-18.
  7  const row = {
  8    OBJECT_NAME: "ISS (ZARYA)", OBJECT_ID: "1998-067A", EPOCH: "2026-10-18T03:28:15.076416", MEAN_MOTION: 15.48918166,
  9    ECCENTRICITY: 0.000678, INCLINATION: 51.6328, RA_OF_ASC_NODE: 51.6259, ARG_OF_PERICENTER: 277.7856, MEAN_ANOMALY: 82.2363,
 10    EPHEMERIS_TYPE: 0, CLASSIFICATION_TYPE: "U", NORAD_CAT_ID: 25544, ELEMENT_SET_NO: 999, REV_AT_EPOCH: 59081, BSTAR: 0.00014456,
 11    MEAN_MOTION_DOT: 7.503e-05, MEAN_MOTION_DDOT: 0,
 12  };
 13  const [o] = pick([row]);
 14  assert.deepEqual(Object.keys(o).sort(), ["ARG_OF_PERICENTER", "BSTAR", "ECCENTRICITY", "EPOCH", "INCLINATION", "MEAN_ANOMALY", "MEAN_MOTION", "NORAD_CAT_ID", "OBJECT_NAME", "RA_OF_ASC_NODE"]);
 15  assert.equal(o.NORAD_CAT_ID, 25544);
 16});
 17
 18test("one Starlink train per low recent launch, median satellite", () => {
 19  const sat = (id: string, n: number, mm: number) => ({
 20    OBJECT_NAME: `STARLINK-${n}`, OBJECT_ID: id, NORAD_CAT_ID: n, EPOCH: "2026-10-18T00:00:00", MEAN_MOTION: mm,
 21    ECCENTRICITY: 0.0001, INCLINATION: 53, RA_OF_ASC_NODE: 10, ARG_OF_PERICENTER: 90, MEAN_ANOMALY: 0, BSTAR: 0.001,
 22  });
 23  const trains = starlinkTrains(byDesignator([
 24    sat("2026-232A", 1, 15.9), sat("2026-232B", 2, 16.0), sat("2026-232C", 3, 15.95),
 25    sat("2026-180A", 4, 15.06), sat("2026-180B", 5, 15.07),
 26    { ...sat("2026-232D", 6, 16.1), OBJECT_NAME: "FALCON 9 R/B" },
 27  ]));
 28  assert.equal(trains.length, 1);
 29  assert.equal(trains[0].OBJECT_NAME, "Starlink train (2026-232)");
 30  assert.equal(trains[0].NORAD_CAT_ID, 3);
 31});
 32
 33// sup-gp.php?FILE=starlink-g15-15&FORMAT=JSON on 2026-10-19 (trimmed). Its OBJECT_ID is a guess:
 34// 2026-235 went to USSF-481 on 2026-10-16.
 35const SUPGP = `[{"OBJECT_NAME":"STARLINK-G15-15 STACK","OBJECT_ID":"2026-235A","EPOCH":"2026-10-21T00:11:38.559984","MEAN_MOTION":16.05876681,"ECCENTRICITY":0.0009517,"INCLINATION":70.007,"RA_OF_ASC_NODE":92.2113,"ARG_OF_PERICENTER":261.8925,"MEAN_ANOMALY":112.7386,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"C","NORAD_CAT_ID":72000,"ELEMENT_SET_NO":0,"REV_AT_EPOCH":1,"BSTAR":0.00011238,"MEAN_MOTION_DOT":0.00082246,"MEAN_MOTION_DDOT":0,"RMS":"0.096","DATA_SOURCE":"SpaceX-SV"},
 36{"OBJECT_NAME":"STARLINK-G15-15 SINGLE","OBJECT_ID":"2026-235B","EPOCH":"2026-10-21T00:11:38.559984","MEAN_MOTION":16.05877557,"ECCENTRICITY":0.0009499,"INCLINATION":70.0069,"RA_OF_ASC_NODE":92.2113,"ARG_OF_PERICENTER":263.1103,"MEAN_ANOMALY":111.5203,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"C","NORAD_CAT_ID":72001,"ELEMENT_SET_NO":0,"REV_AT_EPOCH":1,"BSTAR":0.0016439,"MEAN_MOTION_DOT":0.012324,"MEAN_MOTION_DDOT":0,"RMS":"0.110","DATA_SOURCE":"SpaceX-SV"}]`;
 37
 38test("maps Starlink mission names to SupGP files", () => {
 39  assert.equal(supgpFile("Starlink Group 15-15"), "starlink-g15-15");
 40  assert.equal(supgpFile("Starlink Group 6-100"), "starlink-g6-100");
 41  assert.equal(supgpFile("Transporter 18 (Dedicated SSO Rideshare)"), null);
 42  assert.equal(supgpFile(null), null);
 43});
 44
 45test("SupGP misses and malformed rows are no data", () => {
 46  assert.equal(parseSupGP(SUPGP).length, 2);
 47  // An unknown FILE comes back as HTTP 200 with plain text.
 48  assert.deepEqual(parseSupGP("Invalid query: \"starlink-g99-99\" not found."), []);
 49  assert.deepEqual(parseSupGP("{}"), []);
 50  assert.deepEqual(parseSupGP("[{"), []);
 51  const rows = JSON.parse(SUPGP);
 52  delete rows[1].BSTAR;
 53  assert.equal(parseSupGP(JSON.stringify(rows)).length, 1);
 54  assert.deepEqual(parseSupGP(JSON.stringify([{ ...rows[0], MEAN_MOTION: "16" }])), []);
 55});
 56
 57test("SupGP sets must fit the launch time", () => {
 58  const rows = parseSupGP(SUPGP);
 59  const before = new Date("2026-10-19T12:00:00Z");
 60  // LL2 NET for Group 15-15: deployment 1 h 57 min later.
 61  assert.equal(fitsLaunch(rows, "2026-10-20T22:14:00Z", before).length, 2);
 62  // Slipped a day: the file is for the old time.
 63  assert.equal(fitsLaunch(rows, "2026-10-21T23:10:00Z", before).length, 0);
 64  // Epoch four hours after a liftoff still to come: not this opportunity.
 65  assert.equal(fitsLaunch(rows, "2026-10-20T20:00:00Z", before).length, 0);
 66  // After liftoff, later post-deployment sets are fine.
 67  assert.equal(fitsLaunch(rows, "2026-10-20T20:00:00Z", new Date("2026-10-21T06:00:00Z")).length, 2);
 68});
 69
 70const launch = (id: string, mission: string | null, net: string, designator: string | null = null, provider = "SpaceX") => ({ id, mission, provider, net, designator });
 71
 72test("SupGP is fetched for at most four nearby Starlink launches, nearest first", () => {
 73  const now = new Date("2026-10-19T12:00:00Z");
 74  const picked = supgpLaunches([
 75    launch("far", "Starlink Group 1-1", "2026-10-22T12:00:00Z"),
 76    launch("old", "Starlink Group 1-2", "2026-10-15T12:00:00Z"),
 77    launch("a", "Starlink Group 1-3", "2026-10-19T14:00:00Z"),
 78    launch("b", "Starlink Group 1-4", "2026-10-18T12:00:00Z"),
 79    launch("c", "Starlink Group 1-5", "2026-10-20T12:00:00Z"),
 80    launch("d", "Starlink Group 1-6", "2026-10-17T00:00:00Z"),
 81    launch("e", "Starlink Group 1-7", "2026-10-16T13:00:00Z"),
 82    launch("ussf", "USSF-481", "2026-10-19T13:00:00Z"),
 83    launch("other", "Starlink Group 1-8", "2026-10-19T13:00:00Z", null, "Rocket Lab"),
 84  ], now);
 85  assert.deepEqual(picked.map((l) => l.id), ["a", "b", "c", "d"]);
 86});
 87
 88const gp = (id: string, name: string, n: number, epoch = "2026-10-18T00:00:00", mm = 15.9) => ({
 89  OBJECT_NAME: name, OBJECT_ID: id, NORAD_CAT_ID: n, EPOCH: epoch, MEAN_MOTION: mm,
 90  ECCENTRICITY: 0.0001, INCLINATION: 53, RA_OF_ASC_NODE: 10, ARG_OF_PERICENTER: 90, MEAN_ANOMALY: 0, BSTAR: 0.001,
 91});
 92
 93test("labels trains with the mission and keeps SupGP apart from a designator it guessed", () => {
 94  const recent = [
 95    gp("2026-232A", "STARLINK-1", 1), gp("2026-232B", "STARLINK-2", 2), gp("2026-232C", "STARLINK-3", 3),
 96    gp("2026-235A", "USA 600", 10), gp("2026-235B", "FALCON 9 R/B", 11),
 97  ];
 98  const launches = [
 99    launch("g1525", "Starlink Group 15-25", "2026-10-13T00:28:11Z", "2026-232"),
100    launch("ussf", "USSF-481", "2026-10-16T00:01:05Z", "2026-235"),
101    launch("g1515", "Starlink Group 15-15", "2026-10-20T23:10:00Z"),
102  ];
103  const out = compose([gp("1998-067A", "ISS (ZARYA)", 25544)], recent, launches, { g1515: parseSupGP(SUPGP) }, new Date("2026-10-21T06:00:00Z"));
104  assert.deepEqual(out.satellites.map((s) => s.OBJECT_NAME), ["ISS (ZARYA)", "Starlink train (Group 15-25)", "Starlink train (Group 15-15)"]);
105  assert.deepEqual(out.launches.g1525.elements.map((e) => e.OBJECT_NAME), ["Starlink train (Group 15-25)"]);
106  assert.equal(out.launches.g1525.source, "gp");
107  assert.deepEqual(out.launches.ussf.elements.map((e) => e.OBJECT_NAME), ["FALCON 9 R/B", "USA 600"]);
108  assert.equal(out.launches.g1515.source, "supgp");
109  assert.equal(out.launches.g1515.elements[0].NORAD_CAT_ID, 72001);
110  // Before its predicted deployment a train is on the launch only: the satellite list is propagated from now.
111  const early = compose([], recent, launches, { g1515: parseSupGP(SUPGP) }, new Date("2026-10-19T12:00:00Z"));
112  assert.deepEqual(early.satellites.map((s) => s.OBJECT_NAME), ["Starlink train (Group 15-25)"]);
113  assert.equal(early.launches.g1515.source, "supgp");
114});
115
116test("the newer of GP and SupGP wins for a launch", () => {
117  const launches = [launch("g1525", "Starlink Group 15-25", "2026-10-13T00:28:11Z", "2026-232")];
118  const recent = [gp("2026-232A", "STARLINK-1", 1, "2026-10-18T00:00:00")];
119  const older = [gp("2026-999A", "STARLINK-G15-25 STACK", 72000, "2026-10-13T01:30:00")];
120  const newer = [gp("2026-999A", "STARLINK-G15-25 STACK", 72000, "2026-10-18T06:00:00")];
121  assert.equal(compose([], recent, launches, { g1525: older }, new Date("2026-10-19T12:00:00Z")).launches.g1525.source, "gp");
122  const out = compose([], recent, launches, { g1525: newer }, new Date("2026-10-19T12:00:00Z"));
123  assert.equal(out.launches.g1525.source, "supgp");
124  assert.equal(out.satellites.length, 1);
125});
126
127test("other launches list rocket bodies first, skip debris and stop at eight", () => {
128  const recent = [
129    ...Array.from({ length: 12 }, (_, i) => gp(`2026-227${String.fromCharCode(65 + i)}`, `SAT ${i}`, 100 + i)),
130    gp("2026-227M", "FALCON 9 R/B", 200), gp("2026-227N", "FALCON 9 DEB", 201),
131  ];
132  const out = compose([], recent, [launch("t18", "Transporter 18", "2026-10-01T18:32:00Z", "2026-227")], {}, new Date("2026-10-19T12:00:00Z"));
133  const names = out.launches.t18.elements.map((e) => e.OBJECT_NAME);
134  assert.equal(names.length, 8);
135  assert.equal(names[0], "FALCON 9 R/B");
136  assert.ok(!names.includes("FALCON 9 DEB"));
137});