Files
star-map/tools/etl/lib/horizons.ts
SenrokaiandClaude Opus 5.5 c38a42cbcb Fix what the review of this branch found, starting with the pick rule it only claimed
The off-screen rule for clicks was described in 3f0abf8 and in the pull request, but only its
comment was committed: pickAt still let the slop reach past the frame. The mutant that was said
to catch it matched nothing, and an unrelated flaky test failed instead. The frame test is now in
pickAt, before the slop, and its test fails without it (star at NDC 1.01, click at 0.995).

Venus, Uranus and Pluto turned forwards: Horizons states a retrograde spin twice, by a negative
rate and by an obliquity over 90 degrees, and both were applied. The period's sign is now used
only when no obliquity is known. Measured on the live markers, spin axis against orbit normal is
cos(obliquity) for each: Venus -0.999, Uranus -0.135, Pluto -0.494, Earth 0.917.

Moons listed as rates rather than "Synchronous" drifted about 5 degrees an orbit and Titan did not
turn: every moon is now locked at its Kepler period. Pluto's obliquity comes from IAU WGCCRE 2015,
Horizons gives none.

Also:
- the star's light is white at pi, not a warm 2.2 that left the photographs dim;
- procedural textures are 128x64, not 512x256 that froze the main thread ~60 ms a body;
- Io, Pluto, Titan and Deimos lose their "maps", which were disc photographs with black sky;
- an exoplanet with only a mass gets a radius from it (M^0.55, capped at Jupiter), not Earth's;
- the clock knows when it has left the present even once back at real time, so the date and
  "Back to now" stay up.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 18:50:07 +02:00

166 lines
6.5 KiB
TypeScript

import { OrbitalElements } from '../../../src/app/shared/models/body.model';
import { fetchTextCached } from './http';
const HORIZONS_URL = 'https://ssd.jpl.nasa.gov/api/horizons.api';
const KM_PER_AU = 149597870.7;
// Fixed reference epoch: keeps the ETL output (and its cache) stable across reruns,
// consistent with the "bake once at build time" ETL philosophy.
const REFERENCE_START = '2025-01-01';
const REFERENCE_STOP = '2025-01-02';
export interface HorizonsQuery {
/** Horizons body id, e.g. `'499'` for Mars. */
command: string;
/** Horizons coordinate center, e.g. `'500@10'` (Sun) or `'500@399'` (Earth). */
center: string;
cacheKey: string;
}
export interface HorizonsResult {
radiusKm?: number;
orbit: OrbitalElements;
/**
* Sidereal rotation period in hours, negative where the page gives a negative rate —
* Venus and Uranus. Absent where the page publishes none. The same pages also give an obliquity
* past 90 degrees for those two, which says the same thing again; see the renderer's spinFor.
*/
rotationPeriodHours?: number;
/** Tilt of the rotation axis from the body's own orbital plane, in degrees. */
obliquityDeg?: number;
/** The page says "Synchronous" instead of a period: its day is its orbit. */
tidallyLocked: boolean;
}
const RADIUS_PATTERNS = [
/Vol\.?\s*mean\s*radius[^=]*=\s*([\d.]+)/i,
/Mean\s*radius[^=]*=\s*([\d.]+)/i,
/Radius\s*\(IAU\)[^=]*=\s*([\d.]+)/i,
/Radius,?\s*\(km\)\s*=\s*([\d.]+)/i,
/Radius\s*\(gravity\),?\s*km\s*=\s*([\d.]+)/i
];
/**
* How each page states how fast the body turns, in the order they are tried.
*
* The rate in radians per second is preferred wherever it appears: it is unambiguous and it is
* signed: Venus and Uranus carry a negative one. A period in hours or days
* comes next, then the sexagesimal form the giant planets use, and finally the word most moons carry
* instead of a number, Synchronous. Not all do — the Moon's page gives a rate, Titan's nothing —
* so the caller treats every moon it lists as locked whatever its page says.
*/
const ROTATION_RATE_PATTERN = /Rot(?:ational)?\.?\s*Rate\s*[(,]\s*rad\/s\s*\)?\s*=\s*(-?[\d.]+)/i;
const ROTATION_PERIOD_PATTERNS = [
/Sid(?:ereal|\.)?\s*rot\.?\s*period[^=]*=\s*(-?[\d.]+)(?:\+-[\d.]+)?\s*(h|hr|hrs|d|day|days)\b/i,
/Rotation(?:al)?\s*period[^=]*=\s*(-?[\d.]+)\s*(h|hr|hrs|d|day|days)\b/i
];
/** `9h 55m 29.711 s`, as Jupiter and Saturn state it. */
const SEXAGESIMAL_ROTATION_PATTERN = /Sid(?:ereal|\.)?\s*rot\.?\s*period[^=]*=\s*(\d+)\s*h\s*(\d+)\s*m\s*([\d.]+)\s*s/i;
const SYNCHRONOUS_PATTERN = /Rotation(?:al)?\s*period\s*=?\s*:?\s*Synchronous/i;
const OBLIQUITY_PATTERN = /Obliquity\s*to\s*orbit[^=]*=\s*(-?[\d.]+)/i;
const HOURS_PER_DAY = 24;
const SECONDS_PER_HOUR = 3600;
/**
* True where the page gives no number because the body keeps one face to its parent, so its day
* is its orbit. The period itself is then Kepler's, which the caller
* works out from the elements above and the parent's mass.
*/
export function isTidallyLocked(text: string): boolean {
return SYNCHRONOUS_PATTERN.test(text);
}
/** Sidereal rotation period, in hours, from whichever form the page states it in. */
export function extractRotationPeriodHours(text: string): number | undefined {
const rate = text.match(ROTATION_RATE_PATTERN);
if (rate && Number(rate[1]) !== 0) {
return (2 * Math.PI) / (Number(rate[1]) * SECONDS_PER_HOUR);
}
const sexagesimal = text.match(SEXAGESIMAL_ROTATION_PATTERN);
if (sexagesimal) {
return Number(sexagesimal[1]) + Number(sexagesimal[2]) / 60 + Number(sexagesimal[3]) / SECONDS_PER_HOUR;
}
for (const pattern of ROTATION_PERIOD_PATTERNS) {
const match = text.match(pattern);
if (match) {
const hours = Number(match[1]) * (match[2].toLowerCase().startsWith('d') ? HOURS_PER_DAY : 1);
return Number.isFinite(hours) && hours !== 0 ? hours : undefined;
}
}
return undefined;
}
export function extractObliquityDeg(text: string): number | undefined {
const match = text.match(OBLIQUITY_PATTERN);
return match ? Number(match[1]) : undefined;
}
/**
* Queries JPL Horizons for a body's heliocentric (or planetocentric, for moons) osculating
* orbital elements plus, when available, its mean physical radius and how it turns — all in a
* single request (`OBJ_DATA=YES` + `EPHEM_TYPE=ELEMENTS`).
*/
export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsResult> {
const url =
`${HORIZONS_URL}?format=text&COMMAND='${query.command}'&OBJ_DATA='YES'` +
`&MAKE_EPHEM='YES'&EPHEM_TYPE='ELEMENTS'&CENTER='${query.center}'` +
`&START_TIME='${REFERENCE_START}'&STOP_TIME='${REFERENCE_STOP}'&STEP_SIZE='1d'`;
const text = await fetchTextCached(url, query.cacheKey);
return {
radiusKm: extractRadiusKm(text),
orbit: extractOrbitalElements(text),
rotationPeriodHours: extractRotationPeriodHours(text),
obliquityDeg: extractObliquityDeg(text),
tidallyLocked: isTidallyLocked(text)
};
}
function extractRadiusKm(text: string): number | undefined {
for (const pattern of RADIUS_PATTERNS) {
const match = text.match(pattern);
if (match) {
return Number(match[1]);
}
}
return undefined;
}
function extractOrbitalElements(text: string): OrbitalElements {
const startIndex = text.indexOf('$$SOE');
const endIndex = text.indexOf('$$EOE');
if (startIndex === -1 || endIndex === -1) {
throw new Error('Horizons response did not contain an elements table ($$SOE/$$EOE).');
}
const block = text.slice(startIndex + '$$SOE'.length, endIndex).trim();
const firstRecord = block.split(/\n(?=\d)/)[0];
const epochJd = extractNumber(firstRecord, /^([\d.]+)\s*=/);
const eccentricity = extractNumber(firstRecord, /EC\s*=\s*([-\d.Ee+]+)/);
const inclinationDeg = extractNumber(firstRecord, /IN\s*=\s*([-\d.Ee+]+)/);
const longitudeOfAscendingNodeDeg = extractNumber(firstRecord, /OM\s*=\s*([-\d.Ee+]+)/);
const argumentOfPeriapsisDeg = extractNumber(firstRecord, /(?<!\w)W\s*=\s*([-\d.Ee+]+)/);
const meanAnomalyAtEpochDeg = extractNumber(firstRecord, /MA\s*=\s*([-\d.Ee+]+)/);
const semiMajorAxisKm = extractNumber(firstRecord, /(?<!\w)A\s*=\s*([-\d.Ee+]+)/);
return {
semiMajorAxisAu: semiMajorAxisKm / KM_PER_AU,
eccentricity,
inclinationDeg,
longitudeOfAscendingNodeDeg,
argumentOfPeriapsisDeg,
meanAnomalyAtEpochDeg,
epochJd
};
}
function extractNumber(text: string, pattern: RegExp): number {
const match = text.match(pattern);
if (!match) {
throw new Error(`Could not find pattern ${pattern} in Horizons elements record.`);
}
return Number(match[1]);
}