Say which band each star was measured in, whose catalogue it is, and how sure its distance is

The readout named Hipparcos, Yale and Gliese while 378 775 of the 455 608 stars are described by
Gaia DR3, printed one "Magnitude" for G and V alike, and gave every distance to the parsec. The
band cannot be read off the star's source, which records whose position it has: 62 002 stars Gaia
places keep HYG's V and B-V, and the 575 hosts renamed after their planets are Gaia's, in G.

stars-meta.bin gains two byte columns, 14 to 16 bytes a star (6 378 512 to 7 289 728 bytes; gzip
-9 2 804 049 to 3 110 991). One holds the magnitude's band (V 76 555 stars, G 378 744, none 309,
whose magnitude is a stand-in), which colour the colour index is (B-V 75 203, BP-RP 376 703), and
whether the distance is Gaia's parallax. The other holds the distance's relative error as its
square root in 255ths: a step is 0.08 % of distance at 1 %, 0.35 % at 20 %, and 100 % is the top.
encode, decode, BYTES_PER_STAR_META and the build.ts round trip cover both; no workflow reads the
format.

Where the errors come from:
- Gaia rows keep the parallax_error their query already fetched: median 0.3 %, 90th percentile
  1.2 %, at most 20 %, the query's own cut.
- A HYG star at Gaia's distance takes the cross-match's parallax_over_error, and a star merged
  into a Gaia entry keeps that entry's error with its position.
- The 3 067 Hipparcos stars that keep their Hipparcos distance, Rigel, Deneb and Alnilam among
  them, take e_plx from van Leeuwen's 2007 reduction: a new cached query of
  public.hipparcos_newreduction on the ESA archive, whose 117 955 rows HYG's distances invert.
- The archive's stars take sy_disterr1/2 from pscomppars, in a query and cache file of their own
  so the composite rows already cached were not refetched.
- 439 distances have no published error: 357 Gliese rows and 82 archive hosts.

Of the errors, 392 786 are 1 % or less and are not printed; 61 156 print as "117 ± 12 pc" to the
distance's own digits; 1 196 between 20 and 100 %, and 30 past it, print as the range the
parallax gives, since a symmetric error in parallax is a lopsided one in distance.

The star card (measured on the dev server) now reads, for example:
- Rigel: 265 ± 23 pc, V 0.18, B-V -0.03, source HYG.
- Deneb: 433 ± 60 pc.
- Alnilam: "476 pc to 833 pc" (Hipparcos 1.65 ± 0.45 mas).
- Gaia DR3 5612323414549657984: 111 ± 2 pc, G 4.63, BP-RP -0.15, source Gaia DR3.
- Proxima Centauri: 1.30 pc, V 11.01, source "HYG, Gaia DR3 distance".
- TRAPPIST-1: G 15.62, BP-RP 4.90, source Gaia DR3.
- Kepler-186: V 15.14, source NASA Exoplanet Archive.
The neighbourhood's subtitle reads "Gaia DR3 378,775 · HYG 73,556 · NASA Exoplanet Archive
3,277", counted by the catalogue describing each star.

build.ts validateStars now fails a catalogue with more than 1 000 stars without a band (309
today) or without a distance error (439). Dropping G from the Gaia rows gave 379 040 without a
band, and dropping their parallax_error gave 441 216 without an error; both runs failed.

Decoding the catalogue in Node took a median 29 ms before and 24 ms after (nine runs each, within
noise). In the app, five cold boots gave a 654-786 ms long task after the data landed and the
HUD at 1.83-2.07 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 23:26:27 +02:00
co-authored by Claude Opus 5.5
parent 4c8e4a02f3
commit a81dd491ad
15 changed files with 450 additions and 45 deletions
@@ -46,7 +46,7 @@ import {
systemFramingDistanceAu,
systemViewDirection,
} from './system-framing';
import { formatAu, formatLuminosity, formatParsecs } from '../../shared/format/quantity';
import { formatAu, formatParsecs } from '../../shared/format/quantity';
import {
distanceRings,
formatRoundLength,
@@ -83,6 +83,7 @@ import { JumpLinkRenderer } from './jump-link-renderer';
import { ReservedBox, ringPlacement } from './label-ring';
import { LabeledPoint, LabelSide, StarLabelOverlay } from './star-label-overlay';
import { SystemOrbitsRenderer } from './system-orbits-renderer';
import { catalogueCensus, starReadouts } from './star-readouts';
/** HYG catalog id for the Sun itself — the only star we have a real close-up photo of. */
const SOL_STAR_ID = 0;
@@ -532,6 +533,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private galacticStrength = 0;
private labelOverlay?: StarLabelOverlay;
private stars: readonly StarRecord[] = [];
/** The neighbourhood's subtitle: what the catalogue holds, by the catalogue describing it. */
private catalogueCensus = '';
private starsById = new Map<number, StarRecord>();
private bodies: readonly BodyRecord[] = [];
private exoplanets: readonly ExoplanetRecord[] = [];
@@ -679,6 +682,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
}),
]);
this.stars = stars;
this.catalogueCensus = catalogueCensus(stars);
this.starsById = new Map(stars.map((star) => [star.id, star]));
this.neighbourhood = new StarNeighbourhood(stars);
this.routing = new RoutingClient(stars, positions, this.neighbourhood);
@@ -1742,8 +1746,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const moonCount = this.bodies.filter(
(body) => body.systemStarId === star.id && body.parentBodyId,
).length;
const distancePc = Math.hypot(star.x, star.y, star.z);
const luminosity = luminosityOf(star);
this.hudEyebrow.set('System');
this.hudTitle.set(star.name);
this.hudSubtitle.set(star.spectralType ? `Spectral type ${star.spectralType}` : '');
@@ -1752,13 +1754,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
label: 'Bodies',
value: moonCount > 0 ? `${planetCount} + ${moonCount} moons` : `${planetCount}`,
},
// Suppressed for the Sun rather than printed as `0.00 pc`, which is arithmetically right
// and reads as a bug: the distance from here to here is not a measurement.
...(distancePc > 0 ? [{ label: 'Distance', value: formatParsecs(distancePc) }] : []),
{ label: 'Magnitude', value: star.magnitude.toFixed(2) },
...(luminosity !== null
? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }]
: []),
...starReadouts(star, luminosityOf(star)),
]);
this.hudNote.set(this.time.atNow() ? 'Orbits propagated from published elements to the current date.' : 'Orbits propagated from published elements to the date on the clock.');
this.hudRange.set(
@@ -1796,7 +1792,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.hudEyebrow.set('Solar Neighbourhood');
this.hudTitle.set('Local Stars');
this.hudSubtitle.set('Hipparcos · Yale Bright Star · Gliese');
this.hudSubtitle.set(this.catalogueCensus);
this.hudReadouts.set([
// Both numbers, because they differ: the catalogue is what the map knows and the first is
// what it draws. See `STAR_RENDER_BUDGET`.
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import { StarRecord } from '../../shared/models/star.model';
import { catalogueCensus, describingCatalogue, starReadouts } from './star-readouts';
/** Three kinds of star the catalogue holds, each as the decoder gives it back. */
const HYG_STAR: StarRecord = {
id: 32263,
name: 'Sirius',
x: -0.49,
y: 2.47,
z: -0.75,
magnitude: -1.44,
magnitudeBand: 'V',
spectralType: 'A0m',
colorIndex: 0.009,
colorSystem: 'B-V',
distanceError: 0.004,
distanceFromGaia: false,
source: 'hyg'
};
// A star Gaia alone knows, at 117 pc and good to a tenth.
const GAIA_STAR: StarRecord = {
id: 1000000001,
name: 'Gaia DR3 5612323414549657984',
x: 117,
y: 0,
z: 0,
magnitude: 11.2,
magnitudeBand: 'G',
spectralType: 'Unknown',
colorIndex: 1.43,
colorSystem: 'BP-RP',
distanceError: 0.1,
distanceFromGaia: true,
source: 'gaia'
};
// HYG's description of Proxima, at the place and distance Gaia measures.
const PLACED_BY_GAIA: StarRecord = { ...HYG_STAR, name: 'Proxima Centauri', magnitude: 11.01, colorIndex: 1.807, spectralType: 'M5Ve', distanceFromGaia: true, source: 'gaia' };
function value(readouts: { label: string; value: string }[], label: string): string | undefined {
return readouts.find((readout) => readout.label === label)?.value;
}
describe('starReadouts', () => {
it('names the band of the magnitude, and which colour the colour index is', () => {
expect(value(starReadouts(HYG_STAR, null), 'Magnitude')).toBe('V -1.44');
expect(value(starReadouts(GAIA_STAR, null), 'Magnitude')).toBe('G 11.20');
expect(value(starReadouts(HYG_STAR, null), 'Colour')).toBe('B−V 0.01');
expect(value(starReadouts(GAIA_STAR, null), 'Colour')).toBe('BP−RP 1.43');
});
it('says a stand-in magnitude was not measured, and leaves out a colour there is none of', () => {
const unmeasured = starReadouts({ ...GAIA_STAR, magnitude: 12, magnitudeBand: undefined, colorIndex: null, colorSystem: undefined }, null);
expect(value(unmeasured, 'Magnitude')).toBe('Not measured');
expect(value(unmeasured, 'Colour')).toBeUndefined();
});
it('gives the distance with its uncertainty', () => {
expect(value(starReadouts(GAIA_STAR, null), 'Distance')).toBe('117 ± 12 pc');
});
it('names the catalogue a star comes from, and whose distance it has', () => {
expect(value(starReadouts(GAIA_STAR, null), 'Source')).toBe('Gaia DR3');
expect(value(starReadouts(HYG_STAR, null), 'Source')).toBe('HYG');
expect(value(starReadouts(PLACED_BY_GAIA, null), 'Source')).toBe('HYG, Gaia DR3 distance');
expect(value(starReadouts({ ...HYG_STAR, source: 'exoplanet-archive' }, null), 'Source')).toBe('NASA Exoplanet Archive');
});
it('marks the luminosity as derived', () => {
expect(starReadouts(HYG_STAR, 25.4).find((readout) => readout.label === 'Luminosity')).toEqual({ label: 'Luminosity', value: '25.40 L☉', derived: true });
});
});
describe('catalogueCensus', () => {
it('counts the stars by the catalogue describing them, not by whose position they have', () => {
expect(describingCatalogue(PLACED_BY_GAIA)).toBe('HYG');
expect(catalogueCensus([GAIA_STAR, GAIA_STAR, GAIA_STAR, HYG_STAR, PLACED_BY_GAIA])).toBe('Gaia DR3 3 · HYG 2');
});
});
@@ -0,0 +1,52 @@
import { formatDistance, formatLuminosity } from '../../shared/format/quantity';
import { StarRecord } from '../../shared/models/star.model';
import { HudReadout } from '../hud/hud-dock.component';
/**
* The catalogue that describes a star — its name, type and photometry — as the readout names it.
*
* Not the same as `source`, which records whose *position* the star has: 62 002 stars HYG
* describes sit where Gaia places them, and carry `gaia`. What gives them away is their V
* magnitude, which only HYG and the archive measure and the archive's stars have their own source.
*/
export function describingCatalogue(star: StarRecord): string {
if (star.source === 'exoplanet-archive') {
return 'NASA Exoplanet Archive';
}
return star.source === 'gaia' && star.magnitudeBand !== 'V' ? 'Gaia DR3' : 'HYG';
}
/**
* A star's measured readouts, each with what it was measured in: the band of its magnitude, which
* colour its colour index is, the distance's uncertainty, and the catalogues they come from.
* `luminosity` is derived, and marked so.
*/
export function starReadouts(star: StarRecord, luminosity: number | null): HudReadout[] {
const distancePc = Math.hypot(star.x, star.y, star.z);
const catalogue = describingCatalogue(star);
return [
// Suppressed for the Sun rather than printed as `0.00 pc`, which is arithmetically right
// and reads as a bug: the distance from here to here is not a measurement.
...(distancePc > 0 ? [{ label: 'Distance', value: formatDistance(distancePc, star.distanceError) }] : []),
// A G magnitude and a V one are not comparable: a red dwarf is up to three brighter in G.
{ label: 'Magnitude', value: star.magnitudeBand ? `${star.magnitudeBand} ${star.magnitude.toFixed(2)}` : 'Not measured' },
...(star.colorIndex !== null
? [{ label: 'Colour', value: `${star.colorSystem === 'BP-RP' ? 'BP−RP' : 'B−V'} ${star.colorIndex.toFixed(2)}` }]
: []),
...(luminosity !== null ? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }] : []),
{ label: 'Source', value: catalogue === 'HYG' && star.distanceFromGaia ? 'HYG, Gaia DR3 distance' : catalogue }
];
}
/** What the catalogue holds, counted by the catalogue describing each star, largest first. */
export function catalogueCensus(stars: readonly StarRecord[]): string {
const counts = new Map<string, number>();
for (const star of stars) {
const catalogue = describingCatalogue(star);
counts.set(catalogue, (counts.get(catalogue) ?? 0) + 1);
}
return [...counts]
.sort((a, b) => b[1] - a[1])
.map(([catalogue, count]) => `${catalogue} ${count.toLocaleString('en-GB')}`)
.join(' · ');
}
+27
View File
@@ -157,6 +157,33 @@ describe('mergeStarCatalogues', () => {
expect(summary.duplicates).toBe(1);
});
it("keeps the distance's error with the distance, and the photometry with the description", () => {
// Proxima's parallax is 768.07 ± 0.05 mas in Gaia and 768.13 ± 1.04 in Hipparcos: the star is
// drawn at Gaia's, so the error it is drawn with is Gaia's, while V 11.01 and B−V 1.81 stay HYG's.
const hyg = at(70666, 217.4289, -62.6795, 1.2959, {
name: 'Proxima Centauri',
magnitude: 11.01,
magnitudeBand: 'V',
colorIndex: 1.807,
colorSystem: 'B-V',
distanceError: 0.00135,
distanceFromGaia: false
});
const gaia = at(1000064182, 217.4289, -62.6795, 1.302, {
name: 'Gaia DR3 5853498713190525696',
magnitude: 8.985,
magnitudeBand: 'G',
colorIndex: 3.805,
colorSystem: 'BP-RP',
distanceError: 0.000065,
distanceFromGaia: true,
source: 'gaia'
});
const [merged] = mergeStarCatalogues([{ ...HIPPARCOS, stars: [hyg] }, { ...GAIA, stars: [gaia] }]).stars;
expect(merged).toMatchObject({ magnitude: 11.01, magnitudeBand: 'V', colorIndex: 1.807, colorSystem: 'B-V', distanceError: 0.000065, distanceFromGaia: true });
});
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.
+12 -3
View File
@@ -222,12 +222,21 @@ export function isSameStar(kept: StarRecord, entry: StarRecord): boolean {
* 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.
* travels with the description, so a star HYG knows keeps its HYG id from one refresh to the next,
* and so does its photometry, V and B−V. `source` stays with the position, since that is what it
* records, and so does the distance's error: Gaia's, not the Hipparcos one of a distance dropped.
*/
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 };
return {
...described,
x: kept.x,
y: kept.y,
z: kept.z,
source: kept.source,
distanceError: kept.distanceError,
distanceFromGaia: kept.distanceFromGaia
};
}
/**
+21 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { formatAu, formatDensity, formatLuminosity, formatMassEarth, formatParsecs, formatPeriod, formatRadiusKm, formatTemperature } from './quantity';
import { formatAu, formatDensity, formatDistance, formatLuminosity, formatMassEarth, formatParsecs, formatPeriod, formatRadiusKm, formatTemperature } from './quantity';
describe('formatParsecs', () => {
it('switches to kiloparsecs past a thousand parsecs', () => {
@@ -13,6 +13,26 @@ describe('formatParsecs', () => {
});
});
describe('formatDistance', () => {
it('gives the error to the digits the distance is shown to', () => {
expect(formatDistance(117.3, 0.1)).toBe('117 ± 12 pc');
expect(formatDistance(4.2, 0.05)).toBe('4.20 ± 0.21 pc');
expect(formatDistance(1830, 0.15)).toBe('1.8 ± 0.3 kpc');
});
it('leaves off an error of a per cent or less, or one too small to show', () => {
expect(formatDistance(117.3, 0.01)).toBe('117 pc');
expect(formatDistance(12, 0.03)).toBe('12 pc');
expect(formatDistance(117.3, undefined)).toBe('117 pc');
});
it("gives a range once the error is a fifth of the parallax, and no upper end past all of it", () => {
// Alnilam: 1.65 ± 0.45 mas in Hipparcos.
expect(formatDistance(606.06, 0.45 / 1.65)).toBe('476 pc to 833 pc');
expect(formatDistance(250, 1)).toBe('125 pc or more');
});
});
describe('formatAu', () => {
it('holds four decimals for close-in orbits', () => {
// 0.0026 AU is a real published semi-major axis; two decimals would render it — and every
+29 -1
View File
@@ -9,7 +9,35 @@
/** Distance in parsecs, switching to kiloparsecs where the number would otherwise run long. */
export function formatParsecs(distancePc: number): string {
return distancePc >= 1000 ? `${(distancePc / 1000).toFixed(1)} kpc` : `${distancePc.toFixed(distancePc < 10 ? 2 : 0)} pc`;
const { divisor, digits, unit } = parsecScale(distancePc);
return `${(distancePc / divisor).toFixed(digits)} ${unit}`;
}
function parsecScale(distancePc: number): { divisor: number; digits: number; unit: string } {
return distancePc >= 1000 ? { divisor: 1000, digits: 1, unit: 'kpc' } : { divisor: 1, digits: distancePc < 10 ? 2 : 0, unit: 'pc' };
}
/**
* A star's distance with its uncertainty, given as a fraction of it: `117 ± 12 pc`, to the
* digits the distance itself is shown to. Left off where it is 1 % or less, or would round to
* nothing at those digits, since the figure is then already as good as it reads.
*
* Past a fifth, a range: the distance is the inverse of a parallax, so the parallax's symmetric
* error bar is a lopsided one in distance — Alnilam's 1.65 ± 0.45 mas is 476 to 833 pc, not
* 606 ± 165. Only a Hipparcos or archive distance gets there; Gaia's query stops at a fifth. An
* error as large as the parallax leaves no upper bound at all.
*/
export function formatDistance(distancePc: number, relativeError: number | undefined): string {
if (relativeError === undefined || relativeError <= 0.01) {
return formatParsecs(distancePc);
}
if (relativeError < 0.2) {
const { divisor, digits, unit } = parsecScale(distancePc);
const error = ((distancePc * relativeError) / divisor).toFixed(digits);
return Number(error) === 0 ? formatParsecs(distancePc) : `${(distancePc / divisor).toFixed(digits)} ± ${error} ${unit}`;
}
const nearest = formatParsecs(distancePc / (1 + relativeError));
return relativeError >= 1 ? `${nearest} or more` : `${nearest} to ${formatParsecs(distancePc / (1 - relativeError))}`;
}
/** Distance in astronomical units, for anything inside a system. */
@@ -97,6 +97,37 @@ describe('encodeStarCatalog / decodeStarCatalog', () => {
});
describe('the photometry and distance error columns', () => {
const MEASURED: StarRecord[] = [
{ id: 1, name: 'Sirius', x: 1, y: 0, z: 0, magnitude: -1.44, magnitudeBand: 'V', spectralType: 'A0m', colorIndex: 0.009, colorSystem: 'B-V', distanceError: 0.0036, source: 'hyg' },
{ id: 2, name: 'Gaia DR3 2', x: 0, y: 117, z: 0, magnitude: 11.2, magnitudeBand: 'G', spectralType: 'Unknown', colorIndex: 1.43, colorSystem: 'BP-RP', distanceError: 0.199, distanceFromGaia: true, source: 'gaia' },
// A HYG star at Gaia's distance, and one no survey gave a magnitude, a colour or an error.
{ id: 3, name: 'HD 3', x: 0, y: 0, z: 300, magnitude: 7, magnitudeBand: 'V', spectralType: 'K0', colorIndex: 1.0, colorSystem: 'B-V', distanceError: 0.000001, distanceFromGaia: true, source: 'hyg' },
{ id: 4, name: 'Gaia DR3 4', x: 5, y: 5, z: 0, magnitude: 12, spectralType: 'Unknown', colorIndex: null, distanceFromGaia: true, source: 'gaia' },
// A Hipparcos parallax smaller than its own error.
{ id: 5, name: 'HIP 5', x: 0, y: 200, z: 0, magnitude: 6, magnitudeBand: 'V', spectralType: 'B8', colorIndex: -0.1, colorSystem: 'B-V', distanceError: 1.4, source: 'hyg' }
];
const encoded = encodeStarCatalog(MEASURED);
const decoded = decodeStarCatalog(encoded.index, encoded.positions, encoded.meta);
it('carries the band, which colour the colour index is, and whose parallax the distance is', () => {
expect(decoded.map((star) => star.magnitudeBand)).toEqual(['V', 'G', 'V', undefined, 'V']);
expect(decoded.map((star) => star.colorSystem)).toEqual(['B-V', 'BP-RP', 'B-V', undefined, 'B-V']);
expect(decoded.map((star) => star.distanceFromGaia)).toEqual([false, true, true, true, false]);
});
it('keeps a distance error to within a step at both ends of its range, and none as none', () => {
// A step is 0.05 % of distance at Sirius's 0.36 %, and 0.35 % at the 20 % Gaia's cut allows.
expect(Math.abs(decoded[0].distanceError! - 0.0036)).toBeLessThan(0.0003);
expect(Math.abs(decoded[1].distanceError! - 0.199)).toBeLessThan(0.002);
// Too small to round to a step, but published, so not read back as unpublished.
expect(decoded[2].distanceError).toBeGreaterThan(0);
expect(decoded[3].distanceError).toBeUndefined();
// Past the parallax itself there is no upper bound on the distance, which is what 100 % says.
expect(decoded[4].distanceError).toBe(1);
});
});
describe('star catalogue provenance and derived names', () => {
const MIXED: StarRecord[] = [
{ id: 5, name: 'Sirius', x: 1, y: 0, z: 0, magnitude: -1.4, spectralType: 'A0', colorIndex: 0.0, source: 'hyg' },
+52 -6
View File
@@ -26,13 +26,38 @@ export const STAR_POSITION_COMPONENTS = 3;
export const BYTES_PER_STAR_POSITION = STAR_POSITION_COMPONENTS * Float32Array.BYTES_PER_ELEMENT;
/**
* Columns in `stars-meta.bin`, in order: catalogue id, apparent magnitude, colour index, and an
* index into the spectral-type dictionary. Stored column by column rather than record by record
* so each one is a single typed-array view over the buffer, with no per-record stride or
* alignment padding.
* Columns in `stars-meta.bin`, in order: catalogue id, apparent magnitude, colour index, an
* index into the spectral-type dictionary, what those were measured in (see {@link PHOTOMETRY}),
* and the distance's relative error (see {@link DISTANCE_ERROR_STEPS}). Stored column by column
* rather than record by record so each one is a single typed-array view over the buffer, with no
* per-record stride or alignment padding.
*/
export const BYTES_PER_STAR_META =
Int32Array.BYTES_PER_ELEMENT + Float32Array.BYTES_PER_ELEMENT + Float32Array.BYTES_PER_ELEMENT + Uint16Array.BYTES_PER_ELEMENT;
Int32Array.BYTES_PER_ELEMENT +
Float32Array.BYTES_PER_ELEMENT +
Float32Array.BYTES_PER_ELEMENT +
Uint16Array.BYTES_PER_ELEMENT +
Uint8Array.BYTES_PER_ELEMENT +
Uint8Array.BYTES_PER_ELEMENT;
/**
* Bits of the photometry column. The band takes two: none (a stand-in magnitude), V or G. None
* of it follows from the source, which records where the *position* came from: 62 002 stars
* Gaia places keep HYG's V and B−V, and the archive's stars in G have a B−V from their
* temperature. The last bit says whose parallax the distance is, which for a HYG star Gaia did
* not place can still be Gaia's.
*/
const PHOTOMETRY = { bandV: 1, bandG: 2, bandMask: 3, colorBpRp: 4, distanceFromGaia: 8 } as const;
/**
* The distance error column holds the square root of the relative error, in 255ths, and 0 where
* none was published. The errors span three orders of magnitude — Gaia's are a median 0.3 % and
* at most 20 %, the cut its queries make, while a Hipparcos parallax the map keeps for a bright
* star can be as large as itself — and the square root keeps a step small at each end: 0.08 % of
* distance at 1 %, 0.35 % at 20 %, under 1 % at 100 %. Anything past 100 % is stored as that,
* where it no longer bounds the distance from above.
*/
const DISTANCE_ERROR_STEPS = 255;
/** `stars-index.json`: everything that is a string, plus the count the columns are sized by. */
export interface StarCatalogIndex {
@@ -81,6 +106,8 @@ interface StarMetaColumns {
magnitudes: Float32Array;
colorIndices: Float32Array;
spectralTypeIndices: Uint16Array;
photometry: Uint8Array;
distanceErrors: Uint8Array;
}
/** Lays typed-array views over the meta buffer at the offsets the format defines. */
@@ -93,8 +120,12 @@ function metaColumns(buffer: ArrayBuffer, count: number): StarMetaColumns {
const colorIndices = new Float32Array(buffer, offset, count);
offset += count * Float32Array.BYTES_PER_ELEMENT;
const spectralTypeIndices = new Uint16Array(buffer, offset, count);
offset += count * Uint16Array.BYTES_PER_ELEMENT;
const photometry = new Uint8Array(buffer, offset, count);
offset += count * Uint8Array.BYTES_PER_ELEMENT;
const distanceErrors = new Uint8Array(buffer, offset, count);
return { ids, magnitudes, colorIndices, spectralTypeIndices };
return { ids, magnitudes, colorIndices, spectralTypeIndices, photometry, distanceErrors };
}
/**
@@ -152,6 +183,13 @@ export function encodeStarCatalog(stars: readonly StarRecord[]): {
columns.magnitudes[index] = star.magnitude;
columns.colorIndices[index] = star.colorIndex ?? Number.NaN;
columns.spectralTypeIndices[index] = spectralTypeId;
columns.photometry[index] =
(star.magnitudeBand === 'V' ? PHOTOMETRY.bandV : star.magnitudeBand === 'G' ? PHOTOMETRY.bandG : 0) |
(star.colorSystem === 'BP-RP' ? PHOTOMETRY.colorBpRp : 0) |
(star.distanceFromGaia ? PHOTOMETRY.distanceFromGaia : 0);
// At least one step, so an error too small to round to one is not read back as none published.
columns.distanceErrors[index] =
star.distanceError === undefined ? 0 : Math.max(1, Math.round(Math.sqrt(Math.min(1, star.distanceError)) * DISTANCE_ERROR_STEPS));
});
// A per-star column is only worth writing when the stars actually differ.
@@ -186,6 +224,9 @@ export function decodeStarCatalog(index: StarCatalogIndex, positions: Float32Arr
const id = columns.ids[i];
const sourceIndex = index.sourceIndices.length > 0 ? index.sourceIndices[i] : index.sources.length === 1 ? 0 : -1;
const source = index.sources[sourceIndex];
const photometry = columns.photometry[i];
const band = photometry & PHOTOMETRY.bandMask;
const distanceError = columns.distanceErrors[i];
stars[i] = {
id,
@@ -196,6 +237,11 @@ export function decodeStarCatalog(index: StarCatalogIndex, positions: Float32Arr
magnitude: columns.magnitudes[i],
spectralType: index.spectralTypes[columns.spectralTypeIndices[i]],
colorIndex: Number.isNaN(colorIndex) ? null : colorIndex,
// Set on every record, if only to undefined, so that all of them have the one shape.
magnitudeBand: band === PHOTOMETRY.bandV ? 'V' : band === PHOTOMETRY.bandG ? 'G' : undefined,
colorSystem: Number.isNaN(colorIndex) ? undefined : photometry & PHOTOMETRY.colorBpRp ? 'BP-RP' : 'B-V',
distanceError: distanceError === 0 ? undefined : (distanceError / DISTANCE_ERROR_STEPS) ** 2,
distanceFromGaia: (photometry & PHOTOMETRY.distanceFromGaia) !== 0,
...(source ? { source: source.id } : {})
};
}
+23 -3
View File
@@ -1,5 +1,6 @@
/**
* A single star from the HYG (Hipparcos/Yale/Gliese) catalog, positioned relative to the
* A single star from Gaia DR3, HYG (Hipparcos/Yale/Gliese) or the NASA Exoplanet Archive — see
* `source` — positioned relative to the
* Sun in the galaxy-scale coordinate system (parsecs). The same positions are also packed
* into a compact binary buffer (`stars.bin`, in index order) for fast bulk rendering; this
* record format (`stars-index.json`) is used for search, labels, and lookups by id/name.
@@ -13,13 +14,32 @@ export interface StarRecord {
magnitude: number;
spectralType: string;
/**
* B-V colour index, or `null` where the catalog has no photometry — about 10% of stars
* within the distance cutoff. Deliberately nullable rather than defaulted: `0` is a real,
* Colour index — B-V, or Gaia's BP-RP where `colorSystem` says so — or `null` where the
* catalog has no photometry. Deliberately nullable rather than defaulted: `0` is a real,
* meaningful colour index (a hot blue-white A-type star), so using it to stand for "unknown"
* silently mis-colours those stars. Consumers resolve the gap from `spectralType`; see
* `colorIndexToRgb`.
*/
colorIndex: number | null;
/**
* The band `magnitude` was measured in: Johnson V (HYG, and the archive where it has one) or
* Gaia's G, which for a red dwarf reads up to three magnitudes brighter than V. Absent where no
* survey measured the star and `magnitude` is the ETL's stand-in.
*/
magnitudeBand?: 'V' | 'G';
/**
* Which colour `colorIndex` is: Johnson B−V, or Gaia's BP−RP, which is larger for the same star
* — 0.82 against 0.65 for a G2 dwarf like the Sun, 3.35 against 1.83 for an M5 dwarf (Pecaut &
* Mamajek). Absent with it.
*/
colorSystem?: 'B-V' | 'BP-RP';
/**
* Relative uncertainty of the distance, σd/d: the relative error of the parallax it was
* inverted from, which to first order is the same. Absent where none was published.
*/
distanceError?: number;
/** Whether the distance is Gaia DR3's parallax, whichever catalogue describes the star. */
distanceFromGaia?: boolean;
/**
* Which catalogue this star's position came from, once more than one contributes. Absent for a
* single-source build; see `star-merge.ts`, where overlapping catalogues are reconciled and
Binary file not shown.
+30
View File
@@ -20,6 +20,15 @@ function assertCondition(condition: boolean, message: string): void {
}
}
/**
* Stars whose magnitude is a stand-in, and distances published without an error. Measured 309 and
* 439: the 44 Gaia sources with no G and the 265 archive hosts with neither V nor G; the 357 Gliese
* distances (no Hipparcos parallax behind them) and 82 archive hosts the archive gives no error
* for. Losing either field loses it for hundreds of thousands of stars.
*/
const MAX_STARS_WITHOUT_BAND = 1_000;
const MAX_STARS_WITHOUT_DISTANCE_ERROR = 1_000;
function validateStars(stars: StarRecord[]): void {
assertCondition(stars.length > 0, 'No stars were produced.');
@@ -49,7 +58,28 @@ function validateStars(stars: StarRecord[]): void {
assertCondition(decoded[i].id === stars[i].id && decoded[i].name === stars[i].name, `Star catalogue round-trip altered record ${i}.`);
assertCondition(decoded[i].spectralType === stars[i].spectralType, `Star catalogue round-trip lost the spectral type of star ${stars[i].id}.`);
assertCondition(decoded[i].colorIndex === null === (stars[i].colorIndex === null), `Star catalogue round-trip changed whether star ${stars[i].id} has a colour index.`);
assertCondition(
decoded[i].magnitudeBand === stars[i].magnitudeBand && decoded[i].colorSystem === stars[i].colorSystem && decoded[i].distanceFromGaia === !!stars[i].distanceFromGaia,
`Star catalogue round-trip changed the photometry of star ${stars[i].id}.`
);
// Stored as its square root in 255ths, up to 100 %; see `star-catalog.ts`.
const error = stars[i].distanceError;
assertCondition(
error === undefined ? decoded[i].distanceError === undefined : Math.abs(Math.sqrt(decoded[i].distanceError!) - Math.sqrt(Math.min(1, error))) <= 0.5 / 255 + 1e-9,
`Star catalogue round-trip changed the distance error of star ${stars[i].id}.`
);
}
// What each star says it was measured in. A band or an error dropped on the way still encodes,
// decodes and draws; it shows only as a card reading "Not measured" for a star that was.
const withoutBand = stars.filter((star) => star.magnitudeBand === undefined).length;
assertCondition(withoutBand <= MAX_STARS_WITHOUT_BAND, `${withoutBand} stars have no magnitude band (at most ${MAX_STARS_WITHOUT_BAND} expected) — the band is being lost.`);
const withoutError = stars.filter((star) => star.id !== SUN_STAR_ID && star.distanceError === undefined).length;
assertCondition(
withoutError <= MAX_STARS_WITHOUT_DISTANCE_ERROR,
`${withoutError} stars have no distance error (at most ${MAX_STARS_WITHOUT_DISTANCE_ERROR} expected) — the parallax errors are being lost.`
);
console.log(` ${withoutBand} stars with a stand-in magnitude; ${withoutError} distances without a published error.`);
}
/**
+21 -5
View File
@@ -77,6 +77,13 @@ const COMPOSITE_COLUMNS = [
// pl_name is unique here too, one row per planet.
const COMPOSITE_URL = `${TAP_BASE_URL}?query=select+${COMPOSITE_COLUMNS}+from+pscomppars+order+by+pl_name&format=csv`;
const COMPOSITE_CACHE_FILE = `exoplanet-archive-pscomppars-${createHash('sha1').update(COMPOSITE_URL).digest('hex').slice(0, 8)}.csv`;
/**
* The same table's distance errors, for the stars placed from the archive. A query of its own,
* cached apart, so that asking for them did not refetch the columns above: the archive changes
* daily, and a new answer would have moved every figure the catalogue was checked against.
*/
const DISTANCE_ERRORS_URL = `${TAP_BASE_URL}?query=select+pl_name,sy_disterr1,sy_disterr2+from+pscomppars+order+by+pl_name&format=csv`;
const DISTANCE_ERRORS_CACHE_FILE = `exoplanet-archive-disterr-${createHash('sha1').update(DISTANCE_ERRORS_URL).digest('hex').slice(0, 8)}.csv`;
/**
* Where the ids of the stars only the archive places begin: past Gaia's two ranges, and under
@@ -109,6 +116,7 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<{ exoplanet
const csv = await fetchTextCached(TAP_URL, CACHE_FILE);
const rows = parseCsvObjects(csv);
const composite = new Map(parseCsvObjects(await fetchTextCached(COMPOSITE_URL, COMPOSITE_CACHE_FILE)).map((row) => [row['pl_name'], row]));
const distanceErrors = new Map(parseCsvObjects(await fetchTextCached(DISTANCE_ERRORS_URL, DISTANCE_ERRORS_CACHE_FILE)).map((row) => [row['pl_name'], row]));
let matched = 0;
// Catalogue stars known only by their Gaia designation, which take the archive's host name —
@@ -144,21 +152,29 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<{ exoplanet
// Carried back from the archive's epoch like any Gaia row, and placed at `sy_dist`.
const j2000 = propagateProperMotion(raDeg, decDeg, pmRaMasPerYear ?? 0, pmDecMasPerYear ?? 0, ARCHIVE_TO_CATALOGUE_YEARS);
// V where the archive has it, as HYG's stars are; else Gaia's G, the band the catalogue's
// Gaia stars are already in. The assets carry no band, so it is counted below instead.
// Gaia stars are already in.
const v = host('sy_vmag');
const g = host('sy_gaiamag');
const b = host('sy_bmag');
const temperatureK = host('st_teff');
bands[v !== undefined ? 'V' : g !== undefined ? 'G' : 'none']++;
const band = v !== undefined ? 'V' : g !== undefined ? 'G' : undefined;
bands[band ?? 'none']++;
// B-V where the archive has both magnitudes, else the effective temperature's; with
// neither, null leaves the colour to the spectral type, as for any other star.
const colorIndex = b !== undefined && v !== undefined ? b - v : temperatureK !== undefined ? temperatureToColorIndex(temperatureK) : null;
// The archive gives the distance's error as two one-sided ones; their mean, relative.
const errors = distanceErrors.get(row['pl_name']);
const [above, below] = [parseOptionalNumber(errors?.['sy_disterr1']), parseOptionalNumber(errors?.['sy_disterr2'])];
archiveStar = {
id: ARCHIVE_ID_BASE + archiveStars.size,
name: row['hostname'],
...raDegDecDistanceToXyz(j2000.raDeg, j2000.decDeg, distancePc),
magnitude: v ?? g ?? UNKNOWN_MAGNITUDE,
...(band === undefined ? {} : { magnitudeBand: band }),
spectralType: compositeRow?.['st_spectype'] || 'Unknown',
// B-V where the archive has both magnitudes, else the effective temperature's; with
// neither, null leaves the colour to the spectral type, as for any other star.
colorIndex: b !== undefined && v !== undefined ? b - v : temperatureK !== undefined ? temperatureToColorIndex(temperatureK) : null,
colorIndex,
...(colorIndex === null ? {} : { colorSystem: 'B-V' as const }),
...(above === undefined || below === undefined ? {} : { distanceError: (Math.abs(above) + Math.abs(below)) / 2 / distancePc }),
source: ARCHIVE_SOURCE
};
archiveStars.set(row['hostname'], archiveStar);
+18 -6
View File
@@ -3,7 +3,7 @@ import { writeFileSync } from 'node:fs';
import { mergeStarCatalogues, placementDistancePc } 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';
import { fetchGaiaDistancesByHip, GaiaAnswerError } from './sources/gaia';
import { fetchGaiaDistancesByHip, fetchHipparcosParallaxErrors, GaiaAnswerError } from './sources/gaia';
import { positionalSources } from './sources/registry';
import { PARALLAX_PRECISION_MAS } from './sources/star-sources';
import { parseCsvObjects, parseOptionalNumber } from './lib/csv';
@@ -71,7 +71,8 @@ export async function fetchStars(): Promise<StarRecord[]> {
const csv = await fetchTextCached(HYG_CSV_URL, 'hygdata_v41.csv');
const rows = parseCsvObjects(csv);
// Not skipped when unreachable, unlike the positional sources below; see its own comment.
const gaiaPcByHip = await fetchGaiaDistancesByHip();
const gaiaByHip = await fetchGaiaDistancesByHip();
const hipparcosErrors = await fetchHipparcosParallaxErrors();
const stars: StarRecord[] = [];
let atGaiaDistance = 0;
@@ -81,14 +82,16 @@ export async function fetchStars(): Promise<StarRecord[]> {
const id = Number(row['id']);
if (id === SUN_STAR_ID) {
stars.push({ id, name: 'Sol', x: 0, y: 0, z: 0, magnitude: parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE, spectralType: row['spect'] || 'G2V', colorIndex: parseOptionalNumber(row['ci']) ?? null });
stars.push({ id, name: 'Sol', x: 0, y: 0, z: 0, magnitude: parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE, magnitudeBand: 'V', spectralType: row['spect'] || 'G2V', colorIndex: parseOptionalNumber(row['ci']) ?? null, colorSystem: 'B-V' });
continue;
}
const hygPc = Number(row['dist']);
const hipparcosPc = Number.isFinite(hygPc) && hygPc > 0 && hygPc < HYG_UNKNOWN_DISTANCE_PC ? hygPc : undefined;
const gaiaPc = row['hip'] ? gaiaPcByHip.get(Number(row['hip'])) : undefined;
const magnitude = parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE;
const gaia = row['hip'] ? gaiaByHip.get(Number(row['hip'])) : undefined;
const gaiaPc = gaia?.distancePc;
const magnitudeV = parseOptionalNumber(row['mag']);
const magnitude = magnitudeV ?? UNKNOWN_MAGNITUDE;
const distancePc = placementDistancePc(hipparcosPc, gaiaPc, magnitude, DISTANCE_CUTOFF_PC);
if (distancePc === null) {
continue;
@@ -119,6 +122,11 @@ export async function fetchStars(): Promise<StarRecord[]> {
pastCutoff++;
}
// Gaia's error with Gaia's distance, Hipparcos's with its own; a Gliese row, with neither, has
// no published error, and its distance is as often photometric as measured.
const distanceError = gaia?.relativeError ?? (row['hip'] ? hipparcosErrors.get(Number(row['hip'])) : undefined);
const colorIndex = parseOptionalNumber(row['ci']);
stars.push({
id,
name: resolveName(row),
@@ -126,8 +134,12 @@ export async function fetchStars(): Promise<StarRecord[]> {
y: y * scale,
z: z * scale,
magnitude,
...(magnitudeV === undefined ? {} : { magnitudeBand: 'V' as const }),
spectralType: row['spect'] || 'Unknown',
colorIndex: parseOptionalNumber(row['ci']) ?? null,
colorIndex: colorIndex ?? null,
...(colorIndex === undefined ? {} : { colorSystem: 'B-V' as const }),
...(distanceError === undefined ? {} : { distanceError }),
distanceFromGaia: gaia !== undefined,
// Only for the Gliese-only rows, whose positions are what the merge needs the motion to see
// past. A Hipparcos position is good to under an arcsecond; given its motion too, 15 stars
// took their co-moving companion's Gaia entry, and the companion was kept twice.
+47 -9
View File
@@ -133,9 +133,9 @@ const MIN_ROW_SHARE = 0.95;
export class GaiaAnswerError extends Error {}
/**
* Gaia publishes no spectral classifications, but `bp_rp` is a colour index on the same footing
* as HYG's `ci` — so the app's existing colour and spectral-class handling works unchanged, and
* the spectral type is left as unknown rather than invented from the colour.
* Gaia publishes no spectral classifications. Its `bp_rp` is a colour index, though not HYG's
* B−V — `colorSystem` says which — and the spectral type is left as unknown rather than
* invented from it.
*/
const UNKNOWN_SPECTRAL_TYPE = 'Unknown';
@@ -209,10 +209,13 @@ function rowsToStars(rows: readonly Record<string, string>[], idBase: number): S
return;
}
const parallaxErrorMas = parseOptionalNumber(row['parallax_error']);
const pmRaMasYr = parseOptionalNumber(row['pmra']);
const pmDecMasYr = parseOptionalNumber(row['pmdec']);
const j2000 = propagateProperMotion(raDeg, decDeg, pmRaMasYr ?? 0, pmDecMasYr ?? 0, CATALOGUE_EPOCH - GAIA_DR3_EPOCH);
const { x, y, z } = raDegDecDistanceToXyz(j2000.raDeg, j2000.decDeg, distancePc);
const magnitudeG = parseOptionalNumber(row['phot_g_mean_mag']);
const colorIndex = parseOptionalNumber(row['bp_rp']);
stars.push({
id: idBase + index,
name: `Gaia DR3 ${row['source_id']}`,
@@ -222,9 +225,13 @@ function rowsToStars(rows: readonly Record<string, string>[], idBase: number): S
// Only the nearby query has sources without a G, 45 of them, and they are given its limit.
// That errs bright: 26 of the 31 that 2MASS measured are at J 12.6–15.7, fainter still in G
// for stars this red, and 5 at J 7–8.
magnitude: parseOptionalNumber(row['phot_g_mean_mag']) ?? MAGNITUDE_LIMIT,
magnitude: magnitudeG ?? MAGNITUDE_LIMIT,
...(magnitudeG === undefined ? {} : { magnitudeBand: 'G' as const }),
spectralType: UNKNOWN_SPECTRAL_TYPE,
colorIndex: parseOptionalNumber(row['bp_rp']) ?? null,
colorIndex: colorIndex ?? null,
...(colorIndex === undefined ? {} : { colorSystem: 'BP-RP' as const }),
...(parallaxErrorMas === undefined ? {} : { distanceError: parallaxErrorMas / parallaxMas }),
distanceFromGaia: true,
source: 'gaia',
pmRaMasYr,
pmDecMasYr
@@ -243,7 +250,8 @@ function rowsToStars(rows: readonly Record<string, string>[], idBase: number): S
const MIN_USABLE_HIP_DISTANCES = 90_000;
/**
* Gaia's distance for every Hipparcos star it has a usable parallax for, keyed by HIP number.
* Gaia's distance for every Hipparcos star it has a usable parallax for, and that distance's
* relative error, keyed by HIP number.
*
* Taken from the archive's own cross-match (`hipparcos2_best_neighbour`) rather than from
* matching positions here, since Gaia's team made that identification star by star with the
@@ -255,7 +263,7 @@ const MIN_USABLE_HIP_DISTANCES = 90_000;
* distance, the 6 833 that Gaia puts past 250 pc move back inside, and the published map would
* flip between the two with the archive's availability.
*/
export async function fetchGaiaDistancesByHip(): Promise<Map<number, number>> {
export async function fetchGaiaDistancesByHip(): Promise<Map<number, { distancePc: number; relativeError: number }>> {
const query = [
'select top 200000 b.original_ext_source_id as hip, g.parallax, g.parallax_over_error',
'from gaiadr3.hipparcos2_best_neighbour b join gaiadr3.gaia_source g on g.source_id = b.source_id'
@@ -264,13 +272,13 @@ export async function fetchGaiaDistancesByHip(): Promise<Map<number, number>> {
console.log('Fetching Gaia DR3 distances for Hipparcos stars (archive cross-match)...');
const rows = parseCsvObjects(await fetchTextCached(url, `gaia-dr3-hip-${createHash('sha1').update(url).digest('hex').slice(0, 8)}.csv`));
const distances = new Map<number, number>();
const distances = new Map<number, { distancePc: number; relativeError: number }>();
for (const row of rows) {
const hip = parseOptionalNumber(row['hip']);
const parallaxMas = parseOptionalNumber(row['parallax']);
const overError = parseOptionalNumber(row['parallax_over_error']);
if (hip !== undefined && parallaxMas !== undefined && parallaxMas > 0 && overError !== undefined && overError > 1 / MAX_PARALLAX_ERROR_RATIO) {
distances.set(hip, 1000 / parallaxMas);
distances.set(hip, { distancePc: 1000 / parallaxMas, relativeError: 1 / overError });
}
}
if (distances.size < MIN_USABLE_HIP_DISTANCES) {
@@ -282,3 +290,33 @@ export async function fetchGaiaDistancesByHip(): Promise<Map<number, number>> {
console.log(` ${distances.size} Hipparcos stars have a Gaia distance (of ${rows.length} cross-matched).`);
return distances;
}
/** The new Hipparcos reduction holds 117 955 stars, and every one has a parallax error. */
const MIN_HIPPARCOS_ERRORS = 110_000;
/**
* The relative error of every Hipparcos parallax, keyed by HIP number, for the stars that keep
* their Hipparcos distance: 3 067 of them, Rigel and Deneb among them, where Gaia saturates.
*
* From van Leeuwen's 2007 reduction, which the ESA archive hosts beside Gaia and HYG's distances
* are the inverse of. HYG publishes the distance and not its error.
*/
export async function fetchHipparcosParallaxErrors(): Promise<Map<number, number>> {
const url = `${GAIA_TAP_URL}?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&QUERY=${encodeURIComponent('select top 200000 hip, plx, e_plx from public.hipparcos_newreduction order by hip')}`;
console.log('Fetching Hipparcos parallax errors (new reduction)...');
const rows = parseCsvObjects(await fetchTextCached(url, `hipparcos-errors-${createHash('sha1').update(url).digest('hex').slice(0, 8)}.csv`));
const errors = new Map<number, number>();
for (const row of rows) {
const hip = parseOptionalNumber(row['hip']);
const parallaxMas = parseOptionalNumber(row['plx']);
const errorMas = parseOptionalNumber(row['e_plx']);
if (hip !== undefined && parallaxMas !== undefined && parallaxMas > 0 && errorMas !== undefined) {
errors.set(hip, errorMas / parallaxMas);
}
}
if (errors.size < MIN_HIPPARCOS_ERRORS) {
throw new Error(`the Hipparcos reduction gave ${errors.size} parallax errors (of ${rows.length} rows), not the ~117 955 it holds; delete tools/etl/.cache/hipparcos-errors-*.csv once the archive answers properly`);
}
return errors;
}