Architecture Overview #
Codebase metrics, performance bottleneck analysis, and future development vision for LibEphemeris. For the implemented LEB binary ephemeris system, see the LEB Technical Guide.
Table of Contents #
- Codebase Overview
- Performance Bottleneck Analysis
- Future Rust Port via PyO3
- Appendix: Full Module Inventory
1. Codebase Overview #
Project Metrics #
| Metric | Value |
|---|---|
| Package version | 0.14.0 |
| Python support | 3.9 - 3.13 |
| Library source files | 37 .py files in libephemeris/ |
| Total library LOC | ~69,000 lines |
| Largest module | eclipse.py (14,240 lines) |
| Unit test files | ~150+ in tests/ |
| Comparison test files | ~120 in compare_scripts/tests/ |
Public API entries (__all__) |
519 |
| Constants defined | ~300+ (SE_, SEFLG_, SIDM_*, etc.) |
| Exception classes | 12 (1 base + 4 categories + 7 specific) |
| Runtime dependencies | 6 |
Architecture Summary #
LibEphemeris does NOT implement its own orbital integrator. It delegates all positional astronomy to Skyfield (which reads JPL DE440/DE441 SPK kernels via jplephem). The library provides:
- A Swiss Ephemeris-compatible Python API (1:1 with pyswisseph)
- 24 astrological house systems
- 43 sidereal/ayanamsha modes
- Solar/lunar eclipse search and contact point calculation
- Besselian element computation
- Lunar node and Lilith calculations (4 methods)
- Fixed star catalog (102 stars)
- Hypothetical/Uranian body support (8 Hamburg School + Transpluto)
- Minor body/asteroid support with auto-SPK download
- Atmospheric extinction and heliacal visibility models
Runtime Dependencies #
| Dependency | Purpose |
|---|---|
skyfield |
Core ephemeris engine (JPL DE440 via Skyfield) |
skyfield-data |
Skyfield data files |
astroquery |
Auto SPK kernel downloads from JPL Horizons |
certifi |
SSL certificates for HTTPS to JPL |
spktype21 |
SPK type 21 files from JPL Horizons |
pyerfa |
IAU ERFA nutation, obliquity, precession, aberration |
Module Breakdown by LOC #
| Module | LOC | Domain |
|---|---|---|
eclipse.py |
14,240 | Solar/lunar eclipses, occultations |
fixed_stars.py |
5,224 | 102-star catalog, proper motion |
houses.py |
4,928 | 25 house systems (26 codes) |
planets.py |
4,877 | Core planetary calculations |
hypothetical.py |
3,651 | Uranian planets, Transpluto |
lunar.py |
3,033 | Lunar nodes, Lilith (4 methods) |
minor_bodies.py |
2,649 | Asteroids, TNO, Kepler equation |
heliacal.py |
2,414 | Heliacal rising/setting |
crossing.py |
1,914 | Sun/Moon/planet crossings |
spk_auto.py |
1,879 | Automatic SPK download/cache |
utils.py |
1,722 | Angular math, coordinate transforms |
extinction.py |
1,623 | Atmospheric extinction model |
state.py |
1,492 | Global state management |
true_node_terms.py |
1,412 | Lunar node perturbation coefficients |
time_utils.py |
1,323 | Julian day, Delta-T, calendar |
spk.py |
1,304 | SPK kernel support |
iers_data.py |
1,286 | IERS Delta-T data |
__init__.py |
1,142 | Public API surface (519 exports) |
constants.py |
1,071 | All SE_, SEFLG_, SIDM_* constants |
exceptions.py |
962 | Exception hierarchy |
context.py |
547 | Thread-safe EphemerisContext |
profiling.py |
518 | Profiling infrastructure |
astrometry.py |
511 | IAU precession/nutation/aberration |
cache.py |
155 | LRU cache for nutation/obliquity |
2. Performance Bottleneck Analysis #
2.1 Current Performance Profile #
The library is pure Python with zero native extensions. No Cython, mypyc, C extensions, or compiled code exists anywhere in the project.
Cost per Operation #
| Operation | Cost | Why |
|---|---|---|
swe_calc_ut() with SEFLG_SPEED |
~1ms | 3 full Skyfield pipelines (9-12 Chebyshev evals + Python object overhead) |
swe_calc_ut() without speed |
~350us | 1 Skyfield pipeline |
swe_houses() (Placidus) |
~500us | Up to 50 trig iterations + GAST from Skyfield |
sol_eclipse_when_glob() |
~500ms-2s | 600-1050 Skyfield pipelines (iterative search) |
| Full chart (10 planets + houses) | ~12ms | 30x swe_calc_ut + 1x swe_houses |
2.2 Where CPU Time Is Spent (Priority Order) #
1. Skyfield .observe() calls (dominant cost, ~90%+ of total time) #
Each .observe() involves light-time iteration (2-3 iterations of JPL
Chebyshev polynomial evaluation via jplephem). This is the single largest
cost factor in every operation.
2. Central difference velocity (3x multiplier) #
The velocity computation in _calc_body() (planets.py:1909-1910) calls
itself recursively twice for central difference:
result_prev, _ = _calc_body(t_prev, ipl, flags_no_speed)
result_next, _ = _calc_body(t_next, ipl, flags_no_speed)
velocity = (result_next - result_prev) / (2 * dt)
This means every swe_calc_ut() with SEFLG_SPEED costs 3x the
position-only cost. Each of the 3 calls runs the full Skyfield pipeline:
.at().observe().apparent().
Total Skyfield calls per swe_calc_ut() with SEFLG_SPEED:
| Step | .at() |
.observe() |
.apparent() |
|---|---|---|---|
| Main position | 1 | 1 | 1 |
| Velocity t-dt (recursive) | 1 | 1 | 1 |
| Velocity t+dt (recursive) | 1 | 1 | 1 |
| Total | 3 | 3 | 3 |
Each .observe() internally iterates light-time (~2-3 iterations), so
the true count of JPL ephemeris segment evaluations is approximately
9-12 Chebyshev polynomial evaluations per single swe_calc_ut() call.
3. Eclipse search (combinatorial explosion) #
sol_eclipse_when_glob() call breakdown:
| Component | swe_calc_ut() calls |
Direct Skyfield pipelines |
|---|---|---|
| Find New Moon | ~42 | ~126 |
| Check eclipse | ~3 | ~18 |
| Refine maximum | 0 | ~90-180 |
| Contact times | 0 | ~369-729 |
| Total | ~45 | ~600-1050 |
4. No position caching #
When calculating a full chart (10 planets at the same JD), the Earth
observer position earth.at(t) is recomputed 30 times (10 planets x
3 pipelines each) despite being identical every time.
5. erfa.nut06a() (mitigated by cache) #
Would be expensive (~0.02ms per call with 1365 nutation terms), but the
LRU cache in cache.py (128 entries) makes this negligible for same-JD
calculations. Cache hit: ~0.0001ms (200x speedup).
2.3 Existing Caching Infrastructure #
| Cached function | Cache size | Underlying call | Speedup |
|---|---|---|---|
get_cached_nutation(jd_tt) |
128 entries | erfa.nut06a() |
200x |
get_cached_obliquity(jd_tt) |
128 entries | erfa.obl06() + nutation |
200x |
What is NOT cached:
- Planet positions (every
swe_calc_ut()hits Skyfield every time) - Skyfield Time objects (created fresh each call)
- Earth position for observer (recomputed per planet)
2.4 numpy Usage in Hot Paths #
All numpy usage in the hot paths is scalar or small 3-element vector
operations (e.g., np.array(offset) for tuple→array conversion). No
vectorized batch computation (np.dot, np.matmul, etc.) is used anywhere
in the critical path.
3. Future Rust Port via PyO3 #
Note: The LEB-reader-only Rust port strategy is also documented in the LEB Technical Guide.
This section describes a vision for porting the full library (~7,800 lines) to Rust, beyond just the LEB reader.
3.1 Strategy #
Once the Python implementation is validated, port the reader + pipeline
to Rust. The same .leb file format works for both Python and Rust. The
generator stays in Python (it runs once, not performance-critical).
3.2 Rust Crate Structure #
engine/
├── Cargo.toml
├── pyproject.toml # maturin build config
├── src/
│ ├── lib.rs # PyO3 module entry point
│ ├── leb.rs # .leb reader, mmap, index (~200 lines)
│ ├── chebyshev.rs # Clenshaw eval + derivative (~80 lines)
│ ├── calc.rs # Main calc pipeline (~400 lines)
│ ├── coords.rs # ICRS/ecliptic/equatorial transforms (~300 lines)
│ ├── precession.rs # IAU 2006 polynomial (~150 lines)
│ ├── light_time.rs # 3-iteration light-time (~60 lines)
│ ├── aberration.rs # Annual aberration (~100 lines)
│ ├── ayanamsha.rs # 43 sidereal systems (~800 lines)
│ ├── houses/ # 25 house systems (26 codes) (~2000 lines)
│ │ ├── mod.rs
│ │ ├── placidus.rs
│ │ ├── koch.rs
│ │ └── ...
│ ├── eclipse.rs # Eclipse search + contacts (~1500 lines)
│ ├── crossing.rs # Crossings, stations (~500 lines)
│ ├── lunar.rs # Lunar nodes, Lilith (~400 lines)
│ ├── hypothetical.rs # Kepler equation, hypotheticals (~300 lines)
│ ├── stars.rs # Fixed stars + proper motion (~200 lines)
│ ├── constants.rs # All SE_*, SEFLG_* (~400 lines)
│ ├── error.rs # Error types (~100 lines)
│ └── pymodule.rs # PyO3 bindings (~800 lines)
│
│ Total: ~7,800 lines Rust
3.3 Rust Dependencies #
[dependencies]
pyo3 = { version = "0.23", features = ["extension-module"] }
memmap2 = "0.9" # Memory-mapped .leb files
byteorder = "1.5" # Binary parsing
thiserror = "2" # Typed errors
parking_lot = "0.12" # Fast Mutex/RwLock
[dev-dependencies]
approx = "0.5" # Float comparison in tests
criterion = "0.5" # Benchmarks
Zero math dependencies: all trigonometry uses f64::sin(), f64::cos(), etc.
3.4 Expected Speedup After Rust Port #
| Operation | Python .leb | Rust .leb | Total vs Skyfield |
|---|---|---|---|
swe_calc_ut() + speed |
~75us | ~1-5us | 200-1000x |
swe_calc_ut() mean node |
~5us | ~0.1us | 1000x |
swe_houses() Placidus |
~400us | ~1-5us | 100-500x |
| Eclipse search | ~100ms | ~0.5-2ms | 500-2000x |
| Full chart (10 planets+houses) | ~1ms | ~20-50us | 240-600x |
3.5 Python Integration (Transparent Fallback) #
# libephemeris/planets.py
try:
from libephemeris_engine import Ephemeris as _NativeEphemeris
_engine = _NativeEphemeris("path/to/ephemeris.leb")
_HAS_ENGINE = True
except ImportError:
_HAS_ENGINE = False
def swe_calc_ut(tjd_ut, ipl, iflag):
if _HAS_ENGINE:
return _engine.calc_ut(tjd_ut, ipl, iflag)
reader = state.get_leb_reader()
if reader is not None:
return fast_calc.fast_calc_ut(reader, tjd_ut, ipl, iflag)
return _calc_body_skyfield(...) # original code, unchanged
Three tiers of performance:
- Rust engine (~1-5us) - if
libephemeris_engineis installed - Python .leb reader (~25-75us) - if .leb file is configured
- Skyfield (~350-1050us) - fallback, always available
3.6 Rust Port Timeline (After Python Validation) #
| Phase | Weeks | Deliverable |
|---|---|---|
| 1 | 1-3 | leb.rs + chebyshev.rs + calc.rs (core pipeline) |
| 2 | 3-4 | coords.rs + precession.rs + aberration.rs |
| 3 | 4-6 | houses/ (all 24 systems) + ayanamsha.rs |
| 4 | 6-9 | eclipse.rs + crossing.rs |
| 5 | 9-11 | lunar.rs + stars.rs + hypothetical.rs |
| 6 | 11-13 | pymodule.rs (PyO3 bindings) + integration |
| 7 | 13-15 | Test suite + validation vs pyswisseph + benchmarks |
Appendix: Full Module Inventory #
A.1 Coordinate Frames by Body Type #
This table documents the coordinate frame produced by each body’s calculation function, as found in the source code analysis:
| Body | Source Function | Uses Skyfield? | Frame | Geocentric? |
|---|---|---|---|---|
| Sun-Pluto | Skyfield .at().observe().apparent() |
Yes | True ecliptic of date | Yes |
SE_MEAN_NODE |
calc_mean_lunar_node() |
No | Mean ecliptic of date | N/A |
SE_TRUE_NODE |
calc_true_lunar_node() |
Yes | True ecliptic of date | Yes |
SE_MEAN_APOG |
calc_mean_lilith_with_latitude() |
No | Mean ecliptic of date | N/A |
SE_OSCU_APOG |
calc_true_lilith() |
Yes | True ecliptic of date | Yes |
SE_INTP_APOG |
calc_interpolated_apogee() |
No | Mean ecliptic of date | N/A |
SE_INTP_PERG |
calc_interpolated_perigee() |
No | Mean ecliptic of date | N/A |
| Uranians (40-47) | calc_uranian_planet() |
No | Heliocentric ecliptic | Helio |
| Transpluto (48) | calc_transpluto() |
No | Heliocentric ecliptic | Helio |
| Fixed stars | calc_fixed_star_position() |
Yes | True ecliptic of date | Yes |
| Chiron, Ceres-Vesta | SPK kernel via Skyfield | Yes | True ecliptic of date | Yes |
A.2 Numerical Algorithms Used #
| Algorithm | Where Used | Parameters |
|---|---|---|
| Clenshaw (Chebyshev eval) | .leb reader, JPL kernel evaluation | degree 9-16 |
| Newton-Raphson | crossing.py, eclipse.py (New Moon) |
tol: 0.001-0.1 arcsec |
| Brent’s method | crossing.py (root finding) |
tol: 1e-6 deg/day |
| Bisection | eclipse.py (contact times) |
60 iterations |
| Golden section search | eclipse.py (min separation) |
60 iterations |
| Fixed-point iteration | planets.py (light-time, 3 iter) |
Convergence in 3 iter |
| Placidus iteration | houses.py |
max 50 iter, tol 1e-7 deg |
| Gauquelin iteration | houses.py |
max 100 iter, tol 1e-8 |
| Pullen SR Newton-Raphson | houses.py |
max 20 iter, tol 1e-10 |
| Kepler equation | minor_bodies.py, hypothetical.py |
Elliptic, hyperbolic, parabolic |
| IAU 2006 precession | astrometry.py, planets.py |
5th degree polynomial |
| IAU 2006/2000A nutation | cache.py -> erfa.nut06a() |
1365 lunisolar + 687 planetary terms |
A.3 House Systems Implemented (24) #
| Code | Name | Method | Iterative? | Polar safe? |
|---|---|---|---|---|
| P | Placidus | Time-based | Yes (50) | No |
| K | Koch (GOH) | Birthplace | No | No |
| O | Porphyry | Arc trisection | No | Yes |
| R | Regiomontanus | Equator proj. | No | Unstable |
| C | Campanus | Prime vertical | No | Unstable |
| E/A | Equal (from Asc) | Geometric | No | Yes |
| W | Whole Sign | Geometric | No | Yes |
| X | Meridian (Zariel) | RA-based | No | Yes |
| H | Horizontal | Azimuthal | No | Unstable |
| T | Polich-Page (Topocentric) | Modified Regio | No | Unstable |
| B | Alcabitius | Arabic | No | Unstable |
| M | Morinus | RA projection | No | Yes |
| U | Krusinski-Pisa | Multi-step | No | Unstable |
| G | Gauquelin (36 sectors) | Iterative | Yes (100) | No |
| V | Vehlow Equal | Geometric | No | Yes |
| Y | APC | Parallel circle | No | Unstable |
| F | Carter Poli-Equatorial | RA-based | No | Unstable |
| N | Natural Gradient | Fixed (0=H1) | No | Yes |
| S | Sripati (Indian) | Midpoints | No | Yes |
| L | Pullen SD | Sinusoidal | No | Yes |
| Q | Pullen SR | Newton-Raphson | Yes (20) | Yes |
| D | Equal from MC | Geometric | No | Yes |
| I/i | Sunshine (Makransky) | Sun arc | No | Unstable |
A.4 Ayanamsha Systems (43) #
All 43 systems are implemented in planets.py::_calc_ayanamsa() (~375 lines).
They use the IAU 2006 general precession polynomial (5th degree) as the
precession backbone, with system-specific reference epochs and offsets.
Categories:
- Formula-based (Lahiri, Fagan-Bradley, Krishnamurti, etc.):
aya = offset + precession_polynomial(T) - Star-based (“True” modes): compute apparent ecliptic longitude of reference star via Skyfield, subtract target position
- Galactic-based: calibrated formulas or galactic pole computation
- User-defined (
SE_SIDM_USER):aya = ayan_t0 + [p(T_now) - p(T0)]