server/src/celestrak.ts
177 lines · 7914 bytes
1// CelesTrak GP data. CelesTrak updates every 2 hours and blocks IPs that fetch more
2// often or keep requesting after errors, so poll every 2 hours and stop on any non-200.
3
4/** ISS, Tiangong (CSS Tianhe) and Hubble; all three are in the visual group. */
5export const FEATURED = [25544, 48274, 20580];
6
7/** Median mean motion above which a Starlink launch is still low enough to be seen as a train (about 400 km). */
8const TRAIN_MEAN_MOTION = 15.45;
9
10export interface OMM {
11 OBJECT_NAME: string;
12 NORAD_CAT_ID: number;
13 EPOCH: string;
14 MEAN_MOTION: number;
15 ECCENTRICITY: number;
16 INCLINATION: number;
17 RA_OF_ASC_NODE: number;
18 ARG_OF_PERICENTER: number;
19 MEAN_ANOMALY: number;
20 BSTAR: number;
21}
22
23const KEYS: (keyof OMM)[] = ["OBJECT_NAME", "NORAD_CAT_ID", "EPOCH", "MEAN_MOTION", "ECCENTRICITY", "INCLINATION", "RA_OF_ASC_NODE", "ARG_OF_PERICENTER", "MEAN_ANOMALY", "BSTAR"];
24
25export type Row = Record<string, unknown>;
26
27export function pick(rows: Row[]): OMM[] {
28 return rows.map((r) => Object.fromEntries(KEYS.map((k) => [k, r[k]])) as unknown as OMM);
29}
30
31/** Objects grouped by launch, keyed by international designator ("2026-232"). */
32export function byDesignator(rows: Row[]): Map<string, Row[]> {
33 const groups = new Map<string, Row[]>();
34 for (const r of rows) {
35 const launch = String(r.OBJECT_ID).slice(0, 8);
36 groups.set(launch, [...(groups.get(launch) ?? []), r]);
37 }
38 return groups;
39}
40
41const isStarlink = (r: Row) => String(r.OBJECT_NAME).startsWith("STARLINK");
42
43/** The Starlink satellite with the median mean motion, named "Starlink train (<label>)". */
44function train(rows: Row[], label: string): OMM | null {
45 const sorted = rows.filter(isStarlink).sort((a, b) => Number(a.MEAN_MOTION) - Number(b.MEAN_MOTION));
46 if (!sorted.length) return null;
47 return { ...pick([sorted[Math.floor(sorted.length / 2)]])[0], OBJECT_NAME: `Starlink train (${label})` };
48}
49
50/** One element set per launch, keyed by label, whose Starlink satellites are still low enough to be seen as a train. */
51export function starlinkTrains(groups: Map<string, Row[]>): OMM[] {
52 const out: OMM[] = [];
53 for (const [label, rows] of groups) {
54 const t = train(rows, label);
55 if (t && t.MEAN_MOTION > TRAIN_MEAN_MOTION) out.push(t);
56 }
57 return out;
58}
59
60// SupGP: pre-launch and post-deployment Starlink sets from SpaceX data. OBJECT_ID and
61// NORAD_CAT_ID are placeholders (a pre-launch OBJECT_ID can be a designator already given to
62// another launch), so SupGP sets are tied to launches by file name and epoch only.
63
64/** "Starlink Group 15-15" → "starlink-g15-15"; null for other missions. */
65export function supgpFile(mission: string | null): string | null {
66 const m = mission?.match(/^Starlink Group (\d+)-(\d+)$/);
67 return m ? `starlink-g${m[1]}-${m[2]}` : null;
68}
69
70/** An unknown FILE is answered with HTTP 200 and plain text; anything but complete element sets is no data. */
71export function parseSupGP(text: string): Row[] {
72 let rows: unknown;
73 try {
74 rows = JSON.parse(text);
75 } catch {
76 return [];
77 }
78 return Array.isArray(rows) ? rows.filter(complete) : [];
79}
80
81function complete(r: unknown): r is Row {
82 if (typeof r !== "object" || r === null) return false;
83 const o = r as Row;
84 return typeof o.OBJECT_NAME === "string" && typeof o.OBJECT_ID === "string" && typeof o.EPOCH === "string" && !Number.isNaN(epoch(o)) &&
85 KEYS.every((k) => k === "OBJECT_NAME" || k === "EPOCH" || (typeof o[k] === "number" && Number.isFinite(o[k])));
86}
87
88const epoch = (r: Row) => Date.parse(`${r.EPOCH}Z`);
89
90/**
91 * Starlink deployment is one to two hours after liftoff (Group 15-15: 1 h 57 min): before launch, sets must be epoched
92 * 0–3 h after NET, which rejects a file left over from an earlier time; after launch, any time after NET.
93 */
94export function fitsLaunch(rows: Row[], net: string, now: Date): Row[] {
95 const t = Date.parse(net);
96 return rows.filter((r) => epoch(r) >= t && (t < now.getTime() || epoch(r) <= t + 3 * 3600_000));
97}
98
99export interface LaunchRef {
100 id: string;
101 mission: string | null;
102 provider: string;
103 net: string;
104 designator: string | null;
105}
106
107/** SpaceX Starlink launches from 3 days ago to 48 h ahead, nearest first, at most 4 (one SupGP request each). */
108export function supgpLaunches(launches: LaunchRef[], now: Date): LaunchRef[] {
109 const from = now.getTime() - 3 * 86400_000, to = now.getTime() + 48 * 3600_000;
110 return launches
111 .filter((l) => l.provider === "SpaceX" && supgpFile(l.mission) && Date.parse(l.net) >= from && Date.parse(l.net) <= to)
112 .sort((a, b) => Math.abs(Date.parse(a.net) - now.getTime()) - Math.abs(Date.parse(b.net) - now.getTime()))
113 .slice(0, 4);
114}
115
116const newest = (rows: Row[]) => Math.max(...rows.map(epoch));
117
118/** A Starlink train, then rocket bodies and other objects (no debris), at most 8. */
119function launchElements(rows: Row[], label: string): OMM[] {
120 const others = rows
121 .filter((r) => !isStarlink(r) && !String(r.OBJECT_NAME).includes(" DEB"))
122 .sort((a, b) => Number(String(b.OBJECT_NAME).includes("R/B")) - Number(String(a.OBJECT_NAME).includes("R/B")) || String(a.OBJECT_ID).localeCompare(String(b.OBJECT_ID)));
123 const t = train(rows, label);
124 return [...(t ? [t] : []), ...pick(others)].slice(0, 8);
125}
126
127export interface LaunchElements {
128 /** "gp": CelesTrak GP matched by designator; "supgp": SpaceX data via CelesTrak SupGP. */
129 source: "gp" | "supgp";
130 elements: OMM[];
131}
132
133/**
134 * The satellite list (visual group plus trains, labelled with the mission name where known) and element sets
135 * per launch. For each launch the GP objects with its designator or its SupGP sets are used, whichever has the
136 * newer epoch. A SupGP train joins the satellite list only once its epoch (predicted deployment) has passed,
137 * since clients propagate that list from now.
138 */
139export function compose(visual: Row[], recent: Row[], launches: LaunchRef[], supgp: Record<string, Row[]>, now: Date): { satellites: OMM[]; launches: Record<string, LaunchElements> } {
140 const gp = byDesignator(recent);
141 const groups = new Map(gp);
142 const out: Record<string, LaunchElements> = {};
143 for (const l of launches) {
144 const g = l.designator ? gp.get(l.designator) : undefined;
145 const s = supgp[l.id]?.length ? supgp[l.id] : undefined;
146 const source = s && (!g || newest(s) > newest(g)) ? "supgp" : "gp";
147 const rows = source === "supgp" ? s : g;
148 if (!rows) continue;
149 const label = l.mission?.startsWith("Starlink ") ? l.mission.slice(9) : (l.mission ?? l.designator ?? l.id);
150 if (l.designator) groups.delete(l.designator);
151 if (source === "gp" || newest(rows) <= now.getTime()) groups.set(label, rows);
152 out[l.id] = { source, elements: launchElements(rows, label) };
153 }
154 return { satellites: [...pick(visual), ...starlinkTrains(groups)], launches: out };
155}
156
157const HEADERS = { "user-agent": "moonrise-server (sh.krz.moonrise)" };
158
159async function group(name: string): Promise<Row[]> {
160 const res = await fetch(`https://celestrak.org/NORAD/elements/gp.php?GROUP=${name}&FORMAT=JSON`, { headers: HEADERS });
161 if (res.status !== 200) throw new Error(`CelesTrak ${res.status} for ${name}`);
162 return (await res.json()) as Row[];
163}
164
165/** The visual group (about 150 bright objects) and the last 30 days' launches; throws on the first non-200 so nothing more is requested. */
166export async function fetchGroups(): Promise<{ visual: Row[]; recent: Row[] }> {
167 const visual = await group("visual");
168 const recent = await group("last-30-days");
169 return { visual, recent };
170}
171
172/** One SupGP file; throws on a non-200 so the caller stops. */
173export async function fetchSupGP(file: string): Promise<Row[]> {
174 const res = await fetch(`https://celestrak.org/NORAD/elements/supplemental/sup-gp.php?FILE=${file}&FORMAT=JSON`, { headers: HEADERS });
175 if (res.status !== 200) throw new Error(`CelesTrak SupGP ${res.status} for ${file}`);
176 return parseSupGP(await res.text());
177}