Two ETL bugs, both fixed at the source and then re-run against HYG. Star ids,
ordering and positions are all unchanged, so stars.bin is byte-identical and
every exoplanet cross-reference still resolves.
Names. HYG's `gl` column already carries its own catalogue prefix ("Gl 581",
"GJ 3512"), unlike the bare numbers in `hd` and `hip`, so prefixing it again
produced 2331 of 8750 stars named "Gl GJ 1076". That corrupted three surfaces at
once: search, the on-screen labels, and exoplanet host-star name matching, which
compares normalised names and could never match "glgj1076" to "gj1076".
Colours. `Number(row['ci']) || 0` cannot tell a blank cell from a real zero, and
0 is a real B-V colour index meaning a hot blue-white A-type star. All 875
affected stars turned out to be blanks — the catalogue contains no genuine zero
inside the distance cutoff — so several hundred red dwarfs were rendering
blue-white. colorIndex is now `number | null` rather than defaulted, because any
numeric default is indistinguishable from a measurement.
Consumers resolve the gap from the spectral type instead. That needs real
parsing: HYG's `spect` column runs to 134 distinct spellings among the affected
stars alone, including a bare lowercase "m" for 354 of them, plus "k-m" ranges,
"dM4" luminosity prefixes and "K:" uncertainty flags. 622 of the 875 recover a
class this way — 497 of them M-class — and the remaining 253, which carry no
classification at all, fall back to neutral white.
The parse is anchored at the start of the string rather than scanning it. A scan
is the obvious implementation and is quietly wrong: the ETL writes the literal
"Unknown" for unclassified stars, that contains a K, and every one of those 253
would have been classified as an orange K-type. A test covers it.
Also lifts parseOptionalNumber out of fetchExoplanets into lib/csv, where both
fetchers now use it, and gives magnitude a faint default instead of 0 — no
current star is affected, but 0 would mean "as bright as Vega" and render an
unphotometered star as one of the largest points on the map.
Tests: 145 passing, up from 116, including the first coverage of
StarFieldRenderer. Build, both typechecks and the Playwright suite are green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
93 lines
3.3 KiB
TypeScript
93 lines
3.3 KiB
TypeScript
import { writeFileSync } from 'node:fs';
|
|
|
|
import { buildStarNameIndex, resolveHostStarId } from '../../src/app/shared/astro/host-star-matching';
|
|
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
|
|
import { StarRecord } from '../../src/app/shared/models/star.model';
|
|
import { fetchStars } from './fetchStars';
|
|
import { parseCsvObjects, parseOptionalNumber } from './lib/csv';
|
|
import { fetchTextCached } from './lib/http';
|
|
import { dataPath, ensureDataDir } from './lib/paths';
|
|
|
|
const TAP_BASE_URL = 'https://exoplanetarchive.ipac.caltech.edu/TAP/sync';
|
|
const TAP_COLUMNS = [
|
|
'pl_name',
|
|
'hostname',
|
|
'ra',
|
|
'dec',
|
|
'sy_dist',
|
|
'pl_orbsmax',
|
|
'pl_orbeccen',
|
|
'pl_orbincl',
|
|
'pl_orblper',
|
|
'pl_orbper',
|
|
'pl_rade',
|
|
'pl_bmasse',
|
|
'disc_year'
|
|
].join(',');
|
|
const TAP_QUERY = `select+${TAP_COLUMNS}+from+ps+where+default_flag=1&format=csv`;
|
|
const TAP_URL = `${TAP_BASE_URL}?query=${TAP_QUERY}`;
|
|
|
|
// A host star match must be within this many parsecs of the catalog position to be
|
|
// accepted as a cross-reference (guards against coincidental name/position collisions).
|
|
const MATCH_TOLERANCE_PC = 0.5;
|
|
|
|
/**
|
|
* 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`.
|
|
*/
|
|
export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRecord[]> {
|
|
console.log('Fetching confirmed exoplanets from the NASA Exoplanet Archive...');
|
|
const knownStars = stars ?? (await fetchStars());
|
|
const nameIndex = buildStarNameIndex(knownStars);
|
|
|
|
const csv = await fetchTextCached(TAP_URL, 'exoplanet-archive-ps.csv');
|
|
const rows = parseCsvObjects(csv);
|
|
|
|
let matched = 0;
|
|
const exoplanets: ExoplanetRecord[] = rows.map((row, index) => {
|
|
// `parseOptionalNumber`, not `Number`: a blank cell would otherwise become 0, which is a
|
|
// finite, plausible-looking coordinate rather than the "not measured" it actually means.
|
|
const raDeg = parseOptionalNumber(row['ra']) ?? Number.NaN;
|
|
const decDeg = parseOptionalNumber(row['dec']) ?? Number.NaN;
|
|
const distancePc = parseOptionalNumber(row['sy_dist']) ?? Number.NaN;
|
|
|
|
const hostStarId = resolveHostStarId(
|
|
{ hostname: row['hostname'], raDeg, decDeg, distancePc },
|
|
knownStars,
|
|
MATCH_TOLERANCE_PC,
|
|
nameIndex
|
|
);
|
|
if (hostStarId !== null) {
|
|
matched++;
|
|
}
|
|
|
|
return {
|
|
id: row['pl_name'] || `exoplanet-${index}`,
|
|
hostStarId,
|
|
hostStarName: row['hostname'],
|
|
name: row['pl_name'],
|
|
radiusEarth: parseOptionalNumber(row['pl_rade']),
|
|
massEarth: parseOptionalNumber(row['pl_bmasse']),
|
|
discoveryYear: parseOptionalNumber(row['disc_year']),
|
|
orbit: {
|
|
semiMajorAxisAu: parseOptionalNumber(row['pl_orbsmax']),
|
|
eccentricity: parseOptionalNumber(row['pl_orbeccen']),
|
|
inclinationDeg: parseOptionalNumber(row['pl_orbincl']),
|
|
argumentOfPeriapsisDeg: parseOptionalNumber(row['pl_orblper'])
|
|
}
|
|
};
|
|
});
|
|
|
|
ensureDataDir();
|
|
writeFileSync(dataPath('exoplanets.json'), JSON.stringify(exoplanets));
|
|
console.log(` wrote ${exoplanets.length} exoplanets (${matched} cross-referenced to a HYG host star).`);
|
|
return exoplanets;
|
|
}
|
|
|
|
if (require.main === module) {
|
|
fetchExoplanets().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|