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

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

63 lines · 2672 bytes

8 symbols in this file
 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 function pick(rows: Record<string, unknown>[]): OMM[] {
26  return rows.map((r) => Object.fromEntries(KEYS.map((k) => [k, r[k]])) as unknown as OMM);
27}
28
29/**
30 * One element set per recent Starlink launch still low enough to be seen as a train,
31 * the satellite with the median mean motion, named "Starlink train (2026-232)".
32 */
33export function starlinkTrains(rows: Record<string, unknown>[]): OMM[] {
34  const groups = new Map<string, Record<string, unknown>[]>();
35  for (const r of rows) {
36    if (!String(r.OBJECT_NAME).startsWith("STARLINK")) continue;
37    const launch = String(r.OBJECT_ID).slice(0, 8);
38    groups.set(launch, [...(groups.get(launch) ?? []), r]);
39  }
40  const out: OMM[] = [];
41  for (const [launch, sats] of groups) {
42    const sorted = [...sats].sort((a, b) => Number(a.MEAN_MOTION) - Number(b.MEAN_MOTION));
43    const median = sorted[Math.floor(sorted.length / 2)];
44    if (Number(median.MEAN_MOTION) <= TRAIN_MEAN_MOTION) continue;
45    out.push({ ...pick([median])[0], OBJECT_NAME: `Starlink train (${launch})` });
46  }
47  return out;
48}
49
50async function group(name: string): Promise<Record<string, unknown>[]> {
51  const res = await fetch(`https://celestrak.org/NORAD/elements/gp.php?GROUP=${name}&FORMAT=JSON`, {
52    headers: { "user-agent": "moonrise-server (sh.krz.moonrise)" },
53  });
54  if (res.status !== 200) throw new Error(`CelesTrak ${res.status} for ${name}`);
55  return (await res.json()) as Record<string, unknown>[];
56}
57
58/** The visual group (about 150 bright objects) and Starlink trains; throws on the first non-200 so nothing more is requested. */
59export async function fetchElements(): Promise<OMM[]> {
60  const visual = pick(await group("visual"));
61  const recent = await group("last-30-days");
62  return [...visual, ...starlinkTrains(recent)];
63}