diff --git a/src/app/shared/astro/star-merge.spec.ts b/src/app/shared/astro/star-merge.spec.ts index 569a862..619a002 100644 --- a/src/app/shared/astro/star-merge.spec.ts +++ b/src/app/shared/astro/star-merge.spec.ts @@ -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); }); diff --git a/src/app/shared/astro/star-merge.ts b/src/app/shared/astro/star-merge.ts index dafaf95..0e6b73e 100644 --- a/src/app/shared/astro/star-merge.ts +++ b/src/app/shared/astro/star-merge.ts @@ -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 }; } diff --git a/src/assets/data/stars-meta.bin b/src/assets/data/stars-meta.bin index 6d4fb64..705ae8a 100644 Binary files a/src/assets/data/stars-meta.bin and b/src/assets/data/stars-meta.bin differ diff --git a/src/assets/data/stars.bin b/src/assets/data/stars.bin index 7dad89d..253ab6c 100644 Binary files a/src/assets/data/stars.bin and b/src/assets/data/stars.bin differ diff --git a/tools/etl/fetchStars.ts b/tools/etl/fetchStars.ts index 723e3cd..434ca29 100644 --- a/tools/etl/fetchStars.ts +++ b/tools/etl/fetchStars.ts @@ -71,9 +71,9 @@ function spectralTypeOf(row: Record): 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 { console.log(`Fetching HYG star catalog (distance cutoff: ${DISTANCE_CUTOFF_PC} pc)...`); @@ -101,10 +101,12 @@ export async function fetchStars(): Promise { 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 { 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 { // 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 { 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. diff --git a/tools/etl/sources/gaia.ts b/tools/etl/sources/gaia.ts index 89d6007..01c860a 100644 --- a/tools/etl/sources/gaia.ts +++ b/tools/etl/sources/gaia.ts @@ -295,8 +295,9 @@ export async function fetchGaiaDistancesByHip(): Promise