Draw each HYG star at Gaia's distance, and keep the ones Hipparcos misplaced

HYG and Gaia were both cut at 250 pc, each on its own distance. A star
Hipparcos put at 200 pc and Gaia at 300 was kept by the first, never
downloaded from the second, and drawn at 200. That is where 83% of the
9 691 mid-magnitude HYG stars without a Gaia counterpart came from, and at
the median Hipparcos had them a third too close. The mirror case, Hipparcos
outside and Gaia inside, dropped the HYG row and left its Gaia entry
anonymous.

Gaia's own Hipparcos cross-match (hipparcos2_best_neighbour, a fixed DR3
table of 99 525 rows) gives a usable Gaia distance for 97 751 of them.
placementDistancePc keeps a star either survey puts inside the cutoff, and
draws every kept star at the better measurement, inside the cutoff or not.
57 121 HYG stars now sit at Gaia's distance. 6 833 of them are past 250 pc:
Zet Per 230 -> 259 pc, 35 Ori 137 -> 330, 44 Cnc 223 -> 613, and the
farthest, HIP 69445, at 8.7 kpc. 3 666 stars that Hipparcos put outside are
now kept, and 3 656 of them give a Gaia entry its name.

The cross-match is required rather than skipped when unreachable. Without
it, every one of those stars would move back to its Hipparcos distance, and
the published map would flip with the archive's availability. The ESA TAP
answered it with a 500 at first and in 102 s on the next try. So fetches
now retry 5xx and network failures twice, after 30 s and 120 s, in the
fetch every source goes through. The refresh job also carries the Gaia DR3
responses from run to run in the Actions cache: the release is frozen, and
a live re-fetch has already reproduced stars.bin byte for byte.

423 651 stars (+10), 61 168 HYG rows folded into Gaia entries (+3 656),
351 597 unnamed designations (-3 656). 10 886 HYG survivors and 23 unmerged
pairs under an arcsecond, both inside the merge gate's ceilings. The same
1 972 exoplanets have a host; KELT-4 A b and MWC 758 c now sit on their
named star.

The HUD's "Radius" becomes "Survey radius": 250 pc is where Gaia is
surveyed to, and no longer the edge of the map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-09-11 19:07:12 +02:00
co-authored by Claude Opus 5
parent 29d3ddb6ef
commit 4eb61ff58e
12 changed files with 189 additions and 32 deletions
+13 -4
View File
@@ -39,10 +39,19 @@ jobs:
- run: npm ci
# The ETL's cache directory is gitignored and this is a fresh runner, so every source is
# fetched live (~50-100 MB). A failed fetch fails the run by design — no refresh is
# better than a partial one. That includes Gaia: the ETL skips it when unreachable, and
# the merge gate in build.ts then refuses a catalogue it contributed nothing to.
# Gaia DR3 is a frozen release: the same query returns the same bytes (a live re-fetch has
# reproduced stars.bin exactly), so its responses are carried from one run to the next
# rather than re-downloaded every week from an archive that times out under load. The key
# follows gaia.ts, where the queries are written, so a changed query is fetched afresh.
- uses: actions/cache@v4
with:
path: tools/etl/.cache/gaia-dr3-*.csv
key: gaia-dr3-${{ hashFiles('tools/etl/sources/gaia.ts') }}
# Every other source is fetched live on this fresh runner. A failed fetch fails the run by
# design — no refresh is better than a partial one. That includes Gaia on a cold cache:
# the ETL skips it when unreachable, and the merge gate in build.ts then refuses a
# catalogue it contributed nothing to.
- name: Rebuild the datasets
run: npm run etl
@@ -1178,7 +1178,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
{ label: 'Catalogued', value: `${this.stars.length} stars` }
]);
// Quotes the catalogue's own reach rather than a figure that has already been raised once.
this.hudNote.set(`Galactic structure is an illustrative model built on measured arm geometry — no catalogue holds the Galaxy’s stars. The ${this.stars.length} catalogued stars within ${LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]} pc are real.`);
this.hudNote.set(`Galactic structure is an illustrative model built on measured arm geometry — no catalogue holds the Galaxy’s stars. The ${this.stars.length} catalogued stars are real.`);
return;
}
@@ -1189,7 +1189,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// Both numbers, because they differ: the catalogue is what the map knows and the first is
// what it draws. See `STAR_RENDER_BUDGET`.
{ label: 'Stars', value: this.starField && this.starField.drawnCount < this.stars.length ? `${this.starField.drawnCount} / ${this.stars.length}` : `${this.stars.length}` },
{ label: 'Radius', value: `${LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]} pc` },
// The radius Gaia is surveyed to, not the edge of the map: the Hipparcos stars Gaia places
// further out are drawn where it places them.
{ label: 'Survey radius', value: `${LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]} pc` },
{ label: 'Exoplanets', value: `${this.exoplanets.length}` },
// The one thing the field itself cannot show: which of those points can be flown into.
{ label: 'Systems', value: `${this.enterableSystems}` }
+33 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { raDegDecDistanceToXyz } from './coordinates';
import { StarRecord } from '../models/star.model';
import { directionCosine, isSameStar, MERGE_ANGULAR_TOLERANCE_DEG, mergeStarCatalogues } from './star-merge';
import { directionCosine, isSameStar, MERGE_ANGULAR_TOLERANCE_DEG, mergeStarCatalogues, placementDistancePc } from './star-merge';
/** A star at a given sky position and distance, which is how catalogues actually report them. */
function at(id: number, raDeg: number, decDeg: number, distancePc: number, overrides: Partial<StarRecord> = {}): StarRecord {
@@ -237,3 +237,35 @@ describe('mergeStarCatalogues', () => {
expect(Date.now() - started).toBeLessThan(10000);
});
});
describe('placementDistancePc', () => {
it("draws a star both surveys measured at Gaia's distance", () => {
expect(placementDistancePc(120, 118.4, 250)).toBe(118.4);
});
// The case the old cut got wrong: Hipparcos inside, Gaia outside. Kept, at the distance Gaia
// gives, rather than at one a third short or dropped for having been misplaced.
it('keeps a star Hipparcos put inside the cutoff, where Gaia puts it, even past the cutoff', () => {
expect(placementDistancePc(200, 306, 250)).toBe(306);
});
// The mirror image: Hipparcos outside, Gaia inside. The Gaia download already holds the star,
// and keeping the HYG row is what lets the merge give that entry its name.
it('keeps a star only Gaia puts inside the cutoff', () => {
expect(placementDistancePc(262, 241, 250)).toBe(241);
});
it('keeps a star Gaia measured and Hipparcos gave no distance for', () => {
expect(placementDistancePc(undefined, 180, 250)).toBe(180);
});
it('falls back to Hipparcos where Gaia has no usable distance', () => {
expect(placementDistancePc(90, undefined, 250)).toBe(90);
});
it('drops a star both surveys put outside, or neither measured', () => {
expect(placementDistancePc(300, 410, 250)).toBeNull();
expect(placementDistancePc(300, undefined, 250)).toBeNull();
expect(placementDistancePc(undefined, undefined, 250)).toBeNull();
});
});
+22
View File
@@ -67,6 +67,28 @@ export const MERGE_BRIGHTER_TOLERANCE = 1;
*/
export const MERGE_DISTANCE_RATIO_TOLERANCE = 0.5;
/**
* Where to draw a star Hipparcos and Gaia both measured, and whether the map keeps it at all.
*
* Gaia's distance wherever it has a usable one, since its parallaxes are fifty times more
* precise; Hipparcos's otherwise. The two catalogues used to be cut at the same radius, each on
* its own distance, so a star Hipparcos put at 200 pc and Gaia at 300 was kept by one, never
* downloaded from the other, and drawn at 200. That was 83% of the HYG stars left without a
* Gaia counterpart, and at the median Hipparcos had them at two-thirds of Gaia's distance.
*
* Now a star either survey places inside `cutoffPc` is kept, and every kept star sits where the
* better measurement puts it, inside the cutoff or not. `null` for a star neither survey places
* inside, or that no survey gives a distance for.
*/
export function placementDistancePc(hipparcosPc: number | undefined, gaiaPc: number | undefined, cutoffPc: number): number | null {
const best = gaiaPc ?? hipparcosPc;
if (best === undefined) {
return null;
}
const inside = best <= cutoffPc || (hipparcosPc !== undefined && hipparcosPc <= cutoffPc);
return inside ? best : null;
}
export interface MergeCandidate {
readonly sourceId: string;
/** Lower is better — the parallax precision this source measures with, in milliarcseconds. */
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+38 -17
View File
@@ -1,8 +1,9 @@
import { writeFileSync } from 'node:fs';
import { mergeStarCatalogues } from '../../src/app/shared/astro/star-merge';
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 } from './sources/gaia';
import { positionalSources } from './sources/registry';
import { PARALLAX_PRECISION_MAS } from './sources/star-sources';
import { parseCsvObjects, parseOptionalNumber } from './lib/csv';
@@ -19,13 +20,14 @@ const HYG_UNKNOWN_DISTANCE_PC = 100000; // HYG's placeholder for unmeasured/unre
const UNKNOWN_MAGNITUDE = 15;
/**
* Stars within this distance (parsecs) of the Sun are kept for the galaxy view.
* 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 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.
* 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.
@@ -58,27 +60,35 @@ function resolveName(row: Record<string, string>): string {
}
/**
* Downloads the HYG (Hipparcos/Yale/Gliese) stellar database, takes each star's equatorial
* Cartesian position (parsecs, epoch J2000.0), filters by distance, unions the other positional
* sources, and writes `stars.bin` (packed positions) + `stars-index.json` (everything else).
* 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']);
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) {
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;
}
@@ -89,26 +99,37 @@ export async function fetchStars(): Promise<StarRecord[]> {
// 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']);
if (![x, y, z].every(Number.isFinite)) {
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,
y,
z,
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).`);
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);
+26 -5
View File
@@ -17,17 +17,38 @@ export async function fetchTextCached(url: string, cacheKey: string): Promise<st
}
console.log(` fetching ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
const text = await response.text();
const text = await fetchText(url);
mkdirSync(dirname(cachePath), { recursive: true });
writeFileSync(cachePath, text, 'utf-8');
return text;
}
/**
* How long to wait before each retry of a failed request. The archives this reads are public
* services that time out under load — the Gaia TAP has answered a five-row join in two and a
* half minutes and a full one with a 500 — and a weekly refresh that gives up on the first of
* those publishes nothing that week.
*/
const RETRY_DELAYS_MS = [30_000, 120_000];
async function fetchText(url: string): Promise<string> {
for (let attempt = 0; ; attempt++) {
const response = await fetch(url).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))));
if (!(response instanceof Error) && response.ok) {
return response.text();
}
const reason = response instanceof Error ? response.message : `${response.status} ${response.statusText}`;
// A 4xx is the request's own fault, and waiting will not change the answer.
const retryable = response instanceof Error || response.status >= 500;
if (!retryable || attempt >= RETRY_DELAYS_MS.length) {
throw new Error(`Failed to fetch ${url}: ${reason}`);
}
console.log(` ${reason}; trying again in ${RETRY_DELAYS_MS[attempt] / 1000} s`);
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS_MS[attempt]));
}
}
/** Convenience wrapper around {@link fetchTextCached} that parses the cached response as JSON. */
export async function fetchJsonCached<T>(url: string, cacheKey: string): Promise<T> {
return JSON.parse(await fetchTextCached(url, cacheKey)) as T;
+49
View File
@@ -125,3 +125,52 @@ export async function fetchGaiaStars(): Promise<StarRecord[]> {
console.log(` kept ${stars.length} Gaia stars (of ${rows.length} rows).`);
return stars;
}
/**
* DR3's Hipparcos cross-match is a fixed table of 99 525 rows, 97 751 of them with a usable
* parallax. Far fewer means the answer was an error page served with a 200, or was cut short,
* and either would pass for "Gaia does not know these stars" and put every one of them back at
* its Hipparcos distance.
*/
const MIN_USABLE_HIP_DISTANCES = 90_000;
/**
* Gaia's distance for every Hipparcos star it has a usable parallax for, keyed by HIP number.
*
* Taken from the archive's own cross-match (`hipparcos2_best_neighbour`) rather than from
* matching positions here, since Gaia's team made that identification star by star with the
* proper motions and photometry in hand. It is deliberately not bounded by distance: the stars
* it exists for are the ones Hipparcos put inside the map and Gaia puts outside, which the main
* query above never fetches.
*
* Required rather than best effort. Without it every HYG star falls back to its Hipparcos
* distance, the 6 833 that Gaia puts past 250 pc move back inside, and the published map would
* flip between the two with the archive's availability.
*/
export async function fetchGaiaDistancesByHip(): Promise<Map<number, number>> {
const query = [
'select top 200000 b.original_ext_source_id as hip, g.parallax, g.parallax_over_error',
'from gaiadr3.hipparcos2_best_neighbour b join gaiadr3.gaia_source g on g.source_id = b.source_id'
].join(' ');
const url = `${GAIA_TAP_URL}?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&QUERY=${encodeURIComponent(query)}`;
console.log('Fetching Gaia DR3 distances for Hipparcos stars (archive cross-match)...');
const rows = parseCsvObjects(await fetchTextCached(url, `gaia-dr3-hip-${createHash('sha1').update(url).digest('hex').slice(0, 8)}.csv`));
const distances = new Map<number, number>();
for (const row of rows) {
const hip = parseOptionalNumber(row['hip']);
const parallaxMas = parseOptionalNumber(row['parallax']);
const overError = parseOptionalNumber(row['parallax_over_error']);
if (hip !== undefined && parallaxMas !== undefined && parallaxMas > 0 && overError !== undefined && overError > 1 / MAX_PARALLAX_ERROR_RATIO) {
distances.set(hip, 1000 / parallaxMas);
}
}
if (distances.size < MIN_USABLE_HIP_DISTANCES) {
throw new Error(
`the Hipparcos cross-match gave ${distances.size} usable distances (of ${rows.length} rows), not the ~97 751 it holds; ` +
'delete tools/etl/.cache/gaia-dr3-hip-*.csv once the archive answers properly'
);
}
console.log(` ${distances.size} Hipparcos stars have a Gaia distance (of ${rows.length} cross-matched).`);
return distances;
}
+2 -1
View File
@@ -14,7 +14,8 @@ export const STAR_SOURCES: readonly StarSource[] = [
name: 'HYG database (Hipparcos, Yale Bright Star, Gliese)',
role: 'positional',
endpoint: 'https://raw.githubusercontent.com/astronexus/HYG-Database',
contributes: 'A complete, named, spectrally classified bright-star catalogue with parallaxes — 68388 stars within 250 pc.',
contributes:
'A complete, named, spectrally classified bright-star catalogue. Its Hipparcos parallaxes give way to Gaia’s wherever Gaia has a usable one, so its stars sit where the better measurement puts them.',
unimplementedBecause: null
},
{