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/live.ts

83 lines · 4088 bytes

7 symbols in this file
 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}