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
229 lines
8.4 KiB
TypeScript
229 lines
8.4 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
distanceBetween,
|
|
eclipticToEquatorial,
|
|
equatorialToEcliptic,
|
|
OBLIQUITY_J2000_DEG,
|
|
parallaxMasToParsecs,
|
|
parseSexagesimal,
|
|
propagateProperMotion,
|
|
raDecDistanceToXyz,
|
|
raDecToUnitVector,
|
|
raDegDecDistanceToXyz
|
|
} from './coordinates';
|
|
|
|
// Reference values taken directly from the HYG v4.1 database (RA/Dec/dist and its own
|
|
// precomputed x/y/z, which uses the same equatorial-Cartesian convention we implement).
|
|
describe('raDecDistanceToXyz', () => {
|
|
it('matches the HYG reference position for Sirius', () => {
|
|
const result = raDecDistanceToXyz(6.752481, -16.716116, 2.6371);
|
|
|
|
expect(result.x).toBeCloseTo(-0.494323, 3);
|
|
expect(result.y).toBeCloseTo(2.476731, 3);
|
|
expect(result.z).toBeCloseTo(-0.758485, 3);
|
|
});
|
|
|
|
it('matches the HYG reference position for Proxima Centauri', () => {
|
|
const result = raDecDistanceToXyz(14.495985, -62.679485, 1.2959);
|
|
|
|
expect(result.x).toBeCloseTo(-0.472264, 3);
|
|
expect(result.y).toBeCloseTo(-0.361451, 3);
|
|
expect(result.z).toBeCloseTo(-1.151219, 3);
|
|
});
|
|
|
|
it('places a star on RA 6h / Dec 0 entirely on the +Y axis', () => {
|
|
const result = raDecDistanceToXyz(6, 0, 10);
|
|
|
|
expect(result.x).toBeCloseTo(0, 9);
|
|
expect(result.y).toBeCloseTo(10, 9);
|
|
expect(result.z).toBeCloseTo(0, 9);
|
|
});
|
|
|
|
it('places the vernal equinox direction entirely on the +X axis', () => {
|
|
const result = raDecDistanceToXyz(0, 0, 10);
|
|
|
|
expect(result.x).toBeCloseTo(10, 9);
|
|
expect(result.y).toBeCloseTo(0, 9);
|
|
expect(result.z).toBeCloseTo(0, 9);
|
|
});
|
|
});
|
|
|
|
describe('raDegDecDistanceToXyz', () => {
|
|
it('is equivalent to raDecDistanceToXyz with RA converted from degrees to hours', () => {
|
|
const fromHours = raDecDistanceToXyz(6.752481, -16.716116, 2.6371);
|
|
const fromDegrees = raDegDecDistanceToXyz(6.752481 * 15, -16.716116, 2.6371);
|
|
|
|
expect(fromDegrees.x).toBeCloseTo(fromHours.x, 9);
|
|
expect(fromDegrees.y).toBeCloseTo(fromHours.y, 9);
|
|
expect(fromDegrees.z).toBeCloseTo(fromHours.z, 9);
|
|
});
|
|
});
|
|
|
|
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
|
|
});
|
|
|
|
it('returns Infinity for zero or negative parallax', () => {
|
|
expect(parallaxMasToParsecs(0)).toBe(Infinity);
|
|
expect(parallaxMasToParsecs(-5)).toBe(Infinity);
|
|
});
|
|
});
|
|
|
|
describe('distanceBetween', () => {
|
|
it('computes the Euclidean distance between two points', () => {
|
|
expect(distanceBetween({ x: 0, y: 0, z: 0 }, { x: 3, y: 4, z: 0 })).toBeCloseTo(5, 9);
|
|
});
|
|
});
|
|
|
|
describe('raDecToUnitVector', () => {
|
|
it('always returns a unit-length vector', () => {
|
|
for (const [ra, dec] of [
|
|
[0, 0],
|
|
[6, 45],
|
|
[13.7, -62.7],
|
|
[23.99, 89.9]
|
|
]) {
|
|
const { x, y, z } = raDecToUnitVector(ra, dec);
|
|
expect(Math.hypot(x, y, z)).toBeCloseTo(1, 12);
|
|
}
|
|
});
|
|
|
|
it('points along +Z at the north celestial pole', () => {
|
|
const { x, y, z } = raDecToUnitVector(0, 90);
|
|
expect(x).toBeCloseTo(0, 12);
|
|
expect(y).toBeCloseTo(0, 12);
|
|
expect(z).toBeCloseTo(1, 12);
|
|
});
|
|
|
|
it('agrees with the distance-carrying conversion, scaled', () => {
|
|
const unit = raDecToUnitVector(6.752481, -16.716116);
|
|
const scaled = raDecDistanceToXyz(6.752481, -16.716116, 2.6371);
|
|
|
|
expect(unit.x * 2.6371).toBeCloseTo(scaled.x, 12);
|
|
expect(unit.y * 2.6371).toBeCloseTo(scaled.y, 12);
|
|
expect(unit.z * 2.6371).toBeCloseTo(scaled.z, 12);
|
|
});
|
|
});
|
|
|
|
describe('parseSexagesimal', () => {
|
|
it('parses a right ascension into decimal hours', () => {
|
|
// 00:08:27.05 = 8/60 + 27.05/3600 hours
|
|
expect(parseSexagesimal('00:08:27.05')).toBeCloseTo(0.140847, 6);
|
|
});
|
|
|
|
it('parses a positive declination into decimal degrees', () => {
|
|
expect(parseSexagesimal('+27:43:03.6')).toBeCloseTo(27.7176667, 6);
|
|
});
|
|
|
|
it('parses a negative declination', () => {
|
|
expect(parseSexagesimal('-12:49:22.3')).toBeCloseTo(-12.8228611, 6);
|
|
});
|
|
|
|
it('keeps the sign for a negative angle inside the first degree', () => {
|
|
// The trap: `Number('-00')` is `-0`, which is `=== 0`, so a naive implementation flips
|
|
// this object into the northern hemisphere.
|
|
const parsed = parseSexagesimal('-00:24:54.8');
|
|
expect(parsed).toBeLessThan(0);
|
|
expect(parsed).toBeCloseTo(-0.4152222, 6);
|
|
});
|
|
|
|
it('treats an unsigned angle as positive', () => {
|
|
expect(parseSexagesimal('00:24:54.8')).toBeCloseTo(0.4152222, 6);
|
|
});
|
|
|
|
it('tolerates surrounding whitespace', () => {
|
|
expect(parseSexagesimal(' +27:43:03.6 ')).toBeCloseTo(27.7176667, 6);
|
|
});
|
|
|
|
it('returns null for missing or malformed values', () => {
|
|
for (const input of ['', ' ', 'not-an-angle', '12:34', '12:34:56:78', '12;34;56', undefined, null]) {
|
|
expect(parseSexagesimal(input)).toBeNull();
|
|
}
|
|
});
|
|
|
|
it('returns null rather than a partial value for empty sub-fields', () => {
|
|
expect(parseSexagesimal('12::56')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('eclipticToEquatorial', () => {
|
|
const RAD = Math.PI / 180;
|
|
|
|
it('leaves the vernal equinox untouched, since both frames share that axis', () => {
|
|
// +X is where the ecliptic crosses the celestial equator, so it is the rotation axis.
|
|
expect(eclipticToEquatorial({ x: 1, y: 0, z: 0 })).toEqual({ x: 1, y: 0, z: 0 });
|
|
});
|
|
|
|
it('puts the ecliptic pole the obliquity away from the celestial pole', () => {
|
|
const pole = eclipticToEquatorial({ x: 0, y: 0, z: 1 });
|
|
const angleFromCelestialPoleDeg = Math.acos(pole.z) / RAD;
|
|
|
|
expect(angleFromCelestialPoleDeg).toBeCloseTo(OBLIQUITY_J2000_DEG, 9);
|
|
expect(pole.x).toBeCloseTo(0, 12);
|
|
expect(pole.y).toBeCloseTo(-Math.sin(OBLIQUITY_J2000_DEG * RAD), 12);
|
|
});
|
|
|
|
it('places the summer solstice point at the obliquity in declination', () => {
|
|
// Ecliptic longitude 90 degrees is the northernmost point of the Sun's yearly path, whose
|
|
// declination is by definition the obliquity — about 23.4 degrees.
|
|
const solstice = eclipticToEquatorial({ x: 0, y: 1, z: 0 });
|
|
const declinationDeg = Math.asin(solstice.z) / RAD;
|
|
|
|
expect(declinationDeg).toBeCloseTo(OBLIQUITY_J2000_DEG, 9);
|
|
});
|
|
|
|
it('preserves length, being a rotation', () => {
|
|
const rotated = eclipticToEquatorial({ x: 0.3, y: -0.5, z: 0.81 });
|
|
expect(Math.hypot(rotated.x, rotated.y, rotated.z)).toBeCloseTo(Math.hypot(0.3, -0.5, 0.81), 12);
|
|
});
|
|
|
|
it('leaves a point in the ecliptic plane in that plane, tilted out of the equator', () => {
|
|
const inPlane = eclipticToEquatorial({ x: 0.6, y: 0.8, z: 0 });
|
|
expect(inPlane.z).toBeCloseTo(0.8 * Math.sin(OBLIQUITY_J2000_DEG * RAD), 12);
|
|
});
|
|
});
|
|
|
|
describe('equatorialToEcliptic', () => {
|
|
it('is the exact inverse of eclipticToEquatorial', () => {
|
|
for (const point of [
|
|
{ x: 1, y: 0, z: 0 },
|
|
{ x: 0, y: 1, z: 0 },
|
|
{ x: 0, y: 0, z: 1 },
|
|
{ x: -0.37, y: 0.42, z: 0.83 }
|
|
]) {
|
|
const round = equatorialToEcliptic(eclipticToEquatorial(point));
|
|
expect(round.x).toBeCloseTo(point.x, 12);
|
|
expect(round.y).toBeCloseTo(point.y, 12);
|
|
expect(round.z).toBeCloseTo(point.z, 12);
|
|
}
|
|
});
|
|
|
|
it('brings the celestial pole back to the obliquity off the ecliptic pole', () => {
|
|
const pole = equatorialToEcliptic({ x: 0, y: 0, z: 1 });
|
|
expect(Math.acos(pole.z) / (Math.PI / 180)).toBeCloseTo(OBLIQUITY_J2000_DEG, 9);
|
|
});
|
|
});
|