Merge pull request #16 from avalon-vanguard/star-map/fix/host-sky-match

Match exoplanet hosts on the sky, at both epochs the archive might mean

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:25 +02:00
committed by GitHub
co-authored by Claude Opus 5
9 changed files with 278 additions and 246 deletions
+142 -33
View File
@@ -1,9 +1,14 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { buildStarNameIndex, normalizeStarName, resolveHostStarId } from './host-star-matching'; import { buildStarNameIndex, normalizeStarName, resolveHostStarId } from './host-star-matching';
import { propagateProperMotion, raDegDecDistanceToXyz } from './coordinates';
import { StarRecord } from '../models/star.model'; import { StarRecord } from '../models/star.model';
// A small fixture standing in for a slice of the HYG star index, used to exercise the function star(id: number, name: string, raDeg: number, decDeg: number, distancePc: number): StarRecord {
return { id, name, ...raDegDecDistanceToXyz(raDeg, decDeg, distancePc), magnitude: 10, spectralType: 'M', colorIndex: 1.0 };
}
// A small fixture standing in for a slice of the star catalogue, used to exercise the
// exoplanet host-star cross-referencing logic without hitting any real API. // exoplanet host-star cross-referencing logic without hitting any real API.
const FIXTURE_STARS: StarRecord[] = [ const FIXTURE_STARS: StarRecord[] = [
// The Sun sits at the origin, exactly where a host with a missing distance lands. // The Sun sits at the origin, exactly where a host with a missing distance lands.
@@ -22,46 +27,150 @@ describe('normalizeStarName', () => {
describe('resolveHostStarId', () => { describe('resolveHostStarId', () => {
it('matches by exact (normalized) host star name', () => { it('matches by exact (normalized) host star name', () => {
const id = resolveHostStarId({ hostname: 'Proxima Centauri', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS, 0.5); const id = resolveHostStarId({ hostname: 'Proxima Centauri', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS);
expect(id).toBe(1); expect(id).toBe(1);
}); });
it('matches by name regardless of case/spacing differences', () => { it('matches by name regardless of case/spacing differences', () => {
const id = resolveHostStarId({ hostname: 'gj3512', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS, 0.5); const id = resolveHostStarId({ hostname: 'gj3512', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS);
expect(id).toBe(3); expect(id).toBe(3);
}); });
it('falls back to nearest-neighbour position matching when the name is unknown', () => { it('returns null when there is no name match and no position is available', () => {
// Slightly off from Sirius's exact position, within tolerance. const id = resolveHostStarId({ hostname: 'Unknown Star XYZ', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS);
const id = resolveHostStarId({ hostname: 'Sirius A', raDeg: 101.29, decDeg: -16.72, distancePc: 2.64 }, FIXTURE_STARS, 0.5);
expect(id).toBeNull();
});
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, 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 },
[decoy, lacaille9352]
);
expect(id).toBe(70);
});
// alf Tau's archive row publishes J2000 outright, and the archive never says which epoch a
// row is at. If the matcher trusted one epoch and carried every query back, Aldebaran's
// planet would land on the Gliese entry sitting 3″ from the carried-back point; the raw
// position, zero arcseconds from Aldebaran itself, has to win.
it('keeps a host published at J2000 on its star, proper motion or not', () => {
const aldebaran = star(80, 'Aldebaran', 68.980163, 16.509302, 20.43);
const carried = propagateProperMotion(68.980163, 16.509302, 63, -189, -16);
const ghost = star(81, 'Gl 171.1B', carried.raDeg, carried.decDeg + 3 / 3600, 20.43);
const id = resolveHostStarId(
{ hostname: 'alf Tau', raDeg: 68.980163, decDeg: 16.509302, distancePc: 20.43, pmRaMasPerYear: 63, pmDecMasPerYear: -189 },
[ghost, aldebaran]
);
expect(id).toBe(80);
});
// GJ 15 A's archive row sits at J2016, 46″ along its proper motion from Groombridge 34's
// J2000 place — and only 16″ from an unrelated Gaia entry. Nearest-to-the-published-point
// picks the interloper; carrying the query back the sixteen years must put the planets on
// the star that actually moved there.
it('picks the star the proper motion says the query is, not the entry nearest the published point', () => {
const primary = star(90, 'Groombridge 34', 4.595364, 44.022955, 3.562);
const published = propagateProperMotion(4.595364, 44.022955, 2891.5, 411.9, 16);
const interloper = star(91, 'Gaia DR3 385334196532776576', published.raDeg, published.decDeg + 16 / 3600, 3.563);
const id = resolveHostStarId(
{ hostname: 'GJ 15 A', raDeg: published.raDeg, decDeg: published.decDeg, distancePc: 3.56228, pmRaMasPerYear: 2891.5, pmDecMasPerYear: 411.9 },
[interloper, primary]
);
expect(id).toBe(90);
});
// GJ 273 is Luyten's Star to the arcsecond, but the archive publishes 5.92 pc for a star
// at 3.79 — a 56% disagreement. Direction alone must not override a distance in flat
// contradiction, or every line-of-sight coincidence becomes a match.
it('refuses a host whose distance flatly contradicts the star it points at', () => {
const luytens = star(100, "Luyten's Star", 111.8496, 5.2258, 3.79);
const id = resolveHostStarId({ hostname: 'GJ 273', raDeg: 111.8496, decDeg: 5.2258, distancePc: 5.921535 }, [luytens]);
expect(id).toBeNull();
});
// The tolerance is transverse — parsecs on the sky, not an angle — so the same 15″ offset
// is a match at 50 pc and a stranger at 200 pc.
it('scales the angular tolerance with the host distance', () => {
const at200 = resolveHostStarId(
{ hostname: 'Unmatched', raDeg: 150, decDeg: -40 + 15 / 3600, distancePc: 200 },
[star(110, 'Far', 150, -40, 200)]
);
const at50 = resolveHostStarId(
{ hostname: 'Unmatched', raDeg: 150, decDeg: -40 + 15 / 3600, distancePc: 50 },
[star(111, 'Near', 150, -40, 50)]
);
expect(at200).toBeNull();
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);
const id = resolveHostStarId({ hostname: 'Sirius', raDeg: NaN, decDeg: NaN, distancePc: NaN }, [], nameIndex);
expect(id).toBe(2); expect(id).toBe(2);
}); });
it('returns null when no name match and no star is within tolerance', () => {
const id = resolveHostStarId({ hostname: 'Unknown Star XYZ', raDeg: 0, decDeg: 0, distancePc: 100 }, FIXTURE_STARS, 0.5);
expect(id).toBeNull();
});
it('returns null when there is no name match and no position is available', () => {
const id = resolveHostStarId({ hostname: 'Unknown Star XYZ', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS, 0.5);
expect(id).toBeNull();
});
it('picks the closest star when more than one falls within tolerance', () => {
const stars: StarRecord[] = [
{ id: 10, name: 'Near', x: 0, y: 0, z: 0, magnitude: 5, spectralType: 'G', colorIndex: 0.5 },
{ id: 11, name: 'Far', x: 0.4, y: 0, z: 0, magnitude: 5, spectralType: 'G', colorIndex: 0.5 }
];
const nameIndex = buildStarNameIndex(stars);
const id = resolveHostStarId({ hostname: 'Unmatched', raDeg: 0, decDeg: 0, distancePc: 0.2 }, stars, 0.5, nameIndex);
expect(id).toBe(10);
}); });
describe('missing distance column', () => { describe('missing distance column', () => {
@@ -70,25 +179,25 @@ describe('resolveHostStarId', () => {
// the Sun at distance 0. That shipped 127 alien planets, all seven TRAPPIST-1 worlds among // the Sun at distance 0. That shipped 127 alien planets, all seven TRAPPIST-1 worlds among
// them, into our own solar system. // them, into our own solar system.
it('does not match a host with a zero distance to the Sun', () => { it('does not match a host with a zero distance to the Sun', () => {
const id = resolveHostStarId({ hostname: 'TRAPPIST-1', raDeg: 346.6, decDeg: -5.04, distancePc: 0 }, FIXTURE_STARS, 0.5); const id = resolveHostStarId({ hostname: 'TRAPPIST-1', raDeg: 346.6, decDeg: -5.04, distancePc: 0 }, FIXTURE_STARS);
expect(id).toBeNull(); expect(id).toBeNull();
}); });
it('rejects a negative distance too', () => { it('rejects a negative distance too', () => {
const id = resolveHostStarId({ hostname: 'Nowhere', raDeg: 10, decDeg: 10, distancePc: -3 }, FIXTURE_STARS, 0.5); const id = resolveHostStarId({ hostname: 'Nowhere', raDeg: 10, decDeg: 10, distancePc: -3 }, FIXTURE_STARS);
expect(id).toBeNull(); expect(id).toBeNull();
}); });
it('still matches a real host at a genuinely small distance', () => { it('still matches a real host at a genuinely small distance', () => {
const id = resolveHostStarId({ hostname: 'Unmatched', raDeg: 217.4, decDeg: -62.68, distancePc: 1.2959 }, FIXTURE_STARS, 0.5); const id = resolveHostStarId({ hostname: 'Unmatched', raDeg: 217.4, decDeg: -62.68, distancePc: 1.2959 }, FIXTURE_STARS);
expect(id).toBe(1); expect(id).toBe(1);
}); });
it('lets a named host resolve even with no usable distance', () => { it('lets a named host resolve even with no usable distance', () => {
const id = resolveHostStarId({ hostname: 'Sirius', raDeg: 101.3, decDeg: -16.7, distancePc: 0 }, FIXTURE_STARS, 0.5); const id = resolveHostStarId({ hostname: 'Sirius', raDeg: 101.3, decDeg: -16.7, distancePc: 0 }, FIXTURE_STARS);
expect(id).toBe(2); expect(id).toBe(2);
}); });
+112 -87
View File
@@ -1,5 +1,5 @@
import { CartesianCoordinates, distanceBetween, raDegDecDistanceToXyz } from './coordinates'; import { propagateProperMotion, raDegDecDistanceToXyz } from './coordinates';
import { ExoplanetRecord } from '../models/exoplanet.model'; import { MERGE_DISTANCE_RATIO_TOLERANCE } from './star-merge';
import { StarRecord } from '../models/star.model'; import { StarRecord } from '../models/star.model';
/** Normalizes a star name for comparison: lowercase, alphanumeric characters only. */ /** Normalizes a star name for comparison: lowercase, alphanumeric characters only. */
@@ -12,25 +12,92 @@ export interface HostStarQuery {
raDeg: number; raDeg: number;
decDeg: number; decDeg: number;
distancePc: number; distancePc: number;
} /** μα·cos δ in mas/yr, as the archive publishes it (`sy_pmra`); missing means unknown. */
pmRaMasPerYear?: number;
/** Builds a lookup of normalized star name -> star, for fast repeated name matching. */ pmDecMasPerYear?: number;
export function buildStarNameIndex(stars: readonly StarRecord[]): Map<string, StarRecord> {
return new Map(stars.map((star) => [normalizeStarName(star.name), star]));
} }
/** /**
* Cross-references an exoplanet host star to the HYG star index: first by (normalized) * Builds a lookup of normalized star name -> star, for fast repeated name matching.
* name, then by nearest-neighbour position matching within `toleranceInPc`. Returns `null` *
* 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<string, StarRecord> {
const index = new Map<string, StarRecord>();
const ambiguous = new Set<string>();
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;
}
/**
* How far, on the sky, a host may sit from a catalogue star and still be the same object —
* expressed as a transverse offset in parsecs (separation angle × the host's distance), not as
* an angle.
*
* The offset between the archive's position and ours is dominated by proper motion over an
* epoch difference, and that is a *physical* displacement: velocity × time, the same in parsecs
* at any distance. As an angle it is anything — Proxima's two positions are 60″ apart, a host at
* 100 pc moves under 2″ — so a fixed angle either loses the near, fast stars or drowns the far
* ones in neighbours. In parsecs the bound is one number: 25 years of an extreme 200 km/s
* transverse velocity is 5·10⁻³ pc.
*
* Measured on the 504 hosts whose archive name matches a catalogue name outright — true pairs,
* matched without coordinates: their transverse offset reaches 3.4·10⁻³ pc (5.0·10⁻³ before the
* epoch straddle below) and 0.01 pc doubles that. Chance stays out of reach: shifting every
* host a quarter of a degree finds nothing within the budget except Proxima's own entry, whose
* budget at 1.3 pc is wider than the shift itself.
*/
export const HOST_TRANSVERSE_TOLERANCE_PC = 0.01;
/**
* The archive does not say which epoch a row's position is for, and they are demonstrably
* mixed: alf Tau and GJ 273 publish J2000 (the raw position sits under an arcsecond from our
* star, and carrying it back doubles the error), HD 133131 and TOI-2459 publish Gaia's J2016
* (the carried-back position lands to 0.1″). So every query is tried at both ends — as
* published, and carried back sixteen years with the archive's own proper motion — and a star
* is judged on whichever is closer. Guessing one epoch picks companions: assume J2016 and
* Aldebaran's planet lands on Gl 171.1B, assume J2000 and GJ 15 A's land on a Gaia entry
* 15.9″ out.
*/
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
* does not flatly contradict the archive's ({@link MERGE_DISTANCE_RATIO_TOLERANCE}, shared with
* the catalogue merge, which faces the same Hipparcos-vs-Gaia disagreements). Returns `null`
* when neither approach finds a confident match, rather than guessing. * when neither approach finds a confident match, rather than guessing.
* *
* Identity lives in the direction, exactly as in `star-merge.ts`: the previous rule — nearest
* neighbour within half a parsec in 3D — turned into a ten-arcminute cone at 170 pc, handing
* planets of stars our catalogue does not contain to whatever bright star floated nearest
* (HATS-6 to HD 39500), while a 1 pc distance disagreement at 60 pc unhosted four bright
* giants' planets whose directions matched to two arcseconds.
*
* `nameIndex` should be built once (via {@link buildStarNameIndex}) and reused across calls * `nameIndex` should be built once (via {@link buildStarNameIndex}) and reused across calls
* when resolving many queries against the same star list. * when resolving many queries against the same star list.
*/ */
export function resolveHostStarId( export function resolveHostStarId(
query: HostStarQuery, query: HostStarQuery,
stars: readonly StarRecord[], stars: readonly StarRecord[],
toleranceInPc: number,
nameIndex: Map<string, StarRecord> = buildStarNameIndex(stars) nameIndex: Map<string, StarRecord> = buildStarNameIndex(stars)
): number | null { ): number | null {
const byName = nameIndex.get(normalizeStarName(query.hostname)); const byName = nameIndex.get(normalizeStarName(query.hostname));
@@ -43,91 +110,49 @@ export function resolveHostStarId(
} }
// A non-positive distance is never a real measurement, and it is the specific shape a // A non-positive distance is never a real measurement, and it is the specific shape a
// missing CSV cell takes: `Number('')` is `0`, which passes the finiteness check above and // missing CSV cell takes: `Number('')` is `0`. Without a believable distance there is no
// then places the host exactly at the origin — where it matches the Sun at distance 0 and // transverse budget and no ratio test, so the position cannot speak.
// hands an alien planet to our own solar system.
if (query.distancePc <= 0) { if (query.distancePc <= 0) {
return null; return null;
} }
const hostPosition = raDegDecDistanceToXyz(query.raDeg, query.decDeg, query.distancePc); const published = raDegDecDistanceToXyz(query.raDeg, query.decDeg, 1);
return findNearestStarWithin(hostPosition, stars, toleranceInPc); const carriedBack = propagateProperMotion(
} query.raDeg,
query.decDeg,
function findNearestStarWithin(position: CartesianCoordinates, stars: readonly StarRecord[], toleranceInPc: number): number | null { // A proper motion that is not a number must read as "stands still", not poison the
let closest: { id: number; distance: number } | null = null; // 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.
for (const star of stars) { knownMotion(query.pmRaMasPerYear),
const distance = distanceBetween(position, star); knownMotion(query.pmDecMasPerYear),
if (distance <= toleranceInPc && (!closest || distance < closest.distance)) { CATALOGUE_EPOCH - ARCHIVE_LATEST_EPOCH
closest = { id: star.id, distance };
}
}
return closest ? closest.id : null;
}
/**
* Re-resolves every exoplanet's host star against a star catalogue.
*
* The cross-reference is a *derived* fact: it depends as much on which stars were loaded as on
* the archive itself. When the catalogue reached 50 pc, 388 of the archive's 4735 named hosts
* found a match and the other 4347 were carried and never drawn — not because their planets are
* unknown, but because their star was out of range. Widening the catalogue rescues some of them,
* and until the host coordinates were stored alongside each planet that meant re-downloading an
* archive which is not always reachable.
*
* Records written before those coordinates were kept can still be matched *by name*, which needs
* no coordinates at all — and that alone is worth doing, because a wider catalogue contains more
* names. What such a record cannot do is disprove its existing match: a name miss means only
* that the name missed, not that the star is absent. So those are upgraded where a match is
* found and left alone otherwise, while records that do carry coordinates take the new result
* outright, match or no match.
*/
/** A host must sit within this many parsecs of a catalogue star to count as the same object. */
export const HOST_MATCH_TOLERANCE_PC = 2;
export interface RematchSummary {
total: number;
/** Records carrying host coordinates, and therefore eligible to be re-matched in full. */
resolvable: number;
matched: number;
gained: number;
lost: number;
}
export function rematchHostStars(exoplanets: ExoplanetRecord[], stars: readonly StarRecord[]): RematchSummary {
const nameIndex = buildStarNameIndex(stars);
const summary: RematchSummary = { total: exoplanets.length, resolvable: 0, matched: 0, gained: 0, lost: 0 };
for (const exoplanet of exoplanets) {
const { hostRaDeg, hostDecDeg, hostDistancePc } = exoplanet;
const positioned = hostRaDeg !== undefined && hostDecDeg !== undefined && hostDistancePc !== undefined;
if (positioned) {
summary.resolvable++;
}
const previous = exoplanet.hostStarId;
// With no coordinates the query still carries the host's name, and `resolveHostStarId` tries
// that first; the positional fallback simply declines to run on non-finite coordinates.
const resolved = resolveHostStarId(
{ hostname: exoplanet.hostStarName, raDeg: hostRaDeg ?? Number.NaN, decDeg: hostDecDeg ?? Number.NaN, distancePc: hostDistancePc ?? Number.NaN },
stars,
HOST_MATCH_TOLERANCE_PC,
nameIndex
); );
const carried = raDegDecDistanceToXyz(carriedBack.raDeg, carriedBack.decDeg, 1);
exoplanet.hostStarId = positioned ? resolved : (resolved ?? previous); const minCosine = Math.cos(Math.min(Math.PI, HOST_TRANSVERSE_TOLERANCE_PC / query.distancePc));
if (exoplanet.hostStarId !== null) { let best: StarRecord | null = null;
summary.matched++; let bestCosine = -2;
for (const star of stars) {
const starDistance = Math.hypot(star.x, star.y, star.z);
// The Sun sits at the origin and has no direction to compare; every real host is elsewhere.
if (starDistance === 0) {
continue;
} }
if (previous === null && exoplanet.hostStarId !== null) { const cosine =
summary.gained++; Math.max(
} else if (previous !== null && exoplanet.hostStarId === null) { star.x * published.x + star.y * published.y + star.z * published.z,
summary.lost++; star.x * carried.x + star.y * carried.y + star.z * carried.z
) / starDistance;
if (cosine < minCosine || cosine <= bestCosine) {
continue;
} }
const [near, far] = query.distancePc < starDistance ? [query.distancePc, starDistance] : [starDistance, query.distancePc];
if ((far - near) / near > MERGE_DISTANCE_RATIO_TOLERANCE) {
continue;
}
best = star;
bestCosine = cosine;
} }
return summary; return best ? best.id : null;
} }
@@ -1,82 +0,0 @@
import { describe, expect, it } from 'vitest';
import { ExoplanetRecord } from '../models/exoplanet.model';
import { StarRecord } from '../models/star.model';
import { rematchHostStars } from './host-star-matching';
/** Two catalogue stars, one of which is only present in the wider of the two catalogues. */
const NEARBY: StarRecord = { id: 100, name: 'Gl 357', x: 9, y: 0, z: 0, magnitude: 10.9, spectralType: 'K', colorIndex: 1.4 };
const DISTANT: StarRecord = { id: 200, name: 'HD 33844', x: 0, y: 120, z: 0, magnitude: 7.7, spectralType: 'K0', colorIndex: 1.0 };
const NARROW_CATALOGUE = [NEARBY];
const WIDE_CATALOGUE = [NEARBY, DISTANT];
function planet(overrides: Partial<ExoplanetRecord> = {}): ExoplanetRecord {
return { id: 'p', hostStarId: null, hostStarName: 'HD 33844', name: 'HD 33844 b', orbit: { semiMajorAxisAu: 1 }, ...overrides };
}
describe('rematchHostStars', () => {
it('rescues a host that the wider catalogue now contains, by name alone', () => {
// The whole point: the cross-reference is a fact about the catalogue as much as about the
// archive, so widening one ought to resolve hosts the other already knew about.
const planets = [planet()];
const summary = rematchHostStars(planets, WIDE_CATALOGUE);
expect(planets[0].hostStarId).toBe(DISTANT.id);
expect(summary.gained).toBe(1);
expect(summary.matched).toBe(1);
});
it('needs no coordinates to do it', () => {
// Which matters, because the shipped records were written before coordinates were kept.
const planets = [planet()];
expect(planets[0].hostRaDeg).toBeUndefined();
rematchHostStars(planets, WIDE_CATALOGUE);
expect(planets[0].hostStarId).toBe(DISTANT.id);
});
it('will not clear an existing match on a name miss when it has no coordinates', () => {
// A name miss says the name missed, not that the star is absent — and the earlier match may
// have been positional, from data this record no longer carries.
const planets = [planet({ hostStarId: 999, hostStarName: 'Some Survey Designation' })];
const summary = rematchHostStars(planets, WIDE_CATALOGUE);
expect(planets[0].hostStarId).toBe(999);
expect(summary.lost).toBe(0);
expect(summary.matched).toBe(1);
});
it('takes the new answer outright when the record does carry coordinates', () => {
// With coordinates the match can be redone in full, so its result is authoritative — a host
// that no longer resolves is cleared rather than left pointing at a star that may be gone.
const planets = [planet({ hostStarId: 999, hostStarName: 'Nowhere', hostRaDeg: 10, hostDecDeg: 10, hostDistancePc: 500 })];
const summary = rematchHostStars(planets, WIDE_CATALOGUE);
expect(planets[0].hostStarId).toBeNull();
expect(summary.resolvable).toBe(1);
expect(summary.lost).toBe(1);
});
it('matches a positioned host to the catalogue star at its coordinates', () => {
const planets = [planet({ hostStarName: 'unlisted alias', hostRaDeg: 90, hostDecDeg: 0, hostDistancePc: 120 })];
rematchHostStars(planets, WIDE_CATALOGUE);
expect(planets[0].hostStarId).toBe(DISTANT.id);
});
it('leaves a host that neither catalogue contains unmatched', () => {
const planets = [planet()];
const summary = rematchHostStars(planets, NARROW_CATALOGUE);
expect(planets[0].hostStarId).toBeNull();
expect(summary.matched).toBe(0);
expect(summary.gained).toBe(0);
});
it('counts every record it was given', () => {
const planets = [planet(), planet({ id: 'q', hostStarName: 'Gl 357' }), planet({ id: 'r', hostStarName: 'nobody' })];
const summary = rematchHostStars(planets, WIDE_CATALOGUE);
expect(summary.total).toBe(3);
expect(summary.matched).toBe(2);
});
});
+8 -6
View File
@@ -21,17 +21,19 @@ export interface ExoplanetRecord {
/** Host star mass in solar masses (`st_mass`); the fallback when no period is published. */ /** Host star mass in solar masses (`st_mass`); the fallback when no period is published. */
hostStarMassSolar?: number; hostStarMassSolar?: number;
/** /**
* The host star's own published position (`ra`, `dec`, `sy_dist`) — the coordinates the * The host star's own published astrometry (`ra`, `dec`, `sy_dist`, `sy_pmra`, `sy_pmdec`) —
* cross-reference above is resolved from. * everything the cross-reference above was resolved from.
* *
* Kept rather than consumed and discarded. `hostStarId` is the *result* of a match against * Kept rather than consumed and discarded. `hostStarId` is the *result* of a match against
* whatever star catalogue was loaded at the time, so widening that catalogue ought to rescue * whatever star catalogue was loaded at the time; keeping the inputs makes auditing or
* some of the 4347 hosts that currently resolve to nothing — but with only the result stored, * redoing that match a local operation instead of a TAP query against an archive that is not
* redoing the match meant re-downloading the archive. These three numbers make it a local * always reachable — it is how the matcher's tolerances were measured. See
* operation. See `rematchHostStars`. * `resolveHostStarId`.
*/ */
hostRaDeg?: number; hostRaDeg?: number;
hostDecDeg?: number; hostDecDeg?: number;
hostDistancePc?: number; hostDistancePc?: number;
hostPmRaMasPerYear?: number;
hostPmDecMasPerYear?: number;
orbit: Partial<OrbitalElements>; orbit: Partial<OrbitalElements>;
} }
File diff suppressed because one or more lines are too long
-13
View File
@@ -10,7 +10,6 @@ import { fetchSolarSystem } from './fetchSolarSystem';
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog } from '../../src/app/shared/models/star-catalog'; import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
import { fetchStars } from './fetchStars'; import { fetchStars } from './fetchStars';
import { describeSources } from './sources/registry'; import { describeSources } from './sources/registry';
import { rematchHostStars } from '../../src/app/shared/astro/host-star-matching';
import { dataPath } from './lib/paths'; import { dataPath } from './lib/paths';
class ValidationError extends Error {} class ValidationError extends Error {}
@@ -161,18 +160,6 @@ async function build(): Promise<void> {
const deepSky = await fetchDeepSky(); const deepSky = await fetchDeepSky();
console.log(); console.log();
// The cross-reference depends on the star catalogue as much as on the archive, so it is
// resolved again here against whatever catalogue this run produced. A no-op when the two were
// fetched together, and the whole point when only one of them was.
const rematch = rematchHostStars(exoplanets, stars);
console.log(
`Cross-referencing exoplanet hosts against ${stars.length} stars...\n` +
` ${rematch.matched}/${rematch.total} matched` +
(rematch.resolvable < rematch.total ? ` (${rematch.total - rematch.resolvable} records predate stored host coordinates and kept their existing match)` : '') +
(rematch.gained || rematch.lost ? `; ${rematch.gained} gained, ${rematch.lost} lost` : '')
);
console.log();
console.log('Validating output...'); console.log('Validating output...');
validateStars(stars); validateStars(stars);
validateBodies(bodies); validateBodies(bodies);
+14 -6
View File
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import { buildStarNameIndex, resolveHostStarId } from '../../src/app/shared/astro/host-star-matching'; import { buildStarNameIndex, resolveHostStarId } from '../../src/app/shared/astro/host-star-matching';
@@ -15,6 +16,8 @@ const TAP_COLUMNS = [
'ra', 'ra',
'dec', 'dec',
'sy_dist', 'sy_dist',
'sy_pmra',
'sy_pmdec',
'pl_orbsmax', 'pl_orbsmax',
'pl_orbeccen', 'pl_orbeccen',
'pl_orbincl', 'pl_orbincl',
@@ -32,9 +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_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}`; const TAP_URL = `${TAP_BASE_URL}?query=${TAP_QUERY}`;
// A host star match must be within this many parsecs of the catalog position to be // The cache is keyed by the request it answers — endpoint included, since the cache records
// accepted as a cross-reference (guards against coincidental name/position collisions). // only that some response arrived: one cached before a column was added would otherwise keep
const MATCH_TOLERANCE_PC = 0.5; // 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 * Downloads confirmed exoplanets from the NASA Exoplanet Archive (`Planetary Systems` TAP
@@ -45,7 +50,7 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
const knownStars = stars ?? (await fetchStars()); const knownStars = stars ?? (await fetchStars());
const nameIndex = buildStarNameIndex(knownStars); const nameIndex = buildStarNameIndex(knownStars);
const csv = await fetchTextCached(TAP_URL, 'exoplanet-archive-ps.csv'); const csv = await fetchTextCached(TAP_URL, CACHE_FILE);
const rows = parseCsvObjects(csv); const rows = parseCsvObjects(csv);
let matched = 0; let matched = 0;
@@ -55,11 +60,12 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
const raDeg = parseOptionalNumber(row['ra']) ?? Number.NaN; const raDeg = parseOptionalNumber(row['ra']) ?? Number.NaN;
const decDeg = parseOptionalNumber(row['dec']) ?? Number.NaN; const decDeg = parseOptionalNumber(row['dec']) ?? Number.NaN;
const distancePc = parseOptionalNumber(row['sy_dist']) ?? Number.NaN; const distancePc = parseOptionalNumber(row['sy_dist']) ?? Number.NaN;
const pmRaMasPerYear = parseOptionalNumber(row['sy_pmra']);
const pmDecMasPerYear = parseOptionalNumber(row['sy_pmdec']);
const hostStarId = resolveHostStarId( const hostStarId = resolveHostStarId(
{ hostname: row['hostname'], raDeg, decDeg, distancePc }, { hostname: row['hostname'], raDeg, decDeg, distancePc, pmRaMasPerYear, pmDecMasPerYear },
knownStars, knownStars,
MATCH_TOLERANCE_PC,
nameIndex nameIndex
); );
if (hostStarId !== null) { if (hostStarId !== null) {
@@ -84,6 +90,8 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
hostRaDeg: parseOptionalNumber(row['ra']), hostRaDeg: parseOptionalNumber(row['ra']),
hostDecDeg: parseOptionalNumber(row['dec']), hostDecDeg: parseOptionalNumber(row['dec']),
hostDistancePc: parseOptionalNumber(row['sy_dist']), hostDistancePc: parseOptionalNumber(row['sy_dist']),
hostPmRaMasPerYear: pmRaMasPerYear,
hostPmDecMasPerYear: pmDecMasPerYear,
orbit: { orbit: {
semiMajorAxisAu: parseOptionalNumber(row['pl_orbsmax']), semiMajorAxisAu: parseOptionalNumber(row['pl_orbsmax']),
eccentricity: parseOptionalNumber(row['pl_orbeccen']), eccentricity: parseOptionalNumber(row['pl_orbeccen']),
-18
View File
@@ -1,18 +0,0 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { RematchSummary, rematchHostStars } from '../../src/app/shared/astro/host-star-matching';
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
import { StarRecord } from '../../src/app/shared/models/star.model';
import { dataPath } from './lib/paths';
/**
* Reads the written assets, re-resolves every exoplanet's host star against the given catalogue,
* and writes the exoplanets back. The matching itself lives with the matcher, in
* `host-star-matching.ts`; this is only the file handling around it.
*/
export function rematchWrittenAssets(stars: readonly StarRecord[]): RematchSummary {
const exoplanets = JSON.parse(readFileSync(dataPath('exoplanets.json'), 'utf8')) as ExoplanetRecord[];
const summary = rematchHostStars(exoplanets, stars);
writeFileSync(dataPath('exoplanets.json'), JSON.stringify(exoplanets));
return summary;
}
+4 -3
View File
@@ -87,9 +87,10 @@ export async function fetchGaiaStars(): Promise<StarRecord[]> {
const url = `${GAIA_TAP_URL}?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&QUERY=${encodeURIComponent(query)}`; 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)...`); 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 // Keyed by the whole request, so a response cached for other columns, another order, or
// never be mistaken for this one. // another endpoint can never be mistaken for this one — the cache records only that some
const csv = await fetchTextCached(url, `gaia-dr3-${createHash('sha1').update(query).digest('hex').slice(0, 8)}.csv`); // 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 rows = parseCsvObjects(csv);
const stars: StarRecord[] = []; const stars: StarRecord[] = [];