Read moons given against their planet's equator, and dwarf planets from the Small-Body Database

Two sources the missing bodies need, read and tested before any body uses them.

JPL's satellite table gives Uranus's and Pluto's moons against the planet's equator ("Mean
equatorial orbital elements") rather than a Laplace plane, and does not print that equator's
pole. parseSatelliteMeanElements now takes the pole from its caller for such a section and reads
the row against it exactly as against a Laplace plane's; it throws if a section is equatorial and
no pole was given, or a pole was given for a section that is not. Read as ecliptic elements, which
is what the old code would have done, Titania is 88 degrees from Horizons on 2025-01-01. The
section's plane is now the nearest heading above the row, with the ecliptic as before where there
is none.

Ceres, Eris, Haumea and Makemake are in none of Standish's tables. parseSmallBodyElements reads a
JPL SBDB answer (sbdb.api?sstr=...&phys-par=1&full-prec=1): the osculating heliocentric elements
against the J2000 ecliptic, carried round at their own n with nothing turning, and half the
published diameter and the rotation period where the answer has them. full-prec matters: without
it SBDB rounds to three figures, Ceres's n to 0.214 degrees a day for 0.2143045, 1.1 degrees out
within a decade. The fetcher, tools/etl/lib/mean-elements.ts, caches the answer like the others.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 21:47:55 +02:00
co-authored by Claude Opus 5.5
parent cdc0cf4678
commit b0d7989c6f
3 changed files with 163 additions and 5 deletions
+77 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { parsePlanetMeanElements, parseSatelliteMeanElements } from './mean-elements'; import { parsePlanetMeanElements, parseSatelliteMeanElements, parseSmallBodyElements, SbdbAnswer } from './mean-elements';
/** Standish's p_elem_t2.txt, cut to the lines that matter here, as JPL published them. */ /** Standish's p_elem_t2.txt, cut to the lines that matter here, as JPL published them. */
const TABLE_2 = `Keplerian elements and their rates, with respect to the mean ecliptic and equinox of J2000, const TABLE_2 = `Keplerian elements and their rates, with respect to the mean ecliptic and equinox of J2000,
@@ -47,8 +47,42 @@ Epoch 2000 Jan. 1.50 TT<BR>
<TD>61.2572638</TD><TD>5.877</TD><TD>386.371</TD><TD>687.446</TD> <TD>61.2572638</TD><TD>5.877</TD><TD>386.371</TD><TD>687.446</TD>
<TD>299.456</TD><TD>43.414</TD><TD>0.010</TD> <TD>299.456</TD><TD>43.414</TD><TD>0.010</TD>
<TD ALIGN=right><A HREF="#ref54">54</A></TD></TR> <TD ALIGN=right><A HREF="#ref54">54</A></TD></TR>
<td align="left" nowrap><b>Satellites of Uranus</b></td>
<td align="right" nowrap><b>jump to:</b> <a href="#earth">Earth</a>, <a href="#mars">Mars</a></td>
<H3>Mean equatorial orbital elements</H3>
Epoch 1980 Jan. 1.0 TT<BR>
<TR ALIGN=right><TD ALIGN=left>Titania</TD><TD>436300.</TD><TD>0.0011</TD>
<TD>284.400</TD><TD>24.614</TD><TD>0.079</TD><TD>99.771</TD><TD>41.3514246</TD>
<TD>8.706</TD><TD>161.525</TD><TD>195.369</TD>
<TD ALIGN=right><A HREF="#ref10">10</A></TD></TR>
`; `;
/** Ceres as the SBDB API answers `sstr=Ceres&phys-par=1&full-prec=1`, cut to what is read. */
const CERES: SbdbAnswer = {
orbit: {
epoch: '2461200.5',
elements: [
{ name: 'e', value: '.07969229514816586' },
{ name: 'a', value: '2.765552595034094' },
{ name: 'q', value: '2.545159361382861' },
{ name: 'i', value: '10.58802780183462' },
{ name: 'om', value: '80.24862682043221' },
{ name: 'w', value: '73.29421453021587' },
{ name: 'ma', value: '274.4193463761342' },
{ name: 'tp', value: '2461599.841466614066' },
{ name: 'per', value: '1679.853119758983' },
{ name: 'n', value: '.21430445064843' },
{ name: 'ad', value: '2.985945828685327' }
]
},
phys_par: [
{ name: 'H', value: '3.34' },
{ name: 'diameter', value: '939.4' },
{ name: 'GM', value: '62.6284' },
{ name: 'rot_per', value: '9.074170' }
]
};
describe('parsePlanetMeanElements', () => { describe('parsePlanetMeanElements', () => {
it('turns Standish’s longitudes into the argument of periapsis and mean anomaly', () => { it('turns Standish’s longitudes into the argument of periapsis and mean anomaly', () => {
const { orbit } = parsePlanetMeanElements(TABLE_2, 'jupiter'); const { orbit } = parsePlanetMeanElements(TABLE_2, 'jupiter');
@@ -103,8 +137,50 @@ describe('parseSatelliteMeanElements', () => {
expect(rates.longitudeOfAscendingNodeDegPerDay).toBeCloseTo(360 / (687.446 * 365.25), 9); expect(rates.longitudeOfAscendingNodeDegPerDay).toBeCloseTo(360 / (687.446 * 365.25), 9);
}); });
it('reads a section referred to the planet’s equator against the pole it is given', () => {
const pole = { raDeg: 77.311, decDeg: 15.175 };
const titania = parseSatelliteMeanElements(SATELLITES, 'Uranus', 'Titania', false, pole);
expect(titania.laplacePole).toEqual(pole);
expect(titania.orbit.epochJd).toBe(2444239.5);
expect(titania.rates.meanMotionDegPerDay).toBe(41.3514246);
// Read as ecliptic elements, which is what a missing pole would mean, Titania is 88 degrees
// from Horizons on 2025-01-01.
expect(() => parseSatelliteMeanElements(SATELLITES, 'Uranus', 'Titania', false)).toThrow(/equator/);
expect(() => parseSatelliteMeanElements(SATELLITES, 'Jupiter', 'Io', true, pole)).toThrow(/equator/);
});
it('turns the periapsis backwards where a resonance holds it', () => { it('turns the periapsis backwards where a resonance holds it', () => {
const { rates } = parseSatelliteMeanElements(SATELLITES, 'Jupiter', 'Io', true); const { rates } = parseSatelliteMeanElements(SATELLITES, 'Jupiter', 'Io', true);
expect(rates.argumentOfPeriapsisDegPerDay).toBeCloseTo(-360 / (1.625 * 365.25), 9); expect(rates.argumentOfPeriapsisDegPerDay).toBeCloseTo(-360 / (1.625 * 365.25), 9);
}); });
}); });
describe('parseSmallBodyElements', () => {
it('carries a dwarf planet on its osculating elements at their own mean motion', () => {
const ceres = parseSmallBodyElements(CERES);
expect(ceres.orbit).toEqual({
semiMajorAxisAu: 2.765552595034094,
eccentricity: 0.07969229514816586,
inclinationDeg: 10.58802780183462,
longitudeOfAscendingNodeDeg: 80.24862682043221,
argumentOfPeriapsisDeg: 73.29421453021587,
meanAnomalyAtEpochDeg: 274.4193463761342,
epochJd: 2461200.5
});
expect(ceres.rates).toEqual({ meanMotionDegPerDay: 0.21430445064843, longitudeOfAscendingNodeDegPerDay: 0, argumentOfPeriapsisDegPerDay: 0 });
expect(ceres.laplacePole).toBeUndefined();
expect(ceres.orbitSource).toBe('JPL SBDB osculating elements, epoch 2026 Jun 9');
});
it('takes half the diameter as the radius, and the rotation period in hours', () => {
const ceres = parseSmallBodyElements(CERES);
expect(ceres.radiusKm).toBe(469.7);
expect(ceres.rotationPeriodHours).toBe(9.07417);
});
it('leaves out what the answer does not publish', () => {
const eris = parseSmallBodyElements({ ...CERES, phys_par: [{ name: 'rot_per', value: '25.9' }] });
expect(eris.radiusKm).toBeUndefined();
expect(eris.rotationPeriodHours).toBe(25.9);
});
});
+75 -3
View File
@@ -103,8 +103,18 @@ function julianDate(year: number, month: string, day: number): number {
* the line of their conjunctions, which turns backwards at 2 n(Europa) - n(Io) = 0.74 degrees a * the line of their conjunctions, which turns backwards at 2 n(Europa) - n(Io) = 0.74 degrees a
* day, and that is exactly the 1.625- and 1.394-year periods the table gives for them. Read as * day, and that is exactly the 1.625- and 1.394-year periods the table gives for them. Read as
* advancing, Io was 0.9 degrees out and Europa 2.1. * advancing, Io was 0.9 degrees out and Europa 2.1.
*
* Uranus's and Pluto's sections are referred to the planet's equator instead, and the page does
* not print its pole, so the caller passes it as `equatorPole`: the elements are then read
* against that pole exactly as against a Laplace plane's.
*/ */
export function parseSatelliteMeanElements(html: string, planetName: string, moonName: string, apsidesRegress: boolean): MeanOrbit { export function parseSatelliteMeanElements(
html: string,
planetName: string,
moonName: string,
apsidesRegress: boolean,
equatorPole?: { raDeg: number; decDeg: number }
): MeanOrbit {
const text = html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' '); const text = html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ');
const section = text.indexOf(`Satellites of ${planetName} jump to`); const section = text.indexOf(`Satellites of ${planetName} jump to`);
if (section < 0) { if (section < 0) {
@@ -119,7 +129,14 @@ export function parseSatelliteMeanElements(html: string, planetName: string, moo
if (!epoch) { if (!epoch) {
throw new Error(`No epoch above ${moonName}'s row.`); throw new Error(`No epoch above ${moonName}'s row.`);
} }
const laplace = before.lastIndexOf('Laplace plane') > before.lastIndexOf('Mean ecliptic'); // The nearest heading above the row says which plane its section is referred to; the ecliptic
// where there is none.
const [plane] = ['Mean ecliptic', 'Laplace plane', 'Mean equatorial'].sort((x, y) => before.lastIndexOf(y) - before.lastIndexOf(x));
const laplace = plane === 'Laplace plane';
const equatorial = plane === 'Mean equatorial';
if (equatorial !== (equatorPole !== undefined)) {
throw new Error(`${moonName}'s elements are ${equatorial ? '' : 'not '}referred to ${planetName}'s equator, and its pole was ${equatorPole ? '' : 'not '}given.`);
}
const values = numbers(row[1]); const values = numbers(row[1]);
const expected = laplace ? 14 : 11; const expected = laplace ? 14 : 11;
if (values.length !== expected || !values.every(Number.isFinite)) { if (values.length !== expected || !values.every(Number.isFinite)) {
@@ -145,7 +162,62 @@ export function parseSatelliteMeanElements(html: string, planetName: string, moo
longitudeOfAscendingNodeDegPerDay: nodeSense * perDay(nodePeriodYears), longitudeOfAscendingNodeDegPerDay: nodeSense * perDay(nodePeriodYears),
argumentOfPeriapsisDegPerDay: periapsisSense * perDay(periapsisPeriodYears) argumentOfPeriapsisDegPerDay: periapsisSense * perDay(periapsisPeriodYears)
}, },
...(laplace ? { laplacePole: { raDeg, decDeg } } : {}), ...(laplace ? { laplacePole: { raDeg, decDeg } } : equatorPole ? { laplacePole: equatorPole } : {}),
orbitSource: `JPL SSD satellite mean elements, epoch ${epoch[1]} ${epoch[2]} ${Math.floor(Number(epoch[3]))}` orbitSource: `JPL SSD satellite mean elements, epoch ${epoch[1]} ${epoch[2]} ${Math.floor(Number(epoch[3]))}`
}; };
} }
/** What this reads of a JPL Small-Body Database answer (`sbdb.api?sstr=…&phys-par=1&full-prec=1`). */
export interface SbdbAnswer {
orbit: { epoch: string; elements: Array<{ name: string; value: string | null }> };
phys_par?: Array<{ name: string; value: string | null }>;
}
export interface SmallBody extends MeanOrbit {
radiusKm?: number;
rotationPeriodHours?: number;
}
/**
* A dwarf planet from the Small-Body Database: its osculating heliocentric elements against the
* J2000 ecliptic, the frame Standish's are in, carried round at their own mean motion n with
* nothing turning. Standish's tables stop at Pluto and JPL publishes no mean elements for the
* others, so these are exact on their epoch and drift from it — for Ceres, whose orbit Jupiter
* pulls on, by degrees within decades; see the ETL's check against Horizons.
*
* Radius and spin come from the same answer where it has them: half the published diameter, and
* the rotation period, in hours.
*/
export function parseSmallBodyElements(answer: SbdbAnswer): SmallBody {
const element = (name: string): number => {
const value = Number(answer.orbit.elements.find((candidate) => candidate.name === name)?.value ?? NaN);
if (!Number.isFinite(value)) {
throw new Error(`The SBDB answer has no element ${name}.`);
}
return value;
};
const physical = (name: string): number | undefined => {
const value = Number(answer.phys_par?.find((candidate) => candidate.name === name)?.value ?? NaN);
return Number.isFinite(value) ? value : undefined;
};
const epochJd = Number(answer.orbit.epoch);
const epoch = new Date((epochJd - 2440587.5) * 86400000);
const diameterKm = physical('diameter');
const rotationPeriodHours = physical('rot_per');
return {
orbit: {
semiMajorAxisAu: element('a'),
eccentricity: element('e'),
inclinationDeg: element('i'),
longitudeOfAscendingNodeDeg: element('om'),
argumentOfPeriapsisDeg: element('w'),
meanAnomalyAtEpochDeg: element('ma'),
epochJd
},
rates: { meanMotionDegPerDay: element('n'), longitudeOfAscendingNodeDegPerDay: 0, argumentOfPeriapsisDegPerDay: 0 },
orbitSource: `JPL SBDB osculating elements, epoch ${epoch.getUTCFullYear()} ${MONTHS[epoch.getUTCMonth()]} ${epoch.getUTCDate()}`,
...(diameterKm !== undefined ? { radiusKm: diameterKm / 2 } : {}),
...(rotationPeriodHours !== undefined ? { rotationPeriodHours } : {})
};
}
+11 -1
View File
@@ -1,4 +1,5 @@
import { fetchTextCached } from './http'; import { SbdbAnswer } from '../../../src/app/shared/astro/mean-elements';
import { fetchJsonCached, fetchTextCached } from './http';
/** /**
* Standish's "Keplerian Elements for Approximate Positions of the Major Planets", Table 2a/2b: * Standish's "Keplerian Elements for Approximate Positions of the Major Planets", Table 2a/2b:
@@ -29,3 +30,12 @@ export async function fetchPlanetMeanElementsText(): Promise<string> {
export async function fetchSatelliteMeanElementsHtml(): Promise<string> { export async function fetchSatelliteMeanElementsHtml(): Promise<string> {
return fetchTextCached(SATELLITE_ELEMENTS_URL, 'jpl-satellite-mean-elements.html'); return fetchTextCached(SATELLITE_ELEMENTS_URL, 'jpl-satellite-mean-elements.html');
} }
/**
* A small body's answer from JPL's Small-Body Database: osculating elements to full precision
* (without `full-prec` they come rounded to three figures: Ceres's n as 0.214 degrees a day for
* 0.2143045, which is 1.1 degrees out within a decade) and its physical parameters.
*/
export async function fetchSmallBodyAnswer(designation: string, cacheKey: string): Promise<SbdbAnswer> {
return fetchJsonCached<SbdbAnswer>(`https://ssd-api.jpl.nasa.gov/sbdb.api?sstr=${encodeURIComponent(designation)}&phys-par=1&full-prec=1`, cacheKey);
}