Files
star-map/tools/etl/fetchStars.ts
Claude efa9e4084a Draw the whole catalogue, and build the aggregation the rest would need
Two things, one verified and one that cannot be.

The render budget is now the whole catalogue: 68388 stars, one instanced
draw call, which is what a GPU should be asked to do. The budget itself
stays, because the catalogue is meant to grow past what any machine should
draw at once — Gaia alone could contribute a million — and at that point
the selection is what keeps the field legible rather than a grey wash. A
`?stars=` override handles the machines that cannot, including the
software rasterizer the end-to-end suite runs against, whose frame rate is
two orders of magnitude below a real GPU's and which was measuring the
rasterizer rather than the app.

The aggregation is the second thing, and none of it has run. Every ESA,
NOIRLab, SDSS and Euclid endpoint is unreachable from here — only GitHub
raw is, which is why HYG and OpenNGC are the current sources. So this is
infrastructure and a Gaia query written against the published DR3 schema,
not data.

What the framework encodes is that these surveys are not interchangeable.
The distinction is not size but whether a catalogue knows how far away its
objects are, because a 3D map cannot place a star it only has a direction
for. Gaia is the only one of the five that can add stars here, because it
is the only one that measures parallaxes. DECaPS2 has fifty times Gaia's
object count and photometry alone — not one of its 3.32 billion objects
can be placed in depth. Euclid's bulge is 8 kpc away, where a parallax is
microarcseconds; its contribution would be imagery. SDSS-V and SAGA are
keyed to stars something else already places, so they enrich rather than
extend. Those roles are recorded as data the ETL prints, not as prose that
can drift.

Overlapping catalogues are reconciled on direction rather than on 3D
proximity, which is the one non-obvious part. Two surveys agree on a
star's direction to within an arcsecond and disagree on its distance by
tens of per cent, so a star at 200 pc is 50 pc from itself between
catalogues while being unmistakably the same object. Matching in 3D would
need a tolerance so loose it swallowed real neighbours. The better
parallax wins where both reach; where only one does, the star stays.

Names become dense-with-holes with a source dictionary, because a survey
catalogue has no proper names — writing "Gaia DR3 4472832130942575872"
once per star would cost 25 MB per million to repeat what two adjacent
fields already say. An empty entry costs three bytes and is regenerated on
load. The Sun needed its own case in the merge: it sits at the origin, has
no direction to compare, and appears in every catalogue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
2026-08-05 08:51:54 +00:00

171 lines
6.3 KiB
TypeScript

import { writeFileSync } from 'node:fs';
import { raDecDistanceToXyz } from '../../src/app/shared/astro/coordinates';
import { mergeStarCatalogues } from '../../src/app/shared/astro/star-merge';
import { encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
import { positionalSources } from './sources/registry';
import { PARALLAX_PRECISION_MAS } from './sources/star-sources';
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.
*
* Set at the range HYG's own measurements reach rather than at a round number. 98.6% of its
* rows carry a Hipparcos identifier, and Hipparcos parallaxes are good to roughly a
* milliarcsecond — so at 250 pc (4 mas) a star's distance is uncertain by some tens of per
* cent, and beyond it the catalogue is plotting noise. Note that only the *radial* placement
* blurs: a star's direction on the sky stays exact at any distance.
*
* The catalogue is also magnitude-limited, so this is not a volume-complete sample beyond about
* 50 pc: it thins to the intrinsically bright, which is the same selection the naked eye makes.
*/
const DISTANCE_CUTOFF_PC = Number(process.env['ETL_STAR_DISTANCE_PC'] ?? 250);
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
});
}
console.log(` kept ${stars.length} stars (of ${rows.length} in the catalog).`);
const merged = await mergeWithOtherSources(stars);
merged.sort((a, b) => a.id - b.id);
writeStarAssets(merged);
return merged;
}
/**
* Unions HYG with every other positional source that is wired in and reachable.
*
* A source that cannot be reached is reported and skipped rather than failing the run. That is
* not defensive padding: the archives this would draw on are frequently unavailable, and a build
* that produces a smaller catalogue is far better than one that produces none.
*/
async function mergeWithOtherSources(hygStars: StarRecord[]): Promise<StarRecord[]> {
const others = positionalSources().filter((source) => source.id !== 'hyg');
if (others.length === 0) {
return hygStars;
}
const candidates = [{ sourceId: 'hyg', parallaxPrecisionMas: PARALLAX_PRECISION_MAS['hyg'], stars: hygStars }];
for (const source of others) {
try {
candidates.push({
sourceId: source.id,
parallaxPrecisionMas: PARALLAX_PRECISION_MAS[source.id] ?? 1,
stars: await source.fetch!()
});
} catch (error) {
console.log(` skipping ${source.name}: ${error instanceof Error ? error.message : error}`);
}
}
if (candidates.length === 1) {
return hygStars;
}
const { stars, summary } = mergeStarCatalogues(candidates);
console.log(` merged ${summary.total} stars from ${candidates.length} catalogues (${summary.duplicates} duplicates resolved to the better parallax):`);
for (const [sourceId, count] of Object.entries(summary.bySource)) {
console.log(` ${sourceId}: ${count}`);
}
return stars;
}
function writeStarAssets(stars: StarRecord[]): void {
ensureDataDir();
// The layout lives in `star-catalog.ts`, which the app decodes with — one definition, so the
// writer and the reader cannot drift.
const { index, positions, meta } = encodeStarCatalog(stars);
writeFileSync(dataPath('stars.bin'), Buffer.from(positions.buffer));
writeFileSync(dataPath('stars-meta.bin'), Buffer.from(meta));
writeFileSync(dataPath('stars-index.json'), JSON.stringify(index));
}
if (require.main === module) {
fetchStars().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}