diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 78454f9..59428cf 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -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(); 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`. diff --git a/src/app/features/galaxy-system/star-readouts.spec.ts b/src/app/features/galaxy-system/star-readouts.spec.ts new file mode 100644 index 0000000..96024a6 --- /dev/null +++ b/src/app/features/galaxy-system/star-readouts.spec.ts @@ -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'); + }); +}); diff --git a/src/app/features/galaxy-system/star-readouts.ts b/src/app/features/galaxy-system/star-readouts.ts new file mode 100644 index 0000000..47b8a5a --- /dev/null +++ b/src/app/features/galaxy-system/star-readouts.ts @@ -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(); + 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(' · '); +} diff --git a/src/app/shared/astro/star-merge.spec.ts b/src/app/shared/astro/star-merge.spec.ts index 073d74a..9132a86 100644 --- a/src/app/shared/astro/star-merge.spec.ts +++ b/src/app/shared/astro/star-merge.spec.ts @@ -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. diff --git a/src/app/shared/astro/star-merge.ts b/src/app/shared/astro/star-merge.ts index aa38862..95592e2 100644 --- a/src/app/shared/astro/star-merge.ts +++ b/src/app/shared/astro/star-merge.ts @@ -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 + }; } /** diff --git a/src/app/shared/format/quantity.spec.ts b/src/app/shared/format/quantity.spec.ts index 3195cdb..5d60584 100644 --- a/src/app/shared/format/quantity.spec.ts +++ b/src/app/shared/format/quantity.spec.ts @@ -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 diff --git a/src/app/shared/format/quantity.ts b/src/app/shared/format/quantity.ts index 3ba8403..0b785d4 100644 --- a/src/app/shared/format/quantity.ts +++ b/src/app/shared/format/quantity.ts @@ -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. */ diff --git a/src/app/shared/models/star-catalog.spec.ts b/src/app/shared/models/star-catalog.spec.ts index fadb1e5..f085ce6 100644 --- a/src/app/shared/models/star-catalog.spec.ts +++ b/src/app/shared/models/star-catalog.spec.ts @@ -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' }, diff --git a/src/app/shared/models/star-catalog.ts b/src/app/shared/models/star-catalog.ts index cac8a00..0972a10 100644 --- a/src/app/shared/models/star-catalog.ts +++ b/src/app/shared/models/star-catalog.ts @@ -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 } : {}) }; } diff --git a/src/app/shared/models/star.model.ts b/src/app/shared/models/star.model.ts index ed95acb..a3f0ad9 100644 --- a/src/app/shared/models/star.model.ts +++ b/src/app/shared/models/star.model.ts @@ -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 diff --git a/src/assets/data/stars-meta.bin b/src/assets/data/stars-meta.bin index 5006e81..09eb0dd 100644 Binary files a/src/assets/data/stars-meta.bin and b/src/assets/data/stars-meta.bin differ diff --git a/tools/etl/build.ts b/tools/etl/build.ts index bc4a5ce..eab46c5 100644 --- a/tools/etl/build.ts +++ b/tools/etl/build.ts @@ -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.`); } /** diff --git a/tools/etl/fetchExoplanets.ts b/tools/etl/fetchExoplanets.ts index c56754c..ac8884f 100644 --- a/tools/etl/fetchExoplanets.ts +++ b/tools/etl/fetchExoplanets.ts @@ -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); diff --git a/tools/etl/fetchStars.ts b/tools/etl/fetchStars.ts index 47f8912..ef3d3af 100644 --- a/tools/etl/fetchStars.ts +++ b/tools/etl/fetchStars.ts @@ -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 { 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 { 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 { 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 { 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. diff --git a/tools/etl/sources/gaia.ts b/tools/etl/sources/gaia.ts index 86a7b62..6dfde19 100644 --- a/tools/etl/sources/gaia.ts +++ b/tools/etl/sources/gaia.ts @@ -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[], 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[], 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[], 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> { +export async function fetchGaiaDistancesByHip(): Promise> { 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> { 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(); + const distances = new Map(); 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> { 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> { + 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(); + 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; +}