Three things this got wrong. The direction: truncating Gaia leaves the HYG rows whose counterpart it dropped without one, so survivors rise — 10 886 today, 12 711 at half the rows, 16 258 at a third — which the comment claimed was the other way, and which decides whether the 15 000 ceiling can be leaned on at all (it catches a truncation past about two thirds, and nothing shallower). The throw: `fetchStars` catches everything a source throws and skips it, so a truncated CSV was reported as "the archive was unreachable" one step after `writeStarAssets` had already overwritten the published catalogue. Marked with `GaiaAnswerError` and rethrown there, so an answer that cannot be worked with fails the run where it happened. Measured end to end in a throwaway working directory, 300 000 rows in the cache: fails, names the cache file to delete, assets untouched. With the rethrow taken back out again: assets written, then "the archive was unreachable". The row limit: `rows.length >= ROW_LIMIT` is true for every reduced ETL_GAIA_ROW_LIMIT, so the tripwire fired on exactly the deliberate slice the override exists for — and told the operator to raise it. Gated on the same flag as its neighbour. `ETL_GAIA_ROW_LIMIT=20000` now runs through; without the gate it dies on the limit it was given. Also: the row floor names the one cache file it is about rather than a glob that takes the Hipparcos cross-match with it, and says an edited query is a third reason it can fire — DEFAULT_QUERY_ROWS now sits under the query it counts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
204 lines
8.5 KiB
TypeScript
204 lines
8.5 KiB
TypeScript
import { writeFileSync } from 'node:fs';
|
|
|
|
import { mergeStarCatalogues, placementDistancePc } 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 { fetchGaiaDistancesByHip, GaiaAnswerError } from './sources/gaia';
|
|
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 either survey places within this distance (parsecs) of the Sun are kept for the galaxy
|
|
* view; `placementDistancePc` decides which distance a kept star is drawn at.
|
|
*
|
|
* Set at the range Hipparcos's own measurements reach rather than at a round number: its
|
|
* parallaxes are good to roughly a milliarcsecond, so at 250 pc (4 mas) a distance is uncertain
|
|
* by some tens of per cent. That is why it is not applied to the Hipparcos distance alone:
|
|
* Gaia puts 6 833 of the stars Hipparcos places inside it outside, and 3 666 the other way
|
|
* round. Only the *radial* placement blurs; a star's direction on the sky stays exact.
|
|
*
|
|
* 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, places each star along its
|
|
* equatorial direction (epoch J2000.0) at the better of its Hipparcos and Gaia distances, keeps
|
|
* the ones either survey puts within range, unions the other positional sources, 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);
|
|
// Not skipped when unreachable, unlike the positional sources below; see its own comment.
|
|
const gaiaPcByHip = await fetchGaiaDistancesByHip();
|
|
|
|
const stars: StarRecord[] = [];
|
|
let atGaiaDistance = 0;
|
|
let pastCutoff = 0;
|
|
|
|
for (const row of rows) {
|
|
const id = Number(row['id']);
|
|
|
|
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;
|
|
}
|
|
|
|
const hygPc = Number(row['dist']);
|
|
const hipparcosPc = Number.isFinite(hygPc) && hygPc > 0 && hygPc < HYG_UNKNOWN_DISTANCE_PC ? hygPc : undefined;
|
|
const gaiaPc = row['hip'] ? gaiaPcByHip.get(Number(row['hip'])) : undefined;
|
|
const distancePc = placementDistancePc(hipparcosPc, gaiaPc, DISTANCE_CUTOFF_PC);
|
|
if (distancePc === null) {
|
|
continue;
|
|
}
|
|
|
|
// HYG's own Cartesian columns rather than its `ra`/`dec`, which are in the same frame as
|
|
// `raDecDistanceToXyz` and would be redundant if the two agreed. They do not, for the stars
|
|
// that move: the right ascension was carried from the Hipparcos epoch to 2000.0 without the
|
|
// cos δ its motion needs, which puts Proxima 17.9″ from where HYG's own x/y/z — and Gaia,
|
|
// once brought to the same epoch — have it. 1813 stars differ by over an arcsecond, and the
|
|
// Cartesian columns are the ones Gaia agrees with for 1155 of them against 156 (one of those,
|
|
// HIP 57146, has x/y/z 161″ from its own ra/dec and stays double).
|
|
//
|
|
// Only their direction is used. They sit at HYG's own distance, or at its 100 000 pc
|
|
// placeholder where it has none, and are carried along that direction to the one chosen above.
|
|
const x = Number(row['x']);
|
|
const y = Number(row['y']);
|
|
const z = Number(row['z']);
|
|
const length = Math.hypot(x, y, z);
|
|
if (![x, y, z].every(Number.isFinite) || length === 0) {
|
|
continue;
|
|
}
|
|
const scale = distancePc / length;
|
|
if (gaiaPc !== undefined) {
|
|
atGaiaDistance++;
|
|
}
|
|
if (distancePc > DISTANCE_CUTOFF_PC) {
|
|
pastCutoff++;
|
|
}
|
|
|
|
stars.push({
|
|
id,
|
|
name: resolveName(row),
|
|
x: x * scale,
|
|
y: y * scale,
|
|
z: z * scale,
|
|
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): ${atGaiaDistance} at Gaia's distance, ${pastCutoff} of them past ${DISTANCE_CUTOFF_PC} pc.`);
|
|
|
|
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 here rather than thrown, so a run still
|
|
* gets as far as validation and says what it has. Whether that may be published is decided
|
|
* there: `validateMerge` in build.ts refuses a catalogue Gaia contributed nothing to. A source
|
|
* that answered with something unusable ({@link GaiaAnswerError}) is a different matter, and stops
|
|
* the run where it happened rather than being reported later as an outage.
|
|
*/
|
|
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) {
|
|
// An answer that cannot be worked with is not an outage: skipping it would write a
|
|
// half-catalogue over the published assets before the merge gate got to say so.
|
|
if (error instanceof GaiaAnswerError) {
|
|
throw 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} entries folded into a better-measured one):`);
|
|
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;
|
|
});
|
|
}
|