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:
@@ -1,6 +1,6 @@
|
||||
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. */
|
||||
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>299.456</TD><TD>43.414</TD><TD>0.010</TD>
|
||||
<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', () => {
|
||||
it('turns Standish’s longitudes into the argument of periapsis and mean anomaly', () => {
|
||||
const { orbit } = parsePlanetMeanElements(TABLE_2, 'jupiter');
|
||||
@@ -103,8 +137,50 @@ describe('parseSatelliteMeanElements', () => {
|
||||
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', () => {
|
||||
const { rates } = parseSatelliteMeanElements(SATELLITES, 'Jupiter', 'Io', true);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
* 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.
|
||||
*
|
||||
* 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(/ /g, ' ').replace(/\s+/g, ' ');
|
||||
const section = text.indexOf(`Satellites of ${planetName} jump to`);
|
||||
if (section < 0) {
|
||||
@@ -119,7 +129,14 @@ export function parseSatelliteMeanElements(html: string, planetName: string, moo
|
||||
if (!epoch) {
|
||||
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 expected = laplace ? 14 : 11;
|
||||
if (values.length !== expected || !values.every(Number.isFinite)) {
|
||||
@@ -145,7 +162,62 @@ export function parseSatelliteMeanElements(html: string, planetName: string, moo
|
||||
longitudeOfAscendingNodeDegPerDay: nodeSense * perDay(nodePeriodYears),
|
||||
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]))}`
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 } : {})
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user