diff --git a/src/app/shared/astro/horizons-page.spec.ts b/src/app/shared/astro/horizons-page.spec.ts new file mode 100644 index 0000000..44938ef --- /dev/null +++ b/src/app/shared/astro/horizons-page.spec.ts @@ -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(); + }); +}); diff --git a/src/app/shared/astro/horizons-page.ts b/src/app/shared/astro/horizons-page.ts new file mode 100644 index 0000000..16cb634 --- /dev/null +++ b/src/app/shared/astro/horizons-page.ts @@ -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; +} diff --git a/src/assets/data/bodies.json b/src/assets/data/bodies.json index ea8f8e6..c3971f7 100644 --- a/src/assets/data/bodies.json +++ b/src/assets/data/bodies.json @@ -302,7 +302,7 @@ "systemStarId": 0, "name": "Phobos", "kind": "moon", - "radiusKm": 13.1, + "radiusKm": 11.058402868477721, "orbit": { "semiMajorAxisAu": 0.00006267468885838895, "eccentricity": 0.0151, @@ -330,7 +330,7 @@ "systemStarId": 0, "name": "Deimos", "kind": "moon", - "radiusKm": 7.8, + "radiusKm": 6.203050874487247, "orbit": { "semiMajorAxisAu": 0.00015680704471417322, "eccentricity": 0.0002, diff --git a/tools/etl/lib/horizons.ts b/tools/etl/lib/horizons.ts index aad7f45..d1c10c2 100644 --- a/tools/etl/lib/horizons.ts +++ b/tools/etl/lib/horizons.ts @@ -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 { 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