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.
- caelus_engine-0.8.0/LICENSE +21 -0
- caelus_engine-0.8.0/PKG-INFO +92 -0
- caelus_engine-0.8.0/README.md +66 -0
- caelus_engine-0.8.0/astroengine/__init__.py +1 -0
- caelus_engine-0.8.0/astroengine/chart.py +293 -0
- caelus_engine-0.8.0/astroengine/chebyshev.py +94 -0
- caelus_engine-0.8.0/astroengine/core.py +751 -0
- caelus_engine-0.8.0/astroengine/data/ceres_cheb.json +1 -0
- caelus_engine-0.8.0/astroengine/data/chiron_cheb.json +1 -0
- caelus_engine-0.8.0/astroengine/data/fixed_stars.json +1 -0
- caelus_engine-0.8.0/astroengine/data/juno_cheb.json +1 -0
- caelus_engine-0.8.0/astroengine/data/moon_cheb.embedded.json +1 -0
- caelus_engine-0.8.0/astroengine/data/moon_cheb.full.json +1 -0
- caelus_engine-0.8.0/astroengine/data/moon_meeus47.json +1 -0
- caelus_engine-0.8.0/astroengine/data/nutation_iau1980.json +1 -0
- caelus_engine-0.8.0/astroengine/data/pallas_cheb.json +1 -0
- caelus_engine-0.8.0/astroengine/data/pholus_cheb.json +1 -0
- caelus_engine-0.8.0/astroengine/data/pluto_meeus37.json +1 -0
- caelus_engine-0.8.0/astroengine/data/uranian_kepler.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vesta_cheb.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_earth.embedded.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_earth.full.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_earth.high.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_earth.micro.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_jupiter.embedded.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_jupiter.full.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_jupiter.high.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_jupiter.micro.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_mars.embedded.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_mars.full.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_mars.high.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_mars.micro.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_mercury.embedded.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_mercury.full.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_mercury.high.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_mercury.micro.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_neptune.embedded.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_neptune.full.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_neptune.high.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_neptune.micro.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_saturn.embedded.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_saturn.full.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_saturn.high.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_saturn.micro.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_uranus.embedded.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_uranus.full.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_uranus.high.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_uranus.micro.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_venus.embedded.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_venus.full.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_venus.high.json +1 -0
- caelus_engine-0.8.0/astroengine/data/vsop87d_venus.micro.json +1 -0
- caelus_engine-0.8.0/astroengine/derived.py +240 -0
- caelus_engine-0.8.0/astroengine/eclipses.py +184 -0
- caelus_engine-0.8.0/astroengine/events.py +193 -0
- caelus_engine-0.8.0/astroengine/houses.py +336 -0
- caelus_engine-0.8.0/astroengine/pheno.py +153 -0
- caelus_engine-0.8.0/astroengine/query.py +170 -0
- caelus_engine-0.8.0/astroengine/stars.py +65 -0
- caelus_engine-0.8.0/astroengine/turbo.py +105 -0
- caelus_engine-0.8.0/caelus_engine.egg-info/PKG-INFO +92 -0
- caelus_engine-0.8.0/caelus_engine.egg-info/SOURCES.txt +65 -0
- caelus_engine-0.8.0/caelus_engine.egg-info/dependency_links.txt +1 -0
- caelus_engine-0.8.0/caelus_engine.egg-info/requires.txt +3 -0
- caelus_engine-0.8.0/caelus_engine.egg-info/top_level.txt +1 -0
- caelus_engine-0.8.0/pyproject.toml +50 -0
- 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
|