Draw four moons' nodes at JPL's current rates, which Horizons and the IAU's poles agree with

The archived satellite table the moons are read from gives older node
periods than JPL's current one for Miranda (17.727 years against URA182's
17.787), Ganymede (132.654 against 137.812), Callisto (338.82 against
577.264) and Titan (704.60 against 687.370), and 74ea1d6 turned the IAU's
poles after those older rates, taking them for the right ones. They were
not: fitted to Horizons' osculating elements on Uranus's equator over
1601-2399, Miranda's node turns 2023.97 degrees a century (rms 0.04),
against the current table's 2023.95, the IAU's U11 2024.22 and the row's
2030.80. That commit moved Miranda's axis from 0.38 to 2.36 degrees of
Horizons' orbit normal. Mimas's node, 0.986 years in both tables, now takes
the IAU's S3, 36505.5 degrees a century, 1.2 from a Horizons fit where
the row's is 4.5.

The ETL spec carries the node period (nodePeriodYears), and the periapsis
keeps the row's longitude rate: the row gives the longitude's rate (Callisto
68.7 degrees a century, the current table 67.2), so the argument takes up
the change. On the row's argument Callisto's periapsis moved 44 degrees by
2100 and it strayed 0.71 degrees from Horizons over 1950-2100.

Worst angle from Horizons' osculating orbit normal (ICRF), HEAD then now:
- Miranda, 1601-2399: drawn orbit 2.11 -> 0.12, axis 2.36 -> 0.34.
- Mimas, 1750-2249: orbit 0.33 -> 0.11, axis 0.65 -> 0.40.
- Ganymede, 1600-2199: orbit 0.17 -> 0.11, axis 0.13 -> 0.01.
- Callisto, 1600-2199: orbit 0.53 -> 0.20, axis 0.02 -> 0.03.
- Titan, 1750-2249: orbit 0.048 -> 0.052.
Against the 1950-2100 Horizons tracks the ETL checks: Callisto 0.19 -> 0.08,
Miranda 1.73 -> 1.62, Ganymede 0.30 -> 0.29, Mimas 7.43 -> 7.42, Titan 0.06.

lockedToOrbit now barely moves the IAU's node terms: Miranda's U11 to
-2023.95, Ganymede's J5 to 261.23 (262.1), Mimas's S3 not at all, and
Callisto's J6, 3.1 per cent from its new node rate, to 62.36 (64.3), which
takes Callisto's axis from 0.56 to 0.22 degrees of its drawn orbit.

With the drawn rates right, leaving every node term at the IAU's rate no
longer failed the ETL (Mimas used to), nor did a 1 per cent tolerance, the
node's angle without its harmonics, or the older node periods. The axis
ceiling is now 0.25 degrees for Europa (0.13 measured), Ganymede (0.16),
Callisto (0.22), Rhea (0.17), Miranda (0.23) and Triton (0.15), and
Callisto's track ceiling 0.15 (0.08). Guarded mutants, each through the
solar ETL on the real catalogue and then the suite on the bodies.json it
wrote:
- node terms left at the IAU's rates: ETL "europa's spin axis leans up to
  0.33"; suite fails only 'turns the poles of Europa, Ganymede, Callisto,
  Rhea, Miranda and Triton round with their drawn nodes'.
- tolerance 1 per cent: ETL, Callisto 0.33; the same test, alone.
- harmonics dropped: ETL, Triton 0.29 (the suite's three dates miss it).
- the archived node periods: ETL, Callisto's track 0.19; the suite fails
  that test and 'draws Miranda's orbit, and turns its axis, where Horizons
  has its orbit in 1601 and 2390' (orbit 2.02, axis 2.36 at 1601).
- the row's argument kept: ETL, Callisto's track 0.71.

The docs that called the IAU's rates the wrong ones are corrected
(lockedToOrbit, the axis ceiling in build.ts, whose Titan node now turns
in 687 years), and the README and BodyRecord doc no longer say every
locked moon's node terms are re-rated: only those within 5 per cent of a
multiple of the node's rate, never the Moon's or Phobos's, and not the
circles Ariel's, Umbriel's, Titania's and Oberon's poles go round on
(0.36 to 0.50 degrees off their orbits). The README also names the five
bodies the IAU's elements do not turn. Unit suite 866 passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 19:22:57 +02:00
co-authored by Claude Opus 5.5
parent ac23496b37
commit 24c7be1bae
7 changed files with 182 additions and 68 deletions
+29 -8
View File
@@ -180,12 +180,12 @@ const MOON_OFFSET_CEILINGS_DEG: Record<string, number> = { hyperion: 10, iapetus
* - Iapetus, 10.34: the row sits 9.4 degrees behind Horizons at its own epoch, 2000 Jan 1.5, and
* keeps that offset; its plane agrees with Horizons' to 0.07 degrees and its period to 0.001 per
* cent, so the fault is in the row's longitude, which this has no second source to correct.
* - Mimas, 7.43: its orbit carries the 44-degree libration of its resonance with Tethys (see
* - Mimas, 7.42: its orbit carries the 44-degree libration of its resonance with Tethys (see
* `orbitFromW` in `fetchSolarSystem.ts`), but not the rest of what Horizons integrates.
* - Ceres, 7.12 (1953): the SBDB's elements are osculating, exact at 2026 Jun 9 and drifting
* either side; 1.9 by 2050, 5.3 by 2100, and 39 at 1600 on Horizons' own figures.
*
* And three are held tighter than the rest, each where one reading of its row is all that keeps it
* And four are held tighter than the rest, each where one reading of its row is all that keeps it
* close, and without it the card would quietly restate itself under the general ceiling:
*
* - Tethys, 0.28, which takes the other half of that libration, 2.23 degrees, from its W. Without
@@ -193,9 +193,12 @@ const MOON_OFFSET_CEILINGS_DEG: Record<string, number> = { hyperion: 10, iapetus
* - Io, 0.07, and Europa, 0.23, whose periapses turn backwards, held by the Laplace resonance at
* 2n(Europa) - n(Io), -0.7395 degrees a day (`apsidesRegress`). Read as advancing, Io strays 0.96
* and Europa 2.24, and their cards said "within 1.0" and "within 2.3".
* - Callisto, 0.08, whose node turns at JPL's current rate and its periapsis's longitude at the
* row's (`nodePeriodYears`). On the row's argument its periapsis moves 44 degrees by 2100 and it
* strays 0.71; on the row's node, 0.19.
*/
const MAX_TRACK_OFFSET_DEG = 3;
const TRACK_OFFSET_CEILINGS_DEG: Record<string, number> = { hyperion: 23, nereid: 12, iapetus: 11, mimas: 8, ceres: 8, tethys: 0.5, io: 0.2, europa: 0.5 };
const TRACK_OFFSET_CEILINGS_DEG: Record<string, number> = { hyperion: 23, nereid: 12, iapetus: 11, mimas: 8, ceres: 8, tethys: 0.5, io: 0.2, europa: 0.5, callisto: 0.15 };
/**
* The bodies the IAU WGCCRE 2015 report gives no rotational elements for: Hyperion tumbles, and
@@ -273,18 +276,36 @@ const SUB_PLANET_CEILINGS_DEG: Record<string, number> = { moon: 8, mimas: 9.5, i
* How far a locked moon's spin axis may lean from the normal of the orbit it is drawn going round,
* over the same dates. A locked moon sits in a Cassini state, its axis on its orbit normal as the
* node carries both round the Laplace pole, and the IAU's pole goes round on a term of the node's
* angle; at the rate the IAU's source had for it and not the drawn orbit's, Miranda's axis was 7.89
* degrees off at AD 9 and Mimas's 2.63, and an Iapetus pole left on the Laplace pole is 8.30 off at
* every date (see `lockedToOrbit`).
* angle; at the rate the IAU's source had for it and not the drawn orbit's, Rhea's axis is 0.77
* degrees off by AD 1 and Triton's 0.51, and an Iapetus pole left on the Laplace pole is 8.30 off
* at every date (see `lockedToOrbit`).
*
* Measured on this catalogue: at most 0.97 degrees (Tethys, whose IAU pole sits 0.69 from its orbit
* normal today; Titan 0.94, whose pole the IAU holds still while its node turns in 705 years) but
* normal today; Titan 0.94, whose pole the IAU holds still while its node turns in 687 years) but
* for four. The Moon 6.98, its real 6.7-degree tilt to its orbit. Phobos 1.81 and Deimos 1.74, and
* Proteus 1.09: their IAU poles nod with Mars's and Neptune's precessing poles, the Laplace poles
* their orbits are drawn round are fixed.
*
* And six are held tighter, each where its node terms turned at the node's rate, or its node at
* JPL's current rate, are what keep it close: Europa 0.13, Ganymede 0.16, Callisto 0.22, Rhea 0.17,
* Miranda 0.23 and Triton 0.15. On the IAU's rates they are 0.33, 0.21, 0.33, 0.77, 0.59 and 0.51,
* on a tolerance of 1 per cent Callisto and Rhea are left there, on the node's angle alone and not
* its harmonics Triton is 0.29, and on the archived table's node periods Callisto is 0.56 and
* Miranda 0.42: all under the general ceiling.
*/
const MAX_AXIS_FROM_ORBIT_DEG = 1;
const AXIS_FROM_ORBIT_CEILINGS_DEG: Record<string, number> = { moon: 7.1, phobos: 2, deimos: 2, proteus: 1.2 };
const AXIS_FROM_ORBIT_CEILINGS_DEG: Record<string, number> = {
moon: 7.1,
phobos: 2,
deimos: 2,
proteus: 1.2,
europa: 0.25,
ganymede: 0.25,
callisto: 0.25,
rhea: 0.25,
miranda: 0.25,
triton: 0.25
};
/** The clock's window, AD 1 to 3000 (`CLOCK_WINDOW` in `time.store.ts`), as Julian dates. */
const CLOCK_START_JD = Date.parse('0001-01-01T00:00Z') / 86400000 + 2440587.5;
const CLOCK_END_JD = Date.parse('3000-01-01T00:00Z') / 86400000 + 2440587.5;
+43 -6
View File
@@ -13,6 +13,7 @@ import { lockedToOrbit } from './lib/locked-spin';
import { dataPath, ensureDataDir } from './lib/paths';
const HOURS_PER_DAY = 24;
const DAYS_PER_JULIAN_YEAR = 365.25;
interface BodySpec {
id: string;
@@ -51,6 +52,25 @@ interface BodySpec {
nodeOffsetDeg?: number;
epochJd?: number;
periodDays?: number;
/**
* The node's period, in Julian years, where the row's is out of date. The archived table the rows
* are read from (see `fetchSatelliteMeanElementsHtml`) has older node periods for four moons than
* JPL's current one (ssd.jpl.nasa.gov/sats/elem), and Horizons and the IAU agree with the current
* ones: Miranda 17.787 years there (URA182) against the row's 17.727, Ganymede 137.812 (JUP365)
* against 132.654, Callisto 577.264 against 338.82, Titan 687.370 (SAT441) against 704.60. Fitted
* to Horizons' osculating elements on Uranus's equator over 1601-2399, Miranda's node turns
* 2023.97 degrees a century (rms 0.04): the current table's 2023.95, the IAU's U11 2024.22, the
* row's 2030.80. On the row's rate Miranda's drawn orbit was 2.0 degrees from Horizons' at 1601
* and 2.1 at 2399, and the axis `lockedToOrbit` turned after it 2.4 at 1601; on this one, at most 0.12 and 0.34 over 1601-2399.
*
* The row's periapsis turns at its argument's rate from that node, and it is the longitude, node
* plus argument, the row gives the rate of: Callisto's turns 68.7 degrees a century in the row and
* 67.2 in the current table, where their arguments turn at 175.0 and 129.5. So the argument takes
* up what the node's rate gives: on the new node and the row's argument, Callisto's periapsis
* moved 44 degrees by 2100 and Callisto strayed 0.71 degrees from Horizons over 1950-2100 (0.19
* before); keeping the row's longitude, 0.08.
*/
nodePeriodYears?: number;
/**
* The terms of the IAU's W that are this locked moon's motion along its orbit, which its row has
* no column for: W's quadratic, and the term whose angle turns at `angleRateDegPerCentury`, if
@@ -72,6 +92,14 @@ interface BodySpec {
/** S5 in pck00011.tpc, 316.45 + 506.2 T: the libration of Mimas and Tethys in their 4:2 resonance. */
const MIMAS_TETHYS_LIBRATION = { angleRateDegPerCentury: 506.2 };
/**
* Mimas's node period, 0.986 years in both JPL's tables, is given to three figures: anywhere from
* 36 493 to 36 530 degrees a century. It takes the IAU's S3, 36 505.5, which a fit to Horizons'
* osculating elements on Saturn's equator over 1750-2249, 36 506.7, is 1.2 from; the row's figure,
* 36 511.2, is 4.5.
*/
const MIMAS_NODE_PERIOD_YEARS = 36000 / 36505.5;
/**
* The poles of the equators JPL refers Uranus's and Pluto's moons to, from the IAU WGCCRE 2015
* report, each taken at the end the table's inclinations are measured from (Titania 0.079
@@ -125,14 +153,14 @@ const BODY_SPECS: BodySpec[] = [
{ 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', 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: 'mimas', name: 'Mimas', kind: 'moon', horizonsCommand: '601', center: '500@699', parentBodyId: 'saturn', orbitFromW: MIMAS_TETHYS_LIBRATION },
{ id: 'ganymede', name: 'Ganymede', kind: 'moon', horizonsCommand: '503', center: '500@599', parentBodyId: 'jupiter', nodePeriodYears: 137.812 },
{ id: 'callisto', name: 'Callisto', kind: 'moon', horizonsCommand: '504', center: '500@599', parentBodyId: 'jupiter', nodePeriodYears: 577.264 },
{ id: 'mimas', name: 'Mimas', kind: 'moon', horizonsCommand: '601', center: '500@699', parentBodyId: 'saturn', orbitFromW: MIMAS_TETHYS_LIBRATION, nodePeriodYears: MIMAS_NODE_PERIOD_YEARS },
{ id: 'enceladus', name: 'Enceladus', kind: 'moon', horizonsCommand: '602', center: '500@699', parentBodyId: 'saturn' },
{ id: 'tethys', name: 'Tethys', kind: 'moon', horizonsCommand: '603', center: '500@699', parentBodyId: 'saturn', orbitFromW: MIMAS_TETHYS_LIBRATION },
{ id: 'dione', name: 'Dione', kind: 'moon', horizonsCommand: '604', center: '500@699', parentBodyId: 'saturn' },
{ id: 'rhea', name: 'Rhea', kind: 'moon', horizonsCommand: '605', center: '500@699', parentBodyId: 'saturn' },
{ id: 'titan', name: 'Titan', kind: 'moon', horizonsCommand: '606', center: '500@699', parentBodyId: 'saturn' },
{ id: 'titan', name: 'Titan', kind: 'moon', horizonsCommand: '606', center: '500@699', parentBodyId: 'saturn', nodePeriodYears: 687.37 },
// Hyperion tumbles ("Rotational period = Chaotic") and Phoebe, captured, turns in 9.27 hours.
// Hyperion's eccentricity is 0.105 in JPL's current table (ssd.jpl.nasa.gov/sats/elem, SAT441).
{ id: 'hyperion', name: 'Hyperion', kind: 'moon', horizonsCommand: '607', center: '500@699', parentBodyId: 'saturn', spinsFreely: true, measuredEccentricity: 0.105, trackStepDays: 1 },
@@ -147,7 +175,7 @@ const BODY_SPECS: BodySpec[] = [
// (2.58 in 1969). The table's note on misstated retrograde mean motions is about another source,
// Jacobson 2000 on Jupiter's outer moons, and says the table carries the corrected values.
{ id: 'phoebe', name: 'Phoebe', kind: 'moon', horizonsCommand: '609', center: '500@699', parentBodyId: 'saturn', spinsFreely: true, periodDays: 550.30391 },
{ id: 'miranda', name: 'Miranda', horizonsCommand: '705', ...URANUS_MOON },
{ id: 'miranda', name: 'Miranda', horizonsCommand: '705', ...URANUS_MOON, nodePeriodYears: 17.787 },
{ id: 'ariel', name: 'Ariel', horizonsCommand: '701', ...URANUS_MOON },
{ id: 'umbriel', name: 'Umbriel', horizonsCommand: '702', ...URANUS_MOON },
{ id: 'titania', name: 'Titania', horizonsCommand: '703', ...URANUS_MOON },
@@ -215,6 +243,9 @@ export async function fetchSolarSystem(): Promise<{ bodies: BodyRecord[]; horizo
(parentName
? parseSatelliteMeanElements(satelliteElements, parentName, spec.name, spec.apsidesRegress ?? false, spec.equatorPole)
: parsePlanetMeanElements(planetElements, spec.id));
const nodeRate = spec.nodePeriodYears
? Math.sign(read.rates.longitudeOfAscendingNodeDegPerDay) * (360 / (spec.nodePeriodYears * DAYS_PER_JULIAN_YEAR))
: read.rates.longitudeOfAscendingNodeDegPerDay;
const corrected: MeanOrbit = {
...read,
orbit: {
@@ -222,7 +253,13 @@ export async function fetchSolarSystem(): Promise<{ bodies: BodyRecord[]; horizo
longitudeOfAscendingNodeDeg: read.orbit.longitudeOfAscendingNodeDeg + (spec.nodeOffsetDeg ?? 0),
epochJd: spec.epochJd ?? read.orbit.epochJd
},
rates: spec.periodDays ? { ...read.rates, meanMotionDegPerDay: 360 / spec.periodDays } : read.rates
rates: {
...read.rates,
...(spec.periodDays ? { meanMotionDegPerDay: 360 / spec.periodDays } : {}),
longitudeOfAscendingNodeDegPerDay: nodeRate,
// The periapsis's longitude keeps the row's rate; see `nodePeriodYears`.
argumentOfPeriapsisDegPerDay: read.rates.argumentOfPeriapsisDegPerDay + (read.rates.longitudeOfAscendingNodeDegPerDay - nodeRate)
}
};
if (spec.orbitFromW && !rotation) {
throw new Error(`${spec.name}'s orbit takes terms from a W the kernel does not give.`);
+16 -10
View File
@@ -23,8 +23,8 @@ const POLE_HARMONICS = 5;
/**
* How far a periodic term's angle may turn from a multiple of the node's rate, as a fraction of it,
* and still be taken for the node's angle as the IAU's source had it. Measured: at most 3.4e-2
* (Ganymede's J5), then Rhea's R4 1.2e-2 and Miranda's U11 3.2e-3; the nearest that is not a node is
* and still be taken for the node's angle as the IAU's source had it. Measured: at most 3.1e-2
* (Callisto's J6), then Rhea's R4 1.2e-2 and Ganymede's J5 3.3e-3; the nearest that is not a node is
* a term of Miranda's W alone, 6.0e-2 from three times it. Multiples go up to the ninth, the most the
* report takes (Triton's N7); past that, Umbriel's W has a term 1.0e-2 from ten times its node's.
*/
@@ -58,14 +58,20 @@ export function subPlanetLongitudeDeg(body: Pick<BodyRecord, 'orbit' | 'rates' |
*
* The node's angle goes the same way. A moon in a Cassini state keeps its axis on its orbit normal,
* which goes round the Laplace pole with the node, and the IAU's pole goes round with it on a term
* of the node's angle, at the node's rate as its source had it: Miranda's U11 at -2024.22 degrees a
* century, where the table the orbit is drawn from has -2030.80. On a 4.3-degree circle that parted
* the axis from the drawn orbit by 7.9 degrees at AD 1, and Mimas's by 2.6. Every term whose angle
* turns within {@link MAX_NODE_RATE_OFFSET} of a multiple of the node's rate is set to that multiple,
* its constant moved so the angle is unchanged at the present, and the pole with it: over AD 1-3000
* Miranda's axis stays within 0.42 degrees of its orbit normal and Mimas's within 0.47, and the
* Io's, Europa's, Ganymede's, Rhea's and Triton's within 0.17. The rest of the pole and its terms
* are the IAU's: Callisto's J6 turns 40 per cent slower than its node, and is left.
* of the node's angle, at the node's rate as its source had it, not quite JPL's current one the
* orbit is drawn at (see `nodePeriodYears` in `fetchSolarSystem.ts`): Rhea's R4 turns 1.2 per cent
* faster than its node, Callisto's J6 3.1 per cent, and Miranda's U11 0.013 per cent, which on a
* 4.4-degree circle over twenty centuries still adds up. On the IAU's rates the axes part from the
* drawn orbits by AD 1 or 3000: Rhea's by 0.77 degrees, Miranda's 0.59, Triton's 0.51, Europa's and
* Callisto's 0.33. Every term whose angle turns within {@link MAX_NODE_RATE_OFFSET} of a multiple of
* the node's rate is set to that multiple, its constant moved so the angle is unchanged at the
* present, and the pole with it: over AD 1-3000 the axes of Io, Europa, Ganymede, Callisto, Rhea,
* Miranda and Triton stay within 0.23 degrees of their orbit normals, and Mimas's, whose drawn node
* takes the IAU's S3 itself, within 0.44. The rest of the pole and its terms are the IAU's, and so
* are all of the Moon's and Phobos's, left whole with their W: Ariel's, Umbriel's, Titania's and
* Oberon's poles go round on angles of their own at none of their nodes' multiples (Oberon's, the
* nearest, 8.7 per cent from three times its node's rate), and their axes stay within 0.50 degrees
* of their orbit normals without.
*
* `poleFollowsOrbit` is for Iapetus, whose IAU pole moves 3.9 degrees a century in right ascension
* and 1.1 in declination: a straight line through its orbit normal's 3 439-year circle round the