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>
187 lines
8.1 KiB
TypeScript
187 lines
8.1 KiB
TypeScript
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();
|
||
});
|
||
});
|