caelus-engine 0.8.0__tar.gz

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 (67) hide show
  1. caelus_engine-0.8.0/LICENSE +21 -0
  2. caelus_engine-0.8.0/PKG-INFO +92 -0
  3. caelus_engine-0.8.0/README.md +66 -0
  4. caelus_engine-0.8.0/astroengine/__init__.py +1 -0
  5. caelus_engine-0.8.0/astroengine/chart.py +293 -0
  6. caelus_engine-0.8.0/astroengine/chebyshev.py +94 -0
  7. caelus_engine-0.8.0/astroengine/core.py +751 -0
  8. caelus_engine-0.8.0/astroengine/data/ceres_cheb.json +1 -0
  9. caelus_engine-0.8.0/astroengine/data/chiron_cheb.json +1 -0
  10. caelus_engine-0.8.0/astroengine/data/fixed_stars.json +1 -0
  11. caelus_engine-0.8.0/astroengine/data/juno_cheb.json +1 -0
  12. caelus_engine-0.8.0/astroengine/data/moon_cheb.embedded.json +1 -0
  13. caelus_engine-0.8.0/astroengine/data/moon_cheb.full.json +1 -0
  14. caelus_engine-0.8.0/astroengine/data/moon_meeus47.json +1 -0
  15. caelus_engine-0.8.0/astroengine/data/nutation_iau1980.json +1 -0
  16. caelus_engine-0.8.0/astroengine/data/pallas_cheb.json +1 -0
  17. caelus_engine-0.8.0/astroengine/data/pholus_cheb.json +1 -0
  18. caelus_engine-0.8.0/astroengine/data/pluto_meeus37.json +1 -0
  19. caelus_engine-0.8.0/astroengine/data/uranian_kepler.json +1 -0
  20. caelus_engine-0.8.0/astroengine/data/vesta_cheb.json +1 -0
  21. caelus_engine-0.8.0/astroengine/data/vsop87d_earth.embedded.json +1 -0
  22. caelus_engine-0.8.0/astroengine/data/vsop87d_earth.full.json +1 -0
  23. caelus_engine-0.8.0/astroengine/data/vsop87d_earth.high.json +1 -0
  24. caelus_engine-0.8.0/astroengine/data/vsop87d_earth.micro.json +1 -0
  25. caelus_engine-0.8.0/astroengine/data/vsop87d_jupiter.embedded.json +1 -0
  26. caelus_engine-0.8.0/astroengine/data/vsop87d_jupiter.full.json +1 -0
  27. caelus_engine-0.8.0/astroengine/data/vsop87d_jupiter.high.json +1 -0
  28. caelus_engine-0.8.0/astroengine/data/vsop87d_jupiter.micro.json +1 -0
  29. caelus_engine-0.8.0/astroengine/data/vsop87d_mars.embedded.json +1 -0
  30. caelus_engine-0.8.0/astroengine/data/vsop87d_mars.full.json +1 -0
  31. caelus_engine-0.8.0/astroengine/data/vsop87d_mars.high.json +1 -0
  32. caelus_engine-0.8.0/astroengine/data/vsop87d_mars.micro.json +1 -0
  33. caelus_engine-0.8.0/astroengine/data/vsop87d_mercury.embedded.json +1 -0
  34. caelus_engine-0.8.0/astroengine/data/vsop87d_mercury.full.json +1 -0
  35. caelus_engine-0.8.0/astroengine/data/vsop87d_mercury.high.json +1 -0
  36. caelus_engine-0.8.0/astroengine/data/vsop87d_mercury.micro.json +1 -0
  37. caelus_engine-0.8.0/astroengine/data/vsop87d_neptune.embedded.json +1 -0
  38. caelus_engine-0.8.0/astroengine/data/vsop87d_neptune.full.json +1 -0
  39. caelus_engine-0.8.0/astroengine/data/vsop87d_neptune.high.json +1 -0
  40. caelus_engine-0.8.0/astroengine/data/vsop87d_neptune.micro.json +1 -0
  41. caelus_engine-0.8.0/astroengine/data/vsop87d_saturn.embedded.json +1 -0
  42. caelus_engine-0.8.0/astroengine/data/vsop87d_saturn.full.json +1 -0
  43. caelus_engine-0.8.0/astroengine/data/vsop87d_saturn.high.json +1 -0
  44. caelus_engine-0.8.0/astroengine/data/vsop87d_saturn.micro.json +1 -0
  45. caelus_engine-0.8.0/astroengine/data/vsop87d_uranus.embedded.json +1 -0
  46. caelus_engine-0.8.0/astroengine/data/vsop87d_uranus.full.json +1 -0
  47. caelus_engine-0.8.0/astroengine/data/vsop87d_uranus.high.json +1 -0
  48. caelus_engine-0.8.0/astroengine/data/vsop87d_uranus.micro.json +1 -0
  49. caelus_engine-0.8.0/astroengine/data/vsop87d_venus.embedded.json +1 -0
  50. caelus_engine-0.8.0/astroengine/data/vsop87d_venus.full.json +1 -0
  51. caelus_engine-0.8.0/astroengine/data/vsop87d_venus.high.json +1 -0
  52. caelus_engine-0.8.0/astroengine/data/vsop87d_venus.micro.json +1 -0
  53. caelus_engine-0.8.0/astroengine/derived.py +240 -0
  54. caelus_engine-0.8.0/astroengine/eclipses.py +184 -0
  55. caelus_engine-0.8.0/astroengine/events.py +193 -0
  56. caelus_engine-0.8.0/astroengine/houses.py +336 -0
  57. caelus_engine-0.8.0/astroengine/pheno.py +153 -0
  58. caelus_engine-0.8.0/astroengine/query.py +170 -0
  59. caelus_engine-0.8.0/astroengine/stars.py +65 -0
  60. caelus_engine-0.8.0/astroengine/turbo.py +105 -0
  61. caelus_engine-0.8.0/caelus_engine.egg-info/PKG-INFO +92 -0
  62. caelus_engine-0.8.0/caelus_engine.egg-info/SOURCES.txt +65 -0
  63. caelus_engine-0.8.0/caelus_engine.egg-info/dependency_links.txt +1 -0
  64. caelus_engine-0.8.0/caelus_engine.egg-info/requires.txt +3 -0
  65. caelus_engine-0.8.0/caelus_engine.egg-info/top_level.txt +1 -0
  66. caelus_engine-0.8.0/pyproject.toml +50 -0
  67. caelus_engine-0.8.0/setup.cfg +4 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 caelus contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: caelus-engine
3
+ Version: 0.8.0
4
+ Summary: Clean-room astrological ephemeris engine: planetary and lunar positions, houses, aspects, and events from published sources. The Python reference for the Caelus engine.
5
+ Author-email: EphemEngine <info@ephemengine.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://ephemengine.com
8
+ Project-URL: Repository, https://github.com/heavyblotto/caelus
9
+ Project-URL: Issues, https://github.com/heavyblotto/caelus/issues
10
+ Project-URL: Changelog, https://github.com/heavyblotto/caelus/blob/main/CHANGELOG.md
11
+ Keywords: astronomy,astrology,ephemeris,vsop87,horoscope,chart,houses,aspects
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: fit
24
+ Requires-Dist: numpy>=1.24; extra == "fit"
25
+ Dynamic: license-file
26
+
27
+ # caelus-engine
28
+
29
+ The Python reference implementation of the Caelus astrological ephemeris
30
+ engine. Clean-room, MIT-licensed, written from published sources (VSOP87D,
31
+ ELP/Meeus, IAU models, JPL Horizons fits). No Swiss Ephemeris code, no
32
+ bundled third-party ephemeris files.
33
+
34
+ The engine reads pre-built Chebyshev and series packs shipped with the
35
+ package and runs on the Python standard library alone. It is also the
36
+ reference that the TypeScript engine (`caelus` on npm) is pinned to by a
37
+ golden conformance suite.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install caelus-engine
43
+ ```
44
+
45
+ The import package is `astroengine`:
46
+
47
+ ```python
48
+ from astroengine import Engine, BODIES
49
+
50
+ eng = Engine("full")
51
+ jd = 2451545.0 # 2000-01-01 12:00 UT
52
+ for body in BODIES:
53
+ print(f"{body:10s} {eng.longitude(body, jd):10.5f}")
54
+ ```
55
+
56
+ ## What it computes
57
+
58
+ - Planetary and lunar apparent geocentric ecliptic longitudes (VSOP87D for
59
+ the planets, Meeus Ch. 47 for the Moon, Meeus Ch. 37 series for Pluto),
60
+ with light-time, annual aberration, FK5 frame correction, and IAU 1980
61
+ nutation.
62
+ - 12 house systems; tropical and sidereal (8 ayanamsas).
63
+ - Aspects with configurable orbs.
64
+ - Events: rise/set/transit, longitude crossings, lunar phases, stations,
65
+ Gauquelin sectors, solar and lunar eclipses.
66
+ - Fixed stars and topocentric positions.
67
+ - Derived charts: returns, secondary progressions, solar arc, composite and
68
+ Davison, harmonics, antiscia, declination aspects and parallels,
69
+ out-of-bounds, dignities, sect.
70
+ - A declarative `when()` query engine over celestial predicates.
71
+ - A turbo tier (`Turbo`): segmented Chebyshev longitude packs fit to the
72
+ engine for bulk scans.
73
+
74
+ ## Accuracy
75
+
76
+ Accuracy is calibrated against Swiss Ephemeris and validated against JPL
77
+ Horizons, stated per body rather than as a blanket figure. See the
78
+ validation tables at https://ephemengine.com.
79
+
80
+ ## Optional: the fitting toolchain
81
+
82
+ The engine runs without any third-party dependency. The data-fitting tools
83
+ that regenerate the coefficient packs need numpy:
84
+
85
+ ```bash
86
+ pip install "caelus-engine[fit]"
87
+ ```
88
+
89
+ ## License
90
+
91
+ MIT. See `LICENSE`. Project home: https://ephemengine.com. Source:
92
+ https://github.com/heavyblotto/caelus.
@@ -0,0 +1,66 @@
1
+ # caelus-engine
2
+
3
+ The Python reference implementation of the Caelus astrological ephemeris
4
+ engine. Clean-room, MIT-licensed, written from published sources (VSOP87D,
5
+ ELP/Meeus, IAU models, JPL Horizons fits). No Swiss Ephemeris code, no
6
+ bundled third-party ephemeris files.
7
+
8
+ The engine reads pre-built Chebyshev and series packs shipped with the
9
+ package and runs on the Python standard library alone. It is also the
10
+ reference that the TypeScript engine (`caelus` on npm) is pinned to by a
11
+ golden conformance suite.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install caelus-engine
17
+ ```
18
+
19
+ The import package is `astroengine`:
20
+
21
+ ```python
22
+ from astroengine import Engine, BODIES
23
+
24
+ eng = Engine("full")
25
+ jd = 2451545.0 # 2000-01-01 12:00 UT
26
+ for body in BODIES:
27
+ print(f"{body:10s} {eng.longitude(body, jd):10.5f}")
28
+ ```
29
+
30
+ ## What it computes
31
+
32
+ - Planetary and lunar apparent geocentric ecliptic longitudes (VSOP87D for
33
+ the planets, Meeus Ch. 47 for the Moon, Meeus Ch. 37 series for Pluto),
34
+ with light-time, annual aberration, FK5 frame correction, and IAU 1980
35
+ nutation.
36
+ - 12 house systems; tropical and sidereal (8 ayanamsas).
37
+ - Aspects with configurable orbs.
38
+ - Events: rise/set/transit, longitude crossings, lunar phases, stations,
39
+ Gauquelin sectors, solar and lunar eclipses.
40
+ - Fixed stars and topocentric positions.
41
+ - Derived charts: returns, secondary progressions, solar arc, composite and
42
+ Davison, harmonics, antiscia, declination aspects and parallels,
43
+ out-of-bounds, dignities, sect.
44
+ - A declarative `when()` query engine over celestial predicates.
45
+ - A turbo tier (`Turbo`): segmented Chebyshev longitude packs fit to the
46
+ engine for bulk scans.
47
+
48
+ ## Accuracy
49
+
50
+ Accuracy is calibrated against Swiss Ephemeris and validated against JPL
51
+ Horizons, stated per body rather than as a blanket figure. See the
52
+ validation tables at https://ephemengine.com.
53
+
54
+ ## Optional: the fitting toolchain
55
+
56
+ The engine runs without any third-party dependency. The data-fitting tools
57
+ that regenerate the coefficient packs need numpy:
58
+
59
+ ```bash
60
+ pip install "caelus-engine[fit]"
61
+ ```
62
+
63
+ ## License
64
+
65
+ MIT. See `LICENSE`. Project home: https://ephemengine.com. Source:
66
+ https://github.com/heavyblotto/caelus.
@@ -0,0 +1 @@
1
+ from .chart import Engine, fmt_lon, BODIES
@@ -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