Files
star-map/src/app/shared/astro/spectral.spec.ts
T
SenrokaiandClaude Opus 5.5 23547defe0 Read an archive host's colour off the dwarf sequence at its temperature, and say it was not measured
For an archive-placed host with a temperature and no B magnitude, fetchExoplanets took B−V from
Ballesteros' blackbody fit, which runs 0.1 to 0.2 redder than Pecaut & Mamajek's dwarf sequence
below 3 800 K, and the luminosity then read its correction off that sequence at that colour: 3 500 K
came back as 3 102 K with a correction 1.15 magnitudes too large, anything under about 3 170 K was
clamped to B−V 2.00, and the card printed it as a measured "Colour B−V 2.00". CFBDSIR
J145829+101343, a 580 K brown dwarf, read "Spectral type ~M6, from colour".

temperatureToColorIndex now reads the table itself backwards, interpolating B−V between the two
types the temperature falls between, so the temperature and correction read back off the colour
are the table's at that temperature; it has no answer outside 2 420 to 31 400 K. The colour is
flagged colorFromTemperature, a fifth bit in the photometry byte (the format, README and the ETL's
round-trip check follow), and the card prints it "B−V 1.66, from its temperature", marked derived.

From cache: 57 archive stars change colour; 54 carry the flag and 3, CFBDSIR J145829+101343 among
them, now have none. For the 47 of them the archive gives a luminosity, the one derived from
magnitude and colour moves from a median 0.228 dex off it to 0.124; Kepler-445 (3 157 K) from
0.0282 L☉ to 0.0080 against the archive's 0.0079. Its colour goes from 2.00 to 1.67. The card shows
the archive's luminosity where it has one since earlier on this branch, so this is the figure used
for the rest and for their radii.

Controls, each failing its named test: the nearest hotter row taken without interpolating (2 of
803 failed), no refusal outside the table, the flag not encoded, and the card calling the colour
measured (1 of 803 each).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:29:09 +02:00

187 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { dwarfSequenceAtColor, isGiant, parseSpectralClass, spectralClassification, SPECTRAL_CLASSES, spectralTypeFromColor, spectralTypeToColorIndex, temperatureToColorIndex } from './spectral';
describe('parseSpectralClass', () => {
it('reads a clean class and subclass', () => {
expect(parseSpectralClass('M3.5')).toEqual({ spectralClass: 'M', subclass: 3.5 });
expect(parseSpectralClass('G2V')).toEqual({ spectralClass: 'G', subclass: 2 });
});
it('defaults the subclass to 0 when only a class is given', () => {
expect(parseSpectralClass('K')).toEqual({ spectralClass: 'K', subclass: 0 });
});
it('accepts the lowercase forms HYG actually ships', () => {
// 354 nearby stars are classified as a bare lowercase "m".
expect(parseSpectralClass('m')).toEqual({ spectralClass: 'M', subclass: 0 });
expect(parseSpectralClass('k')).toEqual({ spectralClass: 'K', subclass: 0 });
});
it('skips a luminosity prefix to find the class', () => {
expect(parseSpectralClass('dM4')?.spectralClass).toBe('M');
expect(parseSpectralClass('sdM')?.spectralClass).toBe('M');
expect(parseSpectralClass('gK5')?.spectralClass).toBe('K');
});
it('takes the warmer end of a range', () => {
expect(parseSpectralClass('k-m')?.spectralClass).toBe('K');
expect(parseSpectralClass('g-k')?.spectralClass).toBe('G');
});
it('tolerates uncertainty flags and luminosity suffixes', () => {
expect(parseSpectralClass('K:')).toEqual({ spectralClass: 'K', subclass: 0 });
expect(parseSpectralClass('K5 V')).toEqual({ spectralClass: 'K', subclass: 5 });
expect(parseSpectralClass('m+')).toEqual({ spectralClass: 'M', subclass: 0 });
});
it('returns null when there is no recognisable class', () => {
for (const input of ['', ' ', '...', undefined, null]) {
expect(parseSpectralClass(input)).toBeNull();
}
});
it('ignores an out-of-range subclass rather than trusting it', () => {
expect(parseSpectralClass('M42')).toEqual({ spectralClass: 'M', subclass: 0 });
});
});
describe('isGiant', () => {
it('reads luminosity classes I to III off the primary, the giant and supergiant prefixes, and carbon and S stars', () => {
for (const type of ['M1Ib + B2.5V', 'K5III', 'M2II-IIIvar', 'C7Iab', 'K0IIIb', 'gK0', 'cM2', 'N5', 'Ce+', 'S57:']) {
expect(isGiant(type), type).toBe(true);
}
});
it('leaves dwarfs, subgiants, a dwarf with a giant companion and the unclassified alone', () => {
for (const type of ['G2V', 'B2IV', 'F0IVn', 'M5Ve', 'K1V + M3III', 'g-k', 'Unknown', 'DA', '']) {
expect(isGiant(type), type).toBe(false);
}
});
});
describe('spectralTypeToColorIndex', () => {
it('places the Sun near its real B-V of 0.65', () => {
expect(spectralTypeToColorIndex('G2V')).toBeCloseTo(0.626, 2);
});
it('makes hot classes blue (negative) and cool classes red (positive)', () => {
expect(spectralTypeToColorIndex('O5')).toBeLessThan(0);
expect(spectralTypeToColorIndex('B0')).toBeLessThan(0);
expect(spectralTypeToColorIndex('M5')).toBeGreaterThan(1);
});
it('increases monotonically from hot to cool across the sequence', () => {
const values = SPECTRAL_CLASSES.map((spectralClass) => spectralTypeToColorIndex(spectralClass)!);
expect([...values].sort((a, b) => a - b)).toEqual(values);
});
it('interpolates between class anchors by subclass', () => {
const g0 = spectralTypeToColorIndex('G0')!;
const g5 = spectralTypeToColorIndex('G5')!;
const k0 = spectralTypeToColorIndex('K0')!;
expect(g5).toBeGreaterThan(g0);
expect(g5).toBeLessThan(k0);
expect(g5).toBeCloseTo((g0 + k0) / 2, 6);
});
it('keeps the coolest subclasses inside a sane range', () => {
const m9 = spectralTypeToColorIndex('M9')!;
expect(m9).toBeGreaterThan(spectralTypeToColorIndex('M0')!);
expect(m9).toBeLessThanOrEqual(2);
});
it('returns null for an unclassified star', () => {
expect(spectralTypeToColorIndex('Unknown')).toBeNull();
expect(spectralTypeToColorIndex('')).toBeNull();
});
});
describe('temperatureToColorIndex', () => {
it("puts the Sun's temperature at its own B-V and a cool dwarf where the dwarf sequence has it", () => {
expect(temperatureToColorIndex(5772)).toBeCloseTo(0.65, 2);
// Two thirds of the way from M2 (3 560 K, B−V 1.505) to M2.5 (3 470 K, 1.522).
expect(temperatureToColorIndex(3500)).toBeCloseTo(1.5163, 4);
});
it('reads back as the temperature it came from, so the correction is the one at that temperature', () => {
for (const temperatureK of [31400, 12000, 7000, 5772, 4000, 3500, 3157, 2566, 2420]) {
expect(dwarfSequenceAtColor(temperatureToColorIndex(temperatureK))!.temperatureK).toBeCloseTo(temperatureK, 6);
}
});
it('has no answer outside the table, nor for a temperature that is not one', () => {
for (const temperatureK of [580, 2419, 31401, 50000, 0, Number.NaN]) {
expect(temperatureToColorIndex(temperatureK)).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');
});
});
describe('spectralClassification', () => {
it("gives the catalogue's type, else the colour's marked as an estimate, else nothing", () => {
expect(spectralClassification({ spectralType: 'M5Ve', colorIndex: 1.807, colorSystem: 'B-V' })).toBe('M5Ve');
expect(spectralClassification({ spectralType: 'Unknown', colorIndex: 4.902, colorSystem: 'BP-RP' })).toBe('~M8');
expect(spectralClassification({ spectralType: 'Unknown', colorIndex: null })).toBe('');
});
});
describe('dwarfSequenceAtColor', () => {
it("puts the Sun's colour in either system at the Sun's temperature and correction", () => {
for (const [colour, system] of [[0.65, 'B-V'], [0.823, 'BP-RP']] as const) {
const point = dwarfSequenceAtColor(colour, system)!;
expect(point.temperatureK).toBeCloseTo(5770, 0);
expect(point.bolometricCorrectionV).toBeCloseTo(-0.085, 3);
expect(point.gMinusV).toBeCloseTo(-0.165, 3);
}
});
it('interpolates between the two types a colour falls between', () => {
// Halfway from M1.5 (B−V 1.495, 3 620 K, −1.50) to M2 (1.505, 3 560 K, −1.62).
const point = dwarfSequenceAtColor(1.5, 'B-V')!;
expect(point.temperatureK).toBeCloseTo(3590, 6);
expect(point.bolometricCorrectionV).toBeCloseTo(-1.56, 6);
});
it('has no answer past either end of the table, and no G−V where none is tabulated', () => {
expect(dwarfSequenceAtColor(2.2, 'B-V')).toBeNull();
expect(dwarfSequenceAtColor(-0.15, 'BP-RP')).toBeNull();
expect(dwarfSequenceAtColor(null)).toBeNull();
expect(dwarfSequenceAtColor(-0.29, 'B-V')!.gMinusV).toBeNull();
});
it('reads the row at the end a colour is past, when asked to', () => {
expect(dwarfSequenceAtColor(2.2, 'B-V', true)).toEqual({ bMinusV: 2.16, temperatureK: 2420, bolometricCorrectionV: -5.78, gMinusV: -3.09 });
expect(dwarfSequenceAtColor(-0.15, 'BP-RP', true)).toEqual({ bMinusV: -0.07, temperatureK: 10700, bolometricCorrectionV: -0.42, gMinusV: 0.018 });
expect(dwarfSequenceAtColor(null, 'B-V', true)).toBeNull();
});
});