Propagate exoplanets with their real orbital period

Every exoplanet was propagated with gmForParent(undefined) — the Sun's
gravitational parameter — so the whole catalogue orbited as though each host
were exactly one solar mass. Most hosts are red dwarfs far lighter than that,
and a heavier central mass pulls harder and shortens the period, so their
planets were whirling round much too fast: TRAPPIST-1 is 0.09 solar masses, and
its planets were completing an orbit in roughly a third of the true time.

pl_orbper was already in the TAP query and was being discarded on the way into
the record. It is now kept, along with st_mass. A period and a semi-major axis
together pin the host's gravitational parameter exactly, via GM = n^2 a^3 — no
stellar model, no assumption, just the inverse of the orbitalPeriodDays helper
that was already there.

resolveGravitationalParameter picks the best available source: the measured
period, else the published host mass, else one solar mass as before. A derived
value implying something outside 0.01-150 solar masses is rejected and falls
through, since a period and axis taken from disagreeing solutions would
otherwise send a planet spinning at a visibly absurd rate.

Note the direction of the error, which is the opposite of what it looks like:
assuming a *heavier* host than reality makes a planet orbit *faster*. A test
pins it, and caught me stating it backwards first.

The NASA Exoplanet Archive is unreachable from this environment (egress policy
returns 403 on CONNECT), so exoplanets.json cannot be regenerated here and still
carries no periods. Behaviour is therefore unchanged until `npm run etl` is run
somewhere with archive access, at which point every planet with a published
period starts moving correctly with no further code changes. build.ts reports
how many records gained a period, and rejects non-positive ones.

Tests: 171 passing, up from 151, including a new end-to-end check that
TRAPPIST-1 b with its real period completes exactly one orbit in 1.51088 days
and sits a full diameter away at half that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
Claude
2026-08-04 11:19:45 +00:00
parent c1feb4b74e
commit 8d8c65bdb2
7 changed files with 293 additions and 3 deletions
@@ -0,0 +1,100 @@
import * as THREE from 'three/webgpu';
import { describe, expect, it } from 'vitest';
import { DEFAULT_EPOCH_JD } from '../../shared/astro/constants';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { SystemOrbitsRenderer } from './system-orbits-renderer';
/** TRAPPIST-1 b: a real short-period planet around a 0.09 solar-mass red dwarf. */
const TRAPPIST_1B_SEMI_MAJOR_AXIS_AU = 0.01154;
const TRAPPIST_1B_PERIOD_DAYS = 1.51088;
function exoplanet(overrides: Partial<ExoplanetRecord> = {}): ExoplanetRecord {
return {
id: 'TRAPPIST-1 b',
hostStarId: 1,
hostStarName: 'TRAPPIST-1',
name: 'TRAPPIST-1 b',
orbit: { semiMajorAxisAu: TRAPPIST_1B_SEMI_MAJOR_AXIS_AU, eccentricity: 0 },
...overrides
};
}
/** Marker position for the system's single exoplanet at a given Julian date. */
function positionAt(renderer: SystemOrbitsRenderer, epochJd: number): THREE.Vector3 {
renderer.update(epochJd);
return renderer.members[0].marker.position.clone();
}
describe('SystemOrbitsRenderer exoplanet propagation', () => {
it('completes exactly one orbit over the measured period', () => {
// The end-to-end check that the period actually reaches the propagator: after one full
// published period the planet must be back where it started.
const renderer = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS })]);
const start = positionAt(renderer, DEFAULT_EPOCH_JD);
const afterOnePeriod = positionAt(renderer, DEFAULT_EPOCH_JD + TRAPPIST_1B_PERIOD_DAYS);
const afterHalfPeriod = positionAt(renderer, DEFAULT_EPOCH_JD + TRAPPIST_1B_PERIOD_DAYS / 2);
expect(afterOnePeriod.distanceTo(start)).toBeLessThan(1e-6);
// Half an orbit of a circle is the far side, a full diameter away.
expect(afterHalfPeriod.distanceTo(start)).toBeCloseTo(2 * TRAPPIST_1B_SEMI_MAJOR_AXIS_AU, 6);
renderer.dispose();
});
it('moves a red dwarf planet more slowly than the old solar-mass assumption did', () => {
// Assuming a solar-mass host made TRAPPIST-1's planets orbit about 3.3x too fast, so the
// corrected planet must have travelled less far after the same elapsed time.
const corrected = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS })]);
const assumingSolar = new SystemOrbitsRenderer([], [exoplanet()]);
const elapsed = TRAPPIST_1B_PERIOD_DAYS / 8;
const correctedTravel = positionAt(corrected, DEFAULT_EPOCH_JD).distanceTo(positionAt(corrected, DEFAULT_EPOCH_JD + elapsed));
const solarTravel = positionAt(assumingSolar, DEFAULT_EPOCH_JD).distanceTo(
positionAt(assumingSolar, DEFAULT_EPOCH_JD + elapsed)
);
expect(correctedTravel).toBeLessThan(solarTravel);
corrected.dispose();
assumingSolar.dispose();
});
it('uses the host star mass when no period is published', () => {
const fromMass = new SystemOrbitsRenderer([], [exoplanet({ hostStarMassSolar: 0.0898 })]);
const fromPeriod = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS })]);
const elapsed = 0.3;
const massTravel = positionAt(fromMass, DEFAULT_EPOCH_JD).distanceTo(positionAt(fromMass, DEFAULT_EPOCH_JD + elapsed));
const periodTravel = positionAt(fromPeriod, DEFAULT_EPOCH_JD).distanceTo(positionAt(fromPeriod, DEFAULT_EPOCH_JD + elapsed));
// The published mass and the period-derived mass agree, so the two must nearly coincide.
expect(massTravel).toBeCloseTo(periodTravel, 4);
fromMass.dispose();
fromPeriod.dispose();
});
it('still renders an exoplanet that has neither a period nor a host mass', () => {
const renderer = new SystemOrbitsRenderer([], [exoplanet()]);
expect(renderer.members).toHaveLength(1);
expect(positionAt(renderer, DEFAULT_EPOCH_JD).length()).toBeCloseTo(TRAPPIST_1B_SEMI_MAJOR_AXIS_AU, 6);
renderer.dispose();
});
it('skips an exoplanet with no semi-major axis rather than crashing', () => {
const renderer = new SystemOrbitsRenderer([], [exoplanet({ orbit: { eccentricity: 0 } })]);
expect(renderer.members).toHaveLength(0);
renderer.dispose();
});
it('keeps every propagated position finite', () => {
const renderer = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS, orbit: { semiMajorAxisAu: TRAPPIST_1B_SEMI_MAJOR_AXIS_AU, eccentricity: 0.62 } })]);
for (const offset of [0, 0.1, 1, 10, 1000]) {
const { x, y, z } = positionAt(renderer, DEFAULT_EPOCH_JD + offset);
expect([x, y, z].every(Number.isFinite)).toBe(true);
}
renderer.dispose();
});
});
@@ -1,7 +1,7 @@
import * as THREE from 'three/webgpu';
import { gmForParent } from '../../shared/astro/constants';
import { orbitEllipsePoints, propagateOrbit, resolveOrbitalElements } from '../../shared/astro/kepler';
import { orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
@@ -160,7 +160,14 @@ export class SystemOrbitsRenderer {
epochJd: exoplanet.orbit.epochJd
});
const radiusKm = exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined;
const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, gmForParent(undefined), radiusKm);
// Not `gmForParent(undefined)`: that assumes a solar-mass host for every system, and
// most exoplanet hosts are red dwarfs a fraction of the Sun's mass.
const gm = resolveGravitationalParameter({
semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu,
periodDays: exoplanet.periodDays,
hostStarMassSolar: exoplanet.hostStarMassSolar
});
const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, gm, radiusKm);
members.push({ id: exoplanet.id, kind: 'exoplanet', marker: tracked.marker });
}