Stop telling the reader that the hundred directly imaged exoplanets were never imaged

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>
This commit is contained in:
2026-09-29 21:50:04 +02:00
co-authored by Claude Opus 5.5
parent c45d9160e3
commit 48fd6fd0e5
9 changed files with 49 additions and 7 deletions
@@ -32,6 +32,8 @@ export interface BodyDetailViewModel {
appearance: PlanetAppearance;
/** True when a real photograph is being shown rather than the derived surface. */
hasPhotography: boolean;
/** An exoplanet photographed by direct imaging, as a point of light; see `ExoplanetRecord.imaged`. */
imaged?: boolean;
/**
* Sidereal orbital period. For a solar-system body, 360 degrees over JPL's published mean
* motion; for an exoplanet, the archive's period where it published one, and undefined where not.
+11 -4
View File
@@ -75,15 +75,22 @@ export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts {
* The derived surface is a reasoned illustration, and a panel of real measurements sitting next
* to it is exactly the context in which it could be mistaken for another one.
*
* Only an exoplanet has never been imaged. A moon or dwarf planet drawn this way has been — Voyager
* 2 photographed Uranus's five large moons, Proteus and Nereid, Cassini Hyperion, and Hubble sees
* Eris, Haumea and Makemake as points — but has no global map this app can use.
* A moon or dwarf planet drawn this way has been imaged — Voyager 2 photographed Uranus's five
* large moons, Proteus and Nereid, Cassini Hyperion, and Hubble sees Eris, Haumea and Makemake as
* points — but has no global map this app can use. So have the hundred or so exoplanets the
* archive flags as imaged, HR 8799's four among them, though only as points of light beside their
* star. Only the other exoplanets, known from what they do to starlight, have no image at all.
*/
function provenanceFor(body: BodyDetailViewModel): string {
if (body.hasPhotography) {
return 'Surface: NASA/ESA/USGS photography.';
}
const why = body.kind === 'exoplanet' ? 'no image of this world exists' : 'no global map of this world is used here';
const why =
body.kind !== 'exoplanet'
? 'no global map of this world is used here'
: body.imaged
? 'it has been imaged only as a point of light beside its star, and no map of it exists'
: 'no image of this world exists';
return body.appearance.equilibriumTemperatureK === null
? `Surface illustrated from this body’s measured size and mass. Its host star is not in the catalogue, so no temperature could be derived. Not an observation — ${why}.`
: `Surface illustrated from the measurements above — size, density and the temperature derived from its star’s output and its orbit. Not an observation — ${why}.`;
@@ -91,12 +91,20 @@ describe('buildBodyViewModel', () => {
expect(provenance).not.toContain('no image of this world exists');
});
it('says an exoplanet has never been imaged', () => {
it('says an exoplanet the archive does not flag as imaged has no image', () => {
const exoplanet: ExoplanetRecord = { id: 'x', hostStarId: SUN_STAR_ID, hostStarName: 'Sol', name: 'X b', orbit: { semiMajorAxisAu: 0.05 } };
const model = buildBodyViewModel('x', { bodies: [], exoplanets: [exoplanet], stars: [sun] })!;
expect(bodyReadouts(model).provenance).toContain('Not an observation — no image of this world exists.');
});
it('says a directly imaged exoplanet was seen as a point of light, not that no image of it exists', () => {
// HR 8799 b: photographed beside its star at Gemini and Keck (Marois et al. 2008).
const exoplanet: ExoplanetRecord = { id: 'HR 8799 b', hostStarId: SUN_STAR_ID, hostStarName: 'HR 8799', name: 'HR 8799 b', imaged: true, orbit: { semiMajorAxisAu: 68 } };
const provenance = bodyReadouts(buildBodyViewModel('HR 8799 b', { bodies: [], exoplanets: [exoplanet], stars: [sun] })!).provenance;
expect(provenance).toContain('Not an observation — it has been imaged only as a point of light beside its star, and no map of it exists.');
expect(provenance).not.toContain('no image of this world exists');
});
it('marks a published exoplanet period as measured, not derived', () => {
const exoplanet: ExoplanetRecord = {
id: 'kepler-22-b',
@@ -78,6 +78,7 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body
orbit: exoplanet.orbit,
appearance: appearanceForExoplanet(exoplanet, luminosityOf(hostStar)),
hasPhotography: bodyTexturePath(exoplanet.id) !== undefined,
imaged: exoplanet.imaged,
// `periodDays` is populated for none of the shipped records, and deriving one would need the
// host star's mass, which is equally absent. Left undefined rather than assuming a solar-mass
// host, which would silently mis-state the period of every planet around an M dwarf.
+5
View File
@@ -12,6 +12,11 @@ export interface ExoplanetRecord {
radiusEarth?: number;
massEarth?: number;
discoveryYear?: number;
/**
* True where the archive flags the planet as detected by imaging (`ima_flag`): photographed as a
* point of light beside its star, as HR 8799's four planets were. Absent for every other planet.
*/
imaged?: true;
/**
* Measured orbital period in days (`pl_orbper`). Together with the semi-major axis this
* pins the host star's gravitational parameter exactly, so the planet can be propagated at
+2 -1
View File
@@ -5,7 +5,8 @@ import * as THREE from 'three/webgpu';
* keyed by the same ids used in `bodies.json`.
*
* Only surface *maps* belong here: equirectangular images, twice as wide as tall, that wrap a
* sphere. Everything else — every exoplanet, since not one has ever been imaged, and every moon
* sphere. Everything else — every exoplanet, since the few imaged were seen only as points of
* light, and every moon
* or dwarf planet with no such map in the repository — falls through to
* `procedural-planet-texture.ts`, which derives a surface from the body's own measured size,
* mass, orbit and host star instead.
File diff suppressed because one or more lines are too long
+9
View File
@@ -421,8 +421,17 @@ function validateExoplanets(exoplanets: ExoplanetRecord[], starIds: Set<number>)
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 {
+9
View File
@@ -41,6 +41,13 @@ const TAP_URL = `${TAP_BASE_URL}?query=${TAP_QUERY}`;
// silently wrong rather than visibly broken.
const CACHE_FILE = `exoplanet-archive-ps-${createHash('sha1').update(TAP_URL).digest('hex').slice(0, 8)}.csv`;
// The planets the archive flags as detected by imaging (`ima_flag`): 102 of them in September 2026,
// HR 8799's four and 51 Eri b among them, and bet Pic c and eps Ind A b, found by radial velocity
// and imaged since. Asked for on its own, so adding it did not refetch the table above and move
// every other planet to a newer snapshot.
const IMAGED_URL = `${TAP_BASE_URL}?query=select+pl_name+from+ps+where+default_flag=1+and+ima_flag=1+order+by+pl_name&format=csv`;
const IMAGED_CACHE_FILE = `exoplanet-archive-imaged-${createHash('sha1').update(IMAGED_URL).digest('hex').slice(0, 8)}.csv`;
/**
* Downloads confirmed exoplanets from the NASA Exoplanet Archive (`Planetary Systems` TAP
* table), cross-references each host star to the HYG index, and writes `exoplanets.json`.
@@ -52,6 +59,7 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
const csv = await fetchTextCached(TAP_URL, CACHE_FILE);
const rows = parseCsvObjects(csv);
const imaged = new Set(parseCsvObjects(await fetchTextCached(IMAGED_URL, IMAGED_CACHE_FILE)).map((row) => row['pl_name']));
let matched = 0;
const exoplanets: ExoplanetRecord[] = rows.map((row, index) => {
@@ -80,6 +88,7 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
radiusEarth: parseOptionalNumber(row['pl_rade']),
massEarth: parseOptionalNumber(row['pl_bmasse']),
discoveryYear: parseOptionalNumber(row['disc_year']),
imaged: imaged.has(row['pl_name']) || undefined,
// The period was already being downloaded and thrown away. With the semi-major axis it
// determines the host's gravitational parameter, so keeping it is the difference between
// propagating a planet at its real rate and pretending every host is the Sun.