server/src/live.ts
83 lines · 4088 bytes
1// Live coverage of a crewed or lunar mission: its trajectory from JPL Horizons, cached while the event runs.
2// To cover a new mission, add it to EVENTS (Horizons ID, window, phases) and deploy.
3
4export interface LiveEvent {
5 id: string;
6 title: string;
7 /** Horizons COMMAND, e.g. "-1023" for Artemis I. */
8 horizonsId: string;
9 /** ISO 8601 UTC. */
10 launch: string;
11 end: string;
12 phases: { at: string; label: string }[];
13}
14
15export const EVENTS: LiveEvent[] = [
16 {
17 // Past mission, kept for testing with /v1/live?at=2022-11-21T12:00:00Z.
18 id: "artemis-1", title: "Artemis I", horizonsId: "-1023",
19 launch: "2022-11-16T06:47:44Z", end: "2022-12-11T17:40:00Z",
20 phases: [
21 { at: "2022-11-16T06:47:44Z", label: "Launch" },
22 { at: "2022-11-21T12:44:00Z", label: "Outbound lunar flyby" },
23 { at: "2022-11-25T21:52:00Z", label: "Distant retrograde orbit" },
24 { at: "2022-12-01T21:53:00Z", label: "Leaves lunar orbit" },
25 { at: "2022-12-05T16:43:00Z", label: "Return lunar flyby" },
26 { at: "2022-12-11T17:40:00Z", label: "Splashdown" },
27 ],
28 },
29];
30
31export const STEP_MINUTES = 30;
32
33/** The event whose window contains `at`, if any. */
34export function activeEvent(at: Date, events = EVENTS): LiveEvent | undefined {
35 return events.find((e) => Date.parse(e.launch) <= at.getTime() && at.getTime() <= Date.parse(e.end));
36}
37
38/** Horizons rows from a CSV vector table (VEC_TABLE 1): [unix seconds, x, y, z] km. */
39export function parseVectors(result: string): number[][] {
40 const start = result.indexOf("$$SOE"), stop = result.indexOf("$$EOE");
41 if (start < 0 || stop < 0) return [];
42 return result.slice(start + 5, stop).trim().split("\n").map((line) => {
43 const f = line.split(",").map((s) => s.trim());
44 const jd = Number(f[0]);
45 return [Math.round((jd - 2440587.5) * 86400), Number(f[2]), Number(f[3]), Number(f[4])];
46 }).filter((r) => r.every(Number.isFinite));
47}
48
49/**
50 * The limit in Horizons' "No ephemeris for target ... prior to A.D. 2022-NOV-16 08:46:00.3328 TDB"
51 * (or "after A.D. ...") reply.
52 */
53export function ephemerisLimit(result: string, which: "prior to" | "after"): Date | undefined {
54 const m = result.match(new RegExp(`${which} A\\.D\\. (\\d{4})-([A-Z]{3})-(\\d{2}) (\\d{2}):(\\d{2})`));
55 if (!m) return undefined;
56 const month = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"].indexOf(m[2]);
57 return new Date(Date.UTC(Number(m[1]), month, Number(m[3]), Number(m[4]), Number(m[5])));
58}
59
60/**
61 * Geocentric positions on J2000 ecliptic axes from launch to `until`, every STEP_MINUTES. Horizons data
62 * for a spacecraft usually starts after launch and may end before the event does; the request is
63 * narrowed to what Horizons says it has.
64 */
65export async function fetchTrajectory(e: LiveEvent, until: Date, from = new Date(e.launch), to = new Date(e.end)): Promise<number[][]> {
66 const start = from;
67 const stop = new Date(Math.min(until.getTime(), to.getTime()));
68 if (stop <= start) return [];
69 const fmt = (d: Date) => d.toISOString().slice(0, 16).replace("T", " ");
70 const params = new URLSearchParams({
71 format: "json", COMMAND: `'${e.horizonsId}'`, EPHEM_TYPE: "'VECTORS'", CENTER: "'500@399'", REF_PLANE: "'ECLIPTIC'",
72 START_TIME: `'${fmt(start)}'`, STOP_TIME: `'${fmt(stop)}'`, STEP_SIZE: `'${STEP_MINUTES}m'`,
73 VEC_TABLE: "'1'", OUT_UNITS: "'KM-S'", CSV_FORMAT: "'YES'", OBJ_DATA: "'NO'", MAKE_EPHEM: "'YES'",
74 });
75 const res = await fetch(`https://ssd.jpl.nasa.gov/api/horizons.api?${params}`, { headers: { "user-agent": "moonrise-server (sh.krz.moonrise)" } });
76 if (res.status !== 200) throw new Error(`Horizons ${res.status}`);
77 const result = ((await res.json()) as { result: string }).result;
78 const first = ephemerisLimit(result, "prior to");
79 if (first && first > from) return fetchTrajectory(e, until, new Date(first.getTime() + 60_000), to);
80 const last = ephemerisLimit(result, "after");
81 if (last && last < stop) return fetchTrajectory(e, until, from, new Date(last.getTime() - 60_000));
82 return parseVectors(result);
83}