Match exoplanet hosts on the sky, at both epochs the archive might mean
The host cross-reference matched in 3D, nearest star within half a parsec. That is the wrong space for the same reason the star merge learned it: a direction is measured, a distance is inferred. At 170 pc half a parsec is a ten-arcminute cone, wide enough to hand the planets of stars our catalogue does not carry to whatever bright star floats nearest — HATS-6 b sat on HD 39500, seventy arcseconds away. At 60 pc it is tighter than the routine disagreement between the archive's Hipparcos distances and our Gaia ones, which is how four bright giants (7 CMa, HD 81688, omi UMa, xi Aql) lost their planets and GJ 15 A's landed on a neighbouring entry. Hosts are now resolved like stars are merged: by name first, then the nearest star on the sky within a transverse budget — angle times the archive's distance, 0.01 pc — whose distance does not flatly contradict the archive's (the merge's own 50 % ratio). The budget is transverse because the dominant error is proper motion over an epoch difference, a physical displacement that is the same in parsecs at every distance: as an angle it is 60" for Proxima and 2" for a host at 100 pc. Measured on the 504 hosts whose archive name matches a catalogue name outright, true pairs reach 3.4e-3 pc; shifting every host a quarter of a degree finds nothing else within 0.01 but Proxima's own entry, whose budget at 1.3 pc is wider than the shift. The archive never says which epoch a position is for, and they are mixed: alf Tau and GJ 273 publish J2000, HD 133131 and TOI-2459 publish Gaia's J2016. So the query asks for sy_pmra/sy_pmdec too, tries each position at both ends of those sixteen years, and judges a star on whichever is closer. Guess one epoch and a fast star's planets land on a companion: J2016 puts Aldebaran's on Gl 171.1B, J2000 puts GJ 15 A's on a Gaia entry 15.9" out. 1 972 of 6 354 planets now sit on a host, 1 548 before: 432 gained, 26 on a better star (GJ 15 A to Groombridge 34, GJ 676 A off its companion, HD 19994 to 94 Cet), 8 lost — six false 3D matches to stars the catalogue never contained, and GJ 273 b/c, whose archive row says 5.92 pc for Luyten's Star at 3.79: a distance in flat contradiction is exactly what the ratio guard exists to refuse, and the number to fix is upstream. The 2 pc "rematch" apparatus is gone. build.ts recomputed every match after fetchExoplanets had already written the file — at a different tolerance, so the log reported a match count the data did not contain — and the offline entry point that persisted it had no caller. One matcher, one set of constants, used once. The archive cache is now keyed by a hash of the TAP query, so a response cached before the proper-motion columns cannot serve rows without them, where a missing cell would quietly read as "does not move"; the row's astrometry is stored with each planet, which is what made these tolerances measurable offline in the first place. Co-Authored-By: Claude Fable 5 <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,107 @@ 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.
|
||||
it('matches a host published at the Gaia epoch to its star at J2000', () => {
|
||||
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 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 },
|
||||
[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);
|
||||
});
|
||||
|
||||
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 +136,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,6 +12,9 @@ export interface HostStarQuery {
|
||||
raDeg: number;
|
||||
decDeg: number;
|
||||
distancePc: number;
|
||||
/** μα·cos δ in mas/yr, as the archive publishes it (`sy_pmra`); missing means unknown. */
|
||||
pmRaMasPerYear?: number;
|
||||
pmDecMasPerYear?: number;
|
||||
}
|
||||
|
||||
/** Builds a lookup of normalized star name -> star, for fast repeated name matching. */
|
||||
@@ -20,17 +23,57 @@ export function buildStarNameIndex(stars: readonly StarRecord[]): Map<string, St
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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 +86,46 @@ 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,
|
||||
query.pmRaMasPerYear ?? 0,
|
||||
query.pmDecMasPerYear ?? 0,
|
||||
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
Reference in New Issue
Block a user