Gaia's G < 12 cut took a quarter of what lies within 10 pc: the red, brown and white dwarfs most of the neighbourhood is made of, which the map only had where Gliese happened to list them. Teegarden's Star was missing, and with it its three planets' host. A second query fetches the complement out to 50 pc: G >= 12 or no G, parallax > 20 mas, with pmra/pmdec so the J2016 -> J2000 propagation applies. The quality filter was chosen by counting. Parallax over error > 5 keeps 39 751 of the 39 764 sources that pass the floor, but the Gaia Catalogue of Nearby Stars (GCNS; Gaia Collaboration, Smart et al. 2021) rejects 11 752 of them as spurious: median G 20.2, astrometric excess noise 5.4 mas against 0.15 for the ones it keeps, 6 933 toward the Galactic centre. So the query joins the GCNS main table (EDR3 astrometry and source ids, which DR3 carries unchanged) and keeps 28 012 rows; the error cut stays and costs no GCNS source, brown dwarfs included. The query has its own row-count floor (28 012) and the row-limit cap, and is ordered by (phot_g_mean_mag, source_id); a second ETL run reproduced stars.bin, stars-meta.bin and stars-index.json byte for byte. Its ids start at 1 050 000 000, clear of the main query's and under 2^30, which V8 keeps unboxed: numbered from 2 000 000 000 they made the app's boot task 230 ms longer (medians of five interleaved runs, 1.41 s against 1.18). validateStars now refuses an id outside 0 to 2^30. The Gliese entries these stars duplicate were not folded: HYG carries them with positions off by up to a minute of arc and photometric distances, so they missed the 15" tolerance or failed the distance test. Their proper motions, which Gliese measured well, give them away: isSameStar now takes two entries moving within 20 % of each other as one star up to 60" apart, whatever their distances, brightness still permitting. Of the 602 Gliese-only rows left without a counterpart, 253 have such a Gaia entry; with every entry shifted a quarter degree, none does. fetchStars passes HYG's motions only for rows without Hipparcos astrometry: given them too, 15 Hipparcos stars took a co-moving companion's Gaia entry and the cross-catalogue pairs under an arcsecond went from 23 to 35. Of HYG's 1 200 stars fainter than V 12 within 25 pc, 1 024 now sit on a Gaia position (325 before); of the 176 left alone, 43 still have a Gaia entry 3-60" away (218 without the motion rule), some of them real companions. Measured on the rebuilt catalogue, against the GCNS (sources with parallax > 100, 40 and 20 mas): within 10 pc 336 -> 372 (GCNS 312; the map adds 60 HYG-only stars) within 25 pc 3 652 -> 5 560 (GCNS 5 111) within 50 pc 13 702 -> 40 916 (GCNS 40 231) 452 331 stars (+27 214). Proxima, Barnard's Star, Wolf 359, Rigel, Deneb and Alnilam are all present by name; Teegarden's Star is Gaia DR3 35227046884571776 at 3.83 pc and hosts its three planets. Luhman 16 is not in Gaia DR3 with a parallax (5353626573555863424 has a two-parameter solution) and stays absent. 94 more exoplanets find a host (2 071), none changes host. TRAPPIST-1 is now drawn (Gaia DR3 2635476908753563008, 12.47 pc) but its planets are not yet matched to it. Merge gate, ceilings unchanged: HYG rows without a Gaia counterpart 12 352 -> 11 554 (ceiling 15 000; 10 886 before the naked-eye stars), cross-catalogue pairs under an arcsecond 23 -> 23 (ceiling 100). The gate's comment now accounts for the survivors by magnitude. gzip -9 sizes against the catalogue before both changes: stars.bin 4 711 922 -> 5 030 741 B, stars-meta.bin 2 576 213 -> 2 784 614 B, stars-index.json 3 724 855 -> 4 003 584 B (+806 KB, 7.3 %). Boot on the dev server, five interleaved cold runs: the task that indexes the catalogue after the data lands, median 1 072 -> 1 182 ms; HUD shown, median 2 622 -> 2 716 ms. This machine measured 0.82-1.30 s for the same baseline task today, above audit #25's 627-843 ms. The drawn-star budget is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
210 lines
8.9 KiB
TypeScript
210 lines
8.9 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, and every naked-eye star wherever it is; `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 magnitude = parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE;
|
|
const distancePc = placementDistancePc(hipparcosPc, gaiaPc, magnitude, 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,
|
|
spectralType: row['spect'] || 'Unknown',
|
|
colorIndex: parseOptionalNumber(row['ci']) ?? null,
|
|
// Only for the Gliese-only rows, whose positions are what the merge needs the motion to see
|
|
// past. A Hipparcos position is good to under an arcsecond; given its motion too, 15 stars
|
|
// took their co-moving companion's Gaia entry, and the companion was kept twice.
|
|
...(row['hip'] ? {} : { pmRaMasYr: parseOptionalNumber(row['pmra']), pmDecMasYr: parseOptionalNumber(row['pmdec']) })
|
|
});
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|