From 5333be615ee336012225f5a7392532b4c9f12925 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Thu, 24 Sep 2026 23:28:29 +0200 Subject: [PATCH] Estimate a spectral type from each star's colour where no catalogue gives one, and say so 383 695 of the 455 608 stars read "Spectral type Unknown", every Gaia star among them, although 380 884 of them carry a colour. The card now gives those the type of the dwarf whose colour is nearest, in the colour's own system, marked as an estimate: TRAPPIST-1, BP-RP 4.90, reads "Spectral type ~M8, from colour", which is how it was classified (M8 V). A star with neither a type nor a colour gets no subtitle rather than the literal "Unknown". The colours are Pecaut & Mamajek's mean dwarf sequence (2013, ApJS 208, 9, table 5), as Mamajek maintains it online (version 2022.04.16, which carries Gaia BP-RP), from B0 to M8.5. B-V cannot tell O types apart (the whole sequence spans 0.03 of it), and BP-RP turns back past M8.5 and is tabulated only from B9. A colour outside the table gets no estimate: 186 BP-RP and 41 B-V colours, among them the blue Gaia DR3 5612323414549657984 at BP-RP -0.15. 380 657 stars get an estimate: F 97 840, G 144 327, K 105 984, M 29 142, A 3 272, B 92. Checked against catalogued types: - 19 433 HYG dwarfs with a B-V: 74.3 % within two subclasses, 95.5 % within five. - 146 Gaia exoplanet hosts the archive types as dwarfs, from BP-RP: 88.4 % within two subclasses, 98.6 % within five. The estimate assumes a dwarf, so a giant reads later than it is (Pollux, K0 III at B-V 0.99, reads K3). It also ignores reddening. The comment says both. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../galaxy-system-scene.component.ts | 4 +- .../galaxy-system/star-readouts.spec.ts | 17 ++++++- .../features/galaxy-system/star-readouts.ts | 14 ++++++ src/app/shared/astro/spectral.spec.ts | 30 ++++++++++- src/app/shared/astro/spectral.ts | 50 +++++++++++++++++++ tools/etl/sources/gaia.ts | 2 +- 6 files changed, 112 insertions(+), 5 deletions(-) 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 59428cf..2d46871 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -83,7 +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'; +import { catalogueCensus, starReadouts, starSubtitle } 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; @@ -1748,7 +1748,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { ).length; this.hudEyebrow.set('System'); this.hudTitle.set(star.name); - this.hudSubtitle.set(star.spectralType ? `Spectral type ${star.spectralType}` : ''); + this.hudSubtitle.set(starSubtitle(star)); this.hudReadouts.set([ { label: 'Bodies', diff --git a/src/app/features/galaxy-system/star-readouts.spec.ts b/src/app/features/galaxy-system/star-readouts.spec.ts index 96024a6..50bcc38 100644 --- a/src/app/features/galaxy-system/star-readouts.spec.ts +++ b/src/app/features/galaxy-system/star-readouts.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { StarRecord } from '../../shared/models/star.model'; -import { catalogueCensus, describingCatalogue, starReadouts } from './star-readouts'; +import { catalogueCensus, describingCatalogue, starReadouts, starSubtitle } from './star-readouts'; /** Three kinds of star the catalogue holds, each as the decoder gives it back. */ const HYG_STAR: StarRecord = { @@ -72,6 +72,21 @@ describe('starReadouts', () => { }); }); +describe('starSubtitle', () => { + it("prints the catalogue's classification where it has one", () => { + expect(starSubtitle(HYG_STAR)).toBe('Spectral type A0m'); + }); + + it("estimates one from the colour otherwise, in the colour's own system, and says so", () => { + // 1.43 is a K5 dwarf in BP−RP; read as B−V it would be an M0. + expect(starSubtitle(GAIA_STAR)).toBe('Spectral type ~K5, from colour'); + }); + + it('prints nothing rather than "Unknown" when there is neither', () => { + expect(starSubtitle({ ...GAIA_STAR, colorIndex: null, colorSystem: undefined })).toBe(''); + }); +}); + describe('catalogueCensus', () => { it('counts the stars by the catalogue describing them, not by whose position they have', () => { expect(describingCatalogue(PLACED_BY_GAIA)).toBe('HYG'); diff --git a/src/app/features/galaxy-system/star-readouts.ts b/src/app/features/galaxy-system/star-readouts.ts index 47b8a5a..0c347ea 100644 --- a/src/app/features/galaxy-system/star-readouts.ts +++ b/src/app/features/galaxy-system/star-readouts.ts @@ -1,3 +1,4 @@ +import { spectralTypeFromColor } from '../../shared/astro/spectral'; import { formatDistance, formatLuminosity } from '../../shared/format/quantity'; import { StarRecord } from '../../shared/models/star.model'; import { HudReadout } from '../hud/hud-dock.component'; @@ -16,6 +17,19 @@ export function describingCatalogue(star: StarRecord): string { return star.source === 'gaia' && star.magnitudeBand !== 'V' ? 'Gaia DR3' : 'HYG'; } +/** + * What the readout says a star is: the catalogue's classification, or — for the 83 % of stars + * that have none, every Gaia star among them — the dwarf type its colour matches, marked as an + * estimate. Empty with neither, rather than the ETL's literal "Unknown". + */ +export function starSubtitle(star: StarRecord): string { + if (star.spectralType && star.spectralType !== 'Unknown') { + return `Spectral type ${star.spectralType}`; + } + const estimate = spectralTypeFromColor(star.colorIndex, star.colorSystem); + return estimate ? `Spectral type ~${estimate}, from colour` : ''; +} + /** * 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. diff --git a/src/app/shared/astro/spectral.spec.ts b/src/app/shared/astro/spectral.spec.ts index eb7f2b8..b1dcf22 100644 --- a/src/app/shared/astro/spectral.spec.ts +++ b/src/app/shared/astro/spectral.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { parseSpectralClass, SPECTRAL_CLASSES, spectralTypeToColorIndex, temperatureToColorIndex } from './spectral'; +import { parseSpectralClass, SPECTRAL_CLASSES, spectralTypeFromColor, spectralTypeToColorIndex, temperatureToColorIndex } from './spectral'; describe('parseSpectralClass', () => { it('reads a clean class and subclass', () => { @@ -100,3 +100,31 @@ describe('temperatureToColorIndex', () => { expect(temperatureToColorIndex(Number.NaN)).toBeNull(); }); }); + +describe('spectralTypeFromColor', () => { + it("reads the Sun's type off either colour", () => { + expect(spectralTypeFromColor(0.65, 'B-V')).toBe('G2'); + expect(spectralTypeFromColor(0.82, 'BP-RP')).toBe('G2'); + }); + + it('reads a red dwarf the way it was classified', () => { + // TRAPPIST-1 is M8 V, and Gaia has it at BP−RP 4.90; Proxima is M5.5 Ve at B−V 1.81. + expect(spectralTypeFromColor(4.902, 'BP-RP')).toBe('M8'); + expect(spectralTypeFromColor(1.807, 'B-V')).toBe('M5'); + }); + + it('does not read one colour as the other', () => { + // 1.43 is a K5 dwarf in BP−RP and an M0 in B−V. + expect(spectralTypeFromColor(1.43, 'BP-RP')).toBe('K5'); + expect(spectralTypeFromColor(1.43, 'B-V')).toBe('M0'); + expect(spectralTypeFromColor(1.43)).toBe('M0'); + }); + + it('has no answer past either end of the table, nor without a colour', () => { + expect(spectralTypeFromColor(-0.35, 'B-V')).toBeNull(); + expect(spectralTypeFromColor(-0.15, 'BP-RP')).toBeNull(); + expect(spectralTypeFromColor(5.5, 'BP-RP')).toBeNull(); + expect(spectralTypeFromColor(null, 'B-V')).toBeNull(); + expect(spectralTypeFromColor(-0.301, 'B-V')).toBe('B0'); + }); +}); diff --git a/src/app/shared/astro/spectral.ts b/src/app/shared/astro/spectral.ts index 8c52725..00456c0 100644 --- a/src/app/shared/astro/spectral.ts +++ b/src/app/shared/astro/spectral.ts @@ -107,3 +107,53 @@ export function temperatureToColorIndex(temperatureK: number): number | null { const x = (-b + Math.sqrt(b * b - 4 * k * c)) / (2 * k); return Math.min(BEYOND_M, Math.max(COLOR_INDEX_ANCHORS.O, x / 0.92)); } + +/** + * Mean dwarf colours by spectral type: B−V and Gaia BP−RP, from Pecaut & Mamajek (2013, ApJS 208, + * 9, table 5) as Mamajek maintains it online (version 2022.04.16), where the BP−RP column was + * added. From B0, where B−V stops telling types apart — the whole O sequence spans 0.03 of it — + * to M8.5, past which BP−RP turns back; BP−RP starts at B9, the bluest it is tabulated for. + */ +const DWARF_COLOURS: readonly (readonly [string, number, number | null])[] = [ + ['B0', -0.301, null], ['B0.5', -0.289, null], ['B1', -0.278, null], ['B1.5', -0.252, null], + ['B2', -0.215, null], ['B2.5', -0.198, null], ['B3', -0.178, null], ['B4', -0.165, null], + ['B5', -0.156, null], ['B6', -0.14, null], ['B7', -0.128, null], ['B8', -0.109, null], + ['B9', -0.07, -0.12], ['B9.5', -0.05, -0.087], ['A0', 0, -0.037], ['A1', 0.035, 0.005], + ['A2', 0.07, 0.068], ['A3', 0.1, 0.11], ['A4', 0.14, 0.166], ['A5', 0.16, 0.194], + ['A6', 0.185, 0.222], ['A7', 0.21, 0.263], ['A8', 0.25, 0.32], ['A9', 0.27, 0.327], + ['F0', 0.295, 0.377], ['F1', 0.33, 0.434], ['F2', 0.37, 0.49], ['F3', 0.39, 0.518], + ['F4', 0.41, 0.546], ['F5', 0.44, 0.587], ['F6', 0.486, 0.64], ['F7', 0.5, 0.67], + ['F8', 0.53, 0.694], ['F9', 0.56, 0.719], ['F9.5', 0.58, 0.767], ['G0', 0.595, 0.784], + ['G1', 0.622, 0.803], ['G2', 0.65, 0.823], ['G3', 0.66, 0.832], ['G4', 0.67, 0.841], + ['G5', 0.68, 0.85], ['G6', 0.7, 0.869], ['G7', 0.71, 0.88], ['G8', 0.73, 0.9], + ['G9', 0.775, 0.95], ['K0', 0.816, 0.983], ['K1', 0.857, 1.01], ['K2', 0.884, 1.1], + ['K3', 0.99, 1.21], ['K4', 1.09, 1.34], ['K5', 1.15, 1.43], ['K6', 1.24, 1.53], + ['K7', 1.34, 1.7], ['K8', 1.363, 1.73], ['K9', 1.4, 1.79], ['M0', 1.42, 1.84], + ['M0.5', 1.445, 1.97], ['M1', 1.485, 2.09], ['M1.5', 1.495, 2.13], ['M2', 1.505, 2.23], + ['M2.5', 1.522, 2.39], ['M3', 1.53, 2.5], ['M3.5', 1.6, 2.78], ['M4', 1.65, 2.94], + ['M4.5', 1.69, 3.16], ['M5', 1.83, 3.35], ['M5.5', 1.94, 3.71], ['M6', 2.01, 4.16], + ['M6.5', 2.07, 4.5], ['M7', 2.12, 4.65], ['M7.5', 2.14, 4.72], ['M8', 2.15, 4.86], + ['M8.5', 2.16, 5.1] +]; + +/** + * The spectral type of the dwarf whose colour is nearest, for the stars no catalogue classified — + * every Gaia star, 83 % of the map. An estimate, and the caller must say so: it assumes a dwarf, + * so a giant is given a later type than its own — Pollux, a K0 giant at B−V 0.99, reads as K3 — + * and it ignores reddening, which makes a star behind dust look later still. `null` for a + * colour outside the table, rather than the nearest end of it. + */ +export function spectralTypeFromColor(colorIndex: number | null, system: 'B-V' | 'BP-RP' = 'B-V'): string | null { + const column = system === 'B-V' ? 1 : 2; + const rows = DWARF_COLOURS.filter((row) => row[column] !== null); + if (colorIndex === null || !(colorIndex >= rows[0][column]! && colorIndex <= rows[rows.length - 1][column]!)) { + return null; + } + let nearest = rows[0]; + for (const row of rows) { + if (Math.abs(row[column]! - colorIndex) < Math.abs(nearest[column]! - colorIndex)) { + nearest = row; + } + } + return nearest[0]; +} diff --git a/tools/etl/sources/gaia.ts b/tools/etl/sources/gaia.ts index 6dfde19..89d6007 100644 --- a/tools/etl/sources/gaia.ts +++ b/tools/etl/sources/gaia.ts @@ -135,7 +135,7 @@ export class GaiaAnswerError extends Error {} /** * 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. + * invented from it; the app estimates one, and says it is an estimate. */ const UNKNOWN_SPECTRAL_TYPE = 'Unknown';