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:
2026-09-09 14:14:05 +02:00
co-authored by Claude Fable 5
parent dc2ce08694
commit 080bbe16dc
8 changed files with 198 additions and 238 deletions
+99 -33
View File
@@ -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);
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);
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.
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: 'GJ 887', raDeg: archive.raDeg, decDeg: archive.decDeg, distancePc: 3.28679, pmRaMasPerYear: 6768.2, pmDecMasPerYear: 1327.52 },
[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);
});
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);
});
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', () => {
@@ -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);
});
+80 -82
View File
@@ -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;
for (const star of stars) {
const distance = distanceBetween(position, star);
if (distance <= toleranceInPc && (!closest || distance < closest.distance)) {
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 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);
exoplanet.hostStarId = positioned ? resolved : (resolved ?? previous);
if (exoplanet.hostStarId !== null) {
summary.matched++;
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 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) {
summary.gained++;
} else if (previous !== null && exoplanet.hostStarId === null) {
summary.lost++;
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 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. */
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
-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 { 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);
+13 -6
View File
@@ -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,10 @@ 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 query it answers: a response cached before a column was added
// would otherwise keep serving rows without it, and a missing proper-motion cell reads as
// "does not move" — silently wrong, not visibly broken.
const CACHE_FILE = `exoplanet-archive-ps-${createHash('sha1').update(TAP_QUERY).digest('hex').slice(0, 8)}.csv`;
/**
* Downloads confirmed exoplanets from the NASA Exoplanet Archive (`Planetary Systems` TAP
@@ -45,7 +49,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 +59,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 +89,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']),
-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;
}