Files
star-map/src/app/shared/models/star-catalog.spec.ts
T
SenrokaiandClaude Opus 5 7a112e4bb3 Answer the review: HYG's own last-resort name is a designation too
Junie: a HYG star that fell all the way through the ETL's naming chain --
no proper name, Bayer, Flamsteed, HD, Gliese or HIP -- is called "HYG <id>",
and with `source: 'hyg'` the predicate was looking for a lower-case "hyg "
prefix and calling it named. None in the current catalogue, but the path is
in `tools/etl/fetchStars.ts` and a refresh could walk it.

Fixed in the table rather than in the predicate: `hyg: 'HYG'` next to
`gaia: 'Gaia DR3'`, so the encoder, the decoder and the predicate all read the
one rule. The sourceless case reads the same entry instead of repeating it.

npm test 609/609, build and ETL typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fcUfL82nvyh9VebX1Fz6w
2026-08-27 20:16:11 +02:00

149 lines
7.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog, isDesignation } from './star-catalog';
import { StarRecord } from './star.model';
const STARS: StarRecord[] = [
{ id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 },
{ id: 71456, name: 'Rigil Kentaurus', x: -1.35, y: -0.04, z: -0.98, magnitude: -0.01, spectralType: 'G2V', colorIndex: 0.71 },
{ id: 32263, name: 'Sirius', x: -0.49, y: 2.47, z: -0.75, magnitude: -1.44, spectralType: 'A0m...', colorIndex: 0.009 },
// The case a plain number cannot carry: about a tenth of the catalogue was never photometered.
{ id: 118554, name: 'GJ 3512', x: 20.1, y: -3.4, z: 8.8, magnitude: 15, spectralType: 'Unknown', colorIndex: null }
];
describe('encodeStarCatalog / decodeStarCatalog', () => {
const encoded = encodeStarCatalog(STARS);
const decoded = decodeStarCatalog(encoded.index, encoded.positions, encoded.meta);
it('round-trips every field of every star', () => {
expect(decoded).toHaveLength(STARS.length);
decoded.forEach((star, index) => {
const original = STARS[index];
expect(star.id).toBe(original.id);
expect(star.name).toBe(original.name);
expect(star.spectralType).toBe(original.spectralType);
expect(star.x).toBeCloseTo(original.x, 4);
expect(star.y).toBeCloseTo(original.y, 4);
expect(star.z).toBeCloseTo(original.z, 4);
expect(star.magnitude).toBeCloseTo(original.magnitude, 4);
});
});
it('carries an absent colour index through as null, not as zero', () => {
// Zero is a real colour index meaning a hot blue-white A-type star, so it cannot double as
// "not measured" — the float column uses NaN, which nothing else can be.
expect(decoded[3].colorIndex).toBeNull();
expect(decoded[0].colorIndex).toBeCloseTo(0.656, 5);
expect(decoded[2].colorIndex).toBeCloseTo(0.009, 5);
});
it('sizes both binaries exactly to the star count', () => {
expect(encoded.positions.byteLength).toBe(STARS.length * BYTES_PER_STAR_POSITION);
expect(encoded.meta.byteLength).toBe(STARS.length * BYTES_PER_STAR_META);
});
it('hands positions over as a bare xyz buffer, which is what the GPU is given', () => {
expect(Array.from(encoded.positions.slice(0, 3))).toEqual([0, 0, 0]);
expect(encoded.positions[3]).toBeCloseTo(-1.35, 4);
});
it('stores each distinct spectral type once and refers to it by index', () => {
// Two of the four stars are G2V. Across the real catalogue this is 68000 stars sharing
// about 2600 strings, which is why the dictionary is worth having.
expect(encoded.index.spectralTypes).toEqual(['G2V', 'A0m...', 'Unknown']);
});
it('keeps the index free of anything that is not a string, since the numbers are elsewhere', () => {
expect(encoded.index.count).toBe(STARS.length);
expect(encoded.index.names).toEqual(STARS.map((star) => star.name));
});
it('writes no per-star source column when every star came from the same place', () => {
// It would be a couple of hundred kilobytes to say nothing.
expect(encoded.index.sourceIndices).toEqual([]);
});
it('is smaller than the array of objects it replaced', () => {
// The whole reason for the format: the old encoding repeated eight key names per star.
const asObjects = JSON.stringify(STARS).length;
const asCatalogue = JSON.stringify(encoded.index).length + encoded.positions.byteLength + encoded.meta.byteLength;
expect(asCatalogue).toBeLessThan(asObjects);
});
it('handles an empty catalogue without producing a malformed buffer', () => {
const empty = encodeStarCatalog([]);
expect(empty.positions.byteLength).toBe(0);
expect(empty.meta.byteLength).toBe(0);
expect(decodeStarCatalog(empty.index, empty.positions, empty.meta)).toEqual([]);
});
it('survives more distinct spectral types than a handful, up to the column width', () => {
// The dictionary index is 16-bit, and the real catalogue has about 2600 distinct types.
const many: StarRecord[] = Array.from({ length: 5000 }, (_, i) => ({
id: i,
name: `HYG ${i}`,
x: i,
y: 0,
z: 0,
magnitude: 10,
spectralType: `S${i}`,
colorIndex: null
}));
const round = decodeStarCatalog(...(({ index, positions, meta }) => [index, positions, meta] as const)(encodeStarCatalog(many)));
expect(round[4999].spectralType).toBe('S4999');
expect(round[4999].id).toBe(4999);
});
});
describe('star catalogue provenance and derived names', () => {
const MIXED: StarRecord[] = [
{ id: 5, name: 'Sirius', x: 1, y: 0, z: 0, magnitude: -1.4, spectralType: 'A0', colorIndex: 0.0, source: 'hyg' },
// A survey star with no name of its own: what it is called is its catalogue designation.
{ id: 900, name: 'Gaia DR3 900', x: 0, y: 2, z: 0, magnitude: 11, spectralType: 'Unknown', colorIndex: 1.2, source: 'gaia' },
{ id: 901, name: 'Gaia DR3 901', x: 0, y: 0, z: 3, magnitude: 11.5, spectralType: 'Unknown', colorIndex: 1.3, source: 'gaia' }
];
const encoded = encodeStarCatalog(MIXED);
const decoded = decodeStarCatalog(encoded.index, encoded.positions, encoded.meta);
it('stores nothing for a name that is just the catalogue designation', () => {
// 25 bytes per star, per million stars, to repeat what two adjacent fields already say.
expect(encoded.index.names).toEqual(['Sirius', '', '']);
});
it('regenerates those names exactly on the way back', () => {
expect(decoded.map((star) => star.name)).toEqual(['Sirius', 'Gaia DR3 900', 'Gaia DR3 901']);
});
it('carries each star provenance through', () => {
expect(decoded.map((star) => star.source)).toEqual(['hyg', 'gaia', 'gaia']);
});
it('writes the source column only once the stars differ', () => {
expect(encoded.index.sources.map((source) => source.id)).toEqual(['hyg', 'gaia']);
expect(encoded.index.sourceIndices).toEqual([0, 1, 1]);
});
it('keeps a real name even when the star has a source that could generate one', () => {
const named = encodeStarCatalog([{ ...MIXED[1], name: 'Some Proper Name' }]);
expect(named.index.names).toEqual(['Some Proper Name']);
expect(decodeStarCatalog(named.index, named.positions, named.meta)[0].name).toBe('Some Proper Name');
});
it('tells a name somebody gave from the designation a survey generates', () => {
expect(isDesignation(decoded[0])).toBe(false);
expect(isDesignation(decoded[1])).toBe(true);
// The real catalogue names Gaia stars by the survey's nineteen-digit source id, which no
// 32-bit row id can equal — so it is the prefix that decides, not a round trip through the id.
expect(isDesignation({ ...MIXED[1], name: 'Gaia DR3 5853498713190525696' })).toBe(true);
expect(isDesignation({ ...MIXED[1], name: 'Proxima Centauri' })).toBe(false);
// HYG's own last resort, once every designation it knows has come up empty. Upper case,
// whatever case the source id is in.
expect(isDesignation({ ...MIXED[0], name: 'HYG 5' })).toBe(true);
expect(isDesignation({ ...MIXED[0], source: undefined, name: 'HYG 5' })).toBe(true);
expect(isDesignation({ ...MIXED[0], name: 'HD 48915' })).toBe(false);
});
});