import { statSync } from 'node:fs'; import { BodyRecord, OrbitalElements, RotationalElements } 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 { subPlanetLongitudeDeg } from './lib/locked-spin'; 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 { foldsInto } from '../../src/app/shared/astro/star-merge'; 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 * 332: the 44 Gaia sources with no G and the 265 archive hosts with neither V nor G; the 250 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 116 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 105; the flag dropped leaves none, and nothing else * notices — the other 61 713 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 898 of V 6.5 or * brighter, 1 663 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. HD 197770 and HD 45291 are two of the twelve only Gaia's bright * sources place, by position; without that lookup they are gone and the count drops by twelve, * which the floor alone would not see. * * HD 45951 is the one only SIMBAD's name for its Gaia source places, HYG's declination for it being * 31.7′ out. Placed along HYG's direction instead, every check here passed, with the star drawn * twice: there, and as its bare source 31.7′ away. So it is held to that source by designation. * * Twenty HYG rows of V 6.5 or brighter are still left out, for want of a distance: neither HYG nor * Hipparcos gives one, and Gaia DR3 has no source brighter than G 7.5 with a parallax five times * its error within a minute of arc, nor under the name SIMBAD gives. They are β Phe, φ Cas, χ Aur, * ο¹ Cen, Polis, 16 Sgr, ρ Cas, η Car, and HD 47240, 50820, 90772, 97534, 100198, 101205, 101947, * 129092, 151804, 185936, 202214 and 212466. A 21st, θ¹ Ori A (HYG 26155), is left out on purpose: * its V 4.98 and O7 are Hipparcos's for it and a companion together, and SIMBAD names it a source * of G 6.63 at 378 pc, which the map does not keep; placed, it was drawn brighter than θ¹ Ori C. */ const MIN_NAKED_EYE_STARS = 8_800; const NAKED_EYE_MAGNITUDE_V = 6.5; const REQUIRED_NAKED_EYE_STARS = ['Rigel', 'Deneb', 'Alnilam', 'HD 197770', 'HD 45291', 'HD 45951']; const IDENTIFIED_NAKED_EYE_STARS = [{ name: 'HD 45951', gaiaDesignation: 'Gaia DR3 3369454521490604416' }]; const BLENDED_NAKED_EYE_ROWS = [{ id: 26155, name: 'θ¹ Ori A' }]; function validateStars(stars: StarRecord[]): void { assertCondition(stars.length > 0, 'No stars were produced.'); const ids = new Set(); 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 65 535ths, 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 / 65_535 + 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_NAKED_EYE_STARS) { assertCondition(stars.some((star) => star.name === name), `${name} is missing — naked-eye stars are no longer kept wherever a survey places them.`); } for (const expected of IDENTIFIED_NAKED_EYE_STARS) { assertCondition( stars.some((star) => star.name === expected.name && star.gaiaDesignation === expected.gaiaDesignation), `${expected.name} is not on ${expected.gaiaDesignation}, the source SIMBAD names it as — it is drawn where HYG has it, beside that source.` ); } for (const blended of BLENDED_NAKED_EYE_ROWS) { assertCondition(!stars.some((star) => star.id === blended.id), `${blended.name} is drawn in the V and type of Hipparcos's blend of it with a companion.`); } 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-two 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 464 today, and no counterpart was possible for most of those. 8 307 of them are * every star beyond 250 pc but the archive's planet hosts, which the main query never downloads: 1 660 * naked-eye stars kept at any distance, and 6 647 fainter ones that Hipparcos put inside * `ETL_STAR_DISTANCE_PC` while Gaia's parallax puts them past `ETL_GAIA_DISTANCE_PC`. The other * 3 156 are what Gaia genuinely lacks: 1 202 brighter than V 8, which it saturates on or measures * poorly, 1 793 between 8 and 12, and 161 fainter, 111 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 464: 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 beside a bare one 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), and 10 beside one a Hipparcos row * already described, Gl 251 at 5.76 pc beside HD 265866. The twin count above does not see them, * being up to minutes of arc apart. * * That count takes SIMBAD's map from the function the fold takes it from, so a slip in the map * turns both off together: with it empty, GJ 2097 and GJ 4285 were back inside 10 pc and the count * read none. These are checked by name and distance instead. */ const MAX_GLIESE_ROWS_BESIDE_THEIR_GAIA_SOURCE = 0; const FOLDED_GLIESE_STARS = [ { name: 'GJ 2097', beyondPc: 20 }, { name: 'GJ 4285', beyondPc: 20 } ]; /** Gliese-only rows of stars HYG also lists by their Hipparcos row, HD 265866 and HD 304043. */ const ABSORBED_GLIESE_ROWS = ['Gl 251', 'Gl 422']; const MAX_HYG_SURVIVORS = 15_000; const TWIN_TOLERANCE_RAD = (1 / 3600) * (Math.PI / 180); function validateMerge(stars: StarRecord[], gaiaDesignationById: ReadonlyMap): 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 byDesignation = new Map(stars.filter((star) => star.gaiaDesignation !== undefined).map((star) => [star.gaiaDesignation!, star])); const beside = stars.filter((star) => { const target = star.source === 'hyg' && star.distanceError === undefined ? byDesignation.get(gaiaDesignationById.get(star.id) ?? '') : undefined; return target !== undefined && foldsInto(target, star); }); 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.` ); for (const expected of FOLDED_GLIESE_STARS) { const star = stars.find((candidate) => candidate.name === expected.name); const distancePc = star && Math.hypot(star.x, star.y, star.z); assertCondition( distancePc !== undefined && distancePc > expected.beyondPc, `${expected.name} is at ${distancePc?.toFixed(2)} pc, not beyond ${expected.beyondPc} where Gaia measures it — Gliese stars are no longer folded into their Gaia source.` ); } for (const name of ABSORBED_GLIESE_ROWS) { assertCondition(!stars.some((star) => star.name === name), `${name} is drawn beside the Hipparcos star it is — Gliese stars are no longer folded into their Gaia source.`); } 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.`); } /** * 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 node run the * wrong way, or a column taken for its neighbour, which put Triton 26 degrees out. Io's periapsis * run forwards put it 0.9 out here, which passes; {@link TRACK_OFFSET_CEILINGS_DEG} catches that. */ 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 (Hyperion 9.41, Iapetus 9.56, Nereid 2.58); see * {@link TRACK_OFFSET_CEILINGS_DEG} for what they reach from 1950 to 2100. Hyperion's was 21, its * worst over twelve dates, which let a row misread by twice its offset through. */ const MOON_OFFSET_CEILINGS_DEG: Record = { hyperion: 10, 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.42: 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. * * And four are held tighter than the rest, each where one reading of its row is all that keeps it * close, and without it the card would quietly restate itself under the general ceiling: * * - Tethys, 0.28, which takes the other half of that libration, 2.23 degrees, from its W. Without * it Tethys strays 2.09. * - Io, 0.07, and Europa, 0.23, whose periapses turn backwards, held by the Laplace resonance at * 2n(Europa) - n(Io), -0.7395 degrees a day (`apsidesRegress`). Read as advancing, Io strays 0.96 * and Europa 2.24, and their cards said "within 1.0" and "within 2.3". * - Callisto, 0.08, whose node turns at JPL's current rate and its periapsis's longitude at the * row's (`nodePeriodYears`). On the row's argument its periapsis moves 44 degrees by 2100 and it * strays 0.71; on the row's node, 0.19. */ const MAX_TRACK_OFFSET_DEG = 3; const TRACK_OFFSET_CEILINGS_DEG: Record = { hyperion: 23, nereid: 12, iapetus: 11, mimas: 8, ceres: 8, tethys: 0.5, io: 0.2, europa: 0.5, callisto: 0.15 }; /** * 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']); /** * The one moon drawn still: Hyperion, whose page says "Rotational period = Chaotic". Every other * moon without a lock has a measured day; Nereid's page states none, and it was drawn still until * its K2 light curve's 11.594 hours was taken (see its spec). */ const TUMBLING = new Set(['hyperion']); /** * How far the IAU's day, 360 degrees over W's rate, may be from the period the body's record * carries, as a fraction of it. That period is not always a second source: * * - The eight planets and Phoebe: the one Horizons states. 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. * - Pluto and Ceres: the IAU's own rate restated. Horizons' 153.29335198 hours for Pluto is 360 over * its W (8.5e-12), and the SBDB's 9.074170 for Ceres, which Horizons prints too, is noted as * derived from the report's 952.1532 degrees a day (3.3e-10). * - The 22 locked moons: their orbit's period, from JPL's satellite table, not a figure from their * Horizons pages ("Synchronous" on eighteen of them, nothing on Titan's or Proteus's). Their W is * turned at that rate (see `lockedToOrbit`, which first holds the kernel's own rate to it within * 1e-5), so here they are 0, but for the Moon and Phobos, whose W keeps its own rate and its * quadratic (1.1e-8 and 3.1e-7). * * 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 = { 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. * * Pluto's Horizons page states no obliquity: its 119.6 is worked out from the IAU pole itself (see * `BodySpec.obliquityDeg`), so for Pluto this checks only that the kernel's pole and W were read as * written, not the pole against a second source. */ 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 over the clock's AD 1 to 3000. Every locked moon's W turns at its orbit's * own rate (see `lockedToOrbit`); at the IAU's own rates, and sampled only from 1950 to 2100, this * let Proteus turn its far side to Neptune at AD 1 (146 degrees), Iapetus 87 degrees, Mimas 52 and * Miranda 23, on dates the clock offers. * * Measured on this catalogue: at most 5.36 degrees (Titan) but for three. The Moon 7.62, at AD 1: * its longitude swings 6.3 either way with its eccentricity, Horizons' too, and W's quadratic, the * tidal slowing its orbit here does not carry, adds 0.75 by then. Mimas 8.89: about 6.3 off on * average because the IAU's W and JPL's mean longitude disagree, and swung 2.3 either way (2e) by * its eccentricity. None of that is Mimas: its measured physical libration is 0.84 degrees * (Tajeddine et al. 2014, Science 346, 322), and W carries none; Horizons, on the same W against its * integrated orbit, runs from -2.7 to 12.7 degrees over 1950-2100 with the 71-year S5 term the * orbit here cancels. Iapetus 15.95, whose row sits 9.4 degrees behind Horizons. 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 = { moon: 8, mimas: 9.5, iapetus: 16.5 }; /** * How far a locked moon's spin axis may lean from the normal of the orbit it is drawn going round, * over the same dates. A locked moon sits in a Cassini state, its axis on its orbit normal as the * node carries both round the Laplace pole, and the IAU's pole goes round on a term of the node's * angle; at the rate the IAU's source had for it and not the drawn orbit's, Rhea's axis is 0.77 * degrees off by AD 1 and Triton's 0.51, and an Iapetus pole left on the Laplace pole is 8.30 off * at every date (see `lockedToOrbit`). * * Measured on this catalogue: at most 0.97 degrees (Tethys, whose IAU pole sits 0.69 from its orbit * normal today; Titan 0.94, whose pole the IAU holds still while its node turns in 687 years) but * for four. The Moon 6.98, its real 6.7-degree tilt to its orbit. Phobos 1.81 and Deimos 1.74, and * Proteus 1.09: their IAU poles nod with Mars's and Neptune's precessing poles, the Laplace poles * their orbits are drawn round are fixed. * * And six are held tighter, each where its node terms turned at the node's rate, or its node at * JPL's current rate, are what keep it close: Europa 0.13, Ganymede 0.16, Callisto 0.22, Rhea 0.17, * Miranda 0.23 and Triton 0.15. On the IAU's rates they are 0.33, 0.21, 0.33, 0.77, 0.59 and 0.51, * on a tolerance of 1 per cent Callisto and Rhea are left there, on the node's angle alone and not * its harmonics Triton is 0.29, and on the archived table's node periods Callisto is 0.56 and * Miranda 0.42: all under the general ceiling. */ const MAX_AXIS_FROM_ORBIT_DEG = 1; const AXIS_FROM_ORBIT_CEILINGS_DEG: Record = { moon: 7.1, phobos: 2, deimos: 2, proteus: 1.2, europa: 0.25, ganymede: 0.25, callisto: 0.25, rhea: 0.25, miranda: 0.25, triton: 0.25 }; /** The clock's window, AD 1 to 3000 (`CLOCK_WINDOW` in `time.store.ts`), as Julian dates. */ const CLOCK_START_JD = Date.parse('0001-01-01T00:00Z') / 86400000 + 2440587.5; const CLOCK_END_JD = Date.parse('3000-01-01T00:00Z') / 86400000 + 2440587.5; const LOCK_DATES_JD = Array.from({ length: Math.floor((CLOCK_END_JD - CLOCK_START_JD) / 135) + 1 }, (_, index) => CLOCK_START_JD + index * 135); 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; } /** Degrees between a body's spin axis — its IAU pole, turned over where W runs backwards — and the normal of the orbit it is drawn going round, at a TDB date. */ function axisFromOrbitDeg(body: BodyRecord, rotation: RotationalElements, jd: number): number { const pole = orientationAt(rotation, jd); const pointing = raDecToUnitVector(pole.poleRaDeg / 15, pole.poleDecDeg); const sense = Math.sign(rotation.primeMeridianDeg[1]); const axis = { x: sense * pointing.x, y: sense * pointing.y, z: sense * pointing.z }; const { inclinationDeg, longitudeOfAscendingNodeDeg } = meanElementsAt(body.orbit, body.rates, jd); 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) }; return angleBetweenDeg(axis, body.laplacePole ? laplacePlaneToEquatorial(normal, body.laplacePole) : eclipticToEquatorial(normal)); } function validateBodies(bodies: BodyRecord[], horizonsOrbits: Map, horizonsTracks: Map): 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'}.` ); // JPL's satellite table carries no periodic terms: a moon's are from its IAU W, and its card // names the kernel they come from as well as the table. assertCondition( !body.parentBodyId || !body.rates.meanAnomalyTerms || body.orbitSource.includes('NAIF pck00011'), `${body.name}'s orbit carries terms taken from its IAU W, and its card, "${body.orbitSource}", credits only the table.` ); 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.`); // A triaxial body's card gives its mean radius beside its semi-axes, so the two must agree: the // radius of the sphere of the same volume. Measured: Haumea's 797.6 against 797.62. if (body.semiAxesKm) { const volumeRadius = Math.cbrt(body.semiAxesKm[0] * body.semiAxesKm[1] * body.semiAxesKm[2]); assertCondition( Math.abs(volumeRadius / body.radiusKm - 1) < 0.001, `${body.name}'s radius, ${body.radiusKm} km, is not the mean of its semi-axes ${body.semiAxesKm.join(' x ')}, ${volumeRadius.toFixed(1)} km.` ); } const rotation = body.rotationalElements; assertCondition( (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 the ${Math.abs(body.rotationPeriodHours).toFixed(5)} its record carries (at most ${dayCeiling} expected).` ); spins.push(`${body.id} day ${dayOffset.toExponential(1)}`); } if (body.obliquityDeg !== undefined) { const obliquity = axisFromOrbitDeg(body, rotation, horizons!.epochJd); 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 ${body.id === 'pluto' ? 'its IAU pole' : '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 has no period; Nereid turns in 11.594 hours against a 360-day orbit, // and Phoebe in 9.27 against 550 days. A lock here would be the rule below misapplied. assertCondition( body.rotationPeriodHours !== undefined || TUMBLING.has(body.id), `Moon ${body.id} is drawn not turning, and is not known to tumble: its day was measured somewhere, find it.` ); 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, rotation!, jd)))); assertCondition( worst <= ceiling, `Moon ${body.id} turns its face up to ${worst.toFixed(2)} degrees from its planet between AD 1 and 3000 (at most ${ceiling} expected) — its orbit and its W disagree.` ); spins.push(`${body.id} faces ${worst.toFixed(2)}`); const axisCeiling = AXIS_FROM_ORBIT_CEILINGS_DEG[body.id] ?? MAX_AXIS_FROM_ORBIT_DEG; const worstAxis = Math.max(...LOCK_DATES_JD.map((jd) => axisFromOrbitDeg(body, rotation!, jd))); assertCondition( worstAxis <= axisCeiling, `Moon ${body.id}'s spin axis leans up to ${worstAxis.toFixed(2)} degrees from its orbit's normal between AD 1 and 3000 (at most ${axisCeiling} expected) — its pole does not go round with its node.` ); spins.push(`${body.id} axis ${worstAxis.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 each record's day and tilt (see MAX_DAY_OFFSET for where each comes from; day as a fraction of it, tilt and a locked moon's face in degrees): ${spins.join(', ')}.`); } /** * 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, and 6 328 * once a blank sy_dist gave way to the parallax (mu2 Sco b). The other 26 have neither a distance * nor a parallax 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; /** * Planets whose host only one path places, which the share above has room to lose: mu2 Sco b's * archive rows give a parallax and no distance, and without the parallax it had no star — 6 327 * hosted instead of 6 328, and every check passed. */ const REQUIRED_HOSTED_PLANETS = ['mu2 Sco b']; /** * Two hosts' luminosities as the archive gives them, one either side of the Sun's: st_lum −2.821 * for Proxima (Ribas et al. 2017 measure 1.51×10⁻³ L☉) and +1.602 for HD 97048. That every * luminosity is positive catches st_lum stored unconverted and nothing else: read as 10^−x or e^x, * Proxima came out 662 or 0.0595 L☉ and every check passed. */ const LUMINOSITY_ANCHORS = [ { planet: 'Proxima Cen b', luminositySolar: 1.51e-3 }, { planet: 'HD 97048 b', luminositySolar: 40 } ]; const LUMINOSITY_ANCHOR_TOLERANCE = 0.1; /** * 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 574 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.` ); for (const name of REQUIRED_HOSTED_PLANETS) { const planet = exoplanets.find((candidate) => candidate.name === name); assertCondition(planet?.hostStarId != null, `${name} has no host star — a host the archive places by its parallax alone is no longer placed.`); } 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.' ); for (const anchor of LUMINOSITY_ANCHORS) { const luminosity = exoplanets.find((exoplanet) => exoplanet.name === anchor.planet)?.hostStarLuminositySolar; assertCondition( luminosity !== undefined && Math.abs(luminosity / anchor.luminositySolar - 1) <= LUMINOSITY_ANCHOR_TOLERANCE, `${anchor.planet}'s host is ${luminosity} L☉, not the ${anchor.luminositySolar} its st_lum gives — st_lum is not being read as the base-10 logarithm it is.` ); } 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.`); // The planets photographed by direct imaging, whose card must not say no image of them exists. // Measured: 101 of 101 flagged in the archive, and not transiting, are in the catalogue. What this // catches is the join by name failing, which would put every one of them back under "no image". const imaged = exoplanets.filter((exoplanet) => exoplanet.imaged).length; assertCondition(imaged >= MIN_IMAGED_EXOPLANETS, `Only ${imaged} exoplanets are marked as imaged (at least ${MIN_IMAGED_EXOPLANETS} expected).`); // And the one the flag is wrong on, which the count cannot see: a 2.68-day transiting hot Jupiter // 0.15 mas from its star, flagged for the companion star a survey imaged beside it. Its record // carries neither a period nor an axis, so no separation check could catch it either. assertCondition( !exoplanets.some((exoplanet) => exoplanet.id === 'WASP-108 b' && exoplanet.imaged), 'WASP-108 b is marked as imaged; it transits, and only a companion star beside it was imaged (Bohn et al. 2020).' ); console.log(` ${imaged} were imaged directly.`); } const MIN_IMAGED_EXOPLANETS = 95; 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(); 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 { console.log('=== NASA star map ETL ===\n'); console.log('Catalogues:'); console.log(describeSources()); console.log(); const catalogueStars = await fetchStars(); console.log(); const { bodies, horizonsOrbits, horizonsTracks } = 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, horizonsOrbits, horizonsTracks); 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; });