Turn Nereid in the 11.594 hours Kepler measured, where it was drawn still

Nereid's Horizons page states no spin, and the ETL, finding none, left it still; the validator's
comment read that as "Nereid has no spin". Its rotation is measured: Kepler's K2 light curve gives
11.594 +/- 0.017 hours, confirming earlier ground-based periods (Kiss et al. 2016, MNRAS 457, 2908;
arXiv:1601.02395). Its spec now carries that day, as Eris's carries Bernstein et al.'s, and with no
known pole it turns about its orbit normal, as Eris, Haumea and Makemake do. The free-spinner check
accepts it (11.594 hours against a 360-day orbit).

A new validator: a moon without a lock must have a day unless it tumbles, and only Hyperion
("Rotational period = Chaotic") does. Nereid, left without one, fails it: "Moon nereid is drawn not
turning, and is not known to tumble". The renderer spec's example of a body left still was Titan,
said to have no period on Horizons, though it carries its orbit's; it is Hyperion now, and
BodyRecord.rotationPeriodHours says where each kind of period comes from.

Measured: the solar ETL passes; in bodies.json only Hyperion has no rotationPeriodHours; live on
:4301 Nereid's marker turns 60.000 degrees in a sixth of its day. Test: Nereid, as shipped, turns 60
degrees in 1.93 hours. Guarded mutant, the day removed from its spec and run through the solar
ETL: the validator fails, and the suite on the data it wrote fails that test and only it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:02:30 +02:00
co-authored by Claude Opus 5.5
parent e8e857ec25
commit fc7677e715
6 changed files with 37 additions and 11 deletions
@@ -442,8 +442,18 @@ describe('rotation without IAU elements', () => {
expect(spinSense(spinning({ rotationPeriodHours: 23.934 }))).toBeGreaterThan(0.99);
});
it('turns Nereid, as shipped, once in the 11.594 hours Kepler measured: a sixth of a turn in 1.93 hours', () => {
const shipped: BodyRecord[] = JSON.parse(readFileSync(`${process.cwd()}/src/assets/data/bodies.json`, 'utf8'));
const renderer = new SystemOrbitsRenderer(shipped.filter((body) => body.id === 'neptune' || body.id === 'nereid'), []);
const nereid = renderer.members.find((member) => member.id === 'nereid')!.marker;
renderer.update(DEFAULT_EPOCH_JD);
const start = nereid.quaternion.clone();
renderer.update(DEFAULT_EPOCH_JD + 11.594 / 6 / 24);
expect((nereid.quaternion.angleTo(start) * 180) / Math.PI).toBeCloseTo(60, 1);
});
it('leaves a body with no published rotation still', () => {
// Titan: Horizons states no period for it, and an invented one would be a claim.
// Hyperion, which tumbles: an invented period 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();
@@ -297,9 +297,9 @@ const SPIN_AXIS = new THREE.Vector3(0, 1, 0);
const HOURS_PER_DAY = 24;
/**
* How a body the IAU gives no rotational elements for is turned at a given date — Eris, Haumea
* and Makemake, whose periods are measured (Makemake's only to a factor of two, see its spec in
* `fetchSolarSystem.ts`) and whose poles are not: at its own sidereal rate, about
* How a body the IAU gives no rotational elements for is turned at a given date — Eris, Haumea,
* Makemake and Nereid, whose periods are measured (Makemake's only to a factor of two, see its
* spec in `fetchSolarSystem.ts`) and whose poles are not: at its own sidereal rate, about
* its orbit's normal, backwards for a negative period. None of them has an obliquity, so none is
* applied. The phase is arbitrary: each body starts at its elements' epoch in the shortest
* rotation of +Y onto its axis, and turns from there. Exoplanets have no published rotation at
+4 -2
View File
@@ -88,8 +88,10 @@ export interface BodyRecord {
* Horizons gives a negative rate (Venus, Uranus), and the tilt of that axis from its orbital
* plane — which past 90 degrees already says the turn is retrograde.
*
* Absent where Horizons publishes neither — the view then leaves the body still rather than
* spinning it at an invented rate.
* For a locked moon the period is its orbit's, from the mean motion that carries it round; for
* a body whose source states none, the measured one its ETL spec carries (Eris, Nereid). Absent
* only for Hyperion, which tumbles — the view leaves it still rather than spinning it at an
* invented rate.
*/
rotationPeriodHours?: number;
obliquityDeg?: number;
+2 -1
View File
@@ -2130,7 +2130,8 @@
"decDeg": 69.117
},
"orbitSource": "JPL SSD satellite mean elements, epoch 2000 Jan 1, within 11.2 degrees of Horizons from 1950 to 2100",
"parentBodyId": "neptune"
"parentBodyId": "neptune",
"rotationPeriodHours": 11.594
},
{
"id": "proteus",
+13 -2
View File
@@ -204,6 +204,13 @@ const TRACK_OFFSET_CEILINGS_DEG: Record<string, number> = { hyperion: 23, nereid
*/
const WITHOUT_ROTATIONAL_ELEMENTS = new Set(['hyperion', 'nereid', 'eris', 'haumea', 'makemake']);
/**
* The one moon drawn still: Hyperion, whose page says "Rotational period = Chaotic". Every other
* moon without a lock has a measured day; Nereid's page states none, and it was drawn still until
* its K2 light curve's 11.594 hours was taken (see its spec).
*/
const TUMBLING = new Set(['hyperion']);
/**
* How far the IAU's day, 360 degrees over W's rate, may be from the one Horizons states, as a
* fraction of it. Measured on this catalogue: at most 1.8e-5 (Jupiter's System III, 9.92492 hours
@@ -357,8 +364,12 @@ function validateBodies(bodies: BodyRecord[], horizonsOrbits: Map<string, Orbita
assertCondition(parent !== undefined, `Moon ${body.id} has no valid parentBodyId.`);
const orbitHours = (360 / body.rates.meanMotionDegPerDay) * 24;
if (FREELY_SPINNING_MOONS.has(body.id)) {
// Hyperion tumbles and Nereid's page gives no spin, so they have none; Phoebe turns in
// 9.27 hours against a 550-day orbit. A lock here would be the rule below misapplied.
// Hyperion tumbles, and has no period; Nereid turns in 11.594 hours against a 360-day orbit,
// and Phoebe in 9.27 against 550 days. A lock here would be the rule below misapplied.
assertCondition(
body.rotationPeriodHours !== undefined || TUMBLING.has(body.id),
`Moon ${body.id} is drawn not turning, and is not known to tumble: its day was measured somewhere, find it.`
);
assertCondition(
body.rotationPeriodHours === undefined || Math.abs(body.rotationPeriodHours - orbitHours) > orbitHours * 0.1,
`Moon ${body.id} does not keep one face to its planet, yet turns once in ${body.rotationPeriodHours} hours against an orbit of ${orbitHours}.`
+4 -2
View File
@@ -147,8 +147,10 @@ const BODY_SPECS: BodySpec[] = [
{ id: 'titania', name: 'Titania', horizonsCommand: '703', ...URANUS_MOON },
{ id: 'oberon', name: 'Oberon', horizonsCommand: '704', ...URANUS_MOON },
{ id: 'triton', name: 'Triton', kind: 'moon', horizonsCommand: '801', center: '500@899', parentBodyId: 'neptune' },
// Nereid's eccentric orbit, 0.75, cannot hold a face to Neptune; its page states no spin.
{ id: 'nereid', name: 'Nereid', kind: 'moon', horizonsCommand: '802', center: '500@899', parentBodyId: 'neptune', spinsFreely: true, trackStepDays: 1 },
// Nereid's eccentric orbit, 0.75, cannot hold a face to Neptune. Its page states no spin, but
// Kepler's K2 light curve gives 11.594 +/- 0.017 hours, confirming the short periods measured
// from the ground (Kiss et al. 2016, MNRAS 457, 2908; arXiv:1601.02395). No pole is known.
{ id: 'nereid', name: 'Nereid', kind: 'moon', horizonsCommand: '802', center: '500@899', parentBodyId: 'neptune', spinsFreely: true, rotationPeriodHours: 11.594, trackStepDays: 1 },
{ id: 'proteus', name: 'Proteus', kind: 'moon', horizonsCommand: '808', center: '500@899', parentBodyId: 'neptune' },
// Pluto's section prints its epoch as 2000 Jan 1.0; JPL's current table gives Charon's as
// 2000-01-01.5, and read at 1.0 Charon sat 27.8 to 28.2 degrees — half a day of its motion is