The catalogues are regenerated by a scheduled job that pushes straight to main once the unit suite and a production build pass in the same run. Both passed, every Monday, on a catalogue that carried 23 000 stars twice: the suite tests code against fixtures, and no fixture is 400 000 real stars. Nothing between the ETL and the map ever looked at what came out. Two numbers now have to hold, and each is the signature of a way the merge has actually failed here. Different catalogues placing a star within an arcsecond of each other is never two stars at this depth, and one catalogue does not list a star twice, so every cross-source pair that close is a miss. Nineteen survive today — each a second HYG row wanting a Gaia entry that already absorbed one, which is how Gliese lists some doubles — against 1 112 in the catalogue on main, where a Hipparcos parallax off by half outvoted a direction that agreed to a hundredth of an arcsecond. The ceiling is 100. The epoch failure leaves no close pair at all, because sixteen years of proper motion had already carried the two entries tens of arcseconds apart. What it leaves instead is HYG rows that found no counterpart: 36 056 on main against the 10 876 Gaia genuinely lacks — the stars it saturates on and the red dwarfs past its magnitude cut. The ceiling is 15 000. The pair sweep sorts by declination and walks a one-arcsecond window, so it costs about 300 ms on 423 641 stars — cheap enough to run on every ETL, which is the point: the gate has to sit where the bot already is, before the push, because a GITHUB_TOKEN push fires no CI of its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
246 lines
12 KiB
TypeScript
246 lines
12 KiB
TypeScript
import { statSync } from 'node:fs';
|
|
|
|
import { BodyRecord } from '../../src/app/shared/models/body.model';
|
|
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 } from './fetchSolarSystem';
|
|
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 876 Gaia genuinely lacks (bright stars it saturates on, red dwarfs past its
|
|
* magnitude cut).
|
|
*/
|
|
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 {
|
|
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.`);
|
|
}
|
|
|
|
function validateBodies(bodies: BodyRecord[]): 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.');
|
|
|
|
for (const body of bodies) {
|
|
const orbitValues = Object.values(body.orbit);
|
|
assertCondition(orbitValues.every(Number.isFinite), `Body ${body.id} has non-finite orbital elements.`);
|
|
|
|
if (body.kind === 'moon') {
|
|
assertCondition(!!body.parentBodyId && ids.has(body.parentBodyId), `Moon ${body.id} has no valid parentBodyId.`);
|
|
}
|
|
}
|
|
|
|
const planetCount = bodies.filter((body) => body.kind === 'planet').length;
|
|
assertCondition(planetCount === 8, `Expected 8 planets, found ${planetCount}.`);
|
|
}
|
|
|
|
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 = 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);
|
|
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;
|
|
});
|