Place a star at whichever of its two distances is the more precise, not at Gaia's regardless

placementDistancePc took Gaia's distance wherever the cross-match gave a usable one, and its
docstring and c64eea0 called that "the better measurement", adding that Gaia saturates on the
brightest stars "so those sit at their Hipparcos distance". For 273 of the 88 781 HYG stars with
both, Gaia's relative error is the larger, 257 of them naked-eye and 38 by more than twice: Eta Leo
was drawn at Gaia's 556.6 pc ±17 % against Hipparcos's 389 pc ±6.2 %, Schedar and Tarazed at
Gaia's though both have Gaia G of about 2.

placementDistancePc now takes both relative errors and picks the smaller, Gaia's as before when
either is missing; fetchStars gives the placed star the error and the Gaia-distance flag of the
distance it took. The merge's combine keeps the Gaia entry's direction, and now takes the other
entry's distance with its error where that is the more precise, so a bright star Gaia's main query
also holds lands at its Hipparcos distance too. Gaia still wins for the rest, whose parallaxes are
some fifty times more precise.

From cache: 424 stars move, 386 of them naked-eye; HYG rows at Gaia's distance 58 379 -> 58 109 and
HYG stars flagged so 8 129 -> 8 095. Eta Leo 556.6 -> 389.1 pc, Tarazed 178.9 -> 121.1, Imai 139.5 ->
105.8, Schedar 71.0 -> 70.0, Alphecca 23.7 -> 23.0, each now "±" its Hipparcos error. Where the two
disagree by more than 5 σ (21 stars, Iot Gem 11 σ), one of the formal errors is wrong, and this
takes the smaller one at its word. The validators pass unchanged.

Controls, each failing its named test: the placement ignoring the errors, the merge keeping Gaia's
distance whatever the errors, and taking the distance but not its error (1 of 805 each).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 21:37:35 +02:00
co-authored by Claude Opus 5.5
parent 23547defe0
commit 8c3a86097c
6 changed files with 57 additions and 22 deletions
+16
View File
@@ -192,6 +192,16 @@ describe('mergeStarCatalogues', () => {
expect(merged).toMatchObject({ magnitude: 11.01, magnitudeBand: 'V', colorIndex: 1.807, colorSystem: 'B-V', distanceError: 0.000065, distanceFromGaia: true });
});
it("takes a Hipparcos distance more precise than the Gaia entry it folds into, along Gaia's direction", () => {
// Schedar: 71.0 pc ±3.5 % in Gaia, which saturates on it, and 70.0 pc ±1.0 % in Hipparcos.
const hyg = at(3179, 10.1268, 56.5373, 70.0, { name: 'Schedar', magnitude: 2.24, distanceError: 0.0105, distanceFromGaia: false });
const gaia = at(1000000100, 10.1268 + arcsecOfRa(0.2, 56.5373), 56.5373, 71.0, { name: 'Gaia DR3 425040000962559616', magnitude: 1.94, distanceError: 0.035, distanceFromGaia: true, source: 'gaia' });
const [merged] = mergeStarCatalogues([{ ...HIPPARCOS, stars: [hyg] }, { ...GAIA, stars: [gaia] }]).stars;
expect(Math.hypot(merged.x, merged.y, merged.z)).toBeCloseTo(70.0, 9);
expect(directionCosine(merged, gaia)).toBeCloseTo(1, 12);
expect(merged).toMatchObject({ name: 'Schedar', distanceError: 0.0105, distanceFromGaia: false, source: 'gaia' });
});
it('keeps two entries of one source apart, however close they are', () => {
// Gaia resolves doubles Hipparcos saw as one star: two source ids 0.8″ apart are two stars,
// and only *another* catalogue can claim to have already listed either of them.
@@ -320,6 +330,12 @@ describe('placementDistancePc', () => {
expect(placementDistancePc(undefined, 180, 8, 250)).toBe(180);
});
it("takes the distance with the smaller error, Hipparcos's where Gaia saturated", () => {
// Eta Leo: 556.6 pc ±17 % in Gaia, 389.1 pc ±6.2 % in Hipparcos. Sirius the other way round.
expect(placementDistancePc(389.1, 556.6, 3.5, 250, 0.062, 0.17)).toBe(389.1);
expect(placementDistancePc(2.64, 2.67, -1.44, 250, 0.004, 0.002)).toBe(2.67);
});
it('falls back to Hipparcos where Gaia has no usable distance', () => {
expect(placementDistancePc(90, undefined, 8, 250)).toBe(90);
});
+29 -13
View File
@@ -92,8 +92,11 @@ export const NAKED_EYE_MAGNITUDE = 6.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
* At whichever distance has the smaller relative error, given both; Gaia's without them, or
* Hipparcos's where Gaia has none. Gaia's parallaxes are some fifty times more precise, and its
* distance wins for all but 273 of the 88 781 HYG stars with both; those are bright stars Gaia
* saturates on, 257 of them naked-eye — Eta Leo is 556.6 pc ±17 % in Gaia and 389 pc ±6.2 % in
* Hipparcos, Schedar ±3.5 % against ±1.0 %. 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.
@@ -101,12 +104,20 @@ export const NAKED_EYE_MAGNITUDE = 6.5;
* 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. So is every star the naked eye sees, at
* any distance: the cutoff took 1 543 of HYG's 8 920 stars of V 6.5 or brighter, Rigel, Deneb
* and Alnilam among them, while 11th-magnitude Gaia stars at the same distance were drawn. Gaia
* saturates on the brightest of them, so those sit at their Hipparcos distance. `null` for a star
* kept by neither rule, or that no survey gives a distance for.
* and Alnilam among them, while 11th-magnitude Gaia stars at the same distance were drawn. Those
* Gaia has no usable parallax for sit at their Hipparcos distance. `null` for a star kept by
* neither rule, or that no survey gives a distance for.
*/
export function placementDistancePc(hipparcosPc: number | undefined, gaiaPc: number | undefined, magnitude: number, cutoffPc: number): number | null {
const best = gaiaPc ?? hipparcosPc;
export function placementDistancePc(
hipparcosPc: number | undefined,
gaiaPc: number | undefined,
magnitude: number,
cutoffPc: number,
hipparcosError?: number,
gaiaError?: number
): number | null {
const hipparcosBetter = hipparcosPc !== undefined && hipparcosError !== undefined && gaiaError !== undefined && hipparcosError < gaiaError;
const best = hipparcosBetter ? hipparcosPc : (gaiaPc ?? hipparcosPc);
if (best === undefined) {
return null;
}
@@ -227,18 +238,23 @@ export function isSameStar(kept: StarRecord, entry: StarRecord): boolean {
* and keeping Gaia's whole once cost the map 102 proper names and 32 000 spectral types. The id
* travels with the description, so a star HYG knows keeps its HYG id from one refresh to the next,
* and so does its photometry, V and B−V. `source` stays with the position, since that is what it
* records, and so does the distance's error: Gaia's, not the Hipparcos one of a distance dropped.
* records, and so does the distance's error: Gaia's, not the Hipparcos one of a distance dropped —
* unless the other entry's distance is the more precise, as the Hipparcos one of a bright star
* `placementDistancePc` keeps at it is, which then sets the distance along Gaia's direction.
*/
function combine(kept: StarRecord, other: StarRecord): StarRecord {
const described = isDesignation(kept) && !isDesignation(other) ? other : kept;
const otherBetter = other.distanceError !== undefined && kept.distanceError !== undefined && other.distanceError < kept.distanceError;
const placed = otherBetter ? other : kept;
const scale = otherBetter ? distanceOf(other) / distanceOf(kept) : 1;
return {
...described,
x: kept.x,
y: kept.y,
z: kept.z,
x: kept.x * scale,
y: kept.y * scale,
z: kept.z * scale,
source: kept.source,
distanceError: kept.distanceError,
distanceFromGaia: kept.distanceFromGaia
distanceError: placed.distanceError,
distanceFromGaia: placed.distanceFromGaia
};
}
Binary file not shown.
Binary file not shown.
+9 -7
View File
@@ -71,9 +71,9 @@ function spectralTypeOf(row: Record<string, string>): string {
/**
* 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).
* 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, 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)...`);
@@ -101,10 +101,12 @@ export async function fetchStars(): Promise<StarRecord[]> {
const gaiaPc = gaia?.distancePc;
const magnitudeV = parseOptionalNumber(row['mag']);
const magnitude = magnitudeV ?? UNKNOWN_MAGNITUDE;
const distancePc = placementDistancePc(hipparcosPc, gaiaPc, magnitude, DISTANCE_CUTOFF_PC);
const hipparcosError = row['hip'] ? hipparcosErrors.get(Number(row['hip'])) : undefined;
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
@@ -124,7 +126,7 @@ export async function fetchStars(): Promise<StarRecord[]> {
continue;
}
const scale = distancePc / length;
if (gaiaPc !== undefined) {
if (fromGaia) {
atGaiaDistance++;
}
if (distancePc > DISTANCE_CUTOFF_PC) {
@@ -133,7 +135,7 @@ export async function fetchStars(): Promise<StarRecord[]> {
// 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 = gaia?.relativeError ?? (row['hip'] ? hipparcosErrors.get(Number(row['hip'])) : undefined);
const distanceError = fromGaia ? gaia.relativeError : hipparcosError;
const colorIndex = parseOptionalNumber(row['ci']);
stars.push({
@@ -148,7 +150,7 @@ export async function fetchStars(): Promise<StarRecord[]> {
colorIndex: colorIndex ?? null,
...(colorIndex === undefined ? {} : { colorSystem: 'B-V' as const }),
...(distanceError === undefined ? {} : { distanceError }),
distanceFromGaia: gaia !== undefined,
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.
+3 -2
View File
@@ -295,8 +295,9 @@ export async function fetchGaiaDistancesByHip(): Promise<Map<number, { distanceP
const MIN_HIPPARCOS_ERRORS = 110_000;
/**
* The relative error of every Hipparcos parallax, keyed by HIP number, for the stars that keep
* their Hipparcos distance: 3 067 of them, Rigel and Deneb among them, where Gaia saturates.
* The relative error of every Hipparcos parallax, keyed by HIP number: to choose between it and
* Gaia's, and for the stars that keep their Hipparcos distance — Rigel and Deneb, which Gaia has
* no usable parallax for, and the few hundred bright stars where Gaia's is the less precise.
*
* From van Leeuwen's 2007 reduction, which the ESA archive hosts beside Gaia and HYG's distances
* are the inverse of. HYG publishes the distance and not its error.