From dadae7b8ca59db47bcb4e27b69da167a040305dd Mon Sep 17 00:00:00 2001 From: Senrokai Date: Wed, 30 Sep 2026 16:12:30 +0200 Subject: [PATCH] Give Haumea's card its three semi-axes beside its mean radius Haumea is triaxial, 1161 x 852 x 513 km (Ortiz et al. 2017, Nature 550, 219), and is drawn as the sphere of its volume, 797.6 km. Its card listed "Radius 798 km" under Measured and said nothing of its shape, which only a code comment and a commit message gave: its long semi-axis is 1.46 times that radius and its short one 0.64. BodyRecord takes semiAxesKm, set by the ETL from Haumea's spec, and the card then reads "Mean radius 798 km" and "Semi-axes 1,161 x 852 x 513 km". Every other body keeps its one Radius row. The ETL checks that a body's radius is the mean of its semi-axes, the radius of the sphere of the same volume, to 0.1 per cent (797.6 against 797.62). Measured on :4301, Haumea's page: "Mean radius 798 km, Semi-axes 1,161 x 852 x 513 km". Test: the card of Haumea as shipped in bodies.json. Guarded mutants: the semi-axes dropped from the spec (run through the solar ETL), the row, or the view model, or the label left as Radius, each fail it and only it; a radius that is not their mean fails the ETL ("Haumea's radius, 1161 km, is not the mean of its semi-axes"). Horizons gives triaxial radii for Phobos, Deimos, Miranda and Ariel too, which the page parser already reads into their mean; they are not carried here. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/features/body-detail/body-detail.model.ts | 2 ++ src/app/features/body-detail/body-readouts.ts | 6 +++++- .../features/body-detail/body-view-model.spec.ts | 15 +++++++++++++++ src/app/features/body-detail/body-view-model.ts | 1 + src/app/shared/models/body.model.ts | 7 +++++++ src/assets/data/bodies.json | 5 +++++ tools/etl/build.ts | 9 +++++++++ tools/etl/fetchSolarSystem.ts | 5 ++++- 8 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/app/features/body-detail/body-detail.model.ts b/src/app/features/body-detail/body-detail.model.ts index f6709d8..0d53215 100644 --- a/src/app/features/body-detail/body-detail.model.ts +++ b/src/app/features/body-detail/body-detail.model.ts @@ -20,6 +20,8 @@ export interface BodyDetailViewModel { */ hostStarId?: number; radiusKm?: number; + /** A triaxial body's semi-axes, where `radiusKm` is the mean of them; see `BodyRecord.semiAxesKm`. */ + semiAxesKm?: readonly [number, number, number]; massEarth?: number; discoveryYear?: number; orbit: Partial; diff --git a/src/app/features/body-detail/body-readouts.ts b/src/app/features/body-detail/body-readouts.ts index 3006971..57c8b3d 100644 --- a/src/app/features/body-detail/body-readouts.ts +++ b/src/app/features/body-detail/body-readouts.ts @@ -31,7 +31,11 @@ export interface BodyReadouts { export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts { const measured: Readout[] = []; if (body.radiusKm !== undefined) { - measured.push({ label: 'Radius', value: formatRadiusKm(body.radiusKm) }); + // A triaxial body is drawn as the sphere of its volume; a radius alone would hide its shape. + measured.push({ label: body.semiAxesKm ? 'Mean radius' : 'Radius', value: formatRadiusKm(body.radiusKm) }); + } + if (body.semiAxesKm) { + measured.push({ label: 'Semi-axes', value: `${body.semiAxesKm.map((axis) => axis.toLocaleString('en-GB')).join(' × ')} km` }); } if (body.massEarth !== undefined) { measured.push({ label: 'Mass', value: formatMassEarth(body.massEarth) }); diff --git a/src/app/features/body-detail/body-view-model.spec.ts b/src/app/features/body-detail/body-view-model.spec.ts index 21ee800..889e0ae 100644 --- a/src/app/features/body-detail/body-view-model.spec.ts +++ b/src/app/features/body-detail/body-view-model.spec.ts @@ -1,3 +1,6 @@ +/// + +import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { BodyRecord, OrbitalElements } from '../../shared/models/body.model'; @@ -80,6 +83,18 @@ describe('buildBodyViewModel', () => { expect(bodyReadouts(model).measured.find((row) => row.label === 'Eccentricity')?.value).toBe('0.105'); }); + it('gives a triaxial body its semi-axes beside its mean radius, not a radius alone', () => { + // As shipped: Haumea's shape (Ortiz et al. 2017) travels from the ETL's spec to its card. + const shipped: BodyRecord[] = JSON.parse(readFileSync(`${process.cwd()}/src/assets/data/bodies.json`, 'utf8')); + const haumea = shipped.find((body) => body.id === 'haumea')!; + const measured = bodyReadouts(buildBodyViewModel('haumea', { ...catalogues, bodies: [earth, haumea] })!).measured; + expect(measured.find((row) => row.label === 'Mean radius')?.value).toBe('798 km'); + expect(measured.find((row) => row.label === 'Semi-axes')?.value).toBe('1,161 × 852 × 513 km'); + expect(measured.find((row) => row.label === 'Radius')).toBeUndefined(); + // Every other body keeps its one radius. + expect(bodyReadouts(buildBodyViewModel('earth', catalogues)!).measured.find((row) => row.label === 'Radius')?.value).toBe('6,371 km'); + }); + 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.'); }); diff --git a/src/app/features/body-detail/body-view-model.ts b/src/app/features/body-detail/body-view-model.ts index 6e9a059..0b07980 100644 --- a/src/app/features/body-detail/body-view-model.ts +++ b/src/app/features/body-detail/body-view-model.ts @@ -52,6 +52,7 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body hostStarName: hostStar?.name ?? 'Unknown star', hostStarId: body.systemStarId, radiusKm: body.radiusKm, + semiAxesKm: body.semiAxesKm, orbit: body.measuredEccentricity === undefined ? body.orbit : { ...body.orbit, eccentricity: body.measuredEccentricity }, appearance: appearanceForBody(body, catalogues.bodies, luminosityOf(hostStar)), hasPhotography: bodyTexturePath(body.id) !== undefined, diff --git a/src/app/shared/models/body.model.ts b/src/app/shared/models/body.model.ts index 8fd579d..b25c3bb 100644 --- a/src/app/shared/models/body.model.ts +++ b/src/app/shared/models/body.model.ts @@ -50,7 +50,14 @@ export interface BodyRecord { systemStarId: number; name: string; kind: 'planet' | 'moon' | 'dwarf'; + /** Mean radius: for a triaxial body, the radius of the sphere of its volume, which is how it is drawn. */ radiusKm: number; + /** + * A triaxial body's three semi-axes, in km, largest first, where its shape is too far from a + * sphere for one radius to say it: Haumea's 1161 x 852 x 513 (Ortiz et al. 2017), whose mean + * radius is 798. + */ + semiAxesKm?: readonly [number, number, number]; /** Mean elements at `orbit.epochJd`, moving at `rates`. */ orbit: OrbitalElements; rates: MeanElementRates; diff --git a/src/assets/data/bodies.json b/src/assets/data/bodies.json index fe24818..978b3d1 100644 --- a/src/assets/data/bodies.json +++ b/src/assets/data/bodies.json @@ -548,6 +548,11 @@ "name": "Haumea", "kind": "dwarf", "radiusKm": 797.6, + "semiAxesKm": [ + 1161, + 852, + 513 + ], "orbit": { "semiMajorAxisAu": 43.06029023650952, "eccentricity": 0.1944430148898797, diff --git a/tools/etl/build.ts b/tools/etl/build.ts index b5ceba1..2d7a260 100644 --- a/tools/etl/build.ts +++ b/tools/etl/build.ts @@ -325,6 +325,15 @@ function validateBodies(bodies: BodyRecord[], horizonsOrbits: Map 0, `Body ${body.id} has no radius; its page states it in a form the ETL does not read.`); + // A triaxial body's card gives its mean radius beside its semi-axes, so the two must agree: the + // radius of the sphere of the same volume. Measured: Haumea's 797.6 against 797.62. + if (body.semiAxesKm) { + const volumeRadius = Math.cbrt(body.semiAxesKm[0] * body.semiAxesKm[1] * body.semiAxesKm[2]); + assertCondition( + Math.abs(volumeRadius / body.radiusKm - 1) < 0.001, + `${body.name}'s radius, ${body.radiusKm} km, is not the mean of its semi-axes ${body.semiAxesKm.join(' x ')}, ${volumeRadius.toFixed(1)} km.` + ); + } const rotation = body.rotationalElements; assertCondition( diff --git a/tools/etl/fetchSolarSystem.ts b/tools/etl/fetchSolarSystem.ts index 984fb79..a0b2d5c 100644 --- a/tools/etl/fetchSolarSystem.ts +++ b/tools/etl/fetchSolarSystem.ts @@ -34,6 +34,8 @@ interface BodySpec { sbdb?: string; /** A measured mean radius, in km, for a body neither Horizons nor the SBDB gives one for. */ radiusKm?: number; + /** A triaxial body's semi-axes, in km, largest first, where its card should give its shape; see `BodyRecord.semiAxesKm`. */ + semiAxesKm?: [number, number, number]; /** A measured sidereal day, in hours, where a later measurement overturns the one its source gives. */ rotationPeriodHours?: number; /** The eccentricity for the card, where the row the orbit is drawn from gives an outdated one; see `BodyRecord.measuredEccentricity`. */ @@ -116,7 +118,7 @@ const BODY_SPECS: BodySpec[] = [ // +/- 0.08 hour single peak again with TESS and Gaia, cannot confirm the 22.8, and take 11.4 as // their default. Neither overturns the other; the SBDB's 22.83 is kept, and may be twice the day. { id: 'eris', name: 'Eris', kind: 'dwarf', horizonsCommand: '136199;', center: '500@10', sbdb: 'Eris', radiusKm: 1163, rotationPeriodHours: 15.771 * 24 }, - { id: 'haumea', name: 'Haumea', kind: 'dwarf', horizonsCommand: '136108;', center: '500@10', sbdb: 'Haumea', radiusKm: 797.6 }, + { id: 'haumea', name: 'Haumea', kind: 'dwarf', horizonsCommand: '136108;', center: '500@10', sbdb: 'Haumea', radiusKm: 797.6, semiAxesKm: [1161, 852, 513] }, { id: 'makemake', name: 'Makemake', kind: 'dwarf', horizonsCommand: '136472;', center: '500@10', sbdb: 'Makemake', radiusKm: 715 }, { id: 'moon', name: 'Moon', kind: 'moon', horizonsCommand: '301', center: '500@399', parentBodyId: 'earth' }, { id: 'phobos', name: 'Phobos', kind: 'moon', horizonsCommand: '401', center: '500@499', parentBodyId: 'mars', orbitFromW: {} }, @@ -271,6 +273,7 @@ export async function fetchSolarSystem(): Promise<{ bodies: BodyRecord[]; horizo name: spec.name, kind: spec.kind, radiusKm: radiusKm ?? 0, + ...(spec.semiAxesKm ? { semiAxesKm: spec.semiAxesKm } : {}), orbit: mean.orbit, rates: mean.rates, ...(mean.laplacePole ? { laplacePole: mean.laplacePole } : {}),