diff --git a/src/app/shared/astro/host-star-matching.spec.ts b/src/app/shared/astro/host-star-matching.spec.ts index fef3a2d..c08c96c 100644 --- a/src/app/shared/astro/host-star-matching.spec.ts +++ b/src/app/shared/astro/host-star-matching.spec.ts @@ -47,14 +47,18 @@ describe('resolveHostStarId', () => { describe('matching on the sky', () => { // GJ 887's archive row: position at Gaia's epoch, carried by 6.9″/yr of proper motion — // 110″ from where the catalogue has the star at J2000. The matcher must carry the query - // back those sixteen years itself. - it('matches a host published at the Gaia epoch to its star at J2000', () => { + // back those sixteen years itself, and judge each star on the better of the two epochs: the + // decoy standing halfway along the star's own track is nearer than Lacaille 9352 at the + // published point *and* nearer at the worse of the two epochs, so it wins unless the + // carried-back position is tried and the best epoch — not the worst — decides. + it('matches a host published at the Gaia epoch to its star at J2000, past a decoy on its track', () => { const lacaille9352 = star(70, 'Lacaille 9352', 346.46683, -35.85306, 3.29); const archive = propagateProperMotion(346.46683, -35.85306, 6768.2, 1327.52, 16); + const decoy = star(71, 'Decoy', (346.46683 + archive.raDeg) / 2, (-35.85306 + archive.decDeg) / 2, 3.29); const id = resolveHostStarId( { hostname: 'GJ 887', raDeg: archive.raDeg, decDeg: archive.decDeg, distancePc: 3.28679, pmRaMasPerYear: 6768.2, pmDecMasPerYear: 1327.52 }, - [lacaille9352] + [decoy, lacaille9352] ); expect(id).toBe(70); @@ -121,6 +125,45 @@ describe('resolveHostStarId', () => { expect(at50).toBe(111); }); + // A star whose distance disqualifies it is not merely rejected — it must not become the + // best-so-far either, or an unmerged twin with a bad parallax, sitting nearer on the sky + // than the true host, silently unhosts the planet by outranking a star that is never + // allowed to win. + it('does not let a star its distance disqualifies shadow the true host behind it', () => { + const badParallaxTwin = star(120, 'Gaia DR3 twin', 40, 12 + 1 / 3600, 480); + const host = star(121, 'True host', 40, 12 + 3 / 3600, 100); + + const id = resolveHostStarId({ hostname: 'Unmatched', raDeg: 40, decDeg: 12, distancePc: 100 }, [badParallaxTwin, host]); + + expect(id).toBe(121); + }); + + // A proper motion that is not a number must not poison the comparison: NaN loses every + // `<` it appears in, so an unguarded one lets each star past the direction test and hands + // the planet to whichever happens to be last in the catalogue. + it('treats an unusable proper motion as no motion rather than matching by array order', () => { + const pointedAt = star(130, 'Pointed at', 10, 10, 5); + const acrossTheSky = star(131, 'Across the sky', 190, -10, 5); + + const id = resolveHostStarId( + { hostname: 'Unmatched', raDeg: 10, decDeg: 10, distancePc: 5, pmRaMasPerYear: NaN, pmDecMasPerYear: 0 }, + [pointedAt, acrossTheSky] + ); + + expect(id).toBe(130); + }); + + // Normalizing strips the dot, so `Gl 55.2` and `Gl 552` — two stars 135° apart — answer to + // one key. A name that names both names neither: the sky has to settle it. + it('sends a name two stars answer to back to the sky', () => { + const gl552 = star(140, 'Gl 552', 217.0, 15.0, 14.2); + const gl55dot2 = star(141, 'Gl 55.2', 30.0, -20.0, 23.9); + + const id = resolveHostStarId({ hostname: 'Gl 552', raDeg: 217.0, decDeg: 15.0, distancePc: 14.2 }, [gl552, gl55dot2]); + + expect(id).toBe(140); + }); + it('reuses a prebuilt name index when given one', () => { const nameIndex = buildStarNameIndex(FIXTURE_STARS); diff --git a/src/app/shared/astro/host-star-matching.ts b/src/app/shared/astro/host-star-matching.ts index c2c5041..01e3e88 100644 --- a/src/app/shared/astro/host-star-matching.ts +++ b/src/app/shared/astro/host-star-matching.ts @@ -17,9 +17,29 @@ export interface HostStarQuery { pmDecMasPerYear?: number; } -/** Builds a lookup of normalized star name -> star, for fast repeated name matching. */ +/** + * Builds a lookup of normalized star name -> star, for fast repeated name matching. + * + * A name two stars answer to names neither: normalizing strips the dot, so `Gl 55.2` and + * `Gl 552` — 135° apart, and 64 such groups exist in the catalogue — collide on `gl552`, and a + * map would silently keep whichever came last. Ambiguous keys are dropped instead, which sends + * the query to the sky, where direction settles it. + */ export function buildStarNameIndex(stars: readonly StarRecord[]): Map { - return new Map(stars.map((star) => [normalizeStarName(star.name), star])); + const index = new Map(); + const ambiguous = new Set(); + for (const star of stars) { + const key = normalizeStarName(star.name); + if (index.has(key)) { + ambiguous.add(key); + } else { + index.set(key, star); + } + } + for (const key of ambiguous) { + index.delete(key); + } + return index; } /** @@ -55,6 +75,10 @@ export const HOST_TRANSVERSE_TOLERANCE_PC = 0.01; const CATALOGUE_EPOCH = 2000.0; const ARCHIVE_LATEST_EPOCH = 2016.0; +function knownMotion(masPerYear: number | undefined): number { + return Number.isFinite(masPerYear) ? (masPerYear as number) : 0; +} + /** * Cross-references an exoplanet host star to the star catalogue: first by (normalized) name, * then on the sky — the nearest star within {@link HOST_TRANSVERSE_TOLERANCE_PC} whose distance @@ -96,8 +120,11 @@ export function resolveHostStarId( const carriedBack = propagateProperMotion( query.raDeg, query.decDeg, - query.pmRaMasPerYear ?? 0, - query.pmDecMasPerYear ?? 0, + // A proper motion that is not a number must read as "stands still", not poison the + // comparison: one NaN makes every star's cosine NaN, and `NaN < min` is false, so every + // star would pass the direction test and the last one in array order would win. + knownMotion(query.pmRaMasPerYear), + knownMotion(query.pmDecMasPerYear), CATALOGUE_EPOCH - ARCHIVE_LATEST_EPOCH ); const carried = raDegDecDistanceToXyz(carriedBack.raDeg, carriedBack.decDeg, 1); diff --git a/tools/etl/fetchExoplanets.ts b/tools/etl/fetchExoplanets.ts index 4c638fb..767c935 100644 --- a/tools/etl/fetchExoplanets.ts +++ b/tools/etl/fetchExoplanets.ts @@ -35,10 +35,11 @@ const TAP_COLUMNS = [ const TAP_QUERY = `select+${TAP_COLUMNS}+from+ps+where+default_flag=1+order+by+pl_name&format=csv`; const TAP_URL = `${TAP_BASE_URL}?query=${TAP_QUERY}`; -// The cache is keyed by the query it answers: a response cached before a column was added -// would otherwise keep serving rows without it, and a missing proper-motion cell reads as -// "does not move" — silently wrong, not visibly broken. -const CACHE_FILE = `exoplanet-archive-ps-${createHash('sha1').update(TAP_QUERY).digest('hex').slice(0, 8)}.csv`; +// The cache is keyed by the request it answers — endpoint included, since the cache records +// only that some response arrived: one cached before a column was added would otherwise keep +// serving rows without it, and a missing proper-motion cell reads as "does not move", +// silently wrong rather than visibly broken. +const CACHE_FILE = `exoplanet-archive-ps-${createHash('sha1').update(TAP_URL).digest('hex').slice(0, 8)}.csv`; /** * Downloads confirmed exoplanets from the NASA Exoplanet Archive (`Planetary Systems` TAP diff --git a/tools/etl/sources/gaia.ts b/tools/etl/sources/gaia.ts index a4fb700..ebcc255 100644 --- a/tools/etl/sources/gaia.ts +++ b/tools/etl/sources/gaia.ts @@ -87,9 +87,10 @@ export async function fetchGaiaStars(): Promise { const url = `${GAIA_TAP_URL}?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&QUERY=${encodeURIComponent(query)}`; console.log(`Fetching Gaia DR3 (within ${DISTANCE_CUTOFF_PC} pc, G < ${MAGNITUDE_LIMIT}, at most ${ROW_LIMIT} rows)...`); - // Keyed by the query itself, so a response cached for other columns or another order can - // never be mistaken for this one. - const csv = await fetchTextCached(url, `gaia-dr3-${createHash('sha1').update(query).digest('hex').slice(0, 8)}.csv`); + // Keyed by the whole request, so a response cached for other columns, another order, or + // another endpoint can never be mistaken for this one — the cache records only that some + // response arrived, not what it answered. + const csv = await fetchTextCached(url, `gaia-dr3-${createHash('sha1').update(url).digest('hex').slice(0, 8)}.csv`); const rows = parseCsvObjects(csv); const stars: StarRecord[] = [];