caelus 0.2.1 → 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/dist/src/chart.js CHANGED
@@ -1,10 +1,17 @@
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, } 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
+ const TWO_PI = 2 * Math.PI;
4
5
  export const BODIES = [
5
6
  "sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn",
6
7
  "uranus", "neptune", "pluto", "chiron", "mean_node", "true_node",
7
8
  ];
9
+ /** Computable on request (not in the default chart set). */
10
+ export const EXTRA_BODIES = ["mean_lilith", "true_lilith"];
11
+ /** Points: excluded from aspect search by default. */
12
+ const NOT_ASPECTABLE = new Set([
13
+ "mean_node", "true_node", "mean_lilith", "true_lilith",
14
+ ]);
8
15
  export const SIGNS = [
9
16
  "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra",
10
17
  "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
@@ -15,106 +22,291 @@ export const ASPECTS = {
15
22
  export const DEFAULT_ORBS = {
16
23
  conjunction: 8, sextile: 4, square: 7, trine: 7, opposition: 8,
17
24
  };
25
+ const KM_PER_AU = 149597870.7;
26
+ function parseZodiac(zodiac) {
27
+ if (zodiac === "tropical")
28
+ return null;
29
+ if (zodiac.startsWith("sidereal:")) {
30
+ const mode = zodiac.slice("sidereal:".length);
31
+ if (AYANAMSA_J2000[mode] !== undefined)
32
+ return mode;
33
+ }
34
+ throw new Error(`unknown zodiac ${JSON.stringify(zodiac)}`);
35
+ }
36
+ const VSOP_BODIES = new Set([
37
+ "mercury", "venus", "earth", "mars", "jupiter", "saturn", "uranus", "neptune",
38
+ ]);
18
39
  export class Engine {
19
40
  data;
20
41
  moonCheb;
21
42
  chironCheb;
43
+ packs = new Map();
22
44
  constructor(data) {
23
45
  this.data = data;
24
46
  this.moonCheb = data.moonCheb ? new ChebSeries(data.moonCheb) : null;
25
47
  this.chironCheb = data.chiron ? new ChebSeries(data.chiron) : null;
26
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
+ }
27
64
  moonInRange(jde) {
28
65
  return !!this.moonCheb
29
66
  && this.moonCheb.jd0 <= jde - 0.1 && jde + 0.1 <= this.moonCheb.jd1;
30
67
  }
31
- /** Apparent geocentric ecliptic longitude (deg), true equinox of date. */
32
- longitude(body, jdUt) {
33
- const jde = jdTT(jdUt);
34
- let lon;
35
- if (body === "sun") {
36
- [lon] = sunApparent(this.data, jde);
37
- }
38
- else if (body === "moon") {
39
- [lon] = this.moonInRange(jde)
68
+ /** Body ids this engine can compute, given the data it was handed. */
69
+ bodies() {
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
+ ];
75
+ }
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. */
79
+ ecliptic(body, jde) {
80
+ if (body === "sun")
81
+ return sunApparent(this.data, jde);
82
+ if (body === "moon") {
83
+ const [lon, lat, km] = this.moonInRange(jde)
40
84
  ? moonApparentPrecise(this.data, this.moonCheb, jde)
41
85
  : moonApparentSeries(this.data, jde);
86
+ return [lon, lat, km / KM_PER_AU];
87
+ }
88
+ if (body === "pluto")
89
+ return plutoApparent(this.data, jde);
90
+ if (body === "chiron") {
91
+ if (!this.chironCheb)
92
+ throw new Error("chiron data not loaded");
93
+ return chironApparent(this.data, this.chironCheb, jde);
94
+ }
95
+ if (body === "mean_node")
96
+ return [meanNode(this.data, jde), 0.0, null];
97
+ if (body === "true_node") {
98
+ return [
99
+ this.moonInRange(jde)
100
+ ? trueNodePrecise(this.data, this.moonCheb, jde)
101
+ : trueNodeSeries(this.data, jde),
102
+ 0.0, null,
103
+ ];
104
+ }
105
+ if (body === "mean_lilith") {
106
+ const [lon, lat] = meanLilith(this.data, jde);
107
+ return [lon, lat, null];
42
108
  }
43
- else if (body === "pluto") {
44
- [lon] = plutoApparent(this.data, jde);
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
+ }
119
+ if (this.data.vsop[body])
120
+ return planetApparent(this.data, body, jde);
121
+ throw new Error(`no data loaded for body '${body}'`);
122
+ }
123
+ lonOnly(body, jdUt, mode, topo) {
124
+ const jde = jdTT(jdUt);
125
+ let [lon, lat, dist] = this.ecliptic(body, jde);
126
+ if (topo !== null && dist !== null) {
127
+ const lst = mod(H.gast(this.data, jdUt) + topo.lonEast * DEG, TWO_PI);
128
+ [lon, lat, dist] = topocentricEcl(lon, lat, dist, lst, topo.lat * DEG, topo.altM ?? 0.0, trueObliquity(this.data, jde));
129
+ }
130
+ let lonDeg = lon / DEG;
131
+ if (mode !== null) {
132
+ lonDeg = mod(lonDeg - nutation(this.data, jde)[0] / DEG - ayanamsa(jde, mode), 360);
133
+ }
134
+ return lonDeg;
135
+ }
136
+ /** Apparent geocentric ecliptic longitude (deg). Tropical: true equinox
137
+ * of date. Sidereal: mean equinox minus ayanamsa. */
138
+ longitude(body, jdUt, opts = {}) {
139
+ const mode = parseZodiac(opts.zodiac ?? "tropical");
140
+ const topo = opts.topocentric ? opts.observer ?? null : null;
141
+ return this.lonOnly(body, jdUt, mode, topo);
142
+ }
143
+ /** Geometric heliocentric ecliptic of date (deg, deg, AU). */
144
+ heliocentric(body, jdUt) {
145
+ const jde = jdTT(jdUt);
146
+ let l;
147
+ let b;
148
+ let r;
149
+ if (body === "pluto") {
150
+ [l, b, r] = plutoHeliocentric(this.data, jde);
151
+ [l, b] = precessEcliptic(l, b, J2000, jde);
45
152
  }
46
153
  else if (body === "chiron") {
47
154
  if (!this.chironCheb)
48
155
  throw new Error("chiron data not loaded");
49
- [lon] = chironApparent(this.data, this.chironCheb, jde);
156
+ const [x, y, z] = this.chironCheb.xyz(jde);
157
+ r = Math.sqrt(x * x + y * y + z * z);
158
+ l = mod(Math.atan2(y, x), TWO_PI);
159
+ b = Math.atan2(z, Math.hypot(x, y));
160
+ [l, b] = precessEcliptic(l, b, J2000, jde);
50
161
  }
51
- else if (body === "mean_node") {
52
- lon = meanNode(this.data, jde);
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);
53
168
  }
54
- else if (body === "true_node") {
55
- lon = this.moonInRange(jde)
56
- ? trueNodePrecise(this.data, this.moonCheb, jde)
57
- : trueNodeSeries(this.data, jde);
169
+ else if (VSOP_BODIES.has(body) && this.data.vsop[body]) {
170
+ [l, b, r] = vsopHeliocentric(this.data.vsop[body], jde);
58
171
  }
59
172
  else {
60
- [lon] = planetApparent(this.data, body, jde);
173
+ throw new Error(`no heliocentric position for '${body}'`);
61
174
  }
62
- return lon / DEG;
175
+ return { lon: l / DEG, lat: b / DEG, dist: r };
63
176
  }
64
- /** Longitude (deg) + speed (deg/day) + retrograde flag. */
65
- position(body, jdUt) {
66
- const h = 0.25;
67
- const lon = this.longitude(body, jdUt);
68
- const l0 = this.longitude(body, jdUt - h);
69
- const l1 = this.longitude(body, jdUt + h);
177
+ /** Full position: lon/speed/retrograde/sign + lat, dist (AU), ra, dec. */
178
+ position(body, jdUt, opts = {}) {
179
+ const mode = parseZodiac(opts.zodiac ?? "tropical");
180
+ const topo = opts.topocentric ? opts.observer ?? null : null;
181
+ const jde = jdTT(jdUt);
182
+ let [lonR, latR, dist] = this.ecliptic(body, jde);
183
+ if (topo !== null && dist !== null) {
184
+ const lst = mod(H.gast(this.data, jdUt) + topo.lonEast * DEG, TWO_PI);
185
+ [lonR, latR, dist] = topocentricEcl(lonR, latR, dist, lst, topo.lat * DEG, topo.altM ?? 0.0, trueObliquity(this.data, jde));
186
+ }
187
+ const [ra, dec] = equatorial(lonR, latR, trueObliquity(this.data, jde));
188
+ let lon = lonR / DEG;
189
+ if (mode !== null) {
190
+ lon = mod(lon - nutation(this.data, jde)[0] / DEG - ayanamsa(jde, mode), 360);
191
+ }
192
+ const h = 0.25; // days; central difference
193
+ const l0 = this.lonOnly(body, jdUt - h, mode, topo);
194
+ const l1 = this.lonOnly(body, jdUt + h, mode, topo);
70
195
  const speed = (mod(l1 - l0 + 540, 360) - 180) / (2 * h);
71
196
  return {
72
197
  lon, speed, retrograde: speed < 0,
73
198
  sign: SIGNS[Math.floor(lon / 30)], signDeg: mod(lon, 30),
199
+ lat: latR / DEG, dist,
200
+ ra: ra / DEG, dec: dec / DEG,
74
201
  };
75
202
  }
76
- /** Full natal chart. Time is UT. East longitude positive. */
77
- chart(y, mo, d, h, mi, s, lat, lonEast, houseSystem = "placidus") {
203
+ /** Full natal chart. Time is UT. East longitude positive. The ninth
204
+ * argument takes a house system name (0.2.x form) or a ChartOptions bag. */
205
+ chart(y, mo, d, h, mi, s, lat, lonEast, opts = "placidus") {
206
+ const o = typeof opts === "string" ? { houseSystem: opts } : opts;
207
+ const houseSystem = o.houseSystem ?? "placidus";
208
+ const zodiac = o.zodiac ?? "tropical";
209
+ const mode = parseZodiac(zodiac);
78
210
  const jdUt = julianDay(y, mo, d, h, mi, s);
211
+ const calc = {
212
+ zodiac,
213
+ topocentric: o.topocentric,
214
+ observer: o.topocentric ? o.observer ?? { lat, lonEast, altM: 0.0 } : undefined,
215
+ };
216
+ const names = [
217
+ ...BODIES, ...(o.bodies ?? []).filter((b) => !BODIES.includes(b)),
218
+ ];
79
219
  const bodies = {};
80
- for (const b of BODIES)
81
- bodies[b] = this.position(b, jdUt);
220
+ for (const b of names)
221
+ bodies[b] = this.position(b, jdUt, calc);
82
222
  const [asc, mc, armc, eps] = H.angles(this.data, jdUt, lat, lonEast);
223
+ const [vtx, east] = H.vertexEastPoint(armc, lat * DEG, eps);
83
224
  const phi = lat * DEG;
84
- let cusps;
85
225
  let used = houseSystem;
86
- if (houseSystem === "placidus") {
87
- if (Math.abs(lat) < 66.0) {
226
+ let cusps;
227
+ try {
228
+ if (houseSystem === "placidus") {
229
+ if (Math.abs(lat) >= 66.0) {
230
+ throw new RangeError("placidus undefined above polar circles");
231
+ }
88
232
  cusps = H.housesPlacidus(armc, phi, eps);
89
233
  }
90
- else {
91
- used = "whole_sign"; // Placidus undefined above polar circles
234
+ else if (houseSystem === "porphyry") {
235
+ cusps = H.housesPorphyry(asc, mc);
236
+ }
237
+ else if (houseSystem === "equal") {
238
+ cusps = H.housesEqual(asc);
239
+ }
240
+ else if (houseSystem === "whole_sign") {
92
241
  cusps = H.housesWholeSign(asc);
93
242
  }
243
+ else if (houseSystem === "koch") {
244
+ cusps = H.housesKoch(armc, phi, eps);
245
+ }
246
+ else if (houseSystem === "regiomontanus") {
247
+ cusps = H.housesRegiomontanus(armc, phi, eps);
248
+ }
249
+ else if (houseSystem === "campanus") {
250
+ cusps = H.housesCampanus(armc, phi, eps);
251
+ }
252
+ else if (houseSystem === "alcabitius") {
253
+ cusps = H.housesAlcabitius(armc, phi, eps);
254
+ }
255
+ else if (houseSystem === "morinus") {
256
+ cusps = H.housesMorinus(armc, phi, eps);
257
+ }
258
+ else if (houseSystem === "meridian") {
259
+ cusps = H.housesMeridian(armc, phi, eps);
260
+ }
261
+ else if (houseSystem === "polich_page") {
262
+ cusps = H.housesPolichPage(armc, phi, eps);
263
+ }
264
+ else if (houseSystem === "vehlow") {
265
+ cusps = H.housesVehlow(armc, phi, eps);
266
+ }
267
+ else {
268
+ throw new Error(`unknown house system '${houseSystem}'`);
269
+ }
94
270
  }
95
- else if (houseSystem === "porphyry") {
96
- cusps = H.housesPorphyry(asc, mc);
271
+ catch (err) {
272
+ if (!(err instanceof RangeError))
273
+ throw err;
274
+ used = "whole_sign"; // Placidus/Koch undefined above polar circles
275
+ cusps = H.housesWholeSign(asc);
97
276
  }
98
- else if (houseSystem === "equal") {
99
- cusps = H.housesEqual(asc);
277
+ const jde = jdTT(jdUt);
278
+ let shift = 0.0;
279
+ if (mode !== null) {
280
+ shift = nutation(this.data, jde)[0] / DEG + ayanamsa(jde, mode);
281
+ }
282
+ const outDeg = (rad) => mod(rad / DEG - shift, 360);
283
+ let cuspsDeg;
284
+ if (mode !== null && used === "whole_sign") {
285
+ // whole-sign cusps must stay sign-aligned in the sidereal zodiac
286
+ const first = Math.floor(outDeg(asc) / 30) * 30.0;
287
+ cuspsDeg = Array.from({ length: 12 }, (_, i) => mod(first + i * 30.0, 360));
100
288
  }
101
289
  else {
102
- cusps = H.housesWholeSign(asc);
290
+ cuspsDeg = cusps.map(outDeg);
103
291
  }
104
292
  return {
105
293
  jdUt,
294
+ zodiac,
106
295
  houseSystem: used,
107
296
  houseSystemRequested: houseSystem,
108
297
  bodies,
109
- angles: { asc: asc / DEG, mc: mc / DEG },
110
- cusps: cusps.map((c) => c / DEG),
111
- aspects: findAspects(bodies),
298
+ angles: {
299
+ asc: outDeg(asc), mc: outDeg(mc),
300
+ vertex: outDeg(vtx), eastPoint: outDeg(east),
301
+ },
302
+ cusps: cuspsDeg,
303
+ aspects: findAspects(bodies, o.orbs ?? DEFAULT_ORBS),
112
304
  };
113
305
  }
114
306
  }
115
307
  export function findAspects(bodies, orbs = DEFAULT_ORBS) {
116
308
  const out = [];
117
- const names = Object.keys(bodies).filter((b) => !b.endsWith("_node"));
309
+ const names = Object.keys(bodies).filter((b) => !NOT_ASPECTABLE.has(b));
118
310
  for (let i = 0; i < names.length; i++) {
119
311
  for (let j = i + 1; j < names.length; j++) {
120
312
  const a = names[i];
@@ -134,5 +326,5 @@ export function fmtLon(deg) {
134
326
  const sign = SIGNS[Math.floor(deg / 30)];
135
327
  const d = mod(deg, 30);
136
328
  const m = mod(d, 1) * 60;
137
- return `${String(Math.floor(d)).padStart(2)}\u00b0${String(Math.floor(m)).padStart(2, "0")}' ${sign}`;
329
+ return `${String(Math.floor(d)).padStart(2)}°${String(Math.floor(m)).padStart(2, "0")}' ${sign}`;
138
330
  }
@@ -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,
@@ -68,5 +91,38 @@ export declare function trueNodePrecise(data: EngineData, cheb: ChebSeries, jde:
68
91
  export declare function meanNode(data: EngineData, jde: number): number;
69
92
  /** Osculating node from the series moon (fallback outside Chebyshev range). */
70
93
  export declare function trueNodeSeries(data: EngineData, jde: number): number;
94
+ /** Ecliptic lon/lat -> right ascension, declination (all radians). */
95
+ export declare function equatorial(lon: number, lat: number, eps: number): [number, number];
96
+ /** Mean ayanamsa at J2000.0 (degrees) per mode. Standard epoch anchors
97
+ * (matched to Swiss Ephemeris 2.10 to 1e-9 deg); propagation uses IAU 1976
98
+ * ecliptic precession. Agreement with Swiss Ephemeris over 1900-2099 is
99
+ * <=0.30 arcsec (precession-model difference: SE uses Vondrak 2011). */
100
+ export declare const AYANAMSA_J2000: Record<string, number>;
101
+ /** Mean ayanamsa in degrees. Sidereal longitude = (tropical true-equinox
102
+ * longitude - nutation in longitude) - ayanamsa: the sidereal zodiac is
103
+ * anchored to the mean equinox. */
104
+ export declare function ayanamsa(jde: number, mode: string): number;
105
+ /** Mean lunar apogee (Black Moon Lilith) on the inclined lunar orbit:
106
+ * apparent lon (true equinox) and orbital latitude, radians. */
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
+ }
121
+ export declare const EARTH_RADIUS_AU: number;
122
+ /** Diurnal parallax in ecliptic coordinates (Meeus ch. 11/40).
123
+ * lst = local apparent sidereal time (rad). Returns [lon, lat, distAu]. */
124
+ export declare function topocentricEcl(lon: number, lat: number, distAu: number, lst: number, obsLat: number, altM: number, eps: number): [number, number, number];
125
+ /** Meeus ch.37 heliocentric Pluto, ecliptic J2000: [l rad, b rad, r AU]. */
126
+ export declare function plutoHeliocentric(data: EngineData, jde: number): [number, number, number];
71
127
  export declare function plutoApparent(data: EngineData, jde: number): [number, number, number];
72
- 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
@@ -377,30 +377,177 @@ export function trueNodeSeries(data, jde) {
377
377
  const node = mod(Math.atan2(hx, -hy), TWO_PI);
378
378
  return mod(node + nutation(data, jde)[0], TWO_PI);
379
379
  }
380
- // ---------------------------------------------------------------- pluto
381
- export function plutoApparent(data, jde) {
382
- const helioJ2000 = (tJde) => {
383
- const T = (tJde - J2000) / 36525.0;
384
- const J = (34.35 + 3034.9057 * T) * DEG;
385
- const S = (50.08 + 1222.1138 * T) * DEG;
386
- const P = (238.96 + 144.96 * T) * DEG;
387
- let l = 0.0;
388
- let b = 0.0;
389
- let r = 0.0;
390
- for (const [i, j, k, lA, lB, bA, bB, rA, rB] of data.pluto) {
391
- const a = i * J + j * S + k * P;
392
- const sa = Math.sin(a);
393
- const ca = Math.cos(a);
394
- l += lA * sa + lB * ca;
395
- b += bA * sa + bB * ca;
396
- r += rA * sa + rB * ca;
397
- }
380
+ // ---------------------------------------------------------------- frames+
381
+ /** Ecliptic lon/lat -> right ascension, declination (all radians). */
382
+ export function equatorial(lon, lat, eps) {
383
+ const ra = mod(Math.atan2(Math.sin(lon) * Math.cos(eps) - Math.tan(lat) * Math.sin(eps), Math.cos(lon)), TWO_PI);
384
+ const dec = Math.asin(Math.sin(lat) * Math.cos(eps) + Math.cos(lat) * Math.sin(eps) * Math.sin(lon));
385
+ return [ra, dec];
386
+ }
387
+ /** Mean ayanamsa at J2000.0 (degrees) per mode. Standard epoch anchors
388
+ * (matched to Swiss Ephemeris 2.10 to 1e-9 deg); propagation uses IAU 1976
389
+ * ecliptic precession. Agreement with Swiss Ephemeris over 1900-2099 is
390
+ * <=0.30 arcsec (precession-model difference: SE uses Vondrak 2011). */
391
+ export const AYANAMSA_J2000 = {
392
+ lahiri: 23.857092325,
393
+ fagan_bradley: 24.740299966,
394
+ krishnamurti: 23.760240012,
395
+ raman: 22.410791012,
396
+ yukteshwar: 22.478803000,
397
+ };
398
+ /** Mean ayanamsa in degrees. Sidereal longitude = (tropical true-equinox
399
+ * longitude - nutation in longitude) - ayanamsa: the sidereal zodiac is
400
+ * anchored to the mean equinox. */
401
+ export function ayanamsa(jde, mode) {
402
+ const a0 = AYANAMSA_J2000[mode];
403
+ if (a0 === undefined)
404
+ throw new Error(`unknown ayanamsa ${mode}`);
405
+ const [lon] = precessEcliptic(a0 * DEG, 0.0, J2000, jde);
406
+ return lon / DEG;
407
+ }
408
+ /** Mean lunar apogee (Black Moon Lilith) on the inclined lunar orbit:
409
+ * apparent lon (true equinox) and orbital latitude, radians. */
410
+ export function meanLilith(data, jde) {
411
+ const T = (jde - J2000) / 36525.0;
412
+ const [Lp, , , Mp] = moonFundamental(T);
413
+ const apog = Lp - Mp + Math.PI; // mean perigee + 180
414
+ const om = (125.0445479 - 1934.1362891 * T + 0.0020754 * T * T
415
+ + T ** 3 / 467441 - T ** 4 / 60616000) * DEG;
416
+ const inc = 5.145396374 * DEG;
417
+ const u = apog - om;
418
+ const lat = Math.asin(Math.sin(inc) * Math.sin(u));
419
+ let lon = om + Math.atan2(Math.cos(inc) * Math.sin(u), Math.cos(u));
420
+ lon = mod(lon + nutation(data, jde)[0], TWO_PI);
421
+ return [lon, lat];
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);
398
461
  return [
399
- (l + 238.958116 + 144.96 * T) * DEG,
400
- (b - 3.908239) * DEG,
401
- r + 40.7241346,
462
+ dist * Math.cos(lat) * Math.cos(lon),
463
+ dist * Math.cos(lat) * Math.sin(lon),
464
+ dist * Math.sin(lat),
402
465
  ];
403
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
+ }
501
+ export const EARTH_RADIUS_AU = 6378.14 / 149597870.7;
502
+ const EARTH_FLAT = 0.99664719; // 1 - f, IAU 1976 figure
503
+ /** Diurnal parallax in ecliptic coordinates (Meeus ch. 11/40).
504
+ * lst = local apparent sidereal time (rad). Returns [lon, lat, distAu]. */
505
+ export function topocentricEcl(lon, lat, distAu, lst, obsLat, altM, eps) {
506
+ const u = Math.atan(EARTH_FLAT * Math.tan(obsLat));
507
+ const rs = EARTH_FLAT * Math.sin(u) + (altM / 6378140.0) * Math.sin(obsLat);
508
+ const rc = Math.cos(u) + (altM / 6378140.0) * Math.cos(obsLat);
509
+ const ox = EARTH_RADIUS_AU * rc * Math.cos(lst);
510
+ const oy = EARTH_RADIUS_AU * rc * Math.sin(lst);
511
+ const oz = EARTH_RADIUS_AU * rs;
512
+ const [ra, dec] = equatorial(lon, lat, eps);
513
+ const bx = distAu * Math.cos(dec) * Math.cos(ra);
514
+ const by = distAu * Math.cos(dec) * Math.sin(ra);
515
+ const bz = distAu * Math.sin(dec);
516
+ const tx = bx - ox;
517
+ const ty = by - oy;
518
+ const tz = bz - oz;
519
+ const ra2 = Math.atan2(ty, tx);
520
+ const dec2 = Math.atan2(tz, Math.hypot(tx, ty));
521
+ const lon2 = mod(Math.atan2(Math.sin(ra2) * Math.cos(eps) + Math.tan(dec2) * Math.sin(eps), Math.cos(ra2)), TWO_PI);
522
+ const lat2 = Math.asin(Math.sin(dec2) * Math.cos(eps) - Math.cos(dec2) * Math.sin(eps) * Math.sin(ra2));
523
+ return [lon2, lat2, Math.sqrt(tx * tx + ty * ty + tz * tz)];
524
+ }
525
+ // ---------------------------------------------------------------- pluto
526
+ /** Meeus ch.37 heliocentric Pluto, ecliptic J2000: [l rad, b rad, r AU]. */
527
+ export function plutoHeliocentric(data, jde) {
528
+ const T = (jde - J2000) / 36525.0;
529
+ const J = (34.35 + 3034.9057 * T) * DEG;
530
+ const S = (50.08 + 1222.1138 * T) * DEG;
531
+ const P = (238.96 + 144.96 * T) * DEG;
532
+ let l = 0.0;
533
+ let b = 0.0;
534
+ let r = 0.0;
535
+ for (const [i, j, k, lA, lB, bA, bB, rA, rB] of data.pluto) {
536
+ const a = i * J + j * S + k * P;
537
+ const sa = Math.sin(a);
538
+ const ca = Math.cos(a);
539
+ l += lA * sa + lB * ca;
540
+ b += bA * sa + bB * ca;
541
+ r += rA * sa + rB * ca;
542
+ }
543
+ return [
544
+ (l + 238.958116 + 144.96 * T) * DEG,
545
+ (b - 3.908239) * DEG,
546
+ r + 40.7241346,
547
+ ];
548
+ }
549
+ export function plutoApparent(data, jde) {
550
+ const helioJ2000 = (tJde) => plutoHeliocentric(data, tJde);
404
551
  const [L0d, B0d, R0d] = vsopHeliocentric(data.vsop.earth, jde);
405
552
  const [Lj, Bj] = precessEcliptic(L0d, B0d, jde, J2000);
406
553
  const ex = R0d * Math.cos(Bj) * Math.cos(Lj);
@@ -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"]>;