Files
star-map/tools/etl/build.ts
T
SenrokaiandClaude Opus 5.5 030447b83c Fold a Gliese star into the Gaia source SIMBAD names it as, so none is drawn twice or inside 10 pc by mistake
HYG's Gliese-only rows, with no Hipparcos astrometry and no published error on their distance,
reach the merge with positions off by up to minutes of arc, photometric distances and sometimes
wrong proper motions, and isSameStar's geometry missed 49 of them beside their own Gaia entry. 43
lie within 25 pc and 27 are fainter than V 12, the layer the brief asked to merge without
duplicates. GJ 3478 is 16.3" from its Gaia entry, past the 15" tolerance; GJ 2097 moves 39 %
differently by HYG's motion; GJ 4285 is co-moving but 1.6 magnitudes brighter in HYG's V than
Gaia's G. Two of them were false stars inside 10 pc: GJ 2097 at 6.41 pc and GJ 4285 at 6.80, which
Gaia measures at 24.47 and 28.25. HYG also put Gl 94 31.6 degrees from where it is, and HD 23585
and HD 23713, Pleiades members at 135 pc, at 20.6 and 22.2. 0e9ab6f's "no Gliese row within 25 pc
has a co-moving bare Gaia entry 3-300" away" held only under its own motion rule.

fetchStars now asks SIMBAD once, in one cached TAP query, for the Gaia DR3 designation of every
object it knows by a GJ number (4 868), maps HYG's `gl` column onto it, and foldByIdentity folds
each Gliese-only row into the bare Gaia entry of that source: HYG's name, type and photometry,
Gaia's position and distance, as combine does for any other pair. An ETL run from cache folds 49:
455 571 stars become 455 522, the stars within 10 pc 369 become 367, within 25 pc 5 522 become
5 479, HYG rows without a Gaia counterpart 11 517 become 11 468, distances with no published error
395 become 346. exoplanets.json is unchanged. In the running app GJ 2097 reads "24 pc, HYG, Gaia
DR3 distance", GJ 4285 28 pc, Gl 94 17 pc, and 367 stars lie within 10 pc.

validateMerge now refuses any Gliese-only row beside the bare Gaia entry SIMBAD names as the same
star. Controls: folding with an empty identity map fails the ETL with "49 Gliese stars are drawn
beside the Gaia source SIMBAD names them as, starting with GJ 1033" (the baseline passed); in the
unit suite, folding a row with a Hipparcos error fails "leaves a star with a Hipparcos error, and
a Gaia entry already folded into, alone", and keeping the Gliese position fails "folds a Gliese
entry into the Gaia entry SIMBAD names it as, at Gaia's position and distance". HYG's V is kept
for a folded star, which for GJ 3207 is the wrong one (11.51 where SIMBAD has 13.75).

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

522 lines
30 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, isDesignation } from '../../src/app/shared/models/star-catalog';
import { fetchStars, glieseGaiaDesignations } from './fetchStars';
import { ARCHIVE_EPOCH, archiveStarId, CATALOGUE_EPOCH } from '../../src/app/shared/astro/host-star-matching';
import { propagateProperMotion, raDegDecDistanceToXyz } from '../../src/app/shared/astro/coordinates';
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);
}
}
/**
* Stars whose magnitude is a stand-in, and distances published without an error. Measured 309 and
* 439: the 44 Gaia sources with no G and the 265 archive hosts with neither V nor G; the 357 Gliese
* distances (no Hipparcos parallax behind them) and 82 archive hosts the archive gives no error
* for. Losing either field loses it for hundreds of thousands of stars.
*/
const MAX_STARS_WITHOUT_BAND = 1_000;
const MAX_STARS_WITHOUT_DISTANCE_ERROR = 1_000;
/**
* What the errors themselves come to, which the two counts above cannot see: Gaia's parallax_error
* stored in milliarcseconds rather than over the parallax still encodes, decodes and passes both.
* Measured: the median relative error of the stars at Gaia's distance is 0.33 %, and 1 109 parallax
* distances (0.24 % of the stars) have one of a fifth or more, which the card prints as a range.
* The mistake above gives 1.92 % and 17 689.
*/
const MAX_MEDIAN_GAIA_DISTANCE_ERROR = 0.01;
const MAX_RANGED_DISTANCE_SHARE = 0.01;
/**
* HYG stars that keep their own row but sit at Gaia's distance, which fetchStars flags for the card
* to say "HYG, Gaia DR3 distance". Measured 8 129; the flag dropped leaves none, and nothing else
* notices — the other 62 002 carry it from their Gaia row.
*/
const MIN_HYG_STARS_AT_GAIA_DISTANCE = 6_000;
/**
* The other half of placing a star at its more precise distance (8c3a860): a HYG star folded into
* its Gaia entry that keeps its Hipparcos distance, which Gaia saturates on. Measured 384, none
* before 8c3a860, and 148 with fetchStars handing combine Gaia's error with the Hipparcos distance,
* where the two errors tie and Gaia's distance wins — which the floor above cannot see, since that
* raises its count to 8 129. The two stars below are what that looks like: Tarazed went back to
* Gaia's 178.9 pc, and Eta Leo kept its 389 pc but read "±17 %, HYG, Gaia DR3 distance".
*/
const MIN_GAIA_STARS_AT_HIPPARCOS_DISTANCE = 300;
const HIPPARCOS_PLACED_STARS = [
{ id: 96970, name: 'Tarazed', distancePc: 121.07 },
{ id: 49441, name: 'Eta Leo', distancePc: 389.11 }
];
/** Their distances' errors, 2.1 % and 6.3 %, against Gaia's 6.9 % and 17 %. */
const MAX_HIPPARCOS_PLACED_ERROR = 0.065;
/**
* Archive hosts whose colour is read off their temperature, which the card marks "from its
* temperature" (23547de). Measured 54; the flag's write dropped from fetchExoplanets leaves none,
* and every other check passes, since the round trip compares the flag with itself.
*/
const MIN_COLOURS_FROM_TEMPERATURE = 40;
/**
* Archive stars numbered after their host's name (62f81f2), so a refresh keeps each one's id and a
* bookmark its star. Measured: 3 276 of the 3 277 take the id their name hashes to, one having
* probed past a taken one. Numbered in arrival order, as before, none do, and nothing else fails.
*/
const MAX_ARCHIVE_IDS_OFF_THEIR_NAME = 5;
/**
* Every star the naked eye sees, kept at any distance (c64eea0): measured 8 886 of V 6.5 or
* brighter, 1 653 of them past 250 pc. With no magnitude handed to placementDistancePc, 7 379 are
* left and Rigel, Deneb and Alnilam are gone — and every other check passed, the HYG survivors
* going down rather than up.
*/
const MIN_NAKED_EYE_STARS = 8_800;
const NAKED_EYE_MAGNITUDE_V = 6.5;
const REQUIRED_DISTANT_STARS = ['Rigel', 'Deneb', 'Alnilam'];
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.`);
assertCondition(
decoded[i].magnitudeBand === stars[i].magnitudeBand &&
decoded[i].colorSystem === stars[i].colorSystem &&
decoded[i].distanceFromGaia === !!stars[i].distanceFromGaia &&
decoded[i].colorFromTemperature === !!stars[i].colorFromTemperature,
`Star catalogue round-trip changed the photometry of star ${stars[i].id}.`
);
// Stored as its square root in 255ths, up to 100 %; see `star-catalog.ts`.
const error = stars[i].distanceError;
assertCondition(
error === undefined ? decoded[i].distanceError === undefined : Math.abs(Math.sqrt(decoded[i].distanceError!) - Math.sqrt(Math.min(1, error))) <= 0.5 / 255 + 1e-9,
`Star catalogue round-trip changed the distance error of star ${stars[i].id}.`
);
}
// What each star says it was measured in. A band or an error dropped on the way still encodes,
// decodes and draws; it shows only as a card reading "Not measured" for a star that was.
const withoutBand = stars.filter((star) => star.magnitudeBand === undefined).length;
assertCondition(withoutBand <= MAX_STARS_WITHOUT_BAND, `${withoutBand} stars have no magnitude band (at most ${MAX_STARS_WITHOUT_BAND} expected) — the band is being lost.`);
const withoutError = stars.filter((star) => star.id !== SUN_STAR_ID && star.distanceError === undefined).length;
assertCondition(
withoutError <= MAX_STARS_WITHOUT_DISTANCE_ERROR,
`${withoutError} stars have no distance error (at most ${MAX_STARS_WITHOUT_DISTANCE_ERROR} expected) — the parallax errors are being lost.`
);
console.log(` ${withoutBand} stars with a stand-in magnitude; ${withoutError} distances without a published error.`);
const gaiaErrors = stars
.filter((star) => star.distanceFromGaia && star.distanceError !== undefined)
.map((star) => star.distanceError!)
.sort((a, b) => a - b);
const medianGaiaError = gaiaErrors[Math.floor(gaiaErrors.length / 2)] ?? 0;
assertCondition(
medianGaiaError <= MAX_MEDIAN_GAIA_DISTANCE_ERROR,
`The median error of Gaia's distances is ${(medianGaiaError * 100).toFixed(2)} % (at most ${MAX_MEDIAN_GAIA_DISTANCE_ERROR * 100} % expected) — the parallax errors are no longer relative.`
);
// The archive's errors are on the distance and never printed as a range; see formatDistance.
const ranged = stars.filter((star) => star.source !== 'exoplanet-archive' && (star.distanceError ?? 0) >= 0.2).length;
assertCondition(
ranged <= stars.length * MAX_RANGED_DISTANCE_SHARE,
`${ranged} parallax distances have an error of a fifth or more (at most ${MAX_RANGED_DISTANCE_SHARE * 100} % of stars expected).`
);
const hygAtGaiaDistance = stars.filter((star) => star.source === 'hyg' && star.distanceFromGaia).length;
assertCondition(
hygAtGaiaDistance >= MIN_HYG_STARS_AT_GAIA_DISTANCE,
`Only ${hygAtGaiaDistance} HYG stars are flagged at Gaia's distance (at least ${MIN_HYG_STARS_AT_GAIA_DISTANCE} expected) — fetchStars no longer says whose parallax it placed them by.`
);
console.log(
` Gaia distances a median ${(medianGaiaError * 100).toFixed(2)} % uncertain; ${ranged} parallax distances ranged; ${hygAtGaiaDistance} HYG stars at Gaia's distance.`
);
const nakedEye = stars.filter((star) => star.magnitudeBand === 'V' && star.magnitude <= NAKED_EYE_MAGNITUDE_V && star.id !== SUN_STAR_ID).length;
assertCondition(
nakedEye >= MIN_NAKED_EYE_STARS,
`Only ${nakedEye} stars of V ${NAKED_EYE_MAGNITUDE_V} or brighter (at least ${MIN_NAKED_EYE_STARS} expected) — naked-eye stars are no longer kept at any distance.`
);
for (const name of REQUIRED_DISTANT_STARS) {
assertCondition(stars.some((star) => star.name === name), `${name} is missing — a naked-eye star past the survey is no longer kept.`);
}
console.log(` ${nakedEye} naked-eye stars.`);
const gaiaAtHipparcosDistance = stars.filter((star) => star.source === 'gaia' && star.magnitudeBand === 'V' && !star.distanceFromGaia).length;
assertCondition(
gaiaAtHipparcosDistance >= MIN_GAIA_STARS_AT_HIPPARCOS_DISTANCE,
`Only ${gaiaAtHipparcosDistance} HYG stars folded into Gaia keep their Hipparcos distance (at least ${MIN_GAIA_STARS_AT_HIPPARCOS_DISTANCE} expected) — the more precise distance no longer wins.`
);
for (const expected of HIPPARCOS_PLACED_STARS) {
const star = stars.find((candidate) => candidate.id === expected.id);
const distancePc = star && Math.hypot(star.x, star.y, star.z);
assertCondition(
star !== undefined && Math.abs(distancePc! / expected.distancePc - 1) < 0.01 && !star.distanceFromGaia && (star.distanceError ?? 1) < MAX_HIPPARCOS_PLACED_ERROR,
`${expected.name} is at ${distancePc?.toFixed(1)} pc, error ${star?.distanceError}, Gaia's: ${star?.distanceFromGaia} — expected its Hipparcos ${expected.distancePc} pc and error.`
);
}
const coloursFromTemperature = stars.filter((star) => star.colorFromTemperature).length;
assertCondition(
coloursFromTemperature >= MIN_COLOURS_FROM_TEMPERATURE,
`Only ${coloursFromTemperature} colours are marked as read off a temperature (at least ${MIN_COLOURS_FROM_TEMPERATURE} expected) — fetchExoplanets no longer says so.`
);
const archiveStars = stars.filter((star) => star.source === 'exoplanet-archive');
const offTheirName = archiveStars.filter((star) => star.id !== archiveStarId(star.name, new Set())).length;
assertCondition(
offTheirName <= MAX_ARCHIVE_IDS_OFF_THEIR_NAME,
`${offTheirName} of the ${archiveStars.length} archive stars have an id other than their name's (at most ${MAX_ARCHIVE_IDS_OFF_THEIR_NAME} expected) — a refresh would renumber them.`
);
console.log(
` ${gaiaAtHipparcosDistance} Gaia stars at their Hipparcos distance; ${coloursFromTemperature} colours from a temperature; ${offTheirName} archive ids off their name.`
);
// Hipparcos's mark on a classification it does not print in full reached the card as "Spectral
// type A0m...", for Sirius and 2 126 other stars, which reads as text the app cut short.
const dotted = stars.filter((star) => star.spectralType.endsWith('...')).length;
assertCondition(dotted === 0, `${dotted} spectral types end in "..." — fetchStars no longer trims Hipparcos's mark from them.`);
}
/**
* 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;
/**
* Gliese-only rows beside the Gaia entry SIMBAD names as the same star, which no geometry saw:
* 49 before `foldByIdentity`, two of them false stars inside 10 pc (GJ 2097 at 6.41 pc and GJ 4285
* at 6.80, which Gaia has at 24.47 and 28.25). The twin count above does not see them, being up to
* minutes of arc apart.
*/
const MAX_GLIESE_ROWS_BESIDE_THEIR_GAIA_SOURCE = 0;
const MAX_HYG_SURVIVORS = 15_000;
const TWIN_TOLERANCE_RAD = (1 / 3600) * (Math.PI / 180);
function validateMerge(stars: StarRecord[], gaiaDesignationById: ReadonlyMap<number, string>): 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.`
);
const bare = new Set(stars.filter((star) => star.source === 'gaia' && isDesignation(star)).map((star) => star.name));
const beside = stars.filter((star) => star.source === 'hyg' && star.distanceError === undefined && bare.has(gaiaDesignationById.get(star.id) ?? ''));
assertCondition(
beside.length <= MAX_GLIESE_ROWS_BESIDE_THEIR_GAIA_SOURCE,
`${beside.length} Gliese stars are drawn beside the Gaia source SIMBAD names them as, starting with ${beside[0]?.name} — the identity fold is not being made.`
);
console.log(` ${survivors} HYG stars have no Gaia counterpart; ${twins} unmerged cross-catalogue pairs within an arcsecond; ${beside.length} Gliese stars beside their own Gaia source.`);
}
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}.`);
}
/**
* The share of planets that must have a star on the map: 6 327 of 6 354 did when the ETL began
* adding the hosts the catalogue lacks from the archive's own figures, up from 2 071. The other
* 27 have no distance in either archive table, so nothing can place them; the floor leaves room
* for a few more of those, not for the matching or the additions to stop working.
*/
const MIN_HOSTED_SHARE = 0.995;
/**
* The share of planets whose host's radius and temperature the archive gives, which is what
* starSurfaceOf draws a host with before deriving one. Measured 6 030 and 6 054 of 6 354 (0.95).
* Both come from the composite table only; a column lost on the way leaves every host derived —
* Proxima 0.105 R☉ instead of 0.141 — and nothing else fails.
*/
const MIN_HOST_SURFACE_SHARE = 0.9;
/**
* How far, in milliarcseconds, a star placed from the archive may sit from its planets' published
* position carried from the archive's epoch to the catalogue's. Measured 0.0001 at most, rounding;
* carried from J2016 instead, TOI-2406 moves 203 mas and 2 287 archive stars more than 1.
*/
const MAX_ARCHIVE_EPOCH_OFFSET_MAS = 1;
function validateExoplanets(exoplanets: ExoplanetRecord[], stars: StarRecord[]): void {
assertCondition(exoplanets.length > 0, 'No exoplanets were produced.');
const starsById = new Map(stars.map((star) => [star.id, star]));
let crossReferenced = 0;
for (const exoplanet of exoplanets) {
assertCondition(!!exoplanet.name, `Exoplanet ${exoplanet.id} has no name.`);
if (exoplanet.hostStarId !== null) {
const host = starsById.get(exoplanet.hostStarId);
assertCondition(host !== undefined, `Exoplanet ${exoplanet.id} references unknown star id ${exoplanet.hostStarId}.`);
// A host known only as "Gaia DR3 2635476908753563008" cannot be found by searching for
// TRAPPIST-1; fetchExoplanets names it after its host, and 575 were renamed.
assertCondition(!isDesignation(host!), `Exoplanet ${exoplanet.id}'s host is only a designation, ${host!.name}, not named after ${exoplanet.hostStarName}.`);
// 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 have a host star on the map.`);
assertCondition(
crossReferenced >= exoplanets.length * MIN_HOSTED_SHARE,
`Only ${crossReferenced} of ${exoplanets.length} exoplanets have a host star (at least ${MIN_HOSTED_SHARE * 100} % expected) — hosts are no longer being matched or added.`
);
const withRadius = exoplanets.filter((exoplanet) => exoplanet.hostStarRadiusSolar !== undefined).length;
const withTemperature = exoplanets.filter((exoplanet) => exoplanet.hostStarTemperatureK !== undefined).length;
assertCondition(
Math.min(withRadius, withTemperature) >= exoplanets.length * MIN_HOST_SURFACE_SHARE,
`Only ${withRadius} of ${exoplanets.length} exoplanets carry their host's radius and ${withTemperature} its temperature (at least ${MIN_HOST_SURFACE_SHARE * 100} % expected) — st_rad or st_teff is being lost.`
);
// Since d94451e st_lum warms the planets of 4 441 hosts and is what their cards print; lost, each
// falls back to a derived luminosity, 757 of them more than 1.5 times off it. Measured 6 036.
const luminosities = exoplanets.map((exoplanet) => exoplanet.hostStarLuminositySolar).filter((luminosity) => luminosity !== undefined);
assertCondition(
luminosities.length >= exoplanets.length * MIN_HOST_SURFACE_SHARE,
`Only ${luminosities.length} of ${exoplanets.length} exoplanets carry their host's luminosity (at least ${MIN_HOST_SURFACE_SHARE * 100} % expected) — st_lum is being lost.`
);
// Published as a logarithm, most of them negative: stored unconverted, they would not be.
assertCondition(
luminosities.every((luminosity) => Number.isFinite(luminosity) && luminosity! > 0),
'A host luminosity is not a positive number — st_lum is no longer converted from its logarithm.'
);
console.log(` ${withRadius}/${exoplanets.length} carry their host's radius, ${withTemperature} its temperature, ${luminosities.length} its luminosity.`);
let worstOffsetMas = 0;
for (const star of stars.filter((candidate) => candidate.source === 'exoplanet-archive')) {
const planet = exoplanets.find((candidate) => candidate.hostStarId === star.id)!;
const at = propagateProperMotion(planet.hostRaDeg!, planet.hostDecDeg!, planet.hostPmRaMasPerYear ?? 0, planet.hostPmDecMasPerYear ?? 0, CATALOGUE_EPOCH - ARCHIVE_EPOCH);
const expected = raDegDecDistanceToXyz(at.raDeg, at.decDeg, 1);
const length = Math.hypot(star.x, star.y, star.z);
// The chord between the two directions, which unlike an arccosine resolves a milliarcsecond.
const chord = Math.hypot(star.x / length - expected.x, star.y / length - expected.y, star.z / length - expected.z);
worstOffsetMas = Math.max(worstOffsetMas, (chord * 180 * 3_600_000) / Math.PI);
}
assertCondition(
worstOffsetMas <= MAX_ARCHIVE_EPOCH_OFFSET_MAS,
`A star placed from the archive sits ${worstOffsetMas.toFixed(1)} mas from its published position carried to J2000 (at most ${MAX_ARCHIVE_EPOCH_OFFSET_MAS} expected) — it is carried from another epoch.`
);
console.log(` Stars placed from the archive at most ${worstOffsetMas.toExponential(1)} mas from their published position carried to J2000.`);
// 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;
/**
* Objects the backdrop cannot ship without, two from each of OpenNGC's files: the Andromeda
* Galaxy and the Small Magellanic Cloud from NGC.csv, the Large Magellanic Cloud and the
* Pleiades from addendum.csv. The addendum went unread for as long as the ETL has existed,
* because 463 objects without the brightest deep-sky object in the sky validated cleanly.
*/
const REQUIRED_DEEP_SKY_IDS = ['NGC0224', 'NGC0292', 'ESO056-115', 'Mel022'];
/**
* 107 of the 110 Messier objects. OpenNGC types the other three as what they are: M40 a double
* star, M73 an asterism and M102 a duplicate of M101, none of them a deep-sky object to draw.
*/
const MIN_MESSIER_OBJECTS = 107;
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.`);
}
for (const id of REQUIRED_DEEP_SKY_IDS) {
assertCondition(ids.has(id), `Deep-sky object ${id} is missing — one of OpenNGC's two files was not read.`);
}
const messier = new Set(objects.map((object) => object.messier).filter((designation) => designation !== null)).size;
assertCondition(messier >= MIN_MESSIER_OBJECTS, `Only ${messier} Messier objects were produced (at least ${MIN_MESSIER_OBJECTS} expected).`);
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 catalogueStars = await fetchStars();
console.log();
const bodies = await fetchSolarSystem();
console.log();
// Adds the hosts the catalogue lacks, so it is this list, not the one above, that is published.
const { exoplanets, stars } = await fetchExoplanets(catalogueStars);
console.log();
const deepSky = await fetchDeepSky();
console.log();
console.log('Validating output...');
validateStars(stars);
validateMerge(stars, await glieseGaiaDesignations());
validateBodies(bodies);
validateExoplanets(exoplanets, stars);
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;
});