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:
@@ -1,9 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildStarNameIndex, normalizeStarName, resolveHostStarId } from './host-star-matching';
|
||||
import { propagateProperMotion, raDegDecDistanceToXyz } from './coordinates';
|
||||
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.
|
||||
const FIXTURE_STARS: StarRecord[] = [
|
||||
// The Sun sits at the origin, exactly where a host with a missing distance lands.
|
||||
@@ -22,46 +27,150 @@ describe('normalizeStarName', () => {
|
||||
|
||||
describe('resolveHostStarId', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('falls back to nearest-neighbour position matching when the name is unknown', () => {
|
||||
// Slightly off from Sirius's exact position, within tolerance.
|
||||
const id = resolveHostStarId({ hostname: 'Sirius A', raDeg: 101.29, decDeg: -16.72, distancePc: 2.64 }, FIXTURE_STARS, 0.5);
|
||||
|
||||
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);
|
||||
const id = resolveHostStarId({ hostname: 'Unknown Star XYZ', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS);
|
||||
|
||||
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);
|
||||
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: 'Unmatched', raDeg: 0, decDeg: 0, distancePc: 0.2 }, stars, 0.5, nameIndex);
|
||||
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(10);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
// them, into our own solar system.
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CartesianCoordinates, distanceBetween, raDegDecDistanceToXyz } from './coordinates';
|
||||
import { ExoplanetRecord } from '../models/exoplanet.model';
|
||||
import { propagateProperMotion, raDegDecDistanceToXyz } from './coordinates';
|
||||
import { MERGE_DISTANCE_RATIO_TOLERANCE } from './star-merge';
|
||||
import { StarRecord } from '../models/star.model';
|
||||
|
||||
/** Normalizes a star name for comparison: lowercase, alphanumeric characters only. */
|
||||
@@ -12,25 +12,92 @@ export interface HostStarQuery {
|
||||
raDeg: number;
|
||||
decDeg: number;
|
||||
distancePc: number;
|
||||
}
|
||||
|
||||
/** Builds a lookup of normalized star name -> star, for fast repeated name matching. */
|
||||
export function buildStarNameIndex(stars: readonly StarRecord[]): Map<string, StarRecord> {
|
||||
return new Map(stars.map((star) => [normalizeStarName(star.name), star]));
|
||||
/** μα·cos δ in mas/yr, as the archive publishes it (`sy_pmra`); missing means unknown. */
|
||||
pmRaMasPerYear?: number;
|
||||
pmDecMasPerYear?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-references an exoplanet host star to the HYG star index: first by (normalized)
|
||||
* name, then by nearest-neighbour position matching within `toleranceInPc`. Returns `null`
|
||||
* 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<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.
|
||||
*
|
||||
* 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
|
||||
* when resolving many queries against the same star list.
|
||||
*/
|
||||
export function resolveHostStarId(
|
||||
query: HostStarQuery,
|
||||
stars: readonly StarRecord[],
|
||||
toleranceInPc: number,
|
||||
nameIndex: Map<string, StarRecord> = buildStarNameIndex(stars)
|
||||
): number | null {
|
||||
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
|
||||
// missing CSV cell takes: `Number('')` is `0`, which passes the finiteness check above and
|
||||
// then places the host exactly at the origin — where it matches the Sun at distance 0 and
|
||||
// hands an alien planet to our own solar system.
|
||||
// missing CSV cell takes: `Number('')` is `0`. Without a believable distance there is no
|
||||
// transverse budget and no ratio test, so the position cannot speak.
|
||||
if (query.distancePc <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostPosition = raDegDecDistanceToXyz(query.raDeg, query.decDeg, query.distancePc);
|
||||
return findNearestStarWithin(hostPosition, stars, toleranceInPc);
|
||||
}
|
||||
|
||||
function findNearestStarWithin(position: CartesianCoordinates, stars: readonly StarRecord[], toleranceInPc: number): number | null {
|
||||
let closest: { id: number; distance: number } | null = null;
|
||||
const published = raDegDecDistanceToXyz(query.raDeg, query.decDeg, 1);
|
||||
const carriedBack = propagateProperMotion(
|
||||
query.raDeg,
|
||||
query.decDeg,
|
||||
// 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);
|
||||
|
||||
const minCosine = Math.cos(Math.min(Math.PI, HOST_TRANSVERSE_TOLERANCE_PC / query.distancePc));
|
||||
let best: StarRecord | null = null;
|
||||
let bestCosine = -2;
|
||||
for (const star of stars) {
|
||||
const distance = distanceBetween(position, star);
|
||||
if (distance <= toleranceInPc && (!closest || distance < closest.distance)) {
|
||||
closest = { id: star.id, distance };
|
||||
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;
|
||||
}
|
||||
const cosine =
|
||||
Math.max(
|
||||
star.x * published.x + star.y * published.y + star.z * published.z,
|
||||
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 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
|
||||
);
|
||||
|
||||
exoplanet.hostStarId = positioned ? resolved : (resolved ?? previous);
|
||||
if (exoplanet.hostStarId !== null) {
|
||||
summary.matched++;
|
||||
}
|
||||
if (previous === null && exoplanet.hostStarId !== null) {
|
||||
summary.gained++;
|
||||
} else if (previous !== null && exoplanet.hostStarId === null) {
|
||||
summary.lost++;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -21,17 +21,19 @@ export interface ExoplanetRecord {
|
||||
/** Host star mass in solar masses (`st_mass`); the fallback when no period is published. */
|
||||
hostStarMassSolar?: number;
|
||||
/**
|
||||
* The host star's own published position (`ra`, `dec`, `sy_dist`) — the coordinates the
|
||||
* cross-reference above is resolved from.
|
||||
* The host star's own published astrometry (`ra`, `dec`, `sy_dist`, `sy_pmra`, `sy_pmdec`) —
|
||||
* everything the cross-reference above was resolved from.
|
||||
*
|
||||
* 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
|
||||
* some of the 4347 hosts that currently resolve to nothing — but with only the result stored,
|
||||
* redoing the match meant re-downloading the archive. These three numbers make it a local
|
||||
* operation. See `rematchHostStars`.
|
||||
* whatever star catalogue was loaded at the time; keeping the inputs makes auditing or
|
||||
* redoing that match a local operation instead of a TAP query against an archive that is not
|
||||
* always reachable — it is how the matcher's tolerances were measured. See
|
||||
* `resolveHostStarId`.
|
||||
*/
|
||||
hostRaDeg?: number;
|
||||
hostDecDeg?: number;
|
||||
hostDistancePc?: number;
|
||||
hostPmRaMasPerYear?: number;
|
||||
hostPmDecMasPerYear?: number;
|
||||
orbit: Partial<OrbitalElements>;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 { fetchStars } from './fetchStars';
|
||||
import { describeSources } from './sources/registry';
|
||||
import { rematchHostStars } from '../../src/app/shared/astro/host-star-matching';
|
||||
import { dataPath } from './lib/paths';
|
||||
|
||||
class ValidationError extends Error {}
|
||||
@@ -161,18 +160,6 @@ async function build(): Promise<void> {
|
||||
const deepSky = await fetchDeepSky();
|
||||
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...');
|
||||
validateStars(stars);
|
||||
validateBodies(bodies);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
import { buildStarNameIndex, resolveHostStarId } from '../../src/app/shared/astro/host-star-matching';
|
||||
@@ -15,6 +16,8 @@ const TAP_COLUMNS = [
|
||||
'ra',
|
||||
'dec',
|
||||
'sy_dist',
|
||||
'sy_pmra',
|
||||
'sy_pmdec',
|
||||
'pl_orbsmax',
|
||||
'pl_orbeccen',
|
||||
'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_URL = `${TAP_BASE_URL}?query=${TAP_QUERY}`;
|
||||
|
||||
// A host star match must be within this many parsecs of the catalog position to be
|
||||
// accepted as a cross-reference (guards against coincidental name/position collisions).
|
||||
const MATCH_TOLERANCE_PC = 0.5;
|
||||
// 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
|
||||
@@ -45,7 +50,7 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
|
||||
const knownStars = stars ?? (await fetchStars());
|
||||
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);
|
||||
|
||||
let matched = 0;
|
||||
@@ -55,11 +60,12 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
|
||||
const raDeg = parseOptionalNumber(row['ra']) ?? Number.NaN;
|
||||
const decDeg = parseOptionalNumber(row['dec']) ?? 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(
|
||||
{ hostname: row['hostname'], raDeg, decDeg, distancePc },
|
||||
{ hostname: row['hostname'], raDeg, decDeg, distancePc, pmRaMasPerYear, pmDecMasPerYear },
|
||||
knownStars,
|
||||
MATCH_TOLERANCE_PC,
|
||||
nameIndex
|
||||
);
|
||||
if (hostStarId !== null) {
|
||||
@@ -84,6 +90,8 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
|
||||
hostRaDeg: parseOptionalNumber(row['ra']),
|
||||
hostDecDeg: parseOptionalNumber(row['dec']),
|
||||
hostDistancePc: parseOptionalNumber(row['sy_dist']),
|
||||
hostPmRaMasPerYear: pmRaMasPerYear,
|
||||
hostPmDecMasPerYear: pmDecMasPerYear,
|
||||
orbit: {
|
||||
semiMajorAxisAu: parseOptionalNumber(row['pl_orbsmax']),
|
||||
eccentricity: parseOptionalNumber(row['pl_orbeccen']),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -87,9 +87,10 @@ export async function fetchGaiaStars(): Promise<StarRecord[]> {
|
||||
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[] = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user