Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
# @suaveplan/stars - Star catalog and observation provider: in-memory catalog source, multi-source aggregating provider, cone queries, and observed-star projections built on the @suaveplan/types StarProvider contract.
## Summary
Star catalog provider.
<!-- begin:auto-docs -->
## Documentation tree
- [catalog](./src/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [catalog](./src/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types.md) — The types module declares the two foundational shapes that `@suaveplan/stars` is built on: `RawStar`, the lossless cata…
<!-- end:auto-docs --> - [catalog](./src/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types.md) — The types module declares the two foundational shapes that `@suaveplan/stars` is built on: `RawStar`, the lossless cata…
- [catalog](./src/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types.md) — The types module declares the two foundational shapes that `@suaveplan/stars` is built on: `RawStar`, the lossless cata…
- [catalog](./src/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types.md) — The types module declares the two foundational shapes that `@suaveplan/stars` is built on: `RawStar`, the lossless cata…
- [catalog](./src/catalog/catalog.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ - [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types.md) — The types module declares the two foundational shapes that `@suaveplan/stars` is built on: `RawStar`, the lossless cata…
## Install
```bash
bun add @suaveplan/stars
```

View File

@@ -0,0 +1,47 @@
{
"name": "@suaveplan/stars",
"version": "0.1.0",
"description": "Star catalog and observation provider: in-memory catalog source, multi-source aggregating provider, cone queries, and observed-star projections built on the @suaveplan/types StarProvider contract.",
"type": "module",
"sideEffects": false,
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./provider/provider": {
"import": "./dist/provider/provider.js",
"types": "./dist/provider/provider.d.ts"
},
"./catalog/catalog": {
"import": "./dist/catalog/catalog.js",
"types": "./dist/catalog/catalog.d.ts"
}
},
"scripts": {
"build": "vite build",
"lint": "bunx biome check src/",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json"
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/geo": "workspace:^",
"@suaveplan/geo-planet": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/universe": "workspace:^",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"peerDependencies": {
"typescript": "^5.8"
}
}

View File

@@ -0,0 +1,50 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The catalog module provides an in-memory implementation of the `StarCatalogSource` contract used by `@suaveplan/stars` providers. It accepts a static array of `RawStar` records and exposes them through the same `queryCone` and `getStar` surface that production catalog adapters (Hipparcos, Tycho-2, Gaia DR3) implement, so consumers can swap real catalogs for fixtures without touching call sites.
It is the recommended source for tests, demos, offline planetariums, and bootstrap data flows where pulling a remote catalog is unnecessary or undesirable. The implementation favours predictability over scale: lookups are O(n) for cone queries and O(1) for id resolution backed by a single `Map`.
## Features
- Pure in-memory `StarCatalogSource` with no external dependencies.
- Constant-time `getStar(catalogId)` lookup via internal `Map`.
- Naive Cartesian cone filter on `(ra, dec)` for deterministic test output.
- Returns the same `RawStar` shape consumed by `createStarProvider`.
- Zero allocations on the hot path beyond the result array.
## Usage: Basic Example
```ts
import { createInMemoryCatalogSource } from "@suaveplan/stars";
const source = createInMemoryCatalogSource([
{ catalogId: "HIP-32349", ra: 101.287, dec: -16.716, apparentMagnitude: -1.46 },
]);
const sirius = await source.getStar("HIP-32349");
```
## Usage: Advanced Example
```ts
import { createInMemoryCatalogSource, createStarProvider } from "@suaveplan/stars";
const source = createInMemoryCatalogSource([
{ catalogId: "HIP-32349", ra: 101.287, dec: -16.716, apparentMagnitude: -1.46, spectralClass: "A1V", distance: 2.64 },
{ catalogId: "HIP-37279", ra: 114.825, dec: 5.225, apparentMagnitude: 0.4, spectralClass: "F5IV-V" },
]);
const provider = createStarProvider([source]);
const nearby = await provider.queryCone(101, -16, 5);
```
## API Reference
- `createInMemoryCatalogSource(stars: RawStar[]): StarCatalogSource` — builds a source whose `queryCone(ra, dec, radiusDeg)` returns every star inside the Cartesian disk of the given radius (degrees) around the centre, and whose `getStar(catalogId)` returns the matching record or `null`. Both methods are async to satisfy the interface; neither throws.
## Implementation Notes
The cone filter uses Euclidean distance on raw `(ra, dec)` pairs rather than a spherical-trig great-circle distance. This is deliberate: the source is for fixtures and small catalogs, where simplicity and determinism beat correctness near the poles or across the RA wrap. For real-sky use, layer a cosine-correction adapter or replace the source with a HEALPix-backed implementation. The internal `Map` is built once at construction; mutating the input array post-construction is not supported.

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createInMemoryCatalogSource } from "./catalog.js";
describe("createInMemoryCatalogSource", () => {
const stars = [
{ catalogId: "S1", ra: 10, dec: 20, apparentMagnitude: 2 },
{ catalogId: "S2", ra: 11, dec: 21, apparentMagnitude: 3 },
];
it("queries cone", { timeout: 30000 }, async () => {
const source = createInMemoryCatalogSource(stars);
const results = await source.queryCone(10, 20, 2);
expect(results.length).toBeGreaterThan(0);
});
it("gets star by catalogId", { timeout: 30000 }, async () => {
const source = createInMemoryCatalogSource(stars);
const star = await source.getStar("S1");
expect(star).not.toBeNull();
expect(star!.catalogId).toBe("S1");
});
it("returns null for missing catalogId", { timeout: 30000 }, async () => {
const source = createInMemoryCatalogSource(stars);
expect(await source.getStar("S3")).toBeNull();
});
});

View File

@@ -0,0 +1,23 @@
import type { RawStar, StarCatalogSource } from "../types.js";
export function createInMemoryCatalogSource(
stars: RawStar[]
): StarCatalogSource {
const map = new Map(stars.map((s) => [s.catalogId, s]));
return {
async queryCone(
ra: number,
dec: number,
radiusDeg: number
): Promise<RawStar[]> {
return stars.filter((s) => {
const dRa = s.ra - ra;
const dDec = s.dec - dec;
return Math.sqrt(dRa * dRa + dDec * dDec) <= radiusDeg;
});
},
async getStar(catalogId: string): Promise<RawStar | null> {
return map.get(catalogId) ?? null;
},
};
}

View File

@@ -0,0 +1 @@
export { createInMemoryCatalogSource } from "./catalog.js";

View File

@@ -0,0 +1,3 @@
export { createInMemoryCatalogSource } from "./catalog/index.js";
export { createStarProvider, getVisibleStars } from "./provider/index.js";
export type { RawStar, StarCatalogSource } from "./types.js";

View File

@@ -0,0 +1,4 @@
export {
createStarProvider,
getVisibleStars,
} from "./provider.js";

View File

@@ -0,0 +1,57 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The provider module composes one or more `StarCatalogSource` instances into a single `StarProvider` (from `@suaveplan/types`) that consumers — sky renderers, planetarium UIs, telescope planners — can program against without binding to a specific catalog backend. It implements the cross-source fallback semantics expected by the rest of the geo family.
It also exposes a low-level `getVisibleStars` helper that returns `RawStar` records filtered by apparent magnitude, intended for diagnostic tools and tests that operate below the `StarProvider` abstraction.
## Features
- Multi-source aggregation with first-non-empty fallback for `queryCone`.
- First-hit short-circuit for `getStar` across sources.
- `getVisibleStars` projects every queried star into an `ObservedStar` with placeholder altitude/azimuth/extincted-magnitude fields suitable for the contract surface.
- Total support for the optional `RawStar` columns (`spectralClass`, `distance`, `properMotionRa`, `properMotionDec`, `parallax`) — present fields are forwarded, absent ones omitted under `exactOptionalPropertyTypes`.
- Pure functions with no global state and no I/O beyond delegated source calls.
## Usage: Basic Example
```ts
import { createInMemoryCatalogSource, createStarProvider } from "@suaveplan/stars";
const source = createInMemoryCatalogSource([
{ catalogId: "HIP-32349", ra: 101.287, dec: -16.716, apparentMagnitude: -1.46 },
]);
const provider = createStarProvider([source]);
const stars = await provider.queryCone(101, -16, 5);
```
## Usage: Advanced Example
```ts
import { createInMemoryCatalogSource, createStarProvider, getVisibleStars } from "@suaveplan/stars";
const primary = createInMemoryCatalogSource([]);
const fallback = createInMemoryCatalogSource([
{ catalogId: "HIP-37279", ra: 114.825, dec: 5.225, apparentMagnitude: 0.4 },
{ catalogId: "HIP-91262", ra: 279.234, dec: 38.784, apparentMagnitude: 0.03 },
]);
const provider = createStarProvider([primary, fallback]);
const visible = await provider.getVisibleStars({
kind: "surface",
planet: { name: "Earth", radius: 6_371_000, rotationPeriod: 86_164 },
coord: { latitude: 51.4779, longitude: -0.0015 },
});
const bright = await getVisibleStars([fallback], { latitude: 0, longitude: 0 }, 0, 1);
```
## API Reference
- `createStarProvider(sources: StarCatalogSource[]): StarProvider` — returns a `StarProvider` whose `queryCone(ra, dec, radiusDeg)` walks `sources` in order and returns the first non-empty result mapped to `CatalogStar`; whose `getStar(catalogId)` returns the first match across sources or `null`; and whose `getVisibleStars(observer)` returns every star across all sources projected to `ObservedStar`.
- `getVisibleStars(sources, observer, minAltitudeDeg?, maxMagnitude?): Promise<RawStar[]>` — collects raw stars from every source via a 90-degree cone at the celestial origin and filters by `maxMagnitude` (default 6). `observer` and `minAltitudeDeg` are accepted for forward-compatibility but currently unused.
## Implementation Notes
`toCatalogStar` uses guarded `Object.assign` for optional fields so undefined values do not leak as own properties — this matters under `exactOptionalPropertyTypes: true`. `toObservedStar` returns hard-coded altitude/azimuth values; this is a contract-only shim and a real implementation should plug in horizontal-coordinate transforms from `@suaveplan/geo`. `queryCone` short-circuits on the first non-empty source, which is appropriate for layered hot/cold caches but wrong for true union semantics; if you need union behaviour, compose sources upstream.

View File

@@ -0,0 +1,136 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createInMemoryCatalogSource } from "../catalog/catalog.js";
import { createStarProvider, getVisibleStars } from "./provider.js";
describe("createStarProvider", () => {
it("returns stars from source via queryCone", {
timeout: 30000,
}, async () => {
const source = createInMemoryCatalogSource([
{ catalogId: "S1", ra: 10, dec: 20, apparentMagnitude: 2 },
]);
const provider = createStarProvider([source]);
const stars = await provider.queryCone(10, 20, 2);
expect(stars.length).toBe(1);
expect(stars[0]!.catalogId).toBe("S1");
});
it("returns empty when no sources match", { timeout: 30000 }, async () => {
const provider = createStarProvider([]);
const stars = await provider.queryCone(0, 0, 1);
expect(stars).toEqual([]);
});
it("falls back to next source when first is empty", {
timeout: 30000,
}, async () => {
const empty = createInMemoryCatalogSource([]);
const source = createInMemoryCatalogSource([
{ catalogId: "S1", ra: 10, dec: 20, apparentMagnitude: 2 },
]);
const provider = createStarProvider([empty, source]);
const stars = await provider.queryCone(10, 20, 2);
expect(stars.length).toBe(1);
});
it("getStar returns a catalog star", { timeout: 30000 }, async () => {
const source = createInMemoryCatalogSource([
{ catalogId: "S1", ra: 10, dec: 20, apparentMagnitude: 2 },
]);
const provider = createStarProvider([source]);
const star = await provider.getStar("S1");
expect(star).not.toBeNull();
expect(star!.catalogId).toBe("S1");
});
it("getStar returns null when not found", { timeout: 30000 }, async () => {
const provider = createStarProvider([]);
expect(await provider.getStar("X")).toBeNull();
});
it("getStar falls through to next source when first returns null", {
timeout: 30000,
}, async () => {
// Exercises the falsy branch of `if (star)` in getStar: first
// source has no match, second source supplies the result.
const empty = createInMemoryCatalogSource([]);
const populated = createInMemoryCatalogSource([
{ catalogId: "S2", ra: 5, dec: 5, apparentMagnitude: 3 },
]);
const provider = createStarProvider([empty, populated]);
const star = await provider.getStar("S2");
expect(star).not.toBeNull();
expect(star!.catalogId).toBe("S2");
});
it("toCatalogStar preserves every optional field when present", {
timeout: 30000,
}, async () => {
// Exercises the five `if (s.X !== undefined)` branches in
// toCatalogStar so each optional field copies through.
const source = createInMemoryCatalogSource([
{
catalogId: "S1",
ra: 10,
dec: 20,
apparentMagnitude: 2,
spectralClass: "G2V",
distance: 1.0,
properMotionRa: 0.001,
properMotionDec: -0.002,
parallax: 0.05,
},
]);
const provider = createStarProvider([source]);
const star = await provider.getStar("S1");
expect(star).not.toBeNull();
expect(star).toMatchObject({
catalogId: "S1",
ra: 10,
dec: 20,
apparentMagnitude: 2,
spectralClass: "G2V",
distance: 1.0,
properMotionRa: 0.001,
properMotionDec: -0.002,
parallax: 0.05,
});
});
it("getVisibleStars returns observed stars", {
timeout: 30000,
}, async () => {
const source = createInMemoryCatalogSource([
{ catalogId: "S1", ra: 10, dec: 20, apparentMagnitude: 2 },
]);
const provider = createStarProvider([source]);
const stars = await provider.getVisibleStars({
kind: "surface",
planet: {
name: "Earth",
radius: 6_371_000,
rotationPeriod: 86_164,
},
coord: { latitude: 0, longitude: 0 },
});
expect(stars.length).toBeGreaterThan(0);
expect(stars[0]!).toHaveProperty("altitude");
expect(stars[0]!).toHaveProperty("azimuth");
expect(stars[0]!).toHaveProperty("extinctedMagnitude");
});
});
describe("getVisibleStars", () => {
it("filters by magnitude", { timeout: 30000 }, async () => {
const source = createInMemoryCatalogSource([
{ catalogId: "S1", ra: 0, dec: 0, apparentMagnitude: 2 },
{ catalogId: "S2", ra: 0, dec: 0, apparentMagnitude: 8 },
]);
const stars = await getVisibleStars([source], {
latitude: 0,
longitude: 0,
});
expect(stars.length).toBe(1);
expect(stars[0]!.catalogId).toBe("S1");
});
});

View File

@@ -0,0 +1,83 @@
import type { CatalogStar, ObservedStar, StarProvider } from "@suaveplan/types";
import type { RawStar, StarCatalogSource } from "../types.js";
function toCatalogStar(s: RawStar): CatalogStar {
const star: CatalogStar = {
catalogId: s.catalogId,
ra: s.ra,
dec: s.dec,
apparentMagnitude: s.apparentMagnitude,
};
if (s.spectralClass !== undefined) {
Object.assign(star, { spectralClass: s.spectralClass });
}
if (s.distance !== undefined) {
Object.assign(star, { distance: s.distance });
}
if (s.properMotionRa !== undefined) {
Object.assign(star, { properMotionRa: s.properMotionRa });
}
if (s.properMotionDec !== undefined) {
Object.assign(star, { properMotionDec: s.properMotionDec });
}
if (s.parallax !== undefined) {
Object.assign(star, { parallax: s.parallax });
}
return star;
}
function toObservedStar(s: RawStar): ObservedStar {
return {
...toCatalogStar(s),
altitude: 45,
azimuth: 180,
extinctedMagnitude: s.apparentMagnitude,
};
}
export function createStarProvider(sources: StarCatalogSource[]): StarProvider {
return {
async getVisibleStars(): Promise<ObservedStar[]> {
const all: ObservedStar[] = [];
for (const source of sources) {
const stars = await source.queryCone(0, 0, 180);
all.push(...stars.map(toObservedStar));
}
return all;
},
async queryCone(
ra: number,
dec: number,
radiusDeg: number
): Promise<CatalogStar[]> {
for (const source of sources) {
const stars = await source.queryCone(ra, dec, radiusDeg);
if (stars.length > 0) {
return stars.map(toCatalogStar);
}
}
return [];
},
async getStar(catalogId: string): Promise<CatalogStar | null> {
for (const source of sources) {
const star = await source.getStar(catalogId);
if (star) return toCatalogStar(star);
}
return null;
},
};
}
export async function getVisibleStars(
sources: StarCatalogSource[],
_observer: { latitude: number; longitude: number },
_minAltitudeDeg = 0,
_maxMagnitude = 6
): Promise<RawStar[]> {
const all: RawStar[] = [];
for (const source of sources) {
const stars = await source.queryCone(0, 0, 90);
all.push(...stars);
}
return all.filter((s) => s.apparentMagnitude <= _maxMagnitude);
}

View File

@@ -0,0 +1,48 @@
## Purpose
The types module declares the two foundational shapes that `@suaveplan/stars` is built on: `RawStar`, the lossless catalog record returned by every source, and `StarCatalogSource`, the source contract that providers compose. They sit deliberately one layer below `@suaveplan/types`'s `CatalogStar` / `ObservedStar` / `StarProvider` so that catalog-backend authors have a stable, narrowly-scoped surface to implement against.
Keeping these types local to the `stars` package — instead of in `@suaveplan/types` — avoids forcing every downstream universe-tier consumer to take on the source-adapter shape, while still letting the provider layer translate cleanly into the public `StarProvider` contract.
## Features
- `RawStar` carries the required astrometry (`catalogId`, `ra`, `dec`, `apparentMagnitude`) plus optional photometric and motion columns (`spectralClass`, `distance`, `properMotionRa`, `properMotionDec`, `parallax`).
- `StarCatalogSource` is a structural interface — any object meeting the shape works, no class inheritance required.
- Async-by-default methods so HTTP-, file-, and DB-backed sources fit without wrapping.
- No runtime cost; the module is types-only and erased at build time.
## Usage: Basic Example
```ts
import type { RawStar, StarCatalogSource } from "@suaveplan/stars";
const star: RawStar = { catalogId: "HIP-32349", ra: 101.287, dec: -16.716, apparentMagnitude: -1.46 };
```
## Usage: Advanced Example
```ts
import type { RawStar, StarCatalogSource } from "@suaveplan/stars";
class HttpCatalogSource implements StarCatalogSource {
constructor(private readonly endpoint: string) {}
async queryCone(ra: number, dec: number, radiusDeg: number): Promise<RawStar[]> {
const url = `${this.endpoint}/cone?ra=${ra}&dec=${dec}&r=${radiusDeg}`;
const res = await fetch(url);
return (await res.json()) as RawStar[];
}
async getStar(catalogId: string): Promise<RawStar | null> {
const res = await fetch(`${this.endpoint}/star/${encodeURIComponent(catalogId)}`);
return res.ok ? ((await res.json()) as RawStar) : null;
}
}
```
## API Reference
- `RawStar` — `{ catalogId: string; ra: number; dec: number; apparentMagnitude: number; spectralClass?: string; distance?: number; properMotionRa?: number; properMotionDec?: number; parallax?: number }`. Right ascension and declination are degrees (J2000); apparent magnitude is the visual band; distance is parsecs; proper motions are mas/yr; parallax is mas.
- `StarCatalogSource` — `{ queryCone(ra, dec, radiusDeg): Promise<RawStar[]>; getStar(catalogId): Promise<RawStar | null> }`. Implementations must be referentially safe (no shared mutable result arrays) and must not throw for missing ids — return `null` instead.
## Implementation Notes
`RawStar` intentionally omits derived horizontal coordinates and extincted magnitudes — those belong on `ObservedStar` and are computed by the provider layer against an observer pose. The optional fields use `T | undefined` rather than nullable so authors can `Object.assign` only when present (see `provider.ts`), which keeps records small and TypeScript happy under `exactOptionalPropertyTypes`.

View File

@@ -0,0 +1,16 @@
export interface RawStar {
catalogId: string;
ra: number;
dec: number;
apparentMagnitude: number;
spectralClass?: string;
distance?: number;
properMotionRa?: number;
properMotionDec?: number;
parallax?: number;
}
export interface StarCatalogSource {
queryCone(ra: number, dec: number, radiusDeg: number): Promise<RawStar[]>;
getStar(catalogId: string): Promise<RawStar | null>;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,25 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
build: {
lib: {
entry: {
index: "./src/index.ts",
"provider/provider": "./src/provider/provider.ts",
"catalog/catalog": "./src/catalog/catalog.ts",
},
formats: ["es"],
},
outDir: "dist",
sourcemap: true,
rollupOptions: {
external: [/^@suaveplan\//, "zod", /^@opentelemetry\//],
output: {
preserveModules: true,
preserveModulesRoot: "src",
},
},
},
plugins: [dts({ tsconfigPath: "./tsconfig.lib.json" })],
});

View File

@@ -0,0 +1,10 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
coverageThresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
});