Metadata-Version: 2.4
Name: pyswisseph-rs
Version: 0.1.0
Classifier: Development Status :: 4 - Beta
Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Rust
Classifier: Topic :: Scientific/Engineering :: Astronomy
Classifier: Typing :: Typed
License-File: LICENSE
Summary: Python bindings for the swisseph-rs crate — a pure-Rust Swiss Ephemeris
Author-email: Ninth House Studios <josh@ninthhousestudios.com>
License-Expression: AGPL-3.0-or-later
Requires-Python: >=3.11
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Repository, https://github.com/ninthhousestudios/pyswisseph-rs
Project-URL: Source (Rust crate), https://github.com/ninthhousestudios/swisseph-rs

# pyswisseph-rs

Python bindings for the [`swisseph-rs`](https://github.com/ninthhousestudios/swisseph-rs)
crate — a pure-Rust reimplementation of the
[Swiss Ephemeris](https://www.astro.com/swisseph/).

**Why this package?** Every method on `Ephemeris` releases the GIL, so a single
shared instance can drive a `ThreadPoolExecutor` at full core utilization with
zero coordination. No global state, no `swe_close()`, no mutex — just pass
the same object to every thread.

```
pip install pyswisseph-rs     # or: uv add pyswisseph-rs
```

## Quickstart (zero files, Moshier ephemeris)

The built-in Moshier analytical ephemeris covers all planets for any date
without ephemeris files. Accuracy is ~1 arc-second for modern dates.

```python
from swisseph_rs import Body, CalcFlags, Ephemeris, EphemerisConfig

eph = Ephemeris(EphemerisConfig())   # Moshier by default, no files needed
result = eph.calc_ut(2451545.0, Body.SUN, CalcFlags.SPEED)

lon, lat, dist, lon_speed, lat_speed, dist_speed = result.data
print(f"Sun longitude at J2000: {lon:.6f}°")
```

## Using Swiss Ephemeris data files

For sub-arc-second precision, point `EphemerisConfig` at a directory
containing the Swiss Ephemeris data files:

```python
from swisseph_rs import Ephemeris, EphemerisConfig, EphemerisSource

eph = Ephemeris(EphemerisConfig(
    ephemeris_source=EphemerisSource.SWISS,
    ephe_path="/path/to/ephe",
))
```

Data files (`sepl*.se1`, `semo*.se1`, `seas*.se1`, etc.) are available from
[Astrodienst](https://www.astro.com/ftp/swisseph/ephe/). Download the files
covering your date range and place them in the directory you point `ephe_path`
to.

For JPL ephemerides, set `ephemeris_source=EphemerisSource.JPL` and optionally
`jpl_filename="de441.eph"`.

## Threading

`pyswisseph-rs` is designed for concurrent workloads. Every `Ephemeris`
method releases the GIL around the Rust computation, so multiple Python
threads run in true parallel on separate cores.

```python
import concurrent.futures
from swisseph_rs import Body, CalcFlags, Ephemeris, EphemerisConfig

eph = Ephemeris(EphemerisConfig())
bodies = [Body.SUN, Body.MOON, Body.MERCURY, Body.VENUS, Body.MARS,
          Body.JUPITER, Body.SATURN]

def calc_year(year_offset):
    """Calculate daily positions for one year."""
    jd_start = 2451545.0 + year_offset * 365.25
    results = []
    for day in range(365):
        for body in bodies:
            r = eph.calc_ut(jd_start + day, body, CalcFlags.SPEED)
            results.append(r.data[0])  # longitude
    return results

# Same Ephemeris instance shared across all threads — no copies, no locks
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
    futures = [pool.submit(calc_year, y) for y in range(20)]
    all_results = [f.result() for f in futures]
```

This consistently achieves near-linear speedup (e.g. ~3.5x on 4 cores).
Results are bit-identical to serial execution.

### What about multiprocessing?

`Ephemeris` is not picklable. For process-based parallelism, construct a
separate `Ephemeris` in each worker. But threading is usually the better
choice here — there is no GIL contention, so threads give you the speedup
without the IPC overhead.

## Sidereal mode and topographic position

Configuration that the C library sets via global state (`swe_set_sid_mode`,
`swe_set_topo`) is passed through `EphemerisConfig` instead:

```python
from swisseph_rs import (
    Ephemeris, EphemerisConfig, SiderealMode, TopoPosition,
)

eph = Ephemeris(EphemerisConfig(
    sidereal_mode=SiderealMode.LAHIRI,
    topographic=TopoPosition(longitude=-74.006, latitude=40.7128, altitude=10.0),
))
```

The config is frozen after construction — no mutable global state, no
ordering bugs between `set_*` calls.

## Migrating from C pyswisseph

`pyswisseph-rs` wraps the same Swiss Ephemeris engine but replaces the C
library's global-state API with an object-oriented, stateless design. The
key differences:

1. **No global state.** `swe_set_ephe_path`, `swe_set_sid_mode`, `swe_set_topo`
   become fields on `EphemerisConfig`. `swe_close` is unnecessary.
2. **Methods on `Ephemeris`.** Functions like `swe_calc` become `eph.calc()`.
3. **Named types.** Flags are `CalcFlags.SPEED`, bodies are `Body.SUN`,
   not raw integers.

Every method's docstring includes the corresponding `swe_*` name. Use
`help(eph.calc)` or check the type stubs for the mapping.

### Function mapping (top 25)

| C pyswisseph | pyswisseph-rs |
|---|---|
| `swe_calc(jd, ipl, iflag)` | `eph.calc(jd, body, flags)` |
| `swe_calc_ut(jd, ipl, iflag)` | `eph.calc_ut(jd, body, flags)` |
| `swe_calc_pctr(jd, ipl, ictr, iflag)` | `eph.calc_pctr(jd, body, center, flags)` |
| `swe_fixstar2(star, jd, iflag)` | `eph.fixstar2(star, jd, flags)` |
| `swe_fixstar2_ut(star, jd, iflag)` | `eph.fixstar2_ut(star, jd, flags)` |
| `swe_fixstar2_mag(star)` | `eph.fixstar2_mag(star)` |
| `swe_houses(jd, lat, lon, hsys)` | `eph.houses(jd, lat, lon, hsys)` |
| `swe_houses_ex(jd, iflag, lat, lon, hsys)` | `eph.houses_ex(jd, flags, lat, lon, hsys)` |
| `swe_house_pos(armc, lat, eps, hsys, ...)` | `houses.house_pos(armc, lat, eps, hsys, xpin)` |
| `swe_get_ayanamsa_ex(jd, iflag)` | `eph.get_ayanamsa_ex(jd, flags)` |
| `swe_julday(y, m, d, h, cal)` | `date.julday(y, m, d, h, cal)` |
| `swe_revjul(jd, cal)` | `date.revjul(jd, cal)` |
| `swe_utc_to_jd(y,m,d,h,mi,s, cal)` | `date.utc_to_jd(utc, cal, eph)` |
| `swe_day_of_week(jd)` | `date.day_of_week(jd)` |
| `swe_rise_trans(...)` | `eph.rise_trans(...)` |
| `swe_pheno_ut(jd, ipl, iflag)` | `eph.pheno_ut(jd, body, flags)` |
| `swe_nod_aps_ut(jd, ipl, iflag, method)` | `eph.nod_aps_ut(jd, body, flags, method)` |
| `swe_sol_eclipse_when_glob(jd, iflag, ifltype, bwd)` | `eph.sol_eclipse_when_glob(jd, flags, ifltype, backward)` |
| `swe_lun_eclipse_when(jd, iflag, ifltype, bwd)` | `eph.lun_eclipse_when(jd, flags, ifltype, backward)` |
| `swe_get_orbital_elements(jd, ipl, iflag)` | `eph.get_orbital_elements(jd, body, flags)` |
| `swe_split_deg(ddeg, roundflag)` | `math.split_degrees(ddeg, flags)` |
| `swe_sidtime(jd)` | `sidereal_time.sidereal_time(jd, config)` |
| `swe_refrac(inalt, atpress, attemp, dir)` | `azalt.refrac(inalt, atpress, attemp, dir)` |
| `swe_set_ephe_path(path)` | `EphemerisConfig(ephe_path=path)` |
| `swe_set_sid_mode(sid_mode, t0, ayan_t0)` | `EphemerisConfig(sidereal_mode=..., sidereal_t0=..., sidereal_ayan_t0=...)` |
| `swe_set_topo(lon, lat, alt)` | `EphemerisConfig(topographic=TopoPosition(...))` |
| `swe_close()` | *(not needed — no global state)* |

## Module structure

Free functions live in submodules mirroring the Rust crate's module tree:

```python
from swisseph_rs import date, math, houses, azalt, sidereal_time
```

Types and flags are re-exported at the top level:

```python
from swisseph_rs import Body, CalcFlags, EphemerisConfig, Ephemeris
```

## Design and transliteration discipline

`pyswisseph-rs` maintains a strict 1:1 correspondence with the underlying
Rust crate's public API. Every Python class, enum, and function mirrors
exactly one Rust symbol. This is a deliberate design choice documented in
[`CONTEXT.md`](CONTEXT.md) and the architecture decision records in
[`docs/adr/`](docs/adr/):

- [ADR 0001](docs/adr/0001-pyo3-on-lib-crate-not-ffi.md) — PyO3 on the
  lib crate, not the C FFI layer
- [ADR 0002](docs/adr/0002-strict-one-to-one-transliteration.md) — strict
  1:1 transliteration from Rust to Python

## License

This project is licensed under the **GNU Affero General Public License v3.0
or later** (AGPL-3.0-or-later). See [LICENSE](LICENSE) for the full text.

The wheel statically links the `swisseph-rs` crate, which is a derivative
work of the [Swiss Ephemeris](https://www.astro.com/swisseph/) by Astrodienst
AG, also licensed under AGPL-3.0-or-later.

