Take TT - UT from the historical record before 1972, so the far dates the clock reaches turn every body by the right amount

The clock reaches AD 1, but TT - UT was held at today's 69.184 s. At AD 1000 it was 1 574 s and at
AD 1 about 10 570 (Espenak and Meeus, NASA's Five Millennium Canon; Horizons' TDB - UT gives 1 658
and 10 466 on JD 2086455 and 1721600). So every spin but Earth's was (ΔT - 69 s) times its rate
out, Jupiter 15.2 degrees at AD 1000 and 106 at AD 1, Mars 6 and 43, and every orbit that much
behind: the Moon about 0.2 and 1.4 degrees.

ttMinusUtSeconds gives TT - UT for a date on the clock: the Espenak-Meeus polynomials before 1972,
32.184 s plus UTC's leap seconds from 1972 to the last one, at the start of 2017, and 69.184 s
held after it, as Horizons holds it. Its pieces join within 0.1 s. tdbFromUtc, which positions
and spins already share, now adds it. Within 0.2 s of Horizons in 1950, 105 s at AD 1 and 86 s
at AD 1000, where the historical record itself is that uncertain.

Earth is the exception: its turning is what UT counts, so the clock's date already says how far it
has turned, and ΔT would turn it again, 44 degrees at AD 1. Its W, fitted to today, keeps today's
69.184 s (bodyOrientation's followsUt, set for Earth in the system view and on its page).

In the running app at 1000-01-01 00:00 UT, Jupiter's drawn prime meridian sits 0.000 degrees from
its IAU W at TT and 15.164 from where the held offset put it; Earth's sits on its W at UT + 69.184 s,
6.288 degrees short of what TT would have turned it to.

The renderer spec now hands its frozen Horizons vectors over as the UT dates that name them
through the same TT - UT, and checks Jupiter's and Earth's prime meridians at AD 1000.

Controls: the leap-second rule used before 1972 fails "follows the historical record before 1972";
TT - UT held at 69 s fails "turns Jupiter at AD 1000 by its W"; Earth turned at TDB, or the renderer
or the page not keeping it on UT, fails the Earth tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 19:03:26 +02:00
co-authored by Claude Opus 5.5
parent d4808788ec
commit 34ae803c06
6 changed files with 167 additions and 30 deletions
@@ -1,13 +1,17 @@
import * as THREE from 'three/webgpu';
import { describe, expect, it } from 'vitest';
import { DEFAULT_EPOCH_JD, GM_SUN_AU3_PER_DAY2, TT_MINUS_UTC_DAYS } from '../../shared/astro/constants';
import { DEFAULT_EPOCH_JD, GM_SUN_AU3_PER_DAY2, TT_MINUS_UTC_DAYS, ttMinusUtSeconds } from '../../shared/astro/constants';
import { keplerRates } from '../../shared/astro/kepler';
import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
import { eclipticToEquatorial, laplacePlaneToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
import { orientationAt } from '../../shared/astro/rotational-elements';
import { BodyRecord, RotationalElements } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { SystemOrbitsRenderer } from './system-orbits-renderer';
/** The clock's UT date that names a TDB one: TT - UT, which moves by under a second a year, earlier. */
const utOf = (jdTdb: number): number => jdTdb - ttMinusUtSeconds(jdTdb) / 86400;
/** TRAPPIST-1 b: a real short-period planet around a 0.09 solar-mass red dwarf. */
const TRAPPIST_1B_SEMI_MAJOR_AXIS_AU = 0.01154;
const TRAPPIST_1B_PERIOD_DAYS = 1.51088;
@@ -203,8 +207,8 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => {
// A body at ecliptic longitude 0 sits on the +X axis in both frames, so it must not move.
const atEquinox: BodyRecord = { ...EARTH, orbit: { ...EARTH.orbit, eccentricity: 0 } };
const renderer = new SystemOrbitsRenderer([atEquinox], []);
// The clock's UTC date whose TDB is the elements' epoch.
renderer.update(DEFAULT_EPOCH_JD - TT_MINUS_UTC_DAYS);
// The clock's UT date whose TDB is the elements' epoch.
renderer.update(utOf(DEFAULT_EPOCH_JD));
const p = renderer.members[0].marker.position;
expect(p.x).toBeCloseTo(1, 6);
@@ -491,7 +495,7 @@ describe('solar-system bodies against Horizons', () => {
// for the planets, planet-centred for the moons) at dates across 1950-2100, so the whole path —
// mean elements, their rates, the Laplace planes and the scene's frame — is checked against
// JPL's ephemeris rather than against itself. Horizons' dates are TDB and the renderer's are the
// clock's UTC, so each is handed over 69.184 s earlier.
// clock's UT, so each is handed over TT - UT earlier: 69.184 s today, 29 in 1950.
const RECORDS: Record<string, Pick<BodyRecord, 'kind' | 'orbit' | 'rates' | 'laplacePole' | 'parentBodyId' | 'massRatio'>> = {
earth: {kind: 'planet', orbit: {semiMajorAxisAu: 1.00000018, eccentricity: 0.01673163, inclinationDeg: -0.00054346, longitudeOfAscendingNodeDeg: -5.11260389, argumentOfPeriapsisDeg: 108.04266274, meanAnomalyAtEpochDeg: -2.4631431299999917, epochJd: 2451545}, rates: {meanMotionDegPerDay: 0.9856091187759068, longitudeOfAscendingNodeDegPerDay: -0.000006604751813826146, argumentOfPeriapsisDegPerDay: 0.000015309819575633124, semiMajorAxisAuPerDay: -8.213552361396303e-13, eccentricityPerDay: -1.002327173169062e-9, inclinationDegPerDay: -3.6609938398357287e-7}},
jupiter: {kind: 'planet', orbit: {semiMajorAxisAu: 5.20248019, eccentricity: 0.0485359, inclinationDeg: 1.29861416, longitudeOfAscendingNodeDeg: 100.29282654, argumentOfPeriapsisDeg: -86.0178741, meanAnomalyAtEpochDeg: 20.059839080000003, epochJd: 2451545}, rates: {meanMotionDegPerDay: 0.08309113532019165, longitudeOfAscendingNodeDegPerDay: 0.0000035659463381245725, argumentOfPeriapsisDegPerDay: 0.0000014167219712525667, semiMajorAxisAuPerDay: -7.841204654346339e-10, eccentricityPerDay: 4.935249828884326e-9, inclinationDegPerDay: -8.83501711156742e-8, meanAnomalyTerms: {b: -0.00012452, c: 0.0606406, s: -0.35635438, f: 38.35125}}},
@@ -555,7 +559,7 @@ describe('solar-system bodies against Horizons', () => {
for (const [id, jd, x, y, z, maxDeg] of HORIZONS) {
it(`puts ${id} within ${maxDeg} degrees of Horizons on JD ${jd}`, () => {
renderer.update(jd - TT_MINUS_UTC_DAYS);
renderer.update(utOf(jd));
const drawn = renderer.members.find((member) => member.id === id)!.marker.position;
const angleDeg = (drawn.angleTo(new THREE.Vector3(x, y, z)) * 180) / Math.PI;
expect(angleDeg).toBeLessThan(maxDeg);
@@ -565,7 +569,7 @@ describe('solar-system bodies against Horizons', () => {
it('puts Pluto where Horizons has it round its barycentre with Charon, 2 131 km out and opposite Charon', () => {
// Horizons, Pluto (999) from the Pluto-system barycentre (9), on JD 2488069.5 TDB (2100).
const horizons = new THREE.Vector3(0.000003313612032581019, 0.000001023040948538272, -0.00001381793390079716);
renderer.update(2488069.5 - TT_MINUS_UTC_DAYS);
renderer.update(utOf(2488069.5));
const charon = renderer.members.find((member) => member.id === 'charon')!.marker;
const barycentre = charon.parent!.position;
const pluto = renderer.members.find((member) => member.id === 'pluto')!.marker.position.clone().sub(barycentre);
@@ -656,6 +660,33 @@ describe('solar-system bodies against Horizons', () => {
/** Degrees between two longitudes, the short way round. */
const apart = (a: number, b: number): number => Math.abs(((((a - b) % 360) + 540) % 360) - 180);
/** Where the IAU puts a body's prime meridian at a TDB date, in the scene. */
function iauPrimeMeridian(id: string, jdTdb: number): THREE.Vector3 {
const { poleRaDeg, poleDecDeg, primeMeridianDeg } = orientationAt(ROTATION[id], jdTdb);
const w = (primeMeridianDeg * Math.PI) / 180;
const meridian = laplacePlaneToEquatorial({ x: Math.cos(w), y: Math.sin(w), z: 0 }, { raDeg: poleRaDeg, decDeg: poleDecDeg });
return new THREE.Vector3(meridian.x, meridian.y, meridian.z);
}
/** The drawn sphere's longitude 0 on its equator: +X of the sphere as `SphereGeometry` wraps its map. */
function drawnPrimeMeridian(id: string): THREE.Vector3 {
return new THREE.Vector3(1, 0, 0).applyQuaternion(renderer.members.find((member) => member.id === id)!.marker.quaternion);
}
it('turns Jupiter at AD 1000 by its W at that date’s TT, 1 574 s after the UT the clock names', () => {
// Espenak and Meeus's ΔT for JD 2086307.5, 1 January 1000 in the Julian calendar, where TT - UT was 23 times what it is today: held
// at today's 69 s, Jupiter was drawn 15 degrees short of its W.
const jdUt = 2086307.5;
renderer.update(jdUt);
expect((drawnPrimeMeridian('jupiter').angleTo(iauPrimeMeridian('jupiter', jdUt + 1574.1 / 86400)) * 180) / Math.PI).toBeLessThan(0.01);
});
it('turns Earth by the UT the clock names, which is its turning: at AD 1000 its W is not moved on by ΔT', () => {
const jdUt = 2086307.5;
renderer.update(jdUt);
expect((drawnPrimeMeridian('earth').angleTo(iauPrimeMeridian('earth', jdUt + TT_MINUS_UTC_DAYS)) * 180) / Math.PI).toBeLessThan(0.01);
});
it('lights Earth where the Sun really stands: within 4 degrees of Greenwich at noon UTC', () => {
// The equation of time is all that separates them: on 1 June 2025 it puts the Sun over 0.53 W,
// and the drawn sphere has it over 0.43 W.