caelus 0.3.0 → 0.4.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
@@ -6,10 +6,11 @@ ephemeris files. 1:1 port of the Python reference, checked by golden fixtures.
6
6
  ## Verification chain
7
7
 
8
8
  1. Python engine checked against Swiss Ephemeris 2.10 across 1900–2099:
9
- every planet ≤ 1″ (Sun–Saturn), Moon ≤ 2.5″, Chiron ≤ 1″, nodes ≤ 1
9
+ every planet ≤ 1″ (Sun–Saturn), Moon ≤ 2.5″, Chiron ≤ 1″, mean node ≤ 1″,
10
+ true node ≤ 1′ vs SE's built-in ephemeris (≤ 1″ vs JPL DE431)
10
11
  (vs full DE431 files, 1850–2149), angles and Placidus cusps ≤ 3.2″ — all
11
12
  invisible at the arcminute display precision chart software uses.
12
- 2. TypeScript port verified against Python golden fixtures: **3,087 checks,
13
+ 2. TypeScript port verified against Python golden fixtures: **3,177 checks,
13
14
  0 failures, worst deviation 1.64 nano-arcseconds.** The two implementations
14
15
  are numerically identical.
15
16
 
@@ -62,8 +63,15 @@ engine.longitude("chiron", 2451545.0);
62
63
  engine.position("mars", 2451545.0); // { lon, speed, retrograde, sign, signDeg }
63
64
  ```
64
65
 
65
- Bodies: sun, moon, mercury…pluto, chiron, mean_node, true_node.
66
- House systems: placidus, porphyry, equal, whole_sign.
66
+ Bodies: sun, moon, mercury…pluto, chiron, mean_node, true_node; on request:
67
+ mean_lilith, true_lilith, ceres, pallas, juno, vesta, pholus, and the eight
68
+ Hamburg-school Uranian bodies (cupido…poseidon) when their data packs are
69
+ loaded.
70
+ House systems: placidus, porphyry, equal, whole_sign, koch, regiomontanus,
71
+ campanus, alcabitius, morinus, meridian, polich_page, vehlow (Placidus and
72
+ Koch fall back to whole_sign above the polar circles).
73
+ Event search: rise/set/meridian transits, zodiac crossings, lunar phases,
74
+ stations (`events.ts`).
67
75
  Performance: ~2.4 ms per full chart (13 bodies × 3 evaluations + houses +
68
76
  aspects) single-threaded in Node 22 — ~420 charts/sec, faster in hot loops.
69
77
 
@@ -92,4 +100,4 @@ test/golden.test.ts conformance suite vs Python fixtures
92
100
  - caelus — this package
93
101
  - [caelus-birth](https://www.npmjs.com/package/caelus-birth) — local birth time + place → UT (charts take UT; use this)
94
102
  - [caelus-wheel](https://www.npmjs.com/package/caelus-wheel) — React SVG chart wheel
95
- - [caelus-mcp](https://www.npmjs.com/package/caelus-mcp) — MCP server, six chart tools over stdio
103
+ - [caelus-mcp](https://www.npmjs.com/package/caelus-mcp) — MCP server, seven chart tools over stdio
package/accuracy.json CHANGED
@@ -3,41 +3,239 @@
3
3
  "range": "1900-2099",
4
4
  "unit": "arcsec",
5
5
  "bodies": [
6
- { "name": "Sun", "max": "0.4", "rms": "0.2", "note": "" },
7
- { "name": "Moon (precise tier)", "max": "2.5", "rms": "0.9", "note": "JPL DE423 fit (2010); DE423 vs DE440 is <0.1″ over this span" },
8
- { "name": "Moon (embedded series)", "max": "9.6", "rms": "2.8", "note": "60-term ELP abridged" },
9
- { "name": "Mercury", "max": "0.5", "rms": "0.2", "note": "" },
10
- { "name": "Venus", "max": "0.8", "rms": "0.2", "note": "" },
11
- { "name": "Mars", "max": "0.7", "rms": "0.2", "note": "" },
12
- { "name": "Jupiter", "max": "0.9", "rms": "0.3", "note": "" },
13
- { "name": "Saturn", "max": "0.8", "rms": "0.4", "note": "" },
14
- { "name": "Uranus", "max": "1.9", "rms": "0.7", "note": "series truncation; complete VSOP87 holds ≤1″" },
15
- { "name": "Neptune", "max": "4.6", "rms": "2.2", "note": "series truncation; complete VSOP87 holds ≤1″" },
16
- { "name": "Pluto", "max": "2.5", "rms": "1.0", "note": "series valid 1885-2099" },
17
- { "name": "Chiron", "max": "1.0", "rms": "0.3", "note": "JPL Horizons fit, 1850-2150" },
18
- { "name": "Mean node", "max": "0.1", "rms": "0.1", "note": "" },
19
- { "name": "True node", "max": "0.8", "rms": "0.4", "note": "vs full DE431 files; Swiss’s built-in Moshier mode itself differs from DE431 by up to ~15″ here" },
20
- { "name": "Ascendant / MC", "max": "3.2", "rms": "—", "note": "" },
21
- { "name": "Placidus cusps (all 12)", "max": "3.2", "rms": "—", "note": "" },
22
- { "name": "Mean Lilith", "max": "1.3", "rms": "0.5", "note": "mean lunar apogee on the inclined orbit; latitude ≤0.1″" },
23
- { "name": "Sidereal longitudes", "max": "0.1", "rms": "—", "note": "ayanamsa model vs SE ≤0.30″ at the 1900/2099 edges (IAU 1976 vs Vondrák precession); Sun worst-case 0.08″ at 120 sampled epochs" },
24
- { "name": "RA / Dec", "max": "2.1", "rms": "—", "note": "rotation is exact; bound tracks each body's ecliptic accuracy (Moon worst)" },
25
- { "name": "Topocentric Moon", "max": "2.7", "rms": "—", "note": "parallax model adds ≤0.1″ over the geocentric bound" },
26
- { "name": "House cusps, 8 new systems", "max": "0.0", "rms": "0.0", "note": "Koch, Regiomontanus, Campanus, Alcabitius, Morinus, Meridian, Polich-Page, Vehlow: exact vs swe_houses_armc (200 polar-inclusive cases each)" },
27
- { "name": "Vertex / east point", "max": "0.0", "rms": "0.0", "note": "exact vs swe_houses_armc" },
28
- { "name": "Magnitudes", "max": "0.045 mag", "rms": "—", "note": "Mallama 2018; Moon (Allen law) valid to phase angle 140°" }
6
+ {
7
+ "name": "Sun",
8
+ "max": "0.4",
9
+ "rms": "0.2",
10
+ "note": ""
11
+ },
12
+ {
13
+ "name": "Moon (precise tier)",
14
+ "max": "2.5",
15
+ "rms": "0.9",
16
+ "note": "JPL DE423 fit (2010); DE423 vs DE440 is <0.1 over this span"
17
+ },
18
+ {
19
+ "name": "Moon (embedded series)",
20
+ "max": "9.6",
21
+ "rms": "2.8",
22
+ "note": "60-term ELP abridged"
23
+ },
24
+ {
25
+ "name": "Mercury",
26
+ "max": "0.5",
27
+ "rms": "0.2",
28
+ "note": ""
29
+ },
30
+ {
31
+ "name": "Venus",
32
+ "max": "0.8",
33
+ "rms": "0.2",
34
+ "note": ""
35
+ },
36
+ {
37
+ "name": "Mars",
38
+ "max": "0.7",
39
+ "rms": "0.2",
40
+ "note": ""
41
+ },
42
+ {
43
+ "name": "Jupiter",
44
+ "max": "0.9",
45
+ "rms": "0.3",
46
+ "note": ""
47
+ },
48
+ {
49
+ "name": "Saturn",
50
+ "max": "0.8",
51
+ "rms": "0.4",
52
+ "note": ""
53
+ },
54
+ {
55
+ "name": "Uranus",
56
+ "max": "1.9",
57
+ "rms": "0.7",
58
+ "note": "series truncation; complete VSOP87 holds ≤1″"
59
+ },
60
+ {
61
+ "name": "Neptune",
62
+ "max": "4.6",
63
+ "rms": "2.2",
64
+ "note": "series truncation; complete VSOP87 holds ≤1″"
65
+ },
66
+ {
67
+ "name": "Pluto",
68
+ "max": "2.5",
69
+ "rms": "1.0",
70
+ "note": "series valid 1885-2099"
71
+ },
72
+ {
73
+ "name": "Chiron",
74
+ "max": "1.0",
75
+ "rms": "0.3",
76
+ "note": "JPL Horizons fit, 1850-2150"
77
+ },
78
+ {
79
+ "name": "Mean node",
80
+ "max": "0.1",
81
+ "rms": "0.1",
82
+ "note": ""
83
+ },
84
+ {
85
+ "name": "True node",
86
+ "max": "0.8",
87
+ "rms": "0.4",
88
+ "note": "vs full JPL DE431 files; vs Swiss Ephemeris's BUILT-IN (Moshier) ephemeris expect up to ~1′ (measured: 50″ max, 8″ median, 300 epochs) — that is the built-in lunar theory's own node error, not ours"
89
+ },
90
+ {
91
+ "name": "Ascendant / MC",
92
+ "max": "3.2",
93
+ "rms": "—",
94
+ "note": ""
95
+ },
96
+ {
97
+ "name": "Placidus cusps (all 12)",
98
+ "max": "3.2",
99
+ "rms": "—",
100
+ "note": ""
101
+ },
102
+ {
103
+ "name": "Mean Lilith",
104
+ "max": "1.3",
105
+ "rms": "0.5",
106
+ "note": "mean lunar apogee on the inclined orbit; latitude ≤0.1″"
107
+ },
108
+ {
109
+ "name": "Sidereal longitudes",
110
+ "max": "0.1",
111
+ "rms": "—",
112
+ "note": "ayanamsa model vs SE ≤0.30″ at the 1900/2099 edges (IAU 1976 vs Vondrák precession); Sun worst-case 0.08″ at 120 sampled epochs"
113
+ },
114
+ {
115
+ "name": "RA / Dec",
116
+ "max": "2.1",
117
+ "rms": "—",
118
+ "note": "rotation is exact; bound tracks each body's ecliptic accuracy (Moon worst)"
119
+ },
120
+ {
121
+ "name": "Topocentric Moon",
122
+ "max": "2.7",
123
+ "rms": "—",
124
+ "note": "parallax model adds ≤0.1″ over the geocentric bound"
125
+ },
126
+ {
127
+ "name": "House cusps, 8 new systems",
128
+ "max": "0.0",
129
+ "rms": "0.0",
130
+ "note": "Koch, Regiomontanus, Campanus, Alcabitius, Morinus, Meridian, Polich-Page, Vehlow: exact vs swe_houses_armc (200 polar-inclusive cases each)"
131
+ },
132
+ {
133
+ "name": "Vertex / east point",
134
+ "max": "0.0",
135
+ "rms": "0.0",
136
+ "note": "exact vs swe_houses_armc"
137
+ },
138
+ {
139
+ "name": "Magnitudes",
140
+ "max": "0.045 mag",
141
+ "rms": "—",
142
+ "note": "Mallama 2018; Moon (Allen law) valid to phase angle 140°"
143
+ },
144
+ {
145
+ "name": "Rise/set/meridian transit",
146
+ "max": "0.5 s",
147
+ "rms": "—",
148
+ "note": "vs swe_rise_trans, 48 polar-inclusive cases per body; Moon bound tracks its position accuracy"
149
+ },
150
+ {
151
+ "name": "Crossings / lunar phases",
152
+ "max": "4 s",
153
+ "rms": "—",
154
+ "note": "vs swe_solcross/swe_mooncross and elongation root-finds"
155
+ },
156
+ {
157
+ "name": "Stations",
158
+ "max": "55 s",
159
+ "rms": "—",
160
+ "note": "ill-conditioned by nature: speed-zero slope ~0.01°/day² turns sub-arcsecond model differences into minutes"
161
+ },
162
+ {
163
+ "name": "True Lilith (osc. apogee)",
164
+ "max": "187",
165
+ "rms": "—",
166
+ "note": "hypersensitive to the lunar theory (~1/e amplification); SE's own Moshier-vs-DE difference dominates. 'True Lilith' values disagree across software at this scale"
167
+ },
168
+ {
169
+ "name": "Ceres, Pallas, Juno, Vesta, Pholus",
170
+ "max": "1.0",
171
+ "rms": "0.3",
172
+ "note": "JPL Horizons fits 1850-2150 (residual <5e-6 AU); same geocentric pipeline as Chiron. No independent SE oracle here: SE's asteroid files are unavailable in Moshier mode"
173
+ },
174
+ {
175
+ "name": "Uranian bodies (Cupido…Poseidon)",
176
+ "max": "2.3",
177
+ "rms": "—",
178
+ "note": "Hamburg-school constant-element Kepler orbits, elements fitted to SE 2.10's built-in definitions (fit_uranian.py prints per-body figures; Zeus is fit-noise-limited at ~3″ heliocentric). Uranian practice works in arcminutes"
179
+ }
29
180
  ],
30
181
  "summary": [
31
- { "label": "Sun–Saturn", "bound": "≤ 1″" },
32
- { "label": "Uranus / Neptune", "bound": "≤ 1.9″ / ≤ 4.6″" },
33
- { "label": "Moon (1920–2080 tier)", "bound": "≤ 2.5″" },
34
- { "label": "Moon (series, embedded)", "bound": "≤ 10″" },
35
- { "label": "Pluto / Chiron", "bound": "≤ 2.5″ / ≤ 1″" },
36
- { "label": "Angles & Placidus cusps", "bound": "≤ 3.2″" },
37
- { "label": "True node", "bound": "1″" },
38
- { "label": "Mean Lilith", "bound": "≤ 1.3″" },
39
- { "label": "Sidereal (5 ayanamsas)", "bound": "≤ 0.3″ added" },
40
- { "label": "8 new house systems", "bound": "exact (0.0″)" }
182
+ {
183
+ "label": "Sun–Saturn",
184
+ "bound": "≤ 1″"
185
+ },
186
+ {
187
+ "label": "Uranus / Neptune",
188
+ "bound": " 1.9″ /4.6″"
189
+ },
190
+ {
191
+ "label": "Moon (1920–2080 tier)",
192
+ "bound": "≤ 2.5″"
193
+ },
194
+ {
195
+ "label": "Moon (series, embedded)",
196
+ "bound": "≤ 10″"
197
+ },
198
+ {
199
+ "label": "Pluto / Chiron",
200
+ "bound": "≤ 2.5″ / ≤ 1″"
201
+ },
202
+ {
203
+ "label": "Angles & Placidus cusps",
204
+ "bound": "≤ 3.2″"
205
+ },
206
+ {
207
+ "label": "True node",
208
+ "bound": "≤ 1′ vs SE built-in (≤ 1″ vs JPL DE431)"
209
+ },
210
+ {
211
+ "label": "Mean Lilith",
212
+ "bound": "≤ 1.3″"
213
+ },
214
+ {
215
+ "label": "Sidereal (5 ayanamsas)",
216
+ "bound": "≤ 0.3″ added"
217
+ },
218
+ {
219
+ "label": "8 new house systems",
220
+ "bound": "exact (0.0″)"
221
+ },
222
+ {
223
+ "label": "Rise/set/transit",
224
+ "bound": "≤ 0.5 s"
225
+ },
226
+ {
227
+ "label": "Crossings & phases",
228
+ "bound": "≤ 4 s"
229
+ },
230
+ {
231
+ "label": "Asteroids (big 4 + Pholus)",
232
+ "bound": "≤ 1″"
233
+ },
234
+ {
235
+ "label": "Uranian bodies",
236
+ "bound": "≤ 2.3″"
237
+ }
41
238
  ],
42
- "v03_harness": "python/validate_swiss.py regenerates every figure above the line against pyswisseph 2.10 (Moshier mode)"
43
- }
239
+ "v03_harness": "python/validate_swiss.py regenerates every figure above the line against pyswisseph 2.10 (Moshier mode)",
240
+ "true_node_vs_builtin": "1′"
241
+ }
@@ -3,7 +3,7 @@ import { EngineData, AYANAMSA_J2000 } from "./core.js";
3
3
  export declare const BODIES: readonly ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune", "pluto", "chiron", "mean_node", "true_node"];
4
4
  export type Body = (typeof BODIES)[number];
5
5
  /** Computable on request (not in the default chart set). */
6
- export declare const EXTRA_BODIES: readonly ["mean_lilith"];
6
+ export declare const EXTRA_BODIES: readonly ["mean_lilith", "true_lilith"];
7
7
  /** Core names keep autocomplete; any string id is accepted (data packs). */
8
8
  export type BodyId = Body | (typeof EXTRA_BODIES)[number] | (string & {});
9
9
  export declare const SIGNS: string[];
@@ -68,12 +68,16 @@ export declare class Engine {
68
68
  readonly data: EngineData;
69
69
  private moonCheb;
70
70
  private chironCheb;
71
+ private packs;
71
72
  constructor(data: EngineData);
73
+ private pack;
72
74
  private moonInRange;
73
75
  /** Body ids this engine can compute, given the data it was handed. */
74
76
  bodies(): BodyId[];
75
- /** Apparent geocentric [lon rad, lat rad, dist AU | null]. */
76
- private ecliptic;
77
+ /** Apparent geocentric [lon rad, lat rad, dist AU | null] at TT jde.
78
+ * Building block for the events module; chart consumers want
79
+ * position() instead. */
80
+ ecliptic(body: BodyId, jde: number): [number, number, number | null];
77
81
  private lonOnly;
78
82
  /** Apparent geocentric ecliptic longitude (deg). Tropical: true equinox
79
83
  * of date. Sidereal: mean equinox minus ayanamsa. */
package/dist/src/chart.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** astroengine chart -- public API: natal charts, aspects, retrogrades. */
2
- import { DEG, mod, jdTT, julianDay, ChebSeries, planetApparent, sunApparent, moonApparentSeries, moonApparentPrecise, plutoApparent, chironApparent, meanNode, trueNodeSeries, trueNodePrecise, equatorial, ayanamsa, AYANAMSA_J2000, meanLilith, topocentricEcl, trueObliquity, nutation, plutoHeliocentric, vsopHeliocentric, precessEcliptic, J2000, } from "./core.js";
2
+ import { DEG, mod, jdTT, julianDay, ChebSeries, planetApparent, sunApparent, moonApparentSeries, moonApparentPrecise, plutoApparent, chironApparent, meanNode, trueNodeSeries, trueNodePrecise, equatorial, ayanamsa, AYANAMSA_J2000, meanLilith, topocentricEcl, oscApogeePrecise, oscApogeeSeries, KeplerOrbit, trueObliquity, nutation, plutoHeliocentric, vsopHeliocentric, precessEcliptic, J2000, } from "./core.js";
3
3
  import * as H from "./houses.js";
4
4
  const TWO_PI = 2 * Math.PI;
5
5
  export const BODIES = [
@@ -7,9 +7,11 @@ export const BODIES = [
7
7
  "uranus", "neptune", "pluto", "chiron", "mean_node", "true_node",
8
8
  ];
9
9
  /** Computable on request (not in the default chart set). */
10
- export const EXTRA_BODIES = ["mean_lilith"];
10
+ export const EXTRA_BODIES = ["mean_lilith", "true_lilith"];
11
11
  /** Points: excluded from aspect search by default. */
12
- const NOT_ASPECTABLE = new Set(["mean_node", "true_node", "mean_lilith"]);
12
+ const NOT_ASPECTABLE = new Set([
13
+ "mean_node", "true_node", "mean_lilith", "true_lilith",
14
+ ]);
13
15
  export const SIGNS = [
14
16
  "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra",
15
17
  "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
@@ -38,20 +40,42 @@ export class Engine {
38
40
  data;
39
41
  moonCheb;
40
42
  chironCheb;
43
+ packs = new Map();
41
44
  constructor(data) {
42
45
  this.data = data;
43
46
  this.moonCheb = data.moonCheb ? new ChebSeries(data.moonCheb) : null;
44
47
  this.chironCheb = data.chiron ? new ChebSeries(data.chiron) : null;
45
48
  }
49
+ pack(body) {
50
+ let s = this.packs.get(body);
51
+ if (!s) {
52
+ const raw = this.data.chebPacks?.[body];
53
+ const kp = this.data.keplerPack;
54
+ if (raw)
55
+ s = new ChebSeries(raw);
56
+ else if (kp?.bodies[body])
57
+ s = new KeplerOrbit(kp.bodies[body], kp.epoch);
58
+ else
59
+ throw new Error(`no data loaded for body '${body}'`);
60
+ this.packs.set(body, s);
61
+ }
62
+ return s;
63
+ }
46
64
  moonInRange(jde) {
47
65
  return !!this.moonCheb
48
66
  && this.moonCheb.jd0 <= jde - 0.1 && jde + 0.1 <= this.moonCheb.jd1;
49
67
  }
50
68
  /** Body ids this engine can compute, given the data it was handed. */
51
69
  bodies() {
52
- return [...BODIES, ...EXTRA_BODIES].filter((b) => b !== "chiron" || this.chironCheb);
70
+ return [
71
+ ...[...BODIES, ...EXTRA_BODIES].filter((b) => b !== "chiron" || this.chironCheb),
72
+ ...Object.keys(this.data.chebPacks ?? {}),
73
+ ...Object.keys(this.data.keplerPack?.bodies ?? {}),
74
+ ];
53
75
  }
54
- /** Apparent geocentric [lon rad, lat rad, dist AU | null]. */
76
+ /** Apparent geocentric [lon rad, lat rad, dist AU | null] at TT jde.
77
+ * Building block for the events module; chart consumers want
78
+ * position() instead. */
55
79
  ecliptic(body, jde) {
56
80
  if (body === "sun")
57
81
  return sunApparent(this.data, jde);
@@ -82,6 +106,16 @@ export class Engine {
82
106
  const [lon, lat] = meanLilith(this.data, jde);
83
107
  return [lon, lat, null];
84
108
  }
109
+ if (body === "true_lilith") {
110
+ const [lon, lat, km] = this.moonInRange(jde)
111
+ ? oscApogeePrecise(this.data, this.moonCheb, jde)
112
+ : oscApogeeSeries(this.data, jde);
113
+ return [lon, lat, km / KM_PER_AU];
114
+ }
115
+ if (this.data.chebPacks?.[body] || this.data.keplerPack?.bodies[body]) {
116
+ // same heliocentric pipeline as Chiron (Chebyshev or Kepler source)
117
+ return chironApparent(this.data, this.pack(body), jde);
118
+ }
85
119
  if (this.data.vsop[body])
86
120
  return planetApparent(this.data, body, jde);
87
121
  throw new Error(`no data loaded for body '${body}'`);
@@ -125,6 +159,13 @@ export class Engine {
125
159
  b = Math.atan2(z, Math.hypot(x, y));
126
160
  [l, b] = precessEcliptic(l, b, J2000, jde);
127
161
  }
162
+ else if (this.data.chebPacks?.[body] || this.data.keplerPack?.bodies[body]) {
163
+ const [x, y, z] = this.pack(body).xyz(jde);
164
+ r = Math.sqrt(x * x + y * y + z * z);
165
+ l = mod(Math.atan2(y, x), TWO_PI);
166
+ b = Math.atan2(z, Math.hypot(x, y));
167
+ [l, b] = precessEcliptic(l, b, J2000, jde);
168
+ }
128
169
  else if (VSOP_BODIES.has(body) && this.data.vsop[body]) {
129
170
  [l, b, r] = vsopHeliocentric(this.data.vsop[body], jde);
130
171
  }
@@ -26,6 +26,24 @@ export type ChebData = {
26
26
  scale?: number;
27
27
  segments: number[][][];
28
28
  };
29
+ export type KeplerElements = {
30
+ a: number;
31
+ e: number;
32
+ i: number;
33
+ node: number;
34
+ peri: number;
35
+ M0: number;
36
+ n: number;
37
+ };
38
+ export type KeplerPack = {
39
+ epoch: number;
40
+ bodies: Record<string, KeplerElements>;
41
+ };
42
+ /** Anything that yields heliocentric ecliptic-J2000 xyz (AU) at a TT jd:
43
+ * ChebSeries (fitted small bodies) or KeplerOrbit (Uranian bodies). */
44
+ export interface XyzSource {
45
+ xyz(jd: number): [number, number, number];
46
+ }
29
47
  export interface EngineData {
30
48
  vsop: Record<string, VsopSeries>;
31
49
  nutation: number[][];
@@ -33,6 +51,11 @@ export interface EngineData {
33
51
  pluto: number[][];
34
52
  chiron?: ChebData;
35
53
  moonCheb?: ChebData;
54
+ /** Heliocentric ecliptic-J2000 Chebyshev packs by body id (ceres,
55
+ * pallas, juno, vesta, pholus, ...). Same pipeline as Chiron. */
56
+ chebPacks?: Record<string, ChebData>;
57
+ /** Hamburg-school (Uranian) constant-element orbits; see fit_uranian.py. */
58
+ keplerPack?: KeplerPack;
36
59
  }
37
60
  export declare function julianDay(y: number, mo: number, d: number, h?: number, mi?: number, s?: number): number;
38
61
  /** TT - UT1 in seconds. Observed IERS 1955-2025, E&M polynomials before,
@@ -82,6 +105,19 @@ export declare function ayanamsa(jde: number, mode: string): number;
82
105
  /** Mean lunar apogee (Black Moon Lilith) on the inclined lunar orbit:
83
106
  * apparent lon (true equinox) and orbital latitude, radians. */
84
107
  export declare function meanLilith(data: EngineData, jde: number): [number, number];
108
+ /** Osculating lunar apogee (True Lilith) from the Chebyshev moon. */
109
+ export declare function oscApogeePrecise(data: EngineData, cheb: ChebSeries, jde: number): [number, number, number];
110
+ /** Series fallback outside the Chebyshev range (same finite-difference
111
+ * state as the true-node fallback). */
112
+ export declare function oscApogeeSeries(data: EngineData, jde: number): [number, number, number];
113
+ /** Constant-element two-body orbit with the same xyz(jde) interface as
114
+ * ChebSeries, so chironApparent takes either. */
115
+ export declare class KeplerOrbit implements XyzSource {
116
+ private els;
117
+ private epoch;
118
+ constructor(els: KeplerElements, epoch: number);
119
+ xyz(jde: number): [number, number, number];
120
+ }
85
121
  export declare const EARTH_RADIUS_AU: number;
86
122
  /** Diurnal parallax in ecliptic coordinates (Meeus ch. 11/40).
87
123
  * lst = local apparent sidereal time (rad). Returns [lon, lat, distAu]. */
@@ -89,4 +125,4 @@ export declare function topocentricEcl(lon: number, lat: number, distAu: number,
89
125
  /** Meeus ch.37 heliocentric Pluto, ecliptic J2000: [l rad, b rad, r AU]. */
90
126
  export declare function plutoHeliocentric(data: EngineData, jde: number): [number, number, number];
91
127
  export declare function plutoApparent(data: EngineData, jde: number): [number, number, number];
92
- export declare function chironApparent(data: EngineData, cheb: ChebSeries, jde: number): [number, number, number];
128
+ export declare function chironApparent(data: EngineData, cheb: XyzSource, jde: number): [number, number, number];
package/dist/src/core.js CHANGED
@@ -420,6 +420,84 @@ export function meanLilith(data, jde) {
420
420
  lon = mod(lon + nutation(data, jde)[0], TWO_PI);
421
421
  return [lon, lat];
422
422
  }
423
+ const GM_EARTH_MOON = 403503.2356 * 86400.0 ** 2; // km^3/day^2
424
+ /** Osculating apogee point from a geocentric lunar state vector (km,
425
+ * km/day): apparent ecliptic lon/lat of date (rad) + distance (km).
426
+ * Hypersensitive to the lunar theory: the eccentricity vector amplifies
427
+ * position/velocity differences ~1/e (~18x). Swiss Ephemeris in Moshier
428
+ * mode differs from our DE423 fit by up to ~3 arcmin here; published
429
+ * 'True Lilith' values disagree across software at that scale. */
430
+ function oscApogeeFromState(data, x, y, z, vx, vy, vz, jde, frameJ2000) {
431
+ const mu = GM_EARTH_MOON;
432
+ const r = Math.sqrt(x * x + y * y + z * z);
433
+ const v2 = vx * vx + vy * vy + vz * vz;
434
+ const rv = x * vx + y * vy + z * vz;
435
+ const ex = (v2 * x - rv * vx) / mu - x / r;
436
+ const ey = (v2 * y - rv * vy) / mu - y / r;
437
+ const ez = (v2 * z - rv * vz) / mu - z / r;
438
+ const e = Math.sqrt(ex * ex + ey * ey + ez * ez);
439
+ const a = 1.0 / (2.0 / r - v2 / mu);
440
+ const s = (a * (1 + e)) / e;
441
+ let px = -ex * s;
442
+ let py = -ey * s;
443
+ let pz = -ez * s;
444
+ if (frameJ2000)
445
+ [px, py, pz] = eclJ2000ToEclDate([px, py, pz], jde);
446
+ const lon = mod(Math.atan2(py, px) + nutation(data, jde)[0], TWO_PI);
447
+ const lat = Math.atan2(pz, Math.hypot(px, py));
448
+ return [lon, lat, Math.sqrt(px * px + py * py + pz * pz)];
449
+ }
450
+ /** Osculating lunar apogee (True Lilith) from the Chebyshev moon. */
451
+ export function oscApogeePrecise(data, cheb, jde) {
452
+ const [[x, y, z], [vx, vy, vz]] = cheb.xyzVel(jde);
453
+ return oscApogeeFromState(data, x, y, z, vx, vy, vz, jde, true);
454
+ }
455
+ /** Series fallback outside the Chebyshev range (same finite-difference
456
+ * state as the true-node fallback). */
457
+ export function oscApogeeSeries(data, jde) {
458
+ const h = 0.01;
459
+ const xyz = (t) => {
460
+ const [lon, lat, dist] = moonGeometric(data, t);
461
+ return [
462
+ dist * Math.cos(lat) * Math.cos(lon),
463
+ dist * Math.cos(lat) * Math.sin(lon),
464
+ dist * Math.sin(lat),
465
+ ];
466
+ };
467
+ const [x0, y0, z0] = xyz(jde - h);
468
+ const [x1, y1, z1] = xyz(jde + h);
469
+ const [x, y, z] = xyz(jde);
470
+ return oscApogeeFromState(data, x, y, z, (x1 - x0) / (2 * h), (y1 - y0) / (2 * h), (z1 - z0) / (2 * h), jde, false);
471
+ }
472
+ /** Constant-element two-body orbit with the same xyz(jde) interface as
473
+ * ChebSeries, so chironApparent takes either. */
474
+ export class KeplerOrbit {
475
+ els;
476
+ epoch;
477
+ constructor(els, epoch) {
478
+ this.els = els;
479
+ this.epoch = epoch;
480
+ }
481
+ xyz(jde) {
482
+ const { a, e, i, node, peri: w, M0, n } = this.els;
483
+ const M = M0 + n * (jde - this.epoch);
484
+ let E = M;
485
+ for (let k = 0; k < 30; k++) {
486
+ E = E - (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));
487
+ }
488
+ const xv = a * (Math.cos(E) - e);
489
+ const yv = a * Math.sqrt(1 - e * e) * Math.sin(E);
490
+ const cw = Math.cos(w);
491
+ const sw = Math.sin(w);
492
+ const cn = Math.cos(node);
493
+ const sn = Math.sin(node);
494
+ const ci = Math.cos(i);
495
+ const si = Math.sin(i);
496
+ const xp = xv * cw - yv * sw;
497
+ const yp = xv * sw + yv * cw;
498
+ return [xp * cn - yp * sn * ci, xp * sn + yp * cn * ci, yp * si];
499
+ }
500
+ }
423
501
  export const EARTH_RADIUS_AU = 6378.14 / 149597870.7;
424
502
  const EARTH_FLAT = 0.99664719; // 1 - f, IAU 1976 figure
425
503
  /** Diurnal parallax in ecliptic coordinates (Meeus ch. 11/40).
@@ -0,0 +1,22 @@
1
+ import { Engine, BodyId, Zodiac } from "./chart.js";
2
+ export type RiseKind = "rise" | "set" | "mtransit" | "itransit";
3
+ export interface RiseSetOptions {
4
+ altM?: number;
5
+ pressure?: number;
6
+ tempC?: number;
7
+ searchDays?: number;
8
+ }
9
+ /** Next rise/set/meridian transit (UT JD) after jdStart, or null when the
10
+ * event does not occur in the window (polar day/night). */
11
+ export declare function riseSet(engine: Engine, body: BodyId, jdStart: number, latDeg: number, lonDeg: number, kind?: RiseKind, opts?: RiseSetOptions): number | null;
12
+ /** UT JDs where the body's apparent longitude crosses targetLon (degrees)
13
+ * in [jdStart, jdEnd]. Retrograde bodies can cross a degree three times;
14
+ * every crossing is returned in time order. */
15
+ export declare function crossings(engine: Engine, body: BodyId, targetLon: number, jdStart: number, jdEnd: number, zodiac?: Zodiac, maxHits?: number): number[];
16
+ export type PhaseName = "new" | "first_quarter" | "full" | "last_quarter";
17
+ /** New/first-quarter/full/last-quarter times in [jdStart, jdEnd], sorted. */
18
+ export declare function lunarPhases(engine: Engine, jdStart: number, jdEnd: number, maxHits?: number): Array<[number, PhaseName]>;
19
+ /** Times the body stations (speed crosses zero): [jdUt, direction the body
20
+ * turns]. Sun and Moon never station. Station timing is ill-conditioned:
21
+ * expect minute-level differences between ephemerides. */
22
+ export declare function stations(engine: Engine, body: BodyId, jdStart: number, jdEnd: number, maxHits?: number): Array<[number, "retrograde" | "direct"]>;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * astroengine events -- rise/set/meridian transits, zodiac crossings,
3
+ * lunar phases, stations.
4
+ *
5
+ * Rise/set condition (matches Swiss Ephemeris defaults, calibrated against
6
+ * swe_rise_trans at standard pressure/temperature): the topocentric true
7
+ * altitude of the disc center equals -(R0 + topocentric semidiameter),
8
+ * with R0 = 34.076 arcmin scaled by (pressure/1010)(283/(273+temp)). All
9
+ * searches are bracketed sign changes refined by bisection; speeds come
10
+ * from the same apparent-position pipeline as the chart API, so retrograde
11
+ * loops and multiple crossings are found, not assumed away.
12
+ */
13
+ import { DEG, mod, jdTT, equatorial, trueObliquity, topocentricEcl, } from "./core.js";
14
+ import { gast } from "./houses.js";
15
+ import { DIAMETER_KM } from "./pheno.js";
16
+ const TWO_PI = 2 * Math.PI;
17
+ const KM_PER_AU = 149597870.7;
18
+ const R0_ARCMIN = 34.076; // horizon refraction at 1010 hPa / 10 C (vs SE)
19
+ function topoAltHa(engine, body, jdUt, latDeg, lonDeg, altM) {
20
+ const jde = jdTT(jdUt);
21
+ let [lon, lat, dist] = engine.ecliptic(body, jde);
22
+ const eps = trueObliquity(engine.data, jde);
23
+ const lst = mod(gast(engine.data, jdUt) + lonDeg * DEG, TWO_PI);
24
+ if (dist !== null) {
25
+ [lon, lat, dist] = topocentricEcl(lon, lat, dist, lst, latDeg * DEG, altM, eps);
26
+ }
27
+ const [ra, dec] = equatorial(lon, lat, eps);
28
+ const ha = mod(lst - ra + Math.PI, TWO_PI) - Math.PI;
29
+ const phi = latDeg * DEG;
30
+ const alt = Math.asin(Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(ha));
31
+ return [alt, ha, dist];
32
+ }
33
+ function bisect(f, a, b, iters = 45) {
34
+ let fa = f(a);
35
+ for (let i = 0; i < iters; i++) {
36
+ const m = (a + b) / 2;
37
+ if (fa * f(m) <= 0) {
38
+ b = m;
39
+ }
40
+ else {
41
+ a = m;
42
+ fa = f(a);
43
+ }
44
+ }
45
+ return (a + b) / 2;
46
+ }
47
+ /** Next rise/set/meridian transit (UT JD) after jdStart, or null when the
48
+ * event does not occur in the window (polar day/night). */
49
+ export function riseSet(engine, body, jdStart, latDeg, lonDeg, kind = "rise", opts = {}) {
50
+ const altM = opts.altM ?? 0.0;
51
+ const pressure = opts.pressure ?? 1013.25;
52
+ const tempC = opts.tempC ?? 15.0;
53
+ const searchDays = opts.searchDays ?? 2.0;
54
+ const scale = (pressure / 1010.0) * (283.0 / (273.0 + tempC));
55
+ if (kind === "mtransit" || kind === "itransit") {
56
+ const target = kind === "mtransit" ? 0.0 : Math.PI;
57
+ const g = (t) => {
58
+ const [, ha] = topoAltHa(engine, body, t, latDeg, lonDeg, altM);
59
+ return mod(ha - target + Math.PI, TWO_PI) - Math.PI;
60
+ };
61
+ const step = 1.0 / 48;
62
+ let prev = g(jdStart);
63
+ for (let t = jdStart + step; t <= jdStart + searchDays; t += step) {
64
+ const cur = g(t);
65
+ if (prev * cur < 0 && Math.abs(cur - prev) < Math.PI) {
66
+ return bisect(g, t - step, t);
67
+ }
68
+ prev = cur;
69
+ }
70
+ return null;
71
+ }
72
+ const f = (t) => {
73
+ const [alt, , dist] = topoAltHa(engine, body, t, latDeg, lonDeg, altM);
74
+ let sd = 0.0;
75
+ const diam = DIAMETER_KM[body];
76
+ if (diam !== undefined && dist !== null) {
77
+ sd = Math.asin(diam / (2 * dist * KM_PER_AU));
78
+ }
79
+ const h0 = -((R0_ARCMIN / 60.0) * scale * DEG + sd);
80
+ return alt - h0;
81
+ };
82
+ const step = 1.0 / 48; // 30 min: well under the fastest crossing scale
83
+ let prev = f(jdStart);
84
+ for (let t = jdStart + step; t <= jdStart + searchDays; t += step) {
85
+ const cur = f(t);
86
+ if ((kind === "rise" && prev < 0 && cur >= 0)
87
+ || (kind === "set" && prev > 0 && cur <= 0)) {
88
+ return bisect(f, t - step, t);
89
+ }
90
+ prev = cur;
91
+ }
92
+ return null;
93
+ }
94
+ /** UT JDs where the body's apparent longitude crosses targetLon (degrees)
95
+ * in [jdStart, jdEnd]. Retrograde bodies can cross a degree three times;
96
+ * every crossing is returned in time order. */
97
+ export function crossings(engine, body, targetLon, jdStart, jdEnd, zodiac = "tropical", maxHits = 60) {
98
+ const f = (t) => mod(engine.longitude(body, t, { zodiac }) - targetLon + 180, 360) - 180;
99
+ const fast = body === "moon" || body === "mean_node"
100
+ || body === "true_node" || body === "mean_lilith" || body === "true_lilith";
101
+ const step = fast ? 0.25 : 1.0;
102
+ const out = [];
103
+ let prev = f(jdStart);
104
+ for (let t = jdStart + step; t <= jdEnd && out.length < maxHits; t += step) {
105
+ const cur = f(t);
106
+ if (prev * cur < 0 && Math.abs(cur - prev) < 180) {
107
+ out.push(bisect(f, t - step, t));
108
+ }
109
+ prev = cur;
110
+ }
111
+ return out;
112
+ }
113
+ /** New/first-quarter/full/last-quarter times in [jdStart, jdEnd], sorted. */
114
+ export function lunarPhases(engine, jdStart, jdEnd, maxHits = 60) {
115
+ const elong = (t) => mod(engine.longitude("moon", t) - engine.longitude("sun", t), 360);
116
+ const names = [
117
+ [0, "new"], [90, "first_quarter"], [180, "full"], [270, "last_quarter"],
118
+ ];
119
+ const out = [];
120
+ const step = 0.25;
121
+ for (const [angle, name] of names) {
122
+ const f = (t) => mod(elong(t) - angle + 180, 360) - 180;
123
+ let prev = f(jdStart);
124
+ for (let t = jdStart + step; t <= jdEnd && out.length < maxHits; t += step) {
125
+ const cur = f(t);
126
+ if (prev * cur < 0 && Math.abs(cur - prev) < 180) {
127
+ out.push([bisect(f, t - step, t), name]);
128
+ }
129
+ prev = cur;
130
+ }
131
+ }
132
+ out.sort((a, b) => a[0] - b[0]);
133
+ return out;
134
+ }
135
+ /** Times the body stations (speed crosses zero): [jdUt, direction the body
136
+ * turns]. Sun and Moon never station. Station timing is ill-conditioned:
137
+ * expect minute-level differences between ephemerides. */
138
+ export function stations(engine, body, jdStart, jdEnd, maxHits = 30) {
139
+ const h = 0.25;
140
+ const speed = (t) => {
141
+ const l0 = engine.longitude(body, t - h);
142
+ const l1 = engine.longitude(body, t + h);
143
+ return (mod(l1 - l0 + 540, 360) - 180) / (2 * h);
144
+ };
145
+ const step = 2.0;
146
+ const out = [];
147
+ let prev = speed(jdStart);
148
+ for (let t = jdStart + step; t <= jdEnd && out.length < maxHits; t += step) {
149
+ const cur = speed(t);
150
+ if (prev * cur < 0) {
151
+ out.push([bisect(speed, t - step, t), prev > 0 ? "retrograde" : "direct"]);
152
+ }
153
+ prev = cur;
154
+ }
155
+ return out;
156
+ }
@@ -2,3 +2,4 @@ export * from "./core.js";
2
2
  export * from "./houses.js";
3
3
  export * from "./chart.js";
4
4
  export * from "./pheno.js";
5
+ export * from "./events.js";
package/dist/src/index.js CHANGED
@@ -2,3 +2,4 @@ export * from "./core.js";
2
2
  export * from "./houses.js";
3
3
  export * from "./chart.js";
4
4
  export * from "./pheno.js";
5
+ export * from "./events.js";
@@ -7,8 +7,18 @@ const PLANETS = ["mercury", "venus", "earth", "mars", "jupiter", "saturn",
7
7
  export function loadNodeData(dir, level = "embedded", moonTier = "full") {
8
8
  const j = (name) => JSON.parse(readFileSync(join(dir, name), "utf8"));
9
9
  const vsop = {};
10
- for (const p of PLANETS)
11
- vsop[p] = j(`vsop87d_${p}.${level}.json`);
10
+ // The npm package ships the embedded and micro VSOP tiers; full/high live
11
+ // in the repo. Fall back per planet so "full" against the published
12
+ // tarball loads instead of throwing ENOENT.
13
+ for (const p of PLANETS) {
14
+ const tiers = level === "embedded" || level === "micro" ? [level] : [level, "embedded"];
15
+ const found = tiers.find((t) => existsSync(join(dir, `vsop87d_${p}.${t}.json`)));
16
+ if (!found) {
17
+ throw new Error(`no VSOP87D data for ${p} in ${dir} (tried ${tiers.join(", ")}); `
18
+ + "the full/high tiers live in the caelus repo, not the npm package");
19
+ }
20
+ vsop[p] = j(`vsop87d_${p}.${found}.json`);
21
+ }
12
22
  const data = {
13
23
  vsop,
14
24
  nutation: j("nutation_iau1980.json"),
@@ -18,6 +28,15 @@ export function loadNodeData(dir, level = "embedded", moonTier = "full") {
18
28
  const chironPath = join(dir, "chiron_cheb.json");
19
29
  if (existsSync(chironPath))
20
30
  data.chiron = j("chiron_cheb.json");
31
+ if (existsSync(join(dir, "uranian_kepler.json"))) {
32
+ data.keplerPack = j("uranian_kepler.json");
33
+ }
34
+ // asteroid packs (Horizons fits): loaded when present, ~380 KB total
35
+ for (const b of ["ceres", "pallas", "juno", "vesta", "pholus"]) {
36
+ if (existsSync(join(dir, `${b}_cheb.json`))) {
37
+ (data.chebPacks ??= {})[b] = j(`${b}_cheb.json`);
38
+ }
39
+ }
21
40
  if (moonTier !== "none") {
22
41
  // The npm package ships only the embedded tier (1920-2080); the full
23
42
  // tier (1850-2150, 3.1 MB, same precision) lives in the repo. Fall back
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caelus",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Astrological ephemeris engine. MIT, no AGPL, no ephemeris files. Checked against Swiss Ephemeris.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",