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..99169aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed +- 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 eca8e0b..09d5def 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" @@ -337,26 +314,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 +667,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 +675,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 +1051,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 +1061,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 +1105,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" @@ -1383,12 +1274,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 +1420,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" @@ -1620,7 +1496,6 @@ dependencies = [ "hex", "keplerian", "optica", - "parquet", "paste", "principia", "qtty", @@ -1632,7 +1507,7 @@ dependencies = [ "spin", "tempoch", "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", + "toml", "wide", ] @@ -1648,7 +1523,7 @@ dependencies = [ "regex", "serde", "sha2", - "toml 1.1.2+spec-1.1.0", + "toml", "walkdir", ] @@ -1685,14 +1560,11 @@ name = "spod" version = "0.1.0" dependencies = [ "anyhow", - "approx", "axum", - "cheby", "chrono", "clap", "env_logger", "log", - "quick-xml", "serde", "serde_json", "serde_yaml", @@ -1700,9 +1572,7 @@ dependencies = [ "siderust", "thiserror 1.0.69", "tokio", - "toml 0.8.23", "tower", - "trybuild", "uuid", ] @@ -1754,12 +1624,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 +1645,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 +1685,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 +1710,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 +1718,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 +1734,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 +1797,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 +1833,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 +2044,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..5186df9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +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. The [workspace] block is kept only so target-sgp4-test/probe -# can inherit package metadata. +# 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" @@ -19,10 +19,6 @@ 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" @@ -31,22 +27,10 @@ path = "examples/04_sgp4_from_tle.rs" 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"] } +sgp4 = { version = "2.4", default-features = false, features = ["std"] } # ── Linear algebra ──────────────────────────────────────────────────────────── # ── Serialisation ───────────────────────────────────────────────────────────── @@ -62,9 +46,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,12 +60,8 @@ 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"] diff --git a/README.md b/README.md index 5726bb5..2ef9e84 100644 --- a/README.md +++ b/README.md @@ -18,23 +18,20 @@ 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 | +| 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 | -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 + sgp4/ Temporary full Vallado SGP4/SDP4 compatibility layer (#29) bin/ CLI and experimental REST entry points ``` @@ -48,7 +45,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: @@ -67,20 +68,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/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/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 index b150fcf..01d0af1 100644 --- a/examples/04_sgp4_from_tle.rs +++ b/examples/04_sgp4_from_tle.rs @@ -9,8 +9,8 @@ #![allow(clippy::print_stdout)] +use siderust::formats::tle::parse_tle; 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"; diff --git a/examples/README.md b/examples/README.md index d810046..dbe019c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,13 +18,13 @@ 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 | +| `04_sgp4_from_tle` | Exercises the temporarily local full Vallado-style SGP4/SDP4 backend | -Run them with: +Run it with: ```bash -cargo run --example 03_lambert_earth_to_mars +cargo run --bin spod -- run examples/configs/leo_gnss_mvp1.yaml cargo run --example 04_sgp4_from_tle ``` diff --git a/scripts/check_dep_graph.sh b/scripts/check_dep_graph.sh index d67595d..fe55a08 100755 --- a/scripts/check_dep_graph.sh +++ b/scripts/check_dep_graph.sh @@ -1,5 +1,7 @@ #!/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 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..4b1cd5b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,12 +12,8 @@ //! //! | 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 | +//! | [`sgp4`] | Temporary full Vallado SGP4/SDP4 compatibility layer; removal tracked by spod #29 | //! //! ## Binary entry-points //! @@ -26,9 +22,6 @@ #![forbid(unsafe_code)] -pub mod io; -pub mod lambert; pub mod service; +/// Full Vallado-style SGP4/SDP4 retained until upstream semantic parity. 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 index b9372cc..8ec4d9d 100644 --- a/src/sgp4/elements.rs +++ b/src/sgp4/elements.rs @@ -1,26 +1,26 @@ // 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. +//! 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 -//! [`crate::tle::Tle`]. +//! [`siderust::formats::tle::TLE`]. -use crate::tle::{Classification as TleClass, 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 +/// 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 { +pub(crate) fn tle_to_elements(tle: &TLE) -> Result { let dt_utc = tle .epoch .try_to_chrono() diff --git a/src/sgp4/mod.rs b/src/sgp4/mod.rs index 9033765..93d5238 100644 --- a/src/sgp4/mod.rs +++ b/src/sgp4/mod.rs @@ -7,23 +7,23 @@ //! ## 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 +//! propagator. Inputs are typed [`siderust::formats::tle::TLE`] records and target //! epochs are typed [`siderust::tempoch::JulianDate`]. Outputs are -//! typed [`TemeState`] values: a geocentric **TEME** position in +//! 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 -//! `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. +//! `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 [`crate::tle`] for that. +//! * 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. +//! those live in Siderust's POD APIs. //! //! ## Backend //! @@ -37,7 +37,7 @@ //! //! ``` //! use spod::sgp4::Sgp4Propagator; -//! use spod::tle::parse_3le; +//! use siderust::formats::tle::parse_3le; //! //! let tle = parse_3le( //! "ISS (ZARYA)", diff --git a/src/sgp4/propagator.rs b/src/sgp4/propagator.rs index 27b675b..d485a70 100644 --- a/src/sgp4/propagator.rs +++ b/src/sgp4/propagator.rs @@ -8,14 +8,14 @@ //! 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, +//! * 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 crate::tle::Tle; use sgp4::{Constants, Elements, Geopotential, MinutesSinceEpoch}; +use siderust::formats::tle::TLE; use siderust::tempoch::{JulianDate, UTC}; use super::elements::tle_to_elements; @@ -86,7 +86,7 @@ impl GravityModel { /// /// ``` /// use spod::sgp4::{GravityModel, Sgp4Propagator}; -/// use spod::tle::parse_3le; +/// use siderust::formats::tle::parse_3le; /// /// let tle = parse_3le( /// "ISS (ZARYA)", @@ -106,7 +106,7 @@ pub struct Sgp4Propagator { } impl Sgp4Propagator { - /// Initialise the propagator from a [`Tle`] using the default + /// Initialise the propagator from a [`TLE`] using the default /// (WGS-72, Vallado 2006) gravity model. /// /// # Errors @@ -121,7 +121,7 @@ impl Sgp4Propagator { /// /// ``` /// use spod::sgp4::Sgp4Propagator; - /// use spod::tle::parse_3le; + /// use siderust::formats::tle::parse_3le; /// let tle = parse_3le( /// "ISS (ZARYA)", /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", @@ -129,18 +129,18 @@ impl Sgp4Propagator { /// ).unwrap(); /// let _ = Sgp4Propagator::from_tle(&tle).unwrap(); /// ``` - pub fn from_tle(tle: &Tle) -> Result { + pub fn from_tle(tle: &TLE) -> Result { Self::from_tle_with_model(tle, GravityModel::default()) } - /// Initialise the propagator from a [`Tle`] under an explicit + /// Initialise the propagator from a [`TLE`] under an explicit /// [`GravityModel`]. /// /// # Examples /// /// ``` /// use spod::sgp4::{GravityModel, Sgp4Propagator}; - /// use spod::tle::parse_3le; + /// use siderust::formats::tle::parse_3le; /// let tle = parse_3le( /// "ISS (ZARYA)", /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", @@ -148,7 +148,7 @@ impl Sgp4Propagator { /// ).unwrap(); /// let _ = Sgp4Propagator::from_tle_with_model(&tle, GravityModel::Wgs84).unwrap(); /// ``` - pub fn from_tle_with_model(tle: &Tle, model: GravityModel) -> Result { + 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) @@ -201,7 +201,7 @@ impl Sgp4Propagator { /// /// ``` /// use spod::sgp4::{GravityModel, Sgp4Propagator}; - /// use spod::tle::parse_3le; + /// use siderust::formats::tle::parse_3le; /// let tle = parse_3le( /// "ISS (ZARYA)", /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", @@ -221,7 +221,7 @@ impl Sgp4Propagator { /// /// ``` /// use spod::sgp4::Sgp4Propagator; - /// use spod::tle::parse_3le; + /// use siderust::formats::tle::parse_3le; /// let tle = parse_3le( /// "ISS (ZARYA)", /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", @@ -251,7 +251,7 @@ impl Sgp4Propagator { /// /// ``` /// use spod::sgp4::Sgp4Propagator; - /// use spod::tle::parse_3le; + /// use siderust::formats::tle::parse_3le; /// let tle = parse_3le( /// "ISS (ZARYA)", /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", @@ -289,7 +289,7 @@ impl Sgp4Propagator { /// /// ``` /// use spod::sgp4::Sgp4Propagator; - /// use spod::tle::parse_3le; + /// use siderust::formats::tle::parse_3le; /// let tle = parse_3le( /// "ISS (ZARYA)", /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", diff --git a/src/sgp4/state.rs b/src/sgp4/state.rs index 0364fb5..b483411 100644 --- a/src/sgp4/state.rs +++ b/src/sgp4/state.rs @@ -53,7 +53,7 @@ pub type TemeVelocityKmPerSec = velocity::TEME; /// /// ``` /// use spod::sgp4::{Sgp4Propagator, TemeState}; -/// use spod::tle::parse_3le; +/// use siderust::formats::tle::parse_3le; /// /// let tle = parse_3le( /// "ISS (ZARYA)", @@ -128,7 +128,7 @@ impl TemeState { /// /// ``` /// use spod::sgp4::Sgp4Propagator; - /// use spod::tle::parse_3le; + /// use siderust::formats::tle::parse_3le; /// let tle = parse_3le( /// "ISS (ZARYA)", /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", @@ -148,7 +148,7 @@ impl TemeState { /// /// ``` /// use spod::sgp4::Sgp4Propagator; - /// use spod::tle::parse_3le; + /// use siderust::formats::tle::parse_3le; /// let tle = parse_3le( /// "ISS (ZARYA)", /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", @@ -169,7 +169,7 @@ impl TemeState { /// /// ``` /// use spod::sgp4::Sgp4Propagator; - /// use spod::tle::parse_3le; + /// use siderust::formats::tle::parse_3le; /// let tle = parse_3le( /// "ISS (ZARYA)", /// "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927", 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/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")); +}