Widen the star catalogue, and separate what is drawn from what is known
The map held 8750 stars within 50 pc and rendered 371 systems. Both were lower than they needed to be, for different reasons. The star catalogue was capped by its own encoding as much as by the cutoff: one JSON object per star, eight key names repeated each time, 157 bytes a star. At the range HYG actually reaches that is 17 MB to download and parse before the first frame. So the numbers move into two binary column stores — positions in stars.bin, which the GPU is handed verbatim, and id/magnitude/colour/spectral index in stars-meta.bin — and the JSON keeps only the strings, with 2600 distinct spectral classifications collapsed to a dictionary. The layout is defined once, in star-catalog.ts, and the ETL and the app both use it, so the writer and the reader cannot drift. The cutoff then goes to 250 pc: 68388 stars, 7.8x as many for 1.7x the bytes. That is where HYG's measurements stop rather than a round number — 98.6% of its rows are Hipparcos, whose parallaxes are good to about a milliarcsecond, so beyond 250 pc it would be plotting noise. Drawing all of them is a separate question from knowing them, and it is answered separately. The field draws a budget: every star inside 25 pc, because the nearest are faint red dwarfs and Proxima Centauri is magnitude 11, then the brightest of everything beyond. Search, navigation and the planet cross-reference still see the whole catalogue. A real GPU would draw all 68388 without noticing; the budget is for the machines that would not, and it is one constant. Systems were limited by something else entirely. The archive data already shipped named 4735 host stars and only 388 resolved, because the rest lay outside a 50 pc catalogue — and the cross-reference kept only its own result, so redoing it meant re-downloading an archive that is not reachable from here. Host coordinates are now stored with each planet, and the match is re-resolved at build time against whatever catalogue the run produced. Even name matching alone, which needs no coordinates and so works on the records already shipped, rescues 335 planets across 238 systems: 371 renderable systems become 609. Two selection rules were tuned for a 50 pc bubble and no longer fit. Tethers followed the Sun's nearest neighbours, which are a speck at this range, and now follow the brightest; labels were ranked by proximity, which named whatever sat nearest the middle of the screen, and are now ranked by brightness — so the view names Canopus, Achernar and Spica rather than a clump of catalogue designations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { CartesianCoordinates, distanceBetween, raDegDecDistanceToXyz } from './coordinates';
|
||||
import { ExoplanetRecord } from '../models/exoplanet.model';
|
||||
import { StarRecord } from '../models/star.model';
|
||||
|
||||
/** Normalizes a star name for comparison: lowercase, alphanumeric characters only. */
|
||||
@@ -65,3 +66,68 @@ function findNearestStarWithin(position: CartesianCoordinates, stars: readonly S
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user