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/src/celestrak.ts

b327e0d6e7ab67511c5bade3f3c3a93834f6e9ca
moonrise/server/src/celestrak.ts history · blame · raw

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}