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.25726385.877386.371687.446 299.45643.4140.010 54 +Satellites of Uranus +jump to: Earth, Mars +

Mean equatorial orbital elements

+Epoch 1980 Jan. 1.0 TT
+Titania436300.0.0011 +284.40024.6140.07999.77141.3514246 +8.706161.525195.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); +}