diff --git a/README.md b/README.md index 57ff142..705debe 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/app/features/galaxy-system/star-readouts.spec.ts b/src/app/features/galaxy-system/star-readouts.spec.ts index ced5dec..2fffbcb 100644 --- a/src/app/features/galaxy-system/star-readouts.spec.ts +++ b/src/app/features/galaxy-system/star-readouts.spec.ts @@ -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'); diff --git a/src/app/features/galaxy-system/star-readouts.ts b/src/app/features/galaxy-system/star-readouts.ts index b601a6f..dd18ae6 100644 --- a/src/app/features/galaxy-system/star-readouts.ts +++ b/src/app/features/galaxy-system/star-readouts.ts @@ -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 } : {}) }] diff --git a/src/app/shared/astro/spectral.spec.ts b/src/app/shared/astro/spectral.spec.ts index 7fcef7d..0300767 100644 --- a/src/app/shared/astro/spectral.spec.ts +++ b/src/app/shared/astro/spectral.spec.ts @@ -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(); + } }); }); diff --git a/src/app/shared/astro/spectral.ts b/src/app/shared/astro/spectral.ts index 82fa1c9..1b8362a 100644 --- a/src/app/shared/astro/spectral.ts +++ b/src/app/shared/astro/spectral.ts @@ -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]); } /** diff --git a/src/app/shared/models/star-catalog.spec.ts b/src/app/shared/models/star-catalog.spec.ts index f085ce6..012d531 100644 --- a/src/app/shared/models/star-catalog.spec.ts +++ b/src/app/shared/models/star-catalog.spec.ts @@ -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', () => { diff --git a/src/app/shared/models/star-catalog.ts b/src/app/shared/models/star-catalog.ts index 0972a10..db850b9 100644 --- a/src/app/shared/models/star-catalog.ts +++ b/src/app/shared/models/star-catalog.ts @@ -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 } : {}) }; } diff --git a/src/app/shared/models/star.model.ts b/src/app/shared/models/star.model.ts index 392c281..43ee7ff 100644 --- a/src/app/shared/models/star.model.ts +++ b/src/app/shared/models/star.model.ts @@ -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, diff --git a/src/assets/data/stars-meta.bin b/src/assets/data/stars-meta.bin index c3e0e8e..6d4fb64 100644 Binary files a/src/assets/data/stars-meta.bin and b/src/assets/data/stars-meta.bin differ diff --git a/tools/etl/build.ts b/tools/etl/build.ts index 6b89d7d..00aca0b 100644 --- a/tools/etl/build.ts +++ b/tools/etl/build.ts @@ -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`. diff --git a/tools/etl/fetchExoplanets.ts b/tools/etl/fetchExoplanets.ts index 7d1f1c0..6400b8f 100644 --- a/tools/etl/fetchExoplanets.ts +++ b/tools/etl/fetchExoplanets.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 };