Files
star-map/tools/etl/lib/horizons.ts
T
SenrokaiandClaude Opus 5.5 cdc0cf4678 Read the Horizons pages the missing moons are written in, and give triaxial bodies their mean radius
The pages of the moons this branch is about to add state their size and spin in forms the ETL
did not read. Charon's gives "Radius (km, IAU2015) = 606", which none of the radius patterns
matched, so it would have come out at radius 0. Phoebe's gives "Rotational period = 9h 16.438 m",
which the hours-or-days pattern read as 9 hours flat instead of 9.274. Pluto's and the moons' GMs
are now read too ("GM (planet) km^3/s^2 = 869.326" on Pluto's page, "GM (km^3/s^2) = 106.10" on
Charon's), for placing a pair's barycentre.

Miranda and Ariel, like Phobos and Deimos already, give three semi-axes, "240x234.2x232.9". The
first figure was taken as the radius, which is the longest axis. A triaxial body now gets the
radius of the sphere of its volume, the cube root of the product, which is how the IAU states a
mean radius. That changes two bodies already shipped: Phobos 13.1 -> 11.06 km (IAU 11.08) and
Deimos 7.8 -> 6.20 km (IAU 6.2). Nothing else in bodies.json moves.

The page parsers move from tools/etl/lib/horizons.ts to src/app/shared/astro/horizons-page.ts,
as the mean-element parsers did, so the unit suite runs their tests; the ETL imports them. A
small body's command ("1;" for Ceres) is now URL-encoded: sent raw, the semicolon made Horizons
refuse the request ("one or more query parameter was not recognized").

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

96 lines
3.9 KiB
TypeScript

import { OrbitalElements } from '../../../src/app/shared/models/body.model';
import { extractGmKm3PerS2, extractObliquityDeg, extractRadiusKm, extractRotationPeriodHours, isTidallyLocked } from '../../../src/app/shared/astro/horizons-page';
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, or a small body's number and a semicolon, `'1;'` for Ceres. */
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;
/** The body's own GM, km³/s², where the page states one: what sets where a pair's barycentre lies. */
gmKm3PerS2?: number;
}
/**
* 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='${encodeURIComponent(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),
gmKm3PerS2: extractGmKm3PerS2(text)
};
}
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]);
}