Files
star-map/src/app/shared/astro/deep-sky.spec.ts
T
Claude 3a859360ba Add the deep-sky backdrop, the last unbuilt piece of the plan
The design doc scopes deep-sky objects as a galaxy-view backdrop and lists
fetchDeepSky.ts, deepsky.json and deepsky.model.ts, but none of it existed —
it was the only part of the plan with no implementation behind it.

ETL: fetchDeepSky.ts pulls the OpenNGC catalog, classifies each object as a
galaxy/nebula/cluster, and keeps the ~460 worth drawing (everything Messier,
everything with a common name, and anything brighter than magnitude 9) out of
~12,000 mostly-anonymous rows. build.ts runs it and validates the output.

Distances are the hard part: OpenNGC has no distance column, and both fallbacks
fail for the best-known objects. M31, M33 and M42 are Local Group members whose
redshift is negative or absent, and a galaxy's catalog parallax comes from a
cross-matched foreground star — 6 mas for M31 would put a 780 kpc galaxy at
167 pc. So records store a unit direction on the celestial sphere rather than a
position (the line of sight is always known precisely, and the objects are drawn
on a fixed backdrop shell where true distance is unusable anyway), and distance
is optional metadata carrying its own provenance. Parallax is trusted only for
galactic objects, redshift only above z=0.003 where expansion outweighs peculiar
velocity. 330 of 463 get a distance; the rest honestly report none.

Rendering: DeepSkyRenderer paints the objects as soft additive billboards on a
2500 pc shell — clear of the 50 pc star field, beyond the camera's 2000 pc orbit
limit, and inside its 5000 pc far plane. Size comes from real angular extent, so
Andromeda is six times wider than the full Moon, clamped at both ends. Sprites
rather than points because the WebGPU backend caps point primitives at one pixel;
materials are shared per kind and brightness band, so 460 objects cost nine of
them. The brightest dozen get permanent labels, which needed the label overlay to
accept string ids alongside numeric star ids. The backdrop is decorative, so a
failure to load its dataset is logged and the star field comes up regardless.

Also documents the app in the README, which until now covered only the plugin
marketplace.

Tests: 112 passing, up from 54. Build, both typechecks and the Playwright suite
are green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
2026-08-03 15:19:39 +00:00

142 lines
5.4 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
classifyOpenNgcType,
estimateDeepSkyDistancePc,
HUBBLE_CONSTANT_KM_S_PER_MPC,
isNotableDeepSkyObject,
NOTABLE_MAGNITUDE_LIMIT,
redshiftToDistancePc,
SPEED_OF_LIGHT_KM_S
} from './deep-sky';
describe('classifyOpenNgcType', () => {
it('groups every galaxy-ish type as a galaxy', () => {
for (const type of ['G', 'GPair', 'GTrpl', 'GGroup']) {
expect(classifyOpenNgcType(type)).toBe('galaxy');
}
});
it('groups nebulae, remnants and cluster-with-nebulosity as nebulae', () => {
for (const type of ['PN', 'HII', 'EmN', 'RfN', 'Neb', 'DrkN', 'SNR', 'Cl+N']) {
expect(classifyOpenNgcType(type)).toBe('nebula');
}
});
it('groups open, globular and stellar-association types as clusters', () => {
for (const type of ['OCl', 'GCl', '*Ass']) {
expect(classifyOpenNgcType(type)).toBe('cluster');
}
});
it('rejects catalog rows that are not deep-sky objects', () => {
// Duplicates, non-existent entries, plain and double stars, novae, and the catch-all.
for (const type of ['Dup', 'NonEx', '*', '**', 'Nova', 'Other']) {
expect(classifyOpenNgcType(type)).toBeNull();
}
});
it('rejects missing or unknown types', () => {
for (const type of ['', ' ', 'wat', undefined, null]) {
expect(classifyOpenNgcType(type)).toBeNull();
}
});
it('ignores surrounding whitespace', () => {
expect(classifyOpenNgcType(' G ')).toBe('galaxy');
});
});
describe('redshiftToDistancePc', () => {
it('applies the Hubble law', () => {
const redshift = 0.02286;
const expectedMpc = (SPEED_OF_LIGHT_KM_S * redshift) / HUBBLE_CONSTANT_KM_S_PER_MPC;
expect(redshiftToDistancePc(redshift)).toBeCloseTo(expectedMpc * 1e6, 0);
});
it('scales linearly with redshift', () => {
const near = redshiftToDistancePc(0.01)!;
const far = redshiftToDistancePc(0.02)!;
expect(far / near).toBeCloseTo(2, 9);
});
it('rejects a blueshift, which carries no distance information', () => {
// M31 approaches us at ~300 km/s.
expect(redshiftToDistancePc(-0.001)).toBeNull();
});
it('rejects redshifts too small to be dominated by cosmological expansion', () => {
// The Small Magellanic Cloud: a real positive redshift that yields a 33x-wrong distance.
expect(redshiftToDistancePc(0.000527)).toBeNull();
});
it('rejects null and non-finite input', () => {
expect(redshiftToDistancePc(null)).toBeNull();
expect(redshiftToDistancePc(Number.NaN)).toBeNull();
expect(redshiftToDistancePc(Number.POSITIVE_INFINITY)).toBeNull();
});
});
describe('estimateDeepSkyDistancePc', () => {
it('prefers parallax for a galactic object', () => {
// The Helix Nebula, ~200 pc away.
const estimate = estimateDeepSkyDistancePc({ kind: 'nebula', redshift: 0.05, parallaxMas: 4.98 });
expect(estimate?.method).toBe('parallax');
expect(estimate?.distancePc).toBeCloseTo(200.8, 1);
});
it('refuses parallax for a galaxy, because the catalog value is a foreground star', () => {
// OpenNGC lists 6 mas for M31 — that would put a 780 kpc galaxy at 167 pc.
const estimate = estimateDeepSkyDistancePc({ kind: 'galaxy', redshift: -0.001, parallaxMas: 6 });
expect(estimate).toBeNull();
});
it('falls back to redshift for a distant galaxy', () => {
const estimate = estimateDeepSkyDistancePc({ kind: 'galaxy', redshift: 0.00365, parallaxMas: null });
expect(estimate?.method).toBe('redshift');
expect(estimate!.distancePc).toBeGreaterThan(1e7);
});
it('falls back to redshift when a galactic object has no usable parallax', () => {
for (const parallaxMas of [null, 0, -3]) {
const estimate = estimateDeepSkyDistancePc({ kind: 'cluster', redshift: 0.01, parallaxMas });
expect(estimate?.method).toBe('redshift');
}
});
it('rejects a parallax implying a distance beyond the Milky Way', () => {
// 0.000001 mas would imply a billion parsecs — noise, not a measurement.
const estimate = estimateDeepSkyDistancePc({ kind: 'cluster', redshift: null, parallaxMas: 1e-6 });
expect(estimate).toBeNull();
});
it('returns null when neither source is usable', () => {
expect(estimateDeepSkyDistancePc({ kind: 'nebula', redshift: null, parallaxMas: null })).toBeNull();
expect(estimateDeepSkyDistancePc({ kind: 'galaxy', redshift: null, parallaxMas: null })).toBeNull();
});
});
describe('isNotableDeepSkyObject', () => {
it('keeps anything in the Messier catalog, however faint', () => {
expect(isNotableDeepSkyObject({ messier: 'M76', commonName: null, magnitude: 12 })).toBe(true);
});
it('keeps anything with a common name', () => {
expect(isNotableDeepSkyObject({ messier: null, commonName: 'Helix Nebula', magnitude: 20 })).toBe(true);
});
it('keeps an anonymous object that is bright enough', () => {
expect(isNotableDeepSkyObject({ messier: null, commonName: null, magnitude: NOTABLE_MAGNITUDE_LIMIT })).toBe(true);
});
it('drops an anonymous object fainter than the limit', () => {
expect(isNotableDeepSkyObject({ messier: null, commonName: null, magnitude: NOTABLE_MAGNITUDE_LIMIT + 0.1 })).toBe(false);
});
it('drops an anonymous object with no measured magnitude', () => {
// Guards the `Number('') === 0` trap: an unphotometered object must not be treated as
// magnitude 0, which would make it brighter than every star in the sky.
expect(isNotableDeepSkyObject({ messier: null, commonName: null, magnitude: null })).toBe(false);
});
});