server/test/celestrak.test.ts
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});