caelus 0.5.0 → 0.7.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
@@ -11,7 +11,7 @@ ephemeris files. 1:1 port of the Python reference, checked by golden fixtures.
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
13
  2. TypeScript port verified against Python golden fixtures: **3,218 checks,
14
- 0 failures, worst deviation 1.64 nano-arcseconds.** The two implementations
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
  },
@@ -179,15 +179,15 @@
179
179
  },
180
180
  {
181
181
  "name": "Fixed stars (318-star catalog)",
182
- "max": "0.6",
182
+ "max": "0.3",
183
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"
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
185
  },
186
186
  {
187
187
  "name": "Star-anchored ayanamsas",
188
- "max": "0.2",
188
+ "max": "0.5",
189
189
  "rms": "—",
190
- "note": "galcent_0sag and true_citra computed from the apparent star (Galactic Center / Spica); sidereal Sun vs SE ≤0.19″"
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
191
  },
192
192
  {
193
193
  "name": "Gauquelin sectors",
@@ -257,11 +257,11 @@
257
257
  },
258
258
  {
259
259
  "label": "Fixed stars",
260
- "bound": "≤ 0.6″"
260
+ "bound": "≤ 0.3″"
261
261
  },
262
262
  {
263
263
  "label": "Sidereal (7 ayanamsas)",
264
- "bound": "≤ 0.3″ added"
264
+ "bound": "≤ 0.005″ added"
265
265
  },
266
266
  {
267
267
  "label": "Eclipses",
@@ -69,7 +69,8 @@ export declare function vsopHeliocentric(series: VsopSeries, jde: number): [numb
69
69
  export declare function nutation(data: EngineData, jde: number): [number, number];
70
70
  export declare function meanObliquity(jde: number): number;
71
71
  export declare function trueObliquity(data: EngineData, jde: number): number;
72
- /** 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. */
73
74
  export declare function precessEcliptic(lon: number, lat: number, jdeFrom: number, jdeTo: number): [number, number];
74
75
  /** Apparent geocentric ecliptic lon/lat (true equinox of date), distance. */
75
76
  export declare function planetApparent(data: EngineData, name: string, jde: number): [number, number, number];
@@ -96,9 +97,9 @@ export declare function trueNodeSeries(data: EngineData, jde: number): number;
96
97
  /** Ecliptic lon/lat -> right ascension, declination (all radians). */
97
98
  export declare function equatorial(lon: number, lat: number, eps: number): [number, number];
98
99
  /** Mean ayanamsa at J2000.0 (degrees) per mode. Standard epoch anchors
99
- * (matched to Swiss Ephemeris 2.10 to 1e-9 deg); propagation uses IAU 1976
100
- * ecliptic precession. Agreement with Swiss Ephemeris over 1900-2099 is
101
- * <=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. */
102
103
  export declare const AYANAMSA_J2000: Record<string, number>;
103
104
  /** Mean ayanamsa in degrees. Sidereal longitude = (tropical true-equinox
104
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,51 @@
1
+ import { Engine, BodyId, Zodiac } from "./chart.js";
2
+ export declare const TROPICAL_YEAR = 365.24219;
3
+ /** Shorter-arc midpoint of two longitudes (degrees). */
4
+ export declare function midpointLon(a: number, b: number): number;
5
+ /** UT JDs in [jdStart, jdEnd] when `body` returns to its natal longitude.
6
+ * Outer-planet returns can show three crossings around a retrograde loop. */
7
+ export declare function returns(engine: Engine, body: BodyId, natalJd: number, jdStart: number, jdEnd: number, zodiac?: Zodiac, maxHits?: number): number[];
8
+ export declare function solarReturn(engine: Engine, natalJd: number, jdStart: number, jdEnd: number, zodiac?: Zodiac): number[];
9
+ export declare function lunarReturn(engine: Engine, natalJd: number, jdStart: number, jdEnd: number, zodiac?: Zodiac): number[];
10
+ /** The JD whose real positions are the secondary-progressed positions for the
11
+ * age (targetJd - natalJd): one day of motion per year of life. */
12
+ export declare function progressedJd(natalJd: number, targetJd: number, yearLength?: number): number;
13
+ export declare function progressedLongitude(engine: Engine, body: BodyId, natalJd: number, targetJd: number, yearLength?: number, zodiac?: Zodiac): number;
14
+ /** Solar-arc direction angle (degrees, forward): how far the secondary-
15
+ * progressed Sun has moved from the natal Sun. Add it to any natal longitude. */
16
+ export declare function solarArc(engine: Engine, natalJd: number, targetJd: number, yearLength?: number, zodiac?: Zodiac): number;
17
+ export declare function directedLongitude(engine: Engine, body: BodyId, natalJd: number, targetJd: number, yearLength?: number, zodiac?: Zodiac): number;
18
+ /** Midpoint-method composite: the shorter-arc midpoint of each body's two
19
+ * longitudes. Angles compose the same way via midpointLon on the two ASC/MC. */
20
+ export declare function compositeLongitudes(engine: Engine, jdA: number, jdB: number, bodies: BodyId[], zodiac?: Zodiac): Record<string, number>;
21
+ /** Time and place for a Davison relationship chart: the temporal midpoint and
22
+ * the geographic midpoint (mean latitude, shorter-arc mean longitude). Compute
23
+ * a normal chart at these to get the Davison chart. Returns [jd, lat, lonEast]. */
24
+ export declare function davisonParams(jdA: number, jdB: number, latA: number, lonEastA: number, latB: number, lonEastB: number): [number, number, number];
25
+ /** The nth-harmonic longitude of a point: lon * n, wrapped to 360. */
26
+ export declare function harmonicLongitude(lon: number, n: number): number;
27
+ export declare function harmonicChart(engine: Engine, jd: number, bodies: BodyId[], n: number, zodiac?: Zodiac): Record<string, number>;
28
+ /** Reflection across the solstice (Cancer-Capricorn) axis. */
29
+ export declare function antiscion(lon: number): number;
30
+ /** Reflection across the equinox (Aries-Libra) axis. */
31
+ export declare function contraAntiscion(lon: number): number;
32
+ export type DeclinationKind = "parallel" | "contraparallel" | null;
33
+ /** Classify two declinations: parallel (same), contraparallel (opposite), null. */
34
+ export declare function declinationAspect(decA: number, decB: number, orb?: number): DeclinationKind;
35
+ export interface DeclinationPair {
36
+ a: string;
37
+ b: string;
38
+ kind: DeclinationKind;
39
+ }
40
+ export declare function declinationAspects(engine: Engine, bodies: BodyId[], jd: number, orb?: number): DeclinationPair[];
41
+ /** |declination| minus the mean obliquity, degrees. Positive = out of bounds. */
42
+ export declare function outOfBoundsMargin(engine: Engine, body: BodyId, jd: number): number;
43
+ export declare function outOfBounds(engine: Engine, body: BodyId, jd: number): boolean;
44
+ /** Essential dignities of `body` in `sign`: domicile, exaltation, detriment,
45
+ * fall (the last two are the signs opposite domicile and exaltation). */
46
+ export declare function dignities(body: string, sign: number | string): string[];
47
+ export declare function dignityOf(engine: Engine, body: BodyId, jd: number, zodiac?: Zodiac): string[];
48
+ /** Diurnal when the Sun is above the horizon at the given place. */
49
+ export declare function isDayChart(engine: Engine, jd: number, lat: number, lonEast: number): boolean;
50
+ export declare function planetarySect(body: string): "diurnal" | "nocturnal" | null;
51
+ export declare function inSect(body: string, dayChart: boolean): boolean | null;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * astroengine derived -- standard chart derivations built on the validated
3
+ * primitives: returns, secondary progressions, solar arc directions, composite
4
+ * charts, Davison charts.
5
+ *
6
+ * These are constructions on top of apparent positions (already checked against
7
+ * Swiss Ephemeris), so this layer is time-mapping and arithmetic, not new
8
+ * ephemeris. Mirrors the Python reference (astroengine/derived.py); the
9
+ * golden fixtures pin the two together.
10
+ */
11
+ import { mod, meanObliquity, jdTT, DEG } from "./core.js";
12
+ import { SIGNS } from "./chart.js";
13
+ import { crossings } from "./events.js";
14
+ import { azAlt } from "./pheno.js";
15
+ export const TROPICAL_YEAR = 365.24219; // mean tropical year, days
16
+ /** Shorter-arc midpoint of two longitudes (degrees). */
17
+ export function midpointLon(a, b) {
18
+ const d = mod(b - a + 180, 360) - 180; // signed shortest a -> b
19
+ return mod(a + d / 2, 360);
20
+ }
21
+ // ---------------------------------------------------------------- returns
22
+ /** UT JDs in [jdStart, jdEnd] when `body` returns to its natal longitude.
23
+ * Outer-planet returns can show three crossings around a retrograde loop. */
24
+ export function returns(engine, body, natalJd, jdStart, jdEnd, zodiac = "tropical", maxHits = 60) {
25
+ const natalLon = engine.longitude(body, natalJd, { zodiac });
26
+ return crossings(engine, body, natalLon, jdStart, jdEnd, zodiac, maxHits);
27
+ }
28
+ export function solarReturn(engine, natalJd, jdStart, jdEnd, zodiac = "tropical") {
29
+ return returns(engine, "sun", natalJd, jdStart, jdEnd, zodiac);
30
+ }
31
+ export function lunarReturn(engine, natalJd, jdStart, jdEnd, zodiac = "tropical") {
32
+ return returns(engine, "moon", natalJd, jdStart, jdEnd, zodiac);
33
+ }
34
+ // ----------------------------------------------- secondary progressions
35
+ /** The JD whose real positions are the secondary-progressed positions for the
36
+ * age (targetJd - natalJd): one day of motion per year of life. */
37
+ export function progressedJd(natalJd, targetJd, yearLength = TROPICAL_YEAR) {
38
+ return natalJd + (targetJd - natalJd) / yearLength;
39
+ }
40
+ export function progressedLongitude(engine, body, natalJd, targetJd, yearLength = TROPICAL_YEAR, zodiac = "tropical") {
41
+ return engine.longitude(body, progressedJd(natalJd, targetJd, yearLength), { zodiac });
42
+ }
43
+ // ----------------------------------------------------------- solar arc
44
+ /** Solar-arc direction angle (degrees, forward): how far the secondary-
45
+ * progressed Sun has moved from the natal Sun. Add it to any natal longitude. */
46
+ export function solarArc(engine, natalJd, targetJd, yearLength = TROPICAL_YEAR, zodiac = "tropical") {
47
+ const pjd = progressedJd(natalJd, targetJd, yearLength);
48
+ const natalSun = engine.longitude("sun", natalJd, { zodiac });
49
+ const progSun = engine.longitude("sun", pjd, { zodiac });
50
+ return mod(progSun - natalSun, 360); // Sun only moves forward
51
+ }
52
+ export function directedLongitude(engine, body, natalJd, targetJd, yearLength = TROPICAL_YEAR, zodiac = "tropical") {
53
+ const arc = solarArc(engine, natalJd, targetJd, yearLength, zodiac);
54
+ return mod(engine.longitude(body, natalJd, { zodiac }) + arc, 360);
55
+ }
56
+ // ----------------------------------------------------------- composite
57
+ /** Midpoint-method composite: the shorter-arc midpoint of each body's two
58
+ * longitudes. Angles compose the same way via midpointLon on the two ASC/MC. */
59
+ export function compositeLongitudes(engine, jdA, jdB, bodies, zodiac = "tropical") {
60
+ const out = {};
61
+ for (const body of bodies) {
62
+ const la = engine.longitude(body, jdA, { zodiac });
63
+ const lb = engine.longitude(body, jdB, { zodiac });
64
+ out[body] = midpointLon(la, lb);
65
+ }
66
+ return out;
67
+ }
68
+ // ----------------------------------------------------------- davison
69
+ /** Time and place for a Davison relationship chart: the temporal midpoint and
70
+ * the geographic midpoint (mean latitude, shorter-arc mean longitude). Compute
71
+ * a normal chart at these to get the Davison chart. Returns [jd, lat, lonEast]. */
72
+ export function davisonParams(jdA, jdB, latA, lonEastA, latB, lonEastB) {
73
+ const midJd = 0.5 * (jdA + jdB);
74
+ const midLat = 0.5 * (latA + latB);
75
+ let midLon = midpointLon(mod(lonEastA, 360), mod(lonEastB, 360));
76
+ if (midLon > 180)
77
+ midLon -= 360; // back to (-180, 180] east-longitude
78
+ return [midJd, midLat, midLon];
79
+ }
80
+ // ----------------------------------------------------------- harmonics
81
+ /** The nth-harmonic longitude of a point: lon * n, wrapped to 360. */
82
+ export function harmonicLongitude(lon, n) {
83
+ return mod(lon * n, 360);
84
+ }
85
+ export function harmonicChart(engine, jd, bodies, n, zodiac = "tropical") {
86
+ const out = {};
87
+ for (const b of bodies)
88
+ out[b] = harmonicLongitude(engine.longitude(b, jd, { zodiac }), n);
89
+ return out;
90
+ }
91
+ // ----------------------------------------------------------- antiscia
92
+ /** Reflection across the solstice (Cancer-Capricorn) axis. */
93
+ export function antiscion(lon) {
94
+ return mod(180 - lon, 360);
95
+ }
96
+ /** Reflection across the equinox (Aries-Libra) axis. */
97
+ export function contraAntiscion(lon) {
98
+ return mod(-lon, 360);
99
+ }
100
+ /** Classify two declinations: parallel (same), contraparallel (opposite), null. */
101
+ export function declinationAspect(decA, decB, orb = 1.0) {
102
+ if (Math.abs(decA - decB) <= orb)
103
+ return "parallel";
104
+ if (Math.abs(decA + decB) <= orb)
105
+ return "contraparallel";
106
+ return null;
107
+ }
108
+ export function declinationAspects(engine, bodies, jd, orb = 1.0) {
109
+ const decs = {};
110
+ for (const b of bodies)
111
+ decs[b] = engine.position(b, jd).dec;
112
+ const out = [];
113
+ for (let i = 0; i < bodies.length; i++) {
114
+ for (let j = i + 1; j < bodies.length; j++) {
115
+ const kind = declinationAspect(decs[bodies[i]], decs[bodies[j]], orb);
116
+ if (kind)
117
+ out.push({ a: bodies[i], b: bodies[j], kind });
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+ // ----------------------------------------------------------- out of bounds
123
+ /** |declination| minus the mean obliquity, degrees. Positive = out of bounds. */
124
+ export function outOfBoundsMargin(engine, body, jd) {
125
+ const dec = engine.position(body, jd).dec;
126
+ const eps = meanObliquity(jdTT(jd)) / DEG;
127
+ return Math.abs(dec) - eps;
128
+ }
129
+ export function outOfBounds(engine, body, jd) {
130
+ return outOfBoundsMargin(engine, body, jd) > 0;
131
+ }
132
+ // ----------------------------------------------------------- dignities
133
+ const DOMICILE = {
134
+ sun: [4], moon: [3], mercury: [2, 5], venus: [1, 6],
135
+ mars: [0, 7], jupiter: [8, 11], saturn: [9, 10],
136
+ };
137
+ const EXALTATION = {
138
+ sun: 0, moon: 1, mercury: 5, venus: 11, mars: 9, jupiter: 3, saturn: 6,
139
+ };
140
+ function signIndex(sign) {
141
+ return typeof sign === "number" ? sign : SIGNS.indexOf(sign);
142
+ }
143
+ /** Essential dignities of `body` in `sign`: domicile, exaltation, detriment,
144
+ * fall (the last two are the signs opposite domicile and exaltation). */
145
+ export function dignities(body, sign) {
146
+ const idx = signIndex(sign);
147
+ const dom = DOMICILE[body] ?? [];
148
+ const out = [];
149
+ if (dom.includes(idx))
150
+ out.push("domicile");
151
+ if (EXALTATION[body] === idx)
152
+ out.push("exaltation");
153
+ if (dom.map((d) => mod(d + 6, 12)).includes(idx))
154
+ out.push("detriment");
155
+ if (body in EXALTATION && mod(EXALTATION[body] + 6, 12) === idx)
156
+ out.push("fall");
157
+ return out;
158
+ }
159
+ export function dignityOf(engine, body, jd, zodiac = "tropical") {
160
+ const lon = engine.longitude(body, jd, { zodiac });
161
+ return dignities(body, mod(Math.floor(lon / 30), 12));
162
+ }
163
+ // ----------------------------------------------------------- sect
164
+ const DIURNAL = new Set(["sun", "jupiter", "saturn"]);
165
+ const NOCTURNAL = new Set(["moon", "venus", "mars"]);
166
+ /** Diurnal when the Sun is above the horizon at the given place. */
167
+ export function isDayChart(engine, jd, lat, lonEast) {
168
+ const sun = engine.position("sun", jd);
169
+ const [, alt] = azAlt(engine.data, sun.lon, sun.lat, jd, lat, lonEast);
170
+ return alt > 0;
171
+ }
172
+ export function planetarySect(body) {
173
+ if (DIURNAL.has(body))
174
+ return "diurnal";
175
+ if (NOCTURNAL.has(body))
176
+ return "nocturnal";
177
+ return null;
178
+ }
179
+ export function inSect(body, dayChart) {
180
+ const s = planetarySect(body);
181
+ if (s === null)
182
+ return null;
183
+ return (s === "diurnal") === Boolean(dayChart);
184
+ }
@@ -5,3 +5,5 @@ export * from "./pheno.js";
5
5
  export * from "./events.js";
6
6
  export * from "./stars.js";
7
7
  export * from "./eclipses.js";
8
+ export * from "./query.js";
9
+ export * from "./derived.js";
package/dist/src/index.js CHANGED
@@ -5,3 +5,5 @@ export * from "./pheno.js";
5
5
  export * from "./events.js";
6
6
  export * from "./stars.js";
7
7
  export * from "./eclipses.js";
8
+ export * from "./query.js";
9
+ export * from "./derived.js";
@@ -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
+ }
@@ -3,11 +3,10 @@
3
3
  * catalog (data/fixed_stars.json; ICRS J2000 with proper motions).
4
4
  *
5
5
  * Chain: full 3D space motion (proper motion + radial velocity at the
6
- * parallax distance) -> ICRS equatorial -> ecliptic J2000 -> IAU 1976
6
+ * parallax distance) -> ICRS equatorial -> ecliptic J2000 -> Vondrak 2011
7
7
  * precession to date -> annual aberration (classic elliptic form, as for
8
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).
9
+ * catalog rows: <=0.3 arcsec over 1900-2099.
11
10
  */
12
11
  import { EngineData } from "./core.js";
13
12
  export interface StarEntry {
package/dist/src/stars.js CHANGED
@@ -3,11 +3,10 @@
3
3
  * catalog (data/fixed_stars.json; ICRS J2000 with proper motions).
4
4
  *
5
5
  * Chain: full 3D space motion (proper motion + radial velocity at the
6
- * parallax distance) -> ICRS equatorial -> ecliptic J2000 -> IAU 1976
6
+ * parallax distance) -> ICRS equatorial -> ecliptic J2000 -> Vondrak 2011
7
7
  * precession to date -> annual aberration (classic elliptic form, as for
8
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).
9
+ * catalog rows: <=0.3 arcsec over 1900-2099.
11
10
  */
12
11
  import { DEG, ARCSEC, J2000, mod, nutation, precessEcliptic, vsopHeliocentric, } from "./core.js";
13
12
  const TWO_PI = 2 * Math.PI;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caelus",
3
- "version": "0.5.0",
3
+ "version": "0.7.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",