diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts index 27dea69..d093944 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts @@ -1014,14 +1014,15 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { expect(scene.hudReadouts().find((readout) => readout.label === 'Radius')?.value).toBe('0.141 solar radii'); }); - it('keeps the camera three radii out from a supergiant drawn at the radius its colour and brightness give', async () => { + it('keeps the camera three radii out from a supergiant drawn at the radius its type and brightness give', async () => { const scene = await enter(ANTARES); const radius = scene.starMarkerGeometry.parameters.radius; - // 690 R☉ against the 680 Ohnaka et al. (2013) measure: 3.2 AU. - expect(radius / SUN_RADIUS_AU).toBeGreaterThan(600); - expect(radius / SUN_RADIUS_AU).toBeLessThan(760); + // 410 R☉, 1.9 AU, at M1's 3 730 K: short of the 680 Ohnaka et al. (2013) measure, whose + // luminosity is 0.4 dex above what V gives at the catalogue's distance. + expect(radius / SUN_RADIUS_AU).toBeGreaterThan(350); + expect(radius / SUN_RADIUS_AU).toBeLessThan(480); expect(scene.controls.minDistance).toBeCloseTo(closestApproachAu(radius), 9); - expect(scene.controls.minDistance).toBeGreaterThan(9); + expect(scene.controls.minDistance).toBeGreaterThan(5); // Settled where its disc stays inside the ring of its neighbours' names, not pressed up to it. expect(engine.getCamera().position.length()).toBeGreaterThan(1.2 * scene.controls.minDistance); }); diff --git a/src/app/shared/astro/spectral.spec.ts b/src/app/shared/astro/spectral.spec.ts index acefe11..1233d19 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 { dwarfSequenceAtColor, dwarfSequenceAtTemperature, isGiant, parseSpectralClass, spectralClassification, SPECTRAL_CLASSES, spectralTypeFromColor, spectralTypeToColorIndex, temperatureToColorIndex } from './spectral'; +import { dwarfSequenceAtColor, dwarfSequenceAtTemperature, dwarfSequenceAtType, isGiant, parseSpectralClass, spectralClassification, SPECTRAL_CLASSES, spectralTypeFromColor, spectralTypeToColorIndex, temperatureToColorIndex } from './spectral'; describe('parseSpectralClass', () => { it('reads a clean class and subclass', () => { @@ -195,6 +195,16 @@ describe('dwarfSequenceAtColor', () => { }); }); +describe('dwarfSequenceAtType', () => { + it("reads an O type off Mamajek's O rows, which carry no colour, and any other off its own row", () => { + expect(dwarfSequenceAtType('O7.5Iab:')).toEqual({ bMinusV: null, temperatureK: 36100, bolometricCorrectionV: -3.33, gMinusV: null }); + expect(dwarfSequenceAtType('B8Ia')!.temperatureK).toBe(12300); + expect(dwarfSequenceAtType('O9.7')!.temperatureK).toBeCloseTo(31900 - 500 * (0.2 / 0.5), 6); + expect(dwarfSequenceAtType('M9')!.temperatureK).toBe(2420); + expect(dwarfSequenceAtType('Unknown')).toBeNull(); + }); +}); + describe('dwarfSequenceAtTemperature', () => { it("is a type's own row at its temperature, between two rows between them, and the end row past either end", () => { expect(dwarfSequenceAtTemperature(5770)).toEqual({ bMinusV: 0.65, temperatureK: 5770, bolometricCorrectionV: -0.085, gMinusV: -0.165 }); diff --git a/src/app/shared/astro/spectral.ts b/src/app/shared/astro/spectral.ts index af2a240..bcde728 100644 --- a/src/app/shared/astro/spectral.ts +++ b/src/app/shared/astro/spectral.ts @@ -114,20 +114,25 @@ export function spectralTypeToColorIndex(spectralType: string | null | undefined * J145829+101343, a 580 K brown dwarf, read as B−V 2.00 and "~M6". */ export function temperatureToColorIndex(temperatureK: number): number | null { - const [hottest, coolest] = [DWARF_SEQUENCE[0], DWARF_SEQUENCE[DWARF_SEQUENCE.length - 1]]; + const [hottest, coolest] = [ROWS_WITH_COLOUR[1][0], DWARF_SEQUENCE[DWARF_SEQUENCE.length - 1]]; return temperatureK <= hottest[3] && temperatureK >= coolest[3] ? dwarfSequenceAtTemperature(temperatureK).bMinusV : null; } /** * The mean dwarf sequence: B−V, Gaia BP−RP, effective temperature (K), bolometric correction to V * and Gaia G−V by spectral type, from Pecaut & Mamajek (2013, ApJS 208, 9, table 5) as Mamajek - * maintains it online (version 2022.04.16), where the Gaia columns were 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, and G−V at B1.5. + * maintains it online (version 2022.04.16), where the Gaia columns were added. From O3 to M8.5, + * past which BP−RP turns back. The O rows, read by type only, carry no colour: B−V stops telling + * types apart there, the whole O sequence spanning 0.03 of it. BP−RP starts at B9, the bluest it + * is tabulated for, and G−V at B1.5. */ -type SequenceRow = readonly [string, number, number | null, number, number, number | null]; +type SequenceRow = readonly [string, number | null, number | null, number, number, number | null]; const DWARF_SEQUENCE: readonly SequenceRow[] = [ + ['O3', null, null, 44900, -4.01, null], ['O4', null, null, 42900, -3.89, null], ['O5', null, null, 41400, -3.76, null], + ['O5.5', null, null, 40500, -3.67, null], ['O6', null, null, 39500, -3.57, null], ['O6.5', null, null, 38300, -3.49, null], + ['O7', null, null, 37100, -3.41, null], ['O7.5', null, null, 36100, -3.33, null], ['O8', null, null, 35100, -3.24, null], + ['O8.5', null, null, 34300, -3.18, null], ['O9', null, null, 33300, -3.11, null], ['O9.5', null, null, 31900, -3.01, null], ['B0', -0.301, null, 31400, -2.99, null], ['B0.5', -0.289, null, 29000, -2.83, null], ['B1', -0.278, null, 26000, -2.58, null], ['B1.5', -0.252, null, 24500, -2.44, -0.021], ['B2', -0.215, null, 20600, -2.03, -0.008], ['B2.5', -0.198, null, 18500, -1.77, -0.003], ['B3', -0.178, null, 17000, -1.54, 0.001], ['B4', -0.165, null, 16400, -1.49, 0.004], ['B5', -0.156, null, 15700, -1.34, 0.007], @@ -199,8 +204,8 @@ export function spectralClassification(star: { spectralType: string; colorIndex: /** What the dwarf sequence says of a star of a given colour. */ export interface DwarfSequencePoint { - /** B−V, the colour in the other system's terms where it was read off BP−RP. */ - bMinusV: number; + /** B−V, the colour in the other system's terms where it was read off BP−RP; `null` among the O rows. */ + bMinusV: number | null; temperatureK: number; /** Bolometric correction to V: what V leaves out of the star's total output, in magnitudes. */ bolometricCorrectionV: number; @@ -243,6 +248,22 @@ const WHITE_DWARF_BP_RP: readonly (readonly [number, number])[] = [ [-0.4, 28585], [-0.35, 24521], [-0.3, 22090], [-0.25, 19012], [-0.2, 17079], [-0.15, 15369], [-0.12, 10700] ]; +/** + * The dwarf sequence at a spectral type, between the two rows it falls between, O3 to M8.5; the end + * row past either end. `null` for a type with no class the parser reads. + */ +export function dwarfSequenceAtType(spectralType: string | null | undefined): DwarfSequencePoint | null { + const parsed = parseSpectralClass(spectralType); + return parsed && sequenceWhere(DWARF_SEQUENCE, (row) => TYPE_INDEX.get(row)!, typeIndex(parsed)); +} + +/** A type as a number rising down the table: ten to a class, O0 at 0. */ +function typeIndex({ spectralClass, subclass }: { spectralClass: SpectralClass; subclass: number }): number { + return SPECTRAL_CLASSES.indexOf(spectralClass) * 10 + subclass; +} + +const TYPE_INDEX = new Map(DWARF_SEQUENCE.map((row) => [row, typeIndex(parseSpectralClass(row[0])!)])); + /** The dwarf sequence at an effective temperature, between the two types it falls between; the end row past either end. */ export function dwarfSequenceAtTemperature(temperatureK: number): DwarfSequencePoint { return sequenceWhere(ROWS_WITH_COLOUR[1], (row) => -row[3], -temperatureK); @@ -255,7 +276,7 @@ function sequenceWhere(rows: readonly SequenceRow[], key: (row: SequenceRow) => const t = Math.min(Math.max((value - key(first)) / (key(second) - key(first)), 0), 1); const lerp = (from: number, to: number): number => from + (to - from) * t; return { - bMinusV: lerp(first[1], second[1]), + bMinusV: first[1] === null || second[1] === null ? null : lerp(first[1], second[1]), temperatureK: lerp(first[3], second[3]), bolometricCorrectionV: lerp(first[4], second[4]), gMinusV: first[5] === null || second[5] === null ? null : lerp(first[5], second[5]) diff --git a/src/app/shared/astro/stellar.spec.ts b/src/app/shared/astro/stellar.spec.ts index 209a541..b7d58ce 100644 --- a/src/app/shared/astro/stellar.spec.ts +++ b/src/app/shared/astro/stellar.spec.ts @@ -122,18 +122,41 @@ describe('luminositySolar', () => { expect(derived / 5.53e-4).toBeLessThan(1.5); }); - it("gives a giant its type's correction, not the cooler dwarf's its colour reads as", () => { - // Antares, M1 Ib at B−V 1.87 and 170 pc, and Aldebaran, K5 III: 680 R☉ (Ohnaka et al. 2013) - // and 44.2 (Richichi & Roccatagliata 2005). As dwarfs they came out 1 516 and 86. + it("reads a giant's temperature and correction both off its type, not the cooler dwarf's its colour reads as", () => { + // Antares, M1 Ib at B−V 1.87: 3 660 K (Ohnaka et al. 2013), where its colour's dwarf is 3 019. const antares = { magnitude: 1.06, distancePc: 169.78, spectralType: 'M1Ib + B2.5V', magnitudeBand: 'V', colorIndex: 1.865, colorSystem: 'B-V' } as const; + expect(Math.abs(effectiveTemperatureK(antares)! - 3660)).toBeLessThan(100); + // Aldebaran, K5 III, 44.2 R☉ (Richichi & Roccatagliata 2005), to a tenth; Rigel, B8 Ia, 74.1 + // (Baines et al. 2018), to a fifth. With a correction off the type beside the colour's + // temperature, Rigel came out 101.6; with K5's own correction at 3 902 K, Aldebaran 52. const aldebaran = { magnitude: 0.87, distancePc: 20.433, spectralType: 'K5III', magnitudeBand: 'V', colorIndex: 1.538, colorSystem: 'B-V' } as const; - for (const [star, published] of [[antares, 680], [aldebaran, 44.2]] as const) { + const rigel = { magnitude: 0.18, distancePc: 264.55, spectralType: 'B8Ia', magnitudeBand: 'V', colorIndex: -0.03, colorSystem: 'B-V' } as const; + for (const [star, published, tolerance] of [[aldebaran, 44.2, 1.1], [rigel, 74.1, 1.2]] as const) { const radius = radiusFromLuminositySolar(luminositySolar(star)!, effectiveTemperatureK(star)!); - expect(radius / published).toBeGreaterThan(1 / 1.2); - expect(radius / published).toBeLessThan(1.2); + expect(radius / published).toBeGreaterThan(1 / tolerance); + expect(radius / published).toBeLessThan(tolerance); } }); + it('reads a hot giant reddened by dust at its type, not at the cool star its colour reads as', () => { + // Menkib, O7.5 Iab at B−V 0.02: 14 R☉ (Krtička & Kubát 2010). At its colour's 9 517 K and its + // type's correction it was drawn at 95; the dust it is behind still leaves it dimmer than it is. + const menkib = { magnitude: 3.98, distancePc: 408.881, spectralType: 'O7.5Iab:', magnitudeBand: 'V', colorIndex: 0.016, colorSystem: 'B-V' } as const; + expect(effectiveTemperatureK(menkib)).toBeCloseTo(36100, 6); + const radius = radiusFromLuminositySolar(luminositySolar(menkib)!, effectiveTemperatureK(menkib)!); + expect(radius / 14).toBeGreaterThan(1 / 2.5); + expect(radius / 14).toBeLessThan(2.5); + }); + + it("gives a carbon star the carbon stars' correction and temperature, not the Sun's correction at an M dwarf's", () => { + // La Superba, C7 Iab: Bergeat et al. (2001) have it at bolometric magnitude 2.43, which at the + // catalogue's 310 pc is 8 090 L☉. The Sun's −0.06 at 2 420 K gave 544 L☉ and 133 R☉. + const laSuperba = { magnitude: 5.42, distancePc: 310.342, spectralType: 'C7Iab', magnitudeBand: 'V', colorIndex: 2.994, colorSystem: 'B-V' } as const; + expect(luminositySolar(laSuperba)! / 8090).toBeGreaterThan(1 / 1.2); + expect(luminositySolar(laSuperba)! / 8090).toBeLessThan(1.2); + expect(effectiveTemperatureK(laSuperba)).toBe(2990); + }); + it('clamps a pathological record instead of producing an absurd luminosity', () => { const absurd = luminositySolar({ magnitude: -40, distancePc: 5000, spectralType: 'O5V' })!; expect(Number.isFinite(absurd)).toBe(true); diff --git a/src/app/shared/astro/stellar.ts b/src/app/shared/astro/stellar.ts index 0e5f9d9..4c88465 100644 --- a/src/app/shared/astro/stellar.ts +++ b/src/app/shared/astro/stellar.ts @@ -1,4 +1,4 @@ -import { DwarfSequencePoint, dwarfSequenceAtColor, isGiant, parseSpectralClass, SpectralClass, spectralTypeToColorIndex } from './spectral'; +import { DwarfSequencePoint, dwarfSequenceAtColor, dwarfSequenceAtTemperature, dwarfSequenceAtType, isGiant, parseSpectralClass, SpectralClass, spectralTypeToColorIndex } from './spectral'; /** * Stellar luminosity, derived from the two things the star catalogue actually measures. @@ -129,14 +129,12 @@ export function luminositySolar(star: StellarPhotometry): number | null { // the archive's own figure for 1 449 hosts, the worst tenth was off by 0.29 dex or more, and is // now off by 0.12. // - // Not for a star its type says is a giant, though: at its colour the dwarf sequence is a cooler - // dwarf, whose correction is larger. Antares, M1 Ib at B−V 1.87, read as an M5 dwarf's −3.26 - // came out 1 516 R☉ against the 680 Ohnaka et al. (2013) measure, and 119 Tau 2 838 against 587; - // its type's −1.55 gives 690. + // Not for a star its type says is a giant, though, whose correction is read off its type along + // with its temperature: see giantSurface. const sequence = sequenceAtColour(star); const absoluteV = absolute - (star.magnitudeBand === 'G' ? (sequence?.gMinusV ?? 0) : 0); - const bolometric = - absoluteV + (sequence && !isGiant(star.spectralType) ? sequence.bolometricCorrectionV : bolometricCorrection(star.spectralType)); + const correction = giantSurface(star.spectralType)?.bolometricCorrectionV ?? sequence?.bolometricCorrectionV ?? bolometricCorrection(star.spectralType); + const bolometric = absoluteV + correction; const luminosity = Math.pow(10, (SOLAR_BOLOMETRIC_MAGNITUDE - bolometric) / 2.5); return Math.min(Math.max(luminosity, MIN_LUMINOSITY_SOLAR), MAX_LUMINOSITY_SOLAR); } @@ -163,18 +161,72 @@ export const SOLAR_EFFECTIVE_TEMPERATURE_K = 5772; /** * Effective temperature, off the dwarf sequence at the star's colour, or at the colour its - * spectral type implies where it has none. Exactly the Sun's for the Sun, which is at zero - * distance here. A giant is read as the dwarf of its colour: a few hundred kelvin too cool at K, - * and Antares, an M1 supergiant, 3 019 K against the 3 660 Ohnaka et al. (2013) measure. + * spectral type implies where it has none; a giant's off its type (giantSurface). Exactly the + * Sun's for the Sun, which is at zero distance here. */ export function effectiveTemperatureK(star: StellarPhotometry): number | null { if (star.distancePc === 0) { return SOLAR_EFFECTIVE_TEMPERATURE_K; } // A type's colour past the table is an O star's, which B−V no longer tells apart from B0. - return (sequenceAtColour(star) ?? dwarfSequenceAtColor(spectralTypeToColorIndex(star.spectralType), 'B-V', true))?.temperatureK ?? null; + return ( + giantSurface(star.spectralType)?.temperatureK ?? + (sequenceAtColour(star) ?? dwarfSequenceAtColor(spectralTypeToColorIndex(star.spectralType), 'B-V', true))?.temperatureK ?? + null + ); } +/** + * What a giant's type says of its surface: its effective temperature, and the bolometric + * correction the dwarf sequence has at that temperature — both off the type, so that the two a + * radius is drawn from come from the same place. `null` for a star that is not a giant, or whose + * class the parser cannot read. + * + * Read off the colour, a giant is the dwarf of its colour, too cool: Antares, M1 Ib at B−V 1.87, + * came out 3 019 K against the 3 660 Ohnaka et al. (2013) measure, and the 610 M giants a median + * 3 275 K where M2 III is about 3 650. Worse, a hot giant behind dust reads as a far cooler star: + * Menkib, O7.5 Iab at B−V 0.02, was 9 517 K, and with its type's correction beside that colour's + * temperature it was drawn at 95 R☉, and Alp Cam, O9.5 Ia, at 338, where 14 and 21 are published. + * + * G to M giants take van Belle et al.'s (2021, ApJ 922, 163, table 8) interferometric scale, fitted + * to 191 giants from G1 to M7.75 III: 4 692 K at K0, 3 816 at M0, 3 472 at M4, held at 3 134 past + * M7.75. O to F giants take the dwarf of their type, which a supergiant of the same type is within + * a few per cent of from B8 on, and a few thousand kelvin cooler than at B0 (Alnilam, B0 Ia, about + * 27 000 K against B0 V's 31 400). Carbon and S stars take {@link CARBON_STAR}. + */ +export function giantSurface(spectralType: string | null | undefined): { temperatureK: number; bolometricCorrectionV: number } | null { + if (!isGiant(spectralType)) { + return null; + } + const primary = (spectralType ?? '').split('+')[0].trim(); + if (/^[CNRS]/.test(primary)) { + return CARBON_STAR; + } + const parsed = parseSpectralClass(primary); + if (!parsed) { + return null; + } + const { spectralClass, subclass } = parsed; + if (spectralClass === 'G' || spectralClass === 'K' || spectralClass === 'M') { + // van Belle's index: G0 at 50, K0 at 60, K5 at 65 and M0 at 66, so a K later than K5 falls between. + const index = spectralClass === 'G' ? 50 + subclass : spectralClass === 'K' ? 60 + Math.min(subclass, 5) + Math.max(subclass - 5, 0) / 5 : 66 + subclass; + const temperatureK = index <= 61 ? 7856 - 52.74 * index : index <= 64 ? 16751 - 199.41 * index : Math.max(9491 - 85.98 * index, 3134); + return { temperatureK, bolometricCorrectionV: dwarfSequenceAtTemperature(temperatureK).bolometricCorrectionV }; + } + const dwarf = dwarfSequenceAtType(primary)!; + return { temperatureK: dwarf.temperatureK, bolometricCorrectionV: dwarf.bolometricCorrectionV }; +} + +/** + * A carbon or S star's temperature and bolometric correction to V: the medians of Bergeat, Knapik & + * Rutily (2001, A&A 369, 178) over the 441 carbon stars of their table 10, and over the 383 of + * those with a V magnitude. No type in the table reads for them, and they were given the Sun's + * −0.06 at the M8.5 dwarf's 2 420 K: La Superba came out 544 L☉ and 133 R☉, where Bergeat's own + * figures give 8 090 L☉ at the same distance and McDonald et al. (2017) 315 R☉. S stars, between M + * and C, are given the carbon stars' figures for want of their own. + */ +const CARBON_STAR = { temperatureK: 2990, bolometricCorrectionV: -2.83 } as const; + /** * Radius in solar radii from luminosity and temperature — Stefan-Boltzmann, L = 4πR²σT⁴, in solar * units. Luminosity-class blind, since the luminosity comes from the distance: a giant comes out a