Bring Gaia to HYG's epoch before merging, and keep a star's name when it matches
Gaia DR3 gives positions for J2016.0, HYG for 2000.0, and the merge matched them on the sky to one arcsecond without propagating any proper motion. Sixteen years of motion is 62" for Proxima and 166" for Barnard's Star, so every star faster than ~62 mas/yr — most of the nearest ones — was kept twice, some 23 000 in all. The slow ones were matched, and lost: the merge kept Gaia's row whole, so 102 proper names, 1 336 Bayer/Flamsteed names and 32 000 spectral types became "Gaia DR3 <id>" and "Unknown", and 92 named exoplanet hosts handed their planets to their anonymous twin. Gaia is now asked for its proper motions and carried back to J2000 before it leaves the fetcher. HYG is placed from its own x/y/z columns, which are right where its `ra` is not: that column was carried from the Hipparcos epoch without the cos δ its motion needs, 17.9" off for Proxima. A match combines the two entries — Gaia's position, HYG's name, type, magnitude, colour and id — instead of choosing one. The tolerance is 15" with a five-magnitude guard, both set by measurement: 55 457 pairs sit under 1" once the epochs agree, the Gliese-only entries up to 12" (Ross 248), shifting every entry a quarter of a degree finds 16 chance neighbours at 15", and the guard keeps Sirius out of Sirius B's entry. Entries of one source are never merged with each other: the 1 411 Gaia doubles resolved under 1" are two stars, not one. Regenerated: 425 071 stars (was 447 410), 56 082 of them Gaia positions carrying HYG identities; no HYG id or name lost; the sixteen stars nearest the Sun carry no survey designation; 196 residual doubles, all components 17" or more from their counterpart. Five planets of four bright giants (7 CMa, HD 81688, omi UMa, xi Aql) lose their host link: their Gaia distance sits 0.7–1.1 pc from the archive's Hipparcos-based one, past the 0.5 pc the host match allows. Matching hosts on the sky rather than in space, as the merge does, is the follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QL6F9Bgfh8SgAiAAcPB9Hw
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
|||||||
OBLIQUITY_J2000_DEG,
|
OBLIQUITY_J2000_DEG,
|
||||||
parallaxMasToParsecs,
|
parallaxMasToParsecs,
|
||||||
parseSexagesimal,
|
parseSexagesimal,
|
||||||
|
propagateProperMotion,
|
||||||
raDecDistanceToXyz,
|
raDecDistanceToXyz,
|
||||||
raDecToUnitVector,
|
raDecToUnitVector,
|
||||||
raDegDecDistanceToXyz
|
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', () => {
|
describe('parallaxMasToParsecs', () => {
|
||||||
it('converts a positive parallax to the expected distance', () => {
|
it('converts a positive parallax to the expected distance', () => {
|
||||||
expect(parallaxMasToParsecs(769.33)).toBeCloseTo(1.3, 2); // Proxima Centauri
|
expect(parallaxMasToParsecs(769.33)).toBeCloseTo(1.3, 2); // Proxima Centauri
|
||||||
|
|||||||
@@ -33,6 +33,21 @@ export function raDegDecDistanceToXyz(raDeg: number, decDeg: number, distancePc:
|
|||||||
return raDecDistanceToXyz(raDeg / HOURS_TO_DEG, decDeg, 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
|
* 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.
|
* its equator, and so the angle between this app's two source frames.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { raDegDecDistanceToXyz } from './coordinates';
|
import { raDegDecDistanceToXyz } from './coordinates';
|
||||||
import { StarRecord } from '../models/star.model';
|
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. */
|
/** 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 {
|
function at(id: number, raDeg: number, decDeg: number, distancePc: number, overrides: Partial<StarRecord> = {}): StarRecord {
|
||||||
@@ -13,17 +13,37 @@ function at(id: number, raDeg: number, decDeg: number, distancePc: number, overr
|
|||||||
const HIPPARCOS = { sourceId: 'hyg', parallaxPrecisionMas: 1 };
|
const HIPPARCOS = { sourceId: 'hyg', parallaxPrecisionMas: 1 };
|
||||||
const GAIA = { sourceId: 'gaia', parallaxPrecisionMas: 0.02 };
|
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', () => {
|
describe('isSameStar', () => {
|
||||||
it('matches two catalogues reporting the same star', () => {
|
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);
|
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', () => {
|
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
|
// 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.
|
// 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);
|
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 sirius = at(32263, 101.2875, -16.7161, 2.637, { name: 'Sirius', magnitude: -1.44 });
|
||||||
|
const siriusB = at(1, 101.2875 + arcsecOfRa(6.1, -16.7161), -16.7161, 2.67, { name: 'Gaia DR3 2947050466531873024', magnitude: 8.5, source: 'gaia' });
|
||||||
|
expect(isSameStar(sirius, siriusB)).toBe(false);
|
||||||
|
expect(isSameStar(sirius, { ...siriusB, magnitude: -1.3 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not match two different stars that happen to be at the same distance', () => {
|
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);
|
expect(isSameStar(at(1, 200, 10, 200), at(2, 200.5, 10, 200))).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -72,6 +92,44 @@ describe('mergeStarCatalogues', () => {
|
|||||||
expect(summary.duplicates).toBe(1);
|
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', () => {
|
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
|
// 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.
|
// everything, and a bright star it omits should not vanish from the map.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isDesignation } from '../models/star-catalog';
|
||||||
import { StarRecord } from '../models/star.model';
|
import { StarRecord } from '../models/star.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,8 +18,32 @@ import { StarRecord } from '../models/star.model';
|
|||||||
|
|
||||||
const DEG_TO_RAD = Math.PI / 180;
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far two catalogues may disagree about a star's brightness and still describe the same
|
||||||
|
* star. Bands differ — HYG's V and Gaia's G are three magnitudes apart for the reddest dwarfs —
|
||||||
|
* but five is not a band, it is a companion: Sirius B sits 6″ from Sirius and ten magnitudes
|
||||||
|
* fainter, Polaris B 18″ and seven. Gaia saturates below G ≈ 3, so without this a bright primary
|
||||||
|
* it does not carry is folded into its companion's entry, and the companion is gone.
|
||||||
|
*/
|
||||||
|
export const MERGE_MAGNITUDE_TOLERANCE = 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far two distances may disagree, as a ratio, and still describe the same star. Generous on
|
* How far two distances may disagree, as a ratio, and still describe the same star. Generous on
|
||||||
@@ -36,7 +61,7 @@ export interface MergeCandidate {
|
|||||||
|
|
||||||
export interface MergeSummary {
|
export interface MergeSummary {
|
||||||
readonly total: number;
|
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 duplicates: number;
|
||||||
readonly bySource: Readonly<Record<string, number>>;
|
readonly bySource: Readonly<Record<string, number>>;
|
||||||
}
|
}
|
||||||
@@ -86,8 +111,15 @@ export function directionCosine(a: StarRecord, b: StarRecord): number {
|
|||||||
return Math.max(-1, Math.min(1, ax * bx + ay * by + az * bz));
|
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. */
|
/**
|
||||||
|
* Whether two entries describe the same star: same direction, and neither distance nor
|
||||||
|
* brightness in conflict.
|
||||||
|
*/
|
||||||
export function isSameStar(a: StarRecord, b: StarRecord): boolean {
|
export function isSameStar(a: StarRecord, b: StarRecord): boolean {
|
||||||
|
if (Math.abs(a.magnitude - b.magnitude) > MERGE_MAGNITUDE_TOLERANCE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const [near, far] = [distanceOf(a), distanceOf(b)].sort((p, q) => p - q);
|
const [near, far] = [distanceOf(a), distanceOf(b)].sort((p, q) => p - q);
|
||||||
|
|
||||||
// The Sun sits at the origin of this coordinate system and so has no direction at all, which
|
// The Sun sits at the origin of this coordinate system and so has no direction at all, which
|
||||||
@@ -105,17 +137,37 @@ export function isSameStar(a: StarRecord, b: StarRecord): boolean {
|
|||||||
return (far - near) / near <= MERGE_DISTANCE_RATIO_TOLERANCE;
|
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.
|
* 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
|
* Sources are taken in order of how precisely they measure parallax, best first. An entry that a
|
||||||
* only added if no better-measured catalogue already has it. So where Gaia and Hipparcos
|
* better-measured catalogue already has is folded into that entry — the nearest one within the
|
||||||
* overlap, the position is Gaia's; where only Hipparcos reaches, the star is still there.
|
* 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 } {
|
export function mergeStarCatalogues(candidates: readonly MergeCandidate[]): { stars: StarRecord[]; summary: MergeSummary } {
|
||||||
const ordered = [...candidates].sort((a, b) => a.parallaxPrecisionMas - b.parallaxPrecisionMas);
|
const ordered = [...candidates].sort((a, b) => a.parallaxPrecisionMas - b.parallaxPrecisionMas);
|
||||||
const merged: StarRecord[] = [];
|
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> = {};
|
const bySource: Record<string, number> = {};
|
||||||
let duplicates = 0;
|
let duplicates = 0;
|
||||||
|
|
||||||
@@ -123,24 +175,41 @@ export function mergeStarCatalogues(candidates: readonly MergeCandidate[]): { st
|
|||||||
bySource[candidate.sourceId] = 0;
|
bySource[candidate.sourceId] = 0;
|
||||||
|
|
||||||
for (const star of candidate.stars) {
|
for (const star of candidate.stars) {
|
||||||
const { raDeg, decDeg } = skyAngles(star);
|
const entry: StarRecord = { ...star, source: star.source ?? candidate.sourceId };
|
||||||
const alreadyPresent = neighbouringCells(raDeg, decDeg).some((key) => (grid.get(key) ?? []).some((existing) => isSameStar(existing, star)));
|
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++;
|
duplicates++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const withSource: StarRecord = { ...star, source: star.source ?? candidate.sourceId };
|
const index = merged.push(entry) - 1;
|
||||||
merged.push(withSource);
|
|
||||||
bySource[candidate.sourceId]++;
|
bySource[candidate.sourceId]++;
|
||||||
|
|
||||||
const key = cellKey(raDeg, decDeg);
|
const key = cellKey(raDeg, decDeg);
|
||||||
const cell = grid.get(key);
|
const cell = grid.get(key);
|
||||||
if (cell) {
|
if (cell) {
|
||||||
cell.push(withSource);
|
cell.push(index);
|
||||||
} else {
|
} 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.
+13
-10
@@ -1,6 +1,5 @@
|
|||||||
import { writeFileSync } from 'node:fs';
|
import { writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
import { raDecDistanceToXyz } from '../../src/app/shared/astro/coordinates';
|
|
||||||
import { mergeStarCatalogues } from '../../src/app/shared/astro/star-merge';
|
import { mergeStarCatalogues } from '../../src/app/shared/astro/star-merge';
|
||||||
import { encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
|
import { encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
|
||||||
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
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
|
* Downloads the HYG (Hipparcos/Yale/Gliese) stellar database, takes each star's equatorial
|
||||||
* RA/Dec/distance into galaxy-scale Cartesian coordinates (parsecs), filters by distance,
|
* Cartesian position (parsecs, epoch J2000.0), filters by distance, unions the other positional
|
||||||
* and writes `stars.bin` (packed positions) + `stars-index.json` (everything else).
|
* sources, and writes `stars.bin` (packed positions) + `stars-index.json` (everything else).
|
||||||
*/
|
*/
|
||||||
export async function fetchStars(): Promise<StarRecord[]> {
|
export async function fetchStars(): Promise<StarRecord[]> {
|
||||||
console.log(`Fetching HYG star catalog (distance cutoff: ${DISTANCE_CUTOFF_PC} pc)...`);
|
console.log(`Fetching HYG star catalog (distance cutoff: ${DISTANCE_CUTOFF_PC} pc)...`);
|
||||||
@@ -83,14 +82,18 @@ export async function fetchStars(): Promise<StarRecord[]> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const raHours = Number(row['ra']);
|
// HYG's own Cartesian columns rather than its `ra`/`dec`, which are in the same frame as
|
||||||
const decDeg = Number(row['dec']);
|
// `raDecDistanceToXyz` and would be redundant if the two agreed. They do not, for the stars
|
||||||
if (!Number.isFinite(raHours) || !Number.isFinite(decDeg)) {
|
// 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.
|
||||||
|
const x = Number(row['x']);
|
||||||
|
const y = Number(row['y']);
|
||||||
|
const z = Number(row['z']);
|
||||||
|
if (![x, y, z].every(Number.isFinite)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { x, y, z } = raDecDistanceToXyz(raHours, decDeg, distancePc);
|
|
||||||
|
|
||||||
stars.push({
|
stars.push({
|
||||||
id,
|
id,
|
||||||
name: resolveName(row),
|
name: resolveName(row),
|
||||||
@@ -143,7 +146,7 @@ async function mergeWithOtherSources(hygStars: StarRecord[]): Promise<StarRecord
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { stars, summary } = mergeStarCatalogues(candidates);
|
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)) {
|
for (const [sourceId, count] of Object.entries(summary.bySource)) {
|
||||||
console.log(` ${sourceId}: ${count}`);
|
console.log(` ${sourceId}: ${count}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { raDegDecDistanceToXyz } from '../../../src/app/shared/astro/coordinates';
|
import { propagateProperMotion, raDegDecDistanceToXyz } from '../../../src/app/shared/astro/coordinates';
|
||||||
import { StarRecord } from '../../../src/app/shared/models/star.model';
|
import { StarRecord } from '../../../src/app/shared/models/star.model';
|
||||||
import { parseCsvObjects, parseOptionalNumber } from '../lib/csv';
|
import { parseCsvObjects, parseOptionalNumber } from '../lib/csv';
|
||||||
import { fetchTextCached } from '../lib/http';
|
import { fetchTextCached } from '../lib/http';
|
||||||
@@ -11,14 +11,24 @@ import { fetchTextCached } from '../lib/http';
|
|||||||
* Galaxy — no catalogue is close to the rest — but within a few hundred parsecs it is complete
|
* 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.
|
* 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
|
* Written blind against the published DR3 schema, since no ESA endpoint was reachable from the
|
||||||
* the environment this was written in, so the query below is written against the published DR3
|
* environment it was written in; first run for real by the scheduled refresh of 2026-08-24, which
|
||||||
* schema and has not been executed against it. Treat the column names as the first thing to
|
* fetched 412 765 rows.
|
||||||
* check if a real run misbehaves.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const GAIA_TAP_URL = 'https://gea.esac.esa.int/tap-server/tap/sync';
|
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.
|
* How far out to take Gaia, in parsecs, and the faintest star to keep.
|
||||||
*
|
*
|
||||||
@@ -44,7 +54,7 @@ function parallaxFloorMas(distancePc: number): number {
|
|||||||
function buildQuery(): string {
|
function buildQuery(): string {
|
||||||
return [
|
return [
|
||||||
`select top ${ROW_LIMIT}`,
|
`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',
|
'from gaiadr3.gaia_source',
|
||||||
`where parallax > ${parallaxFloorMas(DISTANCE_CUTOFF_PC).toFixed(6)}`,
|
`where parallax > ${parallaxFloorMas(DISTANCE_CUTOFF_PC).toFixed(6)}`,
|
||||||
`and parallax_over_error > ${(1 / MAX_PARALLAX_ERROR_RATIO).toFixed(1)}`,
|
`and parallax_over_error > ${(1 / MAX_PARALLAX_ERROR_RATIO).toFixed(1)}`,
|
||||||
@@ -72,7 +82,9 @@ export async function fetchGaiaStars(): Promise<StarRecord[]> {
|
|||||||
const url = `${GAIA_TAP_URL}?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&QUERY=${encodeURIComponent(buildQuery())}`;
|
const url = `${GAIA_TAP_URL}?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&QUERY=${encodeURIComponent(buildQuery())}`;
|
||||||
console.log(`Fetching Gaia DR3 (within ${DISTANCE_CUTOFF_PC} pc, G < ${MAGNITUDE_LIMIT}, at most ${ROW_LIMIT} rows)...`);
|
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');
|
// The cache key names the columns, so a response cached before proper motions were asked for
|
||||||
|
// cannot be mistaken for one that has them.
|
||||||
|
const csv = await fetchTextCached(url, 'gaia-dr3-pm.csv');
|
||||||
const rows = parseCsvObjects(csv);
|
const rows = parseCsvObjects(csv);
|
||||||
const stars: StarRecord[] = [];
|
const stars: StarRecord[] = [];
|
||||||
|
|
||||||
@@ -89,7 +101,8 @@ export async function fetchGaiaStars(): Promise<StarRecord[]> {
|
|||||||
return;
|
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({
|
stars.push({
|
||||||
id: GAIA_ID_BASE + index,
|
id: GAIA_ID_BASE + index,
|
||||||
name: `Gaia DR3 ${row['source_id']}`,
|
name: `Gaia DR3 ${row['source_id']}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user