Merge pull request #15 from avalon-vanguard/star-map/fix/merge-epochs

Bring Gaia to HYG's epoch before merging, and keep a star's name when it matches

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
Senrokai
2026-09-17 15:04:12 +02:00
committed by GitHub
co-authored by Claude Opus 5
12 changed files with 310 additions and 45 deletions
@@ -200,6 +200,17 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
expect(navigationStore.viewLevel()).toBe('galaxy');
});
it('clears a selection the catalogue no longer holds instead of chasing it', async () => {
// A bookmark saved against a Gaia row id that the next refresh renumbered. Before the guard,
// entering the missing system completed at once, completion re-read the same id, and the
// two recursed until the stack overflowed.
navigationStore.selectStar(987654321);
await flushAsync();
expect(navigationStore.selectedStarId()).toBeNull();
expect(navigationStore.viewLevel()).toBe('galaxy');
});
it('flies the camera into a selected star system: hides the galaxy group, shows the system group, and switches to AU-scale near/far planes', async () => {
navigationStore.selectStar(SUN.id);
await flushAsync();
@@ -1391,6 +1391,15 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
if (this.busy || selectedStarId === this.currentStarId) {
return;
}
// An id the catalogue no longer holds — a bookmark to a Gaia row that a refresh renumbered
// or folded into a named star — has nowhere to fly to. It is cleared here rather than left
// to `enterSystem` to decline, because declining completes the transition, and completing
// re-reads the same id: the two would call each other until the stack ran out.
if (selectedStarId !== null && !this.starsById.has(selectedStarId)) {
this.navigationStore.selectStar(null);
return;
}
this.busy = true;
if (selectedStarId === null) {
+22
View File
@@ -7,6 +7,7 @@ import {
OBLIQUITY_J2000_DEG,
parallaxMasToParsecs,
parseSexagesimal,
propagateProperMotion,
raDecDistanceToXyz,
raDecToUnitVector,
raDegDecDistanceToXyz
@@ -59,6 +60,27 @@ describe('raDegDecDistanceToXyz', () => {
});
});
describe('propagateProperMotion', () => {
it("carries Barnard's Star from Gaia's epoch back to HYG's", () => {
// Gaia DR3 4472832130942575872 as published for J2016.0, moved back sixteen years with its
// own proper motion, lands on the J2000.0 position SIMBAD lists to a milliarcsecond — and
// 0.08″ from where HYG has Barnard's Star, instead of the 166″ the two epochs put between them.
const j2000 = propagateProperMotion(269.44850252543836, 4.739420051112412, -801.550978, 10362.394207, -16);
expect(j2000.raDeg).toBeCloseTo(269.4520772, 6);
expect(j2000.decDeg).toBeCloseTo(4.693365, 6);
});
it('divides the right-ascension motion by cos δ, since pmra is published on the sky', () => {
// 3600 mas/yr for one year is 3.6″ on the sky; at Dec 60° that is 7.2″ of right ascension.
expect(propagateProperMotion(0, 60, 3600, 0, 1).raDeg).toBeCloseTo(7.2 / 3600, 9);
expect(propagateProperMotion(0, 60, 0, 3600, 1).decDeg).toBeCloseTo(60 + 3.6 / 3600, 9);
});
it('leaves a star with no proper motion where it is', () => {
expect(propagateProperMotion(100, -20, 0, 0, 16)).toEqual({ raDeg: 100, decDeg: -20 });
});
});
describe('parallaxMasToParsecs', () => {
it('converts a positive parallax to the expected distance', () => {
expect(parallaxMasToParsecs(769.33)).toBeCloseTo(1.3, 2); // Proxima Centauri
+15
View File
@@ -33,6 +33,21 @@ export function raDegDecDistanceToXyz(raDeg: number, decDeg: number, distancePc:
return raDecDistanceToXyz(raDeg / HOURS_TO_DEG, decDeg, distancePc);
}
const MAS_TO_DEG = 1 / 3_600_000;
/**
* Moves a sky position along its proper motion by `years` — negative to go back in time — so
* catalogues that observed at different epochs can be compared at one. `pmRaMasPerYear` is
* μα cos δ, the on-sky rate Hipparcos and Gaia both publish, hence the division by cos δ to turn
* it back into right ascension.
*/
export function propagateProperMotion(raDeg: number, decDeg: number, pmRaMasPerYear: number, pmDecMasPerYear: number, years: number): { raDeg: number; decDeg: number } {
return {
raDeg: raDeg + (years * pmRaMasPerYear * MAS_TO_DEG) / Math.cos(decDeg * DEG_TO_RAD),
decDeg: decDeg + years * pmDecMasPerYear * MAS_TO_DEG
};
}
/**
* Obliquity of the ecliptic at J2000.0, in degrees — the tilt of Earth's orbital plane against
* its equator, and so the angle between this app's two source frames.
+95 -4
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { raDegDecDistanceToXyz } from './coordinates';
import { StarRecord } from '../models/star.model';
import { directionCosine, isSameStar, mergeStarCatalogues } from './star-merge';
import { directionCosine, isSameStar, MERGE_ANGULAR_TOLERANCE_DEG, mergeStarCatalogues } from './star-merge';
/** A star at a given sky position and distance, which is how catalogues actually report them. */
function at(id: number, raDeg: number, decDeg: number, distancePc: number, overrides: Partial<StarRecord> = {}): StarRecord {
@@ -13,24 +13,70 @@ function at(id: number, raDeg: number, decDeg: number, distancePc: number, overr
const HIPPARCOS = { sourceId: 'hyg', parallaxPrecisionMas: 1 };
const GAIA = { sourceId: 'gaia', parallaxPrecisionMas: 0.02 };
/** Degrees of right ascension that span `arcsec` on the sky at declination `decDeg`. */
function arcsecOfRa(arcsec: number, decDeg: number): number {
return arcsec / 3600 / Math.cos((decDeg * Math.PI) / 180);
}
describe('isSameStar', () => {
it('matches two catalogues reporting the same star', () => {
expect(isSameStar(at(1, 101.28, -16.71, 2.64), at(2, 101.28, -16.71, 2.63))).toBe(true);
});
it('matches within the angular tolerance and not beyond it', () => {
const toleranceArcsec = MERGE_ANGULAR_TOLERANCE_DEG * 3600;
expect(isSameStar(at(1, 200, 10, 100), at(2, 200 + arcsecOfRa(0.9 * toleranceArcsec, 10), 10, 100))).toBe(true);
expect(isSameStar(at(1, 200, 10, 100), at(2, 200 + arcsecOfRa(1.1 * toleranceArcsec, 10), 10, 100))).toBe(false);
});
it('tolerates the distance disagreement two parallaxes actually have', () => {
// Hipparcos and Gaia routinely differ by tens of per cent at a few hundred parsecs. That
// disagreement is the reason to prefer one of them, not evidence they are different stars.
expect(isSameStar(at(1, 200, 10, 200), at(2, 200, 10, 260))).toBe(true);
});
it('keeps a bright primary out of the entry of its faint companion', () => {
// Gaia has no Sirius — it saturates — but has Sirius B, 6″ away at the same distance and ten
// magnitudes fainter. Direction and distance say "same star"; the brightness says otherwise.
const siriusB = at(1, 101.2875, -16.7161, 2.67, { name: 'Gaia DR3 2947050466531873024', magnitude: 8.5, source: 'gaia' });
const sirius = at(32263, 101.2875 + arcsecOfRa(6.1, -16.7161), -16.7161, 2.637, { name: 'Sirius', magnitude: -1.44 });
expect(isSameStar(siriusB, sirius)).toBe(false);
expect(isSameStar(siriusB, { ...sirius, magnitude: 8.6 })).toBe(true);
});
it('lets the folded entry be fainter, as a red star is in V, but not much brighter', () => {
// Wolf 359 is V 13.45 in HYG and G 11.0 in Gaia — the same star, 5″ apart on a Gliese
// position. Almach is V 2.1 and sits 10″ from γ² And, G 4.9: Gaia has no Almach, and its
// name must not land on the companion.
const wolf359 = at(1, 164.1, 7.0, 2.41, { name: 'Gaia DR3 3864972938605115520', magnitude: 11.0, source: 'gaia' });
expect(isSameStar(wolf359, at(118720, 164.1 + arcsecOfRa(5, 7), 7.0, 2.39, { name: 'Wolf 359', magnitude: 13.45 }))).toBe(true);
const gamma2And = at(2, 30.97, 42.33, 50, { name: 'Gaia DR3 346231302441905920', magnitude: 4.9, source: 'gaia' });
expect(isSameStar(gamma2And, at(9640, 30.97 + arcsecOfRa(9.9, 42.33), 42.33, 50, { name: 'Almach', magnitude: 2.1 }))).toBe(false);
});
it('does not match two different stars that happen to be at the same distance', () => {
expect(isSameStar(at(1, 200, 10, 200), at(2, 200.5, 10, 200))).toBe(false);
});
it('does not match along a line of sight when the distances genuinely conflict', () => {
// Same direction, one three times further away: a background star, not the same object.
expect(isSameStar(at(1, 200, 10, 100), at(2, 200, 10, 300))).toBe(false);
it('takes two entries within three arcseconds for one star, whatever their distances say', () => {
// HD 225021: 143.7 pc by its Hipparcos parallax, 239.4 by Gaia's, 0.01″ apart; HIP 82724:
// 3.7 pc by Hipparcos, 62.8 by Gaia, 2.3″ apart. A coincidence of direction that close is
// never chance at this depth; the parallax is what is wrong.
const gaia = at(1, 1.72, -8.9, 239.4, { name: 'Gaia DR3 395581679270412160', source: 'gaia' });
expect(isSameStar(gaia, at(213, 1.72 + arcsecOfRa(0.1, -8.9), -8.9, 143.7, { name: 'HD 225021' }))).toBe(true);
expect(isSameStar(at(2, 253.6, -38.1, 62.8, { source: 'gaia' }), at(82724, 253.6 + arcsecOfRa(2.3, -38.1), -38.1, 3.7))).toBe(true);
});
it('past those three arcseconds, does not match along a line of sight when the distances conflict', () => {
// Nearly the same direction, one three times further away: a background star, not the same object.
expect(isSameStar(at(1, 200, 10, 100), at(2, 200 + arcsecOfRa(5, 10), 10, 300))).toBe(false);
});
it('still hears the brightness inside those three arcseconds', () => {
// Ashlesha (ε Hya, V 3.38) has a companion 2.7″ away that Gaia does carry, three magnitudes
// fainter, while it does not carry Ashlesha. Direction alone would put the name on the companion.
const companion = at(1, 131.69, 6.42, 40, { name: 'Gaia DR3 1', magnitude: 6.7, source: 'gaia' });
expect(isSameStar(companion, at(43109, 131.69 + arcsecOfRa(2.7, 6.42), 6.42, 40, { name: 'Ashlesha', magnitude: 3.38 }))).toBe(false);
});
it('matches on direction rather than on 3D proximity', () => {
@@ -72,6 +118,44 @@ describe('mergeStarCatalogues', () => {
expect(summary.duplicates).toBe(1);
});
it('gives a matched star the better position and the name somebody gave it', () => {
// What a merge is for: Gaia knows where Proxima is to a fraction of a milliarcsecond and
// calls it by a nineteen-digit number; HYG knows its name, its spectral type and its V
// magnitude. Keeping one row whole loses half of that either way. The id follows the
// description, so a star HYG knows keeps its HYG id from one refresh to the next.
const hyg = at(70666, 217.4289, -62.6795, 1.2959, { name: 'Proxima Centauri', spectralType: 'M5Ve', magnitude: 11.01, colorIndex: 1.807 });
const gaia = at(1000064182, 217.4289, -62.6795, 1.302, { name: 'Gaia DR3 5853498713190525696', spectralType: 'Unknown', magnitude: 8.985, colorIndex: 3.805, source: 'gaia' });
const { stars, summary } = mergeStarCatalogues([{ ...HIPPARCOS, stars: [hyg] }, { ...GAIA, stars: [gaia] }]);
expect(stars).toEqual([{ ...hyg, x: gaia.x, y: gaia.y, z: gaia.z, source: 'gaia' }]);
expect(summary.duplicates).toBe(1);
});
it('keeps two entries of one source apart, however close they are', () => {
// Gaia resolves doubles Hipparcos saw as one star: two source ids 0.8″ apart are two stars,
// and only *another* catalogue can claim to have already listed either of them.
const { stars, summary } = mergeStarCatalogues([{ ...GAIA, stars: [at(1, 10, 10, 100), at(2, 10 + arcsecOfRa(0.8, 10), 10, 100)] }]);
expect(stars).toHaveLength(2);
expect(summary.duplicates).toBe(0);
});
it('folds an entry into the nearest match, and into each match once', () => {
// Gliese lists both components of a double; Gaia resolves them 0.8″ apart. Each HYG
// component must land on its own Gaia counterpart — not both on whichever the grid yields
// first, and not both on the same one.
const gaiaA = at(1, 10, 10, 2.68, { name: 'Gaia DR3 1', source: 'gaia' });
const gaiaB = at(2, 10 + arcsecOfRa(0.8, 10), 10, 2.68, { name: 'Gaia DR3 2', source: 'gaia' });
const a = at(118079, 10, 10, 2.63, { name: 'Gl 65A' });
const b = at(118080, 10 + arcsecOfRa(0.8, 10), 10, 2.63, { name: 'Gl 65B' });
const position = (star: StarRecord) => [star.x, star.y, star.z];
const nearest = mergeStarCatalogues([{ ...HIPPARCOS, stars: [b, a] }, { ...GAIA, stars: [gaiaA, gaiaB] }]);
expect(nearest.stars.map((star) => [star.name, ...position(star)])).toEqual([['Gl 65A', ...position(gaiaA)], ['Gl 65B', ...position(gaiaB)]]);
const onePlace = mergeStarCatalogues([{ ...HIPPARCOS, stars: [a, { ...b, x: a.x, y: a.y, z: a.z }] }, { ...GAIA, stars: [gaiaA, gaiaB] }]);
expect(onePlace.stars.map((star) => [star.name, ...position(star)])).toEqual([['Gl 65A', ...position(gaiaA)], ['Gl 65B', ...position(gaiaB)]]);
});
it('keeps a star the better catalogue does not reach', () => {
// The point of merging rather than replacing: Gaia is more precise but not a superset of
// everything, and a bright star it omits should not vanish from the map.
@@ -123,6 +207,13 @@ describe('mergeStarCatalogues', () => {
]);
expect(stars).toHaveLength(1);
}
// And the one edge the grid has to wrap: 3.6″ apart, either side of 0h.
const { stars } = mergeStarCatalogues([
{ ...HIPPARCOS, stars: [at(1, 359.9995, 0, 100)] },
{ ...GAIA, stars: [at(2, 0.0005, 0, 100)] }
]);
expect(stars).toHaveLength(1);
});
it('handles a single catalogue as a plain pass-through', () => {
+113 -19
View File
@@ -1,3 +1,4 @@
import { isDesignation } from '../models/star-catalog';
import { StarRecord } from '../models/star.model';
/**
@@ -17,8 +18,47 @@ import { StarRecord } from '../models/star.model';
const DEG_TO_RAD = Math.PI / 180;
/** Angular separation, in degrees, below which two entries are taken to be the same star. */
export const MERGE_ANGULAR_TOLERANCE_DEG = 1 / 3600;
/**
* Angular separation, in degrees, below which two entries are taken to be the same star.
*
* Every source arrives here at epoch J2000.0 — HYG publishes it, Gaia is carried back to it with
* its own proper motions in `gaia.ts` — so what separates two entries of one star is measurement,
* not motion. Left at their own epochs, sixteen years of proper motion put Proxima's two entries
* 62″ apart and Barnard's 166″, and an arcsecond of tolerance kept every fast star twice while
* folding the slow ones.
*
* What measurement leaves is under an arcsecond for a Hipparcos position — 55 457 of the 56 000
* stars both catalogues hold — and up to tens of arcseconds for the Gliese-only entries HYG
* carries without Hipparcos astrometry: Wolf 359 sits 5″ from where Gaia has it, Ross 248 12″.
* Fifteen arcseconds takes those. The sky is sparse enough at this depth that shifting every
* entry a quarter of a degree finds only 16 chance neighbours within it, against 116 real ones
* between ten and fifteen; past twenty the two curves run together.
*/
export const MERGE_ANGULAR_TOLERANCE_DEG = 15 / 3600;
/**
* Angular separation, in degrees, under which the distances are not consulted. A coincidence of
* direction this close is never chance at this depth — the quarter-degree shift finds none under
* 3″ — so two entries this close are one star whatever their parallaxes say, and what they say
* is often a Hipparcos parallax off by half: 1 500 stars sat within this of their Gaia entry and
* were kept twice by the distance test, thirty of them at a false few parsecs from the Sun
* (HIP 82724 at 3.7 pc, where Gaia has it at 62.8). Brightness keeps its say at any separation,
* because a companion can sit this close: Ashlesha's is 2.7″ away and three magnitudes fainter.
*/
export const MERGE_CERTAIN_ANGULAR_TOLERANCE_DEG = 3 / 3600;
/**
* How much fainter, and how much brighter, an entry may be than the one it is folded into and
* still be the same star. Bands differ, and not symmetrically: a red dwarf is three magnitudes
* fainter in HYG's V than in Gaia's G, so the folded entry may be up to five fainter. A star is
* never much brighter in V than in G, though, and an entry a magnitude brighter than what is
* already at that spot is a primary Gaia does not carry — it saturates below G ≈ 3 — sitting
* beside its companion: Sirius 6″ from Sirius B and ten magnitudes brighter, Almach 10″ from
* γ² And, Alfirk 13″ from β Cep B. Without this the primary's name lands on the companion's
* entry, and the companion is gone.
*/
export const MERGE_FAINTER_TOLERANCE = 5;
export const MERGE_BRIGHTER_TOLERANCE = 1;
/**
* How far two distances may disagree, as a ratio, and still describe the same star. Generous on
@@ -36,7 +76,7 @@ export interface MergeCandidate {
export interface MergeSummary {
readonly total: number;
/** Entries dropped because a better-measured catalogue already had that star. */
/** Entries folded into one a better-measured catalogue already had; see {@link combine}. */
readonly duplicates: number;
readonly bySource: Readonly<Record<string, number>>;
}
@@ -60,8 +100,13 @@ function distanceOf(star: StarRecord): number {
*/
const SKY_CELL_DEG = 0.5;
const RA_CELLS = 360 / SKY_CELL_DEG;
function cellKey(raDeg: number, decDeg: number): string {
return `${Math.floor(raDeg / SKY_CELL_DEG)}:${Math.floor(decDeg / SKY_CELL_DEG)}`;
// Right ascension wraps: the cell after 359.5° is 0°, so a pair straddling 0h shares a
// neighbourhood rather than sitting 719 cells apart.
const raCell = ((Math.floor(raDeg / SKY_CELL_DEG) % RA_CELLS) + RA_CELLS) % RA_CELLS;
return `${raCell}:${Math.floor(decDeg / SKY_CELL_DEG)}`;
}
function skyAngles(star: StarRecord): { raDeg: number; decDeg: number } {
@@ -86,9 +131,13 @@ export function directionCosine(a: StarRecord, b: StarRecord): number {
return Math.max(-1, Math.min(1, ax * bx + ay * by + az * bz));
}
/** Whether two entries describe the same star: same direction, and distances not in conflict. */
export function isSameStar(a: StarRecord, b: StarRecord): boolean {
const [near, far] = [distanceOf(a), distanceOf(b)].sort((p, q) => p - q);
/**
* Whether `entry` describes the star already `kept`: the same direction, the brightness not in
* conflict and — unless the directions agree closely enough to settle it — the distance not in
* conflict either.
*/
export function isSameStar(kept: StarRecord, entry: StarRecord): boolean {
const [near, far] = [distanceOf(kept), distanceOf(entry)].sort((p, q) => p - q);
// The Sun sits at the origin of this coordinate system and so has no direction at all, which
// the angular test below cannot speak about. Every catalogue contains it, so without this the
@@ -97,25 +146,53 @@ export function isSameStar(a: StarRecord, b: StarRecord): boolean {
return far === 0;
}
const separationDeg = Math.acos(directionCosine(a, b)) / DEG_TO_RAD;
const separationDeg = Math.acos(directionCosine(kept, entry)) / DEG_TO_RAD;
if (separationDeg > MERGE_ANGULAR_TOLERANCE_DEG) {
return false;
}
const fainterBy = entry.magnitude - kept.magnitude;
if (fainterBy < -MERGE_BRIGHTER_TOLERANCE || fainterBy > MERGE_FAINTER_TOLERANCE) {
return false;
}
if (separationDeg <= MERGE_CERTAIN_ANGULAR_TOLERANCE_DEG) {
return true;
}
return (far - near) / near <= MERGE_DISTANCE_RATIO_TOLERANCE;
}
/**
* One entry from two of the same star: the position of the better-measured one — inserted first,
* so it is the one already `kept` — and the description of whichever knows the star as more than
* a catalogue number. HYG's "Proxima Centauri", "M5Ve" and V magnitude over Gaia's
* "Gaia DR3 5853498713190525696", "Unknown" and G; keeping either row whole loses half of that,
* and keeping Gaia's whole once cost the map 102 proper names and 32 000 spectral types. The id
* travels with the description, so a star HYG knows keeps its HYG id from one refresh to the next;
* `source` stays with the position, since that is what it records.
*/
function combine(kept: StarRecord, other: StarRecord): StarRecord {
const described = isDesignation(kept) && !isDesignation(other) ? other : kept;
return { ...described, x: kept.x, y: kept.y, z: kept.z, source: kept.source };
}
/**
* Unions the given catalogues, keeping one entry per star.
*
* Sources are taken in order of how precisely they measure parallax, best first, and a star is
* only added if no better-measured catalogue already has it. So where Gaia and Hipparcos
* overlap, the position is Gaia's; where only Hipparcos reaches, the star is still there.
* Sources are taken in order of how precisely they measure parallax, best first. An entry that a
* better-measured catalogue already has is folded into that entry — the nearest one within the
* tolerance, see {@link combine} for what each side keeps. Only entries from *other* sources
* count as already there: a catalogue does not list a star twice, so two of its own entries
* within the tolerance are two stars, typically a double that Gaia resolves and Hipparcos did
* not. Where only one source reaches, the star is still there.
*/
export function mergeStarCatalogues(candidates: readonly MergeCandidate[]): { stars: StarRecord[]; summary: MergeSummary } {
const ordered = [...candidates].sort((a, b) => a.parallaxPrecisionMas - b.parallaxPrecisionMas);
const merged: StarRecord[] = [];
const grid = new Map<string, StarRecord[]>();
const grid = new Map<string, number[]>();
// Entries that already absorbed one from a source, as `${index}/${source}`: a double that
// Gliese lists as two entries at one position has to land on two Gaia entries, not on one.
const taken = new Set<string>();
const bySource: Record<string, number> = {};
let duplicates = 0;
@@ -123,24 +200,41 @@ export function mergeStarCatalogues(candidates: readonly MergeCandidate[]): { st
bySource[candidate.sourceId] = 0;
for (const star of candidate.stars) {
const { raDeg, decDeg } = skyAngles(star);
const alreadyPresent = neighbouringCells(raDeg, decDeg).some((key) => (grid.get(key) ?? []).some((existing) => isSameStar(existing, star)));
const entry: StarRecord = { ...star, source: star.source ?? candidate.sourceId };
const { raDeg, decDeg } = skyAngles(entry);
if (alreadyPresent) {
let match: number | null = null;
let matchCosine = -1;
for (const key of neighbouringCells(raDeg, decDeg)) {
for (const index of grid.get(key) ?? []) {
const existing = merged[index];
if (existing.source === entry.source || taken.has(`${index}/${entry.source}`) || !isSameStar(existing, entry)) {
continue;
}
const cosine = directionCosine(existing, entry);
if (cosine > matchCosine) {
match = index;
matchCosine = cosine;
}
}
}
if (match !== null) {
merged[match] = combine(merged[match], entry);
taken.add(`${match}/${entry.source}`);
duplicates++;
continue;
}
const withSource: StarRecord = { ...star, source: star.source ?? candidate.sourceId };
merged.push(withSource);
const index = merged.push(entry) - 1;
bySource[candidate.sourceId]++;
const key = cellKey(raDeg, decDeg);
const cell = grid.get(key);
if (cell) {
cell.push(withSource);
cell.push(index);
} else {
grid.set(key, [withSource]);
grid.set(key, [index]);
}
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+15 -10
View File
@@ -1,6 +1,5 @@
import { writeFileSync } from 'node:fs';
import { raDecDistanceToXyz } from '../../src/app/shared/astro/coordinates';
import { mergeStarCatalogues } from '../../src/app/shared/astro/star-merge';
import { encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
@@ -59,9 +58,9 @@ function resolveName(row: Record<string, string>): string {
}
/**
* Downloads the HYG (Hipparcos/Yale/Gliese) stellar database, converts each star's
* RA/Dec/distance into galaxy-scale Cartesian coordinates (parsecs), filters by distance,
* and writes `stars.bin` (packed positions) + `stars-index.json` (everything else).
* Downloads the HYG (Hipparcos/Yale/Gliese) stellar database, takes each star's equatorial
* Cartesian position (parsecs, epoch J2000.0), filters by distance, unions the other positional
* sources, and writes `stars.bin` (packed positions) + `stars-index.json` (everything else).
*/
export async function fetchStars(): Promise<StarRecord[]> {
console.log(`Fetching HYG star catalog (distance cutoff: ${DISTANCE_CUTOFF_PC} pc)...`);
@@ -83,14 +82,20 @@ export async function fetchStars(): Promise<StarRecord[]> {
continue;
}
const raHours = Number(row['ra']);
const decDeg = Number(row['dec']);
if (!Number.isFinite(raHours) || !Number.isFinite(decDeg)) {
// HYG's own Cartesian columns rather than its `ra`/`dec`, which are in the same frame as
// `raDecDistanceToXyz` and would be redundant if the two agreed. They do not, for the stars
// that move: the right ascension was carried from the Hipparcos epoch to 2000.0 without the
// cos δ its motion needs, which puts Proxima 17.9″ from where HYG's own x/y/z — and Gaia,
// once brought to the same epoch — have it. 1813 stars differ by over an arcsecond, and the
// Cartesian columns are the ones Gaia agrees with for 1155 of them against 156 (one of those,
// HIP 57146, has x/y/z 161″ from its own ra/dec and stays double).
const x = Number(row['x']);
const y = Number(row['y']);
const z = Number(row['z']);
if (![x, y, z].every(Number.isFinite)) {
continue;
}
const { x, y, z } = raDecDistanceToXyz(raHours, decDeg, distancePc);
stars.push({
id,
name: resolveName(row),
@@ -143,7 +148,7 @@ async function mergeWithOtherSources(hygStars: StarRecord[]): Promise<StarRecord
}
const { stars, summary } = mergeStarCatalogues(candidates);
console.log(` merged ${summary.total} stars from ${candidates.length} catalogues (${summary.duplicates} duplicates resolved to the better parallax):`);
console.log(` merged ${summary.total} stars from ${candidates.length} catalogues (${summary.duplicates} entries folded into a better-measured one):`);
for (const [sourceId, count] of Object.entries(summary.bySource)) {
console.log(` ${sourceId}: ${count}`);
}
+28 -10
View File
@@ -1,4 +1,6 @@
import { raDegDecDistanceToXyz } from '../../../src/app/shared/astro/coordinates';
import { createHash } from 'node:crypto';
import { propagateProperMotion, raDegDecDistanceToXyz } from '../../../src/app/shared/astro/coordinates';
import { StarRecord } from '../../../src/app/shared/models/star.model';
import { parseCsvObjects, parseOptionalNumber } from '../lib/csv';
import { fetchTextCached } from '../lib/http';
@@ -11,14 +13,24 @@ import { fetchTextCached } from '../lib/http';
* Galaxy — no catalogue is close to the rest — but within a few hundred parsecs it is complete
* in a way Hipparcos never was, and its parallaxes are fifty times more precise.
*
* **This has never been run.** Every ESA, NOIRLab, SDSS and Euclid endpoint is unreachable from
* the environment this was written in, so the query below is written against the published DR3
* schema and has not been executed against it. Treat the column names as the first thing to
* check if a real run misbehaves.
* Written blind against the published DR3 schema, since no ESA endpoint was reachable from the
* environment it was written in; first run for real by the scheduled refresh of 2026-08-24, which
* fetched 412 765 rows.
*/
const GAIA_TAP_URL = 'https://gea.esac.esa.int/tap-server/tap/sync';
/**
* Gaia DR3 gives positions for J2016.0; HYG for J2000.0, which is the epoch this map keeps.
* Sixteen years of proper motion is over an arcsecond for anything faster than ~62 mas/yr —
* which is most of the nearest stars: 62″ for Proxima, 166″ for Barnard's — so as published, the
* two catalogues never agree on where those stars are, and a merge that matched them on the sky
* kept every one of them twice. Each position is therefore carried back to J2000.0 with Gaia's
* own proper motion before it leaves here.
*/
const GAIA_DR3_EPOCH = 2016.0;
const CATALOGUE_EPOCH = 2000.0;
/**
* How far out to take Gaia, in parsecs, and the faintest star to keep.
*
@@ -44,12 +56,14 @@ function parallaxFloorMas(distancePc: number): number {
function buildQuery(): string {
return [
`select top ${ROW_LIMIT}`,
'source_id, ra, dec, parallax, parallax_error, phot_g_mean_mag, bp_rp',
'source_id, ra, dec, pmra, pmdec, parallax, parallax_error, phot_g_mean_mag, bp_rp',
'from gaiadr3.gaia_source',
`where parallax > ${parallaxFloorMas(DISTANCE_CUTOFF_PC).toFixed(6)}`,
`and parallax_over_error > ${(1 / MAX_PARALLAX_ERROR_RATIO).toFixed(1)}`,
`and phot_g_mean_mag < ${MAGNITUDE_LIMIT}`,
'order by phot_g_mean_mag asc'
// source_id breaks the ties — 20 064 groups share a G at the published precision — so the
// row order, and with it the ids assigned below, is a pure function of the archive's content.
'order by phot_g_mean_mag asc, source_id asc'
].join(' ');
}
@@ -69,10 +83,13 @@ const UNKNOWN_SPECTRAL_TYPE = 'Unknown';
const GAIA_ID_BASE = 1_000_000_000;
export async function fetchGaiaStars(): Promise<StarRecord[]> {
const url = `${GAIA_TAP_URL}?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&QUERY=${encodeURIComponent(buildQuery())}`;
const query = buildQuery();
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)...`);
const csv = await fetchTextCached(url, 'gaia-dr3.csv');
// 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`);
const rows = parseCsvObjects(csv);
const stars: StarRecord[] = [];
@@ -89,7 +106,8 @@ export async function fetchGaiaStars(): Promise<StarRecord[]> {
return;
}
const { x, y, z } = raDegDecDistanceToXyz(raDeg, decDeg, distancePc);
const j2000 = propagateProperMotion(raDeg, decDeg, parseOptionalNumber(row['pmra']) ?? 0, parseOptionalNumber(row['pmdec']) ?? 0, CATALOGUE_EPOCH - GAIA_DR3_EPOCH);
const { x, y, z } = raDegDecDistanceToXyz(j2000.raDeg, j2000.decDeg, distancePc);
stars.push({
id: GAIA_ID_BASE + index,
name: `Gaia DR3 ${row['source_id']}`,