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>
This commit is contained in:
2026-09-24 20:41:22 +02:00
co-authored by Claude Opus 5.5
parent 2e5daa0f97
commit cdc0cf4678
4 changed files with 170 additions and 79 deletions
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { extractGmKm3PerS2, extractRadiusKm, extractRotationPeriodHours, isTidallyLocked } from './horizons-page';
// Lines as the Horizons pages print them.
const JUPITER = ` Vol. Mean Radius (km) = 69911+-6 Flattening = 0.06487
Sid. rot. period (III)= 9h 55m 29.711 s Sid. rot. rate (rad/s)= 0.00017585`;
const MIRANDA = ` Radius (km) = 240x234.2x232.9 Density (g cm^-3) = 1.18 +- 0.05
GM (km^3/s^2) = 4.3 +- 0.2 Geometric Albedo = 0.27
Eccentricity, e = 0.0027 Rotational period = Synchronous`;
const CHARON = ` GM (km^3/s^2) = 106.10 +- 0.3 Density (g cm^-3) = 1.853 +- 0.004
Radius (km, IAU2015) = 606 +- 0.5 Geometric albedo = `;
const PLUTO = ` GM (planet) km^3/s^2 = 869.326 Density (R=1195 km) = 1.86 g/cm^3
Vol. mean radius (km) = 1188.3+-1.6 Mass ratio (Mc/Mp) = 0.122`;
const PHOEBE = ` Radius (km) = 106.6 +- 1.1 Density (g/cm^3)= 1.633 +- 0.049
Eccentricity, e = 0.1635 Rotational period = 9h 16.438 m`;
const HYPERION = ` Mean Radius (km) = 133 +- 8 Density (g/cm^3) = 0.569 +- 0.108
Eccentricity, e = 0.0232 Rotational period = Chaotic`;
describe('Horizons page radius', () => {
it('reads a volumetric mean radius', () => {
expect(extractRadiusKm(PLUTO)).toBe(1188.3);
});
it('reads the radius Charon states against IAU 2015', () => {
expect(extractRadiusKm(CHARON)).toBe(606);
});
it('gives a triaxial body the radius of the sphere of its volume, not its longest axis', () => {
// (240 × 234.2 × 232.9)^(1/3); the IAU's mean radius for Miranda is 235.8.
expect(extractRadiusKm(MIRANDA)).toBeCloseTo(235.7, 1);
// Phobos spaces its axes out; it was drawn at its longest, 13.1 km, against the IAU's 11.08.
expect(extractRadiusKm(' Radius (km) = 13.1 x11.1 x9.3 Density (g cm^-3) = 1.90')).toBeCloseTo(11.06, 2);
});
});
describe('Horizons page rotation', () => {
it('reads hours, minutes and seconds', () => {
expect(extractRotationPeriodHours(JUPITER)).toBeCloseTo(9.925, 3);
});
it('reads hours and minutes, as Phoebe states them', () => {
expect(extractRotationPeriodHours(PHOEBE)).toBeCloseTo(9 + 16.438 / 60, 6);
});
it('finds no period where the spin is chaotic', () => {
expect(extractRotationPeriodHours(HYPERION)).toBeUndefined();
expect(isTidallyLocked(HYPERION)).toBe(false);
expect(isTidallyLocked(MIRANDA)).toBe(true);
});
});
describe('Horizons page GM', () => {
it('reads a moon’s GM and Pluto’s, which are written differently', () => {
expect(extractGmKm3PerS2(CHARON)).toBe(106.1);
expect(extractGmKm3PerS2(PLUTO)).toBe(869.326);
expect(extractGmKm3PerS2(HYPERION)).toBeUndefined();
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* Reads the physical-data block at the top of a JPL Horizons object page, which the ETL fetches
* (see `tools/etl/lib/horizons.ts`). Every page is written by hand, so each quantity is stated in
* several ways; the patterns below are the ones the bodies in `bodies.json` actually use.
*/
/**
* What follows the `=`: one radius, or a triaxial body's three semi-axes as `240x234.2x232.9`,
* as Miranda's and Ariel's pages give them.
*/
const RADIUS_VALUE = String.raw`=\s*([\d.]+(?:\s*x\s*[\d.]+)*)`;
const RADIUS_PATTERNS = [
new RegExp(String.raw`Vol\.?\s*mean\s*radius[^=]*${RADIUS_VALUE}`, 'i'),
new RegExp(String.raw`Mean\s*radius[^=]*${RADIUS_VALUE}`, 'i'),
new RegExp(String.raw`Radius\s*\(IAU\)[^=]*${RADIUS_VALUE}`, 'i'),
// Charon's page says `Radius (km, IAU2015) = 606`.
new RegExp(String.raw`Radius,?\s*\(km(?:,\s*IAU\s*2015)?\)\s*${RADIUS_VALUE}`, 'i'),
new RegExp(String.raw`Radius\s*\(gravity\),?\s*km\s*${RADIUS_VALUE}`, '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 and minutes comes next, as the
* giant planets and Phoebe state it, then one in hours or days, 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;
/**
* `9h 55m 29.711 s`, as Jupiter and Saturn state it, and `9h 16.438 m`, as Phoebe does: read as
* a period in hours alone, Phoebe turned once in 9 hours instead of 9.274.
*/
const SEXAGESIMAL_ROTATION_PATTERN = /(?:Sid(?:ereal|\.)?\s*rot\.?|Rotation(?:al)?)\s*period[^=]*=\s*(\d+)\s*h\s*([\d.]+)\s*m(?:\s*([\d.]+)\s*s)?/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
];
const SYNCHRONOUS_PATTERN = /Rotation(?:al)?\s*period\s*=?\s*:?\s*Synchronous/i;
const OBLIQUITY_PATTERN = /Obliquity\s*to\s*orbit[^=]*=\s*(-?[\d.]+)/i;
/** `GM (km^3/s^2) = 106.10` on a moon's page, `GM (planet) km^3/s^2 = 869.326` on Pluto's. */
const GM_PATTERN = /GM\s*(?:\(planet\)\s*)?,?\s*\(?km\^3\/s\^2\)?\s*=\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 the orbit's, which the caller takes from the body's
* mean motion.
*/
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] ?? 0) / 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;
}
/**
* Mean radius in km. For a triaxial body, the radius of the sphere of the same volume, the cube
* root of the three semi-axes' product, which is how the IAU states a mean radius: Miranda's
* 240 x 234.2 x 232.9 km is 235.7, where the first figure alone overstated it by 2 per cent.
*/
export function extractRadiusKm(text: string): number | undefined {
for (const pattern of RADIUS_PATTERNS) {
const match = text.match(pattern);
if (match) {
const axes = match[1].split('x').map(Number);
return axes.reduce((product, axis) => product * axis, 1) ** (1 / axes.length);
}
}
return undefined;
}
/** The body's own GM, in km³/s², where the page publishes one. */
export function extractGmKm3PerS2(text: string): number | undefined {
const match = text.match(GM_PATTERN);
return match ? Number(match[1]) : undefined;
}
+2 -2
View File
@@ -302,7 +302,7 @@
"systemStarId": 0, "systemStarId": 0,
"name": "Phobos", "name": "Phobos",
"kind": "moon", "kind": "moon",
"radiusKm": 13.1, "radiusKm": 11.058402868477721,
"orbit": { "orbit": {
"semiMajorAxisAu": 0.00006267468885838895, "semiMajorAxisAu": 0.00006267468885838895,
"eccentricity": 0.0151, "eccentricity": 0.0151,
@@ -330,7 +330,7 @@
"systemStarId": 0, "systemStarId": 0,
"name": "Deimos", "name": "Deimos",
"kind": "moon", "kind": "moon",
"radiusKm": 7.8, "radiusKm": 6.203050874487247,
"orbit": { "orbit": {
"semiMajorAxisAu": 0.00015680704471417322, "semiMajorAxisAu": 0.00015680704471417322,
"eccentricity": 0.0002, "eccentricity": 0.0002,
+7 -77
View File
@@ -1,4 +1,5 @@
import { OrbitalElements } from '../../../src/app/shared/models/body.model'; 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'; import { fetchTextCached } from './http';
const HORIZONS_URL = 'https://ssd.jpl.nasa.gov/api/horizons.api'; const HORIZONS_URL = 'https://ssd.jpl.nasa.gov/api/horizons.api';
@@ -10,7 +11,7 @@ const REFERENCE_START = '2025-01-01';
const REFERENCE_STOP = '2025-01-02'; const REFERENCE_STOP = '2025-01-02';
export interface HorizonsQuery { export interface HorizonsQuery {
/** Horizons body id, e.g. `'499'` for Mars. */ /** Horizons body id, e.g. `'499'` for Mars, or a small body's number and a semicolon, `'1;'` for Ceres. */
command: string; command: string;
/** Horizons coordinate center, e.g. `'500@10'` (Sun) or `'500@399'` (Earth). */ /** Horizons coordinate center, e.g. `'500@10'` (Sun) or `'500@399'` (Earth). */
center: string; center: string;
@@ -30,70 +31,8 @@ export interface HorizonsResult {
obliquityDeg?: number; obliquityDeg?: number;
/** The page says "Synchronous" instead of a period: its day is its orbit. */ /** The page says "Synchronous" instead of a period: its day is its orbit. */
tidallyLocked: boolean; tidallyLocked: boolean;
} /** The body's own GM, km³/s², where the page states one: what sets where a pair's barycentre lies. */
gmKm3PerS2?: number;
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 the orbit's, which the caller takes from the body's
* mean motion.
*/
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;
} }
/** /**
@@ -103,7 +42,7 @@ export function extractObliquityDeg(text: string): number | undefined {
*/ */
export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsResult> { export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsResult> {
const url = const url =
`${HORIZONS_URL}?format=text&COMMAND='${query.command}'&OBJ_DATA='YES'` + `${HORIZONS_URL}?format=text&COMMAND='${encodeURIComponent(query.command)}'&OBJ_DATA='YES'` +
`&MAKE_EPHEM='YES'&EPHEM_TYPE='ELEMENTS'&CENTER='${query.center}'` + `&MAKE_EPHEM='YES'&EPHEM_TYPE='ELEMENTS'&CENTER='${query.center}'` +
`&START_TIME='${REFERENCE_START}'&STOP_TIME='${REFERENCE_STOP}'&STEP_SIZE='1d'`; `&START_TIME='${REFERENCE_START}'&STOP_TIME='${REFERENCE_STOP}'&STEP_SIZE='1d'`;
@@ -113,20 +52,11 @@ export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsR
orbit: extractOrbitalElements(text), orbit: extractOrbitalElements(text),
rotationPeriodHours: extractRotationPeriodHours(text), rotationPeriodHours: extractRotationPeriodHours(text),
obliquityDeg: extractObliquityDeg(text), obliquityDeg: extractObliquityDeg(text),
tidallyLocked: isTidallyLocked(text) tidallyLocked: isTidallyLocked(text),
gmKm3PerS2: extractGmKm3PerS2(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 { function extractOrbitalElements(text: string): OrbitalElements {
const startIndex = text.indexOf('$$SOE'); const startIndex = text.indexOf('$$SOE');
const endIndex = text.indexOf('$$EOE'); const endIndex = text.indexOf('$$EOE');