The IAU gives a locked moon's W the mean motion of whichever orbit its authors had, and JPL's table has another. Near the present the difference is nothing; over the clock's AD 1 to 3000 it turned Proteus's far side to Neptune at AD 1 (146 degrees), Mimas 52 degrees from Saturn and Miranda 23. Iapetus was worse for another reason: its IAU pole is a straight line, 3.9 degrees a century in right ascension, through its orbit normal's 3 439-year circle round the Laplace pole, which by AD 1 has run past the celestial pole (Dec 97.9), 11 degrees off the orbit, with the face 87 degrees from Saturn. Mimas and Iapetus carry mission maps, so a wrong hemisphere was drawn facing Saturn. The ETL's lock check sampled only 1950-2100, so none of it failed. tools/etl/lib/locked-spin.ts, lockedToOrbit, called for every locked moon: - W's rate becomes the orbit's own mean motion, its constant moved so W is unchanged on 2025-01-01; the pole and every periodic term stay the IAU's. A W with a quadratic is left (Phobos's orbit already takes it; the Moon's is its tidal slowing, 0.75 degrees at AD 1). The kernel's rate must be within 1e-5 of the orbit's first (at most 3.4e-6, Iapetus). - Iapetus (poleFollowsOrbit): the pole follows its orbit normal, as a moon in a Cassini state does, in the IAU's own form: sines of the node's angle and four harmonics on right ascension, cosines on declination, fitted to the normal's circle and pinned to the IAU pole at the present; W takes sines of the same angles, fitted to hold the face where it is today. build.ts samples the lock over AD 1 to 3000 (8 114 dates, every 135 days) instead of 1950-2100, and subPlanetLongitudeDeg moved to the lib, shared by both. Measured on the real catalogue: at most 5.36 degrees (Titan) but the Moon 7.62 (its eccentricity, and W's quadratic at AD 1: a new named ceiling of 8), Mimas 8.94 (ceiling 11 -> 9.5) and Iapetus 15.95 (19 -> 16.5, 9.4 of it its row's lag); Proteus's own ceiling of 9 is gone, at 2.66. Iapetus's axis stays within 0.74 degrees of its orbit normal (11.06 before) and its pole is the IAU's at the present to 1e-4 degrees. Live on :4301, the longitude facing the planet at AD 1 / 1000 / 2025 / 2999: Proteus 2.6 / 2.6 / 2.6 / 2.6 (was -146.5 / -72.9 / 2.6 / 74.5), Iapetus -15.9 / -15.4 / -15.3 / -9.3 (-87.0 / -50.9 / -15.3 / 22.8), Mimas 4.1 / 6.8 / 5.7 / 8.4 (48.6 / 29.3 / 5.7 / -13.0), Miranda -0.1 / 2.2 / 0.0 / 1.4 (-23.4 / -9.6 / 0.0 / 12.6); the present is unchanged. The renderer spec now takes the rotational elements from bodies.json too, so no hand copy is left, and a new test turns Proteus, Miranda, Mimas and Iapetus to their planets at AD 1 and AD 3000. Guarded mutants, each run through the solar ETL and then the full suite on what it wrote: lockedToOrbit bypassed (validator: Mimas 52.30, ceiling 9.5; the new test fails), Iapetus on the IAU's straight pole (98.48), and its pole round the orbit without W's terms (73.13); each fails the new test and only it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
142 lines
8.8 KiB
TypeScript
142 lines
8.8 KiB
TypeScript
import { eclipticToEquatorial, laplacePlaneToEquatorial } from '../../../src/app/shared/astro/coordinates';
|
|
import { meanElementsAt, positionAtEpoch } from '../../../src/app/shared/astro/kepler';
|
|
import { MeanOrbit } from '../../../src/app/shared/astro/mean-elements';
|
|
import { orientationAt } from '../../../src/app/shared/astro/rotational-elements';
|
|
import { BodyRecord, RotationalElements } from '../../../src/app/shared/models/body.model';
|
|
|
|
const J2000_JD = 2451545;
|
|
const DAYS_PER_JULIAN_CENTURY = 36525;
|
|
const DEG_TO_RAD = Math.PI / 180;
|
|
|
|
/** 2025-01-01, the date the ETL asks Horizons about: where a locked moon's W is left as the IAU has it. */
|
|
export const PRESENT_JD = 2460676.5;
|
|
|
|
/**
|
|
* How far the IAU's W rate for a locked moon may be from the mean motion its orbit is drawn at, as
|
|
* a fraction of it, before it is taken for the orbit's. Measured: at most 3.4e-6 (Iapetus; Proteus
|
|
* 6.3e-7). What this catches is a rate read for the wrong body: Oberon's for Titania's is 55 per cent out.
|
|
*/
|
|
const MAX_LOCKED_RATE_OFFSET = 1e-5;
|
|
|
|
/** Harmonics of the node's angle that carry a pole round its orbit's; see {@link lockedToOrbit}. */
|
|
const POLE_HARMONICS = 5;
|
|
|
|
/** The planet's east longitude on a moon's IAU body-fixed frame, from the moon's mean place, at a TDB date. */
|
|
export function subPlanetLongitudeDeg(body: Pick<BodyRecord, 'orbit' | 'rates' | 'laplacePole'>, elements: RotationalElements, jd: number): number {
|
|
const own = positionAtEpoch(meanElementsAt(body.orbit, body.rates, jd));
|
|
const place = body.laplacePole ? laplacePlaneToEquatorial(own, body.laplacePole) : eclipticToEquatorial(own);
|
|
const { poleRaDeg, poleDecDeg, primeMeridianDeg } = orientationAt(elements, jd);
|
|
const pole = { raDeg: poleRaDeg, decDeg: poleDecDeg };
|
|
const w = primeMeridianDeg * DEG_TO_RAD;
|
|
const meridian = laplacePlaneToEquatorial({ x: Math.cos(w), y: Math.sin(w), z: 0 }, pole);
|
|
const east = laplacePlaneToEquatorial({ x: -Math.sin(w), y: Math.cos(w), z: 0 }, pole);
|
|
const along = (axis: { x: number; y: number; z: number }) => -(place.x * axis.x + place.y * axis.y + place.z * axis.z);
|
|
return Math.atan2(along(east), along(meridian)) / DEG_TO_RAD;
|
|
}
|
|
|
|
/**
|
|
* A locked moon's IAU elements, turned at the rate its orbit is drawn at, so it keeps its face to
|
|
* its planet over the clock's AD 1 to 3000 and not only near the present its W was fitted to.
|
|
*
|
|
* The report gives a locked moon's W the mean motion of whichever orbit its authors had, and JPL's
|
|
* table has another: Proteus's W turns 6.3e-7 of its rate slower than its row, which turned its far
|
|
* side to Neptune at AD 1 (146 degrees), Mimas's 1.6e-7 faster (52 at AD 1) and Miranda's (23).
|
|
* W's rate is set to the orbit's here, its constant moved so W is unchanged at {@link PRESENT_JD},
|
|
* and the pole and every periodic term are the IAU's. Measured over AD 1-3000: Proteus 2.7 degrees,
|
|
* Mimas 8.9, Miranda 2.8, Ariel 1.0. A W with a quadratic is left: Phobos's orbit already takes the
|
|
* quadratic from W (see `orbitalTermsOfPrimeMeridian`), and the Moon's, its tidal slowing, is 0.75
|
|
* degrees at AD 1.
|
|
*
|
|
* `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
|
|
* Laplace pole, 8.3 degrees across, which by AD 1 has run past the celestial pole (Dec 97.9) and 11
|
|
* degrees off the orbit, and turned its face 87 degrees from Saturn. Its axis sits on its orbit normal
|
|
* (0.04 degrees apart today), as a moon in a Cassini state keeps it, so its pole is given the circle: the
|
|
* normal's right ascension as sines and declination as cosines of the node's angle and its first
|
|
* {@link POLE_HARMONICS} harmonics, the IAU's own form for a precessing pole, with its constants
|
|
* set so the pole is the IAU's at the present. W counts from where the equator crosses the ICRF
|
|
* equator, which swings as the pole goes round, so W takes sines of the same angles, fitted to hold
|
|
* the face where it is today. Measured over AD 1-3000: the axis within 0.74 degrees of the orbit
|
|
* normal (the IAU's line, 11.06), and the face within 16 of Saturn (87), which is what the row's own
|
|
* 9.4-degree lag and its eccentricity make it from 1950 to 2100 as well (15.9).
|
|
*/
|
|
export function lockedToOrbit(elements: RotationalElements, mean: Pick<MeanOrbit, 'orbit' | 'rates' | 'laplacePole'>, name: string, poleFollowsOrbit = false): RotationalElements {
|
|
const [w0, w1, w2 = 0] = elements.primeMeridianDeg;
|
|
if (w2 !== 0) {
|
|
return elements;
|
|
}
|
|
const n = Math.sign(w1) * mean.rates.meanMotionDegPerDay;
|
|
if (Math.abs(w1 / n - 1) > MAX_LOCKED_RATE_OFFSET) {
|
|
throw new Error(`${name}'s IAU W turns at ${w1} degrees a day, ${Math.abs(w1 / n - 1).toExponential(2)} of its orbit's ${n}: not the rate of the orbit it keeps its face to.`);
|
|
}
|
|
const locked: RotationalElements = { ...elements, primeMeridianDeg: [w0 + (w1 - n) * (PRESENT_JD - J2000_JD), n, 0] };
|
|
return poleFollowsOrbit ? poleRoundOrbit(locked, mean) : locked;
|
|
}
|
|
|
|
function poleRoundOrbit(elements: RotationalElements, mean: Pick<MeanOrbit, 'orbit' | 'rates' | 'laplacePole'>): RotationalElements {
|
|
if (!mean.laplacePole) {
|
|
throw new Error('A pole that follows its orbit is carried round the orbit\'s Laplace pole, and this orbit has none.');
|
|
}
|
|
const laplacePole = mean.laplacePole;
|
|
const normalAt = (jd: number) => {
|
|
const { inclinationDeg, longitudeOfAscendingNodeDeg } = meanElementsAt(mean.orbit, mean.rates, jd);
|
|
const tilt = inclinationDeg * DEG_TO_RAD;
|
|
const node = longitudeOfAscendingNodeDeg * DEG_TO_RAD;
|
|
const normal = laplacePlaneToEquatorial({ x: Math.sin(tilt) * Math.sin(node), y: -Math.sin(tilt) * Math.cos(node), z: Math.cos(tilt) }, laplacePole);
|
|
return { raDeg: Math.atan2(normal.y, normal.x) / DEG_TO_RAD, decDeg: Math.asin(normal.z) / DEG_TO_RAD };
|
|
};
|
|
// The node's angle, T in centuries, turned so that 0 is where the normal is furthest north: the
|
|
// circle is then even in declination and odd in right ascension about it, as the form requires.
|
|
const nodeRate = mean.rates.longitudeOfAscendingNodeDegPerDay * DAYS_PER_JULIAN_CENTURY;
|
|
const nodeAtJ2000 = meanElementsAt(mean.orbit, mean.rates, J2000_JD).longitudeOfAscendingNodeDeg;
|
|
const jdAtAngle = (angleDeg: number, phaseDeg: number) => J2000_JD + ((angleDeg - phaseDeg - nodeAtJ2000) / nodeRate) * DAYS_PER_JULIAN_CENTURY;
|
|
let phase = 0;
|
|
let northmost = -Infinity;
|
|
for (let candidate = 0; candidate < 360; candidate += 0.01) {
|
|
const dec = normalAt(jdAtAngle(0, candidate)).decDeg;
|
|
if (dec > northmost) {
|
|
northmost = dec;
|
|
phase = candidate;
|
|
}
|
|
}
|
|
const centre = laplacePole;
|
|
const samples = 3600;
|
|
const ra = new Array<number>(POLE_HARMONICS + 1).fill(0);
|
|
const dec = new Array<number>(POLE_HARMONICS + 1).fill(0);
|
|
for (let sample = 0; sample < samples; sample++) {
|
|
const angle = (sample / samples) * 360;
|
|
const normal = normalAt(jdAtAngle(angle, phase));
|
|
const raOffset = ((((normal.raDeg - centre.raDeg) % 360) + 540) % 360) - 180;
|
|
for (let k = 0; k <= POLE_HARMONICS; k++) {
|
|
ra[k] += (2 / samples) * raOffset * Math.sin(k * angle * DEG_TO_RAD);
|
|
dec[k] += ((k === 0 ? 1 : 2) / samples) * (normal.decDeg - centre.decDeg) * Math.cos(k * angle * DEG_TO_RAD);
|
|
}
|
|
}
|
|
const terms = Array.from({ length: POLE_HARMONICS }, (_, index) => {
|
|
const k = index + 1;
|
|
return { angleDeg: [k * (nodeAtJ2000 + phase), k * nodeRate], ra: ra[k], dec: dec[k], pm: 0 };
|
|
});
|
|
const round: RotationalElements = { ...elements, poleRaDeg: [centre.raDeg, 0, 0], poleDecDeg: [centre.decDeg + dec[0], 0, 0], terms: [...(elements.terms ?? []), ...terms] };
|
|
// The IAU's pole at the present, exactly: the fitted circle's constants moved onto it.
|
|
const iau = orientationAt(elements, PRESENT_JD);
|
|
const fitted = orientationAt(round, PRESENT_JD);
|
|
round.poleRaDeg = [round.poleRaDeg[0] + iau.poleRaDeg - fitted.poleRaDeg, 0, 0];
|
|
round.poleDecDeg = [round.poleDecDeg[0] + iau.poleDecDeg - fitted.poleDecDeg, 0, 0];
|
|
// W's sines on the same angles, fitted over a turn of the node to what the face drifts by.
|
|
const pm = new Array<number>(POLE_HARMONICS + 1).fill(0);
|
|
const wSamples = 36000;
|
|
for (let sample = 0; sample < wSamples; sample++) {
|
|
const angle = (sample / wSamples) * 360;
|
|
const drift = subPlanetLongitudeDeg(mean, round, jdAtAngle(angle, phase));
|
|
for (let k = 1; k <= POLE_HARMONICS; k++) {
|
|
pm[k] += (2 / wSamples) * drift * Math.sin(k * angle * DEG_TO_RAD);
|
|
}
|
|
}
|
|
const ownTerms = elements.terms?.length ?? 0;
|
|
const turned: RotationalElements = { ...round, terms: round.terms!.map((term, index) => (index < ownTerms ? term : { ...term, pm: pm[index - ownTerms + 1] })) };
|
|
// And W the IAU's at the present.
|
|
const shift = orientationAt(turned, PRESENT_JD).primeMeridianDeg - orientationAt(elements, PRESENT_JD).primeMeridianDeg;
|
|
turned.primeMeridianDeg = [turned.primeMeridianDeg[0] - shift, turned.primeMeridianDeg[1], 0];
|
|
return turned;
|
|
}
|