Files
star-map/src/app/shared/astro/coordinates.spec.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

250 lines
9.5 KiB
TypeScript
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.
import { describe, expect, it } from 'vitest';
import {
distanceBetween,
eclipticToEquatorial,
equatorialToEcliptic,
laplacePlaneToEquatorial,
OBLIQUITY_J2000_DEG,
parallaxMasToParsecs,
parseSexagesimal,
propagateProperMotion,
raDecDistanceToXyz,
raDecToUnitVector,
raDegDecDistanceToXyz
} from './coordinates';
// Reference values taken directly from the HYG v4.1 database (RA/Dec/dist and its own
// precomputed x/y/z, which uses the same equatorial-Cartesian convention we implement).
describe('raDecDistanceToXyz', () => {
it('matches the HYG reference position for Sirius', () => {
const result = raDecDistanceToXyz(6.752481, -16.716116, 2.6371);
expect(result.x).toBeCloseTo(-0.494323, 3);
expect(result.y).toBeCloseTo(2.476731, 3);
expect(result.z).toBeCloseTo(-0.758485, 3);
});
it('matches the HYG reference position for Proxima Centauri', () => {
const result = raDecDistanceToXyz(14.495985, -62.679485, 1.2959);
expect(result.x).toBeCloseTo(-0.472264, 3);
expect(result.y).toBeCloseTo(-0.361451, 3);
expect(result.z).toBeCloseTo(-1.151219, 3);
});
it('places a star on RA 6h / Dec 0 entirely on the +Y axis', () => {
const result = raDecDistanceToXyz(6, 0, 10);
expect(result.x).toBeCloseTo(0, 9);
expect(result.y).toBeCloseTo(10, 9);
expect(result.z).toBeCloseTo(0, 9);
});
it('places the vernal equinox direction entirely on the +X axis', () => {
const result = raDecDistanceToXyz(0, 0, 10);
expect(result.x).toBeCloseTo(10, 9);
expect(result.y).toBeCloseTo(0, 9);
expect(result.z).toBeCloseTo(0, 9);
});
});
describe('raDegDecDistanceToXyz', () => {
it('is equivalent to raDecDistanceToXyz with RA converted from degrees to hours', () => {
const fromHours = raDecDistanceToXyz(6.752481, -16.716116, 2.6371);
const fromDegrees = raDegDecDistanceToXyz(6.752481 * 15, -16.716116, 2.6371);
expect(fromDegrees.x).toBeCloseTo(fromHours.x, 9);
expect(fromDegrees.y).toBeCloseTo(fromHours.y, 9);
expect(fromDegrees.z).toBeCloseTo(fromHours.z, 9);
});
});
describe('propagateProperMotion', () => {
it("carries Barnard's Star from Gaia's epoch back to HYG's", () => {
// Gaia DR3 4472832130942575872 as published for J2016.0, moved back sixteen years with its
// own proper motion, lands on the J2000.0 position SIMBAD lists to a milliarcsecond — and
// 0.08″ from where HYG has Barnard's Star, instead of the 166″ the two epochs put between them.
const j2000 = propagateProperMotion(269.44850252543836, 4.739420051112412, -801.550978, 10362.394207, -16);
expect(j2000.raDeg).toBeCloseTo(269.4520772, 6);
expect(j2000.decDeg).toBeCloseTo(4.693365, 6);
});
it('divides the right-ascension motion by cos δ, since pmra is published on the sky', () => {
// 3600 mas/yr for one year is 3.6″ on the sky; at Dec 60° that is 7.2″ of right ascension.
expect(propagateProperMotion(0, 60, 3600, 0, 1).raDeg).toBeCloseTo(7.2 / 3600, 9);
expect(propagateProperMotion(0, 60, 0, 3600, 1).decDeg).toBeCloseTo(60 + 3.6 / 3600, 9);
});
it('leaves a star with no proper motion where it is', () => {
expect(propagateProperMotion(100, -20, 0, 0, 16)).toEqual({ raDeg: 100, decDeg: -20 });
});
});
describe('parallaxMasToParsecs', () => {
it('converts a positive parallax to the expected distance', () => {
expect(parallaxMasToParsecs(769.33)).toBeCloseTo(1.3, 2); // Proxima Centauri
});
it('returns Infinity for zero or negative parallax', () => {
expect(parallaxMasToParsecs(0)).toBe(Infinity);
expect(parallaxMasToParsecs(-5)).toBe(Infinity);
});
});
describe('distanceBetween', () => {
it('computes the Euclidean distance between two points', () => {
expect(distanceBetween({ x: 0, y: 0, z: 0 }, { x: 3, y: 4, z: 0 })).toBeCloseTo(5, 9);
});
});
describe('raDecToUnitVector', () => {
it('always returns a unit-length vector', () => {
for (const [ra, dec] of [
[0, 0],
[6, 45],
[13.7, -62.7],
[23.99, 89.9]
]) {
const { x, y, z } = raDecToUnitVector(ra, dec);
expect(Math.hypot(x, y, z)).toBeCloseTo(1, 12);
}
});
it('points along +Z at the north celestial pole', () => {
const { x, y, z } = raDecToUnitVector(0, 90);
expect(x).toBeCloseTo(0, 12);
expect(y).toBeCloseTo(0, 12);
expect(z).toBeCloseTo(1, 12);
});
it('agrees with the distance-carrying conversion, scaled', () => {
const unit = raDecToUnitVector(6.752481, -16.716116);
const scaled = raDecDistanceToXyz(6.752481, -16.716116, 2.6371);
expect(unit.x * 2.6371).toBeCloseTo(scaled.x, 12);
expect(unit.y * 2.6371).toBeCloseTo(scaled.y, 12);
expect(unit.z * 2.6371).toBeCloseTo(scaled.z, 12);
});
});
describe('parseSexagesimal', () => {
it('parses a right ascension into decimal hours', () => {
// 00:08:27.05 = 8/60 + 27.05/3600 hours
expect(parseSexagesimal('00:08:27.05')).toBeCloseTo(0.140847, 6);
});
it('parses a positive declination into decimal degrees', () => {
expect(parseSexagesimal('+27:43:03.6')).toBeCloseTo(27.7176667, 6);
});
it('parses a negative declination', () => {
expect(parseSexagesimal('-12:49:22.3')).toBeCloseTo(-12.8228611, 6);
});
it('keeps the sign for a negative angle inside the first degree', () => {
// The trap: `Number('-00')` is `-0`, which is `=== 0`, so a naive implementation flips
// this object into the northern hemisphere.
const parsed = parseSexagesimal('-00:24:54.8');
expect(parsed).toBeLessThan(0);
expect(parsed).toBeCloseTo(-0.4152222, 6);
});
it('treats an unsigned angle as positive', () => {
expect(parseSexagesimal('00:24:54.8')).toBeCloseTo(0.4152222, 6);
});
it('tolerates surrounding whitespace', () => {
expect(parseSexagesimal(' +27:43:03.6 ')).toBeCloseTo(27.7176667, 6);
});
it('returns null for missing or malformed values', () => {
for (const input of ['', ' ', 'not-an-angle', '12:34', '12:34:56:78', '12;34;56', undefined, null]) {
expect(parseSexagesimal(input)).toBeNull();
}
});
it('returns null rather than a partial value for empty sub-fields', () => {
expect(parseSexagesimal('12::56')).toBeNull();
});
});
describe('eclipticToEquatorial', () => {
const RAD = Math.PI / 180;
it('leaves the vernal equinox untouched, since both frames share that axis', () => {
// +X is where the ecliptic crosses the celestial equator, so it is the rotation axis.
expect(eclipticToEquatorial({ x: 1, y: 0, z: 0 })).toEqual({ x: 1, y: 0, z: 0 });
});
it('puts the ecliptic pole the obliquity away from the celestial pole', () => {
const pole = eclipticToEquatorial({ x: 0, y: 0, z: 1 });
const angleFromCelestialPoleDeg = Math.acos(pole.z) / RAD;
expect(angleFromCelestialPoleDeg).toBeCloseTo(OBLIQUITY_J2000_DEG, 9);
expect(pole.x).toBeCloseTo(0, 12);
expect(pole.y).toBeCloseTo(-Math.sin(OBLIQUITY_J2000_DEG * RAD), 12);
});
it('places the summer solstice point at the obliquity in declination', () => {
// Ecliptic longitude 90 degrees is the northernmost point of the Sun's yearly path, whose
// declination is by definition the obliquity — about 23.4 degrees.
const solstice = eclipticToEquatorial({ x: 0, y: 1, z: 0 });
const declinationDeg = Math.asin(solstice.z) / RAD;
expect(declinationDeg).toBeCloseTo(OBLIQUITY_J2000_DEG, 9);
});
it('preserves length, being a rotation', () => {
const rotated = eclipticToEquatorial({ x: 0.3, y: -0.5, z: 0.81 });
expect(Math.hypot(rotated.x, rotated.y, rotated.z)).toBeCloseTo(Math.hypot(0.3, -0.5, 0.81), 12);
});
it('leaves a point in the ecliptic plane in that plane, tilted out of the equator', () => {
const inPlane = eclipticToEquatorial({ x: 0.6, y: 0.8, z: 0 });
expect(inPlane.z).toBeCloseTo(0.8 * Math.sin(OBLIQUITY_J2000_DEG * RAD), 12);
});
});
describe('laplacePlaneToEquatorial', () => {
const RAD = Math.PI / 180;
/** Jupiter's moons' Laplace pole, as JPL gives it for Io. */
const POLE = { raDeg: 268.057, decDeg: 64.495 };
it('sends the plane’s own pole to the right ascension and declination it is named by', () => {
const pole = laplacePlaneToEquatorial({ x: 0, y: 0, z: 1 }, POLE);
expect(Math.asin(pole.z) / RAD).toBeCloseTo(POLE.decDeg, 9);
expect(((Math.atan2(pole.y, pole.x) / RAD) + 360) % 360).toBeCloseTo(POLE.raDeg, 9);
});
it('counts the node from where the plane rises through the equator, 90 degrees past the pole', () => {
const node = laplacePlaneToEquatorial({ x: 1, y: 0, z: 0 }, POLE);
expect(node.z).toBeCloseTo(0, 12);
expect(((Math.atan2(node.y, node.x) / RAD) + 360) % 360).toBeCloseTo((POLE.raDeg + 90) % 360, 9);
// Rising: a quarter-turn on along the plane is north of the equator.
expect(laplacePlaneToEquatorial({ x: 0, y: 1, z: 0 }, POLE).z).toBeGreaterThan(0);
});
});
describe('equatorialToEcliptic', () => {
it('is the exact inverse of eclipticToEquatorial', () => {
for (const point of [
{ x: 1, y: 0, z: 0 },
{ x: 0, y: 1, z: 0 },
{ x: 0, y: 0, z: 1 },
{ x: -0.37, y: 0.42, z: 0.83 }
]) {
const round = equatorialToEcliptic(eclipticToEquatorial(point));
expect(round.x).toBeCloseTo(point.x, 12);
expect(round.y).toBeCloseTo(point.y, 12);
expect(round.z).toBeCloseTo(point.z, 12);
}
});
it('brings the celestial pole back to the obliquity off the ecliptic pole', () => {
const pole = equatorialToEcliptic({ x: 0, y: 0, z: 1 });
expect(Math.acos(pole.z) / (Math.PI / 180)).toBeCloseTo(OBLIQUITY_J2000_DEG, 9);
});
});