caelus 0.4.0 → 0.6.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
@@ -10,8 +10,8 @@ ephemeris files. 1:1 port of the Python reference, checked by golden fixtures.
10
10
  true node ≤ 1′ vs SE's built-in ephemeris (≤ 1″ vs JPL DE431)
11
11
  (vs full DE431 files, 1850–2149), angles and Placidus cusps ≤ 3.2″ — all
12
12
  invisible at the arcminute display precision chart software uses.
13
- 2. TypeScript port verified against Python golden fixtures: **3,177 checks,
14
- 0 failures, worst deviation 1.64 nano-arcseconds.** The two implementations
13
+ 2. TypeScript port verified against Python golden fixtures: **3,218 checks,
14
+ 0 failures, worst deviation 0.41 nano-arcseconds.** The two implementations
15
15
  are numerically identical.
16
16
 
17
17
  Regenerate fixtures any time from the Python side; any future TS change must
package/accuracy.json CHANGED
@@ -107,9 +107,9 @@
107
107
  },
108
108
  {
109
109
  "name": "Sidereal longitudes",
110
- "max": "0.1",
110
+ "max": "",
111
111
  "rms": "—",
112
- "note": "ayanamsa model vs SE ≤0.30at the 1900/2099 edges (IAU 1976 vs Vondrák precession); Sun worst-case 0.08″ at 120 sampled epochs"
112
+ "note": "ayanamsa model vs SE ≤0.005(Vondrák 2011, same model both sides); sidereal longitudes inherit each body's tropical bound"
113
113
  },
114
114
  {
115
115
  "name": "RA / Dec",
@@ -119,7 +119,7 @@
119
119
  },
120
120
  {
121
121
  "name": "Topocentric Moon",
122
- "max": "2.7",
122
+ "max": "2.5",
123
123
  "rms": "—",
124
124
  "note": "parallax model adds ≤0.1″ over the geocentric bound"
125
125
  },
@@ -176,6 +176,30 @@
176
176
  "max": "2.3",
177
177
  "rms": "—",
178
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.3",
183
+ "rms": "—",
184
+ "note": "HYG-derived catalog (ICRS J2000 + proper motions, full 3D space motion, Vondrák 2011 precession); vs swe_fixstar fed the same rows"
185
+ },
186
+ {
187
+ "name": "Star-anchored ayanamsas",
188
+ "max": "0.5",
189
+ "rms": "—",
190
+ "note": "galcent_0sag and true_citra computed from the apparent star; bound tracks the fixed-star chain (≤0.3″) plus the body's tropical accuracy"
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"
179
203
  }
180
204
  ],
181
205
  "summary": [
@@ -211,10 +235,6 @@
211
235
  "label": "Mean Lilith",
212
236
  "bound": "≤ 1.3″"
213
237
  },
214
- {
215
- "label": "Sidereal (5 ayanamsas)",
216
- "bound": "≤ 0.3″ added"
217
- },
218
238
  {
219
239
  "label": "8 new house systems",
220
240
  "bound": "exact (0.0″)"
@@ -234,6 +254,18 @@
234
254
  {
235
255
  "label": "Uranian bodies",
236
256
  "bound": "≤ 2.3″"
257
+ },
258
+ {
259
+ "label": "Fixed stars",
260
+ "bound": "≤ 0.3″"
261
+ },
262
+ {
263
+ "label": "Sidereal (7 ayanamsas)",
264
+ "bound": "≤ 0.005″ added"
265
+ },
266
+ {
267
+ "label": "Eclipses",
268
+ "bound": "types exact; max ≤ 9 s"
237
269
  }
238
270
  ],
239
271
  "v03_harness": "python/validate_swiss.py regenerates every figure above the line against pyswisseph 2.10 (Moshier mode)",
@@ -78,6 +78,20 @@ export declare class Engine {
78
78
  * Building block for the events module; chart consumers want
79
79
  * position() instead. */
80
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[];
81
95
  private lonOnly;
82
96
  /** Apparent geocentric ecliptic longitude (deg). Tropical: true equinox
83
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
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 = [
@@ -28,11 +29,18 @@ function parseZodiac(zodiac) {
28
29
  return null;
29
30
  if (zodiac.startsWith("sidereal:")) {
30
31
  const mode = zodiac.slice("sidereal:".length);
31
- if (AYANAMSA_J2000[mode] !== undefined)
32
+ if (AYANAMSA_J2000[mode] !== undefined || STAR_AYANAMSAS[mode])
32
33
  return mode;
33
34
  }
34
35
  throw new Error(`unknown zodiac ${JSON.stringify(zodiac)}`);
35
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
+ };
36
44
  const VSOP_BODIES = new Set([
37
45
  "mercury", "venus", "earth", "mars", "jupiter", "saturn", "uranus", "neptune",
38
46
  ]);
@@ -120,6 +128,39 @@ export class Engine {
120
128
  return planetApparent(this.data, body, jde);
121
129
  throw new Error(`no data loaded for body '${body}'`);
122
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
+ }
123
164
  lonOnly(body, jdUt, mode, topo) {
124
165
  const jde = jdTT(jdUt);
125
166
  let [lon, lat, dist] = this.ecliptic(body, jde);
@@ -128,9 +169,8 @@ export class Engine {
128
169
  [lon, lat, dist] = topocentricEcl(lon, lat, dist, lst, topo.lat * DEG, topo.altM ?? 0.0, trueObliquity(this.data, jde));
129
170
  }
130
171
  let lonDeg = lon / DEG;
131
- if (mode !== null) {
132
- lonDeg = mod(lonDeg - nutation(this.data, jde)[0] / DEG - ayanamsa(jde, mode), 360);
133
- }
172
+ if (mode !== null)
173
+ lonDeg = mod(lonDeg - this.ayanShift(jde, mode), 360);
134
174
  return lonDeg;
135
175
  }
136
176
  /** Apparent geocentric ecliptic longitude (deg). Tropical: true equinox
@@ -186,9 +226,8 @@ export class Engine {
186
226
  }
187
227
  const [ra, dec] = equatorial(lonR, latR, trueObliquity(this.data, jde));
188
228
  let lon = lonR / DEG;
189
- if (mode !== null) {
190
- lon = mod(lon - nutation(this.data, jde)[0] / DEG - ayanamsa(jde, mode), 360);
191
- }
229
+ if (mode !== null)
230
+ lon = mod(lon - this.ayanShift(jde, mode), 360);
192
231
  const h = 0.25; // days; central difference
193
232
  const l0 = this.lonOnly(body, jdUt - h, mode, topo);
194
233
  const l1 = this.lonOnly(body, jdUt + h, mode, topo);
@@ -276,9 +315,8 @@ export class Engine {
276
315
  }
277
316
  const jde = jdTT(jdUt);
278
317
  let shift = 0.0;
279
- if (mode !== null) {
280
- shift = nutation(this.data, jde)[0] / DEG + ayanamsa(jde, mode);
281
- }
318
+ if (mode !== null)
319
+ shift = this.ayanShift(jde, mode);
282
320
  const outDeg = (rad) => mod(rad / DEG - shift, 360);
283
321
  let cuspsDeg;
284
322
  if (mode !== null && used === "whole_sign") {
@@ -56,6 +56,8 @@ export interface EngineData {
56
56
  chebPacks?: Record<string, ChebData>;
57
57
  /** Hamburg-school (Uranian) constant-element orbits; see fit_uranian.py. */
58
58
  keplerPack?: KeplerPack;
59
+ /** Fixed-star catalog (HYG-derived; ICRS J2000 + proper motions). */
60
+ fixedStars?: import("./stars.js").StarPack;
59
61
  }
60
62
  export declare function julianDay(y: number, mo: number, d: number, h?: number, mi?: number, s?: number): number;
61
63
  /** TT - UT1 in seconds. Observed IERS 1955-2025, E&M polynomials before,
@@ -67,7 +69,8 @@ export declare function vsopHeliocentric(series: VsopSeries, jde: number): [numb
67
69
  export declare function nutation(data: EngineData, jde: number): [number, number];
68
70
  export declare function meanObliquity(jde: number): number;
69
71
  export declare function trueObliquity(data: EngineData, jde: number): number;
70
- /** Precession of ecliptic coordinates (Meeus 21.7). */
72
+ /** Precession of ecliptic coordinates between epochs (Vondrak 2011):
73
+ * ecliptic-of-from -> J2000 equatorial -> ecliptic-of-to. */
71
74
  export declare function precessEcliptic(lon: number, lat: number, jdeFrom: number, jdeTo: number): [number, number];
72
75
  /** Apparent geocentric ecliptic lon/lat (true equinox of date), distance. */
73
76
  export declare function planetApparent(data: EngineData, name: string, jde: number): [number, number, number];
@@ -94,9 +97,9 @@ export declare function trueNodeSeries(data: EngineData, jde: number): number;
94
97
  /** Ecliptic lon/lat -> right ascension, declination (all radians). */
95
98
  export declare function equatorial(lon: number, lat: number, eps: number): [number, number];
96
99
  /** 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
+ * (matched to Swiss Ephemeris 2.10 to 1e-9 deg); propagation uses Vondrak
101
+ * 2011 ecliptic precession, the same model Swiss Ephemeris uses:
102
+ * agreement over 1900-2099 is <=0.005 arcsec. */
100
103
  export declare const AYANAMSA_J2000: Record<string, number>;
101
104
  /** Mean ayanamsa in degrees. Sidereal longitude = (tropical true-equinox
102
105
  * longitude - nutation in longitude) - ayanamsa: the sidereal zodiac is
package/dist/src/core.js CHANGED
@@ -140,48 +140,138 @@ function fk5Correction(L, B, jde) {
140
140
  const dB = 0.03916 * ARCSEC * (Math.cos(Lp) - Math.sin(Lp));
141
141
  return [L + dL, B + dB];
142
142
  }
143
- /** Precession of ecliptic coordinates (Meeus 21.7). */
143
+ // ------------------------------------------------- Vondrak 2011 precession
144
+ // Long-term precession of the ecliptic and equator (Vondrak, Capitaine &
145
+ // Wallace 2011, A&A 534 A22; coefficient tables as carried by ERFA under
146
+ // BSD-3). Replaces the IAU 1976 angles.
147
+ const PQ_POL = [
148
+ [5851.607687, -0.1189, -0.00028913, 0.000000101],
149
+ [-1600.8863, 1.1689818, -0.0000002, -0.000000437],
150
+ ];
151
+ const PQ_PER = [
152
+ [708.15, -5486.751211, -684.66156, 667.66673, -5523.863691],
153
+ [2309.0, -17.127623, 2446.28388, -2354.886252, -549.74745],
154
+ [1620.0, -617.517403, 399.671049, -428.152441, -310.998056],
155
+ [492.2, 413.44294, -356.652376, 376.202861, 421.535876],
156
+ [1183.0, 78.614193, -186.387003, 184.778874, -36.776172],
157
+ [622.0, -180.732815, -316.80007, 335.321713, -145.278396],
158
+ [882.0, -87.676083, 198.296701, -185.138669, -34.74445],
159
+ [547.0, 46.140315, 101.135679, -120.97283, 22.885731],
160
+ ];
161
+ const XY_POL = [
162
+ [5453.282155, 0.4252841, -0.00037173, -0.000000152],
163
+ [-73750.93035, -0.7675452, -0.00018725, 0.000000231],
164
+ ];
165
+ const XY_PER = [
166
+ [256.75, -819.940624, 75004.344875, 81491.287984, 1558.515853],
167
+ [708.15, -8444.676815, 624.033993, 787.163481, 7774.939698],
168
+ [274.2, 2600.009459, 1251.136893, 1251.296102, -2219.534038],
169
+ [241.45, 2755.17563, -1102.212834, -1257.950837, -2523.969396],
170
+ [2309.0, -167.659835, -2660.66498, -2966.79973, 247.850422],
171
+ [492.2, 871.855056, 699.291817, 639.744522, -846.485643],
172
+ [396.1, 44.769698, 153.16722, 131.600209, -1393.124055],
173
+ [288.9, -512.313065, -950.865637, -445.040117, 368.526116],
174
+ [231.1, -819.415595, 499.754645, 584.522874, 749.045012],
175
+ [1610.0, -538.071099, -145.18821, -89.756563, 444.704518],
176
+ [620.0, -189.793622, 558.116553, 524.42963, 235.934465],
177
+ [157.87, -402.922932, -23.923029, -13.549067, 374.049623],
178
+ [220.3, 179.516345, -165.405086, -210.157124, -171.33018],
179
+ [1200.0, -9.814756, 9.344131, -44.919798, -22.899655],
180
+ ];
181
+ const EPS0_V = 84381.406 * ARCSEC; // J2000 obliquity of the Vondrak model
182
+ const EPS0_FRAME = 84381.448 * ARCSEC; // obliquity defining our ecliptic-J2000 data
183
+ function ltpPecl(jde) {
184
+ const t = (jde - J2000) / 36525.0;
185
+ let p = 0.0;
186
+ let q = 0.0;
187
+ const w = 2.0 * Math.PI * t;
188
+ for (const [per, c1, c2, s1, s2] of PQ_PER) {
189
+ const a = w / per;
190
+ const ca = Math.cos(a);
191
+ const sa = Math.sin(a);
192
+ p += ca * c1 + sa * s1;
193
+ q += ca * c2 + sa * s2;
194
+ }
195
+ let tn = 1.0;
196
+ for (let i = 0; i < 4; i++) {
197
+ p += PQ_POL[0][i] * tn;
198
+ q += PQ_POL[1][i] * tn;
199
+ tn *= t;
200
+ }
201
+ p *= ARCSEC;
202
+ q *= ARCSEC;
203
+ const z = Math.sqrt(Math.max(1.0 - p * p - q * q, 0.0));
204
+ const s = Math.sin(EPS0_V);
205
+ const c = Math.cos(EPS0_V);
206
+ return [p, -q * c - z * s, -q * s + z * c];
207
+ }
208
+ function ltpPequ(jde) {
209
+ const t = (jde - J2000) / 36525.0;
210
+ let x = 0.0;
211
+ let y = 0.0;
212
+ const w = 2.0 * Math.PI * t;
213
+ for (const [per, c1, c2, s1, s2] of XY_PER) {
214
+ const a = w / per;
215
+ const ca = Math.cos(a);
216
+ const sa = Math.sin(a);
217
+ x += ca * c1 + sa * s1;
218
+ y += ca * c2 + sa * s2;
219
+ }
220
+ let tn = 1.0;
221
+ for (let i = 0; i < 4; i++) {
222
+ x += XY_POL[0][i] * tn;
223
+ y += XY_POL[1][i] * tn;
224
+ tn *= t;
225
+ }
226
+ x *= ARCSEC;
227
+ y *= ARCSEC;
228
+ return [x, y, Math.sqrt(Math.max(1.0 - x * x - y * y, 0.0))];
229
+ }
230
+ /** Rows of the rotation J2000-equatorial -> mean ecliptic/equinox of date
231
+ * (ERFA eraLtecm): x = equinox, z = ecliptic pole, y = z cross x. */
232
+ function ltpEclMatrix(jde) {
233
+ const p = ltpPequ(jde);
234
+ const z = ltpPecl(jde);
235
+ const wx = [
236
+ p[1] * z[2] - p[2] * z[1], p[2] * z[0] - p[0] * z[2], p[0] * z[1] - p[1] * z[0],
237
+ ];
238
+ const n = Math.sqrt(wx[0] ** 2 + wx[1] ** 2 + wx[2] ** 2);
239
+ const x = [wx[0] / n, wx[1] / n, wx[2] / n];
240
+ const y = [
241
+ z[1] * x[2] - z[2] * x[1], z[2] * x[0] - z[0] * x[2], z[0] * x[1] - z[1] * x[0],
242
+ ];
243
+ return [x, y, z];
244
+ }
245
+ /** Precession of ecliptic coordinates between epochs (Vondrak 2011):
246
+ * ecliptic-of-from -> J2000 equatorial -> ecliptic-of-to. */
144
247
  export function precessEcliptic(lon, lat, jdeFrom, jdeTo) {
145
- const T = (jdeFrom - J2000) / 36525.0;
146
- const t = (jdeTo - jdeFrom) / 36525.0;
147
- const eta = ((47.0029 - 0.06603 * T + 0.000598 * T * T) * t
148
- + (-0.03302 + 0.000598 * T) * t * t + 0.00006 * t ** 3) * ARCSEC;
149
- const Pi = (174.876384 * 3600 + 3289.4789 * T + 0.60622 * T * T) * ARCSEC
150
- - ((869.8089 + 0.50491 * T) * t - 0.03536 * t * t) * ARCSEC;
151
- const p = ((5029.0966 + 2.22226 * T - 0.000042 * T * T) * t
152
- + (1.11113 - 0.000042 * T) * t * t - 0.000006 * t ** 3) * ARCSEC;
153
- const se = Math.sin(eta);
154
- const ce = Math.cos(eta);
155
- const A = ce * Math.cos(lat) * Math.sin(Pi - lon) - se * Math.sin(lat);
156
- const Bv = Math.cos(lat) * Math.cos(Pi - lon);
157
- const C = ce * Math.sin(lat) + se * Math.cos(lat) * Math.sin(Pi - lon);
158
- return [mod(p + Pi - Math.atan2(A, Bv), TWO_PI), Math.asin(C)];
159
- }
160
- /** Rotate a vector from ecliptic-J2000 to ecliptic-of-date frame. */
248
+ const cb = Math.cos(lat);
249
+ const v = [cb * Math.cos(lon), cb * Math.sin(lon), Math.sin(lat)];
250
+ const [xf, yf, zf] = ltpEclMatrix(jdeFrom);
251
+ const e = [0, 1, 2].map((i) => xf[i] * v[0] + yf[i] * v[1] + zf[i] * v[2]);
252
+ const [xt, yt, zt] = ltpEclMatrix(jdeTo);
253
+ const u = [
254
+ xt[0] * e[0] + xt[1] * e[1] + xt[2] * e[2],
255
+ yt[0] * e[0] + yt[1] * e[1] + yt[2] * e[2],
256
+ zt[0] * e[0] + zt[1] * e[1] + zt[2] * e[2],
257
+ ];
258
+ return [mod(Math.atan2(u[1], u[0]), TWO_PI),
259
+ Math.asin(Math.max(-1, Math.min(1, u[2])))];
260
+ }
261
+ /** Rotate a vector from the ecliptic-J2000 data frame (obliquity 84381.448
262
+ * arcsec, as used by Horizons/Meeus) to the mean ecliptic of date
263
+ * (Vondrak 2011). */
161
264
  function eclJ2000ToEclDate(v, jde) {
162
- let [x, y, z] = v;
163
- const e0 = 84381.448 * ARCSEC;
164
- [y, z] = [y * Math.cos(e0) - z * Math.sin(e0), y * Math.sin(e0) + z * Math.cos(e0)];
165
- const T = (jde - J2000) / 36525.0;
166
- const zeta = (2306.2181 * T + 0.30188 * T * T + 0.017998 * T ** 3) * ARCSEC;
167
- const zz = (2306.2181 * T + 1.09468 * T * T + 0.018203 * T ** 3) * ARCSEC;
168
- const th = (2004.3109 * T - 0.42665 * T * T - 0.041833 * T ** 3) * ARCSEC;
169
- const rz = (a) => {
170
- const c = Math.cos(a);
171
- const s = Math.sin(a);
172
- [x, y] = [c * x + s * y, -s * x + c * y];
173
- };
174
- const ry = (a) => {
175
- const c = Math.cos(a);
176
- const s = Math.sin(a);
177
- [x, z] = [c * x - s * z, s * x + c * z];
178
- };
179
- rz(-zeta);
180
- ry(th);
181
- rz(-zz);
182
- const e = meanObliquity(jde);
183
- [y, z] = [y * Math.cos(e) + z * Math.sin(e), -y * Math.sin(e) + z * Math.cos(e)];
184
- return [x, y, z];
265
+ const [x, y, z] = v;
266
+ const s = Math.sin(EPS0_FRAME);
267
+ const c = Math.cos(EPS0_FRAME);
268
+ const e = [x, y * c - z * s, y * s + z * c];
269
+ const [xt, yt, zt] = ltpEclMatrix(jde);
270
+ return [
271
+ xt[0] * e[0] + xt[1] * e[1] + xt[2] * e[2],
272
+ yt[0] * e[0] + yt[1] * e[1] + yt[2] * e[2],
273
+ zt[0] * e[0] + zt[1] * e[1] + zt[2] * e[2],
274
+ ];
185
275
  }
186
276
  // ---------------------------------------------------------------- planets
187
277
  function geoVector(data, name, jde) {
@@ -385,9 +475,9 @@ export function equatorial(lon, lat, eps) {
385
475
  return [ra, dec];
386
476
  }
387
477
  /** 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). */
478
+ * (matched to Swiss Ephemeris 2.10 to 1e-9 deg); propagation uses Vondrak
479
+ * 2011 ecliptic precession, the same model Swiss Ephemeris uses:
480
+ * agreement over 1900-2099 is <=0.005 arcsec. */
391
481
  export const AYANAMSA_J2000 = {
392
482
  lahiri: 23.857092325,
393
483
  fagan_bradley: 24.740299966,
@@ -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
+ }
@@ -5,6 +5,8 @@ export interface RiseSetOptions {
5
5
  pressure?: number;
6
6
  tempC?: number;
7
7
  searchDays?: number;
8
+ /** Rise/set of the disc center instead of the upper limb. */
9
+ discCenter?: boolean;
8
10
  }
9
11
  /** Next rise/set/meridian transit (UT JD) after jdStart, or null when the
10
12
  * event does not occur in the window (polar day/night). */
@@ -20,3 +22,8 @@ export declare function lunarPhases(engine: Engine, jdStart: number, jdEnd: numb
20
22
  * turns]. Sun and Moon never station. Station timing is ill-conditioned:
21
23
  * expect minute-level differences between ephemerides. */
22
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;
@@ -73,7 +73,7 @@ export function riseSet(engine, body, jdStart, latDeg, lonDeg, kind = "rise", op
73
73
  const [alt, , dist] = topoAltHa(engine, body, t, latDeg, lonDeg, altM);
74
74
  let sd = 0.0;
75
75
  const diam = DIAMETER_KM[body];
76
- if (diam !== undefined && dist !== null) {
76
+ if (!opts.discCenter && diam !== undefined && dist !== null) {
77
77
  sd = Math.asin(diam / (2 * dist * KM_PER_AU));
78
78
  }
79
79
  const h0 = -((R0_ARCMIN / 60.0) * scale * DEG + sd);
@@ -154,3 +154,31 @@ export function stations(engine, body, jdStart, jdEnd, maxHits = 30) {
154
154
  }
155
155
  return out;
156
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
+ }
@@ -3,3 +3,6 @@ export * from "./houses.js";
3
3
  export * from "./chart.js";
4
4
  export * from "./pheno.js";
5
5
  export * from "./events.js";
6
+ export * from "./stars.js";
7
+ export * from "./eclipses.js";
8
+ export * from "./query.js";
package/dist/src/index.js CHANGED
@@ -3,3 +3,6 @@ export * from "./houses.js";
3
3
  export * from "./chart.js";
4
4
  export * from "./pheno.js";
5
5
  export * from "./events.js";
6
+ export * from "./stars.js";
7
+ export * from "./eclipses.js";
8
+ export * from "./query.js";
@@ -31,6 +31,9 @@ export function loadNodeData(dir, level = "embedded", moonTier = "full") {
31
31
  if (existsSync(join(dir, "uranian_kepler.json"))) {
32
32
  data.keplerPack = j("uranian_kepler.json");
33
33
  }
34
+ if (existsSync(join(dir, "fixed_stars.json"))) {
35
+ data.fixedStars = j("fixed_stars.json");
36
+ }
34
37
  // asteroid packs (Horizons fits): loaded when present, ~380 KB total
35
38
  for (const b of ["ceres", "pallas", "juno", "vesta", "pholus"]) {
36
39
  if (existsSync(join(dir, `${b}_cheb.json`))) {
@@ -0,0 +1,32 @@
1
+ import { Engine, BodyId, Zodiac } from "./chart.js";
2
+ export declare const QUERY_ASPECTS: Record<string, number>;
3
+ export type Interval = [number, number];
4
+ /** Margin function (true where >= 0) carrying the bodies it depends on. */
5
+ export interface Predicate {
6
+ (engine: Engine, t: number): number;
7
+ bodies: Set<string>;
8
+ }
9
+ /** True while `body` is within `orb` deg of an exact `kind` aspect to
10
+ * `target` -- a fixed ecliptic longitude (deg) or another body name. */
11
+ export declare function aspect(body: BodyId, kind: string, target: number | BodyId, orb?: number, zodiac?: Zodiac): Predicate;
12
+ /** True while `body` is in `sign` (index 0=Aries..11=Pisces, or name). */
13
+ export declare function inSign(body: BodyId, sign: number | string, zodiac?: Zodiac): Predicate;
14
+ /** True while `body` is in apparent retrograde motion. */
15
+ export declare function retrograde(body: BodyId, zodiac?: Zodiac): Predicate;
16
+ /** True while `body` is direct or stationary. */
17
+ export declare function notRetrograde(body: BodyId, zodiac?: Zodiac): Predicate;
18
+ /** True where every predicate is true (interval intersection). */
19
+ export declare function allOf(...preds: Predicate[]): Predicate;
20
+ /** True where any predicate is true (interval union). */
21
+ export declare function anyOf(...preds: Predicate[]): Predicate;
22
+ /** True where `pred` is false (interval complement). */
23
+ export declare function notOf(pred: Predicate): Predicate;
24
+ export interface WhenOptions {
25
+ step?: number;
26
+ maxIntervals?: number;
27
+ }
28
+ /** Time intervals (jdStartUt, jdEndUt) in [jdStart, jdEnd] where `predicate`
29
+ * is true. Endpoints touching the range bounds are clamped. The scan step
30
+ * defaults to 0.125 d when a fast body (Moon, nodes, Lilith) is involved
31
+ * and 1 d otherwise. */
32
+ export declare function when(engine: Engine, predicate: Predicate, jdStart: number, jdEnd: number, opts?: WhenOptions): Interval[];
@@ -0,0 +1,161 @@
1
+ /**
2
+ * astroengine query -- declarative time queries ("when is ...?").
3
+ *
4
+ * The engine answers "where is the body?"; this answers "when is the
5
+ * configuration true?" over a time range. A predicate is a continuous
6
+ * "margin" function, true exactly where margin >= 0 (e.g. aspect-within-orb
7
+ * -> orb minus angular distance from exact). A boolean combination is then
8
+ * itself a margin -- AND = min of the parts, OR = max, NOT = negation -- so
9
+ * any query reduces to one continuous function and `when()` returns the
10
+ * intervals where it is true using the same coarse-scan-then-bisect root
11
+ * finder as events.crossings.
12
+ *
13
+ * when(engine, allOf(aspect("saturn", "square", natalMoon),
14
+ * notRetrograde("mercury"),
15
+ * inSign("venus", "Taurus")), jdStart, jdEnd)
16
+ *
17
+ * Mirrors the Python reference (astroengine/query.py); the golden fixtures
18
+ * pin the two implementations together.
19
+ */
20
+ import { mod } from "./core.js";
21
+ import { SIGNS } from "./chart.js";
22
+ export const QUERY_ASPECTS = {
23
+ conjunction: 0, semisextile: 30, sextile: 60, square: 90,
24
+ trine: 120, quincunx: 150, opposition: 180,
25
+ };
26
+ const FAST = new Set([
27
+ "moon", "mean_node", "true_node", "mean_lilith", "true_lilith",
28
+ ]);
29
+ function wrap180(d) {
30
+ return mod(d + 180, 360) - 180;
31
+ }
32
+ function mk(fn, bodies) {
33
+ const p = fn;
34
+ p.bodies = bodies;
35
+ return p;
36
+ }
37
+ // ---------------------------------------------------------------- predicates
38
+ /** True while `body` is within `orb` deg of an exact `kind` aspect to
39
+ * `target` -- a fixed ecliptic longitude (deg) or another body name. */
40
+ export function aspect(body, kind, target, orb = 1.0, zodiac = "tropical") {
41
+ const ang = QUERY_ASPECTS[kind];
42
+ if (ang === undefined)
43
+ throw new Error(`unknown aspect ${kind}`);
44
+ const isLon = typeof target === "number";
45
+ const bodies = new Set([body]);
46
+ if (!isLon)
47
+ bodies.add(target);
48
+ return mk((engine, t) => {
49
+ const lon = engine.longitude(body, t, { zodiac });
50
+ const tl = isLon
51
+ ? target
52
+ : engine.longitude(target, t, { zodiac });
53
+ const sep = lon - tl;
54
+ return orb - Math.min(Math.abs(wrap180(sep - ang)), Math.abs(wrap180(sep + ang)));
55
+ }, bodies);
56
+ }
57
+ /** True while `body` is in `sign` (index 0=Aries..11=Pisces, or name). */
58
+ export function inSign(body, sign, zodiac = "tropical") {
59
+ const idx = typeof sign === "number" ? sign : SIGNS.indexOf(sign);
60
+ if (idx < 0)
61
+ throw new Error(`unknown sign ${sign}`);
62
+ const lo = idx * 30;
63
+ return mk((engine, t) => {
64
+ const d = mod(engine.longitude(body, t, { zodiac }) - lo, 360);
65
+ // signed distance to the nearest 30-deg band edge, positive inside
66
+ return d <= 30 ? Math.min(d, 30 - d) : -Math.min(d - 30, 360 - d);
67
+ }, new Set([body]));
68
+ }
69
+ /** True while `body` is in apparent retrograde motion. */
70
+ export function retrograde(body, zodiac = "tropical") {
71
+ const h = 0.25;
72
+ return mk((engine, t) => {
73
+ const l0 = engine.longitude(body, t - h, { zodiac });
74
+ const l1 = engine.longitude(body, t + h, { zodiac });
75
+ return -wrap180(l1 - l0) / (2 * h); // >= 0 when moving backwards
76
+ }, new Set([body]));
77
+ }
78
+ /** True while `body` is direct or stationary. */
79
+ export function notRetrograde(body, zodiac = "tropical") {
80
+ return notOf(retrograde(body, zodiac));
81
+ }
82
+ // --------------------------------------------------------------- combinators
83
+ function combine(op, preds) {
84
+ const bodies = new Set();
85
+ for (const p of preds)
86
+ for (const b of p.bodies)
87
+ bodies.add(b);
88
+ return mk((engine, t) => op(preds.map((p) => p(engine, t))), bodies);
89
+ }
90
+ /** True where every predicate is true (interval intersection). */
91
+ export function allOf(...preds) {
92
+ return combine((xs) => Math.min(...xs), preds);
93
+ }
94
+ /** True where any predicate is true (interval union). */
95
+ export function anyOf(...preds) {
96
+ return combine((xs) => Math.max(...xs), preds);
97
+ }
98
+ /** True where `pred` is false (interval complement). */
99
+ export function notOf(pred) {
100
+ return mk((engine, t) => -pred(engine, t), new Set(pred.bodies));
101
+ }
102
+ // --------------------------------------------------------------- solver
103
+ function bisect(f, a, b, tol = 1e-6) {
104
+ let fa = f(a);
105
+ for (let i = 0; i < 60; i++) {
106
+ const m = 0.5 * (a + b);
107
+ if (Math.abs(b - a) < tol)
108
+ return m;
109
+ const fm = f(m);
110
+ if ((fa < 0) !== (fm < 0)) {
111
+ b = m;
112
+ }
113
+ else {
114
+ a = m;
115
+ fa = fm;
116
+ }
117
+ }
118
+ return 0.5 * (a + b);
119
+ }
120
+ /** Time intervals (jdStartUt, jdEndUt) in [jdStart, jdEnd] where `predicate`
121
+ * is true. Endpoints touching the range bounds are clamped. The scan step
122
+ * defaults to 0.125 d when a fast body (Moon, nodes, Lilith) is involved
123
+ * and 1 d otherwise. */
124
+ export function when(engine, predicate, jdStart, jdEnd, opts = {}) {
125
+ let step = opts.step;
126
+ if (step === undefined) {
127
+ let fast = false;
128
+ for (const b of predicate.bodies)
129
+ if (FAST.has(b))
130
+ fast = true;
131
+ step = fast ? 0.125 : 1.0;
132
+ }
133
+ const maxIntervals = opts.maxIntervals ?? 500;
134
+ const f = (t) => predicate(engine, t);
135
+ const intervals = [];
136
+ let prev = f(jdStart);
137
+ let openStart = prev >= 0 ? jdStart : null;
138
+ let t = jdStart + step;
139
+ while (t <= jdEnd + 1e-9 && intervals.length < maxIntervals) {
140
+ if (t > jdEnd)
141
+ t = jdEnd;
142
+ const cur = f(t);
143
+ if ((prev < 0) !== (cur < 0)) {
144
+ const edge = bisect(f, t - step, t);
145
+ if (cur >= 0) {
146
+ openStart = edge;
147
+ }
148
+ else if (openStart !== null) {
149
+ intervals.push([openStart, edge]);
150
+ openStart = null;
151
+ }
152
+ }
153
+ prev = cur;
154
+ if (t >= jdEnd)
155
+ break;
156
+ t += step;
157
+ }
158
+ if (openStart !== null)
159
+ intervals.push([openStart, jdEnd]);
160
+ return intervals;
161
+ }
@@ -0,0 +1,28 @@
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 -> Vondrak 2011
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.3 arcsec over 1900-2099.
10
+ */
11
+ import { EngineData } from "./core.js";
12
+ export interface StarEntry {
13
+ ra: number;
14
+ dec: number;
15
+ pmra: number;
16
+ pmdec: number;
17
+ rv: number;
18
+ plx: number;
19
+ mag: number;
20
+ bayer: string;
21
+ }
22
+ export interface StarPack {
23
+ provenance: string;
24
+ frame: string;
25
+ stars: Record<string, StarEntry>;
26
+ }
27
+ /** Apparent ecliptic [lon, lat] of date (rad) for a catalog entry. */
28
+ export declare function starApparent(data: EngineData, s: StarEntry, jde: number): [number, number];
@@ -0,0 +1,52 @@
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 -> Vondrak 2011
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.3 arcsec over 1900-2099.
10
+ */
11
+ import { DEG, ARCSEC, J2000, mod, nutation, precessEcliptic, vsopHeliocentric, } from "./core.js";
12
+ const TWO_PI = 2 * Math.PI;
13
+ const KM_PER_AU = 149597870.7;
14
+ const AU_PER_PC = 206264.806;
15
+ /** Apparent ecliptic [lon, lat] of date (rad) for a catalog entry. */
16
+ export function starApparent(data, s, jde) {
17
+ const t = (jde - J2000) / 365.25;
18
+ const ra = s.ra * DEG;
19
+ const dec = s.dec * DEG;
20
+ const rAu = s.plx > 0 ? AU_PER_PC / (s.plx * 1e-3) : 1e9 * AU_PER_PC;
21
+ const cd = Math.cos(dec);
22
+ const sd = Math.sin(dec);
23
+ const cr = Math.cos(ra);
24
+ const sr = Math.sin(ra);
25
+ const p = [cd * cr, cd * sr, sd];
26
+ const east = [-sr, cr, 0.0];
27
+ const north = [-sd * cr, -sd * sr, cd];
28
+ const pmra = s.pmra * 1e-3 * ARCSEC;
29
+ const pmdec = s.pmdec * 1e-3 * ARCSEC;
30
+ const rv = (s.rv * 86400 * 365.25) / KM_PER_AU;
31
+ const pos = [0, 1, 2].map((i) => p[i] * rAu + (east[i] * pmra * rAu + north[i] * pmdec * rAu + p[i] * rv) * t);
32
+ const rn = Math.sqrt(pos[0] ** 2 + pos[1] ** 2 + pos[2] ** 2);
33
+ const x = pos[0] / rn;
34
+ const y = pos[1] / rn;
35
+ const z = pos[2] / rn;
36
+ const ra2 = Math.atan2(y, x);
37
+ const dec2 = Math.asin(z);
38
+ const e0 = 84381.448 * ARCSEC;
39
+ let lat = Math.asin(Math.sin(dec2) * Math.cos(e0) - Math.cos(dec2) * Math.sin(e0) * Math.sin(ra2));
40
+ let lon = mod(Math.atan2(Math.sin(ra2) * Math.cos(e0) + Math.tan(dec2) * Math.sin(e0), Math.cos(ra2)), TWO_PI);
41
+ [lon, lat] = precessEcliptic(lon, lat, J2000, jde);
42
+ const [L0] = vsopHeliocentric(data.vsop.earth, jde);
43
+ const sunLon = mod(L0 + Math.PI, TWO_PI);
44
+ const T = (jde - J2000) / 36525.0;
45
+ const k = 20.4898 * ARCSEC;
46
+ const e = 0.016708634 - 0.000042037 * T;
47
+ const piPer = (102.93735 + 1.71946 * T) * DEG;
48
+ lon += (-k * Math.cos(sunLon - lon) + e * k * Math.cos(piPer - lon)) / Math.cos(lat);
49
+ lat += -k * Math.sin(lat) * (Math.sin(sunLon - lon) - e * Math.sin(piPer - lon));
50
+ lon = mod(lon + nutation(data, jde)[0], TWO_PI);
51
+ return [lon, lat];
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caelus",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",