Files
star-map/tools/etl/build.ts
T
SenrokaiandClaude Opus 5.5 74b93a0428 Fill in the Sun's faint neighbours from Gaia, and fold the Gliese entries they repeat
Gaia's G < 12 cut took a quarter of what lies within 10 pc: the red,
brown and white dwarfs most of the neighbourhood is made of, which the
map only had where Gliese happened to list them. Teegarden's Star was
missing, and with it its three planets' host.

A second query fetches the complement out to 50 pc: G >= 12 or no G,
parallax > 20 mas, with pmra/pmdec so the J2016 -> J2000 propagation
applies. The quality filter was chosen by counting. Parallax over error
> 5 keeps 39 751 of the 39 764 sources that pass the floor, but the Gaia
Catalogue of Nearby Stars (GCNS; Gaia Collaboration, Smart et al. 2021)
rejects 11 752 of them as spurious: median G 20.2, astrometric excess
noise 5.4 mas against 0.15 for the ones it keeps, 6 933 toward the
Galactic centre. So the query joins the GCNS main table (EDR3 astrometry
and source ids, which DR3 carries unchanged) and keeps 28 012 rows; the
error cut stays and costs no GCNS source, brown dwarfs included. The
query has its own row-count floor (28 012) and the row-limit cap, and is
ordered by (phot_g_mean_mag, source_id); a second ETL run reproduced
stars.bin, stars-meta.bin and stars-index.json byte for byte. Its ids
start at 1 050 000 000, clear of the main query's and under 2^30, which
V8 keeps unboxed: numbered from 2 000 000 000 they made the app's boot
task 230 ms longer (medians of five interleaved runs, 1.41 s against
1.18). validateStars now refuses an id outside 0 to 2^30.

The Gliese entries these stars duplicate were not folded: HYG carries
them with positions off by up to a minute of arc and photometric
distances, so they missed the 15" tolerance or failed the distance test.
Their proper motions, which Gliese measured well, give them away:
isSameStar now takes two entries moving within 20 % of each other as one
star up to 60" apart, whatever their distances, brightness still
permitting. Of the 602 Gliese-only rows left without a counterpart, 253
have such a Gaia entry; with every entry shifted a quarter degree, none
does. fetchStars passes HYG's motions only for rows without Hipparcos
astrometry: given them too, 15 Hipparcos stars took a co-moving
companion's Gaia entry and the cross-catalogue pairs under an arcsecond
went from 23 to 35. Of HYG's 1 200 stars fainter than V 12 within 25 pc,
1 024 now sit on a Gaia position (325 before); of the 176 left alone, 43
still have a Gaia entry 3-60" away (218 without the motion rule), some of
them real companions.

Measured on the rebuilt catalogue, against the GCNS (sources with
parallax > 100, 40 and 20 mas):
  within 10 pc  336 -> 372  (GCNS 312; the map adds 60 HYG-only stars)
  within 25 pc  3 652 -> 5 560  (GCNS 5 111)
  within 50 pc  13 702 -> 40 916  (GCNS 40 231)
452 331 stars (+27 214). Proxima, Barnard's Star, Wolf 359, Rigel, Deneb
and Alnilam are all present by name; Teegarden's Star is Gaia DR3
35227046884571776 at 3.83 pc and hosts its three planets. Luhman 16 is
not in Gaia DR3 with a parallax (5353626573555863424 has a two-parameter
solution) and stays absent. 94 more exoplanets find a host (2 071), none
changes host. TRAPPIST-1 is now drawn (Gaia DR3 2635476908753563008,
12.47 pc) but its planets are not yet matched to it.

Merge gate, ceilings unchanged: HYG rows without a Gaia counterpart
12 352 -> 11 554 (ceiling 15 000; 10 886 before the naked-eye stars),
cross-catalogue pairs under an arcsecond 23 -> 23 (ceiling 100). The
gate's comment now accounts for the survivors by magnitude.

gzip -9 sizes against the catalogue before both changes: stars.bin
4 711 922 -> 5 030 741 B, stars-meta.bin 2 576 213 -> 2 784 614 B,
stars-index.json 3 724 855 -> 4 003 584 B (+806 KB, 7.3 %). Boot on the
dev server, five interleaved cold runs: the task that indexes the
catalogue after the data lands, median 1 072 -> 1 182 ms; HUD shown,
median 2 622 -> 2 716 ms. This machine measured 0.82-1.30 s for the
same baseline task today, above audit #25's 627-843 ms. The drawn-star
budget is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 20:35:21 +02:00

268 lines
13 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)}`);
// Under 2^30, which V8 keeps unboxed; past it every id is a heap number, and the app's boot
// task grew by 230 ms when the nearby Gaia stars were numbered from 2 000 000 000.
assertCondition(star.id >= 0 && star.id < 2 ** 30, `Star ${star.id} has an id outside 0 to 2^30.`);
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. Twenty-three survive today; the nineteen first counted were
* all a second HYG row wanting a Gaia entry that already absorbed one (Gliese lists some doubles
* twice), and 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 11 554 today, and no counterpart was possible for most of those. 8 301 of them are
* every star in the published catalogue beyond 250 pc, which the main query never downloads: 6 835
* that Gaia's parallax puts past `ETL_GAIA_DISTANCE_PC` while Hipparcos put them inside
* `ETL_STAR_DISTANCE_PC`, and 1 466 naked-eye stars kept at any distance. The other 3 252 are what
* Gaia genuinely lacks: 1 194 brighter than V 8, which it saturates on or measures poorly, 1 832
* between 8 and 12, and 226 fainter, 184 of them Gliese stars within 50 pc that neither of its
* queries holds. 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 on the main query when it was the only one, with 10 886 survivors
* against today's 11 554: 11 004 at nine tenths of its rows, 12 711 at half, 16 258 at a third. So
* this ceiling only catches a deep truncation, and `fetchGaiaStars` catches the shallower ones
* with a row floor on each query.
*/
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.`);
}
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;
});