From 225d676ab0cb6070f7b7502156feb70fc5fd7c0a Mon Sep 17 00:00:00 2001 From: Senrokai Date: Mon, 21 Sep 2026 23:12:02 +0200 Subject: [PATCH] Turn each body at its own rate, from Horizons' own figures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- .../system-orbits-renderer.spec.ts | 55 ++++++++++++++ .../galaxy-system/system-orbits-renderer.ts | 55 ++++++++++++-- src/app/shared/models/body.model.ts | 9 +++ src/assets/data/bodies.json | 60 ++++++++++----- tools/etl/fetchSolarSystem.ts | 17 ++++- tools/etl/lib/horizons.ts | 74 ++++++++++++++++++- 6 files changed, 243 insertions(+), 27 deletions(-) diff --git a/src/app/features/galaxy-system/system-orbits-renderer.spec.ts b/src/app/features/galaxy-system/system-orbits-renderer.spec.ts index 91c56bb..855de38 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.spec.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.spec.ts @@ -372,3 +372,58 @@ describe('SystemOrbitsRenderer exoplanet propagation', () => { }); }); }); + +describe('rotation', () => { + /** Earth, near enough: a day of 23.934 h, tipped 23.44 degrees off its orbit. */ + function spinning(overrides: Partial = {}): BodyRecord { + return { + id: 'earth', + systemStarId: 0, + name: 'Earth', + kind: 'planet', + radiusKm: 6371, + orbit: { semiMajorAxisAu: 1, eccentricity: 0.0167, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD }, + rotationPeriodHours: 23.934, + obliquityDeg: 23.4392911, + ...overrides + }; + } + + /** How far the marker has turned about its own axis between two dates, in degrees. */ + function turnedDegrees(body: BodyRecord, afterDays: number): number { + const renderer = new SystemOrbitsRenderer([body], [], undefined, 1); + renderer.update(DEFAULT_EPOCH_JD); + const start = renderer.members[0].marker.quaternion.clone(); + renderer.update(DEFAULT_EPOCH_JD + afterDays); + const turn = start.invert().multiply(renderer.members[0].marker.quaternion); + const axis = new THREE.Vector3(); + const angle = 2 * Math.acos(Math.min(1, Math.abs(turn.w))); + turn.normalize(); + axis.set(turn.x, turn.y, turn.z); + const signed = axis.y >= 0 ? angle : -angle; + return (signed * 180) / Math.PI; + } + + it('turns a body once per its own sidereal day', () => { + // A full turn in 23.934 h, so a quarter of that is a quarter turn. + expect(Math.abs(turnedDegrees(spinning(), 23.934 / 96))).toBeCloseTo(90, 1); + }); + + it('turns a retrograde body the other way', () => { + // Venus: its day runs backwards, which the catalogue carries as a negative period. + const forward = turnedDegrees(spinning(), 23.934 / 96); + const backward = turnedDegrees(spinning({ rotationPeriodHours: -23.934 }), 23.934 / 96); + + expect(Math.sign(backward)).toBe(-Math.sign(forward)); + }); + + it('leaves a body with no published rotation still', () => { + // Titan: Horizons states no period for it, and an invented one would be a claim. + const renderer = new SystemOrbitsRenderer([spinning({ rotationPeriodHours: undefined })], [], undefined, 1); + renderer.update(DEFAULT_EPOCH_JD); + const start = renderer.members[0].marker.quaternion.clone(); + renderer.update(DEFAULT_EPOCH_JD + 40); + + expect(renderer.members[0].marker.quaternion.angleTo(start)).toBe(0); + }); +}); diff --git a/src/app/features/galaxy-system/system-orbits-renderer.ts b/src/app/features/galaxy-system/system-orbits-renderer.ts index d003445..3e05190 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.ts @@ -136,6 +136,36 @@ function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined, appea return new THREE.Mesh(geometry, material); } + +/** Local axis a sphere is built around, and what the spin is applied about. */ +const SPIN_AXIS = new THREE.Vector3(0, 1, 0); +const HOURS_PER_DAY = 24; + +/** + * How a body is turned at a given date: its own sidereal rotation, about its own axis. + * + * 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 not published for any of these bodies, so a body is drawn + * with its prime meridian toward the node at J2000 and turned from there: the rate and the + * direction are real, where the face pointed at the camera is not. + * + * A negative period is retrograde — Venus, and Uranus by the IAU’s convention — and comes + * out of the arithmetic without a special case. + */ +function spinFor(elements: OrbitalElements, frame: THREE.Quaternion, rotationPeriodHours: number, obliquityDeg: number, epochJd: number): THREE.Quaternion { + const node = elements.longitudeOfAscendingNodeDeg * DEG_TO_RAD; + const inclination = elements.inclinationDeg * DEG_TO_RAD; + const nodeDirection = new THREE.Vector3(Math.cos(node), Math.sin(node), 0); + const axis = new THREE.Vector3(Math.sin(inclination) * Math.sin(node), -Math.sin(inclination) * Math.cos(node), Math.cos(inclination)) + .applyAxisAngle(nodeDirection, obliquityDeg * DEG_TO_RAD) + .applyQuaternion(frame); + const turns = ((epochJd - elements.epochJd) * HOURS_PER_DAY) / rotationPeriodHours; + return new THREE.Quaternion() + .setFromUnitVectors(SPIN_AXIS, axis) + .multiply(new THREE.Quaternion().setFromAxisAngle(SPIN_AXIS, turns * 2 * Math.PI)); +} + interface TrackedTopLevelBody { id: string; kind: SystemMemberKind; @@ -146,6 +176,9 @@ interface TrackedTopLevelBody { frame: THREE.Quaternion; /** AU position last computed for this body; moons read their parent's here. */ position: THREE.Vector3; + /** Sidereal rotation, where the catalogue publishes one; negative is retrograde. */ + rotationPeriodHours?: number; + obliquityDeg?: number; } interface TrackedMoon { @@ -156,6 +189,8 @@ interface TrackedMoon { frame: THREE.Quaternion; pivot: THREE.Group; parentId: string; + rotationPeriodHours?: number; + obliquityDeg?: number; } /** @@ -229,7 +264,7 @@ export class SystemOrbitsRenderer { } // A body reaches here only when it has no parentBodyId, so `kind` is 'planet' or 'dwarf'. const kind: SystemMemberKind = body.kind; - const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar)); + const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar), { periodHours: body.rotationPeriodHours, obliquityDeg: body.obliquityDeg }); members.push({ id: body.id, kind, marker: tracked.marker }); } @@ -242,7 +277,7 @@ export class SystemOrbitsRenderer { if (!parentTracked) { continue; // orphaned moon reference; skip rather than crash. } - const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar)); + const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar), { periodHours: body.rotationPeriodHours, obliquityDeg: body.obliquityDeg }); members.push({ id: body.id, kind: 'moon', marker: moon.marker, parentId: parent.id }); } @@ -309,6 +344,9 @@ export class SystemOrbitsRenderer { const orbital = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd); body.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(body.frame); body.marker.position.copy(body.position); + if (body.rotationPeriodHours) { + body.marker.quaternion.copy(spinFor(body.elements, body.frame, body.rotationPeriodHours, body.obliquityDeg ?? 0, epochJd)); + } } for (const moon of this.moons) { @@ -319,6 +357,9 @@ export class SystemOrbitsRenderer { moon.pivot.position.copy(parent.position); const orbital = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd); moon.marker.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(moon.frame); + if (moon.rotationPeriodHours) { + moon.marker.quaternion.copy(spinFor(moon.elements, moon.frame, moon.rotationPeriodHours, moon.obliquityDeg ?? 0, epochJd)); + } } // Moons are left out: their tether would land within a marker's width of their planet's and @@ -373,7 +414,8 @@ export class SystemOrbitsRenderer { gmAu3PerDay2: number, radiusKm: number | undefined, frame: THREE.Quaternion, - appearance?: PlanetAppearance + appearance?: PlanetAppearance, + rotation?: { periodHours?: number; obliquityDeg?: number } ): TrackedTopLevelBody { const orbitLine = buildOrbitLine(elements, kind, frame); const marker = buildMarker(kind, radiusKm, appearance); @@ -381,7 +423,7 @@ export class SystemOrbitsRenderer { this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material); this.trackDisposable(marker.geometry, marker.material as THREE.Material); - const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, frame, position: new THREE.Vector3() }; + const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, frame, position: new THREE.Vector3(), rotationPeriodHours: rotation?.periodHours, obliquityDeg: rotation?.obliquityDeg }; this.topLevelBodies.push(tracked); return tracked; } @@ -393,7 +435,8 @@ export class SystemOrbitsRenderer { radiusKm: number | undefined, parent: TrackedTopLevelBody, frame: THREE.Quaternion, - appearance?: PlanetAppearance + appearance?: PlanetAppearance, + rotation?: { periodHours?: number; obliquityDeg?: number } ): TrackedMoon { const pivot = new THREE.Group(); const orbitLine = buildOrbitLine(elements, 'moon', frame); @@ -405,7 +448,7 @@ export class SystemOrbitsRenderer { this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material); this.trackDisposable(marker.geometry, marker.material as THREE.Material); - const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, frame, pivot, parentId: parent.id }; + const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, frame, pivot, parentId: parent.id, rotationPeriodHours: rotation?.periodHours, obliquityDeg: rotation?.obliquityDeg }; this.moons.push(moon); return moon; } diff --git a/src/app/shared/models/body.model.ts b/src/app/shared/models/body.model.ts index 2485db6..95c9126 100644 --- a/src/app/shared/models/body.model.ts +++ b/src/app/shared/models/body.model.ts @@ -29,4 +29,13 @@ export interface BodyRecord { * relative to that planet, not heliocentrically. Undefined for planets/dwarfs. */ parentBodyId?: string; + /** + * How the body turns on its own axis: the sidereal rotation period in hours, negative where it + * turns retrograde (Venus, Triton), and the tilt of that axis from its orbital plane. + * + * Absent where Horizons publishes neither — the view then leaves the body still rather than + * spinning it at an invented rate. + */ + rotationPeriodHours?: number; + obliquityDeg?: number; } diff --git a/src/assets/data/bodies.json b/src/assets/data/bodies.json index 571d22c..5220896 100644 --- a/src/assets/data/bodies.json +++ b/src/assets/data/bodies.json @@ -13,7 +13,9 @@ "argumentOfPeriapsisDeg": 29.19561108948743, "meanAnomalyAtEpochDeg": 103.9465145977117, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": 1407.512239412851, + "obliquityDeg": 2.11 }, { "id": "venus", @@ -29,7 +31,9 @@ "argumentOfPeriapsisDeg": 55.15075425343929, "meanAnomalyAtEpochDeg": 280.0749102629981, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": -5832.539941165383, + "obliquityDeg": 177.3 }, { "id": "earth", @@ -45,7 +49,9 @@ "argumentOfPeriapsisDeg": 272.9783142442708, "meanAnomalyAtEpochDeg": 357.4122246804211, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": 23.934472399219285, + "obliquityDeg": 23.4392911 }, { "id": "mars", @@ -61,7 +67,9 @@ "argumentOfPeriapsisDeg": 286.7114828288203, "meanAnomalyAtEpochDeg": 124.444888195349, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": 24.622955438662025, + "obliquityDeg": 25.19 }, { "id": "jupiter", @@ -77,7 +85,9 @@ "argumentOfPeriapsisDeg": 273.6090683600047, "meanAnomalyAtEpochDeg": 58.98282567286461, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": 9.925102371306965, + "obliquityDeg": 3.13 }, { "id": "saturn", @@ -93,7 +103,9 @@ "argumentOfPeriapsisDeg": 337.1663598259115, "meanAnomalyAtEpochDeg": 264.9877650588944, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": 10.656221583138441, + "obliquityDeg": 26.73 }, { "id": "uranus", @@ -109,7 +121,9 @@ "argumentOfPeriapsisDeg": 90.49593456147204, "meanAnomalyAtEpochDeg": 255.8822481742851, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": -17.24003330792427, + "obliquityDeg": 97.77 }, { "id": "neptune", @@ -125,7 +139,9 @@ "argumentOfPeriapsisDeg": 268.1146665565159, "meanAnomalyAtEpochDeg": 319.6858384317641, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": 16.110037586020876, + "obliquityDeg": 28.32 }, { "id": "pluto", @@ -141,7 +157,8 @@ "argumentOfPeriapsisDeg": 113.5754868232679, "meanAnomalyAtEpochDeg": 51.93655343727463, "epochJd": 2460676.5 - } + }, + "rotationPeriodHours": 153.29335198 }, { "id": "moon", @@ -158,7 +175,9 @@ "meanAnomalyAtEpochDeg": 290.7825171697369, "epochJd": 2460676.5 }, - "parentBodyId": "earth" + "parentBodyId": "earth", + "rotationPeriodHours": 655.7197475276438, + "obliquityDeg": 6.67 }, { "id": "phobos", @@ -175,7 +194,8 @@ "meanAnomalyAtEpochDeg": 342.6005509941174, "epochJd": 2460676.5 }, - "parentBodyId": "mars" + "parentBodyId": "mars", + "rotationPeriodHours": 7.660212212136228 }, { "id": "deimos", @@ -192,7 +212,8 @@ "meanAnomalyAtEpochDeg": 273.8943716897566, "epochJd": 2460676.5 }, - "parentBodyId": "mars" + "parentBodyId": "mars", + "rotationPeriodHours": 30.304279685850094 }, { "id": "io", @@ -209,7 +230,8 @@ "meanAnomalyAtEpochDeg": 74.88524962049125, "epochJd": 2460676.5 }, - "parentBodyId": "jupiter" + "parentBodyId": "jupiter", + "rotationPeriodHours": 42.51537583211252 }, { "id": "europa", @@ -226,7 +248,8 @@ "meanAnomalyAtEpochDeg": 40.72575250295771, "epochJd": 2460676.5 }, - "parentBodyId": "jupiter" + "parentBodyId": "jupiter", + "rotationPeriodHours": 85.27848729079142 }, { "id": "ganymede", @@ -243,7 +266,8 @@ "meanAnomalyAtEpochDeg": 355.7249344187845, "epochJd": 2460676.5 }, - "parentBodyId": "jupiter" + "parentBodyId": "jupiter", + "rotationPeriodHours": 171.78271980469088 }, { "id": "callisto", @@ -260,7 +284,8 @@ "meanAnomalyAtEpochDeg": 126.0198540648681, "epochJd": 2460676.5 }, - "parentBodyId": "jupiter" + "parentBodyId": "jupiter", + "rotationPeriodHours": 400.52470451330635 }, { "id": "titan", @@ -294,6 +319,7 @@ "meanAnomalyAtEpochDeg": 273.1157438944629, "epochJd": 2460676.5 }, - "parentBodyId": "neptune" + "parentBodyId": "neptune", + "rotationPeriodHours": 141.05626027628523 } ] \ No newline at end of file diff --git a/tools/etl/fetchSolarSystem.ts b/tools/etl/fetchSolarSystem.ts index 9eda8d5..d71b0cc 100644 --- a/tools/etl/fetchSolarSystem.ts +++ b/tools/etl/fetchSolarSystem.ts @@ -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 { 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 { 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 } : {}) }); } diff --git a/tools/etl/lib/horizons.ts b/tools/etl/lib/horizons.ts index a4960f9..67e086b 100644 --- a/tools/etl/lib/horizons.ts +++ b/tools/etl/lib/horizons.ts @@ -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 { const url = @@ -44,7 +109,10 @@ export async function fetchHorizonsBody(query: HorizonsQuery): Promise