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.
- astroengine/__init__.py +1 -0
- astroengine/chart.py +293 -0
- astroengine/chebyshev.py +94 -0
- astroengine/core.py +751 -0
- astroengine/data/ceres_cheb.json +1 -0
- astroengine/data/chiron_cheb.json +1 -0
- astroengine/data/fixed_stars.json +1 -0
- astroengine/data/juno_cheb.json +1 -0
- astroengine/data/moon_cheb.embedded.json +1 -0
- astroengine/data/moon_cheb.full.json +1 -0
- astroengine/data/moon_meeus47.json +1 -0
- astroengine/data/nutation_iau1980.json +1 -0
- astroengine/data/pallas_cheb.json +1 -0
- astroengine/data/pholus_cheb.json +1 -0
- astroengine/data/pluto_meeus37.json +1 -0
- astroengine/data/uranian_kepler.json +1 -0
- astroengine/data/vesta_cheb.json +1 -0
- astroengine/data/vsop87d_earth.embedded.json +1 -0
- astroengine/data/vsop87d_earth.full.json +1 -0
- astroengine/data/vsop87d_earth.high.json +1 -0
- astroengine/data/vsop87d_earth.micro.json +1 -0
- astroengine/data/vsop87d_jupiter.embedded.json +1 -0
- astroengine/data/vsop87d_jupiter.full.json +1 -0
- astroengine/data/vsop87d_jupiter.high.json +1 -0
- astroengine/data/vsop87d_jupiter.micro.json +1 -0
- astroengine/data/vsop87d_mars.embedded.json +1 -0
- astroengine/data/vsop87d_mars.full.json +1 -0
- astroengine/data/vsop87d_mars.high.json +1 -0
- astroengine/data/vsop87d_mars.micro.json +1 -0
- astroengine/data/vsop87d_mercury.embedded.json +1 -0
- astroengine/data/vsop87d_mercury.full.json +1 -0
- astroengine/data/vsop87d_mercury.high.json +1 -0
- astroengine/data/vsop87d_mercury.micro.json +1 -0
- astroengine/data/vsop87d_neptune.embedded.json +1 -0
- astroengine/data/vsop87d_neptune.full.json +1 -0
- astroengine/data/vsop87d_neptune.high.json +1 -0
- astroengine/data/vsop87d_neptune.micro.json +1 -0
- astroengine/data/vsop87d_saturn.embedded.json +1 -0
- astroengine/data/vsop87d_saturn.full.json +1 -0
- astroengine/data/vsop87d_saturn.high.json +1 -0
- astroengine/data/vsop87d_saturn.micro.json +1 -0
- astroengine/data/vsop87d_uranus.embedded.json +1 -0
- astroengine/data/vsop87d_uranus.full.json +1 -0
- astroengine/data/vsop87d_uranus.high.json +1 -0
- astroengine/data/vsop87d_uranus.micro.json +1 -0
- astroengine/data/vsop87d_venus.embedded.json +1 -0
- astroengine/data/vsop87d_venus.full.json +1 -0
- astroengine/data/vsop87d_venus.high.json +1 -0
- astroengine/data/vsop87d_venus.micro.json +1 -0
- astroengine/derived.py +240 -0
- astroengine/eclipses.py +184 -0
- astroengine/events.py +193 -0
- astroengine/houses.py +336 -0
- astroengine/pheno.py +153 -0
- astroengine/query.py +170 -0
- astroengine/stars.py +65 -0
- astroengine/turbo.py +105 -0
- caelus_engine-0.8.0.dist-info/METADATA +92 -0
- caelus_engine-0.8.0.dist-info/RECORD +62 -0
- caelus_engine-0.8.0.dist-info/WHEEL +5 -0
- caelus_engine-0.8.0.dist-info/licenses/LICENSE +21 -0
- caelus_engine-0.8.0.dist-info/top_level.txt +1 -0
astroengine/__init__.py
ADDED
|
@@ -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}"
|
astroengine/chebyshev.py
ADDED
|
@@ -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
|