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
118 lines
3.9 KiB
TypeScript
118 lines
3.9 KiB
TypeScript
import { writeFileSync } from 'node:fs';
|
|
|
|
import { raDecDistanceToXyz } from '../../src/app/shared/astro/coordinates';
|
|
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
|
import { parseCsvObjects, parseOptionalNumber } from './lib/csv';
|
|
import { fetchTextCached } from './lib/http';
|
|
import { dataPath, ensureDataDir } from './lib/paths';
|
|
|
|
const HYG_CSV_URL = 'https://raw.githubusercontent.com/astronexus/HYG-Database/main/hyg/CURRENT/hygdata_v41.csv';
|
|
const HYG_UNKNOWN_DISTANCE_PC = 100000; // HYG's placeholder for unmeasured/unreliable parallax
|
|
|
|
/**
|
|
* Stand-in magnitude for a star with no photometry. Faint rather than 0, because 0 would mean
|
|
* "as bright as Vega" and render it as one of the largest points on the map.
|
|
*/
|
|
const UNKNOWN_MAGNITUDE = 15;
|
|
|
|
/** Stars within this distance (parsecs) of the Sun are kept for the galaxy view. */
|
|
const DISTANCE_CUTOFF_PC = Number(process.env['ETL_STAR_DISTANCE_PC'] ?? 50);
|
|
|
|
function resolveName(row: Record<string, string>): string {
|
|
if (row['proper']) {
|
|
return row['proper'];
|
|
}
|
|
if (row['bayer'] && row['con']) {
|
|
return `${row['bayer']} ${row['con']}`;
|
|
}
|
|
if (row['flam'] && row['con']) {
|
|
return `${row['flam']} ${row['con']}`;
|
|
}
|
|
if (row['hd']) {
|
|
return `HD ${row['hd']}`;
|
|
}
|
|
if (row['gl']) {
|
|
// Already a complete designation ("Gl 581", "GJ 3512"), unlike the bare numbers in `hd`
|
|
// and `hip` — prefixing it again produced 2331 stars named "Gl GJ 1076", which broke
|
|
// search, the on-screen labels, and exoplanet host-star name matching alike.
|
|
return row['gl'];
|
|
}
|
|
if (row['hip']) {
|
|
return `HIP ${row['hip']}`;
|
|
}
|
|
return `HYG ${row['id']}`;
|
|
}
|
|
|
|
/**
|
|
* Downloads the HYG (Hipparcos/Yale/Gliese) stellar database, converts each star's
|
|
* RA/Dec/distance into galaxy-scale Cartesian coordinates (parsecs), filters by distance,
|
|
* and writes `stars.bin` (packed positions) + `stars-index.json` (everything else).
|
|
*/
|
|
export async function fetchStars(): Promise<StarRecord[]> {
|
|
console.log(`Fetching HYG star catalog (distance cutoff: ${DISTANCE_CUTOFF_PC} pc)...`);
|
|
const csv = await fetchTextCached(HYG_CSV_URL, 'hygdata_v41.csv');
|
|
const rows = parseCsvObjects(csv);
|
|
|
|
const stars: StarRecord[] = [];
|
|
|
|
for (const row of rows) {
|
|
const id = Number(row['id']);
|
|
const distancePc = Number(row['dist']);
|
|
|
|
if (id === SUN_STAR_ID) {
|
|
stars.push({ id, name: 'Sol', x: 0, y: 0, z: 0, magnitude: parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE, spectralType: row['spect'] || 'G2V', colorIndex: parseOptionalNumber(row['ci']) ?? null });
|
|
continue;
|
|
}
|
|
|
|
if (!Number.isFinite(distancePc) || distancePc >= HYG_UNKNOWN_DISTANCE_PC || distancePc > DISTANCE_CUTOFF_PC) {
|
|
continue;
|
|
}
|
|
|
|
const raHours = Number(row['ra']);
|
|
const decDeg = Number(row['dec']);
|
|
if (!Number.isFinite(raHours) || !Number.isFinite(decDeg)) {
|
|
continue;
|
|
}
|
|
|
|
const { x, y, z } = raDecDistanceToXyz(raHours, decDeg, distancePc);
|
|
|
|
stars.push({
|
|
id,
|
|
name: resolveName(row),
|
|
x,
|
|
y,
|
|
z,
|
|
magnitude: parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE,
|
|
spectralType: row['spect'] || 'Unknown',
|
|
colorIndex: parseOptionalNumber(row['ci']) ?? null
|
|
});
|
|
}
|
|
|
|
stars.sort((a, b) => a.id - b.id);
|
|
writeStarAssets(stars);
|
|
|
|
console.log(` kept ${stars.length} stars (of ${rows.length} in the catalog).`);
|
|
return stars;
|
|
}
|
|
|
|
function writeStarAssets(stars: StarRecord[]): void {
|
|
ensureDataDir();
|
|
|
|
const positions = new Float32Array(stars.length * 3);
|
|
stars.forEach((star, index) => {
|
|
positions[index * 3] = star.x;
|
|
positions[index * 3 + 1] = star.y;
|
|
positions[index * 3 + 2] = star.z;
|
|
});
|
|
|
|
writeFileSync(dataPath('stars.bin'), Buffer.from(positions.buffer));
|
|
writeFileSync(dataPath('stars-index.json'), JSON.stringify(stars));
|
|
}
|
|
|
|
if (require.main === module) {
|
|
fetchStars().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|