diff --git a/src/app/shared/astro/mean-elements.spec.ts b/src/app/shared/astro/mean-elements.spec.ts
index 32b61a7..789bf4a 100644
--- a/src/app/shared/astro/mean-elements.spec.ts
+++ b/src/app/shared/astro/mean-elements.spec.ts
@@ -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
61.2572638 | 5.877 | 386.371 | 687.446 |
299.456 | 43.414 | 0.010 |
54 |
+Satellites of Uranus |
+jump to: Earth, Mars |
+Mean equatorial orbital elements
+Epoch 1980 Jan. 1.0 TT
+| Titania | 436300. | 0.0011 |
+284.400 | 24.614 | 0.079 | 99.771 | 41.3514246 |
+8.706 | 161.525 | 195.369 |
+10 |
`;
+/** 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);
+ });
+});
diff --git a/src/app/shared/astro/mean-elements.ts b/src/app/shared/astro/mean-elements.ts
index bbd8251..e5c8dd0 100644
--- a/src/app/shared/astro/mean-elements.ts
+++ b/src/app/shared/astro/mean-elements.ts
@@ -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 } : {})
+ };
+}
diff --git a/tools/etl/lib/mean-elements.ts b/tools/etl/lib/mean-elements.ts
index 25e66be..a0a3d4b 100644
--- a/tools/etl/lib/mean-elements.ts
+++ b/tools/etl/lib/mean-elements.ts
@@ -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:
@@ -29,3 +30,12 @@ export async function fetchPlanetMeanElementsText(): Promise {
export async function fetchSatelliteMeanElementsHtml(): Promise {
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 {
+ return fetchJsonCached(`https://ssd-api.jpl.nasa.gov/sbdb.api?sstr=${encodeURIComponent(designation)}&phys-par=1&full-prec=1`, cacheKey);
+}