HYG's Gliese-only rows, with no Hipparcos astrometry and no published error on their distance, reach the merge with positions off by up to minutes of arc, photometric distances and sometimes wrong proper motions, and isSameStar's geometry missed 49 of them beside their own Gaia entry. 43 lie within 25 pc and 27 are fainter than V 12, the layer the brief asked to merge without duplicates. GJ 3478 is 16.3" from its Gaia entry, past the 15" tolerance; GJ 2097 moves 39 % differently by HYG's motion; GJ 4285 is co-moving but 1.6 magnitudes brighter in HYG's V than Gaia's G. Two of them were false stars inside 10 pc: GJ 2097 at 6.41 pc and GJ 4285 at 6.80, which Gaia measures at 24.47 and 28.25. HYG also put Gl 94 31.6 degrees from where it is, and HD 23585 and HD 23713, Pleiades members at 135 pc, at 20.6 and 22.2. 0e9ab6f's "no Gliese row within 25 pc has a co-moving bare Gaia entry 3-300" away" held only under its own motion rule. fetchStars now asks SIMBAD once, in one cached TAP query, for the Gaia DR3 designation of every object it knows by a GJ number (4 868), maps HYG's `gl` column onto it, and foldByIdentity folds each Gliese-only row into the bare Gaia entry of that source: HYG's name, type and photometry, Gaia's position and distance, as combine does for any other pair. An ETL run from cache folds 49: 455 571 stars become 455 522, the stars within 10 pc 369 become 367, within 25 pc 5 522 become 5 479, HYG rows without a Gaia counterpart 11 517 become 11 468, distances with no published error 395 become 346. exoplanets.json is unchanged. In the running app GJ 2097 reads "24 pc, HYG, Gaia DR3 distance", GJ 4285 28 pc, Gl 94 17 pc, and 367 stars lie within 10 pc. validateMerge now refuses any Gliese-only row beside the bare Gaia entry SIMBAD names as the same star. Controls: folding with an empty identity map fails the ETL with "49 Gliese stars are drawn beside the Gaia source SIMBAD names them as, starting with GJ 1033" (the baseline passed); in the unit suite, folding a row with a Hipparcos error fails "leaves a star with a Hipparcos error, and a Gaia entry already folded into, alone", and keeping the Gliese position fails "folds a Gliese entry into the Gaia entry SIMBAD names it as, at Gaia's position and distance". HYG's V is kept for a folded star, which for GJ 3207 is the wrong one (11.51 where SIMBAD has 13.75). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
261 lines
12 KiB
TypeScript
261 lines
12 KiB
TypeScript
import { writeFileSync } from 'node:fs';
|
|
|
|
import { foldByIdentity, hipparcosDistancePc, 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, fetchHipparcosParallaxErrors, GaiaAnswerError } from './sources/gaia';
|
|
import { positionalSources } from './sources/registry';
|
|
import { fetchGaiaDesignationsByGj } from './sources/simbad';
|
|
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';
|
|
|
|
/**
|
|
* 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']}`;
|
|
}
|
|
|
|
/**
|
|
* HYG's spectral type, without the `...` that 2 127 of the map's stars end in, all of them
|
|
* Hipparcos stars: the Hipparcos catalogue's mark for a classification it does not print in full
|
|
* (Sirius is "A0m..."). On the map it read as text the app had cut short.
|
|
*/
|
|
function spectralTypeOf(row: Record<string, string>): string {
|
|
return (row['spect'] ?? '').replace(/\.\.\.$/, '') || 'Unknown';
|
|
}
|
|
|
|
/**
|
|
* Downloads the HYG (Hipparcos/Yale/Gliese) stellar database, places each star along its
|
|
* equatorial direction (epoch J2000.0) at whichever of its Hipparcos and Gaia distances has the
|
|
* smaller error, keeps the ones either survey puts within range, and unions the other positional
|
|
* sources. Writes nothing: fetchExoplanets adds the hosts the catalogue lacks and renames the
|
|
* ones known only by a designation, then writes the star assets once. Written here as well, they
|
|
* stood on disk without those 3 277 stars whenever a run stopped between the two — and that
|
|
* catalogue left 4 237 planets pointing at stars it did not have.
|
|
*/
|
|
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 gaiaByHip = await fetchGaiaDistancesByHip();
|
|
const hipparcosErrors = await fetchHipparcosParallaxErrors();
|
|
|
|
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, magnitudeBand: 'V', spectralType: row['spect'] || 'G2V', colorIndex: parseOptionalNumber(row['ci']) ?? null, colorSystem: 'B-V' });
|
|
continue;
|
|
}
|
|
|
|
const hygPc = Number(row['dist']);
|
|
const hipparcos = row['hip'] ? hipparcosErrors.get(Number(row['hip'])) : undefined;
|
|
const hipparcosPc = hipparcosDistancePc(hygPc, hipparcos);
|
|
const gaia = row['hip'] ? gaiaByHip.get(Number(row['hip'])) : undefined;
|
|
const gaiaPc = gaia?.distancePc;
|
|
const magnitudeV = parseOptionalNumber(row['mag']);
|
|
const magnitude = magnitudeV ?? UNKNOWN_MAGNITUDE;
|
|
const hipparcosError = hipparcos?.relativeError;
|
|
const distancePc = placementDistancePc(hipparcosPc, gaiaPc, magnitude, DISTANCE_CUTOFF_PC, hipparcosError, gaia?.relativeError);
|
|
if (distancePc === null) {
|
|
continue;
|
|
}
|
|
const fromGaia = gaia !== undefined && distancePc === gaiaPc;
|
|
|
|
// 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 (fromGaia) {
|
|
atGaiaDistance++;
|
|
}
|
|
if (distancePc > DISTANCE_CUTOFF_PC) {
|
|
pastCutoff++;
|
|
}
|
|
|
|
// Gaia's error with Gaia's distance, Hipparcos's with its own; a Gliese row, with neither, has
|
|
// no published error, and its distance is as often photometric as measured.
|
|
const distanceError = fromGaia ? gaia.relativeError : hipparcosError;
|
|
const colorIndex = parseOptionalNumber(row['ci']);
|
|
|
|
stars.push({
|
|
id,
|
|
name: resolveName(row),
|
|
x: x * scale,
|
|
y: y * scale,
|
|
z: z * scale,
|
|
magnitude,
|
|
...(magnitudeV === undefined ? {} : { magnitudeBand: 'V' as const }),
|
|
spectralType: spectralTypeOf(row),
|
|
colorIndex: colorIndex ?? null,
|
|
...(colorIndex === undefined ? {} : { colorSystem: 'B-V' as const }),
|
|
...(distanceError === undefined ? {} : { distanceError }),
|
|
distanceFromGaia: fromGaia,
|
|
// 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 { stars: merged, folded } = foldByIdentity(await mergeWithOtherSources(stars), await glieseGaiaDesignations(rows));
|
|
console.log(` ${folded} Gliese entries folded into the Gaia source SIMBAD names them as.`);
|
|
merged.sort((a, b) => a.id - b.id);
|
|
return merged;
|
|
}
|
|
|
|
/** HYG's Gliese designation as SIMBAD writes it: "Gl 734B" is "GJ 734 B". */
|
|
function simbadGliese(gl: string): string {
|
|
return gl.trim().replace(/^Gl\s+/, 'GJ ').replace(/^(GJ \d+(?:\.\d+)?)\s*([A-Z]+)$/, '$1 $2');
|
|
}
|
|
|
|
/**
|
|
* The Gaia DR3 designation SIMBAD gives each HYG star with a Gliese number, by HYG id: what the
|
|
* merge folds its Gliese-only rows by, and what build.ts checks it did. `rows` are HYG's, read
|
|
* again from the cache when not given.
|
|
*/
|
|
export async function glieseGaiaDesignations(rows?: Record<string, string>[]): Promise<Map<number, string>> {
|
|
const hyg = rows ?? parseCsvObjects(await fetchTextCached(HYG_CSV_URL, 'hygdata_v41.csv'));
|
|
const byGj = await fetchGaiaDesignationsByGj();
|
|
const designations = new Map<number, string>();
|
|
for (const row of hyg) {
|
|
const designation = row['gl'] ? byGj.get(simbadGliese(row['gl'])) : undefined;
|
|
if (designation) {
|
|
designations.set(Number(row['id']), designation);
|
|
}
|
|
}
|
|
return designations;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
export 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));
|
|
}
|
|
|
|
// On its own it writes what the ETL would, hosts included; imported late, since fetchExoplanets imports this module.
|
|
if (require.main === module) {
|
|
import('./fetchExoplanets').then(({ fetchExoplanets }) => fetchExoplanets()).catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|