caelus 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # caelus
2
2
 
3
3
  Clean-room TypeScript astrology computation: ephemeris, charts, events,
4
- hellenistic timing techniques, Vedic methods, citable chart facts, and sky-view
5
- image-prompt frames. MIT, no Swiss Ephemeris code, no AGPL, no ephemeris files.
4
+ hellenistic timing techniques, Vedic methods, citable chart facts, sky-view
5
+ image-prompt frames, and synthetic ephemeris for imaginary bodies. MIT, no Swiss Ephemeris code, no AGPL, no ephemeris files.
6
6
  1:1 port of the Python reference, checked by golden fixtures.
7
7
 
8
8
  ## Verification chain
@@ -103,4 +103,4 @@ test/golden.test.ts conformance suite vs Python fixtures
103
103
  - caelus — this package
104
104
  - [caelus-birth](https://www.npmjs.com/package/caelus-birth) — local birth time + place → UT (charts take UT; use this)
105
105
  - [caelus-wheel](https://www.npmjs.com/package/caelus-wheel) — React SVG chart wheel
106
- - [caelus-mcp](https://www.npmjs.com/package/caelus-mcp) — MCP server, thirty-one chart tools over stdio
106
+ - [caelus-mcp](https://www.npmjs.com/package/caelus-mcp) — MCP server, thirty-four chart tools over stdio
package/accuracy.json CHANGED
@@ -273,7 +273,7 @@
273
273
  "counts": {
274
274
  "house_systems": 12,
275
275
  "sidereal_ayanamsas": 7,
276
- "mcp_tools": 31,
276
+ "mcp_tools": 34,
277
277
  "default_bodies": 13
278
278
  }
279
279
  }
@@ -1,5 +1,6 @@
1
1
  /** astroengine chart -- public API: natal charts, aspects, retrogrades. */
2
- import { EngineData, AYANAMSA_J2000 } from "./core.js";
2
+ import { EngineData, AYANAMSA_J2000, XyzSource } from "./core.js";
3
+ import type { SyntheticRender } from "./synthetic.js";
3
4
  import type { AspectPhase } from "./electional.js";
4
5
  export declare const BODIES: readonly ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune", "pluto", "chiron", "mean_node", "true_node"];
5
6
  export type Body = (typeof BODIES)[number];
@@ -173,7 +174,35 @@ export declare class Engine {
173
174
  private moonCheb;
174
175
  private chironCheb;
175
176
  private packs;
177
+ private runtimeSources;
178
+ private renderAttrs;
176
179
  constructor(data: EngineData);
180
+ /**
181
+ * Register a runtime {@link XyzSource} under a body id, so it resolves through
182
+ * {@link Engine.position}, {@link Engine.longitude}, {@link Engine.chartAt},
183
+ * and everything built on them (transits, returns, retrograde, SkyView) with
184
+ * no special-casing — exactly like a baked-in Chebyshev or Kepler pack. The
185
+ * source yields heliocentric ecliptic-J2000 xyz in AU at a **TT** Julian Day,
186
+ * the same contract Chiron and the Uranian bodies satisfy; the engine applies
187
+ * light-time, aberration, precession and nutation to it like any real body.
188
+ *
189
+ * This is the seam the `synthetic` module plugs imaginary bodies into (see
190
+ * {@link registerSyntheticSystem}). A registered id shadows a baked-in pack of
191
+ * the same name and persists for the engine's lifetime.
192
+ *
193
+ * @param id The body id to register (any string).
194
+ * @param source A heliocentric xyz source; see {@link XyzSource}.
195
+ * @returns This engine, for chaining.
196
+ */
197
+ registerSource(id: string, source: XyzSource): this;
198
+ /** Author how a runtime body should look in SkyView (size, magnitude, colour).
199
+ * Position still comes from {@link registerSource}; this owns appearance only. */
200
+ registerRender(id: string, render: SyntheticRender): this;
201
+ /** SkyView appearance for a registered body, if any. */
202
+ renderFor(id: string): SyntheticRender | undefined;
203
+ /** Whether `body` resolves through the generic packed-source path: a baked-in
204
+ * Chebyshev/Kepler pack or a runtime source from {@link registerSource}. */
205
+ private hasPack;
177
206
  private pack;
178
207
  private moonInRange;
179
208
  /**
package/dist/src/chart.js CHANGED
@@ -135,11 +135,51 @@ export class Engine {
135
135
  moonCheb;
136
136
  chironCheb;
137
137
  packs = new Map();
138
+ runtimeSources = new Map();
139
+ renderAttrs = new Map();
138
140
  constructor(data) {
139
141
  this.data = data;
140
142
  this.moonCheb = data.moonCheb ? new ChebSeries(data.moonCheb) : null;
141
143
  this.chironCheb = data.chiron ? new ChebSeries(data.chiron) : null;
142
144
  }
145
+ /**
146
+ * Register a runtime {@link XyzSource} under a body id, so it resolves through
147
+ * {@link Engine.position}, {@link Engine.longitude}, {@link Engine.chartAt},
148
+ * and everything built on them (transits, returns, retrograde, SkyView) with
149
+ * no special-casing — exactly like a baked-in Chebyshev or Kepler pack. The
150
+ * source yields heliocentric ecliptic-J2000 xyz in AU at a **TT** Julian Day,
151
+ * the same contract Chiron and the Uranian bodies satisfy; the engine applies
152
+ * light-time, aberration, precession and nutation to it like any real body.
153
+ *
154
+ * This is the seam the `synthetic` module plugs imaginary bodies into (see
155
+ * {@link registerSyntheticSystem}). A registered id shadows a baked-in pack of
156
+ * the same name and persists for the engine's lifetime.
157
+ *
158
+ * @param id The body id to register (any string).
159
+ * @param source A heliocentric xyz source; see {@link XyzSource}.
160
+ * @returns This engine, for chaining.
161
+ */
162
+ registerSource(id, source) {
163
+ this.runtimeSources.set(id, source);
164
+ this.packs.set(id, source);
165
+ return this;
166
+ }
167
+ /** Author how a runtime body should look in SkyView (size, magnitude, colour).
168
+ * Position still comes from {@link registerSource}; this owns appearance only. */
169
+ registerRender(id, render) {
170
+ this.renderAttrs.set(id, render);
171
+ return this;
172
+ }
173
+ /** SkyView appearance for a registered body, if any. */
174
+ renderFor(id) {
175
+ return this.renderAttrs.get(id);
176
+ }
177
+ /** Whether `body` resolves through the generic packed-source path: a baked-in
178
+ * Chebyshev/Kepler pack or a runtime source from {@link registerSource}. */
179
+ hasPack(body) {
180
+ return !!(this.data.chebPacks?.[body] || this.data.keplerPack?.bodies[body])
181
+ || this.runtimeSources.has(body);
182
+ }
143
183
  pack(body) {
144
184
  let s = this.packs.get(body);
145
185
  if (!s) {
@@ -176,6 +216,7 @@ export class Engine {
176
216
  ...[...BODIES, ...EXTRA_BODIES].filter((b) => b !== "chiron" || this.chironCheb),
177
217
  ...Object.keys(this.data.chebPacks ?? {}),
178
218
  ...Object.keys(this.data.keplerPack?.bodies ?? {}),
219
+ ...this.runtimeSources.keys(),
179
220
  ];
180
221
  }
181
222
  /**
@@ -231,8 +272,9 @@ export class Engine {
231
272
  : oscApogeeSeries(this.data, jde);
232
273
  return [lon, lat, km / KM_PER_AU];
233
274
  }
234
- if (this.data.chebPacks?.[body] || this.data.keplerPack?.bodies[body]) {
235
- // same heliocentric pipeline as Chiron (Chebyshev or Kepler source)
275
+ if (this.hasPack(body)) {
276
+ // same heliocentric pipeline as Chiron (Chebyshev, Kepler, or a runtime
277
+ // source registered via registerSource — e.g. a synthetic body)
236
278
  return chironApparent(this.data, this.pack(body), jde);
237
279
  }
238
280
  if (this.data.vsop[body])
@@ -425,7 +467,7 @@ export class Engine {
425
467
  b = Math.atan2(z, Math.hypot(x, y));
426
468
  [l, b] = precessEcliptic(l, b, J2000, jde);
427
469
  }
428
- else if (this.data.chebPacks?.[body] || this.data.keplerPack?.bodies[body]) {
470
+ else if (this.hasPack(body)) {
429
471
  const [x, y, z] = this.pack(body).xyz(jde);
430
472
  r = Math.sqrt(x * x + y * y + z * z);
431
473
  l = mod(Math.atan2(y, x), TWO_PI);
@@ -16,6 +16,7 @@ export * from "./astrocartography.js";
16
16
  export * from "./ephemeris.js";
17
17
  export * from "./features.js";
18
18
  export * from "./compiler.js";
19
+ export * from "./synthetic.js";
19
20
  export * from "./lots.js";
20
21
  export * from "./profections.js";
21
22
  export * from "./firdaria.js";
package/dist/src/index.js CHANGED
@@ -16,6 +16,7 @@ export * from "./astrocartography.js";
16
16
  export * from "./ephemeris.js";
17
17
  export * from "./features.js";
18
18
  export * from "./compiler.js";
19
+ export * from "./synthetic.js";
19
20
  export * from "./lots.js";
20
21
  export * from "./profections.js";
21
22
  export * from "./firdaria.js";
@@ -1,4 +1,5 @@
1
- import { Engine, BodyId, Observer, HouseSystem } from "./chart.js";
1
+ import { Engine, Observer, HouseSystem } from "./chart.js";
2
+ import type { SyntheticRender } from "./synthetic.js";
2
3
  export type SkyProjection = "rectilinear" | "fisheye";
3
4
  /** A resolved lens: field of view and projection actually used. */
4
5
  export interface SkyLens {
@@ -57,8 +58,12 @@ export interface SkyViewOptions {
57
58
  /** Reference-frame overlays to project onto the sky (annotations, not part of
58
59
  * a photoreal render). */
59
60
  overlays?: SkyViewOverlaysRequest;
60
- /** Bodies to place. Defaults to Sun, Moon, and the naked-eye planets. */
61
- bodies?: BodyId[];
61
+ /** Bodies to place. Defaults to Sun, Moon, and the naked-eye planets. Any
62
+ * string id works for runtime bodies registered via {@link Engine.registerSource}. */
63
+ bodies?: readonly string[];
64
+ /** Per-body appearance overrides (size, magnitude, colour). Merged with
65
+ * {@link Engine.renderFor} for registered bodies; opts win on conflict. */
66
+ render?: Record<string, SyntheticRender>;
62
67
  }
63
68
  export interface SkyBody {
64
69
  id: string;
@@ -81,6 +86,8 @@ export interface SkyBody {
81
86
  phaseName?: string;
82
87
  brightLimbAngleDeg?: number;
83
88
  brightLimbClock?: string;
89
+ /** Authored colour hint for synthetic or overridden bodies. */
90
+ color?: string;
84
91
  note?: string;
85
92
  }
86
93
  export interface SkyOffFrameBody {
@@ -188,6 +188,22 @@ function displayName(id) {
188
188
  return id.slice(5);
189
189
  return id.charAt(0).toUpperCase() + id.slice(1);
190
190
  }
191
+ /** Merge engine-registered and opt-in render attrs; apply size, magnitude, colour. */
192
+ function applyRenderAttrs(body, id, render, lens, width, limit) {
193
+ if (!render)
194
+ return;
195
+ if (render.sizeDeg !== undefined) {
196
+ body.angularDiameterDeg = Math.round(render.sizeDeg * 1e4) / 1e4;
197
+ body.sizePx = Math.max(1, Math.round((render.sizeDeg * width) / lens.hfovDeg));
198
+ }
199
+ if (render.magnitude !== undefined) {
200
+ body.magnitude = Math.round(render.magnitude * 100) / 100;
201
+ body.nakedEye = id === "sun" || id === "moon" || render.magnitude <= limit;
202
+ body.brightnessHint = brightnessDescriptor(body.magnitude, body.nakedEye);
203
+ }
204
+ if (render.color)
205
+ body.color = render.color;
206
+ }
191
207
  /** Map apparent magnitude to a rendering cue: how prominent the point should
192
208
  * look in the image. This is deliberately decoupled from the body's true
193
209
  * angular size, which is sub-pixel for a planet: in a photo a bright point's
@@ -443,6 +459,11 @@ export function skyView(engine, jdUt, view, opts = {}) {
443
459
  body.brightLimbAngleDeg = Math.round(angle * 10) / 10;
444
460
  body.brightLimbClock = clockOf(angle);
445
461
  }
462
+ const authored = {
463
+ ...engine.renderFor(id),
464
+ ...opts.render?.[id],
465
+ };
466
+ applyRenderAttrs(body, id, Object.keys(authored).length ? authored : undefined, lens, width, limit);
446
467
  bodies.push(body);
447
468
  }
448
469
  // Stars. With the deep pack loaded and a dark sky, pin the complete naked-eye
@@ -873,6 +894,8 @@ function buildPrompt(bodies, offFrame, directives, starfield) {
873
894
  if (b.brightnessHint)
874
895
  parts.push(b.brightnessHint);
875
896
  }
897
+ if (b.color)
898
+ parts.push(`colour ${b.color}`);
876
899
  let line = `- ${parts.join(", ")}`;
877
900
  if (b.note)
878
901
  line += `. ${b.note}`;
@@ -0,0 +1,249 @@
1
+ /**
2
+ * caelus synthetic ephemeris -- give imaginary bodies motion, not just a
3
+ * single longitude.
4
+ *
5
+ * {@link compileForm} places an authored body at one static longitude
6
+ * (a snapshot). A sky that does not move is dead: no transits, no returns, no
7
+ * seasons. This module makes a body's position a deterministic **function of
8
+ * time** from authored parameters, so every derived computation — transits,
9
+ * returns, retrograde, conjunctions, phases, SkyView — works on it unchanged.
10
+ *
11
+ * Three tiers, increasing in physics:
12
+ * - **placement** — a fixed longitude (parity with `compileForm`; effectively a
13
+ * body on the celestial sphere that never moves).
14
+ * - **periodic** — `lon(t) = wrap360(phaseDeg + 360·(t − epoch)/periodDays)`.
15
+ * Cheap, no real physics, but yields returns, cyclic transits, and (viewed
16
+ * from an inner observer body) apparent retrograde. Covers ~all
17
+ * narrative/game needs.
18
+ * - **kepler** — constant Keplerian elements solved each instant
19
+ * (`{ a, e, i, node, peri, M0, periodDays }`), giving heliocentric ecliptic
20
+ * (x, y, z, lon, lat, r); with a designated **observer body** the positions
21
+ * become geocentric/apparent, enabling true retrograde, phases and
22
+ * occultation.
23
+ *
24
+ * Determinism is non-negotiable: every export is a pure function of
25
+ * `(system, t)`. No randomness, no wall-clock reads — identical inputs yield
26
+ * identical outputs, like the rest of Caelus.
27
+ *
28
+ * Two ways to consume a system:
29
+ * 1. {@link syntheticPositions} / {@link syntheticEphemeris} — the self-
30
+ * contained *world frame*. An `observer` body, if set, is the vantage point;
31
+ * longitudes are in the world's own ecliptic, decoupled from Earth. This is
32
+ * the canonical position-over-time contract.
33
+ * 2. {@link registerSyntheticSystem} — drop the bodies into a real {@link
34
+ * Engine} via {@link Engine.registerSource}, so `transits`, `returns`,
35
+ * `skyView`, `relational` etc. consume them through their existing `Engine`
36
+ * interfaces with zero changes. Here the vantage point is real Earth (the
37
+ * system's heliocentre maps to the Sun), so the synthetic `observer` does
38
+ * not apply — it is "imaginary bodies in our sky".
39
+ */
40
+ import { XyzSource } from "./core.js";
41
+ /** A single authored body. Its `id` is how every consumer (charts, transits,
42
+ * SkyView) refers to it. */
43
+ export type SyntheticBody =
44
+ /** A fixed longitude that never moves — parity with `compileForm`. */
45
+ {
46
+ id: string;
47
+ mode: "placement";
48
+ lonDeg: number;
49
+ }
50
+ /** Uniform angular motion: `lon(t) = phaseDeg + 360·(t − epoch)/periodDays`.
51
+ * `epoch` defaults to `0`. */
52
+ | {
53
+ id: string;
54
+ mode: "periodic";
55
+ periodDays: number;
56
+ phaseDeg: number;
57
+ epoch?: number;
58
+ }
59
+ /** Constant Keplerian elements. Angles `i`, `node`, `peri`, `M0` in **degrees**;
60
+ * `a` in arbitrary length units (consistent within a system); `e` in `[0, 1)`;
61
+ * `epoch` defaults to `0`. */
62
+ | {
63
+ id: string;
64
+ mode: "kepler";
65
+ a: number;
66
+ e: number;
67
+ i: number;
68
+ node: number;
69
+ peri: number;
70
+ M0: number;
71
+ periodDays: number;
72
+ epoch?: number;
73
+ };
74
+ /** Render attributes for a synthetic body — how an imaginary body should *look*
75
+ * in a SkyView frame. The engine owns position; these own appearance, so a body
76
+ * stays visually consistent across {@link skyViewSequence} frames. */
77
+ export interface SyntheticRender {
78
+ /** Apparent angular diameter in degrees (the Moon is ~0.5°). */
79
+ sizeDeg?: number;
80
+ /** Apparent visual magnitude (smaller is brighter; Sirius is ~-1.5). */
81
+ magnitude?: number;
82
+ /** A colour hint, e.g. a CSS colour or evocative name ("pale gold"). */
83
+ color?: string;
84
+ }
85
+ /** An authored celestial system: a set of {@link SyntheticBody} that move
86
+ * together, an optional `observer` body that fixes the vantage point, and
87
+ * optional per-body {@link SyntheticRender} attributes. */
88
+ export interface SyntheticSystem {
89
+ id: string;
90
+ bodies: SyntheticBody[];
91
+ /** Body id of the vantage point. When set, {@link syntheticPositions} returns
92
+ * geocentric/apparent positions seen from this body (it sees itself at the
93
+ * origin, so it is reported at its own heliocentric place). When unset,
94
+ * positions are heliocentric. */
95
+ observer?: string;
96
+ /** Render attributes keyed by body id. */
97
+ render?: Record<string, SyntheticRender>;
98
+ }
99
+ /** A body's position at one instant, in degrees and system length units. */
100
+ export interface SyntheticPosition {
101
+ /** Ecliptic longitude in degrees, `[0, 360)`. */
102
+ lonDeg: number;
103
+ /** Ecliptic latitude in degrees. */
104
+ latDeg: number;
105
+ /** Distance from the vantage point (heliocentric, or from the observer body
106
+ * when one is set), in the system's length units. */
107
+ r: number;
108
+ }
109
+ /** Why a system is unsatisfiable, if it is — the `impossible`/`residual` honesty
110
+ * pattern, mirroring {@link CompiledForm}. A system with problems
111
+ * still computes for the bodies that are individually valid, but flags the rest
112
+ * rather than silently producing garbage. */
113
+ export interface SyntheticDiagnosis {
114
+ /** `true` when at least one problem makes the system ill-defined. */
115
+ impossible: boolean;
116
+ /** One human-readable line per problem (empty when the system is sound). */
117
+ problems: string[];
118
+ }
119
+ /** A pluggable position-over-time source: the same shape every derived
120
+ * computation needs from a body. Returned by {@link syntheticEphemeris}. */
121
+ export interface BodyPositionSource extends SyntheticDiagnosis {
122
+ /** The body ids this source can place. */
123
+ bodies(): string[];
124
+ /** Apparent ecliptic longitude (degrees, `[0, 360)`) at time `t`. */
125
+ longitude(id: string, t: number): number;
126
+ /** Full position at time `t`, with longitude speed and a retrograde flag
127
+ * derived by central difference — matching the {@link Position}
128
+ * contract that real bodies satisfy. */
129
+ position(id: string, t: number): SyntheticPosition & {
130
+ speed: number;
131
+ retrograde: boolean;
132
+ };
133
+ }
134
+ /**
135
+ * Check an authored system for the ways it can be ill-defined — duplicate ids, a
136
+ * dangling `observer`, non-positive periods, or out-of-range eccentricity — and
137
+ * report each, in the `impossible`/`residual` honesty style. Pure.
138
+ *
139
+ * @param sys The system to check.
140
+ * @returns A {@link SyntheticDiagnosis}; `impossible` is `true` when any problem
141
+ * is found.
142
+ */
143
+ export declare function validateSyntheticSystem(sys: SyntheticSystem): SyntheticDiagnosis;
144
+ /**
145
+ * The heliocentric xyz source for one authored body — the per-body engine behind
146
+ * every tier, and the exact object {@link Engine.registerSource} consumes.
147
+ *
148
+ * - **placement** sits on a very distant sphere so its direction (longitude) is
149
+ * fixed and parallax-free from any vantage.
150
+ * - **periodic** is a circular, coplanar orbit (`e = 0`, `i = 0`) whose radius
151
+ * follows Kepler's third law in the system's units (`a = periodDays^(2/3)`),
152
+ * so an inner body both moves faster and orbits tighter — making outer bodies
153
+ * show apparent retrograde near opposition, just like the real sky. Its
154
+ * heliocentric longitude is exactly `phaseDeg + 360·(t − epoch)/periodDays`.
155
+ * - **kepler** is the full constant-element solver ({@link KeplerOrbit}).
156
+ *
157
+ * @param body The authored body.
158
+ * @returns An {@link XyzSource} yielding heliocentric ecliptic xyz at time `t`.
159
+ */
160
+ export declare function bodySource(body: SyntheticBody): XyzSource;
161
+ /**
162
+ * Build the heliocentric {@link XyzSource} for every body in a system, keyed by
163
+ * id — the raw material for both the world-frame contract and engine
164
+ * registration.
165
+ *
166
+ * @param sys The authored system.
167
+ * @returns One source per body id.
168
+ */
169
+ export declare function syntheticSources(sys: SyntheticSystem): Record<string, XyzSource>;
170
+ /**
171
+ * Positions of every body at one instant — the core position-over-time
172
+ * contract. Pure: a function of `(sys, t)` only.
173
+ *
174
+ * When `sys.observer` is set, each other body's position is geocentric/apparent
175
+ * as seen from the observer body (its heliocentric vector subtracted), so an
176
+ * outer body shows a real apparent retrograde arc around opposition. The
177
+ * observer body itself is reported at its own heliocentric place. With no
178
+ * observer the positions are heliocentric.
179
+ *
180
+ * @param sys The authored system.
181
+ * @param tDays The instant, in the same day units as each body's `periodDays`
182
+ * and `epoch`.
183
+ * @returns Position per body id (`lonDeg`, `latDeg`, `r`).
184
+ * @example
185
+ * ```ts
186
+ * const sys: SyntheticSystem = {
187
+ * id: "twin-moons",
188
+ * bodies: [
189
+ * { id: "fast", mode: "periodic", periodDays: 20, phaseDeg: 0 },
190
+ * { id: "slow", mode: "periodic", periodDays: 80, phaseDeg: 0 },
191
+ * ],
192
+ * };
193
+ * syntheticPositions(sys, 0).fast.lonDeg; // 0
194
+ * syntheticPositions(sys, 5).fast.lonDeg; // 90 (a quarter of its 20-day period)
195
+ * ```
196
+ */
197
+ export declare function syntheticPositions(sys: SyntheticSystem, tDays: number): Record<string, SyntheticPosition>;
198
+ /**
199
+ * A {@link BodyPositionSource} over an authored system: the world-frame view as
200
+ * a reusable object, with longitude speed and a retrograde flag so it satisfies
201
+ * the same contract real ephemeris bodies do. Carries the system's
202
+ * {@link validateSyntheticSystem} diagnosis (`impossible`/`problems`) — the
203
+ * honesty pattern — and still serves the bodies that are individually valid.
204
+ *
205
+ * @param sys The authored system.
206
+ * @returns A position source: `bodies()`, `longitude(id, t)`, `position(id, t)`,
207
+ * plus `impossible`/`problems`.
208
+ */
209
+ export declare function syntheticEphemeris(sys: SyntheticSystem): BodyPositionSource;
210
+ /** A type guard for the surface {@link registerSyntheticSystem} needs, so the
211
+ * synthetic module does not import the heavy {@link Engine} class. */
212
+ export interface SourceRegistrar {
213
+ registerSource(id: string, source: XyzSource): unknown;
214
+ registerRender?(id: string, render: SyntheticRender): unknown;
215
+ }
216
+ /**
217
+ * Register a system's bodies on a real {@link Engine} so `transits`, `returns`,
218
+ * retrograde detection, `skyView`, `relational` — every computation built on
219
+ * `Engine.position`/`Engine.longitude` — consume them unchanged. The bodies are
220
+ * placed heliocentrically in the real solar system and viewed from real Earth,
221
+ * so the synthetic `observer` is *not* applied here (use
222
+ * {@link syntheticPositions} for the self-contained world frame). Each body's
223
+ * `epoch` is then interpreted as a TT Julian Day.
224
+ *
225
+ * @param engine The engine to register onto (anything with `registerSource`).
226
+ * @param sys The authored system.
227
+ * @returns The ids registered.
228
+ * @example
229
+ * ```ts
230
+ * registerSyntheticSystem(engine, {
231
+ * id: "nemesis", bodies: [{ id: "nemesis", mode: "kepler", a: 520, e: 0.1,
232
+ * i: 5, node: 0, peri: 0, M0: 0, periodDays: 4_300_000, epoch: 2451545 }],
233
+ * });
234
+ * returns(engine, "nemesis", natalJd, jdStart, jdEnd); // works, zero changes
235
+ * engine.position("nemesis", jd).retrograde; // apparent, from Earth
236
+ * ```
237
+ */
238
+ export declare function registerSyntheticSystem(engine: SourceRegistrar, sys: SyntheticSystem): string[];
239
+ /**
240
+ * The {@link SyntheticRender} attributes for a body id, or `undefined` when none
241
+ * were authored. The accessor SkyView/`skyViewSequence` reads so an imaginary
242
+ * body keeps a consistent size, brightness and colour across frames — the engine
243
+ * owns position, this owns appearance.
244
+ *
245
+ * @param sys The authored system.
246
+ * @param id A body id.
247
+ * @returns The render attributes, or `undefined`.
248
+ */
249
+ export declare function syntheticRender(sys: SyntheticSystem, id: string): SyntheticRender | undefined;
@@ -0,0 +1,260 @@
1
+ /**
2
+ * caelus synthetic ephemeris -- give imaginary bodies motion, not just a
3
+ * single longitude.
4
+ *
5
+ * {@link compileForm} places an authored body at one static longitude
6
+ * (a snapshot). A sky that does not move is dead: no transits, no returns, no
7
+ * seasons. This module makes a body's position a deterministic **function of
8
+ * time** from authored parameters, so every derived computation — transits,
9
+ * returns, retrograde, conjunctions, phases, SkyView — works on it unchanged.
10
+ *
11
+ * Three tiers, increasing in physics:
12
+ * - **placement** — a fixed longitude (parity with `compileForm`; effectively a
13
+ * body on the celestial sphere that never moves).
14
+ * - **periodic** — `lon(t) = wrap360(phaseDeg + 360·(t − epoch)/periodDays)`.
15
+ * Cheap, no real physics, but yields returns, cyclic transits, and (viewed
16
+ * from an inner observer body) apparent retrograde. Covers ~all
17
+ * narrative/game needs.
18
+ * - **kepler** — constant Keplerian elements solved each instant
19
+ * (`{ a, e, i, node, peri, M0, periodDays }`), giving heliocentric ecliptic
20
+ * (x, y, z, lon, lat, r); with a designated **observer body** the positions
21
+ * become geocentric/apparent, enabling true retrograde, phases and
22
+ * occultation.
23
+ *
24
+ * Determinism is non-negotiable: every export is a pure function of
25
+ * `(system, t)`. No randomness, no wall-clock reads — identical inputs yield
26
+ * identical outputs, like the rest of Caelus.
27
+ *
28
+ * Two ways to consume a system:
29
+ * 1. {@link syntheticPositions} / {@link syntheticEphemeris} — the self-
30
+ * contained *world frame*. An `observer` body, if set, is the vantage point;
31
+ * longitudes are in the world's own ecliptic, decoupled from Earth. This is
32
+ * the canonical position-over-time contract.
33
+ * 2. {@link registerSyntheticSystem} — drop the bodies into a real {@link
34
+ * Engine} via {@link Engine.registerSource}, so `transits`, `returns`,
35
+ * `skyView`, `relational` etc. consume them through their existing `Engine`
36
+ * interfaces with zero changes. Here the vantage point is real Earth (the
37
+ * system's heliocentre maps to the Sun), so the synthetic `observer` does
38
+ * not apply — it is "imaginary bodies in our sky".
39
+ */
40
+ import { DEG, mod, KeplerOrbit } from "./core.js";
41
+ const TWO_PI = 2 * Math.PI;
42
+ // ----------------------------------------------------------------- validation
43
+ /**
44
+ * Check an authored system for the ways it can be ill-defined — duplicate ids, a
45
+ * dangling `observer`, non-positive periods, or out-of-range eccentricity — and
46
+ * report each, in the `impossible`/`residual` honesty style. Pure.
47
+ *
48
+ * @param sys The system to check.
49
+ * @returns A {@link SyntheticDiagnosis}; `impossible` is `true` when any problem
50
+ * is found.
51
+ */
52
+ export function validateSyntheticSystem(sys) {
53
+ const problems = [];
54
+ const seen = new Set();
55
+ for (const b of sys.bodies) {
56
+ if (seen.has(b.id))
57
+ problems.push(`duplicate body id '${b.id}'`);
58
+ seen.add(b.id);
59
+ if (b.mode === "periodic" && !(b.periodDays > 0)) {
60
+ problems.push(`body '${b.id}': periodDays must be > 0 (got ${b.periodDays})`);
61
+ }
62
+ if (b.mode === "kepler") {
63
+ if (!(b.periodDays > 0))
64
+ problems.push(`body '${b.id}': periodDays must be > 0 (got ${b.periodDays})`);
65
+ if (!(b.a > 0))
66
+ problems.push(`body '${b.id}': a must be > 0 (got ${b.a})`);
67
+ if (!(b.e >= 0 && b.e < 1))
68
+ problems.push(`body '${b.id}': e must be in [0, 1) (got ${b.e})`);
69
+ }
70
+ }
71
+ if (sys.observer !== undefined && !seen.has(sys.observer)) {
72
+ problems.push(`observer '${sys.observer}' is not a body in the system`);
73
+ }
74
+ return { impossible: problems.length > 0, problems };
75
+ }
76
+ // -------------------------------------------------------------------- sources
77
+ /**
78
+ * The heliocentric xyz source for one authored body — the per-body engine behind
79
+ * every tier, and the exact object {@link Engine.registerSource} consumes.
80
+ *
81
+ * - **placement** sits on a very distant sphere so its direction (longitude) is
82
+ * fixed and parallax-free from any vantage.
83
+ * - **periodic** is a circular, coplanar orbit (`e = 0`, `i = 0`) whose radius
84
+ * follows Kepler's third law in the system's units (`a = periodDays^(2/3)`),
85
+ * so an inner body both moves faster and orbits tighter — making outer bodies
86
+ * show apparent retrograde near opposition, just like the real sky. Its
87
+ * heliocentric longitude is exactly `phaseDeg + 360·(t − epoch)/periodDays`.
88
+ * - **kepler** is the full constant-element solver ({@link KeplerOrbit}).
89
+ *
90
+ * @param body The authored body.
91
+ * @returns An {@link XyzSource} yielding heliocentric ecliptic xyz at time `t`.
92
+ */
93
+ export function bodySource(body) {
94
+ if (body.mode === "placement") {
95
+ const R = 1e9;
96
+ const lam = body.lonDeg * DEG;
97
+ const v = [R * Math.cos(lam), R * Math.sin(lam), 0];
98
+ return { xyz: () => v };
99
+ }
100
+ if (body.mode === "periodic") {
101
+ const els = {
102
+ a: Math.cbrt(body.periodDays * body.periodDays),
103
+ e: 0, i: 0, node: 0, peri: 0,
104
+ M0: body.phaseDeg * DEG, n: TWO_PI / body.periodDays,
105
+ };
106
+ return new KeplerOrbit(els, body.epoch ?? 0);
107
+ }
108
+ const els = {
109
+ a: body.a, e: body.e, i: body.i * DEG, node: body.node * DEG,
110
+ peri: body.peri * DEG, M0: body.M0 * DEG, n: TWO_PI / body.periodDays,
111
+ };
112
+ return new KeplerOrbit(els, body.epoch ?? 0);
113
+ }
114
+ /**
115
+ * Build the heliocentric {@link XyzSource} for every body in a system, keyed by
116
+ * id — the raw material for both the world-frame contract and engine
117
+ * registration.
118
+ *
119
+ * @param sys The authored system.
120
+ * @returns One source per body id.
121
+ */
122
+ export function syntheticSources(sys) {
123
+ const out = {};
124
+ for (const b of sys.bodies)
125
+ out[b.id] = bodySource(b);
126
+ return out;
127
+ }
128
+ function vecToPos(v) {
129
+ const r = Math.hypot(v[0], v[1], v[2]);
130
+ return {
131
+ lonDeg: mod(Math.atan2(v[1], v[0]) / DEG, 360),
132
+ latDeg: r === 0 ? 0 : Math.asin(v[2] / r) / DEG,
133
+ r,
134
+ };
135
+ }
136
+ // ------------------------------------------------------------------- contract
137
+ /**
138
+ * Positions of every body at one instant — the core position-over-time
139
+ * contract. Pure: a function of `(sys, t)` only.
140
+ *
141
+ * When `sys.observer` is set, each other body's position is geocentric/apparent
142
+ * as seen from the observer body (its heliocentric vector subtracted), so an
143
+ * outer body shows a real apparent retrograde arc around opposition. The
144
+ * observer body itself is reported at its own heliocentric place. With no
145
+ * observer the positions are heliocentric.
146
+ *
147
+ * @param sys The authored system.
148
+ * @param tDays The instant, in the same day units as each body's `periodDays`
149
+ * and `epoch`.
150
+ * @returns Position per body id (`lonDeg`, `latDeg`, `r`).
151
+ * @example
152
+ * ```ts
153
+ * const sys: SyntheticSystem = {
154
+ * id: "twin-moons",
155
+ * bodies: [
156
+ * { id: "fast", mode: "periodic", periodDays: 20, phaseDeg: 0 },
157
+ * { id: "slow", mode: "periodic", periodDays: 80, phaseDeg: 0 },
158
+ * ],
159
+ * };
160
+ * syntheticPositions(sys, 0).fast.lonDeg; // 0
161
+ * syntheticPositions(sys, 5).fast.lonDeg; // 90 (a quarter of its 20-day period)
162
+ * ```
163
+ */
164
+ export function syntheticPositions(sys, tDays) {
165
+ const sources = syntheticSources(sys);
166
+ const obs = sys.observer ? sources[sys.observer]?.xyz(tDays) ?? null : null;
167
+ const out = {};
168
+ for (const b of sys.bodies) {
169
+ const v = sources[b.id].xyz(tDays);
170
+ if (obs && sys.observer !== b.id) {
171
+ out[b.id] = vecToPos([v[0] - obs[0], v[1] - obs[1], v[2] - obs[2]]);
172
+ }
173
+ else {
174
+ out[b.id] = vecToPos(v);
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+ /**
180
+ * A {@link BodyPositionSource} over an authored system: the world-frame view as
181
+ * a reusable object, with longitude speed and a retrograde flag so it satisfies
182
+ * the same contract real ephemeris bodies do. Carries the system's
183
+ * {@link validateSyntheticSystem} diagnosis (`impossible`/`problems`) — the
184
+ * honesty pattern — and still serves the bodies that are individually valid.
185
+ *
186
+ * @param sys The authored system.
187
+ * @returns A position source: `bodies()`, `longitude(id, t)`, `position(id, t)`,
188
+ * plus `impossible`/`problems`.
189
+ */
190
+ export function syntheticEphemeris(sys) {
191
+ const { impossible, problems } = validateSyntheticSystem(sys);
192
+ const sources = syntheticSources(sys);
193
+ const ids = sys.bodies.map((b) => b.id);
194
+ const apparent = (id, t) => {
195
+ const v = sources[id].xyz(t);
196
+ if (sys.observer && sys.observer !== id) {
197
+ const o = sources[sys.observer].xyz(t);
198
+ return vecToPos([v[0] - o[0], v[1] - o[1], v[2] - o[2]]);
199
+ }
200
+ return vecToPos(v);
201
+ };
202
+ return {
203
+ impossible, problems,
204
+ bodies: () => [...ids],
205
+ longitude: (id, t) => apparent(id, t).lonDeg,
206
+ position: (id, t) => {
207
+ const p = apparent(id, t);
208
+ const h = 0.05; // days; central difference, matching Engine.position
209
+ const l0 = apparent(id, t - h).lonDeg;
210
+ const l1 = apparent(id, t + h).lonDeg;
211
+ const speed = (mod(l1 - l0 + 540, 360) - 180) / (2 * h);
212
+ return { ...p, speed, retrograde: speed < 0 };
213
+ },
214
+ };
215
+ }
216
+ /**
217
+ * Register a system's bodies on a real {@link Engine} so `transits`, `returns`,
218
+ * retrograde detection, `skyView`, `relational` — every computation built on
219
+ * `Engine.position`/`Engine.longitude` — consume them unchanged. The bodies are
220
+ * placed heliocentrically in the real solar system and viewed from real Earth,
221
+ * so the synthetic `observer` is *not* applied here (use
222
+ * {@link syntheticPositions} for the self-contained world frame). Each body's
223
+ * `epoch` is then interpreted as a TT Julian Day.
224
+ *
225
+ * @param engine The engine to register onto (anything with `registerSource`).
226
+ * @param sys The authored system.
227
+ * @returns The ids registered.
228
+ * @example
229
+ * ```ts
230
+ * registerSyntheticSystem(engine, {
231
+ * id: "nemesis", bodies: [{ id: "nemesis", mode: "kepler", a: 520, e: 0.1,
232
+ * i: 5, node: 0, peri: 0, M0: 0, periodDays: 4_300_000, epoch: 2451545 }],
233
+ * });
234
+ * returns(engine, "nemesis", natalJd, jdStart, jdEnd); // works, zero changes
235
+ * engine.position("nemesis", jd).retrograde; // apparent, from Earth
236
+ * ```
237
+ */
238
+ export function registerSyntheticSystem(engine, sys) {
239
+ const sources = syntheticSources(sys);
240
+ for (const b of sys.bodies) {
241
+ engine.registerSource(b.id, sources[b.id]);
242
+ const render = sys.render?.[b.id];
243
+ if (render)
244
+ engine.registerRender?.(b.id, render);
245
+ }
246
+ return sys.bodies.map((b) => b.id);
247
+ }
248
+ /**
249
+ * The {@link SyntheticRender} attributes for a body id, or `undefined` when none
250
+ * were authored. The accessor SkyView/`skyViewSequence` reads so an imaginary
251
+ * body keeps a consistent size, brightness and colour across frames — the engine
252
+ * owns position, this owns appearance.
253
+ *
254
+ * @param sys The authored system.
255
+ * @param id A body id.
256
+ * @returns The render attributes, or `undefined`.
257
+ */
258
+ export function syntheticRender(sys, id) {
259
+ return sys.render?.[id];
260
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caelus",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Clean-room TypeScript astrology computation: ephemeris, charts, events, timing techniques, Vedic methods, and citable chart facts. MIT, no AGPL or ephemeris files.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "build": "tsc",
24
- "test": "node dist/test/golden.test.js && node dist/test/skyview.test.js"
24
+ "test": "node dist/test/golden.test.js && node dist/test/skyview.test.js && node dist/test/synthetic.test.js"
25
25
  },
26
26
  "license": "MIT",
27
27
  "publishConfig": {