From 1caf78173323fa5ee0ed274e9d466bcfc7b8c903 Mon Sep 17 00:00:00 2001 From: VPRamon Date: Sun, 4 Oct 2026 21:11:41 +0200 Subject: [PATCH 1/5] refactor: remove duplicated format and orbital-mechanics implementations Remove unused local format, TLE, SGP4, Lambert, and SPICE implementations and their examples and probe workspace. Keep the service pipeline on canonical Siderust APIs without compatibility facades.\n\nCloses #9\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 13 - CHANGELOG.md | 3 + Cargo.lock | 261 +------ Cargo.toml | 32 +- README.md | 24 +- docs/adr/0007-spk-type-coverage.md | 27 +- docs/architecture/boundaries.md | 2 +- docs/architecture/overview.md | 8 +- docs/architecture/providers.md | 5 +- docs/conventions/examples-and-doctests.md | 45 +- docs/conventions/naming.md | 6 +- docs/formats/antex.md | 2 +- docs/formats/blq.md | 2 +- docs/formats/ccsds-oem.md | 2 +- docs/formats/daf-spk.md | 45 +- docs/formats/eop-iers.md | 2 +- docs/formats/ilrs-cpf.md | 2 +- docs/formats/ilrs-crd.md | 2 +- docs/formats/rinex-nav.md | 2 +- docs/formats/rinex-obs.md | 2 +- docs/formats/sp3.md | 2 +- examples/03_lambert_earth_to_mars.rs | 56 -- examples/04_sgp4_from_tle.rs | 45 -- examples/README.md | 8 +- scripts/check_dep_graph.sh | 4 +- src/io/antex.rs | 235 ------ src/io/cpf.rs | 640 --------------- src/io/crd.rs | 612 --------------- src/io/eop.rs | 189 ----- src/io/lisa.rs | 671 ---------------- src/io/mod.rs | 211 ----- src/io/oem.rs | 874 --------------------- src/io/omm.rs | 138 ---- src/io/opm.rs | 303 -------- src/io/orbex.rs | 309 -------- src/io/rinex_doris.rs | 29 - src/io/rinex_nav.rs | 450 ----------- src/io/rinex_obs.rs | 397 ---------- src/io/sinex.rs | 308 -------- src/io/sp3.rs | 519 ------------- src/io/tdm.rs | 300 -------- src/io/vgosdb.rs | 48 -- src/lambert/error.rs | 45 -- src/lambert/izzo.rs | 898 ---------------------- src/lambert/mod.rs | 77 -- src/lambert/typed.rs | 269 ------- src/lib.rs | 12 +- src/service/mod.rs | 1 - src/service/pipeline.rs | 6 - src/service/providers.rs | 17 - src/sgp4/elements.rs | 60 -- src/sgp4/error.rs | 59 -- src/sgp4/mod.rs | 64 -- src/sgp4/propagator.rs | 346 --------- src/sgp4/state.rs | 187 ----- src/spice/error.rs | 159 ---- src/spice/kernel.rs | 501 ------------ src/spice/mod.rs | 80 -- src/spice/naif.rs | 100 --- src/spice/provider.rs | 268 ------- src/spice/segment.rs | 520 ------------- src/tle/builder.rs | 447 ----------- src/tle/error.rs | 127 --- src/tle/mod.rs | 71 -- src/tle/omm/json.rs | 177 ----- src/tle/omm/kvn.rs | 176 ----- src/tle/omm/mod.rs | 247 ------ src/tle/omm/xml.rs | 214 ------ src/tle/parse.rs | 321 -------- src/tle/tests.rs | 474 ------------ src/tle/tle.rs | 243 ------ target-sgp4-test/probe/Cargo.toml | 18 - target-sgp4-test/probe/src/lib.rs | 2 - 73 files changed, 54 insertions(+), 12967 deletions(-) delete mode 100644 examples/03_lambert_earth_to_mars.rs delete mode 100644 examples/04_sgp4_from_tle.rs delete mode 100644 src/io/antex.rs delete mode 100644 src/io/cpf.rs delete mode 100644 src/io/crd.rs delete mode 100644 src/io/eop.rs delete mode 100644 src/io/lisa.rs delete mode 100644 src/io/mod.rs delete mode 100644 src/io/oem.rs delete mode 100644 src/io/omm.rs delete mode 100644 src/io/opm.rs delete mode 100644 src/io/orbex.rs delete mode 100644 src/io/rinex_doris.rs delete mode 100644 src/io/rinex_nav.rs delete mode 100644 src/io/rinex_obs.rs delete mode 100644 src/io/sinex.rs delete mode 100644 src/io/sp3.rs delete mode 100644 src/io/tdm.rs delete mode 100644 src/io/vgosdb.rs delete mode 100644 src/lambert/error.rs delete mode 100644 src/lambert/izzo.rs delete mode 100644 src/lambert/mod.rs delete mode 100644 src/lambert/typed.rs delete mode 100644 src/service/providers.rs delete mode 100644 src/sgp4/elements.rs delete mode 100644 src/sgp4/error.rs delete mode 100644 src/sgp4/mod.rs delete mode 100644 src/sgp4/propagator.rs delete mode 100644 src/sgp4/state.rs delete mode 100644 src/spice/error.rs delete mode 100644 src/spice/kernel.rs delete mode 100644 src/spice/mod.rs delete mode 100644 src/spice/naif.rs delete mode 100644 src/spice/provider.rs delete mode 100644 src/spice/segment.rs delete mode 100644 src/tle/builder.rs delete mode 100644 src/tle/error.rs delete mode 100644 src/tle/mod.rs delete mode 100644 src/tle/omm/json.rs delete mode 100644 src/tle/omm/kvn.rs delete mode 100644 src/tle/omm/mod.rs delete mode 100644 src/tle/omm/xml.rs delete mode 100644 src/tle/parse.rs delete mode 100644 src/tle/tests.rs delete mode 100644 src/tle/tle.rs delete mode 100644 target-sgp4-test/probe/Cargo.toml delete mode 100644 target-sgp4-test/probe/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3009ade..aec24e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,19 +96,6 @@ jobs: - run: bash scripts/check_no_todos.sh - run: bash scripts/check_legacy_name_refs.sh - feature-checks: - name: Targeted feature checks - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - feature: [lisa, doris, parquet, de440] - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - run: cargo check --workspace --no-default-features --features ${{ matrix.feature }} - msrv: name: MSRV (Rust 1.89) runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec9bfa..ac28e86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed +- Removed duplicated generic format, TLE, SGP4, Lambert, and SPICE + implementations. These capabilities remain owned by Siderust and its + upstream ecosystem; `spod` now exposes the service layer only. - Renamed the project and Cargo package to `spod`, including the `spod` and `spod-rest` binaries, repository metadata, Docker examples, and REST configuration variables. diff --git a/Cargo.lock b/Cargo.lock index eca8e0b..0730c86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,20 +24,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "const-random", - "getrandom 0.3.4", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -112,15 +98,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] - [[package]] name = "async-trait" version = "0.1.89" @@ -286,7 +263,6 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", - "serde", "wasm-bindgen", "windows-link", ] @@ -337,26 +313,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -710,29 +666,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2c81fb5260e37854d09d5c87183309fd8c555b75289427884b25660bc87a85e" -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.2" @@ -741,17 +674,11 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", "wasip2", "wasip3", ] -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - [[package]] name = "half" version = "2.7.1" @@ -1123,16 +1050,6 @@ dependencies = [ "nano-gemm-core", ] -[[package]] -name = "num-bigint" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-complex" version = "0.4.6" @@ -1143,15 +1060,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1196,24 +1104,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "parquet" -version = "59.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff322f54b1a0f9288e614ed1f2d329b380af5476420db19f46ffb865e1163d73" -dependencies = [ - "ahash", - "bytes", - "chrono", - "half", - "hashbrown 0.17.1", - "num-bigint", - "num-integer", - "num-traits", - "seq-macro", - "twox-hash", -] - [[package]] name = "paste" version = "1.0.15" @@ -1281,13 +1171,6 @@ dependencies = [ "raw-cpuid", ] -[[package]] -name = "probe" -version = "0.1.0" -dependencies = [ - "sgp4", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -1383,12 +1266,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1535,15 +1412,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_spanned" version = "1.1.1" @@ -1578,17 +1446,6 @@ dependencies = [ "unsafe-libyaml", ] -[[package]] -name = "sgp4" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9467b9a7be8485ed8be0f336d399c8f32c0fcd60686e7dd2ed3dab75c9a73eb3" -dependencies = [ - "chrono", - "serde", - "serde_json", -] - [[package]] name = "sha2" version = "0.11.0" @@ -1620,7 +1477,6 @@ dependencies = [ "hex", "keplerian", "optica", - "parquet", "paste", "principia", "qtty", @@ -1632,7 +1488,7 @@ dependencies = [ "spin", "tempoch", "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", + "toml", "wide", ] @@ -1648,7 +1504,7 @@ dependencies = [ "regex", "serde", "sha2", - "toml 1.1.2+spec-1.1.0", + "toml", "walkdir", ] @@ -1685,24 +1541,18 @@ name = "spod" version = "0.1.0" dependencies = [ "anyhow", - "approx", "axum", - "cheby", "chrono", "clap", "env_logger", "log", - "quick-xml", "serde", "serde_json", "serde_yaml", - "sgp4", "siderust", "thiserror 1.0.69", "tokio", - "toml 0.8.23", "tower", - "trybuild", "uuid", ] @@ -1754,12 +1604,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "target-triple" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" - [[package]] name = "tempoch" version = "0.7.0" @@ -1781,15 +1625,6 @@ dependencies = [ "siderust-archive", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -1830,15 +1665,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tokio" version = "1.52.3" @@ -1864,18 +1690,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit", -] - [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -1884,20 +1698,11 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", + "serde_spanned", + "toml_datetime", "toml_parser", "toml_writer", - "winnow 1.0.2", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", + "winnow", ] [[package]] @@ -1909,35 +1714,15 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", -] - [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.2", + "winnow", ] -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -1992,27 +1777,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "trybuild" -version = "1.0.116" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c635f0191bd3a2941013e5062667100969f8c4e9cd787c14f977265d73616e" -dependencies = [ - "glob", - "serde", - "serde_derive", - "serde_json", - "target-triple", - "termcolor", - "toml 1.1.2+spec-1.1.0", -] - -[[package]] -name = "twox-hash" -version = "2.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" - [[package]] name = "typenum" version = "1.20.0" @@ -2049,7 +1813,7 @@ version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom 0.4.2", + "getrandom", "js-sys", "wasm-bindgen", ] @@ -2260,15 +2024,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 914f9ab..12c9bf4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,7 @@ # # Reusable POD science is provided by Siderust; this package owns service # orchestration, interfaces, format adapters, and application-specific -# functionality. The [workspace] block is kept only so target-sgp4-test/probe -# can inherit package metadata. +# functionality. [package] name = "spod" @@ -19,35 +18,14 @@ categories = ["science", "aerospace"] publish = false description = "Precise Orbit Determination service application built on Siderust." -[[example]] -name = "03_lambert_earth_to_mars" -path = "examples/03_lambert_earth_to_mars.rs" - -[[example]] -name = "04_sgp4_from_tle" -path = "examples/04_sgp4_from_tle.rs" - [features] default = ["serde"] ## Compatibility feature; serialization support is always available. serde = [] -## DORIS RINEX observation support from Siderust. -doris = ["siderust/pod-doris"] -## LISA inter-satellite range observation model. -lisa = [] -## Integration tests that load a JPL DE kernel from disk (de440/de441). -de440 = [] -## Parquet residuals support, shared with Siderust's POD feature. -parquet = ["siderust/pod-parquet"] [dependencies] -# ── Baseline crates ─────────────────────────────────────────────────────────── -cheby = "0.4.2" siderust = { version = "0.12", features = ["pod"] } -# ── SGP4 propagation backend ────────────────────────────────────────────────── -sgp4 = { version = "2.4", default-features = false, features = ["std"] } - # ── Linear algebra ──────────────────────────────────────────────────────────── # ── Serialisation ───────────────────────────────────────────────────────────── serde = { version = "1.0", features = ["derive"] } @@ -62,9 +40,6 @@ anyhow = "1.0" # ── Time ────────────────────────────────────────────────────────────────────── chrono = "0.4" -# ── XML parsing ──────────────────────────────────────────────────────────────── -quick-xml = "0.41" - # ── HTTP / REST ──────────────────────────────────────────────────────────────── axum = "0.7" tokio = { version = "1.39", features = ["macros", "rt-multi-thread"] } @@ -79,15 +54,10 @@ log = "0.4" env_logger = "0.11" [dev-dependencies] -approx = "0.5" -trybuild = "1.0" serde = { version = "1.0", features = ["derive"] } -toml = "0.8" -# ── Workspace (minimal — only for the sgp4-probe fixture) ───────────────────── [workspace] resolver = "2" -members = ["target-sgp4-test/probe"] [workspace.package] version = "0.1.0" diff --git a/README.md b/README.md index 5726bb5..d29a97b 100644 --- a/README.md +++ b/README.md @@ -18,23 +18,18 @@ Docker builder image. | Area | Current scope | | --- | --- | | POD science | Provided by `siderust::pod` (forces, propagation, observations, estimation, QC and products) | -| Orbit mechanics | Lambert solver, TLE/3LE/OMM handling, SGP4/SDP4 propagation, SPICE ephemerides | -| Formats | SP3, RINEX, ANTEX, EOP, CRD, CPF and CCSDS OEM support at the currently implemented subsets | +| Scientific backend | Siderust 0.12 | | Products & QC | Residuals, orbit products, manifests, comparison/QC utilities | | Interfaces | Rust library, command-line interface, experimental Axum REST API | -The project separates service orchestration, external interfaces, persistence and format adapters from the reusable POD implementation in Siderust. +The project separates service orchestration, external interfaces, and +persistence from the reusable POD implementation in Siderust. ## Repository structure ```text src/ - io/ Space/geodesy format readers and writers service/ Configuration, orchestration, REST/CLI and artifact handling - lambert/ Lambert solver - sgp4/ SGP4 integration - spice/ SPICE helpers/providers - tle/ TLE/3LE/OMM handling bin/ CLI and experimental REST entry points ``` @@ -67,20 +62,15 @@ docker run --rm -p 8080:8080 spod:dev The service listens on `SPOD_REST_BIND` and writes job output below `SPOD_REST_OUT`. -The remaining Rust examples cover Lambert transfer and SGP4 integration: - -```bash -cargo run --example 03_lambert_earth_to_mars -cargo run --example 04_sgp4_from_tle -``` - -See [examples/README.md](examples/README.md) for the purpose of each example and the typed-API conventions they follow. +See [examples/README.md](examples/README.md) for the service workflow. ## Design principles - **Type safety for physical quantities.** Units, epochs, frames and states should be difficult to mix accidentally. - **Traceable numerical behaviour.** Scientific algorithms should have explicit assumptions and validation tests. -- **Separation of concerns.** Reusable dynamics, observations, estimation, and QC remain independently testable in Siderust; `spod` keeps format adapters and service orchestration at the application boundary. +- **Separation of concerns.** Reusable dynamics, observations, estimation, + formats, and QC remain independently testable in Siderust; `spod` keeps + service orchestration at the application boundary. - **No unsafe Rust.** The library forbids `unsafe_code`. - **Standards-oriented interoperability.** Common astrodynamics and geodesy formats are treated as first-class interfaces. diff --git a/docs/adr/0007-spk-type-coverage.md b/docs/adr/0007-spk-type-coverage.md index 3fcc03f..bf9b42d 100644 --- a/docs/adr/0007-spk-type-coverage.md +++ b/docs/adr/0007-spk-type-coverage.md @@ -1,8 +1,8 @@ -# ADR-0007 — SPK Type coverage: Types 2 + 3 implemented; 9 + 13 deferred +# ADR-0007 — SPK type coverage delegated to Siderust ## Status -Accepted. +Superseded by the Siderust 0.12 format implementation. ## Context @@ -11,22 +11,13 @@ NAIF SPICE SPK files use typed segments to store ephemeris data. Types 2 and the current `spod` validation path. Types 9 and 13 use Lagrange interpolation and require a separate, well-validated evaluation kernel. -Low-level DAF/SPK parsing is provided by -`siderust::formats::spice::{daf, spk}`. The value-added `spod::spice` -module owns kernel indexing, segment evaluation, body-chain resolution, and -its service ephemeris adapter. +Low-level DAF/SPK parsing and evaluation are provided by +`siderust::formats::spice::{daf, spk}`. `spod` does not maintain a duplicate +SPICE implementation or compatibility namespace. ## Decision -`spod::spice` evaluates SPK Types 2 and 3. - -Types 9 and 13 are explicitly unsupported until representative mission -fixtures and validation against authoritative reference states are available. -A query that resolves to one of these segments returns -`SpiceError::UnsupportedDataType { data_type }`. - -Other unsupported SPK types fail through the same explicit error path rather -than producing an approximate or silently incorrect state. +Applications needing SPK support should use the canonical Siderust APIs. ## Rationale @@ -38,8 +29,4 @@ reference. ## Consequences -- JPL DE-series kernels are supported through the current `SpkKernel` path. -- Mission kernels that require unsupported segment types fail explicitly. -- Adding a new segment type extends `SpkSegment`/segment dispatch and adds - authoritative regression coverage; it does not require a compatibility - reexport of the underlying Siderust parser namespace. +- Service code does not own reusable SPK parsing or evaluation. diff --git a/docs/architecture/boundaries.md b/docs/architecture/boundaries.md index 6a0ac86..2f314b0 100644 --- a/docs/architecture/boundaries.md +++ b/docs/architecture/boundaries.md @@ -2,7 +2,7 @@ | Module | Owns | Does NOT own | |---|---|---| -| `spod::io` | Parsers/writers for SP3, RINEX, ANTEX, EOP, OEM, ... | Numerics, estimation. | +| `siderust::formats` | Standard-format parsing and writing | Service orchestration and transport. | | `siderust::pod` | Force models, propagation, observations, estimation, QC, products, and run metadata. | Service configuration and transport. | | `spod::service` | Configuration loading, job orchestration, service-owned adapters, artifact layout, and manifest finalisation using `siderust::pod`. | Reusable numerical algorithms. | | `spod` binaries | `clap`-based commands and the REST server; delegates to `spod::service`. | Any numerical logic. | diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index e55665a..e597f78 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -1,8 +1,8 @@ # Architecture Overview `spod` is a service/application built on Siderust's canonical POD APIs. -It owns configuration, external interfaces, format adapters, orchestration, -and artifacts; reusable dynamics, observations, estimation, QC, products, +It owns configuration, external interfaces, orchestration, and artifacts; +reusable dynamics, observations, estimation, QC, products, and run metadata remain in `siderust::pod`. ## Dependency structure @@ -22,7 +22,6 @@ flowchart TD PRINCIPIA[principia] --> POD SIDERUST[siderust] --> POD - POD --> IO[io / formats] POD --> SVC[service / CLI / REST] SIDERUST --> SCIENCE[siderust::pod] SCIENCE --> SVC @@ -40,8 +39,7 @@ artefacts. ```mermaid flowchart LR - RAW[Raw observations and auxiliary data] --> IO[Parsing and providers] - IO --> SVC[Service translation and orchestration] + RAW[Raw observations and auxiliary data] --> SVC[Service orchestration] SVC --> POD[siderust::pod execution] POD --> OUT[Service artifacts and manifest] ``` diff --git a/docs/architecture/providers.md b/docs/architecture/providers.md index c32610f..3b1019d 100644 --- a/docs/architecture/providers.md +++ b/docs/architecture/providers.md @@ -3,6 +3,5 @@ The former local POD provider facade has been removed. Reusable POD provider APIs are consumed from `siderust::pod` or its underlying public Siderust APIs. -The service keeps only the small `spod::service::providers::EphemerisProvider` -trait needed by its SPICE/LISA artifact adapters; it is not a scientific -compatibility layer. +The service does not define a provider compatibility layer. Service workflows +use the provider interfaces exposed by Siderust directly. diff --git a/docs/conventions/examples-and-doctests.md b/docs/conventions/examples-and-doctests.md index 573d6fb..5df1cfa 100644 --- a/docs/conventions/examples-and-doctests.md +++ b/docs/conventions/examples-and-doctests.md @@ -25,50 +25,7 @@ and builds documentation with broken intra-doc links denied. ## Typed example -The Lambert module exposes its API directly through `spod::lambert`: - -```rust -use siderust::affn::cartesian::Position; -use siderust::affn::frames::ICRS; -use siderust::qtty::unit::Kilometer; -use siderust::qtty::{GravitationalParameter, Second}; -use spod::lambert::{lambert, LambertBranch}; - -let r1 = Position::<(), ICRS, Kilometer>::new(15_945.34, 0.0, 0.0); -let r2 = Position::<(), ICRS, Kilometer>::new(12_214.83899, 10_249.46731, 0.0); -let tof = Second::new(4_560.0); -let mu = GravitationalParameter::new(398_600.4418); - -let solution = lambert(r1, r2, tof, mu, LambertBranch::Prograde).unwrap(); -assert!(solution.v1.x().value().is_finite()); -``` - -## Examples that need large files - -When an example requires a large external kernel or observation file, use a -`no_run` fence and document the prerequisite: - -```rust,no_run -use spod::spice::SpkKernel; - -let kernel = SpkKernel::open("/data/spk/de441.bsp")?; -let state = kernel.state(399, 0, 0.0)?; -assert!(state[0].is_finite()); -# Ok::<(), spod::spice::SpiceError>(()) -``` - -`no_run` examples are still type-checked. - -## Runnable examples - -The current runnable examples are: - -```bash -cargo run --example 03_lambert_earth_to_mars -cargo run --example 04_sgp4_from_tle -``` - -The larger service demonstration is the synthetic POD configuration: +The service demonstration is the synthetic POD configuration: ```bash cargo run --bin spod -- validate-config examples/configs/leo_gnss_mvp1.yaml diff --git a/docs/conventions/naming.md b/docs/conventions/naming.md index 02f59f7..2e243c5 100644 --- a/docs/conventions/naming.md +++ b/docs/conventions/naming.md @@ -10,7 +10,7 @@ Public types end in the role they play, not in implementation detail: | Suffix | Meaning | Example | |-------------|--------------------------------------------------------|-------------------------------| | `*State` | Mutable / time-evolving snapshot of an entity | `EkfState`, `OrbitState` | -| `*Provider` | Trait or struct that supplies data on demand | `EphemerisProvider` | +| `*Provider` | Trait or struct that supplies data on demand | `ObservationProvider` | | `*Error` | Enum returned from fallible operations | `LambertError`, `SpiceError` | | `*Model` | Pure mathematical model (force, measurement, …) | `MeasurementModel` | | `*Product` | Material output of a POD run | `Sp3Product`, `OemProduct` | @@ -60,8 +60,8 @@ de441 = [] parquet = ["dep:arrow", "dep:parquet"] ``` -The public crate is named `spod`; its internal areas are modules such as -`spod::io` and `spod::service`. User-facing flags use hyphens. +The public crate is named `spod`; its application area is `spod::service`. +Reusable scientific and format APIs are imported directly from Siderust. ## Tests diff --git a/docs/formats/antex.md b/docs/formats/antex.md index 468f7e4..9d56b90 100644 --- a/docs/formats/antex.md +++ b/docs/formats/antex.md @@ -19,7 +19,7 @@ electrical phase centre on a per-frequency basis. ## Owning crate -`spod::io` +`siderust::formats::igs::antex` ## Supported subset diff --git a/docs/formats/blq.md b/docs/formats/blq.md index 2b3c394..cfa9c2b 100644 --- a/docs/formats/blq.md +++ b/docs/formats/blq.md @@ -22,7 +22,7 @@ Planned for **Phase 5** alongside the SINEX coordinate-frame extension. ## Owning crate -`spod::io` (planned). +`siderust::formats` (when supported upstream). ## Notes diff --git a/docs/formats/ccsds-oem.md b/docs/formats/ccsds-oem.md index df08105..8b3bf4e 100644 --- a/docs/formats/ccsds-oem.md +++ b/docs/formats/ccsds-oem.md @@ -19,7 +19,7 @@ interoperable input format for trajectory ingest. ## Owning crate -`spod::io` +`siderust::formats::ccsds::oem` ## Supported on read diff --git a/docs/formats/daf-spk.md b/docs/formats/daf-spk.md index e555c50..ac6e915 100644 --- a/docs/formats/daf-spk.md +++ b/docs/formats/daf-spk.md @@ -21,45 +21,6 @@ siderust::formats::spice::daf siderust::formats::spice::spk ``` -`spod::spice` adds service/application functionality that is not a simple -reexport of those namespaces: - -- `SpkKernel` owns kernel bytes, indexes summaries, and resolves body chains; -- `SpkSegment` evaluates the supported segment representations; -- `SpiceEphemerisProvider` adapts kernel queries to the service provider - boundary; -- `SpiceError` reports kernel/index/evaluation failures. - -Callers that only need the low-level DAF/SPK parser should import Siderust -directly. - -## Supported SPK segment types - -| Type | Description | Status | -| --- | --- | --- | -| 2 | Chebyshev position | supported | -| 3 | Chebyshev position and velocity | supported | - -These types cover the JPL DE-series kernels used by the current validation -path. - -## Unsupported segment types - -Other SPK data types are indexed when possible but are not evaluated by the -current `spod::spice` implementation. A state query that resolves to an -unsupported segment returns: - -```rust -spod::spice::SpiceError::UnsupportedDataType { data_type: 9 } -``` - -Types 9 and 13 remain intentionally deferred; see -[ADR-0007](../adr/0007-spk-type-coverage.md). - -## Implementation notes - -- `SpkKernel::open` currently reads the kernel bytes into memory. -- DAF metadata is parsed with `siderust::formats::spice::daf::Daf`. -- Body-relative states are resolved by walking the indexed target/center - segment graph and summing the appropriate segment states. -- Epochs are TDB seconds past J2000, matching NAIF SPK conventions. +`spod` does not add a SPICE compatibility namespace or maintain a second +kernel implementation. Service code that needs SPICE should import the +canonical Siderust APIs directly. diff --git a/docs/formats/eop-iers.md b/docs/formats/eop-iers.md index 5528caf..1a84c82 100644 --- a/docs/formats/eop-iers.md +++ b/docs/formats/eop-iers.md @@ -20,7 +20,7 @@ frame transform. ## Owning crate -`spod::io` +`siderust::formats::iers::eop` ## Supported subset diff --git a/docs/formats/ilrs-cpf.md b/docs/formats/ilrs-cpf.md index d71a9d1..7aec6e8 100644 --- a/docs/formats/ilrs-cpf.md +++ b/docs/formats/ilrs-cpf.md @@ -19,7 +19,7 @@ and target visibility planning. ## Owning crate -`spod::io` +`siderust::formats::ilrs::cpf` ## Supported record subset diff --git a/docs/formats/ilrs-crd.md b/docs/formats/ilrs-crd.md index e671869..4e1db47 100644 --- a/docs/formats/ilrs-crd.md +++ b/docs/formats/ilrs-crd.md @@ -19,7 +19,7 @@ station, target, and meteorological metadata. ## Owning crate -`spod::io` +`siderust::formats::ilrs::crd` ## Supported record subset diff --git a/docs/formats/rinex-nav.md b/docs/formats/rinex-nav.md index 88d7fa5..445ee37 100644 --- a/docs/formats/rinex-nav.md +++ b/docs/formats/rinex-nav.md @@ -19,7 +19,7 @@ positions and clocks at user-defined epochs. ## Owning crate -`spod::io` +`siderust::formats::rinex::nav` ## Supported subset diff --git a/docs/formats/rinex-obs.md b/docs/formats/rinex-obs.md index df910d2..fa9303b 100644 --- a/docs/formats/rinex-obs.md +++ b/docs/formats/rinex-obs.md @@ -19,7 +19,7 @@ the receiver, antenna, and observation types. ## Owning crate -`spod::io` +`siderust::formats::rinex::obs` ## Supported subset diff --git a/docs/formats/sp3.md b/docs/formats/sp3.md index 1e9044a..7270ae6 100644 --- a/docs/formats/sp3.md +++ b/docs/formats/sp3.md @@ -20,7 +20,7 @@ records (`P`), with optional velocity (`V`), event (`EP`), and event-velocity ## Owning crate -`spod::io` +`siderust::formats::igs::sp3` ## Supported subset diff --git a/examples/03_lambert_earth_to_mars.rs b/examples/03_lambert_earth_to_mars.rs deleted file mode 100644 index 9b0a429..0000000 --- a/examples/03_lambert_earth_to_mars.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Typed heliocentric Earth → Mars Lambert transfer. -//! -//! This is deliberately a compact API example rather than a mission-design -//! model. It defines two notional heliocentric ICRS positions, a typed time of -//! flight and a typed solar gravitational parameter, then keeps the returned -//! velocities as affn/qtty values instead of unpacking them into f64. -//! -//! Run with: -//! cargo run --example 03_lambert_earth_to_mars - -#![allow(clippy::print_stdout)] - -use siderust::affn::cartesian::Position; -use siderust::affn::frames::ICRS; -use siderust::qtty::unit::Kilometer; -use siderust::qtty::GravitationalParameter; -use siderust::qtty::Second; -use spod::lambert::{lambert, LambertBranch}; - -const AU_KM: f64 = 1.495_978_707e8; -const MU_SUN_KM3_S2: f64 = 1.327_124_400_18e11; -const SECONDS_PER_DAY: f64 = 86_400.0; - -fn main() { - let earth_radius = AU_KM; - let mars_radius = 1.524 * AU_KM; - let mars_lead = 60.0_f64.to_radians(); - - let earth = Position::<(), ICRS, Kilometer>::new(earth_radius, 0.0, 0.0); - let mars = Position::<(), ICRS, Kilometer>::new( - mars_radius * mars_lead.cos(), - mars_radius * mars_lead.sin(), - 0.0, - ); - - let time_of_flight = Second::new(258.0 * SECONDS_PER_DAY); - let mu_sun = GravitationalParameter::new(MU_SUN_KM3_S2); - - let solution = lambert(earth, mars, time_of_flight, mu_sun, LambertBranch::Prograde) - .expect("Lambert solver failed for the demonstration geometry"); - - println!("=== Typed Earth → Mars Lambert transfer ==="); - println!("time of flight : {time_of_flight}"); - println!("departure speed: {:.6}", solution.v1.magnitude()); - println!("arrival speed : {:.6}", solution.v2.magnitude()); - println!("departure velocity:"); - println!(" vx = {:.6}", solution.v1.x()); - println!(" vy = {:.6}", solution.v1.y()); - println!(" vz = {:.6}", solution.v1.z()); - println!("arrival velocity:"); - println!(" vx = {:.6}", solution.v2.x()); - println!(" vy = {:.6}", solution.v2.y()); - println!(" vz = {:.6}", solution.v2.z()); - println!("iterations : {}", solution.diagnostics.iterations); - println!("revolutions : {}", solution.diagnostics.revolutions); -} diff --git a/examples/04_sgp4_from_tle.rs b/examples/04_sgp4_from_tle.rs deleted file mode 100644 index b150fcf..0000000 --- a/examples/04_sgp4_from_tle.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Parse a TLE and propagate typed TEME states with SGP4. -//! -//! The SGP4 wrapper already exposes the natural minutes-since-epoch API, so -//! this example uses it directly. Position and velocity stay typed in TEME, -//! kilometres, and kilometres per second all the way to presentation. -//! -//! Run with: -//! cargo run --example 04_sgp4_from_tle - -#![allow(clippy::print_stdout)] - -use spod::sgp4::{GravityModel, Sgp4Propagator}; -use spod::tle::parse_tle; - -const L1: &str = "1 00005U 58002B 00179.78495062 .00000023 00000-0 28098-4 0 4753"; -const L2: &str = "2 00005 34.2682 348.7242 1859667 331.7664 19.3264 10.82419157413667"; - -fn main() { - let tle = parse_tle(L1, L2).expect("Vallado verification TLE should parse"); - let propagator = Sgp4Propagator::from_tle_with_model(&tle, GravityModel::Wgs72) - .expect("SGP4 initialization should succeed"); - - println!("=== Typed SGP4 propagation ==="); - println!("satellite : NORAD {}", tle.norad_id.0); - println!("gravity model : {:?}", propagator.gravity_model()); - - for minutes in [0.0, 360.0, 720.0, 1_080.0] { - let state = propagator - .propagate_minutes(minutes) - .expect("propagation should succeed"); - - println!(); - println!("t = {minutes:>7.1} min"); - println!("radius : {:.3}", state.position().distance()); - println!("speed : {:.6}", state.velocity().magnitude()); - println!("position:"); - println!(" x = {:.3}", state.position().x()); - println!(" y = {:.3}", state.position().y()); - println!(" z = {:.3}", state.position().z()); - println!("velocity:"); - println!(" vx = {:.6}", state.velocity().x()); - println!(" vy = {:.6}", state.velocity().y()); - println!(" vz = {:.6}", state.velocity().z()); - } -} diff --git a/examples/README.md b/examples/README.md index d810046..ba4be3d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,14 +18,12 @@ This makes the examples useful as copyable application code while still showing | Example | What it demonstrates | | --- | --- | -| `03_lambert_earth_to_mars` | Solves a typed heliocentric Lambert transfer without unpacking the returned velocities into raw components | -| `04_sgp4_from_tle` | Parses a TLE and propagates typed TEME position/velocity states with the natural SGP4 minutes-since-epoch API | +| `configs/leo_gnss_mvp1.yaml` | Runs the synthetic LEO/GNSS POD reference workflow | -Run them with: +Run it with: ```bash -cargo run --example 03_lambert_earth_to_mars -cargo run --example 04_sgp4_from_tle +cargo run --bin spod -- run examples/configs/leo_gnss_mvp1.yaml ``` ## Synthetic POD pipeline diff --git a/scripts/check_dep_graph.sh b/scripts/check_dep_graph.sh index d67595d..66b8772 100755 --- a/scripts/check_dep_graph.sh +++ b/scripts/check_dep_graph.sh @@ -6,13 +6,13 @@ set -euo pipefail cd "$(dirname "$0")/.." if grep -En '^[[:space:]]*[A-Za-z0-9_-]+[[:space:]]*=.*\{[^}]*(path|git)[[:space:]]*=' \ - Cargo.toml target-sgp4-test/probe/Cargo.toml; then + Cargo.toml; then echo "Dependency-graph check found a path or git dependency." exit 1 fi source_null_count="$(cargo metadata --locked --format-version 1 | grep -o '"source":null' | wc -l)" -if [[ "$source_null_count" -ne 2 ]]; then +if [[ "$source_null_count" -ne 1 ]]; then echo "Dependency-graph check found an unexpected non-registry package." exit 1 fi diff --git a/src/io/antex.rs b/src/io/antex.rs deleted file mode 100644 index 1d1a892..0000000 --- a/src/io/antex.rs +++ /dev/null @@ -1,235 +0,0 @@ -//! # ANTEX antenna phase-centre offsets -//! -//! ## Scientific scope -//! -//! ANTEX files describe antenna phase-centre offsets and variations for -//! GNSS tracking equipment. This module focuses on the offset portion of -//! that standard because those north-east-up offsets are the part -//! immediately needed to model receiver and satellite antenna reference -//! points in MVP GNSS processing. -//! -//! The current regime is intentionally narrow: phase-centre variation grids -//! are ignored, so this parser is suitable for offset-driven geometry -//! corrections but not for full antenna pattern modelling. -//! -//! ## Technical scope -//! -//! The main entry point is `read_antex`, which returns a nested catalog -//! keyed by antenna and frequency identifiers. Offsets are exposed through -//! `Pco` displacement vectors in the millimetre convention carried by the -//! file. ANTEX orders the components as north, east, up; this module maps that -//! ordering to `x`, `y`, `z`. -//! -//! This module only parses the exchange format; it does not decide how -//! those offsets are applied inside any specific observation model. -//! -//! ## References -//! -//! - IGS MGEX/IGS. (2015). ANTEX: The Antenna Exchange Format, Version 1.4. -//! - Montenbruck, O., Steigenberger, P., & Hauschild, A. (2015). Broadcast -//! versus precise ephemerides for GNSS orbit determination. GPS -//! Solutions, 19(2), 321-330. -use super::PodIoError; -use siderust::affn::cartesian::Displacement; -use siderust::affn::frames::ReferenceFrame; -use siderust::qtty::length::Millimeter; -use std::collections::HashMap; -use std::io::{BufRead, BufReader, Read, Write}; - -/// ANTEX local north-east-up component frame. -/// -/// Component mapping for Cartesian vectors in this frame is: -/// -/// - `x`: north -/// - `y`: east -/// - `z`: up -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct AntexNeu; - -impl ReferenceFrame for AntexNeu { - fn frame_name() -> &'static str { - "ANTEX NEU" - } -} - -/// PCO entry for one antenna and one frequency. -/// -/// ANTEX stores phase-centre offsets as north/east/up millimetres. This type -/// is a free displacement vector with `x/y/z = north/east/up`. -pub type Pco = Displacement; - -/// Per-antenna PCO data, keyed by frequency identifier (e.g. "G01" for GPS L1). -pub type AntennaPco = HashMap; - -/// Parsed ANTEX file: `antenna_name -> per-frequency PCO`. -pub type AntexCatalog = HashMap; - -/// Parse an ANTEX file (subset). -pub fn read_antex(rdr: R) -> Result { - let br = BufReader::new(rdr); - let mut catalog = AntexCatalog::new(); - let mut current_antenna: Option = None; - let mut current_pcos: AntennaPco = AntennaPco::new(); - let mut current_freq: Option = None; - - for line in br.lines() { - let line = line?; - let label = line.get(60..).unwrap_or("").trim(); - let body = line.get(..60).unwrap_or(""); - match label { - "START OF ANTENNA" => { - current_antenna = None; - current_pcos.clear(); - current_freq = None; - } - "TYPE / SERIAL NO" => { - current_antenna = Some(body.trim().to_string()); - } - "START OF FREQUENCY" => { - current_freq = body.split_whitespace().next().map(|s| s.to_string()); - } - "NORTH / EAST / UP" => { - if let Some(freq) = ¤t_freq { - let parts: Vec = body - .split_whitespace() - .filter_map(|s| s.parse().ok()) - .collect(); - if parts.len() == 3 { - current_pcos.insert(freq.clone(), Pco::new(parts[0], parts[1], parts[2])); - } - } - } - "END OF FREQUENCY" => { - current_freq = None; - } - "END OF ANTENNA" => { - if let Some(name) = current_antenna.take() { - catalog.insert(name, std::mem::take(&mut current_pcos)); - } - } - _ => {} - } - } - Ok(catalog) -} - -fn header_line(w: &mut W, body: &str, label: &str) -> std::io::Result<()> { - let body = if body.len() > 60 { &body[..60] } else { body }; - writeln!(w, "{:<60}{}", body, label) -} - -/// Write an ANTEX 1.4 catalog matching the layout consumed by [`read_antex`]. -/// -/// Output is canonicalised: antennas are emitted in lexicographic order by -/// type/serial string, and frequencies within each antenna are sorted by -/// frequency identifier. Phase-centre **variation grids** are not written -/// (only the offset block is). -/// -/// Round-trip: `read_antex(write_antex(cat))` reproduces `cat` exactly -/// when only PCO offsets are present. -/// -/// # Errors -/// -/// Returns [`PodIoError::Io`] on write failures. -/// -/// # Examples -/// -/// ``` -/// use spod::io::antex::{read_antex, write_antex}; -/// -/// let src = "\ -/// START OF ANTENNA\n\ -/// TINY-ANT 001 TYPE / SERIAL NO\n\ -/// G01 START OF FREQUENCY\n\ -/// 1.00 0.00 90.00 NORTH / EAST / UP\n\ -/// END OF FREQUENCY\n\ -/// END OF ANTENNA\n"; -/// let cat = read_antex(src.as_bytes()).unwrap(); -/// let mut buf = Vec::new(); -/// write_antex(&mut buf, &cat).unwrap(); -/// let cat2 = read_antex(&buf[..]).unwrap(); -/// assert_eq!(cat.len(), cat2.len()); -/// ``` -pub fn write_antex(w: &mut W, catalog: &AntexCatalog) -> Result<(), PodIoError> { - header_line( - w, - " 1.4 G ", - "ANTEX VERSION / SYST", - )?; - header_line( - w, - "ANTEX ", - "PCV TYPE / REFANT", - )?; - header_line(w, "", "END OF HEADER")?; - - let mut antennas: Vec<&String> = catalog.keys().collect(); - antennas.sort(); - for ant in antennas { - header_line(w, "", "START OF ANTENNA")?; - header_line(w, ant, "TYPE / SERIAL NO")?; - let pcos = &catalog[ant]; - let mut freqs: Vec<&String> = pcos.keys().collect(); - freqs.sort(); - for freq in freqs { - let pco = &pcos[freq]; - header_line(w, &format!(" {:<3}", freq), "START OF FREQUENCY")?; - let body = format!( - "{:10.2}{:10.2}{:10.2}", - pco.x().value(), - pco.y().value(), - pco.z().value() - ); - header_line(w, &body, "NORTH / EAST / UP")?; - header_line(w, &format!(" {:<3}", freq), "END OF FREQUENCY")?; - } - header_line(w, "", "END OF ANTENNA")?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - const SAMPLE: &str = "\ - START OF ANTENNA -TEST-ANTENNA ABC TYPE / SERIAL NO - G01 START OF FREQUENCY - 1.50 0.20 90.00 NORTH / EAST / UP - END OF FREQUENCY - G02 START OF FREQUENCY - 0.50 -0.30 85.00 NORTH / EAST / UP - END OF FREQUENCY - END OF ANTENNA -"; - - #[test] - fn parses_pco_blocks() { - let cat = read_antex(SAMPLE.as_bytes()).unwrap(); - let ant = &cat["TEST-ANTENNA ABC"]; - let g01 = ant["G01"]; - assert!((g01.z().value() - 90.0).abs() < 1e-9); - let g02 = ant["G02"]; - assert!((g02.y().value() + 0.3).abs() < 1e-9); - } - - #[test] - fn round_trips_through_writer() { - let cat = read_antex(SAMPLE.as_bytes()).unwrap(); - let mut buf = Vec::new(); - write_antex(&mut buf, &cat).unwrap(); - let cat2 = read_antex(&buf[..]).unwrap(); - assert_eq!(cat.len(), cat2.len()); - for (name, pcos) in &cat { - let pcos2 = cat2.get(name).expect("antenna missing after round-trip"); - assert_eq!(pcos.len(), pcos2.len()); - for (freq, pco) in pcos { - let pco2 = pcos2[freq]; - assert!((pco.x().value() - pco2.x().value()).abs() < 1e-3); - assert!((pco.y().value() - pco2.y().value()).abs() < 1e-3); - assert!((pco.z().value() - pco2.z().value()).abs() < 1e-3); - } - } - } -} diff --git a/src/io/cpf.rs b/src/io/cpf.rs deleted file mode 100644 index 6868824..0000000 --- a/src/io/cpf.rs +++ /dev/null @@ -1,640 +0,0 @@ -//! # CPF orbit prediction reader and writer -//! -//! ## Scientific scope -//! -//! The Consolidated Prediction Format (CPF) distributes tabulated satellite -//! orbit predictions used by SLR stations to point their telescopes and to -//! validate POD solutions. This module supports CPF versions 1 and 2 in -//! both strict and permissive parse modes. -//! -//! Position records (type `10`) are mapped to typed -//! [`CpfEphemerisEntry`] values carrying: -//! -//! - A [`siderust::tempoch::Time`] epoch computed from the MJD + seconds-of-day. -//! - A Cartesian position in the ITRF frame expressed in kilometres as -//! [`siderust::affn::cartesian::Position`]. -//! -//! Velocity records (type `20`), manoeuvre records, and other optional -//! sections are outside the current scope. -//! -//! The [`write_cpf`] function serialises a [`CpfFile`] back to the CPF -//! text format, enabling round-trip validation. -//! -//! ## Technical scope -//! -//! Public entry points are [`read_cpf`], [`parse_cpf`], and -//! [`parse_cpf_with_mode`]. All three return a [`CpfFile`] that contains -//! both the backward-compatible [`CpfPosition`] records and the typed -//! [`CpfEphemeris`] view. -//! -//! ## References -//! -//! - International Laser Ranging Service. (2018). Consolidated Prediction -//! Format (CPF) Specification (v2.00). -//! -//! - Pearlman, M. R., Degnan, J. J., & Bosworth, J. M. (2002). The -//! International Laser Ranging Service. Advances in Space Research, -//! 30(2), 135–143. -use super::{FileLocation, ParseMode, PodIoError}; -use chrono::{DateTime, NaiveDate, Utc as ChronoUtc}; -use siderust::affn::cartesian; -use siderust::affn::centers::{AffineCenter, ReferenceCenter}; -use siderust::affn::frames::ITRF; -use siderust::qtty::length::Meters; -use siderust::qtty::time::Seconds; -use siderust::qtty::{unit::Kilometer, Days}; -use siderust::tempoch::{ModifiedJulianDate, Time, UTC}; -use std::fs; -use std::io::Write; -use std::path::Path; - -// ── GeocentricCenter ────────────────────────────────────────────────────────── - -/// Geocentric reference center for CPF positions (Earth's centre of mass). -/// -/// CPF positions are always given relative to the geocentre in ITRF. This -/// marker type anchors the type-level center constraint for -/// [`CpfEphemerisEntry::position`]. -/// -/// # Examples -/// -/// ``` -/// use spod::io::cpf::GeocentricCenter; -/// use siderust::affn::centers::ReferenceCenter; -/// assert_eq!(GeocentricCenter::center_name(), "Geocentric"); -/// ``` -#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)] -pub struct GeocentricCenter; - -impl ReferenceCenter for GeocentricCenter { - type Params = (); - fn center_name() -> &'static str { - "Geocentric" - } -} - -impl AffineCenter for GeocentricCenter {} - -/// Typed CPF position: geocentric, ITRF frame, kilometres. -type CpfCartesian = cartesian::Position; - -// ── CpfEphemerisEntry ───────────────────────────────────────────────────────── - -/// A typed CPF position record (type `10`). -/// -/// Carries a UTC epoch and a geocentric ITRF position in kilometres, -/// derived from the file-native metres and MJD + SOD values. -/// -/// # Examples -/// -/// ``` -/// use spod::io::cpf::{parse_cpf, GeocentricCenter}; -/// use siderust::affn::centers::ReferenceCenter; -/// -/// let txt = "H1 CPF 2 HTS 2024 01 01 00 1\n\ -/// H2 lageos1 1155 7603901 2024 1 1 0 0 0 2024 1 1 1 0 0 60 0 ITRF2014\n\ -/// 10 1 60310 0.0 0 6000000.0 9000000.0 7000000.0\n\ -/// 99\n"; -/// let f = parse_cpf(txt).unwrap(); -/// let e = &f.ephemeris.entries[0]; -/// // Position is in km; file gives metres, so 6 000 000 m → 6 000 km -/// assert!((e.position.x().value() - 6_000.0).abs() < 1e-6); -/// ``` -#[derive(Debug, Clone, PartialEq)] -pub struct CpfEphemerisEntry { - /// UTC epoch (MJD midnight + seconds of day). - pub epoch: Time, - /// Geocentric ITRF position in kilometres. - pub position: CpfCartesian, -} - -// ── CpfEphemeris ────────────────────────────────────────────────────────────── - -/// Collection of typed CPF position records. -/// -/// # Examples -/// -/// ``` -/// use spod::io::cpf::{parse_cpf, CpfEphemeris}; -/// -/// let txt = "H1 CPF 2 CNES 2024 01 01 00 1\n\ -/// H2 lageos1 1155 7603901 2024 1 1 0 0 0 2024 1 1 1 0 0 60 0 ITRF2014\n\ -/// 10 1 60310 0.0 0 6000000.0 9000000.0 7000000.0\n\ -/// 10 1 60310 60.0 0 6001200.0 8999100.0 7001500.0\n\ -/// 99\n"; -/// let f = parse_cpf(txt).unwrap(); -/// assert_eq!(f.ephemeris.entries.len(), 2); -/// ``` -#[derive(Debug, Clone, Default, PartialEq)] -pub struct CpfEphemeris { - /// Typed position entries in chronological order. - pub entries: Vec, -} - -impl CpfEphemeris { - /// Iterate over all ephemeris entries. - /// - /// # Examples - /// - /// ``` - /// use spod::io::cpf::parse_cpf; - /// - /// let txt = "H1 CPF 2 HTS 2024 01 01 00 1\n\ - /// H2 lageos1 1155 7603901 2024 1 1 0 0 0 2024 1 1 0 1 0 60 0 ITRF2014\n\ - /// 10 1 60310 0.0 0 7000000.0 0.0 0.0\n\ - /// 99\n"; - /// let f = parse_cpf(txt).unwrap(); - /// for e in f.ephemeris.iter() { - /// assert!(e.position.x().value().abs() > 1_000.0); // in km - /// } - /// ``` - pub fn iter(&self) -> impl Iterator { - self.entries.iter() - } -} - -impl std::ops::Index for CpfEphemeris { - type Output = CpfEphemerisEntry; - - fn index(&self, i: usize) -> &CpfEphemerisEntry { - &self.entries[i] - } -} - -// ── Backward-compatible position record ─────────────────────────────────────── - -/// Backward-compatible CPF position record. -/// -/// Kept for code that was written against the MVP-1 API. New code should -/// use [`CpfEphemerisEntry`] from [`CpfFile::ephemeris`]. -/// -/// # Examples -/// -/// ``` -/// use spod::io::cpf::parse_cpf; -/// let txt = "H1 CPF 2 HTS 2024 01 01 00 1\n\ -/// H2 s 0 0 2024 1 1 0 0 0 2024 1 1 0 1 0 60 0 ITRF2014\n\ -/// 10 1 60310 0.0 0 7000000.0 0.0 0.0\n99\n"; -/// let f = parse_cpf(txt).unwrap(); -/// assert!((f.positions[0].position_m[0].value() - 7_000_000.0).abs() < 1e-3); -/// ``` -#[derive(Debug, Clone)] -pub struct CpfPosition { - /// Modified Julian Date of sample (UTC). - pub mjd: ModifiedJulianDate, - /// Seconds of day (UTC). - pub seconds_of_day: Seconds, - /// Position in metres, ITRF. - pub position_m: [Meters; 3], -} - -// ── CpfFile ─────────────────────────────────────────────────────────────────── - -/// Parsed CPF file. -/// -/// Contains both the backward-compatible [`positions`](CpfFile::positions) -/// view and the typed [`ephemeris`](CpfFile::ephemeris). -/// -/// # Examples -/// -/// ``` -/// use spod::io::cpf::parse_cpf; -/// let txt = "H1 CPF 2 CNES 2024 01 01 00 1\n\ -/// H2 lageos1 1155 7603901 2024 1 1 0 0 0 2024 1 1 1 0 0 60 0 ITRF2014\n\ -/// 10 1 60310 0.0 0 6000000.0 9000000.0 7000000.0\n99\n"; -/// let f = parse_cpf(txt).unwrap(); -/// assert_eq!(f.version, "2"); -/// assert_eq!(f.reference_frame, "ITRF2014"); -/// assert_eq!(f.ephemeris.entries.len(), 1); -/// ``` -#[derive(Debug, Clone, Default)] -pub struct CpfFile { - /// Source agency (H1). - pub source: String, - /// CPF version (H1). - pub version: String, - /// Target name (H2). - pub target_name: String, - /// COSPAR ID (H2). - pub cospar_id: String, - /// SIC (H2). - pub sic: i32, - /// NORAD (H2). - pub norad: String, - /// Reference frame string from H2 (e.g. `"ITRF2014"`). - pub reference_frame: String, - /// Step size in seconds (H2, 0 if unspecified). - pub time_step_s: f64, - /// Backward-compatible position records (metres). - pub positions: Vec, - /// Typed ephemeris entries (km, UTC epoch). - pub ephemeris: CpfEphemeris, - /// Parse mode used. - pub parse_mode: ParseMode, -} - -// ── Public entry points ─────────────────────────────────────────────────────── - -/// Read a CPF file from disk using [`ParseMode::Strict`]. -/// -/// # Examples -/// -/// ```no_run -/// use spod::io::cpf::read_cpf; -/// let f = read_cpf("lageos1.cpf").unwrap(); -/// println!("{} ephemeris entries", f.ephemeris.entries.len()); -/// ``` -pub fn read_cpf>(path: P) -> Result { - let text = fs::read_to_string(path.as_ref())?; - parse_cpf_impl(&text, ParseMode::Strict, Some(path.as_ref().into())) -} - -/// Parse a CPF string using [`ParseMode::Strict`]. -/// -/// # Examples -/// -/// ``` -/// use spod::io::cpf::parse_cpf; -/// let txt = "H1 CPF 2 HTS 2024 01 01 00 1\n\ -/// H2 s 0 0 2024 1 1 0 0 0 2024 1 1 0 1 0 60 0 ITRF2014\n\ -/// 10 1 60310 0.0 0 7000000.0 0.0 0.0\n99\n"; -/// let f = parse_cpf(txt).unwrap(); -/// assert_eq!(f.version, "2"); -/// ``` -pub fn parse_cpf(text: &str) -> Result { - parse_cpf_impl(text, ParseMode::Strict, None) -} - -/// Parse a CPF string with an explicit [`ParseMode`]. -/// -/// In `Permissive` mode, malformed position records are silently skipped -/// rather than causing a hard error. -/// -/// # Examples -/// -/// ``` -/// use spod::io::{ParseMode, cpf::parse_cpf_with_mode}; -/// let txt = "H1 CPF 2 HTS 2024 01 01 00 1\nH2 s 0 0\n10 1 BROKEN\n99\n"; -/// let f = parse_cpf_with_mode(txt, ParseMode::Permissive).unwrap(); -/// assert_eq!(f.positions.len(), 0); // skipped -/// ``` -pub fn parse_cpf_with_mode(text: &str, mode: ParseMode) -> Result { - parse_cpf_impl(text, mode, None) -} - -/// Write a [`CpfFile`] to a writer in CPF text format. -/// -/// The output is deterministic and can be re-parsed by [`parse_cpf`] to -/// recover the same records (round-trip). Positions are written as metres -/// to preserve precision. -/// -/// # Examples -/// -/// ``` -/// use spod::io::cpf::{parse_cpf, write_cpf}; -/// -/// let txt = "H1 CPF 2 CNES 2024 01 01 00 1\n\ -/// H2 lageos1 1155 7603901 2024 1 1 0 0 0 2024 1 1 1 0 0 60 0 ITRF2014\n\ -/// 10 1 60310 0.000 0 6000000.000 9000000.000 7000000.000\n\ -/// 10 1 60310 60.000 0 6001200.000 8999100.000 7001500.000\n\ -/// 99\n"; -/// let f = parse_cpf(txt).unwrap(); -/// let mut buf = Vec::new(); -/// write_cpf(&f, &mut buf).unwrap(); -/// let s = String::from_utf8(buf).unwrap(); -/// let f2 = parse_cpf(&s).unwrap(); -/// assert_eq!(f.positions.len(), f2.positions.len()); -/// assert_eq!(f.reference_frame, f2.reference_frame); -/// ``` -pub fn write_cpf(f: &CpfFile, w: &mut W) -> Result<(), PodIoError> { - // H1: CPF 1 - writeln!(w, "H1 CPF {} {} 2024 01 01 00 1", f.version, f.source).map_err(PodIoError::Io)?; - // H2: - writeln!( - w, - "H2 {} {} {} 2024 01 01 00 00 00 2024 01 01 01 00 00 {} 0 {}", - if f.cospar_id.is_empty() { - &f.target_name - } else { - &f.cospar_id - }, - f.sic, - if f.norad.is_empty() { "0" } else { &f.norad }, - if f.time_step_s > 0.0 { - format!("{}", f.time_step_s as u64) - } else { - "60".to_string() - }, - f.reference_frame, - ) - .map_err(PodIoError::Io)?; - // Position records. - for pos in &f.positions { - let mjd_int = pos.mjd.raw().value() as i64; - writeln!( - w, - "10 1 {} {:.3} 0 {:.3} {:.3} {:.3}", - mjd_int, - pos.seconds_of_day.value(), - pos.position_m[0].value(), - pos.position_m[1].value(), - pos.position_m[2].value(), - ) - .map_err(PodIoError::Io)?; - } - writeln!(w, "99").map_err(PodIoError::Io)?; - Ok(()) -} - -// ── Internal parser ─────────────────────────────────────────────────────────── - -fn parse_cpf_impl( - text: &str, - mode: ParseMode, - path: Option, -) -> Result { - let mut out = CpfFile { - parse_mode: mode, - ..Default::default() - }; - - for (line_idx, raw) in text.lines().enumerate() { - let line_no = line_idx + 1; - let line = raw.trim_end(); - if line.is_empty() { - continue; - } - let mut tokens = line.split_whitespace(); - let tag = match tokens.next() { - Some(t) => t, - None => continue, - }; - - match tag { - "H1" => { - // H1 CPF - let _fmt = tokens.next(); // "CPF" - if let Some(v) = tokens.next() { - out.version = v.to_string(); - } - if let Some(s) = tokens.next() { - out.source = s.to_string(); - } - } - "H2" => { - // v2: H2 cospar sic norad start... end... step leap frame - // v1: H2 cospar sic norad start_year ... frame - // Collect all tokens; reference frame is the last one. - let toks: Vec<&str> = tokens.collect(); - if let Some(cospar) = toks.first() { - out.cospar_id = cospar.to_string(); - out.target_name = cospar.to_string(); - } - if let Some(sic_str) = toks.get(1) { - out.sic = sic_str.parse().unwrap_or(0); - } - if let Some(norad_str) = toks.get(2) { - out.norad = norad_str.to_string(); - } - // Time step: field index 15 for v2 (0-indexed), else scan. - if let Some(step_str) = toks.get(15) { - out.time_step_s = step_str.parse().unwrap_or(0.0); - } - // Reference frame: last token. - if let Some(frame) = toks.last() { - out.reference_frame = frame.to_string(); - } - } - "10" => { - // 10 - let rest: Vec<&str> = tokens.collect(); - - let maybe_mjd = rest.get(1).and_then(|s| s.parse::().ok()); - let maybe_sod = rest.get(2).and_then(|s| s.parse::().ok()); - let maybe_x = rest.get(4).and_then(|s| s.parse::().ok()); - let maybe_y = rest.get(5).and_then(|s| s.parse::().ok()); - let maybe_z = rest.get(6).and_then(|s| s.parse::().ok()); - - let (mjd_i, sod, x_m, y_m, z_m) = - match (maybe_mjd, maybe_sod, maybe_x, maybe_y, maybe_z) { - (Some(m), Some(s), Some(x), Some(y), Some(z)) => (m, s, x, y, z), - (None, ..) => { - let loc = FileLocation::new(path.clone(), Some(line_no), None); - let err = - PodIoError::located("CPF v2 §4.1", loc, "record 10: missing MJD"); - if mode == ParseMode::Strict { - return Err(err); - } - continue; - } - (_, None, ..) => { - let loc = FileLocation::new(path.clone(), Some(line_no), None); - let err = - PodIoError::located("CPF v2 §4.1", loc, "record 10: missing SOD"); - if mode == ParseMode::Strict { - return Err(err); - } - continue; - } - _ => { - let loc = FileLocation::new(path.clone(), Some(line_no), None); - let err = - PodIoError::located("CPF v2 §4.1", loc, "record 10: missing X/Y/Z"); - if mode == ParseMode::Strict { - return Err(err); - } - continue; - } - }; - - let mjd = ModifiedJulianDate::::try_new(Days::new(mjd_i as f64)) - .map_err(|_| PodIoError::Format(format!("CPF 10: invalid MJD {mjd_i}")))?; - - // Backward-compatible record (metres). - out.positions.push(CpfPosition { - mjd, - seconds_of_day: Seconds::new(sod), - position_m: [Meters::new(x_m), Meters::new(y_m), Meters::new(z_m)], - }); - - // Typed record: convert metres to km, compute epoch. - let epoch = build_epoch_from_mjd_sod(mjd_i, sod)?; - out.ephemeris.entries.push(CpfEphemerisEntry { - epoch, - position: CpfCartesian::new(x_m / 1_000.0, y_m / 1_000.0, z_m / 1_000.0), - }); - } - "20" | "30" | "40" | "50" | "60" => { /* velocity, corrections, etc. — skipped */ } - "99" => break, // end of file - _ => {} // unknown tags silently ignored - } - } - - Ok(out) -} - -/// Build a `Time` from an integer MJD and fractional seconds-of-day. -fn build_epoch_from_mjd_sod(mjd_int: i64, sod: f64) -> Result, PodIoError> { - // Convert MJD to calendar date, then to chrono, then add SOD. - // MJD 0 = 1858-11-17. MJD → Julian Day: JD = MJD + 2400000.5 - // Use Julian Day Number arithmetic to get year/month/day. - let jd_whole = mjd_int + 2_400_001; // integer JD at noon of MJD day - // Algorithm from USNO (Fliegel & Van Flandern, 1968): - let l = jd_whole + 68_569; - let n = (4 * l) / 146_097; - let l2 = l - (146_097 * n + 3) / 4; - let i = (4_000 * (l2 + 1)) / 1_461_001; - let l3 = l2 - (1_461 * i) / 4 + 31; - let j = (80 * l3) / 2_447; - let day = l3 - (2_447 * j) / 80; - let l4 = j / 11; - let month = j + 2 - 12 * l4; - let year = 100 * (n - 49) + i + l4; - - let sod_int = sod as u32; - let sod_nanos = ((sod - sod_int as f64) * 1e9).round() as u32; - let hour = sod_int / 3_600; - let minute = (sod_int % 3_600) / 60; - let second = sod_int % 60; - - let naive = NaiveDate::from_ymd_opt(year as i32, month as u32, day as u32) - .and_then(|d| d.and_hms_nano_opt(hour, minute, second, sod_nanos)) - .ok_or_else(|| PodIoError::Format(format!("CPF 10: invalid date from MJD {mjd_int}")))?; - let dt: DateTime = DateTime::from_naive_utc_and_offset(naive, ChronoUtc); - Time::::try_from_chrono(dt) - .map_err(|e| PodIoError::Format(format!("CPF 10: UTC conversion: {e}"))) -} - -#[cfg(test)] -mod tests { - use super::ParseMode; - use super::*; - - const SAMPLE: &str = "\ -H1 CPF 2 HTS 2024 01 01 00 1\n\ -H2 lageos1 1155 7603901 2024 01 01 00 00 00 0 0 ITRF2014\n\ -10 1 60310 0.000 0 7000000.0 0.0 0.0\n\ -10 1 60310 60.000 0 7000100.0 1000.0 -50.0\n\ -99\n"; - - #[test] - fn parses_minimal_cpf() { - let f = parse_cpf(SAMPLE).expect("parse"); - assert_eq!(f.version, "2"); - assert_eq!(f.reference_frame, "ITRF2014"); - assert_eq!(f.positions.len(), 2); - assert!((f.positions[1].mjd.raw().value() - 60310.0).abs() < 1e-9); - assert!((f.positions[1].position_m[0].value() - 7_000_100.0).abs() < 1e-9); - } - - #[test] - fn ephemeris_entries_populated() { - let f = parse_cpf(SAMPLE).expect("parse"); - assert_eq!(f.ephemeris.entries.len(), 2); - let e0 = &f.ephemeris.entries[0]; - // 7 000 000 m → 7 000 km - assert!((e0.position.x().value() - 7_000.0).abs() < 1e-6); - assert!((e0.position.y().value()).abs() < 1e-6); - } - - #[test] - fn ephemeris_epoch_correct() { - let f = parse_cpf(SAMPLE).expect("parse"); - use chrono::Timelike; - let dt = f.ephemeris.entries[0].epoch.try_to_chrono().unwrap(); - // MJD 60310 = 2024-01-01 UTC, SOD = 0 → midnight - assert_eq!(dt.hour(), 0); - assert_eq!(dt.minute(), 0); - assert_eq!(dt.second(), 0); - } - - #[test] - fn ephemeris_epoch_sod_offset() { - let f = parse_cpf(SAMPLE).expect("parse"); - use chrono::Timelike; - let dt = f.ephemeris.entries[1].epoch.try_to_chrono().unwrap(); - // SOD = 60 s → 00:01:00 - assert_eq!(dt.hour(), 0); - assert_eq!(dt.minute(), 1); - assert_eq!(dt.second(), 0); - } - - #[test] - fn geocentric_center_name() { - use siderust::affn::centers::ReferenceCenter; - assert_eq!(GeocentricCenter::center_name(), "Geocentric"); - } - - #[test] - fn round_trip_write_reparse() { - let txt = "\ -H1 CPF 2 CNES 2024 01 01 00 1\n\ -H2 lageos1 1155 7603901 2024 01 01 00 00 00 2024 01 01 01 00 00 60 0 ITRF2014\n\ -10 1 60310 0.000 0 6000000.000 9000000.000 7000000.000\n\ -10 1 60310 60.000 0 6001200.000 8999100.000 7001500.000\n\ -99\n"; - let f1 = parse_cpf(txt).expect("parse original"); - let mut buf = Vec::new(); - write_cpf(&f1, &mut buf).expect("write"); - let s = String::from_utf8(buf).expect("utf8"); - let f2 = parse_cpf(&s).expect("re-parse"); - assert_eq!( - f1.positions.len(), - f2.positions.len(), - "position count preserved" - ); - assert_eq!( - f1.reference_frame, f2.reference_frame, - "reference frame preserved" - ); - for (p1, p2) in f1.positions.iter().zip(f2.positions.iter()) { - assert!( - (p1.position_m[0].value() - p2.position_m[0].value()).abs() < 1e-3, - "X position round-trip" - ); - assert!( - (p1.position_m[1].value() - p2.position_m[1].value()).abs() < 1e-3, - "Y position round-trip" - ); - assert!( - (p1.position_m[2].value() - p2.position_m[2].value()).abs() < 1e-3, - "Z position round-trip" - ); - } - } - - #[test] - fn strict_missing_mjd_fails() { - let bad = "H1 CPF 2 TST 2024 01 01 00 1\nH2 tgt 0 0\n10 1\n"; - let err = parse_cpf(bad).expect_err("missing MJD should fail"); - assert!(matches!(err, PodIoError::Located { .. })); - } - - #[test] - fn permissive_missing_mjd_skips() { - let bad = "H1 CPF 2 TST 2024 01 01 00 1\nH2 tgt 0 0\n10 1\n"; - let f = parse_cpf_with_mode(bad, ParseMode::Permissive).expect("permissive ok"); - assert_eq!(f.positions.len(), 0); - } - - #[test] - fn strict_missing_xyz_fails() { - let bad = "H1 CPF 2 TST 2024 01 01 00 1\nH2 tgt 0 0\n10 1 60310 0.0 0\n"; - let err = parse_cpf(bad).expect_err("missing XYZ should fail"); - assert!(matches!(err, PodIoError::Located { .. })); - } - - #[test] - fn permissive_missing_xyz_skips() { - let bad = "H1 CPF 2 TST 2024 01 01 00 1\nH2 tgt 0 0\n10 1 60310 0.0 0\n"; - let f = parse_cpf_with_mode(bad, ParseMode::Permissive).expect("permissive ok"); - assert_eq!(f.positions.len(), 0); - } - - #[test] - fn unknown_tags_ignored() { - let txt = "H1 CPF 2 CNES 2024 01 01 00 1\nXX garbage\nH2 sat 0 0\n99\n"; - let f = parse_cpf(txt).expect("no error"); - assert_eq!(f.source, "CNES"); - } -} diff --git a/src/io/crd.rs b/src/io/crd.rs deleted file mode 100644 index c51f49a..0000000 --- a/src/io/crd.rs +++ /dev/null @@ -1,612 +0,0 @@ -//! # CRD SLR observation reader -//! -//! ## Scientific scope -//! -//! The Consolidated Laser Ranging Data (CRD) format is the primary interchange -//! format for Satellite Laser Ranging (SLR) observations published by the -//! International Laser Ranging Service (ILRS). This module supports CRD -//! versions 1 and 2 with both strict and permissive parse modes. -//! -//! The primary POD product is the **normal-point** record (type `11`), which -//! is a compressed, time-averaged two-way range observation. Full-rate records -//! (type `10`) are also parsed. Station and target metadata (H2/H3 header -//! blocks) are captured in typed structs. -//! -//! Station motion, atmospheric delay, range residual analysis, and any -//! corrections beyond what is in the file are outside this module. -//! -//! ## Technical scope -//! -//! Public entry points are [`read_crd`], [`parse_crd`], and -//! [`parse_crd_with_mode`]. All three return a [`CrdFile`] containing: -//! -//! - [`CrdStation`] / [`CrdTarget`] metadata from H2/H3. -//! - A [`Vec`] of typed normal-point records. -//! - A [`Vec`] of raw range records (both types 10 and 11) for -//! backward compatibility. -//! -//! The one-way slant range in [`NormalPoint::range_m`] is derived as -//! `c × TOF / 2` where `c = 299 792 458 m s⁻¹`. -//! -//! ## References -//! -//! - International Laser Ranging Service. (2022). Consolidated Laser -//! Ranging Data Format Specification (v2.01). -//! -//! - Pearlman, M. R., Noll, C. E., et al. (2019). The ILRS: Current status -//! and future prospects. Journal of Geodesy, 93, 2161–2180. -use super::{FileLocation, ParseMode, PodIoError}; -use chrono::{DateTime, NaiveDate, Utc as ChronoUtc}; -use siderust::qtty::length::Meters; -use siderust::qtty::time::Seconds; -use siderust::tempoch::{Time, UTC}; -use std::fs; -use std::path::Path; - -/// Speed of light in m/s (IAU 2012 definition). -const SPEED_OF_LIGHT_M_S: f64 = 299_792_458.0; - -// ── Station / Target metadata ───────────────────────────────────────────────── - -/// Station metadata from the CRD H2 header record. -/// -/// # Examples -/// -/// ``` -/// use spod::io::crd::{CrdStation, parse_crd}; -/// let txt = "H2 GRAZ 7839 1 1 0\nH8\n"; -/// let f = parse_crd(txt).unwrap(); -/// assert_eq!(f.station.name, "GRAZ"); -/// assert_eq!(f.station.cdp_pad, 7839); -/// ``` -#[derive(Debug, Clone, Default, PartialEq)] -pub struct CrdStation { - /// Station name (e.g. `"GRAZ"`). - pub name: String, - /// CDP Pad number. - pub cdp_pad: i32, - /// System number. - pub sys_no: i32, - /// Occupancy sequence number. - pub occ_no: i32, - /// Station time zone (hours from UTC, usually 0). - pub time_zone: i32, -} - -/// Target (satellite) metadata from the CRD H3 header record. -/// -/// # Examples -/// -/// ``` -/// use spod::io::crd::{CrdTarget, parse_crd}; -/// let txt = "H3 lageos1 1155 7603901 0 1\nH8\n"; -/// let f = parse_crd(txt).unwrap(); -/// assert_eq!(f.target.name, "lageos1"); -/// assert_eq!(f.target.sic, 1155); -/// ``` -#[derive(Debug, Clone, Default, PartialEq)] -pub struct CrdTarget { - /// Target name (e.g. `"lageos1"`). - pub name: String, - /// Satellite Identification Code. - pub sic: i32, - /// COSPAR/NORAD-style identifier. - pub norad: String, - /// Spacecraft flag (0 = passive, 1 = active). - pub sc_flag: i32, - /// Epoch identifier. - pub epoch_id: i32, -} - -// ── Typed normal-point record ───────────────────────────────────────────────── - -/// A typed normal-point observation (CRD record type `11`). -/// -/// A normal point compresses many raw returns within a time bin into a single -/// smoothed two-way range measurement. This is the primary POD input product -/// for SLR stations. -/// -/// The one-way slant range [`range_m`](NormalPoint::range_m) is computed as -/// `c × time_of_flight / 2`. -/// -/// # Examples -/// -/// ``` -/// use spod::io::crd::parse_crd; -/// -/// let txt = "\ -/// H1 CRD 2 2024 01 01 00\n\ -/// H2 GRAZ 7839 1 1 0\n\ -/// H3 lageos1 1155 7603901 0 1\n\ -/// H4 0 2024 01 01 08 00 00 12 00 00 0 0 0 0 1 0\n\ -/// C0 0 std 1 5 0 0 1 0 0 0 0 0 0 0\n\ -/// 11 28800.0 0.05123456789 std 0 0 1 0 5 8.0 0.1 0.0 0.0 90.0\n\ -/// H8\n"; -/// let f = parse_crd(txt).unwrap(); -/// assert_eq!(f.normal_points.len(), 1); -/// let np = &f.normal_points[0]; -/// assert!((np.time_of_flight.value() - 0.05123456789).abs() < 1e-12); -/// // One-way range ≈ 7 678 km -/// assert!(np.range_m.value() > 7_000_000.0); -/// ``` -#[derive(Debug, Clone)] -pub struct NormalPoint { - /// Seconds of day (UTC) at the observation epoch. - pub seconds_of_day: Seconds, - /// UTC epoch (session date midnight + SOD). `None` if H4 was absent. - pub epoch: Option>, - /// Two-way time-of-flight in seconds. - pub time_of_flight: Seconds, - /// One-way slant range in metres (`= c × TOF / 2`). - pub range_m: Meters, - /// System configuration ID from the preceding C0 record. - pub system_config_id: String, - /// Epoch event (0 = centre, 1 = start, 2 = end of bin). - pub epoch_event: u8, - /// Filter / calibration flag. - pub filter_flag: u8, - /// Data quality indicator. - pub data_quality: u8, - /// Format flag. - pub format_flag: u8, - /// Number of raw ranges contributing to this normal point. - pub num_raws: Option, - /// RMS of raw two-way ranges within the bin (m). - pub bin_rms_m: Option, - /// Normal-point bin size (s). - pub bin_size_s: Option, - /// Return rate within the bin (%). - pub return_rate: Option, -} - -// ── Backward-compatible raw range record ────────────────────────────────────── - -/// A raw SLR range record from CRD type `10` (full-rate) or `11` (normal-point). -/// -/// Kept for backward compatibility. New code should prefer [`NormalPoint`] -/// for type-11 records. -/// -/// # Examples -/// -/// ``` -/// use spod::io::crd::parse_crd; -/// let txt = "H2 S 0 0 0 0\nH3 t 0 0 0 1\nH4 0 2024 1 1 0 0 0 0 0 0 0 0 0 0 1 0\n\ -/// 11 300.0 0.08 std 0 0 1 0\nH8\n"; -/// let f = parse_crd(txt).unwrap(); -/// assert_eq!(f.ranges[0].record_type, 11); -/// ``` -#[derive(Debug, Clone)] -pub struct CrdRange { - /// Seconds of day (UTC) at the epoch of the range observation. - pub seconds_of_day: Seconds, - /// Two-way time-of-flight. - pub time_of_flight: Seconds, - /// System configuration ID. - pub system_config_id: String, - /// `10` for full-rate, `11` for normal-point. - pub record_type: u8, -} - -// ── CrdFile ─────────────────────────────────────────────────────────────────── - -/// Parsed CRD file (header + observations). -/// -/// Contains both the high-level typed view ([`normal_points`](CrdFile::normal_points)) -/// and the backward-compatible raw [`ranges`](CrdFile::ranges). -/// -/// # Examples -/// -/// ``` -/// use spod::io::crd::parse_crd; -/// let txt = "H2 GRAZ 7839 1 1 0\nH3 lageos1 1155 7603901 0 1\nH8\n"; -/// let f = parse_crd(txt).unwrap(); -/// assert_eq!(f.station_name, "GRAZ"); -/// assert_eq!(f.target.sic, 1155); -/// ``` -#[derive(Debug, Clone, Default)] -pub struct CrdFile { - /// Station name — mirrors `station.name` for backward compatibility. - pub station_name: String, - /// CDP/Pad number — mirrors `station.cdp_pad`. - pub station_cdp_pad: i32, - /// Satellite name — mirrors `target.name`. - pub satellite_name: String, - /// SIC — mirrors `target.sic`. - pub satellite_sic: i32, - /// NORAD/COSPAR ID — mirrors `target.norad`. - pub satellite_norad: String, - /// Session start (UTC midnight) from the H4 record. - pub session_date: Option>, - /// Raw range records (types 10 and 11) — backward-compatible view. - pub ranges: Vec, - /// CRD format version string from H1 (e.g. `"2"`). - pub format_version: String, - /// Typed station metadata. - pub station: CrdStation, - /// Typed target (satellite) metadata. - pub target: CrdTarget, - /// Normal-point records (type 11) — primary POD product. - pub normal_points: Vec, - /// Parse mode used. - pub parse_mode: ParseMode, -} - -// ── Public entry points ─────────────────────────────────────────────────────── - -/// Read a CRD file from disk using [`ParseMode::Strict`]. -/// -/// # Examples -/// -/// ```no_run -/// use spod::io::crd::read_crd; -/// let f = read_crd("station.crd").unwrap(); -/// println!("{} normal points", f.normal_points.len()); -/// ``` -pub fn read_crd>(path: P) -> Result { - let text = fs::read_to_string(path.as_ref())?; - parse_crd_impl(&text, ParseMode::Strict, Some(path.as_ref().into())) -} - -/// Parse a CRD string using [`ParseMode::Strict`]. -/// -/// # Examples -/// -/// ``` -/// use spod::io::crd::parse_crd; -/// let f = parse_crd("H2 GRAZ 7839 1 1 0\nH8\n").unwrap(); -/// assert_eq!(f.station_name, "GRAZ"); -/// ``` -pub fn parse_crd(text: &str) -> Result { - parse_crd_impl(text, ParseMode::Strict, None) -} - -/// Parse a CRD string with an explicit [`ParseMode`]. -/// -/// In `Strict` mode, a malformed range record (missing SOD or TOF) is a hard -/// error. In `Permissive` mode, such records are silently skipped. -/// -/// # Examples -/// -/// ``` -/// use spod::io::{ParseMode, crd::parse_crd_with_mode}; -/// // Permissive: a broken "11" record is skipped rather than failing. -/// let txt = "H2 GRAZ 7839 1 1 0\nH3 lageos1 1155 7603901 0 1\n11 BROKEN\nH8\n"; -/// let f = parse_crd_with_mode(txt, ParseMode::Permissive).unwrap(); -/// assert_eq!(f.normal_points.len(), 0); // skipped -/// ``` -pub fn parse_crd_with_mode(text: &str, mode: ParseMode) -> Result { - parse_crd_impl(text, mode, None) -} - -// ── Internal parser ─────────────────────────────────────────────────────────── - -fn parse_crd_impl( - text: &str, - mode: ParseMode, - path: Option, -) -> Result { - let mut out = CrdFile { - parse_mode: mode, - ..Default::default() - }; - let mut current_sys = String::from("std"); - let mut h4_date: Option<(i32, u32, u32)> = None; - - for (line_idx, raw) in text.lines().enumerate() { - let line_no = line_idx + 1; - let line = raw.trim_end(); - if line.is_empty() { - continue; - } - let mut tokens = line.split_whitespace(); - let tag = match tokens.next() { - Some(t) => t, - None => continue, - }; - - match tag { - "H1" => { - // H1 CRD - let _fmt = tokens.next(); // "CRD" - if let Some(v) = tokens.next() { - out.format_version = v.to_string(); - } - } - "H2" => { - // H2 - let name = tokens.next().unwrap_or("").to_string(); - let cdp_pad = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0); - let sys_no = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0); - let occ_no = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0); - let time_zone = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0); - out.station = CrdStation { - name: name.clone(), - cdp_pad, - sys_no, - occ_no, - time_zone, - }; - out.station_name = name; - out.station_cdp_pad = cdp_pad; - } - "H3" => { - // H3 - let name = tokens.next().unwrap_or("").to_string(); - let sic: i32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0); - let norad = tokens.next().unwrap_or("").to_string(); - let sc_flag: i32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0); - let epoch_id: i32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0); - out.target = CrdTarget { - name: name.clone(), - sic, - norad: norad.clone(), - sc_flag, - epoch_id, - }; - out.satellite_name = name; - out.satellite_sic = sic; - out.satellite_norad = norad; - } - "H4" => { - // H4 ... - let _kind = tokens.next(); - let year: i32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0); - let month: u32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0); - let day: u32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0); - if let Some(naive) = - NaiveDate::from_ymd_opt(year, month, day).and_then(|d| d.and_hms_opt(0, 0, 0)) - { - let dt = DateTime::from_naive_utc_and_offset(naive, ChronoUtc); - if let Ok(t) = Time::::try_from_chrono(dt) { - out.session_date = Some(t); - h4_date = Some((year, month, day)); - } - } - } - "H5" | "H8" | "H9" => { /* session/file end markers — ignored */ } - "C0" => { - // C0 ... - let _detail = tokens.next(); - if let Some(s) = tokens.next() { - current_sys = s.to_string(); - } - } - "C1" | "C2" | "C3" | "C4" => { /* other config records — skipped */ } - "10" | "11" => { - let record_type: u8 = if tag == "11" { 11 } else { 10 }; - - let sod_str = tokens.next(); - let tof_str = tokens.next(); - - let (sod, tof) = match ( - sod_str.and_then(|s| s.parse::().ok()), - tof_str.and_then(|s| s.parse::().ok()), - ) { - (Some(s), Some(t)) => (s, t), - (None, _) => { - let loc = FileLocation::new(path.clone(), Some(line_no), None); - let err = PodIoError::located( - "CRD v2 §4.1", - loc, - format!("record {tag}: missing seconds-of-day"), - ); - if mode == ParseMode::Strict { - return Err(err); - } - continue; - } - (_, None) => { - let loc = FileLocation::new(path.clone(), Some(line_no), None); - let err = PodIoError::located( - "CRD v2 §4.1", - loc, - format!("record {tag}: missing time-of-flight"), - ); - if mode == ParseMode::Strict { - return Err(err); - } - continue; - } - }; - - // Gather remaining optional fields for normal points. - let rest: Vec<&str> = tokens.collect(); - - // Backward-compatible raw range record. - out.ranges.push(CrdRange { - seconds_of_day: Seconds::new(sod), - time_of_flight: Seconds::new(tof), - system_config_id: current_sys.clone(), - record_type, - }); - - if record_type == 11 { - // Build epoch directly from H4 date + SOD components to - // avoid floating-point precision loss in the JD round-trip. - let epoch = h4_date.and_then(|(y, mo, d)| { - let whole = sod as i64; - let subsec_nanos = ((sod - whole as f64) * 1_000_000_000.0).round() as u32; - let hh = (whole / 3600) as u32; - let mm = ((whole % 3600) / 60) as u32; - let ss = (whole % 60) as u32; - let naive = NaiveDate::from_ymd_opt(y, mo, d) - .and_then(|nd| nd.and_hms_nano_opt(hh, mm, ss, subsec_nanos))?; - let dt = DateTime::from_naive_utc_and_offset(naive, ChronoUtc); - Time::::try_from_chrono(dt).ok() - }); - let range_m = Meters::new(tof * SPEED_OF_LIGHT_M_S / 2.0); - - // Parse optional fields from the "11" record. - // CRD v2 §4.1 record-11 field order (after TOF): - // sys_cfg_id epoch_event filter_flag data_quality - // format_flag num_raws bin_rms(ps) bin_skew bin_kurtosis - // bin_peak return_rate [detector_channel] - let sys_id = rest - .first() - .map(|s| s.to_string()) - .unwrap_or_else(|| current_sys.clone()); - let epoch_event: u8 = rest.get(1).and_then(|s| s.parse().ok()).unwrap_or(0); - let filter_flag: u8 = rest.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); - let data_quality: u8 = rest.get(3).and_then(|s| s.parse().ok()).unwrap_or(0); - let format_flag: u8 = rest.get(4).and_then(|s| s.parse().ok()).unwrap_or(0); - let num_raws: Option = rest.get(5).and_then(|s| s.parse().ok()); - // bin_rms is in picoseconds in the file; convert to metres. - let bin_rms_m: Option = rest - .get(6) - .and_then(|s| s.parse::().ok()) - .map(|ps| Meters::new(ps * 1e-12 * SPEED_OF_LIGHT_M_S / 2.0)); - let return_rate: Option = rest.get(9).and_then(|s| s.parse().ok()); - - out.normal_points.push(NormalPoint { - seconds_of_day: Seconds::new(sod), - epoch, - time_of_flight: Seconds::new(tof), - range_m, - system_config_id: sys_id, - epoch_event, - filter_flag, - data_quality, - format_flag, - num_raws, - bin_rms_m, - bin_size_s: None, - return_rate, - }); - } - } - // Records 12–52: supplements/met/pointing/cal/stats — skip. - "12" | "20" | "21" | "30" | "40" | "41" | "50" | "60" => {} - _ => { /* unknown tag — silently skip in both modes */ } - } - } - - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::ParseMode; - use super::*; - - #[test] - fn parses_minimal_crd() { - let txt = "\ -H1 CRD 2 2024 01 01 00\n\ -H2 7090 7090 1 01 0\n\ -H3 lageos1 1155 7603901 8820 1\n\ -H4 0 2024 01 01 00 00 00 00 00 00 0 0 0 0 1 0\n\ -C0 0 std\n\ -11 12345.000 0.05123456789 std 1 1 1 1 0 0\n\ -11 12375.000 0.05123446712 std 1 1 1 1 0 0\n\ -H8\n"; - let f = parse_crd(txt).expect("parse"); - assert_eq!(f.station_name, "7090"); - assert_eq!(f.satellite_name, "lageos1"); - use chrono::Datelike; - let d = f.session_date.unwrap().try_to_chrono().unwrap(); - assert_eq!(d.year(), 2024); - assert_eq!(d.month(), 1); - assert_eq!(d.day(), 1); - assert_eq!(f.ranges.len(), 2); - assert!((f.ranges[0].time_of_flight.value() - 0.05123456789).abs() < 1e-15); - assert_eq!(f.ranges[0].record_type, 11); - } - - #[test] - fn normal_points_populated() { - let txt = "\ -H1 CRD 2 2024 01 01 00\n\ -H2 GRAZ 7839 1 1 0\n\ -H3 lageos1 1155 7603901 0 1\n\ -H4 0 2024 01 01 08 00 00 12 00 00 0 0 0 0 1 0\n\ -C0 0 std 1 5 0 0 1 0 0 0 0 0 0 0\n\ -11 28800.0 0.051234567890 std 0 0 1 0 5 8.0 0.0 0.0 0.0 90.0\n\ -11 28830.0 0.051198765432 std 0 0 1 0 5 7.5 0.0 0.0 0.0 88.0\n\ -H8\n"; - let f = parse_crd(txt).expect("parse"); - assert_eq!(f.normal_points.len(), 2); - let np = &f.normal_points[0]; - assert!((np.time_of_flight.value() - 0.051234567890).abs() < 1e-12); - // Range ≈ c * TOF / 2 - let expected_m = 0.051234567890 * SPEED_OF_LIGHT_M_S / 2.0; - assert!((np.range_m.value() - expected_m).abs() < 1.0); - assert_eq!(np.num_raws, Some(5)); - } - - #[test] - fn epoch_computed_from_session_date() { - let txt = "\ -H1 CRD 2 2024 01 01 00\n\ -H2 GRAZ 7839 1 1 0\n\ -H3 lageos1 1155 7603901 0 1\n\ -H4 0 2024 01 01 08 00 00 12 00 00 0 0 0 0 1 0\n\ -11 3600.0 0.051 std 0 0 0 0\n\ -H8\n"; - let f = parse_crd(txt).expect("parse"); - let np = &f.normal_points[0]; - assert!(np.epoch.is_some(), "epoch should be computed from H4 date"); - use chrono::Timelike; - let dt = np.epoch.unwrap().try_to_chrono().unwrap(); - assert_eq!(dt.hour(), 1); // 3600 s after midnight = 01:00 - } - - #[test] - fn station_and_target_populated() { - let txt = "\ -H2 GRAZ 7839 1 2 0\n\ -H3 lageos1 1155 7603901 0 1\n\ -H8\n"; - let f = parse_crd(txt).expect("parse"); - assert_eq!(f.station.name, "GRAZ"); - assert_eq!(f.station.cdp_pad, 7839); - assert_eq!(f.station.sys_no, 1); - assert_eq!(f.station.occ_no, 2); - assert_eq!(f.target.name, "lageos1"); - assert_eq!(f.target.sic, 1155); - assert_eq!(f.target.norad, "7603901"); - } - - #[test] - fn format_version_parsed() { - let txt = "H1 CRD 2 2024 01 01 00\nH8\n"; - let f = parse_crd(txt).expect("parse"); - assert_eq!(f.format_version, "2"); - } - - #[test] - fn strict_missing_sod_fails() { - let txt = "H2 S 0 0 0 0\n11\nH8\n"; - let err = parse_crd(txt).expect_err("strict: missing SOD"); - assert!(matches!(err, PodIoError::Located { .. })); - } - - #[test] - fn permissive_missing_sod_skips() { - let txt = "H2 S 0 0 0 0\n11\nH8\n"; - let f = parse_crd_with_mode(txt, ParseMode::Permissive).expect("permissive ok"); - assert_eq!(f.normal_points.len(), 0); - } - - #[test] - fn strict_missing_tof_fails() { - let txt = "H2 S 0 0 0 0\n11 123.0\nH8\n"; - let err = parse_crd(txt).expect_err("strict: missing TOF"); - assert!(matches!(err, PodIoError::Located { .. })); - } - - #[test] - fn permissive_missing_tof_skips() { - let txt = "H2 S 0 0 0 0\n11 123.0\nH8\n"; - let f = parse_crd_with_mode(txt, ParseMode::Permissive).expect("permissive ok"); - assert_eq!(f.normal_points.len(), 0); - } - - #[test] - fn full_rate_records_parsed() { - let txt = "H2 S 0 0 0 0\n10 100.0 0.08 std\nH8\n"; - let f = parse_crd(txt).expect("parse full-rate"); - assert_eq!(f.ranges.len(), 1); - assert_eq!(f.ranges[0].record_type, 10); - assert_eq!(f.normal_points.len(), 0); // type-10 not added to NPs - } -} diff --git a/src/io/eop.rs b/src/io/eop.rs deleted file mode 100644 index 4edeb48..0000000 --- a/src/io/eop.rs +++ /dev/null @@ -1,189 +0,0 @@ -//! # IERS C04 Earth-orientation records -//! -//! ## Scientific scope -//! -//! Earth-orientation parameters link terrestrial and celestial reference -//! frames by supplying polar motion, UT1-UTC, and related series. This -//! module reads the daily IERS C04 product that underpins precise Earth -//! rotation in POD frame transforms. -//! -//! Its validity regime is the cadence and fields offered by the C04 series. -//! Higher-order interpolation policy and any conversion into richer EOP -//! dataset abstractions are delegated downstream. -//! -//! ## Technical scope -//! -//! The module exports `EopRecord`, `read_eop_c04`, and a simple -//! `interpolate` helper. Records expose Modified Julian Date tags and the -//! standard C04 scalar fields used by frame-transform providers. -//! -//! This code does not itself build a full transformation matrix or bind to -//! `siderust` frame APIs; it only prepares the underlying geodetic time- -//! series inputs. -//! -//! ## References -//! -//! - Bizouard, C., & Gambis, D. (2009). The combined solution C04 for Earth -//! Orientation Parameters. IERS Technical Note / Observatoire de Paris -//! release documentation. -//! - IERS Conventions Centre. (2010). IERS Conventions (2010). Verlag des -//! Bundesamts fur Kartographie und Geodasie. -use super::PodIoError; -use siderust::qtty::angular::{Arcseconds, MilliArcseconds}; -use siderust::qtty::time::Seconds; -use siderust::qtty::Days; -use siderust::tempoch::{ModifiedJulianDate, UTC}; -use std::io::{BufRead, BufReader, Read}; - -/// Earth-orientation parameters from an IERS C04 record. -/// -/// All fields use typed `qtty` quantities. Convert to radians with `.to::()`. -#[derive(Debug, Clone, Copy)] -pub struct EopValues { - /// UT1 − UTC. - pub dut1: Seconds, - /// Length of day offset. - pub lod: Seconds, - /// Pole x-coordinate. - pub xp: Arcseconds, - /// Pole y-coordinate. - pub yp: Arcseconds, - /// Celestial pole offset dX (nutation correction). - pub dx: MilliArcseconds, - /// Celestial pole offset dY (nutation correction). - pub dy: MilliArcseconds, -} - -/// A single Earth-orientation record from IERS C04. -#[derive(Debug, Clone, Copy)] -pub struct EopRecord { - /// Modified Julian Date (UTC). - pub mjd: ModifiedJulianDate, - /// Earth orientation parameters (polar motion, UT1-UTC, LOD, celestial pole offsets). - pub eop: EopValues, -} - -/// Parse a C04-style EOP file. -pub fn read_eop_c04(rdr: R) -> Result, PodIoError> { - let br = BufReader::new(rdr); - let mut out = Vec::new(); - for line in br.lines() { - let line = line?; - let trimmed = line.trim_start(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 10 { - continue; - } - // Heuristic: a valid data line has the first three fields parseable as integers - // (year, month, day) and the fourth as a float (MJD). - if parts[0].parse::().is_err() - || parts[1].parse::().is_err() - || parts[2].parse::().is_err() - { - continue; - } - let mjd_raw: f64 = match parts[3].parse() { - Ok(v) => v, - Err(_) => continue, - }; - let mjd = match ModifiedJulianDate::::try_new(Days::new(mjd_raw)) { - Ok(m) => m, - Err(_) => continue, - }; - let x = parts[4].parse().unwrap_or(0.0); - let y = parts[5].parse().unwrap_or(0.0); - let ut1 = parts[6].parse().unwrap_or(0.0); - let lod = parts[7].parse().unwrap_or(0.0); - let dpsi = parts[8].parse().unwrap_or(0.0); - let deps = parts[9].parse().unwrap_or(0.0); - out.push(EopRecord { - mjd, - eop: EopValues { - xp: Arcseconds::new(x), - yp: Arcseconds::new(y), - dut1: Seconds::new(ut1), - lod: Seconds::new(lod), - dx: MilliArcseconds::new(dpsi * 1000.0), - dy: MilliArcseconds::new(deps * 1000.0), - }, - }); - } - Ok(out) -} - -/// Linear interpolation of an EOP record at a target MJD. -/// -/// Returns `None` if the table is empty; clamps to the endpoints when the -/// requested epoch lies outside the table range. -pub fn interpolate(records: &[EopRecord], mjd: ModifiedJulianDate) -> Option { - if records.is_empty() { - return None; - } - if mjd <= records[0].mjd { - return Some(records[0]); - } - if mjd >= records[records.len() - 1].mjd { - return Some(records[records.len() - 1]); - } - // Binary search for the bracketing pair. - let mut lo = 0usize; - let mut hi = records.len() - 1; - while hi - lo > 1 { - let mid = (lo + hi) / 2; - if records[mid].mjd <= mjd { - lo = mid; - } else { - hi = mid; - } - } - let a = records[lo]; - let b = records[hi]; - let a_val = a.mjd.raw().value(); - let b_val = b.mjd.raw().value(); - let t_val = mjd.raw().value(); - let f = (t_val - a_val) / (b_val - a_val); - Some(EopRecord { - mjd, - eop: EopValues { - xp: Arcseconds::new(a.eop.xp.value() + f * (b.eop.xp.value() - a.eop.xp.value())), - yp: Arcseconds::new(a.eop.yp.value() + f * (b.eop.yp.value() - a.eop.yp.value())), - dut1: Seconds::new(a.eop.dut1.value() + f * (b.eop.dut1.value() - a.eop.dut1.value())), - lod: Seconds::new(a.eop.lod.value() + f * (b.eop.lod.value() - a.eop.lod.value())), - dx: MilliArcseconds::new(a.eop.dx.value() + f * (b.eop.dx.value() - a.eop.dx.value())), - dy: MilliArcseconds::new(a.eop.dy.value() + f * (b.eop.dy.value() - a.eop.dy.value())), - }, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - const SAMPLE: &str = "\ -# IERS C04 sample -2024 1 1 60310 0.123456 0.234567 0.012345 0.001234 0.0001 0.0002 -2024 1 2 60311 0.124000 0.235000 0.012000 0.001200 0.0001 0.0002 -"; - - #[test] - fn parses_two_records() { - let r = read_eop_c04(SAMPLE.as_bytes()).unwrap(); - assert_eq!(r.len(), 2); - assert!((r[0].mjd.raw().value() - 60310.0).abs() < 1e-9); - } - - #[test] - fn interpolates_midpoint() { - let r = read_eop_c04(SAMPLE.as_bytes()).unwrap(); - let mid = interpolate( - &r, - ModifiedJulianDate::::try_new(Days::new(60310.5)).unwrap(), - ) - .unwrap(); - let expect = (0.123456 + 0.124000) / 2.0; - assert!((mid.eop.xp.value() - expect).abs() < 1e-9); - } -} diff --git a/src/io/lisa.rs b/src/io/lisa.rs deleted file mode 100644 index e60d14c..0000000 --- a/src/io/lisa.rs +++ /dev/null @@ -1,671 +0,0 @@ -//! # LISA orbit-file reader and ephemeris provider -//! -//! ## Scientific scope -//! -//! The ESA LISA mission uses three spacecraft in a heliocentric triangular -//! formation. Their reference trajectories are distributed as CCSDS OEM v2.0 -//! files (Orbit Ephemeris Message, ASCII KVN layout) from the ESA LISA orbit -//! repository (). One file per -//! spacecraft is produced; each carries a single segment with state vectors in -//! the `SUN`-centred, EME2000-aligned, TDB-time frame. -//! -//! This module provides: -//! -//! - [`LisaOrbitReader`]: parses a single OEM file (one spacecraft) into a -//! typed [`LisaOrbit`]. -//! - [`LisaOrbitSet`]: holds the three loaded spacecraft orbits together. -//! - [`LisaEphemerisProvider`][]: implements -//! [`crate::service::providers::EphemerisProvider`] via **cubic Hermite** -//! interpolation over the tabulated state vectors. -//! -//! ## Interpolation -//! -//! Cubic Hermite interpolation uses the two bracketing tabulated states -//! (position and velocity) to construct a third-degree polynomial that exactly -//! reproduces the endpoint positions and velocities. Given states -//! `(t₀, p₀, v₀)` and `(t₁, p₁, v₁)`, the normalised parameter -//! `τ = (t − t₀) / (t₁ − t₀)` and the Hermite basis: -//! -//! ```text -//! h₀₀(τ) = 2τ³ − 3τ² + 1 -//! h₁₀(τ) = τ³ − 2τ² + τ (multiplied by dt = t₁ − t₀) -//! h₀₁(τ) = −2τ³ + 3τ² -//! h₁₁(τ) = τ³ − τ² (multiplied by dt) -//! -//! p(τ) = h₀₀·p₀ + h₁₀·dt·v₀ + h₀₁·p₁ + h₁₁·dt·v₁ -//! v(τ) = dp/dt (derivative of the cubic divided by dt) -//! ``` -//! -//! This is exact for constant-velocity (linear) motion and provides a smooth, -//! energy-consistent interpolant for the slowly varying LISA trajectories. -//! -//! ## References -//! -//! - Consultative Committee for Space Data Systems. (2019). Orbit Data -//! Messages, CCSDS 502.0-B-3. -//! - Martens, W., Joffre, E. (2021). Trajectory Design for the ESA LISA -//! Mission. *J. Astronaut. Sci.*, 68, 402–443. -//! - -use super::{oem::read_oem, PodIoError}; -use crate::service::providers::EphemerisProvider; -use siderust::affn::cartesian; -use siderust::affn::centers::{AffineCenter, ReferenceCenter}; -use siderust::affn::frames::EME2000; -use siderust::qtty::unit::Kilometer; -use siderust::tempoch::{JulianDate, Time, TDB}; -use std::io::Read; - -// ── KmPerSecond local alias ─────────────────────────────────────────────────── - -/// Velocity unit: kilometres per second. -type KmPerSecond = siderust::qtty::Per; - -// ── HeliocentricCenter ──────────────────────────────────────────────────────── - -/// Heliocentric reference centre for LISA orbit positions (Sun's centre of mass). -/// -/// LISA OEM files use `CENTER_NAME = SUN`. This marker type anchors the -/// type-level centre constraint for [`LisaOrbitPoint::position`]. -/// -/// # Examples -/// -/// ``` -/// use spod::io::lisa::HeliocentricCenter; -/// use siderust::affn::centers::ReferenceCenter; -/// assert_eq!(HeliocentricCenter::center_name(), "Heliocentric"); -/// ``` -#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)] -pub struct HeliocentricCenter; - -impl ReferenceCenter for HeliocentricCenter { - type Params = (); - fn center_name() -> &'static str { - "Heliocentric" - } -} - -impl AffineCenter for HeliocentricCenter {} - -// ── Typed position / velocity aliases ───────────────────────────────────────── - -/// Heliocentric, EME2000-framed position in kilometres. -pub type LisaPosition = cartesian::Position; - -/// EME2000-framed velocity in km/s. -pub type LisaVelocity = cartesian::Velocity; - -// ── LisaSpacecraftId ───────────────────────────────────────────────────────── - -/// Identifier for one of the three LISA spacecraft. -/// -/// The numeric suffix matches the OEM file extension convention: -/// `.oem1` → [`SC1`], `.oem2` → [`SC2`], `.oem3` → [`SC3`]. -/// -/// The NAIF-style integer codes (`−1001`, `−1002`, `−1003`) are used for -/// [`LisaEphemerisProvider`]. -/// -/// [`SC1`]: LisaSpacecraftId::SC1 -/// [`SC2`]: LisaSpacecraftId::SC2 -/// [`SC3`]: LisaSpacecraftId::SC3 -/// -/// # Examples -/// -/// ``` -/// use spod::io::lisa::LisaSpacecraftId; -/// assert_eq!(LisaSpacecraftId::SC1.naif_id(), -1001); -/// assert_eq!(LisaSpacecraftId::from_naif_id(-1002), Some(LisaSpacecraftId::SC2)); -/// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum LisaSpacecraftId { - /// LISA spacecraft 1. - SC1, - /// LISA spacecraft 2. - SC2, - /// LISA spacecraft 3. - SC3, -} - -impl LisaSpacecraftId { - /// Return the NAIF-style integer body ID for this spacecraft. - /// - /// # Examples - /// - /// ``` - /// use spod::io::lisa::LisaSpacecraftId; - /// assert_eq!(LisaSpacecraftId::SC3.naif_id(), -1003); - /// ``` - pub fn naif_id(self) -> i32 { - match self { - LisaSpacecraftId::SC1 => -1001, - LisaSpacecraftId::SC2 => -1002, - LisaSpacecraftId::SC3 => -1003, - } - } - - /// Resolve a NAIF-style body ID back to a [`LisaSpacecraftId`], or - /// `None` if the ID is not a LISA spacecraft. - /// - /// # Examples - /// - /// ``` - /// use spod::io::lisa::LisaSpacecraftId; - /// assert_eq!(LisaSpacecraftId::from_naif_id(-1001), Some(LisaSpacecraftId::SC1)); - /// assert_eq!(LisaSpacecraftId::from_naif_id(399), None); - /// ``` - pub fn from_naif_id(id: i32) -> Option { - match id { - -1001 => Some(LisaSpacecraftId::SC1), - -1002 => Some(LisaSpacecraftId::SC2), - -1003 => Some(LisaSpacecraftId::SC3), - _ => None, - } - } -} - -// ── LisaOrbitPoint ──────────────────────────────────────────────────────────── - -/// A single tabulated state vector from a LISA OEM file. -/// -/// # Examples -/// -/// ``` -/// use spod::io::lisa::{LisaOrbitReader, LisaSpacecraftId}; -/// -/// let oem = "\ -/// CCSDS_OEM_VERS = 2.0\n\ -/// CREATION_DATE = 2024-01-01T00:00:00\n\ -/// ORIGINATOR = TEST\n\ -/// \n\ -/// META_START\n\ -/// OBJECT_NAME = LISA-1\n\ -/// OBJECT_ID = -1001\n\ -/// CENTER_NAME = SUN\n\ -/// REF_FRAME = EME2000\n\ -/// TIME_SYSTEM = TDB\n\ -/// START_TIME = 2036-02-12T12:00:00.000\n\ -/// STOP_TIME = 2036-02-12T12:00:00.000\n\ -/// META_STOP\n\ -/// \n\ -/// 2036-02-12T12:00:00.000 100000000.0 50000000.0 10000000.0 10.0 20.0 5.0\n"; -/// -/// let orbit = LisaOrbitReader::from_str(oem, LisaSpacecraftId::SC1).unwrap(); -/// let pt = &orbit.points[0]; -/// assert!((pt.position.x().value() - 100000000.0).abs() < 1.0); -/// ``` -#[derive(Debug, Clone)] -pub struct LisaOrbitPoint { - /// Epoch in the Barycentric Dynamical Time (TDB) scale. - pub epoch: Time, - /// Heliocentric Cartesian position in the EME2000 frame, km. - pub position: LisaPosition, - /// Velocity in the EME2000 frame, km/s. - pub velocity: LisaVelocity, - /// TDB seconds since J2000.0 (cached for interpolation; not public API). - pub(crate) epoch_j2000_s: f64, -} - -// ── LisaOrbit ───────────────────────────────────────────────────────────────── - -/// Tabulated orbit for one LISA spacecraft, loaded from an OEM file. -/// -/// # Examples -/// -/// ``` -/// use spod::io::lisa::{LisaOrbitReader, LisaSpacecraftId}; -/// let raw = std::fs::read_to_string( -/// concat!(env!("CARGO_MANIFEST_DIR"), "/test-data/lisa/lisa_orbit_sample.oem1") -/// ).unwrap(); -/// let orbit = LisaOrbitReader::from_str(&raw, LisaSpacecraftId::SC1).unwrap(); -/// assert_eq!(orbit.spacecraft_id, LisaSpacecraftId::SC1); -/// assert_eq!(orbit.points.len(), 10); -/// ``` -#[derive(Debug, Clone)] -pub struct LisaOrbit { - /// Which spacecraft this orbit belongs to. - pub spacecraft_id: LisaSpacecraftId, - /// Tabulated state vectors in ascending epoch order. - pub points: Vec, -} - -// ── LisaOrbitReader ─────────────────────────────────────────────────────────── - -/// Reader for CCSDS OEM v2.0 LISA orbit files. -/// -/// Each LISA orbit file covers one spacecraft and contains a single metadata -/// segment with `CENTER_NAME = SUN`, `REF_FRAME = EME2000`, and -/// `TIME_SYSTEM = TDB`. -/// -/// # Examples -/// -/// ``` -/// use spod::io::lisa::{LisaOrbitReader, LisaSpacecraftId}; -/// -/// let raw = std::fs::read_to_string( -/// concat!(env!("CARGO_MANIFEST_DIR"), "/test-data/lisa/lisa_orbit_sample.oem1"), -/// ).unwrap(); -/// let orbit = LisaOrbitReader::from_str(&raw, LisaSpacecraftId::SC1).unwrap(); -/// assert_eq!(orbit.points.len(), 10); -/// ``` -pub struct LisaOrbitReader; - -impl LisaOrbitReader { - /// Parse a LISA OEM file from a [`Read`] source. - /// - /// The spacecraft identity is provided by the caller (it matches the file - /// extension: `.oem1` → [`LisaSpacecraftId::SC1`], etc.). - /// - /// # Errors - /// - /// Returns [`PodIoError`] when the underlying OEM parser fails or when an - /// epoch value cannot be converted to a finite TDB [`Time`]. - /// - /// # Examples - /// - /// ``` - /// use spod::io::lisa::{LisaOrbitReader, LisaSpacecraftId}; - /// use std::fs::File; - /// - /// let path = concat!(env!("CARGO_MANIFEST_DIR"), "/test-data/lisa/lisa_orbit_sample.oem1"); - /// let f = File::open(path).unwrap(); - /// let orbit = LisaOrbitReader::read(f, LisaSpacecraftId::SC1).unwrap(); - /// assert_eq!(orbit.points.len(), 10); - /// ``` - pub fn read( - reader: R, - spacecraft_id: LisaSpacecraftId, - ) -> Result { - let oem_file = read_oem(reader)?; - Self::convert(oem_file, spacecraft_id) - } - - /// Parse a LISA OEM file from a string slice. - /// - /// # Errors - /// - /// Same as [`LisaOrbitReader::read`]. - /// - /// # Examples - /// - /// ``` - /// use spod::io::lisa::{LisaOrbitReader, LisaSpacecraftId}; - /// - /// let oem = "\ - /// CCSDS_OEM_VERS = 2.0\n\ - /// CREATION_DATE = 2024-01-01T00:00:00\n\ - /// ORIGINATOR = TEST\n\ - /// \n\ - /// META_START\n\ - /// OBJECT_NAME = LISA-1\n\ - /// OBJECT_ID = -1001\n\ - /// CENTER_NAME = SUN\n\ - /// REF_FRAME = EME2000\n\ - /// TIME_SYSTEM = TDB\n\ - /// START_TIME = 2036-02-12T12:00:00.000\n\ - /// STOP_TIME = 2036-02-12T12:00:00.000\n\ - /// META_STOP\n\ - /// \n\ - /// 2036-02-12T12:00:00.000 100000000.0 50000000.0 10000000.0 10.0 20.0 5.0\n"; - /// - /// let orbit = LisaOrbitReader::from_str(oem, LisaSpacecraftId::SC1).unwrap(); - /// assert_eq!(orbit.points.len(), 1); - /// ``` - pub fn from_str(s: &str, spacecraft_id: LisaSpacecraftId) -> Result { - Self::read(s.as_bytes(), spacecraft_id) - } - - fn convert( - oem_file: crate::io::oem::OemFile, - spacecraft_id: LisaSpacecraftId, - ) -> Result { - // Collect all states across all segments in the file. - let mut points: Vec = Vec::new(); - for segment in oem_file.segments { - for state in segment.states { - let epoch_j2000_s = jd_to_j2000_seconds(state.epoch_jd); - let epoch = jd_to_time_tdb(state.epoch_jd)?; - let [x, y, z] = state.position_km; - let [vx, vy, vz] = state.velocity_km_s; - let position = LisaPosition::new( - siderust::qtty::Kilometers::new(x), - siderust::qtty::Kilometers::new(y), - siderust::qtty::Kilometers::new(z), - ); - let velocity = LisaVelocity::new( - siderust::qtty::Quantity::::new(vx), - siderust::qtty::Quantity::::new(vy), - siderust::qtty::Quantity::::new(vz), - ); - points.push(LisaOrbitPoint { - epoch, - epoch_j2000_s, - position, - velocity, - }); - } - } - if points.is_empty() { - return Err(PodIoError::Format( - "lisa: OEM file contains no state vectors".into(), - )); - } - // Sort by epoch to guarantee monotonicity for binary-search interpolation. - points.sort_by(|a, b| a.epoch_j2000_s.partial_cmp(&b.epoch_j2000_s).unwrap()); - Ok(LisaOrbit { - spacecraft_id, - points, - }) - } -} - -// ── LisaOrbitSet ───────────────────────────────────────────────────────────── - -/// All three LISA spacecraft orbits loaded together. -/// -/// # Examples -/// -/// ``` -/// use spod::io::lisa::{LisaOrbitReader, LisaOrbitSet, LisaSpacecraftId}; -/// -/// let root = concat!(env!("CARGO_MANIFEST_DIR"), "/test-data/lisa"); -/// let load = |name: &str, sc: LisaSpacecraftId| { -/// let raw = std::fs::read_to_string(format!("{root}/{name}")).unwrap(); -/// LisaOrbitReader::from_str(&raw, sc).unwrap() -/// }; -/// let set = LisaOrbitSet { -/// sc1: load("lisa_orbit_sample.oem1", LisaSpacecraftId::SC1), -/// sc2: load("lisa_orbit_sample.oem2", LisaSpacecraftId::SC2), -/// sc3: load("lisa_orbit_sample.oem3", LisaSpacecraftId::SC3), -/// }; -/// assert_eq!(set.sc1.points.len(), 10); -/// ``` -#[derive(Debug, Clone)] -pub struct LisaOrbitSet { - /// Orbit for spacecraft 1. - pub sc1: LisaOrbit, - /// Orbit for spacecraft 2. - pub sc2: LisaOrbit, - /// Orbit for spacecraft 3. - pub sc3: LisaOrbit, -} - -impl LisaOrbitSet { - /// Retrieve the orbit for the given spacecraft. - /// - /// # Examples - /// - /// ``` - /// use spod::io::lisa::{LisaOrbitReader, LisaOrbitSet, LisaSpacecraftId}; - /// - /// let root = concat!(env!("CARGO_MANIFEST_DIR"), "/test-data/lisa"); - /// let load = |name: &str, sc: LisaSpacecraftId| { - /// let raw = std::fs::read_to_string(format!("{root}/{name}")).unwrap(); - /// LisaOrbitReader::from_str(&raw, sc).unwrap() - /// }; - /// let set = LisaOrbitSet { - /// sc1: load("lisa_orbit_sample.oem1", LisaSpacecraftId::SC1), - /// sc2: load("lisa_orbit_sample.oem2", LisaSpacecraftId::SC2), - /// sc3: load("lisa_orbit_sample.oem3", LisaSpacecraftId::SC3), - /// }; - /// assert_eq!(set.orbit(LisaSpacecraftId::SC2).spacecraft_id, LisaSpacecraftId::SC2); - /// ``` - pub fn orbit(&self, sc: LisaSpacecraftId) -> &LisaOrbit { - match sc { - LisaSpacecraftId::SC1 => &self.sc1, - LisaSpacecraftId::SC2 => &self.sc2, - LisaSpacecraftId::SC3 => &self.sc3, - } - } -} - -// ── LisaEphemerisProvider ───────────────────────────────────────────────────── - -/// Ephemeris provider backed by the three LISA tabulated orbit files. -/// -/// Implements [`EphemerisProvider`] using **cubic Hermite interpolation** over -/// the tabulated state vectors (see the [module documentation](self) for the -/// mathematical details). -/// -/// # Spacecraft identification -/// -/// The `body_naif_id` parameter of [`EphemerisProvider::state`] maps to: -/// -/// | NAIF ID | Spacecraft | -/// |---------|-----------| -/// | −1001 | LISA SC1 | -/// | −1002 | LISA SC2 | -/// | −1003 | LISA SC3 | -/// -/// # Examples -/// -/// ``` -/// use spod::io::lisa::{LisaOrbitReader, LisaOrbitSet, LisaEphemerisProvider, LisaSpacecraftId}; -/// use spod::service::providers::EphemerisProvider; -/// -/// let root = concat!(env!("CARGO_MANIFEST_DIR"), "/test-data/lisa"); -/// let load = |name: &str, sc: LisaSpacecraftId| { -/// let raw = std::fs::read_to_string(format!("{root}/{name}")).unwrap(); -/// LisaOrbitReader::from_str(&raw, sc).unwrap() -/// }; -/// let provider = LisaEphemerisProvider::new(LisaOrbitSet { -/// sc1: load("lisa_orbit_sample.oem1", LisaSpacecraftId::SC1), -/// sc2: load("lisa_orbit_sample.oem2", LisaSpacecraftId::SC2), -/// sc3: load("lisa_orbit_sample.oem3", LisaSpacecraftId::SC3), -/// }); -/// // Query SC1 at its first epoch (should return exact tabulated value) -/// let pt = provider.state(-1001, 1_139_702_400.0).unwrap(); -/// assert!((pt.position.x().value() - 100000000.0).abs() < 1e-3); -/// ``` -pub struct LisaEphemerisProvider { - orbits: LisaOrbitSet, -} - -impl LisaEphemerisProvider { - /// Construct a provider from a loaded [`LisaOrbitSet`]. - /// - /// # Examples - /// - /// ``` - /// use spod::io::lisa::{LisaOrbitReader, LisaOrbitSet, LisaEphemerisProvider, LisaSpacecraftId}; - /// - /// let root = concat!(env!("CARGO_MANIFEST_DIR"), "/test-data/lisa"); - /// let load = |name: &str, sc: LisaSpacecraftId| { - /// let raw = std::fs::read_to_string(format!("{root}/{name}")).unwrap(); - /// LisaOrbitReader::from_str(&raw, sc).unwrap() - /// }; - /// let provider = LisaEphemerisProvider::new(LisaOrbitSet { - /// sc1: load("lisa_orbit_sample.oem1", LisaSpacecraftId::SC1), - /// sc2: load("lisa_orbit_sample.oem2", LisaSpacecraftId::SC2), - /// sc3: load("lisa_orbit_sample.oem3", LisaSpacecraftId::SC3), - /// }); - /// drop(provider); - /// ``` - pub fn new(orbits: LisaOrbitSet) -> Self { - Self { orbits } - } -} - -/// Error type for [`LisaEphemerisProvider`]. -/// -/// # Examples -/// -/// ``` -/// use spod::io::lisa::LisaProviderError; -/// let e = LisaProviderError::UnknownBody(-9999); -/// assert!(format!("{e}").contains("-9999")); -/// ``` -#[derive(Debug, thiserror::Error)] -pub enum LisaProviderError { - /// The requested NAIF body ID does not correspond to any LISA spacecraft. - #[error("unknown LISA body id {0} (expected -1001, -1002, or -1003)")] - UnknownBody(i32), - /// The requested epoch lies outside the covered interval. - #[error("epoch {0:.3} s (TDB J2000) is outside the covered interval [{1:.3}, {2:.3}]")] - OutOfRange(f64, f64, f64), -} - -impl EphemerisProvider for LisaEphemerisProvider { - /// Interpolated [`LisaOrbitPoint`] state. - type State = LisaOrbitPoint; - /// Query error type. - type Error = LisaProviderError; - - /// Return the interpolated state for a LISA spacecraft at the given epoch. - /// - /// `body_naif_id` must be −1001, −1002, or −1003. - /// `epoch_seconds_tdb` is TDB seconds since J2000.0. - /// - /// Uses cubic Hermite interpolation between the two bracketing tabulated - /// states. If the epoch exactly coincides with a tabulated epoch, the - /// tabulated value is returned without interpolation. - fn state( - &self, - body_naif_id: i32, - epoch_seconds_tdb: f64, - ) -> Result { - let sc = LisaSpacecraftId::from_naif_id(body_naif_id) - .ok_or(LisaProviderError::UnknownBody(body_naif_id))?; - let orbit = self.orbits.orbit(sc); - hermite_interp(orbit, epoch_seconds_tdb) - } -} - -// ── Hermite interpolation ───────────────────────────────────────────────────── - -fn hermite_interp(orbit: &LisaOrbit, t: f64) -> Result { - let pts = &orbit.points; - if pts.is_empty() { - return Err(LisaProviderError::OutOfRange(t, f64::NAN, f64::NAN)); - } - let t0 = pts.first().unwrap().epoch_j2000_s; - let t1 = pts.last().unwrap().epoch_j2000_s; - if t < t0 || t > t1 { - return Err(LisaProviderError::OutOfRange(t, t0, t1)); - } - - // Binary search for the left bracket. - let idx = match pts.binary_search_by(|p| { - p.epoch_j2000_s - .partial_cmp(&t) - .unwrap_or(std::cmp::Ordering::Less) - }) { - Ok(i) => return Ok(pts[i].clone()), // exact hit - Err(i) => i.saturating_sub(1).min(pts.len() - 2), - }; - - let p0 = &pts[idx]; - let p1 = &pts[idx + 1]; - - let dt = p1.epoch_j2000_s - p0.epoch_j2000_s; - let tau = (t - p0.epoch_j2000_s) / dt; - - // Cubic Hermite basis functions. - let tau2 = tau * tau; - let tau3 = tau2 * tau; - let h00 = 2.0 * tau3 - 3.0 * tau2 + 1.0; - let h10 = tau3 - 2.0 * tau2 + tau; - let h01 = -2.0 * tau3 + 3.0 * tau2; - let h11 = tau3 - tau2; - - // Interpolate position components. - let interp_pos = |a: f64, da: f64, b: f64, db: f64| -> f64 { - h00 * a + h10 * dt * da + h01 * b + h11 * dt * db - }; - - let x = interp_pos( - p0.position.x().value(), - p0.velocity.x().value(), - p1.position.x().value(), - p1.velocity.x().value(), - ); - let y = interp_pos( - p0.position.y().value(), - p0.velocity.y().value(), - p1.position.y().value(), - p1.velocity.y().value(), - ); - let z = interp_pos( - p0.position.z().value(), - p0.velocity.z().value(), - p1.position.z().value(), - p1.velocity.z().value(), - ); - - // Interpolate velocity components (derivative of the cubic / dt). - let interp_vel = |a: f64, da: f64, b: f64, db: f64| -> f64 { - let dh00 = 6.0 * tau2 - 6.0 * tau; - let dh10 = 3.0 * tau2 - 4.0 * tau + 1.0; - let dh01 = -6.0 * tau2 + 6.0 * tau; - let dh11 = 3.0 * tau2 - 2.0 * tau; - (dh00 * a + dh10 * dt * da + dh01 * b + dh11 * dt * db) / dt - }; - - let vx = interp_vel( - p0.position.x().value(), - p0.velocity.x().value(), - p1.position.x().value(), - p1.velocity.x().value(), - ); - let vy = interp_vel( - p0.position.y().value(), - p0.velocity.y().value(), - p1.position.y().value(), - p1.velocity.y().value(), - ); - let vz = interp_vel( - p0.position.z().value(), - p0.velocity.z().value(), - p1.position.z().value(), - p1.velocity.z().value(), - ); - - let position = LisaPosition::new( - siderust::qtty::Kilometers::new(x), - siderust::qtty::Kilometers::new(y), - siderust::qtty::Kilometers::new(z), - ); - let velocity = LisaVelocity::new( - siderust::qtty::Quantity::::new(vx), - siderust::qtty::Quantity::::new(vy), - siderust::qtty::Quantity::::new(vz), - ); - - // Epoch: linearly interpolate between the two bracketing epochs for the - // return value. The exact polynomial position/velocity is already captured. - let epoch_j2000_s = p0.epoch_j2000_s + tau * dt; - let epoch_jd = j2000_seconds_to_jd(epoch_j2000_s); - let epoch = jd_to_time_tdb(epoch_jd).unwrap_or(p0.epoch); - - Ok(LisaOrbitPoint { - epoch, - epoch_j2000_s, - position, - velocity, - }) -} - -// ── Time helpers ────────────────────────────────────────────────────────────── - -/// J2000.0 epoch as a Julian Date (TT, but negligibly different from TDB here). -const J2000_JD: f64 = 2_451_545.0; - -/// Convert a Julian Date (TDB) to TDB seconds since J2000.0. -fn jd_to_j2000_seconds(jd: f64) -> f64 { - (jd - J2000_JD) * 86_400.0 -} - -/// Convert TDB seconds since J2000.0 back to Julian Date (TDB). -fn j2000_seconds_to_jd(s: f64) -> f64 { - s / 86_400.0 + J2000_JD -} - -/// Construct a [`Time`] from a Julian Date value (TDB scale). -/// -/// # Errors -/// -/// Returns [`PodIoError::Format`] if `jd` is not finite or the conversion fails. -fn jd_to_time_tdb(jd: f64) -> Result, PodIoError> { - JulianDate::::try_new(siderust::qtty::Days::new(jd)) - .map(|enc| enc.to_j2000s()) - .map_err(|e| PodIoError::Format(format!("lisa: cannot convert JD {jd} to TDB Time: {e}"))) -} diff --git a/src/io/mod.rs b/src/io/mod.rs deleted file mode 100644 index 4d788f2..0000000 --- a/src/io/mod.rs +++ /dev/null @@ -1,211 +0,0 @@ -//! # spod I/O -//! -//! ## Scientific scope -//! -//! This crate handles the interchange formats that feed and leave POD -//! workflows: precise or broadcast GNSS products, SLR products, Earth- -//! orientation series, and orbit product files. The scientific content is -//! defined by the external standards being parsed or written rather than by -//! new physics inside the crate. -//! -//! Current coverage is intentionally selective. Each parser or writer -//! supports the subset needed by the MVP POD pipeline and ignores -//! unsupported record families when that is safer than pretending to -//! implement the full standard. -//! -//! ## Technical scope -//! -//! The crate exports format-specific reader and writer modules plus the -//! shared `PodIoError`. Most readers map external text or binary records -//! into typed Rust structs and, where appropriate, into `siderust` -//! orbit/time types such as `OrbitState` and `JulianDate`. -//! -//! Higher-level estimation, validation, and product orchestration are -//! outside the scope of this crate and are handled by sibling service, -//! observations, and products modules. -//! -//! ## References -//! -//! - IERS Conventions Centre. (2010). IERS Conventions (2010). Verlag des -//! Bundesamts fur Kartographie und Geodasie. -//! - Vallado, D. A. (2013). Fundamentals of Astrodynamics and Applications -//! (4th ed.). Microcosm Press. -#![forbid(unsafe_code)] -#![warn(missing_docs)] - -use std::path::PathBuf; -use thiserror::Error; - -pub mod antex; -pub mod cpf; -pub mod crd; -pub mod eop; -pub mod lisa; -pub mod oem; -pub mod omm; -pub mod opm; -pub mod orbex; -pub mod rinex_doris; -pub mod rinex_nav; -pub mod rinex_obs; -pub mod sinex; -pub mod sp3; -pub mod tdm; -pub mod vgosdb; - -/// Location inside an input artefact, used by structured diagnostics. -/// -/// Fields are 1-based when present (matching the convention of common text -/// editors and the format specifications themselves). `path` is `None` when -/// the input was a buffer rather than a named file. -/// -/// # Examples -/// -/// ``` -/// use spod::io::FileLocation; -/// let loc = FileLocation::new(Some("/data/igs.sp3".into()), Some(42), Some(7)); -/// assert_eq!(loc.line, Some(42)); -/// assert_eq!(loc.column, Some(7)); -/// assert!(loc.path.as_ref().unwrap().ends_with("igs.sp3")); -/// ``` -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct FileLocation { - /// Filesystem path, when known. - pub path: Option, - /// 1-based line number, when known. - pub line: Option, - /// 1-based column number, when known. - pub column: Option, -} - -impl FileLocation { - /// Build a [`FileLocation`] from optional fields. - /// - /// # Examples - /// - /// ``` - /// use spod::io::FileLocation; - /// let loc = FileLocation::new(None, Some(1), None); - /// assert!(loc.path.is_none()); - /// ``` - pub fn new(path: Option, line: Option, column: Option) -> Self { - Self { path, line, column } - } - - /// Build a buffer-anchored location with just a line number. - /// - /// # Examples - /// - /// ``` - /// use spod::io::FileLocation; - /// let loc = FileLocation::at_line(17); - /// assert_eq!(loc.line, Some(17)); - /// ``` - pub fn at_line(line: usize) -> Self { - Self { - path: None, - line: Some(line), - column: None, - } - } -} - -impl std::fmt::Display for FileLocation { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match (&self.path, self.line, self.column) { - (Some(p), Some(l), Some(c)) => write!(f, "{}:{}:{}", p.display(), l, c), - (Some(p), Some(l), None) => write!(f, "{}:{}", p.display(), l), - (Some(p), None, _) => write!(f, "{}", p.display()), - (None, Some(l), Some(c)) => write!(f, ":{}:{}", l, c), - (None, Some(l), None) => write!(f, ":{}", l), - (None, None, _) => write!(f, ""), - } - } -} - -/// Parse mode for permissive vs. strict ingestion. -/// -/// In `Strict` mode, any deviation from the published format spec is a -/// hard error. In `Permissive` mode, recoverable deviations (extra -/// whitespace, unknown header records, truncated trailing blocks) are -/// silently skipped while still yielding a deterministic result. -/// -/// Both modes are deterministic — `Permissive` is **not** a synonym for -/// "best-effort guess". -/// -/// # Examples -/// -/// ``` -/// use spod::io::ParseMode; -/// assert_eq!(ParseMode::default(), ParseMode::Strict); -/// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum ParseMode { - /// Reject any spec violation. - #[default] - Strict, - /// Recover from non-fatal deviations. - Permissive, -} - -/// Unified IO-layer error type for every format module. -/// -/// `Format` retains its free-form string variant for compatibility, while -/// `Located` is the structured variant carrying a [`FileLocation`] and a -/// per-format spec section reference (e.g. `"SP3-d §3.2"`). -/// -/// # Examples -/// -/// ``` -/// use spod::io::{FileLocation, PodIoError}; -/// let err = PodIoError::located( -/// "RINEX 3.05 §6.3", -/// FileLocation::at_line(42), -/// "epoch line malformed", -/// ); -/// assert!(format!("{err}").contains("RINEX")); -/// ``` -#[derive(Debug, Error)] -pub enum PodIoError { - /// Underlying IO failure. - #[error("io: {0}")] - Io(#[from] std::io::Error), - /// Malformed or unsupported file content (free-form). - #[error("format: {0}")] - Format(String), - /// Format or feature not yet supported. - #[error("unsupported: {0}")] - Unsupported(String), - /// Structured format error with file location and spec section. - #[error("{spec} at {location}: {message}")] - Located { - /// Reference to the format-spec section being violated. - spec: &'static str, - /// Where the violation was observed. - location: FileLocation, - /// Human-readable diagnostic. - message: String, - }, -} - -impl PodIoError { - /// Construct a structured [`PodIoError::Located`]. - /// - /// # Examples - /// - /// ``` - /// use spod::io::{FileLocation, PodIoError}; - /// let _ = PodIoError::located("OEM v3 §5.2", FileLocation::at_line(3), "missing OBJECT_ID"); - /// ``` - pub fn located>( - spec: &'static str, - location: FileLocation, - message: M, - ) -> Self { - Self::Located { - spec, - location, - message: message.into(), - } - } -} diff --git a/src/io/oem.rs b/src/io/oem.rs deleted file mode 100644 index b469f41..0000000 --- a/src/io/oem.rs +++ /dev/null @@ -1,874 +0,0 @@ -//! # CCSDS OEM writer -//! -//! ## Scientific scope -//! -//! The Orbit Ephemeris Message is a standard interchange product for -//! spacecraft state histories. This module writes the subset needed by the -//! current POD pipeline: a deterministic ASCII KVN ephemeris segment -//! without covariance. -//! -//! Its scientific interpretation comes from the supplied state history and -//! metadata. No interpolation, force modelling, or covariance transport is -//! introduced during writing. -//! -//! ## Technical scope -//! -//! The public items are `OemMetadata` and `write_oem`. Callers provide a -//! stream of `OemState` values already expressed in the desired frame and -//! centre, and the writer serializes them into the CCSDS text layout. -//! -//! Reading OEM files and handling richer message features such as maneuver -//! blocks or covariance segments are out of scope here. -//! -//! ## References -//! -//! - Consultative Committee for Space Data Systems. (2010). Orbit Data -//! Messages, CCSDS 502.0-B-2 / 502.0-B-3. -//! - Vallado, D. A. (2013). Fundamentals of Astrodynamics and Applications -//! (4th ed.). Microcosm Press. -use super::PodIoError; -use std::io::Write; - -/// A spacecraft state as stored in a CCSDS OEM file. -/// -/// The reference frame and time system are metadata-level: they are recorded -/// in the containing [`OemMetadata`] rather than in the type, because OEM -/// supports multiple frames (EME2000, GCRF, ITRF, …) chosen at write time. -/// All numeric fields use the conventional OEM units: km for position, -/// km/s for velocity, Julian Date (any time system) for epoch. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct OemState { - /// Epoch expressed as a Julian Date in the time system given by the metadata. - pub epoch_jd: f64, - /// X, Y, Z position components (km). - pub position_km: [f64; 3], - /// VX, VY, VZ velocity components (km/s). - pub velocity_km_s: [f64; 3], -} - -impl OemState { - /// Construct an `OemState` from a Julian Date and component arrays. - pub fn new(epoch_jd: f64, position_km: [f64; 3], velocity_km_s: [f64; 3]) -> Self { - Self { - epoch_jd, - position_km, - velocity_km_s, - } - } -} - -/// Metadata for a single OEM segment. -#[derive(Debug, Clone)] -pub struct OemMetadata { - /// Object identifier (free-form). - pub object_id: String, - /// Object name (free-form). - pub object_name: String, - /// Inertial reference frame string, e.g. "EME2000" or "GCRF". - pub ref_frame: String, - /// Time system string, e.g. "TT" or "UTC". - pub time_system: String, - /// Centre body, e.g. "EARTH". - pub center_name: String, -} - -/// Write a CCSDS OEM file from a sequence of states. -pub fn write_oem( - w: &mut W, - meta: &OemMetadata, - states: &[OemState], -) -> Result<(), PodIoError> { - if states.is_empty() { - return Err(PodIoError::Format( - "write_oem: no states to write".to_string(), - )); - } - writeln!(w, "CCSDS_OEM_VERS = 3.0")?; - writeln!(w, "CREATION_DATE = 2026-01-01T00:00:00")?; - writeln!(w, "ORIGINATOR = SPOD")?; - writeln!(w)?; - writeln!(w, "META_START")?; - writeln!(w, "OBJECT_NAME = {}", meta.object_name)?; - writeln!(w, "OBJECT_ID = {}", meta.object_id)?; - writeln!(w, "CENTER_NAME = {}", meta.center_name)?; - writeln!(w, "REF_FRAME = {}", meta.ref_frame)?; - writeln!(w, "TIME_SYSTEM = {}", meta.time_system)?; - writeln!( - w, - "START_TIME = {}", - jd_to_iso8601(states[0].epoch_jd) - )?; - writeln!( - w, - "STOP_TIME = {}", - jd_to_iso8601(states.last().unwrap().epoch_jd) - )?; - writeln!(w, "META_STOP")?; - writeln!(w)?; - for s in states { - writeln!( - w, - "{} {:.6} {:.6} {:.6} {:.9} {:.9} {:.9}", - jd_to_iso8601(s.epoch_jd), - s.position_km[0], - s.position_km[1], - s.position_km[2], - s.velocity_km_s[0], - s.velocity_km_s[1], - s.velocity_km_s[2], - )?; - } - Ok(()) -} - -/// Convert a Julian Date (any time scale) to an ISO-8601-ish string. -/// -/// Conversion is "calendar date in proleptic Gregorian, fractional seconds"; -/// no leap-second handling — the caller is responsible for the time system. -fn jd_to_iso8601(jd: f64) -> String { - let jd_int = (jd + 0.5).floor() as i64; - let frac = jd + 0.5 - jd_int as f64; - - let a = jd_int + 32_044; - let b = (4 * a + 3) / 146_097; - let c = a - (146_097 * b) / 4; - let d = (4 * c + 3) / 1_461; - let e = c - (1_461 * d) / 4; - let m = (5 * e + 2) / 153; - let day = (e - (153 * m + 2) / 5 + 1) as u32; - let month = (m + 3 - 12 * (m / 10)) as u32; - let year = (100 * b + d - 4_800 + (m / 10)) as i32; - - let total_seconds = frac * 86_400.0; - let hours = (total_seconds / 3600.0).floor() as u32 % 24; - let mins = ((total_seconds % 3600.0) / 60.0).floor() as u32; - let secs = total_seconds % 60.0; - - format!( - "{:04}-{:02}-{:02}T{:02}:{:02}:{:09.6}", - year, month, day, hours, mins, secs - ) -} - -/// A parsed OEM segment. -#[derive(Debug, Clone)] -pub struct OemSegment { - /// Metadata block. - pub metadata: OemMetadata, - /// State history in the order they appear in the file. - pub states: Vec, -} - -/// A parsed OEM file (header + one or more segments). -#[derive(Debug, Clone)] -pub struct OemFile { - /// `CCSDS_OEM_VERS` value (e.g. `"3.0"`). - pub version: String, - /// `CREATION_DATE` header field, verbatim. - pub creation_date: String, - /// `ORIGINATOR` header field, verbatim. - pub originator: String, - /// One segment per `META_START`/`META_STOP` block in input order. - pub segments: Vec, -} - -/// Read a CCSDS OEM (KVN, version 2.x/3.x) file from `r`. -/// -/// The reader accepts the subset that [`write_oem`] produces *and* the -/// canonical CCSDS layout: arbitrary whitespace inside data lines, optional -/// `COMMENT` lines anywhere outside a data block, and one or more segments. -/// Covariance and maneuver sub-blocks are tolerated but skipped. -/// -/// # Errors -/// -/// Returns [`PodIoError::Format`] for any malformed line, missing required -/// metadata field, unparseable epoch, or non-finite numeric component. -/// -/// # Examples -/// -/// ``` -/// use spod::io::oem::{read_oem, write_oem, OemMetadata}; -/// use spod::io::oem::OemState; -/// -/// let states = vec![OemState::new(2_451_545.0, [7000.0, 0.0, 0.0], [0.0, 7.5, 0.0])]; -/// let meta = OemMetadata { -/// object_id: "1900-001A".into(), -/// object_name: "TEST".into(), -/// ref_frame: "EME2000".into(), -/// time_system: "TT".into(), -/// center_name: "EARTH".into(), -/// }; -/// let mut buf = Vec::new(); -/// write_oem(&mut buf, &meta, &states).unwrap(); -/// let parsed = read_oem(&buf[..]).unwrap(); -/// assert_eq!(parsed.segments.len(), 1); -/// assert_eq!(parsed.segments[0].states.len(), 1); -/// ``` -pub fn read_oem(mut r: R) -> Result { - let mut text = String::new(); - r.read_to_string(&mut text) - .map_err(|e| PodIoError::Format(format!("read_oem: io error: {e}")))?; - - let mut version: Option = None; - let mut creation_date: Option = None; - let mut originator: Option = None; - let mut segments: Vec = Vec::new(); - - enum State { - Header, - Meta(MetaBuilder), - Data(OemSegment), - SkipBlock, - } - - #[derive(Default)] - struct MetaBuilder { - object_id: Option, - object_name: Option, - ref_frame: Option, - time_system: Option, - center_name: Option, - } - - let mut state = State::Header; - for (lineno, raw) in text.lines().enumerate() { - let line = raw.trim(); - if line.is_empty() || line.starts_with("COMMENT") { - continue; - } - match &mut state { - State::Header => { - if line == "META_START" { - state = State::Meta(MetaBuilder::default()); - continue; - } - let (k, v) = split_kv(line, lineno)?; - match k { - "CCSDS_OEM_VERS" => version = Some(v.into()), - "CREATION_DATE" => creation_date = Some(v.into()), - "ORIGINATOR" => originator = Some(v.into()), - _ => {} - } - } - State::Meta(mb) => { - if line == "META_STOP" { - let MetaBuilder { - object_id, - object_name, - ref_frame, - time_system, - center_name, - } = std::mem::take(mb); - let need = |o: Option, name: &str| -> Result { - o.ok_or_else(|| { - PodIoError::Format(format!( - "read_oem: META block missing required field {name}" - )) - }) - }; - let metadata = OemMetadata { - object_id: need(object_id, "OBJECT_ID")?, - object_name: need(object_name, "OBJECT_NAME")?, - ref_frame: need(ref_frame, "REF_FRAME")?, - time_system: need(time_system, "TIME_SYSTEM")?, - center_name: need(center_name, "CENTER_NAME")?, - }; - state = State::Data(OemSegment { - metadata, - states: Vec::new(), - }); - continue; - } - let (k, v) = split_kv(line, lineno)?; - match k { - "OBJECT_ID" => mb.object_id = Some(v.into()), - "OBJECT_NAME" => mb.object_name = Some(v.into()), - "REF_FRAME" => mb.ref_frame = Some(v.into()), - "TIME_SYSTEM" => mb.time_system = Some(v.into()), - "CENTER_NAME" => mb.center_name = Some(v.into()), - // START_TIME / STOP_TIME / USEABLE_*: informational, ignored. - _ => {} - } - } - State::Data(seg) => { - if line == "META_START" { - let done = std::mem::replace( - seg, - OemSegment { - metadata: seg.metadata.clone(), - states: Vec::new(), - }, - ); - segments.push(done); - state = State::Meta(MetaBuilder::default()); - continue; - } - if line == "COVARIANCE_START" || line == "MAN_START" { - state = State::SkipBlock; - continue; - } - seg.states.push(parse_data_line(line, lineno)?); - } - State::SkipBlock => { - if line == "COVARIANCE_STOP" || line == "MAN_STOP" { - // Re-enter the most recent Data segment. - if let Some(last) = segments.last_mut() { - let resumed = std::mem::replace( - last, - OemSegment { - metadata: last.metadata.clone(), - states: Vec::new(), - }, - ); - state = State::Data(resumed); - } else { - state = State::Header; - } - } - } - } - } - if let State::Data(seg) = state { - segments.push(seg); - } - if segments.is_empty() { - return Err(PodIoError::Format( - "read_oem: file contains no segments".into(), - )); - } - Ok(OemFile { - version: version.unwrap_or_else(|| "unknown".into()), - creation_date: creation_date.unwrap_or_default(), - originator: originator.unwrap_or_default(), - segments, - }) -} - -fn split_kv(line: &str, lineno: usize) -> Result<(&str, &str), PodIoError> { - let (k, v) = line.split_once('=').ok_or_else(|| { - PodIoError::Format(format!( - "read_oem: line {}: expected KEY = VALUE", - lineno + 1 - )) - })?; - Ok((k.trim(), v.trim())) -} - -fn parse_data_line(line: &str, lineno: usize) -> Result { - let mut it = line.split_whitespace(); - let epoch_str = it.next().ok_or_else(|| { - PodIoError::Format(format!("read_oem: line {}: empty data row", lineno + 1)) - })?; - let mut next_f64 = |what: &str| -> Result { - let tok = it.next().ok_or_else(|| { - PodIoError::Format(format!("read_oem: line {}: missing {what}", lineno + 1)) - })?; - let v: f64 = tok.parse().map_err(|e| { - PodIoError::Format(format!( - "read_oem: line {}: cannot parse {what} ({tok}): {e}", - lineno + 1 - )) - })?; - if !v.is_finite() { - return Err(PodIoError::Format(format!( - "read_oem: line {}: {what} not finite", - lineno + 1 - ))); - } - Ok(v) - }; - let x = next_f64("X")?; - let y = next_f64("Y")?; - let z = next_f64("Z")?; - let vx = next_f64("VX")?; - let vy = next_f64("VY")?; - let vz = next_f64("VZ")?; - let jd = iso8601_to_jd(epoch_str).ok_or_else(|| { - PodIoError::Format(format!( - "read_oem: line {}: cannot parse epoch {epoch_str}", - lineno + 1 - )) - })?; - Ok(OemState::new(jd, [x, y, z], [vx, vy, vz])) -} - -/// Inverse of [`jd_to_iso8601`]. Returns `None` on malformed input. -/// -/// Accepts `YYYY-MM-DDTHH:MM:SS[.ffffff]`. The conversion is purely calendrical -/// (no leap-second handling); the caller owns the time-system semantics. -fn iso8601_to_jd(s: &str) -> Option { - let (date, time) = s.split_once('T')?; - let mut date_parts = date.split('-'); - let year: i32 = date_parts.next()?.parse().ok()?; - let month: u32 = date_parts.next()?.parse().ok()?; - let day: u32 = date_parts.next()?.parse().ok()?; - let mut time_parts = time.split(':'); - let hour: u32 = time_parts.next()?.parse().ok()?; - let minute: u32 = time_parts.next()?.parse().ok()?; - let second: f64 = time_parts.next()?.parse().ok()?; - - // Proleptic Gregorian calendar → JDN at 0h UT. - let m = month as i64; - let y = year as i64; - let a = (14 - m) / 12; - let yy = y + 4800 - a; - let mm = m + 12 * a - 3; - let jdn = day as i64 + (153 * mm + 2) / 5 + 365 * yy + yy / 4 - yy / 100 + yy / 400 - 32_045; - let day_frac = (hour as f64 * 3600.0 + minute as f64 * 60.0 + second) / 86_400.0; - Some(jdn as f64 - 0.5 + day_frac) -} - -// ─── OEM XML (CCSDS 502.0-B-3 §6) ────────────────────────────────────────── - -/// Write a CCSDS OEM XML (`` root) document equivalent to the KVN -/// produced by [`write_oem`]. -/// -/// One `` is emitted with `` and `/` -/// children. Epochs use the same ISO-8601 calendrical conversion as -/// [`write_oem`]. -/// -/// # Examples -/// -/// ``` -/// use spod::io::oem::{write_oem_xml, OemMetadata}; -/// use spod::io::oem::OemState; -/// -/// let states = vec![OemState::new(2_451_545.0, [7000.0, 0.0, 0.0], [0.0, 7.5, 0.0])]; -/// let meta = OemMetadata { -/// object_id: "1900-001A".into(), -/// object_name: "TEST".into(), -/// ref_frame: "EME2000".into(), -/// time_system: "TT".into(), -/// center_name: "EARTH".into(), -/// }; -/// let mut buf = Vec::new(); -/// write_oem_xml(&mut buf, &meta, &states).unwrap(); -/// let txt = std::str::from_utf8(&buf).unwrap(); -/// assert!(txt.contains("( - w: &mut W, - meta: &OemMetadata, - states: &[OemState], -) -> Result<(), PodIoError> { - if states.is_empty() { - return Err(PodIoError::Format( - "write_oem_xml: no states to write".into(), - )); - } - let start = jd_to_iso8601(states[0].epoch_jd); - let stop = jd_to_iso8601(states.last().unwrap().epoch_jd); - writeln!(w, "")?; - writeln!(w, "")?; - writeln!(w, "
")?; - writeln!(w, " 2026-01-01T00:00:00")?; - writeln!(w, " SPOD")?; - writeln!(w, "
")?; - writeln!(w, " ")?; - writeln!(w, " ")?; - writeln!(w, " ")?; - writeln!( - w, - " {}", - xml_escape(&meta.object_name) - )?; - writeln!( - w, - " {}", - xml_escape(&meta.object_id) - )?; - writeln!( - w, - " {}", - xml_escape(&meta.center_name) - )?; - writeln!( - w, - " {}", - xml_escape(&meta.ref_frame) - )?; - writeln!( - w, - " {}", - xml_escape(&meta.time_system) - )?; - writeln!(w, " {}", start)?; - writeln!(w, " {}", stop)?; - writeln!(w, " ")?; - writeln!(w, " ")?; - for s in states { - writeln!(w, " ")?; - writeln!(w, " {}", jd_to_iso8601(s.epoch_jd))?; - writeln!(w, " {:.6}", s.position_km[0])?; - writeln!(w, " {:.6}", s.position_km[1])?; - writeln!(w, " {:.6}", s.position_km[2])?; - writeln!( - w, - " {:.9}", - s.velocity_km_s[0] - )?; - writeln!( - w, - " {:.9}", - s.velocity_km_s[1] - )?; - writeln!( - w, - " {:.9}", - s.velocity_km_s[2] - )?; - writeln!(w, " ")?; - } - writeln!(w, " ")?; - writeln!(w, " ")?; - writeln!(w, " ")?; - writeln!(w, "
")?; - Ok(()) -} - -fn xml_escape(s: &str) -> String { - s.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) -} - -/// Read a CCSDS OEM XML (`` root) document. -/// -/// The reader is the symmetric inverse of [`write_oem_xml`] and additionally -/// tolerates whitespace, comments, and namespace prefixes. It uses -/// `quick_xml` as the underlying parser. -/// -/// # Errors -/// -/// Returns [`PodIoError::Format`] for any malformed XML, missing required -/// metadata field, unparseable epoch, or non-finite numeric component. -/// -/// # Examples -/// -/// ``` -/// use spod::io::oem::{read_oem_xml, write_oem_xml, OemMetadata}; -/// use spod::io::oem::OemState; -/// -/// let states = vec![OemState::new(2_451_545.0, [7000.0, 0.0, 0.0], [0.0, 7.5, 0.0])]; -/// let meta = OemMetadata { -/// object_id: "1900-001A".into(), -/// object_name: "TEST".into(), -/// ref_frame: "EME2000".into(), -/// time_system: "TT".into(), -/// center_name: "EARTH".into(), -/// }; -/// let mut buf = Vec::new(); -/// write_oem_xml(&mut buf, &meta, &states).unwrap(); -/// let parsed = read_oem_xml(&buf[..]).unwrap(); -/// assert_eq!(parsed.segments.len(), 1); -/// assert_eq!(parsed.segments[0].states.len(), 1); -/// ``` -pub fn read_oem_xml(mut r: R) -> Result { - use quick_xml::events::Event; - use quick_xml::Reader; - - let mut text = String::new(); - r.read_to_string(&mut text) - .map_err(|e| PodIoError::Format(format!("read_oem_xml: io: {e}")))?; - let mut rdr = Reader::from_str(&text); - rdr.config_mut().trim_text(true); - - #[derive(Default)] - struct StateBuilder { - epoch: Option, - x: Option, - y: Option, - z: Option, - vx: Option, - vy: Option, - vz: Option, - } - #[derive(Default)] - struct SegBuilder { - object_id: Option, - object_name: Option, - ref_frame: Option, - time_system: Option, - center_name: Option, - states: Vec, - cur: Option, - } - - let mut version = "3.0".to_string(); - let mut creation_date = String::new(); - let mut originator = String::new(); - let mut segments: Vec = Vec::new(); - let mut seg: Option = None; - let mut cur_tag: Option = None; - let mut buf = Vec::new(); - - loop { - match rdr - .read_event_into(&mut buf) - .map_err(|e| PodIoError::Format(format!("read_oem_xml: xml: {e}")))? - { - Event::Eof => break, - Event::Start(e) => { - let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); - let local = name.rsplit(':').next().unwrap_or(&name).to_string(); - match local.as_str() { - "oem" => { - for attr in e.attributes().flatten() { - if attr.key.as_ref() == b"version" { - version = String::from_utf8_lossy(&attr.value).into_owned(); - } - } - } - "segment" => seg = Some(SegBuilder::default()), - "stateVector" => { - if let Some(s) = seg.as_mut() { - s.cur = Some(StateBuilder::default()); - } - } - _ => {} - } - cur_tag = Some(local); - } - Event::End(e) => { - let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); - let local = name.rsplit(':').next().unwrap_or(&name).to_string(); - match local.as_str() { - "stateVector" => { - if let Some(s) = seg.as_mut() { - if let Some(b) = s.cur.take() { - let need = |o: Option, n: &str| -> Result { - o.ok_or_else(|| { - PodIoError::Format(format!( - "read_oem_xml: stateVector missing {n}" - )) - }) - }; - let epoch_jd = need(b.epoch, "EPOCH")?; - let st = OemState::new( - epoch_jd, - [need(b.x, "X")?, need(b.y, "Y")?, need(b.z, "Z")?], - [ - need(b.vx, "X_DOT")?, - need(b.vy, "Y_DOT")?, - need(b.vz, "Z_DOT")?, - ], - ); - s.states.push(st); - } - } - } - "segment" => { - if let Some(s) = seg.take() { - let need = |o: Option, n: &str| -> Result { - o.ok_or_else(|| { - PodIoError::Format(format!( - "read_oem_xml: metadata missing {n}" - )) - }) - }; - segments.push(OemSegment { - metadata: OemMetadata { - object_id: need(s.object_id, "OBJECT_ID")?, - object_name: need(s.object_name, "OBJECT_NAME")?, - ref_frame: need(s.ref_frame, "REF_FRAME")?, - time_system: need(s.time_system, "TIME_SYSTEM")?, - center_name: need(s.center_name, "CENTER_NAME")?, - }, - states: s.states, - }); - } - } - _ => {} - } - cur_tag = None; - } - Event::Text(t) => { - let decoded = t - .decode() - .map_err(|e| PodIoError::Format(format!("read_oem_xml: xml text: {e}")))?; - let txt = quick_xml::escape::unescape(&decoded) - .map_err(|e| PodIoError::Format(format!("read_oem_xml: xml text: {e}")))? - .to_string(); - let tag = match &cur_tag { - Some(t) => t.as_str(), - None => continue, - }; - if let Some(s) = seg.as_mut() { - if let Some(b) = s.cur.as_mut() { - match tag { - "EPOCH" => b.epoch = iso8601_to_jd(&txt), - "X" => b.x = txt.parse().ok(), - "Y" => b.y = txt.parse().ok(), - "Z" => b.z = txt.parse().ok(), - "X_DOT" => b.vx = txt.parse().ok(), - "Y_DOT" => b.vy = txt.parse().ok(), - "Z_DOT" => b.vz = txt.parse().ok(), - _ => {} - } - } else { - match tag { - "OBJECT_ID" => s.object_id = Some(txt), - "OBJECT_NAME" => s.object_name = Some(txt), - "REF_FRAME" => s.ref_frame = Some(txt), - "TIME_SYSTEM" => s.time_system = Some(txt), - "CENTER_NAME" => s.center_name = Some(txt), - _ => {} - } - } - } else { - match tag { - "CREATION_DATE" => creation_date = txt, - "ORIGINATOR" => originator = txt, - _ => {} - } - } - } - _ => {} - } - buf.clear(); - } - - if segments.is_empty() { - return Err(PodIoError::Format( - "read_oem_xml: file contains no segments".into(), - )); - } - Ok(OemFile { - version, - creation_date, - originator, - segments, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn writes_header_and_data_lines() { - let states = vec![ - OemState::new(2_451_545.0, [7000.0, 0.0, 0.0], [0.0, 7.5, 0.0]), - OemState::new( - 2_451_545.0 + 30.0 / 86_400.0, - [6999.0, 225.0, 0.0], - [-0.24, 7.49, 0.0], - ), - ]; - let mut buf = Vec::new(); - write_oem( - &mut buf, - &OemMetadata { - object_id: "1900-001A".into(), - object_name: "TEST-SAT".into(), - ref_frame: "EME2000".into(), - time_system: "TT".into(), - center_name: "EARTH".into(), - }, - &states, - ) - .unwrap(); - let text = String::from_utf8(buf).unwrap(); - assert!(text.contains("CCSDS_OEM_VERS")); - assert!(text.contains("META_START")); - assert!(text.contains("META_STOP")); - assert!(text.contains("EME2000")); - assert_eq!(text.lines().filter(|l| l.starts_with("2000-")).count(), 2); - } - - #[test] - fn jd_2451545_is_j2000() { - // J2000 = 2000-01-01 12:00:00 (TT). - let s = jd_to_iso8601(2_451_545.0); - assert!(s.starts_with("2000-01-01T12:00"), "{}", s); - } - - #[test] - fn iso_jd_round_trip_at_j2000() { - let jd = 2_451_545.0; - let iso = jd_to_iso8601(jd); - let back = iso8601_to_jd(&iso).expect("parse iso"); - assert!((back - jd).abs() < 1e-9, "{back} vs {jd}"); - } - - #[test] - fn write_then_read_round_trip() { - let states = vec![ - OemState::new(2_451_545.0, [7000.0, 0.0, 0.0], [0.0, 7.5, 0.0]), - OemState::new( - 2_451_545.0 + 30.0 / 86_400.0, - [6999.0, 225.0, 0.0], - [-0.24, 7.49, 0.0], - ), - ]; - let meta = OemMetadata { - object_id: "1900-001A".into(), - object_name: "TEST-SAT".into(), - ref_frame: "EME2000".into(), - time_system: "TT".into(), - center_name: "EARTH".into(), - }; - let mut buf = Vec::new(); - write_oem(&mut buf, &meta, &states).unwrap(); - let parsed = read_oem(&buf[..]).expect("parse OEM"); - assert_eq!(parsed.version, "3.0"); - assert_eq!(parsed.segments.len(), 1); - let seg = &parsed.segments[0]; - assert_eq!(seg.metadata.object_id, "1900-001A"); - assert_eq!(seg.metadata.ref_frame, "EME2000"); - assert_eq!(seg.states.len(), 2); - for (a, b) in seg.states.iter().zip(states.iter()) { - assert!((a.position_km[0] - b.position_km[0]).abs() < 1e-3); - assert!((a.position_km[1] - b.position_km[1]).abs() < 1e-3); - assert!((a.position_km[2] - b.position_km[2]).abs() < 1e-3); - assert!((a.velocity_km_s[0] - b.velocity_km_s[0]).abs() < 1e-6); - assert!((a.velocity_km_s[1] - b.velocity_km_s[1]).abs() < 1e-6); - assert!((a.velocity_km_s[2] - b.velocity_km_s[2]).abs() < 1e-6); - } - } - - #[test] - fn read_oem_rejects_missing_meta_field() { - let txt = "CCSDS_OEM_VERS = 3.0\n\ - META_START\n\ - OBJECT_NAME = X\n\ - META_STOP\n"; - let err = read_oem(txt.as_bytes()).unwrap_err(); - match err { - PodIoError::Format(msg) => assert!(msg.contains("OBJECT_ID"), "{msg}"), - other => panic!("expected Format error, got {other:?}"), - } - } - - #[test] - fn xml_round_trip() { - let states = vec![ - OemState::new(2_451_545.0, [7000.0, 0.0, 0.0], [0.0, 7.5, 0.0]), - OemState::new( - 2_451_545.0 + 30.0 / 86_400.0, - [6999.0, 225.0, 0.0], - [-0.24, 7.49, 0.0], - ), - ]; - let meta = OemMetadata { - object_id: "1900-001A".into(), - object_name: "TEST-SAT".into(), - ref_frame: "EME2000".into(), - time_system: "TT".into(), - center_name: "EARTH".into(), - }; - let mut buf = Vec::new(); - write_oem_xml(&mut buf, &meta, &states).unwrap(); - let parsed = read_oem_xml(&buf[..]).expect("parse OEM XML"); - assert_eq!(parsed.segments.len(), 1); - let seg = &parsed.segments[0]; - assert_eq!(seg.metadata.ref_frame, "EME2000"); - assert_eq!(seg.states.len(), 2); - for (a, b) in seg.states.iter().zip(states.iter()) { - assert!((a.position_km[0] - b.position_km[0]).abs() < 1e-3); - assert!((a.velocity_km_s[1] - b.velocity_km_s[1]).abs() < 1e-6); - } - } -} diff --git a/src/io/omm.rs b/src/io/omm.rs deleted file mode 100644 index 312ed19..0000000 --- a/src/io/omm.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! # OMM reader/writer bridging `spod::tle` -//! -//! Delegates KVN and XML parsing/writing to [`crate::tle::omm::kvn`] and -//! [`crate::tle::omm::xml`], exposing them under the unified -//! [`PodIoError`] error type. -//! -//! ## References -//! -//! - CCSDS 502.0-B-2: Orbit Mean-Elements Message (OMM). - -use super::PodIoError; -use crate::tle::omm::{kvn, xml}; -use std::io::{Read, Write}; - -/// Re-export of the `spod::tle` OMM record. -/// -/// See [`crate::tle::omm::Omm`] for field documentation. -/// -/// # Examples -/// -/// ``` -/// use spod::io::omm::OmmMessage; -/// use spod::tle::{parse_3le, omm::Omm}; -/// let l1 = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927"; -/// let l2 = "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537"; -/// let tle = parse_3le("ISS (ZARYA)", l1, l2).unwrap(); -/// let _omm: OmmMessage = Omm::from_tle(&tle); -/// ``` -pub use crate::tle::omm::Omm as OmmMessage; - -/// Read an OMM KVN message from a byte source. -/// -/// # Errors -/// -/// Returns [`PodIoError::Io`] on read failure or [`PodIoError::Format`] -/// on parse error. -/// -/// # Examples -/// -/// ``` -/// use spod::io::omm::read_omm_kvn; -/// -/// let data = b"CCSDS_OMM_VERS = 2.0\nOBJECT_NAME = TEST\nOBJECT_ID = 1998-067A\n\ -/// MEAN_ELEMENT_THEORY = SGP4\nEPOCH = 2008-09-20T12:25:40.104192\n\ -/// MEAN_MOTION = 15.72125391\nECCENTRICITY = 0.0006703\n\ -/// INCLINATION = 51.6416\nRA_OF_ASC_NODE = 247.4627\n\ -/// ARG_OF_PERICENTER = 130.5360\nMEAN_ANOMALY = 325.0288\n\ -/// EPHEMERIS_TYPE = 0\nCLASSIFICATION_TYPE = U\nNORAD_CAT_ID = 25544\n\ -/// ELEMENT_SET_NO = 292\nREV_AT_EPOCH = 56353\n\ -/// BSTAR = -1.1606e-5\nMEAN_MOTION_DOT = -2.182e-5\nMEAN_MOTION_DDOT = 0.0\n"; -/// let omm = read_omm_kvn(&data[..]).unwrap(); -/// assert_eq!(omm.norad_id.0, 25544); -/// ``` -pub fn read_omm_kvn(mut reader: R) -> Result { - let mut buf = String::new(); - reader.read_to_string(&mut buf).map_err(PodIoError::Io)?; - kvn::read(&buf).map_err(|e| PodIoError::Format(e.to_string())) -} - -/// Write an OMM KVN message to a writer. -/// -/// # Errors -/// -/// Returns [`PodIoError::Format`] if serialisation fails or -/// [`PodIoError::Io`] on write failure. -/// -/// # Examples -/// -/// ``` -/// use spod::io::omm::{write_omm_kvn, OmmMessage}; -/// use spod::tle::{parse_3le, omm::Omm}; -/// -/// let l1 = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927"; -/// let l2 = "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537"; -/// let tle = parse_3le("ISS (ZARYA)", l1, l2).unwrap(); -/// let omm = Omm::from_tle(&tle); -/// let mut buf = Vec::new(); -/// write_omm_kvn(&mut buf, &omm).unwrap(); -/// assert!(!buf.is_empty()); -/// ``` -pub fn write_omm_kvn(w: &mut W, omm: &OmmMessage) -> Result<(), PodIoError> { - let text = kvn::write(omm).map_err(|e| PodIoError::Format(e.to_string()))?; - w.write_all(text.as_bytes()).map_err(PodIoError::Io) -} - -/// Read an OMM XML message from a byte source. -/// -/// # Errors -/// -/// Returns [`PodIoError::Io`] on read failure or [`PodIoError::Format`] -/// on parse error. -/// -/// # Examples -/// -/// ``` -/// use spod::io::omm::{read_omm_xml, write_omm_xml, OmmMessage}; -/// use spod::tle::{parse_3le, omm::Omm}; -/// -/// let l1 = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927"; -/// let l2 = "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537"; -/// let tle = parse_3le("ISS (ZARYA)", l1, l2).unwrap(); -/// let omm = Omm::from_tle(&tle); -/// let mut buf = Vec::new(); -/// write_omm_xml(&mut buf, &omm).unwrap(); -/// let omm2 = read_omm_xml(&buf[..]).unwrap(); -/// assert_eq!(omm2.norad_id, omm.norad_id); -/// ``` -pub fn read_omm_xml(mut reader: R) -> Result { - let mut buf = String::new(); - reader.read_to_string(&mut buf).map_err(PodIoError::Io)?; - xml::read(&buf).map_err(|e| PodIoError::Format(e.to_string())) -} - -/// Write an OMM XML message to a writer. -/// -/// # Errors -/// -/// Returns [`PodIoError::Format`] if serialisation fails or -/// [`PodIoError::Io`] on write failure. -/// -/// # Examples -/// -/// ``` -/// use spod::io::omm::{write_omm_xml, OmmMessage}; -/// use spod::tle::{parse_3le, omm::Omm}; -/// -/// let l1 = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927"; -/// let l2 = "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537"; -/// let tle = parse_3le("ISS (ZARYA)", l1, l2).unwrap(); -/// let omm = Omm::from_tle(&tle); -/// let mut buf = Vec::new(); -/// write_omm_xml(&mut buf, &omm).unwrap(); -/// assert!(!buf.is_empty()); -/// ``` -pub fn write_omm_xml(w: &mut W, omm: &OmmMessage) -> Result<(), PodIoError> { - let text = xml::write(omm).map_err(|e| PodIoError::Format(e.to_string()))?; - w.write_all(text.as_bytes()).map_err(PodIoError::Io) -} diff --git a/src/io/opm.rs b/src/io/opm.rs deleted file mode 100644 index ab81a1e..0000000 --- a/src/io/opm.rs +++ /dev/null @@ -1,303 +0,0 @@ -//! # CCSDS OPM reader/writer (CCSDS 502.0-B-3) -//! -//! Parses and emits Orbit Parameter Messages in Keyword-Value Notation (KVN). -//! Supported blocks: header, `META_START`/`META_STOP`, and state vector -//! keywords (`EPOCH`, `X`/`Y`/`Z`, `X_DOT`/`Y_DOT`/`Z_DOT`). -//! -//! ## References -//! -//! - CCSDS 502.0-B-3: Orbit Data Messages, Blue Book (2019). - -use super::PodIoError; -use std::io::{BufRead, BufReader, Read, Write}; - -/// OPM state vector (Cartesian). -/// -/// # Examples -/// -/// ``` -/// use spod::io::opm::CartesianState; -/// let s = CartesianState { -/// epoch: "2024-001T12:00:00.000".to_string(), -/// position_km: [7000.0, 0.0, 0.0], -/// velocity_km_s: [0.0, 7.5, 0.0], -/// }; -/// assert_eq!(s.position_km[0], 7000.0); -/// ``` -#[derive(Debug, Clone, PartialEq)] -pub struct CartesianState { - /// Epoch in ISO 8601 format. - pub epoch: String, - /// Position X, Y, Z in km. - pub position_km: [f64; 3], - /// Velocity Vx, Vy, Vz in km/s. - pub velocity_km_s: [f64; 3], -} - -/// OPM Keplerian elements section. -/// -/// # Examples -/// -/// ``` -/// use spod::io::opm::KeplerianElements; -/// let k = KeplerianElements { -/// semi_major_axis_km: 7000.0, -/// eccentricity: 0.001, -/// inclination_deg: 51.6, -/// ra_of_asc_node_deg: 247.0, -/// arg_of_pericenter_deg: 130.0, -/// true_anomaly_deg: 325.0, -/// }; -/// assert!(k.semi_major_axis_km > 0.0); -/// ``` -#[derive(Debug, Clone, PartialEq)] -pub struct KeplerianElements { - /// Semi-major axis in km. - pub semi_major_axis_km: f64, - /// Eccentricity (dimensionless). - pub eccentricity: f64, - /// Inclination in degrees. - pub inclination_deg: f64, - /// RAAN in degrees. - pub ra_of_asc_node_deg: f64, - /// Argument of perigee in degrees. - pub arg_of_pericenter_deg: f64, - /// True anomaly in degrees. - pub true_anomaly_deg: f64, -} - -/// OPM metadata block. -/// -/// # Examples -/// -/// ``` -/// use spod::io::opm::OpmMetadata; -/// let m = OpmMetadata { -/// object_name: "TEST-SAT".to_string(), -/// object_id: "2024-001A".to_string(), -/// center_name: "EARTH".to_string(), -/// ref_frame: "EME2000".to_string(), -/// time_system: "UTC".to_string(), -/// }; -/// assert_eq!(m.object_name, "TEST-SAT"); -/// ``` -#[derive(Debug, Clone)] -pub struct OpmMetadata { - /// Object name. - pub object_name: String, - /// Object ID (COSPAR or user). - pub object_id: String, - /// Center body. - pub center_name: String, - /// Reference frame. - pub ref_frame: String, - /// Time system. - pub time_system: String, -} - -/// Parsed CCSDS OPM message. -/// -/// # Examples -/// -/// ``` -/// use spod::io::opm::{OpmMessage, OpmMetadata, CartesianState}; -/// let msg = OpmMessage { -/// metadata: OpmMetadata { -/// object_name: "SAT".to_string(), -/// object_id: "2024-001A".to_string(), -/// center_name: "EARTH".to_string(), -/// ref_frame: "EME2000".to_string(), -/// time_system: "UTC".to_string(), -/// }, -/// state: CartesianState { -/// epoch: "2024-001T00:00:00.000".to_string(), -/// position_km: [7000.0, 0.0, 0.0], -/// velocity_km_s: [0.0, 7.5, 0.0], -/// }, -/// keplerian: None, -/// }; -/// assert_eq!(msg.state.position_km[0], 7000.0); -/// ``` -#[derive(Debug, Clone)] -pub struct OpmMessage { - /// Metadata block. - pub metadata: OpmMetadata, - /// Cartesian state (mandatory). - pub state: CartesianState, - /// Keplerian elements (optional). - pub keplerian: Option, -} - -/// Read a CCSDS OPM KVN message from a byte source. -/// -/// Parses `CCSDS_OPM_VERS`, `META_START`/`META_STOP`, state vector keywords, -/// and optionally the `KEPLERIAN` block. -/// -/// # Errors -/// -/// Returns [`PodIoError::Format`] if mandatory fields are absent or malformed. -/// -/// # Examples -/// -/// ``` -/// use spod::io::opm::read_opm; -/// -/// let data = b"CCSDS_OPM_VERS = 2.0\nMETA_START\nOBJECT_NAME = SAT\n\ -/// OBJECT_ID = 2024-001A\nCENTER_NAME = EARTH\nREF_FRAME = EME2000\n\ -/// TIME_SYSTEM = UTC\nMETA_STOP\n\ -/// EPOCH = 2024-001T12:00:00.000\nX = 7000.0\nY = 0.0\nZ = 0.0\n\ -/// X_DOT = 0.0\nY_DOT = 7.5\nZ_DOT = 0.0\n"; -/// let msg = read_opm(&data[..]).unwrap(); -/// assert_eq!(msg.metadata.object_name, "SAT"); -/// ``` -pub fn read_opm(reader: R) -> Result { - let buf = BufReader::new(reader); - let mut kv: std::collections::HashMap = std::collections::HashMap::new(); - - for result in buf.lines() { - let line = result.map_err(PodIoError::Io)?; - let line = line.trim().to_string(); - if line.is_empty() - || line.starts_with("COMMENT") - || line == "META_START" - || line == "META_STOP" - { - continue; - } - if let Some(eq) = line.find('=') { - let key = line[..eq].trim().to_string(); - let val = line[eq + 1..].trim().to_string(); - kv.insert(key, val); - } - } - - let get = |k: &str| -> Result { - kv.get(k) - .cloned() - .ok_or_else(|| PodIoError::Format(format!("OPM: missing field {k}"))) - }; - let getf = |k: &str| -> Result { - let v = kv - .get(k) - .ok_or_else(|| PodIoError::Format(format!("OPM: missing field {k}")))?; - v.parse::() - .map_err(|_| PodIoError::Format(format!("OPM: cannot parse {k}={v:?}"))) - }; - - let metadata = OpmMetadata { - object_name: get("OBJECT_NAME")?, - object_id: get("OBJECT_ID").unwrap_or_default(), - center_name: get("CENTER_NAME").unwrap_or_else(|_| "EARTH".to_string()), - ref_frame: get("REF_FRAME").unwrap_or_else(|_| "EME2000".to_string()), - time_system: get("TIME_SYSTEM").unwrap_or_else(|_| "UTC".to_string()), - }; - - let epoch = get("EPOCH")?; - let state = CartesianState { - epoch, - position_km: [getf("X")?, getf("Y")?, getf("Z")?], - velocity_km_s: [getf("X_DOT")?, getf("Y_DOT")?, getf("Z_DOT")?], - }; - - let keplerian = if kv.contains_key("SEMI_MAJOR_AXIS") { - Some(KeplerianElements { - semi_major_axis_km: getf("SEMI_MAJOR_AXIS")?, - eccentricity: getf("ECCENTRICITY")?, - inclination_deg: getf("INCLINATION")?, - ra_of_asc_node_deg: getf("RA_OF_ASC_NODE")?, - arg_of_pericenter_deg: getf("ARG_OF_PERICENTER")?, - true_anomaly_deg: getf("TRUE_ANOMALY")?, - }) - } else { - None - }; - - Ok(OpmMessage { - metadata, - state, - keplerian, - }) -} - -/// Write a CCSDS OPM message in KVN format. -/// -/// Emits the header, `META_START`/`META_STOP` block, and state vector keywords. -/// -/// # Errors -/// -/// Returns [`PodIoError::Io`] on write failure. -/// -/// # Examples -/// -/// ``` -/// use spod::io::opm::{write_opm, read_opm, OpmMessage, OpmMetadata, CartesianState}; -/// -/// let msg = OpmMessage { -/// metadata: OpmMetadata { -/// object_name: "SAT".to_string(), -/// object_id: "2024-001A".to_string(), -/// center_name: "EARTH".to_string(), -/// ref_frame: "EME2000".to_string(), -/// time_system: "UTC".to_string(), -/// }, -/// state: CartesianState { -/// epoch: "2024-001T12:00:00.000".to_string(), -/// position_km: [7000.0, 0.0, 0.0], -/// velocity_km_s: [0.0, 7.5, 0.0], -/// }, -/// keplerian: None, -/// }; -/// let mut buf = Vec::new(); -/// write_opm(&mut buf, &msg).unwrap(); -/// let text = String::from_utf8(buf).unwrap(); -/// assert!(text.contains("OBJECT_NAME")); -/// ``` -pub fn write_opm(w: &mut W, msg: &OpmMessage) -> Result<(), PodIoError> { - writeln!(w, "CCSDS_OPM_VERS = 2.0").map_err(PodIoError::Io)?; - writeln!(w, "META_START").map_err(PodIoError::Io)?; - writeln!(w, "OBJECT_NAME = {}", msg.metadata.object_name).map_err(PodIoError::Io)?; - writeln!(w, "OBJECT_ID = {}", msg.metadata.object_id).map_err(PodIoError::Io)?; - writeln!(w, "CENTER_NAME = {}", msg.metadata.center_name).map_err(PodIoError::Io)?; - writeln!(w, "REF_FRAME = {}", msg.metadata.ref_frame).map_err(PodIoError::Io)?; - writeln!(w, "TIME_SYSTEM = {}", msg.metadata.time_system).map_err(PodIoError::Io)?; - writeln!(w, "META_STOP").map_err(PodIoError::Io)?; - writeln!(w).map_err(PodIoError::Io)?; - writeln!(w, "EPOCH = {}", msg.state.epoch).map_err(PodIoError::Io)?; - writeln!(w, "X = {:.6}", msg.state.position_km[0]) - .map_err(PodIoError::Io)?; - writeln!(w, "Y = {:.6}", msg.state.position_km[1]) - .map_err(PodIoError::Io)?; - writeln!(w, "Z = {:.6}", msg.state.position_km[2]) - .map_err(PodIoError::Io)?; - writeln!( - w, - "X_DOT = {:.6}", - msg.state.velocity_km_s[0] - ) - .map_err(PodIoError::Io)?; - writeln!( - w, - "Y_DOT = {:.6}", - msg.state.velocity_km_s[1] - ) - .map_err(PodIoError::Io)?; - writeln!( - w, - "Z_DOT = {:.6}", - msg.state.velocity_km_s[2] - ) - .map_err(PodIoError::Io)?; - if let Some(k) = &msg.keplerian { - writeln!(w).map_err(PodIoError::Io)?; - writeln!(w, "SEMI_MAJOR_AXIS = {:.6}", k.semi_major_axis_km) - .map_err(PodIoError::Io)?; - writeln!(w, "ECCENTRICITY = {:.7}", k.eccentricity).map_err(PodIoError::Io)?; - writeln!(w, "INCLINATION = {:.4}", k.inclination_deg).map_err(PodIoError::Io)?; - writeln!(w, "RA_OF_ASC_NODE = {:.4}", k.ra_of_asc_node_deg) - .map_err(PodIoError::Io)?; - writeln!(w, "ARG_OF_PERICENTER = {:.4}", k.arg_of_pericenter_deg) - .map_err(PodIoError::Io)?; - writeln!(w, "TRUE_ANOMALY = {:.4}", k.true_anomaly_deg).map_err(PodIoError::Io)?; - } - Ok(()) -} diff --git a/src/io/orbex.rs b/src/io/orbex.rs deleted file mode 100644 index 33d73b0..0000000 --- a/src/io/orbex.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! # ORBEX orbit/clock/attitude product reader -//! -//! ORBEX is a flexible, text-based format for distributing precise satellite -//! orbit, clock, and attitude data. This module parses the `#ORB`, `#CLK`, -//! and `#ATT` record types from the `+EPHEMERIS/DATA` block. -//! -//! ## References -//! -//! - IGS ORBEX Format Description (draft, 2020). - -use super::{FileLocation, ParseMode, PodIoError}; -use std::io::{BufRead, BufReader, Read}; - -/// Single orbit record from an ORBEX `#ORB` block. -/// -/// # Examples -/// -/// ``` -/// use spod::io::orbex::OrbexOrbitEntry; -/// let e = OrbexOrbitEntry { -/// sat: "G01".to_string(), -/// epoch_mjd: 60000.25, -/// pos_m: [1e7, 2e6, 3e6], -/// vel_m_s: None, -/// }; -/// assert_eq!(e.sat, "G01"); -/// ``` -#[derive(Debug, Clone)] -pub struct OrbexOrbitEntry { - /// Satellite identifier (e.g. `"G01"`). - pub sat: String, - /// Epoch as modified Julian date. - pub epoch_mjd: f64, - /// Position components in meters. - pub pos_m: [f64; 3], - /// Velocity components in m/s, if present. - pub vel_m_s: Option<[f64; 3]>, -} - -/// Single clock record from an ORBEX `#CLK` block. -/// -/// # Examples -/// -/// ``` -/// use spod::io::orbex::OrbexClockEntry; -/// let e = OrbexClockEntry { sat: "G01".to_string(), epoch_mjd: 60000.25, bias_s: 1e-7 }; -/// assert!((e.bias_s - 1e-7).abs() < 1e-15); -/// ``` -#[derive(Debug, Clone)] -pub struct OrbexClockEntry { - /// Satellite identifier. - pub sat: String, - /// Epoch as modified Julian date. - pub epoch_mjd: f64, - /// Clock bias in seconds. - pub bias_s: f64, -} - -/// Single attitude record from an ORBEX `#ATT` block. -/// -/// # Examples -/// -/// ``` -/// use spod::io::orbex::OrbexAttitudeEntry; -/// let e = OrbexAttitudeEntry { -/// sat: "G01".to_string(), -/// epoch_mjd: 60000.25, -/// quaternion: [1.0, 0.0, 0.0, 0.0], -/// }; -/// assert_eq!(e.quaternion[0], 1.0); -/// ``` -#[derive(Debug, Clone)] -pub struct OrbexAttitudeEntry { - /// Satellite identifier. - pub sat: String, - /// Epoch as modified Julian date. - pub epoch_mjd: f64, - /// Quaternion \[w, x, y, z\]. - pub quaternion: [f64; 4], -} - -/// Parsed ORBEX product. -/// -/// # Examples -/// -/// ``` -/// use spod::io::orbex::OrbexProduct; -/// let prod = OrbexProduct::default(); -/// assert!(prod.orbits.is_empty()); -/// ``` -#[derive(Debug, Default)] -pub struct OrbexProduct { - /// File version string. - pub version: String, - /// List of orbit entries. - pub orbits: Vec, - /// List of clock entries. - pub clocks: Vec, - /// List of attitude entries. - pub attitudes: Vec, -} - -/// Read an ORBEX file from a byte source. -/// -/// Parses `#ORB`, `#CLK`, and `#ATT` record types. Unknown record types are -/// skipped in both `Strict` and `Permissive` modes (they are a defined -/// extension mechanism in the ORBEX spec). -/// -/// # Errors -/// -/// Returns [`PodIoError::Format`] on parse failure in `Strict` mode, -/// or [`PodIoError::Io`] for I/O failures. -/// -/// # Examples -/// -/// ``` -/// use spod::io::orbex::read_orbex; -/// use spod::io::ParseMode; -/// -/// let lines = [ -/// "%=ORBEX 0.09", "%%", "+EPHEMERIS/DATA", "#ORB", -/// "## 2024 1 21600.000000000", -/// " G01 1.0E+07 2.0E+06 3.0E+06", -/// "-EPHEMERIS/DATA", "%ENDORBEX", -/// ]; -/// let data = lines.join("\n"); -/// let prod = read_orbex(data.as_bytes(), ParseMode::Permissive).unwrap(); -/// assert_eq!(prod.orbits.len(), 1); -/// ``` -pub fn read_orbex(reader: R, mode: ParseMode) -> Result { - let mut product = OrbexProduct::default(); - let buf = BufReader::new(reader); - - #[derive(PartialEq, Clone, Copy)] - enum Section { - None, - Orb, - Clk, - Att, - } - - let mut section = Section::None; - let mut current_epoch_mjd: f64 = 0.0; - - for (lineno, result) in buf.lines().enumerate() { - let line = result.map_err(PodIoError::Io)?; - let lineno = lineno + 1; - - if line.starts_with("%=ORBEX") { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 2 { - product.version = parts[1].to_string(); - } - continue; - } - if line.starts_with("%ENDORBEX") || line.starts_with("%EOF") { - break; - } - // Skip comment/description block lines - if line.starts_with('%') { - continue; - } - if line.starts_with('+') || line.starts_with('-') { - continue; - } - - // Epoch header: ## YYYY DDD SSSSS.SSSSSSS (must check before generic '#') - if line.starts_with("##") { - let rest = line.trim_start_matches('#').trim(); - let parts: Vec<&str> = rest.split_whitespace().collect(); - if parts.len() >= 3 { - let year: i32 = parts[0].parse().unwrap_or(2000); - let doy: u32 = parts[1].parse().unwrap_or(1); - let sod: f64 = parts[2].parse().unwrap_or(0.0); - current_epoch_mjd = orbex_epoch_to_mjd(year, doy, sod); - } - continue; - } - - // Section type markers - if line.starts_with("#ORB") { - section = Section::Orb; - continue; - } - if line.starts_with("#CLK") { - section = Section::Clk; - continue; - } - if line.starts_with("#ATT") { - section = Section::Att; - continue; - } - // Other # markers: switch to none - if line.starts_with('#') { - section = Section::None; - continue; - } - - // Satellite data lines start with a space then sat id - if !line.starts_with(' ') { - continue; - } - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.is_empty() { - continue; - } - - match section { - Section::Orb => { - if parts.len() < 4 { - if mode == ParseMode::Strict { - return Err(PodIoError::located( - "ORBEX §3.3", - FileLocation::at_line(lineno), - format!("ORB record has {} fields, need ≥ 4", parts.len()), - )); - } - continue; - } - let sat = parts[0].to_string(); - let x = parse_f64(parts[1], lineno, "pos_x", mode)?; - let y = parse_f64(parts[2], lineno, "pos_y", mode)?; - let z = parse_f64(parts[3], lineno, "pos_z", mode)?; - let vel_m_s = if parts.len() >= 7 { - let vx = parse_f64(parts[4], lineno, "vel_x", mode)?; - let vy = parse_f64(parts[5], lineno, "vel_y", mode)?; - let vz = parse_f64(parts[6], lineno, "vel_z", mode)?; - Some([vx, vy, vz]) - } else { - None - }; - product.orbits.push(OrbexOrbitEntry { - sat, - epoch_mjd: current_epoch_mjd, - pos_m: [x, y, z], - vel_m_s, - }); - } - Section::Clk => { - if parts.len() < 2 { - if mode == ParseMode::Strict { - return Err(PodIoError::located( - "ORBEX §3.4", - FileLocation::at_line(lineno), - format!("CLK record has {} fields, need ≥ 2", parts.len()), - )); - } - continue; - } - let sat = parts[0].to_string(); - let bias_s = parse_f64(parts[1], lineno, "bias", mode)?; - product.clocks.push(OrbexClockEntry { - sat, - epoch_mjd: current_epoch_mjd, - bias_s, - }); - } - Section::Att => { - if parts.len() < 5 { - if mode == ParseMode::Strict { - return Err(PodIoError::located( - "ORBEX §3.5", - FileLocation::at_line(lineno), - format!("ATT record has {} fields, need ≥ 5", parts.len()), - )); - } - continue; - } - let sat = parts[0].to_string(); - let w = parse_f64(parts[1], lineno, "q_w", mode)?; - let x = parse_f64(parts[2], lineno, "q_x", mode)?; - let y = parse_f64(parts[3], lineno, "q_y", mode)?; - let z = parse_f64(parts[4], lineno, "q_z", mode)?; - product.attitudes.push(OrbexAttitudeEntry { - sat, - epoch_mjd: current_epoch_mjd, - quaternion: [w, x, y, z], - }); - } - Section::None => {} - } - } - - Ok(product) -} - -fn parse_f64(s: &str, lineno: usize, what: &str, mode: ParseMode) -> Result { - s.parse::().map_err(|_| { - if mode == ParseMode::Strict { - PodIoError::located( - "ORBEX §3", - FileLocation::at_line(lineno), - format!("cannot parse {what}: {s:?}"), - ) - } else { - PodIoError::Format(format!("ORBEX line {lineno}: cannot parse {what}: {s:?}")) - } - }) -} - -fn orbex_epoch_to_mjd(year: i32, doy: u32, sod: f64) -> f64 { - let epoch = match chrono::NaiveDate::from_yo_opt(year, doy.max(1)) { - Some(d) => d, - None => return 0.0, - }; - let mjd_ref = chrono::NaiveDate::from_ymd_opt(1858, 11, 17).expect("valid MJD epoch"); - let days = (epoch - mjd_ref).num_days() as f64; - days + sod / 86400.0 -} diff --git a/src/io/rinex_doris.rs b/src/io/rinex_doris.rs deleted file mode 100644 index 3991d50..0000000 --- a/src/io/rinex_doris.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! RINEX-DORIS support provided by Siderust. -//! -//! The parser and record types are re-exported unchanged so this service -//! does not maintain a second DORIS implementation. - -pub use siderust::formats::rinex::doris::{ - read_rinex_doris, DorisHeader, DorisObservation, RinexDoris, RinexDorisRecord, -}; - -#[cfg(test)] -mod tests { - #[cfg(feature = "doris")] - use super::read_rinex_doris; - - #[cfg(feature = "doris")] - #[test] - fn parses_minimal_doris_input() { - let input = format!( - "{:<60}RINEX VERSION / TYPE\n{:<60}END OF HEADER\n\ - > 2024 01 02 03 04 05.000\nD01 12345.678 678.9\n", - " 3.00 OBSERVATION DATA D", "" - ); - let parsed = read_rinex_doris(input.as_bytes()).expect("minimal DORIS input is valid"); - - assert_eq!(parsed.header.version, "3.00"); - assert_eq!(parsed.observations.len(), 1); - assert_eq!(parsed.observations[0].satellite_id, "D01"); - } -} diff --git a/src/io/rinex_nav.rs b/src/io/rinex_nav.rs deleted file mode 100644 index 7d7bdee..0000000 --- a/src/io/rinex_nav.rs +++ /dev/null @@ -1,450 +0,0 @@ -//! # RINEX navigation message reader -//! -//! ## Scientific scope -//! -//! Broadcast navigation files encode the coarse orbital and clock -//! information transmitted by GNSS spacecraft. This module reads the GPS- -//! focused subset needed to reconstruct broadcast ephemerides for -//! observation modelling and comparative POD workflows. -//! -//! The scientific regime is therefore limited to the message families and -//! fields currently parsed from RINEX 3 NAV. Unsupported systems and -//! optional record features are intentionally left for follow-up work. -//! -//! ## Technical scope -//! -//! The main entry points are `read_rinex_nav` and `parse_rinex_nav`, -//! returning `RinexNavFile` collections of `GpsNavRecord` values. Parsed -//! fields stay close to the broadcast representation so downstream code can -//! choose how to convert them into propagated states. -//! -//! This module does not itself evaluate the broadcast model or perform -//! orbit determination. -//! -//! ## References -//! -//! - International GNSS Service / RTCM. (2020). RINEX: The Receiver -//! Independent Exchange Format, Version 3.05. -//! - IS-GPS-200. (current revision). Navstar GPS Space Segment / Navigation -//! User Interfaces. -use super::PodIoError; -use chrono::{DateTime, Datelike, NaiveDate, Timelike, Utc as ChronoUtc}; -use siderust::qtty::angular::Radians; -use siderust::qtty::angular_rate::AngularRate; -use siderust::qtty::length::Meters; -use siderust::qtty::time::Seconds; -use siderust::qtty::unit::{Radian, Second}; -use siderust::tempoch::{Time, UTC}; -use std::fs; -use std::io::Write; -use std::path::Path; - -/// One GPS broadcast navigation record (subset). -#[derive(Debug, Clone)] -pub struct GpsNavRecord { - /// PRN identifier (e.g. `G01` → 1). - pub prn: u8, - /// Time of clock (UTC). - pub toc: Time, - /// SV clock bias. - pub af0: Seconds, - /// SV clock drift (s/s — dimensionless rate; kept as scalar). - pub af1: f64, - /// SV clock drift rate (s/s² — kept as scalar). - pub af2: f64, - /// IODE (dimensionless index). - pub iode: f64, - /// Crs amplitude correction. - pub crs: Meters, - /// Δn — mean motion correction. - pub delta_n: AngularRate, - /// M0 — mean anomaly at reference time. - pub m0: Radians, - /// Cuc — argument-of-latitude correction (cosine). - pub cuc: Radians, - /// Eccentricity (dimensionless). - pub e: f64, - /// Cus — argument-of-latitude correction (sine). - pub cus: Radians, - /// √a (m^½ — composite dimension; kept as scalar). - pub sqrt_a: f64, - /// Toe — reference time of ephemeris. - pub toe: Seconds, - /// Cic — inclination correction (cosine). - pub cic: Radians, - /// Ω0 — longitude of ascending node at weekly epoch. - pub omega0: Radians, - /// Cis — inclination correction (sine). - pub cis: Radians, - /// i0 — inclination angle at reference time. - pub i0: Radians, - /// Crc amplitude correction. - pub crc: Meters, - /// ω — argument of perigee. - pub omega: Radians, - /// Ω̇ — rate of right ascension. - pub omega_dot: AngularRate, - /// IDOT — rate of inclination angle. - pub idot: AngularRate, -} - -impl Default for GpsNavRecord { - fn default() -> Self { - let naive = NaiveDate::from_ymd_opt(2000, 1, 1) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap(); - let dt = DateTime::from_naive_utc_and_offset(naive, ChronoUtc); - Self { - prn: 0, - toc: Time::::try_from_chrono(dt).unwrap(), - af0: Seconds::new(0.0), - af1: 0.0, - af2: 0.0, - iode: 0.0, - crs: Meters::new(0.0), - delta_n: AngularRate::new(0.0), - m0: Radians::new(0.0), - cuc: Radians::new(0.0), - e: 0.0, - cus: Radians::new(0.0), - sqrt_a: 0.0, - toe: Seconds::new(0.0), - cic: Radians::new(0.0), - omega0: Radians::new(0.0), - cis: Radians::new(0.0), - i0: Radians::new(0.0), - crc: Meters::new(0.0), - omega: Radians::new(0.0), - omega_dot: AngularRate::new(0.0), - idot: AngularRate::new(0.0), - } - } -} - -/// Parsed RINEX NAV file. -#[derive(Debug, Clone, Default)] -pub struct RinexNavFile { - /// All GPS broadcast records found in the file. - pub gps: Vec, -} - -/// Read a RINEX NAV file from disk. -pub fn read_rinex_nav>(path: P) -> Result { - let text = fs::read_to_string(path)?; - parse_rinex_nav(&text) -} - -/// Parse a RINEX NAV file from a string slice. -pub fn parse_rinex_nav(text: &str) -> Result { - let mut out = RinexNavFile::default(); - - let mut lines = text.lines(); - // Skip header up to and including "END OF HEADER". - for l in lines.by_ref() { - if l.contains("END OF HEADER") { - break; - } - } - - let collected: Vec<&str> = lines.collect(); - let mut i = 0; - while i + 7 < collected.len() { - let l0 = collected[i]; - if l0.len() < 4 || !(l0.starts_with('G') || l0.as_bytes()[0].is_ascii_digit()) { - i += 1; - continue; - } - let prn = if l0.starts_with('G') { - l0[1..3].trim().parse::().unwrap_or(0) - } else { - l0[..3].trim().parse::().unwrap_or(0) - }; - if prn == 0 { - i += 1; - continue; - } - - // TOC: positions 4..23 → "YYYY MM DD HH MM SS" - let rest = &l0[3..]; - let toc_tokens: Vec<&str> = rest.split_whitespace().collect(); - if toc_tokens.len() < 9 { - i += 1; - continue; - } - let mut rec = GpsNavRecord { - prn, - toc: { - let y: i32 = toc_tokens[0].parse().unwrap_or(2000); - let mo: u32 = toc_tokens[1].parse().unwrap_or(1); - let d: u32 = toc_tokens[2].parse().unwrap_or(1); - let h: u32 = toc_tokens[3].parse().unwrap_or(0); - let mi: u32 = toc_tokens[4].parse().unwrap_or(0); - let sec_f: f64 = toc_tokens[5].parse().unwrap_or(0.0); - let sec_i = sec_f as u32; - let nanos = ((sec_f - sec_i as f64) * 1e9).round() as u32; - NaiveDate::from_ymd_opt(y, mo, d) - .and_then(|d| d.and_hms_nano_opt(h, mi, sec_i, nanos)) - .and_then(|naive| { - let dt = DateTime::from_naive_utc_and_offset(naive, ChronoUtc); - Time::::try_from_chrono(dt).ok() - }) - .unwrap_or_else(|| { - let naive = NaiveDate::from_ymd_opt(2000, 1, 1) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap(); - Time::::try_from_chrono(DateTime::from_naive_utc_and_offset( - naive, ChronoUtc, - )) - .unwrap() - }) - }, - af0: Seconds::new(parse_d(toc_tokens[6])), - af1: parse_d(toc_tokens[7]), - af2: parse_d(toc_tokens[8]), - ..Default::default() - }; - - // Lines 1..7 each carry up to 4 floats in fixed 19-char fields, - // but split_whitespace works for our subset. - let v1: Vec = collect_floats(collected[i + 1]); - let v2: Vec = collect_floats(collected[i + 2]); - let v3: Vec = collect_floats(collected[i + 3]); - let v4: Vec = collect_floats(collected[i + 4]); - let v5: Vec = collect_floats(collected[i + 5]); - let v6: Vec = collect_floats(collected[i + 6]); - let v7: Vec = collect_floats(collected[i + 7]); - - if v1.len() >= 4 { - rec.iode = v1[0]; - rec.crs = Meters::new(v1[1]); - rec.delta_n = AngularRate::new(v1[2]); - rec.m0 = Radians::new(v1[3]); - } - if v2.len() >= 4 { - rec.cuc = Radians::new(v2[0]); - rec.e = v2[1]; - rec.cus = Radians::new(v2[2]); - rec.sqrt_a = v2[3]; - } - if v3.len() >= 4 { - rec.toe = Seconds::new(v3[0]); - rec.cic = Radians::new(v3[1]); - rec.omega0 = Radians::new(v3[2]); - rec.cis = Radians::new(v3[3]); - } - if v4.len() >= 4 { - rec.i0 = Radians::new(v4[0]); - rec.crc = Meters::new(v4[1]); - rec.omega = Radians::new(v4[2]); - rec.omega_dot = AngularRate::new(v4[3]); - } - if !v5.is_empty() { - rec.idot = AngularRate::new(v5[0]); - } - let _ = v6; - let _ = v7; - - out.gps.push(rec); - i += 8; - } - - Ok(out) -} - -fn parse_d(s: &str) -> f64 { - s.replace('D', "E").replace('d', "e").parse().unwrap_or(0.0) -} - -fn collect_floats(line: &str) -> Vec { - line.split_whitespace().map(parse_d).collect() -} - -/// Format an `f64` in Fortran D-notation (e.g. `1.234567890123456E+02` → -/// `1.234567890123456D+02`), as used by RINEX NAV body lines. -fn fmt_d(v: f64) -> String { - if !v.is_finite() { - return format!("{:>19}", "0.000000000000000D+00"); - } - let s = format!("{:19.12E}", v); - s.replace('E', "D") -} - -/// Write a RINEX 3.04 NAV file matching the layout consumed by -/// [`parse_rinex_nav`]. -/// -/// Only the GPS broadcast subset is written. The header is canonicalised to -/// the minimal `RINEX VERSION / TYPE` + `END OF HEADER` pair; per-record -/// output uses fixed-width Fortran D-notation in the standard -/// `19.12D` field width. -/// -/// Round-trip parity is exact at 1e-15 relative for every numeric field -/// under [`parse_rinex_nav`]. -/// -/// # Errors -/// -/// Returns [`PodIoError::Io`] on write failures and [`PodIoError::Format`] -/// on epoch projection failures. -/// -/// # Examples -/// -/// ``` -/// use spod::io::rinex_nav::{parse_rinex_nav, write_rinex_nav}; -/// -/// let txt = "\ -/// 3.04 N: GNSS NAV DATA M (Mixed) RINEX VERSION / TYPE\n\ -/// END OF HEADER\n\ -/// G01 2024 01 01 00 00 00 1.234567E-04 5.678E-12 0.000E+00\n\ -/// 1.000000E+00 2.000000E+01 3.456E-09 1.234567E+00\n\ -/// 5.000E-07 1.000E-03 9.000E-07 5.153651E+03\n\ -/// 5.184000E+05 1.000E-08 1.000E+00 2.000E-08\n\ -/// 9.760000E-01 2.500E+02 -1.500E+00 -8.000E-09\n\ -/// 1.000E-10\n\ -/// 0.0E+00 0.0E+00 0.0E+00 0.0E+00\n\ -/// 0.0E+00 0.0E+00 0.0E+00 0.0E+00\n"; -/// let f = parse_rinex_nav(txt).unwrap(); -/// let mut buf = Vec::new(); -/// write_rinex_nav(&mut buf, &f).unwrap(); -/// let f2 = parse_rinex_nav(std::str::from_utf8(&buf).unwrap()).unwrap(); -/// assert_eq!(f.gps.len(), f2.gps.len()); -/// ``` -pub fn write_rinex_nav(w: &mut W, file: &RinexNavFile) -> Result<(), PodIoError> { - fn header_line(w: &mut impl Write, body: &str, label: &str) -> std::io::Result<()> { - let body = if body.len() > 60 { &body[..60] } else { body }; - writeln!(w, "{:<60}{}", body, label) - } - header_line( - w, - " 3.04 N: GNSS NAV DATA M (Mixed)", - "RINEX VERSION / TYPE", - )?; - header_line(w, "", "END OF HEADER")?; - - for r in &file.gps { - let dt = r - .toc - .try_to_chrono() - .map_err(|e| PodIoError::Format(format!("rinex_nav: TOC to chrono failed: {e}")))?; - let sec = dt.second() as f64 + dt.nanosecond() as f64 / 1e9; - // Line 0: PRN + TOC + 3 clock terms. - writeln!( - w, - "G{:02} {:04} {:02} {:02} {:02} {:02} {:02}{}{}{}", - r.prn, - dt.year(), - dt.month(), - dt.day(), - dt.hour(), - dt.minute(), - sec as u32, - fmt_d(r.af0.value()), - fmt_d(r.af1), - fmt_d(r.af2), - )?; - let cont = |w: &mut W, vs: [f64; 4]| -> std::io::Result<()> { - writeln!( - w, - " {}{}{}{}", - fmt_d(vs[0]), - fmt_d(vs[1]), - fmt_d(vs[2]), - fmt_d(vs[3]) - ) - }; - cont(w, [r.iode, r.crs.value(), r.delta_n.value(), r.m0.value()])?; - cont(w, [r.cuc.value(), r.e, r.cus.value(), r.sqrt_a])?; - cont( - w, - [ - r.toe.value(), - r.cic.value(), - r.omega0.value(), - r.cis.value(), - ], - )?; - cont( - w, - [ - r.i0.value(), - r.crc.value(), - r.omega.value(), - r.omega_dot.value(), - ], - )?; - cont(w, [r.idot.value(), 0.0, 0.0, 0.0])?; - cont(w, [0.0, 0.0, 0.0, 0.0])?; - cont(w, [0.0, 0.0, 0.0, 0.0])?; - } - Ok(()) -} - -/// Convenience wrapper to write a RINEX NAV file to disk. -/// -/// # Examples -/// -/// ```no_run -/// use spod::io::rinex_nav::{write_rinex_nav_to_path, RinexNavFile}; -/// write_rinex_nav_to_path("/tmp/out.rnx", &RinexNavFile::default()).ok(); -/// ``` -pub fn write_rinex_nav_to_path>( - path: P, - file: &RinexNavFile, -) -> Result<(), PodIoError> { - let mut f = fs::File::create(path)?; - write_rinex_nav(&mut f, file) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_one_gps_record() { - let txt = "\ - 3.04 N: GNSS NAV DATA M (Mixed) RINEX VERSION / TYPE\n\ - END OF HEADER\n\ -G01 2024 01 01 00 00 00 1.234567E-04 5.678E-12 0.000E+00\n\ - 1.000000E+00 2.000000E+01 3.456E-09 1.234567E+00\n\ - 5.000E-07 1.000E-03 9.000E-07 5.153651E+03\n\ - 5.184000E+05 1.000E-08 1.000E+00 2.000E-08\n\ - 9.760000E-01 2.500E+02 -1.500E+00 -8.000E-09\n\ - 1.000E-10\n\ - 0.000E+00 0.000E+00 0.000E+00 0.000E+00\n\ - 0.000E+00 0.000E+00 0.000E+00 0.000E+00\n"; - let f = parse_rinex_nav(txt).expect("parse"); - assert_eq!(f.gps.len(), 1); - let r = &f.gps[0]; - assert_eq!(r.prn, 1); - assert!((r.sqrt_a - 5_153.651).abs() < 1e-3); - assert!((r.e - 1e-3).abs() < 1e-9); - assert!((r.toe.value() - 518_400.0).abs() < 1e-3); - } - - #[test] - fn round_trips_through_writer() { - let txt = "\ - 3.04 N: GNSS NAV DATA M (Mixed) RINEX VERSION / TYPE\n\ - END OF HEADER\n\ -G01 2024 01 01 00 00 00 1.234567E-04 5.678E-12 0.000E+00\n\ - 1.000000E+00 2.000000E+01 3.456E-09 1.234567E+00\n\ - 5.000E-07 1.000E-03 9.000E-07 5.153651E+03\n\ - 5.184000E+05 1.000E-08 1.000E+00 2.000E-08\n\ - 9.760000E-01 2.500E+02 -1.500E+00 -8.000E-09\n\ - 1.000E-10\n\ - 0.000E+00 0.000E+00 0.000E+00 0.000E+00\n\ - 0.000E+00 0.000E+00 0.000E+00 0.000E+00\n"; - let f = parse_rinex_nav(txt).unwrap(); - let mut buf = Vec::new(); - write_rinex_nav(&mut buf, &f).unwrap(); - let f2 = parse_rinex_nav(std::str::from_utf8(&buf).unwrap()).unwrap(); - assert_eq!(f.gps.len(), f2.gps.len()); - let r = &f.gps[0]; - let r2 = &f2.gps[0]; - assert_eq!(r.prn, r2.prn); - assert!((r.sqrt_a - r2.sqrt_a).abs() < 1e-9); - assert!((r.e - r2.e).abs() < 1e-15); - assert!((r.toe.value() - r2.toe.value()).abs() < 1e-9); - assert!((r.m0.value() - r2.m0.value()).abs() < 1e-12); - } -} diff --git a/src/io/rinex_obs.rs b/src/io/rinex_obs.rs deleted file mode 100644 index 9b9f840..0000000 --- a/src/io/rinex_obs.rs +++ /dev/null @@ -1,397 +0,0 @@ -//! # RINEX observation reader -//! -//! ## Scientific scope -//! -//! RINEX observation files provide the time-tagged code and carrier -//! measurements that drive GNSS POD. This module implements the compact -//! subset needed by the current workspace: a small selection of RINEX 3 -//! header fields and dual-frequency observation values for the supported -//! systems. -//! -//! It is designed for deterministic MVP pipelines rather than exhaustive -//! archive ingestion. Unsupported observables and header records are -//! skipped instead of being approximated. -//! -//! ## Technical scope -//! -//! The public items are `ObsEpoch`, `RinexObs`, and `read_rinex_obs`. -//! Parsed epochs retain the file-native observation codes and scalar values -//! while associating them with typed epoch information suitable for later -//! observation modelling. -//! -//! Bias handling, ambiguity resolution, and troposphere/ionosphere -//! modelling are beyond the scope of this parser. -//! -//! ## References -//! -//! - International GNSS Service / RTCM. (2020). RINEX: The Receiver -//! Independent Exchange Format, Version 3.05. -//! - Misra, P., & Enge, P. (2012). Global Positioning System: Signals, -//! Measurements, and Performance (2nd ed.). Ganga-Jamuna Press. -use super::PodIoError; -use chrono::{DateTime, Datelike, NaiveDate, Timelike, Utc as ChronoUtc}; -use siderust::qtty::length::Meters; -use siderust::qtty::time::Seconds; -use siderust::tempoch::{Time, UTC}; -use std::collections::HashMap; -use std::io::{BufRead, BufReader, Read, Write}; - -/// One observation epoch. -#[derive(Debug, Clone, PartialEq)] -pub struct ObsEpoch { - /// UTC epoch. - pub time: Time, - /// Per-satellite observations: `sat -> {obs_code -> value}`. - pub satellites: HashMap>, -} - -/// Parsed RINEX OBS file. -#[derive(Debug, Clone)] -pub struct RinexObs { - /// Marker name (station name). - pub marker: String, - /// Approximate XYZ in metres (ITRF). - pub approx_xyz_m: Option<[Meters; 3]>, - /// Sampling interval, if declared. - pub interval_s: Option, - /// Per-system observation type list. - pub obs_types: HashMap>, - /// Epoch records. - pub epochs: Vec, -} - -/// Parse a RINEX OBS file. -pub fn read_rinex_obs(rdr: R) -> Result { - let mut br = BufReader::new(rdr); - let mut line = String::new(); - - let mut marker = String::new(); - let mut approx_xyz_m: Option<[Meters; 3]> = None; - let mut interval_s: Option = None; - let mut obs_types: HashMap> = HashMap::new(); - - // Header. - loop { - line.clear(); - if br.read_line(&mut line)? == 0 { - return Err(PodIoError::Format("rinex_obs: unexpected EOF".into())); - } - let label = line.get(60..).unwrap_or("").trim(); - let body = line.get(..60).unwrap_or(""); - match label { - "MARKER NAME" => marker = body.trim().to_string(), - "APPROX POSITION XYZ" => { - let parts: Vec = body - .split_whitespace() - .filter_map(|s| s.parse().ok()) - .collect(); - if parts.len() == 3 { - approx_xyz_m = Some([ - Meters::new(parts[0]), - Meters::new(parts[1]), - Meters::new(parts[2]), - ]); - } - } - "INTERVAL" => { - interval_s = body - .split_whitespace() - .next() - .and_then(|s| s.parse().ok()) - .map(Seconds::new); - } - "SYS / # / OBS TYPES" => { - let bytes = body.as_bytes(); - if bytes.is_empty() { - continue; - } - let sys = bytes[0] as char; - if !sys.is_ascii_alphabetic() { - continue; - } - let count: usize = body[1..6].trim().parse().unwrap_or(0); - let mut types: Vec = body[7..] - .split_whitespace() - .map(|s| s.to_string()) - .collect(); - while types.len() < count { - let mut cont = String::new(); - if br.read_line(&mut cont)? == 0 { - break; - } - let c_body = cont.get(..60).unwrap_or(""); - types.extend(c_body.split_whitespace().map(|s| s.to_string())); - } - types.truncate(count); - obs_types.insert(sys, types); - } - "END OF HEADER" => break, - _ => {} - } - } - - // Epochs. - let mut epochs = Vec::new(); - loop { - line.clear(); - if br.read_line(&mut line)? == 0 { - break; - } - let trimmed = line.trim_end(); - if !trimmed.starts_with('>') { - continue; - } - let parts: Vec<&str> = trimmed.split_whitespace().collect(); - if parts.len() < 9 { - continue; - } - let year: i32 = parts[1].parse().unwrap_or(0); - let month: u32 = parts[2].parse().unwrap_or(0); - let day: u32 = parts[3].parse().unwrap_or(0); - let hour: u32 = parts[4].parse().unwrap_or(0); - let minute: u32 = parts[5].parse().unwrap_or(0); - let second_f: f64 = parts[6].parse().unwrap_or(0.0); - let second_int = second_f as u32; - let nanos = ((second_f - second_int as f64) * 1e9).round() as u32; - let naive = NaiveDate::from_ymd_opt(year, month, day) - .and_then(|d| d.and_hms_nano_opt(hour, minute, second_int, nanos)) - .ok_or_else(|| PodIoError::Format("rinex_obs: invalid epoch date".into()))?; - let epoch_time = - Time::::try_from_chrono(DateTime::from_naive_utc_and_offset(naive, ChronoUtc)) - .map_err(|e| PodIoError::Format(format!("rinex_obs: epoch UTC conversion: {e}")))?; - let n_sat: usize = parts[8].parse().unwrap_or(0); - - let mut sats = HashMap::new(); - for _ in 0..n_sat { - line.clear(); - if br.read_line(&mut line)? == 0 { - break; - } - let row = line.trim_end(); - if row.len() < 3 { - continue; - } - let sat = row[..3].to_string(); - let sys = sat.chars().next().unwrap_or(' '); - let types = match obs_types.get(&sys) { - Some(t) => t.clone(), - None => Vec::new(), - }; - let mut vals: HashMap = HashMap::new(); - // RINEX 3: each observable occupies 16 columns (F14.3 + 2 status - // chars). We slice that and parse loosely. - let payload = &row[3..]; - for (i, ty) in types.iter().enumerate() { - let start = i * 16; - if start >= payload.len() { - break; - } - let end = (start + 14).min(payload.len()); - let raw = payload[start..end].trim(); - if raw.is_empty() { - continue; - } - if let Ok(v) = raw.parse::() { - vals.insert(ty.clone(), v); - } - } - sats.insert(sat, vals); - } - epochs.push(ObsEpoch { - time: epoch_time, - satellites: sats, - }); - } - - Ok(RinexObs { - marker, - approx_xyz_m, - interval_s, - obs_types, - epochs, - }) -} - -/// Write a RINEX 3.04 observation file matching the layout produced by -/// [`read_rinex_obs`]. -/// -/// The writer emits a minimal but spec-conformant RINEX 3 header (VERSION / -/// TYPE, MARKER NAME, optional APPROX POSITION XYZ, SYS / # / OBS TYPES per -/// system, optional INTERVAL, END OF HEADER) followed by one epoch block -/// per [`ObsEpoch`]. Per-satellite observations are written in the order -/// declared by `obs_types[&sys]`; missing observations are written as -/// blanks (the standard sentinel for "not observed"). -/// -/// Round-trip parity is **canonicalised**: header line order is normalised, -/// trailing whitespace is stripped, and observation columns are emitted in -/// the standard 16-column F14.3 layout. Numerical values round-trip to -/// 1e-3 m (code) / 1e-3 cycles (carrier), which exceeds RINEX 3 native -/// precision. -/// -/// # Errors -/// -/// Returns [`PodIoError::Io`] on write failures and [`PodIoError::Format`] -/// if an epoch cannot be projected onto the proleptic Gregorian calendar. -/// -/// # Examples -/// -/// ``` -/// use spod::io::rinex_obs::{read_rinex_obs, write_rinex_obs}; -/// -/// let src = concat!( -/// " 3.04 OBSERVATION DATA M (MIXED) RINEX VERSION / TYPE\n", -/// "TINY MARKER NAME\n", -/// "G 1 C1C SYS / # / OBS TYPES\n", -/// " END OF HEADER\n", -/// "> 2024 01 01 00 00 0.0000000 0 1\n", -/// "G01 20100000.000\n", -/// ); -/// let obs = read_rinex_obs(src.as_bytes()).unwrap(); -/// let mut buf = Vec::new(); -/// write_rinex_obs(&mut buf, &obs).unwrap(); -/// let parsed = read_rinex_obs(&buf[..]).unwrap(); -/// assert_eq!(parsed.marker, obs.marker); -/// assert_eq!(parsed.epochs.len(), obs.epochs.len()); -/// ``` -pub fn write_rinex_obs(w: &mut W, obs: &RinexObs) -> Result<(), PodIoError> { - fn header_line(w: &mut impl Write, body: &str, label: &str) -> std::io::Result<()> { - let body = if body.len() > 60 { &body[..60] } else { body }; - writeln!(w, "{:<60}{}", body, label) - } - header_line( - w, - " 3.04 OBSERVATION DATA M (MIXED)", - "RINEX VERSION / TYPE", - )?; - header_line(w, &obs.marker, "MARKER NAME")?; - if let Some(xyz) = &obs.approx_xyz_m { - let body = format!( - "{:14.4}{:14.4}{:14.4}", - xyz[0].value(), - xyz[1].value(), - xyz[2].value() - ); - header_line(w, &body, "APPROX POSITION XYZ")?; - } - let mut systems: Vec<&char> = obs.obs_types.keys().collect(); - systems.sort(); - for sys in systems { - let types = &obs.obs_types[sys]; - let mut body = format!("{}{:>5}", sys, types.len()); - for (i, t) in types.iter().enumerate() { - // RINEX 3: 13 obs codes per header line, 4 chars each. - if i > 0 && i % 13 == 0 { - header_line(w, &body, "SYS / # / OBS TYPES")?; - body = format!("{:>6}", ""); - } - body.push_str(&format!(" {:<3}", t)); - } - header_line(w, &body, "SYS / # / OBS TYPES")?; - } - if let Some(interval) = obs.interval_s { - let body = format!("{:10.3}", interval.value()); - header_line(w, &body, "INTERVAL")?; - } - header_line(w, "", "END OF HEADER")?; - - for ep in &obs.epochs { - let dt = ep - .time - .try_to_chrono() - .map_err(|e| PodIoError::Format(format!("rinex_obs: epoch to chrono failed: {e}")))?; - let second = dt.second() as f64 + dt.nanosecond() as f64 / 1e9; - writeln!( - w, - "> {:4} {:02} {:02} {:02} {:02} {:10.7} 0 {:>2}", - dt.year(), - dt.month(), - dt.day(), - dt.hour(), - dt.minute(), - second, - ep.satellites.len(), - )?; - let mut sats: Vec<&String> = ep.satellites.keys().collect(); - sats.sort(); - for sat in sats { - let vals = &ep.satellites[sat]; - let sys = sat.chars().next().unwrap_or(' '); - let types = obs.obs_types.get(&sys).cloned().unwrap_or_default(); - let mut row = format!("{:<3}", sat); - for ty in &types { - match vals.get(ty) { - Some(v) => row.push_str(&format!("{:14.3} ", v)), - None => row.push_str(&" ".repeat(16)), - } - } - writeln!(w, "{}", row.trim_end())?; - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - const SAMPLE: &str = "\ - 3.04 OBSERVATION DATA M (MIXED) RINEX VERSION / TYPE -TEST-MARK MARKER NAME - 1234567.8901 -2345678.9012 5678901.2345 APPROX POSITION XYZ -G 2 C1C L1C SYS / # / OBS TYPES - 30 INTERVAL - END OF HEADER -> 2024 01 01 00 00 0.0000000 0 1 -G01 20123456.789 123456789.012 -> 2024 01 01 00 00 30.0000000 0 1 -G01 20124000.000 123457000.000 -"; - - #[test] - fn parses_header_and_epochs() { - let r = read_rinex_obs(SAMPLE.as_bytes()).unwrap(); - assert_eq!(r.marker, "TEST-MARK"); - assert_eq!(r.interval_s.map(|s| s.value()), Some(30.0)); - assert_eq!(r.obs_types[&'G'], vec!["C1C", "L1C"]); - assert_eq!(r.epochs.len(), 2); - let e0 = &r.epochs[0]; - assert!(e0.satellites.contains_key("G01")); - let g01 = &e0.satellites["G01"]; - assert!((g01["C1C"] - 20_123_456.789).abs() < 1e-3); - } - - #[test] - fn round_trips_through_writer() { - let r = read_rinex_obs(SAMPLE.as_bytes()).unwrap(); - let mut buf = Vec::new(); - write_rinex_obs(&mut buf, &r).unwrap(); - let r2 = read_rinex_obs(&buf[..]).unwrap(); - assert_eq!(r2.marker, r.marker); - assert_eq!(r2.epochs.len(), r.epochs.len()); - assert_eq!(r2.obs_types, r.obs_types); - assert_eq!( - r2.interval_s.map(|s| s.value()), - r.interval_s.map(|s| s.value()) - ); - for (a, b) in r.epochs.iter().zip(r2.epochs.iter()) { - assert_eq!( - a.satellites - .keys() - .collect::>(), - b.satellites - .keys() - .collect::>() - ); - for (sat, vals_a) in &a.satellites { - let vals_b = &b.satellites[sat]; - for (k, v) in vals_a { - assert!( - (vals_b[k] - v).abs() < 1e-3, - "{sat}/{k} {v} vs {}", - vals_b[k] - ); - } - } - } - } -} diff --git a/src/io/sinex.rs b/src/io/sinex.rs deleted file mode 100644 index cc7dd7a..0000000 --- a/src/io/sinex.rs +++ /dev/null @@ -1,308 +0,0 @@ -//! # SINEX station-coordinate reader -//! -//! Parses the Solution INdependent EXchange (SINEX) format to extract -//! station coordinate and velocity estimates from geodetic solutions. -//! -//! Supported blocks: `+SITE/ID`, `+SOLUTION/ESTIMATE`. -//! Estimate types: `STAX`, `STAY`, `STAZ` (position), `VELX`, `VELY`, `VELZ` -//! (velocity). -//! -//! ## References -//! -//! - IERS/IGS SINEX Format Description Version 2.10. - -use super::{FileLocation, ParseMode, PodIoError}; -use siderust::affn::cartesian; -use siderust::affn::centers::{AffineCenter, ReferenceCenter}; -use siderust::affn::frames::ITRF; -use siderust::qtty::unit::Meter; -use std::collections::HashMap; -use std::io::{BufRead, BufReader, Read}; - -/// Earth geocenter marker for SINEX ITRF coordinates. -/// -/// # Examples -/// -/// ``` -/// use spod::io::sinex::GeoCenterItrf; -/// use siderust::affn::centers::ReferenceCenter; -/// assert_eq!(GeoCenterItrf::center_name(), "Geocentric ITRF"); -/// ``` -#[derive(Debug, Copy, Clone)] -pub struct GeoCenterItrf; - -impl ReferenceCenter for GeoCenterItrf { - type Params = (); - fn center_name() -> &'static str { - "Geocentric ITRF" - } -} - -impl AffineCenter for GeoCenterItrf {} - -/// SINEX ITRF Cartesian position (meters). -/// -/// # Examples -/// -/// ``` -/// use spod::io::sinex::SinexPosition; -/// let _pos = SinexPosition::new(4_641_949.0_f64, 1_393_045.0_f64, 4_133_287.0_f64); -/// ``` -pub type SinexPosition = cartesian::Position; - -/// Velocity unit: meters per year. -/// -/// Used for VELX/VELY/VELZ estimates in SINEX files. -pub type MeterPerYear = - siderust::qtty::Per; - -/// A station solution extracted from a SINEX file. -/// -/// # Examples -/// -/// ``` -/// use spod::io::sinex::{StationCoordinate, SinexPosition}; -/// let pos = SinexPosition::new(1.0_f64, 2.0_f64, 3.0_f64); -/// let _ = StationCoordinate { -/// code: "MATE".to_string(), -/// point_code: "A".to_string(), -/// solution_id: "1".to_string(), -/// ref_epoch_mjd: 51544.0, -/// position: pos, -/// velocity_m_yr: None, -/// }; -/// ``` -#[derive(Debug, Clone)] -pub struct StationCoordinate { - /// 4-character station code (DOMES notation). - pub code: String, - /// Point code (e.g. `"A"`). - pub point_code: String, - /// Solution ID (e.g. `"1"`). - pub solution_id: String, - /// Reference epoch (MJD, f64). - pub ref_epoch_mjd: f64, - /// ITRF Cartesian position (meters). - pub position: SinexPosition, - /// Velocity vector components in meters/year, if present. - pub velocity_m_yr: Option<[f64; 3]>, -} - -/// Parsed SINEX solution. -/// -/// # Examples -/// -/// ``` -/// use spod::io::sinex::SinexSolution; -/// let sol = SinexSolution::default(); -/// assert!(sol.stations.is_empty()); -/// ``` -#[derive(Debug, Default)] -pub struct SinexSolution { - /// File creation agency. - pub agency: String, - /// Solution description. - pub description: String, - /// Station coordinate/velocity records. - pub stations: Vec, -} - -/// Read a SINEX file from a byte source. -/// -/// Parses `+SITE/ID`, `+SOLUTION/EPOCHS`, and `+SOLUTION/ESTIMATE` blocks. -/// Estimates for `STAX`/`STAY`/`STAZ` become position components; -/// `VELX`/`VELY`/`VELZ` become velocity components. -/// -/// # Errors -/// -/// Returns [`PodIoError::Format`] for fatal parse errors in `Strict` mode, -/// or [`PodIoError::Io`] for underlying I/O failures. -/// -/// # Examples -/// -/// ``` -/// use spod::io::sinex::read_sinex; -/// use spod::io::ParseMode; -/// -/// let data = b"%=SNX 2.10 IGS 24:001:00000 IGS 00:000:00000 23:365:86370 P 00000 0 S\n\ -/// +SITE/ID\n\ -/// *Code Pt Domes____ T _Station Description__ _Longitude_ _Latitude__ _Height_\n\ -/// ABCD A 10101M001 P Some Station 010 00 00.0 050 00 00.0 100.000\n\ -/// -SITE/ID\n\ -/// +SOLUTION/ESTIMATE\n\ -/// *Index Type__ Code Pt Soln _Ref_Epoch__ Unit S ___Estimated_Value___ __Std_Dev__\n\ -/// 1 STAX ABCD A 1 00:001:00000 m 2 1.000000000000000E+06 1.000E-04\n\ -/// 2 STAY ABCD A 1 00:001:00000 m 2 -2.000000000000000E+06 1.000E-04\n\ -/// 3 STAZ ABCD A 1 00:001:00000 m 2 3.000000000000000E+06 1.000E-04\n\ -/// -SOLUTION/ESTIMATE\n\ -/// %ENDSNX\n"; -/// -/// let sol = read_sinex(&data[..], ParseMode::Permissive).unwrap(); -/// assert_eq!(sol.stations.len(), 1); -/// assert_eq!(sol.stations[0].code, "ABCD"); -/// ``` -pub fn read_sinex(reader: R, mode: ParseMode) -> Result { - let mut solution = SinexSolution::default(); - let buf = BufReader::new(reader); - - // Accumulate SOLUTION/ESTIMATE rows keyed by station code. - #[derive(Default)] - struct EstRow { - xyz: [Option; 3], - vel: [Option; 3], - ref_epoch_mjd: f64, - pt: String, - soln: String, - } - let mut estimates: HashMap = HashMap::new(); - - #[derive(PartialEq)] - enum Block { - None, - SiteId, - Estimate, - } - let mut block = Block::None; - - for (lineno, result) in buf.lines().enumerate() { - let line = result.map_err(PodIoError::Io)?; - let lineno = lineno + 1; - - // SINEX header - if line.starts_with("%=SNX") { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 3 { - solution.agency = parts[2].to_string(); - } - continue; - } - if line.starts_with("%ENDSNX") { - break; - } - // Comment lines inside blocks - if line.starts_with('*') || line.starts_with('%') { - continue; - } - // Block start/end markers - if let Some(bname) = line.strip_prefix('+') { - match bname.trim() { - "SITE/ID" => block = Block::SiteId, - "SOLUTION/ESTIMATE" => block = Block::Estimate, - _ => block = Block::None, - } - continue; - } - if line.starts_with('-') { - block = Block::None; - continue; - } - - match block { - Block::SiteId => { - // We only record that the site exists; ignore detailed site info. - // The station code is in the first token. - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.is_empty() { - continue; - } - // Just note the code exists; coordinate data comes from ESTIMATE block. - let _ = parts[0]; - } - Block::Estimate => { - // Format: index type code pt soln epoch unit constraint value stddev - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 9 { - if mode == ParseMode::Strict { - return Err(PodIoError::located( - "SINEX 2.10 §4.2", - FileLocation::at_line(lineno), - format!("ESTIMATE line has {} fields, need ≥ 9", parts.len()), - )); - } - continue; - } - let est_type = parts[1]; - let code = parts[2].to_string(); - let pt = parts[3].to_string(); - let soln = parts[4].to_string(); - let epoch_raw = parts[5]; - let value: f64 = match parts[8].parse() { - Ok(v) => v, - Err(_) => { - if mode == ParseMode::Strict { - return Err(PodIoError::located( - "SINEX 2.10 §4.2", - FileLocation::at_line(lineno), - format!("cannot parse value: {}", parts[8]), - )); - } - continue; - } - }; - - let ref_epoch_mjd = sinex_epoch_to_mjd(epoch_raw); - let row = estimates.entry(code.clone()).or_default(); - row.pt = pt; - row.soln = soln; - row.ref_epoch_mjd = ref_epoch_mjd; - match est_type { - "STAX" => row.xyz[0] = Some(value), - "STAY" => row.xyz[1] = Some(value), - "STAZ" => row.xyz[2] = Some(value), - "VELX" => row.vel[0] = Some(value), - "VELY" => row.vel[1] = Some(value), - "VELZ" => row.vel[2] = Some(value), - _ => {} - } - } - Block::None => {} - } - } - - // Build station records from accumulated estimates. - for (code, row) in estimates { - let (x, y, z) = match (row.xyz[0], row.xyz[1], row.xyz[2]) { - (Some(x), Some(y), Some(z)) => (x, y, z), - _ => continue, - }; - let velocity_m_yr = match (row.vel[0], row.vel[1], row.vel[2]) { - (Some(vx), Some(vy), Some(vz)) => Some([vx, vy, vz]), - _ => None, - }; - solution.stations.push(StationCoordinate { - code, - point_code: row.pt, - solution_id: row.soln, - ref_epoch_mjd: row.ref_epoch_mjd, - position: SinexPosition::new(x, y, z), - velocity_m_yr, - }); - } - - Ok(solution) -} - -/// Convert a SINEX epoch string `"YY:DDD:SSSSS"` to MJD. -fn sinex_epoch_to_mjd(s: &str) -> f64 { - let parts: Vec<&str> = s.split(':').collect(); - if parts.len() != 3 { - return 0.0; - } - let yy: i32 = parts[0].parse().unwrap_or(0); - let doy: u32 = parts[1].parse().unwrap_or(1); - let sod: f64 = parts[2].parse().unwrap_or(0.0); - let yyyy = if yy == 0 { - 2000 - } else if yy >= 80 { - 1900 + yy - } else { - 2000 + yy - }; - let epoch = match chrono::NaiveDate::from_yo_opt(yyyy, doy.max(1)) { - Some(d) => d, - None => return 0.0, - }; - let mjd_ref = chrono::NaiveDate::from_ymd_opt(1858, 11, 17).expect("valid MJD epoch"); - let days = (epoch - mjd_ref).num_days() as f64; - days + sod / 86400.0 -} diff --git a/src/io/sp3.rs b/src/io/sp3.rs deleted file mode 100644 index 895836e..0000000 --- a/src/io/sp3.rs +++ /dev/null @@ -1,519 +0,0 @@ -//! # SP3 precise orbit reader and writer -//! -//! ## Scientific scope -//! -//! SP3 files distribute precise GNSS orbit and clock products as regularly -//! sampled Cartesian state tables. This module supports the subset required -//! by the POD workspace to ingest reference trajectories and emit -//! comparable precise-orbit products. -//! -//! The implementation is intentionally format-focused. It does not -//! interpolate trajectories, reconcile multi-constellation metadata -//! differences, or estimate clocks beyond the values explicitly present in -//! the file. -//! -//! ## Technical scope -//! -//! The public APIs are `read_sp3`, `write_sp3`, and the data containers -//! `Sp3Record`, `Sp3Epoch`, and `Sp3Position`. They preserve the row-major, -//! epoch-sampled structure of the SP3 standard so service and QC code can -//! decide how to consume the product. -//! -//! Any orbit fitting, frame conversion, or residual analysis is delegated -//! to other crates. -//! -//! ## References -//! -//! - International GNSS Service. (2020). SP3-c / SP3-d Orbit Format -//! Specification. -//! - Montenbruck, O., Steigenberger, P., & Khachikyan, R. (2017). GNSS -//! satellite geometry and ephemeris products. GPS Solutions, 21, 101-111. -use chrono::{DateTime, NaiveDate, Utc as ChronoUtc}; -use siderust::affn::cartesian; -use siderust::affn::centers::{AffineCenter, ReferenceCenter}; -use siderust::affn::frames::GCRS; -use siderust::qtty::time::Microseconds; -use siderust::qtty::unit::Kilometer; - -/// Geocentric center marker for SP3 positions. -#[derive(Debug, Copy, Clone)] -pub struct EarthCenter; - -impl ReferenceCenter for EarthCenter { - type Params = (); - fn center_name() -> &'static str { - "Geocentric" - } -} - -impl AffineCenter for EarthCenter {} - -/// Local type alias for SP3 positions: geocentric, GCRS-framed, km. -type Position = cartesian::Position; -use siderust::tempoch::{Time, UTC}; -use std::io::{BufRead, BufReader, Read, Write}; -use thiserror::Error; - -/// SP3 parse / write errors. -#[derive(Debug, Error)] -pub enum Sp3Error { - /// I/O failure. - #[error("I/O error: {0}")] - Io(#[from] std::io::Error), - /// Header malformed. - #[error("malformed SP3 header at line {line}: {message}")] - Header { - /// 1-based line number. - line: usize, - /// Diagnostic. - message: String, - }, - /// Record line malformed. - #[error("malformed SP3 record at line {line}: {message}")] - Record { - /// 1-based line number. - line: usize, - /// Diagnostic. - message: String, - }, -} - -/// One satellite position record at one epoch (km, microsecond clock). -#[derive(Debug, Clone, PartialEq)] -pub struct Sp3Position { - /// Satellite identifier (3 chars, e.g. `G01`). - pub sat_id: String, - /// Position in GCRS frame (km). - pub position: Position, - /// Clock bias. 999999.999999 µs means unavailable. - pub clock: Microseconds, -} - -/// One epoch with all satellite records. -#[derive(Debug, Clone, PartialEq)] -pub struct Sp3Epoch { - /// UTC epoch. - pub time: Time, - /// Position records. - pub positions: Vec, -} - -/// Full SP3 record. -#[derive(Debug, Clone, PartialEq)] -pub struct Sp3Record { - /// Verbatim header lines (preserved for round-trip). - pub header: Vec, - /// Epochs. - pub epochs: Vec, -} - -fn civil_to_time_utc( - year: i32, - month: u32, - day: u32, - hour: u32, - minute: u32, - second: f64, - line: usize, -) -> Result, Sp3Error> { - let second_int = second as u32; - let nanos = ((second - second_int as f64) * 1e9).round() as u32; - let naive = NaiveDate::from_ymd_opt(year, month, day) - .and_then(|d| d.and_hms_nano_opt(hour, minute, second_int, nanos)) - .ok_or_else(|| Sp3Error::Record { - line, - message: "invalid epoch date/time".into(), - })?; - let dt: DateTime = DateTime::from_naive_utc_and_offset(naive, ChronoUtc); - Time::::try_from_chrono(dt).map_err(|e| Sp3Error::Record { - line, - message: format!("epoch UTC conversion: {e}"), - }) -} - -/// Parse an SP3 file from a reader. -pub fn read_sp3(r: R) -> Result { - let mut buf = BufReader::new(r); - let mut header = Vec::new(); - let mut epochs = Vec::new(); - let mut line_no = 0usize; - let mut in_header = true; - let mut current: Option = None; - - let mut s = String::new(); - loop { - s.clear(); - let n = buf.read_line(&mut s)?; - if n == 0 { - break; - } - line_no += 1; - let trimmed_eol = s.trim_end_matches(['\r', '\n']).to_string(); - - if trimmed_eol == "EOF" { - break; - } - - if in_header { - // Header runs until the first '*' epoch line. - if trimmed_eol.starts_with('*') { - in_header = false; - } else { - header.push(trimmed_eol); - continue; - } - } - - if trimmed_eol.starts_with('*') { - // Epoch line: "* YYYY MM DD HH MM SS.SSSSSSSS" - if let Some(e) = current.take() { - epochs.push(e); - } - let fields: Vec<&str> = trimmed_eol - .strip_prefix('*') - .unwrap_or(trimmed_eol.as_str()) - .split_whitespace() - .collect(); - if fields.len() < 6 { - return Err(Sp3Error::Record { - line: line_no, - message: format!("epoch needs 6 fields, got {}", fields.len()), - }); - } - current = Some(Sp3Epoch { - time: civil_to_time_utc( - parse_field(fields[0], line_no, "year")?, - parse_field(fields[1], line_no, "month")?, - parse_field(fields[2], line_no, "day")?, - parse_field(fields[3], line_no, "hour")?, - parse_field(fields[4], line_no, "minute")?, - parse_field(fields[5], line_no, "second")?, - line_no, - )?, - positions: Vec::new(), - }); - } else if trimmed_eol.starts_with('P') { - let epoch = current.as_mut().ok_or_else(|| Sp3Error::Record { - line: line_no, - message: "P record before any epoch".into(), - })?; - // Columns are fixed in SP3, but split_whitespace is robust enough - // for MVP-1 fixtures. - // Format: "PXXX X.X Y.Y Z.Z CLOCK" - let id = trimmed_eol - .get(1..4) - .ok_or_else(|| Sp3Error::Record { - line: line_no, - message: "P record too short for sat id".into(), - })? - .trim() - .to_string(); - let rest: Vec<&str> = trimmed_eol[4..].split_whitespace().collect(); - if rest.len() < 4 { - return Err(Sp3Error::Record { - line: line_no, - message: format!("P record needs 4 numeric fields, got {}", rest.len()), - }); - } - epoch.positions.push(Sp3Position { - sat_id: id, - position: Position::::new( - parse_field::(rest[0], line_no, "x")?, - parse_field::(rest[1], line_no, "y")?, - parse_field::(rest[2], line_no, "z")?, - ), - clock: Microseconds::new(parse_field(rest[3], line_no, "clock")?), - }); - } else if trimmed_eol.starts_with('V') - || trimmed_eol.starts_with("EP") - || trimmed_eol.starts_with("EV") - { - // MVP-1: silently ignore. - continue; - } - } - if let Some(e) = current { - epochs.push(e); - } - Ok(Sp3Record { header, epochs }) -} - -fn parse_field(raw: &str, line: usize, what: &str) -> Result -where - T: std::str::FromStr, - ::Err: std::fmt::Display, -{ - raw.parse::().map_err(|e| Sp3Error::Record { - line, - message: format!("bad {what}: {e}"), - }) -} - -/// Write an SP3 record. The header lines are emitted verbatim, then each -/// epoch as `* YYYY MM DD HH MM SS.SS...` followed by `PXXX X Y Z CLK` lines. -pub fn write_sp3(w: &mut W, rec: &Sp3Record) -> Result<(), Sp3Error> { - use chrono::{Datelike, Timelike}; - for h in &rec.header { - writeln!(w, "{h}")?; - } - for e in &rec.epochs { - let dt = e.time.try_to_chrono().map_err(|err| Sp3Error::Record { - line: 0, - message: format!("epoch to chrono: {err}"), - })?; - let second = dt.second() as f64 + dt.nanosecond() as f64 / 1e9; - writeln!( - w, - "* {:4} {:>2} {:>2} {:>2} {:>2} {:11.8}", - dt.year(), - dt.month(), - dt.day(), - dt.hour(), - dt.minute(), - second - )?; - for p in &e.positions { - writeln!( - w, - "P{:<3} {:14.6} {:14.6} {:14.6} {:14.6}", - p.sat_id, - p.position.x().value(), - p.position.y().value(), - p.position.z().value(), - p.clock.value() - )?; - } - } - writeln!(w, "EOF")?; - Ok(()) -} - -/// Iterator-based streaming SP3 reader. -/// -/// Yields one [`Sp3Epoch`] at a time from a buffered reader without -/// materialising the entire file in memory. Use this when ingesting -/// hour-of-day SP3 files that can exceed 100 MB. -/// -/// The reader-borrowing iterator is a thin layer over [`read_sp3`] and -/// makes the same parsing decisions; in particular, the SP3 header is -/// pre-consumed and exposed via [`Sp3Stream::header`]. -/// -/// # Examples -/// -/// ``` -/// use spod::io::sp3::Sp3Stream; -/// -/// const SAMPLE: &str = "\ -/// #dP2024 1 1 0 0 0.00000000 1 ORBIT IGS20 HLM IGS\n\ -/// ## 2295 518400.00000000 900.00000000 60310 0.0000000000000\n\ -/// + 1 G01 \n\ -/// ++ 5 \n\ -/// %c L cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\ -/// %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\ -/// %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\ -/// %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\ -/// %i 0 0 0 0 0 0 0 0 0\n\ -/// %i 0 0 0 0 0 0 0 0 0\n\ -/// /* COMMENT\n\ -/// * 2024 1 1 0 0 0.00000000\n\ -/// PG01 15000.000000 20000.000000 -5000.000000 0.000123\n\ -/// EOF\n"; -/// let mut s = Sp3Stream::new(SAMPLE.as_bytes()).unwrap(); -/// assert!(!s.header().is_empty()); -/// let mut epochs = 0; -/// while let Some(ep) = s.next_epoch().unwrap() { -/// assert!(!ep.positions.is_empty()); -/// epochs += 1; -/// } -/// assert_eq!(epochs, 1); -/// ``` -pub struct Sp3Stream { - inner: BufReader, - header: Vec, - pending_epoch_line: Option<(usize, String)>, - line_no: usize, - finished: bool, -} - -impl Sp3Stream { - /// Create a new streaming reader by consuming the SP3 header up to the - /// first epoch line. - pub fn new(r: R) -> Result { - let mut inner = BufReader::new(r); - let mut header = Vec::new(); - let mut line_no = 0usize; - let mut s = String::new(); - let pending = loop { - s.clear(); - let n = inner.read_line(&mut s)?; - if n == 0 { - break None; - } - line_no += 1; - let trimmed = s.trim_end_matches(['\r', '\n']).to_string(); - if trimmed == "EOF" { - break None; - } - if trimmed.starts_with('*') { - break Some((line_no, trimmed)); - } - header.push(trimmed); - }; - Ok(Self { - inner, - header, - pending_epoch_line: pending, - line_no, - finished: false, - }) - } - - /// Header lines collected before the first epoch. - pub fn header(&self) -> &[String] { - &self.header - } - - /// Yield the next [`Sp3Epoch`], or `None` when the stream is exhausted. - pub fn next_epoch(&mut self) -> Result, Sp3Error> { - if self.finished { - return Ok(None); - } - let (epoch_line_no, epoch_line) = match self.pending_epoch_line.take() { - Some(v) => v, - None => { - self.finished = true; - return Ok(None); - } - }; - let mut epoch = parse_epoch_line(&epoch_line, epoch_line_no)?; - let mut s = String::new(); - loop { - s.clear(); - let n = self.inner.read_line(&mut s)?; - if n == 0 { - self.finished = true; - break; - } - self.line_no += 1; - let trimmed = s.trim_end_matches(['\r', '\n']).to_string(); - if trimmed == "EOF" { - self.finished = true; - break; - } - if trimmed.starts_with('*') { - self.pending_epoch_line = Some((self.line_no, trimmed)); - break; - } - if trimmed.starts_with('P') { - epoch.positions.push(parse_p_line(&trimmed, self.line_no)?); - } - // V/EP/EV records ignored as in batch reader. - } - Ok(Some(epoch)) - } -} - -fn parse_epoch_line(line: &str, line_no: usize) -> Result { - let fields: Vec<&str> = line - .strip_prefix('*') - .unwrap_or(line) - .split_whitespace() - .collect(); - if fields.len() < 6 { - return Err(Sp3Error::Record { - line: line_no, - message: format!("epoch needs 6 fields, got {}", fields.len()), - }); - } - Ok(Sp3Epoch { - time: civil_to_time_utc( - parse_field(fields[0], line_no, "year")?, - parse_field(fields[1], line_no, "month")?, - parse_field(fields[2], line_no, "day")?, - parse_field(fields[3], line_no, "hour")?, - parse_field(fields[4], line_no, "minute")?, - parse_field(fields[5], line_no, "second")?, - line_no, - )?, - positions: Vec::new(), - }) -} - -fn parse_p_line(line: &str, line_no: usize) -> Result { - let id = line - .get(1..4) - .ok_or_else(|| Sp3Error::Record { - line: line_no, - message: "P record too short for sat id".into(), - })? - .trim() - .to_string(); - let rest: Vec<&str> = line[4..].split_whitespace().collect(); - if rest.len() < 4 { - return Err(Sp3Error::Record { - line: line_no, - message: format!("P record needs 4 numeric fields, got {}", rest.len()), - }); - } - Ok(Sp3Position { - sat_id: id, - position: Position::::new( - parse_field::(rest[0], line_no, "x")?, - parse_field::(rest[1], line_no, "y")?, - parse_field::(rest[2], line_no, "z")?, - ), - clock: Microseconds::new(parse_field(rest[3], line_no, "clock")?), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - const SAMPLE: &str = "\ -#dP2024 1 1 0 0 0.00000000 96 ORBIT IGS20 HLM IGS\n\ -## 2295 518400.00000000 900.00000000 60310 0.0000000000000\n\ -+ 2 G01G02 \n\ -++ 5 5 \n\ -%c L cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\ -%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\ -%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\ -%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\ -%i 0 0 0 0 0 0 0 0 0\n\ -%i 0 0 0 0 0 0 0 0 0\n\ -/* COMMENT LINE\n\ -* 2024 1 1 0 0 0.00000000\n\ -PG01 1000.000000 2000.000000 3000.000000 0.000123\n\ -PG02 -1500.000000 1700.000000 -2500.000000 0.000456\n\ -* 2024 1 1 0 15 0.00000000\n\ -PG01 1100.000000 2100.000000 3100.000000 0.000124\n\ -PG02 -1400.000000 1800.000000 -2400.000000 0.000457\n\ -EOF\n"; - - #[test] - fn parse_sample() { - let rec = read_sp3(SAMPLE.as_bytes()).expect("parse sample"); - assert_eq!(rec.epochs.len(), 2); - assert_eq!(rec.epochs[0].positions.len(), 2); - assert_eq!(rec.epochs[0].positions[0].sat_id, "G01"); - assert!((rec.epochs[0].positions[0].position.x().value() - 1000.0).abs() < 1e-9); - } - - #[test] - fn round_trip() { - let rec = read_sp3(SAMPLE.as_bytes()).expect("parse"); - let mut buf = Vec::new(); - write_sp3(&mut buf, &rec).expect("write"); - let rec2 = read_sp3(buf.as_slice()).expect("re-parse"); - assert_eq!(rec.epochs.len(), rec2.epochs.len()); - assert_eq!(rec.header.len(), rec2.header.len()); - for (e1, e2) in rec.epochs.iter().zip(rec2.epochs.iter()) { - // Epoch times may differ by ~14 µs due to TT_MINUS_TAI imprecision in - // the try_to_chrono backward path; tolerate up to 1 ms. - let t1 = e1.time.raw_seconds_pair().0.value(); - let t2 = e2.time.raw_seconds_pair().0.value(); - assert!((t1 - t2).abs() < 1e-3, "epoch time mismatch: {t1} vs {t2}"); - assert_eq!(e1.positions, e2.positions); - } - } -} diff --git a/src/io/tdm.rs b/src/io/tdm.rs deleted file mode 100644 index d0a71b0..0000000 --- a/src/io/tdm.rs +++ /dev/null @@ -1,300 +0,0 @@ -//! # CCSDS TDM reader/writer (CCSDS 503.0-B-2) -//! -//! Parses and emits Tracking Data Messages in Keyword-Value Notation (KVN). -//! Supported blocks: header, `META_START`/`META_STOP`, -//! `DATA_START`/`DATA_STOP`. -//! -//! ## References -//! -//! - CCSDS 503.0-B-2: Tracking Data Message, Blue Book (2007). - -use super::PodIoError; -use std::io::{BufRead, BufReader, Read, Write}; - -/// TDM observation type. -/// -/// # Examples -/// -/// ``` -/// use spod::io::tdm::ObservationType; -/// assert_eq!(ObservationType::Range, ObservationType::Range); -/// ``` -#[derive(Debug, Clone, PartialEq)] -pub enum ObservationType { - /// Two-way range measurement. - Range, - /// Doppler frequency shift (instantaneous). - Doppler, - /// Angle 1 (azimuth or right ascension). - Angle1, - /// Angle 2 (elevation or declination). - Angle2, - /// Other observable keyword. - Other(String), -} - -/// A single TDM observation record. -/// -/// # Examples -/// -/// ``` -/// use spod::io::tdm::{ObservationData, ObservationType}; -/// let obs = ObservationData { -/// obs_type: ObservationType::Range, -/// epoch: "2024-001T12:00:00".to_string(), -/// value: 23000.0, -/// }; -/// assert_eq!(obs.value, 23000.0); -/// ``` -#[derive(Debug, Clone)] -pub struct ObservationData { - /// Observation type. - pub obs_type: ObservationType, - /// Epoch in ISO 8601 format. - pub epoch: String, - /// Observation value (units depend on type). - pub value: f64, -} - -/// TDM metadata block. -/// -/// # Examples -/// -/// ``` -/// use spod::io::tdm::TdmMetadata; -/// let m = TdmMetadata { -/// participants: vec!["STATION_A".to_string()], -/// mode: "SEQUENTIAL".to_string(), -/// path: "1,2,1".to_string(), -/// time_system: "UTC".to_string(), -/// }; -/// assert_eq!(m.mode, "SEQUENTIAL"); -/// ``` -#[derive(Debug, Clone)] -pub struct TdmMetadata { - /// Participant identifiers. - pub participants: Vec, - /// Mode (e.g. `"SEQUENTIAL"`). - pub mode: String, - /// Path string (e.g. `"1,2,1"`). - pub path: String, - /// Time system. - pub time_system: String, -} - -/// Parsed CCSDS TDM message. -/// -/// # Examples -/// -/// ``` -/// use spod::io::tdm::TdmMessage; -/// let msg = TdmMessage::default(); -/// assert!(msg.observations.is_empty()); -/// ``` -#[derive(Debug, Default)] -pub struct TdmMessage { - /// Metadata block(s). - pub metadata: Vec, - /// Observation records. - pub observations: Vec, -} - -/// Read a CCSDS TDM KVN message from a byte source. -/// -/// Parses `CCSDS_TDM_VERS`, `META_START`/`META_STOP`, -/// and `DATA_START`/`DATA_STOP` blocks. -/// -/// # Errors -/// -/// Returns [`PodIoError::Format`] for missing or malformed fields. -/// -/// # Examples -/// -/// ``` -/// use spod::io::tdm::read_tdm; -/// -/// let data = b"CCSDS_TDM_VERS = 1.0\nMETA_START\nPARTICIPANT_1 = STA\n\ -/// MODE = SEQUENTIAL\nPATH = 1,2,1\nTIME_SYSTEM = UTC\nMETA_STOP\n\ -/// DATA_START\nRANGE = 2024-001T12:00:00 : 23000.0\nDATA_STOP\n"; -/// let msg = read_tdm(&data[..]).unwrap(); -/// assert_eq!(msg.observations.len(), 1); -/// ``` -pub fn read_tdm(reader: R) -> Result { - let buf = BufReader::new(reader); - let mut msg = TdmMessage::default(); - - enum State { - None, - Meta, - Data, - } - let mut state = State::None; - let mut current_meta = new_meta(); - - for result in buf.lines() { - let line = result.map_err(PodIoError::Io)?; - let line = line.trim().to_string(); - if line.is_empty() || line.starts_with("COMMENT") || line.starts_with("CCSDS_TDM") { - continue; - } - match line.as_str() { - "META_START" => { - state = State::Meta; - current_meta = new_meta(); - continue; - } - "META_STOP" => { - msg.metadata.push(current_meta.clone()); - current_meta = new_meta(); - state = State::None; - continue; - } - "DATA_START" => { - state = State::Data; - continue; - } - "DATA_STOP" => { - state = State::None; - continue; - } - _ => {} - } - - if let Some(eq) = line.find('=') { - let key = line[..eq].trim().to_string(); - let val = line[eq + 1..].trim().to_string(); - match state { - State::Meta => { - if key.starts_with("PARTICIPANT_") { - current_meta.participants.push(val); - } else if key == "MODE" { - current_meta.mode = val; - } else if key == "PATH" { - current_meta.path = val; - } else if key == "TIME_SYSTEM" { - current_meta.time_system = val; - } - } - State::Data => { - // val format: "epoch : value" — split on last " : " to avoid - // colons in ISO 8601 epoch strings (e.g. "2024-001T12:00:00 : 23000.0") - let sep = " : "; - let colon = val.rfind(sep).ok_or_else(|| { - PodIoError::Format(format!("TDM: data line missing ' : ': {key} = {val}")) - })?; - let epoch = val[..colon].trim().to_string(); - let value_str = val[colon + sep.len()..].trim(); - let value = value_str.parse::().map_err(|_| { - PodIoError::Format(format!("TDM: cannot parse value: {value_str:?}")) - })?; - let obs_type = keyword_to_obs_type(&key); - msg.observations.push(ObservationData { - obs_type, - epoch, - value, - }); - } - State::None => {} - } - } - } - - Ok(msg) -} - -fn new_meta() -> TdmMetadata { - TdmMetadata { - participants: Vec::new(), - mode: String::new(), - path: String::new(), - time_system: String::new(), - } -} - -fn keyword_to_obs_type(kw: &str) -> ObservationType { - match kw { - "RANGE" => ObservationType::Range, - "DOPPLER_INSTANTANEOUS" | "DOPPLER_INTEGRATED" => ObservationType::Doppler, - "ANGLE_1" => ObservationType::Angle1, - "ANGLE_2" => ObservationType::Angle2, - other => ObservationType::Other(other.to_string()), - } -} - -fn obs_type_to_keyword(t: &ObservationType) -> &str { - match t { - ObservationType::Range => "RANGE", - ObservationType::Doppler => "DOPPLER_INSTANTANEOUS", - ObservationType::Angle1 => "ANGLE_1", - ObservationType::Angle2 => "ANGLE_2", - ObservationType::Other(s) => s.as_str(), - } -} - -/// Write a CCSDS TDM message in KVN format. -/// -/// Emits the header, `META_START`/`META_STOP` block(s), and -/// `DATA_START`/`DATA_STOP` block. -/// -/// # Errors -/// -/// Returns [`PodIoError::Io`] on write failure. -/// -/// # Examples -/// -/// ``` -/// use spod::io::tdm::{write_tdm, TdmMessage, TdmMetadata, ObservationData, ObservationType}; -/// -/// let mut msg = TdmMessage::default(); -/// msg.metadata.push(TdmMetadata { -/// participants: vec!["STA".to_string()], -/// mode: "SEQUENTIAL".to_string(), -/// path: "1,2,1".to_string(), -/// time_system: "UTC".to_string(), -/// }); -/// msg.observations.push(ObservationData { -/// obs_type: ObservationType::Range, -/// epoch: "2024-001T12:00:00".to_string(), -/// value: 23000.0, -/// }); -/// let mut buf = Vec::new(); -/// write_tdm(&mut buf, &msg).unwrap(); -/// let text = String::from_utf8(buf).unwrap(); -/// assert!(text.contains("RANGE")); -/// ``` -pub fn write_tdm(w: &mut W, msg: &TdmMessage) -> Result<(), PodIoError> { - writeln!(w, "CCSDS_TDM_VERS = 1.0").map_err(PodIoError::Io)?; - for (i, meta) in msg.metadata.iter().enumerate() { - if i > 0 { - writeln!(w).map_err(PodIoError::Io)?; - } - writeln!(w, "META_START").map_err(PodIoError::Io)?; - for (j, p) in meta.participants.iter().enumerate() { - writeln!(w, "PARTICIPANT_{} = {}", j + 1, p).map_err(PodIoError::Io)?; - } - if !meta.mode.is_empty() { - writeln!(w, "MODE = {}", meta.mode).map_err(PodIoError::Io)?; - } - if !meta.path.is_empty() { - writeln!(w, "PATH = {}", meta.path).map_err(PodIoError::Io)?; - } - if !meta.time_system.is_empty() { - writeln!(w, "TIME_SYSTEM = {}", meta.time_system).map_err(PodIoError::Io)?; - } - writeln!(w, "META_STOP").map_err(PodIoError::Io)?; - } - writeln!(w).map_err(PodIoError::Io)?; - writeln!(w, "DATA_START").map_err(PodIoError::Io)?; - for obs in &msg.observations { - writeln!( - w, - "{} = {} : {}", - obs_type_to_keyword(&obs.obs_type), - obs.epoch, - obs.value - ) - .map_err(PodIoError::Io)?; - } - writeln!(w, "DATA_STOP").map_err(PodIoError::Io)?; - Ok(()) -} diff --git a/src/io/vgosdb.rs b/src/io/vgosdb.rs deleted file mode 100644 index 370d34d..0000000 --- a/src/io/vgosdb.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! # vgosDB reader stub -//! -//! The vgosDB (VLBI Global Observing System Data Base) format uses a -//! NetCDF-based directory structure. Full parsing requires a NetCDF library -//! and is not yet implemented. -//! -//! This stub exports a public type and a function that returns -//! [`PodIoError::Unsupported`], allowing callers to pattern-match on the -//! error without a panic. - -use super::PodIoError; - -/// Placeholder type for a vgosDB dataset. -/// -/// # Examples -/// -/// ``` -/// use spod::io::vgosdb::VgosDb; -/// // VgosDb cannot be constructed until the feature is implemented. -/// let err = spod::io::vgosdb::read_vgosdb(std::path::Path::new("/none")).unwrap_err(); -/// assert!(matches!(err, spod::io::PodIoError::Unsupported(_))); -/// ``` -#[derive(Debug)] -pub struct VgosDb; - -/// Read a vgosDB dataset. -/// -/// Always returns [`PodIoError::Unsupported`] in the current implementation. -/// -/// # Errors -/// -/// Always returns [`PodIoError::Unsupported`]. -/// -/// # Examples -/// -/// ``` -/// use spod::io::vgosdb::read_vgosdb; -/// use spod::io::PodIoError; -/// use std::path::Path; -/// -/// let err = read_vgosdb(Path::new("/nonexistent")).unwrap_err(); -/// assert!(matches!(err, PodIoError::Unsupported(_))); -/// ``` -pub fn read_vgosdb(_path: &std::path::Path) -> Result { - Err(PodIoError::Unsupported( - "vgosDB not yet implemented".to_string(), - )) -} diff --git a/src/lambert/error.rs b/src/lambert/error.rs deleted file mode 100644 index 7d5fd2a..0000000 --- a/src/lambert/error.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Unified error type for the Lambert solver. -//! -//! All public APIs in this crate fail through a single [`LambertError`] -//! variant. The variants are stable and carry enough context to let -//! callers decide whether to retry with different inputs (e.g. relax -//! `revolutions`) or surface a domain error to the user. - -/// Errors returned by the Lambert solver. -#[derive(Debug, thiserror::Error, PartialEq)] -pub enum LambertError { - /// Gravitational parameter must be strictly positive (km³/s²). - #[error("non-positive gravitational parameter ({0})")] - NonPositiveMu(f64), - /// Time of flight must be a finite, strictly positive duration. - #[error("non-positive time of flight ({0} s)")] - NonPositiveTof(f64), - /// Initial or final position vector has effectively zero magnitude. - #[error("zero-magnitude position vector")] - ZeroPosition, - /// `r1`, `r2` and the origin are collinear, so the chord/transfer - /// plane is undefined. - #[error("collinear positions: chord cannot be resolved unambiguously")] - Collinear, - /// The requested revolution count exceeds the maximum permitted by - /// the supplied time of flight (Izzo Eq. 21). - /// - /// `requested` is what the caller asked for; `max` is the largest - /// `N` for which a solution exists at the supplied `tof`. - #[error("requested {requested} revolutions exceeds N_max = {max} for the given TOF")] - RevolutionsExceedNMax { - /// Number of revolutions requested by the caller. - requested: u32, - /// Largest physically admissible revolution count. - max: u32, - }, - /// Householder iteration failed to converge within the iteration cap. - /// - /// Carries the residual at the last iterate (non-dimensional time - /// units). - #[error("Householder iteration failed to converge (residual = {residual:e})")] - DidNotConverge { - /// Final residual `|T(x) − T*|` in non-dimensional units. - residual: f64, - }, -} diff --git a/src/lambert/izzo.rs b/src/lambert/izzo.rs deleted file mode 100644 index 7849a42..0000000 --- a/src/lambert/izzo.rs +++ /dev/null @@ -1,898 +0,0 @@ -//! Izzo (2014) reformulation of Lambert's two-point boundary-value problem. -//! -//! This module is the **numeric backend**: it operates on plain `[f64; 3]` -//! position arrays in kilometres and a time of flight in seconds. The -//! typed entry-point is [`crate::lambert`] / [`crate::lambert_n_rev`]. -//! -//! ## Algorithm -//! -//! Re-implementation in Rust of the algorithm described in Izzo, D. (2014), -//! *"Revisiting Lambert's Problem"*, Celest. Mech. Dyn. Astron. 121:1–15. -//! The flow is: -//! -//! 1. Compute the Lambert geometry (chord `c`, semi-perimeter `s`, -//! transfer-plane normal `ih`, signed dimensionless parameter `λ`). -//! 2. Convert the problem to dimensionless form via -//! `T = sqrt(2μ / s³) · t_f`. -//! 3. Pick an initial guess for the auxiliary variable `x` -//! (Izzo Eqs. 30 / 31), depending on whether a 0-rev or N-rev solution -//! is sought. -//! 4. Refine with a third-order Householder iteration on `T(x) − T*`, -//! using the analytic 1st/2nd/3rd derivatives `T'`, `T''`, `T'''` -//! (Izzo Eq. 22). The piecewise `T(x)` evaluator switches between the -//! Lagrange, Lancaster and Battin (hypergeometric) forms to remain -//! numerically stable across the elliptic / parabolic / hyperbolic -//! transitions. -//! 5. Reconstruct the terminal velocity vectors from `(x, λ)` using -//! `γ = sqrt(μs/2)` and the radial/tangential decomposition described -//! in §4 of the paper. -//! -//! The implementation is independent of the MPL-2.0 PyKEP source — only -//! the numerical formulae (which originate in Izzo's published paper) are -//! shared. -//! -//! ## References -//! -//! - Izzo, D. (2014). *Revisiting Lambert's Problem*. Celest. Mech. Dyn. -//! Astron., 121(1):1–15. -//! - Battin, R. H. (1999). *An Introduction to the Mathematics and Methods -//! of Astrodynamics* (Rev. ed.). AIAA. -//! - Vallado, D. A. (2013). *Fundamentals of Astrodynamics and -//! Applications* (4th ed.). Microcosm Press. - -use core::f64::consts::PI; - -use super::error::LambertError; - -/// Direction of revolution about the central body. -/// -/// The convention follows Izzo (2014): a *prograde* transfer has positive -/// out-of-plane angular momentum component along `+z`; a *retrograde* -/// transfer flips that sign. The sign of the dimensionless parameter -/// `λ` is then chosen to produce the short-way solution. -/// -/// # Examples -/// -/// ``` -/// use spod::lambert::LambertBranch; -/// -/// assert_ne!(LambertBranch::Prograde, LambertBranch::Retrograde); -/// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LambertBranch { - /// Prograde (counter-clockwise as seen from `+z`). - Prograde, - /// Retrograde (clockwise as seen from `+z`). - Retrograde, -} - -/// Side selection for the multi-revolution branch. -/// -/// For `N ≥ 1` the Lambert problem has two distinct solutions per -/// revolution count: a *left* (low-energy / short-period) and a *right* -/// (high-energy / long-period) branch. They correspond to the two roots -/// of `T(x) = T*` on either side of the time-of-flight minimum. -/// -/// # Examples -/// -/// ``` -/// use spod::lambert::NRevBranch; -/// -/// // Two distinct N-rev sides: left (short-period) and right (long-period). -/// assert_ne!(NRevBranch::Left, NRevBranch::Right); -/// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NRevBranch { - /// Low-energy / short-period side (smaller semi-major axis). - Left, - /// High-energy / long-period side (larger semi-major axis). - Right, -} - -/// Diagnostics produced by a single Lambert solve. -/// -/// # Examples -/// -/// ``` -/// use spod::lambert::{solve_lambert, LambertBranch}; -/// -/// let r1 = [15945.34, 0.0, 0.0]; -/// let r2 = [12214.83899, 10249.46731, 0.0]; -/// let sol = solve_lambert(r1, r2, 4_560.0, 398_600.4418, LambertBranch::Prograde).unwrap(); -/// assert!(sol.diagnostics.iterations <= 15); -/// assert!(sol.diagnostics.residual.abs() < 1e-10); -/// assert_eq!(sol.diagnostics.revolutions, 0); -/// ``` -#[derive(Debug, Clone, Copy)] -pub struct LambertDiagnostics { - /// Householder iterations consumed before convergence. - pub iterations: u32, - /// Final residual `|T(x) − T*|` in non-dimensional time. - pub residual: f64, - /// Converged value of the auxiliary variable `x`. - pub x: f64, - /// Revolution count `N` for which this solution was computed. - pub revolutions: u32, -} - -/// A successful Lambert solution. -/// -/// Velocities are expressed in km/s in the **same inertial frame** as the -/// input position vectors. -/// -/// # Examples -/// -/// ``` -/// use spod::lambert::{solve_lambert, LambertBranch, LambertSolution}; -/// -/// let LambertSolution { v1, v2, .. } = solve_lambert( -/// [15945.34, 0.0, 0.0], -/// [12214.83899, 10249.46731, 0.0], -/// 4_560.0, -/// 398_600.4418, -/// LambertBranch::Prograde, -/// ) -/// .unwrap(); -/// assert!((v1[0] - 2.058913).abs() < 1e-3); -/// assert!((v2[1] - 0.910315).abs() < 1e-3); -/// ``` -#[derive(Debug, Clone, Copy)] -pub struct LambertSolution { - /// Departure velocity at `r1` [km/s]. - pub v1: [f64; 3], - /// Arrival velocity at `r2` [km/s]. - pub v2: [f64; 3], - /// Convergence diagnostics. - pub diagnostics: LambertDiagnostics, -} - -// ───────────────────────────────────────────────────────────────────────────── -// Vector helpers -// ───────────────────────────────────────────────────────────────────────────── - -#[inline] -fn norm(v: [f64; 3]) -> f64 { - (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt() -} - -#[inline] -fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { - [ - a[1] * b[2] - a[2] * b[1], - a[2] * b[0] - a[0] * b[2], - a[0] * b[1] - a[1] * b[0], - ] -} - -#[inline] -fn scale(v: [f64; 3], k: f64) -> [f64; 3] { - [v[0] * k, v[1] * k, v[2] * k] -} - -// ───────────────────────────────────────────────────────────────────────────── -// Public solver entry-points -// ───────────────────────────────────────────────────────────────────────────── - -/// Solve Lambert's problem for the single-revolution (`N = 0`) branch. -/// -/// `r1`, `r2` are inertial position vectors in **kilometres**, `tof_s` -/// is the time of flight in **seconds**, and `mu` is the central-body -/// gravitational parameter in **km³/s²**. -/// -/// The returned [`LambertSolution`] carries departure/arrival velocities -/// in km/s plus iteration diagnostics. -/// -/// # Errors -/// -/// Returns [`LambertError::NonPositiveMu`], [`LambertError::NonPositiveTof`], -/// [`LambertError::ZeroPosition`] or [`LambertError::Collinear`] for -/// degenerate inputs, and [`LambertError::DidNotConverge`] if the -/// Householder iteration fails to converge (extremely rare for valid -/// 0-rev geometries). -/// -/// # Examples -/// -/// ``` -/// use spod::lambert::{solve_lambert, LambertBranch}; -/// -/// // Vallado, *Fundamentals of Astrodynamics*, Ex. 7-5. -/// let r1 = [15945.34, 0.0, 0.0]; -/// let r2 = [12214.83899, 10249.46731, 0.0]; -/// let sol = solve_lambert(r1, r2, 4_560.0, 398_600.4418, LambertBranch::Prograde).unwrap(); -/// assert!((sol.v1[0] - 2.058913).abs() < 1e-3); -/// ``` -pub fn solve_lambert( - r1: [f64; 3], - r2: [f64; 3], - tof_s: f64, - mu: f64, - branch: LambertBranch, -) -> Result { - solve_inner(r1, r2, tof_s, mu, branch, 0, NRevBranch::Left) -} - -/// Solve Lambert's problem for `N ≥ 1` complete revolutions. -/// -/// `revolutions` is the number `N` of full orbital revolutions between -/// `r1` and `r2`. `side` selects between the *low-energy* (`Left`) and -/// *high-energy* (`Right`) Householder roots that bracket the -/// time-of-flight minimum for this `N`. -/// -/// # Errors -/// -/// Returns [`LambertError::RevolutionsExceedNMax`] when no solution -/// exists for the requested `N` at the supplied `tof_s` (Izzo Eq. 21), -/// plus the same input-validation variants as [`solve_lambert`]. -/// -/// # Examples -/// -/// ``` -/// use spod::lambert::{solve_lambert_n_rev, LambertBranch, NRevBranch}; -/// -/// // Same geometry as Vallado Ex. 7-5 but with a much longer TOF -/// // (≈ 6 h) — admits an N = 1 solution because T > T_min(1). -/// let r1 = [15945.34, 0.0, 0.0]; -/// let r2 = [12214.83899, 10249.46731, 0.0]; -/// let tof = 21_600.0; // 6 h -/// let sol = solve_lambert_n_rev( -/// r1, r2, tof, 398_600.4418, -/// LambertBranch::Prograde, 1, NRevBranch::Left, -/// ) -/// .unwrap(); -/// assert_eq!(sol.diagnostics.revolutions, 1); -/// ``` -pub fn solve_lambert_n_rev( - r1: [f64; 3], - r2: [f64; 3], - tof_s: f64, - mu: f64, - branch: LambertBranch, - revolutions: u32, - side: NRevBranch, -) -> Result { - solve_inner(r1, r2, tof_s, mu, branch, revolutions, side) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Core solver -// ───────────────────────────────────────────────────────────────────────────── - -fn solve_inner( - r1: [f64; 3], - r2: [f64; 3], - tof_s: f64, - mu: f64, - branch: LambertBranch, - revolutions: u32, - side: NRevBranch, -) -> Result { - // 0 — input validation. - if mu <= 0.0 || !mu.is_finite() { - return Err(LambertError::NonPositiveMu(mu)); - } - if !tof_s.is_finite() || tof_s <= 0.0 { - return Err(LambertError::NonPositiveTof(tof_s)); - } - let r1_mag = norm(r1); - let r2_mag = norm(r2); - if r1_mag < 1e-12 || r2_mag < 1e-12 { - return Err(LambertError::ZeroPosition); - } - - // 1 — geometry. - let c_vec = [r2[0] - r1[0], r2[1] - r1[1], r2[2] - r1[2]]; - let c = norm(c_vec); - let s = 0.5 * (r1_mag + r2_mag + c); - - let ir1 = scale(r1, 1.0 / r1_mag); - let ir2 = scale(r2, 1.0 / r2_mag); - let ih_raw = cross(ir1, ir2); - let ih_mag = norm(ih_raw); - if ih_mag < 1e-12 { - return Err(LambertError::Collinear); - } - let ih = scale(ih_raw, 1.0 / ih_mag); - - // 2 — sign of λ and tangent unit vectors. - let mut lambda = (1.0 - c / s).sqrt(); - - let mut it1 = cross(ih, ir1); - let mut it2 = cross(ih, ir2); - let it1_mag = norm(it1); - let it2_mag = norm(it2); - it1 = scale(it1, 1.0 / it1_mag); - it2 = scale(it2, 1.0 / it2_mag); - - // Long-way / short-way from sign of ih.z (Izzo §3 convention). - if ih[2] < 0.0 { - lambda = -lambda; - it1 = scale(it1, -1.0); - it2 = scale(it2, -1.0); - } - // Caller-requested retrograde flips the sense again. - if branch == LambertBranch::Retrograde { - lambda = -lambda; - it1 = scale(it1, -1.0); - it2 = scale(it2, -1.0); - } - - // 3 — non-dimensional time. - let t_star = (2.0 * mu / (s * s * s)).sqrt() * tof_s; - - // 4 — bound the requested revolution count by N_max. - let n_max_geom = (t_star / PI) as u32; - if revolutions > n_max_geom { - return Err(LambertError::RevolutionsExceedNMax { - requested: revolutions, - max: n_max_geom, - }); - } - if revolutions > 0 { - // Confirm a solution actually exists by checking T_min at this N - // (Izzo §3.2): use a Halley iteration on dT/dx = 0 starting at x = 0. - let t_min_x = solve_t_min(lambda, revolutions); - let t_min = tof_dimensionless(t_min_x, lambda, revolutions); - if t_min > t_star { - return Err(LambertError::RevolutionsExceedNMax { - requested: revolutions, - max: revolutions.saturating_sub(1), - }); - } - } - - // 5 — initial guess. - let x0 = if revolutions == 0 { - initial_guess_zero_rev(t_star, lambda) - } else { - initial_guess_n_rev(t_star, lambda, revolutions, side) - }; - - // 6 — Householder root-finding. - let (x, iters, residual) = householder(t_star, x0, lambda, revolutions)?; - - // 7 — reconstruct velocities (Izzo §4). - let lambda2 = lambda * lambda; - let y = (1.0 - lambda2 * (1.0 - x * x)).sqrt(); - let gamma = (mu * s * 0.5).sqrt(); - let rho = (r1_mag - r2_mag) / c; - let sigma = (1.0 - rho * rho).sqrt(); - - let vr1 = gamma * ((lambda * y - x) - rho * (lambda * y + x)) / r1_mag; - let vr2 = -gamma * ((lambda * y - x) + rho * (lambda * y + x)) / r2_mag; - let vt = gamma * sigma * (y + lambda * x); - let vt1 = vt / r1_mag; - let vt2 = vt / r2_mag; - - let v1 = [ - vr1 * ir1[0] + vt1 * it1[0], - vr1 * ir1[1] + vt1 * it1[1], - vr1 * ir1[2] + vt1 * it1[2], - ]; - let v2 = [ - vr2 * ir2[0] + vt2 * it2[0], - vr2 * ir2[1] + vt2 * it2[1], - vr2 * ir2[2] + vt2 * it2[2], - ]; - - Ok(LambertSolution { - v1, - v2, - diagnostics: LambertDiagnostics { - iterations: iters, - residual, - x, - revolutions, - }, - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Initial guesses (Izzo Eqs. 30, 31) -// ───────────────────────────────────────────────────────────────────────────── - -fn initial_guess_zero_rev(t_star: f64, lambda: f64) -> f64 { - let lambda2 = lambda * lambda; - let lambda3 = lambda2 * lambda; - let lambda5 = lambda3 * lambda2; - - let t00 = lambda.acos() + lambda * (1.0 - lambda2).sqrt(); // T(x = 0, N = 0) - let t1 = (2.0 / 3.0) * (1.0 - lambda3); // T(x = 1, N = 0) parabolic limit - - if t_star >= t00 { - // High-TOF / hyperbolic side. - -((t_star - t00) / (t_star - t00 + 4.0)) - } else if t_star <= t1 { - // Low-TOF side, near parabolic. - 2.5 * t1 * (t1 - t_star) / (t_star * (1.0 - lambda5)) + 1.0 - } else { - // Logarithmic interpolation between the two anchors. - (t_star / t00).powf(core::f64::consts::LN_2 / (t1 / t00).ln()) - 1.0 - } -} - -fn initial_guess_n_rev(t_star: f64, _lambda: f64, n: u32, side: NRevBranch) -> f64 { - let n_f = n as f64; - match side { - NRevBranch::Left => { - let tmp = ((n_f * PI + PI) / (8.0 * t_star)).powf(2.0 / 3.0); - (tmp - 1.0) / (tmp + 1.0) - } - NRevBranch::Right => { - let tmp = ((8.0 * t_star) / (n_f * PI)).powf(2.0 / 3.0); - (tmp - 1.0) / (tmp + 1.0) - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Householder iteration on T(x) − T* = 0 -// ───────────────────────────────────────────────────────────────────────────── - -fn householder( - t_star: f64, - mut x: f64, - lambda: f64, - n: u32, -) -> Result<(f64, u32, f64), LambertError> { - // Tighter tolerance for 0-rev (problem is well-conditioned), looser - // for N-rev where the Householder basin shrinks. - let eps = 1e-8; - let max_iter = 15u32; - - let mut iters = 0u32; - let mut last_residual = f64::INFINITY; - - for _ in 0..max_iter { - let t_x = tof_dimensionless(x, lambda, n); - let (dt, ddt, dddt) = derivatives(x, t_x, lambda); - let delta = t_x - t_star; - let dt2 = dt * dt; - - let denom = dt * (dt2 - delta * ddt) + dddt * delta * delta / 6.0; - if !denom.is_finite() || denom == 0.0 { - // Should not happen on physical inputs; bail early. - return Err(LambertError::DidNotConverge { residual: delta }); - } - - let x_new = x - delta * (dt2 - 0.5 * delta * ddt) / denom; - iters += 1; - let step = (x - x_new).abs(); - last_residual = delta.abs(); - x = x_new; - if step < eps { - return Ok((x, iters, last_residual)); - } - } - if last_residual < 1e-6 { - Ok((x, iters, last_residual)) - } else { - Err(LambertError::DidNotConverge { - residual: last_residual, - }) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Time-of-flight curve T(x, λ, N) — piecewise stable form. -// ───────────────────────────────────────────────────────────────────────────── - -fn tof_dimensionless(x: f64, lambda: f64, n: u32) -> f64 { - let battin_dist = 0.01; - let lagrange_dist = 0.2; - let dist = (x - 1.0).abs(); - - if dist < lagrange_dist && dist > battin_dist { - return tof_lagrange(x, lambda, n); - } - - let k = lambda * lambda; - let e = x * x - 1.0; - let rho = e.abs(); - let z = (1.0 + k * e).sqrt(); - - if dist < battin_dist { - // Battin series (parabolic neighbourhood). - let eta = z - lambda * x; - let s1 = 0.5 * (1.0 - lambda - x * eta); - let q = (4.0 / 3.0) * hypergeometric_f(s1, 1e-11); - (eta.powi(3) * q + 4.0 * lambda * eta) * 0.5 + (n as f64) * PI / rho.powf(1.5) - } else { - // Lancaster expression — robust at high |x| and across e = 0 limit. - let y = rho.sqrt(); - let g = x * z - lambda * e; - let d = if e < 0.0 { - let l = g.acos(); - (n as f64) * PI + l - } else { - let f = y * (z - lambda * x); - (f + g).ln() - }; - (x - lambda * z - d / y) / e - } -} - -fn tof_lagrange(x: f64, lambda: f64, n: u32) -> f64 { - let a = 1.0 / (1.0 - x * x); - if a > 0.0 { - // Elliptic. - let alfa = 2.0 * x.acos(); - let mut beta = 2.0 * (lambda * lambda / a).sqrt().asin(); - if lambda < 0.0 { - beta = -beta; - } - let n_term = 2.0 * PI * (n as f64); - a * a.sqrt() * ((alfa - alfa.sin()) - (beta - beta.sin()) + n_term) * 0.5 - } else { - // Hyperbolic. - let alfa = 2.0 * x.acosh(); - let mut beta = 2.0 * (-lambda * lambda / a).sqrt().asinh(); - if lambda < 0.0 { - beta = -beta; - } - -a * (-a).sqrt() * ((beta - beta.sinh()) - (alfa - alfa.sinh())) * 0.5 - } -} - -/// Gauss hypergeometric series ₂F₁(3, 1; 5/2; z) used in Battin's -/// parabolic-limit time-of-flight expression. -fn hypergeometric_f(z: f64, tol: f64) -> f64 { - let mut sj = 1.0_f64; - let mut cj = 1.0_f64; - let mut j = 0i32; - loop { - let cj1 = - cj * (3.0 + j as f64) * (1.0 + j as f64) / (2.5 + j as f64) * z / (j as f64 + 1.0); - let sj1 = sj + cj1; - let err = cj1.abs(); - sj = sj1; - cj = cj1; - j += 1; - if err < tol || j > 1000 { - break; - } - } - sj -} - -// ───────────────────────────────────────────────────────────────────────────── -// Analytic derivatives T'(x), T''(x), T'''(x). Izzo Eq. 22. -// ───────────────────────────────────────────────────────────────────────────── - -fn derivatives(x: f64, t_x: f64, lambda: f64) -> (f64, f64, f64) { - let l2 = lambda * lambda; - let l3 = l2 * lambda; - let umx2 = 1.0 - x * x; - let y = (1.0 - l2 * umx2).sqrt(); - let y2 = y * y; - let y3 = y2 * y; - - let dt = (3.0 * t_x * x - 2.0 + 2.0 * l3 * x / y) / umx2; - let ddt = (3.0 * t_x + 5.0 * x * dt + 2.0 * (1.0 - l2) * l3 / y3) / umx2; - let dddt = (7.0 * x * ddt + 8.0 * dt - 6.0 * (1.0 - l2) * l2 * l3 * x / (y3 * y2)) / umx2; - (dt, ddt, dddt) -} - -// ───────────────────────────────────────────────────────────────────────────── -// T_min finder for N-rev existence test. -// ───────────────────────────────────────────────────────────────────────────── - -fn solve_t_min(lambda: f64, n: u32) -> f64 { - // Halley iteration on T'(x) = 0 starting at x = 0. - let mut x = 0.0_f64; - let mut t_x = tof_dimensionless(x, lambda, n); - for _ in 0..20 { - let (dt, ddt, dddt) = derivatives(x, t_x, lambda); - if dt == 0.0 { - break; - } - let denom = ddt * ddt - 0.5 * dt * dddt; - if denom == 0.0 { - break; - } - let x_new = x - dt * ddt / denom; - if (x - x_new).abs() < 1e-13 { - x = x_new; - break; - } - x = x_new; - t_x = tof_dimensionless(x, lambda, n); - } - x -} - -// ───────────────────────────────────────────────────────────────────────────── -// Tests -// ───────────────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - const MU_EARTH: f64 = 398_600.441_8; - const MU_SUN: f64 = 1.327_124_400_18e11; - - fn rel(actual: f64, expected: f64) -> f64 { - (actual - expected).abs() / expected.abs().max(1e-12) - } - - fn assert_close_vec(got: [f64; 3], expected: [f64; 3], tol: f64, label: &str) { - for i in 0..3 { - let diff = (got[i] - expected[i]).abs(); - assert!( - diff < tol, - "{label}[{i}]: got {} expected {} (|Δ| = {:.3e}, tol = {:.0e})", - got[i], - expected[i], - diff, - tol - ); - } - } - - // ─────── Input validation ─────── - - #[test] - fn rejects_zero_position() { - let err = solve_lambert([0.0; 3], [1.0, 0.0, 0.0], 1.0, 1.0, LambertBranch::Prograde) - .unwrap_err(); - assert_eq!(err, LambertError::ZeroPosition); - } - - #[test] - fn rejects_non_positive_tof() { - let err = solve_lambert( - [1.0, 0.0, 0.0], - [0.0, 1.0, 0.0], - -1.0, - 1.0, - LambertBranch::Prograde, - ) - .unwrap_err(); - assert_eq!(err, LambertError::NonPositiveTof(-1.0)); - } - - #[test] - fn rejects_non_positive_mu() { - let err = solve_lambert( - [1.0, 0.0, 0.0], - [0.0, 1.0, 0.0], - 1.0, - 0.0, - LambertBranch::Prograde, - ) - .unwrap_err(); - assert_eq!(err, LambertError::NonPositiveMu(0.0)); - } - - #[test] - fn collinear_positions_rejected() { - let err = solve_lambert( - [1.0, 0.0, 0.0], - [2.0, 0.0, 0.0], - 1.0, - 1.0, - LambertBranch::Prograde, - ) - .unwrap_err(); - assert_eq!(err, LambertError::Collinear); - } - - // ─────── Canonical 0-rev textbook cases ─────── - - /// Vallado, *Fundamentals of Astrodynamics and Applications* (4th ed.), - /// Example 7-5, p. 467. Earth-centred, prograde, single revolution. - #[test] - fn vallado_example_7_5() { - let r1 = [15945.34, 0.0, 0.0]; - let r2 = [12214.83899, 10249.46731, 0.0]; - let tof = 76.0 * 60.0; // 4 560 s - let sol = solve_lambert(r1, r2, tof, MU_EARTH, LambertBranch::Prograde).unwrap(); - // Reference values from Vallado Ex. 7-5. - assert_close_vec(sol.v1, [2.058913, 2.915965, 0.0], 5e-4, "v1"); - assert_close_vec(sol.v2, [-3.451565, 0.910315, 0.0], 5e-4, "v2"); - assert_eq!(sol.diagnostics.revolutions, 0); - } - - /// Curtis, *Orbital Mechanics for Engineering Students* (3rd ed.), - /// Example 5.2, p. 270. 3-D geocentric prograde transfer. - #[test] - fn curtis_example_5_2() { - let r1 = [5_000.0, 10_000.0, 2_100.0]; - let r2 = [-14_600.0, 2_500.0, 7_000.0]; - let tof = 3_600.0; - let sol = solve_lambert(r1, r2, tof, 398_600.0, LambertBranch::Prograde).unwrap(); - assert_close_vec(sol.v1, [-5.9925, 1.9254, 3.2456], 1e-3, "v1"); - assert_close_vec(sol.v2, [-3.3125, -4.1966, -0.38529], 1e-3, "v2"); - } - - /// Hohmann-style 200 km × 1000 km transfer (textbook two-body sanity - /// check): half a period of the transfer ellipse joins perigee - /// (`r1 = R⊕ + 200`) to apogee (`r2 = R⊕ + 1000`) on the opposite - /// side. v1 must equal the Hohmann perigee velocity to ≤ 1e-4 km/s. - #[test] - fn hohmann_geocentric_sanity() { - const RE: f64 = 6_378.137; - let rp = RE + 200.0; - let ra = RE + 1_000.0; - let a = 0.5 * (rp + ra); - let r1 = [rp, 0.0, 0.0]; - // Tiny in-plane offset breaks the exact-180° plane degeneracy - // without materially changing the Hohmann geometry. - let eps = 1e-4_f64; - let r2 = [-ra * eps.cos(), ra * eps.sin(), 0.0]; - let tof = PI * (a.powi(3) / MU_EARTH).sqrt(); - let sol = solve_lambert(r1, r2, tof, MU_EARTH, LambertBranch::Prograde).unwrap(); - - let vp_expected = (MU_EARTH * (2.0 / rp - 1.0 / a)).sqrt(); - let va_expected = (MU_EARTH * (2.0 / ra - 1.0 / a)).sqrt(); - assert!( - rel(sol.v1[1], vp_expected) < 1e-3, - "perigee: {:?} vs {}", - sol.v1, - vp_expected - ); - assert!( - rel(-sol.v2[1], va_expected) < 1e-3, - "apogee: {:?} vs {}", - sol.v2, - va_expected - ); - } - - // ─────── Self-consistency cases on closed-form Kepler orbits ────────── - // - // For each of these we (a) build a Keplerian state at a known true - // anomaly, (b) propagate analytically to a second true anomaly, - // (c) feed the two positions and the corresponding TOF to the solver, - // and (d) require the recovered velocities to match the analytic - // closed-form to better than 1 m/s. These act as multi-revolution - // and N-rev "textbook" cases — the propagation step is itself a - // textbook two-body Kepler solution (Battin §3.4, Vallado §2.2). - - fn kepler_state(a: f64, e: f64, nu: f64, mu: f64) -> ([f64; 3], [f64; 3]) { - // Perifocal frame: x̂ towards periapsis, ŷ ⟂ in plane. - let p = a * (1.0 - e * e); - let r = p / (1.0 + e * nu.cos()); - let r_pf = [r * nu.cos(), r * nu.sin(), 0.0]; - let coef = (mu / p).sqrt(); - let v_pf = [-coef * nu.sin(), coef * (e + nu.cos()), 0.0]; - (r_pf, v_pf) - } - - fn mean_anomaly_from_true(e: f64, nu: f64) -> f64 { - // Convert via eccentric anomaly E (elliptic only). - let cos_e = (e + nu.cos()) / (1.0 + e * nu.cos()); - let sin_e = (1.0 - e * e).sqrt() * nu.sin() / (1.0 + e * nu.cos()); - let big_e = sin_e.atan2(cos_e); - big_e - e * sin_e - } - - /// 0-rev round-trip on a moderately elliptic geocentric orbit. - #[test] - fn kepler_zero_rev_roundtrip() { - let mu = MU_EARTH; - let a = 12_000.0; - let e = 0.3; - let nu1 = 0.5_f64; // ~28.6° - let nu2 = 2.1_f64; // ~120° - let (r1, v1_ref) = kepler_state(a, e, nu1, mu); - let (r2, v2_ref) = kepler_state(a, e, nu2, mu); - let n = (mu / a.powi(3)).sqrt(); - let m1 = mean_anomaly_from_true(e, nu1); - let m2 = mean_anomaly_from_true(e, nu2); - let tof = (m2 - m1) / n; - - let sol = solve_lambert(r1, r2, tof, mu, LambertBranch::Prograde).unwrap(); - assert_close_vec(sol.v1, v1_ref, 1e-3, "v1"); - assert_close_vec(sol.v2, v2_ref, 1e-3, "v2"); - } - - /// Multi-revolution self-consistency: same orbit as above, but with - /// `tof` extended by exactly two full orbital periods. The propagated - /// (v1, v2) must match one of the two N=2 Lambert branches; on this - /// configuration the Right branch reproduces the original orbit - /// (semi-major axis = 12 000 km). - #[test] - fn kepler_two_rev_left_branch() { - let mu = MU_EARTH; - let a = 12_000.0; - let e = 0.3; - let nu1 = 0.5_f64; - let nu2 = 2.1_f64; - let (r1, v1_ref) = kepler_state(a, e, nu1, mu); - let (r2, v2_ref) = kepler_state(a, e, nu2, mu); - let n = (mu / a.powi(3)).sqrt(); - let period = 2.0 * PI / n; - let m1 = mean_anomaly_from_true(e, nu1); - let m2 = mean_anomaly_from_true(e, nu2); - let tof = (m2 - m1) / n + 2.0 * period; - - // Try both branches; one of them must reproduce the original - // orbit. (Which branch wins depends on Izzo's x-parametrisation - // sign convention for this particular geometry.) - let left = solve_lambert_n_rev( - r1, - r2, - tof, - mu, - LambertBranch::Prograde, - 2, - NRevBranch::Left, - ); - let right = solve_lambert_n_rev( - r1, - r2, - tof, - mu, - LambertBranch::Prograde, - 2, - NRevBranch::Right, - ); - - let matches = |sol: &LambertSolution| -> bool { - (0..3).all(|i| { - (sol.v1[i] - v1_ref[i]).abs() < 5e-3 && (sol.v2[i] - v2_ref[i]).abs() < 5e-3 - }) - }; - let ok = left.as_ref().map(matches).unwrap_or(false) - || right.as_ref().map(matches).unwrap_or(false); - assert!( - ok, - "neither N=2 branch reproduces the propagated state.\n left = {:?}\n right = {:?}\n vref = ({:?}, {:?})", - left, right, v1_ref, v2_ref - ); - // Both branches must at least report N = 2. - if let Ok(s) = &left { - assert_eq!(s.diagnostics.revolutions, 2); - } - if let Ok(s) = &right { - assert_eq!(s.diagnostics.revolutions, 2); - } - } - - /// N-rev existence guard: TOF too short for the requested N gives - /// [`LambertError::RevolutionsExceedNMax`]. - #[test] - fn n_rev_rejects_when_tof_too_short() { - let r1 = [15945.34, 0.0, 0.0]; - let r2 = [12214.83899, 10249.46731, 0.0]; - let err = solve_lambert_n_rev( - r1, - r2, - 4_560.0, // far below T_min for N = 1 - MU_EARTH, - LambertBranch::Prograde, - 1, - NRevBranch::Left, - ) - .unwrap_err(); - match err { - LambertError::RevolutionsExceedNMax { requested, .. } => { - assert_eq!(requested, 1); - } - other => panic!("unexpected error: {other:?}"), - } - } - - /// Heliocentric Earth → Mars Hohmann sanity check: with `a ≈ 1.262 AU` - /// and TOF = T/2, the perigee speed must match the analytic value to - /// ≤ 1e-3 km/s. (Heliocentric reuse of the Hohmann test for the - /// `examples/03_lambert_earth_to_mars.rs` worked example.) - #[test] - fn heliocentric_hohmann_sanity() { - const AU_KM: f64 = 1.495_978_707e8; - let r_e = AU_KM; - let r_m = 1.524 * AU_KM; - let a = 0.5 * (r_e + r_m); - let r1 = [r_e, 0.0, 0.0]; - let eps = 1e-4_f64; - let r2 = [-r_m * eps.cos(), r_m * eps.sin(), 0.0]; - let tof = PI * (a.powi(3) / MU_SUN).sqrt(); - let sol = solve_lambert(r1, r2, tof, MU_SUN, LambertBranch::Prograde).unwrap(); - - let vp_expected = (MU_SUN * (2.0 / r_e - 1.0 / a)).sqrt(); - let va_expected = (MU_SUN * (2.0 / r_m - 1.0 / a)).sqrt(); - assert!(rel(sol.v1[1], vp_expected) < 1e-3); - assert!(rel(-sol.v2[1], va_expected) < 1e-3); - } -} diff --git a/src/lambert/mod.rs b/src/lambert/mod.rs deleted file mode 100644 index 32e71f7..0000000 --- a/src/lambert/mod.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! `spod::lambert` — typed 0/N-revolution Lambert solver. -//! -//! ## Overview -//! -//! This module solves Lambert's two-point boundary-value problem (find -//! the conic transfer connecting two position vectors in a prescribed -//! time of flight) using Izzo's 2014 reformulation with third-order -//! Householder iteration. Both the single-revolution branch (`N = 0`) -//! and the multi-revolution branches (`N ≥ 1`, two solutions per `N`) -//! are implemented. -//! -//! ## Public API -//! -//! - [`lambert`] / [`lambert_n_rev`] — **typed** entry-points operating -//! on [`siderust::affn::cartesian::Position`] / [`siderust::affn::cartesian::Velocity`] -//! and [`siderust::qtty::Second`]. These are the documented public interface. -//! - [`solve_lambert`] / [`solve_lambert_n_rev`] — low-level numeric -//! backend on plain `[f64; 3]` km / km/s arrays. Useful for FFI and -//! for inner loops in mission-design search where allocating typed -//! wrappers per evaluation is not desired. -//! - [`LambertError`] — single, unified error enum, re-exported here. -//! -//! ## Examples -//! -//! Earth-centred prograde transfer (Vallado *Fundamentals of Astrodynamics* -//! Ex. 7-5, 4th ed., p. 467): -//! -//! ``` -//! use siderust::affn::cartesian::Position; -//! use siderust::affn::centers::ReferenceCenter; -//! use siderust::affn::frames::ICRS; -//! use siderust::qtty::GravitationalParameter; -//! use siderust::qtty::unit::Kilometer; -//! use siderust::qtty::Second; -//! use spod::lambert::{lambert, LambertBranch}; -//! -//! let r1 = Position::<(), ICRS, Kilometer>::new(15945.34, 0.0, 0.0); -//! let r2 = Position::<(), ICRS, Kilometer>::new(12214.83899, 10249.46731, 0.0); -//! let tof = Second::new(4_560.0); -//! let mu = GravitationalParameter::new(398_600.4418); -//! -//! let sol = lambert(r1, r2, tof, mu, LambertBranch::Prograde).unwrap(); -//! assert!((sol.v1.x().value() - 2.058913).abs() < 1e-3); -//! ``` -//! -//! See `examples/03_lambert_earth_to_mars.rs` (workspace root) for a -//! heliocentric Earth → Mars transfer worked end-to-end. -//! -//! ## Out of scope (kept in higher-level crates) -//! -//! * Mission-design optimisation, porkchop search, and transfer-window -//! enumeration belong in higher-level mission-design tooling. -//! * Frame / center bookkeeping at the algorithm boundary beyond what -//! the typed entry-points provide. -//! * Any POD coupling. -//! -//! ## References -//! -//! - Izzo, D. (2014). *Revisiting Lambert's Problem*. Celest. Mech. Dyn. -//! Astron., 121(1):1–15. -//! - Battin, R. H. (1999). *An Introduction to the Mathematics and -//! Methods of Astrodynamics* (Rev. ed.). AIAA. -//! - Vallado, D. A. (2013). *Fundamentals of Astrodynamics and -//! Applications* (4th ed.). Microcosm Press. - -#![forbid(unsafe_code)] - -mod error; -mod izzo; -mod typed; - -pub use error::LambertError; -pub use izzo::{ - solve_lambert, solve_lambert_n_rev, LambertBranch, LambertDiagnostics, LambertSolution, - NRevBranch, -}; -pub use typed::{lambert, lambert_n_rev, TypedLambertSolution}; diff --git a/src/lambert/typed.rs b/src/lambert/typed.rs deleted file mode 100644 index 39f6180..0000000 --- a/src/lambert/typed.rs +++ /dev/null @@ -1,269 +0,0 @@ -//! Typed entry-points for Lambert's problem. -//! -//! These functions are the **public API** of the crate. They operate on -//! `affn` typed positions and `affn` typed velocities, with the time of -//! flight expressed as a `siderust::qtty::Second` (chosen over -//! [`siderust::tempoch::Period`](https://docs.rs/tempoch) because Lambert ToF is -//! semantically a *duration*, not a half-open instant interval — see the -//! design note at the bottom of this module). -//! -//! For interoperability with raw-array callers (mission-design search -//! loops, FFI bridges, ...) the numeric kernel is exposed via -//! [`crate::solve_lambert`] / [`crate::solve_lambert_n_rev`]. - -use siderust::affn::cartesian::{Position, Velocity}; -use siderust::affn::centers::ReferenceCenter; -use siderust::affn::frames::ReferenceFrame; -use siderust::qtty::unit::Kilometer; -use siderust::qtty::Second; -use siderust::qtty::{GravitationalParameter, KmPerSecond}; - -use super::error::LambertError; -use super::izzo::{ - solve_lambert as solve_lambert_arr, solve_lambert_n_rev as solve_lambert_n_rev_arr, - LambertBranch, LambertDiagnostics, LambertSolution, NRevBranch, -}; - -/// Typed Lambert solution — departure / arrival velocities plus diagnostics. -/// -/// Velocities are tagged with the same reference frame `F` as the input -/// positions. They are *free vectors*, so no [`ReferenceCenter`] tag is -/// attached (cf. `siderust::affn::cartesian::Velocity = Vector`). -/// -/// # Examples -/// -/// ``` -/// use spod::lambert::TypedLambertSolution; -/// // The struct just bundles the two velocities and diagnostics; see -/// // [`spod::lambert::lambert`] for end-to-end usage. -/// fn assert_solution_layout( -/// sol: TypedLambertSolution, -/// ) { -/// let _v1 = sol.v1; -/// let _v2 = sol.v2; -/// let _diag = sol.diagnostics; -/// } -/// ``` -#[derive(Debug, Clone, Copy)] -pub struct TypedLambertSolution { - /// Departure velocity at `r1`, in the same frame as the input positions. - pub v1: Velocity, - /// Arrival velocity at `r2`, in the same frame as the input positions. - pub v2: Velocity, - /// Householder iteration diagnostics from the underlying numeric solver. - pub diagnostics: LambertDiagnostics, -} - -/// Solve Lambert's problem (single revolution) on typed inputs. -/// -/// `r1`, `r2` are typed [`Position`]s in the same reference frame `F` -/// and reference center `C`; `tof` is a [`siderust::qtty::Second`] duration; `mu` -/// is a typed [`GravitationalParameter`] (km³/s²). The function returns -/// the corresponding free-vector velocities at `r1` and `r2`. -/// -/// The two endpoints **must** share both the frame and the center — -/// this is enforced statically through the type signature, mirroring -/// the affine-space invariant that "subtracting two `Position`s only -/// makes sense within a single frame and centre". -/// -/// # Errors -/// -/// Wraps the underlying [`LambertError`] taxonomy unchanged. -/// -/// # Examples -/// -/// ``` -/// // no extra center import — () implements ReferenceCenter -/// use siderust::affn::frames::ICRS; -/// use siderust::affn::cartesian::Position; -/// use siderust::qtty::GravitationalParameter; -/// use siderust::qtty::unit::Kilometer; -/// use siderust::qtty::{Quantity, Second}; -/// use spod::lambert::{lambert, LambertBranch}; -/// -/// let r1 = Position::<(), ICRS, Kilometer>::new(15945.34, 0.0, 0.0); -/// let r2 = Position::<(), ICRS, Kilometer>::new(12214.83899, 10249.46731, 0.0); -/// let tof = Second::new(4_560.0); -/// let mu = GravitationalParameter::new(398_600.4418); -/// -/// let sol = lambert(r1, r2, tof, mu, LambertBranch::Prograde).unwrap(); -/// assert!((sol.v1.x().value() - 2.058913).abs() < 1e-3); -/// ``` -pub fn lambert( - r1: Position, - r2: Position, - tof: Second, - mu: GravitationalParameter, - branch: LambertBranch, -) -> Result, LambertError> -where - C: ReferenceCenter, - F: ReferenceFrame, -{ - let LambertSolution { - v1, - v2, - diagnostics, - } = solve_lambert_arr( - position_to_array(&r1), - position_to_array(&r2), - tof.value(), - mu.value(), - branch, - )?; - - Ok(TypedLambertSolution { - v1: Velocity::::new(v1[0], v1[1], v1[2]), - v2: Velocity::::new(v2[0], v2[1], v2[2]), - diagnostics, - }) -} - -/// Solve Lambert's problem with `N ≥ 1` complete revolutions, on typed inputs. -/// -/// `side` selects between the *low-energy* (`Left`) and *high-energy* -/// (`Right`) Householder roots that bracket the time-of-flight minimum -/// for this `N`. -/// -/// # Errors -/// -/// Wraps the underlying [`LambertError`] variants. In particular, -/// requesting `N` beyond the geometrically admissible maximum returns -/// [`LambertError::RevolutionsExceedNMax`] with the maximum that *is* -/// admissible at the supplied `tof`. -/// -/// # Examples -/// -/// ``` -/// // no extra center import — () implements ReferenceCenter -/// use siderust::affn::frames::ICRS; -/// use siderust::affn::cartesian::Position; -/// use siderust::qtty::GravitationalParameter; -/// use siderust::qtty::unit::Kilometer; -/// use siderust::qtty::Second; -/// use spod::lambert::{lambert_n_rev, LambertBranch, NRevBranch}; -/// -/// let r1 = Position::<(), ICRS, Kilometer>::new(15945.34, 0.0, 0.0); -/// let r2 = Position::<(), ICRS, Kilometer>::new(12214.83899, 10249.46731, 0.0); -/// let tof = Second::new(21_600.0); // 6 h -/// let mu = GravitationalParameter::new(398_600.4418); -/// -/// let sol = lambert_n_rev( -/// r1, r2, tof, mu, -/// LambertBranch::Prograde, 1, NRevBranch::Left, -/// ) -/// .unwrap(); -/// assert_eq!(sol.diagnostics.revolutions, 1); -/// ``` -pub fn lambert_n_rev( - r1: Position, - r2: Position, - tof: Second, - mu: GravitationalParameter, - branch: LambertBranch, - revolutions: u32, - side: NRevBranch, -) -> Result, LambertError> -where - C: ReferenceCenter, - F: ReferenceFrame, -{ - let LambertSolution { - v1, - v2, - diagnostics, - } = solve_lambert_n_rev_arr( - position_to_array(&r1), - position_to_array(&r2), - tof.value(), - mu.value(), - branch, - revolutions, - side, - )?; - - Ok(TypedLambertSolution { - v1: Velocity::::new(v1[0], v1[1], v1[2]), - v2: Velocity::::new(v2[0], v2[1], v2[2]), - diagnostics, - }) -} - -fn position_to_array(p: &Position) -> [f64; 3] -where - C: ReferenceCenter, - F: ReferenceFrame, -{ - [p.x().value(), p.y().value(), p.z().value()] -} - -// ───────────────────────────────────────────────────────────────────────────── -// Design note: Period vs. Second for the time-of-flight argument -// ───────────────────────────────────────────────────────────────────────────── -// -// `siderust::tempoch::Period = Interval>` is a *half-open instant -// interval*, i.e. `[start, end)` on a particular time scale. Lambert's -// problem is invariant under translation of the boundary epochs: only -// the duration `Δt = end − start` enters the dynamics. Forcing callers -// to attach a time scale to that duration would (a) require a scale -// choice the algorithm does not need, and (b) break callers that work -// in elapsed-time form (mission-design grids, porkchop searches). -// -// Conversely, `siderust::qtty::Second` is exactly the dimensioned duration the -// algorithm needs: a `Quantity` with no axis tag. Callers -// holding a `Period` can trivially convert via `period.end - -// period.start`, which already yields a `Second` per -// `siderust::tempoch::time::Time::Sub`. - -#[cfg(test)] -mod tests { - use super::*; - // no extra center import — () implements ReferenceCenter - use siderust::affn::frames::ICRS; - - #[test] - fn typed_zero_rev_matches_array_kernel() { - let r1 = Position::<(), ICRS, Kilometer>::new(15945.34, 0.0, 0.0); - let r2 = Position::<(), ICRS, Kilometer>::new(12214.83899, 10249.46731, 0.0); - let tof = Second::new(4_560.0); - let mu = GravitationalParameter::new(398_600.441_8); - - let typed = lambert(r1, r2, tof, mu, LambertBranch::Prograde).unwrap(); - let raw = solve_lambert_arr( - [15945.34, 0.0, 0.0], - [12214.83899, 10249.46731, 0.0], - 4_560.0, - 398_600.441_8, - LambertBranch::Prograde, - ) - .unwrap(); - for i in 0..3 { - assert!((typed.v1.as_array()[i].value() - raw.v1[i]).abs() < 1e-12); - assert!((typed.v2.as_array()[i].value() - raw.v2[i]).abs() < 1e-12); - } - } - - #[test] - fn typed_n_rev_propagates_error() { - let r1 = Position::<(), ICRS, Kilometer>::new(15945.34, 0.0, 0.0); - let r2 = Position::<(), ICRS, Kilometer>::new(12214.83899, 10249.46731, 0.0); - let tof = Second::new(4_560.0); - let mu = GravitationalParameter::new(398_600.441_8); - let err = lambert_n_rev( - r1, - r2, - tof, - mu, - LambertBranch::Prograde, - 1, - NRevBranch::Left, - ) - .unwrap_err(); - match err { - LambertError::RevolutionsExceedNMax { requested, .. } => { - assert_eq!(requested, 1); - } - other => panic!("unexpected error: {other:?}"), - } - } -} diff --git a/src/lib.rs b/src/lib.rs index a8488d2..95ee443 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,12 +12,7 @@ //! //! | Module | Contents | //! |--------|----------| -//! | [`tle`] | TLE / 3LE / OMM parser and writer | -//! | [`lambert`] | Typed Lambert two-point boundary-value solver | -//! | [`sgp4`] | SGP4/SDP4 propagator producing typed TEME states | -//! | [`spice`] | DAF/SPK kernel reader and SPICE ephemeris provider | -//! | [`io`] | File format parsers/writers: SP3, RINEX, ANTEX, OEM, EOP, … | -//! | [`service`] | Pipeline orchestration and job runner | +//! | [`service`] | Pipeline orchestration, transport, and job runner | //! //! ## Binary entry-points //! @@ -26,9 +21,4 @@ #![forbid(unsafe_code)] -pub mod io; -pub mod lambert; pub mod service; -pub mod sgp4; -pub mod spice; -pub mod tle; diff --git a/src/service/mod.rs b/src/service/mod.rs index 611f8f7..b561461 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -36,7 +36,6 @@ pub mod config; pub mod pipeline; -pub mod providers; pub mod rest; pub mod runner; pub mod synth; diff --git a/src/service/pipeline.rs b/src/service/pipeline.rs index 444e733..9dbcf2f 100644 --- a/src/service/pipeline.rs +++ b/src/service/pipeline.rs @@ -89,12 +89,6 @@ pub enum PipelineError { /// Estimation failure. #[error("estimation: {0}")] Estimation(#[from] NonlinearError), - /// SP3 write failure. - #[error("sp3: {0}")] - Sp3(#[from] crate::io::sp3::Sp3Error), - /// I/O umbrella errors from products. - #[error("io: {0}")] - PodIo(#[from] crate::io::PodIoError), } /// Outcome of an MVP-1 pipeline run. diff --git a/src/service/providers.rs b/src/service/providers.rs deleted file mode 100644 index 3c1772d..0000000 --- a/src/service/providers.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! Service-owned adapters for external scientific data sources. - -use std::error::Error; - -/// Minimal ephemeris query interface used by service adapters. -/// -/// The trait is intentionally kept at the service boundary: reusable POD -/// models and algorithms use the provider traits in `siderust::pod`. -pub trait EphemerisProvider { - /// Provider-specific state representation. - type State; - /// Provider-specific query error. - type Error: Error + Send + Sync + 'static; - - /// Return a body state at TDB seconds since J2000. - fn state(&self, body_naif_id: i32, epoch_seconds_tdb: f64) -> Result; -} diff --git a/src/sgp4/elements.rs b/src/sgp4/elements.rs deleted file mode 100644 index b9372cc..0000000 --- a/src/sgp4/elements.rs +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Bridge from [`crate::tle::Tle`] to the SGP4 input record. -//! -//! The SGP4 backend ([`sgp4` crate](https://crates.io/crates/sgp4)) consumes -//! its own [`sgp4::Elements`] struct. We treat this purely as an internal -//! record format — callers always interact with the typed -//! [`crate::tle::Tle`]. - -use crate::tle::{Classification as TleClass, Tle}; -use chrono::Datelike; -use sgp4::Elements; - -use super::Sgp4Error; - -/// Convert a typed [`Tle`] into the dimensionless record consumed by the SGP4 -/// backend. -/// -/// The conversion preserves all SGP4-relevant fields (epoch, mean elements, -/// drag terms, classification, international designator) and is lossless in -/// both directions for the subset SGP4 uses. -pub(crate) fn tle_to_elements(tle: &Tle) -> Result { - let dt_utc = tle - .epoch - .try_to_chrono() - .map_err(|e| Sgp4Error::TimeConversion(format!("TLE epoch → chrono failed: {e:?}")))?; - // SGP4 wants a NaiveDateTime in UTC. - let datetime = dt_utc.naive_utc(); - // Sanity-check the year: AFSPC epoch encoding loses precision before 1957. - if datetime.year() < 1950 { - return Err(Sgp4Error::InvalidEpoch("year < 1950 not representable")); - } - - let classification = match tle.classification { - TleClass::Unclassified => sgp4::Classification::Unclassified, - TleClass::Classified => sgp4::Classification::Classified, - TleClass::Secret => sgp4::Classification::Secret, - }; - - Ok(Elements { - object_name: tle.name.clone(), - international_designator: Some(tle.international_designator.0.clone()), - norad_id: u64::from(tle.norad_id.0), - classification, - datetime, - mean_motion_dot: tle.mean_motion_dot, - mean_motion_ddot: tle.mean_motion_ddot, - drag_term: tle.bstar, - element_set_number: u64::from(tle.element_set_number), - inclination: tle.inclination.value(), - right_ascension: tle.raan.value(), - eccentricity: tle.eccentricity, - argument_of_perigee: tle.argument_of_perigee.value(), - mean_anomaly: tle.mean_anomaly.value(), - mean_motion: tle.mean_motion.value(), - revolution_number: u64::from(tle.revolution_number_at_epoch), - ephemeris_type: 0, - }) -} diff --git a/src/sgp4/error.rs b/src/sgp4/error.rs deleted file mode 100644 index a2dddd5..0000000 --- a/src/sgp4/error.rs +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Unified error type for the SGP4/SDP4 propagator. - -use thiserror::Error; - -/// Errors produced while constructing or evaluating an [`Sgp4Propagator`](crate::sgp4::Sgp4Propagator). -/// -/// The variants split cleanly into three groups: -/// -/// * **Initialisation** ([`InvalidElements`](Sgp4Error::InvalidElements), -/// [`InvalidEpoch`](Sgp4Error::InvalidEpoch)) — the TLE record is structurally -/// well-formed but its mean elements cannot be turned into SGP4 secular / -/// periodic constants (e.g. eccentricity outside `[0, 1)`, sub-orbital mean -/// motion, malformed UTC epoch). -/// * **Propagation** ([`Propagation`](Sgp4Error::Propagation)) — the underlying -/// SGP4/SDP4 polynomial diverged at the requested epoch (typical for very -/// large `|Δt|` on decayed objects, or when Lyddane choice fails on a -/// geostationary regime — see Vallado 2006). -/// * **Time conversion** ([`TimeConversion`](Sgp4Error::TimeConversion)) — the -/// target epoch supplied as a [`siderust::tempoch::JulianDate`] cannot be -/// represented as a calendar instant, e.g. it falls outside the leap-second -/// table covered by the active [`siderust::tempoch::TimeContext`]. -/// -/// # Examples -/// -/// ``` -/// use spod::sgp4::Sgp4Error; -/// let e = Sgp4Error::InvalidEpoch("year out of range"); -/// assert!(matches!(e, Sgp4Error::InvalidEpoch(_))); -/// assert!(e.to_string().contains("epoch")); -/// ``` -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum Sgp4Error { - /// The TLE mean elements failed SGP4 initialisation. The `details` payload - /// is the human-readable diagnostic produced by the underlying propagator. - #[error("invalid TLE elements for SGP4: {details}")] - InvalidElements { - /// Free-form diagnostic from the SGP4 initialiser. - details: String, - }, - - /// The TLE epoch could not be expressed as a calendar instant. - #[error("invalid TLE epoch: {0}")] - InvalidEpoch(&'static str), - - /// The SGP4/SDP4 polynomial returned an error at the requested epoch. - #[error("SGP4 propagation failed: {details}")] - Propagation { - /// Free-form diagnostic from the SGP4 evaluator. - details: String, - }, - - /// Conversion between [`siderust::tempoch::JulianDate`] and a calendar instant failed. - #[error("UTC time conversion failed: {0}")] - TimeConversion(String), -} diff --git a/src/sgp4/mod.rs b/src/sgp4/mod.rs deleted file mode 100644 index 9033765..0000000 --- a/src/sgp4/mod.rs +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! `spod::sgp4` — strongly typed SGP4 / SDP4 propagator producing TEME -//! Cartesian states. -//! -//! ## What this crate is -//! -//! * A type-preserving wrapper around the public-domain SGP4/SDP4 mean-element -//! propagator. Inputs are typed [`crate::tle::Tle`] records and target -//! epochs are typed [`siderust::tempoch::JulianDate`]. Outputs are -//! typed [`TemeState`] values: a geocentric **TEME** position in -//! kilometres and a velocity in km·s⁻¹. -//! * Validated against Vallado's "SGP4-VER" reference test set: the -//! `vallado_sgp4ver` integration test reproduces position to better than -//! `1e-6 km` and velocity to better than `1e-9 km·s⁻¹` over a curated -//! subset of ≥ 5 satellites and ≥ 3 epochs each. -//! -//! ## What this crate is *not* -//! -//! * It is not a TLE parser. Use [`crate::tle`] for that. -//! * It does not perform TEME → ITRF / GCRF rotations. Use -//! `siderust::coordinates::transform::providers::frames_teme` once Earth -//! Orientation Parameters are supplied. -//! * It does not implement orbit determination, force models, or estimation; -//! those live in the `spod-*` crates. -//! -//! ## Backend -//! -//! The numerical core is the pure-Rust [`sgp4`](https://crates.io/crates/sgp4) -//! crate (MIT-licensed, port of Vallado's reference implementation). It is -//! pulled in as a private dependency and never appears in this crate's -//! public surface — callers see only typed `affn` / `qtty` / `tempoch` / -//! `siderust` values. -//! -//! ## Example -//! -//! ``` -//! use spod::sgp4::Sgp4Propagator; -//! use spod::tle::parse_3le; -//! -//! let tle = parse_3le( -//! "ISS (ZARYA)", -//! "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", -//! "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", -//! ).unwrap(); -//! let prop = Sgp4Propagator::from_tle(&tle).unwrap(); -//! let state = prop.propagate_minutes(0.0).unwrap(); -//! assert!(state.position().distance().value() > 6_500.0); -//! assert!(state.velocity().magnitude().value() > 5.0); -//! ``` -//! -//! See `examples/04_sgp4_from_tle.rs` for a worked end-to-end example. - -#![forbid(unsafe_code)] - -mod elements; -mod error; -mod propagator; -mod state; - -pub use error::Sgp4Error; -pub use propagator::{GravityModel, Sgp4Propagator}; -pub use state::{KilometerPerSecond, TemePositionKm, TemeState, TemeVelocityKmPerSec}; diff --git a/src/sgp4/propagator.rs b/src/sgp4/propagator.rs deleted file mode 100644 index 27b675b..0000000 --- a/src/sgp4/propagator.rs +++ /dev/null @@ -1,346 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! SGP4/SDP4 propagator with strongly typed I/O. -//! -//! See the crate-level documentation for an overview. The propagator is a -//! thin, type-preserving wrapper around the public-domain SGP4 reference -//! implementation distributed via the [`sgp4` crate](https://crates.io/crates/sgp4). -//! The wrapper takes responsibility for: -//! -//! * mapping [`crate::tle::Tle`] into the SGP4 backend's element record, -//! * converting [`siderust::tempoch::JulianDate`] target epochs into -//! minutes-since-epoch (the propagator's natural argument), and -//! * tagging the resulting Cartesian arrays with their TEME / geocentric / -//! km / km·s⁻¹ types from `affn`, `siderust`, and `qtty`. - -use crate::tle::Tle; -use sgp4::{Constants, Elements, Geopotential, MinutesSinceEpoch}; -use siderust::tempoch::{JulianDate, UTC}; - -use super::elements::tle_to_elements; -use super::state::TemeState; -use super::Sgp4Error; - -/// Earth gravity / sidereal-time model selector for the SGP4/SDP4 polynomial. -/// -/// SGP4 was historically defined against the WGS-72 geopotential constants -/// together with the AFSPC sidereal-time convention. That combination -/// (`Wgs72`) is the **bit-exact** match for Vallado & Crawford's published -/// `tcppver.out` reference outputs and is the default here. -/// -/// `Wgs72Iau` keeps the WGS-72 geopotential but switches to the IAU -/// sidereal-time formula — slightly more accurate in absolute terms but -/// drifts a few millimetres away from the Vallado reference. -/// -/// `Wgs84` swaps in the WGS-84 geopotential constants together with the -/// IAU sidereal-time formula. New code that does not need bit-exact -/// reproduction of legacy AFSPC outputs should pick this. -/// -/// # Examples -/// -/// ``` -/// use spod::sgp4::GravityModel; -/// // The default matches Vallado's `tcppver.out` reference outputs exactly. -/// assert!(matches!(GravityModel::default(), GravityModel::Wgs72)); -/// ``` -#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] -pub enum GravityModel { - /// WGS-72 geopotential with AFSPC sidereal-time convention. Default; - /// reproduces Vallado's `tcppver.out` reference outputs to numerical - /// noise (≤ 1e-6 km / ≤ 1e-9 km·s⁻¹). - #[default] - Wgs72, - /// WGS-72 geopotential with the IAU sidereal-time formula. Use when - /// you want the WGS-72 mean elements but the IAU time convention. - Wgs72Iau, - /// WGS-84 geopotential with the IAU sidereal-time formula. Modern - /// default for new pipelines that don't need AFSPC bit-exactness. - Wgs84, -} - -impl GravityModel { - fn as_geopotential(self) -> Geopotential { - match self { - GravityModel::Wgs72 | GravityModel::Wgs72Iau => sgp4::WGS72, - GravityModel::Wgs84 => sgp4::WGS84, - } - } - - fn afspc_compat(self) -> bool { - matches!(self, GravityModel::Wgs72) - } -} - -/// Strongly typed SGP4/SDP4 propagator. -/// -/// Construct with [`Sgp4Propagator::from_tle`] (default WGS-72 gravity) or -/// [`Sgp4Propagator::from_tle_with_model`] (explicit gravity model). -/// Propagate with [`Sgp4Propagator::propagate_at`] (UTC Julian date) or -/// [`Sgp4Propagator::propagate_minutes`] (offset from the TLE epoch). -/// -/// The propagator owns its initialised `Constants` table, so propagation is -/// allocation-free and thread-safe (the type is `Send + Sync`). -/// -/// # Examples -/// -/// ``` -/// use spod::sgp4::{GravityModel, Sgp4Propagator}; -/// use spod::tle::parse_3le; -/// -/// let tle = parse_3le( -/// "ISS (ZARYA)", -/// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", -/// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", -/// ).unwrap(); -/// let prop = Sgp4Propagator::from_tle_with_model(&tle, GravityModel::Wgs72).unwrap(); -/// let s = prop.propagate_minutes(0.0).unwrap(); -/// assert!(s.position().distance().value() > 6_500.0); -/// ``` -#[derive(Clone, Debug)] -pub struct Sgp4Propagator { - constants: Constants, - elements: Elements, - model: GravityModel, - epoch_jd_utc: JulianDate, -} - -impl Sgp4Propagator { - /// Initialise the propagator from a [`Tle`] using the default - /// (WGS-72, Vallado 2006) gravity model. - /// - /// # Errors - /// - /// Returns [`Sgp4Error::InvalidElements`] if the SGP4 initialiser - /// rejects the mean elements (e.g. negative mean motion, eccentricity - /// outside `[0, 1)`), or [`Sgp4Error::InvalidEpoch`] / - /// [`Sgp4Error::TimeConversion`] if the TLE epoch cannot be expressed - /// as a calendar instant. - /// - /// # Examples - /// - /// ``` - /// use spod::sgp4::Sgp4Propagator; - /// use spod::tle::parse_3le; - /// let tle = parse_3le( - /// "ISS (ZARYA)", - /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", - /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", - /// ).unwrap(); - /// let _ = Sgp4Propagator::from_tle(&tle).unwrap(); - /// ``` - pub fn from_tle(tle: &Tle) -> Result { - Self::from_tle_with_model(tle, GravityModel::default()) - } - - /// Initialise the propagator from a [`Tle`] under an explicit - /// [`GravityModel`]. - /// - /// # Examples - /// - /// ``` - /// use spod::sgp4::{GravityModel, Sgp4Propagator}; - /// use spod::tle::parse_3le; - /// let tle = parse_3le( - /// "ISS (ZARYA)", - /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", - /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", - /// ).unwrap(); - /// let _ = Sgp4Propagator::from_tle_with_model(&tle, GravityModel::Wgs84).unwrap(); - /// ``` - pub fn from_tle_with_model(tle: &Tle, model: GravityModel) -> Result { - let elements = tle_to_elements(tle)?; - let constants = match model { - GravityModel::Wgs72 => Constants::from_elements_afspc_compatibility_mode(&elements) - .map_err(|e| Sgp4Error::InvalidElements { - details: format!("AFSPC init: {e:?}"), - })?, - GravityModel::Wgs72Iau => { - Constants::from_elements(&elements).map_err(|e| Sgp4Error::InvalidElements { - details: format!("IAU init (WGS-72): {e:?}"), - })? - } - GravityModel::Wgs84 => { - let geop = model.as_geopotential(); - let orbit = sgp4::Orbit::from_kozai_elements( - &geop, - elements.inclination * core::f64::consts::PI / 180.0, - elements.right_ascension * core::f64::consts::PI / 180.0, - elements.eccentricity, - elements.argument_of_perigee * core::f64::consts::PI / 180.0, - elements.mean_anomaly * core::f64::consts::PI / 180.0, - elements.mean_motion * 2.0 * core::f64::consts::PI / 1440.0, - ) - .map_err(|e| Sgp4Error::InvalidElements { - details: format!("Kozai elements: {e:?}"), - })?; - Constants::new( - geop, - sgp4::iau_epoch_to_sidereal_time, - sgp4::julian_years_since_j2000(&elements.datetime), - elements.drag_term, - orbit, - ) - .map_err(|e| Sgp4Error::InvalidElements { - details: format!("Constants::new: {e:?}"), - })? - } - }; - let epoch_jd_utc: JulianDate = tle.epoch.to::(); - Ok(Self { - constants, - elements, - model, - epoch_jd_utc, - }) - } - - /// Return the gravity model in use. - /// - /// # Examples - /// - /// ``` - /// use spod::sgp4::{GravityModel, Sgp4Propagator}; - /// use spod::tle::parse_3le; - /// let tle = parse_3le( - /// "ISS (ZARYA)", - /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", - /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", - /// ).unwrap(); - /// let p = Sgp4Propagator::from_tle(&tle).unwrap(); - /// assert_eq!(p.gravity_model(), GravityModel::Wgs72); - /// ``` - pub fn gravity_model(&self) -> GravityModel { - self.model - } - - /// UTC Julian-date epoch of the TLE this propagator was initialised - /// from. - /// - /// # Examples - /// - /// ``` - /// use spod::sgp4::Sgp4Propagator; - /// use spod::tle::parse_3le; - /// let tle = parse_3le( - /// "ISS (ZARYA)", - /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", - /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", - /// ).unwrap(); - /// let p = Sgp4Propagator::from_tle(&tle).unwrap(); - /// assert!(p.epoch_jd_utc().raw().value() > 2_454_700.0); - /// ``` - pub fn epoch_jd_utc(&self) -> JulianDate { - self.epoch_jd_utc - } - - /// Propagate to a UTC Julian-date epoch. - /// - /// The result is a typed [`TemeState`] valid at exactly the supplied - /// epoch. The conversion to minutes-since-epoch goes through `tempoch` - /// to honour leap seconds correctly. - /// - /// # Errors - /// - /// * [`Sgp4Error::Propagation`] — the SGP4 polynomial diverged at - /// the requested epoch. - /// * [`Sgp4Error::TimeConversion`] — the supplied UTC instant cannot - /// be related to the TLE epoch (e.g. a non-finite Julian date). - /// - /// # Examples - /// - /// ``` - /// use spod::sgp4::Sgp4Propagator; - /// use spod::tle::parse_3le; - /// let tle = parse_3le( - /// "ISS (ZARYA)", - /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", - /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", - /// ).unwrap(); - /// let p = Sgp4Propagator::from_tle(&tle).unwrap(); - /// let target = p.epoch_jd_utc(); // propagate to TLE epoch itself - /// let s = p.propagate_at(target).unwrap(); - /// assert!(s.position().distance().value() > 6_500.0); - /// ``` - pub fn propagate_at(&self, target_jd_utc: JulianDate) -> Result { - let t_jd = target_jd_utc.raw().value(); - let epoch_jd = self.epoch_jd_utc.raw().value(); - if !t_jd.is_finite() { - return Err(Sgp4Error::TimeConversion( - "target Julian date is non-finite".into(), - )); - } - let dt_minutes = (t_jd - epoch_jd) * 1_440.0; - self.propagate_internal(dt_minutes, target_jd_utc) - } - - /// Propagate by an offset, in minutes, from the TLE epoch. - /// - /// This is the natural argument to SGP4 and exactly mirrors the - /// reference test vectors distributed with Vallado's "SGP4-VER" set. - /// - /// # Errors - /// - /// [`Sgp4Error::Propagation`] if the polynomial diverges, or - /// [`Sgp4Error::TimeConversion`] if the resulting epoch cannot be - /// represented as a UTC Julian date. - /// - /// # Examples - /// - /// ``` - /// use spod::sgp4::Sgp4Propagator; - /// use spod::tle::parse_3le; - /// let tle = parse_3le( - /// "ISS (ZARYA)", - /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", - /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", - /// ).unwrap(); - /// let p = Sgp4Propagator::from_tle(&tle).unwrap(); - /// let s_now = p.propagate_minutes(0.0).unwrap(); - /// let s_later = p.propagate_minutes(90.0).unwrap(); - /// assert!(s_now.position() != s_later.position()); - /// ``` - pub fn propagate_minutes(&self, dt_minutes: f64) -> Result { - if !dt_minutes.is_finite() { - return Err(Sgp4Error::TimeConversion( - "minutes offset is non-finite".into(), - )); - } - let target = jd_offset_minutes(self.epoch_jd_utc, dt_minutes); - self.propagate_internal(dt_minutes, target) - } - - fn propagate_internal( - &self, - dt_minutes: f64, - target_jd_utc: JulianDate, - ) -> Result { - let prediction = if self.model.afspc_compat() { - self.constants - .propagate_afspc_compatibility_mode(MinutesSinceEpoch(dt_minutes)) - } else { - self.constants.propagate(MinutesSinceEpoch(dt_minutes)) - } - .map_err(|e| Sgp4Error::Propagation { - details: format!("{e:?}"), - })?; - // Touch `elements` so unused-field lints stay happy and so users - // can later add a `pub fn elements(&self)` accessor without an - // ABI change. - let _ = &self.elements; - Ok(TemeState::from_arrays( - target_jd_utc, - prediction.position, - prediction.velocity, - )) - } -} - -fn jd_offset_minutes(epoch: JulianDate, minutes: f64) -> JulianDate { - use siderust::qtty::unit::Day; - use siderust::qtty::Quantity; - let days = minutes / 1_440.0; - let raw = epoch.raw().value() + days; - JulianDate::::try_new(Quantity::::new(raw)) - .expect("finite Julian date increments remain finite") -} diff --git a/src/sgp4/state.rs b/src/sgp4/state.rs deleted file mode 100644 index 0364fb5..0000000 --- a/src/sgp4/state.rs +++ /dev/null @@ -1,187 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Strongly typed SGP4/SDP4 prediction state. -//! -//! [`TemeState`] bundles the position and velocity returned by a single -//! [`Sgp4Propagator::propagate_at`](crate::Sgp4Propagator::propagate_at) call, -//! together with the UTC instant the prediction is valid for. -//! -//! The state lives in the **TEME** (True Equator, Mean Equinox) frame defined -//! by `siderust::affn::frames::TEME`, geocentric, with positions in -//! [`siderust::qtty::unit::Kilometer`] and velocities in [`KilometerPerSecond`]. -//! Downstream conversion to ITRF / GCRF / ECEF is provided by -//! `siderust::coordinates::transform::providers::frames_teme` once Earth -//! Orientation Parameters are supplied. - -use siderust::coordinates::cartesian::{position, velocity}; -use siderust::qtty::{length::Kilometer, time::Second, Per}; -use siderust::tempoch::{JulianDate, UTC}; - -/// Velocity unit alias used by the SGP4 propagator: kilometres per second. -/// -/// SGP4 publishes velocities in km·s⁻¹; we expose them as a typed -/// [`siderust::qtty::Per`] so users cannot accidentally -/// mix them with m·s⁻¹ values from other parts of the stack. -/// -/// # Examples -/// -/// ``` -/// use spod::sgp4::KilometerPerSecond; -/// use siderust::qtty::Quantity; -/// let v: Quantity = Quantity::new(7.5); -/// assert!((v.value() - 7.5).abs() < 1e-12); -/// ``` -pub type KilometerPerSecond = Per; - -/// Geocentric **TEME** Cartesian position with kilometre units. -/// -/// Re-export of [`siderust::coordinates::cartesian::position::TEME`] specialised -/// to [`siderust::qtty::unit::Kilometer`]. -pub type TemePositionKm = position::TEME; - -/// **TEME** Cartesian velocity with km·s⁻¹ units. -pub type TemeVelocityKmPerSec = velocity::TEME; - -/// SGP4 prediction at a single epoch in the **TEME** frame. -/// -/// Construction is performed by the propagator; this struct only exposes -/// typed accessors. The fields are public-but-stable — adding new components -/// (e.g. covariance) would be a breaking change requiring a new type. -/// -/// # Examples -/// -/// ``` -/// use spod::sgp4::{Sgp4Propagator, TemeState}; -/// use spod::tle::parse_3le; -/// -/// let tle = parse_3le( -/// "ISS (ZARYA)", -/// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", -/// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", -/// ).unwrap(); -/// let prop = Sgp4Propagator::from_tle(&tle).unwrap(); -/// let state: TemeState = prop.propagate_minutes(0.0).unwrap(); -/// // Magnitudes are in kilometres and km/s for an LEO. -/// let r = state.position().as_array(); -/// assert!(r[0].value().abs() < 8_000.0); -/// ``` -#[derive(Clone, Debug)] -pub struct TemeState { - epoch_utc: JulianDate, - position: TemePositionKm, - velocity: TemeVelocityKmPerSec, -} - -impl TemeState { - /// Construct a typed state from the SGP4 raw arrays plus its valid epoch. - /// - /// `position_km` / `velocity_km_per_s` follow the standard SGP4 ordering - /// `[x, y, z]` in TEME. - /// - /// # Examples - /// - /// ``` - /// use spod::sgp4::TemeState; - /// use siderust::tempoch::{JulianDate, Time, UTC}; - /// use chrono::{TimeZone, Utc}; - /// - /// let t = Time::::try_from_chrono( - /// Utc.with_ymd_and_hms(2000, 1, 1, 12, 0, 0).unwrap(), - /// ) - /// .unwrap(); - /// let jd = t.to::(); - /// let s = TemeState::from_arrays( - /// jd, - /// [7000.0, 0.0, 0.0], - /// [0.0, 7.5, 0.0], - /// ); - /// assert!((s.position().x().value() - 7000.0).abs() < 1e-12); - /// assert!((s.velocity().y().value() - 7.5).abs() < 1e-12); - /// ``` - pub fn from_arrays( - epoch_utc: JulianDate, - position_km: [f64; 3], - velocity_km_per_s: [f64; 3], - ) -> Self { - use siderust::qtty::Quantity; - let p = TemePositionKm::new( - Quantity::::new(position_km[0]), - Quantity::::new(position_km[1]), - Quantity::::new(position_km[2]), - ); - let v = TemeVelocityKmPerSec::new( - Quantity::::new(velocity_km_per_s[0]), - Quantity::::new(velocity_km_per_s[1]), - Quantity::::new(velocity_km_per_s[2]), - ); - Self { - epoch_utc, - position: p, - velocity: v, - } - } - - /// UTC epoch the prediction is valid for. - /// - /// # Examples - /// - /// ``` - /// use spod::sgp4::Sgp4Propagator; - /// use spod::tle::parse_3le; - /// let tle = parse_3le( - /// "ISS (ZARYA)", - /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", - /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", - /// ).unwrap(); - /// let p = Sgp4Propagator::from_tle(&tle).unwrap(); - /// let s = p.propagate_minutes(0.0).unwrap(); - /// assert!(s.epoch().raw().value() > 2_454_000.0); - /// ``` - pub fn epoch(&self) -> JulianDate { - self.epoch_utc - } - - /// Geocentric TEME position in kilometres. - /// - /// # Examples - /// - /// ``` - /// use spod::sgp4::Sgp4Propagator; - /// use spod::tle::parse_3le; - /// let tle = parse_3le( - /// "ISS (ZARYA)", - /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", - /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", - /// ).unwrap(); - /// let p = Sgp4Propagator::from_tle(&tle).unwrap(); - /// let s = p.propagate_minutes(0.0).unwrap(); - /// let r = s.position(); - /// assert!(r.distance().value() > 6_000.0); - /// ``` - pub fn position(&self) -> &TemePositionKm { - &self.position - } - - /// TEME velocity in km·s⁻¹. - /// - /// # Examples - /// - /// ``` - /// use spod::sgp4::Sgp4Propagator; - /// use spod::tle::parse_3le; - /// let tle = parse_3le( - /// "ISS (ZARYA)", - /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", - /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", - /// ).unwrap(); - /// let p = Sgp4Propagator::from_tle(&tle).unwrap(); - /// let s = p.propagate_minutes(0.0).unwrap(); - /// let v = s.velocity(); - /// // LEO speed is ~7.7 km/s. - /// assert!(v.magnitude().value() > 5.0 && v.magnitude().value() < 10.0); - /// ``` - pub fn velocity(&self) -> &TemeVelocityKmPerSec { - &self.velocity - } -} diff --git a/src/spice/error.rs b/src/spice/error.rs deleted file mode 100644 index 5519706..0000000 --- a/src/spice/error.rs +++ /dev/null @@ -1,159 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Unified error type for SPICE kernel operations. - -use thiserror::Error; - -/// Errors produced by SPK kernel operations. -/// -/// All public APIs in this crate return `Result<_, SpiceError>`. The -/// variants are kept small and structured so adapters can branch on the -/// concrete failure mode (out-of-coverage vs. format error vs. missing -/// chain) without string matching. -/// -/// # Examples -/// -/// ```rust -/// use spod::spice::SpiceError; -/// -/// let e = SpiceError::OutOfCoverage { -/// target: 399, -/// center: 0, -/// epoch_tdb_seconds: 0.0, -/// start_tdb_seconds: 1.0, -/// end_tdb_seconds: 2.0, -/// }; -/// assert!(format!("{e}").contains("out of coverage")); -/// ``` -#[derive(Debug, Error)] -pub enum SpiceError { - /// I/O error reading a kernel file from disk. - #[error("SPICE kernel I/O error: {0}")] - Io(#[from] std::io::Error), - - /// The underlying DAF/SPK parser rejected the file. - #[error("SPICE kernel parse error: {message}")] - Parse { - /// Human-readable description from the upstream parser. - message: String, - }, - - /// No segment in the kernel covers `target → center` at `epoch`. - #[error( - "SPICE kernel: state for target={target} center={center} is out of coverage \ - at et={epoch_tdb_seconds} TDB-s; available segment range is \ - [{start_tdb_seconds}, {end_tdb_seconds}] TDB-s" - )] - OutOfCoverage { - /// NAIF body ID of the requested target. - target: i32, - /// NAIF body ID of the requested center. - center: i32, - /// Epoch in TDB seconds past J2000. - epoch_tdb_seconds: f64, - /// Start of the closest segment's coverage window (TDB seconds). - start_tdb_seconds: f64, - /// End of the closest segment's coverage window (TDB seconds). - end_tdb_seconds: f64, - }, - - /// Kernel contains no segment chain from `target` to `center`. - #[error( - "SPICE kernel: no chain from target={target} to center={center} \ - (kernel has no segment with this body pair, even via transitive \ - centers)" - )] - NoChain { - /// NAIF body ID of the requested target. - target: i32, - /// NAIF body ID of the requested center. - center: i32, - }, - - /// SPK data type is not implemented in this drop. - /// - /// This crate ships SPK Type 2 (Chebyshev position) and Type 3 - /// (Chebyshev position+velocity). Types 9 / 13 (Lagrange equal- and - /// unequal-step interpolation) and the higher Types are not - /// implemented; the parser still indexes them so callers can detect - /// their presence via [`crate::spice::SpkKernel::segments`], but a state - /// query that resolves to such a segment fails with this error. - #[error("SPICE kernel: SPK Type {data_type} is not implemented")] - UnsupportedDataType { - /// NAIF SPK data type code (e.g. 9 or 13). - data_type: i32, - }, - - /// Internal invariant violated while decoding a segment. - /// - /// Returned when a record's metadata (e.g. `radius`) is non-finite or - /// non-positive, which would indicate kernel corruption. - #[error("SPICE kernel: segment record corrupted: {message}")] - Corrupted { - /// Human-readable description. - message: String, - }, -} - -impl From for SpiceError { - fn from(err: siderust::formats::spice::SpiceError) -> Self { - match err { - siderust::formats::spice::SpiceError::Io(error) => SpiceError::Io(error), - siderust::formats::spice::SpiceError::Parse { message } => { - SpiceError::Parse { message } - } - siderust::formats::spice::SpiceError::OutOfCoverage { - target, - center, - epoch_tdb_seconds, - start_tdb_seconds, - end_tdb_seconds, - } => SpiceError::OutOfCoverage { - target, - center, - epoch_tdb_seconds, - start_tdb_seconds, - end_tdb_seconds, - }, - siderust::formats::spice::SpiceError::NoChain { target, center } => { - SpiceError::NoChain { target, center } - } - siderust::formats::spice::SpiceError::UnsupportedDataType { data_type } => { - SpiceError::UnsupportedDataType { data_type } - } - siderust::formats::spice::SpiceError::Corrupted { message } => { - SpiceError::Corrupted { message } - } - other => SpiceError::Parse { - message: other.to_string(), - }, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn out_of_coverage_message_lists_window() { - let e = SpiceError::OutOfCoverage { - target: 399, - center: 0, - epoch_tdb_seconds: 5.0, - start_tdb_seconds: 10.0, - end_tdb_seconds: 20.0, - }; - let msg = format!("{e}"); - assert!(msg.contains("out of coverage")); - assert!(msg.contains("399")); - assert!(msg.contains("10")); - } - - #[test] - fn unsupported_data_type_message_contains_code() { - let e = SpiceError::UnsupportedDataType { data_type: 13 }; - assert!(format!("{e}").contains("Type 13")); - } -} diff --git a/src/spice/kernel.rs b/src/spice/kernel.rs deleted file mode 100644 index e6ddd73..0000000 --- a/src/spice/kernel.rs +++ /dev/null @@ -1,501 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! [`SpkKernel`] — owns DAF bytes, indexes every segment, and resolves -//! body-relative state queries by chaining segments through the kernel -//! graph. - -use std::collections::{HashMap, VecDeque}; -use std::path::Path; - -use siderust::formats::spice::daf::Daf; - -use super::error::SpiceError; -use super::segment::{segment_for_summary, SpkSegment}; - -/// One segment loaded from a kernel, paired with its summary metadata. -#[derive(Debug)] -pub struct LoadedSegment { - /// NAIF id of the target body for this segment. - pub target: i32, - /// NAIF id of the center body for this segment. - pub center: i32, - /// SPICE frame id (typically 1 = J2000 ≡ ICRS for DE kernels). - pub frame_id: i32, - /// SPK data type code (`2`, `3`, or `9`/`13` if loaded but not - /// evaluable). - pub data_type: i32, - /// Coverage start (TDB seconds past J2000). - pub start_tdb_seconds: f64, - /// Coverage end (TDB seconds past J2000). - pub end_tdb_seconds: f64, - /// Decoded segment payload, or `None` if the data type is not - /// supported by this crate (e.g. SPK Type 9 / 13). - pub segment: Option, -} - -/// A loaded SPK kernel ready to answer body-relative state queries. -/// -/// `SpkKernel` owns the raw bytes of the DAF file and a parsed list of -/// every segment in the kernel. State queries are resolved by walking -/// the directed graph of `(target, center)` segments using BFS, summing -/// segment states along the chain. This matches the behavior of NAIF -/// CSPICE `spkez_c` / `spkgeo_c` for J2000-frame ephemerides. -/// -/// # Examples -/// -/// ```rust,no_run -/// use spod::spice::SpkKernel; -/// -/// let kernel = SpkKernel::open("de440.bsp")?; -/// // Earth (NAIF 399) relative to the Solar System Barycenter (NAIF 0): -/// let state = kernel.state(399, 0, 0.0)?; -/// assert!(state[0].abs() < 2.0e8); // ~1.5e8 km from SSB at J2000 -/// # Ok::<_, spod::spice::SpiceError>(()) -/// ``` -pub struct SpkKernel { - bytes: Vec, - daf: Daf, - segments: Vec, - // Adjacency map: target -> Vec of segment indices. - by_target: HashMap>, -} - -impl std::fmt::Debug for SpkKernel { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SpkKernel") - .field("bytes_len", &self.bytes.len()) - .field("segment_count", &self.segments.len()) - .finish() - } -} - -impl SpkKernel { - /// Open and parse a DAF/SPK kernel from a filesystem path. - pub fn open(path: impl AsRef) -> Result { - let bytes = std::fs::read(path)?; - Self::from_bytes(bytes) - } - - /// Parse a DAF/SPK kernel from an in-memory buffer. - /// - /// Useful for tests and for embedding small kernels (e.g. unit - /// tests, fuzz inputs, or memory-mapped files). - pub fn from_bytes(bytes: Vec) -> Result { - let daf = Daf::parse(&bytes)?; - let mut segments = Vec::with_capacity(daf.summaries.len()); - for summary in &daf.summaries { - let (segment, start, end) = match segment_for_summary(&bytes, &daf, summary) { - Ok(seg) => { - let start = seg.start_tdb_seconds(); - let end = seg.end_tdb_seconds(); - (Some(seg), start, end) - } - Err(SpiceError::UnsupportedDataType { .. }) => { - // Index the segment but mark it as unevaluable. - (None, summary.start_et, summary.end_et) - } - Err(other) => return Err(other), - }; - segments.push(LoadedSegment { - target: summary.target_id, - center: summary.center_id, - frame_id: summary.frame_id, - data_type: summary.data_type, - start_tdb_seconds: start, - end_tdb_seconds: end, - segment, - }); - } - - let mut by_target: HashMap> = HashMap::new(); - for (i, s) in segments.iter().enumerate() { - by_target.entry(s.target).or_default().push(i); - } - - Ok(SpkKernel { - bytes, - daf, - segments, - by_target, - }) - } - - /// Borrow the parsed DAF metadata. Useful for low-level inspection. - pub fn daf(&self) -> &Daf { - &self.daf - } - - /// Borrow the raw kernel bytes. - pub fn bytes(&self) -> &[u8] { - &self.bytes - } - - /// All loaded segments in summary order. - pub fn segments(&self) -> &[LoadedSegment] { - &self.segments - } - - /// Compute the state of `target` relative to `center` at TDB - /// `epoch_tdb_seconds` (seconds past J2000), expressed in the - /// kernel's frame (`frame_id`, typically J2000 ≡ ICRS). - /// - /// The result is `[x, y, z, vx, vy, vz]` in km / km·s⁻¹. - /// - /// The chain is resolved by BFS over the directed segment graph; - /// per-segment states are summed (with sign for reverse traversal). - pub fn state( - &self, - target: i32, - center: i32, - epoch_tdb_seconds: f64, - ) -> Result<[f64; 6], SpiceError> { - if target == center { - return Ok([0.0; 6]); - } - // BFS over directed edges. Each "edge" is the segment that - // expresses `seg.target` relative to `seg.center`. A traversal - // from `target` to `center` walks edges in either direction; - // a forward edge contributes +state, a reverse edge contributes - // -state. - let path = self.find_chain(target, center, epoch_tdb_seconds)?; - let mut acc = [0.0_f64; 6]; - let mut current = target; - for (seg_idx, forward) in path { - let seg = &self.segments[seg_idx]; - let segment = seg - .segment - .as_ref() - .ok_or(SpiceError::UnsupportedDataType { - data_type: seg.data_type, - })?; - let s = segment.evaluate(epoch_tdb_seconds).map_err(|e| match e { - // Re-stamp the chain endpoints onto coverage errors. - SpiceError::OutOfCoverage { - epoch_tdb_seconds, - start_tdb_seconds, - end_tdb_seconds, - .. - } => SpiceError::OutOfCoverage { - target, - center, - epoch_tdb_seconds, - start_tdb_seconds, - end_tdb_seconds, - }, - other => other, - })?; - if forward { - for i in 0..6 { - acc[i] += s[i]; - } - current = seg.center; - } else { - for i in 0..6 { - acc[i] -= s[i]; - } - current = seg.target; - } - } - debug_assert_eq!(current, center); - let _ = current; - Ok(acc) - } - - /// BFS over the segment graph; returns the list of `(segment_index, - /// forward)` to walk to get from `target` to `center` at `epoch`. - fn find_chain( - &self, - target: i32, - center: i32, - epoch: f64, - ) -> Result, SpiceError> { - // Build undirected adjacency on demand: for each node we visit, - // walk every segment whose target or center matches it and - // whose coverage covers `epoch`. - let mut visited: HashMap = HashMap::new(); - // value: (predecessor_node, edge_segment_index, forward_from_pred) - let mut queue = VecDeque::new(); - queue.push_back(target); - visited.insert(target, (target, usize::MAX, true)); - - let mut found = false; - while let Some(node) = queue.pop_front() { - if node == center { - found = true; - break; - } - // Outgoing forward edges (segments where node is the target). - if let Some(idxs) = self.by_target.get(&node) { - for &i in idxs { - let s = &self.segments[i]; - if epoch < s.start_tdb_seconds || epoch > s.end_tdb_seconds { - continue; - } - if let std::collections::hash_map::Entry::Vacant(e) = visited.entry(s.center) { - e.insert((node, i, true)); - queue.push_back(s.center); - } - } - } - // Reverse edges (segments where node is the center). - for (i, s) in self.segments.iter().enumerate() { - if s.center != node { - continue; - } - if epoch < s.start_tdb_seconds || epoch > s.end_tdb_seconds { - continue; - } - if let std::collections::hash_map::Entry::Vacant(e) = visited.entry(s.target) { - e.insert((node, i, false)); - queue.push_back(s.target); - } - } - } - - if !found { - // Distinguish "no chain at all" vs "chain exists but not at this epoch". - let any_chain_ignoring_epoch = self.has_chain_ignoring_epoch(target, center); - if any_chain_ignoring_epoch { - // Find tightest window for diagnostics. - let (start, end) = self - .coverage_for(target, center) - .unwrap_or((f64::NAN, f64::NAN)); - return Err(SpiceError::OutOfCoverage { - target, - center, - epoch_tdb_seconds: epoch, - start_tdb_seconds: start, - end_tdb_seconds: end, - }); - } - return Err(SpiceError::NoChain { target, center }); - } - - // Reconstruct path from `center` back to `target`. - let mut steps = Vec::new(); - let mut node = center; - while node != target { - let (pred, seg_idx, forward) = *visited.get(&node).expect("BFS invariant"); - steps.push((seg_idx, forward)); - node = pred; - } - steps.reverse(); - Ok(steps) - } - - fn has_chain_ignoring_epoch(&self, target: i32, center: i32) -> bool { - let mut visited = std::collections::HashSet::new(); - let mut queue = VecDeque::new(); - queue.push_back(target); - visited.insert(target); - while let Some(node) = queue.pop_front() { - if node == center { - return true; - } - for s in &self.segments { - let next = if s.target == node { - Some(s.center) - } else if s.center == node { - Some(s.target) - } else { - None - }; - if let Some(n) = next { - if visited.insert(n) { - queue.push_back(n); - } - } - } - } - false - } - - fn coverage_for(&self, target: i32, center: i32) -> Option<(f64, f64)> { - let mut start = f64::NEG_INFINITY; - let mut end = f64::INFINITY; - let mut hit = false; - for s in &self.segments { - if (s.target == target && s.center == center) - || (s.target == center && s.center == target) - { - start = start.max(s.start_tdb_seconds); - end = end.min(s.end_tdb_seconds); - hit = true; - } - } - if hit { - Some((start, end)) - } else { - None - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Build a minimal valid DAF/SPK file with two Type-2 segments - /// (Earth → SSB and Moon → Earth), each a single-record constant - /// polynomial. - fn build_two_segment_kernel() -> Vec { - // Layout: - // record 1 (1024 bytes): file header - // record 2 (1024 bytes): summary record (2 summaries) - // record 3 (1024 bytes): segment data (two trailers + two records) - let mut buf = vec![0u8; 3 * 1024]; - - // ── header ──────────────────────────────────────────────────── - buf[0..8].copy_from_slice(b"DAF/SPK "); - buf[8..12].copy_from_slice(&2i32.to_le_bytes()); // ND - buf[12..16].copy_from_slice(&6i32.to_le_bytes()); // NI - buf[76..80].copy_from_slice(&2i32.to_le_bytes()); // FWARD = 2 - - // ── summary record (record 2, bytes 1024..2048) ─────────────── - let rec = &mut buf[1024..2048]; - rec[0..8].copy_from_slice(&0.0_f64.to_le_bytes()); // next - rec[8..16].copy_from_slice(&0.0_f64.to_le_bytes()); // prev - rec[16..24].copy_from_slice(&2.0_f64.to_le_bytes()); // nsum - - // SS (size of summary in doubles) = nd + ceil(ni/2) = 2 + 3 = 5 - // Each summary occupies 5 * 8 = 40 bytes. - - // We will lay out segment data starting at file word - // 2 * 1024 / 8 + 1 = 257 (1-based word index of byte 2048). - let seg1_start_word: i32 = 257; - let seg1_end_word: i32 = seg1_start_word + 8; // 1 record (5 doubles) + 4 trailer = 9 words - let seg2_start_word: i32 = seg1_end_word + 1; - let seg2_end_word: i32 = seg2_start_word + 8; - - // Summary 1: target=399 (Earth), center=0 (SSB) - let s1 = &mut rec[24..24 + 40]; - s1[0..8].copy_from_slice(&0.0_f64.to_le_bytes()); // start_et - s1[8..16].copy_from_slice(&1.0_f64.to_le_bytes()); // end_et - s1[16..20].copy_from_slice(&399i32.to_le_bytes()); // target - s1[20..24].copy_from_slice(&0i32.to_le_bytes()); // center - s1[24..28].copy_from_slice(&1i32.to_le_bytes()); // frame - s1[28..32].copy_from_slice(&2i32.to_le_bytes()); // data_type - s1[32..36].copy_from_slice(&seg1_start_word.to_le_bytes()); - s1[36..40].copy_from_slice(&seg1_end_word.to_le_bytes()); - - // Summary 2: target=301 (Moon), center=399 (Earth) - let s2 = &mut rec[24 + 40..24 + 80]; - s2[0..8].copy_from_slice(&0.0_f64.to_le_bytes()); - s2[8..16].copy_from_slice(&1.0_f64.to_le_bytes()); - s2[16..20].copy_from_slice(&301i32.to_le_bytes()); - s2[20..24].copy_from_slice(&399i32.to_le_bytes()); - s2[24..28].copy_from_slice(&1i32.to_le_bytes()); - s2[28..32].copy_from_slice(&2i32.to_le_bytes()); - s2[32..36].copy_from_slice(&seg2_start_word.to_le_bytes()); - s2[36..40].copy_from_slice(&seg2_end_word.to_le_bytes()); - - // ── segment data (record 3, bytes 2048..3072) ───────────────── - let wf = |word: i32, v: f64, b: &mut [u8]| { - let off = (word as usize - 1) * 8; - b[off..off + 8].copy_from_slice(&v.to_le_bytes()); - }; - // Segment 1: Earth wrt SSB = (1.0e8, 0, 0) - // record at words seg1_start_word..seg1_start_word+4 - wf(seg1_start_word, 0.5, &mut buf); // mid - wf(seg1_start_word + 1, 0.5, &mut buf); // radius - wf(seg1_start_word + 2, 1.0e8, &mut buf); // x coeff - wf(seg1_start_word + 3, 0.0, &mut buf); - wf(seg1_start_word + 4, 0.0, &mut buf); - // trailer - wf(seg1_end_word - 3, 0.0, &mut buf); // init - wf(seg1_end_word - 2, 1.0, &mut buf); // intlen - wf(seg1_end_word - 1, 5.0, &mut buf); // rsize - wf(seg1_end_word, 1.0, &mut buf); // n_records - - // Segment 2: Moon wrt Earth = (0, 3.84e5, 0) - wf(seg2_start_word, 0.5, &mut buf); - wf(seg2_start_word + 1, 0.5, &mut buf); - wf(seg2_start_word + 2, 0.0, &mut buf); - wf(seg2_start_word + 3, 3.84e5, &mut buf); - wf(seg2_start_word + 4, 0.0, &mut buf); - wf(seg2_end_word - 3, 0.0, &mut buf); - wf(seg2_end_word - 2, 1.0, &mut buf); - wf(seg2_end_word - 1, 5.0, &mut buf); - wf(seg2_end_word, 1.0, &mut buf); - - buf - } - - #[test] - fn open_synthetic_kernel_indexes_segments() { - let bytes = build_two_segment_kernel(); - let kernel = SpkKernel::from_bytes(bytes).unwrap(); - assert_eq!(kernel.segments().len(), 2); - assert_eq!(kernel.segments()[0].target, 399); - assert_eq!(kernel.segments()[0].center, 0); - assert_eq!(kernel.segments()[1].target, 301); - assert_eq!(kernel.segments()[1].center, 399); - } - - #[test] - fn direct_state_round_trip() { - let kernel = SpkKernel::from_bytes(build_two_segment_kernel()).unwrap(); - let s = kernel.state(399, 0, 0.5).unwrap(); - assert!((s[0] - 1.0e8).abs() < 1e-6); - } - - #[test] - fn chained_state_moon_relative_to_ssb() { - let kernel = SpkKernel::from_bytes(build_two_segment_kernel()).unwrap(); - // Moon → SSB = (Moon → Earth) + (Earth → SSB) = (1e8, 3.84e5, 0) - let s = kernel.state(301, 0, 0.5).unwrap(); - assert!((s[0] - 1.0e8).abs() < 1e-6); - assert!((s[1] - 3.84e5).abs() < 1e-6); - } - - #[test] - fn reverse_chain_state_ssb_relative_to_moon() { - let kernel = SpkKernel::from_bytes(build_two_segment_kernel()).unwrap(); - // SSB → Moon = -(Moon → SSB) - let s = kernel.state(0, 301, 0.5).unwrap(); - assert!((s[0] + 1.0e8).abs() < 1e-6); - assert!((s[1] + 3.84e5).abs() < 1e-6); - } - - #[test] - fn same_body_state_is_zero() { - let kernel = SpkKernel::from_bytes(build_two_segment_kernel()).unwrap(); - let s = kernel.state(301, 301, 0.5).unwrap(); - assert_eq!(s, [0.0; 6]); - } - - #[test] - fn no_chain_returns_no_chain_error() { - let kernel = SpkKernel::from_bytes(build_two_segment_kernel()).unwrap(); - let err = kernel.state(499 /* Mars */, 0, 0.5).unwrap_err(); - assert!(matches!( - err, - SpiceError::NoChain { - target: 499, - center: 0 - } - )); - } - - #[test] - fn out_of_coverage_returned_for_far_future_epoch() { - let kernel = SpkKernel::from_bytes(build_two_segment_kernel()).unwrap(); - let err = kernel.state(399, 0, 1.0e9).unwrap_err(); - assert!(matches!( - err, - SpiceError::OutOfCoverage { - target: 399, - center: 0, - .. - } - )); - } - - #[test] - fn debug_repr_contains_segment_count() { - let kernel = SpkKernel::from_bytes(build_two_segment_kernel()).unwrap(); - let s = format!("{kernel:?}"); - assert!(s.contains("segment_count: 2")); - } -} diff --git a/src/spice/mod.rs b/src/spice/mod.rs deleted file mode 100644 index e10d618..0000000 --- a/src/spice/mod.rs +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! SPICE DAF/SPK support and `SpiceEphemerisProvider` service adapter. -//! -//! Low-level DAF parsing and raw SPK readers come directly from -//! [`siderust::formats::spice::{daf, spk}`]. This module adds the value-owned -//! kernel indexing, segment evaluation, body-chain resolution, and service -//! provider integration needed by `spod`. -//! -//! # Scope -//! -//! The module builds on the upstream `siderust::formats::spice::{daf, spk}` -//! readers and adds: -//! -//! * A unified [`SpkSegment`] enum that supports SPK **Type 2** -//! (Chebyshev position) and **Type 3** (Chebyshev position+velocity). -//! * Per-record Chebyshev evaluation of position and velocity using the -//! workspace `cheby` crate (Clenshaw recurrence + analytic derivative). -//! * An indexed [`SpkKernel`] that owns the file bytes, parses every -//! summary, and chains body-relative segments to compute the state of -//! any target relative to any kernel-supported center. -//! * [`SpiceEphemerisProvider`], a typed adapter implementing -//! [`crate::service::providers::EphemerisProvider`]. Center selection -//! is **explicit** at construction time and is propagated as a typed -//! `affn` reference center on the returned [`SpiceState`]. -//! -//! SPK **Type 9** and **Type 13** (Lagrange interpolation, equal- and -//! unequal-step) are out of scope for this drop. Loading a kernel that -//! contains a Type-9/13 segment succeeds, but a state query that resolves -//! to such a segment returns -//! [`SpiceError::UnsupportedDataType`]. -//! -//! # Time semantics -//! -//! All epochs are **TDB seconds past J2000**, matching the convention of -//! NAIF SPK files and [`siderust::tempoch::J2000s`]. The provider trait method -//! exposes the same numeric convention; the typed `siderust::tempoch::EncodedTime` -//! constructor is available via [`SpiceEphemerisProvider::state_at`]. -//! -//! # Validation gate -//! -//! Position recovery is byte-identical to NAIF's CSPICE -//! `spkez_c` for the planet body chains in `de440.bsp` / `de441.bsp`. -//! The `de440` feature unlocks an integration test -//! (`tests/de440_validation.rs`) that loads a kernel from disk -//! (`SIDERUST_SPICE_DE_PATH`) and compares against committed reference -//! states under `tests/data/de440_reference.json`. -//! -//! # Examples -//! -//! ```rust -//! use spod::spice::{SpkKernel, SpiceError}; -//! -//! // The crate ships no DAF binaries; in production you load a path: -//! // let kernel = SpkKernel::open("de440.bsp")?; -//! // let state = kernel.state(/*target=*/3, /*center=*/0, /*et=*/0.0)?; -//! // assert!(state.position_km[0].is_finite()); -//! -//! // For the doc-test we just construct an explicit error to assert -//! // the error type wires into `std::error::Error` correctly. -//! let err: SpiceError = SpiceError::UnsupportedDataType { data_type: 9 }; -//! assert!(format!("{err}").contains("Type 9")); -//! # Ok::<_, SpiceError>(()) -//! ``` - -#![forbid(unsafe_code)] -#![deny(missing_docs)] - -mod error; -mod kernel; -mod naif; -mod provider; -mod segment; - -pub use error::SpiceError; -pub use kernel::{LoadedSegment, SpkKernel}; -pub use naif::{naif_id_for_name, well_known}; -pub use provider::{SpiceEphemerisProvider, SpiceState}; -pub use segment::{segment_for_summary, ChebSegment, SpkSegment}; diff --git a/src/spice/naif.rs b/src/spice/naif.rs deleted file mode 100644 index 054ab61..0000000 --- a/src/spice/naif.rs +++ /dev/null @@ -1,100 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Conveniences for the small set of NAIF body identifiers exercised by -//! POD workflows. The full NAIF id namespace is not enumerated here; -//! only the bodies covered by JPL DE planetary kernels are exposed -//! through [`well_known`]. - -/// Resolve a case-insensitive textual body name to its NAIF integer id. -/// -/// Returns `None` for any name not in the small table covered by JPL -/// DE planetary kernels (Sun, planets, EMB, Moon, SSB). -/// -/// # Examples -/// -/// ```rust -/// use spod::spice::naif_id_for_name; -/// -/// assert_eq!(naif_id_for_name("EARTH"), Some(399)); -/// assert_eq!(naif_id_for_name("solar system barycenter"), Some(0)); -/// assert_eq!(naif_id_for_name("Pluto"), Some(9)); -/// assert_eq!(naif_id_for_name("Andromeda"), None); -/// ``` -pub fn naif_id_for_name(name: &str) -> Option { - let n = name.trim().to_ascii_uppercase(); - match n.as_str() { - "SSB" | "SOLAR SYSTEM BARYCENTER" => Some(0), - "MERCURY BARYCENTER" => Some(1), - "VENUS BARYCENTER" => Some(2), - "EMB" | "EARTH BARYCENTER" | "EARTH MOON BARYCENTER" => Some(3), - "MARS BARYCENTER" => Some(4), - "JUPITER BARYCENTER" => Some(5), - "SATURN BARYCENTER" => Some(6), - "URANUS BARYCENTER" => Some(7), - "NEPTUNE BARYCENTER" => Some(8), - "PLUTO BARYCENTER" | "PLUTO" => Some(9), - "SUN" => Some(10), - "MERCURY" => Some(199), - "VENUS" => Some(299), - "EARTH" => Some(399), - "MARS" => Some(499), - "JUPITER" => Some(599), - "SATURN" => Some(699), - "URANUS" => Some(799), - "NEPTUNE" => Some(899), - "MOON" => Some(301), - _ => None, - } -} - -/// Compile-time NAIF id constants for the bodies exercised by POD. -/// -/// These mirror the constants in `siderust::data::spk` and the NAIF -/// "Integer ID Codes" reference. They are duplicated here so callers -/// can write `well_known::EARTH` / `well_known::MOON` without -/// reaching into upstream modules. -pub mod well_known { - /// Solar System Barycenter. - pub const SSB: i32 = 0; - /// Earth-Moon Barycenter. - pub const EARTH_MOON_BARYCENTER: i32 = 3; - /// Mars-system Barycenter. - pub const MARS_BARYCENTER: i32 = 4; - /// Jupiter-system Barycenter. - pub const JUPITER_BARYCENTER: i32 = 5; - /// Sun. - pub const SUN: i32 = 10; - /// Earth (planet, NAIF id 399). - pub const EARTH: i32 = 399; - /// Moon (NAIF id 301). - pub const MOON: i32 = 301; - /// Mars (planet, NAIF id 499). - pub const MARS: i32 = 499; -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn name_lookup_handles_common_aliases() { - assert_eq!(naif_id_for_name("SSB"), Some(0)); - assert_eq!(naif_id_for_name("Sun"), Some(10)); - assert_eq!(naif_id_for_name(" earth "), Some(399)); - assert_eq!(naif_id_for_name("emb"), Some(3)); - assert_eq!(naif_id_for_name("MoOn"), Some(301)); - assert_eq!(naif_id_for_name("notabody"), None); - } - - #[test] - fn well_known_ids_are_consistent_with_lookup() { - assert_eq!(naif_id_for_name("EARTH"), Some(well_known::EARTH)); - assert_eq!(naif_id_for_name("MOON"), Some(well_known::MOON)); - assert_eq!(naif_id_for_name("SUN"), Some(well_known::SUN)); - assert_eq!( - naif_id_for_name("EMB"), - Some(well_known::EARTH_MOON_BARYCENTER) - ); - } -} diff --git a/src/spice/provider.rs b/src/spice/provider.rs deleted file mode 100644 index 2acc173..0000000 --- a/src/spice/provider.rs +++ /dev/null @@ -1,268 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! [`SpiceEphemerisProvider`] — typed service adapter implementing -//! [`crate::service::providers::EphemerisProvider`]. -//! -//! Center selection is **explicit at construction time**: the caller -//! must pick a typed `affn` reference center (e.g. -//! `siderust::coordinates::centers::Barycentric`) and the matching NAIF -//! body id. Every state returned by the provider carries that center on -//! its typed [`siderust::affn::cartesian::Position`], so downstream POD code -//! cannot accidentally mix barycentric and heliocentric vectors. - -use std::marker::PhantomData; -use std::sync::Arc; - -use crate::service::providers::EphemerisProvider; -use siderust::affn::cartesian::Position; -use siderust::coordinates::centers::ReferenceCenter; -use siderust::coordinates::frames::ICRS; -use siderust::qtty::unit::Kilometer; -use siderust::qtty::Quantity; -use siderust::tempoch::{EncodedTime, J2000s, Time, TDB}; - -use super::error::SpiceError; -use super::kernel::SpkKernel; - -/// One body state returned by [`SpiceEphemerisProvider`]. -/// -/// The position is typed by the construction-time reference center `C`, -/// so adding a barycentric position to a heliocentric position fails to -/// compile via `affn`'s affine algebra rules. Velocity is returned as a -/// raw `[f64; 3]` (km/s) for now; once the workspace `siderust::affn::Velocity` -/// or `siderust::qtty::Velocity` types stabilize for the J2000-reference-frame -/// case, this can be tightened without breaking the public surface. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct SpiceState> { - /// Position in the kernel's frame (typically ICRS / J2000), - /// expressed in kilometers from the construction-time center. - pub position: Position, - /// Velocity in km/s in the same frame. - pub velocity_km_s: [f64; 3], -} - -/// Typed `EphemerisProvider` backed by an in-memory [`SpkKernel`]. -/// -/// Construct with the NAIF id of the desired reference center and the -/// matching `affn` typed center marker. Cloning is cheap (`Arc`-shared -/// kernel). -/// -/// # Examples -/// -/// ```rust,no_run -/// use spod::spice::{SpkKernel, SpiceEphemerisProvider, well_known}; -/// use siderust::coordinates::centers::Barycentric; -/// use spod::service::providers::EphemerisProvider; -/// -/// let kernel = SpkKernel::open("de440.bsp")?; -/// let provider: SpiceEphemerisProvider = -/// SpiceEphemerisProvider::new(kernel, well_known::SSB); -/// // Earth state at J2000: -/// let state = provider.state(well_known::EARTH, 0.0)?; -/// let _x_km = state.position.x(); -/// # Ok::<_, spod::spice::SpiceError>(()) -/// ``` -pub struct SpiceEphemerisProvider> { - kernel: Arc, - center_naif_id: i32, - _center: PhantomData C>, -} - -impl> Clone for SpiceEphemerisProvider { - fn clone(&self) -> Self { - Self { - kernel: Arc::clone(&self.kernel), - center_naif_id: self.center_naif_id, - _center: PhantomData, - } - } -} - -impl> std::fmt::Debug for SpiceEphemerisProvider { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SpiceEphemerisProvider") - .field("center_name", &C::center_name()) - .field("center_naif_id", &self.center_naif_id) - .field("kernel", &self.kernel) - .finish() - } -} - -impl> SpiceEphemerisProvider { - /// Construct a provider that returns positions relative to - /// `center_naif_id`, typed at `C`. - /// - /// The caller is responsible for matching the typed marker `C` - /// (e.g. `Barycentric`) with the NAIF id (e.g. `0`); this crate - /// does not enforce that mapping at compile time because POD - /// adapters may want to use custom centers. - pub fn new(kernel: SpkKernel, center_naif_id: i32) -> Self { - Self { - kernel: Arc::new(kernel), - center_naif_id, - _center: PhantomData, - } - } - - /// Construct from an `Arc`-shared kernel (useful when several - /// providers share one kernel with different centers). - pub fn from_arc(kernel: Arc, center_naif_id: i32) -> Self { - Self { - kernel, - center_naif_id, - _center: PhantomData, - } - } - - /// NAIF id of the construction-time reference center. - pub fn center_naif_id(&self) -> i32 { - self.center_naif_id - } - - /// Borrow the underlying kernel. - pub fn kernel(&self) -> &SpkKernel { - &self.kernel - } - - /// Convenience: query state at a typed `tempoch` TDB epoch. - /// - /// Equivalent to [`EphemerisProvider::state`] but accepts a typed - /// [`Time`] instead of a raw `f64` so callers using the - /// `tempoch` time-stack stay typed end-to-end. - pub fn state_at( - &self, - body_naif_id: i32, - epoch: Time, - ) -> Result, SpiceError> { - let encoded: EncodedTime = epoch.to::(); - let secs: Quantity = encoded.raw(); - EphemerisProvider::state(self, body_naif_id, secs.value()) - } -} - -impl> EphemerisProvider for SpiceEphemerisProvider { - type State = SpiceState; - type Error = SpiceError; - - fn state(&self, body_naif_id: i32, epoch_seconds_tdb: f64) -> Result { - let s = self - .kernel - .state(body_naif_id, self.center_naif_id, epoch_seconds_tdb)?; - let position = Position::::new( - Quantity::::new(s[0]), - Quantity::::new(s[1]), - Quantity::::new(s[2]), - ); - Ok(SpiceState { - position, - velocity_km_s: [s[3], s[4], s[5]], - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use siderust::coordinates::centers::{Barycentric, Geocentric}; - - /// Same synthetic kernel used in `kernel::tests`. - fn synthetic_kernel() -> SpkKernel { - let mut buf = vec![0u8; 3 * 1024]; - buf[0..8].copy_from_slice(b"DAF/SPK "); - buf[8..12].copy_from_slice(&2i32.to_le_bytes()); - buf[12..16].copy_from_slice(&6i32.to_le_bytes()); - buf[76..80].copy_from_slice(&2i32.to_le_bytes()); - let rec = &mut buf[1024..2048]; - rec[16..24].copy_from_slice(&2.0_f64.to_le_bytes()); - - let seg1_start_word: i32 = 257; - let seg1_end_word: i32 = seg1_start_word + 8; - let seg2_start_word: i32 = seg1_end_word + 1; - let seg2_end_word: i32 = seg2_start_word + 8; - - let s1 = &mut rec[24..64]; - s1[0..8].copy_from_slice(&0.0_f64.to_le_bytes()); - s1[8..16].copy_from_slice(&1.0_f64.to_le_bytes()); - s1[16..20].copy_from_slice(&399i32.to_le_bytes()); - s1[20..24].copy_from_slice(&0i32.to_le_bytes()); - s1[24..28].copy_from_slice(&1i32.to_le_bytes()); - s1[28..32].copy_from_slice(&2i32.to_le_bytes()); - s1[32..36].copy_from_slice(&seg1_start_word.to_le_bytes()); - s1[36..40].copy_from_slice(&seg1_end_word.to_le_bytes()); - - let s2 = &mut rec[64..104]; - s2[0..8].copy_from_slice(&0.0_f64.to_le_bytes()); - s2[8..16].copy_from_slice(&1.0_f64.to_le_bytes()); - s2[16..20].copy_from_slice(&301i32.to_le_bytes()); - s2[20..24].copy_from_slice(&399i32.to_le_bytes()); - s2[24..28].copy_from_slice(&1i32.to_le_bytes()); - s2[28..32].copy_from_slice(&2i32.to_le_bytes()); - s2[32..36].copy_from_slice(&seg2_start_word.to_le_bytes()); - s2[36..40].copy_from_slice(&seg2_end_word.to_le_bytes()); - - let wf = |word: i32, v: f64, b: &mut [u8]| { - let off = (word as usize - 1) * 8; - b[off..off + 8].copy_from_slice(&v.to_le_bytes()); - }; - wf(seg1_start_word, 0.5, &mut buf); - wf(seg1_start_word + 1, 0.5, &mut buf); - wf(seg1_start_word + 2, 1.0e8, &mut buf); - wf(seg1_start_word + 3, 0.0, &mut buf); - wf(seg1_start_word + 4, 0.0, &mut buf); - wf(seg1_end_word - 3, 0.0, &mut buf); - wf(seg1_end_word - 2, 1.0, &mut buf); - wf(seg1_end_word - 1, 5.0, &mut buf); - wf(seg1_end_word, 1.0, &mut buf); - wf(seg2_start_word, 0.5, &mut buf); - wf(seg2_start_word + 1, 0.5, &mut buf); - wf(seg2_start_word + 2, 0.0, &mut buf); - wf(seg2_start_word + 3, 3.84e5, &mut buf); - wf(seg2_start_word + 4, 0.0, &mut buf); - wf(seg2_end_word - 3, 0.0, &mut buf); - wf(seg2_end_word - 2, 1.0, &mut buf); - wf(seg2_end_word - 1, 5.0, &mut buf); - wf(seg2_end_word, 1.0, &mut buf); - - SpkKernel::from_bytes(buf).unwrap() - } - - #[test] - fn provider_returns_typed_barycentric_position_for_earth() { - let kernel = synthetic_kernel(); - let provider: SpiceEphemerisProvider = SpiceEphemerisProvider::new(kernel, 0); - let state = provider.state(399, 0.5).unwrap(); - assert!((state.position.x().value() - 1.0e8).abs() < 1e-6); - assert!(state.position.y().value().abs() < 1e-6); - } - - #[test] - fn provider_returns_typed_geocentric_position_for_moon() { - let kernel = synthetic_kernel(); - let provider: SpiceEphemerisProvider = SpiceEphemerisProvider::new(kernel, 399); - let state = provider.state(301, 0.5).unwrap(); - assert!((state.position.y().value() - 3.84e5).abs() < 1e-6); - assert!(state.position.x().value().abs() < 1e-6); - } - - #[test] - fn cloned_provider_shares_kernel() { - let kernel = synthetic_kernel(); - let p1: SpiceEphemerisProvider = SpiceEphemerisProvider::new(kernel, 0); - let p2 = p1.clone(); - let s1 = p1.state(399, 0.5).unwrap(); - let s2 = p2.state(399, 0.5).unwrap(); - assert_eq!(s1.position.x().value(), s2.position.x().value()); - assert_eq!(p1.center_naif_id(), 0); - assert_eq!(p2.center_naif_id(), 0); - } - - #[test] - fn debug_repr_lists_center_name() { - let kernel = synthetic_kernel(); - let provider: SpiceEphemerisProvider = SpiceEphemerisProvider::new(kernel, 0); - let dbg = format!("{provider:?}"); - assert!(dbg.contains("Barycentric")); - assert!(dbg.contains("center_naif_id: 0")); - } -} diff --git a/src/spice/segment.rs b/src/spice/segment.rs deleted file mode 100644 index 6d43dbd..0000000 --- a/src/spice/segment.rs +++ /dev/null @@ -1,520 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! SPK segment decoding and Chebyshev evaluation. -//! -//! This module covers the two SPK data types relevant to the JPL DE -//! planetary kernels exercised by POD: -//! -//! * **Type 2** — Chebyshev polynomials for position only; velocity is -//! recovered analytically from the position polynomial derivative. -//! * **Type 3** — Chebyshev polynomials for position **and** velocity, -//! with independent coefficient sets. -//! -//! Higher SPK types (9 / 13 / 21 / etc.) are out of scope; loading is -//! still permitted, but evaluation returns -//! [`SpiceError::UnsupportedDataType`]. -//! -//! # Per-record layout -//! -//! Both Type 2 and Type 3 segments are stored as a packed sequence of -//! fixed-size records. Each record contains the midpoint and radius -//! (TDB seconds) of the interval it covers, followed by the polynomial -//! coefficients: -//! -//! ```text -//! [ MID, RADIUS, c0_x, c1_x, ..., c0_y, c1_y, ..., c0_z, c1_z, ...] -//! ^-- Type 2: 3 components (x, y, z) -//! [ MID, RADIUS, c0_x, ..., c0_y, ..., c0_z, ..., c0_vx, ..., c0_vy, ..., c0_vz, ...] -//! ^-- Type 3: 6 components -//! ``` -//! -//! The polynomial argument is `tau = (et - MID) / RADIUS ∈ [-1, 1]`. -//! Velocity for Type 2 is `dPos/dtau / RADIUS` (in km/s). - -use super::error::SpiceError; -use siderust::formats::spice::daf::{Daf, Summary}; - -/// A typed SPK segment. Currently restricted to Chebyshev variants. -#[derive(Debug, Clone)] -pub enum SpkSegment { - /// SPK Type 2 — Chebyshev polynomial for position; velocity is - /// derived analytically. - Type2(ChebSegment), - /// SPK Type 3 — Chebyshev polynomials for position and velocity - /// stored side by side (no analytic differentiation needed). - Type3(ChebSegment), -} - -impl SpkSegment { - /// Coverage start (TDB seconds past J2000) of the segment. - pub fn start_tdb_seconds(&self) -> f64 { - self.cheb().init - } - - /// Coverage end (TDB seconds past J2000) of the segment. - pub fn end_tdb_seconds(&self) -> f64 { - let c = self.cheb(); - c.init + c.intlen * c.n_records as f64 - } - - /// Borrow the underlying Chebyshev segment data. - pub fn cheb(&self) -> &ChebSegment { - match self { - SpkSegment::Type2(c) | SpkSegment::Type3(c) => c, - } - } - - /// Evaluate the segment at `et` (TDB seconds past J2000). - /// - /// Returns `[x, y, z, vx, vy, vz]` with positions in **kilometers** - /// and velocities in **kilometers per second**, expressed in the - /// segment's reference frame (typically J2000 ≡ ICRS for DE - /// kernels). - pub fn evaluate(&self, et: f64) -> Result<[f64; 6], SpiceError> { - match self { - SpkSegment::Type2(c) => c.evaluate_type2(et), - SpkSegment::Type3(c) => c.evaluate_type3(et), - } - } -} - -/// Chebyshev segment data shared by Type 2 and Type 3. -#[derive(Debug, Clone)] -pub struct ChebSegment { - /// Initial epoch of the segment (TDB seconds past J2000). - pub init: f64, - /// Length (seconds) of each record's coverage interval. - pub intlen: f64, - /// Doubles per record (`2 + components * ncoeff`). - pub rsize: usize, - /// Number of coefficients per Chebyshev series. - pub ncoeff: usize, - /// Number of records in the segment. - pub n_records: usize, - /// Components per record (`3` for Type 2, `6` for Type 3). - pub components: u8, - /// Flat record data (`n_records * rsize` doubles). - pub records: Vec, -} - -impl ChebSegment { - /// Locate the record covering `et` and return `(record_index, mid, radius)`. - fn locate(&self, et: f64) -> Result<(usize, f64, f64), SpiceError> { - if !et.is_finite() { - return Err(SpiceError::Corrupted { - message: format!("epoch is not finite: {et}"), - }); - } - let end = self.init + self.intlen * self.n_records as f64; - if et < self.init || et > end { - return Err(SpiceError::OutOfCoverage { - target: 0, - center: 0, - epoch_tdb_seconds: et, - start_tdb_seconds: self.init, - end_tdb_seconds: end, - }); - } - let mut idx = ((et - self.init) / self.intlen).floor() as i64; - if idx < 0 { - idx = 0; - } - if idx as usize >= self.n_records { - idx = self.n_records as i64 - 1; - } - let off = (idx as usize) * self.rsize; - let mid = self.records[off]; - let radius = self.records[off + 1]; - if !radius.is_finite() || radius <= 0.0 { - return Err(SpiceError::Corrupted { - message: format!("record {idx} has non-positive radius {radius} (mid={mid})"), - }); - } - Ok((idx as usize, mid, radius)) - } - - /// Evaluate as a Type 2 (Chebyshev position) segment. - pub fn evaluate_type2(&self, et: f64) -> Result<[f64; 6], SpiceError> { - debug_assert_eq!(self.components, 3); - let (idx, mid, radius) = self.locate(et)?; - let off = idx * self.rsize + 2; - let tau = (et - mid) / radius; - let n = self.ncoeff; - let mut out = [0.0_f64; 6]; - for i in 0..3 { - let coeffs = &self.records[off + i * n..off + (i + 1) * n]; - let (p, dp) = cheby::core::evaluate_both(coeffs, tau); - out[i] = p; - out[i + 3] = dp / radius; - } - Ok(out) - } - - /// Evaluate as a Type 3 (Chebyshev position+velocity) segment. - pub fn evaluate_type3(&self, et: f64) -> Result<[f64; 6], SpiceError> { - debug_assert_eq!(self.components, 6); - let (idx, mid, radius) = self.locate(et)?; - let off = idx * self.rsize + 2; - let tau = (et - mid) / radius; - let n = self.ncoeff; - let mut out = [0.0_f64; 6]; - for (i, slot) in out.iter_mut().enumerate() { - let coeffs = &self.records[off + i * n..off + (i + 1) * n]; - *slot = cheby::core::evaluate(coeffs, tau); - } - Ok(out) - } -} - -/// Read an SPK segment summary into a typed [`SpkSegment`]. -/// -/// Supports SPK Type 2 and Type 3; any other type is rejected with -/// [`SpiceError::UnsupportedDataType`] so the caller can index the -/// kernel without losing information about what is and isn't loadable. -/// -/// # Examples -/// -/// ```rust -/// use siderust::formats::spice::daf::{Daf, Summary}; -/// use spod::spice::{segment_for_summary, SpiceError}; -/// // Build a synthetic single-record Type 2 segment with one -/// // coefficient per axis (a constant polynomial). -/// // rsize = 2 (mid,radius) + 3 components * 1 coeff = 5 doubles. -/// let mid = 0.5_f64; -/// let radius = 0.5_f64; -/// let coeffs_x = 100.0_f64; -/// let coeffs_y = 200.0_f64; -/// let coeffs_z = 300.0_f64; -/// let mut buf = vec![0u8; 9 * 8]; -/// let mut write = |word: usize, v: f64| { -/// buf[(word - 1) * 8..word * 8].copy_from_slice(&v.to_le_bytes()); -/// }; -/// // Records (words 1..=5) -/// write(1, mid); -/// write(2, radius); -/// write(3, coeffs_x); -/// write(4, coeffs_y); -/// write(5, coeffs_z); -/// // Trailer (init, intlen, rsize, n_records) at words 6..=9 -/// write(6, 0.0); // init -/// write(7, 1.0); // intlen -/// write(8, 5.0); // rsize -/// write(9, 1.0); // n_records -/// -/// let daf = Daf { nd: 2, ni: 6, summaries: vec![] }; -/// let summary = Summary { -/// start_et: 0.0, end_et: 1.0, -/// target_id: 399, center_id: 0, frame_id: 1, data_type: 2, -/// start_word: 1, end_word: 9, -/// }; -/// let segment = segment_for_summary(&buf, &daf, &summary)?; -/// let state = segment.evaluate(0.5)?; -/// assert!((state[0] - 100.0).abs() < 1e-12); -/// assert!((state[1] - 200.0).abs() < 1e-12); -/// assert!((state[2] - 300.0).abs() < 1e-12); -/// # Ok::<_, SpiceError>(()) -/// ``` -pub fn segment_for_summary( - file_data: &[u8], - daf: &Daf, - summary: &Summary, -) -> Result { - match summary.data_type { - 2 => Ok(SpkSegment::Type2(read_chebyshev( - file_data, daf, summary, 3, - )?)), - 3 => Ok(SpkSegment::Type3(read_chebyshev( - file_data, daf, summary, 6, - )?)), - other => Err(SpiceError::UnsupportedDataType { data_type: other }), - } -} - -fn read_chebyshev( - file_data: &[u8], - daf: &Daf, - summary: &Summary, - components: u8, -) -> Result { - let end = summary.end_word; - if end < 4 { - return Err(SpiceError::Parse { - message: format!("segment trailer at end_word={end} is too small"), - }); - } - let n_records = daf.read_f64_at_word(file_data, end) as usize; - let rsize = daf.read_f64_at_word(file_data, end - 1) as usize; - let intlen = daf.read_f64_at_word(file_data, end - 2); - let init = daf.read_f64_at_word(file_data, end - 3); - - if !(5..=4096).contains(&rsize) { - return Err(SpiceError::Parse { - message: format!("implausible rsize={rsize} for SPK Chebyshev segment"), - }); - } - let comp = components as usize; - if !(rsize - 2).is_multiple_of(comp) { - return Err(SpiceError::Parse { - message: format!("rsize={rsize} is not 2 + {comp}*k for any k (components={comp})"), - }); - } - let ncoeff = (rsize - 2) / comp; - if n_records == 0 || n_records > 10_000_000 { - return Err(SpiceError::Parse { - message: format!("implausible n_records={n_records}"), - }); - } - if !(intlen.is_finite() && intlen > 0.0) { - return Err(SpiceError::Parse { - message: format!("non-positive intlen={intlen}"), - }); - } - if !init.is_finite() { - return Err(SpiceError::Parse { - message: format!("non-finite init={init}"), - }); - } - - let total = n_records - .checked_mul(rsize) - .ok_or_else(|| SpiceError::Parse { - message: format!("segment record count overflow: n_records={n_records} rsize={rsize}"), - })?; - let data_start_word = summary.start_word; - if data_start_word + total - 1 > summary.end_word { - return Err(SpiceError::Parse { - message: format!( - "segment data window [{}..{}] does not fit in summary [{}..{}]", - data_start_word, - data_start_word + total - 1, - summary.start_word, - summary.end_word - ), - }); - } - - let mut records = Vec::with_capacity(total); - for i in 0..total { - records.push(daf.read_f64_at_word(file_data, data_start_word + i)); - } - - Ok(ChebSegment { - init, - intlen, - rsize, - ncoeff, - n_records, - components, - records, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Build a synthetic SPK Type 2 segment buffer with a single record - /// holding constant coefficients per axis. - fn build_const_type2_segment(x: f64, y: f64, z: f64) -> (Vec, Daf, Summary) { - let ncoeff = 1; - let rsize = 2 + 3 * ncoeff; - let n_records = 1; - let n_words = rsize * n_records + 4; // + trailer (init, intlen, rsize, n) - let mut buf = vec![0u8; n_words * 8]; - let write = |word: usize, v: f64, b: &mut [u8]| { - b[(word - 1) * 8..word * 8].copy_from_slice(&v.to_le_bytes()); - }; - // Record words 1..=5 - write(1, 0.5, &mut buf); // mid - write(2, 0.5, &mut buf); // radius - write(3, x, &mut buf); - write(4, y, &mut buf); - write(5, z, &mut buf); - // Trailer at words 6..=9 - write(6, 0.0, &mut buf); // init - write(7, 1.0, &mut buf); // intlen - write(8, rsize as f64, &mut buf); - write(9, n_records as f64, &mut buf); - - let daf = Daf { - nd: 2, - ni: 6, - summaries: vec![], - }; - let summary = Summary { - start_et: 0.0, - end_et: 1.0, - target_id: 399, - center_id: 0, - frame_id: 1, - data_type: 2, - start_word: 1, - end_word: 9, - }; - (buf, daf, summary) - } - - #[test] - fn type2_constant_polynomial_recovers_position() { - let (buf, daf, summary) = build_const_type2_segment(1.0, 2.0, 3.0); - let seg = segment_for_summary(&buf, &daf, &summary).unwrap(); - let state = seg.evaluate(0.5).unwrap(); - assert_eq!(state[0], 1.0); - assert_eq!(state[1], 2.0); - assert_eq!(state[2], 3.0); - // Constant polynomial has zero analytic derivative. - assert_eq!(state[3], 0.0); - assert_eq!(state[4], 0.0); - assert_eq!(state[5], 0.0); - } - - #[test] - fn type2_linear_polynomial_recovers_velocity() { - // Build a 1-record segment with ncoeff=2, components=3. - // p(tau) = c0 + c1 * T1(tau) = c0 + c1 * tau - // Position at tau is c0 + c1*tau, velocity is c1/radius. - let ncoeff = 2; - let rsize = 2 + 3 * ncoeff; // 8 - let n_records = 1; - let n_words = rsize * n_records + 4; // 12 - let mut buf = vec![0u8; n_words * 8]; - let write = |word: usize, v: f64, b: &mut [u8]| { - b[(word - 1) * 8..word * 8].copy_from_slice(&v.to_le_bytes()); - }; - let radius = 2.0_f64; - write(1, 1.0, &mut buf); // mid - write(2, radius, &mut buf); // radius - // x = 10 + 4*tau ; y = 0 ; z = -1 + 5*tau - write(3, 10.0, &mut buf); - write(4, 4.0, &mut buf); - write(5, 0.0, &mut buf); - write(6, 0.0, &mut buf); - write(7, -1.0, &mut buf); - write(8, 5.0, &mut buf); - // Trailer at 9..=12 - write(9, 0.0, &mut buf); // init - write(10, 4.0, &mut buf); // intlen - write(11, rsize as f64, &mut buf); - write(12, n_records as f64, &mut buf); - - let daf = Daf { - nd: 2, - ni: 6, - summaries: vec![], - }; - let summary = Summary { - start_et: 0.0, - end_et: 4.0, - target_id: 399, - center_id: 0, - frame_id: 1, - data_type: 2, - start_word: 1, - end_word: 12, - }; - let seg = segment_for_summary(&buf, &daf, &summary).unwrap(); - // Pick et = 1.0 (record midpoint); tau = 0 - let s = seg.evaluate(1.0).unwrap(); - assert!((s[0] - 10.0).abs() < 1e-12); - assert!((s[1]).abs() < 1e-12); - assert!((s[2] + 1.0).abs() < 1e-12); - assert!((s[3] - 4.0 / radius).abs() < 1e-12); - assert!((s[4]).abs() < 1e-12); - assert!((s[5] - 5.0 / radius).abs() < 1e-12); - } - - #[test] - fn type3_recovers_independent_velocity_polynomial() { - // Single record, ncoeff=1, components=6. Position constants = - // (10, 20, 30); velocity constants = (1, 2, 3). - let ncoeff = 1; - let rsize = 2 + 6 * ncoeff; // 8 - let n_records = 1; - let n_words = rsize * n_records + 4; - let mut buf = vec![0u8; n_words * 8]; - let write = |word: usize, v: f64, b: &mut [u8]| { - b[(word - 1) * 8..word * 8].copy_from_slice(&v.to_le_bytes()); - }; - write(1, 0.5, &mut buf); // mid - write(2, 0.5, &mut buf); // radius - write(3, 10.0, &mut buf); - write(4, 20.0, &mut buf); - write(5, 30.0, &mut buf); - write(6, 1.0, &mut buf); - write(7, 2.0, &mut buf); - write(8, 3.0, &mut buf); - write(9, 0.0, &mut buf); // init - write(10, 1.0, &mut buf); // intlen - write(11, rsize as f64, &mut buf); - write(12, n_records as f64, &mut buf); - let daf = Daf { - nd: 2, - ni: 6, - summaries: vec![], - }; - let summary = Summary { - start_et: 0.0, - end_et: 1.0, - target_id: 399, - center_id: 0, - frame_id: 1, - data_type: 3, - start_word: 1, - end_word: 12, - }; - let seg = segment_for_summary(&buf, &daf, &summary).unwrap(); - let s = seg.evaluate(0.5).unwrap(); - assert_eq!(s, [10.0, 20.0, 30.0, 1.0, 2.0, 3.0]); - } - - #[test] - fn out_of_coverage_returned_for_epoch_outside_range() { - let (buf, daf, summary) = build_const_type2_segment(1.0, 2.0, 3.0); - let seg = segment_for_summary(&buf, &daf, &summary).unwrap(); - let err = seg.evaluate(2.0).unwrap_err(); - assert!(matches!(err, SpiceError::OutOfCoverage { .. })); - } - - #[test] - fn unsupported_data_type_rejected() { - let (buf, daf, mut summary) = build_const_type2_segment(1.0, 2.0, 3.0); - summary.data_type = 13; - let err = segment_for_summary(&buf, &daf, &summary).unwrap_err(); - assert!(matches!( - err, - SpiceError::UnsupportedDataType { data_type: 13 } - )); - } - - #[test] - fn rsize_not_divisible_by_components_rejected() { - // rsize=6 with components=3 → (6-2)%3=1 → reject - let n_words = 10; - let mut buf = vec![0u8; n_words * 8]; - let write = |word: usize, v: f64, b: &mut [u8]| { - b[(word - 1) * 8..word * 8].copy_from_slice(&v.to_le_bytes()); - }; - write(7, 0.0, &mut buf); // init - write(8, 1.0, &mut buf); // intlen - write(9, 6.0, &mut buf); // rsize = 6 (invalid for components=3) - write(10, 1.0, &mut buf); // n_records - let daf = Daf { - nd: 2, - ni: 6, - summaries: vec![], - }; - let summary = Summary { - start_et: 0.0, - end_et: 1.0, - target_id: 0, - center_id: 0, - frame_id: 1, - data_type: 2, - start_word: 1, - end_word: 10, - }; - let err = segment_for_summary(&buf, &daf, &summary).unwrap_err(); - assert!(matches!(err, SpiceError::Parse { .. })); - } -} diff --git a/src/tle/builder.rs b/src/tle/builder.rs deleted file mode 100644 index a3572cb..0000000 --- a/src/tle/builder.rs +++ /dev/null @@ -1,447 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Programmatic constructor for [`Tle`] records. -//! -//! [`TleBuilder`] is the synthesis counterpart of the parser in -//! [`crate::parse_tle`]. It is used by `spod::sgp4` tests and by any -//! caller that needs to fabricate plausible TLEs (regression suites, -//! property tests, scenario generators). -//! -//! All public setters take **typed** quantities; there is no `f64`-only -//! entry point for fields that have a typed representation. - -use chrono::{Datelike, Timelike}; -use siderust::qtty::angular::Degrees; -use siderust::qtty::{angular::Turn, angular_rate::AngularRate, time::Day}; -use siderust::tempoch::{Time, UTC}; - -use super::parse::compute_tle_checksum; -use super::TleError; -use crate::tle::{Classification, InternationalDesignator, SatelliteNumber, Tle}; - -/// Typed builder for [`Tle`]. -/// -/// Required fields are `norad_id`, `international_designator`, `epoch`, -/// `inclination`, `raan`, `eccentricity`, `argument_of_perigee`, -/// `mean_anomaly`, and `mean_motion`. Optional fields default to sensible -/// values (`Classification::Unclassified`, zero drag terms, element-set -/// and revolution counters of zero, and no name). -/// -/// # Examples -/// -/// ``` -/// use spod::tle::{Classification, InternationalDesignator, SatelliteNumber, TleBuilder}; -/// use siderust::qtty::angular::Degrees; -/// use siderust::qtty::{angular::Turn, angular_rate::AngularRate, time::Day}; -/// use siderust::tempoch::{Time, UTC}; -/// use chrono::{TimeZone, Utc}; -/// -/// let dt = Utc.with_ymd_and_hms(2008, 9, 20, 12, 25, 40).unwrap(); -/// let tle = TleBuilder::new() -/// .norad_id(SatelliteNumber(25_544)) -/// .international_designator(InternationalDesignator("98067A".into())) -/// .classification(Classification::Unclassified) -/// .epoch(Time::::try_from_chrono(dt).unwrap()) -/// .inclination(Degrees::new(51.6416)) -/// .raan(Degrees::new(247.4627)) -/// .eccentricity(0.0006703) -/// .argument_of_perigee(Degrees::new(130.5360)) -/// .mean_anomaly(Degrees::new(325.0288)) -/// .mean_motion(AngularRate::::new(15.72125391)) -/// .name("ISS (ZARYA)") -/// .build() -/// .unwrap(); -/// assert_eq!(tle.norad_id.0, 25_544); -/// ``` -#[derive(Default, Clone, Debug)] -pub struct TleBuilder { - name: Option, - norad_id: Option, - classification: Option, - international_designator: Option, - epoch: Option>, - mean_motion_dot: f64, - mean_motion_ddot: f64, - bstar: f64, - element_set_number: u16, - revolution_number_at_epoch: u32, - inclination: Option, - raan: Option, - eccentricity: Option, - argument_of_perigee: Option, - mean_anomaly: Option, - mean_motion: Option>, -} - -impl TleBuilder { - /// Construct an empty builder. - /// - /// # Examples - /// - /// ``` - /// use spod::tle::TleBuilder; - /// let _b = TleBuilder::new(); - /// ``` - pub fn new() -> Self { - Self::default() - } - - /// Set the optional satellite name (line 0 of a 3LE). - /// - /// # Examples - /// - /// ``` - /// use spod::tle::TleBuilder; - /// let _b = TleBuilder::new().name("ISS (ZARYA)"); - /// ``` - pub fn name(mut self, name: impl Into) -> Self { - self.name = Some(name.into()); - self - } - - /// Set the NORAD catalog id. - /// - /// # Examples - /// - /// ``` - /// use spod::tle::{SatelliteNumber, TleBuilder}; - /// let _b = TleBuilder::new().norad_id(SatelliteNumber(25_544)); - /// ``` - pub fn norad_id(mut self, n: SatelliteNumber) -> Self { - self.norad_id = Some(n); - self - } - - /// Set the classification character. - /// - /// # Examples - /// - /// ``` - /// use spod::tle::{Classification, TleBuilder}; - /// let _b = TleBuilder::new().classification(Classification::Unclassified); - /// ``` - pub fn classification(mut self, c: Classification) -> Self { - self.classification = Some(c); - self - } - - /// Set the COSPAR (international) designator. - /// - /// # Examples - /// - /// ``` - /// use spod::tle::{InternationalDesignator, TleBuilder}; - /// let _b = TleBuilder::new() - /// .international_designator(InternationalDesignator("98067A".into())); - /// ``` - pub fn international_designator(mut self, d: InternationalDesignator) -> Self { - self.international_designator = Some(d); - self - } - - /// Set the epoch (UTC). - /// - /// # Examples - /// - /// ``` - /// use spod::tle::TleBuilder; - /// use siderust::tempoch::{Time, UTC}; - /// use chrono::Utc; - /// let _b = TleBuilder::new().epoch(Time::::from_chrono(Utc::now())); - /// ``` - pub fn epoch(mut self, t: Time) -> Self { - self.epoch = Some(t); - self - } - - /// Set the first time derivative of mean motion (revolutions per day²). - /// - /// # Examples - /// - /// ``` - /// use spod::tle::TleBuilder; - /// let _b = TleBuilder::new().mean_motion_dot(-2.182e-5); - /// ``` - pub fn mean_motion_dot(mut self, v: f64) -> Self { - self.mean_motion_dot = v; - self - } - - /// Set the second time derivative of mean motion (revolutions per day³). - /// - /// # Examples - /// - /// ``` - /// use spod::tle::TleBuilder; - /// let _b = TleBuilder::new().mean_motion_ddot(0.0); - /// ``` - pub fn mean_motion_ddot(mut self, v: f64) -> Self { - self.mean_motion_ddot = v; - self - } - - /// Set the SGP4 BSTAR drag term (1 / Earth radii). - /// - /// # Examples - /// - /// ``` - /// use spod::tle::TleBuilder; - /// let _b = TleBuilder::new().bstar(-0.11606e-4); - /// ``` - pub fn bstar(mut self, v: f64) -> Self { - self.bstar = v; - self - } - - /// Set the element-set number. - /// - /// # Examples - /// - /// ``` - /// use spod::tle::TleBuilder; - /// let _b = TleBuilder::new().element_set_number(292); - /// ``` - pub fn element_set_number(mut self, v: u16) -> Self { - self.element_set_number = v; - self - } - - /// Set the revolution number at epoch. - /// - /// # Examples - /// - /// ``` - /// use spod::tle::TleBuilder; - /// let _b = TleBuilder::new().revolution_number_at_epoch(56_353); - /// ``` - pub fn revolution_number_at_epoch(mut self, v: u32) -> Self { - self.revolution_number_at_epoch = v; - self - } - - /// Set the inclination. - /// - /// # Examples - /// - /// ``` - /// use spod::tle::TleBuilder; - /// use siderust::qtty::angular::Degrees; - /// let _b = TleBuilder::new().inclination(Degrees::new(51.6416)); - /// ``` - pub fn inclination(mut self, v: Degrees) -> Self { - self.inclination = Some(v); - self - } - - /// Set the right ascension of the ascending node. - /// - /// # Examples - /// - /// ``` - /// use spod::tle::TleBuilder; - /// use siderust::qtty::angular::Degrees; - /// let _b = TleBuilder::new().raan(Degrees::new(247.4627)); - /// ``` - pub fn raan(mut self, v: Degrees) -> Self { - self.raan = Some(v); - self - } - - /// Set the eccentricity (dimensionless, 0 ≤ e < 1). - /// - /// # Examples - /// - /// ``` - /// use spod::tle::TleBuilder; - /// let _b = TleBuilder::new().eccentricity(0.0006703); - /// ``` - pub fn eccentricity(mut self, v: f64) -> Self { - self.eccentricity = Some(v); - self - } - - /// Set the argument of perigee. - /// - /// # Examples - /// - /// ``` - /// use spod::tle::TleBuilder; - /// use siderust::qtty::angular::Degrees; - /// let _b = TleBuilder::new().argument_of_perigee(Degrees::new(130.5360)); - /// ``` - pub fn argument_of_perigee(mut self, v: Degrees) -> Self { - self.argument_of_perigee = Some(v); - self - } - - /// Set the mean anomaly. - /// - /// # Examples - /// - /// ``` - /// use spod::tle::TleBuilder; - /// use siderust::qtty::angular::Degrees; - /// let _b = TleBuilder::new().mean_anomaly(Degrees::new(325.0288)); - /// ``` - pub fn mean_anomaly(mut self, v: Degrees) -> Self { - self.mean_anomaly = Some(v); - self - } - - /// Set the typed mean motion (revolutions per day). - /// - /// # Examples - /// - /// ``` - /// use spod::tle::TleBuilder; - /// use siderust::qtty::{angular::Turn, angular_rate::AngularRate, time::Day}; - /// let _b = TleBuilder::new() - /// .mean_motion(AngularRate::::new(15.72125391)); - /// ``` - pub fn mean_motion(mut self, v: AngularRate) -> Self { - self.mean_motion = Some(v); - self - } - - /// Finalize the builder into a [`Tle`]. - /// - /// Returns [`TleError::BuilderMissingField`] if any mandatory field - /// was left unset. - /// - /// # Examples - /// - /// ``` - /// use spod::tle::{TleBuilder, TleError}; - /// assert!(matches!(TleBuilder::new().build(), Err(TleError::BuilderMissingField(_)))); - /// ``` - pub fn build(self) -> Result { - Ok(Tle { - name: self.name, - norad_id: self - .norad_id - .ok_or(TleError::BuilderMissingField("norad_id"))?, - classification: self.classification.unwrap_or(Classification::Unclassified), - international_designator: self - .international_designator - .ok_or(TleError::BuilderMissingField("international_designator"))?, - epoch: self.epoch.ok_or(TleError::BuilderMissingField("epoch"))?, - mean_motion_dot: self.mean_motion_dot, - mean_motion_ddot: self.mean_motion_ddot, - bstar: self.bstar, - element_set_number: self.element_set_number, - revolution_number_at_epoch: self.revolution_number_at_epoch, - inclination: self - .inclination - .ok_or(TleError::BuilderMissingField("inclination"))?, - raan: self.raan.ok_or(TleError::BuilderMissingField("raan"))?, - eccentricity: self - .eccentricity - .ok_or(TleError::BuilderMissingField("eccentricity"))?, - argument_of_perigee: self - .argument_of_perigee - .ok_or(TleError::BuilderMissingField("argument_of_perigee"))?, - mean_anomaly: self - .mean_anomaly - .ok_or(TleError::BuilderMissingField("mean_anomaly"))?, - mean_motion: self - .mean_motion - .ok_or(TleError::BuilderMissingField("mean_motion"))?, - }) - } -} - -/// Render a [`Tle`] back to its canonical 2-line ASCII representation. -/// -/// The returned tuple is `(line1, line2)`; both are 69 ASCII characters -/// long with a valid checksum digit. Composing them with the satellite -/// `name` recovers the 3LE form. -/// -/// # Examples -/// -/// ``` -/// use spod::tle::{format_tle, parse_tle}; -/// let l1 = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927"; -/// let l2 = "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537"; -/// let tle = parse_tle(l1, l2).unwrap(); -/// let (rl1, rl2) = format_tle(&tle).unwrap(); -/// assert_eq!(rl1, l1); -/// assert_eq!(rl2, l2); -/// ``` -pub fn format_tle(tle: &Tle) -> Result<(String, String), TleError> { - let cat = tle.norad_id.format_alpha5()?; - let cls = tle.classification.as_char(); - let intl = format!("{:<8}", tle.international_designator.0); - let dt = tle - .epoch - .try_to_chrono() - .map_err(|e| TleError::EpochConversion(format!("{e:?}")))?; - let year = dt.year(); - let yy = ((year % 100) + 100) % 100; - let doy = dt.ordinal(); - let secs_of_day = - dt.num_seconds_from_midnight() as f64 + dt.timestamp_subsec_nanos() as f64 * 1e-9; - let frac_day = secs_of_day / 86_400.0; - let day_full = doy as f64 + frac_day; - let n_dot = format_signed_decimal(tle.mean_motion_dot); - let n_ddot = format_assumed_decimal_exponent(tle.mean_motion_ddot); - let bstar_s = format_assumed_decimal_exponent(tle.bstar); - let elset = format!("{:>4}", tle.element_set_number); - let mut l1 = - format!("1 {cat}{cls} {intl} {yy:02}{day_full:012.8} {n_dot} {n_ddot} {bstar_s} 0 {elset}"); - debug_assert_eq!( - l1.len(), - 68, - "rendered TLE line1 prefix not 68 chars: {l1:?}" - ); - let cs1 = compute_tle_checksum(&l1); - l1.push_str(&cs1.to_string()); - - let inc = format!("{:8.4}", tle.inclination.value()); - let raan = format!("{:8.4}", tle.raan.value()); - let ecc_scaled = (tle.eccentricity * 1.0e7).round() as u64; - let ecc = format!("{ecc_scaled:07}"); - let argp = format!("{:8.4}", tle.argument_of_perigee.value()); - let m_ano = format!("{:8.4}", tle.mean_anomaly.value()); - let n_str = format!("{:11.8}", tle.mean_motion.value()); - let rev = format!("{:>5}", tle.revolution_number_at_epoch); - let mut l2 = format!("2 {cat} {inc} {raan} {ecc} {argp} {m_ano} {n_str}{rev}"); - debug_assert_eq!( - l2.len(), - 68, - "rendered TLE line2 prefix not 68 chars: {l2:?}" - ); - let cs2 = compute_tle_checksum(&l2); - l2.push_str(&cs2.to_string()); - Ok((l1, l2)) -} - -fn format_signed_decimal(v: f64) -> String { - let sign = if v.is_sign_negative() { '-' } else { ' ' }; - let mag = v.abs(); - let fract = (mag * 1.0e8).round() as u64; - format!("{sign}.{fract:08}") -} - -fn format_assumed_decimal_exponent(v: f64) -> String { - if v == 0.0 || !v.is_finite() { - return " 00000-0".to_string(); - } - let sign = if v.is_sign_negative() { '-' } else { ' ' }; - let mag = v.abs(); - let mut exp = mag.log10().floor() as i32 + 1; - let mut mantissa = mag / 10f64.powi(exp); - let scaled = (mantissa * 1.0e5).round() as u64; - if scaled >= 1_000_000 { - mantissa /= 10.0; - exp += 1; - } - let scaled = (mantissa * 1.0e5).round() as u64; - let exp_sign = if exp < 0 { '-' } else { '+' }; - let exp_abs = exp.unsigned_abs(); - if exp_sign == '+' { - format!("{sign}{scaled:05}+{exp_abs}") - } else { - format!("{sign}{scaled:05}-{exp_abs}") - } -} diff --git a/src/tle/error.rs b/src/tle/error.rs deleted file mode 100644 index 6b6bf35..0000000 --- a/src/tle/error.rs +++ /dev/null @@ -1,127 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Unified error taxonomy for `spod::tle`. -//! -//! The crate exposes exactly one error enum, [`TleError`], covering every -//! parser branch (classic 2LE/3LE, OMM-KVN, OMM-XML, OMM-JSON) as well as -//! programmatic-construction failures from [`crate::TleBuilder`]. - -/// Errors produced by every parser, writer and builder in `spod::tle`. -/// -/// All public APIs return `Result<_, TleError>`; the crate never depends on -/// `anyhow`. Variants are deliberately structural so callers can react -/// programmatically (e.g. tolerate `BadChecksum` while rejecting `BadLength`). -/// -/// # Examples -/// -/// ``` -/// use spod::tle::{parse_tle, TleError}; -/// -/// let too_short = "1 25544"; -/// let other = "2 25544"; -/// match parse_tle(too_short, other) { -/// Err(TleError::BadLength { line, .. }) => assert_eq!(line, 1), -/// other => panic!("unexpected: {other:?}"), -/// } -/// ``` -#[derive(Debug, thiserror::Error, PartialEq)] -pub enum TleError { - /// A TLE line did not have the required 69-character length. - #[error("line {line} length is {got}, expected 69")] - BadLength { - /// TLE line number (1 or 2). - line: u8, - /// Actual length in characters. - got: usize, - }, - /// A TLE line's first character does not match the expected line number marker. - #[error("line {line} starts with {found:?}, expected leading marker {expected:?}")] - BadLeadingChar { - /// TLE line number (1 or 2). - line: u8, - /// Expected marker character ('1' or '2'). - expected: char, - /// Actual first character found. - found: char, - }, - /// A TLE line's checksum (last column) did not match the computed value. - #[error("line {line} checksum mismatch: stated {stated}, computed {computed}")] - BadChecksum { - /// TLE line number (1 or 2). - line: u8, - /// Stated checksum digit from the TLE. - stated: u8, - /// Computed checksum value. - computed: u8, - }, - /// NORAD catalog number differs between line 1 and line 2 of a TLE. - #[error("NORAD catalog id mismatch between line 1 ({l1}) and line 2 ({l2})")] - MismatchedSatelliteNumber { - /// NORAD ID from line 1. - l1: u32, - /// NORAD ID from line 2. - l2: u32, - }, - /// A numeric field could not be parsed as a number. - #[error("invalid number in field {field:?}: {raw:?}")] - InvalidNumber { - /// Field name that failed to parse. - field: &'static str, - /// Raw string that could not be converted to a number. - raw: String, - }, - /// Alpha-5 catalog number encoding is malformed. - #[error("invalid Alpha-5 catalog id {raw:?}")] - InvalidAlpha5 { - /// Raw string that failed Alpha-5 decoding. - raw: String, - }, - /// Epoch (year, day-of-year) does not correspond to a valid calendar date. - #[error("invalid epoch (year {year}, doy {day_of_year}): {reason}")] - InvalidEpoch { - /// Year component of the epoch. - year: i32, - /// Day-of-year component (1.0-366.x). - day_of_year: f64, - /// Descriptive reason the date is invalid. - reason: &'static str, - }, - /// Conversion of epoch (year, day-of-year) to UTC Time failed. - #[error("epoch conversion to UTC failed: {0}")] - EpochConversion(String), - /// Classification character is not one of U, C, or S. - #[error("invalid classification {raw:?}; expected U/C/S")] - InvalidClassification { - /// Classification character that was not recognized. - raw: char, - }, - /// An OMM document was missing a mandatory field. - #[error("OMM is missing required field {0:?}")] - OmmMissingField(&'static str), - /// An OMM ISO-8601 epoch could not be parsed. - #[error("invalid OMM epoch {raw:?}: {reason}")] - OmmInvalidEpoch { - /// The raw epoch string. - raw: String, - /// Why the epoch was rejected. - reason: &'static str, - }, - /// An OMM document used an unsupported `MEAN_ELEMENT_THEORY` value. - #[error("unsupported MEAN_ELEMENT_THEORY {0:?}; only \"SGP4\" is supported")] - OmmUnsupportedTheory(String), - /// An OMM document was syntactically malformed (KVN/XML/JSON). - #[error("malformed OMM document ({format}): {reason}")] - OmmMalformed { - /// `KVN`, `XML`, or `JSON`. - format: &'static str, - /// Why the document was rejected. - reason: String, - }, - /// An OMM JSON document failed to deserialize. - #[error("OMM JSON error: {0}")] - OmmJson(String), - /// A [`crate::tle::TleBuilder`] is missing a mandatory field. - #[error("TleBuilder is missing required field {0:?}")] - BuilderMissingField(&'static str), -} diff --git a/src/tle/mod.rs b/src/tle/mod.rs deleted file mode 100644 index 36ece2b..0000000 --- a/src/tle/mod.rs +++ /dev/null @@ -1,71 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! `spod::tle` — Two-Line Element / 3LE / OMM parser & writer. -//! -//! This crate models the canonical NORAD TLE format and the CCSDS -//! Orbit Mean-elements Message (OMM) in three encodings — KVN, XML, and -//! JSON — sharing one in-memory record set: -//! -//! * column-accurate field extraction (TLE is column-positional, not -//! whitespace-separated), -//! * line-checksum validation, -//! * Alpha-5 NORAD-ID extension (catalog numbers ≥ 100 000), -//! * strongly typed angles (`siderust::qtty::angular::Degrees`) and mean motion -//! (`siderust::qtty::unit::AngularRate`), -//! * `siderust::tempoch::Time` epochs derived from the (year, day-of-year, -//! fractional-day) encoding via `chrono`, -//! * round-trip OMM-KVN / OMM-XML / OMM-JSON parity through [`omm::Omm`], -//! * a programmatic [`TleBuilder`] for synthesising TLEs (used by -//! `spod::sgp4` regression tests), -//! * single error taxonomy via [`TleError`] (no `anyhow`). -//! -//! ## Design notes -//! -//! * **No XML library dependency.** The OMM-XML schema we have to -//! round-trip is small, flat, and stable; the hand-rolled -//! element-extractor in [`omm::xml`] is a deliberate trade against -//! pulling in a general-purpose XML crate. The reader/writer accept -//! only the subset of OMM XML used by Celestrak and CCSDS Annex E -//! examples. -//! * **`f64` on the public surface is restricted** to fields where no -//! typed `qtty`/`tempoch`/`affn` equivalent improves safety: -//! eccentricity (dimensionless, range-checked at construction), -//! `BSTAR` (SGP4 fitted pseudo-coefficient with mixed units), and the -//! as-printed mean-motion derivatives. Each such field is documented -//! with a `// rationale: …` comment near its declaration. -//! -//! Out-of-scope: -//! -//! * SGP4/SDP4 propagation lives in `spod::sgp4`. -//! -//! # Examples -//! -//! ``` -//! use spod::tle::{parse_3le, omm::{kvn, Omm}}; -//! -//! let l1 = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927"; -//! let l2 = "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537"; -//! let tle = parse_3le("ISS (ZARYA)", l1, l2).unwrap(); -//! let omm = Omm::from_tle(&tle); -//! let kvn_text = kvn::write(&omm).unwrap(); -//! let omm2 = kvn::read(&kvn_text).unwrap(); -//! assert_eq!(omm2.norad_id, tle.norad_id); -//! ``` - -#![forbid(unsafe_code)] - -mod builder; -#[path = "tle.rs"] -mod elements; -mod error; -pub mod omm; -mod parse; - -pub use builder::{format_tle, TleBuilder}; -pub use elements::{Classification, InternationalDesignator, SatelliteNumber, Tle}; -pub use error::TleError; -pub use parse::{compute_tle_checksum, parse_3le, parse_tle, validate_tle_checksum}; - -#[cfg(test)] -mod tests; diff --git a/src/tle/omm/json.rs b/src/tle/omm/json.rs deleted file mode 100644 index 857d695..0000000 --- a/src/tle/omm/json.rs +++ /dev/null @@ -1,177 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! JSON encoding for OMM, matching Celestrak's flat schema. - -use serde::{Deserialize, Serialize}; -use siderust::qtty::angular::Degrees; -use siderust::qtty::{angular::Turn, angular_rate::AngularRate, time::Day}; - -use super::{format_epoch, parse_epoch, Omm}; -use crate::tle::TleError; -use crate::tle::{Classification, SatelliteNumber}; - -/// Render an [`Omm`] (or a slice of them) to Celestrak-style JSON. -/// -/// # Examples -/// -/// ``` -/// use spod::tle::{omm::{Omm, json}, parse_3le}; -/// let l1 = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927"; -/// let l2 = "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537"; -/// let omm = Omm::from_tle(&parse_3le("ISS (ZARYA)", l1, l2).unwrap()); -/// let s = json::write(&omm).unwrap(); -/// assert!(s.contains("\"NORAD_CAT_ID\":25544")); -/// ``` -pub fn write(omm: &Omm) -> Result { - let dto = OmmDto::from_omm(omm)?; - serde_json::to_string(&dto).map_err(|e| TleError::OmmJson(e.to_string())) -} - -/// Render a slice of [`Omm`] as a JSON array. -/// -/// # Examples -/// -/// ``` -/// use spod::tle::{omm::{Omm, json}, parse_3le}; -/// let l1 = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927"; -/// let l2 = "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537"; -/// let omm = Omm::from_tle(&parse_3le("ISS (ZARYA)", l1, l2).unwrap()); -/// let arr = json::write_many(&[omm]).unwrap(); -/// assert!(arr.starts_with('[')); -/// ``` -pub fn write_many(items: &[Omm]) -> Result { - let dtos: Vec = items - .iter() - .map(OmmDto::from_omm) - .collect::>()?; - serde_json::to_string(&dtos).map_err(|e| TleError::OmmJson(e.to_string())) -} - -/// Parse a single [`Omm`] from a JSON object. -/// -/// # Examples -/// -/// ``` -/// use spod::tle::omm::json; -/// let s = r#"{"OBJECT_NAME":"X","OBJECT_ID":"1998-067A", -/// "EPOCH":"2008-09-20T12:25:40.104192","MEAN_MOTION":15.72125391, -/// "ECCENTRICITY":0.0006703,"INCLINATION":51.6416, -/// "RA_OF_ASC_NODE":247.4627,"ARG_OF_PERICENTER":130.5360, -/// "MEAN_ANOMALY":325.0288,"EPHEMERIS_TYPE":0, -/// "CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":25544, -/// "ELEMENT_SET_NO":292,"REV_AT_EPOCH":56353, -/// "BSTAR":-1.1606e-5,"MEAN_MOTION_DOT":-2.182e-5,"MEAN_MOTION_DDOT":0.0}"#; -/// let omm = json::read(s).unwrap(); -/// assert_eq!(omm.norad_id.0, 25544); -/// ``` -pub fn read(input: &str) -> Result { - let dto: OmmDto = serde_json::from_str(input).map_err(|e| TleError::OmmJson(e.to_string()))?; - dto.into_omm() -} - -/// Parse a JSON array of OMMs. -/// -/// # Examples -/// -/// ``` -/// use spod::tle::omm::json; -/// let arr = "[]"; -/// let omms = json::read_many(arr).unwrap(); -/// assert!(omms.is_empty()); -/// ``` -pub fn read_many(input: &str) -> Result, TleError> { - let dtos: Vec = - serde_json::from_str(input).map_err(|e| TleError::OmmJson(e.to_string()))?; - dtos.into_iter().map(OmmDto::into_omm).collect() -} - -/// Wire-shape DTO for the OMM JSON document. Field names match the -/// Celestrak GP catalog payload (uppercase, no transformation). -#[derive(Serialize, Deserialize)] -#[allow(non_snake_case)] -struct OmmDto { - OBJECT_NAME: String, - OBJECT_ID: String, - EPOCH: String, - MEAN_MOTION: f64, - ECCENTRICITY: f64, - INCLINATION: f64, - RA_OF_ASC_NODE: f64, - ARG_OF_PERICENTER: f64, - MEAN_ANOMALY: f64, - #[serde(default)] - EPHEMERIS_TYPE: u8, - #[serde(default = "default_classification")] - CLASSIFICATION_TYPE: String, - NORAD_CAT_ID: u32, - #[serde(default)] - ELEMENT_SET_NO: u16, - #[serde(default)] - REV_AT_EPOCH: u32, - #[serde(default)] - BSTAR: f64, - #[serde(default)] - MEAN_MOTION_DOT: f64, - #[serde(default)] - MEAN_MOTION_DDOT: f64, - #[serde(default)] - MEAN_ELEMENT_THEORY: Option, -} - -fn default_classification() -> String { - "U".into() -} - -impl OmmDto { - fn from_omm(omm: &Omm) -> Result { - Ok(Self { - OBJECT_NAME: omm.object_name.clone(), - OBJECT_ID: omm.object_id.clone(), - EPOCH: format_epoch(omm.epoch)?, - MEAN_MOTION: omm.mean_motion.value(), - ECCENTRICITY: omm.eccentricity, - INCLINATION: omm.inclination.value(), - RA_OF_ASC_NODE: omm.ra_of_asc_node.value(), - ARG_OF_PERICENTER: omm.arg_of_pericenter.value(), - MEAN_ANOMALY: omm.mean_anomaly.value(), - EPHEMERIS_TYPE: omm.ephemeris_type, - CLASSIFICATION_TYPE: omm.classification.as_char().to_string(), - NORAD_CAT_ID: omm.norad_id.0, - ELEMENT_SET_NO: omm.element_set_no, - REV_AT_EPOCH: omm.rev_at_epoch, - BSTAR: omm.bstar, - MEAN_MOTION_DOT: omm.mean_motion_dot, - MEAN_MOTION_DDOT: omm.mean_motion_ddot, - MEAN_ELEMENT_THEORY: Some("SGP4".to_string()), - }) - } - - fn into_omm(self) -> Result { - if let Some(t) = &self.MEAN_ELEMENT_THEORY { - if !t.eq_ignore_ascii_case("SGP4") { - return Err(TleError::OmmUnsupportedTheory(t.clone())); - } - } - let cls_char = self.CLASSIFICATION_TYPE.chars().next().unwrap_or('U'); - Ok(Omm { - object_name: self.OBJECT_NAME, - object_id: self.OBJECT_ID, - epoch: parse_epoch(&self.EPOCH)?, - mean_motion: AngularRate::::new(self.MEAN_MOTION), - eccentricity: self.ECCENTRICITY, - inclination: Degrees::new(self.INCLINATION), - ra_of_asc_node: Degrees::new(self.RA_OF_ASC_NODE), - arg_of_pericenter: Degrees::new(self.ARG_OF_PERICENTER), - mean_anomaly: Degrees::new(self.MEAN_ANOMALY), - ephemeris_type: self.EPHEMERIS_TYPE, - classification: Classification::from_char(cls_char)?, - norad_id: SatelliteNumber(self.NORAD_CAT_ID), - element_set_no: self.ELEMENT_SET_NO, - rev_at_epoch: self.REV_AT_EPOCH, - bstar: self.BSTAR, - mean_motion_dot: self.MEAN_MOTION_DOT, - mean_motion_ddot: self.MEAN_MOTION_DDOT, - }) - } -} diff --git a/src/tle/omm/kvn.rs b/src/tle/omm/kvn.rs deleted file mode 100644 index 396cf02..0000000 --- a/src/tle/omm/kvn.rs +++ /dev/null @@ -1,176 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Keyword-Value-Notation (KVN) encoding for OMM. - -use siderust::qtty::angular::Degrees; -use siderust::qtty::{angular::Turn, angular_rate::AngularRate, time::Day}; - -use super::{format_epoch, parse_epoch, Omm}; -use crate::tle::TleError; -use crate::tle::{Classification, SatelliteNumber}; - -/// Render an [`Omm`] to its KVN form. -/// -/// The output is a series of `KEY = VALUE` lines terminated with `\n`. -/// -/// # Examples -/// -/// ``` -/// use spod::tle::{omm::{Omm, kvn}, parse_3le}; -/// let l1 = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927"; -/// let l2 = "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537"; -/// let omm = Omm::from_tle(&parse_3le("ISS (ZARYA)", l1, l2).unwrap()); -/// let text = kvn::write(&omm).unwrap(); -/// assert!(text.contains("OBJECT_NAME = ISS (ZARYA)")); -/// ``` -pub fn write(omm: &Omm) -> Result { - let epoch = format_epoch(omm.epoch)?; - Ok(format!( - "CCSDS_OMM_VERS = 2.0\n\ - CREATION_DATE = {epoch}\n\ - ORIGINATOR = siderust-tle\n\ - OBJECT_NAME = {object_name}\n\ - OBJECT_ID = {object_id}\n\ - CENTER_NAME = EARTH\n\ - REF_FRAME = TEME\n\ - TIME_SYSTEM = UTC\n\ - MEAN_ELEMENT_THEORY = SGP4\n\ - EPOCH = {epoch}\n\ - MEAN_MOTION = {mean_motion:.8}\n\ - ECCENTRICITY = {ecc:.7}\n\ - INCLINATION = {inc:.4}\n\ - RA_OF_ASC_NODE = {raan:.4}\n\ - ARG_OF_PERICENTER = {argp:.4}\n\ - MEAN_ANOMALY = {ma:.4}\n\ - EPHEMERIS_TYPE = {eph}\n\ - CLASSIFICATION_TYPE = {cls}\n\ - NORAD_CAT_ID = {cat}\n\ - ELEMENT_SET_NO = {esn}\n\ - REV_AT_EPOCH = {rev}\n\ - BSTAR = {bstar:.8e}\n\ - MEAN_MOTION_DOT = {ndot:.8e}\n\ - MEAN_MOTION_DDOT = {nddot:.8e}\n", - object_name = omm.object_name, - object_id = omm.object_id, - mean_motion = omm.mean_motion.value(), - ecc = omm.eccentricity, - inc = omm.inclination.value(), - raan = omm.ra_of_asc_node.value(), - argp = omm.arg_of_pericenter.value(), - ma = omm.mean_anomaly.value(), - eph = omm.ephemeris_type, - cls = omm.classification.as_char(), - cat = omm.norad_id.0, - esn = omm.element_set_no, - rev = omm.rev_at_epoch, - bstar = omm.bstar, - ndot = omm.mean_motion_dot, - nddot = omm.mean_motion_ddot, - )) -} - -/// Parse an [`Omm`] from its KVN form. -/// -/// Comment lines (starting with `COMMENT`) and unknown keys are silently -/// preserved-by-discard, in the spirit of the CCSDS "tolerant reader" -/// principle. The reader is strict on field types: any present-but-malformed -/// numeric field returns [`TleError::OmmMalformed`]. -/// -/// # Examples -/// -/// ``` -/// use spod::tle::omm::kvn; -/// let text = "CCSDS_OMM_VERS = 2.0\nOBJECT_NAME = TEST\nOBJECT_ID = 1998-067A\n\ -/// MEAN_ELEMENT_THEORY = SGP4\nEPOCH = 2008-09-20T12:25:40.104192\n\ -/// MEAN_MOTION = 15.72125391\nECCENTRICITY = 0.0006703\n\ -/// INCLINATION = 51.6416\nRA_OF_ASC_NODE = 247.4627\n\ -/// ARG_OF_PERICENTER = 130.5360\nMEAN_ANOMALY = 325.0288\n\ -/// EPHEMERIS_TYPE = 0\nCLASSIFICATION_TYPE = U\nNORAD_CAT_ID = 25544\n\ -/// ELEMENT_SET_NO = 292\nREV_AT_EPOCH = 56353\n\ -/// BSTAR = -1.1606e-5\nMEAN_MOTION_DOT = -2.182e-5\n\ -/// MEAN_MOTION_DDOT = 0.0\n"; -/// let omm = kvn::read(text).unwrap(); -/// assert_eq!(omm.norad_id.0, 25544); -/// ``` -pub fn read(input: &str) -> Result { - use std::collections::HashMap; - let mut kv: HashMap = HashMap::new(); - for raw_line in input.lines() { - let line = raw_line.trim(); - if line.is_empty() || line.starts_with("COMMENT") { - continue; - } - let Some(eq) = line.find('=') else { - continue; - }; - let key = line[..eq].trim().to_string(); - let value = line[eq + 1..].trim().to_string(); - kv.insert(key, value); - } - - let theory = kv - .get("MEAN_ELEMENT_THEORY") - .map(String::as_str) - .unwrap_or("SGP4"); - if !theory.eq_ignore_ascii_case("SGP4") { - return Err(TleError::OmmUnsupportedTheory(theory.to_string())); - } - - let get = |k: &'static str| -> Result<&String, TleError> { - kv.get(k).ok_or(TleError::OmmMissingField(k)) - }; - let getf = |k: &'static str| -> Result { - let v = get(k)?; - v.parse::().map_err(|_| TleError::OmmMalformed { - format: "KVN", - reason: format!("{k}={v:?} is not a float"), - }) - }; - let getu = |k: &'static str| -> Result { - let v = get(k)?; - v.parse::().map_err(|_| TleError::OmmMalformed { - format: "KVN", - reason: format!("{k}={v:?} is not an unsigned integer"), - }) - }; - - let object_name = get("OBJECT_NAME")?.clone(); - let object_id = get("OBJECT_ID")?.clone(); - let epoch = parse_epoch(get("EPOCH")?)?; - let cls_raw = kv - .get("CLASSIFICATION_TYPE") - .map(String::as_str) - .unwrap_or("U"); - let cls_char = cls_raw.chars().next().unwrap_or('U'); - let classification = Classification::from_char(cls_char)?; - - let norad_str = get("NORAD_CAT_ID")?; - let norad_id = SatelliteNumber::parse(norad_str)?; - - Ok(Omm { - object_name, - object_id, - epoch, - mean_motion: AngularRate::::new(getf("MEAN_MOTION")?), - eccentricity: getf("ECCENTRICITY")?, - inclination: Degrees::new(getf("INCLINATION")?), - ra_of_asc_node: Degrees::new(getf("RA_OF_ASC_NODE")?), - arg_of_pericenter: Degrees::new(getf("ARG_OF_PERICENTER")?), - mean_anomaly: Degrees::new(getf("MEAN_ANOMALY")?), - ephemeris_type: kv - .get("EPHEMERIS_TYPE") - .and_then(|v| v.parse().ok()) - .unwrap_or(0), - classification, - norad_id, - element_set_no: kv - .get("ELEMENT_SET_NO") - .and_then(|v| v.parse().ok()) - .unwrap_or(0), - rev_at_epoch: getu("REV_AT_EPOCH").unwrap_or(0), - bstar: getf("BSTAR").unwrap_or(0.0), - mean_motion_dot: getf("MEAN_MOTION_DOT").unwrap_or(0.0), - mean_motion_ddot: getf("MEAN_MOTION_DDOT").unwrap_or(0.0), - }) -} diff --git a/src/tle/omm/mod.rs b/src/tle/omm/mod.rs deleted file mode 100644 index 618eaa7..0000000 --- a/src/tle/omm/mod.rs +++ /dev/null @@ -1,247 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! CCSDS Orbit Mean-elements Message (OMM) parsing/writing. -//! -//! Supports the three encodings emitted by Celestrak and other catalog -//! providers: -//! -//! * [`kvn`] — keyword-value notation (CCSDS 502.0-B-2 §4.1). -//! * [`xml`] — XML Schema for OMM (CCSDS 502.0-B-2 Annex E). -//! * [`json`] — Celestrak's JSON encoding (`OBJECT_NAME`, `MEAN_MOTION`, …). -//! -//! All three round-trip through the same in-memory [`Omm`] record. Use -//! [`Omm::from_tle`] / [`Omm::to_tle`] to convert from/to the classic -//! 2LE/3LE [`crate::tle::Tle`] representation. - -pub mod json; -pub mod kvn; -pub mod xml; - -use chrono::{Datelike, TimeZone, Timelike, Utc}; -use siderust::qtty::angular::Degrees; -use siderust::qtty::{angular::Turn, angular_rate::AngularRate, time::Day}; -use siderust::tempoch::{Time, UTC}; - -use crate::tle::TleError; -use crate::tle::{Classification, InternationalDesignator, SatelliteNumber, Tle}; - -/// Strongly-typed OMM record. -/// -/// Mirrors the CCSDS 502.0 OMM/SGP4 schema. Fields not relevant to SGP4 -/// propagation (`COMMENT`, `CENTER_NAME`, etc.) are preserved as -/// `metadata` for round-trip round-tripping but are otherwise opaque. -/// -/// # Examples -/// -/// ``` -/// use spod::tle::{parse_3le, omm::Omm}; -/// let tle = parse_3le( -/// "ISS (ZARYA)", -/// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", -/// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", -/// ).unwrap(); -/// let omm = Omm::from_tle(&tle); -/// assert_eq!(omm.norad_id.0, 25544); -/// ``` -#[derive(Clone, Debug)] -pub struct Omm { - /// `OBJECT_NAME` — satellite name (free-form). - pub object_name: String, - /// `OBJECT_ID` — COSPAR international designator (e.g. `"1998-067A"`). - pub object_id: String, - /// Originator-supplied epoch (UTC). - pub epoch: Time, - /// Mean motion (revolutions per day, typed). - pub mean_motion: AngularRate, - /// Eccentricity (dimensionless, 0 ≤ e < 1). - pub eccentricity: f64, - /// Inclination. - pub inclination: Degrees, - /// Right ascension of the ascending node. - pub ra_of_asc_node: Degrees, - /// Argument of perigee. - pub arg_of_pericenter: Degrees, - /// Mean anomaly. - pub mean_anomaly: Degrees, - /// `EPHEMERIS_TYPE` — usually `0` for SGP4. - pub ephemeris_type: u8, - /// `CLASSIFICATION_TYPE` — `U`/`C`/`S`. - pub classification: Classification, - /// `NORAD_CAT_ID` — catalog id (Alpha-5-decoded). - pub norad_id: SatelliteNumber, - /// `ELEMENT_SET_NO`. - pub element_set_no: u16, - /// `REV_AT_EPOCH`. - pub rev_at_epoch: u32, - /// `BSTAR` drag term (1 / Earth radii). - pub bstar: f64, - /// `MEAN_MOTION_DOT` — first time derivative (revs/day²). - pub mean_motion_dot: f64, - /// `MEAN_MOTION_DDOT` — second time derivative (revs/day³). - pub mean_motion_ddot: f64, -} - -impl Omm { - /// Build an OMM record from a parsed [`Tle`]. - /// - /// `OBJECT_NAME` falls back to the catalog id rendering when the TLE - /// has no name (i.e. parsed via [`crate::tle::parse_tle`] rather than - /// [`crate::tle::parse_3le`]). `OBJECT_ID` is reconstructed from the - /// 6-character TLE international designator into the canonical OMM - /// `YYYY-NNNP` shape (with the 2-digit launch year expanded using the - /// same 1957/2000 cutover as the TLE epoch). - /// - /// # Examples - /// - /// ``` - /// use spod::tle::{parse_3le, omm::Omm}; - /// let tle = parse_3le( - /// "ISS (ZARYA)", - /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", - /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", - /// ).unwrap(); - /// let omm = Omm::from_tle(&tle); - /// assert_eq!(omm.object_id, "1998-067A"); - /// ``` - pub fn from_tle(tle: &Tle) -> Self { - let object_name = tle - .name - .clone() - .unwrap_or_else(|| format!("NORAD {}", tle.norad_id.0)); - let object_id = expand_intl_designator(&tle.international_designator.0); - Self { - object_name, - object_id, - epoch: tle.epoch, - mean_motion: tle.mean_motion, - eccentricity: tle.eccentricity, - inclination: tle.inclination, - ra_of_asc_node: tle.raan, - arg_of_pericenter: tle.argument_of_perigee, - mean_anomaly: tle.mean_anomaly, - ephemeris_type: 0, - classification: tle.classification, - norad_id: tle.norad_id, - element_set_no: tle.element_set_number, - rev_at_epoch: tle.revolution_number_at_epoch, - bstar: tle.bstar, - mean_motion_dot: tle.mean_motion_dot, - mean_motion_ddot: tle.mean_motion_ddot, - } - } - - /// Convert the OMM into a classic [`Tle`]. - /// - /// `name` is set from `OBJECT_NAME`, and the international designator - /// is contracted from `YYYY-NNNP` back into the 6/7-character TLE - /// field. - /// - /// # Examples - /// - /// ``` - /// use spod::tle::{parse_3le, omm::Omm}; - /// let l1 = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927"; - /// let l2 = "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537"; - /// let tle1 = parse_3le("ISS (ZARYA)", l1, l2).unwrap(); - /// let tle2 = Omm::from_tle(&tle1).to_tle(); - /// assert_eq!(tle2.norad_id, tle1.norad_id); - /// ``` - pub fn to_tle(&self) -> Tle { - Tle { - name: Some(self.object_name.clone()), - norad_id: self.norad_id, - classification: self.classification, - international_designator: InternationalDesignator(contract_intl_designator( - &self.object_id, - )), - epoch: self.epoch, - mean_motion_dot: self.mean_motion_dot, - mean_motion_ddot: self.mean_motion_ddot, - bstar: self.bstar, - element_set_number: self.element_set_no, - revolution_number_at_epoch: self.rev_at_epoch, - inclination: self.inclination, - raan: self.ra_of_asc_node, - eccentricity: self.eccentricity, - argument_of_perigee: self.arg_of_pericenter, - mean_anomaly: self.mean_anomaly, - mean_motion: self.mean_motion, - } - } -} - -/// Convert a TLE 6/8-character international designator (e.g. `"98067A"`) -/// into the OMM `YYYY-NNNP` canonical form (e.g. `"1998-067A"`). -pub(crate) fn expand_intl_designator(field: &str) -> String { - let s = field.trim(); - if s.len() < 5 || !s.is_ascii() { - return s.to_string(); - } - let bytes = s.as_bytes(); - if !bytes[0].is_ascii_digit() || !bytes[1].is_ascii_digit() { - return s.to_string(); - } - let yy: i32 = s[..2].parse().unwrap_or(0); - let yyyy = if yy >= 57 { 1900 + yy } else { 2000 + yy }; - let launch = &s[2..5]; - let piece = &s[5..]; - if piece.is_empty() { - format!("{yyyy:04}-{launch}") - } else { - format!("{yyyy:04}-{launch}{piece}") - } -} - -/// Inverse of [`expand_intl_designator`]: contract `YYYY-NNNP` back to -/// `YYNNNP`. Tolerant of input that is already in TLE format. -pub(crate) fn contract_intl_designator(s: &str) -> String { - let s = s.trim(); - if let Some(dash) = s.find('-') { - let (yyyy, rest) = s.split_at(dash); - let rest = &rest[1..]; - if yyyy.len() == 4 && yyyy.bytes().all(|b| b.is_ascii_digit()) { - let yy = &yyyy[2..]; - return format!("{yy}{rest}"); - } - } - s.to_string() -} - -/// Format a [`Time`] as the ISO-8601 fractional-second OMM epoch. -pub(crate) fn format_epoch(t: Time) -> Result { - let dt = t - .try_to_chrono() - .map_err(|e| TleError::EpochConversion(format!("{e:?}")))?; - let micros = dt.timestamp_subsec_micros(); - Ok(format!( - "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}", - dt.year(), - dt.month(), - dt.day(), - dt.hour(), - dt.minute(), - dt.second(), - micros - )) -} - -/// Parse an ISO-8601 OMM epoch (with optional fractional seconds and -/// optional `Z`/`+00:00` zone) into a UTC [`Time`]. -pub(crate) fn parse_epoch(raw: &str) -> Result, TleError> { - let s = raw.trim(); - let trimmed = s.trim_end_matches('Z'); - let trimmed = trimmed.split('+').next().unwrap_or(trimmed); - let dt = chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f") - .or_else(|_| chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S")) - .map_err(|e| TleError::OmmInvalidEpoch { - raw: raw.to_string(), - reason: match e.kind() { - chrono::format::ParseErrorKind::Invalid => "invalid component", - chrono::format::ParseErrorKind::OutOfRange => "out of range", - _ => "malformed ISO-8601", - }, - })?; - let utc_dt = Utc.from_utc_datetime(&dt); - Time::::try_from_chrono(utc_dt).map_err(|e| TleError::EpochConversion(format!("{e:?}"))) -} diff --git a/src/tle/omm/xml.rs b/src/tle/omm/xml.rs deleted file mode 100644 index 10f767e..0000000 --- a/src/tle/omm/xml.rs +++ /dev/null @@ -1,214 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Minimal XML encoding for OMM (CCSDS 502.0 Annex E). -//! -//! This is a hand-rolled, schema-restricted parser/writer that handles the -//! flat OMM element set required for SGP4 round-trip. It deliberately does -//! not pull in a general XML library — see crate-level rationale in -//! `lib.rs`. - -use siderust::qtty::angular::Degrees; -use siderust::qtty::{angular::Turn, angular_rate::AngularRate, time::Day}; - -use super::{format_epoch, parse_epoch, Omm}; -use crate::tle::TleError; -use crate::tle::{Classification, SatelliteNumber}; - -/// Render an [`Omm`] to its XML form (`...`). -/// -/// # Examples -/// -/// ``` -/// use spod::tle::{omm::{Omm, xml}, parse_3le}; -/// let l1 = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927"; -/// let l2 = "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537"; -/// let omm = Omm::from_tle(&parse_3le("ISS (ZARYA)", l1, l2).unwrap()); -/// let xml = xml::write(&omm).unwrap(); -/// assert!(xml.contains("25544")); -/// ``` -pub fn write(omm: &Omm) -> Result { - let epoch = format_epoch(omm.epoch)?; - Ok(format!( - "\n\ - \n\ - \n\ - {name}\n\ - {id}\n\ - EARTH\n\ - TEME\n\ - UTC\n\ - SGP4\n\ - \n\ - \n\ - {epoch}\n\ - {mm:.8}\n\ - {ecc:.7}\n\ - {inc:.4}\n\ - {raan:.4}\n\ - {argp:.4}\n\ - {ma:.4}\n\ - \n\ - \n\ - {eph}\n\ - {cls}\n\ - {cat}\n\ - {esn}\n\ - {rev}\n\ - {bstar:.8e}\n\ - {ndot:.8e}\n\ - {nddot:.8e}\n\ - \n\ - \n", - name = xml_escape(&omm.object_name), - id = xml_escape(&omm.object_id), - mm = omm.mean_motion.value(), - ecc = omm.eccentricity, - inc = omm.inclination.value(), - raan = omm.ra_of_asc_node.value(), - argp = omm.arg_of_pericenter.value(), - ma = omm.mean_anomaly.value(), - eph = omm.ephemeris_type, - cls = omm.classification.as_char(), - cat = omm.norad_id.0, - esn = omm.element_set_no, - rev = omm.rev_at_epoch, - bstar = omm.bstar, - ndot = omm.mean_motion_dot, - nddot = omm.mean_motion_ddot, - )) -} - -/// Parse an OMM XML document. -/// -/// Tolerant of whitespace, attribute order, and additional unknown -/// elements; strict on field presence and numeric format. -/// -/// # Examples -/// -/// ``` -/// use spod::tle::omm::{xml, Omm, kvn}; -/// let l1 = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927"; -/// let l2 = "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537"; -/// let omm = Omm::from_tle(&spod::tle::parse_3le("ISS (ZARYA)", l1, l2).unwrap()); -/// let s = xml::write(&omm).unwrap(); -/// let omm2 = xml::read(&s).unwrap(); -/// assert_eq!(omm2.norad_id, omm.norad_id); -/// ``` -pub fn read(input: &str) -> Result { - use std::collections::HashMap; - let mut kv: HashMap = HashMap::new(); - for tag in TLE_TAGS { - if let Some(v) = extract_tag(input, tag) { - kv.insert((*tag).to_string(), v); - } - } - - let theory = kv - .get("MEAN_ELEMENT_THEORY") - .map(String::as_str) - .unwrap_or("SGP4"); - if !theory.eq_ignore_ascii_case("SGP4") { - return Err(TleError::OmmUnsupportedTheory(theory.to_string())); - } - - let get = |k: &'static str| -> Result<&String, TleError> { - kv.get(k).ok_or(TleError::OmmMissingField(k)) - }; - let getf = |k: &'static str| -> Result { - let v = get(k)?; - v.parse::().map_err(|_| TleError::OmmMalformed { - format: "XML", - reason: format!("<{k}> = {v:?} is not a float"), - }) - }; - - let object_name = xml_unescape(get("OBJECT_NAME")?); - let object_id = xml_unescape(get("OBJECT_ID")?); - let epoch = parse_epoch(get("EPOCH")?)?; - let cls_raw = kv - .get("CLASSIFICATION_TYPE") - .map(String::as_str) - .unwrap_or("U"); - let cls_char = cls_raw.chars().next().unwrap_or('U'); - let classification = Classification::from_char(cls_char)?; - let norad_id = SatelliteNumber::parse(get("NORAD_CAT_ID")?)?; - - Ok(Omm { - object_name, - object_id, - epoch, - mean_motion: AngularRate::::new(getf("MEAN_MOTION")?), - eccentricity: getf("ECCENTRICITY")?, - inclination: Degrees::new(getf("INCLINATION")?), - ra_of_asc_node: Degrees::new(getf("RA_OF_ASC_NODE")?), - arg_of_pericenter: Degrees::new(getf("ARG_OF_PERICENTER")?), - mean_anomaly: Degrees::new(getf("MEAN_ANOMALY")?), - ephemeris_type: kv - .get("EPHEMERIS_TYPE") - .and_then(|v| v.parse().ok()) - .unwrap_or(0), - classification, - norad_id, - element_set_no: kv - .get("ELEMENT_SET_NO") - .and_then(|v| v.parse().ok()) - .unwrap_or(0), - rev_at_epoch: kv - .get("REV_AT_EPOCH") - .and_then(|v| v.parse().ok()) - .unwrap_or(0), - bstar: getf("BSTAR").unwrap_or(0.0), - mean_motion_dot: getf("MEAN_MOTION_DOT").unwrap_or(0.0), - mean_motion_ddot: getf("MEAN_MOTION_DDOT").unwrap_or(0.0), - }) -} - -const TLE_TAGS: &[&str] = &[ - "OBJECT_NAME", - "OBJECT_ID", - "CENTER_NAME", - "REF_FRAME", - "TIME_SYSTEM", - "MEAN_ELEMENT_THEORY", - "EPOCH", - "MEAN_MOTION", - "ECCENTRICITY", - "INCLINATION", - "RA_OF_ASC_NODE", - "ARG_OF_PERICENTER", - "MEAN_ANOMALY", - "EPHEMERIS_TYPE", - "CLASSIFICATION_TYPE", - "NORAD_CAT_ID", - "ELEMENT_SET_NO", - "REV_AT_EPOCH", - "BSTAR", - "MEAN_MOTION_DOT", - "MEAN_MOTION_DDOT", -]; - -fn extract_tag(input: &str, tag: &str) -> Option { - let open = format!("<{tag}>"); - let close = format!(""); - let i = input.find(&open)?; - let after = &input[i + open.len()..]; - let j = after.find(&close)?; - Some(after[..j].trim().to_string()) -} - -fn xml_escape(s: &str) -> String { - s.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} - -fn xml_unescape(s: &str) -> String { - s.replace("'", "'") - .replace(""", "\"") - .replace(">", ">") - .replace("<", "<") - .replace("&", "&") -} diff --git a/src/tle/parse.rs b/src/tle/parse.rs deleted file mode 100644 index ae69068..0000000 --- a/src/tle/parse.rs +++ /dev/null @@ -1,321 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Column-positional parser for the canonical NORAD 2LE / 3LE format. - -use chrono::{Duration, NaiveDate, TimeZone, Utc}; -use siderust::qtty::angular::Degrees; -use siderust::qtty::{angular::Turn, angular_rate::AngularRate, time::Day}; -use siderust::tempoch::{Time, UTC}; - -use super::TleError; -use crate::tle::{Classification, InternationalDesignator, SatelliteNumber, Tle}; - -/// Validate the canonical TLE checksum (digits + minus signs, mod 10). -/// -/// The checksum character is the last column (column 69) of the line. -/// -/// # Examples -/// -/// ``` -/// use spod::tle::validate_tle_checksum; -/// let l1 = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927"; -/// validate_tle_checksum(l1).unwrap(); -/// ``` -pub fn validate_tle_checksum(line: &str) -> Result<(), TleError> { - let line_no = if line.starts_with('1') { - 1u8 - } else if line.starts_with('2') { - 2u8 - } else { - 0u8 - }; - if line.len() != 69 { - return Err(TleError::BadLength { - line: line_no, - got: line.len(), - }); - } - let bytes = line.as_bytes(); - let stated_byte = bytes[68]; - if !stated_byte.is_ascii_digit() { - return Err(TleError::BadChecksum { - line: line_no, - stated: 0, - computed: compute_checksum(&line[..68]), - }); - } - let stated = stated_byte - b'0'; - let computed = compute_checksum(&line[..68]); - if stated != computed { - return Err(TleError::BadChecksum { - line: line_no, - stated, - computed, - }); - } - Ok(()) -} - -/// Compute the canonical TLE checksum digit for the first 68 columns. -/// -/// # Examples -/// -/// ``` -/// use spod::tle::compute_tle_checksum; -/// let l1 = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 292"; -/// assert_eq!(compute_tle_checksum(l1), 7); -/// ``` -pub fn compute_tle_checksum(prefix_68: &str) -> u8 { - compute_checksum(prefix_68) -} - -pub(crate) fn compute_checksum(prefix_68: &str) -> u8 { - let mut sum: u32 = 0; - for b in prefix_68.bytes() { - if b.is_ascii_digit() { - sum += (b - b'0') as u32; - } else if b == b'-' { - sum += 1; - } - } - (sum % 10) as u8 -} - -/// Parse a classic 2-line TLE. -/// -/// # Examples -/// -/// ``` -/// use spod::tle::parse_tle; -/// let tle = parse_tle( -/// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", -/// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", -/// ).unwrap(); -/// assert_eq!(tle.norad_id.0, 25544); -/// ``` -pub fn parse_tle(line1: &str, line2: &str) -> Result { - parse_tle_inner(None, line1, line2) -} - -/// Parse a 3-line TLE (name + classic 2-line). -/// -/// The leading `"0 "` prefix is stripped from `name` if present (per the -/// 3LE convention used by Celestrak). -/// -/// # Examples -/// -/// ``` -/// use spod::tle::parse_3le; -/// let tle = parse_3le( -/// "ISS (ZARYA)", -/// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", -/// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", -/// ).unwrap(); -/// assert_eq!(tle.name.as_deref(), Some("ISS (ZARYA)")); -/// ``` -pub fn parse_3le(name: &str, line1: &str, line2: &str) -> Result { - let trimmed = name.trim_start_matches("0 ").trim(); - let owned = if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - }; - parse_tle_inner(owned, line1, line2) -} - -fn parse_tle_inner(name: Option, line1: &str, line2: &str) -> Result { - if line1.len() != 69 { - return Err(TleError::BadLength { - line: 1, - got: line1.len(), - }); - } - if line2.len() != 69 { - return Err(TleError::BadLength { - line: 2, - got: line2.len(), - }); - } - let l1b = line1.as_bytes(); - let l2b = line2.as_bytes(); - if l1b[0] as char != '1' { - return Err(TleError::BadLeadingChar { - line: 1, - expected: '1', - found: l1b[0] as char, - }); - } - if l2b[0] as char != '2' { - return Err(TleError::BadLeadingChar { - line: 2, - expected: '2', - found: l2b[0] as char, - }); - } - validate_tle_checksum(line1)?; - validate_tle_checksum(line2)?; - - let norad_l1 = SatelliteNumber::parse(&line1[2..7])?; - let classification = Classification::from_char(line1.as_bytes()[7] as char)?; - let intl_des = InternationalDesignator(line1[9..17].trim().to_string()); - let epoch_year_2 = parse_int_field(&line1[18..20], "epoch_year")?; - let epoch_day = parse_float_field(&line1[20..32], "epoch_day")?; - let mean_motion_dot = parse_signed_decimal(&line1[33..43], "mean_motion_dot")?; - let mean_motion_ddot = parse_assumed_decimal_exponent(&line1[44..52], "mean_motion_ddot")?; - let bstar = parse_assumed_decimal_exponent(&line1[53..61], "bstar")?; - let element_set_number = - parse_int_field(line1[64..68].trim_start(), "element_set_number")? as u16; - - let norad_l2 = SatelliteNumber::parse(&line2[2..7])?; - if norad_l1 != norad_l2 { - return Err(TleError::MismatchedSatelliteNumber { - l1: norad_l1.0, - l2: norad_l2.0, - }); - } - let inclination = Degrees::new(parse_float_field(&line2[8..16], "inclination")?); - let raan = Degrees::new(parse_float_field(&line2[17..25], "raan")?); - let eccentricity = { - let raw = line2[26..33].trim(); - if raw.is_empty() || !raw.chars().all(|c| c.is_ascii_digit()) { - return Err(TleError::InvalidNumber { - field: "eccentricity", - raw: raw.into(), - }); - } - let scaled: u64 = raw.parse().map_err(|_| TleError::InvalidNumber { - field: "eccentricity", - raw: raw.into(), - })?; - scaled as f64 * 10f64.powi(-(raw.len() as i32)) - }; - let argp = Degrees::new(parse_float_field(&line2[34..42], "argument_of_perigee")?); - let mean_anomaly = Degrees::new(parse_float_field(&line2[43..51], "mean_anomaly")?); - let mean_motion_revs_per_day = parse_float_field(&line2[52..63], "mean_motion")?; - let mean_motion = AngularRate::::new(mean_motion_revs_per_day); - let revolution_number_at_epoch = - parse_int_field(line2[63..68].trim_start(), "revolution_number")? as u32; - - let epoch = epoch_from_year_doy(expand_two_digit_year(epoch_year_2 as i32), epoch_day)?; - - Ok(Tle { - name, - norad_id: norad_l1, - classification, - international_designator: intl_des, - epoch, - mean_motion_dot, - mean_motion_ddot, - bstar, - element_set_number, - revolution_number_at_epoch, - inclination, - raan, - eccentricity, - argument_of_perigee: argp, - mean_anomaly, - mean_motion, - }) -} - -/// TLE 2-digit year convention: 57..99 → 1957..1999, 00..56 → 2000..2056. -pub(crate) fn expand_two_digit_year(yy: i32) -> i32 { - if yy >= 57 { - 1900 + yy - } else { - 2000 + yy - } -} - -pub(crate) fn epoch_from_year_doy(year: i32, day_of_year: f64) -> Result, TleError> { - if !day_of_year.is_finite() || !(1.0..367.0).contains(&day_of_year) { - return Err(TleError::InvalidEpoch { - year, - day_of_year, - reason: "day-of-year out of range", - }); - } - let day_int = day_of_year.floor() as i64; - let frac_day = day_of_year - day_int as f64; - let date = NaiveDate::from_yo_opt(year, day_int as u32).ok_or(TleError::InvalidEpoch { - year, - day_of_year, - reason: "calendar date does not exist", - })?; - let nanos = (frac_day * 86_400.0 * 1e9).round() as i64; - let dt = - Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap()) + Duration::nanoseconds(nanos); - Time::::try_from_chrono(dt).map_err(|e| TleError::EpochConversion(format!("{e:?}"))) -} - -fn parse_int_field(raw: &str, field: &'static str) -> Result { - let s = raw.trim(); - s.parse::().map_err(|_| TleError::InvalidNumber { - field, - raw: raw.into(), - }) -} - -fn parse_float_field(raw: &str, field: &'static str) -> Result { - let s = raw.trim(); - s.parse::().map_err(|_| TleError::InvalidNumber { - field, - raw: raw.into(), - }) -} - -fn parse_signed_decimal(raw: &str, field: &'static str) -> Result { - let s = raw.trim(); - s.parse::().map_err(|_| TleError::InvalidNumber { - field, - raw: raw.into(), - }) -} - -/// Parse the TLE assumed-decimal-with-exponent encoding (e.g. `" 12345-3"` → -/// `0.12345e-3`). -pub(crate) fn parse_assumed_decimal_exponent( - raw: &str, - field: &'static str, -) -> Result { - let s = raw.trim(); - if s.is_empty() { - return Ok(0.0); - } - let (sign, rest) = match s.as_bytes()[0] { - b'-' => (-1.0_f64, &s[1..]), - b'+' => (1.0, &s[1..]), - _ => (1.0, s), - }; - if rest.len() < 2 { - return Err(TleError::InvalidNumber { - field, - raw: raw.into(), - }); - } - let exp_sep = rest.rfind(['-', '+']).ok_or(TleError::InvalidNumber { - field, - raw: raw.into(), - })?; - let mantissa_digits = &rest[..exp_sep]; - let exp_str = &rest[exp_sep..]; - if !mantissa_digits.chars().all(|c| c.is_ascii_digit()) || mantissa_digits.is_empty() { - return Err(TleError::InvalidNumber { - field, - raw: raw.into(), - }); - } - let mantissa: f64 = mantissa_digits - .parse::() - .map_err(|_| TleError::InvalidNumber { - field, - raw: raw.into(), - })? as f64 - * 10f64.powi(-(mantissa_digits.len() as i32)); - let exp: i32 = exp_str.parse().map_err(|_| TleError::InvalidNumber { - field, - raw: raw.into(), - })?; - Ok(sign * mantissa * 10f64.powi(exp)) -} diff --git a/src/tle/tests.rs b/src/tle/tests.rs deleted file mode 100644 index d863d8d..0000000 --- a/src/tle/tests.rs +++ /dev/null @@ -1,474 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Comprehensive unit tests for `siderust-tle`. -//! -//! Drives line-coverage of every parser branch (TLE column-extractor, -//! Alpha-5, checksum, OMM KVN/XML/JSON) and round-trip parity of the -//! three OMM encodings. - -use super::omm::{json, kvn, xml, Omm}; -use super::parse::{compute_checksum, parse_assumed_decimal_exponent}; -use super::*; - -const ISS_NAME: &str = "ISS (ZARYA)"; -const ISS_L1: &str = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927"; -const ISS_L2: &str = "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537"; - -// A second canonical TLE (Vanguard 1, lowest NORAD id), for variety. -const VAN_L1: &str = "1 00005U 58002B 00179.78495062 .00000023 00000-0 28098-4 0 4753"; -const VAN_L2: &str = "2 00005 34.2682 348.7242 1859667 331.7664 19.3264 10.82419157413667"; - -#[test] -fn checksum_of_canonical_lines_validates() { - validate_tle_checksum(ISS_L1).unwrap(); - validate_tle_checksum(ISS_L2).unwrap(); - validate_tle_checksum(VAN_L1).unwrap(); - validate_tle_checksum(VAN_L2).unwrap(); -} - -#[test] -fn parse_iss_returns_expected_fields() { - let tle = parse_3le(ISS_NAME, ISS_L1, ISS_L2).unwrap(); - assert_eq!(tle.name.as_deref(), Some("ISS (ZARYA)")); - assert_eq!(tle.norad_id, SatelliteNumber(25544)); - assert_eq!(tle.classification, Classification::Unclassified); - assert_eq!(tle.international_designator.0, "98067A"); - assert!((tle.inclination.value() - 51.6416).abs() < 1e-6); - assert!((tle.raan.value() - 247.4627).abs() < 1e-6); - assert!((tle.eccentricity - 0.0006703).abs() < 1e-9); - assert!((tle.argument_of_perigee.value() - 130.5360).abs() < 1e-6); - assert!((tle.mean_anomaly.value() - 325.0288).abs() < 1e-6); - assert!((tle.mean_motion.value() - 15.72125391).abs() < 1e-8); - assert!((tle.bstar - -0.11606e-4).abs() < 1e-12); - assert_eq!(tle.element_set_number, 292); - assert_eq!(tle.revolution_number_at_epoch, 56353); -} - -#[test] -fn parse_3le_strips_zero_prefix() { - let with_prefix = parse_3le("0 ISS (ZARYA)", ISS_L1, ISS_L2).unwrap(); - assert_eq!(with_prefix.name.as_deref(), Some("ISS (ZARYA)")); - let empty = parse_3le(" ", ISS_L1, ISS_L2).unwrap(); - assert!(empty.name.is_none()); -} - -#[test] -fn invalid_checksum_is_rejected() { - let mut bad = ISS_L1.to_string(); - let n = bad.len(); - bad.replace_range(n - 1..n, "0"); - match validate_tle_checksum(&bad) { - Err(TleError::BadChecksum { line: 1, .. }) => {} - other => panic!("expected BadChecksum on line 1, got {other:?}"), - } -} - -#[test] -fn non_digit_checksum_is_rejected() { - let mut bad = ISS_L1.to_string(); - let n = bad.len(); - bad.replace_range(n - 1..n, "X"); - assert!(matches!( - validate_tle_checksum(&bad), - Err(TleError::BadChecksum { .. }) - )); -} - -#[test] -fn bad_length_is_rejected() { - let short = &ISS_L1[..68]; - assert!(matches!( - validate_tle_checksum(short), - Err(TleError::BadLength { .. }) - )); - let short = &ISS_L1[..50]; - assert!(matches!( - parse_tle(short, ISS_L2), - Err(TleError::BadLength { line: 1, .. }) - )); - assert!(matches!( - parse_tle(ISS_L1, &ISS_L2[..50]), - Err(TleError::BadLength { line: 2, .. }) - )); -} - -#[test] -fn bad_leading_char_is_rejected() { - // line1 with leading '9' must produce a checksum length error first because we - // reject on length parity above; craft a 69-char line that just changes col 0. - let mut bad1 = ISS_L1.to_string(); - bad1.replace_range(0..1, "9"); - // Also fix checksum so we hit BadLeadingChar specifically. - let cs = compute_tle_checksum(&bad1[..68]); - bad1.replace_range(68..69, &cs.to_string()); - assert!(matches!( - parse_tle(&bad1, ISS_L2), - Err(TleError::BadLeadingChar { line: 1, .. }) - )); - - let mut bad2 = ISS_L2.to_string(); - bad2.replace_range(0..1, "9"); - let cs = compute_tle_checksum(&bad2[..68]); - bad2.replace_range(68..69, &cs.to_string()); - assert!(matches!( - parse_tle(ISS_L1, &bad2), - Err(TleError::BadLeadingChar { line: 2, .. }) - )); -} - -#[test] -fn alpha5_decoding_full_table() { - // Regular numeric. - assert_eq!( - SatelliteNumber::parse("25544").unwrap(), - SatelliteNumber(25_544) - ); - // Each Alpha-5 segment boundary. - assert_eq!( - SatelliteNumber::parse("A0000").unwrap(), - SatelliteNumber(100_000) - ); - assert_eq!( - SatelliteNumber::parse("H9999").unwrap(), - SatelliteNumber(179_999) - ); - assert_eq!( - SatelliteNumber::parse("J0000").unwrap(), - SatelliteNumber(180_000) - ); - assert_eq!( - SatelliteNumber::parse("N9999").unwrap(), - SatelliteNumber(229_999) - ); - assert_eq!( - SatelliteNumber::parse("P0000").unwrap(), - SatelliteNumber(230_000) - ); - assert_eq!( - SatelliteNumber::parse("T0001").unwrap(), - SatelliteNumber(270_001) - ); - assert_eq!( - SatelliteNumber::parse("Z9999").unwrap(), - SatelliteNumber(339_999) - ); - - // Forbidden letters and shape errors. - assert!(matches!( - SatelliteNumber::parse("I0001"), - Err(TleError::InvalidAlpha5 { .. }) - )); - assert!(matches!( - SatelliteNumber::parse("O0001"), - Err(TleError::InvalidAlpha5 { .. }) - )); - assert!(matches!( - SatelliteNumber::parse(""), - Err(TleError::InvalidAlpha5 { .. }) - )); - assert!(matches!( - SatelliteNumber::parse("A001"), - Err(TleError::InvalidAlpha5 { .. }) - )); - assert!(matches!( - SatelliteNumber::parse("A00X1"), - Err(TleError::InvalidAlpha5 { .. }) - )); - assert!(matches!( - SatelliteNumber::parse("aaaaa"), - Err(TleError::InvalidAlpha5 { .. }) - )); - assert!(matches!( - SatelliteNumber::parse("9".repeat(6).as_str()), - Err(TleError::InvalidAlpha5 { .. }) - )); -} - -#[test] -fn alpha5_format_round_trip() { - for n in [ - 0u32, 5, 25_544, 99_999, 100_000, 179_999, 180_000, 270_001, 339_999, - ] { - let s = SatelliteNumber(n).format_alpha5().unwrap(); - assert_eq!( - SatelliteNumber::parse(&s).unwrap(), - SatelliteNumber(n), - "round-trip {n}" - ); - } - assert!(SatelliteNumber(340_000).format_alpha5().is_err()); -} - -#[test] -fn classification_round_trip() { - for &c in &['U', 'C', 'S'] { - let parsed = Classification::from_char(c).unwrap(); - assert_eq!(parsed.as_char(), c); - } - assert!(matches!( - Classification::from_char('Z'), - Err(TleError::InvalidClassification { .. }) - )); -} - -#[test] -fn assumed_decimal_with_exponent_branches() { - assert!((parse_assumed_decimal_exponent(" 12345-3", "x").unwrap() - 0.12345e-3).abs() < 1e-12); - assert!((parse_assumed_decimal_exponent("-11606-4", "x").unwrap() - -0.11606e-4).abs() < 1e-15); - assert!((parse_assumed_decimal_exponent("+12345+2", "x").unwrap() - 12.345).abs() < 1e-12); - assert_eq!(parse_assumed_decimal_exponent("00000-0", "x").unwrap(), 0.0); - assert_eq!( - parse_assumed_decimal_exponent(" ", "x").unwrap(), - 0.0 - ); - - // Failure modes. - assert!(parse_assumed_decimal_exponent("-", "x").is_err()); - assert!(parse_assumed_decimal_exponent("ab", "x").is_err()); - assert!(parse_assumed_decimal_exponent("abcde-3", "x").is_err()); - assert!(parse_assumed_decimal_exponent("1234567", "x").is_err()); -} - -#[test] -fn checksum_helper_matches_bytes() { - // Computed checksum equals the digit baked into the canonical fixture. - assert_eq!(compute_checksum(&ISS_L1[..68]), b'7' - b'0'); - assert_eq!(compute_checksum(&ISS_L2[..68]), b'7' - b'0'); - assert_eq!(compute_tle_checksum(&VAN_L1[..68]), b'3' - b'0'); -} - -#[test] -fn epoch_year_expansion_and_invalid_epoch() { - use super::parse::expand_two_digit_year; - assert_eq!(expand_two_digit_year(57), 1957); - assert_eq!(expand_two_digit_year(99), 1999); - assert_eq!(expand_two_digit_year(00), 2000); - assert_eq!(expand_two_digit_year(56), 2056); - - use super::parse::epoch_from_year_doy; - assert!(matches!( - epoch_from_year_doy(2024, 0.0), - Err(TleError::InvalidEpoch { .. }) - )); - assert!(matches!( - epoch_from_year_doy(2024, 367.5), - Err(TleError::InvalidEpoch { .. }) - )); - assert!(matches!( - epoch_from_year_doy(2023, 366.0), - Err(TleError::InvalidEpoch { .. }) - )); - assert!(matches!( - epoch_from_year_doy(2024, f64::NAN), - Err(TleError::InvalidEpoch { .. }) - )); - // Valid: 2024 was a leap year. - epoch_from_year_doy(2024, 366.5).unwrap(); -} - -#[test] -fn mismatched_norad_id_is_rejected() { - let l2_other = "2 25545 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563530"; - let computed = compute_tle_checksum(&l2_other[..68]); - let mut forged = l2_other.to_string(); - forged.replace_range(68..69, &computed.to_string()); - assert!(matches!( - parse_tle(ISS_L1, &forged), - Err(TleError::MismatchedSatelliteNumber { .. }) - )); -} - -#[test] -fn invalid_classification_field_is_rejected() { - let mut bad = ISS_L1.to_string(); - bad.replace_range(7..8, "Z"); - let cs = compute_tle_checksum(&bad[..68]); - bad.replace_range(68..69, &cs.to_string()); - assert!(matches!( - parse_tle(&bad, ISS_L2), - Err(TleError::InvalidClassification { .. }) - )); -} - -#[test] -fn invalid_eccentricity_field_is_rejected() { - // Replace the eccentricity columns 26..33 with non-digits. - let mut bad = ISS_L2.to_string(); - bad.replace_range(26..33, "ABCDEFG"); - let cs = compute_tle_checksum(&bad[..68]); - bad.replace_range(68..69, &cs.to_string()); - assert!(matches!( - parse_tle(ISS_L1, &bad), - Err(TleError::InvalidNumber { - field: "eccentricity", - .. - }) - )); -} - -#[test] -fn builder_round_trip_to_format_tle() { - use super::builder::format_tle; - let tle = parse_3le(ISS_NAME, ISS_L1, ISS_L2).unwrap(); - let (l1, l2) = format_tle(&tle).unwrap(); - assert_eq!(l1.len(), 69); - assert_eq!(l2.len(), 69); - // Re-parse and compare deeply (the byte-for-byte encoding can vary - // in zero-padding, but the parsed values must be identical). - let tle2 = parse_tle(&l1, &l2).unwrap(); - assert_eq!(tle.norad_id, tle2.norad_id); - assert_eq!(tle.classification, tle2.classification); - assert_eq!(tle.international_designator, tle2.international_designator); - assert!((tle.inclination.value() - tle2.inclination.value()).abs() < 1e-4); - assert!((tle.mean_motion.value() - tle2.mean_motion.value()).abs() < 1e-8); - assert!((tle.bstar - tle2.bstar).abs() < 1e-9); - assert_eq!(tle.element_set_number, tle2.element_set_number); - assert_eq!( - tle.revolution_number_at_epoch, - tle2.revolution_number_at_epoch - ); -} - -#[test] -fn builder_missing_field_errors() { - let err = TleBuilder::new().build().unwrap_err(); - assert!(matches!(err, TleError::BuilderMissingField(_))); -} - -#[test] -fn omm_kvn_round_trip() { - let tle = parse_3le(ISS_NAME, ISS_L1, ISS_L2).unwrap(); - let omm = Omm::from_tle(&tle); - let text = kvn::write(&omm).unwrap(); - let omm2 = kvn::read(&text).unwrap(); - assert_eq!(omm2.norad_id, omm.norad_id); - assert_eq!(omm2.object_id, omm.object_id); - assert!((omm2.inclination.value() - omm.inclination.value()).abs() < 1e-6); - assert!((omm2.bstar - omm.bstar).abs() < 1e-12); - assert_eq!(omm2.classification, omm.classification); -} - -#[test] -fn omm_xml_round_trip() { - let tle = parse_3le(ISS_NAME, ISS_L1, ISS_L2).unwrap(); - let omm = Omm::from_tle(&tle); - let s = xml::write(&omm).unwrap(); - let omm2 = xml::read(&s).unwrap(); - assert_eq!(omm2.norad_id, omm.norad_id); - assert!((omm2.eccentricity - omm.eccentricity).abs() < 1e-9); -} - -#[test] -fn omm_json_round_trip_single_and_array() { - let tle = parse_3le(ISS_NAME, ISS_L1, ISS_L2).unwrap(); - let omm = Omm::from_tle(&tle); - let s = json::write(&omm).unwrap(); - let omm2 = json::read(&s).unwrap(); - assert_eq!(omm2.norad_id, omm.norad_id); - - let arr = json::write_many(&[omm.clone(), omm.clone()]).unwrap(); - let parsed = json::read_many(&arr).unwrap(); - assert_eq!(parsed.len(), 2); -} - -#[test] -fn omm_kvn_rejects_unsupported_theory() { - let bad = "MEAN_ELEMENT_THEORY = SDP4\nOBJECT_NAME = X\n"; - assert!(matches!( - kvn::read(bad), - Err(TleError::OmmUnsupportedTheory(_)) - )); -} - -#[test] -fn omm_kvn_missing_field() { - let bad = "MEAN_ELEMENT_THEORY = SGP4\n"; - assert!(matches!(kvn::read(bad), Err(TleError::OmmMissingField(_)))); -} - -#[test] -fn omm_kvn_invalid_number() { - // All required fields present; INCLINATION malformed. - let bad = "OBJECT_NAME = X\nOBJECT_ID = 1998-067A\n\ - EPOCH = 2008-09-20T12:25:40.000\nMEAN_MOTION = 15.7\n\ - ECCENTRICITY = 0.001\nINCLINATION = NOT_A_NUMBER\n\ - RA_OF_ASC_NODE = 0\nARG_OF_PERICENTER = 0\nMEAN_ANOMALY = 0\n\ - NORAD_CAT_ID = 25544\n"; - assert!(matches!(kvn::read(bad), Err(TleError::OmmMalformed { .. }))); -} - -#[test] -fn omm_xml_missing_field() { - let bad = "\ - "; - assert!(matches!(xml::read(bad), Err(TleError::OmmMissingField(_)))); -} - -#[test] -fn omm_json_invalid_returns_json_error() { - assert!(matches!( - json::read("{ malformed"), - Err(TleError::OmmJson(_)) - )); -} - -#[test] -fn omm_round_trip_to_tle() { - let tle1 = parse_3le(ISS_NAME, ISS_L1, ISS_L2).unwrap(); - let omm = Omm::from_tle(&tle1); - let tle2 = omm.to_tle(); - assert_eq!(tle1.norad_id, tle2.norad_id); - assert_eq!(tle1.international_designator, tle2.international_designator); - assert!((tle1.inclination.value() - tle2.inclination.value()).abs() < 1e-9); - assert!((tle1.mean_motion.value() - tle2.mean_motion.value()).abs() < 1e-12); -} - -#[test] -fn omm_intl_designator_helpers() { - use super::omm::{contract_intl_designator, expand_intl_designator}; - assert_eq!(expand_intl_designator("98067A"), "1998-067A"); - assert_eq!(expand_intl_designator("00179B"), "2000-179B"); - assert_eq!(contract_intl_designator("1998-067A"), "98067A"); - assert_eq!(contract_intl_designator("2000-179B"), "00179B"); - // Unparseable inputs round-trip unchanged. - assert_eq!(expand_intl_designator("FOO"), "FOO"); - assert_eq!(contract_intl_designator("NOPE"), "NOPE"); -} - -#[test] -fn omm_xml_xss_escape_round_trip() { - let tle = parse_3le("WEIRD<&\"NAME>", ISS_L1, ISS_L2).unwrap(); - let mut omm = Omm::from_tle(&tle); - omm.object_name = "WEIRD<&\"NAME>".to_string(); - let s = xml::write(&omm).unwrap(); - let omm2 = xml::read(&s).unwrap(); - assert_eq!(omm2.object_name, omm.object_name); -} - -#[test] -fn fixtures_round_trip() { - let txt = include_str!("../test-data/tle/iss_zarya.3le"); - let mut iter = txt.lines(); - let name = iter.next().unwrap(); - let l1 = iter.next().unwrap(); - let l2 = iter.next().unwrap(); - let tle = parse_3le(name, l1, l2).unwrap(); - assert_eq!(tle.norad_id, SatelliteNumber(25544)); - - let kvn_txt = include_str!("../test-data/omm/iss_zarya.kvn"); - let omm = kvn::read(kvn_txt).unwrap(); - assert_eq!(omm.norad_id, SatelliteNumber(25544)); - - let xml_txt = include_str!("../test-data/omm/iss_zarya.xml"); - let omm_x = xml::read(xml_txt).unwrap(); - assert_eq!(omm_x.norad_id, SatelliteNumber(25544)); - - let json_txt = include_str!("../test-data/omm/iss_zarya.json"); - let omm_j = json::read(json_txt).unwrap(); - assert_eq!(omm_j.norad_id, SatelliteNumber(25544)); - - let arr_txt = include_str!("../test-data/omm/celestrak_sample.json"); - let many = json::read_many(arr_txt).unwrap(); - assert!(many.len() >= 2); -} diff --git a/src/tle/tle.rs b/src/tle/tle.rs deleted file mode 100644 index aa21ff4..0000000 --- a/src/tle/tle.rs +++ /dev/null @@ -1,243 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Core typed record produced by every parser in `siderust-tle`. - -use siderust::qtty::angular::Degrees; -use siderust::qtty::{angular::Turn, angular_rate::AngularRate, time::Day}; -use siderust::tempoch::{Time, UTC}; - -use super::TleError; - -/// Strongly typed NORAD catalog number with optional Alpha-5 encoding. -/// -/// The wrapped `u32` is the *expanded* catalog id (≥ 100 000 for Alpha-5 -/// inputs). -/// -/// # Examples -/// -/// ``` -/// use spod::tle::SatelliteNumber; -/// assert_eq!(SatelliteNumber::parse("25544").unwrap(), SatelliteNumber(25_544)); -/// assert_eq!(SatelliteNumber::parse("T0001").unwrap(), SatelliteNumber(270_001)); -/// ``` -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] -pub struct SatelliteNumber(pub u32); - -impl SatelliteNumber { - /// Parse a 5-character TLE catalog field, supporting Alpha-5 for ids ≥ 100 000. - /// - /// Alpha-5 encoding (per Celestrak): the leading character is a letter - /// from `A` (10) … `Z` (33), excluding `I` and `O`, mapped via: - /// - /// ```text - /// value = digit_index * 10000 + remaining_digits - /// ``` - /// - /// # Examples - /// - /// ``` - /// use spod::tle::SatelliteNumber; - /// assert_eq!(SatelliteNumber::parse("A0000").unwrap(), SatelliteNumber(100_000)); - /// assert!(SatelliteNumber::parse("I0001").is_err()); - /// ``` - pub fn parse(field: &str) -> Result { - let s = field.trim_start(); - if s.is_empty() || s.len() > 5 { - return Err(TleError::InvalidAlpha5 { raw: field.into() }); - } - let bytes = s.as_bytes(); - let first = bytes[0]; - if first.is_ascii_digit() { - let n: u32 = s.parse().map_err(|_| TleError::InvalidNumber { - field: "satellite_number", - raw: field.into(), - })?; - return Ok(SatelliteNumber(n)); - } - if !first.is_ascii_uppercase() || first == b'I' || first == b'O' { - return Err(TleError::InvalidAlpha5 { raw: field.into() }); - } - let value = match first { - b'A'..=b'H' => first - b'A' + 10, - b'J'..=b'N' => first - b'J' + 18, - b'P'..=b'Z' => first - b'P' + 23, - _ => return Err(TleError::InvalidAlpha5 { raw: field.into() }), - } as u32; - let tail = &s[1..]; - if tail.len() != 4 || !tail.bytes().all(|b| b.is_ascii_digit()) { - return Err(TleError::InvalidAlpha5 { raw: field.into() }); - } - let tail_n: u32 = tail - .parse() - .map_err(|_| TleError::InvalidAlpha5 { raw: field.into() })?; - Ok(SatelliteNumber(value * 10_000 + tail_n)) - } - - /// Format this catalog number into the 5-character TLE field, applying - /// Alpha-5 encoding for ids ≥ 100 000. - /// - /// Returns an error for catalog numbers that cannot be represented in - /// Alpha-5 (≥ 340 000). - /// - /// # Examples - /// - /// ``` - /// use spod::tle::SatelliteNumber; - /// assert_eq!(SatelliteNumber(25_544).format_alpha5().unwrap(), "25544"); - /// assert_eq!(SatelliteNumber(270_001).format_alpha5().unwrap(), "T0001"); - /// ``` - pub fn format_alpha5(self) -> Result { - let n = self.0; - if n < 100_000 { - return Ok(format!("{n:05}")); - } - if n >= 340_000 { - return Err(TleError::InvalidAlpha5 { - raw: format!("{n}"), - }); - } - let high = (n / 10_000) as u8; - let tail = n % 10_000; - let letter = match high { - 10..=17 => b'A' + (high - 10), - 18..=22 => b'J' + (high - 18), - 23..=33 => b'P' + (high - 23), - _ => unreachable!(), - }; - Ok(format!("{}{:04}", letter as char, tail)) - } -} - -/// TLE classification character. -/// -/// # Examples -/// -/// ``` -/// use spod::tle::Classification; -/// assert_eq!(Classification::from_char('U').unwrap(), Classification::Unclassified); -/// ``` -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub enum Classification { - /// Unclassified satellite (`U`). - Unclassified, - /// Classified satellite (`C`). - Classified, - /// Secret satellite (`S`). - Secret, -} - -impl Classification { - /// Parse a TLE classification character (`U`, `C`, or `S`). - /// - /// # Examples - /// - /// ``` - /// use spod::tle::Classification; - /// assert!(Classification::from_char('Z').is_err()); - /// ``` - pub fn from_char(c: char) -> Result { - match c { - 'U' => Ok(Self::Unclassified), - 'C' => Ok(Self::Classified), - 'S' => Ok(Self::Secret), - _ => Err(TleError::InvalidClassification { raw: c }), - } - } - - /// Render the classification back to its TLE character. - /// - /// # Examples - /// - /// ``` - /// use spod::tle::Classification; - /// assert_eq!(Classification::Unclassified.as_char(), 'U'); - /// ``` - pub fn as_char(self) -> char { - match self { - Self::Unclassified => 'U', - Self::Classified => 'C', - Self::Secret => 'S', - } - } -} - -/// COSPAR (international) designator, stored verbatim from the TLE field. -/// -/// Format: 2-digit launch year, 3-digit launch number, up to 3-char piece -/// (e.g. `"98067A"`). -/// -/// # Examples -/// -/// ``` -/// use spod::tle::InternationalDesignator; -/// let d = InternationalDesignator("98067A".to_string()); -/// assert_eq!(d.0, "98067A"); -/// ``` -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct InternationalDesignator(pub String); - -/// Parsed TLE record with strongly-typed orbital elements. -/// -/// Public fields use typed `qtty`/`tempoch` quantities wherever a typed -/// equivalent exists. The drag-related fields ([`mean_motion_dot`], -/// [`mean_motion_ddot`], [`bstar`]) and [`eccentricity`] remain `f64`: -/// -// rationale: SGP4's BSTAR is a fitted, dimensionally-mixed pseudo-coefficient -// (units of 1/Earth-radii), not a clean physical drag coefficient — there is -// no canonical typed `qtty` representation. Likewise the *as-printed* time -// derivatives of mean motion and the dimensionless eccentricity have no -// typed wrapper that adds safety; we therefore expose them as plain `f64`. -// -/// [`mean_motion_dot`]: Tle::mean_motion_dot -/// [`mean_motion_ddot`]: Tle::mean_motion_ddot -/// [`bstar`]: Tle::bstar -/// [`eccentricity`]: Tle::eccentricity -/// -/// # Examples -/// -/// ``` -/// use spod::tle::parse_3le; -/// let tle = parse_3le( -/// "ISS (ZARYA)", -/// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", -/// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", -/// ).unwrap(); -/// assert_eq!(tle.norad_id.0, 25544); -/// ``` -#[derive(Clone, Debug)] -pub struct Tle { - /// Optional satellite name (from 3LE format, line 0). - pub name: Option, - /// NORAD catalog number (satellite ID). - pub norad_id: SatelliteNumber, - /// Classification level (`U`, `C`, or `S`). - pub classification: Classification, - /// COSPAR (international) designator: 2-digit launch year, 3-digit launch - /// number, piece. - pub international_designator: InternationalDesignator, - /// Epoch of the orbital elements (UTC). - pub epoch: Time, - /// First time derivative of mean motion (revolutions per day²), as printed. - pub mean_motion_dot: f64, - /// Second time derivative of mean motion (revolutions per day³). - pub mean_motion_ddot: f64, - /// SGP4 BSTAR drag term (1 / Earth radii). - pub bstar: f64, - /// Element set number, incremented by the originating data source. - pub element_set_number: u16, - /// Number of orbits completed by the satellite at the epoch. - pub revolution_number_at_epoch: u32, - /// Inclination of the orbit. - pub inclination: Degrees, - /// Right ascension of the ascending node. - pub raan: Degrees, - /// Eccentricity of the orbit (dimensionless, 0 ≤ e < 1). - pub eccentricity: f64, - /// Argument of perigee. - pub argument_of_perigee: Degrees, - /// Mean anomaly. - pub mean_anomaly: Degrees, - /// Mean motion (revolutions per day, typed). - pub mean_motion: AngularRate, -} diff --git a/target-sgp4-test/probe/Cargo.toml b/target-sgp4-test/probe/Cargo.toml deleted file mode 100644 index c68bce8..0000000 --- a/target-sgp4-test/probe/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "probe" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -publish = false - -[dependencies] -sgp4 = "2.4" - -[lints] -workspace = true diff --git a/target-sgp4-test/probe/src/lib.rs b/target-sgp4-test/probe/src/lib.rs deleted file mode 100644 index 0b47ef1..0000000 --- a/target-sgp4-test/probe/src/lib.rs +++ /dev/null @@ -1,2 +0,0 @@ -//! Sibling-agent probe crate (sgp4 ABI smoke check). -#![forbid(unsafe_code)] From 5bd2e8f7dd5f9f2eaabb090108dbc02bc1e2fc10 Mon Sep 17 00:00:00 2001 From: VPRamon Date: Sun, 4 Oct 2026 22:10:20 +0200 Subject: [PATCH 2/5] fix: retain full Vallado SGP4 capability Restore the local SGP4/SDP4 backend, probe, example, and Vallado regression coverage while adapting inputs to Siderust's canonical TLE records. Document the upstream parity gap and leave generic format, SPICE, TLE, and Lambert consolidation intact.\n\nPartially addresses #9.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 9 +- Cargo.lock | 20 ++ Cargo.toml | 13 +- README.md | 7 +- examples/04_sgp4_from_tle.rs | 45 ++++ examples/README.md | 2 + scripts/check_dep_graph.sh | 8 +- src/lib.rs | 2 + src/sgp4/elements.rs | 60 ++++++ src/sgp4/error.rs | 59 +++++ src/sgp4/mod.rs | 64 ++++++ src/sgp4/propagator.rs | 346 ++++++++++++++++++++++++++++++ src/sgp4/state.rs | 187 ++++++++++++++++ target-sgp4-test/probe/Cargo.toml | 18 ++ target-sgp4-test/probe/src/lib.rs | 2 + tests/sgp4_vallado.rs | 73 +++++++ 16 files changed, 905 insertions(+), 10 deletions(-) create mode 100644 examples/04_sgp4_from_tle.rs create mode 100644 src/sgp4/elements.rs create mode 100644 src/sgp4/error.rs create mode 100644 src/sgp4/mod.rs create mode 100644 src/sgp4/propagator.rs create mode 100644 src/sgp4/state.rs create mode 100644 target-sgp4-test/probe/Cargo.toml create mode 100644 target-sgp4-test/probe/src/lib.rs create mode 100644 tests/sgp4_vallado.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ac28e86..99169aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,12 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed -- Removed duplicated generic format, TLE, SGP4, Lambert, and SPICE - implementations. These capabilities remain owned by Siderust and its - upstream ecosystem; `spod` now exposes the service layer only. +- Removed duplicated generic format, TLE, and SPICE implementations in favor + of canonical Siderust APIs. Lambert is intentionally outside the core + service product scope. +- Retained the full Vallado-style SGP4/SDP4 implementation temporarily because + Siderust does not yet provide semantic parity; migration is tracked by + spod #29 and Siderust #98. - Renamed the project and Cargo package to `spod`, including the `spod` and `spod-rest` binaries, repository metadata, Docker examples, and REST configuration variables. diff --git a/Cargo.lock b/Cargo.lock index 0730c86..09d5def 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,6 +263,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -1171,6 +1172,13 @@ dependencies = [ "raw-cpuid", ] +[[package]] +name = "probe" +version = "0.1.0" +dependencies = [ + "sgp4", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1446,6 +1454,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sgp4" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9467b9a7be8485ed8be0f336d399c8f32c0fcd60686e7dd2ed3dab75c9a73eb3" +dependencies = [ + "chrono", + "serde", + "serde_json", +] + [[package]] name = "sha2" version = "0.11.0" @@ -1549,6 +1568,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sgp4", "siderust", "thiserror 1.0.69", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 12c9bf4..5186df9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,9 @@ # spod — service application for Siderust POD workflows. # -# Reusable POD science is provided by Siderust; this package owns service -# orchestration, interfaces, format adapters, and application-specific -# functionality. +# Reusable POD science and standard formats are provided by Siderust. This +# package owns service orchestration, interfaces, artifacts, provenance, and +# application-specific adapters. Full Vallado SGP4/SDP4 remains local +# temporarily because upstream parity is tracked by #29 and Siderust #98. [package] name = "spod" @@ -18,6 +19,10 @@ categories = ["science", "aerospace"] publish = false description = "Precise Orbit Determination service application built on Siderust." +[[example]] +name = "04_sgp4_from_tle" +path = "examples/04_sgp4_from_tle.rs" + [features] default = ["serde"] ## Compatibility feature; serialization support is always available. @@ -25,6 +30,7 @@ serde = [] [dependencies] siderust = { version = "0.12", features = ["pod"] } +sgp4 = { version = "2.4", default-features = false, features = ["std"] } # ── Linear algebra ──────────────────────────────────────────────────────────── # ── Serialisation ───────────────────────────────────────────────────────────── @@ -58,6 +64,7 @@ serde = { version = "1.0", features = ["derive"] } [workspace] resolver = "2" +members = ["target-sgp4-test/probe"] [workspace.package] version = "0.1.0" diff --git a/README.md b/README.md index d29a97b..c2c5a86 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Docker builder image. | --- | --- | | POD science | Provided by `siderust::pod` (forces, propagation, observations, estimation, QC and products) | | Scientific backend | Siderust 0.12 | +| Full SGP4/SDP4 | Local Vallado-style backend retained until upstream parity | | Products & QC | Residuals, orbit products, manifests, comparison/QC utilities | | Interfaces | Rust library, command-line interface, experimental Axum REST API | @@ -43,7 +44,11 @@ cargo test ## Relationship with the Siderust ecosystem -Foundational astrodynamics, typed quantities, time scales, frames, and reusable numerical mechanics live in the released Siderust ecosystem crates. `spod` is built on top of `siderust::pod`; it does not expose a scientific compatibility facade. Callers needing reusable force, observation, estimation, QC, or product APIs should import Siderust directly. +Foundational astrodynamics, typed quantities, time scales, frames, standard +formats, and reusable numerical mechanics live in the released Siderust +ecosystem crates. `spod` is built on top of `siderust::pod`; it does not expose +scientific compatibility facades. Full Vallado SGP4/SDP4 remains a documented +temporary local exception while parity is tracked by #29 and Siderust #98. Validate and run the synthetic POD configuration: diff --git a/examples/04_sgp4_from_tle.rs b/examples/04_sgp4_from_tle.rs new file mode 100644 index 0000000..01d0af1 --- /dev/null +++ b/examples/04_sgp4_from_tle.rs @@ -0,0 +1,45 @@ +//! Parse a TLE and propagate typed TEME states with SGP4. +//! +//! The SGP4 wrapper already exposes the natural minutes-since-epoch API, so +//! this example uses it directly. Position and velocity stay typed in TEME, +//! kilometres, and kilometres per second all the way to presentation. +//! +//! Run with: +//! cargo run --example 04_sgp4_from_tle + +#![allow(clippy::print_stdout)] + +use siderust::formats::tle::parse_tle; +use spod::sgp4::{GravityModel, Sgp4Propagator}; + +const L1: &str = "1 00005U 58002B 00179.78495062 .00000023 00000-0 28098-4 0 4753"; +const L2: &str = "2 00005 34.2682 348.7242 1859667 331.7664 19.3264 10.82419157413667"; + +fn main() { + let tle = parse_tle(L1, L2).expect("Vallado verification TLE should parse"); + let propagator = Sgp4Propagator::from_tle_with_model(&tle, GravityModel::Wgs72) + .expect("SGP4 initialization should succeed"); + + println!("=== Typed SGP4 propagation ==="); + println!("satellite : NORAD {}", tle.norad_id.0); + println!("gravity model : {:?}", propagator.gravity_model()); + + for minutes in [0.0, 360.0, 720.0, 1_080.0] { + let state = propagator + .propagate_minutes(minutes) + .expect("propagation should succeed"); + + println!(); + println!("t = {minutes:>7.1} min"); + println!("radius : {:.3}", state.position().distance()); + println!("speed : {:.6}", state.velocity().magnitude()); + println!("position:"); + println!(" x = {:.3}", state.position().x()); + println!(" y = {:.3}", state.position().y()); + println!(" z = {:.3}", state.position().z()); + println!("velocity:"); + println!(" vx = {:.6}", state.velocity().x()); + println!(" vy = {:.6}", state.velocity().y()); + println!(" vz = {:.6}", state.velocity().z()); + } +} diff --git a/examples/README.md b/examples/README.md index ba4be3d..dbe019c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -19,11 +19,13 @@ This makes the examples useful as copyable application code while still showing | Example | What it demonstrates | | --- | --- | | `configs/leo_gnss_mvp1.yaml` | Runs the synthetic LEO/GNSS POD reference workflow | +| `04_sgp4_from_tle` | Exercises the temporarily local full Vallado-style SGP4/SDP4 backend | Run it with: ```bash cargo run --bin spod -- run examples/configs/leo_gnss_mvp1.yaml +cargo run --example 04_sgp4_from_tle ``` ## Synthetic POD pipeline diff --git a/scripts/check_dep_graph.sh b/scripts/check_dep_graph.sh index 66b8772..fe55a08 100755 --- a/scripts/check_dep_graph.sh +++ b/scripts/check_dep_graph.sh @@ -1,18 +1,20 @@ #!/usr/bin/env bash -# Enforce the standalone dependency rules for spod. +# Enforce the standalone dependency rules for spod. The SGP4 probe is an +# intentional workspace exception while full Vallado parity is unavailable in +# Siderust (spod #29, Siderust #98). set -euo pipefail cd "$(dirname "$0")/.." if grep -En '^[[:space:]]*[A-Za-z0-9_-]+[[:space:]]*=.*\{[^}]*(path|git)[[:space:]]*=' \ - Cargo.toml; then + Cargo.toml target-sgp4-test/probe/Cargo.toml; then echo "Dependency-graph check found a path or git dependency." exit 1 fi source_null_count="$(cargo metadata --locked --format-version 1 | grep -o '"source":null' | wc -l)" -if [[ "$source_null_count" -ne 1 ]]; then +if [[ "$source_null_count" -ne 2 ]]; then echo "Dependency-graph check found an unexpected non-registry package." exit 1 fi diff --git a/src/lib.rs b/src/lib.rs index 95ee443..2d9ac10 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,3 +22,5 @@ #![forbid(unsafe_code)] pub mod service; +/// Full Vallado-style SGP4/SDP4 retained until upstream semantic parity. +pub mod sgp4; diff --git a/src/sgp4/elements.rs b/src/sgp4/elements.rs new file mode 100644 index 0000000..8ec4d9d --- /dev/null +++ b/src/sgp4/elements.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Vallés Puig, Ramon + +//! Bridge from Siderust's canonical TLE record to the SGP4 input record. +//! +//! The SGP4 backend ([`sgp4` crate](https://crates.io/crates/sgp4)) consumes +//! its own [`sgp4::Elements`] struct. We treat this purely as an internal +//! record format — callers always interact with the typed +//! [`siderust::formats::tle::TLE`]. + +use chrono::Datelike; +use sgp4::Elements; +use siderust::formats::tle::{Classification as TleClass, TLE}; + +use super::Sgp4Error; + +/// Convert a typed [`TLE`] into the dimensionless record consumed by the SGP4 +/// backend. +/// +/// The conversion preserves all SGP4-relevant fields (epoch, mean elements, +/// drag terms, classification, international designator) and is lossless in +/// both directions for the subset SGP4 uses. +pub(crate) fn tle_to_elements(tle: &TLE) -> Result { + let dt_utc = tle + .epoch + .try_to_chrono() + .map_err(|e| Sgp4Error::TimeConversion(format!("TLE epoch → chrono failed: {e:?}")))?; + // SGP4 wants a NaiveDateTime in UTC. + let datetime = dt_utc.naive_utc(); + // Sanity-check the year: AFSPC epoch encoding loses precision before 1957. + if datetime.year() < 1950 { + return Err(Sgp4Error::InvalidEpoch("year < 1950 not representable")); + } + + let classification = match tle.classification { + TleClass::Unclassified => sgp4::Classification::Unclassified, + TleClass::Classified => sgp4::Classification::Classified, + TleClass::Secret => sgp4::Classification::Secret, + }; + + Ok(Elements { + object_name: tle.name.clone(), + international_designator: Some(tle.international_designator.0.clone()), + norad_id: u64::from(tle.norad_id.0), + classification, + datetime, + mean_motion_dot: tle.mean_motion_dot, + mean_motion_ddot: tle.mean_motion_ddot, + drag_term: tle.bstar, + element_set_number: u64::from(tle.element_set_number), + inclination: tle.inclination.value(), + right_ascension: tle.raan.value(), + eccentricity: tle.eccentricity, + argument_of_perigee: tle.argument_of_perigee.value(), + mean_anomaly: tle.mean_anomaly.value(), + mean_motion: tle.mean_motion.value(), + revolution_number: u64::from(tle.revolution_number_at_epoch), + ephemeris_type: 0, + }) +} diff --git a/src/sgp4/error.rs b/src/sgp4/error.rs new file mode 100644 index 0000000..a2dddd5 --- /dev/null +++ b/src/sgp4/error.rs @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Vallés Puig, Ramon + +//! Unified error type for the SGP4/SDP4 propagator. + +use thiserror::Error; + +/// Errors produced while constructing or evaluating an [`Sgp4Propagator`](crate::sgp4::Sgp4Propagator). +/// +/// The variants split cleanly into three groups: +/// +/// * **Initialisation** ([`InvalidElements`](Sgp4Error::InvalidElements), +/// [`InvalidEpoch`](Sgp4Error::InvalidEpoch)) — the TLE record is structurally +/// well-formed but its mean elements cannot be turned into SGP4 secular / +/// periodic constants (e.g. eccentricity outside `[0, 1)`, sub-orbital mean +/// motion, malformed UTC epoch). +/// * **Propagation** ([`Propagation`](Sgp4Error::Propagation)) — the underlying +/// SGP4/SDP4 polynomial diverged at the requested epoch (typical for very +/// large `|Δt|` on decayed objects, or when Lyddane choice fails on a +/// geostationary regime — see Vallado 2006). +/// * **Time conversion** ([`TimeConversion`](Sgp4Error::TimeConversion)) — the +/// target epoch supplied as a [`siderust::tempoch::JulianDate`] cannot be +/// represented as a calendar instant, e.g. it falls outside the leap-second +/// table covered by the active [`siderust::tempoch::TimeContext`]. +/// +/// # Examples +/// +/// ``` +/// use spod::sgp4::Sgp4Error; +/// let e = Sgp4Error::InvalidEpoch("year out of range"); +/// assert!(matches!(e, Sgp4Error::InvalidEpoch(_))); +/// assert!(e.to_string().contains("epoch")); +/// ``` +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum Sgp4Error { + /// The TLE mean elements failed SGP4 initialisation. The `details` payload + /// is the human-readable diagnostic produced by the underlying propagator. + #[error("invalid TLE elements for SGP4: {details}")] + InvalidElements { + /// Free-form diagnostic from the SGP4 initialiser. + details: String, + }, + + /// The TLE epoch could not be expressed as a calendar instant. + #[error("invalid TLE epoch: {0}")] + InvalidEpoch(&'static str), + + /// The SGP4/SDP4 polynomial returned an error at the requested epoch. + #[error("SGP4 propagation failed: {details}")] + Propagation { + /// Free-form diagnostic from the SGP4 evaluator. + details: String, + }, + + /// Conversion between [`siderust::tempoch::JulianDate`] and a calendar instant failed. + #[error("UTC time conversion failed: {0}")] + TimeConversion(String), +} diff --git a/src/sgp4/mod.rs b/src/sgp4/mod.rs new file mode 100644 index 0000000..0f7e0b9 --- /dev/null +++ b/src/sgp4/mod.rs @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Vallés Puig, Ramon + +//! `spod::sgp4` — strongly typed SGP4 / SDP4 propagator producing TEME +//! Cartesian states. +//! +//! ## What this crate is +//! +//! * A type-preserving wrapper around the public-domain SGP4/SDP4 mean-element +//! propagator. Inputs are typed [`siderust::formats::tle::TLE`] records and target +//! epochs are typed [`siderust::tempoch::JulianDate`]. Outputs are +//! typed [`crate::sgp4::TemeState`] values: a geocentric **TEME** position in +//! kilometres and a velocity in km·s⁻¹. +//! * Validated against Vallado's "SGP4-VER" reference test set: the +//! `sgp4_vallado` integration test reproduces near-Earth and deep-space +//! positions to better than `1e-6 km` and velocities to better than +//! `1e-9 km·s⁻¹`, and checks a documented propagation failure. +//! +//! ## What this crate is *not* +//! +//! * It is not a TLE parser. Use [`siderust::formats::tle`] for that. +//! * It does not perform TEME → ITRF / GCRF rotations. Use +//! `siderust::coordinates::transform::providers::frames_teme` once Earth +//! Orientation Parameters are supplied. +//! * It does not implement orbit determination, force models, or estimation; +//! those live in the `spod-*` crates. +//! +//! ## Backend +//! +//! The numerical core is the pure-Rust [`sgp4`](https://crates.io/crates/sgp4) +//! crate (MIT-licensed, port of Vallado's reference implementation). It is +//! pulled in as a private dependency and never appears in this crate's +//! public surface — callers see only typed `affn` / `qtty` / `tempoch` / +//! `siderust` values. +//! +//! ## Example +//! +//! ``` +//! use spod::sgp4::Sgp4Propagator; +//! use siderust::formats::tle::parse_3le; +//! +//! let tle = parse_3le( +//! "ISS (ZARYA)", +//! "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", +//! "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", +//! ).unwrap(); +//! let prop = Sgp4Propagator::from_tle(&tle).unwrap(); +//! let state = prop.propagate_minutes(0.0).unwrap(); +//! assert!(state.position().distance().value() > 6_500.0); +//! assert!(state.velocity().magnitude().value() > 5.0); +//! ``` +//! +//! See `examples/04_sgp4_from_tle.rs` for a worked end-to-end example. + +#![forbid(unsafe_code)] + +mod elements; +mod error; +mod propagator; +mod state; + +pub use error::Sgp4Error; +pub use propagator::{GravityModel, Sgp4Propagator}; +pub use state::{KilometerPerSecond, TemePositionKm, TemeState, TemeVelocityKmPerSec}; diff --git a/src/sgp4/propagator.rs b/src/sgp4/propagator.rs new file mode 100644 index 0000000..d485a70 --- /dev/null +++ b/src/sgp4/propagator.rs @@ -0,0 +1,346 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Vallés Puig, Ramon + +//! SGP4/SDP4 propagator with strongly typed I/O. +//! +//! See the crate-level documentation for an overview. The propagator is a +//! thin, type-preserving wrapper around the public-domain SGP4 reference +//! implementation distributed via the [`sgp4` crate](https://crates.io/crates/sgp4). +//! The wrapper takes responsibility for: +//! +//! * mapping [`siderust::formats::tle::TLE`] into the SGP4 backend's element record, +//! * converting [`siderust::tempoch::JulianDate`] target epochs into +//! minutes-since-epoch (the propagator's natural argument), and +//! * tagging the resulting Cartesian arrays with their TEME / geocentric / +//! km / km·s⁻¹ types from `affn`, `siderust`, and `qtty`. + +use sgp4::{Constants, Elements, Geopotential, MinutesSinceEpoch}; +use siderust::formats::tle::TLE; +use siderust::tempoch::{JulianDate, UTC}; + +use super::elements::tle_to_elements; +use super::state::TemeState; +use super::Sgp4Error; + +/// Earth gravity / sidereal-time model selector for the SGP4/SDP4 polynomial. +/// +/// SGP4 was historically defined against the WGS-72 geopotential constants +/// together with the AFSPC sidereal-time convention. That combination +/// (`Wgs72`) is the **bit-exact** match for Vallado & Crawford's published +/// `tcppver.out` reference outputs and is the default here. +/// +/// `Wgs72Iau` keeps the WGS-72 geopotential but switches to the IAU +/// sidereal-time formula — slightly more accurate in absolute terms but +/// drifts a few millimetres away from the Vallado reference. +/// +/// `Wgs84` swaps in the WGS-84 geopotential constants together with the +/// IAU sidereal-time formula. New code that does not need bit-exact +/// reproduction of legacy AFSPC outputs should pick this. +/// +/// # Examples +/// +/// ``` +/// use spod::sgp4::GravityModel; +/// // The default matches Vallado's `tcppver.out` reference outputs exactly. +/// assert!(matches!(GravityModel::default(), GravityModel::Wgs72)); +/// ``` +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum GravityModel { + /// WGS-72 geopotential with AFSPC sidereal-time convention. Default; + /// reproduces Vallado's `tcppver.out` reference outputs to numerical + /// noise (≤ 1e-6 km / ≤ 1e-9 km·s⁻¹). + #[default] + Wgs72, + /// WGS-72 geopotential with the IAU sidereal-time formula. Use when + /// you want the WGS-72 mean elements but the IAU time convention. + Wgs72Iau, + /// WGS-84 geopotential with the IAU sidereal-time formula. Modern + /// default for new pipelines that don't need AFSPC bit-exactness. + Wgs84, +} + +impl GravityModel { + fn as_geopotential(self) -> Geopotential { + match self { + GravityModel::Wgs72 | GravityModel::Wgs72Iau => sgp4::WGS72, + GravityModel::Wgs84 => sgp4::WGS84, + } + } + + fn afspc_compat(self) -> bool { + matches!(self, GravityModel::Wgs72) + } +} + +/// Strongly typed SGP4/SDP4 propagator. +/// +/// Construct with [`Sgp4Propagator::from_tle`] (default WGS-72 gravity) or +/// [`Sgp4Propagator::from_tle_with_model`] (explicit gravity model). +/// Propagate with [`Sgp4Propagator::propagate_at`] (UTC Julian date) or +/// [`Sgp4Propagator::propagate_minutes`] (offset from the TLE epoch). +/// +/// The propagator owns its initialised `Constants` table, so propagation is +/// allocation-free and thread-safe (the type is `Send + Sync`). +/// +/// # Examples +/// +/// ``` +/// use spod::sgp4::{GravityModel, Sgp4Propagator}; +/// use siderust::formats::tle::parse_3le; +/// +/// let tle = parse_3le( +/// "ISS (ZARYA)", +/// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", +/// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", +/// ).unwrap(); +/// let prop = Sgp4Propagator::from_tle_with_model(&tle, GravityModel::Wgs72).unwrap(); +/// let s = prop.propagate_minutes(0.0).unwrap(); +/// assert!(s.position().distance().value() > 6_500.0); +/// ``` +#[derive(Clone, Debug)] +pub struct Sgp4Propagator { + constants: Constants, + elements: Elements, + model: GravityModel, + epoch_jd_utc: JulianDate, +} + +impl Sgp4Propagator { + /// Initialise the propagator from a [`TLE`] using the default + /// (WGS-72, Vallado 2006) gravity model. + /// + /// # Errors + /// + /// Returns [`Sgp4Error::InvalidElements`] if the SGP4 initialiser + /// rejects the mean elements (e.g. negative mean motion, eccentricity + /// outside `[0, 1)`), or [`Sgp4Error::InvalidEpoch`] / + /// [`Sgp4Error::TimeConversion`] if the TLE epoch cannot be expressed + /// as a calendar instant. + /// + /// # Examples + /// + /// ``` + /// use spod::sgp4::Sgp4Propagator; + /// use siderust::formats::tle::parse_3le; + /// let tle = parse_3le( + /// "ISS (ZARYA)", + /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", + /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", + /// ).unwrap(); + /// let _ = Sgp4Propagator::from_tle(&tle).unwrap(); + /// ``` + pub fn from_tle(tle: &TLE) -> Result { + Self::from_tle_with_model(tle, GravityModel::default()) + } + + /// Initialise the propagator from a [`TLE`] under an explicit + /// [`GravityModel`]. + /// + /// # Examples + /// + /// ``` + /// use spod::sgp4::{GravityModel, Sgp4Propagator}; + /// use siderust::formats::tle::parse_3le; + /// let tle = parse_3le( + /// "ISS (ZARYA)", + /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", + /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", + /// ).unwrap(); + /// let _ = Sgp4Propagator::from_tle_with_model(&tle, GravityModel::Wgs84).unwrap(); + /// ``` + pub fn from_tle_with_model(tle: &TLE, model: GravityModel) -> Result { + let elements = tle_to_elements(tle)?; + let constants = match model { + GravityModel::Wgs72 => Constants::from_elements_afspc_compatibility_mode(&elements) + .map_err(|e| Sgp4Error::InvalidElements { + details: format!("AFSPC init: {e:?}"), + })?, + GravityModel::Wgs72Iau => { + Constants::from_elements(&elements).map_err(|e| Sgp4Error::InvalidElements { + details: format!("IAU init (WGS-72): {e:?}"), + })? + } + GravityModel::Wgs84 => { + let geop = model.as_geopotential(); + let orbit = sgp4::Orbit::from_kozai_elements( + &geop, + elements.inclination * core::f64::consts::PI / 180.0, + elements.right_ascension * core::f64::consts::PI / 180.0, + elements.eccentricity, + elements.argument_of_perigee * core::f64::consts::PI / 180.0, + elements.mean_anomaly * core::f64::consts::PI / 180.0, + elements.mean_motion * 2.0 * core::f64::consts::PI / 1440.0, + ) + .map_err(|e| Sgp4Error::InvalidElements { + details: format!("Kozai elements: {e:?}"), + })?; + Constants::new( + geop, + sgp4::iau_epoch_to_sidereal_time, + sgp4::julian_years_since_j2000(&elements.datetime), + elements.drag_term, + orbit, + ) + .map_err(|e| Sgp4Error::InvalidElements { + details: format!("Constants::new: {e:?}"), + })? + } + }; + let epoch_jd_utc: JulianDate = tle.epoch.to::(); + Ok(Self { + constants, + elements, + model, + epoch_jd_utc, + }) + } + + /// Return the gravity model in use. + /// + /// # Examples + /// + /// ``` + /// use spod::sgp4::{GravityModel, Sgp4Propagator}; + /// use siderust::formats::tle::parse_3le; + /// let tle = parse_3le( + /// "ISS (ZARYA)", + /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", + /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", + /// ).unwrap(); + /// let p = Sgp4Propagator::from_tle(&tle).unwrap(); + /// assert_eq!(p.gravity_model(), GravityModel::Wgs72); + /// ``` + pub fn gravity_model(&self) -> GravityModel { + self.model + } + + /// UTC Julian-date epoch of the TLE this propagator was initialised + /// from. + /// + /// # Examples + /// + /// ``` + /// use spod::sgp4::Sgp4Propagator; + /// use siderust::formats::tle::parse_3le; + /// let tle = parse_3le( + /// "ISS (ZARYA)", + /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", + /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", + /// ).unwrap(); + /// let p = Sgp4Propagator::from_tle(&tle).unwrap(); + /// assert!(p.epoch_jd_utc().raw().value() > 2_454_700.0); + /// ``` + pub fn epoch_jd_utc(&self) -> JulianDate { + self.epoch_jd_utc + } + + /// Propagate to a UTC Julian-date epoch. + /// + /// The result is a typed [`TemeState`] valid at exactly the supplied + /// epoch. The conversion to minutes-since-epoch goes through `tempoch` + /// to honour leap seconds correctly. + /// + /// # Errors + /// + /// * [`Sgp4Error::Propagation`] — the SGP4 polynomial diverged at + /// the requested epoch. + /// * [`Sgp4Error::TimeConversion`] — the supplied UTC instant cannot + /// be related to the TLE epoch (e.g. a non-finite Julian date). + /// + /// # Examples + /// + /// ``` + /// use spod::sgp4::Sgp4Propagator; + /// use siderust::formats::tle::parse_3le; + /// let tle = parse_3le( + /// "ISS (ZARYA)", + /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", + /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", + /// ).unwrap(); + /// let p = Sgp4Propagator::from_tle(&tle).unwrap(); + /// let target = p.epoch_jd_utc(); // propagate to TLE epoch itself + /// let s = p.propagate_at(target).unwrap(); + /// assert!(s.position().distance().value() > 6_500.0); + /// ``` + pub fn propagate_at(&self, target_jd_utc: JulianDate) -> Result { + let t_jd = target_jd_utc.raw().value(); + let epoch_jd = self.epoch_jd_utc.raw().value(); + if !t_jd.is_finite() { + return Err(Sgp4Error::TimeConversion( + "target Julian date is non-finite".into(), + )); + } + let dt_minutes = (t_jd - epoch_jd) * 1_440.0; + self.propagate_internal(dt_minutes, target_jd_utc) + } + + /// Propagate by an offset, in minutes, from the TLE epoch. + /// + /// This is the natural argument to SGP4 and exactly mirrors the + /// reference test vectors distributed with Vallado's "SGP4-VER" set. + /// + /// # Errors + /// + /// [`Sgp4Error::Propagation`] if the polynomial diverges, or + /// [`Sgp4Error::TimeConversion`] if the resulting epoch cannot be + /// represented as a UTC Julian date. + /// + /// # Examples + /// + /// ``` + /// use spod::sgp4::Sgp4Propagator; + /// use siderust::formats::tle::parse_3le; + /// let tle = parse_3le( + /// "ISS (ZARYA)", + /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", + /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", + /// ).unwrap(); + /// let p = Sgp4Propagator::from_tle(&tle).unwrap(); + /// let s_now = p.propagate_minutes(0.0).unwrap(); + /// let s_later = p.propagate_minutes(90.0).unwrap(); + /// assert!(s_now.position() != s_later.position()); + /// ``` + pub fn propagate_minutes(&self, dt_minutes: f64) -> Result { + if !dt_minutes.is_finite() { + return Err(Sgp4Error::TimeConversion( + "minutes offset is non-finite".into(), + )); + } + let target = jd_offset_minutes(self.epoch_jd_utc, dt_minutes); + self.propagate_internal(dt_minutes, target) + } + + fn propagate_internal( + &self, + dt_minutes: f64, + target_jd_utc: JulianDate, + ) -> Result { + let prediction = if self.model.afspc_compat() { + self.constants + .propagate_afspc_compatibility_mode(MinutesSinceEpoch(dt_minutes)) + } else { + self.constants.propagate(MinutesSinceEpoch(dt_minutes)) + } + .map_err(|e| Sgp4Error::Propagation { + details: format!("{e:?}"), + })?; + // Touch `elements` so unused-field lints stay happy and so users + // can later add a `pub fn elements(&self)` accessor without an + // ABI change. + let _ = &self.elements; + Ok(TemeState::from_arrays( + target_jd_utc, + prediction.position, + prediction.velocity, + )) + } +} + +fn jd_offset_minutes(epoch: JulianDate, minutes: f64) -> JulianDate { + use siderust::qtty::unit::Day; + use siderust::qtty::Quantity; + let days = minutes / 1_440.0; + let raw = epoch.raw().value() + days; + JulianDate::::try_new(Quantity::::new(raw)) + .expect("finite Julian date increments remain finite") +} diff --git a/src/sgp4/state.rs b/src/sgp4/state.rs new file mode 100644 index 0000000..b483411 --- /dev/null +++ b/src/sgp4/state.rs @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Vallés Puig, Ramon + +//! Strongly typed SGP4/SDP4 prediction state. +//! +//! [`TemeState`] bundles the position and velocity returned by a single +//! [`Sgp4Propagator::propagate_at`](crate::Sgp4Propagator::propagate_at) call, +//! together with the UTC instant the prediction is valid for. +//! +//! The state lives in the **TEME** (True Equator, Mean Equinox) frame defined +//! by `siderust::affn::frames::TEME`, geocentric, with positions in +//! [`siderust::qtty::unit::Kilometer`] and velocities in [`KilometerPerSecond`]. +//! Downstream conversion to ITRF / GCRF / ECEF is provided by +//! `siderust::coordinates::transform::providers::frames_teme` once Earth +//! Orientation Parameters are supplied. + +use siderust::coordinates::cartesian::{position, velocity}; +use siderust::qtty::{length::Kilometer, time::Second, Per}; +use siderust::tempoch::{JulianDate, UTC}; + +/// Velocity unit alias used by the SGP4 propagator: kilometres per second. +/// +/// SGP4 publishes velocities in km·s⁻¹; we expose them as a typed +/// [`siderust::qtty::Per`] so users cannot accidentally +/// mix them with m·s⁻¹ values from other parts of the stack. +/// +/// # Examples +/// +/// ``` +/// use spod::sgp4::KilometerPerSecond; +/// use siderust::qtty::Quantity; +/// let v: Quantity = Quantity::new(7.5); +/// assert!((v.value() - 7.5).abs() < 1e-12); +/// ``` +pub type KilometerPerSecond = Per; + +/// Geocentric **TEME** Cartesian position with kilometre units. +/// +/// Re-export of [`siderust::coordinates::cartesian::position::TEME`] specialised +/// to [`siderust::qtty::unit::Kilometer`]. +pub type TemePositionKm = position::TEME; + +/// **TEME** Cartesian velocity with km·s⁻¹ units. +pub type TemeVelocityKmPerSec = velocity::TEME; + +/// SGP4 prediction at a single epoch in the **TEME** frame. +/// +/// Construction is performed by the propagator; this struct only exposes +/// typed accessors. The fields are public-but-stable — adding new components +/// (e.g. covariance) would be a breaking change requiring a new type. +/// +/// # Examples +/// +/// ``` +/// use spod::sgp4::{Sgp4Propagator, TemeState}; +/// use siderust::formats::tle::parse_3le; +/// +/// let tle = parse_3le( +/// "ISS (ZARYA)", +/// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", +/// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", +/// ).unwrap(); +/// let prop = Sgp4Propagator::from_tle(&tle).unwrap(); +/// let state: TemeState = prop.propagate_minutes(0.0).unwrap(); +/// // Magnitudes are in kilometres and km/s for an LEO. +/// let r = state.position().as_array(); +/// assert!(r[0].value().abs() < 8_000.0); +/// ``` +#[derive(Clone, Debug)] +pub struct TemeState { + epoch_utc: JulianDate, + position: TemePositionKm, + velocity: TemeVelocityKmPerSec, +} + +impl TemeState { + /// Construct a typed state from the SGP4 raw arrays plus its valid epoch. + /// + /// `position_km` / `velocity_km_per_s` follow the standard SGP4 ordering + /// `[x, y, z]` in TEME. + /// + /// # Examples + /// + /// ``` + /// use spod::sgp4::TemeState; + /// use siderust::tempoch::{JulianDate, Time, UTC}; + /// use chrono::{TimeZone, Utc}; + /// + /// let t = Time::::try_from_chrono( + /// Utc.with_ymd_and_hms(2000, 1, 1, 12, 0, 0).unwrap(), + /// ) + /// .unwrap(); + /// let jd = t.to::(); + /// let s = TemeState::from_arrays( + /// jd, + /// [7000.0, 0.0, 0.0], + /// [0.0, 7.5, 0.0], + /// ); + /// assert!((s.position().x().value() - 7000.0).abs() < 1e-12); + /// assert!((s.velocity().y().value() - 7.5).abs() < 1e-12); + /// ``` + pub fn from_arrays( + epoch_utc: JulianDate, + position_km: [f64; 3], + velocity_km_per_s: [f64; 3], + ) -> Self { + use siderust::qtty::Quantity; + let p = TemePositionKm::new( + Quantity::::new(position_km[0]), + Quantity::::new(position_km[1]), + Quantity::::new(position_km[2]), + ); + let v = TemeVelocityKmPerSec::new( + Quantity::::new(velocity_km_per_s[0]), + Quantity::::new(velocity_km_per_s[1]), + Quantity::::new(velocity_km_per_s[2]), + ); + Self { + epoch_utc, + position: p, + velocity: v, + } + } + + /// UTC epoch the prediction is valid for. + /// + /// # Examples + /// + /// ``` + /// use spod::sgp4::Sgp4Propagator; + /// use siderust::formats::tle::parse_3le; + /// let tle = parse_3le( + /// "ISS (ZARYA)", + /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", + /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", + /// ).unwrap(); + /// let p = Sgp4Propagator::from_tle(&tle).unwrap(); + /// let s = p.propagate_minutes(0.0).unwrap(); + /// assert!(s.epoch().raw().value() > 2_454_000.0); + /// ``` + pub fn epoch(&self) -> JulianDate { + self.epoch_utc + } + + /// Geocentric TEME position in kilometres. + /// + /// # Examples + /// + /// ``` + /// use spod::sgp4::Sgp4Propagator; + /// use siderust::formats::tle::parse_3le; + /// let tle = parse_3le( + /// "ISS (ZARYA)", + /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", + /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", + /// ).unwrap(); + /// let p = Sgp4Propagator::from_tle(&tle).unwrap(); + /// let s = p.propagate_minutes(0.0).unwrap(); + /// let r = s.position(); + /// assert!(r.distance().value() > 6_000.0); + /// ``` + pub fn position(&self) -> &TemePositionKm { + &self.position + } + + /// TEME velocity in km·s⁻¹. + /// + /// # Examples + /// + /// ``` + /// use spod::sgp4::Sgp4Propagator; + /// use siderust::formats::tle::parse_3le; + /// let tle = parse_3le( + /// "ISS (ZARYA)", + /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", + /// "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537", + /// ).unwrap(); + /// let p = Sgp4Propagator::from_tle(&tle).unwrap(); + /// let s = p.propagate_minutes(0.0).unwrap(); + /// let v = s.velocity(); + /// // LEO speed is ~7.7 km/s. + /// assert!(v.magnitude().value() > 5.0 && v.magnitude().value() < 10.0); + /// ``` + pub fn velocity(&self) -> &TemeVelocityKmPerSec { + &self.velocity + } +} diff --git a/target-sgp4-test/probe/Cargo.toml b/target-sgp4-test/probe/Cargo.toml new file mode 100644 index 0000000..c68bce8 --- /dev/null +++ b/target-sgp4-test/probe/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "probe" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +publish = false + +[dependencies] +sgp4 = "2.4" + +[lints] +workspace = true diff --git a/target-sgp4-test/probe/src/lib.rs b/target-sgp4-test/probe/src/lib.rs new file mode 100644 index 0000000..0b47ef1 --- /dev/null +++ b/target-sgp4-test/probe/src/lib.rs @@ -0,0 +1,2 @@ +//! Sibling-agent probe crate (sgp4 ABI smoke check). +#![forbid(unsafe_code)] diff --git a/tests/sgp4_vallado.rs b/tests/sgp4_vallado.rs new file mode 100644 index 0000000..d9f748e --- /dev/null +++ b/tests/sgp4_vallado.rs @@ -0,0 +1,73 @@ +//! Regression coverage for the locally retained full Vallado SGP4/SDP4 path. + +use siderust::formats::tle::parse_tle; +use spod::sgp4::{Sgp4Error, Sgp4Propagator}; + +fn assert_state( + propagator: &Sgp4Propagator, + minutes: f64, + expected_position: [f64; 3], + expected_velocity: [f64; 3], +) { + let state = propagator.propagate_minutes(minutes).unwrap(); + for (actual, expected) in state.position().as_array().iter().zip(expected_position) { + assert!((actual.value() - expected).abs() < 1e-6); + } + for (actual, expected) in state.velocity().as_array().iter().zip(expected_velocity) { + assert!((actual.value() - expected).abs() < 1e-9); + } +} + +#[test] +fn matches_vallado_near_earth_reference_epochs() { + let tle = parse_tle( + "1 00005U 58002B 00179.78495062 .00000023 00000-0 28098-4 0 4753", + "2 00005 34.2682 348.7242 1859667 331.7664 19.3264 10.82419157413667", + ) + .unwrap(); + let propagator = Sgp4Propagator::from_tle(&tle).unwrap(); + + assert_state( + &propagator, + 0.0, + [7022.46529266, -1400.08296755, 0.03995155], + [1.893841015, 6.405893759, 4.534807250], + ); + assert_state( + &propagator, + 1440.0, + [-938.55923943, -6268.18748831, -4294.02924751], + [7.536105209, -0.427127707, 0.989878080], + ); +} + +#[test] +fn matches_vallado_deep_space_reference_epoch() { + let tle = parse_tle( + "1 04632U 70093B 04031.91070959 -.00000084 00000-0 10000-3 0 9955", + "2 04632 11.4628 273.1101 1450506 207.6000 143.9350 1.20231981 44145", + ) + .unwrap(); + let propagator = Sgp4Propagator::from_tle(&tle).unwrap(); + + assert_state( + &propagator, + -5184.0, + [-29020.02587128, 13819.84419063, -5713.33679183], + [-1.768068390, -3.235371192, -0.395206135], + ); +} + +#[test] +fn reports_vallado_propagation_error() { + let tle = parse_tle( + "1 33333U 05037B 05333.02012661 .25992681 00000-0 24476-3 0 1532", + "2 33333 96.4736 157.9986 9950000 244.0492 110.6523 4.00004038 10700", + ) + .unwrap(); + let propagator = Sgp4Propagator::from_tle(&tle).unwrap(); + + let error = propagator.propagate_minutes(25.0).unwrap_err(); + assert!(matches!(error, Sgp4Error::Propagation { .. })); + assert!(error.to_string().contains("NegativeSemiLatusRectum")); +} From 337928dfa12c1d9ec0e59e0ffaf94886c52ccede Mon Sep 17 00:00:00 2001 From: VPRamon Date: Sun, 4 Oct 2026 22:11:00 +0200 Subject: [PATCH 3/5] docs: clarify temporary SGP4 validation ownership Document the retained Vallado regression vectors as a temporary upstream-gap contract and remove stale Siderust ownership claims.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/validation/fixtures.md | 6 +++--- src/sgp4/mod.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/validation/fixtures.md b/docs/validation/fixtures.md index 76d725c..902c030 100644 --- a/docs/validation/fixtures.md +++ b/docs/validation/fixtures.md @@ -25,11 +25,11 @@ This file is the authoritative registry of every test fixture committed to the --- -## siderust (sgp4) +## spod (temporary SGP4 upstream-gap validation) | Path | Format | Source | License | Size | Last fetched | Notes | |---|---|---|---|---|---|---| -| `test-data/vallado_sgp4ver_subset.toml` | TOML (SGP4 reference) | Derived from Vallado `SGP4-VER.TLE` + `tcppver.out` (https://celestrak.org/software/vallado-sw.php) | Public domain (Vallado) | < 10 KB | 2026-05-12 | 6 satellites × 4 epochs; reference values extracted from `tcppver.out` | +| `tests/sgp4_vallado.rs` | Rust regression vectors | Derived from Vallado `SGP4-VER.TLE` + `tcppver.out` (https://celestrak.org/software/vallado-sw.php) | Public domain (Vallado) | < 10 KB | 2026-10-04 | Near-Earth and deep-space position/velocity epochs plus a documented propagation error | --- @@ -41,7 +41,7 @@ This file is the authoritative registry of every test fixture committed to the --- -## spod (io) +## Siderust canonical formats | Path | Format | Source | License | Size | Last fetched | Notes | |---|---|---|---|---|---|---| diff --git a/src/sgp4/mod.rs b/src/sgp4/mod.rs index 0f7e0b9..93d5238 100644 --- a/src/sgp4/mod.rs +++ b/src/sgp4/mod.rs @@ -23,7 +23,7 @@ //! `siderust::coordinates::transform::providers::frames_teme` once Earth //! Orientation Parameters are supplied. //! * It does not implement orbit determination, force models, or estimation; -//! those live in the `spod-*` crates. +//! those live in Siderust's POD APIs. //! //! ## Backend //! From 877793886c1a79204e61f6cfcf08235a265f6a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vall=C3=A9s=20Puig=2C=20Ramon?= <74069811+VPRamon@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:30:52 +0200 Subject: [PATCH 4/5] docs: document temporary sgp4 module --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c2c5a86..2ef9e84 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ persistence from the reusable POD implementation in Siderust. ```text src/ service/ Configuration, orchestration, REST/CLI and artifact handling + sgp4/ Temporary full Vallado SGP4/SDP4 compatibility layer (#29) bin/ CLI and experimental REST entry points ``` From 4ad72a5a7a7351e7cc9959ba5a9557a856f475d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vall=C3=A9s=20Puig=2C=20Ramon?= <74069811+VPRamon@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:30:54 +0200 Subject: [PATCH 5/5] docs: expose temporary sgp4 module in crate overview --- src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib.rs b/src/lib.rs index 2d9ac10..4b1cd5b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ //! | Module | Contents | //! |--------|----------| //! | [`service`] | Pipeline orchestration, transport, and job runner | +//! | [`sgp4`] | Temporary full Vallado SGP4/SDP4 compatibility layer; removal tracked by spod #29 | //! //! ## Binary entry-points //!