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:
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -10,7 +11,7 @@ const REFERENCE_START = '2025-01-01';
|
||||
const REFERENCE_STOP = '2025-01-02';
|
||||
|
||||
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;
|
||||
/** Horizons coordinate center, e.g. `'500@10'` (Sun) or `'500@399'` (Earth). */
|
||||
center: string;
|
||||
@@ -30,70 +31,8 @@ export interface HorizonsResult {
|
||||
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 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;
|
||||
/** The body's own GM, km³/s², where the page states one: what sets where a pair's barycentre lies. */
|
||||
gmKm3PerS2?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,7 +42,7 @@ export function extractObliquityDeg(text: string): number | undefined {
|
||||
*/
|
||||
export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsResult> {
|
||||
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}'` +
|
||||
`&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),
|
||||
rotationPeriodHours: extractRotationPeriodHours(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 {
|
||||
const startIndex = text.indexOf('$$SOE');
|
||||
const endIndex = text.indexOf('$$EOE');
|
||||
|
||||
Reference in New Issue
Block a user