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