Files
star-map/src/app/shared/astro/kepler.ts
T
SenrokaiandClaude Opus 5.5 48319c3fe2 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>
2026-09-24 20:18:57 +02:00

283 lines
13 KiB
TypeScript

import { CartesianCoordinates } from './coordinates';
import { DEFAULT_EPOCH_JD, GM_SUN_AU3_PER_DAY2 } from './constants';
import { MeanElementRates, OrbitalElements } from '../models/body.model';
const DEG_TO_RAD = Math.PI / 180;
const TWO_PI = Math.PI * 2;
const DAYS_PER_JULIAN_CENTURY = 36525;
/**
* Fills in the elements the Kepler propagator needs but that some sources (e.g. exoplanets,
* see `ExoplanetRecord.orbit: Partial<OrbitalElements>`) don't report: eccentricity,
* inclination, longitude of ascending node, mean anomaly at epoch, and the epoch itself.
* Missing angles default to zero (a face-on, unrotated ellipse) and the missing epoch defaults
* to J2000 — enough to draw a plausible, period-correct orbit even without full data.
*
* A missing eccentricity defaults to 0, a circle. That is the conventional assumption for an
* orbit whose shape has not been constrained, and it is also the only honest one available: the
* semi-major axis alone says nothing about elongation. It matters because the archive publishes
* an axis far more often than an eccentricity — 1509 exoplanets have the first without the
* second — and treating those as undrawable simply hid them.
*/
export function resolveOrbitalElements(partial: Partial<OrbitalElements> & Pick<OrbitalElements, 'semiMajorAxisAu'>): OrbitalElements {
return {
semiMajorAxisAu: partial.semiMajorAxisAu,
eccentricity: partial.eccentricity ?? 0,
inclinationDeg: partial.inclinationDeg ?? 0,
longitudeOfAscendingNodeDeg: partial.longitudeOfAscendingNodeDeg ?? 0,
argumentOfPeriapsisDeg: partial.argumentOfPeriapsisDeg ?? 0,
meanAnomalyAtEpochDeg: partial.meanAnomalyAtEpochDeg ?? 0,
epochJd: partial.epochJd ?? DEFAULT_EPOCH_JD
};
}
/**
* Whether a partially-specified orbit can actually be propagated as an ellipse.
*
* Everything downstream — the mean motion, the Kepler solver, the ellipse sampling — assumes a
* closed elliptical orbit around a positive semi-major axis. Feed it anything else and it does
* not throw: `sqrt` of a negative number and division by zero both yield `NaN`, which
* propagates silently into the vertex buffer and poisons the geometry's bounding sphere, taking
* out culling for the whole object rather than just the bad orbit.
*
* A missing eccentricity is fine and defaults to a circle (see {@link resolveOrbitalElements});
* a present but non-elliptical one (`e >= 1`, an escape trajectory) is not, since no ellipse
* describes it.
*/
export function isPropagatableOrbit(
partial: Partial<OrbitalElements>
): partial is Partial<OrbitalElements> & Pick<OrbitalElements, 'semiMajorAxisAu'> {
const { semiMajorAxisAu, eccentricity } = partial;
if (semiMajorAxisAu === undefined || !Number.isFinite(semiMajorAxisAu) || semiMajorAxisAu <= 0) {
return false;
}
if (eccentricity !== undefined && (!Number.isFinite(eccentricity) || eccentricity < 0 || eccentricity >= 1)) {
return false;
}
return true;
}
/** Mean motion (rad/day) of a body via Kepler's third law: n = sqrt(GM / a^3). */
export function meanMotionRadPerDay(semiMajorAxisAu: number, gmAu3PerDay2: number): number {
return Math.sqrt(gmAu3PerDay2 / (semiMajorAxisAu * semiMajorAxisAu * semiMajorAxisAu));
}
/** Orbital period (days) of a body via Kepler's third law: T = 2*pi / n. */
export function orbitalPeriodDays(semiMajorAxisAu: number, gmAu3PerDay2: number): number {
return TWO_PI / meanMotionRadPerDay(semiMajorAxisAu, gmAu3PerDay2);
}
/**
* Gravitational parameter implied by a measured orbital period — the inverse of
* {@link orbitalPeriodDays}: `GM = n^2 * a^3`, with `n = 2*pi / T`.
*
* This is how an exoplanet's host star gets its mass into the propagator. Nothing about the
* star needs to be known or guessed: the period and the semi-major axis between them pin the
* gravitational parameter exactly.
*/
export function gravitationalParameterFromPeriod(semiMajorAxisAu: number, periodDays: number): number {
const meanMotion = TWO_PI / periodDays;
return meanMotion * meanMotion * semiMajorAxisAu * semiMajorAxisAu * semiMajorAxisAu;
}
/**
* Plausible range for a host star's mass, in solar masses — from below the hydrogen-burning
* limit to beyond the heaviest known stars. Used only to reject a derived value that cannot be
* a star, which would otherwise send a planet spinning at a visibly absurd rate.
*/
const MIN_PLAUSIBLE_STELLAR_MASS_SOLAR = 0.01;
const MAX_PLAUSIBLE_STELLAR_MASS_SOLAR = 150;
function isPositiveFinite(value: number | undefined): value is number {
return value !== undefined && Number.isFinite(value) && value > 0;
}
/**
* The gravitational parameter to propagate a planet with, in AU^3/day^2, best source first:
*
* 1. **Its measured orbital period.** Exact, and independent of any stellar model.
* 2. **Its host star's measured mass.**
* 3. **One solar mass**, as a last resort.
*
* Falling back to the Sun is a real approximation, not a neutral default. Most exoplanet hosts
* are red dwarfs far lighter than the Sun, and a heavier central mass pulls harder and shortens
* the period, so assuming solar mass makes their planets whirl round far too fast. TRAPPIST-1
* is 0.09 solar masses; its planets were completing an orbit in roughly a third of the true
* time.
*/
export function resolveGravitationalParameter(input: {
semiMajorAxisAu: number;
periodDays?: number;
hostStarMassSolar?: number;
}): number {
const { semiMajorAxisAu, periodDays, hostStarMassSolar } = input;
if (isPositiveFinite(periodDays) && isPositiveFinite(semiMajorAxisAu)) {
const derived = gravitationalParameterFromPeriod(semiMajorAxisAu, periodDays);
const impliedMassSolar = derived / GM_SUN_AU3_PER_DAY2;
// A period and axis drawn from disagreeing solutions can imply something that is not a
// star; prefer a known-approximate answer over a confidently wrong one.
if (impliedMassSolar >= MIN_PLAUSIBLE_STELLAR_MASS_SOLAR && impliedMassSolar <= MAX_PLAUSIBLE_STELLAR_MASS_SOLAR) {
return derived;
}
}
if (
isPositiveFinite(hostStarMassSolar) &&
hostStarMassSolar >= MIN_PLAUSIBLE_STELLAR_MASS_SOLAR &&
hostStarMassSolar <= MAX_PLAUSIBLE_STELLAR_MASS_SOLAR
) {
return GM_SUN_AU3_PER_DAY2 * hostStarMassSolar;
}
return GM_SUN_AU3_PER_DAY2;
}
/** Normalizes an angle (radians) into [0, 2*pi). */
function normalizeAngle(angleRad: number): number {
const wrapped = angleRad % TWO_PI;
return wrapped < 0 ? wrapped + TWO_PI : wrapped;
}
/**
* Solves Kepler's equation `M = E - e*sin(E)` for the eccentric anomaly `E` (radians) via
* Newton-Raphson iteration.
*/
export function solveEccentricAnomaly(meanAnomalyRad: number, eccentricity: number, tolerance = 1e-8, maxIterations = 30): number {
const m = normalizeAngle(meanAnomalyRad);
let e = eccentricity < 0.8 ? m : Math.PI;
for (let i = 0; i < maxIterations; i++) {
const delta = (e - eccentricity * Math.sin(e) - m) / (1 - eccentricity * Math.cos(e));
e -= delta;
if (Math.abs(delta) < tolerance) {
break;
}
}
return e;
}
/** Converts an eccentric anomaly (radians) into the true anomaly (radians). */
export function trueAnomalyFromEccentricAnomaly(eccentricAnomalyRad: number, eccentricity: number): number {
const cosE = Math.cos(eccentricAnomalyRad);
const sinE = Math.sin(eccentricAnomalyRad);
return Math.atan2(Math.sqrt(1 - eccentricity * eccentricity) * sinE, cosE - eccentricity);
}
/**
* Places a point at the given true anomaly (radians) along the orbit described by
* `elements`, in AU, relative to the central body (the Sun for planets/dwarfs, the host
* planet for moons — see `BodyRecord.parentBodyId`). Standard perifocal-to-reference-frame
* rotation: argument of periapsis, then inclination, then longitude of ascending node.
*/
export function positionAtTrueAnomaly(elements: OrbitalElements, trueAnomalyRad: number): CartesianCoordinates {
const { semiMajorAxisAu: a, eccentricity: e } = elements;
const semiLatusRectum = a * (1 - e * e);
const radius = semiLatusRectum / (1 + e * Math.cos(trueAnomalyRad));
// Position in the perifocal (orbital-plane) frame: +x toward periapsis.
const xPerifocal = radius * Math.cos(trueAnomalyRad);
const yPerifocal = radius * Math.sin(trueAnomalyRad);
const omega = elements.argumentOfPeriapsisDeg * DEG_TO_RAD; // argument of periapsis
const inclination = elements.inclinationDeg * DEG_TO_RAD;
const raan = elements.longitudeOfAscendingNodeDeg * DEG_TO_RAD; // right ascension of ascending node
const cosOmega = Math.cos(omega);
const sinOmega = Math.sin(omega);
const cosInclination = Math.cos(inclination);
const sinInclination = Math.sin(inclination);
const cosRaan = Math.cos(raan);
const sinRaan = Math.sin(raan);
// Rotate by argument of periapsis within the orbital plane first.
const xOrbitPlane = xPerifocal * cosOmega - yPerifocal * sinOmega;
const yOrbitPlane = xPerifocal * sinOmega + yPerifocal * cosOmega;
// Tilt by inclination, then rotate by the longitude of the ascending node.
const xTilted = xOrbitPlane;
const yTilted = yOrbitPlane * cosInclination;
const zTilted = yOrbitPlane * sinInclination;
return {
x: xTilted * cosRaan - yTilted * sinRaan,
y: xTilted * sinRaan + yTilted * cosRaan,
z: zTilted
};
}
/**
* The rates of an orbit that only goes round: Kepler's mean motion from the central mass, with
* nothing turning. What an exoplanet has, since the archive publishes no precession.
*/
export function keplerRates(semiMajorAxisAu: number, gmAu3PerDay2: number): MeanElementRates {
return {
meanMotionDegPerDay: meanMotionRadPerDay(semiMajorAxisAu, gmAu3PerDay2) / DEG_TO_RAD,
longitudeOfAscendingNodeDegPerDay: 0,
argumentOfPeriapsisDegPerDay: 0
};
}
/**
* The elements at `epochJdEval`, each moved from its epoch at its own rate, and returned with that
* date as their epoch — so {@link positionAtEpoch} places the body, and the node and periapsis
* say where to draw the orbit it is on.
*
* The mean anomaly is what is left of the body's motion once the node and periapsis have turned:
* `meanMotionDegPerDay` is how fast it goes round in space, and a periapsis that has moved on is
* that much further to reach. On a retrograde orbit, past 90 degrees, the body runs against the
* direction the node is counted in, so the node's turning is added back rather than taken off.
* Taken off, Triton — whose node turns half a degree a year — drifted a degree a year from where
* Horizons has it, 105 degrees by 2100.
*/
export function meanElementsAt(elements: OrbitalElements, rates: MeanElementRates, epochJdEval: number): OrbitalElements {
const days = epochJdEval - elements.epochJd;
const node = rates.longitudeOfAscendingNodeDegPerDay * days;
const periapsis = rates.argumentOfPeriapsisDegPerDay * days;
const nodeAlongOrbit = elements.inclinationDeg > 90 ? -node : node;
const terms = rates.meanAnomalyTerms;
const centuries = days / DAYS_PER_JULIAN_CENTURY;
const extra = terms
? terms.b * centuries * centuries + terms.c * Math.cos(terms.f * centuries * DEG_TO_RAD) + terms.s * Math.sin(terms.f * centuries * DEG_TO_RAD)
: 0;
return {
semiMajorAxisAu: elements.semiMajorAxisAu + (rates.semiMajorAxisAuPerDay ?? 0) * days,
eccentricity: elements.eccentricity + (rates.eccentricityPerDay ?? 0) * days,
inclinationDeg: elements.inclinationDeg + (rates.inclinationDegPerDay ?? 0) * days,
longitudeOfAscendingNodeDeg: elements.longitudeOfAscendingNodeDeg + node,
argumentOfPeriapsisDeg: elements.argumentOfPeriapsisDeg + periapsis,
meanAnomalyAtEpochDeg: elements.meanAnomalyAtEpochDeg + rates.meanMotionDegPerDay * days - periapsis - nodeAlongOrbit + extra,
epochJd: epochJdEval
};
}
/** Where `elements` put the body at their own epoch (AU, relative to the central body). */
export function positionAtEpoch(elements: OrbitalElements): CartesianCoordinates {
const eccentricAnomalyRad = solveEccentricAnomaly(elements.meanAnomalyAtEpochDeg * DEG_TO_RAD, elements.eccentricity);
return positionAtTrueAnomaly(elements, trueAnomalyFromEccentricAnomaly(eccentricAnomalyRad, elements.eccentricity));
}
/**
* Propagates `elements` to Julian date `epochJdEval` around a central mass, returning the body's
* position (AU) relative to its central body, as opposed to {@link orbitEllipsePoints} which
* samples the fixed orbit shape independent of time.
*/
export function propagateOrbit(elements: OrbitalElements, gmAu3PerDay2: number, epochJdEval: number): CartesianCoordinates {
return positionAtEpoch(meanElementsAt(elements, keplerRates(elements.semiMajorAxisAu, gmAu3PerDay2), epochJdEval));
}
/**
* Samples `segments` points around the fixed shape of the orbit (AU, relative to the central
* body), for drawing the orbit ellipse. Independent of epoch/time — unlike {@link propagateOrbit}.
*/
export function orbitEllipsePoints(elements: OrbitalElements, segments = 128): CartesianCoordinates[] {
const points: CartesianCoordinates[] = [];
for (let i = 0; i <= segments; i++) {
const trueAnomalyRad = (i / segments) * TWO_PI;
points.push(positionAtTrueAnomaly(elements, trueAnomalyRad));
}
return points;
}