Turn each body at its own rate, from Horizons' own figures

The view had one rotation in it — the planet on the detail page, at 0.08 rad/s,
a number with no source. Nothing in the system view turned at all.

The data was already on disk: every cached Horizons page carries how its body
spins, in one of five forms. The rate in radians per second is preferred where
it appears, because it is signed — that is how Venus and Uranus are known to
turn backwards — then a period in hours or days, then the `9h 55m 29.711 s`
the giant planets use, and finally the word every major moon here carries
instead of a number: Synchronous. A tidally locked moon's day is its orbit, so
Kepler supplies it from the elements already parsed and the parent it goes
round.

Seventeen of the eighteen bodies come out within 1% of their published period —
Earth 23.934 h, Jupiter 9.925 h, Venus -5832.5 h, Io 42.5 h, Callisto 400.5 h.
Titan is the exception: its page states no period at all, so it is left still
rather than turned at an invented rate.

The axis is the orbit normal tilted by the obliquity about the orbit's
ascending node, which is where an obliquity is measured from and the only line
in the orbit the elements name. The phase at the epoch is published for none of
these bodies, so the face turned toward the camera is not a claim; the rate and
the direction are.

At true rates nothing is visible moving — Earth turns 15 degrees an hour. A
clock the reader can run faster is the next piece, and the audit asks for it
anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-09-21 23:12:02 +02:00
co-authored by Claude Opus 5
parent 3f0abf8717
commit 225d676ab0
6 changed files with 243 additions and 27 deletions
+16 -1
View File
@@ -1,10 +1,14 @@
import { writeFileSync } from 'node:fs';
import { BodyRecord } from '../../src/app/shared/models/body.model';
import { gmForParent } from '../../src/app/shared/astro/constants';
import { orbitalPeriodDays } from '../../src/app/shared/astro/kepler';
import { SUN_STAR_ID } from '../../src/app/shared/models/star.model';
import { fetchHorizonsBody } from './lib/horizons';
import { dataPath, ensureDataDir } from './lib/paths';
const HOURS_PER_DAY = 24;
interface BodySpec {
id: string;
name: string;
@@ -56,6 +60,15 @@ export async function fetchSolarSystem(): Promise<BodyRecord[]> {
console.warn(` no physical radius found for ${spec.name}; defaulting to 0.`);
}
// A tidally locked moon's day is its orbit, which Horizons states as a word rather than a
// number: Kepler gives the period from the elements just parsed and the parent it goes round.
const rotationPeriodHours = result.tidallyLocked
? orbitalPeriodDays(result.orbit.semiMajorAxisAu, gmForParent(spec.parentBodyId)) * HOURS_PER_DAY
: result.rotationPeriodHours;
if (rotationPeriodHours === undefined) {
console.warn(` no rotation period found for ${spec.name}; it will not turn.`);
}
bodies.push({
id: spec.id,
systemStarId: SUN_STAR_ID,
@@ -63,7 +76,9 @@ export async function fetchSolarSystem(): Promise<BodyRecord[]> {
kind: spec.kind,
radiusKm: result.radiusKm ?? 0,
orbit: result.orbit,
...(spec.parentBodyId ? { parentBodyId: spec.parentBodyId } : {})
...(spec.parentBodyId ? { parentBodyId: spec.parentBodyId } : {}),
...(rotationPeriodHours !== undefined ? { rotationPeriodHours } : {}),
...(result.obliquityDeg !== undefined ? { obliquityDeg: result.obliquityDeg } : {})
});
}
+71 -3
View File
@@ -20,6 +20,15 @@ export interface HorizonsQuery {
export interface HorizonsResult {
radiusKm?: number;
orbit: OrbitalElements;
/**
* Sidereal rotation period in hours, negative where the body turns retrograde — Venus, whose
* day runs backwards, and Triton. Absent where the page publishes none.
*/
rotationPeriodHours?: number;
/** Tilt of the rotation axis from the body's own orbital plane, in degrees. */
obliquityDeg?: number;
/** The page says "Synchronous" instead of a period: its day is its orbit. */
tidallyLocked: boolean;
}
const RADIUS_PATTERNS = [
@@ -30,10 +39,66 @@ const RADIUS_PATTERNS = [
/Radius\s*\(gravity\),?\s*km\s*=\s*([\d.]+)/i
];
/**
* How each page states how fast the body turns, in the order they are tried.
*
* The rate in radians per second is preferred wherever it appears: it is unambiguous and it is
* signed, which is how Venus and Triton are known to turn backwards. A period in hours or days
* comes next, then the sexagesimal form the giant planets use, and finally the word every major
* moon here carries instead of a number — tidally locked, so its day is its year, which the
* caller supplies from the orbit it has already parsed.
*/
const ROTATION_RATE_PATTERN = /Rot(?:ational)?\.?\s*Rate\s*[(,]\s*rad\/s\s*\)?\s*=\s*(-?[\d.]+)/i;
const ROTATION_PERIOD_PATTERNS = [
/Sid(?:ereal|\.)?\s*rot\.?\s*period[^=]*=\s*(-?[\d.]+)(?:\+-[\d.]+)?\s*(h|hr|hrs|d|day|days)\b/i,
/Rotation(?:al)?\s*period[^=]*=\s*(-?[\d.]+)\s*(h|hr|hrs|d|day|days)\b/i
];
/** `9h 55m 29.711 s`, as Jupiter and Saturn state it. */
const SEXAGESIMAL_ROTATION_PATTERN = /Sid(?:ereal|\.)?\s*rot\.?\s*period[^=]*=\s*(\d+)\s*h\s*(\d+)\s*m\s*([\d.]+)\s*s/i;
const SYNCHRONOUS_PATTERN = /Rotation(?:al)?\s*period\s*=?\s*:?\s*Synchronous/i;
const OBLIQUITY_PATTERN = /Obliquity\s*to\s*orbit[^=]*=\s*(-?[\d.]+)/i;
const HOURS_PER_DAY = 24;
const SECONDS_PER_HOUR = 3600;
/**
* True where the page gives no number because the body keeps one face to its parent, so its day
* is its orbit — every major moon here. The period itself is then Kepler's, which the caller
* works out from the elements above and the parent's mass.
*/
export function isTidallyLocked(text: string): boolean {
return SYNCHRONOUS_PATTERN.test(text);
}
/** Sidereal rotation period, in hours, from whichever form the page states it in. */
export function extractRotationPeriodHours(text: string): number | undefined {
const rate = text.match(ROTATION_RATE_PATTERN);
if (rate && Number(rate[1]) !== 0) {
return (2 * Math.PI) / (Number(rate[1]) * SECONDS_PER_HOUR);
}
const sexagesimal = text.match(SEXAGESIMAL_ROTATION_PATTERN);
if (sexagesimal) {
return Number(sexagesimal[1]) + Number(sexagesimal[2]) / 60 + Number(sexagesimal[3]) / SECONDS_PER_HOUR;
}
for (const pattern of ROTATION_PERIOD_PATTERNS) {
const match = text.match(pattern);
if (match) {
const hours = Number(match[1]) * (match[2].toLowerCase().startsWith('d') ? HOURS_PER_DAY : 1);
return Number.isFinite(hours) && hours !== 0 ? hours : undefined;
}
}
return undefined;
}
export function extractObliquityDeg(text: string): number | undefined {
const match = text.match(OBLIQUITY_PATTERN);
return match ? Number(match[1]) : undefined;
}
/**
* Queries JPL Horizons for a body's heliocentric (or planetocentric, for moons) osculating
* orbital elements plus, when available, its mean physical radius — both in a single
* request (`OBJ_DATA=YES` + `EPHEM_TYPE=ELEMENTS`).
* orbital elements plus, when available, its mean physical radius and how it turns — all in a
* single request (`OBJ_DATA=YES` + `EPHEM_TYPE=ELEMENTS`).
*/
export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsResult> {
const url =
@@ -44,7 +109,10 @@ export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsR
const text = await fetchTextCached(url, query.cacheKey);
return {
radiusKm: extractRadiusKm(text),
orbit: extractOrbitalElements(text)
orbit: extractOrbitalElements(text),
rotationPeriodHours: extractRotationPeriodHours(text),
obliquityDeg: extractObliquityDeg(text),
tidallyLocked: isTidallyLocked(text)
};
}