diff --git a/.gitattributes b/.gitattributes index 5c9f74d7..292d92e5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,4 @@ data/de421.anise filter=lfs diff=lfs merge=lfs -text data/de430.anise filter=lfs diff=lfs merge=lfs -text data/de438s.anise filter=lfs diff=lfs merge=lfs -text data/de440.anise filter=lfs diff=lfs merge=lfs -text +data/*.pca filter=lfs diff=lfs merge=lfs -text \ No newline at end of file diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index b248f55d..894b354c 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -29,13 +29,13 @@ jobs: run: sh dev-env-setup.sh && cd .. # Return to root - name: Bench JPL Ephemerides - run: cargo bench --bench "*_jpl_ephemerides" + run: cargo bench --bench "*_jpl_ephemerides" --workspace --exclude anise-py - name: Bench Spacecraft (Hermite type 13) - run: cargo bench --bench "*_spacecraft_ephemeris" + run: cargo bench --bench "*_spacecraft_ephemeris" --workspace --exclude anise-py - name: Bench Binary planetary constants - run: cargo bench --bench "crit_bpc_rotation" + run: cargo bench --bench "crit_bpc_rotation" --workspace --exclude anise-py - name: Save benchmark artifacts uses: actions/upload-artifact@v3 diff --git a/.github/workflows/gui.yaml b/.github/workflows/gui.yaml index 42c6f483..33b76a25 100644 --- a/.github/workflows/gui.yaml +++ b/.github/workflows/gui.yaml @@ -32,7 +32,7 @@ jobs: execute_install_scripts: true - name: Build Linux executable - run: cargo build --release --bin anise-gui + run: cargo build --release --bin anise-gui --workspace --exclude anise-py - name: Save executable uses: actions/upload-artifact@v3 @@ -54,7 +54,7 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Build Windows executable - run: cargo build --release --bin anise-gui + run: cargo build --release --bin anise-gui --workspace --exclude anise-py - name: Save executable uses: actions/upload-artifact@v3 diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 00000000..0ad072ad --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,207 @@ +# This file is autogenerated by maturin v1.4.0 +# To update, run +# +# maturin generate-ci github +# +name: Python + +on: + push: + branches: + - main + - master + tags: + - '*' + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux: + runs-on: ubuntu-latest + strategy: + matrix: + target: [x86_64, x86, aarch64, armv7, s390x, ppc64le] + steps: + - uses: actions/checkout@v3 + with: + lfs: true + + - uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + manylinux: auto + working-directory: anise-py + before-script-linux: | + # Source: https://github.com/sfackler/rust-openssl/issues/2036#issuecomment-1724324145 + # If we're running on rhel centos, install needed packages. + if command -v yum &> /dev/null; then + yum update -y && yum install -y perl-core openssl openssl-devel pkgconfig libatomic + + # If we're running on i686 we need to symlink libatomic + # in order to build openssl with -latomic flag. + if [[ ! -d "/usr/lib64" ]]; then + ln -s /usr/lib/libatomic.so.1 /usr/lib/libatomic.so + fi + else + # If we're running on debian-based system. + apt update -y && apt-get install -y libssl-dev openssl pkg-config + fi + + - name: Upload wheels + uses: actions/upload-artifact@v3 + with: + name: wheels + path: anise-py/dist + + - name: pytest + if: ${{ startsWith(matrix.target, 'x86_64') }} + shell: bash + run: | + set -e + pip debug --verbose + pip install anise --find-links anise-py/dist --force-reinstall -vv + pip install pytest + pytest + + - name: pytest + if: ${{ !startsWith(matrix.target, 'x86') && matrix.target != 'ppc64' }} + uses: uraimo/run-on-arch-action@v2 + with: + arch: ${{ matrix.target }} + distro: ubuntu22.04 + githubToken: ${{ github.token }} + install: | + apt-get update + apt-get install -y --no-install-recommends python3 python3-pip + pip3 install -U pip pytest + run: | + set -e + pip debug --verbose + pip install anise --find-links anise-py/dist --force-reinstall -vv + pip install pytest + pytest + + windows: + runs-on: windows-latest + strategy: + matrix: + target: [x64, x86] + steps: + - uses: actions/checkout@v3 + with: + lfs: true + + - uses: actions/setup-python@v4 + with: + python-version: "3.11" + architecture: ${{ matrix.target }} + + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + working-directory: anise-py + + - name: Upload wheels + uses: actions/upload-artifact@v3 + with: + name: wheels + path: anise-py/dist + + - name: pytest + shell: bash + env: + RUST_BACKTRACE: 1 + run: | + set -e + pip install anise --find-links anise-py/dist --force-reinstall + pip install pytest + pytest + + macos: + runs-on: macos-latest + strategy: + matrix: + target: [x86_64, aarch64] + steps: + - uses: actions/checkout@v3 + with: + lfs: true + + - uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + working-directory: anise-py + + - name: Upload wheels + uses: actions/upload-artifact@v3 + with: + name: wheels + path: anise-py/dist + + - name: pytest + if: ${{ !startsWith(matrix.target, 'aarch64') }} + shell: bash + env: + RUST_BACKTRACE: 1 + run: | + set -e + pip install anise --find-links anise-py/dist --force-reinstall -vv + pip install pytest + pytest + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + lfs: true + + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + working-directory: anise-py + + - name: Upload sdist + uses: actions/upload-artifact@v3 + with: + name: wheels + path: anise-py/dist + + release: + name: Release + runs-on: ubuntu-latest + if: "startsWith(github.ref, 'refs/tags/')" + needs: [linux, windows, macos, sdist] + steps: + - uses: actions/download-artifact@v3 + with: + name: wheels + + - name: Publish to PyPI + uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + with: + command: upload + args: --non-interactive --skip-existing * diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 0bf5753f..cbe620cf 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -32,7 +32,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: check - args: --workspace --exclude anise-gui + args: --workspace --exclude anise-gui --exclude anise-py test: name: Run tests @@ -54,10 +54,10 @@ jobs: run: sh dev-env-setup.sh && cd .. # Return to root - name: Test debug - run: cargo test --workspace --exclude anise-gui + run: cargo test --workspace --exclude anise-gui --exclude anise-py - name: Test release - run: cargo test --release --workspace --exclude anise-gui + run: cargo test --release --workspace --exclude anise-gui --exclude anise-py lints: name: Lints @@ -108,22 +108,23 @@ jobs: - name: CLI SPK run: | - cargo run --bin anise-cli -- inspect data/gmat-hermite.bsp - cargo run --bin anise-cli -- inspect data/de440.bsp + cargo build --bin anise-cli --workspace --exclude anise-py + ./target/debug/anise-cli inspect data/gmat-hermite.bsp + ./target/debug/anise-cli inspect data/de440.bsp - name: Rust-SPICE JPL DE validation - run: RUST_BACKTRACE=1 cargo test validate_jplde --features spkezr_validation --release --workspace --exclude anise-gui -- --nocapture --include-ignored --test-threads 1 + run: RUST_BACKTRACE=1 cargo test validate_jplde --features spkezr_validation --release --workspace --exclude anise-gui --exclude anise-py -- --nocapture --include-ignored --test-threads 1 - name: Rust-SPICE hermite validation - run: RUST_BACKTRACE=1 cargo test validate_hermite_type13_ --features spkezr_validation --release --workspace --exclude anise-gui -- --nocapture --include-ignored --test-threads 1 + run: RUST_BACKTRACE=1 cargo test validate_hermite_type13_ --features spkezr_validation --release --workspace --exclude anise-gui --exclude anise-py -- --nocapture --include-ignored --test-threads 1 - name: Rust-SPICE PCK validation - run: RUST_BACKTRACE=1 cargo test validate_iau_rotation_to_parent --release --workspace --exclude anise-gui -- --nocapture --ignored + run: RUST_BACKTRACE=1 cargo test validate_iau_rotation_to_parent --release --workspace --exclude anise-gui --exclude anise-py -- --nocapture --ignored - name: Rust-SPICE BPC validation run: | - RUST_BACKTRACE=1 cargo test validate_bpc_ --release --workspace --exclude anise-gui -- --nocapture --include-ignored --test-threads 1 - RUST_BACKTRACE=1 cargo test de440s_translation_verif_venus2emb --release --workspace --exclude anise-gui -- --nocapture --include-ignored --test-threads 1 + RUST_BACKTRACE=1 cargo test validate_bpc_ --release --workspace --exclude anise-gui --exclude anise-py -- --nocapture --include-ignored --test-threads 1 + RUST_BACKTRACE=1 cargo test de440s_translation_verif_venus2emb --release --workspace --exclude anise-gui --exclude anise-py -- --nocapture --include-ignored --test-threads 1 # Now analyze the results and create pretty plots - uses: actions/setup-python@v4 @@ -169,12 +170,12 @@ jobs: - name: Generate coverage report run: | cargo llvm-cov clean --workspace - cargo llvm-cov test --workspace --exclude anise-gui --no-report -- --test-threads=1 - cargo llvm-cov test --workspace --exclude anise-gui --no-report --tests -- compile_fail - cargo llvm-cov test --workspace --exclude anise-gui --no-report validate_iau_rotation_to_parent -- --nocapture --ignored - cargo llvm-cov test --workspace --exclude anise-gui --no-report validate_bpc_to_iau_rotations -- --nocapture --ignored - cargo llvm-cov test --workspace --exclude anise-gui --no-report validate_jplde_de440s --features spkezr_validation -- --nocapture --ignored - cargo llvm-cov test --workspace --exclude anise-gui --no-report validate_hermite_type13_from_gmat --features spkezr_validation -- --nocapture --ignored + cargo llvm-cov test --workspace --exclude anise-gui --exclude anise-py --no-report -- --test-threads=1 + cargo llvm-cov test --workspace --exclude anise-gui --exclude anise-py --no-report --tests -- compile_fail + cargo llvm-cov test --workspace --exclude anise-gui --exclude anise-py --no-report validate_iau_rotation_to_parent -- --nocapture --ignored + cargo llvm-cov test --workspace --exclude anise-gui --exclude anise-py --no-report validate_bpc_to_iau_rotations -- --nocapture --ignored + cargo llvm-cov test --workspace --exclude anise-gui --exclude anise-py --no-report validate_jplde_de440s --features spkezr_validation -- --nocapture --ignored + cargo llvm-cov test --workspace --exclude anise-gui --exclude anise-py --no-report validate_hermite_type13_from_gmat --features spkezr_validation -- --nocapture --ignored cargo llvm-cov report --lcov > lcov.txt env: RUSTFLAGS: --cfg __ui_tests diff --git a/Cargo.toml b/Cargo.toml index 979a0c52..5df94fc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["anise", "anise-cli", "anise-gui"] +members = ["anise", "anise-cli", "anise-gui", "anise-py"] [workspace.package] version = "0.1.0" @@ -26,7 +26,7 @@ exclude = [ ] [workspace.dependencies] -hifitime = "3.8.6" +hifitime = { version = "3.8.6", default-features = true } memmap2 = "=0.9.3" crc32fast = "=1.3.2" der = { version = "0.7.8", features = ["derive", "alloc", "real"] } @@ -42,6 +42,8 @@ snafu = { version = "0.7.5", features = ["backtrace"] } lexical-core = "0.8.5" heapless = "0.8.0" rstest = "0.18.2" +pyo3 = { version = "0.20.0", features = ["multiple-pymethods"] } +pyo3-log = "0.9.0" anise = { version = "0.1.0", path = "anise", default-features = false } diff --git a/README.md b/README.md index 83aa5542..00200ead 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,9 @@ Inspect an Binary PCK file (BPC) ([video link](http://public-data.nyxspace.com/a ![Inspect an SPK file](http://public-data.nyxspace.com/anise/demo/ANISE-BPC.gif) -## Usage +## Rust Usage -Usage as a library is currently only available in Rust. Start using it by adding to your Rust project: +Start using it by adding to your Rust project: ```sh cargo add anise @@ -99,7 +99,7 @@ let orig_state = Orbit::keplerian( // Transform that orbit into another frame. let state_itrf93 = almanac - .transform_to(orig_state, EARTH_ITRF93, Aberration::None) + .transform_to(orig_state, EARTH_ITRF93, Aberration::NotSet) .unwrap(); // The `:x` prints this orbit's Keplerian elements @@ -109,7 +109,7 @@ println!("{state_itrf93:X}"); // Convert back let from_state_itrf93_to_eme2k = almanac - .transform_to(state_itrf93, EARTH_J2000, Aberration::None) + .transform_to(state_itrf93, EARTH_J2000, Aberration::NotSet) .unwrap(); println!("{from_state_itrf93_to_eme2k}"); @@ -185,13 +185,105 @@ let state = ctx VENUS_J2000, EARTH_MOON_BARYCENTER_J2000, epoch, - Aberration::None, + Aberration::NotSet, ) .unwrap(); println!("{state}"); ``` +## Python Usage + +In Python, start by adding anise to your project: `pip install anise`. + +```python +from anise import Almanac, Aberration +from anise.astro.constants import Frames +from anise.astro import Orbit +from anise.time import Epoch + +from pathlib import Path + + +def test_state_transformation(): + """ + This is the Python equivalent to anise/tests/almanac/mod.rs + """ + data_path = Path(__file__).parent.joinpath("..", "..", "data") + # Must ensure that the path is a string + ctx = Almanac(str(data_path.joinpath("de440s.bsp"))) + # Let's add another file here -- note that the Almanac will load into a NEW variable, so we must overwrite it! + # This prevents memory leaks (yes, I promise) + ctx = ctx.load(str(data_path.joinpath("pck08.pca"))).load( + str(data_path.joinpath("earth_latest_high_prec.bpc")) + ) + eme2k = ctx.frame_info(Frames.EME2000) + assert eme2k.mu_km3_s2() == 398600.435436096 + assert eme2k.shape.polar_radius_km == 6356.75 + assert abs(eme2k.shape.flattening() - 0.0033536422844278) < 2e-16 + + epoch = Epoch("2021-10-29 12:34:56 TDB") + + orig_state = Orbit.from_keplerian( + 8_191.93, + 1e-6, + 12.85, + 306.614, + 314.19, + 99.887_7, + epoch, + eme2k, + ) + + assert orig_state.sma_km() == 8191.93 + assert orig_state.ecc() == 1.000000000361619e-06 + assert orig_state.inc_deg() == 12.849999999999987 + assert orig_state.raan_deg() == 306.614 + assert orig_state.tlong_deg() == 0.6916999999999689 + + state_itrf93 = ctx.transform_to( + orig_state, Frames.EARTH_ITRF93, Aberration.NotSet + ) + + print(orig_state) + print(state_itrf93) + + assert state_itrf93.geodetic_latitude_deg() == 10.549246868302738 + assert state_itrf93.geodetic_longitude_deg() == 133.76889100913047 + assert state_itrf93.geodetic_height_km() == 1814.503598063825 + + # Convert back + from_state_itrf93_to_eme2k = ctx.transform_to( + state_itrf93, Frames.EARTH_J2000, Aberration.NotSet + ) + + print(from_state_itrf93_to_eme2k) + + assert orig_state == from_state_itrf93_to_eme2k + + # Demo creation of a ground station + mean_earth_angular_velocity_deg_s = 0.004178079012116429 + # Grab the loaded frame info + itrf93 = ctx.frame_info(Frames.EARTH_ITRF93) + paris = Orbit.from_latlongalt( + 48.8566, + 2.3522, + 0.4, + mean_earth_angular_velocity_deg_s, + epoch, + itrf93, + ) + + assert abs(paris.geodetic_latitude_deg() - 48.8566) < 1e-3 + assert abs(paris.geodetic_longitude_deg() - 2.3522) < 1e-3 + assert abs(paris.geodetic_height_km() - 0.4) < 1e-3 + + +if __name__ == "__main__": + test_state_transformation() + +``` + ## Contributing Contributions to ANISE are welcome! Whether it's in the form of feature requests, bug reports, code contributions, or documentation improvements, every bit of help is greatly appreciated. diff --git a/anise-py/.cargo/config.toml b/anise-py/.cargo/config.toml new file mode 100644 index 00000000..af951327 --- /dev/null +++ b/anise-py/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.x86_64-apple-darwin] +rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"] + +[target.aarch64-apple-darwin] +rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"] diff --git a/anise-py/.gitignore b/anise-py/.gitignore new file mode 100644 index 00000000..fe9ffdee --- /dev/null +++ b/anise-py/.gitignore @@ -0,0 +1,73 @@ +/target + +# Byte-compiled / optimized / DLL files +__pycache__/ +.pytest_cache/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +.venv/ +env/ +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +include/ +man/ +venv/ +*.egg-info/ +.installed.cfg +*.egg +.ruff_cache + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +pip-selfcheck.json + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +.DS_Store + +# Sphinx documentation +docs/_build/ + +# PyCharm +.idea/ + +# VSCode +.vscode/ + +# Pyenv +.python-version diff --git a/anise-py/Cargo.toml b/anise-py/Cargo.toml new file mode 100644 index 00000000..257ee8b4 --- /dev/null +++ b/anise-py/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "anise-py" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +name = "anise" +crate-type = ["cdylib"] + +[dependencies] +anise = { workspace = true, features = ["python", "metaload"] } +snafu = { workspace = true } +hifitime = { workspace = true, features = ["python"] } +pyo3 = { workspace = true, features = ["extension-module"] } +pyo3-log = { workspace = true } diff --git a/anise-py/README.md b/anise-py/README.md index 8facf8ac..2438555a 100644 --- a/anise-py/README.md +++ b/anise-py/README.md @@ -1,3 +1,11 @@ # ANISE Python -Plaeceholder for the Python bindings to ANISE. \ No newline at end of file +The Python interface to ANISE, a modern rewrite of NAIF SPICE. + +## Getting started as a developer + +1. Install `maturin`, e.g. via `pipx` as `pipx install maturin` +1. Create a virtual environment: `cd anise/anise-py && python3 -m venv .venv` +1. Jump into the vitual environment and install `patchelf` for faster builds: `pip install patchelf`, and `pytest` for the test suite: `pip install pytest` +1. Run `maturin develop` to build the development package and install it in the virtual environment +1. Finally, run the tests `python -m pytest` \ No newline at end of file diff --git a/anise-py/pyproject.toml b/anise-py/pyproject.toml new file mode 100644 index 00000000..82704556 --- /dev/null +++ b/anise-py/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["maturin>=1.4,<2.0"] +build-backend = "maturin" + +[project] +name = "anise" +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dynamic = ["version"] + +[tool.maturin] +features = ["pyo3/extension-module"] diff --git a/anise-py/src/astro.rs b/anise-py/src/astro.rs new file mode 100644 index 00000000..f0b65168 --- /dev/null +++ b/anise-py/src/astro.rs @@ -0,0 +1,99 @@ +/* + * ANISE Toolkit + * Copyright (C) 2021-2023 Christopher Rabotin et al. (cf. AUTHORS.md) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * Documentation: https://nyxspace.com/ + */ + +use anise::structure::planetocentric::ellipsoid::Ellipsoid; +use pyo3::prelude::*; +use pyo3::py_run; + +use anise::astro::orbit::Orbit; +use anise::constants::frames::*; +use anise::frames::Frame; + +pub(crate) fn register_astro(py: Python<'_>, parent_module: &PyModule) -> PyResult<()> { + let sm = PyModule::new(py, "_anise.astro")?; + sm.add_class::()?; + sm.add_class::()?; + sm.add_class::()?; + + register_constants(py, sm)?; + + py_run!(py, sm, "import sys; sys.modules['anise.astro'] = sm"); + parent_module.add_submodule(sm)?; + Ok(()) +} + +#[pyclass] +#[pyo3(module = "anise.astro.constants")] +struct Frames {} + +#[pymethods] +impl Frames { + #[classattr] + const SSB_J2000: Frame = SSB_J2000; + #[classattr] + const MERCURY_J2000: Frame = MERCURY_J2000; + #[classattr] + const VENUS_J2000: Frame = VENUS_J2000; + #[classattr] + const EARTH_MOON_BARYCENTER_J2000: Frame = EARTH_MOON_BARYCENTER_J2000; + #[classattr] + const MARS_BARYCENTER_J2000: Frame = MARS_BARYCENTER_J2000; + #[classattr] + const JUPITER_BARYCENTER_J2000: Frame = JUPITER_BARYCENTER_J2000; + #[classattr] + const SATURN_BARYCENTER_J2000: Frame = SATURN_BARYCENTER_J2000; + #[classattr] + const URANUS_BARYCENTER_J2000: Frame = URANUS_BARYCENTER_J2000; + #[classattr] + const NEPTUNE_BARYCENTER_J2000: Frame = NEPTUNE_BARYCENTER_J2000; + #[classattr] + const PLUTO_BARYCENTER_J2000: Frame = PLUTO_BARYCENTER_J2000; + #[classattr] + const SUN_J2000: Frame = SUN_J2000; + #[classattr] + const LUNA_J2000: Frame = LUNA_J2000; + #[classattr] + const EARTH_J2000: Frame = EARTH_J2000; + #[classattr] + const EME2000: Frame = EME2000; + #[classattr] + const EARTH_ECLIPJ2000: Frame = EARTH_ECLIPJ2000; + #[classattr] + const IAU_MERCURY_FRAME: Frame = IAU_MERCURY_FRAME; + #[classattr] + const IAU_VENUS_FRAME: Frame = IAU_VENUS_FRAME; + #[classattr] + const IAU_EARTH_FRAME: Frame = IAU_EARTH_FRAME; + #[classattr] + const IAU_MARS_FRAME: Frame = IAU_MARS_FRAME; + #[classattr] + const IAU_JUPITER_FRAME: Frame = IAU_JUPITER_FRAME; + #[classattr] + const IAU_SATURN_FRAME: Frame = IAU_SATURN_FRAME; + #[classattr] + const IAU_NEPTUNE_FRAME: Frame = IAU_NEPTUNE_FRAME; + #[classattr] + const IAU_URANUS_FRAME: Frame = IAU_URANUS_FRAME; + #[classattr] + const EARTH_ITRF93: Frame = EARTH_ITRF93; +} + +pub(crate) fn register_constants(py: Python<'_>, parent_module: &PyModule) -> PyResult<()> { + let sm = PyModule::new(py, "_anise.astro.constants")?; + sm.add_class::()?; + + py_run!( + py, + sm, + "import sys; sys.modules['anise.astro.constants'] = sm" + ); + parent_module.add_submodule(sm)?; + Ok(()) +} diff --git a/anise-py/src/errors.rs b/anise-py/src/errors.rs new file mode 100644 index 00000000..7395a46a --- /dev/null +++ b/anise-py/src/errors.rs @@ -0,0 +1,12 @@ +/* + * ANISE Toolkit + * Copyright (C) 2021-2023 Christopher Rabotin et al. (cf. AUTHORS.md) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * Documentation: https://nyxspace.com/ + */ + +use anise::errors::PhysicsError; +use pyo3::{exceptions::PyException, prelude::*}; diff --git a/anise-py/src/lib.rs b/anise-py/src/lib.rs new file mode 100644 index 00000000..076f12ae --- /dev/null +++ b/anise-py/src/lib.rs @@ -0,0 +1,51 @@ +/* + * ANISE Toolkit + * Copyright (C) 2021-2023 Christopher Rabotin et al. (cf. AUTHORS.md) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * Documentation: https://nyxspace.com/ + */ + +use ::anise::almanac::meta::MetaAlmanac; +use ::anise::almanac::Almanac; +use ::anise::astro::Aberration; +use hifitime::leap_seconds::{LatestLeapSeconds, LeapSecondsFile}; +use hifitime::prelude::*; +use hifitime::ut1::Ut1Provider; + +use pyo3::prelude::*; +use pyo3::py_run; + +mod astro; + +/// A Python module implemented in Rust. +#[pymodule] +fn anise(py: Python, m: &PyModule) -> PyResult<()> { + register_time_module(py, m)?; + astro::register_astro(py, m)?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} + +/// Reexport hifitime as anise.time +fn register_time_module(py: Python<'_>, parent_module: &PyModule) -> PyResult<()> { + pyo3_log::init(); + let sm = PyModule::new(py, "_anise.time")?; + + sm.add_class::()?; + sm.add_class::()?; + sm.add_class::()?; + sm.add_class::()?; + sm.add_class::()?; + sm.add_class::()?; + sm.add_class::()?; + sm.add_class::()?; + + py_run!(py, sm, "import sys; sys.modules['anise.time'] = sm"); + parent_module.add_submodule(sm)?; + Ok(()) +} diff --git a/anise-py/tests/README.md b/anise-py/tests/README.md new file mode 100644 index 00000000..a11611c3 --- /dev/null +++ b/anise-py/tests/README.md @@ -0,0 +1,5 @@ +# Python tests + +The sole purpose of the Python tests is to check the export of classes and functions, and check that they behave as expected. + +Unit testing, integration testing, and regression tests all happen in the Rust tests. Please refer to those [here](../../anise/tests/) for details. diff --git a/anise-py/tests/test_almanac.py b/anise-py/tests/test_almanac.py new file mode 100644 index 00000000..056e8ea6 --- /dev/null +++ b/anise-py/tests/test_almanac.py @@ -0,0 +1,122 @@ +from pathlib import Path +import pickle + +from anise import Aberration, Almanac, MetaAlmanac +from anise.astro import * +from anise.astro.constants import Frames +from anise.time import Epoch + + +def test_state_transformation(): + """ + This is the Python equivalent to anise/tests/almanac/mod.rs + """ + data_path = Path(__file__).parent.joinpath("..", "..", "data") + # Must ensure that the path is a string + ctx = Almanac(str(data_path.joinpath("de440s.bsp"))) + # Let's add another file here -- note that the Almanac will load into a NEW variable, so we must overwrite it! + # This prevents memory leaks (yes, I promise) + ctx = ctx.load(str(data_path.joinpath("pck08.pca"))).load( + str(data_path.joinpath("earth_latest_high_prec.bpc")) + ) + eme2k = ctx.frame_info(Frames.EME2000) + assert eme2k.mu_km3_s2() == 398600.435436096 + assert eme2k.shape.polar_radius_km == 6356.75 + assert abs(eme2k.shape.flattening() - 0.0033536422844278) < 2e-16 + + epoch = Epoch("2021-10-29 12:34:56 TDB") + + orig_state = Orbit.from_keplerian( + 8_191.93, + 1e-6, + 12.85, + 306.614, + 314.19, + 99.887_7, + epoch, + eme2k, + ) + + assert abs(orig_state.sma_km() - 8191.93) < 1e-10 + assert abs(orig_state.ecc() - 1.000000000361619e-06) < 1e-10 + assert abs(orig_state.inc_deg() - 12.849999999999987) < 1e-10 + assert abs(orig_state.raan_deg() - 306.614) < 1e-10 + assert abs(orig_state.tlong_deg() - 0.6916999999999689) < 1e-10 + + state_itrf93 = ctx.transform_to(orig_state, Frames.EARTH_ITRF93, Aberration.NotSet) + + print(orig_state) + print(state_itrf93) + + assert abs(state_itrf93.geodetic_latitude_deg() - 10.549246868302738) < 1e-10 + assert abs(state_itrf93.geodetic_longitude_deg() - 133.76889100913047) < 1e-10 + assert abs(state_itrf93.geodetic_height_km() - 1814.503598063825) < 1e-10 + + # Convert back + from_state_itrf93_to_eme2k = ctx.transform_to( + state_itrf93, Frames.EARTH_J2000, Aberration.NotSet + ) + + print(from_state_itrf93_to_eme2k) + + assert orig_state == from_state_itrf93_to_eme2k + + # Demo creation of a ground station + mean_earth_angular_velocity_deg_s = 0.004178079012116429 + # Grab the loaded frame info + itrf93 = ctx.frame_info(Frames.EARTH_ITRF93) + paris = Orbit.from_latlongalt( + 48.8566, + 2.3522, + 0.4, + mean_earth_angular_velocity_deg_s, + epoch, + itrf93, + ) + + assert abs(paris.geodetic_latitude_deg() - 48.8566) < 1e-3 + assert abs(paris.geodetic_longitude_deg() - 2.3522) < 1e-3 + assert abs(paris.geodetic_height_km() - 0.4) < 1e-3 + + # Pickling test + pickle.loads(pickle.dumps(eme2k)) == eme2k + pickle.loads(pickle.dumps(eme2k.shape)) == eme2k.shape + # Cannot yet pickle Epoch, so we can't pickle an Orbit yet + # cf. https://github.com/nyx-space/hifitime/issues/270 + + +def test_meta_load(): + data_path = Path(__file__).parent.joinpath("..", "..", "data", "local.dhall") + meta = MetaAlmanac(str(data_path)) + print(meta) + try: + # Process the files to be loaded + almanac = meta.process() + except Exception as e: + print("Not sure where the files are on Github CI") + print(e) + else: + # And check that everything is loaded + eme2k = almanac.frame_info(Frames.EME2000) + assert eme2k.mu_km3_s2() == 398600.435436096 + assert eme2k.shape.polar_radius_km == 6356.75 + assert abs(eme2k.shape.flattening() - 0.0033536422844278) < 2e-16 + +def test_exports(): + for cls in [Frame, Ellipsoid, Orbit]: + print(f"{cls} OK") + + +def test_frame_defs(): + print(f"{Frames.SSB_J2000}") + print(dir(Frames)) + assert Frames.EME2000 == Frames.EME2000 + assert Frames.EME2000 == Frames.EARTH_J2000 + assert Frames.EME2000 != Frames.SSB_J2000 + + +if __name__ == "__main__": + test_meta_load() + test_exports() + test_frame_defs() + test_state_transformation() diff --git a/anise-py/tests/test_hifitime.py b/anise-py/tests/test_hifitime.py new file mode 100644 index 00000000..1b3b1d1b --- /dev/null +++ b/anise-py/tests/test_hifitime.py @@ -0,0 +1,23 @@ +from anise.time import * + +""" +The time tests only make sure that we can call all of the functions that are re-exported. +For comprehensive tests of the time, refer to the hifitime test suite +""" + + +def test_exports(): + for cls in [ + Epoch, + TimeSeries, + Duration, + Unit, + Ut1Provider, + LatestLeapSeconds, + LeapSecondsFile, + ]: + print(f"{cls} OK") + + +if __name__ == "__main__": + test_exports() diff --git a/anise/Cargo.toml b/anise/Cargo.toml index 81c766fc..de77b630 100644 --- a/anise/Cargo.toml +++ b/anise/Cargo.toml @@ -16,7 +16,6 @@ crc32fast = { workspace = true } der = { workspace = true } log = { workspace = true } pretty_env_logger = { workspace = true } -# tabled = { workspace = true } const_format = { workspace = true } nalgebra = { workspace = true } approx = { workspace = true } @@ -25,19 +24,14 @@ bytes = { workspace = true } snafu = { workspace = true } heapless = { workspace = true } rstest = { workspace = true } -# eframe = { version = "0.24.0", optional = true } -# egui = { version = "0.24.0", optional = true } -# egui_extras = { version = "0.24.0", features = [ -# "datepicker", -# "http", -# "image", -# ], optional = true } -# egui-toast = { version = "0.10.0", optional = true } -# rfd = { version = "0.12.1", optional = true } - -# [target.'cfg(target_arch = "wasm32")'.dependencies] -# wasm-bindgen-futures = "0.4" -# poll-promise = { version = "0.3.0", features = ["web"] } +pyo3 = { workspace = true, optional = true } +pyo3-log = { workspace = true, optional = true } +url = { version = "2.5.0", optional = true } +serde = { version = "1", optional = true } +serde_derive = { version = "1", optional = true } +serde_dhall = { version = "0.12", optional = true } +reqwest = { version = "0.11.23", optional = true, features = ["blocking"] } +platform-dirs = { version = "0.3.0", optional = true } [dev-dependencies] rust-spice = "0.7.6" @@ -49,9 +43,18 @@ polars = { version = "0.35.0", features = ["lazy", "parquet"] } rayon = "1.7" [features] -default = [] +default = ["metaload"] # Enabling this flag significantly increases compilation times due to Arrow and Polars. spkezr_validation = [] +python = ["pyo3", "pyo3-log"] +metaload = [ + "url", + "serde", + "serde_derive", + "serde_dhall", + "reqwest/blocking", + "platform-dirs", +] [[bench]] name = "iai_jpl_ephemerides" diff --git a/anise/src/almanac/meta.rs b/anise/src/almanac/meta.rs new file mode 100644 index 00000000..1fe95b33 --- /dev/null +++ b/anise/src/almanac/meta.rs @@ -0,0 +1,341 @@ +/* + * ANISE Toolkit + * Copyright (C) 2021-2023 Christopher Rabotin et al. (cf. AUTHORS.md) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * Documentation: https://nyxspace.com/ + */ + +use log::{debug, info}; +use platform_dirs::AppDirs; +use reqwest::StatusCode; +use serde_derive::{Deserialize, Serialize}; +use serde_dhall::{SimpleType, StaticType}; +use snafu::prelude::*; +use std::fs::{create_dir_all, File}; +use std::io::Write; +use std::path::Path; +use url::Url; + +#[cfg(feature = "python")] +use pyo3::exceptions::PyTypeError; +#[cfg(feature = "python")] +use pyo3::prelude::*; +#[cfg(feature = "python")] +use pyo3::pyclass::CompareOp; + +use crate::errors::{AlmanacError, MetaSnafu}; +use crate::file2heap; +use crate::prelude::InputOutputError; + +use super::Almanac; + +#[derive(Debug, Snafu)] +#[snafu(visibility(pub(crate)))] +pub enum MetaAlmanacError { + #[snafu(display("could not create the cache folder for ANISE, please use a relative path"))] + AppDirError, + #[snafu(display("could not find a file path in {path}"))] + MissingFilePath { path: String }, + #[snafu(display("IO error {source} when {what} with {path}"))] + MetaIO { + path: String, + what: &'static str, + source: InputOutputError, + }, + #[snafu(display("fetching {uri} returned {status}"))] + FetchError { status: StatusCode, uri: String }, + #[snafu(display("connection {uri} returned {error}"))] + CnxError { uri: String, error: String }, + #[snafu(display("error parsing {path} as Dhall config: {err}"))] + ParseDhall { path: String, err: String }, + #[snafu(display("error exporting as Dhall config: {err}"))] + ExportDhall { err: String }, +} + +/// A structure to set up an Almanac, with automatic downloading, local storage, checksum checking, and more. +/// +/// # Behavior +/// If the URI is a local path, relative or absolute, nothing will be fetched from a remote. Relative paths are relative to the execution folder (i.e. the current working directory). +/// If the URI is a remote path, the MetaAlmanac will first check if the file exists locally. If it exists, it will check that the CRC32 checksum of this file matches that of the specs. +/// If it does not match, the file will be downloaded again. If no CRC32 is provided but the file exists, then the MetaAlmanac will fetch the remote file and overwrite the existing file. +/// The downloaded path will be stored in the "AppData" folder. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "python", pyclass)] +#[cfg_attr(feature = "python", pyo3(module = "anise"))] +pub struct MetaAlmanac { + files: Vec, +} + +impl MetaAlmanac { + /// Loads the provided path as a Dhall file and processes each file. + pub fn new(path: String) -> Result { + match serde_dhall::from_file(&path).parse::() { + Err(e) => Err(MetaAlmanacError::ParseDhall { + path, + err: format!("{e}"), + }), + Ok(me) => Ok(me), + } + } +} + +#[cfg_attr(feature = "python", pymethods)] +impl MetaAlmanac { + /// Loads the provided path as a Dhall file and processes each file. + #[cfg(feature = "python")] + #[new] + pub fn py_new(path: String) -> Result { + Self::new(path) + } + + /// Fetch all of the data and return a loaded Almanac + pub fn process(&mut self) -> Result { + for uri in &mut self.files { + uri.process().with_context(|_| MetaSnafu)?; + } + // At this stage, all of the files are local files, so we can load them as is. + let mut ctx = Almanac::default(); + for uri in &self.files { + ctx = ctx.load(&uri.uri)?; + } + Ok(ctx) + } + + /// Dumps the configured Meta Almanac into a Dhall string + pub fn dump(&self) -> Result { + // Define the Dhall type + let dhall_type: SimpleType = + serde_dhall::from_str("{ files : List { uri : Text, crc32 : Optional Natural } }") + .parse() + .unwrap(); + + serde_dhall::serialize(&self) + .type_annotation(&dhall_type) + .to_string() + .map_err(|e| MetaAlmanacError::ExportDhall { + err: format!("{e}"), + }) + } + + #[cfg(feature = "python")] + fn __str__(&self) -> String { + format!("{self:?}") + } + + #[cfg(feature = "python")] + fn __repr__(&self) -> String { + format!("{self:?} (@{self:p})") + } + + #[cfg(feature = "python")] + fn __richcmp__(&self, other: &Self, op: CompareOp) -> Result { + match op { + CompareOp::Eq => Ok(self == other), + CompareOp::Ne => Ok(self != other), + _ => Err(PyErr::new::(format!( + "{op:?} not available" + ))), + } + } +} + +/// By default, the MetaAlmanac will download the DE440s.bsp file, the PCK0008.PCA, and the latest high precision Earth kernel from JPL. +/// +/// # File list +/// - +/// - +/// - +/// +/// # Reproducibility +/// +/// Note that the `earth_latest_high_prec.bpc` file is regularily updated daily (or so). As such, +/// if queried at some future time, the Earth rotation parameters may have changed between two queries. +/// +impl Default for MetaAlmanac { + fn default() -> Self { + let nyx_cloud_stor = Url::parse("http://public-data.nyxspace.com/anise/").unwrap(); + let jpl_cloud_stor = + Url::parse("https://naif.jpl.nasa.gov/pub/naif/generic_kernels/").unwrap(); + + Self { + files: vec![ + MetaFile { + uri: nyx_cloud_stor.join("de440s.bsp").unwrap().to_string(), + crc32: Some(0x7286750a), + }, + MetaFile { + uri: nyx_cloud_stor.join("pck08.pca").unwrap().to_string(), + crc32: Some(0x487bee78), + }, + MetaFile { + uri: jpl_cloud_stor + .join("pck/earth_latest_high_prec.bpc") + .unwrap() + .to_string(), + crc32: None, + }, + ], + } + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, StaticType)] +pub struct MetaFile { + pub uri: String, + /// Optionally specify the CRC32 of this file, which will be checked prior to loading. + pub crc32: Option, +} + +impl MetaFile { + fn process(&mut self) -> Result<(), MetaAlmanacError> { + match Url::parse(&self.uri) { + Err(e) => { + debug!("parsing {} caused {e} -- assuming local path", self.uri); + Ok(()) + } + Ok(url) => { + // Build the path for this file. + match url.path_segments().and_then(|segments| segments.last()) { + Some(remote_file_path) => { + match Path::new(remote_file_path).file_name() { + Some(file_name) => { + match AppDirs::new(Some("nyx-space/anise"), true) { + Some(app_dir) => { + // Check whether the path currently exists. + let dest_path = app_dir.data_dir.join(file_name); + + if !app_dir.data_dir.exists() { + // Create the folders + create_dir_all(app_dir.data_dir).map_err(|e| { + MetaAlmanacError::MetaIO { + path: dest_path.to_str().unwrap().into(), + what: "creating directories for storage", + source: InputOutputError::IOError { + kind: e.kind(), + }, + } + })?; + } + + if dest_path.exists() { + if let Some(crc32) = self.crc32 { + // Open the file and check the CRC32 + let dest_path_c = dest_path.clone(); // macro token issue + if let Ok(bytes) = file2heap!(dest_path_c) { + if crc32fast::hash(&bytes) == crc32 { + // No need to redownload this, let's just update the uri path + self.uri = + dest_path.to_str().unwrap().to_string(); + info!( + "Using cached {} (CRC32 matched)", + self.uri + ); + return Ok(()); + } + } + } + } + + // At this stage, either the dest path does not exist, or the CRC32 check failed. + match reqwest::blocking::get(url.clone()) { + Ok(resp) => { + if resp.status().is_success() { + // Downloaded the file, let's store it locally. + match File::create(&dest_path) { + Err(e) => Err(MetaAlmanacError::MetaIO { + path: dest_path + .to_str() + .unwrap() + .into(), + what: "creating file for storage", + source: InputOutputError::IOError { + kind: e.kind(), + }, + }), + Ok(mut file) => { + // Created the file, let's write the bytes. + let bytes = resp.bytes().unwrap(); + let crc32 = crc32fast::hash(&bytes); + file.write_all(&bytes).unwrap(); + + info!( + "Saved {url} to {} (CRC32 = {crc32:x})", + dest_path.to_str().unwrap() + ); + + // Set the URI for loading + self.uri = dest_path + .to_str() + .unwrap() + .to_string(); + + // Set the CRC32 + self.crc32 = Some(crc32); + + Ok(()) + } + } + } else { + let err = resp.error_for_status().unwrap(); + Err(MetaAlmanacError::FetchError { + status: err.status(), + uri: self.uri.clone(), + }) + } + } + Err(e) => Err(MetaAlmanacError::CnxError { + uri: self.uri.clone(), + error: format!("{e}"), + }), + } + } + None => Err(MetaAlmanacError::AppDirError), + } + } + None => Err(MetaAlmanacError::MissingFilePath { + path: self.uri.clone(), + }), + } + } + None => Err(MetaAlmanacError::MissingFilePath { + path: self.uri.clone(), + }), + } + } + } + } +} + +#[cfg(test)] +mod meta_test { + use super::{MetaAlmanac, Path}; + use std::env; + + #[test] + fn test_meta_almanac() { + let _ = pretty_env_logger::try_init(); + let mut meta = MetaAlmanac::default(); + println!("{meta:?}"); + + let almanac = meta.process().unwrap(); + println!("{almanac}"); + + // Process again to confirm that the CRC check works + assert!(meta.process().is_ok()); + } + + #[test] + fn test_from_dhall() { + let default = MetaAlmanac::default(); + + println!("{}", default.dump().unwrap()); + + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../data/default_meta.dhall"); + let dhall = MetaAlmanac::new(path.to_str().unwrap().to_string()).unwrap(); + + assert_eq!(dhall, default); + } +} diff --git a/anise/src/almanac/mod.rs b/anise/src/almanac/mod.rs index 50679647..ed260319 100644 --- a/anise/src/almanac/mod.rs +++ b/anise/src/almanac/mod.rs @@ -38,11 +38,22 @@ pub mod planetary; pub mod spk; pub mod transform; +#[cfg(feature = "metaload")] +pub mod meta; + +#[cfg(feature = "python")] +mod python; + +#[cfg(feature = "python")] +use pyo3::prelude::*; + /// An Almanac contains all of the loaded SPICE and ANISE data. /// /// # Limitations /// The stack space required depends on the maximum number of each type that can be loaded. #[derive(Clone, Default)] +#[cfg_attr(feature = "python", pyclass)] +#[cfg_attr(feature = "python", pyo3(module = "anise"))] pub struct Almanac { /// NAIF SPK is kept unchanged pub spk_data: [Option; MAX_LOADED_SPKS], @@ -75,6 +86,11 @@ impl fmt::Display for Almanac { } impl Almanac { + /// Initializes a new Almanac from the provided file path, guessing at the file type + pub fn new(path: &str) -> Result { + Self::default().load(path) + } + /// Loads the provided spacecraft data into a clone of this original Almanac. pub fn with_spacecraft_data(&self, spacecraft_data: SpacecraftDataSet) -> Self { let mut me = self.clone(); @@ -89,55 +105,49 @@ impl Almanac { me } - /// Generic function that tries to load whichever path is provided, guessing to the type. - pub fn load(&self, path: &str) -> Result { - // Load the data onto the heap - let bytes = file2heap!(path).with_context(|_| LoadingSnafu { - path: path.to_string(), - })?; - info!("Loading almanac from {path}"); - self.load_from_bytes(bytes) - } - pub fn load_from_bytes(&self, bytes: Bytes) -> Result { // Try to load as a SPICE DAF first (likely the most typical use case) // Load the header only - let file_record = FileRecord::read_from(&bytes[..FileRecord::SIZE]).unwrap(); - - if let Ok(fileid) = file_record.identification() { - match fileid { - "PCK" => { - info!("Loading as DAF/PCK"); - let bpc = BPC::parse(bytes) - .with_context(|_| BPCSnafu { - action: "parsing bytes", + if let Some(file_record_bytes) = bytes.get(..FileRecord::SIZE) { + let file_record = FileRecord::read_from(file_record_bytes).unwrap(); + if let Ok(fileid) = file_record.identification() { + return match fileid { + "PCK" => { + info!("Loading as DAF/PCK"); + let bpc = BPC::parse(bytes) + .with_context(|_| BPCSnafu { + action: "parsing bytes", + }) + .with_context(|_| OrientationSnafu { + action: "from generic loading", + })?; + self.with_bpc(bpc).with_context(|_| OrientationSnafu { + action: "adding BPC file to context", }) - .with_context(|_| OrientationSnafu { - action: "from generic loading", - })?; - self.with_bpc(bpc).with_context(|_| OrientationSnafu { - action: "adding BPC file to context", - }) - } - "SPK" => { - info!("Loading as DAF/SPK"); - let spk = SPK::parse(bytes) - .with_context(|_| SPKSnafu { - action: "parsing bytes", + } + "SPK" => { + info!("Loading as DAF/SPK"); + let spk = SPK::parse(bytes) + .with_context(|_| SPKSnafu { + action: "parsing bytes", + }) + .with_context(|_| EphemerisSnafu { + action: "from generic loading", + })?; + self.with_spk(spk).with_context(|_| EphemerisSnafu { + action: "adding SPK file to context", }) - .with_context(|_| EphemerisSnafu { - action: "from generic loading", - })?; - self.with_spk(spk).with_context(|_| EphemerisSnafu { - action: "adding SPK file to context", - }) - } - fileid => Err(AlmanacError::GenericError { - err: format!("DAF/{fileid} is not yet supported"), - }), + } + fileid => Err(AlmanacError::GenericError { + err: format!("DAF/{fileid} is not yet supported"), + }), + }; } - } else if let Ok(metadata) = Metadata::decode_header(&bytes) { + // Fall through to try to load as an ANISE file + } + + if let Ok(metadata) = Metadata::decode_header(&bytes) { // Now, we can load this depending on the kind of data that it is match metadata.dataset_type { DataSetType::NotApplicable => unreachable!("no such ANISE data yet"), @@ -177,3 +187,23 @@ impl Almanac { } } } + +#[cfg_attr(feature = "python", pymethods)] +impl Almanac { + /// Generic function that tries to load the provided path guessing to the file type. + pub fn load(&self, path: &str) -> Result { + // Load the data onto the heap + let bytes = file2heap!(path).with_context(|_| LoadingSnafu { + path: path.to_string(), + })?; + info!("Loading almanac from {path}"); + self.load_from_bytes(bytes) + } + + /// Initializes a new Almanac from the provided file path, guessing at the file type + #[cfg(feature = "python")] + #[new] + pub fn py_new(path: &str) -> Result { + Self::new(path) + } +} diff --git a/anise/src/almanac/python.rs b/anise/src/almanac/python.rs new file mode 100644 index 00000000..62396ca7 --- /dev/null +++ b/anise/src/almanac/python.rs @@ -0,0 +1,30 @@ +/* + * ANISE Toolkit + * Copyright (C) 2021-2023 Christopher Rabotin et al. (cf. AUTHORS.md) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * Documentation: https://nyxspace.com/ + */ + +use super::{ + planetary::{PlanetaryDataError, PlanetaryDataSetSnafu}, + Almanac, +}; +use crate::prelude::Frame; +use pyo3::prelude::*; +use snafu::prelude::*; + +#[pymethods] +impl Almanac { + pub fn frame_info(&self, uid: Frame) -> Result { + Ok(self + .planetary_data + .get_by_id(uid.ephemeris_id) + .with_context(|_| PlanetaryDataSetSnafu { + action: "fetching frame by its UID via ephemeris_id", + })? + .to_frame(uid.into())) + } +} diff --git a/anise/src/almanac/transform.rs b/anise/src/almanac/transform.rs index a1da7f68..63a52373 100644 --- a/anise/src/almanac/transform.rs +++ b/anise/src/almanac/transform.rs @@ -21,6 +21,10 @@ use crate::{ use super::Almanac; +#[cfg(feature = "python")] +use pyo3::prelude::*; + +#[cfg_attr(feature = "python", pymethods)] impl Almanac { /// Returns the Cartesian state needed to transform the `from_frame` to the `to_frame`. /// @@ -83,6 +87,22 @@ impl Almanac { }) } + /// Returns the Cartesian state of the object as seen from the provided observer frame (essentially `spkezr`). + /// + /// # Note + /// The units will be those of the underlying ephemeris data (typically km and km/s) + pub fn state_of( + &self, + object: NaifId, + observer: Frame, + epoch: Epoch, + ab_corr: Aberration, + ) -> Result { + self.transform_from_to(Frame::from_ephem_j2000(object), observer, epoch, ab_corr) + } +} + +impl Almanac { /// Translates a state with its origin (`to_frame`) and given its units (distance_unit, time_unit), returns that state with respect to the requested frame /// /// **WARNING:** This function only performs the translation and no rotation _whatsoever_. Use the `transform_state_to` function instead to include rotations. @@ -126,18 +146,4 @@ impl Almanac { action: "transform provided state", }) } - - /// Returns the Cartesian state of the object as seen from the provided observer frame (essentially `spkezr`). - /// - /// # Note - /// The units will be those of the underlying ephemeris data (typically km and km/s) - pub fn state_of( - &self, - object: NaifId, - observer: Frame, - epoch: Epoch, - ab_corr: Aberration, - ) -> Result { - self.transform_from_to(Frame::from_ephem_j2000(object), observer, epoch, ab_corr) - } } diff --git a/anise/src/astro/mod.rs b/anise/src/astro/mod.rs index 67667bd9..e808c7c4 100644 --- a/anise/src/astro/mod.rs +++ b/anise/src/astro/mod.rs @@ -10,11 +10,16 @@ use crate::errors::PhysicsError; +#[cfg(feature = "python")] +use pyo3::prelude::*; + /// Defines the aberration corrections to the state of the target body to account for one-way light time and stellar aberration. /// **WARNING:** This enum is a placeholder until [https://github.com/anise-toolkit/anise.rs/issues/26] is implemented. #[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "python", pyclass)] +#[cfg_attr(feature = "python", pyo3(module = "anise."))] pub enum Aberration { - None, + NotSet, } pub mod orbit; diff --git a/anise/src/astro/orbit.rs b/anise/src/astro/orbit.rs index f83d1d8a..2d4c9404 100644 --- a/anise/src/astro/orbit.rs +++ b/anise/src/astro/orbit.rs @@ -28,6 +28,11 @@ use hifitime::{Duration, Epoch, TimeUnits}; use log::{error, info, warn}; use snafu::ensure; +#[cfg(feature = "python")] +use pyo3::prelude::*; +#[cfg(feature = "python")] +use pyo3::types::PyType; + /// If an orbit has an eccentricity below the following value, it is considered circular (only affects warning messages) pub const ECC_EPSILON: f64 = 1e-11; @@ -253,6 +258,65 @@ impl CartesianState { Ok(self.radius_km.cross(&self.velocity_km_s)) } + /// Returns the eccentricity vector (no unit) + pub fn evec(&self) -> Result { + let r = self.radius_km; + ensure!( + self.rmag_km() > EPSILON, + RadiusSnafu { + action: "cannot compute eccentricity vector with zero radial state" + } + ); + let v = self.velocity_km_s; + Ok( + ((v.norm().powi(2) - self.frame.mu_km3_s2()? / r.norm()) * r - (r.dot(&v)) * v) + / self.frame.mu_km3_s2()?, + ) + } +} + +#[cfg_attr(feature = "python", pymethods)] +impl CartesianState { + /// Creates a new Orbit around the provided Celestial or Geoid frame from the Keplerian orbital elements. + /// + /// **Units:** km, none, degrees, degrees, degrees, degrees + /// + /// NOTE: The state is defined in Cartesian coordinates as they are non-singular. This causes rounding + /// errors when creating a state from its Keplerian orbital elements (cf. the state tests). + /// One should expect these errors to be on the order of 1e-12. + #[cfg(feature = "python")] + #[classmethod] + pub fn from_keplerian( + _cls: &PyType, + sma: f64, + ecc: f64, + inc: f64, + raan: f64, + aop: f64, + ta: f64, + epoch: Epoch, + frame: Frame, + ) -> PhysicsResult { + Self::try_keplerian(sma, ecc, inc, raan, aop, ta, epoch, frame) + } + + /// Attempts to create a new Orbit from the provided radii of apoapsis and periapsis, in kilometers + #[cfg(feature = "python")] + #[classmethod] + pub fn from_keplerian_apsis_radii( + _cls: &PyType, + r_a: f64, + r_p: f64, + inc: f64, + raan: f64, + aop: f64, + ta: f64, + epoch: Epoch, + frame: Frame, + ) -> PhysicsResult { + Self::try_keplerian_apsis_radii(r_a, r_p, inc, raan, aop, ta, epoch, frame) + } + /// Returns the orbital momentum value on the X axis pub fn hx(&self) -> PhysicsResult { Ok(self.hvec()?[0]) @@ -309,15 +373,15 @@ impl CartesianState { } /// Returns a copy of the state with a new SMA - pub fn with_sma(self, new_sma_km: f64) -> PhysicsResult { - let mut me = self; + pub fn with_sma(&self, new_sma_km: f64) -> PhysicsResult { + let mut me = *self; me.set_sma(new_sma_km)?; Ok(me) } /// Returns a copy of the state with a provided SMA added to the current one - pub fn add_sma(self, delta_sma: f64) -> PhysicsResult { - let mut me = self; + pub fn add_sma(&self, delta_sma: f64) -> PhysicsResult { + let mut me = *self; me.set_sma(me.sma_km()? + delta_sma)?; Ok(me) } @@ -331,22 +395,6 @@ impl CartesianState { .seconds()) } - /// Returns the eccentricity vector (no unit) - pub fn evec(&self) -> Result { - let r = self.radius_km; - ensure!( - self.rmag_km() > EPSILON, - RadiusSnafu { - action: "cannot compute eccentricity vector with zero radial state" - } - ); - let v = self.velocity_km_s; - Ok( - ((v.norm().powi(2) - self.frame.mu_km3_s2()? / r.norm()) * r - (r.dot(&v)) * v) - / self.frame.mu_km3_s2()?, - ) - } - /// Returns the eccentricity (no unit) pub fn ecc(&self) -> PhysicsResult { Ok(self.evec()?.norm()) @@ -371,15 +419,15 @@ impl CartesianState { } /// Returns a copy of the state with a new ECC - pub fn with_ecc(self, new_ecc: f64) -> PhysicsResult { - let mut me = self; + pub fn with_ecc(&self, new_ecc: f64) -> PhysicsResult { + let mut me = *self; me.set_ecc(new_ecc)?; Ok(me) } /// Returns a copy of the state with a provided ECC added to the current one - pub fn add_ecc(self, delta_ecc: f64) -> PhysicsResult { - let mut me = self; + pub fn add_ecc(&self, delta_ecc: f64) -> PhysicsResult { + let mut me = *self; me.set_ecc(me.ecc()? + delta_ecc)?; Ok(me) } @@ -408,15 +456,15 @@ impl CartesianState { } /// Returns a copy of the state with a new INC - pub fn with_inc_deg(self, new_inc_deg: f64) -> PhysicsResult { - let mut me = self; + pub fn with_inc_deg(&self, new_inc_deg: f64) -> PhysicsResult { + let mut me = *self; me.set_inc_deg(new_inc_deg)?; Ok(me) } /// Returns a copy of the state with a provided INC added to the current one - pub fn add_inc_deg(self, delta_inc_deg: f64) -> PhysicsResult { - let mut me = self; + pub fn add_inc_deg(&self, delta_inc_deg: f64) -> PhysicsResult { + let mut me = *self; me.set_inc_deg(me.inc_deg()? + delta_inc_deg)?; Ok(me) } @@ -458,15 +506,15 @@ impl CartesianState { } /// Returns a copy of the state with a new AOP - pub fn with_aop_deg(self, new_aop_deg: f64) -> PhysicsResult { - let mut me = self; + pub fn with_aop_deg(&self, new_aop_deg: f64) -> PhysicsResult { + let mut me = *self; me.set_aop_deg(new_aop_deg)?; Ok(me) } /// Returns a copy of the state with a provided AOP added to the current one - pub fn add_aop_deg(self, delta_aop_deg: f64) -> PhysicsResult { - let mut me = self; + pub fn add_aop_deg(&self, delta_aop_deg: f64) -> PhysicsResult { + let mut me = *self; me.set_aop_deg(me.aop_deg()? + delta_aop_deg)?; Ok(me) } @@ -508,15 +556,15 @@ impl CartesianState { } /// Returns a copy of the state with a new RAAN - pub fn with_raan_deg(self, new_raan_deg: f64) -> PhysicsResult { - let mut me = self; + pub fn with_raan_deg(&self, new_raan_deg: f64) -> PhysicsResult { + let mut me = *self; me.set_raan_deg(new_raan_deg)?; Ok(me) } /// Returns a copy of the state with a provided RAAN added to the current one - pub fn add_raan_deg(self, delta_raan_deg: f64) -> PhysicsResult { - let mut me = self; + pub fn add_raan_deg(&self, delta_raan_deg: f64) -> PhysicsResult { + let mut me = *self; me.set_raan_deg(me.raan_deg()? + delta_raan_deg)?; Ok(me) } @@ -571,21 +619,25 @@ impl CartesianState { } /// Returns a copy of the state with a new TA - pub fn with_ta_deg(self, new_ta_deg: f64) -> PhysicsResult { - let mut me = self; + pub fn with_ta_deg(&self, new_ta_deg: f64) -> PhysicsResult { + let mut me = *self; me.set_ta_deg(new_ta_deg)?; Ok(me) } /// Returns a copy of the state with a provided TA added to the current one - pub fn add_ta_deg(self, delta_ta_deg: f64) -> PhysicsResult { - let mut me = self; + pub fn add_ta_deg(&self, delta_ta_deg: f64) -> PhysicsResult { + let mut me = *self; me.set_ta_deg(me.ta_deg()? + delta_ta_deg)?; Ok(me) } /// Returns a copy of this state with the provided apoasis and periapsis - pub fn with_apoapsis_periapsis_km(self, new_ra_km: f64, new_rp_km: f64) -> PhysicsResult { + pub fn with_apoapsis_periapsis_km( + &self, + new_ra_km: f64, + new_rp_km: f64, + ) -> PhysicsResult { Self::try_keplerian_apsis_radii( new_ra_km, new_rp_km, @@ -600,7 +652,7 @@ impl CartesianState { /// Returns a copy of this state with the provided apoasis and periapsis added to the current values pub fn add_apoapsis_periapsis_km( - self, + &self, delta_ra_km: f64, delta_rp_km: f64, ) -> PhysicsResult { @@ -816,7 +868,7 @@ impl fmt::UpperHex for Orbit { format!( "{:.*}", decimals, - self.geodetic_height().map_err(|err| { + self.geodetic_height_km().map_err(|err| { error!("{err}"); fmt::Error })? @@ -824,12 +876,12 @@ impl fmt::UpperHex for Orbit { format!( "{:.*}", decimals, - self.geodetic_latitude().map_err(|err| { + self.geodetic_latitude_deg().map_err(|err| { error!("{err}"); fmt::Error })? ), - format!("{:.*}", decimals, self.geodetic_longitude()), + format!("{:.*}", decimals, self.geodetic_longitude_deg()), ) } } diff --git a/anise/src/astro/orbit_geodetic.rs b/anise/src/astro/orbit_geodetic.rs index eb4f84e6..3fa5c00a 100644 --- a/anise/src/astro/orbit_geodetic.rs +++ b/anise/src/astro/orbit_geodetic.rs @@ -20,6 +20,11 @@ use crate::{ use hifitime::Epoch; use log::error; +#[cfg(feature = "python")] +use pyo3::prelude::*; +#[cfg(feature = "python")] +use pyo3::types::PyType; + impl CartesianState { /// Creates a new Orbit from the provided semi-major axis altitude in kilometers #[allow(clippy::too_many_arguments)] @@ -48,8 +53,8 @@ impl CartesianState { /// Creates a new Orbit from the provided altitudes of apoapsis and periapsis, in kilometers #[allow(clippy::too_many_arguments)] pub fn try_keplerian_apsis_altitude( - a_a: f64, - a_p: f64, + apo_alt: f64, + peri_alt: f64, inc: f64, raan: f64, aop: f64, @@ -58,8 +63,8 @@ impl CartesianState { frame: Frame, ) -> PhysicsResult { Self::try_keplerian_apsis_radii( - a_a + frame.mean_equatorial_radius_km()?, - a_p + frame.mean_equatorial_radius_km()?, + apo_alt + frame.mean_equatorial_radius_km()?, + peri_alt + frame.mean_equatorial_radius_km()?, inc, raan, aop, @@ -74,7 +79,7 @@ impl CartesianState { /// **Units:** degrees, degrees, km, rad/s /// NOTE: This computation differs from the spherical coordinates because we consider the flattening of body. /// Reference: G. Xu and Y. Xu, "GPS", DOI 10.1007/978-3-662-50367-6_2, 2016 - pub fn from_altlatlong( + pub fn try_from_latlongalt( latitude_deg: f64, longitude_deg: f64, height_km: f64, @@ -105,19 +110,82 @@ impl CartesianState { frame, )) } +} + +#[cfg_attr(feature = "python", pymethods)] +impl CartesianState { + /// Creates a new Orbit from the provided semi-major axis altitude in kilometers + #[cfg(feature = "python")] + #[classmethod] + pub fn from_keplerian_altitude( + _cls: &PyType, + sma_altitude: f64, + ecc: f64, + inc: f64, + raan: f64, + aop: f64, + ta: f64, + epoch: Epoch, + frame: Frame, + ) -> PhysicsResult { + Self::try_keplerian_altitude(sma_altitude, ecc, inc, raan, aop, ta, epoch, frame) + } + + /// Creates a new Orbit from the provided altitudes of apoapsis and periapsis, in kilometers + #[cfg(feature = "python")] + #[classmethod] + pub fn from_keplerian_apsis_altitude( + _cls: &PyType, + apo_alt: f64, + peri_alt: f64, + inc: f64, + raan: f64, + aop: f64, + ta: f64, + epoch: Epoch, + frame: Frame, + ) -> PhysicsResult { + Self::try_keplerian_apsis_altitude(apo_alt, peri_alt, inc, raan, aop, ta, epoch, frame) + } + + /// Creates a new Orbit from the latitude (φ), longitude (λ) and height (in km) with respect to the frame's ellipsoid given the angular velocity. + /// + /// **Units:** degrees, degrees, km, rad/s + /// NOTE: This computation differs from the spherical coordinates because we consider the flattening of body. + /// Reference: G. Xu and Y. Xu, "GPS", DOI 10.1007/978-3-662-50367-6_2, 2016 + #[cfg(feature = "python")] + #[classmethod] + pub fn from_latlongalt( + _cls: &PyType, + latitude_deg: f64, + longitude_deg: f64, + height_km: f64, + angular_velocity: f64, + epoch: Epoch, + frame: Frame, + ) -> PhysicsResult { + Self::try_from_latlongalt( + latitude_deg, + longitude_deg, + height_km, + angular_velocity, + epoch, + frame, + ) + } /// Returns the SMA altitude in km - pub fn sma_altitude(&self) -> PhysicsResult { + pub fn sma_altitude_km(&self) -> PhysicsResult { Ok(self.sma_km()? - self.frame.mean_equatorial_radius_km()?) } /// Returns the altitude of periapsis (or perigee around Earth), in kilometers. - pub fn periapsis_altitude(&self) -> PhysicsResult { + pub fn periapsis_altitude_km(&self) -> PhysicsResult { Ok(self.periapsis_km()? - self.frame.mean_equatorial_radius_km()?) } /// Returns the altitude of apoapsis (or apogee around Earth), in kilometers. - pub fn apoapsis_altitude(&self) -> PhysicsResult { + pub fn apoapsis_altitude_km(&self) -> PhysicsResult { Ok(self.apoapsis_km()? - self.frame.mean_equatorial_radius_km()?) } @@ -125,14 +193,14 @@ impl CartesianState { /// /// Although the reference is not Vallado, the math from Vallado proves to be equivalent. /// Reference: G. Xu and Y. Xu, "GPS", DOI 10.1007/978-3-662-50367-6_2, 2016 - pub fn geodetic_longitude(&self) -> f64 { + pub fn geodetic_longitude_deg(&self) -> f64 { between_0_360(self.radius_km.y.atan2(self.radius_km.x).to_degrees()) } /// Returns the geodetic latitude (φ) in degrees. Value is between -180 and +180 degrees. /// /// Reference: Vallado, 4th Ed., Algorithm 12 page 172. - pub fn geodetic_latitude(&self) -> PhysicsResult { + pub fn geodetic_latitude_deg(&self) -> PhysicsResult { let eps = 1e-12; let max_attempts = 20; let mut attempt_no = 0; @@ -160,9 +228,9 @@ impl CartesianState { /// Returns the geodetic height in km. /// /// Reference: Vallado, 4th Ed., Algorithm 12 page 172. - pub fn geodetic_height(&self) -> PhysicsResult { + pub fn geodetic_height_km(&self) -> PhysicsResult { let e2 = self.frame.flattening()? * (2.0 - self.frame.flattening()?); - let latitude = self.geodetic_latitude()?.to_radians(); + let latitude = self.geodetic_latitude_deg()?.to_radians(); let sin_lat = latitude.sin(); if (latitude - 1.0).abs() < 0.1 { // We are near poles, let's use another formulation. diff --git a/anise/src/constants.rs b/anise/src/constants.rs index f6cd2725..e5dee64b 100644 --- a/anise/src/constants.rs +++ b/anise/src/constants.rs @@ -226,35 +226,34 @@ pub mod frames { use super::{celestial_objects::*, orientations::*}; - pub const SSB_J2000: Frame = Frame::from_ephem_orient(SOLAR_SYSTEM_BARYCENTER, J2000); - pub const MERCURY_J2000: Frame = Frame::from_ephem_orient(MERCURY, J2000); - pub const VENUS_J2000: Frame = Frame::from_ephem_orient(VENUS, J2000); - pub const EARTH_MOON_BARYCENTER_J2000: Frame = - Frame::from_ephem_orient(EARTH_MOON_BARYCENTER, J2000); - pub const MARS_BARYCENTER_J2000: Frame = Frame::from_ephem_orient(MARS_BARYCENTER, J2000); - pub const JUPITER_BARYCENTER_J2000: Frame = Frame::from_ephem_orient(JUPITER_BARYCENTER, J2000); - pub const SATURN_BARYCENTER_J2000: Frame = Frame::from_ephem_orient(SATURN_BARYCENTER, J2000); - pub const URANUS_BARYCENTER_J2000: Frame = Frame::from_ephem_orient(URANUS_BARYCENTER, J2000); - pub const NEPTUNE_BARYCENTER_J2000: Frame = Frame::from_ephem_orient(NEPTUNE_BARYCENTER, J2000); - pub const PLUTO_BARYCENTER_J2000: Frame = Frame::from_ephem_orient(PLUTO_BARYCENTER, J2000); - pub const SUN_J2000: Frame = Frame::from_ephem_orient(SUN, J2000); - pub const LUNA_J2000: Frame = Frame::from_ephem_orient(LUNA, J2000); - pub const EARTH_J2000: Frame = Frame::from_ephem_orient(EARTH, J2000); - pub const EME2000: Frame = Frame::from_ephem_orient(EARTH, J2000); - pub const EARTH_ECLIPJ2000: Frame = Frame::from_ephem_orient(EARTH, ECLIPJ2000); + pub const SSB_J2000: Frame = Frame::new(SOLAR_SYSTEM_BARYCENTER, J2000); + pub const MERCURY_J2000: Frame = Frame::new(MERCURY, J2000); + pub const VENUS_J2000: Frame = Frame::new(VENUS, J2000); + pub const EARTH_MOON_BARYCENTER_J2000: Frame = Frame::new(EARTH_MOON_BARYCENTER, J2000); + pub const MARS_BARYCENTER_J2000: Frame = Frame::new(MARS_BARYCENTER, J2000); + pub const JUPITER_BARYCENTER_J2000: Frame = Frame::new(JUPITER_BARYCENTER, J2000); + pub const SATURN_BARYCENTER_J2000: Frame = Frame::new(SATURN_BARYCENTER, J2000); + pub const URANUS_BARYCENTER_J2000: Frame = Frame::new(URANUS_BARYCENTER, J2000); + pub const NEPTUNE_BARYCENTER_J2000: Frame = Frame::new(NEPTUNE_BARYCENTER, J2000); + pub const PLUTO_BARYCENTER_J2000: Frame = Frame::new(PLUTO_BARYCENTER, J2000); + pub const SUN_J2000: Frame = Frame::new(SUN, J2000); + pub const LUNA_J2000: Frame = Frame::new(LUNA, J2000); + pub const EARTH_J2000: Frame = Frame::new(EARTH, J2000); + pub const EME2000: Frame = Frame::new(EARTH, J2000); + pub const EARTH_ECLIPJ2000: Frame = Frame::new(EARTH, ECLIPJ2000); /// Body fixed IAU rotation - pub const IAU_MERCURY_FRAME: Frame = Frame::from_ephem_orient(MERCURY, IAU_MERCURY); - pub const IAU_VENUS_FRAME: Frame = Frame::from_ephem_orient(VENUS, IAU_VENUS); - pub const IAU_EARTH_FRAME: Frame = Frame::from_ephem_orient(EARTH, IAU_EARTH); - pub const IAU_MARS_FRAME: Frame = Frame::from_ephem_orient(MARS, IAU_MARS); - pub const IAU_JUPITER_FRAME: Frame = Frame::from_ephem_orient(JUPITER, IAU_JUPITER); - pub const IAU_SATURN_FRAME: Frame = Frame::from_ephem_orient(SATURN, IAU_SATURN); - pub const IAU_NEPTUNE_FRAME: Frame = Frame::from_ephem_orient(NEPTUNE, IAU_NEPTUNE); - pub const IAU_URANUS_FRAME: Frame = Frame::from_ephem_orient(URANUS, IAU_URANUS); + pub const IAU_MERCURY_FRAME: Frame = Frame::new(MERCURY, IAU_MERCURY); + pub const IAU_VENUS_FRAME: Frame = Frame::new(VENUS, IAU_VENUS); + pub const IAU_EARTH_FRAME: Frame = Frame::new(EARTH, IAU_EARTH); + pub const IAU_MARS_FRAME: Frame = Frame::new(MARS, IAU_MARS); + pub const IAU_JUPITER_FRAME: Frame = Frame::new(JUPITER, IAU_JUPITER); + pub const IAU_SATURN_FRAME: Frame = Frame::new(SATURN, IAU_SATURN); + pub const IAU_NEPTUNE_FRAME: Frame = Frame::new(NEPTUNE, IAU_NEPTUNE); + pub const IAU_URANUS_FRAME: Frame = Frame::new(URANUS, IAU_URANUS); /// Common high precision frame - pub const EARTH_ITRF93: Frame = Frame::from_ephem_orient(EARTH, ITRF93); + pub const EARTH_ITRF93: Frame = Frame::new(EARTH, ITRF93); } #[cfg(test)] diff --git a/anise/src/ephemerides/translations.rs b/anise/src/ephemerides/translations.rs index 038ca80f..93907555 100644 --- a/anise/src/ephemerides/translations.rs +++ b/anise/src/ephemerides/translations.rs @@ -23,6 +23,10 @@ use crate::prelude::Frame; /// **Limitation:** no translation or rotation may have more than 8 nodes. pub const MAX_TREE_DEPTH: usize = 8; +#[cfg(feature = "python")] +use pyo3::prelude::*; + +#[cfg_attr(feature = "python", pymethods)] impl Almanac { /// Returns the Cartesian state needed to translate the `from_frame` to the `to_frame`. /// @@ -110,7 +114,7 @@ impl Almanac { to_frame: Frame, epoch: Epoch, ) -> Result { - self.translate_from_to(from_frame, to_frame, epoch, Aberration::None) + self.translate_from_to(from_frame, to_frame, epoch, Aberration::NotSet) } /// Translates the provided Cartesian state into the requested frame @@ -125,9 +129,11 @@ impl Almanac { ) -> Result { let frame_state = self.translate_from_to(state.frame, to_frame, state.epoch, ab_corr)?; - Ok(state.add_unchecked(frame_state)) + Ok(state.add_unchecked(&frame_state)) } +} +impl Almanac { /// Translates a state with its origin (`to_frame`) and given its units (distance_unit, time_unit), returns that state with respect to the requested frame /// /// **WARNING:** This function only performs the translation and no rotation _whatsoever_. Use the [transform_state_to] function instead to include rotations. diff --git a/anise/src/errors.rs b/anise/src/errors.rs index 2d038961..fc8d665e 100644 --- a/anise/src/errors.rs +++ b/anise/src/errors.rs @@ -21,6 +21,9 @@ use core::convert::From; use der::Error as DerError; use std::io::ErrorKind as IOErrorKind; +#[cfg(feature = "metaload")] +use crate::almanac::meta::MetaAlmanacError; + #[derive(Debug, Snafu)] #[snafu(visibility(pub))] pub enum AlmanacError { @@ -46,9 +49,12 @@ pub enum AlmanacError { }, #[snafu(display("{err}"))] GenericError { err: String }, + #[cfg(feature = "metaload")] + Meta { source: MetaAlmanacError }, } #[derive(Debug, Snafu)] +#[snafu(visibility(pub(crate)))] pub enum InputOutputError { /// Raised for an error in reading or writing the file(s) IOError { kind: IOErrorKind }, diff --git a/anise/src/frames/frame.rs b/anise/src/frames/frame.rs index 39b3179a..f27e5d1e 100644 --- a/anise/src/frames/frame.rs +++ b/anise/src/frames/frame.rs @@ -11,6 +11,7 @@ use core::fmt; use core::fmt::Debug; +use crate::astro::PhysicsResult; use crate::constants::celestial_objects::{celestial_name_from_id, SOLAR_SYSTEM_BARYCENTER}; use crate::constants::orientations::{orientation_name_from_id, J2000}; use crate::errors::PhysicsError; @@ -18,8 +19,18 @@ use crate::prelude::FrameUid; use crate::structure::planetocentric::ellipsoid::Ellipsoid; use crate::NaifId; +#[cfg(feature = "python")] +use pyo3::exceptions::PyTypeError; +#[cfg(feature = "python")] +use pyo3::prelude::*; +#[cfg(feature = "python")] +use pyo3::pyclass::CompareOp; + /// A Frame uniquely defined by its ephemeris center and orientation. Refer to FrameDetail for frames combined with parameters. #[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "python", pyclass)] +#[cfg_attr(feature = "python", pyo3(get_all, set_all))] +#[cfg_attr(feature = "python", pyo3(module = "anise.astro"))] pub struct Frame { pub ephemeris_id: NaifId, pub orientation_id: NaifId, @@ -31,7 +42,7 @@ pub struct Frame { impl Frame { /// Constructs a new frame given its ephemeris and orientations IDs, without defining anything else (so this is not a valid celestial frame, although the data could be populated later). - pub const fn from_ephem_orient(ephemeris_id: NaifId, orientation_id: NaifId) -> Self { + pub const fn new(ephemeris_id: NaifId, orientation_id: NaifId) -> Self { Self { ephemeris_id, orientation_id, @@ -41,11 +52,63 @@ impl Frame { } pub const fn from_ephem_j2000(ephemeris_id: NaifId) -> Self { - Self::from_ephem_orient(ephemeris_id, J2000) + Self::new(ephemeris_id, J2000) } pub const fn from_orient_ssb(orientation_id: NaifId) -> Self { - Self::from_ephem_orient(SOLAR_SYSTEM_BARYCENTER, orientation_id) + Self::new(SOLAR_SYSTEM_BARYCENTER, orientation_id) + } +} + +#[cfg_attr(feature = "python", pymethods)] +impl Frame { + /// Initializes a new [Frame] provided its ephemeris and orientation identifiers, and optionally its gravitational parameter (in km^3/s^2) and optionally its shape (cf. [Ellipsoid]). + #[cfg(feature = "python")] + #[new] + pub fn py_new( + ephemeris_id: NaifId, + orientation_id: NaifId, + mu_km3_s2: Option, + shape: Option, + ) -> Self { + Self { + ephemeris_id, + orientation_id, + mu_km3_s2, + shape, + } + } + + #[cfg(feature = "python")] + fn __str__(&self) -> String { + format!("{self}") + } + + #[cfg(feature = "python")] + fn __repr__(&self) -> String { + format!("{self} (@{self:p})") + } + + #[cfg(feature = "python")] + fn __richcmp__(&self, other: &Self, op: CompareOp) -> Result { + match op { + CompareOp::Eq => Ok(self == other), + CompareOp::Ne => Ok(self != other), + _ => Err(PyErr::new::(format!( + "{op:?} not available" + ))), + } + } + + /// Allows for pickling the object + #[cfg(feature = "python")] + fn __getnewargs__(&self) -> Result<(NaifId, NaifId, Option, Option), PyErr> { + Ok(( + self.ephemeris_id, + self.orientation_id, + self.mu_km3_s2, + self.shape, + )) } /// Returns a copy of this Frame whose ephemeris ID is set to the provided ID @@ -90,7 +153,7 @@ impl Frame { } /// Returns the gravitational parameters of this frame, if defined - pub fn mu_km3_s2(&self) -> Result { + pub fn mu_km3_s2(&self) -> PhysicsResult { self.mu_km3_s2.ok_or(PhysicsError::MissingFrameData { action: "retrieving mean equatorial radius", data: "shape", @@ -99,7 +162,7 @@ impl Frame { } /// Returns the mean equatorial radius in km, if defined - pub fn mean_equatorial_radius_km(&self) -> Result { + pub fn mean_equatorial_radius_km(&self) -> PhysicsResult { Ok(self .shape .ok_or(PhysicsError::MissingFrameData { @@ -111,7 +174,7 @@ impl Frame { } /// Returns the semi major radius of the tri-axial ellipoid shape of this frame, if defined - pub fn semi_major_radius_km(&self) -> Result { + pub fn semi_major_radius_km(&self) -> PhysicsResult { Ok(self .shape .ok_or(PhysicsError::MissingFrameData { @@ -122,7 +185,7 @@ impl Frame { .semi_major_equatorial_radius_km) } - pub fn flattening(&self) -> Result { + pub fn flattening(&self) -> PhysicsResult { Ok(self .shape .ok_or(PhysicsError::MissingFrameData { diff --git a/anise/src/frames/frameuid.rs b/anise/src/frames/frameuid.rs index 878160a4..40fac26d 100644 --- a/anise/src/frames/frameuid.rs +++ b/anise/src/frames/frameuid.rs @@ -49,13 +49,13 @@ impl From<&Frame> for FrameUid { impl From for Frame { fn from(uid: FrameUid) -> Self { - Self::from_ephem_orient(uid.ephemeris_id, uid.orientation_id) + Self::new(uid.ephemeris_id, uid.orientation_id) } } impl From<&FrameUid> for Frame { fn from(uid: &FrameUid) -> Self { - Self::from_ephem_orient(uid.ephemeris_id, uid.orientation_id) + Self::new(uid.ephemeris_id, uid.orientation_id) } } diff --git a/anise/src/lib.rs b/anise/src/lib.rs index c530dc6d..9f715e9d 100644 --- a/anise/src/lib.rs +++ b/anise/src/lib.rs @@ -42,6 +42,9 @@ pub mod prelude { pub use std::fs::File; } +#[cfg(feature = "python")] +mod py_errors; + /// Defines the number of bytes in a double (prevents magic numbers) pub(crate) const DBL_SIZE: usize = 8; diff --git a/anise/src/math/cartesian.rs b/anise/src/math/cartesian.rs index 222c3e60..d3c1537d 100644 --- a/anise/src/math/cartesian.rs +++ b/anise/src/math/cartesian.rs @@ -20,11 +20,16 @@ use hifitime::Epoch; use nalgebra::Vector6; use snafu::ensure; +#[cfg(feature = "python")] +use pyo3::prelude::*; + /// Defines a Cartesian state in a given frame at a given epoch in a given time scale. Radius data is expressed in kilometers. Velocity data is expressed in kilometers per second. /// Regardless of the constructor used, this struct stores all the state information in Cartesian coordinates as these are always non singular. /// /// Unless noted otherwise, algorithms are from GMAT 2016a [StateConversionUtil.cpp](https://github.com/ChristopherRabotin/GMAT/blob/37201a6290e7f7b941bc98ee973a527a5857104b/src/base/util/StateConversionUtil.cpp). #[derive(Copy, Clone, Debug)] +#[cfg_attr(feature = "python", pyclass(name = "Orbit"))] +#[cfg_attr(feature = "python", pyo3(module = "anise.astro"))] pub struct CartesianState { /// Position radius in kilometers pub radius_km: Vector3, @@ -98,16 +103,6 @@ impl CartesianState { ) } - /// Returns the magnitude of the radius vector in km - pub fn rmag_km(&self) -> f64 { - self.radius_km.norm() - } - - /// Returns the magnitude of the velocity vector in km/s - pub fn vmag_km_s(&self) -> f64 { - self.velocity_km_s.norm() - } - /// Returns a copy of the state with a new radius pub fn with_radius_km(self, new_radius_km: Vector3) -> Self { let mut me = self; @@ -134,20 +129,6 @@ impl CartesianState { ) } - /// Returns the distance in kilometers between this state and another state, if both frame match (epoch does not need to match). - pub fn distance_to(&self, other: &Self) -> PhysicsResult { - ensure!( - self.frame == other.frame, - FrameMismatchSnafu { - action: "translating states", - frame1: self.frame, - frame2: other.frame - } - ); - - Ok(self.distance_to_point_km(&other.radius_km)) - } - /// Returns the distance in kilometers between this state and a point assumed to be in the same frame. pub fn distance_to_point_km(&self, other_km: &Vector3) -> f64 { (self.radius_km - other_km).norm() @@ -163,20 +144,8 @@ impl CartesianState { perpv(&self.velocity_km_s, &self.r_hat()) / self.rmag_km() } - /// Returns whether this orbit and another are equal within the specified radial and velocity absolute tolerances - pub fn eq_within(&self, other: &Self, radial_tol_km: f64, velocity_tol_km_s: f64) -> bool { - self.epoch == other.epoch - && (self.radius_km.x - other.radius_km.x).abs() < radial_tol_km - && (self.radius_km.y - other.radius_km.y).abs() < radial_tol_km - && (self.radius_km.z - other.radius_km.z).abs() < radial_tol_km - && (self.velocity_km_s.x - other.velocity_km_s.x).abs() < velocity_tol_km_s - && (self.velocity_km_s.y - other.velocity_km_s.y).abs() < velocity_tol_km_s - && (self.velocity_km_s.z - other.velocity_km_s.z).abs() < velocity_tol_km_s - && self.frame == other.frame - } - /// Adds the other state to this state WITHOUT checking if the frames match. - pub(crate) fn add_unchecked(&self, other: Self) -> Self { + pub(crate) fn add_unchecked(&self, other: &Self) -> Self { Self { radius_km: self.radius_km + other.radius_km, velocity_km_s: self.velocity_km_s + other.velocity_km_s, @@ -186,7 +155,7 @@ impl CartesianState { } /// Subs the other state to this state WITHOUT checking if the frames match. - pub(crate) fn sub_unchecked(&self, other: Self) -> Self { + pub(crate) fn sub_unchecked(&self, other: &Self) -> Self { Self { radius_km: self.radius_km - other.radius_km, velocity_km_s: self.velocity_km_s - other.velocity_km_s, @@ -196,6 +165,46 @@ impl CartesianState { } } +// Methods shared with Python +#[cfg_attr(feature = "python", pymethods)] +impl CartesianState { + /// Returns the magnitude of the radius vector in km + pub fn rmag_km(&self) -> f64 { + self.radius_km.norm() + } + + /// Returns the magnitude of the velocity vector in km/s + pub fn vmag_km_s(&self) -> f64 { + self.velocity_km_s.norm() + } + + /// Returns the distance in kilometers between this state and another state, if both frame match (epoch does not need to match). + pub fn distance_to(&self, other: &Self) -> PhysicsResult { + ensure!( + self.frame == other.frame, + FrameMismatchSnafu { + action: "computing distance between states", + frame1: self.frame, + frame2: other.frame + } + ); + + Ok(self.distance_to_point_km(&other.radius_km)) + } + + /// Returns whether this orbit and another are equal within the specified radial and velocity absolute tolerances + pub fn eq_within(&self, other: &Self, radial_tol_km: f64, velocity_tol_km_s: f64) -> bool { + self.epoch == other.epoch + && (self.radius_km.x - other.radius_km.x).abs() < radial_tol_km + && (self.radius_km.y - other.radius_km.y).abs() < radial_tol_km + && (self.radius_km.z - other.radius_km.z).abs() < radial_tol_km + && (self.velocity_km_s.x - other.velocity_km_s.x).abs() < velocity_tol_km_s + && (self.velocity_km_s.y - other.velocity_km_s.y).abs() < velocity_tol_km_s + && (self.velocity_km_s.z - other.velocity_km_s.z).abs() < velocity_tol_km_s + && self.frame == other.frame + } +} + impl Add for CartesianState { type Output = Result; @@ -204,7 +213,7 @@ impl Add for CartesianState { ensure!( self.epoch == other.epoch, EpochMismatchSnafu { - action: "translating states", + action: "adding states", epoch1: self.epoch, epoch2: other.epoch } @@ -213,13 +222,13 @@ impl Add for CartesianState { ensure!( self.frame.ephemeris_id == other.frame.ephemeris_id, FrameMismatchSnafu { - action: "translating states", + action: "adding states", frame1: self.frame, frame2: other.frame } ); - Ok(self.add_unchecked(other)) + Ok(self.add_unchecked(&other)) } } @@ -240,7 +249,7 @@ impl Sub for CartesianState { ensure!( self.epoch == other.epoch, EpochMismatchSnafu { - action: "translating states", + action: "subtracting states", epoch1: self.epoch, epoch2: other.epoch } @@ -249,13 +258,13 @@ impl Sub for CartesianState { ensure!( self.frame.ephemeris_id == other.frame.ephemeris_id, FrameMismatchSnafu { - action: "translating states", + action: "subtracting states", frame1: self.frame, frame2: other.frame } ); - Ok(self.sub_unchecked(other)) + Ok(self.sub_unchecked(&other)) } } @@ -333,7 +342,7 @@ mod cartesian_state_ut { assert_eq!( s1 + s2, Err(PhysicsError::EpochMismatch { - action: "translating states", + action: "adding states", epoch1: e, epoch2: e2, }) @@ -351,7 +360,7 @@ mod cartesian_state_ut { assert_eq!( s1 + s2, Err(PhysicsError::FrameMismatch { - action: "translating states", + action: "adding states", frame1: frame.into(), frame2: frame2.into(), }) diff --git a/anise/src/math/cartesian_py.rs b/anise/src/math/cartesian_py.rs new file mode 100644 index 00000000..f47274a5 --- /dev/null +++ b/anise/src/math/cartesian_py.rs @@ -0,0 +1,153 @@ +/* + * ANISE Toolkit + * Copyright (C) 2021-2023 Christopher Rabotin et al. (cf. AUTHORS.md) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * Documentation: https://nyxspace.com/ + */ + +// This file contains Python specific helper functions that don't fit anywhere else. + +use super::cartesian::CartesianState; +use crate::prelude::Frame; +use hifitime::Epoch; +use pyo3::exceptions::PyTypeError; +use pyo3::prelude::*; +use pyo3::pyclass::CompareOp; +use pyo3::types::PyType; + +#[pymethods] +impl CartesianState { + /// Creates a new Cartesian state in the provided frame at the provided Epoch. + /// + /// **Units:** km, km, km, km/s, km/s, km/s + + #[classmethod] + pub fn from_cartesian( + _cls: &PyType, + x_km: f64, + y_km: f64, + z_km: f64, + vx_km_s: f64, + vy_km_s: f64, + vz_km_s: f64, + epoch: Epoch, + frame: Frame, + ) -> Self { + Self::new(x_km, y_km, z_km, vx_km_s, vy_km_s, vz_km_s, epoch, frame) + } + + /// Creates a new Cartesian state in the provided frame at the provided Epoch (calls from_cartesian). + /// + /// **Units:** km, km, km, km/s, km/s, km/s + + #[new] + pub fn py_new( + _cls: &PyType, + x_km: f64, + y_km: f64, + z_km: f64, + vx_km_s: f64, + vy_km_s: f64, + vz_km_s: f64, + epoch: Epoch, + frame: Frame, + ) -> Self { + Self::new(x_km, y_km, z_km, vx_km_s, vy_km_s, vz_km_s, epoch, frame) + } + + #[getter] + fn get_x_km(&self) -> PyResult { + Ok(self.radius_km[0]) + } + + #[setter] + fn set_x_km(&mut self, x_km: f64) -> PyResult<()> { + self.radius_km[0] = x_km; + Ok(()) + } + + #[getter] + fn get_y_km(&self) -> PyResult { + Ok(self.radius_km[1]) + } + + #[setter] + fn set_y_km(&mut self, y_km: f64) -> PyResult<()> { + self.radius_km[1] = y_km; + Ok(()) + } + + #[getter] + fn get_z_km(&self) -> PyResult { + Ok(self.radius_km[2]) + } + + #[getter] + fn get_vx_km_s(&self) -> PyResult { + Ok(self.velocity_km_s[0]) + } + + #[setter] + fn set_vx_km_s(&mut self, x_km: f64) -> PyResult<()> { + self.velocity_km_s[0] = x_km; + Ok(()) + } + + #[getter] + fn get_vy_km_s(&self) -> PyResult { + Ok(self.velocity_km_s[1]) + } + + #[setter] + fn set_vy_km_s(&mut self, y_km: f64) -> PyResult<()> { + self.velocity_km_s[1] = y_km; + Ok(()) + } + + #[getter] + fn get_vz_km_s(&self) -> PyResult { + Ok(self.velocity_km_s[2]) + } + + #[setter] + fn set_z_km(&mut self, z_km: f64) -> PyResult<()> { + self.radius_km[2] = z_km; + Ok(()) + } + + fn __str__(&self) -> String { + format!("{self}") + } + + #[cfg(feature = "python")] + fn __repr__(&self) -> String { + format!("{self} (@{self:p})") + } + + fn __richcmp__(&self, other: &Self, op: CompareOp) -> Result { + match op { + CompareOp::Eq => Ok(self == other), + CompareOp::Ne => Ok(self != other), + _ => Err(PyErr::new::(format!( + "{op:?} not available" + ))), + } + } + + #[cfg(feature = "python")] + fn __getnewargs__(&self) -> Result<(f64, f64, f64, f64, f64, f64, Epoch, Frame), PyErr> { + Ok(( + self.radius_km[0], + self.radius_km[1], + self.radius_km[2], + self.velocity_km_s[0], + self.velocity_km_s[1], + self.velocity_km_s[2], + self.epoch, + self.frame, + )) + } +} diff --git a/anise/src/math/mod.rs b/anise/src/math/mod.rs index efcd5ac7..1270ce56 100644 --- a/anise/src/math/mod.rs +++ b/anise/src/math/mod.rs @@ -17,6 +17,8 @@ pub type Matrix6 = nalgebra::Matrix6; pub mod angles; pub mod cartesian; +#[cfg(feature = "python")] +mod cartesian_py; pub mod interpolation; pub mod rotation; pub mod units; diff --git a/anise/src/math/rotation/mrp.rs b/anise/src/math/rotation/mrp.rs index 48b9b25f..54ac7a4e 100644 --- a/anise/src/math/rotation/mrp.rs +++ b/anise/src/math/rotation/mrp.rs @@ -117,10 +117,7 @@ impl MRP { /// # Note /// If the MRP is singular, this returns an angle of zero and a vector of zero. pub fn uvec_angle(&self) -> (Vector3, f64) { - match Quaternion::try_from(*self) { - Ok(q) => q.uvec_angle(), - Err(_) => (Vector3::zeros(), 0.0), - } + Quaternion::from(*self).uvec_angle() } /// Returns the data of this MRP as a vector, simplifies lots of computations diff --git a/anise/src/math/rotation/quaternion.rs b/anise/src/math/rotation/quaternion.rs index 63883387..2eb01bb9 100644 --- a/anise/src/math/rotation/quaternion.rs +++ b/anise/src/math/rotation/quaternion.rs @@ -18,8 +18,6 @@ use der::{Decode, Encode, Reader, Writer}; use nalgebra::Matrix4x3; use snafu::ensure; -pub use core::f64::consts::{FRAC_PI_2, FRAC_PI_3, FRAC_PI_4, PI, TAU}; - use super::EPSILON_RAD; /// Quaternion will always be a unit quaternion in ANISE, cf. [EulerParameter]. @@ -380,8 +378,7 @@ mod ut_quaternion { }; use super::{EulerParameter, Quaternion, Vector3, EPSILON}; - use core::f64::consts::FRAC_PI_2; - use std::f64::consts::PI; + use core::f64::consts::{FRAC_PI_2, PI}; #[test] fn test_quat_frames() { diff --git a/anise/src/naif/daf/daf.rs b/anise/src/naif/daf/daf.rs index fc0684b0..7b0e73ea 100644 --- a/anise/src/naif/daf/daf.rs +++ b/anise/src/naif/daf/daf.rs @@ -22,7 +22,7 @@ use bytes::Bytes; use core::hash::Hash; use core::ops::Deref; use hifitime::Epoch; -use log::{error, trace, warn}; +use log::{debug, error, trace}; use snafu::ResultExt; use std::fmt::Debug; use std::marker::PhantomData; @@ -113,7 +113,20 @@ impl DAF { } pub fn file_record(&self) -> Result { - let file_record = FileRecord::read_from(&self.bytes[..FileRecord::SIZE]).unwrap(); + let file_record = FileRecord::read_from( + self.bytes + .get(..FileRecord::SIZE) + .ok_or_else(|| DecodingError::InaccessibleBytes { + start: 0, + end: FileRecord::SIZE, + size: self.bytes.len(), + }) + .with_context(|_| DecodingDataSnafu { + idx: 0_usize, + kind: R::NAME, + })?, + ) + .unwrap(); // Check that the endian-ness is compatible with this platform. file_record .endianness() @@ -237,7 +250,7 @@ impl DAF { trace!("Found {id} in position {idx}: {summary:?}"); return Ok((summary, idx)); } else { - warn!( + debug!( "Summary {id} not valid at {epoch:?} (only from {:?} to {:?}, offset of {} - {})", summary.start_epoch(), summary.end_epoch(), @@ -338,6 +351,8 @@ impl DAF { ) { Ok(s) => rslt += s.replace('\u{0}', "\n").trim(), Err(e) => { + // At this point, we know that the bytes are accessible because the embedded `match` + // did not fail, so we can perform a direct access. let valid_s = core::str::from_utf8( &self.bytes[rid * RCRD_LEN..(rid * RCRD_LEN + e.valid_up_to())], ) @@ -355,7 +370,6 @@ impl DAF { } /// Writes the contents of this DAF file to a new location. - pub fn persist>(&self, path: P) -> IoResult<()> { let mut fs = File::create(path)?; diff --git a/anise/src/py_errors.rs b/anise/src/py_errors.rs new file mode 100644 index 00000000..476ba39e --- /dev/null +++ b/anise/src/py_errors.rs @@ -0,0 +1,67 @@ +/* + * ANISE Toolkit + * Copyright (C) 2021-2023 Christopher Rabotin et al. (cf. AUTHORS.md) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * Documentation: https://nyxspace.com/ + */ + +use crate::almanac::meta::MetaAlmanacError; +use crate::almanac::planetary::PlanetaryDataError; +use crate::ephemerides::EphemerisError; +use crate::errors::{AlmanacError, DecodingError, InputOutputError, IntegrityError, PhysicsError}; +use crate::orientations::OrientationError; +use core::convert::From; + +use pyo3::{exceptions::PyException, prelude::*}; + +impl From for PyErr { + fn from(err: PhysicsError) -> PyErr { + PyException::new_err(err.to_string()) + } +} + +impl From for PyErr { + fn from(err: IntegrityError) -> PyErr { + PyException::new_err(err.to_string()) + } +} +impl From for PyErr { + fn from(err: DecodingError) -> PyErr { + PyException::new_err(err.to_string()) + } +} +impl From for PyErr { + fn from(err: InputOutputError) -> PyErr { + PyException::new_err(err.to_string()) + } +} +impl From for PyErr { + fn from(err: AlmanacError) -> PyErr { + PyException::new_err(err.to_string()) + } +} +impl From for PyErr { + fn from(err: EphemerisError) -> PyErr { + PyException::new_err(err.to_string()) + } +} +impl From for PyErr { + fn from(err: OrientationError) -> PyErr { + PyException::new_err(err.to_string()) + } +} + +impl From for PyErr { + fn from(err: PlanetaryDataError) -> PyErr { + PyException::new_err(err.to_string()) + } +} + +impl From for PyErr { + fn from(err: MetaAlmanacError) -> PyErr { + PyException::new_err(err.to_string()) + } +} diff --git a/anise/src/structure/planetocentric/ellipsoid.rs b/anise/src/structure/planetocentric/ellipsoid.rs index 402ca633..196a1cd1 100644 --- a/anise/src/structure/planetocentric/ellipsoid.rs +++ b/anise/src/structure/planetocentric/ellipsoid.rs @@ -10,6 +10,14 @@ use core::f64::EPSILON; use core::fmt; use der::{Decode, Encode, Reader, Writer}; + +#[cfg(feature = "python")] +use pyo3::exceptions::PyTypeError; +#[cfg(feature = "python")] +use pyo3::prelude::*; +#[cfg(feature = "python")] +use pyo3::pyclass::CompareOp; + /// Only the tri-axial Ellipsoid shape model is currently supported by ANISE. /// This is directly inspired from SPICE PCK. /// > For each body, three radii are listed: The first number is @@ -21,6 +29,9 @@ use der::{Decode, Encode, Reader, Writer}; /// /// BODY399_RADII = ( 6378.1366 6378.1366 6356.7519 ) #[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "python", pyclass)] +#[cfg_attr(feature = "python", pyo3(get_all, set_all))] +#[cfg_attr(feature = "python", pyo3(module = "anise.astro"))] pub struct Ellipsoid { pub semi_major_equatorial_radius_km: f64, pub semi_minor_equatorial_radius_km: f64, @@ -45,6 +56,63 @@ impl Ellipsoid { polar_radius_km, } } +} + +#[cfg_attr(feature = "python", pymethods)] +impl Ellipsoid { + /// Initializes a new [Ellipsoid] shape provided at least its semi major equatorial radius, optionally its semi minor equatorial radius, and optionally its polar radius. + /// All units are in kilometers. If the semi minor equatorial radius is not provided, a bi-axial spheroid will be created using the semi major equatorial radius as + /// the equatorial radius and using the provided polar axis radius. If only the semi major equatorial radius is provided, a perfect sphere will be built. + #[cfg(feature = "python")] + #[new] + fn py_new( + semi_major_equatorial_radius_km: f64, + polar_radius_km: Option, + semi_minor_equatorial_radius_km: Option, + ) -> Self { + match polar_radius_km { + Some(polar_radius_km) => match semi_minor_equatorial_radius_km { + Some(semi_minor_equatorial_radius_km) => Self { + semi_major_equatorial_radius_km, + semi_minor_equatorial_radius_km, + polar_radius_km, + }, + None => Self::from_spheroid(semi_major_equatorial_radius_km, polar_radius_km), + }, + None => Self::from_sphere(semi_major_equatorial_radius_km), + } + } + + #[cfg(feature = "python")] + fn __str__(&self) -> String { + format!("{self}") + } + + #[cfg(feature = "python")] + fn __repr__(&self) -> String { + format!("{self} (@{self:p})") + } + + #[cfg(feature = "python")] + fn __richcmp__(&self, other: &Self, op: CompareOp) -> Result { + match op { + CompareOp::Eq => Ok(self == other), + CompareOp::Ne => Ok(self != other), + _ => Err(PyErr::new::(format!( + "{op:?} not available" + ))), + } + } + + /// Allows for pickling the object + #[cfg(feature = "python")] + fn __getnewargs__(&self) -> Result<(f64, Option, Option), PyErr> { + Ok(( + self.semi_major_equatorial_radius_km, + Some(self.polar_radius_km), + Some(self.semi_minor_equatorial_radius_km), + )) + } /// Returns the mean equatorial radius in kilometers pub fn mean_equatorial_radius_km(&self) -> f64 { diff --git a/anise/tests/almanac/mod.rs b/anise/tests/almanac/mod.rs index 4d1c35ae..43e51bdf 100644 --- a/anise/tests/almanac/mod.rs +++ b/anise/tests/almanac/mod.rs @@ -14,15 +14,9 @@ fn test_load_ctx() { let dataset = convert_tpc("../data/pck00008.tpc", "../data/gm_de431.tpc").unwrap(); // Load BSP and BPC - let ctx = Almanac::default(); + let ctx = Almanac::new("../data/de440.bsp").unwrap(); - let spk = SPK::load("../data/de440.bsp").unwrap(); - - let mut loaded_ctx = ctx - .with_spk(spk) - .unwrap() - .load("../data/earth_latest_high_prec.bpc") - .unwrap(); + let mut loaded_ctx = ctx.load("../data/earth_latest_high_prec.bpc").unwrap(); loaded_ctx.planetary_data = dataset; @@ -38,14 +32,14 @@ fn test_state_transformation() { let spk = SPK::load("../data/de440.bsp").unwrap(); let bpc = BPC::load("../data/earth_latest_high_prec.bpc").unwrap(); - let pck = convert_tpc("../data/pck00008.tpc", "../data/gm_de431.tpc").unwrap(); let almanac = ctx .with_spk(spk) .unwrap() .with_bpc(bpc) .unwrap() - .with_planetary_data(pck); + .load("../data/pck08.pca") + .unwrap(); // Let's build an orbit // Start by grabbing a copy of the frame. @@ -59,7 +53,7 @@ fn test_state_transformation() { // Transform that into another frame. let state_itrf93 = almanac - .transform_to(orig_state, EARTH_ITRF93, Aberration::None) + .transform_to(orig_state, EARTH_ITRF93, Aberration::NotSet) .unwrap(); println!("{orig_state:x}"); @@ -67,7 +61,7 @@ fn test_state_transformation() { // Convert back let from_state_itrf93_to_eme2k = almanac - .transform_to(state_itrf93, EARTH_J2000, Aberration::None) + .transform_to(state_itrf93, EARTH_J2000, Aberration::NotSet) .unwrap(); println!("{from_state_itrf93_to_eme2k}"); diff --git a/anise/tests/astro/orbit.rs b/anise/tests/astro/orbit.rs index 87bd74d2..a1096ce3 100644 --- a/anise/tests/astro/orbit.rs +++ b/anise/tests/astro/orbit.rs @@ -458,11 +458,27 @@ fn verif_geodetic_vallado(almanac: Almanac) { let long = 46.446_416_856_789_96; // Vallado 46.4464 let height = 5_085.217_419_357_936; // Vallado: 5085.22 let r = Orbit::from_position(ri, rj, rk, epoch, eme2k); - f64_eq!(r.geodetic_latitude().unwrap(), lat, "latitude (φ)"); - f64_eq!(r.geodetic_longitude(), long, "longitude (λ)"); - f64_eq!(r.geodetic_height().unwrap(), height, "height"); + f64_eq!(r.geodetic_latitude_deg().unwrap(), lat, "latitude (φ)"); + f64_eq!(r.geodetic_longitude_deg(), long, "longitude (λ)"); + f64_eq!(r.geodetic_height_km().unwrap(), height, "height"); + // Check that we can compute orbital parameters here, although these will be odd since it's a ground station + f64_eq!( + r.sma_altitude_km().unwrap(), + -649.854_189_724_88, + "SMA altitude" + ); + f64_eq!( + r.apoapsis_altitude_km().unwrap(), + 5_078.431_620_550_23, + "Apoapsis altitude" + ); + f64_eq!( + r.periapsis_altitude_km().unwrap(), + -6378.14, + "Periapsis altitude" + ); let mean_earth_angular_velocity_deg_s = 0.004178079012116429; - let r = Orbit::from_altlatlong( + let r = Orbit::try_from_latlongalt( lat, long, height, @@ -484,7 +500,7 @@ fn verif_geodetic_vallado(almanac: Almanac) { let ri = 6_119.403_233_271_109; let rj = -1_571.480_316_600_378_3; let rk = -871.560_226_712_024_7; - let r = Orbit::from_altlatlong( + let r = Orbit::try_from_latlongalt( lat, long, height, @@ -497,12 +513,12 @@ fn verif_geodetic_vallado(almanac: Almanac) { f64_eq!(r.radius_km.y, rj, "r_j"); f64_eq!(r.radius_km.z, rk, "r_k"); let r = Orbit::from_position(ri, rj, rk, epoch, eme2k); - f64_eq!(r.geodetic_latitude().unwrap(), lat_val, "latitude (φ)"); - f64_eq!(r.geodetic_longitude(), long, "longitude (λ)"); - f64_eq!(r.geodetic_height().unwrap(), height_val, "height"); + f64_eq!(r.geodetic_latitude_deg().unwrap(), lat_val, "latitude (φ)"); + f64_eq!(r.geodetic_longitude_deg(), long, "longitude (λ)"); + f64_eq!(r.geodetic_height_km().unwrap(), height_val, "height"); // Check reciprocity near poles - let r = Orbit::from_altlatlong( + let r = Orbit::try_from_latlongalt( 0.1, long, height_val, @@ -511,7 +527,7 @@ fn verif_geodetic_vallado(almanac: Almanac) { eme2k, ) .unwrap(); - f64_eq!(r.geodetic_latitude().unwrap(), 0.1, "latitude (φ)"); + f64_eq!(r.geodetic_latitude_deg().unwrap(), 0.1, "latitude (φ)"); } #[rstest] diff --git a/anise/tests/ephemerides/parent_translation_verif.rs b/anise/tests/ephemerides/parent_translation_verif.rs index a8efdc40..cd7670a9 100644 --- a/anise/tests/ephemerides/parent_translation_verif.rs +++ b/anise/tests/ephemerides/parent_translation_verif.rs @@ -24,9 +24,7 @@ fn invalid_load_from_static() { #[test] fn de438s_parent_translation_verif() { - if pretty_env_logger::try_init().is_err() { - println!("could not init env_logger"); - } + let _ = pretty_env_logger::try_init(); let bytes = file2heap!("../data/de440s.bsp").unwrap(); let de438s = SPK::parse(bytes).unwrap(); @@ -47,7 +45,7 @@ fn de438s_parent_translation_verif() { */ let state = ctx - .translate_to_parent(VENUS_J2000, epoch, Aberration::None) + .translate_to_parent(VENUS_J2000, epoch, Aberration::NotSet) .unwrap(); let pos_km = state.radius_km; diff --git a/anise/tests/ephemerides/paths.rs b/anise/tests/ephemerides/paths.rs index 4c415cf7..215aa47c 100644 --- a/anise/tests/ephemerides/paths.rs +++ b/anise/tests/ephemerides/paths.rs @@ -18,9 +18,7 @@ use anise::prelude::*; /// Tests that direct path computations match what SPICE returned to within good precision. #[test] fn common_root_verif() { - if pretty_env_logger::try_init().is_err() { - println!("could not init env_logger"); - } + let _ = pretty_env_logger::try_init(); // SLS Launch epoch!!! IT'S LIIIIVEE!! let epoch = Epoch::from_str("2022-11-15T23:47:36+06:00").unwrap(); diff --git a/anise/tests/ephemerides/transform.rs b/anise/tests/ephemerides/transform.rs index ad4b8474..6397c4e1 100644 --- a/anise/tests/ephemerides/transform.rs +++ b/anise/tests/ephemerides/transform.rs @@ -20,9 +20,7 @@ const VELOCITY_EPSILON_KM_S: f64 = 5e-10; #[ignore = "Requires Rust SPICE -- must be executed serially"] #[test] fn de440s_transform_verif_venus2emb() { - if pretty_env_logger::try_init().is_err() { - println!("could not init env_logger"); - } + let _ = pretty_env_logger::try_init(); let spk_path = "../data/de440s.bsp"; let bpc_path = "../data/earth_latest_high_prec.bpc"; @@ -44,7 +42,7 @@ fn de440s_transform_verif_venus2emb() { let epoch = Epoch::from_gregorian_utc_at_midnight(2020, 2, 7); let state = almanac - .transform_from_to(VENUS_J2000, EARTH_ITRF93, epoch, Aberration::None) + .transform_from_to(VENUS_J2000, EARTH_ITRF93, epoch, Aberration::NotSet) .unwrap(); let (spice_state, _) = spice::spkezr("VENUS", epoch.to_et_seconds(), "ITRF93", "NONE", "EARTH"); diff --git a/anise/tests/ephemerides/translation.rs b/anise/tests/ephemerides/translation.rs index 2ee93048..1c89ad27 100644 --- a/anise/tests/ephemerides/translation.rs +++ b/anise/tests/ephemerides/translation.rs @@ -22,9 +22,7 @@ const VELOCITY_EPSILON_KM_S: f64 = 5e-9; #[test] fn de440s_translation_verif_venus2emb() { - if pretty_env_logger::try_init().is_err() { - println!("could not init env_logger"); - } + let _ = pretty_env_logger::try_init(); // "Load" the file via a memory map (avoids allocations) let path = "../data/de440s.bsp"; @@ -53,7 +51,7 @@ fn de440s_translation_verif_venus2emb() { VENUS_J2000, EARTH_MOON_BARYCENTER_J2000, epoch, - Aberration::None, + Aberration::NotSet, ) .unwrap(); @@ -107,9 +105,7 @@ fn de440s_translation_verif_venus2emb() { #[test] fn de438s_translation_verif_venus2luna() { - if pretty_env_logger::try_init().is_err() { - println!("could not init env_logger"); - } + let _ = pretty_env_logger::try_init(); // "Load" the file via a memory map (avoids allocations) let path = "../data/de440s.bsp"; @@ -138,7 +134,7 @@ fn de438s_translation_verif_venus2luna() { */ let state = ctx - .translate_from_to(VENUS_J2000, LUNA_J2000, epoch, Aberration::None) + .translate_from_to(VENUS_J2000, LUNA_J2000, epoch, Aberration::NotSet) .unwrap(); let pos_expct_km = Vector3::new( @@ -199,9 +195,7 @@ fn de438s_translation_verif_venus2luna() { #[test] fn de438s_translation_verif_emb2luna() { - if pretty_env_logger::try_init().is_err() { - println!("could not init env_logger"); - } + let _ = pretty_env_logger::try_init(); // "Load" the file via a memory map (avoids allocations) let path = "../data/de440s.bsp"; @@ -233,7 +227,7 @@ fn de438s_translation_verif_emb2luna() { EARTH_MOON_BARYCENTER_J2000, LUNA_J2000, epoch, - Aberration::None, + Aberration::NotSet, ) .unwrap(); @@ -277,7 +271,7 @@ fn de438s_translation_verif_emb2luna() { LUNA_J2000, EARTH_MOON_BARYCENTER_J2000, epoch, - Aberration::None, + Aberration::NotSet, ) .unwrap(); @@ -427,7 +421,7 @@ fn hermite_query() { summary.target_frame(), summary.center_frame(), summary.start_epoch() + summary_duration * 0.5, - Aberration::None, + Aberration::NotSet, ) .unwrap(); @@ -440,7 +434,7 @@ fn hermite_query() { summary.target_frame(), summary.center_frame(), summary.start_epoch(), - Aberration::None, + Aberration::NotSet, ) .is_ok()); diff --git a/anise/tests/ephemerides/validation/compare.rs b/anise/tests/ephemerides/validation/compare.rs index 99b16e7c..a78cb730 100644 --- a/anise/tests/ephemerides/validation/compare.rs +++ b/anise/tests/ephemerides/validation/compare.rs @@ -87,9 +87,7 @@ impl CompareEphem { output_file_name: String, num_queries_per_pair: usize, ) -> Self { - if pretty_env_logger::try_init().is_err() { - println!("could not init env_logger"); - } + let _ = pretty_env_logger::try_init(); // Build the schema let schema = Schema::new(vec![ diff --git a/anise/tests/naif.rs b/anise/tests/naif.rs index bac5e1ea..61e84acf 100644 --- a/anise/tests/naif.rs +++ b/anise/tests/naif.rs @@ -23,9 +23,7 @@ use anise::{ #[test] fn test_binary_pck_load() { - if pretty_env_logger::try_init().is_err() { - println!("could not init env_logger"); - } + let _ = pretty_env_logger::try_init(); // Using the DE421 as demo because the correct data is in the DAF documentation let filename = "../data/earth_latest_high_prec.bpc"; @@ -50,9 +48,7 @@ fn test_binary_pck_load() { #[test] fn test_spk_load_bytes() { - if pretty_env_logger::try_init().is_err() { - println!("could not init env_logger"); - } + let _ = pretty_env_logger::try_init(); // Using the DE421 as demo because the correct data is in the DAF documentation let bytes = file2heap!("../data/de421.bsp").unwrap(); @@ -167,9 +163,7 @@ fn test_spk_load_bytes() { #[test] fn test_spk_rename_summary() { - if pretty_env_logger::try_init().is_err() { - println!("could not init env_logger"); - } + let _ = pretty_env_logger::try_init(); let path = "../data/variable-seg-size-hermite.bsp"; @@ -188,3 +182,13 @@ fn test_spk_rename_summary() { example_data.persist("../target/rename-test.bsp").unwrap(); } + +#[test] +fn test_invalid_load() { + let _ = pretty_env_logger::try_init(); + + // Check that it doesn't fail if the file does not exist + assert!(BPC::load("i_dont_exist.bpc").is_err()); + // Check that a file that's too small does not panic + assert!(BPC::load("../.gitattributes").is_err()); +} diff --git a/anise/tests/orientations/validation.rs b/anise/tests/orientations/validation.rs index a2d89f0a..3324dcfb 100644 --- a/anise/tests/orientations/validation.rs +++ b/anise/tests/orientations/validation.rs @@ -390,7 +390,7 @@ fn validate_bpc_rotations() { let bpc = BPC::load(pck).unwrap(); let almanac = Almanac::from_bpc(bpc).unwrap(); - let frame = Frame::from_ephem_orient(EARTH, ITRF93); + let frame = Frame::new(EARTH, ITRF93); let mut actual_max_uvec_err_deg = 0.0; let mut actual_max_err_deg = 0.0; diff --git a/data/.gitignore b/data/.gitignore new file mode 100644 index 00000000..e527e6b3 --- /dev/null +++ b/data/.gitignore @@ -0,0 +1 @@ +!pck08.pca \ No newline at end of file diff --git a/data/default_meta.dhall b/data/default_meta.dhall new file mode 100644 index 00000000..64a4374f --- /dev/null +++ b/data/default_meta.dhall @@ -0,0 +1,14 @@ +-- Default Almanac +{ files = + [ { crc32 = Some 1921414410 + , uri = "http://public-data.nyxspace.com/anise/de440s.bsp" + } + , { crc32 = Some 1216081528 + , uri = "http://public-data.nyxspace.com/anise/pck08.pca" + } + , { crc32 = None Natural + , uri = + "https://naif.jpl.nasa.gov/pub/naif/generic_kernels/pck/earth_latest_high_prec.bpc" + } + ] +} diff --git a/data/example_meta.dhall b/data/example_meta.dhall new file mode 100644 index 00000000..8f10f5fb --- /dev/null +++ b/data/example_meta.dhall @@ -0,0 +1,54 @@ +-- Example Dhall meta "kernel" +let MetaFile + : Type + = { uri : Text, crc32 : Optional Natural } + +let Meta + : Type + = { files : List MetaFile } + +let NyxAsset + : Text -> Text + = \(file : Text) -> "http://public-data.nyxspace.com/anise/${file}" + +let JplAsset + : Text -> Text + = \(file : Text) -> + "https://naif.jpl.nasa.gov/pub/naif/generic_kernels/${file}" + +let buildNyxAsset + : Text -> MetaFile + = \(file : Text) -> + let crc32 = None Natural + + let uri + : Text + = NyxAsset file + + let thisAsset + : MetaFile + = { uri, crc32 } + + in thisAsset + +let buildJplAsset + : Text -> MetaFile + = \(file : Text) -> + let crc32 = None Natural + + let uri + : Text + = JplAsset file + + let thisAsset + : MetaFile + = { uri, crc32 } + + in thisAsset + +in { files = + [ buildNyxAsset "de440s.bsp" + , buildNyxAsset "pck08.pca" + , buildJplAsset "pck/earth_latest_high_prec.bpc" + ] + } diff --git a/data/local.dhall b/data/local.dhall new file mode 100644 index 00000000..17907b53 --- /dev/null +++ b/data/local.dhall @@ -0,0 +1,6 @@ +-- Default Almanac +{ files = + [ { crc32 = None Natural, uri = "../../data/de440s.bsp" } + , { crc32 = None Natural, uri = "../../data/pck08.pca" } + ] +} diff --git a/data/pck08.pca b/data/pck08.pca new file mode 100644 index 00000000..2a640548 --- /dev/null +++ b/data/pck08.pca @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a966a907d2052ebafbcd09324b81553e64e0f8a13339ac896df1a1a45df4b450 +size 33550