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>
This commit is contained in:
@@ -209,7 +209,8 @@ objects. At 68 388 stars the old encoding — one JSON object per star, its eigh
|
||||
repeated each time — would have been about 17 MB to download and parse before the first frame.
|
||||
Splitting it puts the numbers in `stars.bin` (positions, handed to the GPU verbatim) and
|
||||
`stars-meta.bin` (id, magnitude, colour index, spectral-type index, the band and colour system
|
||||
those were measured in, and the distance's relative error), and leaves `stars-index.json`
|
||||
those were measured in or whether the colour was read off a temperature, and the distance's
|
||||
relative error), and leaves `stars-index.json`
|
||||
holding only the strings, with the ~2 600 distinct spectral classifications collapsed into a
|
||||
dictionary. The result is 2.6 MB for 7.8× the stars. `star-catalog.ts` defines the layout once
|
||||
and both the ETL and the app use it, so the writer and the reader cannot drift apart.
|
||||
|
||||
@@ -50,6 +50,11 @@ describe('starReadouts', () => {
|
||||
expect(value(starReadouts(GAIA_STAR), 'Colour')).toBe('BP−RP 1.43');
|
||||
});
|
||||
|
||||
it('says a colour read off a temperature was not measured', () => {
|
||||
const kepler445 = { ...HYG_STAR, magnitudeBand: 'G' as const, colorIndex: 1.66, colorFromTemperature: true, source: 'exoplanet-archive' };
|
||||
expect(starReadouts(kepler445).find((readout) => readout.label === 'Colour')).toEqual({ label: 'Colour', value: 'B−V 1.66, from its temperature', derived: true });
|
||||
});
|
||||
|
||||
it('says a stand-in magnitude was not measured, and leaves out a colour there is none of', () => {
|
||||
const unmeasured = starReadouts({ ...GAIA_STAR, magnitude: 12, magnitudeBand: undefined, colorIndex: null, colorSystem: undefined });
|
||||
expect(value(unmeasured, 'Magnitude')).toBe('Not measured');
|
||||
|
||||
@@ -44,7 +44,13 @@ export function starReadouts(star: StarRecord, surface?: StarSurface): HudReadou
|
||||
// A G magnitude and a V one are not comparable: a red dwarf is up to three brighter in G.
|
||||
{ label: 'Magnitude', value: star.magnitudeBand ? `${star.magnitudeBand} ${star.magnitude.toFixed(2)}` : 'Not measured' },
|
||||
...(star.colorIndex !== null
|
||||
? [{ label: 'Colour', value: `${star.colorSystem === 'BP-RP' ? 'BP−RP' : 'B−V'} ${star.colorIndex.toFixed(2)}` }]
|
||||
? [
|
||||
{
|
||||
label: 'Colour',
|
||||
value: `${star.colorSystem === 'BP-RP' ? 'BP−RP' : 'B−V'} ${star.colorIndex.toFixed(2)}${star.colorFromTemperature ? ', from its temperature' : ''}`,
|
||||
...(star.colorFromTemperature ? { derived: true } : {})
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(surface?.luminositySolar
|
||||
? [{ label: 'Luminosity', value: formatLuminosity(surface.luminositySolar), ...(surface.luminosityDerived ? { derived: true } : {}) }]
|
||||
|
||||
@@ -99,19 +99,22 @@ describe('spectralTypeToColorIndex', () => {
|
||||
});
|
||||
|
||||
describe('temperatureToColorIndex', () => {
|
||||
it("puts the Sun's temperature at its own B-V and a cool dwarf redder", () => {
|
||||
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);
|
||||
expect(temperatureToColorIndex(3400)).toBeCloseTo(1.79, 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('stays inside the range the spectral classes span', () => {
|
||||
expect(temperatureToColorIndex(2566)).toBe(2);
|
||||
expect(temperatureToColorIndex(50000)).toBe(-0.33);
|
||||
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 for a temperature that is not one', () => {
|
||||
expect(temperatureToColorIndex(0)).toBeNull();
|
||||
expect(temperatureToColorIndex(Number.NaN)).toBeNull();
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -104,22 +104,23 @@ export function spectralTypeToColorIndex(spectralType: string | null | undefined
|
||||
|
||||
/**
|
||||
* B-V colour index for an effective temperature, for stars the Exoplanet Archive gives a
|
||||
* temperature but no B magnitude. Inverts Ballesteros (2012), T = 4600 K · (1 / (0.92 (B-V) +
|
||||
* 1.7) + 1 / (0.92 (B-V) + 0.62)), a blackbody fit good to a few per cent from A to early M: it
|
||||
* puts the Sun's 5 772 K at 0.65, which is the Sun's own. Clamped to the range the class anchors
|
||||
* above span, because the fit runs on past it — TRAPPIST-1's 2 566 K would come out at 2.7.
|
||||
* temperature but no B magnitude: the dwarf sequence below read the other way, interpolated
|
||||
* between the two types the temperature falls between, so that the correction and the temperature
|
||||
* read back off the colour are the table's at that temperature. Ballesteros' blackbody fit, used
|
||||
* before, runs 0.1 to 0.2 redder than the table below 3 800 K, and the colour it gave was read on
|
||||
* the table: 3 500 K came back as 3 102 K with a correction 1.15 magnitudes too large, and the 57
|
||||
* hosts placed this way were off the archive's own luminosity by 0.23 dex at the median. `null`
|
||||
* outside the table, 2 420 to 31 400 K, rather than a colour clamped to its end — CFBDSIR
|
||||
* J145829+101343, a 580 K brown dwarf, read as B−V 2.00 and "~M6".
|
||||
*/
|
||||
export function temperatureToColorIndex(temperatureK: number): number | null {
|
||||
if (!Number.isFinite(temperatureK) || temperatureK <= 0) {
|
||||
const [hottest, coolest] = [DWARF_SEQUENCE[0], DWARF_SEQUENCE[DWARF_SEQUENCE.length - 1]];
|
||||
if (!(temperatureK <= hottest[3] && temperatureK >= coolest[3])) {
|
||||
return null;
|
||||
}
|
||||
// With x = 0.92 (B-V) and k = T / 4600 the fit is k x² + (2.32 k - 2) x + (1.054 k - 2.32) = 0,
|
||||
// whose larger root is the physical one.
|
||||
const k = temperatureK / 4600;
|
||||
const b = 2.32 * k - 2;
|
||||
const c = 1.054 * k - 2.32;
|
||||
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));
|
||||
const cooler = Math.max(1, DWARF_SEQUENCE.findIndex((row) => row[3] <= temperatureK));
|
||||
const [hot, cool] = [DWARF_SEQUENCE[cooler - 1], DWARF_SEQUENCE[cooler]];
|
||||
return hot[1] + ((cool[1] - hot[1]) * (hot[3] - temperatureK)) / (hot[3] - cool[3]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -105,15 +105,18 @@ describe('the photometry and distance error columns', () => {
|
||||
{ id: 3, name: 'HD 3', x: 0, y: 0, z: 300, magnitude: 7, magnitudeBand: 'V', spectralType: 'K0', colorIndex: 1.0, colorSystem: 'B-V', distanceError: 0.000001, distanceFromGaia: true, source: 'hyg' },
|
||||
{ id: 4, name: 'Gaia DR3 4', x: 5, y: 5, z: 0, magnitude: 12, spectralType: 'Unknown', colorIndex: null, distanceFromGaia: true, source: 'gaia' },
|
||||
// A Hipparcos parallax smaller than its own error.
|
||||
{ id: 5, name: 'HIP 5', x: 0, y: 200, z: 0, magnitude: 6, magnitudeBand: 'V', spectralType: 'B8', colorIndex: -0.1, colorSystem: 'B-V', distanceError: 1.4, source: 'hyg' }
|
||||
{ id: 5, name: 'HIP 5', x: 0, y: 200, z: 0, magnitude: 6, magnitudeBand: 'V', spectralType: 'B8', colorIndex: -0.1, colorSystem: 'B-V', distanceError: 1.4, source: 'hyg' },
|
||||
// An archive host whose B−V is its temperature's.
|
||||
{ id: 6, name: 'Kepler-445', x: 0, y: 0, z: 90, magnitude: 17.6, magnitudeBand: 'G', spectralType: 'M4', colorIndex: 1.66, colorSystem: 'B-V', colorFromTemperature: true, source: 'exoplanet-archive' }
|
||||
];
|
||||
const encoded = encodeStarCatalog(MEASURED);
|
||||
const decoded = decodeStarCatalog(encoded.index, encoded.positions, encoded.meta);
|
||||
|
||||
it('carries the band, which colour the colour index is, and whose parallax the distance is', () => {
|
||||
expect(decoded.map((star) => star.magnitudeBand)).toEqual(['V', 'G', 'V', undefined, 'V']);
|
||||
expect(decoded.map((star) => star.colorSystem)).toEqual(['B-V', 'BP-RP', 'B-V', undefined, 'B-V']);
|
||||
expect(decoded.map((star) => star.distanceFromGaia)).toEqual([false, true, true, true, false]);
|
||||
expect(decoded.map((star) => star.magnitudeBand)).toEqual(['V', 'G', 'V', undefined, 'V', 'G']);
|
||||
expect(decoded.map((star) => star.colorSystem)).toEqual(['B-V', 'BP-RP', 'B-V', undefined, 'B-V', 'B-V']);
|
||||
expect(decoded.map((star) => star.distanceFromGaia)).toEqual([false, true, true, true, false, false]);
|
||||
expect(decoded.map((star) => star.colorFromTemperature)).toEqual([false, false, false, false, false, true]);
|
||||
});
|
||||
|
||||
it('keeps a distance error to within a step at both ends of its range, and none as none', () => {
|
||||
|
||||
@@ -44,10 +44,10 @@ export const BYTES_PER_STAR_META =
|
||||
* Bits of the photometry column. The band takes two: none (a stand-in magnitude), V or G. None
|
||||
* of it follows from the source, which records where the *position* came from: 62 002 stars
|
||||
* Gaia places keep HYG's V and B−V, and the archive's stars in G have a B−V from their
|
||||
* temperature. The last bit says whose parallax the distance is, which for a HYG star Gaia did
|
||||
* not place can still be Gaia's.
|
||||
* temperature. The next bit says whose parallax the distance is, which for a HYG star Gaia did
|
||||
* not place can still be Gaia's, and the last whether the colour was read off a temperature.
|
||||
*/
|
||||
const PHOTOMETRY = { bandV: 1, bandG: 2, bandMask: 3, colorBpRp: 4, distanceFromGaia: 8 } as const;
|
||||
const PHOTOMETRY = { bandV: 1, bandG: 2, bandMask: 3, colorBpRp: 4, distanceFromGaia: 8, colorFromTemperature: 16 } as const;
|
||||
|
||||
/**
|
||||
* The distance error column holds the square root of the relative error, in 255ths, and 0 where
|
||||
@@ -186,7 +186,8 @@ export function encodeStarCatalog(stars: readonly StarRecord[]): {
|
||||
columns.photometry[index] =
|
||||
(star.magnitudeBand === 'V' ? PHOTOMETRY.bandV : star.magnitudeBand === 'G' ? PHOTOMETRY.bandG : 0) |
|
||||
(star.colorSystem === 'BP-RP' ? PHOTOMETRY.colorBpRp : 0) |
|
||||
(star.distanceFromGaia ? PHOTOMETRY.distanceFromGaia : 0);
|
||||
(star.distanceFromGaia ? PHOTOMETRY.distanceFromGaia : 0) |
|
||||
(star.colorFromTemperature ? PHOTOMETRY.colorFromTemperature : 0);
|
||||
// At least one step, so an error too small to round to one is not read back as none published.
|
||||
columns.distanceErrors[index] =
|
||||
star.distanceError === undefined ? 0 : Math.max(1, Math.round(Math.sqrt(Math.min(1, star.distanceError)) * DISTANCE_ERROR_STEPS));
|
||||
@@ -242,6 +243,7 @@ export function decodeStarCatalog(index: StarCatalogIndex, positions: Float32Arr
|
||||
colorSystem: Number.isNaN(colorIndex) ? undefined : photometry & PHOTOMETRY.colorBpRp ? 'BP-RP' : 'B-V',
|
||||
distanceError: distanceError === 0 ? undefined : (distanceError / DISTANCE_ERROR_STEPS) ** 2,
|
||||
distanceFromGaia: (photometry & PHOTOMETRY.distanceFromGaia) !== 0,
|
||||
colorFromTemperature: (photometry & PHOTOMETRY.colorFromTemperature) !== 0,
|
||||
...(source ? { source: source.id } : {})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,6 +33,11 @@ export interface StarRecord {
|
||||
* Mamajek). Absent with it.
|
||||
*/
|
||||
colorSystem?: 'B-V' | 'BP-RP';
|
||||
/**
|
||||
* Whether `colorIndex` was read off the dwarf sequence at the star's effective temperature rather
|
||||
* than measured: the 57 archive-placed hosts with a temperature and no B magnitude.
|
||||
*/
|
||||
colorFromTemperature?: boolean;
|
||||
/**
|
||||
* Relative uncertainty of the distance, σd/d: the relative error of the parallax it was
|
||||
* inverted from, which to first order is the same — or, for a star the Exoplanet Archive places,
|
||||
|
||||
Binary file not shown.
+4
-1
@@ -74,7 +74,10 @@ function validateStars(stars: StarRecord[]): void {
|
||||
assertCondition(decoded[i].spectralType === stars[i].spectralType, `Star catalogue round-trip lost the spectral type of star ${stars[i].id}.`);
|
||||
assertCondition(decoded[i].colorIndex === null === (stars[i].colorIndex === null), `Star catalogue round-trip changed whether star ${stars[i].id} has a colour index.`);
|
||||
assertCondition(
|
||||
decoded[i].magnitudeBand === stars[i].magnitudeBand && decoded[i].colorSystem === stars[i].colorSystem && decoded[i].distanceFromGaia === !!stars[i].distanceFromGaia,
|
||||
decoded[i].magnitudeBand === stars[i].magnitudeBand &&
|
||||
decoded[i].colorSystem === stars[i].colorSystem &&
|
||||
decoded[i].distanceFromGaia === !!stars[i].distanceFromGaia &&
|
||||
decoded[i].colorFromTemperature === !!stars[i].colorFromTemperature,
|
||||
`Star catalogue round-trip changed the photometry of star ${stars[i].id}.`
|
||||
);
|
||||
// Stored as its square root in 255ths, up to 100 %; see `star-catalog.ts`.
|
||||
|
||||
@@ -170,6 +170,7 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<{ exoplanet
|
||||
spectralType: compositeRow?.['st_spectype'] || 'Unknown',
|
||||
colorIndex,
|
||||
...(colorIndex === null ? {} : { colorSystem: 'B-V' as const }),
|
||||
...(colorIndex !== null && (b === undefined || v === undefined) ? { colorFromTemperature: true } : {}),
|
||||
...(above === undefined || below === undefined ? {} : { distanceError: (Math.abs(above) + Math.abs(below)) / 2 / distancePc }),
|
||||
source: ARCHIVE_SOURCE
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user