Give every body the period it is drawn going round in, and say where its orbit comes from

Audit #38: Europa's card listed its axis, eccentricity and inclination but no period, while the
scene turned it round Jupiter all the same: heliocentricPeriodDays refused every moon, since the
catalogue carried no planet masses.

Every solar-system body's period is now 360 over the JPL mean motion that carries it round the
scene, filed under Measured since that is JPL's published figure: Europa 3.55 d, the Moon 27.3 d,
Saturn 29.5 yr on the live cards, Earth 365.2564 d. heliocentricPeriodDays is gone. Exoplanets
keep the archive's period, or none.

The card's provenance line now ends with where the orbit comes from, "Orbit: JPL SSD satellite
mean elements, epoch 1997 Jan 16." for Europa, "Orbit: JPL approximate mean elements (Standish),
fit for 3000 BC to AD 3000." for a planet, and the Sun's system note says so too: "Orbits
propagated from JPL mean elements, the planets' fit for 3000 BC to AD 3000, to the current
date." Other systems keep "published elements". All three read in the running app.

Each has a test that fails without it (moons refused a period, provenance without the orbit, a
note without the source).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 20:20:51 +02:00
co-authored by Claude Opus 5.5
parent 48319c3fe2
commit 2e5daa0f97
6 changed files with 43 additions and 55 deletions
@@ -33,9 +33,8 @@ export interface BodyDetailViewModel {
/** True when a real photograph is being shown rather than the derived surface. */
hasPhotography: boolean;
/**
* Sidereal orbital period. Measured where the archive published one; otherwise derived from the
* semi-major axis for heliocentric orbits, where the central mass is known exactly. Undefined
* when neither applies — see `heliocentricPeriodDays`.
* Sidereal orbital period. For a solar-system body, 360 degrees over JPL's published mean
* motion; for an exoplanet, the archive's period where it published one, and undefined where not.
*/
orbitalPeriodDays?: number;
/**
@@ -44,4 +43,6 @@ export interface BodyDetailViewModel {
* derived surface as a photograph.
*/
orbitalPeriodSource?: 'measured' | 'derived';
/** Where the orbit comes from and the span it holds over; see `BodyRecord.orbitSource`. */
orbitSource?: string;
}
@@ -65,7 +65,8 @@ export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts {
derived.push({ label: 'Bulk density', value: formatDensity(body.appearance.bulkDensityGramsPerCm3) });
}
return { kindLabel: KIND_LABELS[body.kind], measured, derived, provenance: provenanceFor(body) };
const provenance = body.orbitSource ? `${provenanceFor(body)} Orbit: ${body.orbitSource}.` : provenanceFor(body);
return { kindLabel: KIND_LABELS[body.kind], measured, derived, provenance };
}
/**
@@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest';
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model';
import { buildBodyViewModel, heliocentricPeriodDays } from './body-view-model';
import { bodyReadouts } from './body-readouts';
import { buildBodyViewModel } from './body-view-model';
const orbit = (overrides: Partial<OrbitalElements> = {}): OrbitalElements => ({
semiMajorAxisAu: 1,
@@ -51,43 +52,24 @@ const luna: BodyRecord = {
orbitSource: 'JPL SSD satellite mean elements, epoch 2000 Jan 1',
};
describe('heliocentricPeriodDays', () => {
it('recovers a known period from the semi-major axis alone', () => {
// P² = a³ in these units, so Earth must come back a year.
expect(heliocentricPeriodDays(earth)).toBeCloseTo(365.25, 1);
});
it('scales as the three-halves power', () => {
const jupiter: BodyRecord = {
...earth,
id: 'jupiter',
name: 'Jupiter',
orbit: orbit({ semiMajorAxisAu: 5.2044 }),
};
// Jupiter's real sidereal period is 4332.6 days.
expect(heliocentricPeriodDays(jupiter)).toBeCloseTo(4335, -1);
});
it('refuses to compute a period for a moon', () => {
// A moon's elements are relative to its planet, whose mass is not in the catalogue — the
// same arithmetic would be wrong by the ratio of that planet's mass to the Sun's.
expect(heliocentricPeriodDays(luna)).toBeUndefined();
});
});
describe('buildBodyViewModel', () => {
const catalogues = { bodies: [earth, luna], exoplanets: [] as ExoplanetRecord[], stars: [sun] };
it('marks a period computed from the semi-major axis as derived', () => {
it('gives a planet the sidereal year its published mean motion goes round in', () => {
const model = buildBodyViewModel('earth', catalogues);
expect(model?.orbitalPeriodSource).toBe('derived');
expect(model?.orbitalPeriodDays).toBeCloseTo(365.25, 1);
expect(model?.orbitalPeriodSource).toBe('measured');
expect(model?.orbitalPeriodDays).toBeCloseTo(365.2564, 4);
});
it('leaves a moon without a period rather than inventing one', () => {
it('gives a moon its period too, from the same mean motion that carries it round', () => {
// The card used to refuse, while the scene turned the Moon round the Earth all the same.
const model = buildBodyViewModel('luna', catalogues);
expect(model?.orbitalPeriodDays).toBeUndefined();
expect(model?.orbitalPeriodSource).toBeUndefined();
expect(model?.orbitalPeriodSource).toBe('measured');
expect(model?.orbitalPeriodDays).toBeCloseTo(27.32166, 5);
});
it('says where the orbit comes from, in the card’s provenance', () => {
expect(bodyReadouts(buildBodyViewModel('luna', catalogues)!).provenance).toContain('Orbit: JPL SSD satellite mean elements, epoch 2000 Jan 1.');
});
it('marks a published exoplanet period as measured, not derived', () => {
@@ -4,7 +4,7 @@ import { luminositySolar } from '../../shared/astro/stellar';
import { bodyTexturePath } from '../../shared/rendering/texture-catalog';
import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model';
import { StarRecord } from '../../shared/models/star.model';
import { BodyDetailViewModel } from './body-detail.model';
/** Everything the view model is assembled from — the three catalogues, already loaded. */
@@ -41,7 +41,10 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body
const body = catalogues.bodies.find((candidate) => candidate.id === id);
if (body) {
const hostStar = catalogues.stars.find((star) => star.id === body.systemStarId);
const periodDays = heliocentricPeriodDays(body);
// The period the map draws, moons included: JPL's own mean motion, which is also what
// carries the body round the scene. Europa's card had no period at all while the scene
// turned it round Jupiter in 3.55 days.
const periodDays = 360 / body.rates.meanMotionDegPerDay;
return {
id: body.id,
name: body.name,
@@ -53,7 +56,8 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body
appearance: appearanceForBody(body, catalogues.bodies, luminosityOf(hostStar)),
hasPhotography: bodyTexturePath(body.id) !== undefined,
orbitalPeriodDays: periodDays,
orbitalPeriodSource: periodDays === undefined ? undefined : 'derived',
orbitalPeriodSource: 'measured',
orbitSource: body.orbitSource,
};
}
@@ -81,19 +85,3 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body
orbitalPeriodSource: exoplanet.periodDays === undefined ? undefined : 'measured',
};
}
/**
* Kepler's third law for a body orbiting the Sun: P² = a³ with P in years and a in AU, which
* holds exactly in these units because the Sun's mass is the unit of mass.
*
* Only for heliocentric orbits. A moon's elements are relative to its parent planet, whose mass
* the catalogue does not carry, so the same arithmetic there would be wrong by the ratio of the
* planet's mass to the Sun's — a factor of a thousand for Jupiter.
*/
export function heliocentricPeriodDays(body: BodyRecord): number | undefined {
if (body.parentBodyId !== undefined || body.systemStarId !== SUN_STAR_ID) {
return undefined;
}
const a = body.orbit.semiMajorAxisAu;
return a > 0 ? Math.pow(a, 1.5) * 365.25 : undefined;
}
@@ -831,6 +831,19 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
expect(navigationStore.viewLevel()).toBe('system');
});
it('says where a system’s orbits come from, and for the Sun how long they hold', async () => {
const note = (): string => (fixture.componentInstance as unknown as { hudNote: () => string }).hudNote();
navigationStore.selectStar(SUN.id);
await flushAsync();
await advanceFrames(engine, 2.5);
expect(note()).toBe('Orbits propagated from JPL mean elements, the planets’ fit for 3000 BC to AD 3000, to the current date.');
navigationStore.selectStar(ALPHA_CENTAURI.id);
await flushAsync();
await advanceFrames(engine, 5);
expect(note()).toBe('Orbits propagated from published elements to the current date.');
});
it('performs the floating-origin recenter: the camera lands close to the AU-space origin, not out at parsec-scale coordinates', async () => {
navigationStore.selectStar(ALPHA_CENTAURI.id);
await flushAsync();
@@ -1760,7 +1760,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }]
: []),
]);
this.hudNote.set(this.time.atNow() ? 'Orbits propagated from published elements to the current date.' : 'Orbits propagated from published elements to the date on the clock.');
// Where the orbits come from, and for the Sun how far from the present they hold: each
// body's card names its own source and epoch.
const source = this.bodies.some((body) => body.systemStarId === star.id) ? 'JPL mean elements, the planets’ fit for 3000 BC to AD 3000,' : 'published elements';
this.hudNote.set(`Orbits propagated from ${source} to ${this.time.atNow() ? 'the current date' : 'the date on the clock'}.`);
this.hudRange.set(
formatAu(
this.engine.visibleHalfHeight(