caelus-engine 0.8.0__py3-none-any.whl

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.
Files changed (62) hide show
  1. astroengine/__init__.py +1 -0
  2. astroengine/chart.py +293 -0
  3. astroengine/chebyshev.py +94 -0
  4. astroengine/core.py +751 -0
  5. astroengine/data/ceres_cheb.json +1 -0
  6. astroengine/data/chiron_cheb.json +1 -0
  7. astroengine/data/fixed_stars.json +1 -0
  8. astroengine/data/juno_cheb.json +1 -0
  9. astroengine/data/moon_cheb.embedded.json +1 -0
  10. astroengine/data/moon_cheb.full.json +1 -0
  11. astroengine/data/moon_meeus47.json +1 -0
  12. astroengine/data/nutation_iau1980.json +1 -0
  13. astroengine/data/pallas_cheb.json +1 -0
  14. astroengine/data/pholus_cheb.json +1 -0
  15. astroengine/data/pluto_meeus37.json +1 -0
  16. astroengine/data/uranian_kepler.json +1 -0
  17. astroengine/data/vesta_cheb.json +1 -0
  18. astroengine/data/vsop87d_earth.embedded.json +1 -0
  19. astroengine/data/vsop87d_earth.full.json +1 -0
  20. astroengine/data/vsop87d_earth.high.json +1 -0
  21. astroengine/data/vsop87d_earth.micro.json +1 -0
  22. astroengine/data/vsop87d_jupiter.embedded.json +1 -0
  23. astroengine/data/vsop87d_jupiter.full.json +1 -0
  24. astroengine/data/vsop87d_jupiter.high.json +1 -0
  25. astroengine/data/vsop87d_jupiter.micro.json +1 -0
  26. astroengine/data/vsop87d_mars.embedded.json +1 -0
  27. astroengine/data/vsop87d_mars.full.json +1 -0
  28. astroengine/data/vsop87d_mars.high.json +1 -0
  29. astroengine/data/vsop87d_mars.micro.json +1 -0
  30. astroengine/data/vsop87d_mercury.embedded.json +1 -0
  31. astroengine/data/vsop87d_mercury.full.json +1 -0
  32. astroengine/data/vsop87d_mercury.high.json +1 -0
  33. astroengine/data/vsop87d_mercury.micro.json +1 -0
  34. astroengine/data/vsop87d_neptune.embedded.json +1 -0
  35. astroengine/data/vsop87d_neptune.full.json +1 -0
  36. astroengine/data/vsop87d_neptune.high.json +1 -0
  37. astroengine/data/vsop87d_neptune.micro.json +1 -0
  38. astroengine/data/vsop87d_saturn.embedded.json +1 -0
  39. astroengine/data/vsop87d_saturn.full.json +1 -0
  40. astroengine/data/vsop87d_saturn.high.json +1 -0
  41. astroengine/data/vsop87d_saturn.micro.json +1 -0
  42. astroengine/data/vsop87d_uranus.embedded.json +1 -0
  43. astroengine/data/vsop87d_uranus.full.json +1 -0
  44. astroengine/data/vsop87d_uranus.high.json +1 -0
  45. astroengine/data/vsop87d_uranus.micro.json +1 -0
  46. astroengine/data/vsop87d_venus.embedded.json +1 -0
  47. astroengine/data/vsop87d_venus.full.json +1 -0
  48. astroengine/data/vsop87d_venus.high.json +1 -0
  49. astroengine/data/vsop87d_venus.micro.json +1 -0
  50. astroengine/derived.py +240 -0
  51. astroengine/eclipses.py +184 -0
  52. astroengine/events.py +193 -0
  53. astroengine/houses.py +336 -0
  54. astroengine/pheno.py +153 -0
  55. astroengine/query.py +170 -0
  56. astroengine/stars.py +65 -0
  57. astroengine/turbo.py +105 -0
  58. caelus_engine-0.8.0.dist-info/METADATA +92 -0
  59. caelus_engine-0.8.0.dist-info/RECORD +62 -0
  60. caelus_engine-0.8.0.dist-info/WHEEL +5 -0
  61. caelus_engine-0.8.0.dist-info/licenses/LICENSE +21 -0
  62. caelus_engine-0.8.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ from .chart import Engine, fmt_lon, BODIES
astroengine/chart.py ADDED
@@ -0,0 +1,293 @@
1
+ """astroengine.chart -- public API: natal charts, aspects, retrogrades."""
2
+ import math
3
+ from . import core
4
+ from .core import (Vsop, jd_tt, julian_day, planet_apparent, sun_apparent,
5
+ moon_apparent, pluto_apparent, mean_node, true_node,
6
+ equatorial, ayanamsa, mean_lilith, topocentric_ecl,
7
+ true_obliquity, nutation, DEG)
8
+ from . import houses as H
9
+
10
+ BODIES = ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn",
11
+ "uranus", "neptune", "pluto", "chiron", "mean_node", "true_node"]
12
+
13
+ # Computable on request (not in the default chart set). Asteroids load
14
+ # lazily from their Chebyshev packs (Horizons fits, 1850-2150).
15
+ ASTEROIDS = ["ceres", "pallas", "juno", "vesta", "pholus"]
16
+ URANIANS = ["cupido", "hades", "zeus", "kronos", "apollon", "admetos",
17
+ "vulkanus", "poseidon"]
18
+ EXTRA_BODIES = ["mean_lilith", "true_lilith"] + ASTEROIDS + URANIANS
19
+
20
+ # Points: excluded from aspect search by default.
21
+ NOT_ASPECTABLE = {"mean_node", "true_node", "mean_lilith", "true_lilith"}
22
+
23
+ SIGNS = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra",
24
+ "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]
25
+
26
+ ASPECTS = {"conjunction": 0, "sextile": 60, "square": 90, "trine": 120, "opposition": 180}
27
+ DEFAULT_ORBS = {"conjunction": 8, "sextile": 4, "square": 7, "trine": 7, "opposition": 8}
28
+
29
+ KM_PER_AU = 149597870.7
30
+
31
+ HOUSE_FNS = {
32
+ "porphyry": None, "equal": None, "whole_sign": None, "placidus": None, # legacy paths
33
+ "koch": H.houses_koch,
34
+ "regiomontanus": H.houses_regiomontanus,
35
+ "campanus": H.houses_campanus,
36
+ "alcabitius": H.houses_alcabitius,
37
+ "morinus": H.houses_morinus,
38
+ "meridian": H.houses_meridian,
39
+ "polich_page": H.houses_polich_page,
40
+ "vehlow": H.houses_vehlow,
41
+ }
42
+ HOUSE_SYSTEMS = list(HOUSE_FNS.keys())
43
+
44
+
45
+ # Star-anchored ayanamsas: the named star sits at the fixed sidereal
46
+ # longitude by definition (Galactic Center at 0 Sagittarius; Spica at
47
+ # 0 Libra "citra").
48
+ STAR_AYANAMSAS = {"galcent_0sag": ("Galactic Center", 240.0),
49
+ "true_citra": ("Spica", 180.0)}
50
+
51
+
52
+ def _parse_zodiac(zodiac):
53
+ """'tropical' or 'sidereal:<ayanamsa>' -> ayanamsa mode or None."""
54
+ if zodiac == "tropical":
55
+ return None
56
+ if zodiac.startswith("sidereal:"):
57
+ mode = zodiac[len("sidereal:"):]
58
+ if mode in core.AYANAMSA_J2000 or mode in STAR_AYANAMSAS:
59
+ return mode
60
+ raise ValueError(f"unknown zodiac {zodiac!r}")
61
+
62
+
63
+ class Engine:
64
+ def __init__(self, level="full"):
65
+ self.vsop = Vsop(level)
66
+ self._packs = {}
67
+
68
+ def _pack(self, body):
69
+ if body not in self._packs:
70
+ import json
71
+ import os
72
+ if body in URANIANS:
73
+ with open(os.path.join(core.DATA, "uranian_kepler.json")) as f:
74
+ pack = json.load(f)
75
+ for name, els in pack["bodies"].items():
76
+ self._packs[name] = core.KeplerOrbit(els, pack["epoch"])
77
+ else:
78
+ from .chebyshev import ChebSeries
79
+ path = os.path.join(core.DATA, f"{body}_cheb.json")
80
+ if not os.path.exists(path):
81
+ raise ValueError(f"no data pack for {body!r}")
82
+ self._packs[body] = ChebSeries.load(path)
83
+ return self._packs[body]
84
+
85
+ def _ecliptic(self, body, jde):
86
+ """Apparent geocentric (lon rad, lat rad, dist AU or None)."""
87
+ if body == "sun":
88
+ return sun_apparent(self.vsop, jde)
89
+ if body == "moon":
90
+ if core.moon_in_precise_range(jde):
91
+ lon, lat, km = core.moon_apparent_precise(jde)
92
+ else:
93
+ lon, lat, km = moon_apparent(jde)
94
+ return lon, lat, km / KM_PER_AU
95
+ if body == "pluto":
96
+ return pluto_apparent(self.vsop, jde)
97
+ if body == "chiron":
98
+ return core.chiron_apparent(self.vsop, jde)
99
+ if body == "mean_node":
100
+ return mean_node(jde), 0.0, None
101
+ if body == "true_node":
102
+ if core.moon_in_precise_range(jde):
103
+ return core.true_node_precise(jde), 0.0, None
104
+ return true_node(jde), 0.0, None
105
+ if body == "mean_lilith":
106
+ lon, lat = mean_lilith(jde)
107
+ return lon, lat, None
108
+ if body == "true_lilith":
109
+ if core.moon_in_precise_range(jde):
110
+ lon, lat, km = core.osc_apogee_precise(jde)
111
+ else:
112
+ lon, lat, km = core.osc_apogee_series(jde)
113
+ return lon, lat, km / KM_PER_AU
114
+ if body in ASTEROIDS or body in URANIANS:
115
+ return core.smallbody_apparent(self.vsop, self._pack(body), jde)
116
+ return planet_apparent(self.vsop, body, jde)
117
+
118
+ def _ayan_shift(self, jde, mode):
119
+ """Degrees to subtract from a true-equinox tropical longitude."""
120
+ if mode in STAR_AYANAMSAS:
121
+ name, anchor = STAR_AYANAMSAS[mode]
122
+ from . import stars as ST
123
+ lon, _ = ST.star_apparent(self.vsop, ST.catalog()["stars"][name], jde)
124
+ return (lon / DEG - anchor) % 360
125
+ return (nutation(jde)[0] / DEG + ayanamsa(jde, mode)) % 360
126
+
127
+ def fixed_star(self, name, jd_ut, zodiac="tropical"):
128
+ """Apparent place of a catalog star: lon/lat/ra/dec (deg), sign, mag."""
129
+ from . import stars as ST
130
+ s = ST.catalog()["stars"][name]
131
+ mode = _parse_zodiac(zodiac)
132
+ jde = jd_tt(jd_ut)
133
+ lon_r, lat_r = ST.star_apparent(self.vsop, s, jde)
134
+ ra, dec = equatorial(lon_r, lat_r, true_obliquity(jde))
135
+ lon = lon_r / DEG
136
+ if mode is not None:
137
+ lon = (lon - self._ayan_shift(jde, mode)) % 360
138
+ return {"lon": lon, "lat": lat_r / DEG, "ra": ra / DEG, "dec": dec / DEG,
139
+ "mag": s["mag"], "sign": SIGNS[int(lon // 30)], "sign_deg": lon % 30}
140
+
141
+ def stars(self):
142
+ from . import stars as ST
143
+ return sorted(ST.catalog()["stars"])
144
+
145
+ def _lon_only(self, body, jd_ut, mode, topo):
146
+ jde = jd_tt(jd_ut)
147
+ lon, lat, dist = self._ecliptic(body, jde)
148
+ if topo is not None and dist is not None:
149
+ lst = (H.gast(jd_ut) + topo[1] * DEG) % (2 * math.pi)
150
+ lon, lat, dist = topocentric_ecl(lon, lat, dist, lst,
151
+ topo[0] * DEG, topo[2],
152
+ true_obliquity(jde))
153
+ lon_deg = lon / DEG
154
+ if mode is not None:
155
+ lon_deg = (lon_deg - self._ayan_shift(jde, mode)) % 360
156
+ return lon_deg
157
+
158
+ def longitude(self, body, jd_ut, zodiac="tropical", topocentric=False, observer=None):
159
+ """Apparent geocentric ecliptic longitude (deg). Tropical: true
160
+ equinox of date. Sidereal: mean equinox minus ayanamsa."""
161
+ mode = _parse_zodiac(zodiac)
162
+ topo = observer if topocentric else None
163
+ return self._lon_only(body, jd_ut, mode, topo)
164
+
165
+ def heliocentric(self, body, jd_ut):
166
+ """Geometric heliocentric ecliptic of date (deg, deg, AU)."""
167
+ jde = jd_tt(jd_ut)
168
+ if body == "pluto":
169
+ l, b, r = core.pluto_heliocentric(jde)
170
+ l, b = core._precess_ecliptic(l, b, core.J2000, jde)
171
+ elif body == "chiron":
172
+ if core._CHIRON is None:
173
+ core.chiron_apparent(self.vsop, jde) # loads the fit
174
+ x, y, z = core._CHIRON.xyz(jde)
175
+ r = math.sqrt(x * x + y * y + z * z)
176
+ l = math.atan2(y, x) % (2 * math.pi)
177
+ b = math.atan2(z, math.hypot(x, y))
178
+ l, b = core._precess_ecliptic(l, b, core.J2000, jde)
179
+ elif body in ASTEROIDS or body in URANIANS:
180
+ x, y, z = self._pack(body).xyz(jde)
181
+ r = math.sqrt(x * x + y * y + z * z)
182
+ l = math.atan2(y, x) % (2 * math.pi)
183
+ b = math.atan2(z, math.hypot(x, y))
184
+ l, b = core._precess_ecliptic(l, b, core.J2000, jde)
185
+ elif body in core.PLANET_NAMES or body == "earth":
186
+ l, b, r = self.vsop.heliocentric(body, jde)
187
+ else:
188
+ raise ValueError(f"no heliocentric position for {body!r}")
189
+ return {"lon": l / DEG, "lat": b / DEG, "dist": r}
190
+
191
+ def position(self, body, jd_ut, zodiac="tropical", topocentric=False, observer=None):
192
+ """Full position: lon/speed/retrograde/sign + lat, dist (AU), ra, dec."""
193
+ mode = _parse_zodiac(zodiac)
194
+ topo = observer if topocentric else None
195
+ jde = jd_tt(jd_ut)
196
+ lon_r, lat_r, dist = self._ecliptic(body, jde)
197
+ if topo is not None and dist is not None:
198
+ lst = (H.gast(jd_ut) + topo[1] * DEG) % (2 * math.pi)
199
+ lon_r, lat_r, dist = topocentric_ecl(lon_r, lat_r, dist, lst,
200
+ topo[0] * DEG, topo[2],
201
+ true_obliquity(jde))
202
+ ra, dec = equatorial(lon_r, lat_r, true_obliquity(jde))
203
+ lon = lon_r / DEG
204
+ if mode is not None:
205
+ lon = (lon - self._ayan_shift(jde, mode)) % 360
206
+ h = 0.25 # days; central difference
207
+ l0 = self._lon_only(body, jd_ut - h, mode, topo)
208
+ l1 = self._lon_only(body, jd_ut + h, mode, topo)
209
+ speed = ((l1 - l0 + 540) % 360 - 180) / (2 * h)
210
+ return {"lon": lon, "speed": speed, "retrograde": speed < 0,
211
+ "sign": SIGNS[int(lon // 30)], "sign_deg": lon % 30,
212
+ "lat": lat_r / DEG, "dist": dist,
213
+ "ra": ra / DEG, "dec": dec / DEG}
214
+
215
+ def chart(self, y, mo, d, h, mi, s, lat, lon_east, house_system="placidus",
216
+ zodiac="tropical", topocentric=False, extra_bodies=None, orbs=None):
217
+ """Full natal chart. Time is UT. East longitude positive."""
218
+ mode = _parse_zodiac(zodiac)
219
+ jd_ut = julian_day(y, mo, d, h, mi, s)
220
+ observer = (lat, lon_east, 0.0) if topocentric else None
221
+ names = BODIES + [b for b in (extra_bodies or []) if b not in BODIES]
222
+ bodies = {b: self.position(b, jd_ut, zodiac=zodiac,
223
+ topocentric=topocentric, observer=observer)
224
+ for b in names}
225
+ asc, mc, armc, eps = H.angles(jd_ut, lat, lon_east)
226
+ vtx, east = H.vertex_east_point(armc, lat * DEG, eps)
227
+ phi = lat * DEG
228
+ used = house_system
229
+ try:
230
+ if house_system == "placidus":
231
+ if abs(lat) < 66.0:
232
+ cusps = H.houses_placidus(armc, phi, eps)
233
+ else:
234
+ raise ValueError("placidus undefined above polar circles")
235
+ elif house_system == "porphyry":
236
+ cusps = H.houses_porphyry(asc, mc)
237
+ elif house_system == "equal":
238
+ cusps = H.houses_equal(asc)
239
+ elif house_system == "whole_sign":
240
+ cusps = H.houses_whole_sign(asc)
241
+ elif house_system in HOUSE_FNS and HOUSE_FNS[house_system]:
242
+ cusps = HOUSE_FNS[house_system](armc, phi, eps)
243
+ else:
244
+ raise KeyError(house_system)
245
+ except ValueError:
246
+ used = "whole_sign"
247
+ cusps = H.houses_whole_sign(asc)
248
+ jde = jd_tt(jd_ut)
249
+ shift = 0.0
250
+ if mode is not None:
251
+ shift = self._ayan_shift(jde, mode)
252
+
253
+ def out_deg(rad):
254
+ return (rad / DEG - shift) % 360
255
+
256
+ if mode is not None and used == "whole_sign":
257
+ # whole-sign cusps must stay sign-aligned in the sidereal zodiac
258
+ sid_asc = out_deg(asc)
259
+ first = (int(sid_asc // 30)) * 30.0
260
+ cusps_deg = [(first + i * 30.0) % 360 for i in range(12)]
261
+ else:
262
+ cusps_deg = [out_deg(c) for c in cusps]
263
+ return {
264
+ "jd_ut": jd_ut,
265
+ "zodiac": zodiac,
266
+ "house_system": used,
267
+ "house_system_requested": house_system,
268
+ "bodies": bodies,
269
+ "angles": {"asc": out_deg(asc), "mc": out_deg(mc),
270
+ "vertex": out_deg(vtx), "east_point": out_deg(east)},
271
+ "cusps": cusps_deg,
272
+ "aspects": find_aspects(bodies, orbs or DEFAULT_ORBS),
273
+ }
274
+
275
+
276
+ def find_aspects(bodies, orbs=DEFAULT_ORBS):
277
+ out = []
278
+ names = [b for b in bodies if b not in NOT_ASPECTABLE]
279
+ for i, a in enumerate(names):
280
+ for b in names[i + 1:]:
281
+ sep = abs((bodies[a]["lon"] - bodies[b]["lon"] + 180) % 360 - 180)
282
+ for asp, angle in ASPECTS.items():
283
+ orb = abs(sep - angle)
284
+ if orb <= orbs[asp]:
285
+ out.append({"a": a, "b": b, "aspect": asp, "orb": round(orb, 2)})
286
+ return out
287
+
288
+
289
+ def fmt_lon(deg):
290
+ sign = SIGNS[int(deg // 30)]
291
+ d = deg % 30
292
+ m = (d % 1) * 60
293
+ return f"{int(d):2d}°{int(m):02d}' {sign}"
@@ -0,0 +1,94 @@
1
+ """astroengine.chebyshev -- segmented Chebyshev fit/eval for ephemeris data.
2
+
3
+ The 'fit once, ship coefficients' recipe: sample any high-precision source
4
+ (JPL DE, Horizons, ...) for a body's rectangular coordinates, fit Chebyshev
5
+ polynomials per fixed-length time segment, store compactly as JSON. Runtime
6
+ evaluation is a few dozen multiply-adds; derivatives are analytic.
7
+ """
8
+ import json, math, os
9
+
10
+
11
+ def _clenshaw(coeffs, x):
12
+ b0 = b1 = 0.0
13
+ for c in reversed(coeffs[1:]):
14
+ b0, b1 = 2.0 * x * b0 - b1 + c, b0
15
+ return x * b0 - b1 + coeffs[0]
16
+
17
+
18
+ def cheb_eval_deriv(coeffs, x, half_span_days):
19
+ """Series value and time-derivative (per day) via derivative coefficients."""
20
+ n = len(coeffs)
21
+ d = [0.0] * n
22
+ for k in range(n - 1, 0, -1):
23
+ d[k - 1] = (d[k + 1] if k + 1 < n else 0.0) + 2.0 * k * coeffs[k]
24
+ d[0] *= 0.5
25
+ return _clenshaw(coeffs, x), _clenshaw(d[:max(n - 1, 1)], x) / half_span_days
26
+
27
+
28
+ class ChebSeries:
29
+ """Runtime container: jd0, seg_days, segments[ [cx],[cy],[cz] ]."""
30
+
31
+ def __init__(self, data):
32
+ self.jd0 = data["jd0"]
33
+ self.seg = data["seg_days"]
34
+ self.segments = data["segments"]
35
+ self.jd1 = self.jd0 + self.seg * len(self.segments)
36
+ self.scale = data.get("scale", 1.0)
37
+
38
+ @classmethod
39
+ def load(cls, path):
40
+ with open(path) as f:
41
+ return cls(json.load(f))
42
+
43
+ def _locate(self, jd):
44
+ if not (self.jd0 <= jd <= self.jd1):
45
+ raise ValueError(f"jd {jd} outside fitted range {self.jd0}-{self.jd1}")
46
+ i = min(int((jd - self.jd0) / self.seg), len(self.segments) - 1)
47
+ x = 2.0 * (jd - (self.jd0 + i * self.seg)) / self.seg - 1.0
48
+ return i, x
49
+
50
+ def xyz(self, jd):
51
+ i, x = self._locate(jd)
52
+ s = self.segments[i]
53
+ return tuple(_clenshaw(c, x) * self.scale for c in s)
54
+
55
+ def xyz_vel(self, jd):
56
+ """Position and velocity (units/day)."""
57
+ i, x = self._locate(jd)
58
+ s = self.segments[i]
59
+ half = self.seg / 2.0
60
+ pos, vel = [], []
61
+ for c in s:
62
+ p, v = cheb_eval_deriv(c, x, half)
63
+ pos.append(p * self.scale)
64
+ vel.append(v * self.scale)
65
+ return tuple(pos), tuple(vel)
66
+
67
+
68
+ def fit(sample_fn, jd0, jd1, seg_days, degree, scale=1.0, samples_per_seg=None,
69
+ sig=9):
70
+ """Fit segmented Chebyshev series.
71
+
72
+ sample_fn(jd_array) -> (x, y, z) arrays in source units.
73
+ scale: divide stored coefficients by this (runtime multiplies back).
74
+ Returns (data_dict, max_residual_in_source_units).
75
+ """
76
+ import numpy as np
77
+ nseg = int(math.ceil((jd1 - jd0) / seg_days))
78
+ m = samples_per_seg or (2 * degree + 2)
79
+ segments, max_resid = [], 0.0
80
+ # Chebyshev-Gauss-Lobatto sample points within each segment
81
+ xs = np.cos(np.pi * np.arange(m) / (m - 1))[::-1] # [-1, 1]
82
+ for i in range(nseg):
83
+ a = jd0 + i * seg_days
84
+ jds = a + (xs + 1.0) * seg_days / 2.0
85
+ X, Y, Z = sample_fn(jds)
86
+ seg = []
87
+ for arr in (X, Y, Z):
88
+ c = np.polynomial.chebyshev.chebfit(xs, arr, degree)
89
+ resid = np.max(np.abs(np.polynomial.chebyshev.chebval(xs, c) - arr))
90
+ max_resid = max(max_resid, float(resid))
91
+ seg.append([float(f"%.{sig}g" % (v / scale)) for v in c])
92
+ segments.append(seg)
93
+ return {"jd0": jd0, "seg_days": seg_days, "scale": scale,
94
+ "segments": segments}, max_resid