Move the solar system on JPL's mean elements, so it stays right as the clock runs

Every body carried one set of osculating elements from Horizons at 2025-01-01, run forward by
Kepler with a GM from a table of mass ratios. That set is exact at its instant and drifts from
then on, and the clock now runs a month a second: the Moon, with Earth's mass ratio lacking its
own and the osculating axis, went round in 27.70 days instead of 27.32, 66 degrees out after a
year, and its locked face was spun at the same wrong rate.

Planets and Pluto now take Standish's Table 2a/2b ("Keplerian Elements for Approximate
Positions of the Major Planets"): elements against the J2000 ecliptic, their rates per century,
and the b, c, s, f terms of Jupiter to Pluto, fit for 3000 BC to AD 3000. Table 1 is closer near
the present (Saturn 0.23 degrees at worst 1950-2100, against 0.32 here) but is only fit for
1800-2050, and by AD 3000 has Saturn 4.3 degrees out where Table 2 holds every planet within 0.3.
The moons take JPL SSD's satellite mean elements: sidereal mean motion n to ten figures, the
periods of their node and periapsis, and each one's local Laplace plane by its pole. They
propagate with n itself, never a GM: gmForParent and its mass table are gone. Horizons still
gives size, spin and obliquity.

Both tables are read from the Internet Archive's copy of JPL's pages, pinned to one capture: the
live approx_pos page has dropped Pluto, and the live sats/elem page has dropped n and rounds the
period to four or five figures (Phobos 0.3187 d, a revolution out within a decade).

What the tables leave implicit, measured against Horizons before it was accepted:
- The precession periods are magnitudes. A node regresses on a prograde orbit and advances on a
  retrograde one; a periapsis advances except where a resonance forces the eccentricity. Io's
  and Europa's follow their conjunction line backwards at 2 n(Europa) - n(Io) = 0.74 degrees a
  day, which is exactly the 1.625- and 1.394-year periods in the table. Read as advancing, Io
  was 0.9 degrees out and Europa 2.1.
- On a retrograde orbit the node's turning is added back to the mean anomaly. Taken off, Triton
  drifted a degree a year, 105 degrees by 2100.
- The Laplace frame's x axis is where the plane rises through the ICRF equator, RA of the pole
  plus 90. Read against the ecliptic, Io was 2.8 degrees out, Phobos 54 and Titan 127.

Orbit lines are now drawn in their own plane and turned by a quaternion each tick, so a turning
node carries the line with the body: fixed at one date, the Moon's line would be up to 69 000 km
off it nine years on. The Earth row is the Earth-Moon barycentre, 4 700 km from Earth, 0.002
degrees from the Sun. A tidally locked moon's day is now 360 / n, its sidereal period (the Moon
27.321662 d), so it stays locked to the orbit it is drawn on.

Angular error against Horizons VECTORS (ICRF, TDB; heliocentric for planets, planet-centred for
moons), degrees, read from the live renderer's markers in the running app:

body       1950-01-01 1975-01-01 1987-07-23 2000-01-01 2025-01-01 2037-03-06 2050-01-01 2075-01-01 2100-01-01   max
mercury         0.004      0.002      0.003      0.002      0.002      0.001      0.000      0.002      0.000  0.004
venus           0.003      0.007      0.003      0.004      0.004      0.004      0.003      0.004      0.004  0.007
earth           0.003      0.008      0.002      0.005      0.004      0.009      0.003      0.002      0.003  0.009
mars            0.009      0.010      0.008      0.024      0.009      0.012      0.009      0.011      0.028  0.028
jupiter         0.063      0.030      0.171      0.135      0.013      0.020      0.056      0.041      0.075  0.171
saturn          0.080      0.064      0.018      0.320      0.066      0.114      0.044      0.164      0.177  0.320
uranus          0.018      0.169      0.068      0.050      0.101      0.015      0.141      0.017      0.114  0.169
neptune         0.070      0.028      0.004      0.021      0.036      0.037      0.013      0.029      0.072  0.072
pluto           0.045      0.054      0.041      0.033      0.019      0.020      0.023      0.027      0.026  0.054
moon            0.486      1.928      0.127      0.631      1.407      1.086      0.720      0.339      1.180  1.928
phobos          2.068      0.294      0.881      1.113      0.313      0.636      2.089      5.862     11.099 11.099
deimos          0.077      0.043      0.310      0.066      0.164      0.068      0.034      0.468      0.044  0.468
io              0.021      0.015      0.010      0.019      0.009      0.035      0.006      0.011      0.022  0.035
europa          0.036      0.039      0.053      0.064      0.078      0.032      0.006      0.034      0.044  0.078
ganymede        0.132      0.103      0.018      0.007      0.023      0.054      0.091      0.118      0.044  0.132
callisto        0.040      0.019      0.023      0.019      0.038      0.008      0.060      0.119      0.056  0.119
titan           0.003      0.019      0.023      0.023      0.027      0.028      0.048      0.008      0.014  0.048
triton          0.051      0.029      0.009      0.021      0.052      0.048      0.063      0.089      0.137  0.137

Three miss what was hoped for, and why:
- Jupiter 0.17, Saturn 0.32, Uranus 0.17 against the 0.1 hoped for: short-period perturbations
  of the giants by one another, which no Keplerian fit carries. Standish states his own Table 2
  errors as 600, 1 000 and 2 000 arcseconds (0.17, 0.28, 0.56 degrees). Out to AD 3000, measured
  at 1800, 2200, 2400, 2600 and 3000, every planet stays within 0.3.
- The Moon, 1.9: evection (1.27) and variation (0.66), which a mean ellipse leaves out.
- Phobos, 2.1 until 2050, then 5.9 in 2075 and 11.1 in 2100, growing as the square of the time:
  its tidal acceleration, which the table has no column for. Its elements are MAR080's, epoch
  1950. The map's dates are also UTC where the elements are TDB, 69 s today,
  which is 0.9 degrees of Phobos and nothing for anything else.

Held in place by:
- build.ts: each body's mean elements against Horizons' own osculating elements on the ETL's
  2025-01-01, at most 0.25 degrees for a planet and 2.5 for a moon (measured: Uranus 0.101, the
  Moon 1.407; a regressing Triton node reads 10.24 and fails), and every moon's day equal to its
  sidereal period (a 1% error fails).
- Unit tests freezing nine Horizons vectors (Earth 2100, Jupiter 1950, Saturn 2075, Pluto 1975,
  the Moon 2050, Io and Europa 1950, Titan and Triton 2100) through SystemOrbitsRenderer, the
  Moon kept on its own turning line, the retrograde rule, the Standish terms, the Laplace frame,
  and both table parsers. Nine mutants each fail the test named for them, and the two
  validators each refuse a mutated build of the real catalogue.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 20:18:57 +02:00
co-authored by Claude Opus 5.5
parent c38a42cbcb
commit 48319c3fe2
19 changed files with 1062 additions and 244 deletions
+49 -4
View File
@@ -1,6 +1,8 @@
import { statSync } from 'node:fs';
import { BodyRecord } from '../../src/app/shared/models/body.model';
import { BodyRecord, OrbitalElements } from '../../src/app/shared/models/body.model';
import { eclipticToEquatorial, laplacePlaneToEquatorial } from '../../src/app/shared/astro/coordinates';
import { meanElementsAt, positionAtEpoch } from '../../src/app/shared/astro/kepler';
import { DeepSkyRecord } from '../../src/app/shared/models/deepsky.model';
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
@@ -130,23 +132,66 @@ function validateMerge(stars: StarRecord[]): void {
console.log(` ${survivors} HYG stars have no Gaia counterpart; ${twins} unmerged cross-catalogue pairs within an arcsecond.`);
}
function validateBodies(bodies: BodyRecord[]): void {
/**
* How far a body's mean elements may put it from where Horizons has it, on the one date the ETL
* asks Horizons about (2025-01-01), seen from the Sun for a planet and from its planet for a moon.
*
* Measured on this catalogue: the planets at most 0.10 degrees (Uranus; Standish's own stated
* error for his fit is 2 000 arcseconds, 0.56 degrees), the moons at most 1.41 (the Moon, whose
* evection and variation, 1.27 and 0.66 degrees, no mean ellipse has). What this catches is a
* table read wrongly: a moon read against the ecliptic instead of its Laplace plane, a precession
* run the wrong way, or a column taken for its neighbour, which put Triton 26 degrees out and Io
* 0.9.
*/
const MAX_PLANET_OFFSET_DEG = 0.25;
const MAX_MOON_OFFSET_DEG = 2.5;
function angleBetweenDeg(a: { x: number; y: number; z: number }, b: { x: number; y: number; z: number }): number {
const cosine = (a.x * b.x + a.y * b.y + a.z * b.z) / (Math.hypot(a.x, a.y, a.z) * Math.hypot(b.x, b.y, b.z));
return (Math.acos(Math.min(1, Math.max(-1, cosine))) * 180) / Math.PI;
}
function validateBodies(bodies: BodyRecord[], horizonsOrbits: Map<string, OrbitalElements>): void {
assertCondition(bodies.length > 0, 'No solar-system bodies were produced.');
const ids = new Set(bodies.map((body) => body.id));
assertCondition(ids.size === bodies.length, 'Duplicate body ids were found.');
const offsets: string[] = [];
for (const body of bodies) {
const orbitValues = Object.values(body.orbit);
assertCondition(orbitValues.every(Number.isFinite), `Body ${body.id} has non-finite orbital elements.`);
assertCondition(body.rates.meanMotionDegPerDay > 0, `Body ${body.id} has no mean motion.`);
// Horizons' elements are osculating, exact at their own epoch; both sets are placed there.
const horizons = horizonsOrbits.get(body.id);
assertCondition(horizons !== undefined, `Body ${body.id} has no Horizons elements to be checked against.`);
const truth = eclipticToEquatorial(positionAtEpoch(horizons!));
const mean = positionAtEpoch(meanElementsAt(body.orbit, body.rates, horizons!.epochJd));
const offset = angleBetweenDeg(body.laplacePole ? laplacePlaneToEquatorial(mean, body.laplacePole) : eclipticToEquatorial(mean), truth);
const ceiling = body.kind === 'moon' ? MAX_MOON_OFFSET_DEG : MAX_PLANET_OFFSET_DEG;
assertCondition(
offset <= ceiling,
`${body.name}'s mean elements put it ${offset.toFixed(2)} degrees from where Horizons has it (at most ${ceiling} expected) — the elements were read wrongly.`
);
offsets.push(`${body.id} ${offset.toFixed(3)}`);
if (body.kind === 'moon') {
assertCondition(!!body.parentBodyId && ids.has(body.parentBodyId), `Moon ${body.id} has no valid parentBodyId.`);
// Every moon here is tidally locked: its day is its orbit, from the same mean motion that
// carries it round, or its face turns away from its planet: the Kepler period of the
// osculating orbit this used to take would turn the Moon's five degrees an orbit.
const orbitHours = (360 / body.rates.meanMotionDegPerDay) * 24;
assertCondition(
body.rotationPeriodHours !== undefined && Math.abs(body.rotationPeriodHours - orbitHours) <= orbitHours * 1e-9,
`Moon ${body.id} turns once in ${body.rotationPeriodHours} hours but goes round in ${orbitHours} — it will not keep one face to its planet.`
);
}
}
const planetCount = bodies.filter((body) => body.kind === 'planet').length;
assertCondition(planetCount === 8, `Expected 8 planets, found ${planetCount}.`);
console.log(` mean elements against Horizons, degrees: ${offsets.join(', ')}.`);
}
function validateExoplanets(exoplanets: ExoplanetRecord[], starIds: Set<number>): void {
@@ -234,7 +279,7 @@ async function build(): Promise<void> {
const stars = await fetchStars();
console.log();
const bodies = await fetchSolarSystem();
const { bodies, horizonsOrbits } = await fetchSolarSystem();
console.log();
const exoplanets = await fetchExoplanets(stars);
console.log();
@@ -244,7 +289,7 @@ async function build(): Promise<void> {
console.log('Validating output...');
validateStars(stars);
validateMerge(stars);
validateBodies(bodies);
validateBodies(bodies, horizonsOrbits);
validateExoplanets(exoplanets, new Set(stars.map((star) => star.id)));
validateDeepSky(deepSky);
+33 -18
View File
@@ -1,10 +1,10 @@
import { writeFileSync } from 'node:fs';
import { BodyRecord } from '../../src/app/shared/models/body.model';
import { gmForParent } from '../../src/app/shared/astro/constants';
import { orbitalPeriodDays } from '../../src/app/shared/astro/kepler';
import { BodyRecord, OrbitalElements } from '../../src/app/shared/models/body.model';
import { SUN_STAR_ID } from '../../src/app/shared/models/star.model';
import { fetchHorizonsBody } from './lib/horizons';
import { parsePlanetMeanElements, parseSatelliteMeanElements } from '../../src/app/shared/astro/mean-elements';
import { fetchPlanetMeanElementsText, fetchSatelliteMeanElementsHtml } from './lib/mean-elements';
import { dataPath, ensureDataDir } from './lib/paths';
const HOURS_PER_DAY = 24;
@@ -21,6 +21,8 @@ interface BodySpec {
* WGCCRE 2015 pole (RA 132.99, Dec -6.16), 119.6 degrees: past 90, so it turns retrograde.
*/
obliquityDeg?: number;
/** The periapsis turns backwards; see `parseSatelliteMeanElements`. */
apsidesRegress?: boolean;
}
// Sun-centered planets/dwarf, then their major moons (planetocentric elements).
@@ -37,8 +39,8 @@ const BODY_SPECS: BodySpec[] = [
{ id: 'moon', name: 'Moon', kind: 'moon', horizonsCommand: '301', center: '500@399', parentBodyId: 'earth' },
{ id: 'phobos', name: 'Phobos', kind: 'moon', horizonsCommand: '401', center: '500@499', parentBodyId: 'mars' },
{ id: 'deimos', name: 'Deimos', kind: 'moon', horizonsCommand: '402', center: '500@499', parentBodyId: 'mars' },
{ id: 'io', name: 'Io', kind: 'moon', horizonsCommand: '501', center: '500@599', parentBodyId: 'jupiter' },
{ id: 'europa', name: 'Europa', kind: 'moon', horizonsCommand: '502', center: '500@599', parentBodyId: 'jupiter' },
{ id: 'io', name: 'Io', kind: 'moon', horizonsCommand: '501', center: '500@599', parentBodyId: 'jupiter', apsidesRegress: true },
{ id: 'europa', name: 'Europa', kind: 'moon', horizonsCommand: '502', center: '500@599', parentBodyId: 'jupiter', apsidesRegress: true },
{ id: 'ganymede', name: 'Ganymede', kind: 'moon', horizonsCommand: '503', center: '500@599', parentBodyId: 'jupiter' },
{ id: 'callisto', name: 'Callisto', kind: 'moon', horizonsCommand: '504', center: '500@599', parentBodyId: 'jupiter' },
{ id: 'titan', name: 'Titan', kind: 'moon', horizonsCommand: '606', center: '500@699', parentBodyId: 'saturn' },
@@ -46,13 +48,17 @@ const BODY_SPECS: BodySpec[] = [
];
/**
* Queries JPL Horizons for the osculating orbital elements (and mean radius, where
* reported) of the major planets, Pluto, and a curated set of major moons, and writes
* `bodies.json`.
* Writes `bodies.json` for the major planets, Pluto, and a curated set of major moons: JPL's
* mean orbital elements for where they go, and JPL Horizons for their size and spin. Horizons'
* osculating elements for the same date come back alongside, for `build.ts` to check the mean
* ones against.
*/
export async function fetchSolarSystem(): Promise<BodyRecord[]> {
console.log(`Fetching ${BODY_SPECS.length} solar-system bodies from JPL Horizons...`);
export async function fetchSolarSystem(): Promise<{ bodies: BodyRecord[]; horizonsOrbits: Map<string, OrbitalElements> }> {
console.log(`Fetching ${BODY_SPECS.length} solar-system bodies from JPL (mean elements, Horizons)...`);
const bodies: BodyRecord[] = [];
const horizonsOrbits = new Map<string, OrbitalElements>();
const planetElements = await fetchPlanetMeanElementsText();
const satelliteElements = await fetchSatelliteMeanElementsHtml();
for (const spec of BODY_SPECS) {
const result = await fetchHorizonsBody({
@@ -61,17 +67,23 @@ export async function fetchSolarSystem(): Promise<BodyRecord[]> {
cacheKey: `horizons-${spec.id}.txt`
});
horizonsOrbits.set(spec.id, result.orbit);
if (result.radiusKm === undefined) {
console.warn(` no physical radius found for ${spec.name}; defaulting to 0.`);
}
// Every moon listed here is tidally locked, so its day is its orbit — as drawn, from these
// elements and the parent's mass by Kepler. Not every page says so: the Moon's gives a rate,
// the true sidereal month, 1.4% off the orbit these elements trace, so its face drifted five
// degrees an orbit; Titan's gives nothing, so it did not turn. Taking the orbit keeps one face
// towards the parent, which is what synchronous means.
const parentName = BODY_SPECS.find((candidate) => candidate.id === spec.parentBodyId)?.name;
const mean = parentName
? parseSatelliteMeanElements(satelliteElements, parentName, spec.name, spec.apsidesRegress ?? false)
: parsePlanetMeanElements(planetElements, spec.id);
// Every moon listed here is tidally locked, so its day is its orbit: the sidereal period from
// the same mean motion that carries it round, which keeps one face towards the parent however
// long the clock runs. Not every page says so — the Moon's gives a rate, Titan's nothing. The
// Kepler period of the osculating orbit this used to take, 27.70 days for the Moon, would now
// turn its face five degrees an orbit away from the orbit it is drawn on.
const rotationPeriodHours = result.tidallyLocked || spec.kind === 'moon'
? orbitalPeriodDays(result.orbit.semiMajorAxisAu, gmForParent(spec.parentBodyId)) * HOURS_PER_DAY
? (360 / mean.rates.meanMotionDegPerDay) * HOURS_PER_DAY
: result.rotationPeriodHours;
if (rotationPeriodHours === undefined) {
console.warn(` no rotation period found for ${spec.name}; it will not turn.`);
@@ -83,7 +95,10 @@ export async function fetchSolarSystem(): Promise<BodyRecord[]> {
name: spec.name,
kind: spec.kind,
radiusKm: result.radiusKm ?? 0,
orbit: result.orbit,
orbit: mean.orbit,
rates: mean.rates,
...(mean.laplacePole ? { laplacePole: mean.laplacePole } : {}),
orbitSource: mean.orbitSource,
...(spec.parentBodyId ? { parentBodyId: spec.parentBodyId } : {}),
...(rotationPeriodHours !== undefined ? { rotationPeriodHours } : {}),
...((result.obliquityDeg ?? spec.obliquityDeg) !== undefined ? { obliquityDeg: result.obliquityDeg ?? spec.obliquityDeg } : {})
@@ -93,7 +108,7 @@ export async function fetchSolarSystem(): Promise<BodyRecord[]> {
ensureDataDir();
writeFileSync(dataPath('bodies.json'), JSON.stringify(bodies, null, 2));
console.log(` wrote ${bodies.length} bodies.`);
return bodies;
return { bodies, horizonsOrbits };
}
if (require.main === module) {
+2 -2
View File
@@ -64,8 +64,8 @@ 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 Kepler's, which the caller
* works out from the elements above and the parent's mass.
* 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);
+31
View File
@@ -0,0 +1,31 @@
import { fetchTextCached } from './http';
/**
* Standish's "Keplerian Elements for Approximate Positions of the Major Planets", Table 2a/2b:
* elements against the J2000 ecliptic and their rates per century, fit to the JPL ephemeris for
* 3000 BC to AD 3000. Table 1 is closer near the present — Saturn within 0.23 degrees of Horizons
* from 1950 to 2100 against this table's 0.32 — but it is only fit for 1800-2050, which the clock
* leaves in minutes, and by AD 3000 it has Saturn 4.3 degrees out where this one is within 0.3
* of every planet. The page at ssd.jpl.nasa.gov/planets/approx_pos.html carries the same numbers but has
* dropped Pluto, so this reads the plain-text file as JPL last published it, from the Internet
* Archive's copy — pinned to one capture, so the numbers cannot move under the cache.
*/
const PLANET_ELEMENTS_URL = 'https://web.archive.org/web/20210420020242id_/https://ssd.jpl.nasa.gov/txt/p_elem_t2.txt';
/**
* JPL SSD's planetary satellite mean elements, as the page stood until 2021: each moon's elements,
* its sidereal mean motion to ten figures, and how fast its node and periapsis turn, against its
* local Laplace plane (the Moon against the ecliptic). The current page, ssd.jpl.nasa.gov/sats/elem,
* has dropped the mean motion and rounds the period to four or five figures — 0.3187 days for
* Phobos, which is a revolution out within a decade — so a period from it would not hold. Pinned
* to one Internet Archive capture for the same reason as the planets.
*/
const SATELLITE_ELEMENTS_URL = 'https://web.archive.org/web/20210203000649id_/https://ssd.jpl.nasa.gov/?sat_elem';
export async function fetchPlanetMeanElementsText(): Promise<string> {
return fetchTextCached(PLANET_ELEMENTS_URL, 'jpl-planet-mean-elements-t2.txt');
}
export async function fetchSatelliteMeanElementsHtml(): Promise<string> {
return fetchTextCached(SATELLITE_ELEMENTS_URL, 'jpl-satellite-mean-elements.html');
}