From 80c3619089d81578bdec99c04b352d2db010a611 Mon Sep 17 00:00:00 2001 From: ffalcinelli Date: Tue, 22 Sep 2026 22:19:06 +0200 Subject: [PATCH 1/6] docs: add security policy Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011a5A76BzYdi7yn5vUancjY --- SECURITY.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..063840f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,66 @@ +# Security policy + +## Supported versions + +astroceleste-engine is experimental (0.0.x). Security fixes go into a new patch release of +the latest version only; older releases are not patched. This applies to the Rust crate, +the Python wheels and the npm package alike, which share one version number. + +| Version | Supported | +|---|---| +| latest 0.0.x | yes | +| anything older | no | + +## Reporting a vulnerability + +Please **do not open a public issue**. Report it privately through GitHub: +[Report a vulnerability](https://github.com/ffalcinelli/astroceleste-engine/security/advisories/new) +(repository *Security* tab → *Report a vulnerability*). + +Please include: + +- the affected version and how you use it (Rust crate, Python, WebAssembly; OS or browser); +- what an attacker can achieve, and under which conditions; +- a reproducer: for example a crafted kernel file, or the request that triggers the issue. + +## What to expect + +This is a small project maintained on a best-effort basis: + +- an acknowledgement within 7 days; +- an assessment, and a fix or mitigation plan, as soon as it is understood; +- coordinated disclosure: the fix is released first, then a GitHub Security Advisory is + published (plus a [RustSec](https://rustsec.org) advisory for the crate), crediting you + unless you prefer otherwise. + +## Scope + +The engine is often fed data that the host application does not control, so the main +attack surface is untrusted input: + +- **SPK kernel bytes** passed to `Spk::from_bytes` / `Spk::open`, the WebAssembly + `addKernel` and `excerptKernel`, or the Python `Engine` constructor. A malformed kernel + must be rejected with an error. It must never cause memory corruption, a panic that + crosses the bindings, unbounded memory allocation or an endless loop. +- **Chart requests** (dates, coordinates, orb settings, stored chart payloads passed to + `synastry` / `derived_chart`) coming through the Python and WebAssembly bindings. +- **The release pipeline**: GitHub workflows, trusted publishing and the published + artifacts on crates.io, PyPI and npm. + +Out of scope: + +- values that you believe are astronomically or astrologically wrong. Please open a + normal issue with the request and the source of the expected value; +- vulnerabilities in dependencies that cannot be reached through the engine. Please report + them upstream. If you are not sure whether one can be reached, report it here. + +## Hardening in place + +- Pure Rust with no `unsafe` code in the engine or its bindings, and no C dependencies. +- Few dependencies (only `serde` and `serde_json` in the core), all under permissive + licenses. [cargo-deny](https://github.com/EmbarkStudios/cargo-deny) checks them in CI + for advisories, licenses and sources. +- Every GitHub Action is pinned to a commit SHA, which CI verifies, and checked against + OSV. +- Releases are published through Trusted Publishing (OIDC), so no long-lived registry + token is stored. Only maintainers can push release tags. From 6ea7e3150edcd4edd9cc7671b1d87d8a7005ba82 Mon Sep 17 00:00:00 2001 From: ffalcinelli Date: Tue, 22 Sep 2026 22:19:06 +0200 Subject: [PATCH 2/6] docs: expand contributing guide Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011a5A76BzYdi7yn5vUancjY --- CONTRIBUTING.md | 133 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 116 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f5ef2ec..4d09651 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,30 +1,129 @@ # Contributing -## Correctness first +Thanks for your interest in astroceleste-engine! Bug reports, fixes, documentation and +ports of missing features are all welcome. + +## Reporting bugs -Every number the engine produces is checked against a reference: +- **A chart value looks wrong?** Open an issue with the complete request (UTC moment, + latitude, longitude, house system, zodiac type, ayanamsa, orb settings if any), the value + the engine returns, the value you expected, and where the expected value comes from + (another program, an ephemeris, a published table). +- **A crash, or an error you did not expect?** Include the version, the binding (Rust, + Python, WebAssembly), the platform and a minimal reproducer. +- **A security issue?** Do not open an issue. See [SECURITY.md](SECURITY.md). -- `tests/fixtures/spk_reference.json`: raw kernel states from `jplephem` - (`scripts/make_spk_fixtures.py`). -- `tests/fixtures/{natal,horary,transits,synastry,derived,errors}.json`: complete charts from - the reference implementation. A change that moves any of them must explain why the new - value is more correct, and regenerate the fixtures in the same pull request. +## Setup -Run the full suite with the complete kernel before opening a pull request: +- Rust ≥ 1.85 (the MSRV, also tested in CI) via [rustup](https://rustup.rs). +- The full JPL kernel: `scripts/fetch-kernels.sh` downloads `kernels/de440s.bsp` (~32 MB, + gitignored). Without it the tests use a committed one-year excerpt, and **the golden + chart tests silently skip** (they print "skipping" and pass). A green run without the + kernel does not validate charts. +- For the Python bindings: Python ≥ 3.12, a virtualenv, `pip install maturin pytest`. +- For the WebAssembly bindings: `rustup target add wasm32-unknown-unknown`, + [wasm-pack](https://rustwasm.github.io/wasm-pack/) and Node.js. + +## Everyday commands ```bash -scripts/fetch-kernels.sh -cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace +cargo test --workspace --exclude astroceleste-engine-py # fast: committed excerpt +cargo test -p astroceleste-engine --release # with the kernel: golden + full range +cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings +RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps + +# Targets the apps depend on must keep compiling +cargo build -p astroceleste-engine -p astroceleste-engine-wasm --lib --target wasm32-unknown-unknown +cargo check -p astroceleste-engine --lib --target aarch64-linux-android # and aarch64-apple-ios + +# Python bindings (in a virtualenv) +maturin develop -m crates/astroceleste-engine-py/Cargo.toml +pytest crates/astroceleste-engine-py/tests + +# WebAssembly bindings +wasm-pack build --target nodejs --out-dir pkg-node crates/astroceleste-engine-wasm +node crates/astroceleste-engine-wasm/tests/golden.mjs + +# Landing site with the live demo, served at http://localhost:8000 +scripts/build-site.sh && python3 -m http.server -d target/site ``` -## Dependencies +The Python crate is excluded from `cargo test` because it links against libpython. Test it +through maturin. + +[docs/architecture.md](docs/architecture.md) explains how the code is organised. + +## Correctness first + +The engine is a port of the Astroceleste reference implementation (Python on Skyfield), and +every number it produces is checked against a reference: + +- `tests/fixtures/{natal,horary,transits,synastry,derived,errors}.json`: complete charts + from the reference implementation. These fixtures are the acceptance spec. The engine + must reproduce them with floats within 1e-9, the same key order and the same int vs + float number types. +- `tests/fixtures/spk_reference.json` and the `tests/data/de440s_2000.bsp` excerpt: raw + kernel states from `jplephem` (`scripts/make_spk_fixtures.py`). +- `tests/fixtures/reduction.json`: stage-by-stage reduction values from Skyfield + (`scripts/make_reduction_fixtures.py`). + +A change that moves any fixture value must explain why the new value is more correct, and +regenerate the fixtures in the same pull request. + +When porting reference code: + +- Use `pyfloat.rs` (`rem`, `floordiv`, `divmod`, `round`, `round_int`) wherever the + reference uses Python's `%`, `//` or `round()`. Rust's `%` and `f64::round` differ at + the boundaries, and the difference moves signs and orbs. +- Serialize integers and floats exactly as the reference does. +- Keep the module doc comment naming the reference function it ports (for example + `SkyfieldEngine.calculate_planets`). +- The embedded tables (`src/data/*.bin`, `src/frames/nutation_data.rs`) are generated by + `scripts/gen_tables.py`. Do not edit them by hand. + +See [docs/accuracy.md](docs/accuracy.md) for the full picture. + +## Public API + +- The API is curated in `lib.rs`: modules are private, and entry points and every type + reachable from a result are re-exported. A new result type must be added there. +- `missing_docs` is enabled and CI denies warnings: every public item needs a doc comment. +- Public enums that may grow (`EngineError`, `SpkError`, `HouseSystem`) are + `#[non_exhaustive]`. +- A change to the bindings updates the Python type stub (`astroceleste_engine.pyi`), the + binding READMEs and both binding test suites. + +## Dependencies and portability The engine ships inside closed-source applications and in the browser, so it stays -dependency-light and permissively licensed (`deny.toml`). It must keep building for -`wasm32-unknown-unknown`, Android and iOS: no C code, no platform-specific I/O in the core. +dependency-light (the core depends only on `serde` and `serde_json`) and permissively +licensed (`deny.toml`, checked by cargo-deny in CI). It must keep building for +`wasm32-unknown-unknown`, Android and iOS: no C code and no platform-specific I/O in the +core. + +## Commits and pull requests + +Use [Conventional Commits](https://www.conventionalcommits.org). `release-plz update` +derives the version bump and the changelog from them: + +```text +feat: add Koch houses +fix(houses): Placidus cusps above the polar circle +docs: explain kernel excerpts +feat!: rename ChartRequest::zodiac_type # breaking change +``` + +Keep each pull request to one logical change. Before opening it, check that: + +- [ ] `cargo fmt`, `clippy -D warnings` and `cargo doc` are clean; +- [ ] the tests pass **with the full kernel** (`scripts/fetch-kernels.sh`); +- [ ] binding changes are tested through maturin and wasm-pack; +- [ ] any fixture change is explained and regenerated in the same pull request; +- [ ] docs (rustdoc, READMEs, `docs/`) match the change. + +Maintainers release by pushing a version tag: see [RELEASING.md](RELEASING.md). -## Commits +## License -Use [Conventional Commits](https://www.conventionalcommits.org): `release-plz update` derives -the version bump and the changelog from them. By contributing you agree to license your work -under MIT OR Apache-2.0. +By contributing you agree to license your work under MIT OR Apache-2.0, without any +additional terms or conditions. From af17550c536d7255f6053534dd2bf9dff5932587 Mon Sep 17 00:00:00 2001 From: ffalcinelli Date: Tue, 22 Sep 2026 22:19:06 +0200 Subject: [PATCH 3/6] docs: add guides and move release procedure to RELEASING.md Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011a5A76BzYdi7yn5vUancjY --- CLAUDE.md | 10 ++ README.md | 85 ++++------ RELEASING.md | 49 ++++++ crates/astroceleste-engine-py/README.md | 3 + crates/astroceleste-engine-wasm/README.md | 3 + docs/README.md | 19 +++ docs/accuracy.md | 79 ++++++++++ docs/api.md | 184 ++++++++++++++++++++++ docs/architecture.md | 82 ++++++++++ docs/ephemerides.md | 98 ++++++++++++ 10 files changed, 556 insertions(+), 56 deletions(-) create mode 100644 RELEASING.md create mode 100644 docs/README.md create mode 100644 docs/accuracy.md create mode 100644 docs/api.md create mode 100644 docs/architecture.md create mode 100644 docs/ephemerides.md diff --git a/CLAUDE.md b/CLAUDE.md index edbb34d..57d03af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,9 @@ pytest crates/astroceleste-engine-py/tests # WASM bindings wasm-pack build --target nodejs --out-dir pkg-node crates/astroceleste-engine-wasm node crates/astroceleste-engine-wasm/tests/golden.mjs + +# Landing site + live demo (needs wasm-pack): builds target/site, deployed by pages.yml +scripts/build-site.sh && python3 -m http.server -d target/site ``` The golden tests (`golden_chart`, `golden_horary`, `golden_derived`) **silently skip** (print @@ -63,6 +66,13 @@ Core pipeline (`crates/astroceleste-engine/src`): - `catalog/` — static tables (fixed stars, lunar mansions, derived-house meanings), generated code excluded from rustfmt/clippy. +Docs outside rustdoc: `docs/` (API guide, ephemerides, accuracy, architecture), `CONTRIBUTING.md`, +`SECURITY.md`, `RELEASING.md`. `site/` is the static landing page with a WASM demo (`demo.js` +imports `pkg/astroceleste_engine_wasm.js` and loads a 1950–2050 excerpt; the range is set in +`scripts/build-site.sh`). When options, ayanamsas, entry points or the result shape change, +update `docs/api.md` and the demo form too. README is also the crates.io readme: link repo files +with absolute GitHub URLs. + The public API is curated: every module is private except `ephemeris`, and `lib.rs` re-exports the entry points, `ChartRequest`, `EngineError`, `UtcInstant` and every type reachable from a result (`Chart`, `Placement`, `HoraryData`, …). A new result type must be added to those diff --git a/README.md b/README.md index 1a34269..10db9a6 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ It is the calculation core of [Astroceleste](https://astroceleste.it). The same on the server (Python bindings), in the desktop and mobile apps (native) and in the browser (WASM), so every platform computes the same chart down to the arcsecond. +**[Website and live demo](https://ffalcinelli.github.io/astroceleste-engine/)** · [API docs](https://docs.rs/astroceleste-engine) · +[Guides](https://github.com/ffalcinelli/astroceleste-engine/tree/main/docs) + > **Experimental (0.0.x):** the API may change in any release. Pin an exact version. > Status: the complete chart pipeline is ported and matches the reference implementation @@ -43,13 +46,23 @@ Charts serialize to the same JSON as the Astroceleste API. Other entry points: - **Python** (`pip install astroceleste-engine`, CPython ≥ 3.12): `Engine([kernel paths])` with `.chart()`, `.horary()`, `.transit()`, plus `synastry()` and `derived_chart()`, - returning plain dicts. See [crates/astroceleste-engine-py](crates/astroceleste-engine-py). + returning plain dicts. See [crates/astroceleste-engine-py](https://github.com/ffalcinelli/astroceleste-engine/tree/main/crates/astroceleste-engine-py). - **JavaScript / WebAssembly** (`npm install astroceleste-engine`): the same API over kernels loaded from memory, for browsers and Node. See - [crates/astroceleste-engine-wasm](crates/astroceleste-engine-wasm). + [crates/astroceleste-engine-wasm](https://github.com/ffalcinelli/astroceleste-engine/tree/main/crates/astroceleste-engine-wasm). Both are tested against the same golden fixtures as the Rust crate. +## Documentation + +- [API reference on docs.rs](https://docs.rs/astroceleste-engine) +- [API guide](https://github.com/ffalcinelli/astroceleste-engine/blob/main/docs/api.md): request options, the chart JSON, error codes in every + language +- [Ephemerides](https://github.com/ffalcinelli/astroceleste-engine/blob/main/docs/ephemerides.md): kernels, date coverage, loading from memory, + smaller excerpts for the web and mobile +- [Accuracy](https://github.com/ffalcinelli/astroceleste-engine/blob/main/docs/accuracy.md): how every number is verified +- [Architecture](https://github.com/ffalcinelli/astroceleste-engine/blob/main/docs/architecture.md): crates and calculation pipeline + ## Layout | Path | Content | @@ -63,75 +76,35 @@ Both are tested against the same golden fixtures as the Rust crate. | `scripts/make_spk_fixtures.py` | rebuilds the excerpt and the SPK reference states with `jplephem` | | `scripts/make_reduction_fixtures.py` | stage-by-stage reference values from Skyfield | | `scripts/gen_tables.py` | regenerates the embedded ΔT, nutation and Chiron tables | +| `scripts/build-site.sh` | builds the landing site and live demo into `target/site/` | +| `docs/` | guides | +| `site/` | landing site and live demo, deployed to GitHub Pages | + +## Contributing -## Development +Contributions are welcome: see [CONTRIBUTING.md](https://github.com/ffalcinelli/astroceleste-engine/blob/main/CONTRIBUTING.md) for setup, the test +suite and the rules that keep every platform identical. Report vulnerabilities privately, +as described in [SECURITY.md](https://github.com/ffalcinelli/astroceleste-engine/blob/main/SECURITY.md). Maintainers release by pushing a version +tag ([RELEASING.md](https://github.com/ffalcinelli/astroceleste-engine/blob/main/RELEASING.md)). ```bash cargo test --workspace --exclude astroceleste-engine-py # uses the committed excerpt -scripts/fetch-kernels.sh # optional: full de440s (1849-2150), enables the full-range tests +scripts/fetch-kernels.sh # full de440s (1849-2150): enables the golden and full-range tests cargo clippy --workspace --all-targets -- -D warnings - -# Python bindings (in a virtualenv) -pip install maturin pytest && maturin develop -m crates/astroceleste-engine-py/Cargo.toml -pytest crates/astroceleste-engine-py/tests - -# WebAssembly bindings (needs wasm-pack and the wasm32-unknown-unknown target) -wasm-pack build --target nodejs --out-dir pkg-node crates/astroceleste-engine-wasm -node crates/astroceleste-engine-wasm/tests/golden.mjs ``` -## Releasing - -Releases are trunk-based: there are no release branches or release PRs. Pushing a -`vX.Y.Z` tag on `main` publishes that version. - -1. Commits on `main` follow [Conventional Commits](https://www.conventionalcommits.org) - (`feat:`, `fix:`, `perf:`, `refactor:`, … and `!` for breaking changes). While the - version is 0.0.x, `feat`/`fix` bump the patch version and a breaking change bumps the - minor version. -2. Prepare the release on `main`: run `release-plz update` (or edit by hand) to bump the - workspace version, the bindings' `astroceleste-engine` dependency version and - `CHANGELOG.md`, then commit as `chore: release vX.Y.Z` and push. -3. Once CI is green, tag that commit and push the tag: - `git tag vX.Y.Z && git push origin vX.Y.Z`. -4. The `Release` workflow checks that the tag is on `main`, matches the version in - `Cargo.toml` and has a `CHANGELOG.md` entry. It then publishes the crate to crates.io, - creates the GitHub Release from the changelog entry, and publishes the Python wheels - (Linux x86_64/aarch64 glibc and musl, macOS universal2, Windows x64, sdist) to PyPI - and the WebAssembly package to npm. - -Protect `v*` tags with a tag ruleset so only maintainers can trigger a release. - -All three registries use Trusted Publishing (OIDC), so no long-lived token is stored. -crates.io and npm can only trust a workflow for a package that already exists, so the -first release uses short-lived tokens instead: - -1. Create the `release`, `pypi` and `npm` environments in the repository settings - (optionally with required reviewers). -2. **crates.io**: create an API token scoped to `publish-new` and `publish-update` for the - crate `astroceleste-engine`, with a short expiry, and store it as the - `CARGO_REGISTRY_TOKEN` secret of the `release` environment. -3. **npm**: create a granular access token with publish rights and a short expiry, and store - it as the `NPM_TOKEN` secret of the `npm` environment. -4. **PyPI**: add a *pending* trusted publisher for project `astroceleste-engine` (workflow - `release.yml`, environment `pypi`). No token is needed. -5. Push the `vX.Y.Z` tag for the version in `Cargo.toml`. -6. Once the packages exist, add the trusted publisher on crates.io (crate settings → - *Trusted Publishing*: this repository, workflow `release.yml`, environment `release`) - and on npm (package settings: workflow `release.yml`, environment `npm`). Then delete - both secrets and revoke the tokens. The workflow prefers OIDC whenever it is available. - ## Ephemerides The engine reads NASA JPL SPK kernels (DE440s by default, DE441 for dates outside 1849–2150). -Dates outside the loaded kernels are reported as errors, never approximated. +Dates outside the loaded kernels are reported as errors, never approximated. See +[docs/ephemerides.md](https://github.com/ffalcinelli/astroceleste-engine/blob/main/docs/ephemerides.md) for coverage, sizes and excerpts. ## License Licensed under either of -- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE)) -- MIT license ([LICENSE-MIT](LICENSE-MIT)) +- Apache License, Version 2.0 ([LICENSE-APACHE](https://github.com/ffalcinelli/astroceleste-engine/blob/main/LICENSE-APACHE)) +- MIT license ([LICENSE-MIT](https://github.com/ffalcinelli/astroceleste-engine/blob/main/LICENSE-MIT)) at your option. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..761e657 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,49 @@ +# Releasing + +Releases are trunk-based: there are no release branches or release PRs. Pushing a +`vX.Y.Z` tag on `main` publishes that version. One workspace version is shared by the +crate, the PyPI wheels and the npm package. + +1. Commits on `main` follow [Conventional Commits](https://www.conventionalcommits.org) + (`feat:`, `fix:`, `perf:`, `refactor:`, … and `!` for breaking changes). While the + version is 0.0.x, `feat`/`fix` bump the patch version and a breaking change bumps the + minor version. +2. Prepare the release on `main`: run `release-plz update` (or edit by hand) to bump the + workspace version, the bindings' `astroceleste-engine` dependency version and + `CHANGELOG.md`, then commit as `chore: release vX.Y.Z` and push. +3. Once CI is green, tag that commit and push the tag: + `git tag vX.Y.Z && git push origin vX.Y.Z`. +4. The `Release` workflow checks that the tag is on `main`, matches the version in + `Cargo.toml` and has a `CHANGELOG.md` entry. It then publishes the crate to crates.io, + creates the GitHub Release from the changelog entry, and publishes the Python wheels + (Linux x86_64/aarch64 glibc and musl, macOS universal2, Windows x64, sdist) to PyPI + and the WebAssembly package to npm. + +Protect `v*` tags with a tag ruleset so only maintainers can trigger a release. + +## Trusted publishing + +All three registries use Trusted Publishing (OIDC), so no long-lived token is stored. +crates.io and npm can only trust a workflow for a package that already exists, so the +first release uses short-lived tokens instead: + +1. Create the `release`, `pypi` and `npm` environments in the repository settings + (optionally with required reviewers). +2. **crates.io**: create an API token scoped to `publish-new` and `publish-update` for the + crate `astroceleste-engine`, with a short expiry, and store it as the + `CARGO_REGISTRY_TOKEN` secret of the `release` environment. +3. **npm**: create a granular access token with publish rights and a short expiry, and store + it as the `NPM_TOKEN` secret of the `npm` environment. +4. **PyPI**: add a *pending* trusted publisher for project `astroceleste-engine` (workflow + `release.yml`, environment `pypi`). No token is needed. +5. Push the `vX.Y.Z` tag for the version in `Cargo.toml`. +6. Once the packages exist, add the trusted publisher on crates.io (crate settings → + *Trusted Publishing*: this repository, workflow `release.yml`, environment `release`) + and on npm (package settings: workflow `release.yml`, environment `npm`). Then delete + both secrets and revoke the tokens. The workflow prefers OIDC whenever it is available. + +## Website + +The landing site and live demo (`site/`) are deployed to GitHub Pages by +`.github/workflows/pages.yml` on every push to `main` that touches the site or the crates, +independently of releases. The repository's Pages source must be set to *GitHub Actions*. diff --git a/crates/astroceleste-engine-py/README.md b/crates/astroceleste-engine-py/README.md index 97034c2..f28fc86 100644 --- a/crates/astroceleste-engine-py/README.md +++ b/crates/astroceleste-engine-py/README.md @@ -7,6 +7,9 @@ Python bindings for [astroceleste-engine](https://github.com/ffalcinelli/astroceleste-engine), astrological chart calculation on NASA JPL ephemerides, written in Rust. +[Website and live demo](https://ffalcinelli.github.io/astroceleste-engine/) · +[API guide](https://github.com/ffalcinelli/astroceleste-engine/blob/main/docs/api.md) + ```python from datetime import datetime, timezone import astroceleste_engine as ace diff --git a/crates/astroceleste-engine-wasm/README.md b/crates/astroceleste-engine-wasm/README.md index 44920ff..61bf844 100644 --- a/crates/astroceleste-engine-wasm/README.md +++ b/crates/astroceleste-engine-wasm/README.md @@ -8,6 +8,9 @@ Astrological chart calculation on NASA JPL ephemerides, compiled to WebAssembly Rust [astroceleste-engine](https://github.com/ffalcinelli/astroceleste-engine). Charts are computed entirely in the browser (or Node), identical to the Astroceleste server. +[Try the live demo](https://ffalcinelli.github.io/astroceleste-engine/) · +[API guide](https://github.com/ffalcinelli/astroceleste-engine/blob/main/docs/api.md) + ```js import init, { Engine, synastry, derivedChart } from "astroceleste-engine"; diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..48f375a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,19 @@ +# astroceleste-engine documentation + +- **[API guide](api.md)**: requests, options (house systems, zodiacs, ayanamsas, orbs), + the result shape and error codes, for Rust, Python and JavaScript. +- **[Ephemerides](ephemerides.md)**: the JPL kernels the engine reads, date coverage, + loading kernels from files or memory, and cutting smaller excerpts for the web and mobile. +- **[Accuracy](accuracy.md)**: how every number is verified against the reference + implementation, jplephem and Skyfield. +- **[Architecture](architecture.md)**: the crates, the calculation pipeline and where + each piece lives. + +Elsewhere: + +- Rust API reference: [docs.rs/astroceleste-engine](https://docs.rs/astroceleste-engine) +- Python bindings: [crates/astroceleste-engine-py](../crates/astroceleste-engine-py) +- WebAssembly bindings: [crates/astroceleste-engine-wasm](../crates/astroceleste-engine-wasm) +- Website and live demo: [ffalcinelli.github.io/astroceleste-engine](https://ffalcinelli.github.io/astroceleste-engine/) +- Contributing: [CONTRIBUTING.md](../CONTRIBUTING.md) · Security: [SECURITY.md](../SECURITY.md) + · Releasing: [RELEASING.md](../RELEASING.md) diff --git a/docs/accuracy.md b/docs/accuracy.md new file mode 100644 index 0000000..f137623 --- /dev/null +++ b/docs/accuracy.md @@ -0,0 +1,79 @@ +# Accuracy + +astroceleste-engine is a port of the Astroceleste reference implementation (Python on +Skyfield). The goal is not just "accurate", but **identical**: the server, the apps and the +browser must return the same chart, down to the last digit of every number and the order of +every key. Three independent references check that. + +## 1. Golden charts from the reference implementation + +`tests/fixtures/*.json` hold the exact output of the reference implementation: + +| Fixture | Cases | +|---|---| +| `natal.json` | 161 charts: all four house systems, tropical and sidereal with all 14 ayanamsas, latitudes up to ±75°, longitudes up to the date line, 1850–2149 | +| `horary.json` | 33 horary charts | +| `transits.json`, `synastry.json`, `derived.json` | 27 each | +| `errors.json` | dates before and after the kernel (1700, 2200) | + +The engine must reproduce them with: + +- floats within 1e-9 (relative to magnitude, absolute below 1); +- the same keys in the same order; +- the same number types: an integer in the reference is an integer here, and a float + stays a float, even when its value is whole. + +Only application-private keys (`degree_symbol`, `degree_symbols`) are left out of the +comparison. The Rust tests (`tests/golden_*.rs`), the Python binding tests and the +WebAssembly test (`tests/golden.mjs`) all check the same fixtures. These tests need the +full DE440s kernel and **skip** without it. + +## 2. Kernel states from jplephem + +`tests/fixtures/spk_reference.json` holds raw positions and velocities read with +[jplephem](https://github.com/brandon-rhodes/python-jplephem) from the committed one-year +excerpt `tests/data/de440s_2000.bsp`, including segment boundaries. They check the SPK +reader and Chebyshev evaluation on their own (`tests/spk_reference.rs`). Both files come +from `scripts/make_spk_fixtures.py`. + +## 3. Stage by stage against Skyfield + +`tests/fixtures/reduction.json` (`scripts/make_reduction_fixtures.py`) holds Skyfield 1.55 +values for every stage of the reduction, so a discrepancy can be traced to the stage that +introduces it (`tests/reduction.rs`): + +| Stage | Tolerance | +|---|---| +| ΔT, TDB − TT | 1e-9 s | +| sidereal time (GMST, GAST) | 1e-9 s | +| nutation angles | 1e-15 rad | +| precession / nutation matrices | 1e-14 | +| astrometric and apparent vectors (light-time, deflection, aberration) | 1e-11 relative | +| ecliptic longitude and latitude | 1e-5″ | + +The time scales, frames and apparent-position code are ported from Skyfield 1.55 (MIT, see +`NOTICE`), with the same constants and the same order of floating-point operations. + +## Python semantics + +The reference is Python, and Python's `%`, `//` and `round()` differ from Rust's `%` and +`f64::round` at boundaries: `-0.5 % 360` is `359.5` in Python but `-0.5` in Rust, and +Python rounds halves to even (`round(0.125, 2) == 0.12`). A planet at 29°59′59.99″ must +land in the same sign, and an orb must round to the same hundredth, so the engine uses its +own implementations (`pyfloat.rs`) wherever the reference relies on them. + +## Absolute accuracy + +Matching the reference also means inheriting its model: apparent geocentric positions of +date from JPL DE440s/DE441, with light-time, gravitational deflection by the Sun, Jupiter +and Saturn, aberration, IAU 2000A nutation and IAU 2006 precession, the same model as +Skyfield. The mean lunar nodes and mean Lilith follow the reference's formulas. For future +dates ΔT is only predicted (see [Ephemerides](ephemerides.md#time-scales)). + +## What CI runs + +On every push and pull request: formatting, clippy and rustdoc with warnings denied; the +test suite on Linux, macOS and Windows (stable) and on the MSRV; the golden and full-range +tests with the full DE440s kernel; the Python wheel with pytest; the WebAssembly package +with the Node golden test; builds for `wasm32-unknown-unknown`, Android and iOS; the +packaged crate; and cargo-deny. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..e578715 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,184 @@ +# API guide + +The engine has the same five entry points in every language. Requests and results have the +same shape everywhere: results serialize to the JSON of the Astroceleste API, with +snake_case keys. + +| Rust | Python | JavaScript | +|---|---|---| +| `calculate_chart` | `Engine.chart` | `engine.chart` | +| `calculate_horary_chart` | `Engine.horary` | `engine.horary` | +| `calculate_transit_chart` | `Engine.transit` | `engine.transit` | +| `calculate_synastry` | `synastry` | `synastry` | +| `calculate_derived_chart` | `derived_chart` | `derivedChart` | + +Every calculation needs JPL kernels (see [Ephemerides](ephemerides.md)); synastry and +derived charts work on already computed charts and do not. + +## A chart request + +| Field | Meaning | Default | +|---|---|---| +| moment | UTC instant: `UtcInstant` in Rust, `datetime` or ISO string in Python, ISO string `utc` in JS | required | +| latitude | geographic latitude in degrees, north positive | required | +| longitude | geographic longitude in degrees, east positive | required | +| house system | `P` Placidus, `W` Whole Sign, `E` Equal, `O` Porphyry | `P` | +| zodiac type | `tropical` or `sidereal` | `tropical` | +| ayanamsa | sidereal reference, see below (ignored for tropical charts) | `galcent_0sag` | +| orb settings | overrides merged over the default orbs, see below | none | + +Times are always UTC. Convert civil time (with its time zone and daylight saving time) to +UTC before calling the engine. Only the first letter of the house system is used, and +unknown codes fall back to Placidus. Zodiac types other than `sidereal` are tropical. + +```rust +use astroceleste_engine::ephemeris::{Kernel, KernelSet, Spk}; +use astroceleste_engine::{calculate_chart, ChartRequest, UtcInstant}; + +let mut kernels = KernelSet::new(); +kernels.push(Kernel::new("de440s.bsp", Spk::open("kernels/de440s.bsp")?)?); + +let mut request = ChartRequest::new(UtcInstant::parse("1987-05-17T14:30:00Z")?, 41.9, 12.5); +request.house_system = "W"; +request.zodiac_type = "sidereal"; +request.ayanamsa = "lahiri"; +let chart = calculate_chart(&kernels, &request)?; +``` + +```python +import astroceleste_engine as ace + +engine = ace.Engine(["kernels/de440s.bsp"]) +chart = engine.chart("1987-05-17T14:30:00Z", 41.9, 12.5, + house_system="W", zodiac_type="sidereal", ayanamsa="lahiri") +``` + +```js +const chart = engine.chart({ + utc: "1987-05-17T14:30:00Z", latitude: 41.9, longitude: 12.5, + house_system: "W", zodiac_type: "sidereal", ayanamsa: "lahiri", +}); +``` + +### Ayanamsas + +| Code | Name | +|---|---| +| `lahiri` | Lahiri (Chitra Paksha) | +| `fagan_bradley` | Fagan-Bradley | +| `krishnamurti` | Krishnamurti (KP) | +| `raman` | B.V. Raman | +| `true_citra` | True Chitra | +| `yukteshwar` | Sri Yukteswar | +| `deluce` | De Luce | +| `ushashashi` | Usha-Shashi | +| `jn_bhasin` | J.N. Bhasin | +| `aldebaran_15tau` | Aldebaran 15° Taurus | +| `hipparchos` | Hipparchos | +| `sassanian` | Sassanian | +| `galcent_0sag` | Galactic Center 0° Sagittarius (default) | +| `j2000` | J2000.0 Epoch | + +Codes are case-insensitive. Unknown codes use the default. + +### Orb settings + +Pass a JSON object (a `serde_json::Value` in Rust, a dict in Python, an object in JS). +Only these keys are read, and the orb tables are merged key by key over the defaults: + +```json +{ + "method": "moiety_hybrid", + "fixed_star_orb": 1.5, + "aspect_orbs": { "Conjunction": 8.0, "Sextile": 6.0, "Square": 7.0, "Trine": 8.0, + "Opposition": 8.0, "Semi-Sextile": 2.5, "Quincunx": 3.0 }, + "planet_orbs": { "Sun": 15.0, "Moon": 12.0, "Mercury": 7.0, "Venus": 7.0, "Mars": 8.0, + "Jupiter": 9.0, "Saturn": 9.0, "Uranus": 5.0, "Neptune": 5.0, + "Pluto": 5.0, "North Node": 5.0, "South Node": 5.0, "Chiron": 5.0, + "Lilith": 5.0, "Ascendant": 5.0, "Midheaven": 5.0 } +} +``` + +`method` selects how the maximum orb of an aspect between two bodies is computed: + +- `moiety_traditional`: the mean of the two planets' orbs (moieties); +- `moiety_aspect_weighted`: the moiety scaled by the aspect's orb relative to the + conjunction; +- `moiety_hybrid` (default): the moiety, capped by the aspect's orb; +- anything else: the aspect's orb alone. + +The settings in effect are echoed back in the chart's `orb_settings`. + +## The chart + +A chart is an object with these keys, in this order: + +| Key | Content | +|---|---| +| `house_system`, `zodiac_type` | echoed from the request | +| `ayanamsa`, `ayanamsa_name`, `ayanamsa_value`, `ayanamsa_formatted` | sidereal reference at the chart moment (`null` for tropical charts) | +| `precession_rate_arcsec_yr` | precession rate used for the fixed stars | +| `orb_settings` | the orb settings in effect | +| `planets` | Sun to Pluto, North and South Node, Chiron, Lilith, Ascendant, Midheaven | +| `unavailable_bodies` | expected bodies that could not be computed (Chiron outside 1849–2151) | +| `houses` | the twelve cusps | +| `aspects` | aspects between chart points, then fixed-star conjunctions | +| `fixed_stars` | fixed stars conjunct a chart point | +| `arabic_parts` | the lots (Fortune, Spirit, …) with the formula used | +| `temperament` | traditional temperament: scores, qualities and their breakdown | +| `lunar_status` | phase, illumination, age, speed, dignity and lunar mansion | + +A placement in `planets` (houses, fixed stars and lots use a similar shape): + +```json +{ "name": "Sun", "symbol": "☉", "sign": "Capricorn", "sign_symbol": "♑", + "degree": 10, "minute": 22, "ecliptic_longitude": 280.36891840215475, + "house": 10, "speed": 1.0194357508121357, "is_retrograde": false, + "symbolic_degree": 11 } +``` + +An aspect: + +```json +{ "body1": "Sun", "body2": "Moon", "aspect_type": "Sextile", "symbol": "⚹", + "angle": 60.0, "orb": 2.95, "max_orb": 6.0, "is_major": true, "is_applying": true } +``` + +`is_applying` is always `true` in chart aspects, as in the reference implementation, which +does not evaluate it there. Horary charts compute the Moon's applying and separating +aspects from its speed (`horary_data`). + +Longitudes are apparent geocentric ecliptic longitudes of date, in degrees (sidereal charts +subtract the ayanamsa). In Rust, the fields are typed: see [`Chart`] and [`Placement`] on +docs.rs. + +[`Chart`]: https://docs.rs/astroceleste-engine/latest/astroceleste_engine/struct.Chart.html +[`Placement`]: https://docs.rs/astroceleste-engine/latest/astroceleste_engine/struct.Placement.html + +## Other entry points + +- **Horary**: the chart for the moment of the question, plus `horary_data` with the + planetary day and hour (computed from the actual sunrise and sunset at the location), + significators, the Moon's applying and separating aspects, void of course and the + considerations before judgment. +- **Transit**: the sky at a moment and place, with the aspects it forms to a natal chart's + `planets` (`cross_aspects`). +- **Synastry**: cross-aspects from chart B's `planets` to chart A's `planets`. +- **Derived chart**: a stored chart turned so that radix house *n* (1–12) becomes the first + house, with the meaning of each derived house. Fields that are not turned are kept. + +## Errors + +Each failure has a stable code, shared with the Astroceleste HTTP API: + +| Code | Cause | Rust | Python | JavaScript | +|---|---|---|---|---| +| `ephemeris_out_of_range` | no loaded kernel covers the date | `EngineError::OutOfRange` | `EphemerisRangeError` | `Error` with `code` | +| `ephemeris_error` | a kernel could not be read or evaluated | `EngineError::Ephemeris` | `EngineError` | `Error` with `code` | +| `invalid_input` | malformed request (bad date, non-numeric orb, …) | `EngineError::InvalidInput` | `ValueError` | `Error` with `code` | +| `invalid_kernel` | a kernel file is not a valid SPK file (JS `addKernel`) | `SpkError` when loading | `EngineError` | `Error` with `code` | + +In Rust, `EngineError::code()` returns the code. Dates outside the loaded kernels are +always reported as errors and never approximated. Check them upfront with `supports(jd)` / +`coverage` in Python and JavaScript, or `KernelSet::for_jd` / `KernelSet::coverage` in +Rust. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..01823b8 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,82 @@ +# Architecture + +## Crates + +| Crate | Role | Published to | +|---|---|---| +| `crates/astroceleste-engine` | the core library: everything that computes | crates.io | +| `crates/astroceleste-engine-py` | Python bindings (PyO3, built with maturin) | PyPI | +| `crates/astroceleste-engine-wasm` | WebAssembly bindings (wasm-bindgen, built with wasm-pack) | npm | + +The bindings are thin. They build a `KernelSet`, call the core entry points and pass results +across as JSON (`serde_json` with `preserve_order` and `float_roundtrip`). Floats stay exact, +and the shape a binding returns is the Rust `Serialize` output, which is also the JSON of the +Astroceleste API. The one crate shares a single version with both packages. + +The core has only two dependencies (`serde`, `serde_json`), no C code and no +platform-specific I/O, so it builds for servers, desktop, `wasm32-unknown-unknown`, Android +and iOS. + +## The pipeline + +```text + UtcInstant, lat, lon, options (ChartRequest) + │ + time.rs ─────── UT1 / TT / TDB, ΔT (embedded data/deltat.bin) + │ + ephemeris/ ──── spk.rs read JPL SPK kernels (file or bytes), Chebyshev states + kernels.rs KernelSet: first kernel covering the date wins, else OutOfRange + observe.rs apparent geocentric positions: light-time, deflection, aberration + │ + frames/ ─────── precession, nutation, sidereal time → true ecliptic of date + │ + planets.rs ──── planets, mean nodes, Chiron (chiron.rs, data/chiron.bin), Lilith + │ + houses.rs ───── cusps, Ascendant, Midheaven zodiac.rs ── signs, ayanamsas, sidereal + │ + aspects.rs · fixed_stars.rs · lots.rs · temperament.rs · lunar.rs + │ + chart.rs ────── calculate_chart → Chart + │ + horary.rs ───── + planetary hours (almanac.rs sunrise/sunset), significators, strictures + derived.rs ──── transits, synastry, derived charts on top of calculate_chart +``` + +Supporting modules: + +- `instant.rs`: UTC instants with microsecond resolution and Python `datetime` semantics + (ISO formatting, float-day arithmetic). +- `pyfloat.rs`: Python `%`, `//`, `divmod` and `round()`. See [Accuracy](accuracy.md). +- `symbolic.rs`: symbolic (1–30) degree numbering. +- `catalog/`: static tables (fixed stars, lunar mansions, derived-house meanings). This is + generated code, excluded from rustfmt and clippy. +- `constants.rs`: the constants used by Skyfield 1.55 and the reference implementation. + +Each module's doc comment names the reference function it ports (for example +`charts/calc/aspects.py` or `SkyfieldEngine.calculate_houses`), so the two implementations +can be compared side by side. + +## Generated data + +| File | Generated by | +|---|---| +| `src/data/deltat.bin` (ΔT), `src/data/chiron.bin` (Chiron longitudes), `src/frames/nutation_data.rs` (IAU 2000A terms) | `scripts/gen_tables.py` | +| `tests/data/de440s_2000.bsp`, `tests/fixtures/spk_reference.json` | `scripts/make_spk_fixtures.py` (jplephem) | +| `tests/fixtures/reduction.json` | `scripts/make_reduction_fixtures.py` (Skyfield) | +| `tests/fixtures/{natal,horary,…}.json` | the reference implementation | + +None of these is edited by hand. + +## Public API + +The API is curated. Every module is private except `ephemeris`, and `lib.rs` re-exports the +entry points, `ChartRequest`, `EngineError`, `UtcInstant` and every type reachable from a +result. `time`, `frames` and `ephemeris::observe` are public but hidden from the docs, only +so that the stage-by-stage test (`tests/reduction.rs`) can reach them. They are not part of +the API. + +## Website + +`site/` holds the static landing page and the live demo. `scripts/build-site.sh` assembles +it into `target/site/` with the WebAssembly package (`wasm-pack --target web`) and a +1950–2050 DE440s excerpt, and `.github/workflows/pages.yml` deploys it to GitHub Pages. diff --git a/docs/ephemerides.md b/docs/ephemerides.md new file mode 100644 index 0000000..fc4e244 --- /dev/null +++ b/docs/ephemerides.md @@ -0,0 +1,98 @@ +# Ephemerides + +The engine takes planetary positions from NASA JPL development ephemerides, distributed as +SPK kernels (`.bsp` files). It reads them with its own pure-Rust reader (NAIF DAF container, +segment types 2 and 3), with no CSPICE and no C code. + +## Which kernel + +| Kernel | Coverage | Size | Use | +|---|---|---|---| +| `de440s.bsp` | 1849–2150 | 32 MB | the default: modern charts | +| `de441_part-1.bsp` | 13200 BC–1969 | 1.4 GB | historical charts | +| `de441_part-2.bsp` | 1969–17191 | 1.6 GB | far-future charts | + +Download them from , or run +`scripts/fetch-kernels.sh` (DE440s; `--de441` also fetches DE441 part 1) in a checkout. +DE440 and DE441 share their dynamical model and agree closely over their common span, so a +chart does not change noticeably when it is computed from a different kernel. + +Chiron comes from an embedded table covering 1849–2151. Outside it, Chiron is listed in +`unavailable_bodies` instead of being extrapolated. + +## Loading kernels + +A `KernelSet` holds kernels **in preference order**: each date is computed with the first +kernel that covers it. A kernel's coverage is the span that *all* of its segments cover, +because a chart needs every body. + +```rust +use astroceleste_engine::ephemeris::{Kernel, KernelSet, Spk}; + +let mut kernels = KernelSet::new(); +kernels.push(Kernel::new("de440s.bsp", Spk::open("kernels/de440s.bsp")?)?); // preferred +kernels.push(Kernel::new("de441_part-1.bsp", Spk::open("kernels/de441_part-1.bsp")?)?); +``` + +The core does no platform-specific I/O apart from the convenience `Spk::open`. In the +browser or on mobile, load the bytes however the platform allows and use +`Spk::from_bytes(bytes)`: + +```js +const engine = new Engine(); +const bytes = new Uint8Array(await (await fetch("/ephemeris/de440s.bsp")).arrayBuffer()); +engine.addKernel("de440s.bsp", bytes); +engine.coverage; // [first JD, last JD] +engine.supports(julianDay("1987-05-17T14:30:00Z")) // true +``` + +In Python, `Engine(["de440s.bsp", "de441_part-1.bsp"])` takes paths in preference order. + +## Out of range is an error + +A date that no loaded kernel covers fails with `ephemeris_out_of_range` +(`EngineError::OutOfRange`, carrying the requested JD and the loaded coverage). The engine +never extrapolates or falls back to an approximate theory. You can check a date before +calculating it with `KernelSet::for_jd` or `supports(jd)`. + +## Smaller kernels: excerpts + +For the web and for mobile apps, 32 MB is often too much. An excerpt keeps only the records +covering a date range. Positions inside that range are **bit-for-bit identical** to the +full kernel's, because the same Chebyshev records are copied, not refitted. + +| Range (de440s) | Size | +|---|---| +| 1849–2150 (full) | 32.7 MB | +| 1900–2100 | 21.8 MB | +| 1950–2050 | 10.9 MB | +| 2000–2050 | 5.4 MB | + +Kernel data is dense floating point and barely compresses (gzip saves about 5%). + +From a checkout (dates are TDB Julian days): + +```bash +cargo run --release -p astroceleste-engine --example excerpt -- \ + kernels/de440s.bsp de440s-1950-2050.bsp 2433282.5 2469807.5 +``` + +In code, `Spk::excerpt(start_jd, end_jd)` returns the bytes of a new kernel, and the +WebAssembly package exposes the same function: + +```js +import { excerptKernel } from "astroceleste-engine"; +const small = excerptKernel(fullKernelBytes, 2433282.5, 2469807.5); // 1950-2050 +``` + +The [live demo](https://ffalcinelli.github.io/astroceleste-engine/) runs on the 1950–2050 +excerpt. + +## Time scales + +Chart moments are UTC. The engine converts them to UT1, TT and TDB with ΔT from the daily +IERS table bundled with Skyfield 1.55 (1973 onwards, about a year of predictions), and, +outside it, from the Morrison–Stephenson–Hohenkerk splines joined to the long-term +parabola. For future dates beyond the predictions ΔT is extrapolated. An error in ΔT +shifts the bodies along their orbits, the Moon most (about 0.55″ per second of ΔT). This +is inherent to any calculation of future charts. From 0e30760e8333b72ea464b7c392541624842ce1c9 Mon Sep 17 00:00:00 2001 From: ffalcinelli Date: Tue, 22 Sep 2026 22:19:06 +0200 Subject: [PATCH 4/6] docs: expand crate docs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011a5A76BzYdi7yn5vUancjY --- .../astroceleste-engine/src/ephemeris/mod.rs | 9 +++ crates/astroceleste-engine/src/lib.rs | 81 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/crates/astroceleste-engine/src/ephemeris/mod.rs b/crates/astroceleste-engine/src/ephemeris/mod.rs index 4624bc0..c234a81 100644 --- a/crates/astroceleste-engine/src/ephemeris/mod.rs +++ b/crates/astroceleste-engine/src/ephemeris/mod.rs @@ -1,5 +1,14 @@ //! Ephemeris sources. Everything above this layer (houses, aspects, lots, stars, …) is //! independent of where planetary positions come from. +//! +//! - [`Spk`] reads a JPL SPK kernel (segment types 2 and 3) from a file ([`Spk::open`]) or +//! from memory ([`Spk::from_bytes`]), and can write an excerpt of it ([`Spk::excerpt`]). +//! - [`Kernel`] names a loaded kernel and computes the span that all its segments cover. +//! - [`KernelSet`] holds kernels in preference order: the first one covering a date is +//! used, and a date that none covers is an error. +//! +//! Kernels: . `de440s.bsp` (1849–2150, +//! 32 MB) is the one the engine is validated against. mod kernels; #[doc(hidden)] // exposed for the reduction tests; not part of the API diff --git a/crates/astroceleste-engine/src/lib.rs b/crates/astroceleste-engine/src/lib.rs index 9249471..bb8881b 100644 --- a/crates/astroceleste-engine/src/lib.rs +++ b/crates/astroceleste-engine/src/lib.rs @@ -24,6 +24,87 @@ //! # Ok(()) //! # } //! ``` +//! +//! # Entry points +//! +//! | Function | Result | +//! |---|---| +//! | [`calculate_chart`] | a natal or event [`Chart`]: planets, houses, aspects, fixed stars, lots, temperament, lunar status | +//! | [`calculate_horary_chart`] | the chart plus [`HoraryData`]: planetary hours, significators, the Moon's aspects, strictures | +//! | [`calculate_transit_chart`] | the sky at a moment and place, with its [`CrossAspect`]s to natal planets | +//! | [`calculate_synastry`] | cross-aspects between two charts' planets (no kernel needed) | +//! | [`calculate_derived_chart`] | a stored chart turned to a new first house (no kernel needed) | +//! +//! Every result implements [`serde::Serialize`] and serializes to the JSON of the +//! Astroceleste API, with the same key order and the same integer vs float types. The +//! Python and WebAssembly bindings return exactly that JSON. +//! +//! # Loading kernels +//! +//! Positions come from NASA JPL SPK kernels (`de440s.bsp` covers 1849–2150; DE441 covers +//! 13200 BC–17191). A [`KernelSet`](ephemeris::KernelSet) holds them in preference order, +//! and each date is computed with the first kernel that covers it. [`Spk::open`] reads a +//! file. Where there is no file system (WebAssembly) or the kernel is bundled with an app, +//! load the bytes and use [`Spk::from_bytes`]. [`Spk::excerpt`] cuts a smaller kernel for a +//! date range, with positions unchanged inside it (1950–2050 of DE440s is about 11 MB). +//! +//! ```no_run +//! use astroceleste_engine::ephemeris::{Kernel, KernelSet, Spk}; +//! +//! # fn download(_: &str) -> Vec { Vec::new() } +//! # fn main() -> Result<(), Box> { +//! let bytes: Vec = download("https://example.com/de440s-1950-2050.bsp"); +//! let mut kernels = KernelSet::new(); +//! kernels.push(Kernel::new("de440s-1950-2050.bsp", Spk::from_bytes(bytes)?)?); +//! assert!(kernels.coverage().is_some()); +//! # Ok(()) +//! # } +//! ``` +//! +//! # Errors +//! +//! Calculations return [`EngineError`], whose [`code`](EngineError::code) is the stable +//! error code of the Astroceleste API. A date that no loaded kernel covers is +//! [`EngineError::OutOfRange`]: the engine never extrapolates or approximates. +//! +//! ```no_run +//! # use astroceleste_engine::ephemeris::KernelSet; +//! # use astroceleste_engine::{calculate_chart, ChartRequest, EngineError, UtcInstant}; +//! # fn main() -> Result<(), Box> { +//! # let kernels = KernelSet::new(); +//! let request = ChartRequest::new(UtcInstant::parse("1700-01-01T00:00:00Z")?, 41.9, 12.5); +//! match calculate_chart(&kernels, &request) { +//! Ok(chart) => println!("{} planets", chart.planets.len()), +//! Err(EngineError::OutOfRange { jd, coverage }) => { +//! eprintln!("JD {jd} is outside the loaded kernels ({coverage:?})") +//! } +//! Err(err) => eprintln!("{}: {err}", err.code()), +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! # Platforms +//! +//! The crate is pure Rust with no C code, and depends only on `serde` and `serde_json`. +//! It builds for servers and desktops, `wasm32-unknown-unknown`, Android and iOS. Python +//! (`pip install astroceleste-engine`) and JavaScript (`npm install astroceleste-engine`) +//! bindings are published from the same repository. +//! +//! # Further reading +//! +//! - [API guide](https://github.com/ffalcinelli/astroceleste-engine/blob/main/docs/api.md): +//! request options (house systems, ayanamsas, orb settings) and the chart JSON +//! - [Ephemerides](https://github.com/ffalcinelli/astroceleste-engine/blob/main/docs/ephemerides.md): +//! kernels, coverage and excerpts +//! - [Accuracy](https://github.com/ffalcinelli/astroceleste-engine/blob/main/docs/accuracy.md): +//! how results are verified against the reference implementation +//! - [Live demo](https://ffalcinelli.github.io/astroceleste-engine/): this crate compiled +//! to WebAssembly, computing charts in your browser +//! +//! [`Spk::open`]: ephemeris::Spk::open +//! [`Spk::from_bytes`]: ephemeris::Spk::from_bytes +//! [`Spk::excerpt`]: ephemeris::Spk::excerpt mod almanac; mod aspects; From 2bb5c51d37c5b4755c01fcaff0bf06994ee4b49d Mon Sep 17 00:00:00 2001 From: ffalcinelli Date: Tue, 22 Sep 2026 22:19:06 +0200 Subject: [PATCH 5/6] docs: add landing site with live demo Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011a5A76BzYdi7yn5vUancjY --- scripts/build-site.sh | 29 ++++ site/demo.js | 356 ++++++++++++++++++++++++++++++++++++++++++ site/favicon.svg | 6 + site/index.html | 302 +++++++++++++++++++++++++++++++++++ site/style.css | 262 +++++++++++++++++++++++++++++++ 5 files changed, 955 insertions(+) create mode 100755 scripts/build-site.sh create mode 100644 site/demo.js create mode 100644 site/favicon.svg create mode 100644 site/index.html create mode 100644 site/style.css diff --git a/scripts/build-site.sh b/scripts/build-site.sh new file mode 100755 index 0000000..e9e0884 --- /dev/null +++ b/scripts/build-site.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Build the landing site with its live demo into target/site (deployed by pages.yml): +# site/* static pages +# pkg/ the WebAssembly package (wasm-pack --target web) +# ephemeris/de440s-1950-2050.bsp a DE440s excerpt for the demo (~11 MB) +# Needs wasm-pack and the wasm32-unknown-unknown target. Preview locally with +# python3 -m http.server -d target/site +set -euo pipefail +cd "$(dirname "$0")/.." +OUT="$PWD/target/site" +# 1950-01-01 to 2050-01-01, TDB Julian dates. Keep in sync with site/demo.js. +START_JD=2433282.5 +END_JD=2469807.5 + +rm -rf "$OUT" +mkdir -p "$OUT/ephemeris" +cp -R site/. "$OUT/" + +scripts/fetch-kernels.sh >/dev/null +cargo run --release --quiet -p astroceleste-engine --example excerpt -- \ + kernels/de440s.bsp "$OUT/ephemeris/de440s-1950-2050.bsp" "$START_JD" "$END_JD" + +wasm-pack build --release --target web --out-dir "$OUT/pkg" crates/astroceleste-engine-wasm +# Only the module and its bindings are served. +rm -f "$OUT/pkg/.gitignore" "$OUT/pkg/package.json" "$OUT/pkg/README.md" \ + "$OUT/pkg"/LICENSE-* "$OUT/pkg/NOTICE" + +du -h "$OUT"/ephemeris/*.bsp "$OUT"/pkg/*.wasm +du -sh "$OUT" diff --git a/site/demo.js b/site/demo.js new file mode 100644 index 0000000..5e19d79 --- /dev/null +++ b/site/demo.js @@ -0,0 +1,356 @@ +// Live demo: astroceleste-engine compiled to WebAssembly, computing charts in the browser. +// pkg/ and ephemeris/ are added by scripts/build-site.sh. +import init, { Engine, julianDay } from "./pkg/astroceleste_engine_wasm.js"; + +const KERNEL_URL = "ephemeris/de440s-1950-2050.bsp"; +const KERNEL_NAME = "de440s-1950-2050.bsp"; +const COVERAGE_TEXT = "1950–2050"; // keep in sync with scripts/build-site.sh + +const SIGNS = [ + ["Aries", "♈", "fire"], ["Taurus", "♉", "earth"], ["Gemini", "♊", "air"], + ["Cancer", "♋", "water"], ["Leo", "♌", "fire"], ["Virgo", "♍", "earth"], + ["Libra", "♎", "air"], ["Scorpio", "♏", "water"], ["Sagittarius", "♐", "fire"], + ["Capricorn", "♑", "earth"], ["Aquarius", "♒", "air"], ["Pisces", "♓", "water"], +]; +const ASPECT_CLASS = { + Conjunction: "neutral", Sextile: "harmonious", Trine: "harmonious", + Square: "hard", Opposition: "hard", "Semi-Sextile": "harmonious", Quincunx: "hard", +}; +const PRESETS = { + now: () => { + const iso = new Date().toISOString(); + return { date: iso.slice(0, 10), time: iso.slice(11, 19), latitude: 51.4769, longitude: 0 }; + }, + rome: () => ({ date: "1987-05-17", time: "14:30:00", latitude: 41.9, longitude: 12.5 }), + moon: () => ({ date: "1969-07-20", time: "20:17:40", latitude: 29.5597, longitude: -95.0908 }), + y2k: () => ({ date: "2000-01-01", time: "12:00:00", latitude: 51.4769, longitude: 0 }), +}; + +const $ = (id) => document.getElementById(id); +const form = $("chart-form"); +const statusEl = $("status"); +const submit = form.querySelector("button[type=submit]"); +// Force text (not emoji) presentation of zodiac and planet glyphs. +const text = (glyph) => (/^[A-Z]+$/.test(glyph) ? glyph : `${glyph}\uFE0E`); +const escapeHtml = (s) => + String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); +const dm = (p) => `${p.degree}°${String(p.minute).padStart(2, "0")}′`; + +let enginePromise; +let lastJson = ""; + +function setStatus(html, isError = false) { + statusEl.innerHTML = html; + statusEl.classList.toggle("error", isError); +} + +async function fetchWithProgress(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`could not download the ephemeris (HTTP ${res.status})`); + const total = Number(res.headers.get("content-length")) || 0; + if (!res.body) return new Uint8Array(await res.arrayBuffer()); + const reader = res.body.getReader(); + const chunks = []; + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.length; + const mb = (received / 1e6).toFixed(1); + const pct = total ? Math.min(100, (received / total) * 100) : 0; + setStatus( + `Downloading the JPL ephemeris… ${mb} MB` + + (total ? ` of ${(total / 1e6).toFixed(1)} MB` : "") + + ``, + ); + } + const bytes = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return bytes; +} + +function loadEngine() { + enginePromise ??= (async () => { + setStatus("Loading the engine…"); + await init(); + const bytes = await fetchWithProgress(KERNEL_URL); + const engine = new Engine(); + engine.addKernel(KERNEL_NAME, bytes); + return engine; + })().catch((err) => { + enginePromise = undefined; // allow a retry + throw err; + }); + return enginePromise; +} + +function readForm() { + const data = new FormData(form); + const time = data.get("time") || "00:00"; + return { + utc: `${data.get("date")}T${time.length === 5 ? `${time}:00` : time}Z`, + latitude: Number(data.get("latitude")), + longitude: Number(data.get("longitude")), + house_system: data.get("house_system"), + zodiac_type: data.get("zodiac_type"), + ayanamsa: form.elements.ayanamsa.value, + }; +} + +function fillForm(values) { + for (const [key, value] of Object.entries(values)) { + if (form.elements[key] !== undefined) form.elements[key].value = value; + } + syncAyanamsa(); +} + +function syncAyanamsa() { + form.elements.ayanamsa.disabled = form.elements.zodiac_type.value !== "sidereal"; +} + +async function compute() { + if (!form.reportValidity()) return; + const request = readForm(); + submit.disabled = true; + try { + const engine = await loadEngine(); + if (!engine.supports(julianDay(request.utc))) { + setStatus( + `This demo ships an ephemeris excerpt for ${COVERAGE_TEXT} only. The engine itself ` + + `covers 1849–2150 with DE440s, and 13200 BC–17191 AD with DE441.`, + true, + ); + return; + } + const start = performance.now(); + const chart = engine.chart(request); + const ms = performance.now() - start; + render(chart, request); + const took = ms < 1 ? "under 1 ms" : ms < 10 ? `${ms.toFixed(1)} ms` : `${Math.round(ms)} ms`; + setStatus(`Computed in ${took}, on your device.`); + const params = new URLSearchParams({ + utc: request.utc, lat: request.latitude, lon: request.longitude, + houses: request.house_system, zodiac: request.zodiac_type, + }); + if (request.zodiac_type === "sidereal") params.set("ayanamsa", request.ayanamsa); + history.replaceState(null, "", `?${params}#demo`); + } catch (err) { + const code = err && err.code ? ` (${err.code})` : ""; + setStatus(`${escapeHtml(err && err.message ? err.message : err)}${code}`, true); + } finally { + submit.disabled = false; + } +} + +// ---------------------------------------------------------------------------- rendering + +function render(chart, request) { + $("result").hidden = false; + renderWheel(chart); + renderSummary(chart); + renderTables(chart); + lastJson = JSON.stringify(chart, null, 2); + $("json").textContent = lastJson; + $("wheel").setAttribute( + "aria-label", + `Chart wheel for ${request.utc} at latitude ${request.latitude}, longitude ${request.longitude}`, + ); +} + +function renderSummary(chart) { + const find = (name) => chart.planets.find((p) => p.name === name); + const rows = []; + const sun = find("Sun"); + const moon = find("Moon"); + const asc = find("Ascendant"); + if (sun) rows.push(["Sun", `${dm(sun)} ${sun.sign}`]); + if (moon) { + const phase = chart.lunar_status && chart.lunar_status.phase_name; + rows.push([ + "Moon", + `${dm(moon)} ${moon.sign}` + + (phase ? `
${phase}, ${chart.lunar_status.illumination_percentage}% lit` : ""), + ]); + } + if (asc) rows.push(["Ascendant", `${dm(asc)} ${asc.sign}`]); + if (chart.temperament && chart.temperament.primary_temperament) { + rows.push(["Temperament", `${chart.temperament.primary_temperament} – ${chart.temperament.secondary_temperament}`]); + } + rows.push([ + "Zodiac", + chart.zodiac_type === "sidereal" + ? `Sidereal, ${chart.ayanamsa_name}
ayanamsa ${chart.ayanamsa_formatted}` + : "Tropical", + ]); + $("summary").innerHTML = rows + .map(([k, v]) => `
${k}
${v}
`) + .join(""); +} + +function table(el, head, rows) { + el.innerHTML = + `${head.map((h) => `${h}`).join("")}` + + `${rows.map((r) => `${r.join("")}`).join("")}`; +} + +function renderTables(chart) { + const glyphOf = new Map(chart.planets.map((p) => [p.name, p.symbol])); + table($("planets"), ["", "Point", "Position", "House", "Speed °/day"], chart.planets.map((p) => [ + `${escapeHtml(text(p.symbol))}`, + `${escapeHtml(p.name)}${p.is_retrograde ? ' ℞' : ""}`, + `${dm(p)} ${text(p.sign_symbol)} ${p.sign}`, + `${p.house}`, + `${p.speed ? p.speed.toFixed(3) : "–"}`, + ])); + table($("houses"), ["House", "Cusp"], chart.houses.map((h) => [ + `${h.house_number}`, + `${dm(h)} ${text(h.sign_symbol)} ${h.sign}`, + ])); + const label = (name) => + glyphOf.has(name) ? `${escapeHtml(text(glyphOf.get(name)))} ${escapeHtml(name)}` : `★ ${escapeHtml(name)}`; + table($("aspects"), ["", "Aspect", "", "Orb"], chart.aspects.map((a) => [ + `${label(a.body1)}`, + `${escapeHtml(text(a.symbol))} ${escapeHtml(a.aspect_type)}`, + `${label(a.body2)}`, + `${a.orb.toFixed(2)}°`, + ])); +} + +const SVG = "http://www.w3.org/2000/svg"; +const C = 200; +const R = { outer: 195, zodiac: 165, tick: 158, glyph: 141, houseNum: 106, inner: 95 }; + +function renderWheel(chart) { + const svg = $("wheel"); + svg.replaceChildren(); + const byName = new Map(chart.planets.map((p) => [p.name, p])); + const asc = (byName.get("Ascendant") || chart.houses[0]).ecliptic_longitude; + // Ascendant on the left, longitudes increasing counterclockwise. + const xy = (lon, r) => { + const a = ((180 + lon - asc) * Math.PI) / 180; + return [C + r * Math.cos(a), C - r * Math.sin(a)]; + }; + const add = (tag, attrs, content) => { + const el = document.createElementNS(SVG, tag); + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); + if (content !== undefined) el.textContent = content; + svg.append(el); + return el; + }; + const line = (lon1, r1, lon2, r2, cls) => { + const [x1, y1] = xy(lon1, r1); + const [x2, y2] = xy(lon2, r2); + return add("line", { x1, y1, x2, y2, class: cls }); + }; + + add("circle", { cx: C, cy: C, r: R.outer, class: "w-zodiac" }); + add("circle", { cx: C, cy: C, r: R.zodiac, class: "w-ring" }); + add("circle", { cx: C, cy: C, r: R.inner, class: "w-ring" }); + SIGNS.forEach(([name, glyph, element], i) => { + line(i * 30, R.zodiac, i * 30, R.outer, "w-sign-line"); + const [x, y] = xy(i * 30 + 15, (R.zodiac + R.outer) / 2); + add("text", { x, y, class: `w-sign ${element}` }, text(glyph)).append( + Object.assign(document.createElementNS(SVG, "title"), { textContent: name }), + ); + }); + + chart.houses.forEach((h, i) => { + const next = chart.houses[(i + 1) % 12].ecliptic_longitude; + const axis = h.house_number === 1 || h.house_number === 4 || h.house_number === 7 || h.house_number === 10; + line(h.ecliptic_longitude, R.inner, h.ecliptic_longitude, R.zodiac, axis ? "w-axis" : "w-cusp"); + const span = (next - h.ecliptic_longitude + 360) % 360; + const [x, y] = xy(h.ecliptic_longitude + span / 2, R.houseNum); + add("text", { x, y, class: "w-house-num" }, h.house_number); + }); + + const points = chart.planets.filter((p) => typeof p.ecliptic_longitude === "number"); + for (const a of chart.aspects) { + const p1 = byName.get(a.body1); + const p2 = byName.get(a.body2); + const cls = ASPECT_CLASS[a.aspect_type]; + if (!p1 || !p2 || cls === "neutral" || !cls) continue; + const minor = a.is_major === false ? " minor" : ""; + line(p1.ecliptic_longitude, R.inner, p2.ecliptic_longitude, R.inner, `w-aspect ${cls}${minor}`); + } + + const shown = spread(points.map((p) => p.ecliptic_longitude), 8); + points.forEach((p, i) => { + line(p.ecliptic_longitude, R.zodiac, p.ecliptic_longitude, R.tick, "w-tick"); + line(p.ecliptic_longitude, R.tick, shown[i], R.glyph + 9, "w-leader"); + const [x, y] = xy(shown[i], R.glyph); + const angle = /^[A-Z]+$/.test(p.symbol); + const el = add("text", { x, y, class: `w-planet${angle ? " angle" : ""}` }, text(p.symbol)); + el.append(Object.assign(document.createElementNS(SVG, "title"), { + textContent: `${p.name} ${dm(p)} ${p.sign}${p.is_retrograde ? " (retrograde)" : ""}`, + })); + }); +} + +/** Display longitudes at least `minSep` degrees apart, as close as possible to the real ones. */ +function spread(lons, minSep) { + const norm = (a) => ((a % 360) + 360) % 360; + const pos = lons.slice(); + if (pos.length < 2) return pos; + for (let iter = 0; iter < 200; iter++) { + let moved = false; + const order = [...pos.keys()].sort((a, b) => pos[a] - pos[b]); + for (let k = 0; k < order.length; k++) { + const a = order[k]; + const b = order[(k + 1) % order.length]; + const gap = norm(pos[b] - pos[a]); + if (gap < minSep - 1e-6) { + const push = (minSep - gap) / 2; + pos[a] = norm(pos[a] - push); + pos[b] = norm(pos[b] + push); + moved = true; + } + } + if (!moved) break; + } + return pos; +} + +// ---------------------------------------------------------------------------- wiring + +form.addEventListener("submit", (e) => { + e.preventDefault(); + compute(); +}); +form.elements.zodiac_type.addEventListener("change", syncAyanamsa); +for (const button of form.querySelectorAll("[data-preset]")) { + button.addEventListener("click", () => { + fillForm(PRESETS[button.dataset.preset]()); + compute(); + }); +} +$("copy-json").addEventListener("click", async (e) => { + try { + await navigator.clipboard.writeText(lastJson); + e.target.textContent = "Copied"; + } catch { + e.target.textContent = "Copy failed"; + } + setTimeout(() => (e.target.textContent = "Copy"), 1500); +}); + +// A shared link (?utc=…&lat=…) computes its chart on load. +const params = new URLSearchParams(location.search); +const utc = params.get("utc"); +if (utc && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?Z?$/.test(utc)) { + fillForm({ + date: utc.slice(0, 10), + time: utc.slice(11, 19).replace(/Z$/, ""), + latitude: params.get("lat") ?? form.elements.latitude.value, + longitude: params.get("lon") ?? form.elements.longitude.value, + house_system: params.get("houses") ?? "P", + zodiac_type: params.get("zodiac") ?? "tropical", + ayanamsa: params.get("ayanamsa") ?? "galcent_0sag", + }); + compute(); +} +syncAyanamsa(); diff --git a/site/favicon.svg b/site/favicon.svg new file mode 100644 index 0000000..bab4ab7 --- /dev/null +++ b/site/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..ebbea46 --- /dev/null +++ b/site/index.html @@ -0,0 +1,302 @@ + + + + + + astroceleste-engine + + + + + + + + + + +
+ +
+ +
+
+
+

Pure Rust · NASA JPL ephemerides · Python & WebAssembly

+

The same chart,
on every platform.

+

+ astroceleste-engine computes complete astrological charts from JPL ephemerides. + One core runs on the server, in desktop and mobile apps and in the browser, and every + number matches its reference implementation to within 10−9. +

+ +
    +
  • Rustcargo add astroceleste-engine
  • +
  • Pythonpip install astroceleste-engine
  • +
  • JSnpm install astroceleste-engine
  • +
+

Experimental (0.0.x): the API may change in any release. Pin an exact version.

+
+
+ +
+
+

Live demo

+

+ This page runs the engine compiled to WebAssembly. Nothing is sent to a server: the + chart is computed on your device from an 11 MB excerpt of JPL DE440s covering + 1950–2050, which is downloaded the first time you compute. +

+ +
+
+
+ Examples + + + + +
+ +
+ + + + + + + +
+

Times are UTC: convert local time, including daylight saving time, first.

+ +

+
+ + +
+
+
+ +
+
+

One engine, three languages

+

+ The Rust crate, the Python wheel and the npm package share one core and one version. + Their results are the same JSON, down to the key order. +

+
+
+
Rust
+
use astroceleste_engine::ephemeris::{Kernel, KernelSet, Spk};
+use astroceleste_engine::{calculate_chart, ChartRequest, UtcInstant};
+
+let mut kernels = KernelSet::new();
+kernels.push(Kernel::new("de440s.bsp",
+    Spk::open("kernels/de440s.bsp")?)?);
+
+let request = ChartRequest::new(
+    UtcInstant::parse("1987-05-17T14:30:00Z")?, 41.9, 12.5);
+let chart = calculate_chart(&kernels, &request)?;
+
+
+
Python
+
import astroceleste_engine as ace
+
+engine = ace.Engine(["de440s.bsp"])
+chart = engine.chart(
+    "1987-05-17T14:30:00Z", 41.9, 12.5,
+    house_system="P",
+    zodiac_type="sidereal",
+    ayanamsa="lahiri",
+)
+chart["planets"][0]["sign"]   # 'Taurus'
+
+
+
JavaScript
+
import init, { Engine } from "astroceleste-engine";
+
+await init();
+const engine = new Engine();
+const res = await fetch("/ephemeris/de440s.bsp");
+engine.addKernel("de440s.bsp",
+  new Uint8Array(await res.arrayBuffer()));
+
+const chart = engine.chart({
+  utc: "1987-05-17T14:30:00Z",
+  latitude: 41.9, longitude: 12.5,
+});
+
+
+
+
+ +
+
+

What it computes

+
+
+

Planets and points

+

Sun to Pluto, the lunar nodes, Chiron and Black Moon Lilith, with speed and retrograde status, and the Ascendant and Midheaven.

+
+
+

Houses

+

Placidus, Whole Sign, Equal and Porphyry cusps, and the house of every point.

+
+
+

Tropical and sidereal

+

14 ayanamsas, including Lahiri, Fagan-Bradley, Krishnamurti, Raman and Galactic Center.

+
+
+

Aspects and orbs

+

The five major aspects plus semi-sextile and quincunx, with configurable orbs and moiety methods.

+
+
+

Fixed stars and lots

+

Fixed-star conjunctions with precession, and the Arabic parts with day and night formulas.

+
+
+

Temperament and Moon

+

Traditional temperament from the primary qualities, and the lunar phase, illumination, dignity and mansion.

+
+
+

Horary

+

Planetary day and hour from the actual sunrise and sunset, significators, void of course Moon and considerations before judgment.

+
+
+

Transits, synastry, derived charts

+

Cross-aspects to a natal chart or between two charts, and charts turned to any house.

+
+
+
+
+ +
+
+

Verified, not approximated

+
+
+

JPL ephemerides

+

Positions come from NASA JPL DE440s (1849–2150) or DE441 (13200 BC–17191 AD), read by a pure-Rust SPK reader. A date outside the loaded kernels is an error, never a guess.

+
+
+

277 golden charts

+

Every release reproduces the reference implementation's output for natal, horary, transit, synastry and derived charts: floats within 10−9, with the same keys in the same order.

+
+
+

Checked stage by stage

+

Time scales, precession, nutation, light-time, deflection and aberration are checked against Skyfield, and raw kernel states against jplephem.

+
+
+

+ Pure Rust with no C code and two dependencies (serde, serde_json). Builds for servers, + wasm32-unknown-unknown, Android and iOS. + How accuracy is verified → +

+
+
+ +
+ +
+
+ +
+
+

+ astroceleste-engine is the calculation core of Astroceleste. + Licensed under MIT or Apache-2.0. +

+

+ Planetary ephemerides by NASA JPL. Time scales, frames and apparent positions are + ported from Skyfield (MIT). This page makes no third-party requests. +

+
+
+ + diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..f83fe1e --- /dev/null +++ b/site/style.css @@ -0,0 +1,262 @@ +:root { + --bg: #0b1020; + --bg-alt: #0f1529; + --panel: #131a33; + --panel-2: #0e1430; + --text: #e8e9f3; + --muted: #a3a8c3; + --dim: #7c82a3; + --border: #252d50; + --accent: #e0b35a; + --accent-ink: #1a1405; + --link: #9db8ff; + --harmonious: #6ea8ff; + --hard: #ff7f7f; + --neutral: #c9b27a; + --star: rgba(255, 255, 255, 0.55); + --radius: 12px; + --font-body: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-display: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif; + --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, "DejaVu Sans Mono", monospace; + --font-glyph: "Segoe UI Symbol", "Apple Symbols", "Noto Sans Symbols", "Noto Sans Symbols 2", "DejaVu Sans", sans-serif; + color-scheme: dark; +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { + --bg: #f7f5ef; + --bg-alt: #efece3; + --panel: #ffffff; + --panel-2: #f6f4ee; + --text: #1b1e2e; + --muted: #4f5570; + --dim: #6b7090; + --border: #dcd7c9; + --accent: #9a6b12; + --accent-ink: #ffffff; + --link: #2f55c8; + --harmonious: #2f6fd6; + --hard: #c93c3c; + --neutral: #8a6d1f; + --star: transparent; + color-scheme: light; + } +} + +* { box-sizing: border-box; } + +html { scroll-behavior: smooth; scroll-padding-top: 72px; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 16px/1.6 var(--font-body); + -webkit-text-size-adjust: 100%; +} + +a { color: var(--link); text-underline-offset: 2px; } +a:hover { text-decoration-thickness: 2px; } + +code, pre { font-family: var(--font-mono); font-size: 0.9em; } + +h1, h2, h3 { line-height: 1.2; margin: 0 0 0.5em; } +h1, h2 { font-family: var(--font-display); font-weight: 600; letter-spacing: -0.01em; } +h2 { font-size: clamp(1.7rem, 3.5vw, 2.3rem); } +h3 { font-size: 1.05rem; } + +.wrap { width: 100%; max-width: 1120px; margin: 0 auto; padding: 0 16px; } + +/* Top bar */ +.topbar { + position: sticky; top: 0; z-index: 10; + background: color-mix(in srgb, var(--bg) 88%, transparent); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border); +} +.topbar-inner { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 56px; } +.brand { display: flex; align-items: center; gap: 10px; color: var(--text); text-decoration: none; font-weight: 600; } +.brand-dim { color: var(--muted); font-weight: 400; } +.topbar nav { display: flex; gap: 20px; } +.topbar nav a { color: var(--muted); text-decoration: none; font-size: 0.95rem; } +.topbar nav a:hover { color: var(--text); } + +/* Hero */ +.hero { + padding: 88px 0 72px; + background: + radial-gradient(1px 1px at 12% 22%, var(--star), transparent 60%), + radial-gradient(1px 1px at 28% 68%, var(--star), transparent 60%), + radial-gradient(1.5px 1.5px at 44% 14%, var(--star), transparent 60%), + radial-gradient(1px 1px at 63% 42%, var(--star), transparent 60%), + radial-gradient(1.5px 1.5px at 78% 18%, var(--star), transparent 60%), + radial-gradient(1px 1px at 88% 62%, var(--star), transparent 60%), + radial-gradient(1px 1px at 94% 30%, var(--star), transparent 60%), + radial-gradient(1px 1px at 52% 86%, var(--star), transparent 60%), + radial-gradient(ellipse at 80% -10%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 55%), + var(--bg); +} +.eyebrow { color: var(--accent); font-size: 0.85rem; letter-spacing: 0.08em; text-transform: uppercase; margin: 0 0 16px; } +.hero h1 { font-size: clamp(2.4rem, 7vw, 4.4rem); margin-bottom: 20px; } +.lede { font-size: clamp(1.05rem, 2vw, 1.25rem); color: var(--muted); max-width: 44em; margin: 0 0 28px; } +.cta { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 32px; } +.install { list-style: none; padding: 0; margin: 0 0 20px; display: flex; flex-wrap: wrap; gap: 10px; } +.install li { + display: flex; align-items: center; gap: 10px; max-width: 100%; + padding: 8px 12px; border: 1px solid var(--border); border-radius: 999px; background: var(--panel-2); +} +.install span { color: var(--dim); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; } +.install code { overflow-wrap: anywhere; } +.note { color: var(--dim); font-size: 0.9rem; margin: 0; } + +/* Buttons */ +.button { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + padding: 10px 18px; border-radius: 8px; border: 1px solid var(--border); + background: var(--panel); color: var(--text); font: inherit; font-weight: 500; + text-decoration: none; cursor: pointer; +} +.button:hover { border-color: var(--accent); } +.button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); } +.button.primary:hover { filter: brightness(1.08); } +.button.small { padding: 4px 10px; font-size: 0.85rem; } +.button.wide { width: 100%; } +.button:disabled { opacity: 0.6; cursor: progress; } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* Sections */ +.section { padding: 72px 0; } +.section.alt { background: var(--bg-alt); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); } +.section-lede { color: var(--muted); max-width: 46em; margin: 0 0 32px; } +.section-foot { color: var(--muted); margin: 32px 0 0; } + +.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; min-width: 0; } + +/* Demo */ +.demo { display: grid; grid-template-columns: minmax(0, 340px) minmax(0, 1fr); gap: 24px; align-items: start; } +.demo form { position: sticky; top: 76px; } +.presets { border: 0; padding: 0; margin: 0 0 16px; display: flex; flex-wrap: wrap; gap: 8px; } +.presets legend { color: var(--dim); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px; padding: 0; } +.presets button { + font: inherit; font-size: 0.85rem; padding: 5px 10px; border-radius: 999px; cursor: pointer; + border: 1px solid var(--border); background: var(--panel-2); color: var(--text); +} +.presets button:hover { border-color: var(--accent); } +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.span-2 { grid-column: span 2; } +label { display: flex; flex-direction: column; gap: 4px; font-size: 0.9rem; font-weight: 500; min-width: 0; } +label small { color: var(--dim); font-weight: 400; } +input, select { + font: inherit; font-weight: 400; color: var(--text); background: var(--panel-2); + border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; width: 100%; min-width: 0; +} +select:disabled { opacity: 0.55; } +.hint { color: var(--dim); font-size: 0.85rem; margin: 12px 0 16px; } +.status { min-height: 1.6em; margin: 12px 0 0; font-size: 0.9rem; color: var(--muted); } +.status.error { color: var(--hard); } +.progress { display: block; height: 4px; margin-top: 6px; border-radius: 2px; background: var(--border); overflow: hidden; } +.progress i { display: block; height: 100%; background: var(--accent); width: 0; transition: width 0.15s; } + +.result { display: grid; gap: 24px; min-width: 0; } +.wheel-panel { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 220px); gap: 20px; align-items: center; } +#wheel { width: 100%; height: auto; max-width: 440px; margin: 0 auto; display: block; } +.summary { margin: 0; display: grid; gap: 12px; } +.summary div { border-left: 2px solid var(--accent); padding-left: 10px; } +.summary dt { color: var(--dim); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; } +.summary dd { margin: 0; } + +.tables h3 { margin-top: 24px; } +.tables h3:first-child { margin-top: 0; } +.table-scroll { overflow-x: auto; } +.table-scroll.tall { max-height: 460px; overflow-y: auto; } +.table-scroll.tall thead th { position: sticky; top: 0; background: var(--panel); } +table { border-collapse: collapse; width: 100%; font-size: 0.92rem; font-variant-numeric: tabular-nums; } +th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; } +th { color: var(--dim); font-weight: 500; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; } +td.num { text-align: right; } +.glyph { font-family: var(--font-glyph); font-size: 1.1em; } +.retro { color: var(--hard); font-weight: 600; } +.harmonious { color: var(--harmonious); } +.hard { color: var(--hard); } +.neutral { color: var(--neutral); } + +.raw { margin-top: 24px; } +.raw summary { cursor: pointer; font-weight: 500; } +.raw summary small { color: var(--dim); font-weight: 400; } +.raw .button { margin: 12px 0 8px; } +.raw pre { + margin: 0; max-height: 420px; overflow: auto; padding: 12px; + background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; font-size: 0.8rem; +} + +/* Wheel (SVG) */ +.w-ring { fill: none; stroke: var(--border); stroke-width: 1; } +.w-zodiac { fill: var(--panel-2); stroke: var(--border); } +.w-sign-line { stroke: var(--border); } +.w-sign { fill: var(--muted); font-family: var(--font-glyph); font-size: 15px; text-anchor: middle; dominant-baseline: central; } +.w-sign.fire { fill: #e0795a; } +.w-sign.earth { fill: #8fae5c; } +.w-sign.air { fill: #d6b64f; } +.w-sign.water { fill: #5f9fd6; } +.w-cusp { stroke: var(--dim); stroke-width: 0.8; } +.w-axis { stroke: var(--accent); stroke-width: 1.6; } +.w-house-num { fill: var(--dim); font-size: 9px; text-anchor: middle; dominant-baseline: central; font-family: var(--font-body); } +.w-tick { stroke: var(--text); stroke-width: 1; } +.w-leader { stroke: var(--dim); stroke-width: 0.6; } +.w-planet { fill: var(--text); font-family: var(--font-glyph); font-size: 15px; text-anchor: middle; dominant-baseline: central; } +.w-planet.angle { fill: var(--accent); font-family: var(--font-body); font-size: 9px; font-weight: 700; } +.w-aspect { stroke-width: 1; opacity: 0.8; } +.w-aspect.harmonious { stroke: var(--harmonious); } +.w-aspect.hard { stroke: var(--hard); } +.w-aspect.minor { stroke-dasharray: 3 3; opacity: 0.6; } + +/* Code cards */ +.code-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; } +.code-card { margin: 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; min-width: 0; } +.code-card figcaption { padding: 10px 16px; border-bottom: 1px solid var(--border); color: var(--accent); font-weight: 600; font-size: 0.9rem; } +.code-card pre { margin: 0; padding: 16px; overflow-x: auto; font-size: 0.8rem; line-height: 1.55; } + +/* Features */ +.feature-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; } +.feature-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.feature-grid article { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; } +.feature-grid article h3 { color: var(--text); } +.feature-grid p { margin: 0; color: var(--muted); font-size: 0.95rem; } + +/* Docs */ +.link-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; } +.link-grid a { + display: flex; flex-direction: column; gap: 4px; padding: 16px 18px; text-decoration: none; + background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); +} +.link-grid a:hover { border-color: var(--accent); } +.link-grid span { color: var(--muted); font-size: 0.9rem; } +.registries { list-style: none; padding: 0; margin: 28px 0 0; display: flex; flex-wrap: wrap; gap: 8px 24px; } + +.footer { padding: 40px 0 56px; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.9rem; } +.footer p { margin: 0 0 8px; } +.footer .dim { color: var(--dim); } + +@media (max-width: 960px) { + .demo { grid-template-columns: minmax(0, 1fr); } + .demo form { position: static; } + .code-grid { grid-template-columns: minmax(0, 1fr); } + .feature-grid, .link-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .feature-grid.three { grid-template-columns: minmax(0, 1fr); } +} + +@media (max-width: 640px) { + .topbar nav { gap: 14px; } + .topbar nav a:nth-child(2) { display: none; } + .brand-dim { display: none; } + .hero { padding: 56px 0 48px; } + .section { padding: 52px 0; } + .wheel-panel { grid-template-columns: minmax(0, 1fr); } + .feature-grid, .link-grid { grid-template-columns: minmax(0, 1fr); } + .install li { border-radius: 10px; } +} + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + .progress i { transition: none; } +} From daebacedc1e51d347ea9f7050da895ed8f669f73 Mon Sep 17 00:00:00 2001 From: ffalcinelli Date: Tue, 22 Sep 2026 22:19:06 +0200 Subject: [PATCH 6/6] ci: deploy site to GitHub Pages Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011a5A76BzYdi7yn5vUancjY --- .github/workflows/pages.yml | 67 +++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/pages.yml diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..31cd434 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,67 @@ +name: Pages + +# Builds the landing site with its live demo (scripts/build-site.sh: site/, the WASM package +# and a 1950-2050 DE440s excerpt) and deploys it to GitHub Pages. The repository's Pages +# source must be set to "GitHub Actions". + +on: + push: + branches: [main] + paths: + - "site/**" + - "crates/astroceleste-engine/**" + - "crates/astroceleste-engine-wasm/**" + - "scripts/build-site.sh" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + if: github.repository_owner == 'ffalcinelli' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master + with: + toolchain: stable + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: pages + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: kernels + key: jpl-kernels-de440s + - run: npm install --global wasm-pack@0.15.0 + - run: scripts/build-site.sh + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: target/site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + permissions: + pages: write + id-token: write + steps: + - id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1