Files
star-map/tools/etl/build.ts
T
SenrokaiandClaude Opus 5.5 a281f22f34 Measure every moon and dwarf planet against Horizons from 1950 to 2100, and say on its card how far it strays
The clock reaches AD 1 to AD 3000, but only the planets' cards named a span their elements hold
over; the 25 moons and the four SBDB dwarf planets gave a source and an epoch, though
BodyRecord.orbitSource is documented as "the span they hold over". And the worst offsets the ETL
stated came from twelve New Year's Days: Nereid's year is 360 days, so all twelve fell far from
its periapsis, where a mean ellipse is furthest out. The one date the ETL checked, 2025-01-01, saw
Nereid at 2.6 degrees; it reaches 11.19.

For each moon and each SBDB dwarf planet the ETL now fetches Horizons' ICRF vectors from 1950 to
2100, every other day (daily for Nereid, at an eccentricity of 0.75, and Hyperion, whose row's
eccentricity is a quarter of its real one: every other day gave it 22.14, daily 22.23), and
measures how far the mean elements stray, at the same TDB dates. The card appends it: "JPL SBDB
osculating elements, epoch 2026 Jun 9, within 7.2 degrees of Horizons from 1950 to 2100". Worst
offsets on the real catalogue: the Moon 2.62 (2010 March 27), Phoebe 2.58 (1969, where a comment
claimed "within 2.0"), Phobos 1.26, Mimas 7.43, Iapetus 10.34, Nereid 11.19 (2039 Nov 1),
Hyperion 22.23 (2055 Feb 26), Ceres 7.12 (1953); Io 0.07, Titan 0.06, Eris 0.06.

build.ts recomputes each from the same Horizons positions and fails if an orbit other than
Standish's names no span, if a card states less than it strays, or if a body passes its ceiling:
3 degrees, and Hyperion 23, Nereid 12, Iapetus 11, Mimas 8 and Ceres 8, each explained. The
2025-01-01 check stays for reading errors, its comment no longer passing one date's offsets off as
worst ones. The Sun's note says the moons' and those four's elements were checked from 1950 to
2100, and the date field's description that each card says how far its orbit strays over that span.
In the running app Ceres's, Phobos's and Nereid's cards end "within 7.2", "1.3" and "11.2 degrees
of Horizons from 1950 to 2100".

The CLOCK_WINDOW comment also had the calendars the wrong way at AD 1: proleptic Gregorian dates
are two days behind the Julian calendar there, level from AD 200 to 300, and ten days ahead by
1582. It now says so, and names Ceres's drift where it named Phobos's, which its orbit now carries.

Controls: the ETL measuring nothing fails ("Ceres's orbit ... names no span it holds over"),
rounding the stated figure down fails on Ceres (7.1 against 7.12), and Nereid held to the general
ceiling fails at 11.19; the note and the date field without the span fail their named tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:57:16 +02:00

496 lines
29 KiB
TypeScript

import { statSync } from 'node:fs';
import { BodyRecord, OrbitalElements } from '../../src/app/shared/models/body.model';
import { eclipticToEquatorial, laplacePlaneToEquatorial, raDecToUnitVector } from '../../src/app/shared/astro/coordinates';
import { meanElementsAt, positionAtEpoch } from '../../src/app/shared/astro/kepler';
import { orientationAt } from '../../src/app/shared/astro/rotational-elements';
import { DeepSkyRecord } from '../../src/app/shared/models/deepsky.model';
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
import { fetchDeepSky } from './fetchDeepSky';
import { fetchExoplanets } from './fetchExoplanets';
import { fetchSolarSystem, FREELY_SPINNING_MOONS, offsetFromTrackDeg } from './fetchSolarSystem';
import { TrackPoint } from './lib/horizons';
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
import { fetchStars } from './fetchStars';
import { describeSources } from './sources/registry';
import { dataPath } from './lib/paths';
class ValidationError extends Error {}
function assertCondition(condition: boolean, message: string): void {
if (!condition) {
throw new ValidationError(message);
}
}
function validateStars(stars: StarRecord[]): void {
assertCondition(stars.length > 0, 'No stars were produced.');
const ids = new Set<number>();
for (const star of stars) {
assertCondition(Number.isFinite(star.id), `Star has a non-numeric id: ${JSON.stringify(star)}`);
assertCondition(!ids.has(star.id), `Duplicate star id: ${star.id}`);
ids.add(star.id);
assertCondition(!!star.name, `Star ${star.id} has no name.`);
assertCondition([star.x, star.y, star.z].every(Number.isFinite), `Star ${star.id} has a non-finite position.`);
}
const positionBytes = statSync(dataPath('stars.bin')).size;
assertCondition(positionBytes === stars.length * BYTES_PER_STAR_POSITION, `stars.bin size (${positionBytes}) does not match ${stars.length} stars.`);
const metaBytes = statSync(dataPath('stars-meta.bin')).size;
assertCondition(metaBytes === stars.length * BYTES_PER_STAR_META, `stars-meta.bin size (${metaBytes}) does not match ${stars.length} stars.`);
// Round-trips the written assets back through the decoder the app uses, so a format change
// that only half-lands fails here rather than as a silently wrong star map.
const { index, positions, meta } = encodeStarCatalog(stars);
const decoded = decodeStarCatalog(index, positions, meta);
assertCondition(decoded.length === stars.length, `Star catalogue round-trip lost records: ${decoded.length} of ${stars.length}.`);
for (let i = 0; i < stars.length; i++) {
assertCondition(decoded[i].id === stars[i].id && decoded[i].name === stars[i].name, `Star catalogue round-trip altered record ${i}.`);
assertCondition(decoded[i].spectralType === stars[i].spectralType, `Star catalogue round-trip lost the spectral type of star ${stars[i].id}.`);
assertCondition(decoded[i].colorIndex === null === (stars[i].colorIndex === null), `Star catalogue round-trip changed whether star ${stars[i].id} has a colour index.`);
}
}
/**
* What a good merge looks like, in two numbers the unit suite cannot see.
*
* The catalogues are regenerated by a scheduled job that pushes straight to `main` once the unit
* tests and a production build pass — and both passed, for weeks, on a catalogue carrying 23 000
* stars twice: the suite tests code against fixtures, and no fixture is 400 000 real stars. The
* two ways the merge has actually failed both show up here.
*
* A star kept twice leaves its two entries near each other on the sky, from *different* sources —
* one catalogue does not list a star twice. Under an arcsecond that is never two stars at this
* depth, so every such pair is a miss. Nineteen survive today, all of them a second HYG row
* wanting a Gaia entry that already absorbed one (Gliese lists some doubles twice); the merge
* that trusted a Hipparcos parallax over direction left 1 112.
*
* The other failure leaves no close pair at all, because proper motion had already carried the
* two entries tens of arcseconds apart — the 2026-08-24 refresh, where HYG sat at epoch 2000.0
* and Gaia at J2016.0. What it does leave is HYG rows that found no counterpart: 36 056 of them
* against the 10 886 today, and no counterpart was possible for most of those. Two thirds of them,
* 6 835, are the stars Gaia measures but the main query never downloads, because Gaia's parallax
* puts them past `ETL_GAIA_DISTANCE_PC` while Hipparcos put them inside `ETL_STAR_DISTANCE_PC`;
* they are every star in the published catalogue beyond 250 pc. The rest are what Gaia genuinely
* lacks: bright stars it saturates on, red dwarfs past its magnitude cut. So the headroom left to
* the ceiling tracks the gap between those two cutoffs as much as Gaia's completeness.
*
* This bounds a merge that went wrong, and — loosely — a Gaia download that came back short: a
* truncated answer leaves the HYG rows whose counterpart it dropped without one, so survivors go
* *up*, not down. Measured against the published catalogue: 10 886 today, 11 004 at nine tenths of
* the rows, 12 711 at half, 16 258 at a third. So this ceiling only catches a truncation past about
* two thirds, and `fetchGaiaStars` catches the shallower ones with its own row floor.
*/
const MAX_UNMERGED_TWINS = 100;
const MAX_HYG_SURVIVORS = 15_000;
const TWIN_TOLERANCE_RAD = (1 / 3600) * (Math.PI / 180);
function validateMerge(stars: StarRecord[]): void {
// Checked first and on its own: an unreachable Gaia is skipped rather than thrown, and would
// otherwise surface below as "68 000 HYG stars found no counterpart" — true, and no help.
assertCondition(
stars.some((star) => star.source === 'gaia'),
'Gaia DR3 contributed no stars — the archive was unreachable or returned nothing, and a catalogue without it is not one to publish.'
);
const survivors = stars.filter((star) => star.source === 'hyg').length;
assertCondition(
survivors <= MAX_HYG_SURVIVORS,
`${survivors} HYG stars found no Gaia counterpart (at most ${MAX_HYG_SURVIVORS} expected) — the two catalogues are not being matched.`
);
// Sorted by declination, so each star is only compared against the handful sharing its
// parallel — an arcsecond of declination holds one or two of 400 000 stars.
const byDec = stars
.map((star) => {
const distance = Math.hypot(star.x, star.y, star.z);
return { star, distance, dec: distance === 0 ? 0 : Math.asin(Math.max(-1, Math.min(1, star.z / distance))) };
})
.filter((entry) => entry.distance > 0)
.sort((a, b) => a.dec - b.dec);
const cosTolerance = Math.cos(TWIN_TOLERANCE_RAD);
let twins = 0;
let example = '';
for (let i = 0; i < byDec.length; i++) {
const a = byDec[i];
for (let j = i + 1; j < byDec.length && byDec[j].dec - a.dec <= TWIN_TOLERANCE_RAD; j++) {
const b = byDec[j];
if (a.star.source === b.star.source) {
continue;
}
const cosine = (a.star.x * b.star.x + a.star.y * b.star.y + a.star.z * b.star.z) / (a.distance * b.distance);
if (cosine >= cosTolerance) {
twins++;
example ||= `${a.star.name} (${a.star.source}) and ${b.star.name} (${b.star.source})`;
}
}
}
assertCondition(
twins <= MAX_UNMERGED_TWINS,
`${twins} stars from different catalogues sit within an arcsecond of each other (at most ${MAX_UNMERGED_TWINS} expected), starting with ${example} — the merge is keeping the same star twice.`
);
console.log(` ${survivors} HYG stars have no Gaia counterpart; ${twins} unmerged cross-catalogue pairs within an arcsecond.`);
}
/**
* How far a body's mean elements may put it from where Horizons has it, on the one date the ETL
* asks Horizons about (2025-01-01), seen from the Sun for a planet and from its planet for a moon.
*
* Measured on this catalogue: the planets at most 0.10 degrees (Uranus; Standish's own stated
* error for his fit is 2 000 arcseconds, 0.56 degrees), the moons at most 1.41 (the Moon, whose
* evection and variation, 1.27 and 0.66 degrees, no mean ellipse has). What this catches is a
* table read wrongly: a moon read against the ecliptic instead of its Laplace plane, a precession
* run the wrong way, or a column taken for its neighbour, which put Triton 26 degrees out and Io
* 0.9.
*/
const MAX_PLANET_OFFSET_DEG = 0.25;
/** Measured on this catalogue: at most 0.0151 (Phoebe and the Moon) once Hyperion prints its current 0.105. */
const MAX_ECCENTRICITY_OFFSET = 0.03;
const MAX_MOON_OFFSET_DEG = 2.5;
const KM_PER_AU = 149597870.7;
const DEG_TO_RAD = Math.PI / 180;
/**
* The moons whose table row cannot come within that on this one date, each with a ceiling just
* above its offset here; see {@link TRACK_OFFSET_CEILINGS_DEG} for what they reach from 1950 to 2100.
*/
const MOON_OFFSET_CEILINGS_DEG: Record<string, number> = { hyperion: 21, iapetus: 11, nereid: 3 };
/**
* How far a moon's or dwarf planet's orbit may stray from Horizons from 1950 to 2100, sampled every
* other day (Nereid and Hyperion daily). One date showed each at its best: twelve New Year's Days
* gave Nereid 2.6 degrees, and 2025-01-01 alone is all the check above sees. Each card says how
* far its own orbit strays over the span (`fetchSolarSystem`), and this holds that figure to
* account.
*
* Measured on this catalogue: at most 2.62 degrees (the Moon, 2010 March 27: no mean ellipse has
* its evection or variation; Phoebe reaches 2.58 in 1969, where "within 2.0" was once claimed for
* it). Five need their own:
*
* - Hyperion, 22.23 (2055 Feb 26): held in a 4:3 resonance by Titan; the row's eccentricity,
* 0.0232, is less than a quarter of the 0.105 JPL's current table gives.
* - Nereid, 11.19 (2039 Nov 1): an eccentricity of 0.75, the largest here, which a mean ellipse
* follows least well near periapsis, where the true anomaly runs ten times faster than the mean;
* its 360-day year kept every New Year's Day far from one.
* - Iapetus, 10.34: the row sits 9.4 degrees behind Horizons at its own epoch, 2000 Jan 1.5, and
* keeps that offset; its plane agrees with Horizons' to 0.07 degrees and its period to 0.001 per
* cent, so the fault is in the row's longitude, which this has no second source to correct.
* - Mimas, 7.43: its orbit carries the 44-degree libration of its resonance with Tethys (see
* `orbitFromW` in `fetchSolarSystem.ts`), but not the rest of what Horizons integrates.
* - Ceres, 7.12 (1953): the SBDB's elements are osculating, exact at 2026 Jun 9 and drifting
* either side; 1.9 by 2050, 5.3 by 2100, and 39 at 1600 on Horizons' own figures.
*/
const MAX_TRACK_OFFSET_DEG = 3;
const TRACK_OFFSET_CEILINGS_DEG: Record<string, number> = { hyperion: 23, nereid: 12, iapetus: 11, mimas: 8, ceres: 8 };
/**
* The bodies the IAU WGCCRE 2015 report gives no rotational elements for: Hyperion tumbles, and
* Nereid, Eris, Haumea and Makemake have no model. Every other body must carry them, or the
* kernel was read wrongly and the body would be drawn on an invented pole.
*/
const WITHOUT_ROTATIONAL_ELEMENTS = new Set(['hyperion', 'nereid', 'eris', 'haumea', 'makemake']);
/**
* 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
* against 9.92510). Neptune is 0.89 per cent out, because the report takes 15.9663 hours from the
* cloud features Karkoschka (2011) tracked, where Horizons keeps Voyager's radio period, 16.11. What
* this catches is a rate read in the wrong unit or for the wrong body: Oberon's day for Titania's is
* 55 per cent out.
*/
const MAX_DAY_OFFSET = 1e-4;
const DAY_OFFSET_CEILINGS: Record<string, number> = { neptune: 0.01 };
/**
* How far the tilt of the IAU's spin axis from the orbit may be from the obliquity Horizons
* states. The axis is the IAU's pole, turned end for end where W runs backwards: the report names
* a planet's north pole by the side of the solar system it lies on, whichever way the planet turns.
* Measured on this catalogue: at most 0.058 degrees (Venus, 177.358 against 177.3). Taken as the
* pole alone, Venus comes out at 2.6 degrees and Uranus at 82.2, which is what this catches.
*/
const MAX_OBLIQUITY_OFFSET_DEG = 0.1;
/**
* How far from its planet a locked moon's drawn face may turn: the east longitude, on the IAU's
* body-fixed frame, of the direction to the planet from where the mean elements put the moon,
* sampled every 135 days from 1950 to 2100, where both the tables and the IAU's elements hold.
*
* Measured on this catalogue: at most 6.70 degrees (the Moon, whose longitude swings 6.3 either
* way with its eccentricity; Horizons has the same). Three need their own: Mimas 10.15, whose
* physical libration W carries and Horizons shows as 5 to 9 degrees at its true place; Iapetus
* 18.33, whose row sits 9.4 degrees behind Horizons; and Proteus 8.18, whose W turns 6.3e-7 of
* its rate slower than its orbit, a drift of 74 degrees by AD 3000. What this catches is an orbit
* and a W that go round at different rates: the tidal acceleration W carried and the orbit did not
* turned Phobos 13.8 degrees from Mars by 2100, and the Mimas-Tethys libration Mimas 54.5.
*/
const MAX_SUB_PLANET_LONGITUDE_DEG = 7;
const SUB_PLANET_CEILINGS_DEG: Record<string, number> = { mimas: 11, iapetus: 19, proteus: 9 };
const LOCK_DATES_JD = Array.from({ length: 407 }, (_, index) => 2433282.5 + index * 135);
/** The planet's east longitude on a moon's IAU body-fixed frame, from the moon's mean place, at a TDB date. */
function subPlanetLongitudeDeg(body: BodyRecord, jd: number): number {
const own = positionAtEpoch(meanElementsAt(body.orbit, body.rates, jd));
const place = body.laplacePole ? laplacePlaneToEquatorial(own, body.laplacePole) : eclipticToEquatorial(own);
const { poleRaDeg, poleDecDeg, primeMeridianDeg } = orientationAt(body.rotationalElements!, jd);
const pole = { raDeg: poleRaDeg, decDeg: poleDecDeg };
const w = primeMeridianDeg * DEG_TO_RAD;
const meridian = laplacePlaneToEquatorial({ x: Math.cos(w), y: Math.sin(w), z: 0 }, pole);
const east = laplacePlaneToEquatorial({ x: -Math.sin(w), y: Math.cos(w), z: 0 }, pole);
const along = (axis: { x: number; y: number; z: number }) => -(place.x * axis.x + place.y * axis.y + place.z * axis.z);
return Math.atan2(along(east), along(meridian)) / DEG_TO_RAD;
}
function angleBetweenDeg(a: { x: number; y: number; z: number }, b: { x: number; y: number; z: number }): number {
const cosine = (a.x * b.x + a.y * b.y + a.z * b.z) / (Math.hypot(a.x, a.y, a.z) * Math.hypot(b.x, b.y, b.z));
return (Math.acos(Math.min(1, Math.max(-1, cosine))) * 180) / Math.PI;
}
function validateBodies(bodies: BodyRecord[], horizonsOrbits: Map<string, OrbitalElements>, horizonsTracks: Map<string, TrackPoint[]>): void {
assertCondition(bodies.length > 0, 'No solar-system bodies were produced.');
const ids = new Set(bodies.map((body) => body.id));
assertCondition(ids.size === bodies.length, 'Duplicate body ids were found.');
const offsets: string[] = [];
const spins: string[] = [];
for (const body of bodies) {
const orbitValues = Object.values(body.orbit);
assertCondition(orbitValues.every(Number.isFinite), `Body ${body.id} has non-finite orbital elements.`);
assertCondition(body.rates.meanMotionDegPerDay > 0, `Body ${body.id} has no mean motion.`);
// Horizons' elements are osculating, exact at their own epoch; both sets are placed there.
const horizons = horizonsOrbits.get(body.id);
assertCondition(horizons !== undefined, `Body ${body.id} has no Horizons elements to be checked against.`);
const truth = eclipticToEquatorial(positionAtEpoch(horizons!));
const mean = positionAtEpoch(meanElementsAt(body.orbit, body.rates, horizons!.epochJd));
const offset = angleBetweenDeg(body.laplacePole ? laplacePlaneToEquatorial(mean, body.laplacePole) : eclipticToEquatorial(mean), truth);
const ceiling = body.kind === 'moon' ? (MOON_OFFSET_CEILINGS_DEG[body.id] ?? MAX_MOON_OFFSET_DEG) : MAX_PLANET_OFFSET_DEG;
assertCondition(
offset <= ceiling,
`${body.name}'s mean elements put it ${offset.toFixed(2)} degrees from where Horizons has it (at most ${ceiling} expected) — the elements were read wrongly.`
);
offsets.push(`${body.id} ${offset.toFixed(3)}`);
// Standish's fit names its own span; every other orbit is measured over 1950-2100, and says so.
const track = horizonsTracks.get(body.id);
assertCondition(
(track !== undefined) === !body.orbitSource.startsWith('JPL approximate mean elements (Standish)'),
`${body.name}'s orbit, "${body.orbitSource}", ${track ? 'names its own span' : 'names no span it holds over'}.`
);
if (track) {
const worst = Math.max(...track.map((point) => offsetFromTrackDeg(body, point)));
const trackCeiling = TRACK_OFFSET_CEILINGS_DEG[body.id] ?? MAX_TRACK_OFFSET_DEG;
const stated = Number(body.orbitSource.match(/within ([\d.]+) degrees of Horizons/)?.[1]);
assertCondition(
worst <= trackCeiling && stated >= worst,
`${body.name}'s mean elements put it up to ${worst.toFixed(2)} degrees from Horizons between 1950 and 2100 (at most ${trackCeiling} expected), and its card says "${body.orbitSource}".`
);
offsets.push(`${body.id} ${worst.toFixed(2)} at worst`);
}
// The card prints this under "Measured". An osculating eccentricity swings about its mean — the
// Moon's by 0.015 here, Phoebe's by as much — but not by the 0.087 Hyperion's older row was out.
const printed = body.measuredEccentricity ?? body.orbit.eccentricity;
assertCondition(
Math.abs(printed - horizons!.eccentricity) <= MAX_ECCENTRICITY_OFFSET,
`${body.name}'s card gives an eccentricity of ${printed}, where Horizons' osculating orbit has ${horizons!.eccentricity.toFixed(4)} (at most ${MAX_ECCENTRICITY_OFFSET} apart expected).`
);
// A radius of 0 is what a page whose radius no pattern reads comes out as — Charon's did.
assertCondition(body.radiusKm > 0, `Body ${body.id} has no radius; its page states it in a form the ETL does not read.`);
const rotation = body.rotationalElements;
assertCondition(
(rotation === undefined) === WITHOUT_ROTATIONAL_ELEMENTS.has(body.id),
`Body ${body.id} ${rotation ? 'has' : 'has no'} IAU rotational elements, which the report ${rotation ? 'does not give' : 'gives'} for it.`
);
if (rotation) {
const rate = rotation.primeMeridianDeg[1];
if (body.rotationPeriodHours !== undefined) {
const dayOffset = Math.abs(((360 / Math.abs(rate)) * 24) / Math.abs(body.rotationPeriodHours) - 1);
const dayCeiling = DAY_OFFSET_CEILINGS[body.id] ?? MAX_DAY_OFFSET;
assertCondition(
dayOffset <= dayCeiling,
`${body.name}'s IAU day, ${((360 / Math.abs(rate)) * 24).toFixed(5)} hours, is ${dayOffset.toExponential(2)} of its length from Horizons' ${Math.abs(body.rotationPeriodHours).toFixed(5)} (at most ${dayCeiling} expected).`
);
spins.push(`${body.id} day ${dayOffset.toExponential(1)}`);
}
if (body.obliquityDeg !== undefined) {
const pole = orientationAt(rotation, horizons!.epochJd);
const pointing = raDecToUnitVector(pole.poleRaDeg / 15, pole.poleDecDeg);
const axis = { x: Math.sign(rate) * pointing.x, y: Math.sign(rate) * pointing.y, z: Math.sign(rate) * pointing.z };
const { inclinationDeg, longitudeOfAscendingNodeDeg } = meanElementsAt(body.orbit, body.rates, horizons!.epochJd);
const tilt = inclinationDeg * DEG_TO_RAD;
const node = longitudeOfAscendingNodeDeg * DEG_TO_RAD;
const normal = { x: Math.sin(tilt) * Math.sin(node), y: -Math.sin(tilt) * Math.cos(node), z: Math.cos(tilt) };
const obliquity = angleBetweenDeg(axis, body.laplacePole ? laplacePlaneToEquatorial(normal, body.laplacePole) : eclipticToEquatorial(normal));
assertCondition(
Math.abs(obliquity - body.obliquityDeg) <= MAX_OBLIQUITY_OFFSET_DEG,
`${body.name}'s IAU spin axis is ${obliquity.toFixed(3)} degrees from its orbit's pole, where Horizons gives an obliquity of ${body.obliquityDeg} (at most ${MAX_OBLIQUITY_OFFSET_DEG} apart expected) — the pole or the sense of W was read wrongly.`
);
spins.push(`${body.id} tilt ${obliquity.toFixed(3)}`);
}
}
if (body.kind === 'moon') {
const parent = bodies.find((candidate) => candidate.id === body.parentBodyId);
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.
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}.`
);
} else {
// Every other moon here is tidally locked, and drawn by its orbit and its IAU W: the two
// have to agree, or its face turns away from its planet.
assertCondition(rotation !== undefined, `Moon ${body.id} is locked but has no W to keep its face to its planet by.`);
const ceiling = SUB_PLANET_CEILINGS_DEG[body.id] ?? MAX_SUB_PLANET_LONGITUDE_DEG;
const worst = Math.max(...LOCK_DATES_JD.map((jd) => Math.abs(subPlanetLongitudeDeg(body, jd))));
assertCondition(
worst <= ceiling,
`Moon ${body.id} turns its face up to ${worst.toFixed(2)} degrees from its planet between 1950 and 2100 (at most ${ceiling} expected) — its orbit and its W disagree.`
);
spins.push(`${body.id} faces ${worst.toFixed(2)}`);
}
if (body.massRatio !== undefined) {
// The pair's barycentre, which the planet's elements place, must lie outside the planet —
// that is why the two are drawn going round it — and nearer the planet than the moon.
const offsetKm = (body.orbit.semiMajorAxisAu * KM_PER_AU * body.massRatio) / (1 + body.massRatio);
assertCondition(
body.massRatio > 0 && body.massRatio < 1 && offsetKm > parent!.radiusKm,
`${body.name}'s mass ratio ${body.massRatio} puts its barycentre ${offsetKm.toFixed(0)} km from ${parent!.name}'s centre, which is not between its surface, ${parent!.radiusKm} km out, and the moon.`
);
}
}
}
const planetCount = bodies.filter((body) => body.kind === 'planet').length;
assertCondition(planetCount === 8, `Expected 8 planets, found ${planetCount}.`);
const dwarfCount = bodies.filter((body) => body.kind === 'dwarf').length;
assertCondition(dwarfCount === 5, `Expected the IAU's 5 dwarf planets, found ${dwarfCount}.`);
// Eris keeps one face to Dysnomia, whose orbit takes 15.78590 days (Holler et al. 2021); its light
// curve gives 15.771 +/- 0.008 (Bernstein et al. 2023). The SBDB still gives 25.9 hours.
const erisDays = (bodies.find((body) => body.id === 'eris')?.rotationPeriodHours ?? NaN) / 24;
assertCondition(
Math.abs(erisDays / 15.7859 - 1) < 0.002,
`Eris turns once in ${erisDays.toFixed(3)} days; it is locked to Dysnomia's 15.786-day orbit — the SBDB's 25.9-hour period, which it flags as possibly 30 per cent wrong, was taken.`
);
console.log(` mean elements against Horizons, degrees: ${offsets.join(', ')}.`);
console.log(` IAU rotation against Horizons (day as a fraction of it, tilt in degrees): ${spins.join(', ')}.`);
}
function validateExoplanets(exoplanets: ExoplanetRecord[], starIds: Set<number>): void {
assertCondition(exoplanets.length > 0, 'No exoplanets were produced.');
let crossReferenced = 0;
for (const exoplanet of exoplanets) {
assertCondition(!!exoplanet.name, `Exoplanet ${exoplanet.id} has no name.`);
if (exoplanet.hostStarId !== null) {
assertCondition(starIds.has(exoplanet.hostStarId), `Exoplanet ${exoplanet.id} references unknown star id ${exoplanet.hostStarId}.`);
// The Sun has no exoplanets, so any match to it is a matching failure — historically a
// blank distance column parsing as 0, which puts the host at the origin and matches Sol
// exactly. Free, permanent tripwire for that whole class of bug.
assertCondition(
exoplanet.hostStarId !== SUN_STAR_ID,
`Exoplanet ${exoplanet.id} was matched to the Sun, which has no exoplanets — the host-star match is wrong.`
);
crossReferenced++;
}
assertCondition(
exoplanet.periodDays === undefined || exoplanet.periodDays > 0,
`Exoplanet ${exoplanet.id} has a non-positive orbital period.`
);
assertCondition(
exoplanet.hostStarMassSolar === undefined || exoplanet.hostStarMassSolar > 0,
`Exoplanet ${exoplanet.id} has a non-positive host star mass.`
);
}
console.log(` ${crossReferenced}/${exoplanets.length} exoplanets cross-referenced to a HYG host star.`);
// How many can be propagated at their real rate rather than as if the host were the Sun.
const withPeriod = exoplanets.filter((exoplanet) => exoplanet.periodDays !== undefined).length;
const withHostMass = exoplanets.filter((exoplanet) => exoplanet.hostStarMassSolar !== undefined).length;
console.log(` ${withPeriod}/${exoplanets.length} have a measured period, ${withHostMass} a host star mass.`);
}
const UNIT_VECTOR_TOLERANCE = 1e-6;
function validateDeepSky(objects: DeepSkyRecord[]): void {
assertCondition(objects.length > 0, 'No deep-sky objects were produced.');
const ids = new Set<string>();
for (const object of objects) {
assertCondition(!!object.id, `Deep-sky object has no id: ${JSON.stringify(object)}`);
assertCondition(!ids.has(object.id), `Duplicate deep-sky id: ${object.id}`);
ids.add(object.id);
assertCondition(!!object.name, `Deep-sky object ${object.id} has no name.`);
// Positions are directions, so every one of them must be a unit vector — a zero-length
// or mis-scaled entry would silently collapse onto the origin on the backdrop shell.
const length = Math.hypot(object.x, object.y, object.z);
assertCondition(Math.abs(length - 1) < UNIT_VECTOR_TOLERANCE, `Deep-sky object ${object.id} has a non-unit direction (length ${length}).`);
assertCondition(object.angularSizeDeg >= 0, `Deep-sky object ${object.id} has a negative angular size.`);
assertCondition(object.distancePc === null || object.distancePc > 0, `Deep-sky object ${object.id} has a non-positive distance.`);
// The distance and its provenance have to travel together, or the UI cannot say where a
// number came from.
assertCondition(
(object.distancePc === null) === (object.distanceMethod === null),
`Deep-sky object ${object.id} has a distance/method mismatch.`
);
}
const kinds = new Set(objects.map((object) => object.kind));
for (const kind of ['galaxy', 'nebula', 'cluster'] as const) {
assertCondition(kinds.has(kind), `No deep-sky objects of kind "${kind}" were produced.`);
}
const withDistance = objects.filter((object) => object.distancePc !== null).length;
console.log(` ${withDistance}/${objects.length} deep-sky objects have a derived distance.`);
}
/**
* Orchestrates the whole ETL pipeline: fetches every source (each caches its own raw
* responses under `tools/etl/.cache/`), writes the static assets under `src/assets/data/`,
* then validates the combined output for completeness before declaring success.
*/
async function build(): Promise<void> {
console.log('=== NASA star map ETL ===\n');
console.log('Catalogues:');
console.log(describeSources());
console.log();
const stars = await fetchStars();
console.log();
const { bodies, horizonsOrbits, horizonsTracks } = await fetchSolarSystem();
console.log();
const exoplanets = await fetchExoplanets(stars);
console.log();
const deepSky = await fetchDeepSky();
console.log();
console.log('Validating output...');
validateStars(stars);
validateMerge(stars);
validateBodies(bodies, horizonsOrbits, horizonsTracks);
validateExoplanets(exoplanets, new Set(stars.map((star) => star.id)));
validateDeepSky(deepSky);
console.log('\nETL completed successfully:');
console.log(` stars: ${stars.length}`);
console.log(` bodies: ${bodies.length}`);
console.log(` exoplanets: ${exoplanets.length}`);
console.log(` deep sky: ${deepSky.length}`);
}
build().catch((error) => {
console.error('\nETL failed:', error instanceof Error ? error.message : error);
process.exitCode = 1;
});