solargod/js/ephem.js
monsterrobotparty c4b572b8bb 🪐 kepler: planets on rails — Standish ephemeris engine, orbit paths, verify gates (opus)
Stage 1. ephem.js: JPL SSD approximate elements (Table 1 + 2a/2b plumbing),
Newton Kepler solver, reusable eclFromElements core (planets + small bodies),
orbitPath sampler. planets.js: 8 planets + Pluto as textured exaggerated spheres
on MEGA-compressed orbits with labels, rebuild-on-drift. verify.html: 9 gates all
green IN BROWSER incl. live Horizons via proxy — Mars @2026-07-15 Δ3.3e-4 AU,
Jupiter Δ2.9e-3, Earth Δ3.9e-5; Mars @J2000 vs live Δ4.8e-5; Jupiter @1900 Δ2.0e-3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:04:41 +10:00

172 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SOLARGOD ephemeris engine — JPL SSD "Approximate Positions of the Planets"
// (Standish). Tables extracted 2026-07-15 from
// https://ssd.jpl.nasa.gov/planets/approx_pos.html — authoritative, verbatim.
// Angles in degrees, rates per Julian century; `e` is dimensionless (the page's
// "rad" header for e is its own typo). No n-body integration — this IS the spec.
import { centuriesSinceJ2000, normDegPM180, DEG } from './lib.js';
// Row layout: el = [a(au), e, I(deg), L(deg), ϖ long.peri(deg), Ω long.node(deg)]
// rate = same order, per century.
const TABLE1 = {
mercury: { el: [0.38709927, 0.20563593, 7.00497902, 252.25032350, 77.45779628, 48.33076593],
rate: [0.00000037, 0.00001906, -0.00594749, 149472.67411175, 0.16047689, -0.12534081] },
venus: { el: [0.72333566, 0.00677672, 3.39467605, 181.97909950, 131.60246718, 76.67984255],
rate: [0.00000390, -0.00004107, -0.00078890, 58517.81538729, 0.00268329, -0.27769418] },
earth: { el: [1.00000261, 0.01671123, -0.00001531, 100.46457166, 102.93768193, 0.0],
rate: [0.00000562, -0.00004392, -0.01294668, 35999.37244981, 0.32327364, 0.0] },
mars: { el: [1.52371034, 0.09339410, 1.84969142, -4.55343205, -23.94362959, 49.55953891],
rate: [0.00001847, 0.00007882, -0.00813131, 19140.30268499, 0.44441088, -0.29257343] },
jupiter: { el: [5.20288700, 0.04838624, 1.30439695, 34.39644051, 14.72847983, 100.47390909],
rate: [-0.00011607, -0.00013253, -0.00183714, 3034.74612775, 0.21252668, 0.20469106] },
saturn: { el: [9.53667594, 0.05386179, 2.48599187, 49.95424423, 92.59887831, 113.66242448],
rate: [-0.00125060, -0.00050991, 0.00193609, 1222.49362201, -0.41897216, -0.28867794] },
uranus: { el: [19.18916464, 0.04725744, 0.77263783, 313.23810451, 170.95427630, 74.01692503],
rate: [-0.00196176, -0.00004397, -0.00242939, 428.48202785, 0.40805281, 0.04240589] },
neptune: { el: [30.06992276, 0.00859048, 1.77004347, -55.12002969, 44.96476227, 131.78422574],
rate: [0.00026291, 0.00005105, 0.00035372, 218.45945325, -0.32241464, -0.00508664] },
};
// Table 2a — valid 3000 BC3000 AD. Shipped for the extended-range config swap
// (CONFIG.useExtendedRange); unused by v1 UI.
const TABLE2A = {
mercury: { el: [0.38709843, 0.20563661, 7.00559432, 252.25166724, 77.45771895, 48.33961819],
rate: [0.00000000, 0.00002123, -0.00590158, 149472.67486623, 0.15940013, -0.12214182] },
venus: { el: [0.72332102, 0.00676399, 3.39777545, 181.97970850, 131.76755713, 76.67261496],
rate: [-0.00000026, -0.00005107, 0.00043494, 58517.81560260, 0.05679648, -0.27274174] },
earth: { el: [1.00000018, 0.01673163, -0.00054346, 100.46691572, 102.93005885, -5.11260389],
rate: [-0.00000003, -0.00003661, -0.01337178, 35999.37306329, 0.31795260, -0.24123856] },
mars: { el: [1.52371243, 0.09336511, 1.85181869, -4.56813164, -23.91744784, 49.71320984],
rate: [0.00000097, 0.00009149, -0.00724757, 19140.29934243, 0.45223625, -0.26852431] },
jupiter: { el: [5.20248019, 0.04853590, 1.29861416, 34.33479152, 14.27495244, 100.29282654],
rate: [-0.00002864, 0.00018026, -0.00322699, 3034.90371757, 0.18199196, 0.13024619] },
saturn: { el: [9.54149883, 0.05550825, 2.49424102, 50.07571329, 92.86136063, 113.63998702],
rate: [-0.00003065, -0.00032044, 0.00451969, 1222.11494724, 0.54179478, -0.25015002] },
uranus: { el: [19.18797948, 0.04685740, 0.77298127, 314.20276625, 172.43404441, 73.96250215],
rate: [-0.00020455, -0.00001550, -0.00180155, 428.49512595, 0.09266985, 0.05739699] },
neptune: { el: [30.06952752, 0.00895439, 1.77005520, 304.22289287, 46.68158724, 131.78635853],
rate: [0.00006447, 0.00000818, 0.00022400, 218.46515314, 0.01009938, -0.00606302] },
};
// Table 2b — extra terms added to M (deg) for JupiterNeptune, Table 2a only:
// M += b·T² + c·cos(f·T) + s·sin(f·T). [b, c, s, f]
const TABLE2B = {
jupiter: [-0.00012452, 0.06064060, -0.35635438, 38.35125000],
saturn: [0.00025899, -0.13434469, 0.87320147, 38.35125000],
uranus: [0.00058331, -0.97731848, 0.17689245, 7.67025000],
neptune: [-0.00041348, 0.68346318, -0.10162547, 7.67025000],
};
export const PLANET_IDS = ['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'];
let useExtended = false;
export function setExtendedRange(on) { useExtended = !!on; }
// Solve Kepler's equation E e·sinE = M (radians) by NewtonRaphson.
// Seed E₀ = M + e·sinM; converges in a few iters even for e→0.97 (verify gate).
export function solveKepler(M, e, tol = 1e-9, maxIter = 20) {
let E = M + e * Math.sin(M);
for (let i = 0; i < maxIter; i++) {
const dE = (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));
E -= dE;
if (Math.abs(dE) < tol) break;
}
return E;
}
// The reusable core: resolved classical elements → heliocentric J2000 ecliptic
// {x,y,z} in AU. a[au], e, and iDeg/OmDeg(Ω)/wDeg(ω arg.peri)/Mdeg all in degrees.
// Shared by planets (element+rate) and small bodies (epoch elements) — brief §3.
export function eclFromElements(a, e, iDeg, OmDeg, wDeg, Mdeg, out = {}) {
const M = normDegPM180(Mdeg) * DEG;
const E = solveKepler(M, e);
// Orbital-plane coords (perifocal), AU.
const xp = a * (Math.cos(E) - e);
const yp = a * Math.sqrt(1 - e * e) * Math.sin(E);
const w = wDeg * DEG, Om = OmDeg * DEG, I = iDeg * DEG;
const cw = Math.cos(w), sw = Math.sin(w);
const cO = Math.cos(Om), sO = Math.sin(Om);
const cI = Math.cos(I), sI = Math.sin(I);
// Perifocal → ecliptic rotation (brief §3 step 5 = standard R_z(Ω)R_x(I)R_z(ω)).
out.x = (cw * cO - sw * sO * cI) * xp + (-sw * cO - cw * sO * cI) * yp;
out.y = (cw * sO + sw * cO * cI) * xp + (-sw * sO + cw * cO * cI) * yp;
out.z = (sw * sI) * xp + (cw * sI) * yp;
return out;
}
// Resolve a planet's elements at jd (Table 1, 18002050) → {a,e,I,Om,w,M} deg.
function resolvePlanet(bodyId, jd) {
const T = centuriesSinceJ2000(jd);
const row = TABLE1[bodyId];
if (!row) throw new Error(`ephem: unknown planet ${bodyId}`);
const a = row.el[0] + row.rate[0] * T;
const e = row.el[1] + row.rate[1] * T;
const I = row.el[2] + row.rate[2] * T;
const L = row.el[3] + row.rate[3] * T;
const wb = row.el[4] + row.rate[4] * T; // ϖ longitude of perihelion
const Om = row.el[5] + row.rate[5] * T; // Ω longitude of ascending node
const w = wb - Om; // ω argument of perihelion
const M = L - wb; // mean anomaly (normalize AFTER subtract)
return { a, e, I, Om, w, M };
}
// Extended-range resolver (Table 2a + the 2b M-correction for JupiterNeptune,
// valid 3000 BC3000 AD). f·T uses T in centuries; f is deg-per-century so f·T
// is degrees → convert to rad for the trig. Only used when useExtended is set.
function resolvePlanetExt(bodyId, jd) {
const T = centuriesSinceJ2000(jd);
const row = TABLE2A[bodyId];
const a = row.el[0] + row.rate[0] * T;
const e = row.el[1] + row.rate[1] * T;
const I = row.el[2] + row.rate[2] * T;
const L = row.el[3] + row.rate[3] * T;
const wb = row.el[4] + row.rate[4] * T;
const Om = row.el[5] + row.rate[5] * T;
const w = wb - Om;
let M = L - wb;
const b2 = TABLE2B[bodyId];
if (b2) {
const [b, c, s, f] = b2;
M += b * T * T + c * Math.cos(f * T * DEG) + s * Math.sin(f * T * DEG);
}
return { a, e, I, Om, w, M };
}
// Public: heliocentric J2000 ecliptic position of a planet at jd, AU.
export function helioEcl(bodyId, jd, out = {}) {
const p = useExtended ? resolvePlanetExt(bodyId, jd) : resolvePlanet(bodyId, jd);
return eclFromElements(p.a, p.e, p.I, p.Om, p.w, p.M, out);
}
// Mean motion [deg/day] from semi-major axis via Kepler's third law (Gaussian).
export function meanMotionDegPerDay(a) { return 0.9856076686 / Math.pow(a, 1.5); }
// Small-body position (asteroids/comets/Pluto): epoch elements propagated by
// M = M0 + n·(jd epoch). el = {a,e,i,om(Ω),w(ω),ma(M0),epoch}.
export function smallBodyEcl(el, jd, out = {}) {
const n = meanMotionDegPerDay(el.a);
const M = el.ma + n * (jd - el.epoch);
return eclFromElements(el.a, el.e, el.i, el.om, el.w, M, out);
}
// One full orbit sampled in mean anomaly, as a Float64Array of xyz triples
// (nSamples+1 points; last == first so a LineLoop/LineSegments closes cleanly —
// verify gate "orbitPath endpoints join"). Elements frozen at jd (they drift
// slowly; caller rebuilds only when |ΔT| > 0.1 Cy, brief §11).
export function orbitPath(bodyId, jd, nSamples = 256) {
const p = useExtended ? resolvePlanetExt(bodyId, jd) : resolvePlanet(bodyId, jd);
return samplePath(p.a, p.e, p.I, p.Om, p.w, nSamples);
}
export function orbitPathFromElements(el, nSamples = 256) {
return samplePath(el.a, el.e, el.i, el.om, el.w, nSamples);
}
function samplePath(a, e, I, Om, w, nSamples) {
const out = new Float64Array((nSamples + 1) * 3);
const tmp = {};
for (let k = 0; k <= nSamples; k++) {
const M = (360 * k) / nSamples; // 0..360; k=nSamples reproduces k=0
eclFromElements(a, e, I, Om, w, M, tmp);
out[k * 3] = tmp.x; out[k * 3 + 1] = tmp.y; out[k * 3 + 2] = tmp.z;
}
return out;
}