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';