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>
96 lines
3.9 KiB
TypeScript
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]);
|
|
}
|