Merge pull request #18 from avalon-vanguard/star-map/fix/hyg-gaia-distances

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
Senrokai
2026-09-17 15:04:49 +02:00
committed by GitHub
co-authored by Claude Opus 5
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
},
{