caelus 0.3.0 → 0.5.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,218 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,271 @@
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
+ },
180
+ {
181
+ "name": "Fixed stars (318-star catalog)",
182
+ "max": "0.6",
183
+ "rms": "—",
184
+ "note": "HYG-derived catalog (ICRS J2000 + proper motions, full 3D space motion); vs swe_fixstar fed the same rows. Floor is the IAU 1976 vs Vondrák precession difference"
185
+ },
186
+ {
187
+ "name": "Star-anchored ayanamsas",
188
+ "max": "0.2",
189
+ "rms": "—",
190
+ "note": "galcent_0sag and true_citra computed from the apparent star (Galactic Center / Spica); sidereal Sun vs SE ≤0.19″"
191
+ },
192
+ {
193
+ "name": "Gauquelin sectors",
194
+ "max": "0.0001 sectors",
195
+ "rms": "—",
196
+ "note": "rise/set of disc center with refraction (SE method 3): exact to the rise/set bound"
197
+ },
198
+ {
199
+ "name": "Eclipses (solar + lunar)",
200
+ "max": "9 s",
201
+ "rms": "—",
202
+ "note": "times of maximum vs swe; types exact over 1990-2030 (92 lunar + 89 solar, zero mismatches); lunar magnitudes ≤0.0013 (Danjon parallax enlargement, recovered empirically). Contact times typically ≤15 s, minutes for grazing geometries. Global circumstances only — no ground paths"
203
+ }
29
204
  ],
30
205
  "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″)" }
206
+ {
207
+ "label": "Sun–Saturn",
208
+ "bound": "≤ 1″"
209
+ },
210
+ {
211
+ "label": "Uranus / Neptune",
212
+ "bound": " 1.9″ /4.6″"
213
+ },
214
+ {
215
+ "label": "Moon (1920–2080 tier)",
216
+ "bound": "≤ 2.5″"
217
+ },
218
+ {
219
+ "label": "Moon (series, embedded)",
220
+ "bound": "≤ 10″"
221
+ },
222
+ {
223
+ "label": "Pluto / Chiron",
224
+ "bound": "≤ 2.5″ / ≤ 1″"
225
+ },
226
+ {
227
+ "label": "Angles & Placidus cusps",
228
+ "bound": "≤ 3.2″"
229
+ },
230
+ {
231
+ "label": "True node",
232
+ "bound": "≤ 1′ vs SE built-in (≤ 1″ vs JPL DE431)"
233
+ },
234
+ {
235
+ "label": "Mean Lilith",
236
+ "bound": "≤ 1.3″"
237
+ },
238
+ {
239
+ "label": "8 new house systems",
240
+ "bound": "exact (0.0″)"
241
+ },
242
+ {
243
+ "label": "Rise/set/transit",
244
+ "bound": "≤ 0.5 s"
245
+ },
246
+ {
247
+ "label": "Crossings & phases",
248
+ "bound": "≤ 4 s"
249
+ },
250
+ {
251
+ "label": "Asteroids (big 4 + Pholus)",
252
+ "bound": "≤ 1″"
253
+ },
254
+ {
255
+ "label": "Uranian bodies",
256
+ "bound": "≤ 2.3″"
257
+ },
258
+ {
259
+ "label": "Fixed stars",
260
+ "bound": "≤ 0.6″"
261
+ },
262
+ {
263
+ "label": "Sidereal (7 ayanamsas)",
264
+ "bound": "≤ 0.3″ added"
265
+ },
266
+ {
267
+ "label": "Eclipses",
268
+ "bound": "types exact; max ≤ 9 s"
269
+ }
41
270
  ],
42
- "v03_harness": "python/validate_swiss.py regenerates every figure above the line against pyswisseph 2.10 (Moshier mode)"
43
- }
271
+ "v03_harness": "python/validate_swiss.py regenerates every figure above the line against pyswisseph 2.10 (Moshier mode)",
272
+ "true_node_vs_builtin": "1′"
273
+ }
@@ -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,30 @@ 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];
81
+ /** Degrees to subtract from a true-equinox tropical longitude. */
82
+ private ayanShift;
83
+ /** Apparent place of a catalog star: lon/lat/ra/dec (deg), sign, mag. */
84
+ fixedStar(name: string, jdUt: number, opts?: CalcOptions): {
85
+ lon: number;
86
+ lat: number;
87
+ ra: number;
88
+ dec: number;
89
+ mag: number;
90
+ sign: string;
91
+ signDeg: number;
92
+ };
93
+ /** Names in the loaded fixed-star catalog (sorted). */
94
+ starNames(): string[];
77
95
  private lonOnly;
78
96
  /** Apparent geocentric ecliptic longitude (deg). Tropical: true equinox
79
97
  * of date. Sidereal: mean equinox minus ayanamsa. */
package/dist/src/chart.js CHANGED
@@ -1,5 +1,6 @@
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
+ import { starApparent } from "./stars.js";
3
4
  import * as H from "./houses.js";
4
5
  const TWO_PI = 2 * Math.PI;
5
6
  export const BODIES = [
@@ -7,9 +8,11 @@ export const BODIES = [
7
8
  "uranus", "neptune", "pluto", "chiron", "mean_node", "true_node",
8
9
  ];
9
10
  /** Computable on request (not in the default chart set). */
10
- export const EXTRA_BODIES = ["mean_lilith"];
11
+ export const EXTRA_BODIES = ["mean_lilith", "true_lilith"];
11
12
  /** Points: excluded from aspect search by default. */
12
- const NOT_ASPECTABLE = new Set(["mean_node", "true_node", "mean_lilith"]);
13
+ const NOT_ASPECTABLE = new Set([
14
+ "mean_node", "true_node", "mean_lilith", "true_lilith",
15
+ ]);
13
16
  export const SIGNS = [
14
17
  "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra",
15
18
  "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
@@ -26,11 +29,18 @@ function parseZodiac(zodiac) {
26
29
  return null;
27
30
  if (zodiac.startsWith("sidereal:")) {
28
31
  const mode = zodiac.slice("sidereal:".length);
29
- if (AYANAMSA_J2000[mode] !== undefined)
32
+ if (AYANAMSA_J2000[mode] !== undefined || STAR_AYANAMSAS[mode])
30
33
  return mode;
31
34
  }
32
35
  throw new Error(`unknown zodiac ${JSON.stringify(zodiac)}`);
33
36
  }
37
+ /** Star-anchored ayanamsas: the named star sits at the fixed sidereal
38
+ * longitude by definition (Galactic Center at 0 Sagittarius; Spica at
39
+ * 0 Libra "citra"). Need the fixed-star catalog loaded. */
40
+ const STAR_AYANAMSAS = {
41
+ galcent_0sag: ["Galactic Center", 240.0],
42
+ true_citra: ["Spica", 180.0],
43
+ };
34
44
  const VSOP_BODIES = new Set([
35
45
  "mercury", "venus", "earth", "mars", "jupiter", "saturn", "uranus", "neptune",
36
46
  ]);
@@ -38,20 +48,42 @@ export class Engine {
38
48
  data;
39
49
  moonCheb;
40
50
  chironCheb;
51
+ packs = new Map();
41
52
  constructor(data) {
42
53
  this.data = data;
43
54
  this.moonCheb = data.moonCheb ? new ChebSeries(data.moonCheb) : null;
44
55
  this.chironCheb = data.chiron ? new ChebSeries(data.chiron) : null;
45
56
  }
57
+ pack(body) {
58
+ let s = this.packs.get(body);
59
+ if (!s) {
60
+ const raw = this.data.chebPacks?.[body];
61
+ const kp = this.data.keplerPack;
62
+ if (raw)
63
+ s = new ChebSeries(raw);
64
+ else if (kp?.bodies[body])
65
+ s = new KeplerOrbit(kp.bodies[body], kp.epoch);
66
+ else
67
+ throw new Error(`no data loaded for body '${body}'`);
68
+ this.packs.set(body, s);
69
+ }
70
+ return s;
71
+ }
46
72
  moonInRange(jde) {
47
73
  return !!this.moonCheb
48
74
  && this.moonCheb.jd0 <= jde - 0.1 && jde + 0.1 <= this.moonCheb.jd1;
49
75
  }
50
76
  /** Body ids this engine can compute, given the data it was handed. */
51
77
  bodies() {
52
- return [...BODIES, ...EXTRA_BODIES].filter((b) => b !== "chiron" || this.chironCheb);
78
+ return [
79
+ ...[...BODIES, ...EXTRA_BODIES].filter((b) => b !== "chiron" || this.chironCheb),
80
+ ...Object.keys(this.data.chebPacks ?? {}),
81
+ ...Object.keys(this.data.keplerPack?.bodies ?? {}),
82
+ ];
53
83
  }
54
- /** Apparent geocentric [lon rad, lat rad, dist AU | null]. */
84
+ /** Apparent geocentric [lon rad, lat rad, dist AU | null] at TT jde.
85
+ * Building block for the events module; chart consumers want
86
+ * position() instead. */
55
87
  ecliptic(body, jde) {
56
88
  if (body === "sun")
57
89
  return sunApparent(this.data, jde);
@@ -82,10 +114,53 @@ export class Engine {
82
114
  const [lon, lat] = meanLilith(this.data, jde);
83
115
  return [lon, lat, null];
84
116
  }
117
+ if (body === "true_lilith") {
118
+ const [lon, lat, km] = this.moonInRange(jde)
119
+ ? oscApogeePrecise(this.data, this.moonCheb, jde)
120
+ : oscApogeeSeries(this.data, jde);
121
+ return [lon, lat, km / KM_PER_AU];
122
+ }
123
+ if (this.data.chebPacks?.[body] || this.data.keplerPack?.bodies[body]) {
124
+ // same heliocentric pipeline as Chiron (Chebyshev or Kepler source)
125
+ return chironApparent(this.data, this.pack(body), jde);
126
+ }
85
127
  if (this.data.vsop[body])
86
128
  return planetApparent(this.data, body, jde);
87
129
  throw new Error(`no data loaded for body '${body}'`);
88
130
  }
131
+ /** Degrees to subtract from a true-equinox tropical longitude. */
132
+ ayanShift(jde, mode) {
133
+ const star = STAR_AYANAMSAS[mode];
134
+ if (star) {
135
+ const s = this.data.fixedStars?.stars[star[0]];
136
+ if (!s)
137
+ throw new Error(`zodiac 'sidereal:${mode}' needs the fixed-star catalog loaded`);
138
+ const [lon] = starApparent(this.data, s, jde);
139
+ return mod(lon / DEG - star[1], 360);
140
+ }
141
+ return mod(nutation(this.data, jde)[0] / DEG + ayanamsa(jde, mode), 360);
142
+ }
143
+ /** Apparent place of a catalog star: lon/lat/ra/dec (deg), sign, mag. */
144
+ fixedStar(name, jdUt, opts = {}) {
145
+ const s = this.data.fixedStars?.stars[name];
146
+ if (!s)
147
+ throw new Error(`no fixed-star catalog entry for '${name}'`);
148
+ const mode = parseZodiac(opts.zodiac ?? "tropical");
149
+ const jde = jdTT(jdUt);
150
+ const [lonR, latR] = starApparent(this.data, s, jde);
151
+ const [ra, dec] = equatorial(lonR, latR, trueObliquity(this.data, jde));
152
+ let lon = lonR / DEG;
153
+ if (mode !== null)
154
+ lon = mod(lon - this.ayanShift(jde, mode), 360);
155
+ return {
156
+ lon, lat: latR / DEG, ra: ra / DEG, dec: dec / DEG, mag: s.mag,
157
+ sign: SIGNS[Math.floor(lon / 30)], signDeg: mod(lon, 30),
158
+ };
159
+ }
160
+ /** Names in the loaded fixed-star catalog (sorted). */
161
+ starNames() {
162
+ return Object.keys(this.data.fixedStars?.stars ?? {}).sort();
163
+ }
89
164
  lonOnly(body, jdUt, mode, topo) {
90
165
  const jde = jdTT(jdUt);
91
166
  let [lon, lat, dist] = this.ecliptic(body, jde);
@@ -94,9 +169,8 @@ export class Engine {
94
169
  [lon, lat, dist] = topocentricEcl(lon, lat, dist, lst, topo.lat * DEG, topo.altM ?? 0.0, trueObliquity(this.data, jde));
95
170
  }
96
171
  let lonDeg = lon / DEG;
97
- if (mode !== null) {
98
- lonDeg = mod(lonDeg - nutation(this.data, jde)[0] / DEG - ayanamsa(jde, mode), 360);
99
- }
172
+ if (mode !== null)
173
+ lonDeg = mod(lonDeg - this.ayanShift(jde, mode), 360);
100
174
  return lonDeg;
101
175
  }
102
176
  /** Apparent geocentric ecliptic longitude (deg). Tropical: true equinox
@@ -125,6 +199,13 @@ export class Engine {
125
199
  b = Math.atan2(z, Math.hypot(x, y));
126
200
  [l, b] = precessEcliptic(l, b, J2000, jde);
127
201
  }
202
+ else if (this.data.chebPacks?.[body] || this.data.keplerPack?.bodies[body]) {
203
+ const [x, y, z] = this.pack(body).xyz(jde);
204
+ r = Math.sqrt(x * x + y * y + z * z);
205
+ l = mod(Math.atan2(y, x), TWO_PI);
206
+ b = Math.atan2(z, Math.hypot(x, y));
207
+ [l, b] = precessEcliptic(l, b, J2000, jde);
208
+ }
128
209
  else if (VSOP_BODIES.has(body) && this.data.vsop[body]) {
129
210
  [l, b, r] = vsopHeliocentric(this.data.vsop[body], jde);
130
211
  }
@@ -145,9 +226,8 @@ export class Engine {
145
226
  }
146
227
  const [ra, dec] = equatorial(lonR, latR, trueObliquity(this.data, jde));
147
228
  let lon = lonR / DEG;
148
- if (mode !== null) {
149
- lon = mod(lon - nutation(this.data, jde)[0] / DEG - ayanamsa(jde, mode), 360);
150
- }
229
+ if (mode !== null)
230
+ lon = mod(lon - this.ayanShift(jde, mode), 360);
151
231
  const h = 0.25; // days; central difference
152
232
  const l0 = this.lonOnly(body, jdUt - h, mode, topo);
153
233
  const l1 = this.lonOnly(body, jdUt + h, mode, topo);
@@ -235,9 +315,8 @@ export class Engine {
235
315
  }
236
316
  const jde = jdTT(jdUt);
237
317
  let shift = 0.0;
238
- if (mode !== null) {
239
- shift = nutation(this.data, jde)[0] / DEG + ayanamsa(jde, mode);
240
- }
318
+ if (mode !== null)
319
+ shift = this.ayanShift(jde, mode);
241
320
  const outDeg = (rad) => mod(rad / DEG - shift, 360);
242
321
  let cuspsDeg;
243
322
  if (mode !== null && used === "whole_sign") {
@@ -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,13 @@ 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;
59
+ /** Fixed-star catalog (HYG-derived; ICRS J2000 + proper motions). */
60
+ fixedStars?: import("./stars.js").StarPack;
36
61
  }
37
62
  export declare function julianDay(y: number, mo: number, d: number, h?: number, mi?: number, s?: number): number;
38
63
  /** TT - UT1 in seconds. Observed IERS 1955-2025, E&M polynomials before,
@@ -82,6 +107,19 @@ export declare function ayanamsa(jde: number, mode: string): number;
82
107
  /** Mean lunar apogee (Black Moon Lilith) on the inclined lunar orbit:
83
108
  * apparent lon (true equinox) and orbital latitude, radians. */
84
109
  export declare function meanLilith(data: EngineData, jde: number): [number, number];
110
+ /** Osculating lunar apogee (True Lilith) from the Chebyshev moon. */
111
+ export declare function oscApogeePrecise(data: EngineData, cheb: ChebSeries, jde: number): [number, number, number];
112
+ /** Series fallback outside the Chebyshev range (same finite-difference
113
+ * state as the true-node fallback). */
114
+ export declare function oscApogeeSeries(data: EngineData, jde: number): [number, number, number];
115
+ /** Constant-element two-body orbit with the same xyz(jde) interface as
116
+ * ChebSeries, so chironApparent takes either. */
117
+ export declare class KeplerOrbit implements XyzSource {
118
+ private els;
119
+ private epoch;
120
+ constructor(els: KeplerElements, epoch: number);
121
+ xyz(jde: number): [number, number, number];
122
+ }
85
123
  export declare const EARTH_RADIUS_AU: number;
86
124
  /** Diurnal parallax in ecliptic coordinates (Meeus ch. 11/40).
87
125
  * lst = local apparent sidereal time (rad). Returns [lon, lat, distAu]. */
@@ -89,4 +127,4 @@ export declare function topocentricEcl(lon: number, lat: number, distAu: number,
89
127
  /** Meeus ch.37 heliocentric Pluto, ecliptic J2000: [l rad, b rad, r AU]. */
90
128
  export declare function plutoHeliocentric(data: EngineData, jde: number): [number, number, number];
91
129
  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];
130
+ 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,24 @@
1
+ import { Engine } from "./chart.js";
2
+ export interface LunarEclipse {
3
+ tMax: number;
4
+ type: "total" | "partial" | "penumbral";
5
+ magUmbral: number;
6
+ magPenumbral: number;
7
+ penumbralBegin: number | null;
8
+ penumbralEnd: number | null;
9
+ partialBegin: number | null;
10
+ partialEnd: number | null;
11
+ totalBegin: number | null;
12
+ totalEnd: number | null;
13
+ }
14
+ export interface SolarEclipse {
15
+ tMax: number;
16
+ type: "total" | "annular" | "hybrid" | "partial";
17
+ gamma: number;
18
+ begin: number;
19
+ end: number;
20
+ }
21
+ /** Lunar eclipses in [jdStart, jdEnd] (UT JDs). */
22
+ export declare function lunarEclipses(engine: Engine, jdStart: number, jdEnd: number): LunarEclipse[];
23
+ /** Solar eclipses (global circumstances) in [jdStart, jdEnd] (UT JDs). */
24
+ export declare function solarEclipses(engine: Engine, jdStart: number, jdEnd: number): SolarEclipse[];
@@ -0,0 +1,163 @@
1
+ /**
2
+ * astroengine eclipses -- solar and lunar eclipse search.
3
+ *
4
+ * Lunar: direct shadow geometry at the anti-solar point with Danjon's
5
+ * enlargement (lunar parallax x 86/85 on the flattened Earth) — the rule
6
+ * Swiss Ephemeris uses, recovered empirically: magnitudes match to 0.001,
7
+ * types exactly; times of maximum to ~9 s (contact times typically <=15 s,
8
+ * up to ~2 min for grazing geometries where the crossing flattens).
9
+ *
10
+ * Solar (global): shadow-axis geometry. gamma = closest approach of the
11
+ * Sun-Moon axis to the geocenter in Earth radii; the umbral cone's reach
12
+ * at the surface separates total from annular, a sign change along the
13
+ * track marks hybrids. Types match Swiss Ephemeris exactly over decades.
14
+ * Local circumstances (where/visibility) are not computed here.
15
+ */
16
+ import { ARCSEC, jdTT, mod } from "./core.js";
17
+ const KM_PER_AU = 149597870.7;
18
+ const R_EARTH = 6378.14;
19
+ const R_SUN = 696000.0;
20
+ const R_MOON = 1737.4;
21
+ const PI_SUN = 8.794 * ARCSEC;
22
+ const DANJON = (1 + 1 / 85.0) * 0.99834;
23
+ function lunarGeom(engine, jd) {
24
+ const jde = jdTT(jd);
25
+ const [slon, slat, sdist] = engine.ecliptic("sun", jde);
26
+ const [mlon, mlat, mdist] = engine.ecliptic("moon", jde);
27
+ const alon = mod(slon + Math.PI, 2 * Math.PI);
28
+ const alat = -slat;
29
+ const cosd = Math.sin(alat) * Math.sin(mlat)
30
+ + Math.cos(alat) * Math.cos(mlat) * Math.cos(alon - mlon);
31
+ const theta = Math.acos(Math.max(-1, Math.min(1, cosd)));
32
+ const mkm = mdist * KM_PER_AU;
33
+ const piEff = DANJON * Math.asin(R_EARTH / mkm);
34
+ const sM = Math.asin(R_MOON / mkm);
35
+ const sS = Math.asin(R_SUN / (sdist * KM_PER_AU));
36
+ return [theta, piEff - sS + PI_SUN, piEff + sS + PI_SUN, sM];
37
+ }
38
+ function solarGeom(engine, jd) {
39
+ const jde = jdTT(jd);
40
+ const [slon, slat, sdist] = engine.ecliptic("sun", jde);
41
+ const [mlon, mlat, mdist] = engine.ecliptic("moon", jde);
42
+ const vec = (lon, lat, r) => [
43
+ r * Math.cos(lat) * Math.cos(lon),
44
+ r * Math.cos(lat) * Math.sin(lon),
45
+ r * Math.sin(lat),
46
+ ];
47
+ const S = vec(slon, slat, sdist * KM_PER_AU);
48
+ const M = vec(mlon, mlat, mdist * KM_PER_AU);
49
+ const SM = [M[0] - S[0], M[1] - S[1], M[2] - S[2]];
50
+ const smn = Math.sqrt(SM[0] ** 2 + SM[1] ** 2 + SM[2] ** 2);
51
+ const d = SM.map((c) => c / smn);
52
+ const t0 = -(M[0] * d[0] + M[1] * d[1] + M[2] * d[2]);
53
+ const P = [M[0] + t0 * d[0], M[1] + t0 * d[1], M[2] + t0 * d[2]];
54
+ const dAxis = Math.sqrt(P[0] ** 2 + P[1] ** 2 + P[2] ** 2);
55
+ const f1 = Math.asin((R_SUN + R_MOON) / smn);
56
+ const f2 = Math.asin((R_SUN - R_MOON) / smn);
57
+ const rPen = (R_MOON / Math.tan(f1) + t0) * Math.tan(f1);
58
+ const rUmb = (R_MOON / Math.tan(f2) - t0) * Math.tan(f2);
59
+ return [dAxis, rPen, rUmb, t0, f2];
60
+ }
61
+ function minimize(f, lo, hi) {
62
+ for (let i = 0; i < 60; i++) {
63
+ const m1 = lo + (hi - lo) / 3;
64
+ const m2 = hi - (hi - lo) / 3;
65
+ if (f(m1) < f(m2))
66
+ hi = m2;
67
+ else
68
+ lo = m1;
69
+ }
70
+ return (lo + hi) / 2;
71
+ }
72
+ function bisect(f, a, b) {
73
+ let fa = f(a);
74
+ for (let i = 0; i < 50; i++) {
75
+ const m = (a + b) / 2;
76
+ if (fa * f(m) <= 0) {
77
+ b = m;
78
+ }
79
+ else {
80
+ a = m;
81
+ fa = f(a);
82
+ }
83
+ }
84
+ return (a + b) / 2;
85
+ }
86
+ function syzygies(engine, jdStart, jdEnd, angle) {
87
+ const f = (t) => {
88
+ const e = mod(engine.longitude("moon", t) - engine.longitude("sun", t), 360);
89
+ return mod(e - angle + 180, 360) - 180;
90
+ };
91
+ const out = [];
92
+ const step = 5.0;
93
+ let prev = f(jdStart);
94
+ for (let t = jdStart + step; t <= jdEnd + step; t += step) {
95
+ const cur = f(t);
96
+ if (prev * cur < 0 && Math.abs(cur - prev) < 180) {
97
+ out.push(bisect(f, t - step, t));
98
+ }
99
+ prev = cur;
100
+ }
101
+ return out;
102
+ }
103
+ /** Lunar eclipses in [jdStart, jdEnd] (UT JDs). */
104
+ export function lunarEclipses(engine, jdStart, jdEnd) {
105
+ const out = [];
106
+ for (const tFull of syzygies(engine, jdStart - 1, jdEnd + 1, 180.0)) {
107
+ const tMax = minimize((t) => lunarGeom(engine, t)[0], tFull - 0.3, tFull + 0.3);
108
+ const [theta, u, pen, sM] = lunarGeom(engine, tMax);
109
+ const magU = (u + sM - theta) / (2 * sM);
110
+ const magP = (pen + sM - theta) / (2 * sM);
111
+ if (magP <= 0 || tMax < jdStart || tMax > jdEnd)
112
+ continue;
113
+ const kind = magU >= 1 ? "total" : magU > 0 ? "partial" : "penumbral";
114
+ const cross = (idx, sign) => {
115
+ const f = (t) => {
116
+ const g = lunarGeom(engine, t);
117
+ return g[0] - (g[idx] + sign * g[3]);
118
+ };
119
+ return [bisect(f, tMax - 0.35, tMax), bisect(f, tMax, tMax + 0.35)];
120
+ };
121
+ const [penB, penE] = cross(2, 1);
122
+ const [parB, parE] = magU > 0 ? cross(1, 1) : [null, null];
123
+ const [totB, totE] = magU >= 1 ? cross(1, -1) : [null, null];
124
+ out.push({
125
+ tMax, type: kind,
126
+ magUmbral: Math.max(magU, 0), magPenumbral: magP,
127
+ penumbralBegin: penB, penumbralEnd: penE,
128
+ partialBegin: parB, partialEnd: parE,
129
+ totalBegin: totB, totalEnd: totE,
130
+ });
131
+ }
132
+ return out;
133
+ }
134
+ /** Solar eclipses (global circumstances) in [jdStart, jdEnd] (UT JDs). */
135
+ export function solarEclipses(engine, jdStart, jdEnd) {
136
+ const out = [];
137
+ for (const tNew of syzygies(engine, jdStart - 1, jdEnd + 1, 0.0)) {
138
+ const tMax = minimize((t) => solarGeom(engine, t)[0], tNew - 0.4, tNew + 0.4);
139
+ const [dAxis, rPen, rUmb, , f2] = solarGeom(engine, tMax);
140
+ if (dAxis > R_EARTH + rPen || tMax < jdStart || tMax > jdEnd)
141
+ continue;
142
+ const gamma = dAxis / R_EARTH;
143
+ let kind;
144
+ if (dAxis < R_EARTH) {
145
+ const depth = Math.sqrt(Math.max(R_EARTH ** 2 - dAxis ** 2, 0));
146
+ const rUmbSurface = rUmb + depth * Math.tan(f2);
147
+ kind = rUmb > 0 ? "total" : rUmbSurface > 0 ? "hybrid" : "annular";
148
+ }
149
+ else {
150
+ kind = "partial";
151
+ }
152
+ const f = (t) => {
153
+ const g = solarGeom(engine, t);
154
+ return g[0] - (R_EARTH + g[1]);
155
+ };
156
+ out.push({
157
+ tMax, type: kind, gamma,
158
+ begin: bisect(f, tMax - 0.35, tMax),
159
+ end: bisect(f, tMax, tMax + 0.35),
160
+ });
161
+ }
162
+ return out;
163
+ }
@@ -0,0 +1,29 @@
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
+ /** Rise/set of the disc center instead of the upper limb. */
9
+ discCenter?: boolean;
10
+ }
11
+ /** Next rise/set/meridian transit (UT JD) after jdStart, or null when the
12
+ * event does not occur in the window (polar day/night). */
13
+ export declare function riseSet(engine: Engine, body: BodyId, jdStart: number, latDeg: number, lonDeg: number, kind?: RiseKind, opts?: RiseSetOptions): number | null;
14
+ /** UT JDs where the body's apparent longitude crosses targetLon (degrees)
15
+ * in [jdStart, jdEnd]. Retrograde bodies can cross a degree three times;
16
+ * every crossing is returned in time order. */
17
+ export declare function crossings(engine: Engine, body: BodyId, targetLon: number, jdStart: number, jdEnd: number, zodiac?: Zodiac, maxHits?: number): number[];
18
+ export type PhaseName = "new" | "first_quarter" | "full" | "last_quarter";
19
+ /** New/first-quarter/full/last-quarter times in [jdStart, jdEnd], sorted. */
20
+ export declare function lunarPhases(engine: Engine, jdStart: number, jdEnd: number, maxHits?: number): Array<[number, PhaseName]>;
21
+ /** Times the body stations (speed crosses zero): [jdUt, direction the body
22
+ * turns]. Sun and Moon never station. Station timing is ill-conditioned:
23
+ * expect minute-level differences between ephemerides. */
24
+ export declare function stations(engine: Engine, body: BodyId, jdStart: number, jdEnd: number, maxHits?: number): Array<[number, "retrograde" | "direct"]>;
25
+ /** Gauquelin sector (1..36, float) from rise/set times of the disc center
26
+ * with refraction (Swiss Ephemeris method 3). Sectors run from rise: 1-18
27
+ * above the horizon, 19-36 below. Null in polar no-rise/no-set
28
+ * conditions. */
29
+ export declare function gauquelinSector(engine: Engine, body: BodyId, jdUt: number, latDeg: number, lonDeg: number): number | null;
@@ -0,0 +1,184 @@
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 (!opts.discCenter && 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
+ }
157
+ /** Gauquelin sector (1..36, float) from rise/set times of the disc center
158
+ * with refraction (Swiss Ephemeris method 3). Sectors run from rise: 1-18
159
+ * above the horizon, 19-36 below. Null in polar no-rise/no-set
160
+ * conditions. */
161
+ export function gauquelinSector(engine, body, jdUt, latDeg, lonDeg) {
162
+ const surrounding = (kind) => {
163
+ let t = riseSet(engine, body, jdUt - 1.3, latDeg, lonDeg, kind, { discCenter: true });
164
+ let prev = null;
165
+ while (t !== null && t <= jdUt) {
166
+ prev = t;
167
+ t = riseSet(engine, body, t + 1e-4, latDeg, lonDeg, kind, { discCenter: true });
168
+ }
169
+ return [prev, t];
170
+ };
171
+ const [prevRise] = surrounding("rise");
172
+ const [prevSet, nextSetA] = surrounding("set");
173
+ if (prevRise === null || prevSet === null)
174
+ return null;
175
+ if (prevRise > prevSet) {
176
+ if (nextSetA === null)
177
+ return null;
178
+ return 1 + (18 * (jdUt - prevRise)) / (nextSetA - prevRise);
179
+ }
180
+ const [, nextRise] = surrounding("rise");
181
+ if (nextRise === null)
182
+ return null;
183
+ return 19 + (18 * (jdUt - prevSet)) / (nextRise - prevSet);
184
+ }
@@ -2,3 +2,6 @@ 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";
6
+ export * from "./stars.js";
7
+ export * from "./eclipses.js";
package/dist/src/index.js CHANGED
@@ -2,3 +2,6 @@ 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";
6
+ export * from "./stars.js";
7
+ export * from "./eclipses.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,18 @@ 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
+ if (existsSync(join(dir, "fixed_stars.json"))) {
35
+ data.fixedStars = j("fixed_stars.json");
36
+ }
37
+ // asteroid packs (Horizons fits): loaded when present, ~380 KB total
38
+ for (const b of ["ceres", "pallas", "juno", "vesta", "pholus"]) {
39
+ if (existsSync(join(dir, `${b}_cheb.json`))) {
40
+ (data.chebPacks ??= {})[b] = j(`${b}_cheb.json`);
41
+ }
42
+ }
21
43
  if (moonTier !== "none") {
22
44
  // The npm package ships only the embedded tier (1920-2080); the full
23
45
  // tier (1850-2150, 3.1 MB, same precision) lives in the repo. Fall back
@@ -0,0 +1,29 @@
1
+ /**
2
+ * astroengine stars -- fixed stars: apparent places from the HYG-derived
3
+ * catalog (data/fixed_stars.json; ICRS J2000 with proper motions).
4
+ *
5
+ * Chain: full 3D space motion (proper motion + radial velocity at the
6
+ * parallax distance) -> ICRS equatorial -> ecliptic J2000 -> IAU 1976
7
+ * precession to date -> annual aberration (classic elliptic form, as for
8
+ * Pluto/Chiron) -> nutation. Validated against swe_fixstar fed the same
9
+ * catalog rows: <=0.6 arcsec over 1900-2099 (the floor is the IAU 1976 vs
10
+ * Vondrak precession difference, shared with the rest of the engine).
11
+ */
12
+ import { EngineData } from "./core.js";
13
+ export interface StarEntry {
14
+ ra: number;
15
+ dec: number;
16
+ pmra: number;
17
+ pmdec: number;
18
+ rv: number;
19
+ plx: number;
20
+ mag: number;
21
+ bayer: string;
22
+ }
23
+ export interface StarPack {
24
+ provenance: string;
25
+ frame: string;
26
+ stars: Record<string, StarEntry>;
27
+ }
28
+ /** Apparent ecliptic [lon, lat] of date (rad) for a catalog entry. */
29
+ export declare function starApparent(data: EngineData, s: StarEntry, jde: number): [number, number];
@@ -0,0 +1,53 @@
1
+ /**
2
+ * astroengine stars -- fixed stars: apparent places from the HYG-derived
3
+ * catalog (data/fixed_stars.json; ICRS J2000 with proper motions).
4
+ *
5
+ * Chain: full 3D space motion (proper motion + radial velocity at the
6
+ * parallax distance) -> ICRS equatorial -> ecliptic J2000 -> IAU 1976
7
+ * precession to date -> annual aberration (classic elliptic form, as for
8
+ * Pluto/Chiron) -> nutation. Validated against swe_fixstar fed the same
9
+ * catalog rows: <=0.6 arcsec over 1900-2099 (the floor is the IAU 1976 vs
10
+ * Vondrak precession difference, shared with the rest of the engine).
11
+ */
12
+ import { DEG, ARCSEC, J2000, mod, nutation, precessEcliptic, vsopHeliocentric, } from "./core.js";
13
+ const TWO_PI = 2 * Math.PI;
14
+ const KM_PER_AU = 149597870.7;
15
+ const AU_PER_PC = 206264.806;
16
+ /** Apparent ecliptic [lon, lat] of date (rad) for a catalog entry. */
17
+ export function starApparent(data, s, jde) {
18
+ const t = (jde - J2000) / 365.25;
19
+ const ra = s.ra * DEG;
20
+ const dec = s.dec * DEG;
21
+ const rAu = s.plx > 0 ? AU_PER_PC / (s.plx * 1e-3) : 1e9 * AU_PER_PC;
22
+ const cd = Math.cos(dec);
23
+ const sd = Math.sin(dec);
24
+ const cr = Math.cos(ra);
25
+ const sr = Math.sin(ra);
26
+ const p = [cd * cr, cd * sr, sd];
27
+ const east = [-sr, cr, 0.0];
28
+ const north = [-sd * cr, -sd * sr, cd];
29
+ const pmra = s.pmra * 1e-3 * ARCSEC;
30
+ const pmdec = s.pmdec * 1e-3 * ARCSEC;
31
+ const rv = (s.rv * 86400 * 365.25) / KM_PER_AU;
32
+ const pos = [0, 1, 2].map((i) => p[i] * rAu + (east[i] * pmra * rAu + north[i] * pmdec * rAu + p[i] * rv) * t);
33
+ const rn = Math.sqrt(pos[0] ** 2 + pos[1] ** 2 + pos[2] ** 2);
34
+ const x = pos[0] / rn;
35
+ const y = pos[1] / rn;
36
+ const z = pos[2] / rn;
37
+ const ra2 = Math.atan2(y, x);
38
+ const dec2 = Math.asin(z);
39
+ const e0 = 84381.448 * ARCSEC;
40
+ let lat = Math.asin(Math.sin(dec2) * Math.cos(e0) - Math.cos(dec2) * Math.sin(e0) * Math.sin(ra2));
41
+ let lon = mod(Math.atan2(Math.sin(ra2) * Math.cos(e0) + Math.tan(dec2) * Math.sin(e0), Math.cos(ra2)), TWO_PI);
42
+ [lon, lat] = precessEcliptic(lon, lat, J2000, jde);
43
+ const [L0] = vsopHeliocentric(data.vsop.earth, jde);
44
+ const sunLon = mod(L0 + Math.PI, TWO_PI);
45
+ const T = (jde - J2000) / 36525.0;
46
+ const k = 20.4898 * ARCSEC;
47
+ const e = 0.016708634 - 0.000042037 * T;
48
+ const piPer = (102.93735 + 1.71946 * T) * DEG;
49
+ lon += (-k * Math.cos(sunLon - lon) + e * k * Math.cos(piPer - lon)) / Math.cos(lat);
50
+ lat += -k * Math.sin(lat) * (Math.sin(sunLon - lon) - e * Math.sin(piPer - lon));
51
+ lon = mod(lon + nutation(data, jde)[0], TWO_PI);
52
+ return [lon, lat];
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caelus",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",