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 } : {}),