diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bed9e9a..3009ade 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,7 +127,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo build --bins - run: cargo run --quiet --bin spod -- --help - - name: Start REST server and query health + - name: Start REST server and verify job provenance shell: bash run: | set -euo pipefail @@ -135,16 +135,74 @@ jobs: cargo run --quiet --bin spod-rest & server_pid=$! trap 'kill "$server_pid" 2>/dev/null || true' EXIT + + ready=false for attempt in {1..30}; do - if curl --fail --silent "http://127.0.0.1:${port}/healthz"; then - exit 0 + if curl --fail --silent "http://127.0.0.1:${port}/healthz" >/dev/null; then + ready=true + break + fi + sleep 1 + done + if [[ "$ready" != true ]]; then + echo "REST server did not become ready" >&2 + exit 1 + fi + + job_id="$( + curl --fail --silent \ + -H 'content-type: application/json' \ + -d '{"enable_j2":false}' \ + "http://127.0.0.1:${port}/jobs" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' + )" + + status_json= + for attempt in {1..60}; do + status_json="$(curl --fail --silent "http://127.0.0.1:${port}/jobs/${job_id}")" + state="$(printf '%s' "$status_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["state"])')" + if [[ "$state" == "finished" ]]; then + break + fi + if [[ "$state" == "failed" ]]; then + echo "$status_json" >&2 + exit 1 fi sleep 1 done - echo "REST server did not become ready" >&2 - exit 1 + + state="$(printf '%s' "$status_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["state"])')" + [[ "$state" == "finished" ]] + + run_dir="$SPOD_REST_OUT/$job_id" + request_path="$run_dir/request.json" + manifest_path="$run_dir/run.manifest.json" + test -f "$request_path" + test -f "$manifest_path" + + python3 - "$manifest_path" "$request_path" <<'PY' + import hashlib + import json + import pathlib + import sys + + manifest_path = pathlib.Path(sys.argv[1]) + request_path = pathlib.Path(sys.argv[2]) + manifest = json.loads(manifest_path.read_text()) + payload = request_path.read_bytes() + + assert len(manifest["inputs"]) == 1 + input_ref = manifest["inputs"][0] + assert pathlib.Path(input_ref["path"]) == request_path + assert input_ref["kind"] == "configuration" + assert input_ref["bytes"] == len(payload) + digest = hashlib.sha256(payload).hexdigest() + assert input_ref["sha256"] == digest + assert manifest["config_sha256"] == digest + PY env: SPOD_REST_BIND: 127.0.0.1:18080 + SPOD_REST_OUT: ${{ runner.temp }}/spod-rest-smoke docker: name: Docker image and compatibility smoke tests @@ -160,7 +218,7 @@ jobs: run: | set -euo pipefail cleanup() { - docker rm -f spod-ci-default spod-ci-new spod-ci-legacy >/dev/null 2>&1 || true + docker rm -f spod-ci-default spod-ci-new >/dev/null 2>&1 || true } trap cleanup EXIT @@ -183,16 +241,6 @@ jobs: sleep 1 done - docker run -d --name spod-ci-legacy -p 18081:8080 \ - -e SIDERUST_POD_REST_BIND=0.0.0.0:8080 spod:ci >/dev/null - for attempt in {1..30}; do - if curl --fail --silent "http://127.0.0.1:18081/healthz"; then - exit 0 - fi - [[ "$attempt" -eq 30 ]] && exit 1 - sleep 1 - done - coverage: name: Coverage runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 6061007..4ec9bfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,162 +1,65 @@ # Changelog -All notable changes to this workspace are documented here. Format follows +All notable changes to this repository are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project -follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) per -crate (independent versioning). +follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### Migration - -* Renamed the project to `spod`, including the Cargo package/library, the - `spod` and `spod-rest` binaries, repository URLs, and Docker image examples. -* Renamed REST configuration variables to `SPOD_REST_BIND` and - `SPOD_REST_OUT`. The former `SIDERUST_POD_REST_BIND` and - `SIDERUST_POD_REST_OUT` names remain supported as deprecated fallbacks. - Update scripts and deployments to use the `SPOD_*` namespace. -* Raised the MSRV to Rust 1.89 because the released `siderust` dependency - graph requires it. The Docker builder and CI MSRV job use the same version. -* Split CI into blocking format, build, lint, feature-test, doctest, - documentation, service-smoke, Docker, coverage, guard, MSRV, and - supply-chain jobs. - -### Workspace +### Changed -* Consolidated the former multi-crate implementation into the current - `spod` package and migrated its Siderust ecosystem dependencies to - released crates.io versions. -* Added a `[workspace.lints]` table (`unsafe_code = forbid`, - `missing_docs = deny`, `clippy::all = deny`, - `clippy::{todo,unimplemented,dbg_macro} = deny`, - `rustdoc::broken_intra_doc_links = deny`) and wired every workspace - member to inherit it via `[lints] workspace = true`. The full - workspace now builds clean under `cargo clippy --all-targets -- -D - warnings` and `cargo doc -- -D rustdoc::broken-intra-doc-links`. -* CI scripts under `scripts/`: - * `check_no_todos.sh` (gates `todo!`/`unimplemented!`/TODO/FIXME/XXX - comments — currently passes with zero hits in the source tree). - * `snapshot_public_api.sh` (commits `crates//api.snapshot` - baselines for all 14 library crates via `cargo public-api`; runs - in `--update` mode locally and as a diff in CI). -* `.github/workflows/ci.yml`: extended the `build-test` job with - default / no-default-features / all-features test matrices and the - new `todo-sweep` step, and added an advisory `public-api` job that - diffs against the committed snapshots. -* Per-crate library-hygiene baseline: every flagged `missing_docs` - warning across `siderust-{tle,dynamics,lambert,sgp4,spice}` and - `siderust-pod-{core,dynamics}` was resolved (120 warnings → 0) - with informative rustdoc covering items, fields, variants, and - units/encoding. -* `siderust-pod-estimation::WlsSolverError`: new `Other(String)` variant - + `WlsSolverError::other()` constructor so callers can wrap upstream - propagation/STM failures during normal-equation assembly. -* `siderust-pod-service::pipeline`: scoped `#[allow(deprecated)]` - around the legacy `finite_diff_stm_series` call site (upstream notes - explicitly preserve the series API for batch-LS use, since - `propagate_stm` only returns Φ at the terminal epoch). +- Renamed the project and Cargo package to `spod`, including the `spod` and + `spod-rest` binaries, repository metadata, Docker examples, and REST + configuration variables. +- Consolidated `spod` into a service/application built on the canonical + `siderust 0.12` POD APIs instead of maintaining a second POD scientific + implementation. +- The synthetic POD pipeline now uses + `siderust::pod::propagation::VariationalPropagator` for aligned per-epoch + states and state-transition matrices. +- CLI runs hash the executed YAML configuration with Siderust `DatasetRef`; + REST jobs hash a deterministic serialized request representation. +- The service now rejects every real-data input while real-data ingestion is + not implemented, preventing unused files from being recorded as scientific + provenance. +- The synthetic-only configuration rejects unsupported third-body gravity + instead of silently ignoring the requested force model. +- REST jobs persist their canonical request document inside the run directory + before hashing it, so every manifest input path names a real auditable file. +- Reusable scientific types and low-level SPICE parser APIs are imported + directly from Siderust rather than re-exported through compatibility + façades. +- Raised the MSRV to Rust 1.89 to match the released Siderust dependency graph. ### Fixed -* `siderust-pod-observations::gnss::sagnac_km`: convert the typed - `OMEGA_EARTH_RAD_S` (`InverseSeconds`) to its scalar value before the - km/(km·s⁻¹) division, restoring `cargo check` after the upstream - `siderust` typed-constant migration. -* `siderust-pod-service`: pipeline & synthetic-arc generation updated - to the new five-argument `rk4_propagate_series`/`finite_diff_stm_series` - signatures (which now take a `&DynamicsContext` and return - `Result<_, DynamicsError>`); errors are wrapped through - `WlsSolverError::Other`. EKF replay test migrated to the - `EncodedTime`-based `OrbitState::new` and to - `StateCovariance::diagonal_from_sigmas` with typed - `Kilometers`/`KmPerSecond` arguments. +- Run manifests now contain a real configuration SHA-256, canonical hashed + dataset references, and RFC3339 start/finish timestamps. +- Removed unsupported synthetic carrier-ambiguity injection from the MVP, + whose estimated parameters are the six state components plus receiver + clock. +- Synthetic state/STM indexing explicitly maps epoch zero to the identity STM + and subsequent epochs to the corresponding cumulative variational STM. +- Runtime diagnostics no longer reference deleted implementation-plan + documents. ### Added -* `siderust_pod_io::oem::{read_oem, OemFile, OemSegment}` — CCSDS OEM - KVN reader with full round-trip support (`write_oem` ↔ `read_oem`), - multi-segment parsing, tolerant skipping of `COVARIANCE_*` and - `MAN_*` sub-blocks, structured `PodIoError::Format` diagnostics with - line numbers, and a documented inverse `iso8601_to_jd`. Closes the - long-standing `todo!()` in `tests/functional/oem.rs` (replaced by a - proper round-trip functional test). - -* `siderust-pod-core::manifest`: shipped JSON-Schema (draft-07) for the - `RunManifest` and QC report formats as `schema/run_manifest.v1.json` and - `schema/qc.v1.json`, embedded at compile time as `RUN_MANIFEST_SCHEMA_V1` - and `QC_SCHEMA_V1` constants. Two new tests assert the embedded manifest - schema matches the actual serialised field set and that the QC schema - declares `schema = "qc.v1"`. Total `siderust-pod-core` test count: 8. -* `siderust-pod-qc::orbit_compare`: RTN comparison adapter migrated to the - `siderust 0.7` API (`OrbitState::epoch_jd()`, `LocalOrbitalFrame::try_from_state`). - Existing tests still pass. - -### Changed - -* `siderust-pod-{io,observations,estimation,qc,products}`: migrated to the - upstream `siderust` API rename (`OrbitState.epoch_tt → epoch`, - `OrbitState::new(JulianDate, …) → OrbitState::new_at_jd(JulianDate, …)`, - `StateCovariance::from_stddevs([f64;3], [f64;3]) → - StateCovariance::diagonal_from_sigmas([Kilometers;3], [KmPerSeconds;3])`, - `LocalOrbitalFrame::from_state → try_from_state`). Public APIs of pod - crates unchanged. - -* Reserved sibling reusable crates under `rust/`: - `siderust-dynamics` (variational/STM, force-model composition, - thrust-arc physics), `siderust-sgp4` (SGP4 propagator producing typed - TEME states), `siderust-tle` (TLE/3LE/OMM parser), - `siderust-spice` (DAF/SPK reader extending `siderust::data::{daf,spk}` - to Type 3/9/13), and `siderust-lambert` (0/N-revolution Lambert - solver). Each crate ships with a README defining ownership boundaries - and a `CHANGELOG.md`. Implementation lands in plan Phases 3–5; the - scaffolds today only declare crate identity, dependencies on `qtty` / - `tempoch` / `affn[astro]` / `cheby` / `siderust`, an `AGPL-3.0-or-later` - license, and `#![forbid(unsafe_code)]`. None of these crates depend on - `siderust-pod-*`. -* `siderust-pod-core`: new crate scaffolding the POD domain primitives - referenced by the existing design plan (`error::PodError`, - `dataset::DatasetRef`, `manifest::RunManifest` with deterministic JSON, - `parameter::{ParameterKind, Parameter, ParameterOrdering}`, - `covariance::ParameterCovariance` with symmetry/diagonal validators, - `providers::{EphemerisProvider, EarthOrientationProvider, - FrameTransformProvider}` traits). Closes the long-standing gap where - the workspace plan and changelog referenced this crate but it did not - exist on disk. -* `siderust-pod-dynamics`: new crate scaffolding the POD-specific - dynamics composition layer (`force_config::ForceModelConfig`, - `thrust_arc::ThrustArcConfig` emitting estimable parameters, - `process_noise::ProcessNoiseConfig`). Numerical propagation and - analytic STM remain in `siderust` (today) and the future reusable - `siderust-dynamics` crate; this layer only owns POD-specific - composition. -* `siderust-pod-dynamics`: cannonball solar-radiation-pressure force - (`forces::CannonballSrp`) and exponential-density atmospheric drag - (`forces::ExponentialDrag`). (audit fixes A-09, A-10) -* `siderust-pod-core`: `PodError::NotImplemented` variant for - capabilities that are scoped to a future milestone. -* `siderust-pod-service`: integration test verifying the runner refuses - real-input runs with a structured `Unsupported` error rather than - silently propagating-and-ignoring the inputs (audit fix A-03). -* Top-level `SECURITY.md`, `SUPPORT.md`, `CHANGELOG.md`, and - `deny.toml` for cargo-deny gating in CI. -* `crates/siderust-pod-dynamics/benches/propagation.rs`: criterion - bench skeleton for two-body and two-body+J2 RK4 propagation - (audit fix A-13 starter). - -### Changed - -* `siderust-pod-service::runner::run`: when real inputs are supplied - the runner now returns - `std::io::ErrorKind::Unsupported` carrying the - `PodError::NotImplemented` message instead of silently invoking the - legacy propagation-only stub. This closes audit finding C-01. - -### Documentation - -* `spod/docs/architecture/dependency-rules.md`: extended forbidden - edges to include reusable foundational crates (`qtty`, `tempoch`, - `affn`, `cheby`, `siderust`) which must not depend on any - `spod-*` crate. Enforced by `scripts/check_dep_graph.sh`. -* `plan.md` §13 records the post-M7 technical audit (six 🔴 critical, - nine 🟠 high, ten 🟡 medium, five 🟢 low findings) and the M8–M12 - remediation roadmap. +- End-to-end synthetic service coverage from `RunConfig` through Siderust + propagation/estimation to orbit, residual, QC, and run-manifest artifacts. +- Regression coverage ensuring every unsupported real-data input is rejected + rather than hashed and ignored. +- CI guards for formatting, Clippy, feature combinations, documentation, + dependency direction, legacy project namespaces, MSRV, coverage, Docker, + REST/CLI smoke tests, and supply-chain policy. + +### Removed + +- Duplicated local POD scientific modules for dynamics, observations, + estimation, products, quality control, parameters, covariance, and run + metadata. +- Deprecated finite-difference STM compatibility code and its deprecation + allowance. +- Deprecated pre-rename REST environment aliases. +- Obsolete compatibility reexports, stale architecture documents, and + dependencies used only by the removed scientific implementation. diff --git a/Cargo.lock b/Cargo.lock index a7dda46..eca8e0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -205,15 +205,6 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "block-buffer" version = "0.12.0" @@ -372,15 +363,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "cpufeatures" version = "0.3.0" @@ -396,16 +378,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "crypto-common" version = "0.2.1" @@ -421,24 +393,14 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "930c7171c8df9fb1782bdf9b918ed9ed2d33d1d22300abb754f9085bc48bf8e8" -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", -] - [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", - "crypto-common 0.2.1", + "block-buffer", + "crypto-common", ] [[package]] @@ -748,16 +710,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2c81fb5260e37854d09d5c87183309fd8c555b75289427884b25660bc87a85e" -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -1637,17 +1589,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha2" version = "0.11.0" @@ -1655,8 +1596,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", + "cpufeatures", + "digest", ] [[package]] @@ -1686,7 +1627,7 @@ dependencies = [ "quick-xml", "serde", "serde_json", - "sha2 0.11.0", + "sha2", "siderust-archive", "spin", "tempoch", @@ -1706,7 +1647,7 @@ dependencies = [ "qtty", "regex", "serde", - "sha2 0.11.0", + "sha2", "toml 1.1.2+spec-1.1.0", "walkdir", ] @@ -1750,15 +1691,12 @@ dependencies = [ "chrono", "clap", "env_logger", - "faer", - "hex", "log", "quick-xml", "serde", "serde_json", "serde_yaml", "sgp4", - "sha2 0.10.9", "siderust", "thiserror 1.0.69", "tokio", diff --git a/Cargo.toml b/Cargo.toml index cc2676a..914f9ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ -# spod — single-crate Precise Orbit Determination toolkit. +# spod — service application for Siderust POD workflows. # -# The previous multi-crate workspace has been collapsed: every former -# sub-crate is now a module inside this single library. The -# [workspace] block is kept only to let target-sgp4-test/probe -# inherit its edition/version metadata. +# 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. [package] name = "spod" @@ -17,15 +17,7 @@ homepage = "https://github.com/VPRamon/spod" keywords = ["satellite", "pod", "orbit-determination", "geodesy", "gnss"] categories = ["science", "aerospace"] publish = false -description = "Precise Orbit Determination toolkit (POD) service built on Siderust." - -[[example]] -name = "01_typed_two_body_propagation" -path = "examples/01_typed_two_body_propagation.rs" - -[[example]] -name = "02_short_arc_wls" -path = "examples/02_short_arc_wls.rs" +description = "Precise Orbit Determination service application built on Siderust." [[example]] name = "03_lambert_earth_to_mars" @@ -57,8 +49,6 @@ siderust = { version = "0.12", features = ["pod"] } sgp4 = { version = "2.4", default-features = false, features = ["std"] } # ── Linear algebra ──────────────────────────────────────────────────────────── -faer = { version = "0.24", default-features = false, features = ["std", "linalg"] } - # ── Serialisation ───────────────────────────────────────────────────────────── serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -69,10 +59,6 @@ serde_yaml = "0.9" thiserror = "1.0" anyhow = "1.0" -# ── Hashing ─────────────────────────────────────────────────────────────────── -sha2 = "0.10" -hex = "0.4" - # ── Time ────────────────────────────────────────────────────────────────────── chrono = "0.4" diff --git a/Dockerfile b/Dockerfile index a8233e7..9169ca4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,6 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /workspace/target/release/spod /usr/local/bin/spod COPY --from=builder /workspace/target/release/spod-rest /usr/local/bin/spod-rest -ENV SPOD_REST_DEFAULT_BIND=0.0.0.0:8080 +ENV SPOD_REST_BIND=0.0.0.0:8080 EXPOSE 8080 ENTRYPOINT ["spod-rest"] diff --git a/README.md b/README.md index 4d75b55..5726bb5 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ [![CI](https://github.com/VPRamon/spod/actions/workflows/ci.yml/badge.svg)](https://github.com/VPRamon/spod/actions/workflows/ci.yml) [![License: AGPL-3.0-or-later](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue.svg)](LICENSE) -**Precise Orbit Determination and orbit-analysis tooling in Rust.** +**A service application for Siderust POD in Rust.** -`spod` is an engineering-oriented toolkit for orbit propagation, observation modelling, state estimation, orbit-product handling, and quality control. It builds on released crates from the wider [Siderust](https://github.com/Siderust) ecosystem and aims to keep physical units, reference frames, time scales, and estimation primitives explicit in the type system. +`spod` is an engineering-oriented service for configuration, orchestration, artifact handling, and interfaces around the reusable POD APIs in [Siderust](https://github.com/Siderust). It does not maintain a second scientific POD implementation. The current MSRV is Rust 1.89, matching the dependency graph and the Docker builder image. @@ -17,28 +17,20 @@ Docker builder image. | Area | Current scope | | --- | --- | -| Orbit dynamics | Two-body, J2, third-body gravity, solar-radiation pressure, atmospheric drag, numerical integration, STM support | +| 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 | -| Observations | GNSS code/carrier and SLR building blocks; optional LISA-oriented models | -| Estimation | Weighted least squares, Gauss-Newton and EKF components | | Formats | SP3, RINEX, ANTEX, EOP, CRD, CPF and CCSDS OEM support at the currently implemented subsets | | Products & QC | Residuals, orbit products, manifests, comparison/QC utilities | | Interfaces | Rust library, command-line interface, experimental Axum REST API | -The project deliberately separates orbital physics, observations, estimation, I/O, quality control, and service orchestration so each layer can be tested and evolved independently. +The project separates service orchestration, external interfaces, persistence and format adapters from the reusable POD implementation in Siderust. ## Repository structure ```text src/ - core/ Domain primitives, parameters, covariance and providers - dynamics/ Force models, integration and state transition machinery - estimation/ Batch and sequential estimators io/ Space/geodesy format readers and writers - observations/ Measurement models and corrections - products/ Orbit and residual products - qc/ Validation and quality-control tooling - service/ Configuration-driven pipeline orchestration + service/ Configuration, orchestration, REST/CLI and artifact handling lambert/ Lambert solver sgp4/ SGP4 integration spice/ SPICE helpers/providers @@ -56,7 +48,7 @@ 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` focuses on precise orbit determination: estimation, observations, orbit products, quality control, and service orchestration. Some primitives first explored during POD development have since moved upstream and are consumed here through their public APIs. +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. Validate and run the synthetic POD configuration: @@ -73,14 +65,11 @@ docker run --rm -p 8080:8080 spod:dev ``` The service listens on `SPOD_REST_BIND` and writes job output below -`SPOD_REST_OUT`. The former `SIDERUST_POD_REST_BIND` and -`SIDERUST_POD_REST_OUT` variables remain supported as deprecated fallbacks. +`SPOD_REST_OUT`. -Four focused Rust examples cover typed propagation, estimation, Lambert transfer, and SGP4: +The remaining Rust examples cover Lambert transfer and SGP4 integration: ```bash -cargo run --example 01_typed_two_body_propagation -cargo run --example 02_short_arc_wls cargo run --example 03_lambert_earth_to_mars cargo run --example 04_sgp4_from_tle ``` @@ -91,7 +80,7 @@ See [examples/README.md](examples/README.md) for the purpose of each example and - **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.** Dynamics, observations, estimation, formats and orchestration remain independently testable. +- **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. - **No unsafe Rust.** The library forbids `unsafe_code`. - **Standards-oriented interoperability.** Common astrodynamics and geodesy formats are treated as first-class interfaces. @@ -124,12 +113,8 @@ docker run --rm -p 8080:8080 spod:dev ``` The REST container defaults to `0.0.0.0:8080`, so the published port is -reachable from outside the container. Docker supplies this through the -internal `SPOD_REST_DEFAULT_BIND` setting; it is not the normal user-facing -configuration API. `SPOD_REST_BIND` and `SPOD_REST_OUT` -take precedence over the deprecated `SIDERUST_POD_REST_BIND` and -`SIDERUST_POD_REST_OUT` aliases; when neither is set, the service uses its -defaults. +reachable from outside the container. Set `SPOD_REST_BIND` and +`SPOD_REST_OUT` to override the defaults. ## Contributing diff --git a/docs/adr/0002-no-anyhow.md b/docs/adr/0002-no-anyhow.md index 2c8b379..2e4e71a 100644 --- a/docs/adr/0002-no-anyhow.md +++ b/docs/adr/0002-no-anyhow.md @@ -1,48 +1,33 @@ -# ADR-0002 — Unified per-crate `thiserror` Error enums; no `anyhow` +# ADR-0002 — Typed library errors; `anyhow` only at application boundaries ## Status -Accepted (Phase 1, enforced as workspace lint) +Accepted. ## Context -Early scaffolding in several crates used `anyhow::Error` as a catch-all return -type. `anyhow` is an ergonomic choice for application-layer code where callers -only need to print or propagate opaque errors. It is a poor choice for library -crates because: - -1. **Caller cannot match on variants.** Library users need to inspect errors to - decide whether to retry, fall back, or surface diagnostics to their own - callers. `anyhow::Error` erases that information. -2. **Documentation.** `thiserror`-derived enums appear in `rustdoc` with their - variant list, making the error contract part of the public API and - searchable. -3. **`cargo-public-api` snapshots.** A `thiserror` enum is a stable, snapshotted - API surface. `anyhow::Error` is not. -4. **Composability.** Downstream crates (e.g., `siderust-pod-service`) compose - errors from multiple crates; typed enums allow clean `From` impls or - `?`-compatible conversions. +`anyhow::Error` is useful in binaries where the consumer primarily needs a +human-readable diagnostic. It is less suitable for reusable library APIs, +where callers need to match on structured failure modes. -## Decision - -Every library crate in `spod` defines exactly **one** error type per -crate, named `Error` (e.g., `PodIoError`, `LambertError`). It is: +The current `spod` repository is a single package with library modules plus +CLI/REST entry points, while reusable POD science comes from Siderust. -- Derived with `thiserror::Error`. -- Re-exported from the crate's `lib.rs` at the crate root. -- The **only** error type that appears on public API `Result<_, E>` signatures. -- `#[non_exhaustive]` where future variants are expected. +## Decision -`anyhow` must not appear in any library crate dependency. It may be used in -test binaries and the CLI binary where ergonomic error printing is the only -requirement. +- Reusable `spod` library modules expose structured error enums, normally + derived with `thiserror::Error`. +- Errors should preserve useful fields and wrap lower-level typed errors rather + than flattening them to strings when a typed boundary is available. +- `anyhow` is acceptable in the `spod` and `spod-rest` binary entry points, + where errors terminate at a human/operator-facing application boundary. +- Service orchestration may translate external errors into its own structured + service error where that adds meaningful context. ## Consequences -- Library callers get exhaustive match coverage and can inspect error details. -- Error types are versioned through `api.snapshot` CI gate. -- Minor code overhead vs `anyhow` in library internals: internal helpers use - `Result<_, Error>` throughout. -- The workspace `[workspace.lints]` does not currently auto-ban `anyhow` in - libraries; authors rely on code-review and the `api.snapshot` diff to catch - regressions. +- Downstream Rust callers can inspect library failures programmatically. +- Rustdoc exposes the error contract of reusable APIs. +- CLI/REST startup and top-level command handling retain ergonomic contextual + error reporting without turning `anyhow::Error` into a scientific/library + API surface. diff --git a/docs/adr/0005-lisa-poc-scope.md b/docs/adr/0005-lisa-poc-scope.md index 7b3859a..4b54c0b 100644 --- a/docs/adr/0005-lisa-poc-scope.md +++ b/docs/adr/0005-lisa-poc-scope.md @@ -27,9 +27,9 @@ only the orbital mechanics layer? The LISA POC (`E2E-12`) covers **only**: -1. Heliocentric orbit propagation of three spacecraft using `siderust-pod-dynamics`. +1. Heliocentric orbit propagation of three spacecraft using `siderust::pod::propagation`. 2. Simulated MOSA inter-spacecraft range measurements (`InterSatRange` observation - model in `siderust-pod-observations`). + model in `siderust::pod::observation`). 3. A batch-LS or EKF POD run that estimates the three spacecraft states from the range measurements, using orbits from `esa/lisa-orbit-files` as truth reference. @@ -54,7 +54,7 @@ sufficient to validate `spod` at the FocusPOD M6 milestone. ## Consequences -- `siderust-pod-observations` implements `InterSatRange` as a first-class +- `siderust::pod::observation` implements `InterSatRange` as a first-class observation model alongside GNSS pseudorange and SLR normal points. - The LISA POC is implemented as a **configuration** of existing primitives (a `LisaEphemerisProvider` reading `esa/lisa-orbit-files` and an diff --git a/docs/adr/0006-finite-diff-stm-allow.md b/docs/adr/0006-finite-diff-stm-allow.md deleted file mode 100644 index 9684e67..0000000 --- a/docs/adr/0006-finite-diff-stm-allow.md +++ /dev/null @@ -1,42 +0,0 @@ -# ADR-0006 — `#[allow(deprecated)]` on `finite_diff_stm_series` in pod-service - -## Status - -Accepted (Phase 1) - -## Context - -The upstream `siderust` crate marks `finite_diff_stm_series` as deprecated in -favour of `variational::propagate_stm`. The deprecation note states that -`propagate_stm` is the preferred path for single-arc STM computation. - -`siderust-pod-service/src/pipeline.rs` calls `finite_diff_stm_series` inside -the batch-LS assembly loop. - -The reason `propagate_stm` cannot be used here is: - -> `propagate_stm` returns the state-transition matrix Φ at the **terminal -> epoch only**. Batch-LS assembly requires Φ at **every measurement epoch** -> along the arc, which is exactly the use case the upstream deprecation note -> explicitly preserves the series form for. - -## Decision - -`siderust-pod-service/src/pipeline.rs` retains its call to -`finite_diff_stm_series` with a scoped `#[allow(deprecated)]` attribute and a -multi-line rationale comment explaining why `propagate_stm` is insufficient for -this call-site. - -The allow is **not** applied crate-wide or module-wide; it is scoped to the -minimum necessary block. - -## Consequences - -- If upstream `siderust` eventually adds a per-epoch variant of `propagate_stm` - (or exposes an iterator-based STM propagator), this call-site should be - migrated and the allow removed. -- The allow is documented here so future maintainers understand the intent and - know to revisit it when the upstream API evolves. -- `cargo clippy` does not produce a warning at this call-site because the - `allow` is present; the CI `todo-sweep` script does not touch `deprecated` - attrs. diff --git a/docs/adr/0007-spk-type-coverage.md b/docs/adr/0007-spk-type-coverage.md index c0fcf56..3fcc03f 100644 --- a/docs/adr/0007-spk-type-coverage.md +++ b/docs/adr/0007-spk-type-coverage.md @@ -2,55 +2,44 @@ ## Status -Accepted (Phase 2.5) +Accepted. ## Context -NAIF SPICE SPK files use typed segments to store ephemeris data. The types -relevant to planetary-science missions are: +NAIF SPICE SPK files use typed segments to store ephemeris data. Types 2 and +3 use Chebyshev representations and cover the JPL DE-series kernels used by +the current `spod` validation path. Types 9 and 13 use Lagrange interpolation +and require a separate, well-validated evaluation kernel. -| Type | Description | Used by | -|---|---|---| -| 2 | Chebyshev position | JPL DE (de440/de441) | -| 3 | Chebyshev position + velocity | JPL DE (some bodies) | -| 5 | Two-body propagation | Preliminary orbits | -| 9 | Lagrange interpolation, unequal-step | MAVEN, MRO, etc. | -| 13 | Lagrange interpolation, equal-step | Cassini, etc. | -| 21 | Extended modified difference array | SPICE-toolkit | - -The Phase 2.5 plan specified Types 2/3/9/13 as targets. Types 9 and 13 use -Lagrange interpolation with different step patterns, requiring a different -evaluation kernel from Types 2 and 3. +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. ## Decision -`siderust-spice` ships **Types 2 and 3** (Chebyshev) as fully-implemented, -tested, and validated against JPL DE440/441. +`spod::spice` evaluates SPK Types 2 and 3. -Types 9 and 13 (Lagrange, unequal/equal step) are **explicitly rejected** at -runtime with `SpiceError::UnsupportedDataType { type_id: 9 }` (or 13). The -error message directs users to the ADR for context. +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 }`. -All other SPK types (1, 4, 5, 6, 7, 8, 10–12, 14–20, 21) are similarly -rejected with `UnsupportedDataType`. +Other unsupported SPK types fail through the same explicit error path rather +than producing an approximate or silently incorrect state. ## Rationale -Types 2 and 3 cover 100% of the JPL DE series (de430, de440, de441), which -are the ephemerides used in the spod test suite and the LISA POC. -Implementing Types 9/13 correctly requires the full Lagrange-interpolation -kernel with order selection, which is non-trivial and untested without -mission-specific SPK fixtures. - -Implementing them incorrectly would silently produce wrong ephemerides, which -is worse than an explicit `UnsupportedDataType` error that tells the caller -to use a DE-series file instead. +Implementing a segment type without authoritative validation risks silent +ephemeris errors. The current Type 2/3 implementation covers the DE440/DE441 +workflow required by `spod`; additional types should be added only with +fixtures and regression cases against NAIF/CSPICE or another authoritative +reference. ## Consequences -- Users loading mission-specific SPK files (e.g., `cas_2004_v26.bsp`) will - receive a clear `UnsupportedDataType` error rather than wrong positions. -- When Types 9/13 are implemented, the `UnsupportedDataType` arms become - reachable evaluation branches; the API surface does not change. -- The `api.snapshot` for `siderust-spice` reflects only Types 2/3 evaluation - paths until the extension lands. +- 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. diff --git a/docs/adrs/ADR-0002-faer-backend.md b/docs/adrs/ADR-0002-faer-backend.md deleted file mode 100644 index 239d5be..0000000 --- a/docs/adrs/ADR-0002-faer-backend.md +++ /dev/null @@ -1,20 +0,0 @@ -# ADR-0002 — Linear-algebra backend: faer - -## Status -Accepted. - -## Context -The estimator needs dense matrix factorisations (Cholesky / QR) for -weighted least squares, with a sparse upgrade path. Candidates: -`nalgebra`, `ndarray + lapack`, `faer`. - -## Decision -Use `faer` (workspace dependency, version `0.22`) as the dense / sparse -linear algebra backend in `siderust-pod-estimation`. `affn` continues to -own typed positions, vectors, and 3×3 covariance transforms. - -## Consequences -- Pure-Rust, no system LAPACK requirement. -- `faer` exposes both dense and sparse APIs, so the same crate covers - the future high-degree-gravity and large-arc estimators. -- API-stability risk pre-1.0; pinned via the workspace dependency. diff --git a/docs/adrs/ADR-0004-provider-traits.md b/docs/adrs/ADR-0004-provider-traits.md deleted file mode 100644 index c3f5d6c..0000000 --- a/docs/adrs/ADR-0004-provider-traits.md +++ /dev/null @@ -1,48 +0,0 @@ -# ADR-0004 — Provider-trait pattern - -## Status -Superseded (by direct `siderust` dependencies in each POD crate). - -## Context -POD code needs ephemerides, Earth orientation, frame transforms, gravity -fields, and atmosphere densities. Reaching directly into `siderust` -modules would couple POD's compile graph and semantics to upstream's -internal layout and cause churn whenever upstream refactors. - -## Original Decision -`siderust-pod-core::providers` was meant to define five small traits: - -- `EphemerisProvider` -- `EarthOrientationProvider` -- `FrameTransformProvider` -- `GravityFieldProvider` -- `AtmosphereDensityProvider` - -Default implementations would wrap public `siderust` items. All -downstream POD crates were to depend on the *traits*, never on `siderust` -directly for these capabilities. - -## Why This Was Superseded -In practice, four of the five provider modules were never more than -one-line `pub use siderust::...` re-exports — no trait was defined, no -abstraction existed. The only real trait (`FrameTransformProvider`) had -no implementations. Every POD call-site already referenced `siderust` -types by their fully-qualified names, so `siderust-pod-core` provided no -isolation in reality. - -`siderust-pod-core` was therefore eliminated entirely. Its genuine -content was redistributed: - -- `RunManifest` / `DatasetRef` → `siderust-pod-service` -- `FrameTransformProvider` → `siderust-pod-dynamics` -- `ParameterKind` / `Parameter` → `siderust-pod-estimation` -- State types (`Position`, `Velocity`, `OrbitState`, `RTN`) — callers - import directly from `siderust::astro::dynamics::{state,frames}`. - -## Consequences -- Each POD crate takes a direct `siderust` dependency only for the types - it actually uses. -- If upstream `siderust` renames or restructures an API, the change is - visible at each use-site rather than hidden behind an alias layer. -- Mock providers for testing can be added per-crate without a shared - abstraction layer. diff --git a/docs/architecture/boundaries.md b/docs/architecture/boundaries.md index 3a4c1bd..6a0ac86 100644 --- a/docs/architecture/boundaries.md +++ b/docs/architecture/boundaries.md @@ -2,13 +2,9 @@ | Module | Owns | Does NOT own | |---|---|---| -| `spod::dynamics` | Force models, integrators, propagation, STM, RTN/LVLH/VNC frame helpers, `FrameTransformProvider` trait. | File I/O, observation models, estimator. | | `spod::io` | Parsers/writers for SP3, RINEX, ANTEX, EOP, OEM, ... | Numerics, estimation. | -| `spod::observations` | `MeasurementModel` trait + GNSS / SLR / DORIS / VLBI implementations and corrections. | File parsing, estimator solver. | -| `spod::estimation` | Parameter blocks, design-matrix assembly, WLS / EKF, robust weighting, covariance extraction. | File parsing, observation modelling. | -| `spod::qc` | Residual statistics, orbit comparison, QC JSON, HTML reports. | Estimation algorithms, product writing. | -| `spod::products` | SP3 / OEM writers, residual product packaging, manifest packaging, naming, validation. | Numerics, service runtime. | -| `spod::service` | Job model (`RunManifest`, `DatasetRef`), config loading/validation, pipeline runner, artifact layout, manifest finalisation. | Numerical algorithms. | +| `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. | The full set of allowed and forbidden dependency edges is in diff --git a/docs/architecture/error-model.md b/docs/architecture/error-model.md deleted file mode 100644 index 164e284..0000000 --- a/docs/architecture/error-model.md +++ /dev/null @@ -1,106 +0,0 @@ -# Error Model - -`spod` follows a single, uniform convention for fallible APIs: -**one `thiserror`-backed `Error` enum per crate**, re-exported from -`lib.rs`. - -## Rules - -1. Every library crate defines its error type in a private `error` module: - - ```rust - // src/error.rs - #[derive(Debug, thiserror::Error)] - pub enum Error { - #[error("invalid SP3 header at line {line}: {reason}")] - InvalidHeader { line: usize, reason: String }, - - #[error("io error: {0}")] - Io(#[from] std::io::Error), - // ... - } - ``` - -2. The crate root re-exports it: - - ```rust - // src/lib.rs - mod error; - pub use error::Error; - ``` - -3. **No `anyhow` in library crates.** `anyhow::Error` erases the variant, - making programmatic error handling impossible for downstream callers. - `anyhow` is permitted *only* in the binary crates `spod` and - `spod-rest`, where the consumer is a human reading a terminal or - an HTTP error body. - -4. **Variants must be structured.** A single `String` payload is forbidden - when the error context contains discrete fields (line number, satellite - PRN, frame name, parameter index, …). Structured variants enable - localisation, machine-readable logs, and pattern matching. - -5. **Wrap, don’t flatten.** When a lower-layer crate’s error needs to be - surfaced, wrap it as a `#[from]` variant. Never re-stringify it. - -6. The crate-level error must be `Send + Sync + 'static`. This is satisfied - by `thiserror`'s default codegen. - -7. See ADR-0002 in this directory series ([`docs/adr/0002-no-anyhow.md`](../adr/0002-no-anyhow.md)) - for the full rationale. - -## Per-crate error type registry - -| Crate | Error type (re-exported) | `anyhow` allowed? | -|--------------------------------|-------------------------------------------|-------------------| -| `siderust-dynamics` | `siderust_dynamics::Error` | no | -| `siderust-lambert` | `siderust_lambert::LambertError` | no | -| `siderust-sgp4` | `siderust_sgp4::Error` | no | -| `siderust-spice` | `siderust_spice::SpiceError` | no | -| `siderust-tle` | `siderust_tle::TleError` | no | -| `spod::core` | `spod_core::Error` | no | -| `spod::dynamics` | `spod_dynamics::Error` | no | -| `spod::io` | `spod::io::Error` | no | -| `spod::observations` | `spod_observations::Error` | no | -| `spod::estimation` | `spod_estimation::EstimationError`| no | -| `spod::qc` | `spod_qc::Error` | no | -| `spod::products` | `spod_products::Error` | no | -| `spod::service` | `spod_service::PipelineError` | no | -| `spod` | n/a (binary) | **yes** | -| `spod-rest` | n/a (binary) | **yes** | - -## Example - -```rust -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum Error { - #[error("unknown SPK data type: {ty}")] - UnknownDataType { ty: i32 }, - - #[error("requested epoch {epoch} outside coverage [{start}, {end}]")] - EpochOutOfRange { - epoch: tempoch::JulianDate, - start: tempoch::JulianDate, - end: tempoch::JulianDate, - }, - - #[error("io error reading kernel: {0}")] - Io(#[from] std::io::Error), -} -``` - -A downstream caller can then write: - -```rust,ignore -match provider.position_at(epoch) { - Err(siderust_spice::SpiceError::EpochOutOfRange { start, end, .. }) => { - // domain-specific recovery - } - Err(other) => return Err(other.into()), - Ok(p) => use_position(p), -} -``` - -— which is impossible with `anyhow`. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index afd1f32..e55665a 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -1,9 +1,9 @@ # Architecture Overview -`spod` is a single-crate precise-orbit-determination toolkit. Its -modules separate domain primitives, dynamics, observations, estimation, -formats, products, quality control, and application orchestration while -sharing one released dependency graph. +`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, +and run metadata remain in `siderust::pod`. ## Dependency structure @@ -22,14 +22,10 @@ flowchart TD PRINCIPIA[principia] --> POD SIDERUST[siderust] --> POD - POD --> CORE[core] - POD --> DYN[dynamics] POD --> IO[io / formats] - POD --> OBS[observations] - POD --> EST[estimation] - POD --> PROD[products] - POD --> QC[quality control] POD --> SVC[service / CLI / REST] + SIDERUST --> SCIENCE[siderust::pod] + SCIENCE --> SVC ``` The dependency check in `scripts/check_dep_graph.sh` rejects path and git @@ -45,16 +41,11 @@ artefacts. ```mermaid flowchart LR RAW[Raw observations and auxiliary data] --> IO[Parsing and providers] - IO --> OBS[Measurement models and corrections] - IO --> DYN[Force models and propagation] - OBS --> EST[WLS / Gauss-Newton / EKF] - DYN --> EST - EST --> QC[Residual and orbit checks] - EST --> PROD[SP3 / OEM / residual products] - QC --> PROD - PROD --> OUT[Run artefacts and manifest] + IO --> SVC[Service translation and orchestration] + SVC --> POD[siderust::pod execution] + POD --> OUT[Service artifacts and manifest] ``` The CLI and REST binaries are thin front ends over the service module. The -scientific layers remain independently testable, with typed quantities, -time scales, centers, and frames preserved at their boundaries. +Siderust's scientific APIs remain independently testable, while the service +boundary is covered by synthetic end-to-end execution. diff --git a/docs/architecture/providers.md b/docs/architecture/providers.md index 6e5fee3..c32610f 100644 --- a/docs/architecture/providers.md +++ b/docs/architecture/providers.md @@ -1,11 +1,8 @@ # Provider traits -> **Note:** The `spod::core` crate has been eliminated. See -> [ADR-0004](../adrs/ADR-0004-provider-traits.md) for the rationale. -> `FrameTransformProvider` (the only trait that was ever a real trait) -> now lives in `spod::dynamics`. POD crates import other provider -> types (ephemerides, EOP, gravity, atmosphere) directly from `siderust`. +The former local POD provider facade has been removed. Reusable POD provider +APIs are consumed from `siderust::pod` or its underlying public Siderust APIs. -`FrameTransformProvider` in `spod::dynamics::frame_transform` -is the seam for ITRF ↔ GCRF frame rotations inside POD. All other -`siderust` provider types are used directly by each POD crate as needed. +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. diff --git a/docs/conventions/examples-and-doctests.md b/docs/conventions/examples-and-doctests.md index 3e90356..573d6fb 100644 --- a/docs/conventions/examples-and-doctests.md +++ b/docs/conventions/examples-and-doctests.md @@ -1,96 +1,79 @@ # Examples and Doctests -Every public item in `spod` carries an executable example. This is -non-negotiable: the `missing_docs = deny` lint already requires *some* -documentation; this convention says that documentation must include -working code. +Public APIs should have useful rustdoc examples when an example materially +clarifies the intended call site. Examples must use the current `spod` and +Siderust namespaces and should compile under the repository doctest suite. ## The rule -> Every `pub` type, function, trait, method, and constant has a -> `# Examples` section in its rustdoc, and that example compiles under -> `cargo test --doc`. +For public APIs where a usage example is useful: -This is enforced by: +1. Prefer a `# Examples` section in rustdoc. +2. Use typed Siderust/qtty APIs at scientific boundaries. +3. Keep runnable examples deterministic and independent of network access. +4. Use `no_run` only when a real external dataset is required. +5. Keep examples aligned with the current public API rather than preserving + old compatibility namespaces. -1. `#![deny(missing_docs)]` (workspace lint). -2. `#![deny(rustdoc::broken_intra_doc_links)]` (workspace lint). -3. CI step `cargo test --workspace --doc`. -4. PR review: a reviewer rejects a PR that adds undocumented public items - even if it compiles. +CI runs: -## The shape of a good doctest - -```rust -/// Compute the Lambert transfer between two heliocentric positions. -/// -/// # Examples -/// -/// ``` -/// use qtty::si::Length; -/// use siderust_lambert::{lambert, TransferGeometry}; -/// -/// let r1 = Length::from_metres(1.495_978_707e11); // 1 AU -/// let r2 = Length::from_metres(2.279_392_4e11); // ~Mars distance -/// let tof = std::time::Duration::from_secs(259 * 86_400); -/// -/// let solution = lambert(r1, r2, tof, TransferGeometry::Prograde) -/// .expect("feasible transfer"); -/// assert!(solution.v1.norm().value() > 0.0); -/// ``` -pub fn lambert(/* ... */) -> Result { /* ... */ } +```bash +cargo test --doc --workspace --all-features ``` -Notes: +and builds documentation with broken intra-doc links denied. -- The example **uses the typed APIs**. It does not strip down to bare - `f64` for the sake of brevity. -- The example **runs**. Don't fake outputs with comments — assert. -- The example demonstrates the *intended* call site, not a debugging path. +## Typed example -## When the example needs network access or large files +The Lambert module exposes its API directly through `spod::lambert`: -Some examples genuinely require multi-megabyte SP3 files, the DE441 SPK -kernel, or a network round-trip. In those cases: +```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()); +``` -1. Mark the fence with `no_run`: +## Examples that need large files - ```rust - /// # Examples - /// - /// ```no_run - /// # use siderust_spice::SpkKernel; - /// let kernel = SpkKernel::open("/data/spk/de441.bsp")?; - /// // ... use the kernel ... - /// # Ok::<(), siderust_spice::SpiceError>(()) - /// ``` - ``` +When an example requires a large external kernel or observation file, use a +`no_run` fence and document the prerequisite: -2. Add a `# Notes` section explaining what the example *would* do and - how to obtain the inputs: +```rust,no_run +use spod::spice::SpkKernel; - ```text - /// # Notes - /// - /// This example requires the JPL DE441 ephemeris kernel - /// (`de441.bsp`, ~3 GB). Download from - /// . - ``` +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. -3. Prefer `compile_fail` for examples whose entire point is type-safety - ("`Position + Position` must not compile"). +## Runnable examples -`no_run` is still type-checked, so the typed signatures are still verified. +The current runnable examples are: -## The `examples/` directory +```bash +cargo run --example 03_lambert_earth_to_mars +cargo run --example 04_sgp4_from_tle +``` -Workspace-level runnable examples live in `examples/` at the workspace -root and are invoked with: +The larger service demonstration is the synthetic POD configuration: ```bash -cargo run --example 01_minimal_leo_run +cargo run --bin spod -- validate-config examples/configs/leo_gnss_mvp1.yaml +cargo run --bin spod -- run examples/configs/leo_gnss_mvp1.yaml ``` -Each example file maps to a milestone or scenario. The mapping is -maintained in [`examples/README.md`](../../examples/README.md), which -also lists fixture requirements and expected runtime per example. +See [`examples/README.md`](../../examples/README.md) for the current example +inventory and conventions. diff --git a/docs/conventions/frames-and-centers.md b/docs/conventions/frames-and-centers.md index 0a197cd..d3f6ddf 100644 --- a/docs/conventions/frames-and-centers.md +++ b/docs/conventions/frames-and-centers.md @@ -9,14 +9,14 @@ every common situation in the POD workspace. | Marker | Full name | Use for | |---------|----------------------------------------------------|-------------------------------------------------------------------------| | `Teme` | True Equator, Mean Equinox (of date) | SGP4/SDP4 native output. **Always convert to GCRF before propagation.** | -| `Gcrf` | Geocentric Celestial Reference Frame | Inertial frame for Earth-orbiting satellites; the workhorse in `spod::dynamics`. | +| `Gcrf` | Geocentric Celestial Reference Frame | Inertial frame for Earth-orbiting satellites and the service's Siderust POD path. | | `Icrs` | International Celestial Reference System | Deep-space and heliocentric work, including the LISA POC. Aligns with GCRF at the ε level. | | `Itrf` | International Terrestrial Reference Frame | Earth-fixed: ground-station coordinates, EOP-driven GCRF↔ITRF transforms, surface displacements. | | `J2000` | Mean equator/equinox of J2000.0 | **Legacy.** Avoid in new code; prefer GCRF. | Cross-frame transforms live in `siderust` (precession-nutation, polar motion, EOP application) and are surfaced through the -`FrameTransformProvider` trait in `spod::dynamics`. +the corresponding public Siderust frame and transform APIs. ## Center markers (from `affn`) diff --git a/docs/design/plan.md b/docs/design/plan.md deleted file mode 100644 index 32cc1c6..0000000 --- a/docs/design/plan.md +++ /dev/null @@ -1,566 +0,0 @@ -# spod — Implementation Plan - -> Historical implementation plan. It describes the pre-consolidation -> `siderust-pod` workspace and its planned package split. The repository was -> later renamed to `spod` and consolidated into one crate with modules such -> as `spod::core`, `spod::service`, and `spod::io`; the commands and paths -> below are retained as historical context, not current instructions. - -> **Editable workspace:** `rust/siderust-pod/` -> **Untouchable upstream:** `rust/siderust/`, `rust/qtty/`, `rust/tempoch/`, `rust/affn/`, `rust/cheby/` -> **Modifications to foundational crates:** require a separate branch off `main`, written justification, and must be avoided unless strictly necessary. Always prefer adapter layers, wrapper crates, or public provider traits in `spod` first. -> **Attribution rule:** never add Copilot as a co-author anywhere (commits, changelogs, files, docs). - ---- - -## 1. Current-state assessment - -### 1.1 What exists in `siderust-pod/` - -`siderust-pod/` was then a **verbatim fork of the `siderust` crate**, not a POD product workspace: - -- `Cargo.toml` declares `name = "siderust"` (a single-crate package) plus a workspace whose only members are `.` and `siderust-ffi`. -- `src/` is the `siderust` source tree (`astro`, `bodies`, `atmosphere`, `calculus`, `coordinates`, `targets`, `archive`, `provenance`, `observatories`, `time.rs`, `interp.rs`, `spectra`, `tables`, etc.). None of it is POD-domain code. -- `siderust-ffi/` is the FFI crate from upstream. -- `memory/` already contains the authoritative inputs: - - `spod_detailed_design_document.md` (1824 lines — the design contract this plan implements) - - `research-requirements-tests-cases.md` (FocusPOD/competitor research, 341 lines) -- `doc/` carries upstream architecture/conventions/datasets/frames docs (useful to keep as reference). -- `examples/`, `benches/`, `tests/` are upstream-shaped, not POD-shaped. -- No POD crate exists yet (`siderust-pod-core`, `-dynamics`, `-io`, `-observations`, `-estimation`, `-qc`, `-products`, `-service`, `-cli` are all absent). - -**Conclusion:** the M0 milestone was to *reset* `siderust-pod/` into a real Cargo workspace whose members were the new POD crates, depending on upstream `siderust`/`affn`/`qtty`/`tempoch`/`cheby` as external crates. The cloned `src/` was to be deleted (decision locked with user). - -### 1.2 Reusable foundation (no modification) - -| Upstream crate | POD-relevant capability already present | Reuse strategy | -|---|---|---| -| `qtty` | `Quantity` typed units across `f64`/`f32`; angles, lengths, masses, times, etc. `qtty-core`/`qtty-derive`/`qtty-ffi`. | Use directly. New POD-specific units (carrier cycles, TECU, clock bias, area-to-mass) live in `siderust-pod-core` as `qtty` newtypes around existing dimensions; only file an upstream `qtty` change if a *new dimension* is unavoidable. | -| `tempoch` | `Time`, `TT/TAI/UTC/UT1/TDB/TCB/TCG/GPS`, `Interval`, `Period`, `eop`, leap seconds, EOP context. | Use directly. POD-run "time context" wraps an explicit `EopDataset` + `LeapSecondDataset` reference (provenance lives in `siderust-pod-core`). | -| `affn` | `Point`/`Vector` algebra with `qtty` units, frame/center markers, rotations, isometries, conics, `Position - Position = Vector`. | Use directly for `OrbitState` geometry, station positions, residual geometry. POD-only frames (RTN/RIC, LVLH, VNC) defined as new marker types in `siderust-pod-core::frames` *implementing* `affn` traits, not modifying `affn`. | -| `cheby` | Chebyshev series, piecewise interpolation, derivatives, spectral tools, binary/serde I/O. | Use directly for SP3 segment representation, propagated-orbit interpolation, and spectral QC. New POD wrappers (e.g. `OrbitSegment`) live in `siderust-pod-core`. | -| `siderust` | VSOP87, ELP2000, optional DE440/DE441; precession, nutation, polar motion, ERA, EOP plumbing; observatories; targets. | Consume through *new public provider traits* (`EphemerisProvider`, `EarthOrientationProvider`, `FrameTransformProvider`) added in `siderust-pod-core::providers` as a thin adapter over current `siderust` APIs. Only escalate to an upstream PR if a needed capability is gated behind private modules. | - -### 1.3 Duplication, coupling, architectural risks observed - -- **Package-name collision.** `spod/Cargo.toml` declares `name = "siderust"` — this guarantees confusion and would break any consumer that depends on both. Must be renamed in M0. -- **Vendored copy drift risk.** Carrying a full clone of `siderust` source inside `spod/src/` invites accidental edits and silent divergence. Removing it is the only safe option. -- **Scope creep risk.** The design doc explicitly warns against turning `siderust` into a POD monolith and against rebuilding FocusPOD wholesale in v1. The plan honors the M0 → M7 sequencing. -- **Estimator coupling risk.** Without explicit dependency rules, IO/parser code can leak into estimator crates. The plan codifies forbidden edges and enforces them with a CI check (`cargo-deny` + a small graph script). -- **Provider-trait risk.** If POD code reaches into `siderust` private modules, every upstream refactor breaks POD. The plan introduces a thin `siderust-pod-core::providers` adapter that *only* uses public `siderust` items. - ---- - -## 2. Proposed final architecture - -### 2.1 Workspace layout - -```text -rust/spod/ - Cargo.toml # virtual workspace, no [package] - rust-toolchain.toml - .cargo/config.toml - crates/ - siderust-pod-core/ # domain primitives, providers, manifest, errors - siderust-pod-dynamics/ # forces, propagation, STM, RTN/RIC - siderust-pod-io/ # SP3, RINEX, ANTEX, EOP, OEM, … - siderust-pod-observations/ # GNSS/SLR/DORIS/VLBI measurement models - siderust-pod-estimation/ # WLS/EKF/robust/covariance (faer) - siderust-pod-qc/ # residuals, RTN/RIC compare, JSON, HTML - siderust-pod-products/ # SP3/OEM writers, packaging, naming - siderust-pod-service/ # job/pipeline runner, config, artifacts - spod/ # thin CLI over service - examples/ - configs/ - leo_gnss_mvp1.yaml - fixtures/ - gnss/ time/ gravity/ spacecraft/ - docs/ - architecture/ - conventions/ - requirements/ - formats/ - validation/ - tests/ - e2e/ regression/ synthetic/ - benches/ - scripts/ - check_dep_graph.sh # CI dependency-direction enforcement -``` - -The upstream `siderust-ffi` crate that currently lives under `spod/siderust-ffi` is removed from this workspace (it belongs in `rust/siderust/`). POD's own future FFI lives in `crates/siderust-pod-ffi/` and is **not** part of MVP-1. - -### 2.2 Crate responsibilities - -| Crate | Owns | Does not own | -|---|---|---| -| `siderust-pod-core` | `OrbitState`, `SpacecraftState`, `ArcDefinition`, `RunManifest`, `ParameterKind`, `Covariance`, `DatasetRef`, error taxonomy, **provider traits** (`EphemerisProvider`, `EarthOrientationProvider`, `FrameTransformProvider`, `GravityFieldProvider`, `AtmosphereDensityProvider`), POD-specific frame markers (RTN/RIC/LVLH/VNC), config schema primitives. | Numerical algorithms; file parsing; estimator math. | -| `siderust-pod-dynamics` | `ForceModel` trait, `Propagator` trait, two-body/J2/spherical-harmonics/third-body/drag/SRP/relativity/empirical, integrators (RK4 → DOP853), STM/variational, RTN/LVLH/VNC frame transforms. | File I/O; observation models; estimator. | -| `siderust-pod-io` | Parsers/writers for SP3, RINEX OBS/NAV, ANTEX, EOP, OEM (MVP-1) and SINEX/CRD/CPF/ORBEX/CCSDS-OPM/AEM/TDM/OMM/RINEX-DORIS/vgosDB (later). Canonical typed records. Round-trip & strict/permissive parse modes. SHA-256 of input files. | Numerics; estimator; service. | -| `siderust-pod-observations` | `MeasurementModel` trait, GNSS code/carrier prediction + analytic partials, corrections (clock, Sagnac, relativity, antenna phase center, phase wind-up, troposphere, ionosphere), simulation. | File parsing; estimator solver. | -| `siderust-pod-estimation` | Parameter blocks, residual/design-matrix assembly (faer-backed), WLS, nonlinear iteration, robust weighting, covariance extraction, EKF/smoothing later, ambiguity (float now, integer later). | File parsing; observation modeling; service orchestration. | -| `siderust-pod-qc` | Residual statistics, grouping (sat/obs/elev/epoch), orbit overlap, RTN/RIC compare, SLR validation, QC JSON schema, HTML report. | Estimation algorithms; product writing. | -| `siderust-pod-products` | SP3/OEM writers (delegate parsing to `-io`), residual product packaging, manifest/product naming, product validation. | Numerics; service runtime. | -| `siderust-pod-service` | Job model, config load/validate, pipeline stages (ingest → prepare → estimate → qc → products → manifest), artifact layout, hashing, deterministic logging. Future REST/job queue. | Numerical algorithms. | -| `spod` | `clap`-based commands; pure passthrough to `-service` and library APIs; deterministic exit codes. | Any numerical logic. | - -### 2.3 Allowed and forbidden dependency directions - -```text -ALLOWED: - qtty, tempoch, affn, cheby ←— independent foundations - siderust → qtty, tempoch, affn, cheby - pod-core → siderust, qtty, tempoch, affn, cheby - pod-dynamics → pod-core, siderust, cheby (for trajectory segments) - pod-io → pod-core - pod-observations → pod-core, pod-dynamics (for state propagation context only via traits), siderust - pod-estimation → pod-core, faer - pod-qc → pod-core, pod-products (read-only types), cheby (spectral) - pod-products → pod-core, pod-io (writers reuse parsers) - pod-service → ALL pod-* crates - pod-cli → pod-service (only) - -FORBIDDEN: - siderust → pod-* (upstream must not know POD) - qtty/tempoch/affn/cheby → siderust or pod-* (foundations stay foundational) - pod-core → pod-service, pod-cli, pod-io, pod-dynamics, pod-observations, pod-estimation, pod-qc, pod-products - pod-estimation → pod-io, pod-observations (estimator solves algebra; observation models are injected via traits) - pod-observations → pod-io (observation models receive canonical typed records, not raw files) - pod-dynamics → pod-io, pod-observations, pod-estimation - pod-qc → pod-estimation (QC consumes residuals as data) - pod-cli → pod-{core,dynamics,io,observations,estimation,qc,products} directly (must go through pod-service) -``` - -The `scripts/check_dep_graph.sh` CI step parses each crate's `Cargo.toml` and fails if any forbidden edge appears. - -### 2.4 Separation-of-concerns guarantees - -- **Time** lives in `tempoch` only. -- **Units** live in `qtty` only. -- **Geometry** lives in `affn` only (positions, vectors, rotations, conics). -- **Astronomy** lives in `siderust` only, exposed to POD through provider traits. -- **POD domain semantics** (arcs, parameters, residuals, manifests, run config) live in `siderust-pod-core` only. -- **File formats** live in `siderust-pod-io` only; everything else consumes canonical typed records. -- **Estimator** receives data through traits and parameter blocks; it does not know what GNSS or SLR is. -- **QC** consumes residual records; it does not run estimators. -- **Products** consume estimator outputs; they do not run estimators. -- **Service** orchestrates; CLI presents. - -### 2.5 Surface stratification - -- **Library-only:** `pod-core`, `pod-dynamics`, `pod-io`, `pod-observations`, `pod-estimation`, `pod-qc`, `pod-products`. -- **Library + service-only:** `pod-service` (Rust API + later REST). -- **CLI-only:** `pod-cli`. -- **Future UI/API:** layered above `pod-service`. No UI/API code in MVP-1 through MVP-3. - ---- - -## 3. Implementation milestones - -Milestones are numbered M0–M7. Each lists *goal*, *crates touched*, *concrete tasks*, *expected outputs*, *tests*, *definition of done*, *risks*. - -### M0 — Workspace skeleton & dependency lock - -- **Goal:** real virtual workspace; package collision removed; dependency-direction CI live; provider traits compile against upstream `siderust`. -- **Crates touched:** all (created), `pod-core` (real code), others (placeholder `lib.rs`). -- **Concrete tasks:** - - Delete `spod/src/`, `spod/build.rs`, `spod/siderust-ffi/`, upstream-shaped `examples/`/`benches/`/`tests/`, upstream `archive/` data committed under `src/`. - - Replace root `Cargo.toml` with a `[workspace]` (no `[package]`), `members = ["crates/*"]`, `resolver = "2"`. - - `cargo new --lib crates/spod-{core,dynamics,io,observations,estimation,qc,products,service}` and `cargo new crates/spod`. - - Per-crate `Cargo.toml`: AGPL-3.0, version `0.0.0`, `repository`/`license`/`readme` fields, MSRV pin (latest stable −1). - - Add the Siderust ecosystem crates as released registry dependencies. - - In `pod-core`: define provider traits (`EphemerisProvider`, `EarthOrientationProvider`, `FrameTransformProvider`, `GravityFieldProvider`, `AtmosphereDensityProvider`) with default impls that wrap public `siderust` APIs. - - `scripts/check_dep_graph.sh` enforcing §2.3. - - `.github/workflows/ci.yml`: `fmt`, `clippy -- -D warnings`, `test --workspace`, `check_dep_graph.sh`, `cargo deny check`, `cargo doc --no-deps`. - - `docs/architecture/{boundaries.md,dependency-rules.md,providers.md}` (short, normative). - - Move `memory/` to `docs/design/` (keep verbatim; it's the design contract). -- **Expected outputs:** compiling empty workspace; CI green; documented architecture. -- **Tests:** `cargo test --workspace` (placeholder), dep-graph script self-test, doc-build. -- **Definition of done:** all of the above + a short ADR `docs/architecture/ADR-0001-workspace-reset.md` recording the wipe. -- **Risks:** none numerical; risk is bikeshedding the boundary doc — timebox and commit. - -### M1 — State, dynamics foundation, RTN/RIC - -- **Goal:** typed `OrbitState`/`SpacecraftState`, dynamics context, two-body/J2/third-body forces, RK4 + DOP853 integrators, RTN/RIC frame, Cartesian↔RTN covariance round-trip. -- **Crates touched:** `pod-core`, `pod-dynamics`. -- **Concrete tasks:** - - `pod-core`: `state.rs` (`OrbitState`, `SpacecraftState`), `arc.rs`, `spacecraft.rs`, `parameter.rs`, `covariance.rs` (typed 6×6 carrying frame/center/order/units), `frames/{rtn.rs,lvlh.rs,vnc.rs}` defining marker types and `affn` impls. - - `pod-dynamics`: `forces/{two_body,j2,third_body,composite}.rs`, `integrators/{rk4,dop853}.rs`, `propagation/propagator.rs`, `variational/{stm,partials}.rs` (skeleton with two-body STM only), `frames/rtn.rs` (computes basis from state). - - Wire `third_body` to `EphemerisProvider`. -- **Expected outputs:** propagation API; covariance transform; reference J2 / two-body acceleration values matching closed-form. -- **Tests:** ST-005 two-body energy conservation, ST-006 J2 reference vector, ST-007 third-body Sun/Moon vs independent calc, ST-003 covariance Cartesian↔RTN round-trip + PSD preservation; STM finite-difference check on two-body. -- **Definition of done:** all listed tests pass at default tolerances, doc page `docs/architecture/dynamics.md` written. -- **Risks:** STM correctness — mitigation: finite-difference validation gate is mandatory; expand to J2 STM in M3, not M1. - -### M2 — Format ingestion MVP-1 - -- **Goal:** parse/write SP3, parse RINEX OBS/NAV (MVP subset), parse ANTEX, EOP bridge into `tempoch::TimeContext`, write OEM. -- **Crates touched:** `pod-io`, small additions in `pod-core` (`DatasetRef`, dataset format enum). -- **Concrete tasks:** - - `pod-io::sp3` reader+writer with SHA-256, strict/permissive modes, structured diagnostics. - - `pod-io::rinex::{obs,nav}` MVP subset (GPS L1/L2 P/C, Galileo E1/E5a; broadcast NAV). - - `pod-io::antex` PCO/PCV reader. - - `pod-io::eop` adapter producing `tempoch::eop::EopDataset` (without modifying tempoch). - - `pod-io::ccsds::oem` writer. -- **Expected outputs:** typed records (`Sp3Record`, `RinexObservationEpoch`, `AntexRecord`, `OemRecord`) + provenance. -- **Tests:** ST-010 SP3 round-trip, ST-011 RINEX OBS/NAV parse, ST-012 ANTEX query, OEM write+reparse. -- **Definition of done:** parser regression suite green; fixtures in `examples/fixtures/`. -- **Risks:** RINEX OBS edge cases — scope to GPS+Galileo + plain header for MVP-1; document deferred edge cases. - -### M3 — Estimation MVP - -- **Goal:** GNSS code+carrier `MeasurementModel`, weighted least-squares solver (faer), covariance extraction, analytic partials validated. -- **Crates touched:** `pod-observations`, `pod-estimation`. -- **Concrete tasks:** - - `pod-observations::model::MeasurementModel` trait; `gnss::{code,carrier,clock,ambiguity}`; `corrections::{sagnac,relativity,antenna,phase_windup}`. - - `pod-estimation::{parameter,residual,design_matrix,normal_equations}.rs`; `batch::{weighted_least_squares,nonlinear,convergence}`; `robust::{huber,sigma_edit}`; `covariance.rs`. - - faer-backed dense Cholesky/QR; condition diagnostics. - - Analytic-vs-finite-difference Jacobian gate. -- **Expected outputs:** library API `BatchEstimator::solve(...)` returning `EstimationReport`. -- **Tests:** ST-013 code residual, ST-014 carrier residual, ST-015 corrections, ST-016 linear WLS, ST-017 nonlinear synthetic OD, ST-018 convergence report fields, finite-diff Jacobian. -- **Definition of done:** synthetic GNSS arc converges to truth within tolerance. -- **Risks:** ambiguity float-only; document explicitly that integer fixing is post-MVP. - -### M4 — POD MVP-1 end-to-end (config-driven) - -- **Goal:** `spod run examples/configs/leo_gnss_mvp1.yaml` produces all required artifacts; reproducible; manifest with input hashes. -- **Crates touched:** `pod-service`, `pod-cli`, `pod-products`, `pod-qc`. -- **Concrete tasks:** - - `pod-service::config` (YAML + `serde` + JSON-Schema export + `validate-config` command); pipeline stages exactly as design §6.8. - - `pod-products::orbit::{sp3,oem}` writers; `pod-products::residuals` (CSV first, Parquet behind `parquet` feature), `pod-products::manifest` (canonical JSON, sorted keys, fixed float format). - - `pod-qc::residuals::{summary,grouping}`; `pod-qc::schema` (JSON-Schema published in `docs/validation/`); optional `pod-qc::html` (minimal templated report). - - `pod-cli` commands: `validate-config`, `run`, `inspect-manifest`, `qc`. - - End-to-end fixture: synthetic 24 h LEO GNSS arc generated via simulation harness in `tests/synthetic/`. -- **Expected outputs:** the artifact tree of design §17 (`run.manifest.json`, `products/orbit.{sp3,oem}`, `residuals/residuals.csv`, `qc/{qc.json,summary.json,report.html}`, `debug/*`). -- **Tests:** ST-020 reproducibility (rerun = same hashes within configured float tolerance for floating outputs and exact for canonical JSON), ST-023 end-to-end, ST-025 CLI vs Rust-API parity. -- **Definition of done:** the design's §18 checklist (all 9 bullets) passes from clean checkout. -- **Risks:** non-determinism in float formatting / hashmap iteration — mitigation: canonical JSON encoder + sorted iteration + locked rayon seeds. - -### M5 — SLR validation (MVP-2) - -- **Goal:** validate GNSS-derived orbit against SLR observations; orbit overlap and reference-orbit comparison. -- **Crates touched:** `pod-io` (CRD/CPF), `pod-observations` (SLR range model), `pod-qc` (`orbit_compare`, `slr_validation`). -- **Concrete tasks:** CRD/CPF parsers; SLR range model with station, atmosphere, retroreflector; CLI `validate-slr` and `compare-orbits --frame rtn`. -- **Tests:** ST-021 SLR validation, ST-024 GNSS POD + SLR validation E2E. -- **Definition of done:** CRD/CPF fixture produces a residual report linked to a run manifest; RTN/RIC compare artifact validated against synthetic truth. -- **Risks:** atmosphere model accuracy — start with Mendes-Pavlis; document residual-bias expectations. - -### M6 — NRT replay (EKF/smoothing) (MVP-3) - -- **Goal:** EKF replay sharing state/parameter definitions with batch; windowed processing; latency metric. -- **Crates touched:** `pod-estimation::sequential`. -- **Concrete tasks:** EKF time/measurement update reusing `ForceModel`/`MeasurementModel`; process-noise model; PSD covariance check; backward smoother; window driver in `pod-service::stages::estimate`. -- **Tests:** ST-022 EKF replay vs batch within tolerance. -- **Definition of done:** stable EKF on the M4 synthetic arc; QC report contains innovation-NIS time series. - -### M7 — Productization - -- **Goal:** Python bindings (PyO3), REST job API, container image, public-data benchmark corpus, performance dashboards. -- **Crates touched:** new `crates/siderust-pod-py/`, `crates/siderust-pod-rest/`; `pod-service` worker model. -- **Concrete tasks:** PyO3 wrappers calling the same library APIs; `axum` REST over `pod-service::Runner`; OCI image with deterministic build; public LEO GNSS arc benchmark. -- **Definition of done:** API parity test (CLI ≡ Rust API ≡ Python ≡ REST) green; documented commercial-readiness checklist. - ---- - -## 4. MVP-1 plan: GNSS-only LEO batch POD - -### 4.1 Command - -```bash -cargo run -p spod -- run examples/configs/leo_gnss_mvp1.yaml -``` - -### 4.2 Required inputs (per config) - -| Input | Format | Source for fixture | -|---|---|---| -| GNSS observations | RINEX OBS (GPS L1/L2 + Galileo E1/E5a) | Simulated (M4) → public IGS later | -| GNSS broadcast NAV | RINEX NAV | Simulated → public BRDC | -| Precise GNSS products | SP3 + (optional) Clock RINEX | IGS final fixture | -| Antenna phase center | ANTEX | IGS `igs14.atx` snapshot | -| EOP | IERS finals2000A bulletin (subset) | Frozen fixture | -| Leap seconds | `leap-seconds.list` (IERS) | Frozen fixture | -| Spacecraft config | YAML (mass, area, Cd, Cr, antenna offsets) | `examples/fixtures/spacecraft/demo-leo.yaml` | -| Gravity field | EGM2008-style `.gfc` (truncated to deg/order ≤ 20 for MVP-1) | Public fixture (truncated copy) | - -All inputs are SHA-256-hashed and recorded in `run.manifest.json`. - -### 4.3 Required models - -- Time/frame: `tempoch` GPS↔UTC↔TAI↔TT↔UT1↔TDB; `siderust` precession/nutation/polar motion via `EarthOrientationProvider`; ITRF↔GCRF via `FrameTransformProvider`. -- Dynamics: two-body, J2, low-deg/order spherical harmonics (≤ 20 from EGM file), Sun + Moon third-body via `EphemerisProvider` (VSOP87 + ELP2000 backend), simple drag with constant density (`AtmosphereDensityProvider::Constant`), cannonball SRP. Optional relativistic correction. -- Observations: GNSS code + carrier; receiver clock as parameter; carrier float ambiguities per arc per signal; Sagnac correction; relativistic correction; antenna phase-center via ANTEX; satellite clock from broadcast NAV (or precise clock if provided). -- Estimation: nonlinear WLS, sigma-edit robust weighting, max 8 iterations, Cholesky on normal equations with condition diagnostic, faer dense backend. -- Outputs: SP3 (own orbit), OEM (CCSDS), residuals CSV, `qc.json`, `run.manifest.json`, optional `report.html`. - -### 4.4 Acceptance gates (mirrors design §18) - -1. Run completes with exit code 0 and produces all 6 required artifacts. -2. SP3 output reparses with zero diagnostics. -3. OEM output reparses with zero diagnostics. -4. Residual CSV contains both prefit and postfit columns. -5. QC JSON validates against `docs/validation/qc.schema.json`. -6. Estimated synthetic orbit position error ≤ configured threshold (initial target: 5 cm 3D RMS on 24 h synthetic arc; tightened later). -7. Re-run produces identical canonical JSON manifest hash and identical SP3/OEM byte content (modulo timestamps that are recorded as parameters). -8. No POD crate imports a non-`pub` item from `siderust`/`qtty`/`tempoch`/`affn`/`cheby`. -9. Compile-fail tests cover frame/center/unit misuse on the public POD APIs. - ---- - -## 5. Testing and validation plan - -### 5.1 Test categories - -| Category | Location | What it covers | -|---|---|---| -| Unit | per-crate `src/**/tests` and `tests/` | Numerical kernels, parsers (small), corrections, partials. | -| Compile-fail (`trybuild`) | `crates/siderust-pod-core/tests/compile_fail/` | Adding two `Position`s; using ITRF position where GCRF is required; computing residual without center binding; mixing scalar units. | -| Parser round-trip | `crates/siderust-pod-io/tests/roundtrip/` | SP3, OEM, ANTEX, RINEX MVP. | -| Synthetic truth | `tests/synthetic/` | Simulate orbit + observations → estimate → assert recovered parameters within tolerance. | -| End-to-end | `tests/e2e/` | Run the CLI against a fixture config; diff artifacts against committed expected outputs. | -| Regression | `tests/regression/` | Frozen fixtures of public-data parses; pinned outputs. | -| Performance | `benches/` (criterion) | Per the §6 benchmark targets. | -| Reproducibility | `tests/e2e/reproducibility.rs` | Two consecutive runs produce identical manifest hash. | -| API parity | `tests/e2e/parity.rs` | CLI ≡ Rust API; from M7: + Python + REST. | - -### 5.2 Public-data benchmark strategy - -- **MVP-1:** synthetic only (deterministic, fully reproducible, no licensing risk). -- **MVP-2:** add one frozen, redistributable LEO GNSS arc + IGS final products (24 h) committed under `examples/fixtures/public/` with explicit license/source notes. -- **MVP-3+:** publish a separate `siderust-pod-benchmarks` repo with curated multi-day public arcs and CI-published results dashboards. - -### 5.3 Minimum tolerances (initial) - -| Quantity | Tolerance | -|---|---| -| Two-body energy drift over 1 orbit (RK4, fixed-step 30 s) | < 1e-9 relative | -| Two-body STM finite-diff vs analytic | < 1e-7 relative | -| Cartesian↔RTN covariance round-trip Frobenius error | < 1e-12 | -| Synthetic LEO 24 h orbit recovery (MVP-1) | ≤ 5 cm 3D RMS | -| Reproducibility: canonical manifest JSON | byte-identical | - ---- - -## 6. Performance plan - -### 6.1 Likely hot paths - -1. **RINEX OBS parsing** at IGS network scale (1 Hz × tens of stations × tens of satellites). -2. **Spherical-harmonics gravity** evaluation (degree 70+ later). -3. **Force-model + STM RHS** inside the integrator inner loop. -4. **Design-matrix assembly** (n_obs × n_params, possibly 1e5 × 1e3+ later). -5. **Normal-equation Cholesky / SVD** during WLS. -6. **Residual evaluation** at every observation epoch. - -### 6.2 Data structures and memory layout - -- States as `repr(C)` POD structs with `Vector3` from `affn` (which itself is `repr(C)`). -- Observation batches stored in **struct-of-arrays** (epoch[], sat_id[], obs_value[], sigma[]) to vectorize residual loops. -- Design matrix backed by faer dense storage; sparse upgrade path through faer's sparse module without API change. -- Avoid allocations in inner loops: `SmallVec` for per-step accumulators, pre-sized scratch buffers in `BatchEstimator`. - -### 6.3 Static vs dynamic dispatch - -- **Static/generic** for inner loops (force model composition, integrator step, residual evaluation) — composed via traits with generic `S = f64` scalar, monomorphized. -- **Dynamic** (`dyn ForceModel`, `dyn MeasurementModel`) for *configuration assembly* in `pod-service`. Convert dyn → generic at the entry of compute kernels (one boxed call per arc/iteration, not per step). -- Document this in `docs/architecture/dispatch.md`. - -### 6.4 Profiling and parallelism - -- `cargo flamegraph` and `criterion` per benchmark target. -- Parallelize: per-arc estimation (rayon), per-station residual evaluation (rayon `par_iter`), gravity-degree loops only after profiling shows them dominant. -- Pin CI bench machine; track regression with `criterion-cmp` artifacts. - -### 6.5 Benchmark targets (MVP-1 acceptance) - -| ID | Target | -|---|---| -| PB-001 SP3 parse | ≥ 50 MB/s on CI machine | -| PB-002 RINEX OBS parse | ≥ 200 k observations/s | -| PB-003 24 h LEO propagation (MVP forces) | ≤ 250 ms wall (DOP853, 30 s output) | -| PB-004 GNSS residual eval | ≥ 500 k residuals/s | -| PB-005 Batch LSQ (24 h synthetic) | ≤ 3 s wall, ≤ 200 MB | -| PB-006 QC generation | ≥ 1 M residual records/s | - -Baselines locked in `benches/baseline.json`; CI fails on > 15% regression. - ---- - -## 7. Maintainability plan - -### 7.1 Documentation - -- `docs/architecture/` — boundaries, dependency rules, providers, dispatch, dynamics, estimation. -- `docs/conventions/` — error handling, naming, units, frames, time-scale handling, JSON canonicalization. -- `docs/requirements/` — HR-/LR-/ST- IDs from design doc with status table per release. -- `docs/formats/` — per-format compliance notes (which RINEX subset, which SP3 line types, etc.). -- `docs/validation/` — JSON-Schemas and validation reports. -- `docs/adrs/` — Architecture Decision Records (ADR-0001 = workspace reset, ADR-0002 = faer backend, ADR-0003 = AGPL-3.0, ADR-0004 = provider-trait pattern, …). -- `cargo doc` published in CI for every PR; broken intra-doc links fail CI. - -### 7.2 API stability - -- Pre-1.0 (MVP-1..MVP-3): `0.x` versions; SemVer-minor bumps may break. -- 1.0 freeze candidates: `pod-core` types and provider traits (the most depended-upon surface). Mark internal items `#[doc(hidden)]` and `pub(crate)`. -- `cargo public-api` snapshot per crate, diffed in CI. - -### 7.3 Error handling - -- One concrete `Error` enum per crate, built with `thiserror`. -- `pod-core::Error` is the umbrella type; downstream crates re-export their own and convert via `#[from]`. -- All errors carry: dataset id (where relevant), epoch (where relevant), structured machine-readable code. -- Public API never panics on user input; panics are reserved for invariant violations and are documented. - -### 7.4 Feature flags - -| Crate | Flags | -|---|---| -| `pod-core` | `serde` (default), `schemars` (JSON-Schema export). | -| `pod-io` | `parquet` (off), `compression` (off). | -| `pod-estimation` | `faer-sparse` (off until M5+), `rayon` (default). | -| `pod-qc` | `html` (default), `plotters` (off until M5). | -| `pod-service` | `rest` (off until M7). | -| `pod-cli` | `color` (default). | - -No feature gates change *numerical results*. CI matrix tests `--no-default-features` and `--all-features` per crate. - -### 7.5 CI gates - -- `cargo fmt -- --check` -- `cargo clippy --workspace --all-targets -- -D warnings` -- `cargo test --workspace` -- `cargo test --workspace --no-default-features` (for crates that support it) -- `scripts/check_dep_graph.sh` -- `cargo deny check` (advisory + license + bans) -- `cargo public-api --diff` (warn → fail post-1.0) -- `cargo doc --no-deps -D rustdoc::broken-intra-doc-links` -- Reproducibility test in CI matrix. -- Performance bench job on a pinned runner; warn on > 15% regression. - -### 7.6 Dependency audit - -- `cargo deny`: deny `gpl-2.0`-only, deny duplicate dependency major versions across the workspace, allow only `apache-2.0`, `mit`, `bsd-3-clause`, `mpl-2.0`, `agpl-3.0`, `unicode-dfs-2016`. -- Lockfile committed. - -### 7.7 Coding conventions - -- All public types parameterized by `S: Scalar` where useful (default `f64`). -- All public APIs accept `qtty` and `tempoch` types; raw `f64`/`u64` only in serialization and parsers. -- All public functions have rustdoc + an `# Example` or a link to `examples/`. -- No `unsafe` outside vetted FFI shims; `#![forbid(unsafe_code)]` per crate where possible. -- Conventional Commits on every commit (`(): `); never include Copilot co-author trailers. - -### 7.8 Release / versioning - -- Independent SemVer per POD crate; release together via `cargo-release` with workspace-version bumping but separate changelogs. -- `CHANGELOG.md` per crate, Keep-a-Changelog format. -- Pre-1.0: weekly snapshot tag; 1.0 announced after MVP-1 + at least one external user. - ---- - -## 8. Risk register - -| ID | Risk | Likelihood | Impact | Mitigation | -|---|---|---|---|---| -| R-01 | Scope creep into full FocusPOD parity before MVP-1 ships | High | High | M0–M4 enforce a strict synthetic-only first product; design doc §2.2 non-goals quoted in PR template. | -| R-02 | Pressure to modify `qtty`/`tempoch`/`affn`/`cheby`/`siderust` for short-term POD convenience | High | High | Provider-trait + adapter-first rule; any upstream change requires separate branch + ADR + maintainer approval; CI denies path-dependencies on a feature branch. | -| R-03 | Estimator coupling to GNSS specifics | Medium | High | `MeasurementModel` trait owns the seam; add a non-GNSS dummy (range-rate) impl in M3 to prove generality. | -| R-04 | Non-determinism (HashMap iter, float formatting, parallel reductions) | High | High | Canonical JSON encoder; sorted IDs everywhere; deterministic-reduction option; reproducibility test in CI. | -| R-05 | Covariance semantics drift (frame/center/order ambiguity) | Medium | High | `Covariance` carries frame, center, ordering, and units in its type; round-trip + PSD tests are mandatory gates. | -| R-06 | Performance regressions go unnoticed | Medium | Medium | Pinned-runner bench job with baselines + 15% regression gate. | -| R-07 | Vendored siderust copy creeps back into `spod` | Low | High | M0 deletes it; `scripts/check_dep_graph.sh` rejects any `path = "../../rust/siderust"` not whitelisted; CI scans for `src/astro/`, `src/calculus/` etc. inside `crates/`. | -| R-08 | Float-only ambiguity insufficient for users | Medium | Medium | Document explicitly; carve a clean place in `pod-estimation::ambiguity` for integer fixing in MVP-2+. | -| R-09 | Public-data licensing surprises | Medium | Medium | MVP-1 synthetic-only; vetted public fixtures with explicit `LICENSE` and source URL files in `examples/fixtures/public/`. | -| R-10 | RINEX/SP3/ANTEX edge-case interoperability bugs | High | Medium | Strict + permissive modes; structured diagnostics; expand fixture set per bug report; commit golden fixtures. | -| R-11 | Service/CLI logic leaks into compute crates | Medium | High | Forbidden-edge CI; PR template asks "does this introduce config/IO/CLI knowledge into a compute crate?". | -| R-12 | Provider-trait churn breaks downstream | Medium | Medium | Mark `pod-core::providers` semver-stable target; require ADR for any breaking change. | -| R-13 | AGPL chills commercial adoption | Medium | Medium | Document open-source posture; evaluate dual-licensing (AGPL + commercial) only at M7 productization, with a dedicated ADR. | - ---- - -## 9. Branching and change policy - -- `rust/siderust/`, `rust/qtty/`, `rust/tempoch/`, `rust/affn/`, `rust/cheby/`, `rust/NSB/` — **untouched** by the POD plan. CI on the POD branch fails if any file under those paths changes. -- `rust/siderust-pod/` — the **only** editable workspace for this plan. All POD work lands here. -- Any change required in a foundational crate must: - 1. Be created on a **separate branch** off `main`, named `foundational//`. - 2. Carry an **ADR** under `rust/spod/docs/adrs/` justifying why an adapter/provider trait was insufficient. - 3. Be reviewed by a maintainer of the affected crate. - 4. Be merged independently of POD work; POD then bumps the upstream version. -- Working branch convention for POD: `pod//`, e.g. `pod/m2/sp3-writer`. -- PR template includes: - - "No upstream foundational crate modified" checkbox. - - "No private upstream API imported" checkbox. - - "No Copilot co-author attribution" checkbox. - - Forbidden-edges checkbox (auto-checked by CI). - ---- - -## 10. First actionable tasks (ordered) - -Each task: target crate(s), expected files, acceptance criteria. - -1. **Reset workspace.** `spod`. Delete legacy `src/`, `build.rs`, vendored `siderust-ffi/`, upstream `examples/`/`benches/`/`tests/`, `archive/` data. Replace root `Cargo.toml` with virtual workspace. Move `memory/` → `docs/design/`. *Acceptance:* `cargo metadata` lists no members yet; `git status` is clean after re-add. -2. **Create empty crates.** `crates/spod-{core,dynamics,io,observations,estimation,qc,products,service,cli}` via `cargo new`, AGPL-3.0 in each `Cargo.toml`. *Acceptance:* `cargo build --workspace` succeeds. -3. **Wire workspace deps.** Pin `siderust = "0.7"`, `qtty = "0.7"`, `tempoch = "0.4"`, `affn = "0.7"`, `cheby = "0.2"`, `faer`, `serde`, `thiserror`, `clap`. *Acceptance:* `cargo deny check` passes. -4. **CI skeleton.** `.github/workflows/ci.yml` with fmt/clippy/test/doc/dep-graph/deny. *Acceptance:* CI green on empty workspace. -5. **Dep-graph guard.** `scripts/check_dep_graph.sh` + unit test. *Acceptance:* deliberate forbidden edge fails CI in a draft PR. -6. **ADR-0001..0004.** Workspace reset, faer backend, AGPL-3.0, provider-trait pattern. *Acceptance:* committed under `docs/adrs/`. -7. **Boundaries doc.** `docs/architecture/{boundaries.md,dependency-rules.md,providers.md,dispatch.md}`. *Acceptance:* reviewed. -8. **Provider traits.** `pod-core::providers::{ephemeris,earth_orientation,frame_transform,gravity_field,atmosphere_density}.rs` with default impls wrapping public `siderust`. *Acceptance:* unit tests prove a Sun-position call returns the same value as direct `siderust` call. -9. **Domain primitives.** `pod-core::{state,arc,spacecraft,parameter,covariance,manifest,dataset,error}.rs`. *Acceptance:* unit tests build `OrbitState`, `SpacecraftState`, `RunManifest` instances. -10. **POD frames.** `pod-core::frames::{rtn,lvlh,vnc}.rs` defining marker types and `affn` impls. *Acceptance:* type-level test that `Position` cannot be added to `Position`. -11. **Compile-fail harness.** `pod-core/tests/compile_fail/*.rs` via `trybuild`. *Acceptance:* 5 representative misuses fail to compile. -12. **Two-body force.** `pod-dynamics::forces::two_body`. *Acceptance:* ST-005 energy conservation passes. -13. **J2 force.** `pod-dynamics::forces::j2`. *Acceptance:* ST-006 reference vector passes. -14. **RK4 integrator.** `pod-dynamics::integrators::rk4`. *Acceptance:* two-body 1-orbit error bound met. -15. **DOP853 integrator.** `pod-dynamics::integrators::dop853`. *Acceptance:* 24 h LEO propagation finishes within bench target PB-003. -16. **Third-body force.** `pod-dynamics::forces::third_body` calling `EphemerisProvider`. *Acceptance:* ST-007 passes. -17. **Cartesian↔RTN covariance.** `pod-core::covariance` + `pod-dynamics::frames::rtn`. *Acceptance:* ST-003 passes. -18. **STM (two-body).** `pod-dynamics::variational::stm`. *Acceptance:* finite-difference Jacobian agreement < 1e-7. -19. **SP3 reader.** `pod-io::sp3::parser`. *Acceptance:* IGS sample file parses with no diagnostics. -20. **SP3 writer + round-trip test.** `pod-io::sp3::writer`. *Acceptance:* ST-010 passes. -21. **RINEX OBS parser (MVP subset).** `pod-io::rinex::obs`. *Acceptance:* ST-011 passes. -22. **RINEX NAV parser (MVP subset).** `pod-io::rinex::nav`. *Acceptance:* broadcast ephemeris evaluates within tolerance vs SP3. -23. **ANTEX parser.** `pod-io::antex`. *Acceptance:* ST-012 passes. -24. **EOP adapter.** `pod-io::eop` producing a `tempoch::eop::EopDataset` (no upstream change). *Acceptance:* loaded EOP reproduces a known UT1-UTC value. -25. **OEM writer.** `pod-io::ccsds::oem`. *Acceptance:* OEM round-trip via a third-party reader (e.g. CCSDS-validated fixture). -26. **MeasurementModel trait.** `pod-observations::model`. *Acceptance:* trait compiles; doc-tested with a dummy range model. -27. **GNSS code prediction + partials.** `pod-observations::gnss::code`. *Acceptance:* ST-013 passes; finite-diff Jacobian < 1e-6. -28. **GNSS carrier prediction + float ambiguity + partials.** `pod-observations::gnss::carrier`. *Acceptance:* ST-014 passes. -29. **Corrections (Sagnac, relativity, antenna PCO/PCV, phase wind-up).** `pod-observations::corrections::*`. *Acceptance:* ST-015 individual correction tests pass. -30. **WLS solver (faer).** `pod-estimation::batch::weighted_least_squares` + `normal_equations` + `design_matrix`. *Acceptance:* ST-016 linear solve recovers parameters. -31. **Nonlinear iteration + convergence + sigma-edit.** `pod-estimation::batch::{nonlinear,convergence}` + `pod-estimation::robust`. *Acceptance:* ST-017 nonlinear synthetic OD recovers truth. -32. **Covariance extraction & condition diagnostics.** `pod-estimation::covariance`. *Acceptance:* ST-018 fields present. -33. **Synthetic GNSS arc generator.** `tests/synthetic/leo_gnss_24h.rs`. *Acceptance:* generates SP3 + RINEX OBS reproducibly from a seed. -34. **Config schema + loader + validator.** `pod-service::config` + `pod-cli validate-config`. *Acceptance:* `validate-config examples/configs/leo_gnss_mvp1.yaml` succeeds; bad config produces structured error. -35. **Pipeline runner (15 stages).** `pod-service::{pipeline,runner,artifacts,manifest}` + `stages/*`. *Acceptance:* `run` produces all 6 artifacts on the synthetic fixture. -36. **Product writers + naming + manifest hashing.** `pod-products::{orbit::{sp3,oem},residuals,manifest,naming}`. *Acceptance:* product validation passes; manifest contains input + output hashes. -37. **QC residuals + grouping + JSON-Schema + summary.** `pod-qc::{residuals,schema}`. *Acceptance:* `qc.json` validates against committed schema. -38. **HTML report (minimal templated).** `pod-qc::html`. *Acceptance:* `report.html` opens; embeds residual statistics and run metadata. -39. **CLI commands `run` / `inspect-manifest` / `qc`.** `pod-cli`. *Acceptance:* end-to-end ST-023 passes from `cargo run`. -40. **Reproducibility + parity tests.** `tests/e2e/{reproducibility,parity}.rs`. *Acceptance:* ST-020 + ST-025 pass; CI gates on them. - ---- - -## 11. Open decisions still needing human input - -| ID | Decision | Default in this plan | Trigger to revisit | -|---|---|---|---| -| ODD-A | Public benchmark satellite for MVP-2 | Sentinel-class LEO public arc with redistributable products | Before M5 starts | -| ODD-B | Parquet adoption for residuals | CSV first, Parquet behind feature | If a real user needs Parquet for scale | -| ODD-C | Python bindings timing | After M4 (per design ODD-006) | If a paying user requires earlier | -| ODD-D | Integer ambiguity resolution | Deferred past MVP-1 | Customer requirement or research collaboration | -| ODD-E | Dual-licensing posture | AGPL-3.0 single license | M7 commercialization push | -| ODD-F | High-degree gravity strategy | EGM2008 truncated to deg/order ≤ 20 in MVP-1; full degree in M5+ | Performance bench shows acceptable cost | -| ODD-G | Atmosphere model beyond constant | NRLMSISE-00 wrapper (or DTM) added in M5 | SLR validation residuals demand it | - ---- - -## 12. Locked decisions - -- Workspace will be **wiped and rebuilt** as a virtual workspace; legacy cloned `siderust` code under `spod/src/` will be deleted. -- Linear-algebra backend for the estimator is **`faer`**; `affn` continues to own typed positions/vectors and 3×3 covariance transforms. -- License is **AGPL-3.0** (matches `siderust`). -- Foundational crates (`qtty`, `tempoch`, `affn`, `cheby`, `siderust`) are **read-only** to this plan; any change requires a separate branch and ADR. -- No Copilot co-author attribution anywhere. diff --git a/docs/design/research-requirements-tests-cases.md b/docs/design/research-requirements-tests-cases.md deleted file mode 100644 index 1677a8a..0000000 --- a/docs/design/research-requirements-tests-cases.md +++ /dev/null @@ -1,342 +0,0 @@ -# GMV FocusPOD, the POD Market, and a Siderust Parity Roadmap - -## Executive summary - -urlGMVhttps://www.gmv.com/en-es’s entity["software","FocusPOD","GMV precise orbit determination and geodesy software"] is not just a numerical orbit solver. Public GMV material positions it as a proprietary, end-to-end POD and geodesy stack for LEO missions that combines multi-technique estimation, operational quality control, simulation, standard geodetic file I/O, and cloud-oriented deployment. Official GMV sources publicly claim operational use in the entity["organization","Copernicus Programme","European Union Earth observation programme"] POD service, support for GNSS, SLR, DORIS, and VLBI workflows, and performance targets of under 5 cm 3D RMS in under 5 minutes, under 2 cm in one day, and under 1 cm in three weeks for the publicly described Copernicus use case. GMV also places FocusPOD inside a broader operations stack that includes entity["software","FocusSuite","GMV flight dynamics suite"], entity["software","Hifly","GMV satellite monitoring and control system"], entity["software","Magnet","GMV ground station monitoring and control system"], and entity["software","Focusoc","GMV conjunction assessment and collision avoidance service"], which matters because part of GMV’s competitive advantage is system integration, not only POD math. citeturn6search0turn23view0turn23view3turn24view1turn25view0turn20view0turn20view2turn21search1turn21search15 - -The current public urlSiderust organizationturn16search2 already has unusually strong foundations for a Rust-native competitor: `qtty` for dimensional correctness, `tempoch` for time scales and EOP infrastructure, `affn` for typed frames/centers/geometry, `cheby` for interpolation and approximation, and `siderust` for astronomy, ephemerides, coordinates, and physical modeling. The public `siderust` README also explicitly signals future batch orbit-determination helpers and notes that RTN/RIC covariance transport is not yet first-class, which aligns closely with the biggest gap between Siderust today and FocusPOD-class systems. Based on the repo tree you provided, the missing center of gravity is not mathematical infrastructure but an operational POD layer: measurement ingestion, estimators, partial derivatives, ambiguity handling, product I/O, residual/QC tooling, batch and sequential solvers, and service orchestration. citeturn15view0turn15view1turn15view3turn16search2 - -The most credible parity strategy is therefore **not** to overload the existing `siderust` crate with every geodetic and operational concern. The better architecture is a new `spod` workspace layered on top of existing crates. In that split, `qtty`, `tempoch`, `affn`, `cheby`, and `siderust` remain reusable kernels; POD-specific crates handle observations, estimation, QC, standard products, and cloud/runtime concerns. That architecture mirrors the separation publicly described by GMV for FocusPOD and the service/API layering publicly described for FocusSuite, while preserving Siderust’s current strength as a type-safe scientific kernel. citeturn22search0turn25view0turn24view2turn24view3turn20view2 - -The priority sequence is straightforward. **P0** should target GNSS-based LEO POD parity: deterministic time/frame kernels, force and observation models with analytic partials, batch least squares, sequential EKF, standard GNSS/SP3/SINEX-style I/O, and automated QC. **P1** should add multi-technique support and serviceization: SLR, DORIS, mission satellite macro-models, residual analytics, provenance, REST/CLI jobs, and scalable processing. **P2** should add the genuinely differentiating geodesy features: VLBI, normal-equation stacking, GNSS network processing, TRF-like global solutions, and a polished operations UI. That ordering reflects what GMV publicly documents, what the competing toolchain landscape shows, and what Siderust already has versus what it lacks. citeturn24view1turn25view0turn20view4turn28academia42turn11search1turn27search3 - -## Research baseline and source hierarchy - -You asked that research begin with four priority sites, in this order: urlGitHubhttps://github.com, urlGMVhttps://www.gmv.com/en-es, urlCelesTrakhttps://celestrak.org, and urlSatellite Tracker 3Dhttps://satellitetracker3d.com. That sequence turned out to be useful rather than arbitrary: GitHub anchored the current public Siderust organization and public roadmap signals; GMV provided the primary evidence for FocusPOD and FocusSuite capabilities; CelesTrak provided current public realities about operational orbit-data interchange and the shift from legacy TLE toward CCSDS OMM-compatible formats; Satellite Tracker 3D showed what a modern lightweight orbit visualization and tracking frontend built on TLE/SGP4 looks like, which is not a direct POD competitor but is a useful benchmark for public-facing analysis UX. citeturn16search2turn15view1turn6search0turn23view0turn8search1turn9search0 - -| Priority site | Why it mattered | What it changed in this report | -|---|---|---| -| urlGitHubhttps://github.com | Confirmed the public Siderust org structure and the public `siderust` README roadmap, including explicit future LSQ/EKF helpers and the current absence of first-class RTN/RIC covariance transport. citeturn16search2turn15view1turn15view3 | Anchored the current-state inventory and the recommended crate split. | -| urlGMVhttps://www.gmv.com/en-es | Supplied the official FocusPOD page, brochure, FocusSuite page, FocusSuite white paper, and license documents. citeturn6search0turn23view0turn24view1turn20view2turn23view1turn6search1 | Established the authoritative capability catalog and the parity target. | -| urlCelesTrakhttps://celestrak.org | Documented the active industry move from legacy TLE to OMM/XML/JSON/CSV, including 6-digit catalog numbers and explicit rate-limit expectations. citeturn8search1 | Informed fixture design and the requirement for modern orbital-data import/export. | -| urlSatellite Tracker 3Dhttps://satellitetracker3d.com | Showed a browser-based tracking UX using TLE and SGP4 for more than 24,000 satellites, with graphing and search. citeturn9search0 | Informed the optional P2 visualization and public-analysis layer, not core POD parity. | - -After those four sites, the most useful additional sources were official product pages and official documentation for urlAnsys ODTKturn10search0, urlFreeFlyerhttps://ai-solutions.com/freeflyer-astrodynamic-software/, urlOrekitturn10search5, urlGMATturn10search6, urlTudatturn13search19, urlGROOPSturn13search6, urlBernese GNSS Softwareturn11search2, and urlGipsyXturn11search3, together with primary papers and public abstracts where official marketing pages did not provide meaningful algorithmic detail. citeturn10search0turn10search4turn10search5turn10search6turn13search19turn13search6turn11search2turn11search3turn28academia42turn11search30turn11search24 - -## FocusPOD and the adjacent GMV software stack - -GMV’s official FocusPOD product page describes a proprietary “end-to-end solution for Precise Orbit Determination and Geodesy,” powered by GMV MAORI, with high-precision LEO orbit and clock determination from GNSS, built-in quality control, SLR processing, GNSS sensor-performance analysis, and simulation support. GMV’s brochure and 2026 geodesy abstract extend that public picture: FocusPOD is written in modern C++ and Python, is library-oriented, separates data from algorithms, uses a centralized data model, supports multi-use-case operation from analyst workflows to machine-to-machine interfaces, and now publicly claims GNSS, DORIS, SLR, and VLBI processing plus ongoing work on normal-equation stacking and GNSS network processing. GMV’s license agreement also makes clear that the commercial product is licensed as proprietary software in executable form, not an open-source library. citeturn7search1turn24view1turn25view0turn6search1 - -urlFocusPOD official pageturn6search0 - -urlFocusPOD brochureturn6search2 - -The capability envelope that shows up consistently across official GMV product material, brochure pages, and public conference abstracts is broader than “GNSS POD” in the narrow sense. GMV publicly documents: state-of-the-art geophysical models and conventions; weighted least squares and extended Kalman filtering; ambiguity handling; estimation of orbital, geophysical, and observation parameters; interfaces for RINEX, SP3, SINEX, ORBEX, vgosDB, CRD, CPF, and ANTEX; multi-technique processing; and a QC workflow that includes residual analysis, combined solutions, spectral error analysis, and sensor assessment. GMV also publicly associates FocusPOD with Copernicus operations, Galileo system test-bed simulation, Galileo Reference Centre VLBI processing, and LEO-PNT validation work. citeturn24view1turn6search0turn25view0 - -| Capability family | What official GMV sources publicly show | Why it matters for Siderust parity | -|---|---|---| -| Core estimation | Weighted least squares and extended Kalman filtering; orbital, geophysical, GNSS, DORIS, and VLBI parameter estimation. citeturn24view1 | Siderust needs a dedicated estimator layer, not only propagators and transforms. | -| Measurement techniques | GNSS, SLR, DORIS, and VLBI are all publicly documented, with VLBI recently added in the 2026 abstract. citeturn24view1turn25view0 | A GNSS-only first release is viable, but real parity requires a multi-technique architecture from the start. | -| Standards and products | RINEX, SP3, SINEX, ORBEX, vgosDB, CRD, CPF, ANTEX are explicitly listed. citeturn24view1 | Product I/O is a first-class competitive feature, not a side task. | -| QC and analysis | Combined solutions, residual analysis, time-series comparisons, geographic error plotting, spectral error analysis, GNSS sensor performance. citeturn6search0turn24view1 | POD parity requires a QC/reporting subsystem, not only a solver. | -| Simulation | Orbit, clock, attitude, and observation simulation are explicitly public capabilities. citeturn6search0turn24view1 | Siderust should treat simulation as part of validation and mission design, not an external script. | -| Performance | Public claim: <5 cm 3D RMS in <5 min, <2 cm in one day, <1 cm in three weeks for the published Copernicus use case. citeturn23view0turn24view1 | These numbers are the clearest public parity targets available. | - -FocusPOD also benefits from being adjacent to the wider GMV stack rather than living alone. Official GMV sources describe FocusSuite as a cloud-native, web-based, full-lifecycle flight-dynamics suite with Python/SOL automation, REST API, Python SDK, OAuth2/OpenID integration, database-driven architecture, parallel processing, and mission products across GEO, LEOP, LEO, and constellation operations. The white paper shows a layered architecture with GUI/API, process management, data management, event logging, and computation layers; the product page adds batch, sequential, and Kalman orbit-determination algorithms and integrations with mission-control systems such as GMV’s Hifly, ESA SCOS2000, and Kratos EPOCH. In other words, GMV’s public posture is architectural as much as algorithmic. citeturn20view0turn20view2turn20view4turn24view2turn24view3turn24view4 - -```mermaid -flowchart LR - UI[Web UI / Analyst Tools] --> API[REST API / Python SDK] - API --> PM[Process Management] - PM --> DM[Data Management] - PM --> EV[Events / Logs] - PM --> COMP[Computation Layer] - - COMP --> GEO[GEO / station keeping] - COMP --> LEOP[LEOP / transfer] - COMP --> LEO[LEO / POD] - COMP --> CN[Constellation ops] - - LEO --> PRE[Pre-processing] - LEO --> OD[Orbit determination] - LEO --> PRED[Orbit prediction] - LEO --> PROD[Product generation] -``` - -For Siderust, that implies a sharp design conclusion: **competing with FocusPOD alone requires estimation parity; competing with GMV in practice requires a separable service/API/runtime layer that can later plug into mission-control and monitoring systems.** That is why the recommended implementation below separates `siderust` the scientific kernel from a new `spod` workspace for observations, estimation, QC, and services. citeturn24view2turn24view3turn20view2turn20view4 - -## Competing POD and flight-dynamics products - -The current comparison set falls into three groups. First are commercial COTS products with mature operations tooling, such as ODTK and FreeFlyer. Second are open-source flight-dynamics frameworks such as Orekit, GMAT, Tudat, and GROOPS. Third are research or agency-grade licensed tools such as Bernese and GipsyX. The key market pattern is that only a few products publicly present a complete “operations + estimation + QC + file standards + service architecture” story. GMV FocusPOD/FocusSuite, Ansys ODTK, and FreeFlyer are closest to that complete picture in public materials. The open-source tools are strong but more framework-like, leaving more integration work to the adopter. citeturn10search0turn10search4turn10search5turn10search6turn13search19turn13search6turn11search2turn11search3 - -| Product | Category / license | Publicly evidenced capabilities | Public maturity / accuracy evidence | Relative to FocusPOD | -|---|---|---|---|---| -| entity["software","FocusPOD","GMV precise orbit determination and geodesy software"] | Proprietary GMV product; executable-form commercial licensing. citeturn6search1turn7search1 | GNSS, SLR, DORIS, VLBI; QC; simulation; standard geodetic file interfaces; multi-use-case library architecture. citeturn24view1turn25view0 | Public GMV claim: <5 cm 3D RMS in <5 min, <2 cm in one day, <1 cm in three weeks; operational Copernicus use. citeturn23view0turn6search0 | Benchmark target. | -| urlAnsys ODTKturn10search0 | Commercial proprietary. citeturn10search0 | 100+ measurement models, realistic covariance, traditional and nontraditional measurements, sequential filter and smoother, raw GNSS support. citeturn10search0turn10search25turn14search6 | Strong commercial maturity; no single public product-wide cm figure located in the official pages reviewed. citeturn10search0turn10search28 | Closest commercial peer on estimation depth and covariance realism; less public evidence than GMV on multi-technique geodesy breadth. | -| urlFreeFlyerhttps://ai-solutions.com/freeflyer-astrodynamic-software/ | Commercial proprietary, ITAR-free marketing position. citeturn10search4 | Batch LS, EKF, SRIF, UKF, measurement simulation, tracking-data editing, thrust-event estimation, scripting, full mission lifecycle. citeturn14search4turn14search7turn14search11turn14search15 | Heritage on 250+ missions per official public material; no single public product-wide POD accuracy claim located in the official material reviewed. citeturn10search4 | Very strong operations framework; public geodesy-specific depth appears less explicit than FocusPOD. | -| urlOrekitturn10search5 | Open source, Apache 2.0. citeturn12search1turn12search9 | Low-level space-dynamics library; orbit-determination framework with batch least squares, multiple propagators, tutorials for sequential methods. citeturn10search2turn14search2turn14search12 | Mature community library with broad industrial adoption; no product-wide public accuracy claim located. citeturn10search5turn10search34 | Strongest open-source framework analogue to the future Siderust kernel; weakest on turnkey ops/QC UX unless integrator builds it. | -| urlGMATturn10search6 | Open source, Apache 2.0. citeturn27search3turn27search9 | Mission design and navigation, operational orbit determination, batch and EKFS, measurement simulation, rich scripting and GUI. citeturn27search3turn27search4turn27search7turn27search12 | Strong mission-operations heritage; public docs cite operational use for SOHO, ACE, Wind, SDO, and TESS-related work. citeturn27search1 | Strong open alternative for mission/OD workflows; less obviously positioned as a modern multi-technique space-geodesy platform than FocusPOD. | -| urlTudatturn13search19 | Open source, BSD-style permissive. citeturn13search19turn13search3 | Numerical propagation and estimation for spacecraft and celestial bodies; real tracking-data estimation in newer work. citeturn11search0turn13search7turn11search24 | Recent published examples report post-fit Doppler residuals of 1–5 mHz and meter-level orbit differences in representative deep-space cases. citeturn11search24 | Excellent research platform; less publicly positioned than FocusPOD for operational geodesy pipelines and standard operational products. | -| urlGROOPSturn13search6 | Open source, GPLv3. citeturn13search6turn13search18 | Gravity-field recovery, GNSS processing, time-series statistics, LEO orbit determination, GNSS constellation and station-network analysis. citeturn11search1turn11search9 | Strong geodesy relevance; no simple product-wide public mission-operations accuracy figure located. citeturn11search1 | Very relevant to the geodesy side of FocusPOD; less of an end-to-end operations product. | -| urlBernese GNSS Softwareturn11search2 | Commercial / research licensing. citeturn13search0turn13search8 | High-precision multi-GNSS processing; used by CODE; LEO POD based on GPS and SLR validation support. citeturn11search2turn11search14 | Representative literature reports dynamic GPS-based LEO POD with about 1 cm 3D RMS in reduced-dynamic mode for studied cases. citeturn11search30 | Strong benchmark for precise GNSS/LEO POD, but narrower as a full mission-ops product. | -| urlGipsyXturn11search3 | Licensed JPL software, not open-source. citeturn13search1turn13search13 | GNSS/SLR/DORIS with Kalman filtering, real-time and post-processing, geodetic parameter estimation, ambiguity resolution. citeturn11search11turn28academia42 | Published performance includes <5 cm URE in real-time OCX/GDGPS contexts and 2 cm RMS in precision post-processing, plus GRACE relative clock alignment at 20 ps. citeturn28academia42 | Probably the closest published research/operations analogue to FocusPOD’s multi-technique geodesy ambition. | - -Two conclusions matter most for Siderust. First, the commercial market wins on **workflow completeness**: APIs, GUI, automation, products, and quality reporting. Second, the highest-end geodesy tools win on **multi-technique estimation and combination**, not on orbital propagation alone. That means a Siderust competitor that only reaches “Orekit-like astrodynamics plus a least-squares fitter” will still fall short of FocusPOD-class parity. True parity requires coupling an `Orekit/Tudat`-style kernel with `GipsyX/GROOPS/Bernese`-style estimation depth and then wrapping it in an `ODTK/FreeFlyer/FocusSuite`-style operational surface. citeturn10search0turn10search4turn10search5turn11search1turn28academia42 - -## Siderust org inventory and the current gap to FocusPOD - -Cross-checking the public Siderust organization with the repo tree you provided shows a coherent layered ecosystem already exists. The public organization describes `qtty` as a quantity/dimensional-analysis crate, `affn` as a strongly typed affine/frame kernel, `tempoch` as a high-precision time crate, and `siderust` as a reference ephemeris and orbit-analysis library. The public `siderust` README also documents pluggable ephemeris backends, strong frame/center typing, altitude APIs, observatories, targets, `ffi`, and a roadmap that still lists batch orbit-determination helpers as future work. citeturn16search2turn15view0turn15view1 - -| Crate / project | Present role in the org | Why it is strategically important for POD | Main gap versus FocusPOD | -|---|---|---|---| -| `qtty` | Strongly typed physical quantities, units, FFI support, and broad unit catalog, with public no_std support in subcrates. citeturn16search2turn15view0 | Ideal foundation for dimensional safety in force, observation, covariance, and product I/O code. | Needs more geodesy/POD-specific quantity conventions and serialization schemas. | -| `tempoch` | Time scales, tagged times, EOP data infrastructure, generated time data, updater crates. | Exact time-scale conversions are non-negotiable for POD and geodesy. | Needs first-class integration with OD arcs, measurement epochs, leap/EOP provenance, and operational product timestamps. | -| `affn` | Compile-time-safe centers, frames, positions, directions, vectors, conics. | Excellent basis for typed state vectors, frame transforms, and covariance transport. | Still lacks first-class RTN/RIC covariance and OD-state abstractions in the public README. citeturn15view3 | -| `cheby` | Approximation, interpolation, calculus, spectral tools, binary/serde I/O, piecewise tables. | Very strong for ephemerides, force-model tables, and deterministic replay/compression of reference curves. | Not yet attached to a POD estimator or normal-equation workflow. | -| `siderust` | Astronomy, time, coordinates, ephemerides, atmosphere, bodies, targets, FFI. | It already looks like the correct physics kernel for a future POD stack. | Publicly still missing first-class batch OD helpers, sequential OD, covariance transport, observation modeling, and geodetic product I/O. citeturn15view1turn15view3 | -| `NSB` | Night-sky brightness and atmosphere/radiometry modeling. | Orthogonal to POD, but useful for Earth-observation mission analysis and optical planning. | Should stay separate from POD core to avoid scope dilution. | - -The most important architectural fact is that Siderust already has the **kernel pieces** but not the **POD assembly**. Your tree shows ephemerides, EOP data management, atmosphere models, coordinate transforms, targets, interpolation, FFI, and typed units; what it does not yet show is a POD-specific workspace with observation traits, residual pipelines, Jacobian infrastructure, estimator interfaces, ambiguity resolution, standard geodetic product codecs, covariance products, and job-level orchestration. That is exactly the seam where a `spod` specialization should sit. citeturn15view0turn15view1turn24view1turn25view0 - -```mermaid -flowchart TD - qtty[qtty] - tempoch[tempoch] - affn[affn] - cheby[cheby] - siderust[siderust] - NSB[NSB] - - qtty --> siderust - tempoch --> siderust - affn --> siderust - cheby --> siderust - - qtty --> NSB - tempoch --> NSB - affn --> NSB - cheby --> NSB - siderust --> NSB - - siderust --> podcore[siderust-pod-core] - qtty --> podcore - tempoch --> podcore - affn --> podcore - cheby --> podcore - - podcore --> podobs[siderust-pod-observations] - podcore --> podest[siderust-pod-estimation] - podcore --> podio[siderust-pod-io] - podcore --> podqc[siderust-pod-qc] - podcore --> podsvc[siderust-pod-service] - podsvc --> podcli[spod] - podsvc --> podapi[spod-api] -``` - -The gap to FocusPOD can therefore be described precisely: - -- **Already strong in Siderust:** compile-time-safe units and frames; time systems and EOP foundations; high-precision ephemerides; interpolation; physical modeling scaffolding; FFI surface; validation culture. citeturn15view0turn16search2 -- **Partially present:** satellite and atmosphere modeling, data managers, tables, targets, coordinate transforms, observatories, Chebyshev/data plumbing. citeturn15view0 -- **Not yet first-class:** batch LSQ/SRIF/EKF engines; measurement-model abstraction; ambiguity resolution; standard POD/geodesy file I/O; residual and QC tooling; station and troposphere estimation; SLR/DORIS/VLBI support; operational product generation; service APIs; cloud/distributed job orchestration; analyst UI. citeturn15view1turn15view3turn24view1turn20view2 - -That is why the right competitive move is **crate specialization**, not monolith expansion. Let `siderust` remain the reusable astrodynamics/scientific core, and let a new dedicated project absorb the mission-specific operational mass. That mirrors both FocusPOD’s public library-centric design and FocusSuite’s public service-oriented layering. citeturn22search0turn25view0turn24view2turn24view3 - -## Requirements and test specifications - -The recommended architecture is a two-level plan. The **existing crates** remain the base scientific substrate. Above them, a **new POD workspace** should carry every feature whose purpose is estimation, operational product handling, QC, or service orchestration. This keeps Siderust competitive against both open-source libraries and commercial operational suites: the kernel stays elegant; the product stack becomes opinionated. citeturn15view0turn24view2turn24view3 - -```mermaid -flowchart LR - subgraph Kernel - Q[qtty] - T[tempoch] - A[affn] - C[cheby] - S[siderust] - end - - subgraph POD_Workspace - CORE[siderust-pod-core] - OBS[siderust-pod-observations] - EST[siderust-pod-estimation] - IO[siderust-pod-io] - QC[siderust-pod-qc] - GEO[spod-geodesy] - SVC[siderust-pod-service] - CLI[spod] - end - - Q --> CORE - T --> CORE - A --> CORE - C --> CORE - S --> CORE - - CORE --> OBS - CORE --> EST - CORE --> IO - CORE --> QC - CORE --> GEO - CORE --> SVC - SVC --> CLI -``` - -### High-level requirements - -| ID | High-level shall statement | Owner crate / project | Priority | Complexity | -|---|---|---|---|---| -| HR-A | The Siderust ecosystem shall provide a dedicated POD core that represents states, force-model settings, estimation parameters, covariances, and provenance as typed Rust structures with deterministic serialization. | `siderust-pod-core` + `qtty` + `tempoch` + `affn` | P0 | Medium | -| HR-B | The ecosystem shall support GNSS-based LEO POD with batch least squares and sequential EKF processing, including analytic partial derivatives and configurable solve-for sets. | `siderust-pod-estimation` + `siderust-pod-observations` | P0 | High | -| HR-C | The ecosystem shall ingest and emit the minimum operational standards needed for core POD workflows, starting with RINEX, SP3, ANTEX, EOP products, OEM/OPM, and SINEX-like solution outputs. | `siderust-pod-io` + `tempoch` + `cheby` | P0 | High | -| HR-D | The ecosystem shall provide residual analytics, orbit-comparison tooling, covariance export, and machine-readable QC reports so that every estimation run is auditable. | `siderust-pod-qc` | P0 | Medium | -| HR-E | The ecosystem shall support SLR as both a validation technique and an estimated measurement source, including CRD/CPF parsing and station/range-bias handling. | `siderust-pod-observations` + `siderust-pod-io` | P1 | High | -| HR-F | The ecosystem shall support DORIS processing and parameter estimation with mission-appropriate frequency/bias handling. | `siderust-pod-observations` + `siderust-pod-estimation` | P1 | High | -| HR-G | The ecosystem shall estimate mission-critical dynamical nuisance parameters, including drag, SRP scale factors, empirical accelerations, and maneuver parameters. | `siderust-pod-core` + `siderust-pod-estimation` + `siderust` | P1 | High | -| HR-H | The ecosystem shall support reproducible simulation of orbits, clocks, attitudes, and measurements with configurable noise and truth-vs-estimate comparisons. | `siderust-pod-core` + `siderust-pod-qc` | P1 | Medium | -| HR-I | The ecosystem shall expose a job-oriented CLI and service API for batch execution, result retrieval, and provenance tracking. | `siderust-pod-service` + `spod` | P1 | Medium | -| HR-J | The ecosystem shall add multi-technique geodesy features, including VLBI-ready abstractions, normal-equation stacking, and GNSS network processing, without breaking the GNSS LEO POD path. | `spod-geodesy` | P2 | High | -| HR-K | The ecosystem shall provide first-class RTN/RIC covariance transport and frame-aware uncertainty products. | `affn` + `siderust-pod-core` | P0 | Medium | -| HR-L | The ecosystem shall remain usable as a library and as a service, so the same computation kernels are shared between interactive analysis, automation, and distributed operations. | Whole workspace | P0 | Medium | - -### Low-level requirements - -| ID | Low-level shall statement | Owner | Priority | Complexity | -|---|---|---|---|---| -| LR-A | `tempoch` shall expose POD-safe conversions among UTC, TAI, TT, TCG, TDB, and mission epochs, with explicit EOP/leap-second provenance attached to every run. | `tempoch` | P0 | Medium | -| LR-B | `affn` and `siderust` shall expose a typed OD state representation carrying frame, center, and covariance together, with compile-time prevention of mixed-frame residuals. | `affn` + `siderust-pod-core` | P0 | Medium | -| LR-C | Force models shall expose acceleration and state/parameter Jacobians through one trait so the same models work in propagators, batch LSQ, and EKF. | `siderust-pod-core` | P0 | High | -| LR-D | Observation models shall expose predicted measurements, prefit residuals, and analytic Jacobians for GNSS pseudorange and carrier phase at minimum. | `siderust-pod-observations` | P0 | High | -| LR-E | The batch estimator shall support state, clock, bias, ambiguity, and selected dynamical parameter estimation in one solve. | `siderust-pod-estimation` | P0 | High | -| LR-F | The sequential estimator shall support configurable process noise, time updates, measurement updates, and optional fixed-interval smoothing. | `siderust-pod-estimation` | P0 | High | -| LR-G | The QC layer shall emit structured outputs for residual statistics, rejected observations, parameter sigmas, covariance consistency, and orbit-difference metrics. | `siderust-pod-qc` | P0 | Medium | -| LR-H | The I/O layer shall preserve forward compatibility with OMM/OEM/OPM-style ecosystems and with current public GP/OMM data practices documented by CelesTrak. | `siderust-pod-io` | P1 | Medium | -| LR-I | The product layer shall emit deterministic filenames, metadata manifests, checksums, and run digests so any product can be regenerated exactly. | `siderust-pod-service` + `siderust-pod-io` | P1 | Medium | -| LR-J | The simulation layer shall support truth generation, noise injection, bias injection, outage injection, and Monte Carlo ensembles. | `siderust-pod-core` + `siderust-pod-qc` | P1 | Medium | -| LR-K | The service layer shall support headless execution from YAML/TOML/JSON configs as well as a stable Rust API. | `siderust-pod-service` + `spod` | P1 | Medium | -| LR-L | The geodesy layer shall support NEQ accumulation/stacking, station coordinates, EOP estimation, and troposphere-related state components. | `spod-geodesy` | P2 | High | - -### Concrete test cases - -Public fixtures should primarily come from entity["organization","CelesTrak","orbital data service"] for OMM/TLE cases, the entity["organization","Copernicus Programme","European Union Earth observation programme"] POD public documentation for Sentinel product formats, the entity["organization","International GNSS Service","geodetic GNSS consortium"] ecosystem for RINEX/SP3/ANTEX-compatible cases, the entity["organization","International Laser Ranging Service","satellite laser ranging service"] for CRD/CPF-style SLR cases, and the entity["organization","International DORIS Service","DORIS geodesy service"] ecosystem for DORIS examples. CelesTrak’s current documentation is especially important because it explicitly documents the active OMM transition beyond legacy TLE. citeturn8search1turn17search7turn17search12 - -| Requirement | Unit test | Integration test | Regression test | Benchmark | Pass criteria | Suggested fixtures | -|---|---|---|---|---|---|---| -| HR-A / LR-A / LR-B | Time-scale round trips; frame-tagged state serialization; covariance-frame transforms. | Load config → propagate tagged state → save manifest → reload and compare bitwise metadata. | Re-run same job twice and compare hashes of products and manifest. | Serialization/deserialization throughput. | No time-scale round-trip error beyond 1 ns-equivalent for supported representations; manifest deterministic across runs; mixed-frame operations fail at compile time or explicit runtime validation. | Synthetic epochs; public EOP/leap inputs; current Siderust typed-frame tests. | -| HR-B / LR-C / LR-D / LR-E | Analytic Jacobians versus finite-difference Jacobians for GNSS models. | Full synthetic GNSS LEO batch POD recovering truth from perturbed initial state and noisy measurements. | Reproduce a frozen GNSS fixture arc and verify unchanged parameter estimates within tolerance after code changes. | One-satellite 24 h batch solve. | Jacobian relative error < 1e-6 on test points; synthetic 24 h 3D RMS < 2 cm; solution parameters fall within 3σ of truth; no residual-bias drift after convergence. | Synthetic truth generator; public RINEX/SP3/ANTEX-style fixtures; Copernicus product formats for output structure. | -| HR-B / LR-F | EKF measurement-update algebra and process-noise propagation. | Sequential EKF on same synthetic arc as batch solve. | Fixed fixture where EKF and batch LSQ should agree on a stable arc. | NRT-like sequential processing latency. | EKF-vs-batch 3D RMS difference < 2 cm on stable arcs; covariance remains positive semi-definite; no filter divergence on gap/restart cases. | Synthetic GNSS arc with outages and clock events. | -| HR-C / LR-H | Parser round-trip tests for RINEX/SP3/ANTEX/OEM/OPM/SINEX-like metadata. | Ingest public files → estimate → emit products → re-read emitted products in independent reader. | Golden files for every supported standard version. | Large-file parse rate and memory footprint. | Round-trip preserves mandatory semantics; parsed epochs/counts/checksums match expected values; malformed files fail with typed diagnostics. | CelesTrak GP/OMM samples; Copernicus POD format docs; public GNSS sample files. | -| HR-D / LR-G | Residual histogram, sigma-edit, and report schema unit tests. | End-to-end QC report generation from a solved arc. | Freeze QC JSON and plot summaries for known fixture. | Residual pipeline throughput at constellation scale. | QC JSON validates against schema; orbit-difference metrics reproduced within tolerance; outlier counts stable on fixed fixtures. | Synthetic plus one public frozen fixture arc. | -| HR-E | SLR range model and station-bias Jacobian tests. | SLR-only and GNSS+SLR combined fit on synthetic LEO orbit. | Public SLR validation case with frozen residual summary. | CRD/CPF ingest + batch validation runtime. | Synthetic SLR-only orbit recovery meets predeclared cm-level thresholds; combined GNSS+SLR solution shall reduce external validation residuals versus GNSS-only baseline. | ILRS-style CRD/CPF public examples; Copernicus/Sentinel SLR-compatible public orbit cases where available. | -| HR-F | DORIS observable reconstruction and bias-state unit tests. | DORIS-only and combined DORIS+GNSS synthetic estimation. | Frozen DORIS fixture with stable pass-level bias outputs. | DORIS pass pre-processing runtime. | Pass grouping and bias estimation stable; combined solution improves or matches truth relative to single-technique baseline on synthetic fixtures. | IDS-style DORIS examples; synthetic pass generator. | -| HR-G | Drag/SRP/empirical-acceleration partial derivative tests. | Solve-for Cd/Cr/empirical terms on synthetic disturbed arc. | Frozen nuisance-parameter estimates for fixture scenes. | Propagation cost with and without partials. | Estimated nuisance parameters recover truth within 3σ on synthetic cases; enabling corresponding solve-fors reduces residual systematics. | Synthetic truth with known Cd/Cr and empirical accelerations. | -| HR-H / LR-J | Noise injector, outage injector, and Monte Carlo sampler tests. | Truth → simulated measurements → solve → compare recovery statistics. | Freeze Monte Carlo summary metrics with seeded RNG. | 100-run Monte Carlo throughput. | Monte Carlo coverage close to theoretical expectation; seeded runs are exactly reproducible. | Pure synthetic. | -| HR-I / LR-I / LR-K | CLI config parsing and manifest schema tests. | Run job via CLI, API, and file-based batch mode and compare outputs. | Backward-compatibility tests for config versions. | Multi-job queue throughput. | CLI/API parity; deterministic run IDs; provenance complete enough to replay job exactly. | Synthetic plus frozen public fixture jobs. | -| HR-J / LR-L | NEQ build/stack algebra and station/EOP state tests. | Multi-arc stacking over synthetic station + satellite network. | Frozen stacked-solution summaries. | NEQ build/stack throughput and memory. | Stacked and monolithic solutions agree within tolerance on synthetic truth; station/EOP states consistent across repeated runs. | Synthetic geodesy network first; public multi-technique fixtures later. | -| HR-K | RTN/RIC frame basis and covariance-transform unit tests. | Compare Cartesian covariance → RTN/RIC → Cartesian round trips in solved arcs. | Freeze uncertainty products for selected reference cases. | Covariance transport runtime. | Round-trip covariance error below numeric tolerance; positive semi-definiteness preserved. | Synthetic state/covariance sets; current Siderust orbital-frame tests extended. | -| HR-L | Shared-kernel API contract tests between library and service modes. | Same core solve invoked from Rust API and service worker. | Semver/API compatibility suite. | None beyond service overhead tracking. | Numerical equality to within machine precision for identical configs and fixtures. | Synthetic plus frozen batch jobs. | - -### Recommended command surface and example configuration - -The following example is **proposed**, not a description of current Siderust behavior. It is meant to force the architecture to stay executable, testable, and automation-friendly. - -```yaml -# configs/sentinel3a_gnss_batch.yaml -mission: - id: sentinel3a-demo - mode: batch_lsq - satellite_model: sentinel3a_boxwing - -inputs: - rinex_obs: fixtures/gnss/S3A/*.rnx - sp3_orbits: fixtures/gnss/igs/*.sp3 - clocks: fixtures/gnss/igs/*.clk - antex: fixtures/gnss/igs/igs20.atx - eop: fixtures/time/finals2000A.all - attitude: fixtures/mission/S3A/attitude.aem - -force_models: - gravity: - model: EGM2008 - degree: 120 - order: 120 - tides: - solid_earth: true - ocean: FES2014 - third_body: [Sun, Moon] - atmosphere: - density_model: JB2008 - radiation: - srp: true - albedo_ir: CERES - relativity: true - -estimation: - solve_for: - - state - - receiver_clock - - ambiguities - - cd - - cr - ambiguity_resolution: - mode: single_receiver_integer - outlier_rejection: - method: huber - sigma: 5.0 - -outputs: - orbit_sp3: out/orbit.sp3 - solution_sinex: out/solution.snx - qc_report: out/qc.json - provenance: out/run.manifest.json -``` - -A credible test and developer command surface would look like this: - -```bash -cargo test -p qtty -p tempoch -p affn -p cheby -p siderust -cargo test -p siderust-pod-core -p siderust-pod-observations -p siderust-pod-estimation -cargo test -p siderust-pod-io -p siderust-pod-qc -- --nocapture -cargo bench -p siderust-pod-estimation gnss_batch_lsq_24h -cargo run -p spod -- estimate configs/sentinel3a_gnss_batch.yaml -cargo run -p spod -- qc out/run.manifest.json -``` - -## Implementation roadmap - -The roadmap below is designed to reach **core FocusPOD parity first**, then approach **broader GMV-stack competitiveness**. It intentionally front-loads the parts that are hardest to retrofit later: observation abstractions, analytic partials, deterministic provenance, and shared kernels between library and service modes. That follows the architecture publicized for FocusPOD and the service layering publicized for FocusSuite. citeturn25view0turn24view2turn24view3 - -| Phase | Window | Main deliverables | Acceptance criteria | -|---|---|---|---| -| Foundation | Months 0–2 | Create `siderust-pod-core`, `-observations`, `-estimation`, `-io`, `-qc`, `-service`; define typed OD state, covariance, provenance, and config schemas. | One synthetic GNSS batch pipeline runs end-to-end through CLI and Rust API; deterministic manifests; compile-time frame/type safety preserved. | -| GNSS parity core | Months 2–5 | GNSS observation models, analytic partials, batch LSQ, EKF, RINEX/SP3/ANTEX/EOP ingestion, QC JSON. | Synthetic 24 h GNSS LEO batch POD reaches <2 cm 3D RMS; EKF agrees with batch within <2 cm on stable arcs; parser regression suite green. | -| Operational GNSS POD | Months 5–8 | Product generation, residual analytics, covariance export, ambiguity-resolution support, service jobs, seeded Monte Carlo simulation. | Reference fixture pipeline becomes reproducible across releases; benchmarked NRT-like run achieves internal timeliness target; QC reports stable and schema-valid. | -| Multi-technique expansion | Months 8–12 | SLR support, DORIS support, nuisance-parameter solving, better satellite macro-models, RTN/RIC covariance. | Combined GNSS+SLR and GNSS+DORIS synthetic cases outperform single-technique baselines; external validation path exists for public fixtures. | -| Geodesy and global solutions | Months 12–18 | VLBI-ready abstractions, NEQ stacking, station/EOP/troposphere states, GNSS network processing hooks. | Stacked synthetic solutions reproduce monolithic truth; multi-technique state blocks and metadata stable across repeated runs. | -| Productization | Months 18+ | Web UI, dashboards, REST auth, distributed workers, report bundles, optional cloud deployment patterns. | Service can execute multiple jobs concurrently, expose auditable runs, and integrate cleanly with external monitoring/control systems. | - -The operational parity bar should be explicit, not vague. The internal milestone targets should be: - -- **Milestone red:** stable GNSS batch POD with deterministic manifests, standards I/O, and QC. -- **Milestone amber:** GNSS batch + EKF NRT path with public fixture regressions and covariance products. -- **Milestone green:** multi-technique GNSS/SLR/DORIS support with operational report generation. -- **Milestone black:** VLBI, NEQ stacking, and GNSS network processing sufficient to claim geodesy-platform convergence with FocusPOD’s public posture. citeturn24view1turn25view0 - -At the level of ownership, the recommended split is: - -- Keep **`qtty`, `tempoch`, `affn`, `cheby`, and `siderust`** focused on reusable scientific kernels. -- Create **`siderust-pod-core`** for typed OD domain objects and force-model traits. -- Create **`siderust-pod-observations`** for GNSS/SLR/DORIS/VLBI observable models. -- Create **`siderust-pod-estimation`** for batch LSQ, SRIF later if desired, EKF, smoothing, and ambiguity workflows. -- Create **`siderust-pod-io`** for product standards. -- Create **`siderust-pod-qc`** for residuals, comparisons, and analyst outputs. -- Create **`siderust-pod-service`** and **`spod`** for orchestration. - -That mapping preserves current Siderust clarity while making the future product line legible to users, contributors, and integrators. It also prevents the main `siderust` crate from turning into a commercial-style application monolith. citeturn16search2turn15view0turn24view2turn24view3 - -## Open questions and limitations - -Public FocusPOD sources are strong enough to establish a credible parity target, but they do **not** disclose everything a full competitor would want: GMV’s public material does not expose full ICDs, solver internals, covariance-tuning rules, database schema, or exact production acceptance thresholds beyond the public performance claims and public architecture abstracts. The commercial license documents confirm proprietary status, but not detailed deployment or pricing structure. That means the requirement set above is best understood as a **high-confidence competitive reconstruction**, not a reverse-engineered duplicate specification. citeturn6search1turn24view1turn25view0 - -The Siderust inventory in this report uses two sources at once: the public GitHub organization and README material, and the repo tree you provided directly in chat. The public material confirms the overall direction and some explicit gaps, but it does not independently confirm every submodule and internal dependency listed in your tree. Where this report maps ownership to fine-grained modules, it is therefore using your tree as the local ground truth and the public GitHub/org pages as corroborating context. citeturn16search2turn15view0turn15view1turn15view3 - -Finally, public “accuracy claims” are uneven across the market. FocusPOD and GipsyX have unusually concrete public performance statements in the sources reviewed; Bernese has strong published-case evidence; many other products describe capability and maturity but do not publish a single vendor-wide number that is directly comparable. For those tools, this report explicitly distinguishes between **public feature evidence** and **public mission-wide accuracy claims**. citeturn23view0turn28academia42turn11search30turn10search0turn10search4turn10search5turn10search6 \ No newline at end of file diff --git a/docs/design/spod_detailed_design_document.md b/docs/design/spod_detailed_design_document.md deleted file mode 100644 index 67722d2..0000000 --- a/docs/design/spod_detailed_design_document.md +++ /dev/null @@ -1,1823 +0,0 @@ -# spod Detailed Design Document - -## 0. Document status - -**Document type:** Detailed design document -**Target project:** spod / satellite precision-orbit-determination stack -**Primary goal:** Define a concrete architecture and implementation plan for building a FocusPOD-class, Rust-native POD and geodesy product on top of the existing Siderust ecosystem. -**Status:** Draft v0.1 -**Audience:** Siderust maintainers, Rust engineers, astrodynamics/POD engineers, validation engineers, and future product owners. - -This document assumes the current Siderust organization contains these foundational projects: - -- `qtty`: strongly typed physical quantities and units. -- `tempoch`: high-precision time scales, epochs, intervals, EOP and generated time data. -- `affn`: typed frames, centers, affine geometry, Cartesian/spherical/ellipsoidal positions, conics, rotations, translations, and frame/center derivation. -- `cheby`: Chebyshev approximation, interpolation, calculus, quadrature, piecewise series, spectral tools, and compact trajectory-like representations. -- `siderust`: astronomy, ephemerides, Earth orientation, coordinate transforms, observatories, solar/lunar/stellar calculations, targets, data/provenance, spectra/tables, and FFI. -- `NSB`: a domain application proving the model of building specialized applications beside the core Siderust crates. - -The design conclusion is that POD must be implemented as a **new sibling product/workspace**, not by expanding the main `siderust` crate into an operations monolith. - ---- - -## 1. Executive summary - -The Siderust ecosystem already has a strong scientific substrate: typed units, typed frames/centers, high-precision time handling, ephemerides, Earth orientation, Chebyshev interpolation, and astronomy/observability tools. What it lacks is the product layer required for FocusPOD-class competitiveness: standard POD/geodesy file ingestion, force-model orchestration, measurement modeling, partial derivatives, batch and sequential estimation, ambiguity handling, covariance products, QC reports, reproducible run manifests, and operational execution surfaces. - -The proposed design introduces a new product family: - -```text -siderust-pod-core -siderust-pod-dynamics -siderust-pod-io -siderust-pod-observations -siderust-pod-estimation -siderust-pod-qc -siderust-pod-products -siderust-pod-service -spod -``` - -The first competitive target is **GNSS-only LEO POD with float carrier ambiguities, batch weighted least squares, deterministic product generation, and QC artifacts**. The second target is **SLR-validated GNSS POD**. The third target is **near-real-time replay using EKF/sequential estimation**. DORIS, VLBI, GNSS network processing, normal-equation stacking, web UI, RBAC, and distributed operations are later phases. - -The central architecture rule is: - -```text -qtty = physical correctness -tempoch = temporal correctness -affn = geometric correctness -cheby = interpolation and spectral correctness -siderust = astronomical and ephemeris correctness -spod-* = POD, geodesy, estimation, products, QC, and operations -``` - ---- - -## 2. Goals and non-goals - -## 2.1 Goals - -### G-001 — Create a FocusPOD-class open/Rust-native POD stack - -The system shall provide a credible path toward modern precise orbit determination and space geodesy workflows, including GNSS, SLR, DORIS, and eventually VLBI. - -### G-002 — Preserve Siderust as a reusable scientific kernel - -The existing `siderust` crate shall remain focused on astronomy, ephemerides, time/frame integration, observatories, and physical models. POD workflow, file-format, estimator, and service complexity shall be placed in dedicated crates. - -### G-003 — Make POD runs reproducible by construction - -Every run shall generate a manifest containing input hashes, software versions, model choices, config hash, solver settings, and output product hashes. - -### G-004 — Make time, frame, center, and unit mistakes hard - -The design shall use `qtty`, `tempoch`, and `affn` to encode physical units, time scales, frames, and centers explicitly wherever feasible. - -### G-005 — Support both library and product use - -The same computation kernels shall be callable from Rust library APIs, CLI workflows, Python bindings later, and service/job APIs later. - -### G-006 — Build public validation credibility - -The system shall include a benchmark corpus, synthetic truth cases, regression tests, and public-data test paths for GNSS, SP3, ANTEX, EOP, OEM, and SLR formats. - ---- - -## 2.2 Non-goals for the first release - -The first release shall **not** attempt to provide: - -- Full GMV FocusPOD parity. -- Full FocusSuite-style mission operations. -- Web UI. -- OAuth2/OpenID/RBAC. -- Distributed Kubernetes operations. -- VLBI. -- DORIS. -- Full GNSS network processing. -- Normal-equation stacking. -- Integer ambiguity resolution. -- Collision avoidance / CAM workflows. -- General mission-control integration. - -These are later phases. The first release must prove the POD kernel and reproducibility discipline. - ---- - -## 3. Competitive baseline - -## 3.1 FocusPOD baseline - -A FocusPOD-class product publicly advertises or implies the following capability families: - -- GNSS-based LEO orbit and clock determination. -- SLR processing and validation. -- DORIS processing. -- VLBI processing. -- Weighted least squares and extended Kalman filtering. -- Standard geodesy/POD formats: RINEX, SP3, SINEX, ORBEX, vgosDB, CRD, CPF, ANTEX. -- Modern force and environmental models. -- Residual analysis. -- Combined orbit generation. -- Orbit/clock comparison. -- Geographic and spectral error analysis. -- Sensor-performance analysis. -- Simulation of orbit, clock, attitude, and observations. -- Cloud/service-oriented operation. - -## 3.2 Other competing products - -The wider competitive set includes: - -- Ansys ODTK: strong OD estimation, covariance realism, sequential filtering, many measurement models. -- FreeFlyer: mission design/operations, batch LSQ, EKF, SRIF, UKF, scripting, OD, simulation. -- Orekit: open-source Java astrodynamics and OD framework. -- GMAT: open-source mission analysis/navigation and OD tool. -- Tudat: open-source astrodynamics and estimation research platform. -- GROOPS: open geodesy, gravity-field recovery, GNSS and LEO OD workflows. -- Bernese GNSS Software: high-precision multi-GNSS/geodetic processing. -- GipsyX: JPL geodetic/POD processing with GNSS, SLR, DORIS, Kalman filtering, and ambiguity resolution. - -## 3.3 Strategic positioning for Siderust - -Siderust should not attempt to win by matching every operational feature immediately. The near-term differentiator should be: - -> A Rust-native, type-safe, reproducible, public-validation-driven POD kernel with clean architecture and transparent benchmark cases. - ---- - -## 4. Current Siderust ecosystem assessment - -## 4.1 Existing strengths - -### `qtty` - -Strengths: - -- Strongly typed dimensions and units. -- Rich physical quantity catalog. -- Serialization and FFI support. -- `no_std`/feature-conscious design. - -POD relevance: - -- Prevent unit mistakes in force models, observations, covariance, clocks, and products. - -Required extensions: - -- Carrier phase cycles. -- TEC / TECU. -- Clock bias and drift types. -- Area-to-mass ratio. -- Radiation pressure. -- Bias and scale-factor representation. - -### `tempoch` - -Strengths: - -- Time scales. -- Tagged times. -- Intervals. -- EOP infrastructure. -- Generated time data. -- Runtime/update tooling. - -POD relevance: - -- Exact time-scale handling is a core POD requirement. - -Required extensions: - -- GPS time and GNSS system time integration. -- POD run time-context provenance. -- Explicit EOP and leap-second dataset identity. -- Time-window iteration utilities for arcs and observations. - -### `affn` - -Strengths: - -- Typed frames and centers. -- Cartesian/spherical/ellipsoidal positions. -- Conics. -- Rotations, translations, isometries. -- Compile-time derivation support. - -POD relevance: - -- Provides the basis for safe orbit states, station positions, observation geometry, and covariance transforms. - -Required extensions: - -- Orbit-state geometry primitives. -- RTN/RIC, LVLH, VNC frames. -- Covariance metadata and transforms. -- State-vector ordering metadata. - -### `cheby` - -Strengths: - -- Chebyshev series. -- Piecewise interpolation. -- Derivatives and integrals. -- Spectral tools. -- Binary and serde I/O. - -POD relevance: - -- Precise orbit interpolation. -- Ephemeris interpolation. -- SP3-derived segment representation. -- Spectral QC. -- Efficient trajectory compression. - -Required extensions: - -- POD-oriented trajectory segment wrappers. -- Error-bounded interpolation for orbit products. -- Position/velocity consistency checks. - -### `siderust` - -Strengths: - -- Ephemerides: VSOP87, ELP2000, optional JPL DE backends. -- Earth rotation, EOP, precession, nutation, polar motion. -- Coordinate transforms. -- Observatories and topocentric calculations. -- Targets and trackable abstractions. -- Data/provenance infrastructure. -- FFI bindings. - -POD relevance: - -- Source of astronomical and Earth-orientation services for POD dynamics and observation modeling. - -Required extensions: - -- Public provider traits. -- Stable ephemeris and frame-transform interfaces. -- Avoid exposing downstream crates to private module paths. - ---- - -## 5. Target architecture - -## 5.1 Layered architecture - -```text -+------------------------------------------------------------------+ -| spod-ui | -| optional later web layer | -+------------------------------------------------------------------+ -| siderust-pod-service | -| jobs, configs, manifests, artifacts, API | -+------------------------------------------------------------------+ -| spod | spod-python | -+------------------------------------------------------------------+ -| siderust-pod-products | -| SP3, OEM, SINEX-like outputs, residual files | -+------------------------------------------------------------------+ -| siderust-pod-qc | siderust-pod-estimation | -| residuals, reports, plots | WLS, EKF, smoothing, covariances | -+------------------------------------------------------------------+ -| siderust-pod-observations | siderust-pod-io | -| GNSS, SLR, DORIS, VLBI models | RINEX, SP3, ANTEX, CRD... | -+------------------------------------------------------------------+ -| siderust-pod-dynamics | siderust-pod-core | -| forces, propagation, STM | states, params, context, config | -+------------------------------------------------------------------+ -| qtty | tempoch | affn | cheby | siderust | -+------------------------------------------------------------------+ -``` - -## 5.2 Dependency rules - -Hard dependency rules: - -```text -qtty, tempoch, affn, cheby shall not depend on siderust. -siderust may depend on qtty, tempoch, affn, and cheby. -spod-* may depend on qtty, tempoch, affn, cheby, and siderust. -siderust-pod-core shall not depend on siderust-pod-service or CLI crates. -siderust-pod-estimation shall not depend on file-format parsers. -siderust-pod-observations may depend on IO data structures only through canonical models. -siderust-pod-service may depend on all POD crates. -``` - -Forbidden: - -```text -siderust -> spod-* -qtty -> siderust -tempoch -> siderust -affn -> siderust -cheby -> siderust -estimation -> service -observations -> service -core -> service -``` - -## 5.3 Workspace layout - -```text -spod/ - Cargo.toml - crates/ - siderust-pod-core/ - siderust-pod-dynamics/ - siderust-pod-io/ - siderust-pod-observations/ - siderust-pod-estimation/ - siderust-pod-qc/ - siderust-pod-products/ - siderust-pod-service/ - spod/ - examples/ - configs/ - fixtures/ - docs/ - architecture/ - requirements/ - validation/ - formats/ - tests/ - e2e/ - regression/ - synthetic/ - benches/ -``` - ---- - -## 6. Crate designs - -## 6.1 `siderust-pod-core` - -### Purpose - -Own POD domain primitives that are not specific to GNSS, SLR, DORIS, VLBI, file formats, or service execution. - -### Responsibilities - -- OD state representation. -- Spacecraft physical properties. -- Estimation parameter taxonomy. -- Dynamic/observation context types. -- Arc definitions. -- Run configuration schema primitives. -- Provenance primitives. -- Dataset identifiers. -- Error taxonomy. -- Shared constants and conventions. - -### Key modules - -```text -src/ - lib.rs - arc.rs - config.rs - context.rs - covariance.rs - dataset.rs - error.rs - manifest.rs - parameter.rs - provenance.rs - spacecraft.rs - state.rs - traits.rs -``` - -### Core types - -```rust -pub struct OrbitState { - pub epoch: Epoch, - pub position: Position, - pub velocity: Velocity, -} - -pub struct SpacecraftState { - pub orbit: OrbitState, - pub mass: Option>, - pub attitude: Option>, - pub properties: Option>, -} - -pub struct ArcDefinition { - pub id: ArcId, - pub start: Epoch, - pub stop: Epoch, - pub step_hint: Option, -} - -pub enum ParameterKind { - InitialPosition, - InitialVelocity, - ReceiverClockBias, - ReceiverClockDrift, - CarrierAmbiguity, - DragScale, - SolarRadiationScale, - EmpiricalAcceleration, - StationPosition, - RangeBias, - TroposphereZenithDelay, -} - -pub struct RunManifest { - pub run_id: RunId, - pub config_hash: Sha256, - pub software: SoftwareManifest, - pub inputs: Vec, - pub outputs: Vec, - pub models: Vec, - pub estimation: Option, -} -``` - -### Design notes - -`OrbitState` should probably live in `siderust-pod-core`, while lower-level position, velocity, frame, and center types should come from `affn`. This avoids forcing `affn` to know what an OD arc, estimation parameter, or spacecraft model is. - ---- - -## 6.2 `siderust-pod-dynamics` - -### Purpose - -Own force models, propagation, variational equations, state transition matrices, and dynamic model composition. - -### Responsibilities - -- Force-model traits. -- Two-body, J2, high-degree gravity, third-body, drag, SRP. -- Tides and loading later. -- Maneuvers later. -- STM propagation. -- Variational equations. -- Propagator traits and integrator adapters. - -### Key modules - -```text -src/ - lib.rs - context.rs - forces/ - mod.rs - two_body.rs - j2.rs - spherical_harmonics.rs - third_body.rs - drag.rs - srp.rs - relativity.rs - empirical.rs - composite.rs - gravity/ - coefficients.rs - model.rs - normalization.rs - integrators/ - mod.rs - rk.rs - dop853.rs - propagation/ - mod.rs - propagator.rs - events.rs - variational/ - mod.rs - stm.rs - partials.rs - frames/ - rtn.rs - lvlh.rs - vnc.rs - error.rs -``` - -### Core traits - -```rust -pub trait ForceModel { - type Error; - - fn acceleration( - &self, - state: &SpacecraftState, - ctx: &DynamicsContext<'_, S>, - ) -> Result, Self::Error>; - - fn partials( - &self, - state: &SpacecraftState, - ctx: &DynamicsContext<'_, S>, - ) -> Result, Self::Error>; -} - -pub trait Propagator { - type Error; - - fn propagate( - &self, - initial: &SpacecraftState, - target: Epoch, - ctx: &DynamicsContext<'_, S>, - ) -> Result, Self::Error>; -} -``` - -### MVP force models - -POD MVP requires: - -- Two-body. -- J2. -- Third-body Sun/Moon using `siderust` ephemeris provider traits. -- High-degree gravity interface, even if only low-degree support is implemented initially. -- Simple atmospheric drag with density trait. -- Cannonball SRP. -- Relativistic correction if available within MVP scope. - -### Later force models - -- Solid Earth tides. -- Ocean tides. -- Pole tide. -- Atmospheric gravity/loading. -- Earth albedo and IR. -- Antenna power thrust. -- Macro-model SRP. -- Empirical accelerations. -- Impulsive and finite burns. - ---- - -## 6.3 `siderust-pod-io` - -### Purpose - -Own parsing and writing of external POD/geodesy/mission-analysis formats. - -### Responsibilities - -- Format version detection. -- Strict and permissive parse modes. -- Canonical typed records. -- Round-trip where possible. -- Structured diagnostics for malformed files. -- Provenance hashing. - -### Key modules - -```text -src/ - lib.rs - rinex/ - mod.rs - obs.rs - nav.rs - doris.rs - sp3/ - mod.rs - parser.rs - writer.rs - records.rs - antex/ - mod.rs - antenna.rs - parser.rs - sinex/ - mod.rs - station.rs - solution.rs - slr/ - mod.rs - crd.rs - cpf.rs - ccsds/ - mod.rs - oem.rs - opm.rs - tdm.rs - aem.rs - omm.rs - orbex/ - mod.rs - vgosdb/ - mod.rs - eop/ - mod.rs - error.rs -``` - -### MVP formats - -MVP-1: - -- SP3 read. -- SP3 write. -- RINEX GNSS OBS subset. -- RINEX GNSS NAV subset. -- ANTEX read. -- EOP bridge into `tempoch` context. -- OEM write. - -MVP-2: - -- SINEX station/solution subset. -- CRD read. -- CPF read. -- OEM read. - -Later: - -- ORBEX. -- RINEX DORIS. -- vgosDB. -- TDM, OPM, AEM, OMM. - -### Canonical record examples - -```rust -pub struct Sp3Record { - pub epoch: Epoch, - pub satellite: SpaceObjectId, - pub position: Position, - pub clock: Option>, - pub position_sigma: Option>, - pub clock_sigma: Option, -} - -pub struct RinexObservationEpoch { - pub epoch: Epoch, - pub receiver: ReceiverId, - pub observations: Vec>, -} - -pub struct GnssObservation { - pub satellite: GnssSatelliteId, - pub signal: GnssSignal, - pub observable: ObservableKind, - pub value: ObservationValue, - pub lli: Option, - pub snr: Option, -} -``` - ---- - -## 6.4 `siderust-pod-observations` - -### Purpose - -Own measurement models and corrections. - -### Responsibilities - -- GNSS code/carrier models. -- SLR range model. -- DORIS model later. -- VLBI model later. -- Observation residual construction. -- Analytic partials. -- Bias and nuisance parameter mapping. -- Measurement simulation. - -### Key modules - -```text -src/ - lib.rs - model.rs - corrections/ - mod.rs - relativity.rs - sagnac.rs - phase_windup.rs - antenna.rs - ionosphere.rs - troposphere.rs - gnss/ - mod.rs - constellation.rs - signal.rs - observable.rs - code.rs - carrier.rs - clock.rs - ambiguity.rs - cycle_slip.rs - slr/ - mod.rs - station.rs - range.rs - bias.rs - doris/ - mod.rs - vlbi/ - mod.rs - simulation/ - mod.rs - noise.rs - outage.rs - error.rs -``` - -### Core trait - -```rust -pub trait MeasurementModel { - type Error; - - fn predict( - &self, - state: &State, - params: &Params, - obs: &Obs, - ctx: &Ctx, - ) -> Result, Self::Error>; - - fn residual( - &self, - state: &State, - params: &Params, - obs: &Obs, - ctx: &Ctx, - ) -> Result, Self::Error>; - - fn linearize( - &self, - state: &State, - params: &Params, - obs: &Obs, - ctx: &Ctx, - ) -> Result, Self::Error>; -} -``` - -### GNSS MVP corrections - -- Satellite clock. -- Receiver clock. -- Sagnac correction. -- Relativistic correction. -- Antenna phase center. -- Phase wind-up if feasible in MVP. -- Ionosphere-free combinations later in MVP-2. -- DCB/ISB later. -- Troposphere for ground receivers; LEO receiver POD may not need ground troposphere unless processing ground network observations. - ---- - -## 6.5 `siderust-pod-estimation` - -### Purpose - -Own estimation algorithms and solver infrastructure. - -### Responsibilities - -- Parameter blocks. -- Residual vectors. -- Design matrices. -- Normal equations. -- Weighted least squares. -- Robust weighting/outlier rejection. -- Nonlinear iteration. -- Covariance extraction. -- EKF/sequential estimation. -- Smoothing. -- Multi-arc support. -- Normal-equation stacking later. - -### Key modules - -```text -src/ - lib.rs - parameter.rs - residual.rs - design_matrix.rs - normal_equations.rs - solver.rs - batch/ - mod.rs - weighted_least_squares.rs - nonlinear.rs - convergence.rs - sequential/ - mod.rs - ekf.rs - smoother.rs - robust/ - mod.rs - huber.rs - sigma_edit.rs - covariance.rs - multi_arc.rs - ambiguity.rs - neq.rs - error.rs -``` - -### Required solver backends - -MVP: - -- Dense QR or SVD for small synthetic cases. -- Normal-equation Cholesky/LDLT for production-like batch cases. -- Condition diagnostics. - -Later: - -- Sparse normal equations. -- Square-root information filter. -- Block-structured solvers. -- Normal-equation export/import. - -### Core outputs - -Every estimator run shall produce: - -- Converged/not converged flag. -- Iteration count. -- Prefit and postfit RMS. -- Parameter correction norm per iteration. -- Normal matrix condition diagnostics. -- Rejected observation count. -- Estimated parameters. -- Covariance and correlation matrices when available. -- Structured warning list. - ---- - -## 6.6 `siderust-pod-qc` - -### Purpose - -Own all quality-control and validation artifacts. - -### Responsibilities - -- Residual statistics. -- Residual grouping. -- Orbit overlap comparison. -- Reference orbit comparison. -- RTN/RIC difference statistics. -- Clock comparison. -- SLR residual validation. -- Spectral diagnostics. -- QC JSON schema. -- HTML report generation. - -### Key modules - -```text -src/ - lib.rs - residuals/ - mod.rs - summary.rs - grouping.rs - orbit_compare/ - mod.rs - interpolation.rs - rtn.rs - statistics.rs - clock_compare.rs - slr_validation.rs - spectral.rs - schema.rs - html.rs - error.rs -``` - -### QC artifacts - -MVP-1: - -- `qc.json` -- `residuals.csv` or `residuals.parquet` -- `summary.json` -- optional `report.html` - -MVP-2: - -- `orbit_compare.json` -- `slr_residuals.csv` -- `slr_summary.json` -- `overlap_summary.json` - -Later: - -- Spectral diagnostics. -- Sensor dashboards. -- Web-browsable artifacts. - ---- - -## 6.7 `siderust-pod-products` - -### Purpose - -Own product generation and product naming/packaging. - -### Responsibilities - -- SP3 orbit products. -- OEM orbit products. -- SINEX-like solution/covariance products later. -- Residual product packaging. -- Product metadata. -- Product validation. - -### Key modules - -```text -src/ - lib.rs - orbit/ - sp3.rs - oem.rs - solution/ - sinex.rs - covariance.rs - residuals.rs - manifest.rs - naming.rs - package.rs - error.rs -``` - -### Product rules - -Every product shall include or reference: - -- Run ID. -- Config hash. -- Software version. -- Input product hashes. -- Time coverage. -- Satellite/arc ID. -- Model set. -- Product type. -- Quality status. - ---- - -## 6.8 `siderust-pod-service` - -### Purpose - -Own orchestration and job-level behavior without embedding heavy web/UI assumptions in the computation crates. - -### Responsibilities - -- Job model. -- Config loading. -- Artifact directories. -- Run manifests. -- Pipeline stages. -- Event hooks later. -- REST API later. -- Worker model later. - -### Key modules - -```text -src/ - lib.rs - job.rs - pipeline.rs - config.rs - artifacts.rs - manifest.rs - runner.rs - stages/ - ingest.rs - prepare.rs - estimate.rs - qc.rs - products.rs - error.rs -``` - -### Pipeline stages - -```text -1. Load config -2. Validate config -3. Resolve datasets -4. Hash inputs -5. Build time/EOP context -6. Load spacecraft configuration -7. Load observation data -8. Build dynamic model -9. Build observation model -10. Build estimator -11. Solve -12. Generate products -13. Generate QC -14. Write manifest -15. Validate outputs -``` - ---- - -## 6.9 `spod` - -### Purpose - -Expose product workflows to users and CI. - -### Commands - -```bash -spod validate-config config.yaml -spod run config.yaml -spod inspect-manifest out/run.manifest.json -spod compare-orbits orbit_a.sp3 orbit_b.sp3 --frame rtn -spod qc out/run.manifest.json -spod validate-slr out/orbit.sp3 slr/*.crd -spod simulate config.yaml -``` - -### CLI rules - -- CLI shall call service/library code. -- CLI shall not implement numerical logic directly. -- CLI outputs shall be deterministic. -- CLI errors shall be structured and actionable. - ---- - -## 7. Data model - -## 7.1 Dataset model - -```rust -pub struct DatasetRef { - pub id: DatasetId, - pub path: PathBuf, - pub format: DatasetFormat, - pub sha256: Sha256, - pub time_coverage: Option, - pub source: Option, - pub license: Option, -} -``` - -Dataset formats: - -```rust -pub enum DatasetFormat { - RinexObs, - RinexNav, - Sp3, - Clock, - Antex, - Sinex, - Crd, - Cpf, - Oem, - Opm, - Aem, - Tdm, - Orbex, - VgosDb, - Eop, - GravityField, - SpacecraftConfig, -} -``` - -## 7.2 Observation model - -```rust -pub struct ObservationRecord { - pub id: ObservationId, - pub epoch: Epoch, - pub source: ObservationSource, - pub target: SpaceObjectId, - pub observable: Observable, - pub value: ObservationValue, - pub sigma: Option, - pub flags: ObservationFlags, - pub provenance: DatasetId, -} -``` - -Observable kinds: - -```rust -pub enum ObservableKind { - GnssCode, - GnssCarrierPhase, - GnssDoppler, - SlrRange, - DorisDoppler, - VlbiDelay, - Range, - RangeRate, - Azimuth, - Elevation, - RightAscension, - Declination, -} -``` - -## 7.3 Parameter model - -```rust -pub struct ParameterBlock { - pub id: ParameterBlockId, - pub kind: ParameterKind, - pub size: usize, - pub values: Vec, - pub prior: Option>, - pub bounds: Option>, - pub solve_for: bool, -} -``` - -Parameter block families: - -- State vector. -- Receiver clock. -- Satellite clock. -- Carrier ambiguity. -- Drag coefficient or scale. -- SRP coefficient or scale. -- Empirical accelerations. -- Maneuver parameters. -- Station coordinates. -- Range bias. -- Troposphere. -- EOP adjustments later. - -## 7.4 Residual model - -```rust -pub struct ResidualRecord { - pub observation_id: ObservationId, - pub epoch: Epoch, - pub observable: ObservableKind, - pub target: SpaceObjectId, - pub source: ObservationSource, - pub prefit: Option, - pub postfit: Option, - pub sigma: Option, - pub weight: Option, - pub normalized: Option, - pub status: ResidualStatus, - pub rejection_reason: Option, -} -``` - -## 7.5 Product model - -```rust -pub struct ProductManifest { - pub product_id: ProductId, - pub product_type: ProductType, - pub path: PathBuf, - pub sha256: Sha256, - pub coverage: TimeCoverage, - pub quality: ProductQuality, -} -``` - -Product types: - -- Orbit SP3. -- Orbit OEM. -- Residual table. -- QC summary. -- Run manifest. -- HTML report. -- Covariance product. -- Solution SINEX later. - ---- - -## 8. Configuration design - -## 8.1 MVP config example - -```yaml -version: 1 - -run: - id: leo-gnss-mvp1 - mode: batch_lsq - output_dir: out/leo-gnss-mvp1 - -arc: - start: 2026-01-01T00:00:00Z - stop: 2026-01-02T00:00:00Z - step_hint_seconds: 30 - -spacecraft: - id: demo-leo - mass_kg: 1200.0 - area_m2: 12.0 - initial_state: - epoch: 2026-01-01T00:00:00Z - frame: GCRF - center: Earth - position_m: [7000000.0, 0.0, 0.0] - velocity_mps: [0.0, 7500.0, 1000.0] - -inputs: - rinex_obs: - - fixtures/gnss/demo.obs - sp3: - - fixtures/gnss/igs.sp3 - antex: fixtures/gnss/igs.atx - eop: fixtures/time/eop.txt - leap_seconds: fixtures/time/leap-seconds.txt - gravity: fixtures/gravity/egm2008.gfc - -models: - dynamics: - gravity: - model: egm2008 - degree: 20 - order: 20 - j2: true - third_body: - sun: true - moon: true - drag: - enabled: true - density_model: constant - estimate_scale: true - srp: - enabled: true - model: cannonball - estimate_scale: true - observations: - gnss: - code: true - carrier_phase: true - ambiguity_mode: float - antenna_phase_center: true - sagnac: true - relativity: true - -estimation: - method: batch_lsq - max_iterations: 8 - convergence: - parameter_update_norm: 1.0e-6 - residual_rms_delta: 1.0e-5 - robust: - method: sigma_edit - threshold: 5.0 - solve_for: - - initial_state - - receiver_clock - - carrier_ambiguities - - drag_scale - - srp_scale - -outputs: - orbit_sp3: true - orbit_oem: true - residuals_csv: true - qc_json: true - html_report: true - manifest: true -``` - -## 8.2 Config validation rules - -- `arc.start < arc.stop`. -- Input files must exist unless running in dry-run mode. -- Time systems must be explicit or unambiguously inferred from format. -- Force model dependencies must be satisfied. -- Estimation parameters must be compatible with enabled models. -- Output directory must be writable. -- Unsupported format versions must produce explicit errors. -- Network access shall be disabled by default for reproducibility. - ---- - -## 9. Algorithms - -## 9.1 Batch weighted least squares - -The batch estimator solves nonlinear least squares by iterating: - -```text -1. Propagate nominal state over arc. -2. Predict all observations. -3. Compute prefit residuals. -4. Assemble design matrix H. -5. Assemble weight matrix W. -6. Solve normal equations or QR/SVD system. -7. Apply parameter update. -8. Recompute residuals. -9. Check convergence. -10. Emit covariance and QC artifacts. -``` - -Required numerical diagnostics: - -- Normal matrix condition estimate. -- Rank deficiency flags. -- Parameter update norm. -- RMS by observable type. -- RMS by satellite/station/source. -- Rejection count per iteration. -- Warnings for weakly observable parameters. - -## 9.2 EKF / sequential estimator - -Later MVP phase. The EKF shall share state and parameter definitions with batch LSQ. - -Processing loop: - -```text -1. Initialize state and covariance. -2. Time update via propagation and STM. -3. Process observations by epoch or batch window. -4. Apply measurement update. -5. Apply robust gating. -6. Persist state/covariance history. -7. Optionally smooth backward. -``` - -Required diagnostics: - -- Innovation residuals. -- Innovation covariance. -- Normalized innovation squared. -- State covariance positive-semidefinite checks. -- Filter divergence detection. -- Restart/gap handling. - -## 9.3 Simulation - -Simulation is not optional long term. It is essential for validation. - -Simulation modes: - -- Orbit truth generation. -- Clock truth generation. -- Observation generation. -- Noise injection. -- Bias injection. -- Cycle-slip injection. -- Outage injection. -- Maneuver injection. -- Monte Carlo. - -Simulation outputs shall be compatible with estimation inputs. - ---- - -## 10. Requirements specification - -## 10.1 High-level requirements - -| ID | Requirement | Priority | Owner | Acceptance | -|---|---|---:|---|---| -| HR-001 | The system shall provide a dedicated POD workspace separate from the main `siderust` crate. | P0 | org | Workspace exists and dependency rules pass CI. | -| HR-002 | The system shall support deterministic GNSS-only LEO batch POD. | P0 | pod | Synthetic 24 h benchmark converges and emits products. | -| HR-003 | The system shall ingest SP3, RINEX OBS, RINEX NAV, ANTEX, EOP, and gravity data for MVP-1. | P0 | io | Fixtures parse into canonical records with provenance. | -| HR-004 | The system shall produce SP3/OEM orbit products, residual tables, QC JSON, and run manifests. | P0 | products/qc | Outputs validate and reparse. | -| HR-005 | The system shall support typed time, frame, center, and unit handling across all POD APIs. | P0 | core | Compile-fail and runtime validation tests pass. | -| HR-006 | The system shall expose force models through composable traits with acceleration and partials. | P0 | dynamics | WLS and propagation use same force-model interfaces. | -| HR-007 | The system shall expose measurement models through traits with prediction, residual, and linearization methods. | P0 | observations | GNSS code/carrier models implement the traits. | -| HR-008 | The system shall support nonlinear weighted least squares with robust outlier handling. | P0 | estimation | Synthetic truth cases recover known parameters. | -| HR-009 | The system shall support covariance generation and RTN/RIC covariance transforms. | P0 | core/dynamics/qc | Round-trip covariance tests pass. | -| HR-010 | The system shall support SLR residual validation for GNSS-derived orbits. | P1 | observations/qc | CRD/CPF fixtures produce residual reports. | -| HR-011 | The system shall support EKF/sequential estimation for NRT replay. | P1 | estimation | EKF replay agrees with batch within tolerance on stable synthetic arc. | -| HR-012 | The system shall support simulation of orbit, clock, and observations. | P1 | core/observations | Simulate-estimate-compare loop is reproducible. | -| HR-013 | The system shall support DORIS observation processing. | P2 | observations/io | DORIS synthetic and fixture workflows pass. | -| HR-014 | The system shall support VLBI-ready abstractions and vgosDB ingestion. | P2 | geodesy/io | VLBI session can build normal equations. | -| HR-015 | The system shall expose library, CLI, and later service/API execution without duplicating numerical logic. | P1 | service/cli | CLI and Rust API outputs are equivalent. | - -## 10.2 Low-level requirements - -| ID | Requirement | Priority | Owner | Acceptance | -|---|---|---:|---|---| -| LR-001 | `tempoch` shall support UTC, TAI, TT, TDB, UT1, and GPS time in POD contexts. | P0 | tempoch | Leap-boundary tests pass. | -| LR-002 | Every POD run shall bind to explicit EOP and leap-second datasets. | P0 | core/service | Manifest includes dataset IDs/hashes. | -| LR-003 | `affn`/POD core shall represent RTN/RIC and LVLH frames. | P0 | affn/core | Frame transform tests pass. | -| LR-004 | `siderust` shall expose public ephemeris and frame-transform provider traits. | P0 | siderust | POD crates do not import private modules. | -| LR-005 | `siderust-pod-dynamics` shall implement two-body and J2 acceleration. | P0 | dynamics | Reference acceleration tests pass. | -| LR-006 | `siderust-pod-dynamics` shall implement third-body acceleration using Siderust ephemerides. | P0 | dynamics | Sun/Moon tests pass. | -| LR-007 | `siderust-pod-dynamics` shall implement a high-degree gravity interface. | P0 | dynamics | Gravity file loads and truncates degree/order. | -| LR-008 | `siderust-pod-dynamics` shall implement simple drag and cannonball SRP. | P0 | dynamics | Synthetic drag/SRP cases pass. | -| LR-009 | `siderust-pod-io` shall parse and write SP3. | P0 | io | Round-trip test passes. | -| LR-010 | `siderust-pod-io` shall parse RINEX OBS and NAV MVP subsets. | P0 | io | Fixtures parse with no silent loss. | -| LR-011 | `siderust-pod-io` shall parse ANTEX. | P0 | io | Antenna corrections queryable. | -| LR-012 | `siderust-pod-observations` shall implement GNSS pseudorange prediction and partials. | P0 | obs | Synthetic residual/Jacobian tests pass. | -| LR-013 | `siderust-pod-observations` shall implement GNSS carrier-phase prediction with float ambiguity. | P0 | obs | Synthetic residual/Jacobian tests pass. | -| LR-014 | GNSS models shall support satellite clock, receiver clock, Sagnac, relativity, and antenna phase-center corrections. | P0 | obs | Individual correction tests pass. | -| LR-015 | `siderust-pod-estimation` shall implement nonlinear WLS. | P0 | estimation | Synthetic OD converges. | -| LR-016 | WLS shall output convergence report, covariance, residuals, and diagnostics. | P0 | estimation | Required fields present. | -| LR-017 | QC shall group residuals by satellite, observable, epoch, elevation, and rejection status. | P0 | qc | QC schema validates. | -| LR-018 | Products shall include hashes and provenance. | P0 | products | Manifest integrity tests pass. | -| LR-019 | SLR CRD/CPF support shall be implemented for validation. | P1 | io/obs | SLR validation report generated. | -| LR-020 | EKF shall support time update, measurement update, process noise, and smoothing. | P1 | estimation | Replay tests pass. | -| LR-021 | Python bindings shall call the same library APIs as CLI. | P2 | python | Output parity test passes. | -| LR-022 | REST service shall call the same service runner as CLI. | P2 | service | Output parity test passes. | - ---- - -## 11. Test strategy - -## 11.1 Test categories - -```text -Unit tests: - small isolated numerical and parser tests - -Compile-fail tests: - frame/unit/center misuse tests - -Integration tests: - multi-crate workflows with fixtures - -Synthetic truth tests: - generated orbit/observation truth cases - -Public-data regression tests: - curated frozen public files - -Performance benchmarks: - parse speed, propagation speed, estimator throughput - -End-to-end tests: - config-driven POD run from inputs to products -``` - -## 11.2 Test matrix - -| Test ID | Requirement coverage | Type | Description | Pass criterion | -|---|---|---|---|---| -| ST-001 | LR-001 | Unit | Time-scale round trips across leap seconds. | Error below configured tolerance. | -| ST-002 | LR-002 | Integration | Run manifest records EOP/leap datasets. | Hashes and dataset IDs present. | -| ST-003 | LR-003/HR-009 | Unit | Cartesian covariance to RTN/RIC and back. | Round-trip error below tolerance and PSD preserved. | -| ST-004 | LR-004 | Architecture | POD crates import public APIs only. | CI dependency/private API audit passes. | -| ST-005 | LR-005 | Unit | Two-body energy conservation. | Energy drift below tolerance over one orbit. | -| ST-006 | LR-005 | Unit | J2 acceleration reference vector. | Difference below tolerance. | -| ST-007 | LR-006 | Unit | Third-body Sun/Moon acceleration. | Matches independent calculation. | -| ST-008 | LR-007 | Integration | Gravity model file load/truncation. | Degree/order truncation behaves deterministically. | -| ST-009 | LR-008 | Unit | Drag and SRP analytic cases. | Acceleration matches expected value. | -| ST-010 | LR-009 | Parser | SP3 parse/write/parse. | Semantic round trip for supported fields. | -| ST-011 | LR-010 | Parser | RINEX OBS/NAV parse. | Epochs, observables, metadata preserved. | -| ST-012 | LR-011 | Parser | ANTEX parse and query. | Expected PCO/PCV records returned. | -| ST-013 | LR-012 | Unit | GNSS pseudorange synthetic residual. | Residual near zero for truth. | -| ST-014 | LR-013 | Unit | GNSS carrier synthetic residual. | Residual near zero with known ambiguity. | -| ST-015 | LR-014 | Unit | Sagnac/relativity/clock/antenna corrections. | Matches reference values. | -| ST-016 | LR-015 | Estimation | Linear WLS known-parameter solve. | Parameters recovered. | -| ST-017 | LR-015 | Estimation | Nonlinear synthetic OD solve. | State recovered within tolerance. | -| ST-018 | LR-016 | Integration | Convergence report generation. | All mandatory fields present. | -| ST-019 | LR-017 | QC | Residual grouping. | Grouped stats match fixture expectations. | -| ST-020 | LR-018 | Reproducibility | Deterministic rerun. | Same input/config yields same manifest/product hashes or tolerance-equivalent numeric outputs. | -| ST-021 | LR-019 | Integration | SLR residual validation. | CRD/CPF fixture produces residual report. | -| ST-022 | LR-020 | Estimation | EKF replay on synthetic arc. | EKF stable and agrees with batch within tolerance. | -| ST-023 | HR-002 | E2E | GNSS-only LEO POD from config. | Orbit/residuals/QC/manifest generated. | -| ST-024 | HR-010 | E2E | GNSS POD + SLR validation. | SLR residual summary generated and linked to manifest. | -| ST-025 | HR-015 | API parity | Rust API vs CLI run. | Equivalent outputs. | - -## 11.3 Performance benchmarks - -| Benchmark ID | Description | Metric | -|---|---|---| -| PB-001 | SP3 parse throughput. | MB/s and records/s. | -| PB-002 | RINEX OBS parse throughput. | MB/s and observations/s. | -| PB-003 | 24 h LEO propagation with MVP force model. | Runtime and max error vs reference. | -| PB-004 | GNSS residual evaluation throughput. | Observations/s. | -| PB-005 | Batch LSQ solve for synthetic 24 h arc. | Wall time, memory, iterations. | -| PB-006 | QC generation throughput. | Residual records/s. | - ---- - -## 12. Milestones - -## 12.1 Milestone M0 — Architecture lock - -Deliverables: - -- Workspace created. -- Crate-boundary document. -- Dependency rules in CI. -- Provider traits designed. -- Config schema draft. - -Exit criteria: - -- `cargo test --workspace` passes with placeholder crates. -- Architecture document accepted. -- No dependency cycles. - -## 12.2 Milestone M1 — Dynamics and state foundation - -Deliverables: - -- `OrbitState`. -- `SpacecraftState`. -- Dynamics context. -- Two-body, J2, third-body. -- RTN/RIC frame support. -- Covariance transform MVP. - -Exit criteria: - -- Two-body/J2/third-body tests pass. -- RTN covariance round-trip passes. - -## 12.3 Milestone M2 — Format ingestion MVP - -Deliverables: - -- SP3 read/write. -- RINEX OBS/NAV subset. -- ANTEX read. -- EOP bridge. -- OEM write. - -Exit criteria: - -- Parser regression suite passes. -- Product round-trip tests pass. - -## 12.4 Milestone M3 — Estimation MVP - -Deliverables: - -- Measurement-model trait. -- GNSS code/carrier prediction. -- WLS nonlinear estimator. -- Residual and convergence reports. - -Exit criteria: - -- Synthetic GNSS OD recovers truth. -- Jacobians validated against finite differences. - -## 12.5 Milestone M4 — POD MVP-1 - -Deliverables: - -- Config-driven GNSS-only LEO POD. -- SP3/OEM outputs. -- Residual table. -- QC JSON. -- Manifest. -- Optional HTML report. - -Exit criteria: - -- `spod run examples/configs/leo_gnss_mvp1.yaml` completes. -- All required artifacts generated. -- Re-run reproducibility test passes. - -## 12.6 Milestone M5 — SLR validation MVP-2 - -Deliverables: - -- CRD/CPF parse. -- SLR range model. -- SLR residual validation command. -- Orbit overlap/reference comparison. - -Exit criteria: - -- GNSS-derived orbit can be validated against SLR fixtures. -- RTN/RIC comparison artifacts generated. - -## 12.7 Milestone M6 — NRT replay MVP-3 - -Deliverables: - -- EKF. -- Sequential replay. -- Windowed processing. -- Latency metrics. -- State/covariance history. - -Exit criteria: - -- EKF replay stable. -- EKF vs batch comparison within configured threshold. - -## 12.8 Milestone M7 — Productization - -Deliverables: - -- Python bindings. -- REST API. -- Job queue. -- Artifact browsing API. -- Container image. - -Exit criteria: - -- CLI, Rust API, Python API, and REST call same kernel. -- API parity tests pass. - ---- - -## 13. Risks and mitigations - -| Risk | Impact | Mitigation | -|---|---|---| -| Scope expands into full FocusPOD clone too early. | Project stalls. | Enforce MVP-1/MVP-2/MVP-3 sequence. | -| `siderust` becomes monolithic. | Core crate loses clarity. | Keep POD in sibling workspace. | -| Time/frame duplication appears across crates. | Numerical inconsistency. | Use `tempoch`/`affn` as single owners. | -| Estimator design becomes too GNSS-specific. | DORIS/VLBI later become hard. | Keep generic estimation traits separate from observation crates. | -| Format parsers become ad hoc. | Interoperability failures. | Canonical typed records and golden fixtures. | -| Analytic partials are delayed. | Estimation quality/performance suffers. | Validate analytic partials early against finite differences. | -| Public validation data is hard to curate. | Credibility suffers. | Use synthetic truth first, then public frozen fixtures. | -| Performance lags due to dynamic dispatch. | Operational throughput weak. | Support both dynamic configuration and static/generic optimized paths. | -| Covariance semantics are ambiguous. | QC and estimation errors. | Attach state ordering, frame, center, and units to covariance objects. | -| Service layer pollutes core APIs. | Maintenance burden. | Keep service crate at top of dependency graph. | - ---- - -## 14. Open design decisions - -| ID | Decision | Options | Recommendation | -|---|---|---|---| -| ODD-001 | Should `OrbitState` live in `affn` or `siderust-pod-core`? | `affn`, `siderust`, `siderust-pod-core` | Put POD semantics in `siderust-pod-core`; keep geometry primitives in `affn`. | -| ODD-002 | Which linear algebra backend? | `nalgebra`, `faer`, `ndarray`, custom traits | Use backend traits; start with one pragmatic backend. | -| ODD-003 | Which format parser strategy? | hand-written parsers, parser combinators, generated parsers | Hand-written robust parsers for MVP formats. | -| ODD-004 | How strict should file parsing be? | strict only, permissive only, both modes | Support strict and permissive modes with diagnostics. | -| ODD-005 | What is the first public benchmark satellite? | synthetic only, Sentinel-like, public LEO GNSS arc | Synthetic first; public LEO GNSS benchmark second. | -| ODD-006 | Should Python bindings be early? | before MVP-1, after MVP-1, after MVP-2 | After MVP-1 unless external users require earlier. | -| ODD-007 | Should service/API be in first year? | yes/no | Only after CLI and library kernel are stable. | -| ODD-008 | Should DORIS precede EKF? | yes/no | No. EKF/NRT path is more broadly useful. | -| ODD-009 | Should integer ambiguity resolution be MVP? | yes/no | No. Start float; add integer fixing later. | -| ODD-010 | Should `siderust-pod-io` be a general `siderust-formats` crate? | POD-specific vs general | Start POD-specific; extract general formats only after APIs stabilize. | - ---- - -## 15. Recommended first GitHub issues - -1. Create `spod` workspace skeleton. -2. Add architecture boundary document. -3. Add dependency graph CI check. -4. Define `OrbitState`, `SpacecraftState`, `ArcDefinition`, and `RunManifest`. -5. Define `EphemerisProvider`, `EarthOrientationProvider`, and `FrameTransformProvider` public traits in `siderust` or adapter crate. -6. Implement two-body dynamics test. -7. Implement J2 acceleration test. -8. Implement RTN/RIC frame basis and covariance round-trip test. -9. Implement SP3 parser skeleton. -10. Implement SP3 writer round-trip test. -11. Implement RINEX OBS MVP parser skeleton. -12. Implement ANTEX parser skeleton. -13. Define measurement-model trait. -14. Implement synthetic GNSS pseudorange model. -15. Implement weighted least-squares linear synthetic test. -16. Implement nonlinear synthetic OD test. -17. Define QC JSON schema. -18. Define run manifest schema. -19. Implement CLI `validate-config`. -20. Implement CLI `run` with synthetic pipeline. - ---- - -## 16. Appendix A — Proposed public module surface - -```rust -// siderust-pod-core -pub mod arc; -pub mod config; -pub mod context; -pub mod covariance; -pub mod manifest; -pub mod parameter; -pub mod spacecraft; -pub mod state; - -// siderust-pod-dynamics -pub mod forces; -pub mod gravity; -pub mod integrators; -pub mod propagation; -pub mod variational; - -// siderust-pod-io -pub mod rinex; -pub mod sp3; -pub mod antex; -pub mod sinex; -pub mod slr; -pub mod ccsds; - -// siderust-pod-observations -pub mod gnss; -pub mod slr; -pub mod corrections; -pub mod simulation; - -// siderust-pod-estimation -pub mod batch; -pub mod sequential; -pub mod robust; -pub mod covariance; -pub mod multi_arc; - -// siderust-pod-qc -pub mod residuals; -pub mod orbit_compare; -pub mod slr_validation; -pub mod spectral; -pub mod html; -``` - ---- - -## 17. Appendix B — MVP artifact layout - -```text -out/leo-gnss-mvp1/ - run.manifest.json - config.normalized.yaml - logs/ - run.log - products/ - orbit.sp3 - orbit.oem - residuals/ - residuals.csv - qc/ - qc.json - summary.json - report.html - debug/ - iterations.json - parameters.json - covariance.json -``` - ---- - -## 18. Appendix C — Definition of done for MVP-1 - -MVP-1 is done when this command works from a clean checkout with fixtures installed: - -```bash -cargo run -p spod -- run examples/configs/leo_gnss_mvp1.yaml -``` - -and produces: - -```text -out/leo-gnss-mvp1/run.manifest.json -out/leo-gnss-mvp1/products/orbit.sp3 -out/leo-gnss-mvp1/products/orbit.oem -out/leo-gnss-mvp1/residuals/residuals.csv -out/leo-gnss-mvp1/qc/qc.json -out/leo-gnss-mvp1/qc/report.html -``` - -with all of the following true: - -- Run is deterministic. -- Manifest contains input hashes. -- SP3 output reparses. -- OEM output reparses. -- Residuals include prefit and postfit values. -- QC JSON validates against schema. -- Estimated synthetic orbit meets configured accuracy threshold. -- No private APIs from existing Siderust crates are imported. -- All frame/unit/center misuse tests fail to compile or fail validation explicitly. - ---- - -## 19. Final recommendation - -The next implementation step should not be another feature inside `siderust/src`. It should be the creation of a dedicated `spod` workspace with a minimal, test-first GNSS POD pipeline. - -The first build target is: - -```text -Synthetic GNSS-only LEO batch POD - -> typed state - -> SP3/RINEX/ANTEX fixtures - -> GNSS code/carrier residuals - -> nonlinear WLS - -> orbit product - -> residual file - -> QC JSON - -> manifest -``` - -After that, the project should add SLR validation, then EKF/NRT replay, then public-data benchmarks, then DORIS/VLBI/geodesy expansion, and only then service/UI/productization. - -This path gives Siderust the strongest chance to compete credibly: use its current strengths as a scientific kernel, avoid monolithic scope creep, and build a POD product layer with explicit validation and operational discipline. diff --git a/docs/formats/blq.md b/docs/formats/blq.md index 4a87b00..2b3c394 100644 --- a/docs/formats/blq.md +++ b/docs/formats/blq.md @@ -29,8 +29,6 @@ Planned for **Phase 5** alongside the SINEX coordinate-frame extension. - BLQ files are the de-facto standard distributed by the Onsala Bohnsdorff loading service and IGS station coordinate processors. - Until BLQ ingestion lands, callers must compose their own per-station - displacement series and inject them through the - `StationDisplacementProvider` trait in `spod::observations` - (a placeholder API in v0.0.x). + displacement series at the service boundary. - Ocean loading is **required** for the SLR validation accuracy gates (`PB-008` reference; see also the Mendes-Pavlis tropospheric model). diff --git a/docs/formats/daf-spk.md b/docs/formats/daf-spk.md index 2847106..e555c50 100644 --- a/docs/formats/daf-spk.md +++ b/docs/formats/daf-spk.md @@ -2,61 +2,64 @@ ## Summary -NAIF SPICE distributes planetary, satellite, and spacecraft trajectory -data in DAF (Double-precision Array File) container files. The SPK -sub-format ("SP-Kernel") layers a typed segment table on top of DAF and -defines 21 segment types covering Chebyshev, Lagrange, Hermite, and -discrete-state representations. The familiar `.bsp` extension denotes a -binary SPK kernel; there is no separate `.bsp` format. +NAIF SPICE distributes planetary, satellite, and spacecraft trajectory data +in DAF (Double-precision Array File) containers. SPK (SP-Kernel) stores typed +ephemeris segments inside DAF files; binary kernels conventionally use the +`.bsp` extension. -## Authoritative specs +## Authoritative specifications - DAF: - SPK: -## Coverage +## Ownership -| Read | Write | -|------|-------| -| ✅ | — | +Low-level DAF parsing and raw SPK decoding are canonical Siderust APIs: -## Owning crate +```text +siderust::formats::spice::daf +siderust::formats::spice::spk +``` -`siderust::data::spk` +`spod::spice` adds service/application functionality that is not a simple +reexport of those namespaces: -## Supported SPK segment types +- `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. -| Type | Description | Status | -|------|---------------------------------------------------|--------| -| 2 | Chebyshev polynomial — position only | ✅ | -| 3 | Chebyshev polynomial — position and velocity | ✅ | +Callers that only need the low-level DAF/SPK parser should import Siderust +directly. -These two types cover the JPL DE-series planetary ephemerides -(DE440, DE441) and most ITRF-to-GCRF station kernels in common use. +## Supported SPK segment types -## Not supported +| Type | Description | Status | +| --- | --- | --- | +| 2 | Chebyshev position | supported | +| 3 | Chebyshev position and velocity | supported | -The following SPK types return `SpiceError::UnsupportedDataType { ty }` -when encountered. They are explicitly enumerated so that the dispatch -matches against them and produces a clear error rather than panicking on -an unknown data type: +These types cover the JPL DE-series kernels used by the current validation +path. -`1, 5, 8, 9, 10, 12, 13, 14, 15, 17, 18, 19, 20, 21`. +## Unsupported segment types -Adding a new type requires: +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: -1. Implementing the segment evaluator under `siderust_spice::spk::types`. -2. Wiring it into the `SpkSegment` dispatch enum. -3. Adding regression cases against a JPL-published reference for that type. +```rust +spod::spice::SpiceError::UnsupportedDataType { data_type: 9 } +``` -See [ADR-0007 — SPK type coverage](../adr/0007-spk-type-coverage.md). +Types 9 and 13 remain intentionally deferred; see +[ADR-0007](../adr/0007-spk-type-coverage.md). -## Deviations +## Implementation notes -- The reader **does not** load the entire kernel into memory; it memory- - maps the file (read-only) and decodes segments lazily. -- Endianness is detected from the DAF file record; both `LTL-IEEE` and - `BIG-IEEE` kernels are supported. -- Comment area (DAF "ID word" followed by reserved record area) is - exposed as a `String` and used as part of the run manifest provenance - block. +- `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. diff --git a/docs/requirements/pod-functional-requirements.md b/docs/requirements/pod-functional-requirements.md deleted file mode 100644 index 8a825c3..0000000 --- a/docs/requirements/pod-functional-requirements.md +++ /dev/null @@ -1,127 +0,0 @@ -# POD Functional Requirements - -This document tracks the functional requirements of `spod` against -the M0–M7 milestone structure defined in `docs/design/plan.md`. - -Status legend: **Done**, **In progress**, **Planned**, **Deferred**. - -## Group: M-level milestone summary - -| ID | Milestone | Requirement | Status | -|------------|-------------------------|---------------------------------------------------------------------------------------------------|---------------| -| FR-M0 | Workspace bootstrap | `spod` compiles cleanly; upstream crates (`siderust`, `affn`, `qtty`, `tempoch`) are the canonical source for general astrodynamics. | Done | -| FR-M1-01 | GNSS LEO batch | Ingest SP3, RINEX OBS/NAV, ANTEX, EOP fixtures end-to-end. | In progress | -| FR-M1-02 | GNSS LEO batch | Batch WLS with float carrier ambiguities resolves a 24 h LEO arc. | In progress | -| FR-M1-03 | GNSS LEO batch | SP3 + OEM + manifest products written reproducibly. | In progress | -| FR-M2-01 | EKF replay | EKF replays the same arc as the batch and stays within 3·σ of the batch state at every epoch. | Planned | -| FR-M3-01 | SLR validation | CRD/CPF ingestion, SLR forward model, RTN residual stats within Mendes-Pavlis tolerance. | Planned | -| FR-M4-01 | Multi-arc / overlap | Two consecutive arcs overlap; orbit-overlap RMS reported. | Planned | -| FR-M5-01 | Service & CLI parity | CLI ≡ Rust API ≡ REST produce byte-identical artefacts on the same config. | Planned | -| FR-M6-01 | LISA POC | Three-spacecraft heliocentric POC with simulated MOSA inter-spacecraft ranges. | Planned | -| FR-M7-01 | Hardening for 1.0 | Public-API snapshot gate flips from advisory to gating. | Planned | - -The detailed requirements below are grouped by domain area; each maps to -one or more milestones above. - ---- - -## Group: IO formats — `FR-IO-*` - -| ID | Requirement | Crate | Status | -|-----------|----------------------------------------------------------------------------------------------------------|----------------------|---------------| -| FR-IO-01 | Read SP3-d files (header + epoch + P-records). | `spod::io` | In progress | -| FR-IO-02 | Write SP3-d files preserving header verbatim where possible. | `spod::io` | In progress | -| FR-IO-03 | Read RINEX 3 OBS files (GPS L1/L2 + Galileo E1/E5a subset). | `spod::io` | In progress | -| FR-IO-04 | Read RINEX 3 NAV (GPS broadcast ephemeris). | `spod::io` | In progress | -| FR-IO-05 | Read ANTEX 1.4 PCO values (PCV grids skipped). | `spod::io` | In progress | -| FR-IO-06 | Read IERS EOP C04 series (daily rows + linear interpolation). | `spod::io` | In progress | -| FR-IO-07 | Read & write CCSDS OEM (KVN, single-segment write, multi-segment read). | `spod::io` | In progress | -| FR-IO-08 | Read ILRS CRD (record types 10 and 11). | `spod::io` | Planned (M3) | -| FR-IO-09 | Read ILRS CPF (record type 10). | `spod::io` | Planned (M3) | -| FR-IO-10 | Read TLE / 3LE / OMM (KVN, XML, JSON). | `siderust::astro::satellite::tle` | Done | -| FR-IO-11 | Read DAF/SPK Type 2 and Type 3 segments. | `siderust::data::spk` | Done | -| FR-IO-12 | Read BLQ ocean-loading coefficients. | `spod::io` | Deferred (Ph5)| - -## Group: Force / dynamics models — `FR-DYN-*` - -| ID | Requirement | Status | -|-----------|----------------------------------------------------------------------------------------|-------------| -| FR-DYN-01 | Two-body central gravity. | Done | -| FR-DYN-02 | EGM2008 / EGM96 spherical-harmonic gravity field (configurable max degree). | In progress | -| FR-DYN-03 | Solid Earth + ocean tides (IERS Conventions 2010). | Planned | -| FR-DYN-04 | Third-body perturbations (Sun, Moon, planets) sourced from `siderust-spice`. | In progress | -| FR-DYN-05 | Atmospheric drag with NRLMSISE-00 / DTM-2013. | Planned | -| FR-DYN-06 | Solar radiation pressure: cannonball + box-wing. | In progress | -| FR-DYN-07 | Earth radiation pressure (albedo + IR). | Planned | -| FR-DYN-08 | Empirical accelerations (constant / once-per-rev RTN). | In progress | -| FR-DYN-09 | Thrust-arc parameterisation with start/stop epochs. | In progress | -| FR-DYN-10 | Variational equations / analytic STM (replaces finite-diff STM, see ADR-0006). | Planned | -| FR-DYN-11 | Composite force model with run-time configurable registry. | In progress | - -## Group: Observation models — `FR-OBS-*` - -| ID | Requirement | Status | -|-----------|--------------------------------------------------------------------------|-----------------| -| FR-OBS-01 | GNSS code (pseudorange) measurement model. | In progress | -| FR-OBS-02 | GNSS carrier-phase model with float ambiguities. | In progress | -| FR-OBS-03 | Antenna PCO/PCV correction from ANTEX. | In progress | -| FR-OBS-04 | Sagnac correction. | In progress | -| FR-OBS-05 | Relativistic clock correction. | In progress | -| FR-OBS-06 | Phase wind-up correction. | In progress | -| FR-OBS-07 | Tropospheric delay (Saastamoinen + GMF/VMF1 mapping). | In progress | -| FR-OBS-08 | Ionospheric delay (dual-frequency ionosphere-free combination). | In progress | -| FR-OBS-09 | SLR range model (centre-of-mass offset, Marini-Murray / Mendes-Pavlis). | Planned (M3) | -| FR-OBS-10 | DORIS Doppler measurement model. | Planned (M5) | -| FR-OBS-11 | VLBI delay model. | Deferred | -| FR-OBS-12 | Inter-spacecraft range (LISA POC). | Planned (M6) | - -## Group: Estimation — `FR-EST-*` - -| ID | Requirement | Status | -|-----------|--------------------------------------------------------------------------------------|-------------| -| FR-EST-01 | Batch weighted least squares with `faer` Cholesky / QR backend. | In progress | -| FR-EST-02 | Robust weighting (Huber, sigma-edit). | In progress | -| FR-EST-03 | Posterior covariance extraction with parameter labels. | In progress | -| FR-EST-04 | Outlier detection and flagging in residual records. | In progress | -| FR-EST-05 | Extended Kalman Filter on the same state vector as the batch. | Planned (M2)| -| FR-EST-06 | Process-noise wrappers (RTN / inertial Q matrices). | In progress | -| FR-EST-07 | LAMBDA integer ambiguity resolution. | Planned (M4)| - -## Group: QC and products — `FR-QC-*` - -| ID | Requirement | Status | -|----------|------------------------------------------------------------------------------------------|---------------| -| FR-QC-01 | Per-satellite / per-obs-type / per-elevation residual statistics. | In progress | -| FR-QC-02 | Orbit overlap RMS in RTN/RIC frames. | Planned (M4) | -| FR-QC-03 | SLR validation residual report. | Planned (M3) | -| FR-QC-04 | JSON QC record + HTML report with inline templates. | In progress | -| FR-QC-05 | SP3 product writer. | In progress | -| FR-QC-06 | CCSDS OEM product writer. | In progress | -| FR-QC-07 | Residual product (CSV, optional Parquet under `parquet` feature). | In progress | -| FR-QC-08 | Canonical JSON manifest with byte-stable hashing. | In progress | -| FR-QC-09 | Archive layout: predictable per-run directory structure with provenance. | In progress | - -## Group: Service, CLI, REST — `FR-SVC-*` - -| ID | Requirement | Status | -|-----------|----------------------------------------------------------------------------------------------|---------------| -| FR-SVC-01 | YAML `RunConfig` ingestion via `serde_yaml`. | In progress | -| FR-SVC-02 | 15-stage pipeline runner with deterministic ordering. | In progress | -| FR-SVC-03 | `spod` exposes `run`, `validate-config`, `inspect-manifest`, `qc`, `validate-slr`, `compare-orbits`, `lambert`, `propagate`, `lisa-poc`. | In progress | -| FR-SVC-04 | `spod-rest` exposes `POST /jobs`, `GET /jobs/{id}`, `DELETE /jobs/{id}`, `GET /jobs/{id}/products/{file}`. | Planned (M5) | -| FR-SVC-05 | OpenAPI 3.1 schema generated for the REST API. | Planned (M5) | -| FR-SVC-06 | Bearer-token authentication for REST. | Planned | -| FR-SVC-07 | API parity: CLI, library, and REST produce byte-identical artefacts on the same config. | Planned (M5) | - -## Group: LISA POC — `FR-LISA-*` - -See [ADR-0005](../adr/0005-lisa-poc-scope.md) for the scoping of this work. - -| ID | Requirement | Status | -|------------|------------------------------------------------------------------------------------------|---------------| -| FR-LISA-01 | `LisaEphemerisProvider` reads `esa/lisa-orbit-files`. | Planned (M6) | -| FR-LISA-02 | Three heliocentric ICRS state vectors per epoch (one per spacecraft). | Planned (M6) | -| FR-LISA-03 | `InterSatRange` measurement model in `spod::observations`. | Planned (M6) | -| FR-LISA-04 | Three-spacecraft batch estimation using existing WLS infrastructure. | Planned (M6) | -| FR-LISA-05 | Example `examples/12_lisa_poc.rs` runs end-to-end. | Planned (M6) | -| FR-LISA-06 | E2E-09 acceptance test passes within ranging-only observability. | Planned (M6) | diff --git a/docs/validation/acceptance-tests.md b/docs/validation/acceptance-tests.md deleted file mode 100644 index ab1d8d4..0000000 --- a/docs/validation/acceptance-tests.md +++ /dev/null @@ -1,165 +0,0 @@ -# Acceptance Tests — E2E-01 through E2E-12 - -This document describes the twelve end-to-end acceptance scenarios that -constitute the Phase 10 gate for `spod`. All twelve must pass before -a 0.1.0 release tag may be created. - ---- - -## E2E-01 — Two-body LEO propagation round-trip - -**Crates under test**: `spod::dynamics`, `spod::core` -**Fixture**: synthesised circular LEO orbit (h = 500 km, i = 97°) -**Procedure**: -1. Propagate forward 24 h with DOP853. -2. Propagate backward 24 h from the terminal state. -3. Assert that the round-trip residual is < 1 mm in position and < 1 µm/s in - velocity. -**Pass criterion**: position residual < 1 mm, velocity residual < 1 µm/s. - ---- - -## E2E-02 — Real GNSS LEO arc (GPS-only) - -**Crates under test**: `spod::io`, `spod::observations`, -`spod::dynamics`, `spod::estimation` -**Fixture**: 6-h RINEX OBS v3 + RINEX NAV + IGS precise orbits (SP3) for a -LEO spacecraft (e.g., GRACE-FO or TerraSAR-X epoch) -**Fixture gate**: `SPOD_E2E02_DIR` (see `fixtures.md`) -**Procedure**: -1. Read RINEX OBS + NAV + SP3. -2. Form ionosphere-free pseudorange combinations. -3. Run kinematic POD (no dynamics). -4. Compare estimated positions vs SP3 truth. -**Pass criterion**: 3D RMS < 10 cm over the arc. - ---- - -## E2E-03 — Reduced-dynamic LEO arc - -**Crates under test**: full pipeline -**Fixture**: same as E2E-02 + EOP + ANTEX -**Fixture gate**: `SPOD_E2E02_DIR` (shared) -**Procedure**: reduced-dynamic batch-LS with EMP accelerations. -**Pass criterion**: 3D RMS < 5 cm; empirical-accel estimates < 50 nm/s². - ---- - -## E2E-04 — SLR validation (LEO) - -**Crates under test**: `spod::io`, `spod::observations`, -`spod::qc` -**Fixture**: ILRS CRD session file (≥ 20 normal points) -**Fixture gate**: `SPOD_CRD_FILE` -**Procedure**: compute SLR residuals against estimated orbit from E2E-03. -**Pass criterion**: mean residual < 1 cm; σ < 2 cm. - ---- - -## E2E-05 — SGP4 TLE propagation + SP3 comparison - -**Crates under test**: `siderust-sgp4`, `siderust-tle`, `spod::io` -**Fixture**: ISS TLE epoch + IGS SP3 for the same epoch -**Procedure**: propagate ISS TLE to 6 epochs; compare vs SP3 positions. -**Pass criterion**: RMS < 1 km (TLE accuracy budget is ~1 km). - ---- - -## E2E-06 — Lambert solver Earth-to-Mars transfer - -**Crates under test**: `siderust-lambert` -**Fixture**: hard-coded Earth (r₁) and Mars (r₂) positions at a known transfer epoch -**Procedure**: solve Lambert problem for the optimal 0-rev transfer; verify ΔV -budget is within 5% of the Hohmann-approximate value for a circular-to-circular -approximation. -**Pass criterion**: ΔV within 5% of Hohmann estimate; solution converges in < 50 iterations. - ---- - -## E2E-07 — SPICE-based planetary ephemeris accuracy - -**Crates under test**: `siderust-spice` -**Fixture gate**: local `de440.bsp` or `de441.bsp` on `SIDERUST_SPICE_DE_PATH` -**Procedure**: evaluate Earth, Moon, Mars positions at 100 epochs; compare vs -JPL HORIZONS reference values committed in `test-data/de440_ref.json`. -**Pass criterion**: position error < 1 mm for planets, < 1 m for Moon. - ---- - -## E2E-08 — EKF orbit determination - -**Crates under test**: `spod::estimation` (EKF), `spod::dynamics` -**Fixture**: synthesised pseudorange measurements with known truth state -**Procedure**: run EKF for 2 h; run RTS smoother; compare vs truth. -**Pass criterion**: post-fit 3σ covariance contains truth state; smoother -improves RMS by ≥ 10% over filter-only. - ---- - -## E2E-09 — Orbit overlap - -**Crates under test**: full pipeline -**Fixture**: two 3-h arcs with 1-h overlap, same spacecraft -**Procedure**: estimate both arcs independently; compute 3D RMS of the estimated -positions in the overlap interval. -**Pass criterion**: overlap RMS < 5 cm. - ---- - -## E2E-10 — QC HTML report generation - -**Crates under test**: `spod::qc` -**Fixture**: residuals CSV from E2E-03 -**Procedure**: generate QC HTML report; assert all required sections are present -(residual time series, sky plot, orbit-compare table, SLR validation summary). -**Pass criterion**: report renders without error; all required sections present. - ---- - -## E2E-11 — REST API end-to-end - -**Crates under test**: `spod-rest`, `spod::service` -**Fixture**: config YAML + small synthesised RINEX OBS fixture -**Procedure**: -1. Start `spod-rest` in a background thread. -2. POST a job via HTTP. -3. Poll until complete. -4. GET the SP3 product. -**Pass criterion**: job completes without error; SP3 product contains valid orbit data. - ---- - -## E2E-12 — LISA POC (heliocentric three-spacecraft) - -**Crates under test**: `spod::observations` (`InterSatRange`), -`spod::dynamics`, `spod::estimation`, `spod::io` (LISA orbit reader) -**Fixture**: LISA orbit files from `https://github.com/esa/lisa-orbit-files` -(text/ASCII format); downloaded to `SPOD_LISA_ORBIT_DIR` -**Scope**: Heliocentric three-spacecraft orbit estimation from simulated MOSA -inter-spacecraft ranges. No TDI or laser metrology (see -[`../adr/0005-lisa-poc-scope.md`](../adr/0005-lisa-poc-scope.md)). -**Procedure**: -1. Load LISA heliocentric truth orbits for all three spacecraft. -2. Simulate MOSA inter-spacecraft ranges with realistic noise (σ = 1 m). -3. Run batch-LS or EKF POD estimating the three spacecraft states. -4. Compare estimated orbits vs truth over a 30-day arc. -**Pass criterion**: -- Position RMS < 100 m for all three spacecraft. -- Estimated inter-spacecraft distances agree with truth to < 10 m. -- No divergence or NaN in the estimation. - ---- - -## Running the full E2E suite - -```bash -# All non-ignord E2E tests (synthesised fixtures only): -cargo test -p spod::service --test e2e -- --nocapture - -# Unlock fixture-gated tests (all required env vars must be set): -SPOD_E2E02_DIR=/path/to/e2e02 \ -SPOD_CRD_FILE=/path/to/crd \ -SIDERUST_SPICE_DE_PATH=/path/to/de440.bsp \ -SPOD_LISA_ORBIT_DIR=/path/to/lisa-orbit-files \ -cargo test -p spod::service --test e2e -- --include-ignored --nocapture -``` diff --git a/examples/01_typed_two_body_propagation.rs b/examples/01_typed_two_body_propagation.rs deleted file mode 100644 index f45124b..0000000 --- a/examples/01_typed_two_body_propagation.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Minimal typed LEO propagation with the siderust dynamics stack. -//! -//! The example keeps epoch, frame, position, velocity, duration, and gravity -//! parameter typed from construction through propagation. Raw scalars only -//! appear when defining the initial numerical values. -//! -//! Run with: -//! cargo run --example 01_typed_two_body_propagation - -#![allow(clippy::print_stdout)] - -use siderust::astro::dynamics::GM_EARTH; -use siderust::coordinates::frames::GCRS; -use siderust::qtty::Second; -use siderust::time::JulianDate; -use spod::dynamics::{ - DynamicsContext, Integrator, OrbitState, Position, Rk4Integrator, TwoBody, Velocity, -}; - -fn main() -> Result<(), Box> { - let initial = OrbitState::new( - JulianDate::new(2_451_545.0).to_j2000s(), - Position::::new(7_000.0, 0.0, 0.0), - Velocity::::new(0.0, 7.545, 0.0), - ); - - let duration = Second::new(45.0 * 60.0); - let integrator = Rk4Integrator { - step: Second::new(10.0), - }; - let gravity = TwoBody::new(GM_EARTH); - let context = DynamicsContext::empty(); - - let initial_radius = initial.position.distance(); - let initial_speed = initial.velocity.magnitude(); - let propagated = integrator.propagate(&gravity, initial, duration, &context)?; - - println!("=== Typed two-body LEO propagation ==="); - println!("duration : {duration}"); - println!("initial radius : {:.3}", initial_radius); - println!("initial speed : {:.6}", initial_speed); - println!("final radius : {:.3}", propagated.position.distance()); - println!("final speed : {:.6}", propagated.velocity.magnitude()); - println!("final position :"); - println!(" x = {:.3}", propagated.position.x()); - println!(" y = {:.3}", propagated.position.y()); - println!(" z = {:.3}", propagated.position.z()); - - Ok(()) -} diff --git a/examples/02_short_arc_wls.rs b/examples/02_short_arc_wls.rs deleted file mode 100644 index 959eeff..0000000 --- a/examples/02_short_arc_wls.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Short-arc weighted least-squares correction. -//! -//! A small POD-like linearized model is used here: -//! -//! residual(t) ≈ Δr + t·Δv -//! -//! where Δr is a radial position correction and Δv is a radial velocity -//! correction. NormalEquations intentionally operates in normalized solver -//! space (f64); the result is wrapped back into qtty quantities immediately -//! at the domain boundary. -//! -//! Run with: -//! cargo run --example 02_short_arc_wls - -#![allow(clippy::print_stdout)] - -use siderust::qtty::unit::Kilometer; -use siderust::qtty::KmPerSecond; -use siderust::qtty::Quantity; -use spod::estimation::{NormalEquations, ParameterKind}; - -fn main() -> Result<(), Box> { - let solve_for = [ParameterKind::Position(0), ParameterKind::Velocity(0)]; - - // Synthetic range-like residuals in km at successive seconds. - // The trend is close to Δr = 0.120 km and Δv = -0.000020 km/s. - let observations = [ - (0.0, 0.1200, 0.0010), - (60.0, 0.1187, 0.0010), - (120.0, 0.1177, 0.0010), - (180.0, 0.1163, 0.0010), - (240.0, 0.1153, 0.0010), - ]; - - let mut normal = NormalEquations::new(solve_for.len()); - for (t_s, residual_km, sigma_km) in observations { - normal.add_row(&[(0, 1.0), (1, t_s)], residual_km, sigma_km)?; - } - - let result = normal.solve()?; - let delta_r = Quantity::::new(result.update[0]); - let delta_v = Quantity::::new(result.update[1]); - - println!("=== Short-arc WLS correction ==="); - println!("solve-for : {:?}, {:?}", solve_for[0], solve_for[1]); - println!("observations : {}", result.n_obs); - println!("Δr : {:.6}", delta_r); - println!("Δv : {:.9}", delta_v); - println!("reduced χ² : {:.6}", result.reduced_chi2()); - - Ok(()) -} diff --git a/examples/README.md b/examples/README.md index 3a1355c..d810046 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,16 +18,12 @@ This makes the examples useful as copyable application code while still showing | Example | What it demonstrates | | --- | --- | -| `01_typed_two_body_propagation` | Builds a typed GCRS LEO state and propagates it with the siderust two-body model through the common `Integrator` interface | -| `02_short_arc_wls` | Assembles and solves a small POD-like weighted least-squares problem, then wraps solver-space corrections back into typed quantities | | `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 | Run them with: ```bash -cargo run --example 01_typed_two_body_propagation -cargo run --example 02_short_arc_wls cargo run --example 03_lambert_earth_to_mars cargo run --example 04_sgp4_from_tle ``` diff --git a/scripts/check_legacy_name_refs.sh b/scripts/check_legacy_name_refs.sh index 1552040..9b97ea8 100755 --- a/scripts/check_legacy_name_refs.sh +++ b/scripts/check_legacy_name_refs.sh @@ -1,65 +1,33 @@ #!/usr/bin/env bash -# Check that legacy project names are limited to documented history and -# explicitly supported REST environment aliases. +# Reject obsolete project namespaces and compatibility aliases from active code +# and current documentation. A single pre-rename ADR is retained as an explicit +# historical record. set -euo pipefail cd "$(dirname "$0")/.." -matches="$(git grep -n -I -E 'siderust-pod|siderust_pod|SIDERUST_POD' -- ':!target/**' || true)" +pattern='SIDERUST_POD_REST_(BIND|OUT)|siderust[-_]pod|spod::(core|dynamics|estimation|observations|products|qc)' +matches="$(git grep -n -I -E "$pattern" -- ':!target/**' || true)" unexpected=() -historical_files=( - CHANGELOG.md - docs/adrs/ADR-0001-workspace-reset.md - docs/adr/0002-no-anyhow.md - docs/adr/0005-lisa-poc-scope.md - docs/adr/0006-finite-diff-stm-allow.md - docs/adrs/ADR-0002-faer-backend.md - docs/adrs/ADR-0004-provider-traits.md - docs/design/plan.md - docs/design/research-requirements-tests-cases.md - docs/design/spod_detailed_design_document.md -) - -is_historical_file() { - local candidate - for candidate in "${historical_files[@]}"; do - [[ "$1" == "$candidate" ]] && return 0 - done - return 1 -} - -is_allowed_historical_command() { - [[ "$1" == "docs/design/research-requirements-tests-cases.md" && - "$2" =~ ^cargo[[:space:]]+(test|bench)[[:space:]]+-p[[:space:]]+siderust-pod- ]] -} while IFS=: read -r file line text; do [[ -z "${file:-}" ]] && continue case "$file" in + scripts/check_legacy_name_refs.sh) + ;; + docs/adrs/ADR-0001-workspace-reset.md) + # Explicitly marked historical record of the pre-rename workspace. + ;; *) - if is_historical_file "$file"; then - if [[ "$text" =~ (github\.com|git[[:space:]]+clone|cargo[[:space:]]+(run|build|test|install)|docker[[:space:]]+(build|run)) ]] && - ! is_allowed_historical_command "$file" "$text"; then - unexpected+=("$file:$line:$text") - fi - elif [[ "$file" == "scripts/check_legacy_name_refs.sh" ]]; then - : - elif [[ "$file" == "README.md" || "$file" == "src/bin/spod-rest.rs" || "$file" == ".github/workflows/ci.yml" ]]; then - if [[ "$text" != *SIDERUST_POD_REST_BIND* && - "$text" != *SIDERUST_POD_REST_OUT* ]]; then - unexpected+=("$file:$line:$text") - fi - else - unexpected+=("$file:$line:$text") - fi + unexpected+=("$file:$line:$text") ;; esac done <<< "$matches" if ((${#unexpected[@]} > 0)); then - printf 'Unexpected legacy project-name references found:\n' + printf 'Unexpected obsolete project namespace references found:\n' printf ' %s\n' "${unexpected[@]}" exit 1 fi diff --git a/src/bin/spod-rest.rs b/src/bin/spod-rest.rs index 791a1d6..60441ef 100644 --- a/src/bin/spod-rest.rs +++ b/src/bin/spod-rest.rs @@ -31,57 +31,30 @@ use std::path::PathBuf; use spod::service::rest::{router, AppState}; fn resolve_setting( - new: Result, - legacy: Result, - internal_default: Result, + value: Result, default: &str, - new_name: &str, - legacy_name: &str, -) -> anyhow::Result<(String, bool)> { - match new { - Ok(value) => Ok((value, false)), - Err(std::env::VarError::NotPresent) => match legacy { - Ok(value) => Ok((value, true)), - Err(std::env::VarError::NotPresent) => match internal_default { - Ok(value) => Ok((value, false)), - Err(std::env::VarError::NotPresent) => Ok((default.to_owned(), false)), - Err(std::env::VarError::NotUnicode(_)) => { - anyhow::bail!("internal REST default is not valid Unicode") - } - }, - Err(std::env::VarError::NotUnicode(_)) => { - anyhow::bail!("{legacy_name} is not valid Unicode") - } - }, - Err(std::env::VarError::NotUnicode(_)) => anyhow::bail!("{new_name} is not valid Unicode"), + name: &str, +) -> anyhow::Result { + match value { + Ok(value) => Ok(value), + Err(std::env::VarError::NotPresent) => Ok(default.to_owned()), + Err(std::env::VarError::NotUnicode(_)) => anyhow::bail!("{name} is not valid Unicode"), } } #[tokio::main] async fn main() -> anyhow::Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - let (bind, bind_legacy) = resolve_setting( + let bind = resolve_setting( std::env::var("SPOD_REST_BIND"), - std::env::var("SIDERUST_POD_REST_BIND"), - std::env::var("SPOD_REST_DEFAULT_BIND"), "127.0.0.1:8080", "SPOD_REST_BIND", - "SIDERUST_POD_REST_BIND", )?; - if bind_legacy { - log::warn!("SIDERUST_POD_REST_BIND is deprecated; use SPOD_REST_BIND instead"); - } - let (out, out_legacy) = resolve_setting( + let out = resolve_setting( std::env::var("SPOD_REST_OUT"), - std::env::var("SIDERUST_POD_REST_OUT"), - Err(std::env::VarError::NotPresent), &std::env::temp_dir().join("spod-rest").display().to_string(), "SPOD_REST_OUT", - "SIDERUST_POD_REST_OUT", )?; - if out_legacy { - log::warn!("SIDERUST_POD_REST_OUT is deprecated; use SPOD_REST_OUT instead"); - } let out = PathBuf::from(out); std::fs::create_dir_all(&out)?; let state = AppState::new(out); @@ -95,9 +68,6 @@ async fn main() -> anyhow::Result<()> { #[cfg(test)] mod tests { use super::resolve_setting; - use std::ffi::OsString; - #[cfg(unix)] - use std::os::unix::ffi::OsStringExt; fn present(value: &str) -> Result { Ok(value.to_owned()) @@ -105,89 +75,29 @@ mod tests { #[test] fn new_variable_wins() { - let result = resolve_setting( - present("new"), - present("legacy"), - Err(std::env::VarError::NotPresent), - "default", - "NEW", - "LEGACY", - ) - .unwrap(); - assert_eq!(result, ("new".to_owned(), false)); - } - - #[test] - fn legacy_variable_is_used_when_new_is_absent() { - let result = resolve_setting( - Err(std::env::VarError::NotPresent), - present("legacy"), - Err(std::env::VarError::NotPresent), - "default", - "NEW", - "LEGACY", - ) - .unwrap(); - assert_eq!(result, ("legacy".to_owned(), true)); + let result = resolve_setting(present("new"), "default", "NEW").unwrap(); + assert_eq!(result, "new"); } #[test] fn default_is_used_when_variables_are_absent() { - let result = resolve_setting( - Err(std::env::VarError::NotPresent), - Err(std::env::VarError::NotPresent), - Err(std::env::VarError::NotPresent), - "default", - "NEW", - "LEGACY", - ) - .unwrap(); - assert_eq!(result, ("default".to_owned(), false)); + let result = + resolve_setting(Err(std::env::VarError::NotPresent), "default", "NEW").unwrap(); + assert_eq!(result, "default"); } #[cfg(unix)] #[test] - fn invalid_new_variable_is_rejected() { + fn invalid_variable_is_rejected() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; let result = resolve_setting( Err(std::env::VarError::NotUnicode(OsString::from_vec(vec![ 0xff, ]))), - present("legacy"), - Err(std::env::VarError::NotPresent), "default", - "NEW", - "LEGACY", + "SPOD_REST_BIND", ); assert!(result.is_err()); } - - #[cfg(unix)] - #[test] - fn invalid_legacy_variable_is_rejected() { - let result = resolve_setting( - Err(std::env::VarError::NotPresent), - Err(std::env::VarError::NotUnicode(OsString::from_vec(vec![ - 0xff, - ]))), - Err(std::env::VarError::NotPresent), - "default", - "NEW", - "LEGACY", - ); - assert!(result.is_err()); - } - - #[test] - fn internal_default_is_used_after_public_variables() { - let result = resolve_setting( - Err(std::env::VarError::NotPresent), - Err(std::env::VarError::NotPresent), - present("docker-default"), - "native-default", - "NEW", - "LEGACY", - ) - .unwrap(); - assert_eq!(result, ("docker-default".to_owned(), false)); - } } diff --git a/src/core/arc.rs b/src/core/arc.rs deleted file mode 100644 index 01e43ad..0000000 --- a/src/core/arc.rs +++ /dev/null @@ -1,193 +0,0 @@ -//! Arc definitions for POD runs. -//! -//! A POD *arc* is a contiguous span of time over which a single dynamic -//! and observational hypothesis is fitted. Per design §6.1 the arc is -//! identified by a stable opaque [`ArcId`] string and bounded by typed -//! [`Time`] start/stop instants, with an optional integration step -//! hint expressed as a typed [`siderust::qtty::Second`]. -//! -//! Arcs are deliberately TT-anchored so downstream propagation code does -//! not have to disambiguate which time scale `start`/`stop` are expressed -//! in. Conversions from UTC inputs happen at the IO boundary. - -use serde::{Deserialize, Serialize}; - -use siderust::qtty::Second; -use siderust::tempoch::{Time, TT}; - -/// Opaque identifier for an arc. -/// -/// Stable across reruns of the same configuration; used as the key in -/// run-level outputs (residuals, products, manifests) so arcs can be -/// joined across artifacts without ambiguity. -/// -/// # Examples -/// -/// ``` -/// use spod::core::arc::ArcId; -/// let id = ArcId::new("LEO-2026-05-11"); -/// assert_eq!(id.as_str(), "LEO-2026-05-11"); -/// ``` -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ArcId(String); - -impl ArcId { - /// Wrap a string into an [`ArcId`]. - /// - /// # Examples - /// - /// ``` - /// use spod::core::arc::ArcId; - /// let id = ArcId::new("arc-001"); - /// assert_eq!(id.as_str(), "arc-001"); - /// ``` - pub fn new(s: impl Into) -> Self { - Self(s.into()) - } - - /// Borrow the underlying string. - /// - /// # Examples - /// - /// ``` - /// use spod::core::arc::ArcId; - /// assert_eq!(ArcId::new("a").as_str(), "a"); - /// ``` - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl std::fmt::Display for ArcId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -/// Inclusive arc time interval and identification metadata. -/// -/// Anchored on the [`TT`] time scale per design §6.1; the IO layer is -/// responsible for converting external (UTC, GPS, …) timestamps into TT -/// before constructing an [`ArcDefinition`]. -/// -/// `step_hint` is *advisory* — propagators may pick a different step. It -/// is preserved in the [`crate::core::manifest::RunManifest`] so reruns can -/// reproduce the original hint exactly. -/// -/// # Examples -/// -/// ``` -/// use spod::core::arc::{ArcDefinition, ArcId}; -/// use siderust::tempoch::{J2000Seconds, TT}; -/// use siderust::qtty::Second; -/// -/// let start = J2000Seconds::::try_new(Second::new(0.0)).unwrap().to_j2000s(); -/// let stop = J2000Seconds::::try_new(Second::new(86_400.0)).unwrap().to_j2000s(); -/// let arc = ArcDefinition { -/// id: ArcId::new("demo"), -/// start, -/// stop, -/// step_hint: Some(Second::new(60.0)), -/// }; -/// assert!((arc.duration().value() - 86_400.0).abs() < 1e-6); -/// ``` -#[derive(Debug, Clone, PartialEq)] -pub struct ArcDefinition { - /// Stable identifier for this arc. - pub id: ArcId, - /// Inclusive start instant on the [`TT`] scale. - pub start: Time, - /// Inclusive stop instant on the [`TT`] scale. - pub stop: Time, - /// Optional integration-step hint as a typed duration in seconds. - pub step_hint: Option, -} - -impl ArcDefinition { - /// Total wall duration of the arc as a typed [`Second`]. - /// - /// Returns a non-negative duration when `stop >= start`. The value is - /// well-defined-but-possibly-negative for inverted arcs; callers - /// should [`Self::is_valid`] first if input ordering is not trusted. - /// - /// # Examples - /// - /// ``` - /// use spod::core::arc::{ArcDefinition, ArcId}; - /// use siderust::tempoch::{J2000Seconds, TT}; - /// use siderust::qtty::Second; - /// let arc = ArcDefinition { - /// id: ArcId::new("a"), - /// start: J2000Seconds::::try_new(Second::new(0.0)).unwrap().to_j2000s(), - /// stop: J2000Seconds::::try_new(Second::new(86_400.0)).unwrap().to_j2000s(), - /// step_hint: None, - /// }; - /// assert!((arc.duration().value() - 86_400.0).abs() < 1e-6); - /// ``` - pub fn duration(&self) -> Second { - self.stop - self.start - } - - /// `true` iff `stop >= start`. - /// - /// # Examples - /// - /// ``` - /// use spod::core::arc::{ArcDefinition, ArcId}; - /// use siderust::tempoch::{J2000Seconds, TT}; - /// use siderust::qtty::Second; - /// let arc = ArcDefinition { - /// id: ArcId::new("a"), - /// start: J2000Seconds::::try_new(Second::new(0.0)).unwrap().to_j2000s(), - /// stop: J2000Seconds::::try_new(Second::new(86_400.0)).unwrap().to_j2000s(), - /// step_hint: None, - /// }; - /// assert!(arc.is_valid()); - /// ``` - pub fn is_valid(&self) -> bool { - self.stop >= self.start - } -} - -#[cfg(test)] -mod tests { - use super::*; - use siderust::tempoch::J2000Seconds; - - fn at(secs: f64) -> Time { - J2000Seconds::::try_new(Second::new(secs)) - .unwrap() - .to_j2000s() - } - - #[test] - fn duration_in_seconds_matches_one_day() { - let arc = ArcDefinition { - id: ArcId::new("d"), - start: at(0.0), - stop: at(86_400.0), - step_hint: None, - }; - let dt = arc.duration().value(); - assert!((dt - 86_400.0).abs() < 1e-6); - assert!(arc.is_valid()); - } - - #[test] - fn inverted_arc_is_invalid() { - let arc = ArcDefinition { - id: ArcId::new("d"), - start: at(86_400.0), - stop: at(0.0), - step_hint: None, - }; - assert!(!arc.is_valid()); - } - - #[test] - fn arc_id_round_trip() { - let id = ArcId::new("orbit-7"); - assert_eq!(id.as_str(), "orbit-7"); - assert_eq!(format!("{id}"), "orbit-7"); - } -} diff --git a/src/core/covariance.rs b/src/core/covariance.rs deleted file mode 100644 index 91c40ee..0000000 --- a/src/core/covariance.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! Parameter-vector covariance. -//! -//! State-only, frame/center-aware covariance lives in `siderust` as -//! `siderust::astro::dynamics::covariance::StateCovariance` and is re-used -//! directly. This module adds [`ParameterCovariance`] for the *full* -//! estimator parameter vector, which mixes state, receiver clocks, biases, -//! scales, and ambiguities (see [`crate::core::parameter::ParameterOrdering`]). - -use crate::core::parameter::ParameterOrdering; -use serde::{Deserialize, Serialize}; - -/// Symmetric covariance matrix over the full estimator parameter vector. -/// -/// Stored row-major in `data` with `params.len()` rows and columns. Symmetry -/// is *not* enforced at construction; callers should validate via -/// [`ParameterCovariance::validate_symmetric`]. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ParameterCovariance { - /// Parameter ordering defining the row/column correspondence. - pub params: ParameterOrdering, - /// Row-major `n x n` data, where `n = params.len()`. - pub data: Vec, -} - -/// Errors when validating or transforming a parameter covariance. -#[derive(Debug, thiserror::Error)] -pub enum CovarianceError { - /// Row/column count does not match the expected square dimension. - #[error("dimension mismatch: expected {expected}x{expected}, got {actual} entries")] - Dimension { - /// Expected dimension (will be squared for matrix size). - expected: usize, - /// Actual number of elements provided. - actual: usize, - }, - /// Matrix is not symmetric within tolerance. - #[error("not symmetric: |C[{i},{j}] - C[{j},{i}]| = {delta} > tol {tol}")] - NotSymmetric { - /// Row index. - i: usize, - /// Column index. - j: usize, - /// Absolute difference between symmetric pairs. - delta: f64, - /// Tolerance threshold that was exceeded. - tol: f64, - }, - /// Diagonal element is negative, violating positive semi-definiteness. - #[error("not positive semi-definite: diagonal entry {i} = {value} < 0")] - NotPsdDiagonal { - /// Diagonal index. - i: usize, - /// Non-negative value found. - value: f64, - }, -} - -impl ParameterCovariance { - /// Dimension of the parameter covariance matrix (number of parameters). - pub fn n(&self) -> usize { - self.params.len() - } - - /// Verify dimensions match `n x n`. - pub fn validate_dim(&self) -> Result<(), CovarianceError> { - let n = self.n(); - let expected = n * n; - if self.data.len() != expected { - return Err(CovarianceError::Dimension { - expected: n, - actual: self.data.len(), - }); - } - Ok(()) - } - - /// Verify symmetry within `tol` element-wise. - pub fn validate_symmetric(&self, tol: f64) -> Result<(), CovarianceError> { - self.validate_dim()?; - let n = self.n(); - for i in 0..n { - for j in (i + 1)..n { - let a = self.data[i * n + j]; - let b = self.data[j * n + i]; - let delta = (a - b).abs(); - if delta > tol { - return Err(CovarianceError::NotSymmetric { i, j, delta, tol }); - } - } - } - Ok(()) - } - - /// Cheap PSD necessary-condition check: all diagonal entries non-negative. - /// A full PSD test (eigendecomposition) belongs in the estimator crate. - pub fn validate_diagonal_nonneg(&self) -> Result<(), CovarianceError> { - self.validate_dim()?; - let n = self.n(); - for i in 0..n { - let v = self.data[i * n + i]; - if v < 0.0 { - return Err(CovarianceError::NotPsdDiagonal { i, value: v }); - } - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::parameter::{Parameter, ParameterKind}; - - fn ord(n: usize) -> ParameterOrdering { - ParameterOrdering { - params: (0..n) - .map(|_| Parameter { - kind: ParameterKind::DragScale, - initial_value: 1.0, - apriori_sigma: None, - }) - .collect(), - } - } - - #[test] - fn validates_symmetry() { - let cov = ParameterCovariance { - params: ord(2), - data: vec![1.0, 0.5, 0.5, 2.0], - }; - cov.validate_symmetric(1e-12).unwrap(); - cov.validate_diagonal_nonneg().unwrap(); - } - - #[test] - fn detects_asymmetry() { - let cov = ParameterCovariance { - params: ord(2), - data: vec![1.0, 0.5, 0.6, 2.0], - }; - assert!(cov.validate_symmetric(1e-12).is_err()); - } - - #[test] - fn detects_negative_diagonal() { - let cov = ParameterCovariance { - params: ord(1), - data: vec![-1.0], - }; - assert!(cov.validate_diagonal_nonneg().is_err()); - } -} diff --git a/src/core/dataset.rs b/src/core/dataset.rs deleted file mode 100644 index 4245774..0000000 --- a/src/core/dataset.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Dataset reference with content hashing for run manifests. - -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; - -use sha2::{Digest, Sha256}; - -/// Reference to an input or output dataset used by a POD run. -/// -/// A `DatasetRef` records the canonical path, an opaque kind tag (e.g. -/// `"sp3"`, `"rinex_obs"`, `"qc_json"`), the byte length, and the SHA-256 -/// content hash. It is the building block of [`crate::core::manifest::RunManifest`]. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct DatasetRef { - /// Path to the dataset file. - pub path: PathBuf, - /// Opaque content type tag (e.g., `"sp3"`, `"rinex_obs"`). - pub kind: String, - /// File size in bytes. - pub bytes: u64, - /// Lower-case hex SHA-256 digest of the file contents. - pub sha256: String, -} - -impl DatasetRef { - /// Hash a file on disk and build a [`DatasetRef`]. - pub fn from_file(path: impl AsRef, kind: impl Into) -> std::io::Result { - let path = path.as_ref().to_path_buf(); - let bytes_vec = std::fs::read(&path)?; - let bytes = bytes_vec.len() as u64; - let mut hasher = Sha256::new(); - hasher.update(&bytes_vec); - let sha256 = hex::encode(hasher.finalize()); - Ok(Self { - path, - kind: kind.into(), - bytes, - sha256, - }) - } - - /// Hash a byte buffer directly (useful for in-memory artifacts). - pub fn from_bytes(path: impl Into, kind: impl Into, data: &[u8]) -> Self { - let mut hasher = Sha256::new(); - hasher.update(data); - Self { - path: path.into(), - kind: kind.into(), - bytes: data.len() as u64, - sha256: hex::encode(hasher.finalize()), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn from_bytes_hashes_deterministically() { - let a = DatasetRef::from_bytes("/tmp/a", "test", b"hello world"); - let b = DatasetRef::from_bytes("/tmp/a", "test", b"hello world"); - assert_eq!(a, b); - // Known SHA-256 of "hello world". - assert_eq!( - a.sha256, - "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" - ); - assert_eq!(a.bytes, 11); - } -} diff --git a/src/core/error.rs b/src/core/error.rs deleted file mode 100644 index d236784..0000000 --- a/src/core/error.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! POD error taxonomy. -//! -//! `PodError` is the top-level error returned by service-level POD -//! operations. Library crates (`-io`, `-observations`, `-estimation`, ...) -//! define their own `thiserror` enums and convert into `PodError` at the -//! workspace boundary. - -use thiserror::Error; - -/// Top-level POD error. -#[derive(Debug, Error)] -pub enum PodError { - /// Capability scoped to a future milestone is not yet implemented. - #[error("not implemented: {0}")] - NotImplemented(String), - - /// Invalid configuration or input outside the supported envelope. - #[error("invalid input: {0}")] - Invalid(String), - - /// Underlying I/O error. - #[error(transparent)] - Io(#[from] std::io::Error), - - /// JSON serialisation/deserialisation error. - #[error(transparent)] - Json(#[from] serde_json::Error), -} diff --git a/src/core/manifest.rs b/src/core/manifest.rs deleted file mode 100644 index ce7df70..0000000 --- a/src/core/manifest.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Run-level provenance. -//! -//! [`RunManifest`] captures everything needed to reproduce or audit a POD -//! run: tool version, configuration hash, hashed inputs, hashed outputs, -//! and the wall-clock interval. JSON serialisation is deterministic -//! (sorted keys, no incidental ordering) so two identical runs produce -//! byte-identical manifests. - -use super::dataset::DatasetRef; - -use serde::{Deserialize, Serialize}; - -/// Reproducible record of a single POD run. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RunManifest { - /// Free-form identifier (e.g. UUID) for the run. - pub run_id: String, - /// Tool version string (e.g. `"spod 0.0.0"`). - pub tool_version: String, - /// Hex SHA-256 of the canonical run configuration document. - pub config_sha256: String, - /// Inputs consumed by the run, sorted by `(kind, path)` for determinism. - pub inputs: Vec, - /// Artifacts produced by the run, sorted by `(kind, path)` for determinism. - pub outputs: Vec, - /// RFC-3339 UTC timestamp of run start. - pub started_at: String, - /// RFC-3339 UTC timestamp of run end. - pub finished_at: String, -} - -impl RunManifest { - /// Sort inputs and outputs into the canonical order. Call before serialising. - pub fn canonicalize(&mut self) { - let key = |d: &DatasetRef| (d.kind.clone(), d.path.clone()); - self.inputs.sort_by_key(key); - self.outputs.sort_by_key(&key); - } - - /// Serialize to a deterministic pretty-printed JSON string. - pub fn to_json_pretty(&self) -> Result { - let mut clone = self.clone(); - clone.canonicalize(); - serde_json::to_string_pretty(&clone) - } -} - -/// Embedded JSON-Schema (draft-07) for a v1 [`RunManifest`]. -/// -/// The schema lives next to this source file under `schema/run_manifest.v1.json` -/// and is included verbatim at build time so the crate ships a single source -/// of truth without filesystem access at runtime. -pub const RUN_MANIFEST_SCHEMA_V1: &str = include_str!("../schema/run_manifest.v1.json"); - -/// Embedded JSON-Schema (draft-07) for a v1 QC report (`qc.v1`). -pub const QC_SCHEMA_V1: &str = include_str!("../schema/qc.v1.json"); - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - fn sample() -> RunManifest { - RunManifest { - run_id: "00000000-0000-0000-0000-000000000000".into(), - tool_version: "spod test".into(), - config_sha256: "0".repeat(64), - inputs: vec![ - DatasetRef { - path: PathBuf::from("/in/b.sp3"), - kind: "sp3".into(), - bytes: 1, - sha256: "1".repeat(64), - }, - DatasetRef { - path: PathBuf::from("/in/a.sp3"), - kind: "sp3".into(), - bytes: 1, - sha256: "2".repeat(64), - }, - ], - outputs: vec![], - started_at: "2026-05-11T22:00:00Z".into(), - finished_at: "2026-05-11T22:00:01Z".into(), - } - } - - #[test] - fn json_round_trip_is_deterministic() { - let s1 = sample().to_json_pretty().unwrap(); - let s2 = sample().to_json_pretty().unwrap(); - assert_eq!(s1, s2); - // Canonicalisation sorts inputs by (kind, path). - let parsed: RunManifest = serde_json::from_str(&s1).unwrap(); - assert_eq!(parsed.inputs[0].path.to_str().unwrap(), "/in/a.sp3"); - } - - /// The embedded schema must parse as a JSON object and declare the keys - /// the manifest serialises into; this prevents silent drift between - /// `RunManifest` and `schema/run_manifest.v1.json`. - #[test] - fn embedded_schema_lists_all_required_fields() { - let schema: serde_json::Value = - serde_json::from_str(RUN_MANIFEST_SCHEMA_V1).expect("schema is JSON"); - let req = schema["required"].as_array().expect("required is array"); - let req: Vec<&str> = req.iter().map(|v| v.as_str().unwrap()).collect(); - for k in [ - "run_id", - "tool_version", - "config_sha256", - "inputs", - "outputs", - "started_at", - "finished_at", - ] { - assert!(req.contains(&k), "schema missing required field {k}"); - } - let serialised = sample().to_json_pretty().unwrap(); - let v: serde_json::Value = serde_json::from_str(&serialised).unwrap(); - for k in &req { - assert!( - v.get(*k).is_some(), - "manifest missing schema-required field {k}" - ); - } - } - - #[test] - fn qc_schema_is_well_formed_v1() { - let schema: serde_json::Value = serde_json::from_str(QC_SCHEMA_V1).unwrap(); - assert_eq!( - schema["properties"]["schema"]["const"].as_str(), - Some("qc.v1") - ); - let req = schema["required"].as_array().unwrap(); - let req: Vec<&str> = req.iter().map(|v| v.as_str().unwrap()).collect(); - assert!(req.contains(&"schema")); - assert!(req.contains(&"rtn")); - } -} diff --git a/src/core/mod.rs b/src/core/mod.rs deleted file mode 100644 index 646bbcd..0000000 --- a/src/core/mod.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! # spod core -//! -//! POD-specific domain primitives. Geometric and physical types from -//! `siderust`, `affn`, `qtty`, and `tempoch` are used directly throughout -//! the codebase; this module contains only what cannot be expressed by those -//! upstream crates. -//! -//! ## Contents -//! -//! - POD error taxonomy ([`error::PodError`]) -//! - Run-level provenance ([`manifest::RunManifest`], [`dataset::DatasetRef`]) -//! - Estimator parameter typing ([`parameter::ParameterKind`], -//! [`parameter::Parameter`], [`covariance::ParameterCovariance`]) -//! - Arc definitions ([`arc::ArcId`], [`arc::ArcDefinition`]) -//! - Provider traits bridging POD code to external services ([`providers`]) - -#![forbid(unsafe_code)] - -pub mod arc; -pub mod covariance; -pub mod dataset; -pub mod error; -pub mod manifest; -pub mod parameter; -pub mod providers; - -pub use arc::{ArcDefinition, ArcId}; -pub use error::PodError; diff --git a/src/core/parameter.rs b/src/core/parameter.rs deleted file mode 100644 index 5e6ccf0..0000000 --- a/src/core/parameter.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! Parameter typing for the POD estimator. -//! -//! The estimator solves for a heterogeneous parameter vector: orbital -//! state components, receiver clock, drag scale, SRP scale, carrier float -//! ambiguities, range biases, etc. [`ParameterKind`] tags each scalar slot -//! so that downstream code (covariance ordering, residual reports, manifest -//! dumps) preserves semantic meaning. - -use serde::{Deserialize, Serialize}; - -/// Semantic kind of a single estimated parameter. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ParameterKind { - /// X component of Cartesian position at a reference epoch. - StatePositionX, - /// Y component of Cartesian position at a reference epoch. - StatePositionY, - /// Z component of Cartesian position at a reference epoch. - StatePositionZ, - /// X component of Cartesian velocity at a reference epoch. - StateVelocityX, - /// Y component of Cartesian velocity at a reference epoch. - StateVelocityY, - /// Z component of Cartesian velocity at a reference epoch. - StateVelocityZ, - /// Receiver clock bias (seconds) for a given station/receiver id. - ReceiverClockBias { - /// Receiver or station identifier. - receiver_id: String, - }, - /// Drag scale factor (dimensionless multiplier on the nominal drag force). - DragScale, - /// Solar-radiation-pressure scale factor. - SrpScale, - /// Carrier-phase float ambiguity for a (receiver, satellite, signal) triple, in cycles. - CarrierAmbiguity { - /// Receiver or station identifier. - receiver_id: String, - /// Satellite PRN or identifier. - satellite_id: String, - /// Signal band/code designation (e.g., `"L1"`). - signal: String, - }, - /// Range bias for an SLR station, in meters. - SlrRangeBias { - /// SLR station identifier. - station_id: String, - }, - /// Empirical acceleration in a local frame (e.g. RTN) component. - EmpiricalAcceleration { - /// Frame identifier (e.g., `"RTN"`). - frame: String, - /// Component axis (`'R'`, `'T'`, `'N'`, etc.). - component: char, - }, - /// Custom user-defined parameter for extension models. - Custom(String), -} - -/// One slot in the estimator's parameter vector. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Parameter { - /// Semantic kind of this parameter. - pub kind: ParameterKind, - /// Initial value used by the estimator (engineering units appropriate to `kind`). - pub initial_value: f64, - /// Optional a-priori 1-sigma constraint; `None` means unconstrained. - pub apriori_sigma: Option, -} - -/// Total parameter ordering for a run; the estimator must use this to lay -/// out residual rows and design columns. -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] -pub struct ParameterOrdering { - /// List of parameters in the order used by the estimator. - pub params: Vec, -} - -impl ParameterOrdering { - /// Number of parameters in the ordering. - pub fn len(&self) -> usize { - self.params.len() - } - /// Whether the parameter list is empty. - pub fn is_empty(&self) -> bool { - self.params.is_empty() - } - /// Index of the first parameter matching `kind`, if any. - pub fn index_of(&self, kind: &ParameterKind) -> Option { - self.params.iter().position(|p| &p.kind == kind) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ordering_lookup() { - let ord = ParameterOrdering { - params: vec![ - Parameter { - kind: ParameterKind::StatePositionX, - initial_value: 7000.0, - apriori_sigma: Some(0.1), - }, - Parameter { - kind: ParameterKind::DragScale, - initial_value: 1.0, - apriori_sigma: Some(0.2), - }, - ], - }; - assert_eq!(ord.index_of(&ParameterKind::DragScale), Some(1)); - assert_eq!(ord.index_of(&ParameterKind::SrpScale), None); - } -} diff --git a/src/core/providers/eop.rs b/src/core/providers/eop.rs deleted file mode 100644 index e3b78ec..0000000 --- a/src/core/providers/eop.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! [`EarthOrientationProvider`] trait. -//! -//! Concrete implementations typically wrap `siderust::tempoch::eop` or -//! `siderust::astro::eop`. - -use std::error::Error; - -/// Minimal Earth-orientation interface used by frame-rotation code. -/// -/// All inputs are UTC seconds since J2000 UTC. All outputs are expressed -/// in radians (polar motion) or seconds (UT1-UTC). -/// -/// # Examples -/// -/// ``` -/// use spod::core::providers::EarthOrientationProvider; -/// -/// struct ZeroEop; -/// impl EarthOrientationProvider for ZeroEop { -/// type Error = std::io::Error; -/// fn ut1_minus_utc(&self, _t: f64) -> Result { Ok(0.0) } -/// fn polar_motion_x(&self, _t: f64) -> Result { Ok(0.0) } -/// fn polar_motion_y(&self, _t: f64) -> Result { Ok(0.0) } -/// } -/// -/// let p = ZeroEop; -/// assert_eq!(p.ut1_minus_utc(0.0).unwrap(), 0.0); -/// ``` -pub trait EarthOrientationProvider { - /// Error type for EOP queries. - type Error: Error + Send + Sync + 'static; - - /// UT1 - UTC offset (seconds) at the given UTC epoch (seconds since J2000 UTC). - fn ut1_minus_utc(&self, epoch_seconds_utc: f64) -> Result; - /// Polar motion x component (radians) at the given UTC epoch. - fn polar_motion_x(&self, epoch_seconds_utc: f64) -> Result; - /// Polar motion y component (radians) at the given UTC epoch. - fn polar_motion_y(&self, epoch_seconds_utc: f64) -> Result; -} diff --git a/src/core/providers/ephemeris.rs b/src/core/providers/ephemeris.rs deleted file mode 100644 index ce343f1..0000000 --- a/src/core/providers/ephemeris.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! [`EphemerisProvider`] trait. -//! -//! Concrete implementations should typically wrap -//! `siderust::calculus::ephemeris` or sibling crates such as -//! `siderust-spice`. The state representation is left associated so POD -//! code is not pinned to a single `siderust` view. - -use std::error::Error; - -/// Minimal ephemeris query interface used by POD force models. -/// -/// The semantics of `epoch_seconds_tdb` are TDB seconds since J2000. -/// Concrete typed-`Time` constructors live on the implementer -/// (e.g. `crate::spice::SpiceEphemerisProvider::state_at`). -/// -/// # Examples -/// -/// ``` -/// use spod::core::providers::EphemerisProvider; -/// -/// struct DummyState; -/// struct DummyProvider; -/// impl EphemerisProvider for DummyProvider { -/// type State = DummyState; -/// type Error = std::io::Error; -/// fn state(&self, _id: i32, _t: f64) -> Result { -/// Ok(DummyState) -/// } -/// } -/// -/// let p = DummyProvider; -/// let _ = p.state(399, 0.0).unwrap(); -/// ``` -pub trait EphemerisProvider { - /// State representation type (framework-specific). - type State; - /// Error type for state queries. - type Error: Error + Send + Sync + 'static; - - /// Return a state for `body_naif_id` at the given `epoch_seconds_tdb`. - fn state(&self, body_naif_id: i32, epoch_seconds_tdb: f64) -> Result; -} diff --git a/src/core/providers/frame_transform.rs b/src/core/providers/frame_transform.rs deleted file mode 100644 index 4225836..0000000 --- a/src/core/providers/frame_transform.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! [`FrameTransformProvider`] trait. -//! -//! Concrete frame ids are stringly-typed at this layer because POD code -//! must not leak `affn` frame markers across crate boundaries; concrete -//! adapters resolve them statically. - -use std::error::Error; - -/// Minimal frame-rotation interface used by observation models. -/// -/// `from` and `to` are opaque frame identifiers — the implementer maps -/// them to typed `affn` frames. The returned rotation takes a vector -/// expressed in `from` and returns the same vector in `to` at the given -/// TDB epoch. -/// -/// # Examples -/// -/// ``` -/// use spod::core::providers::FrameTransformProvider; -/// -/// struct IdentityRotation; -/// struct IdentityProvider; -/// impl FrameTransformProvider for IdentityProvider { -/// type Rotation = IdentityRotation; -/// type Error = std::io::Error; -/// fn rotation(&self, _f: &str, _t: &str, _e: f64) -> Result { -/// Ok(IdentityRotation) -/// } -/// } -/// let p = IdentityProvider; -/// let _ = p.rotation("GCRS", "ITRF", 0.0).unwrap(); -/// ``` -pub trait FrameTransformProvider { - /// Rotation representation type (framework-specific). - type Rotation; - /// Error type for frame-transform queries. - type Error: Error + Send + Sync + 'static; - - /// Compute the rotation from frame `from` to frame `to` at TDB epoch. - fn rotation( - &self, - from: &str, - to: &str, - epoch_seconds_tdb: f64, - ) -> Result; -} diff --git a/src/core/providers/mod.rs b/src/core/providers/mod.rs deleted file mode 100644 index d3b1a1e..0000000 --- a/src/core/providers/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Provider traits bridging POD code to external services. -//! -//! These traits define the minimal interfaces that POD force models, -//! observation models, and the estimator use to query Earth orientation, -//! ephemerides, and frame transforms. Concrete implementations are supplied -//! by the caller (SPICE, EOP files, siderust services, etc.). -//! -//! Gravity and atmosphere traits live directly in -//! [`siderust::astro::dynamics::gravity`] and -//! [`siderust::astro::dynamics::density`]; import them from there. - -pub mod eop; -pub mod ephemeris; -pub mod frame_transform; - -pub use eop::EarthOrientationProvider; -pub use ephemeris::EphemerisProvider; -pub use frame_transform::FrameTransformProvider; diff --git a/src/dynamics/empirical_periodic.rs b/src/dynamics/empirical_periodic.rs deleted file mode 100644 index 29ebc46..0000000 --- a/src/dynamics/empirical_periodic.rs +++ /dev/null @@ -1,229 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Periodic empirical accelerations (1-CPR, 2-CPR) in the RTN frame. -//! -//! Upstream `siderust` ships only [`siderust::astro::dynamics::forces::EmpiricalAcceleration`], -//! a *constant* RTN acceleration. Sequential-filter POD typically also -//! estimates once-per-revolution (1-CPR) and twice-per-revolution (2-CPR) -//! sinusoidal coefficients. Those are POD-specific composition; they live -//! here, not in the generic `siderust-dynamics` crate. -//! -//! The phase of the periodic term is driven by an externally-supplied -//! orbital period and a reference epoch, so the model is force-model-pure -//! (no implicit two-body propagation inside). -//! -//! ## Equations -//! -//! Define the phase angle -//! ```text -//! θ(t) = n · ω · (t - t_ref), ω = 2π / T_orbit -//! ``` -//! where `n ∈ {1, 2}` selects the harmonic. -//! -//! ```text -//! a_R(t) = R_cos · cos θ + R_sin · sin θ -//! a_T(t) = T_cos · cos θ + T_sin · sin θ -//! a_N(t) = N_cos · cos θ + N_sin · sin θ -//! a_GCRS(t) = R_{GCRS←RTN}(state) · [a_R, a_T, a_N] -//! ``` - -use siderust::astro::dynamics::context::DynamicsContext; -use siderust::astro::dynamics::forces::AccelerationModel; -use siderust::astro::dynamics::frames::{LocalOrbitalFrame, RTN}; -use siderust::astro::dynamics::state::{Acceleration, AccelerationUnit, OrbitState}; -use siderust::coordinates::centers::Geocentric; -use siderust::coordinates::frames::GCRS; -use siderust::principia::PrincipiaError; -use siderust::qtty::{KmPerSecondsSquared, Second}; -use siderust::tempoch::{JD, TT}; -use siderust::time::JulianDate; - -/// Harmonic order of the periodic empirical acceleration. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PeriodicHarmonic { - /// Once-per-revolution. - OncePerRev, - /// Twice-per-revolution. - TwicePerRev, -} - -impl PeriodicHarmonic { - /// Numerical multiplier (1 or 2). - pub fn multiplier(self) -> f64 { - match self { - PeriodicHarmonic::OncePerRev => 1.0, - PeriodicHarmonic::TwicePerRev => 2.0, - } - } -} - -/// Periodic empirical acceleration in the RTN frame, parameterised by the -/// orbital period and a reference epoch. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::{EmpiricalPeriodicAcceleration, PeriodicHarmonic}; -/// use siderust::qtty::{KmPerSecondsSquared, Second}; -/// use siderust::time::JulianDate; -/// -/// let f = EmpiricalPeriodicAcceleration::new( -/// PeriodicHarmonic::OncePerRev, -/// JulianDate::new(2_451_545.0), -/// Second::new(5400.0), -/// KmPerSecondsSquared::new(0.0), KmPerSecondsSquared::new(0.0), -/// KmPerSecondsSquared::new(1e-12), KmPerSecondsSquared::new(0.0), -/// KmPerSecondsSquared::new(0.0), KmPerSecondsSquared::new(0.0), -/// ); -/// assert_eq!(f.harmonic, PeriodicHarmonic::OncePerRev); -/// ``` -#[derive(Debug, Clone, Copy)] -pub struct EmpiricalPeriodicAcceleration { - /// Harmonic of the orbit period. - pub harmonic: PeriodicHarmonic, - /// Reference epoch defining `θ = 0`. - pub epoch_ref: JulianDate, - /// Orbital period `T_orbit` used to compute `ω = 2π / T_orbit`. - pub period: Second, - /// Radial cosine coefficient. - pub r_cos: KmPerSecondsSquared, - /// Radial sine coefficient. - pub r_sin: KmPerSecondsSquared, - /// Transverse cosine coefficient. - pub t_cos: KmPerSecondsSquared, - /// Transverse sine coefficient. - pub t_sin: KmPerSecondsSquared, - /// Normal cosine coefficient. - pub n_cos: KmPerSecondsSquared, - /// Normal sine coefficient. - pub n_sin: KmPerSecondsSquared, -} - -impl EmpiricalPeriodicAcceleration { - /// Construct a periodic empirical acceleration from typed RTN coefficients. - #[allow(clippy::too_many_arguments)] - pub fn new( - harmonic: PeriodicHarmonic, - epoch_ref: JulianDate, - period: Second, - r_cos: KmPerSecondsSquared, - r_sin: KmPerSecondsSquared, - t_cos: KmPerSecondsSquared, - t_sin: KmPerSecondsSquared, - n_cos: KmPerSecondsSquared, - n_sin: KmPerSecondsSquared, - ) -> Self { - Self { - harmonic, - epoch_ref, - period, - r_cos, - r_sin, - t_cos, - t_sin, - n_cos, - n_sin, - } - } - - fn phase(&self, epoch: JulianDate) -> f64 { - let dt_days = epoch.value() - self.epoch_ref.value(); - let dt_s = dt_days * 86_400.0; - let t = self.period.value(); - if t <= 0.0 || !t.is_finite() { - return 0.0; - } - let omega = 2.0 * core::f64::consts::PI / t; - self.harmonic.multiplier() * omega * dt_s - } -} - -impl AccelerationModel for EmpiricalPeriodicAcceleration { - fn name(&self) -> &'static str { - "empirical_periodic" - } - - fn acceleration( - &self, - s: &OrbitState, - _ctx: &DynamicsContext, - ) -> Result, PrincipiaError> { - let theta = self.phase(s.epoch.to::()); - let (sin_t, cos_t) = theta.sin_cos(); - let a_rtn = [ - self.r_cos.value() * cos_t + self.r_sin.value() * sin_t, - self.t_cos.value() * cos_t + self.t_sin.value() * sin_t, - self.n_cos.value() * cos_t + self.n_sin.value() * sin_t, - ]; - let rtn_frame = LocalOrbitalFrame::::try_from_state(s)?; - let a_gcrs = rtn_frame.rotation_inverse().apply_array(a_rtn); - Ok(Acceleration::::new( - a_gcrs[0], a_gcrs[1], a_gcrs[2], - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use siderust::astro::dynamics::{Position, Velocity}; - use siderust::coordinates::frames::GCRS; - - fn s0() -> OrbitState { - OrbitState::new( - JulianDate::new(2_451_545.0).to_j2000s(), - Position::::new(7_000.0, 0.0, 0.0), - Velocity::::new(0.0, 7.5450, 0.0), - ) - } - - #[test] - fn one_cpr_at_reference_epoch_returns_cosine_amplitude() { - let f = EmpiricalPeriodicAcceleration::new( - PeriodicHarmonic::OncePerRev, - JulianDate::new(2_451_545.0), - Second::new(5400.0), - KmPerSecondsSquared::new(1e-9), - KmPerSecondsSquared::new(0.0), - KmPerSecondsSquared::new(0.0), - KmPerSecondsSquared::new(0.0), - KmPerSecondsSquared::new(0.0), - KmPerSecondsSquared::new(0.0), - ); - let a = f.acceleration(&s0(), &DynamicsContext::empty()).unwrap(); - // r̂ at epoch is +X in GCRS, so a_GCRS_x ≈ 1e-9 km/s². - assert!((a.x().value() - 1e-9).abs() < 1e-15); - } - - #[test] - fn two_cpr_doubles_phase() { - let f1 = EmpiricalPeriodicAcceleration::new( - PeriodicHarmonic::OncePerRev, - JulianDate::new(2_451_545.0), - Second::new(5400.0), - KmPerSecondsSquared::new(0.0), - KmPerSecondsSquared::new(0.0), - KmPerSecondsSquared::new(1e-9), - KmPerSecondsSquared::new(0.0), - KmPerSecondsSquared::new(0.0), - KmPerSecondsSquared::new(0.0), - ); - let mut f2 = f1; - f2.harmonic = PeriodicHarmonic::TwicePerRev; - // After T_orbit/4 the 1cpr has cos(π/2)=0, the 2cpr has cos(π)=-1. - let dt_quarter = 5400.0 / 4.0; - let epoch = JulianDate::new(2_451_545.0 + dt_quarter / 86_400.0); - let mut s = s0(); - s.epoch = epoch.to_j2000s(); - let a1 = f1.acceleration(&s, &DynamicsContext::empty()).unwrap(); - let a2 = f2.acceleration(&s, &DynamicsContext::empty()).unwrap(); - let mag1 = (a1.x().value().powi(2) + a1.y().value().powi(2)).sqrt(); - let mag2 = (a2.x().value().powi(2) + a2.y().value().powi(2)).sqrt(); - assert!(mag1 < 1e-13, "1cpr mag at T/4 should be ≈0, got {mag1:e}"); - assert!( - (mag2 - 1e-9).abs() < 1e-12, - "2cpr mag at T/4 should be ≈1e-9, got {mag2:e}" - ); - } -} diff --git a/src/dynamics/error.rs b/src/dynamics/error.rs deleted file mode 100644 index d6f0786..0000000 --- a/src/dynamics/error.rs +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Unified error enum for `siderust-dynamics`. -//! -//! Wraps the two failure surfaces this crate exposes: -//! -//! * Upstream [`siderust::astro::dynamics::errors::DynamicsError`] for any -//! force-model / integrator / variational-equation failure. -//! * [`crate::dynamics::thrust::ManeuverError`] for finite-burn / thrust-arc input -//! validation. -//! -//! Both upstream errors are funnelled here via [`From`] so consumer code -//! can use `?` against a single [`DynamicsError`] return type. - -use thiserror::Error; - -use super::thrust::ManeuverError; - -/// Unified error type for `siderust-dynamics`. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::{DynamicsError, ManeuverError}; -/// -/// fn check(isp: f64) -> Result<(), DynamicsError> { -/// spod::dynamics::mass_flow_rate(siderust::qtty::force::Newtons::new(1.0), isp)?; -/// Ok(()) -/// } -/// assert!(matches!(check(0.0), Err(DynamicsError::Maneuver(ManeuverError::NonPositiveIsp(_))))); -/// ``` -#[derive(Debug, Error)] -pub enum DynamicsError { - /// Error originating from a force model, integrator, or variational - /// propagator in upstream `siderust`. - #[error(transparent)] - Upstream(#[from] siderust::astro::dynamics::errors::DynamicsError), - - /// Error originating from finite-burn / thrust-arc input validation. - #[error(transparent)] - Maneuver(#[from] ManeuverError), -} - -impl From for DynamicsError { - fn from(err: siderust::principia::PrincipiaError) -> Self { - Self::Upstream(err.into()) - } -} - -impl From for DynamicsError { - fn from(err: siderust::principia::PropagationError) -> Self { - let upstream: siderust::astro::dynamics::errors::DynamicsError = err.into(); - Self::Upstream(upstream) - } -} diff --git a/src/dynamics/force_config.rs b/src/dynamics/force_config.rs deleted file mode 100644 index 54cc3e3..0000000 --- a/src/dynamics/force_config.rs +++ /dev/null @@ -1,221 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Boolean / declarative force-model selection for a POD run. -//! -//! [`ForceModelConfig`] is the high-level "which force families are on?" -//! description that the POD service / CLI consumes. It maps to the -//! lower-level [`crate::dynamics::registry::ForceModelSpec`] vector via -//! [`ForceModelConfig::to_specs`], which is then materialised by -//! [`crate::dynamics::registry::ForceModelRegistry`]. -//! -//! The split keeps the config layer free of heavy numerical types while -//! still guaranteeing that the registry-level translation is exhaustive -//! and unit-typed. - -use siderust::astro::dynamics::forces::ShadowModel; -use siderust::qtty::{AreaToMass, DragCoefficient, KmPerSecondsSquared, Second, SrpCoefficient}; -use siderust::time::JulianDate; - -use super::empirical_periodic::PeriodicHarmonic; -use super::registry::{ForceModelParams, ForceModelSpec}; - -/// Declarative description of which force-model contributions are active. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::ForceModelConfig; -/// let cfg = ForceModelConfig::default(); -/// assert!(cfg.two_body && cfg.j2); -/// let specs = cfg.to_specs(); -/// assert_eq!(specs.len(), 2); -/// assert_eq!(specs[0].name, "two_body"); -/// ``` -#[derive(Debug, Clone, PartialEq)] -pub struct ForceModelConfig { - /// Enable two-body central gravity. - pub two_body: bool, - /// Enable J2 oblateness. - pub j2: bool, - /// Maximum spherical-harmonics degree/order. `None` disables non-J2 harmonics. - pub harmonics: Option<(usize, usize)>, - /// Enable Sun third-body perturbation. - pub third_body_sun: bool, - /// Enable Moon third-body perturbation. - pub third_body_moon: bool, - /// Optional drag (typed `C_D`, `A/m`). - pub drag: Option<(DragCoefficient, AreaToMass)>, - /// Optional cannonball SRP (typed `C_R`, `A/m`, shadow model). - pub srp: Option<(SrpCoefficient, AreaToMass, ShadowModel)>, - /// Enable central-body Schwarzschild relativistic correction. - pub relativity: bool, - /// Optional constant RTN empirical acceleration. - pub empirical_constant: Option<( - KmPerSecondsSquared, - KmPerSecondsSquared, - KmPerSecondsSquared, - )>, - /// Optional 1-CPR empirical acceleration: - /// `(epoch_ref, period, [r_cos, r_sin, t_cos, t_sin, n_cos, n_sin])`. - pub empirical_1cpr: Option<(JulianDate, Second, [KmPerSecondsSquared; 6])>, - /// Optional 2-CPR empirical acceleration with the same shape. - pub empirical_2cpr: Option<(JulianDate, Second, [KmPerSecondsSquared; 6])>, -} - -impl Default for ForceModelConfig { - /// MVP-1 default: two-body + J2 only. - fn default() -> Self { - Self { - two_body: true, - j2: true, - harmonics: None, - third_body_sun: false, - third_body_moon: false, - drag: None, - srp: None, - relativity: false, - empirical_constant: None, - empirical_1cpr: None, - empirical_2cpr: None, - } - } -} - -impl ForceModelConfig { - /// Translate the boolean / option flags into an ordered list of - /// registry specs. The order is deterministic and matches the field - /// order on this struct, so it can be hashed into the run manifest. - /// - /// # Example - /// - /// ``` - /// use spod::dynamics::ForceModelConfig; - /// let mut cfg = ForceModelConfig::default(); - /// cfg.relativity = true; - /// let specs = cfg.to_specs(); - /// assert_eq!(specs.last().unwrap().name, "relativity"); - /// ``` - pub fn to_specs(&self) -> Vec { - let mut out = Vec::new(); - if self.two_body { - out.push(ForceModelSpec::named("two_body")); - } - if self.j2 { - out.push(ForceModelSpec::named("j2")); - } - if let Some((d, o)) = self.harmonics { - out.push(ForceModelSpec::with_params( - "geopotential", - ForceModelParams::Geopotential { - degree: d, - order: o, - }, - )); - } - if self.third_body_sun { - out.push(ForceModelSpec::named("third_body_sun")); - } - if self.third_body_moon { - out.push(ForceModelSpec::named("third_body_moon")); - } - if let Some((cd, am)) = self.drag { - out.push(ForceModelSpec::with_params( - "drag", - ForceModelParams::Drag { - cd, - area_to_mass: am, - }, - )); - } - if let Some((cr, am, sh)) = self.srp { - out.push(ForceModelSpec::with_params( - "srp_cannonball", - ForceModelParams::SrpCannonball { - cr, - area_to_mass: am, - shadow: sh, - }, - )); - } - if self.relativity { - out.push(ForceModelSpec::named("relativity")); - } - if let Some((r, t, n)) = self.empirical_constant { - out.push(ForceModelSpec::with_params( - "empirical_constant", - ForceModelParams::EmpiricalConstant { - radial: r, - transverse: t, - normal: n, - }, - )); - } - if let Some((epoch, per, c)) = self.empirical_1cpr { - out.push(ForceModelSpec::with_params( - "empirical_1cpr", - ForceModelParams::EmpiricalPeriodic { - harmonic: PeriodicHarmonic::OncePerRev, - epoch_ref: epoch, - period: per, - coeffs: c, - }, - )); - } - if let Some((epoch, per, c)) = self.empirical_2cpr { - out.push(ForceModelSpec::with_params( - "empirical_2cpr", - ForceModelParams::EmpiricalPeriodic { - harmonic: PeriodicHarmonic::TwicePerRev, - epoch_ref: epoch, - period: per, - coeffs: c, - }, - )); - } - out - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_emits_two_specs() { - let s = ForceModelConfig::default().to_specs(); - assert_eq!(s.len(), 2); - assert_eq!(s[0].name, "two_body"); - assert_eq!(s[1].name, "j2"); - } - - #[test] - fn full_config_round_trips_through_registry() { - let cfg = ForceModelConfig { - two_body: true, - j2: true, - harmonics: Some((4, 4)), - third_body_sun: true, - third_body_moon: true, - drag: Some((DragCoefficient::new(2.2), AreaToMass::new(0.01))), - srp: Some(( - SrpCoefficient::new(1.5), - AreaToMass::new(0.02), - ShadowModel::Conical, - )), - relativity: true, - empirical_constant: Some(( - KmPerSecondsSquared::new(0.0), - KmPerSecondsSquared::new(1e-12), - KmPerSecondsSquared::new(0.0), - )), - empirical_1cpr: None, - empirical_2cpr: None, - }; - let specs = cfg.to_specs(); - assert_eq!(specs.len(), 9); - let reg = crate::dynamics::registry::ForceModelRegistry::with_builtins(); - let composite = reg.build(&specs).unwrap(); - assert_eq!(composite.len(), 9); - } -} diff --git a/src/dynamics/forces.rs b/src/dynamics/forces.rs deleted file mode 100644 index d492fb4..0000000 --- a/src/dynamics/forces.rs +++ /dev/null @@ -1,962 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! POD-layer force-model trait, built-in implementations, and evaluating -//! [`ForceModelRegistry`]. -//! -//! ## Scope -//! -//! This module adds a **POD-layer** [`ForceModel`] trait on top of the -//! upstream `siderust::astro::dynamics::forces::ForceModel` trait. The -//! key differences are: -//! -//! * The acceleration method receives an explicit [`Epoch`] argument -//! (Julian Date) in addition to the Cartesian state, enabling time-varying -//! models (SRP shadow, analytical Sun position) without a context object. -//! * Force models that need external providers (atmosphere for drag) embed -//! them as `Arc` fields rather than reading them from a shared -//! `DynamicsContext`. -//! * Partial derivatives return `Option` rather than a -//! fallible `Result`, making composition cleaner. -//! -//! ## Built-in models -//! -//! | Type | Physics | Analytic partials | -//! |------|---------|-------------------| -//! | [`TwoBodyForce`] | Newtonian two-body (Earth) | ✓ | -//! | [`J2PerturbationForce`] | J2 zonal harmonic (Earth) | ✓ | -//! | [`DragForce`] | Cannonball drag (embedded atmosphere) | — | -//! | [`SolarRadiationPressureForce`] | Cannonball SRP + cylindrical shadow | — | -//! -//! ## Evaluating registry -//! -//! [`ForceModelRegistry`] holds an ordered list of [`Box`] -//! and evaluates the total acceleration and summed partial derivatives in a -//! single call. -//! -//! # Example -//! -//! ``` -//! use std::sync::Arc; -//! use siderust::astro::dynamics::density::ExponentialAtmosphere; -//! use siderust::qtty::{AreaToMass, DragCoefficient, SrpCoefficient}; -//! use spod::dynamics::forces::{ -//! DragForce, ForceModelRegistry, J2PerturbationForce, TwoBodyForce, -//! }; -//! -//! let reg = ForceModelRegistry::new() -//! .push(Box::new(TwoBodyForce::earth())) -//! .push(Box::new(J2PerturbationForce::earth())) -//! .push(Box::new(DragForce::new( -//! DragCoefficient::new(2.2), -//! AreaToMass::new(0.01), -//! Arc::new(ExponentialAtmosphere::LEO_500KM), -//! ))); -//! assert_eq!(reg.len(), 3); -//! assert!(reg.is_variational()); -//! ``` - -use std::sync::Arc; - -use siderust::astro::dynamics::context::DynamicsContextBuilder; -use siderust::astro::dynamics::density::DensityProvider; -use siderust::astro::dynamics::forces::{ - AccelerationModel as UpstreamForceModel, DragForce as UpstreamDragForce, TwoBody, J2, -}; -use siderust::astro::dynamics::state::{Acceleration, AccelerationUnit, OrbitState}; -use siderust::astro::dynamics::{DynamicsContext, EARTH_J2, GM_EARTH, R_EARTH}; -use siderust::coordinates::centers::Geocentric; -use siderust::coordinates::frames::GCRS; -use siderust::qtty::{AreaToMass, DragCoefficient, SrpCoefficient}; -use siderust::time::JulianDate; - -// ───────────────────────────────────────────────────────────────────────────── -// Type aliases -// ───────────────────────────────────────────────────────────────────────────── - -/// Standard geocentric inertial Cartesian orbit state (position, velocity, -/// epoch). Alias for [`OrbitState`]. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::forces::CartesianState; -/// use siderust::astro::dynamics::{Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// let _s: CartesianState = CartesianState::new( -/// JulianDate::new(2_451_545.0).to_j2000s(), -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.545, 0.0), -/// ); -/// ``` -pub type CartesianState = OrbitState; - -/// Julian Date epoch consumed by POD force models. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::forces::Epoch; -/// use siderust::time::JulianDate; -/// let _t: Epoch = JulianDate::new(2_451_545.0); -/// ``` -pub type Epoch = JulianDate; - -// ───────────────────────────────────────────────────────────────────────────── -// Acceleration3 -// ───────────────────────────────────────────────────────────────────────────── - -/// Three-component inertial acceleration in km/s², GCRS frame. -/// -/// Stored as raw `[f64; 3]` (units km/s²) rather than a typed -/// [`siderust::affn`] vector -/// to make it easy to sum contributions from heterogeneous force models. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::forces::Acceleration3; -/// let a = Acceleration3([1e-6, 0.0, -1e-7]); -/// let b = Acceleration3::zero(); -/// let mut c = a; -/// c.add_assign(b); -/// assert!((c.0[0] - 1e-6).abs() < f64::EPSILON); -/// ``` -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Acceleration3(pub [f64; 3]); - -impl Acceleration3 { - /// Zero acceleration. - pub fn zero() -> Self { - Self([0.0; 3]) - } - - /// Accumulate `other` into `self` element-wise. - pub fn add_assign(&mut self, other: Acceleration3) { - for i in 0..3 { - self.0[i] += other.0[i]; - } - } - - /// Euclidean magnitude (km/s²). - pub fn magnitude(&self) -> f64 { - (self.0[0].powi(2) + self.0[1].powi(2) + self.0[2].powi(2)).sqrt() - } -} - -impl From> - for Acceleration3 -{ - fn from(a: Acceleration) -> Self { - Self([a.x().value(), a.y().value(), a.z().value()]) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// AccelPartials -// ───────────────────────────────────────────────────────────────────────────── - -/// Partial derivatives of the acceleration with respect to position -/// (`d_acc_d_pos`, units s⁻²) and velocity (`d_acc_d_vel`, units s⁻¹). -/// -/// These 3×3 blocks form the lower half of the variational dynamics matrix -/// `F(t) = [[0, I], [A_r, A_v]]`. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::forces::AccelPartials; -/// let z = AccelPartials::zero(); -/// for row in &z.d_acc_d_pos { -/// for &v in row { assert_eq!(v, 0.0); } -/// } -/// ``` -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct AccelPartials { - /// `∂a/∂r` (3×3, units: s⁻²). - pub d_acc_d_pos: [[f64; 3]; 3], - /// `∂a/∂v` (3×3, units: s⁻¹). Zero for conservative (position-only) forces. - pub d_acc_d_vel: [[f64; 3]; 3], -} - -impl AccelPartials { - /// Zero partial derivatives. - pub fn zero() -> Self { - Self { - d_acc_d_pos: [[0.0; 3]; 3], - d_acc_d_vel: [[0.0; 3]; 3], - } - } - - /// Accumulate `other` into `self` element-wise. - pub fn add_assign(&mut self, other: &AccelPartials) { - for i in 0..3 { - for j in 0..3 { - self.d_acc_d_pos[i][j] += other.d_acc_d_pos[i][j]; - self.d_acc_d_vel[i][j] += other.d_acc_d_vel[i][j]; - } - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// ForceModel trait -// ───────────────────────────────────────────────────────────────────────────── - -/// POD-layer force model trait. -/// -/// Unlike the upstream `siderust::astro::dynamics::forces::ForceModel`, this -/// trait: -/// -/// * Takes an explicit [`Epoch`] (Julian Date) alongside the state. -/// * Returns acceleration directly as [`Acceleration3`], without `Result`. -/// Implementations must handle errors internally (e.g. by returning zero). -/// * Expresses partial derivatives as `Option` so the -/// evaluating registry can selectively sum only the models that have them. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::forces::{ -/// AccelPartials, Acceleration3, CartesianState, Epoch, ForceModel, -/// }; -/// use siderust::astro::dynamics::{Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// -/// struct ConstantDrag; -/// impl ForceModel for ConstantDrag { -/// fn name(&self) -> &str { "constant_drag" } -/// fn acceleration(&self, _s: &CartesianState, _t: Epoch) -> Acceleration3 { -/// Acceleration3([-1e-8, 0.0, 0.0]) -/// } -/// fn partials(&self, _s: &CartesianState, _t: Epoch) -> Option { None } -/// } -/// -/// assert_eq!(ConstantDrag.name(), "constant_drag"); -/// assert!(!ConstantDrag.is_variational()); -/// ``` -pub trait ForceModel: Send + Sync { - /// Stable human-readable name for this model. - fn name(&self) -> &str; - - /// Inertial acceleration (km/s², GCRS) at `state` and epoch `t`. - /// - /// Implementations that encounter unrecoverable errors (e.g. atmosphere - /// not available) should return [`Acceleration3::zero()`]. - fn acceleration(&self, state: &CartesianState, t: Epoch) -> Acceleration3; - - /// Analytic partial derivatives of the acceleration w.r.t. position and - /// velocity, or `None` if this model does not provide them. - fn partials(&self, state: &CartesianState, t: Epoch) -> Option; - - /// Return `true` iff this model provides analytic partial derivatives. - /// - /// The default returns `false`. Override in models that implement - /// [`ForceModel::partials`] to return `Some`. - fn is_variational(&self) -> bool { - false - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// TwoBodyForce -// ───────────────────────────────────────────────────────────────────────────── - -/// Newtonian two-body (point-mass) central force for Earth. -/// -/// Delegates acceleration and analytic Jacobians to the upstream -/// [`siderust::astro::dynamics::forces::TwoBody`] model. Provides analytic -/// partial derivatives `∂a/∂r` (the Clohessy–Wiltshire tidal matrix). -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::forces::{CartesianState, Epoch, TwoBodyForce, ForceModel}; -/// use siderust::astro::dynamics::{Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// -/// let f = TwoBodyForce::earth(); -/// let s = CartesianState::new( -/// JulianDate::new(2_451_545.0).to_j2000s(), -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.545, 0.0), -/// ); -/// let a = f.acceleration(&s, JulianDate::new(2_451_545.0)); -/// assert!(a.magnitude() > 0.0); -/// assert!(f.is_variational()); -/// ``` -pub struct TwoBodyForce { - inner: TwoBody, -} - -impl TwoBodyForce { - /// Construct with Earth's standard gravitational parameter (GM = 398 600.4418 km³/s²). - pub fn earth() -> Self { - Self { - inner: TwoBody::new(GM_EARTH), - } - } -} - -impl ForceModel for TwoBodyForce { - fn name(&self) -> &str { - "two_body" - } - - fn acceleration(&self, state: &CartesianState, _t: Epoch) -> Acceleration3 { - let ctx = DynamicsContext::empty(); - match UpstreamForceModel::acceleration(&self.inner, state, &ctx) { - Ok(a) => Acceleration3::from(a), - Err(_) => Acceleration3::zero(), - } - } - - fn partials(&self, state: &CartesianState, _t: Epoch) -> Option { - let ctx = DynamicsContext::empty(); - let fp = UpstreamForceModel::partials(&self.inner, state, &ctx).ok()?; - Some(AccelPartials { - d_acc_d_pos: *fp.d_acc_d_pos.as_array(), - d_acc_d_vel: *fp.d_acc_d_vel.as_array(), - }) - } - - fn is_variational(&self) -> bool { - true - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// J2PerturbationForce -// ───────────────────────────────────────────────────────────────────────────── - -/// J2 zonal harmonic oblateness perturbation for Earth. -/// -/// Provides analytic partial derivatives `∂a_J2/∂r` from the upstream -/// [`siderust::astro::dynamics::forces::J2`] implementation. The full -/// STM for a two-body + J2 propagation is obtained by composing -/// [`TwoBodyForce`] and [`J2PerturbationForce`] in a [`ForceModelRegistry`]. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::forces::{ -/// CartesianState, ForceModel, J2PerturbationForce, -/// }; -/// use siderust::astro::dynamics::{Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// -/// let f = J2PerturbationForce::earth(); -/// let s = CartesianState::new( -/// JulianDate::new(2_451_545.0).to_j2000s(), -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.545, 0.0), -/// ); -/// assert!(f.is_variational()); -/// assert!(f.partials(&s, JulianDate::new(2_451_545.0)).is_some()); -/// ``` -pub struct J2PerturbationForce { - inner: J2, -} - -impl J2PerturbationForce { - /// Construct with Earth's J2 = 1.0826257 × 10⁻³. - pub fn earth() -> Self { - Self { - inner: J2::new(GM_EARTH, R_EARTH, EARTH_J2), - } - } -} - -impl ForceModel for J2PerturbationForce { - fn name(&self) -> &str { - "j2" - } - - fn acceleration(&self, state: &CartesianState, _t: Epoch) -> Acceleration3 { - let ctx = DynamicsContext::empty(); - match UpstreamForceModel::acceleration(&self.inner, state, &ctx) { - Ok(a) => Acceleration3::from(a), - Err(_) => Acceleration3::zero(), - } - } - - fn partials(&self, state: &CartesianState, _t: Epoch) -> Option { - let ctx = DynamicsContext::empty(); - let fp = UpstreamForceModel::partials(&self.inner, state, &ctx).ok()?; - Some(AccelPartials { - d_acc_d_pos: *fp.d_acc_d_pos.as_array(), - d_acc_d_vel: *fp.d_acc_d_vel.as_array(), - }) - } - - fn is_variational(&self) -> bool { - true - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// DragForce -// ───────────────────────────────────────────────────────────────────────────── - -/// Cannonball atmospheric drag force. -/// -/// Unlike the upstream model, which reads the atmosphere from a shared -/// [`DynamicsContext`], this POD-layer version **embeds** its atmosphere -/// density provider as an `Arc`. -/// -/// ```text -/// a_drag = −½ · Cd · (A/m) · ρ(h) · |v_rel|_SI · v_rel -/// ``` -/// -/// Partial derivatives are not provided (analytic drag Jacobians are -/// force-model–specific and model-dependent; use the finite-difference -/// harness in [`crate::dynamics::variational`] when needed). -/// -/// # Example -/// -/// ``` -/// use std::sync::Arc; -/// use siderust::astro::dynamics::density::ExponentialAtmosphere; -/// use siderust::qtty::{AreaToMass, DragCoefficient}; -/// use spod::dynamics::forces::{CartesianState, DragForce, ForceModel}; -/// use siderust::astro::dynamics::{Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// -/// let f = DragForce::new( -/// DragCoefficient::new(2.2), -/// AreaToMass::new(0.01), -/// Arc::new(ExponentialAtmosphere::LEO_500KM), -/// ); -/// let s = CartesianState::new( -/// JulianDate::new(2_451_545.0).to_j2000s(), -/// Position::::new(6_871.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.612, 0.0), -/// ); -/// let a = f.acceleration(&s, JulianDate::new(2_451_545.0)); -/// // Drag opposes velocity → x-component should be ~0, y < 0 -/// assert!(!f.is_variational()); -/// ``` -pub struct DragForce { - cd: DragCoefficient, - area_to_mass: AreaToMass, - atmosphere: Arc, -} - -impl DragForce { - /// Construct with explicit drag coefficient, area-to-mass ratio, and - /// atmosphere density provider. - pub fn new( - cd: DragCoefficient, - area_to_mass: AreaToMass, - atmosphere: Arc, - ) -> Self { - Self { - cd, - area_to_mass, - atmosphere, - } - } - - /// Drag coefficient. - pub fn cd(&self) -> DragCoefficient { - self.cd - } - - /// Area-to-mass ratio (m²/kg). - pub fn area_to_mass(&self) -> AreaToMass { - self.area_to_mass - } -} - -impl ForceModel for DragForce { - fn name(&self) -> &str { - "drag" - } - - fn acceleration(&self, state: &CartesianState, _t: Epoch) -> Acceleration3 { - let ctx = DynamicsContextBuilder::new() - .with_atmosphere(Arc::clone(&self.atmosphere)) - .build(); - let upstream = UpstreamDragForce::new(self.cd, self.area_to_mass); - match UpstreamForceModel::acceleration(&upstream, state, &ctx) { - Ok(a) => Acceleration3::from(a), - Err(_) => Acceleration3::zero(), - } - } - - fn partials(&self, _state: &CartesianState, _t: Epoch) -> Option { - None - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// SolarRadiationPressureForce -// ───────────────────────────────────────────────────────────────────────────── - -/// Solar radiation pressure constant at 1 AU (N/m²). -const P0_N_M2: f64 = 4.560e-6; - -/// 1 AU in km. -const AU_KM: f64 = 149_597_870.7; - -/// Earth equatorial radius for shadow geometry (km, WGS-84). -const R_EARTH_SHADOW_KM: f64 = 6_378.137; - -/// Low-precision analytic Sun position in GCRS (km) from Julian Date. -/// -/// Accuracy is approximately 0.01° in ecliptic longitude, adequate for -/// shadow determination in SRP modelling. -/// -/// Formulae from Montenbruck & Gill, *Satellite Orbits*, Appendix A. -fn sun_position_gcrs_km(jd: JulianDate) -> [f64; 3] { - let t: f64 = (jd.value() - 2_451_545.0) / 36_525.0; - let l0_deg: f64 = 280.460 + 36_000.771 * t; - let m_deg: f64 = 357.529_1 + 35_999.050_3 * t; - let m: f64 = m_deg.to_radians(); - let lam_deg: f64 = l0_deg + 1.914_666 * m.sin() + 0.019_994 * (2.0 * m).sin(); - let lam: f64 = lam_deg.to_radians(); - let eps: f64 = (23.439_4_f64 - 0.013_0 * t).to_radians(); - [ - AU_KM * lam.cos(), - AU_KM * eps.cos() * lam.sin(), - AU_KM * eps.sin() * lam.sin(), - ] -} - -/// Cylindrical shadow factor ν for the cannonball SRP model. -/// -/// Returns `0.0` if the satellite is inside Earth's cylindrical shadow -/// (anti-sun side AND within the projected Earth radius), `1.0` otherwise. -/// -/// Reference: Montenbruck & Gill §3.4. -fn cylindrical_shadow_nu(r_sat: [f64; 3], r_sun: [f64; 3]) -> f64 { - let sun_mag = (r_sun[0].powi(2) + r_sun[1].powi(2) + r_sun[2].powi(2)).sqrt(); - if sun_mag == 0.0 { - return 1.0; - } - let s = [r_sun[0] / sun_mag, r_sun[1] / sun_mag, r_sun[2] / sun_mag]; - let dot = r_sat[0] * s[0] + r_sat[1] * s[1] + r_sat[2] * s[2]; - // Satellite on the sun side → fully illuminated. - if dot > 0.0 { - return 1.0; - } - // Perpendicular distance from the anti-sun axis. - let px = r_sat[0] - dot * s[0]; - let py = r_sat[1] - dot * s[1]; - let pz = r_sat[2] - dot * s[2]; - let perp_sq = px.powi(2) + py.powi(2) + pz.powi(2); - if perp_sq < R_EARTH_SHADOW_KM.powi(2) { - 0.0 - } else { - 1.0 - } -} - -/// Cannonball solar radiation pressure with cylindrical Earth shadow model. -/// -/// The Sun position is computed from the supplied epoch using a low-precision -/// analytic formula (Montenbruck & Gill, Appendix A), accurate to ~0.01°. -/// The shadow model follows Montenbruck & Gill §3.4 (cylindrical). -/// -/// ```text -/// a_srp = ν · Cr · P₀ · (AU² / |r_sat − r_sun|²) · (A/m) / 1000 [km/s²] -/// ``` -/// -/// # Example -/// -/// ``` -/// use siderust::qtty::{AreaToMass, SrpCoefficient}; -/// use spod::dynamics::forces::{ -/// CartesianState, ForceModel, SolarRadiationPressureForce, -/// }; -/// use siderust::astro::dynamics::{Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// -/// let f = SolarRadiationPressureForce::new(SrpCoefficient::new(1.5), AreaToMass::new(0.02)); -/// let jd = JulianDate::new(2_451_545.0); -/// let s = CartesianState::new( -/// jd.to_j2000s(), -/// // Place satellite at x=+7000 km (same side as Sun in this epoch) -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.545, 0.0), -/// ); -/// let a = f.acceleration(&s, jd); -/// // Magnitude is finite (not NaN) -/// assert!(a.0.iter().all(|v| v.is_finite())); -/// assert!(!f.is_variational()); -/// ``` -pub struct SolarRadiationPressureForce { - cr: SrpCoefficient, - area_to_mass: AreaToMass, -} - -impl SolarRadiationPressureForce { - /// Construct with reflectivity coefficient `Cr` and area-to-mass ratio. - /// - /// The cylindrical Earth shadow model is always active. To suppress - /// shadow, use `Cr = 0.0`. - pub fn new(cr: SrpCoefficient, area_to_mass: AreaToMass) -> Self { - Self { cr, area_to_mass } - } - - /// SRP reflectivity coefficient. - pub fn cr(&self) -> SrpCoefficient { - self.cr - } - - /// Area-to-mass ratio (m²/kg). - pub fn area_to_mass(&self) -> AreaToMass { - self.area_to_mass - } -} - -impl ForceModel for SolarRadiationPressureForce { - fn name(&self) -> &str { - "srp_cylindrical" - } - - fn acceleration(&self, state: &CartesianState, t: Epoch) -> Acceleration3 { - let r_sat = [ - state.position.x().value(), - state.position.y().value(), - state.position.z().value(), - ]; - let r_sun = sun_position_gcrs_km(t); - let nu = cylindrical_shadow_nu(r_sat, r_sun); - if nu == 0.0 { - return Acceleration3::zero(); - } - // Vector from Sun to satellite (direction of photon flux arriving at s/c). - let dx = r_sat[0] - r_sun[0]; - let dy = r_sat[1] - r_sun[1]; - let dz = r_sat[2] - r_sun[2]; - let r = (dx.powi(2) + dy.powi(2) + dz.powi(2)).sqrt(); - if r == 0.0 { - return Acceleration3::zero(); - } - // a_srp = ν · Cr · P₀ · (AU² / r²) · (A/m) / 1000 [km/s²] - let mag = - nu * self.cr.value() * P0_N_M2 * (AU_KM * AU_KM / (r * r)) * self.area_to_mass.value() - / 1_000.0; - let inv_r = 1.0 / r; - Acceleration3([mag * dx * inv_r, mag * dy * inv_r, mag * dz * inv_r]) - } - - fn partials(&self, _state: &CartesianState, _t: Epoch) -> Option { - None - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// ForceModelRegistry (evaluating kind) -// ───────────────────────────────────────────────────────────────────────────── - -/// Runtime evaluating registry of POD force models. -/// -/// Holds an ordered list of [`Box`] objects and evaluates -/// the total acceleration and summed partial derivatives in a single call. -/// Unlike the factory registry in [`crate::dynamics::registry`] (which maps string -/// keys to factories), this registry holds live model instances ready to -/// be evaluated against a state. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::forces::{ -/// ForceModelRegistry, J2PerturbationForce, TwoBodyForce, -/// }; -/// use siderust::astro::dynamics::{Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// -/// let reg = ForceModelRegistry::new() -/// .push(Box::new(TwoBodyForce::earth())) -/// .push(Box::new(J2PerturbationForce::earth())); -/// -/// assert_eq!(reg.len(), 2); -/// assert!(reg.is_variational()); -/// -/// let s = spod::dynamics::forces::CartesianState::new( -/// JulianDate::new(2_451_545.0).to_j2000s(), -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.545, 0.0), -/// ); -/// let jd = JulianDate::new(2_451_545.0); -/// let acc = reg.total_acceleration(&s, jd); -/// assert!(acc.magnitude() > 0.0); -/// ``` -pub struct ForceModelRegistry { - models: Vec>, -} - -impl Default for ForceModelRegistry { - fn default() -> Self { - Self::new() - } -} - -impl ForceModelRegistry { - /// Create an empty registry. - pub fn new() -> Self { - Self { models: Vec::new() } - } - - /// Append a force model and return `self` for builder-style chaining. - pub fn push(mut self, model: Box) -> Self { - self.models.push(model); - self - } - - /// Number of registered models. - pub fn len(&self) -> usize { - self.models.len() - } - - /// `true` iff the registry holds no models. - pub fn is_empty(&self) -> bool { - self.models.is_empty() - } - - /// `true` iff at least one registered model provides analytic partial - /// derivatives. - pub fn is_variational(&self) -> bool { - self.models.iter().any(|m| m.is_variational()) - } - - /// Evaluate the sum of all registered accelerations at `state` and epoch `t`. - /// - /// Models that encounter errors return [`Acceleration3::zero()`]; they do - /// not propagate failures. - /// - /// # Example - /// - /// ``` - /// use spod::dynamics::forces::{ForceModelRegistry, TwoBodyForce}; - /// use siderust::astro::dynamics::{Position, Velocity}; - /// use siderust::coordinates::frames::GCRS; - /// use siderust::time::JulianDate; - /// - /// let reg = ForceModelRegistry::new().push(Box::new(TwoBodyForce::earth())); - /// let s = spod::dynamics::forces::CartesianState::new( - /// JulianDate::new(2_451_545.0).to_j2000s(), - /// Position::::new(7_000.0, 0.0, 0.0), - /// Velocity::::new(0.0, 7.545, 0.0), - /// ); - /// let a = reg.total_acceleration(&s, JulianDate::new(2_451_545.0)); - /// assert!(a.magnitude() > 0.5e-3); // ≈ 8.15e-3 km/s² for LEO - /// ``` - pub fn total_acceleration(&self, state: &CartesianState, t: Epoch) -> Acceleration3 { - let mut acc = Acceleration3::zero(); - for m in &self.models { - acc.add_assign(m.acceleration(state, t)); - } - acc - } - - /// Sum the partial derivatives of all variational models. - /// - /// Returns `None` if no model provides analytic partials. - /// - /// # Example - /// - /// ``` - /// use spod::dynamics::forces::{ForceModelRegistry, TwoBodyForce}; - /// use siderust::astro::dynamics::{Position, Velocity}; - /// use siderust::coordinates::frames::GCRS; - /// use siderust::time::JulianDate; - /// - /// let reg = ForceModelRegistry::new().push(Box::new(TwoBodyForce::earth())); - /// let s = spod::dynamics::forces::CartesianState::new( - /// JulianDate::new(2_451_545.0).to_j2000s(), - /// Position::::new(7_000.0, 0.0, 0.0), - /// Velocity::::new(0.0, 7.545, 0.0), - /// ); - /// let p = reg.total_partials(&s, JulianDate::new(2_451_545.0)); - /// assert!(p.is_some()); - /// ``` - pub fn total_partials(&self, state: &CartesianState, t: Epoch) -> Option { - if !self.is_variational() { - return None; - } - let mut partials = AccelPartials::zero(); - for m in &self.models { - if let Some(p) = m.partials(state, t) { - partials.add_assign(&p); - } - } - Some(partials) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Tests -// ───────────────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use siderust::astro::dynamics::density::ExponentialAtmosphere; - use siderust::astro::dynamics::{Position, Velocity}; - use siderust::coordinates::frames::GCRS; - use siderust::time::JulianDate; - - use super::*; - - fn leo_state() -> CartesianState { - CartesianState::new( - JulianDate::new(2_451_545.0).to_j2000s(), - Position::::new(6_871.0, 0.0, 0.0), - Velocity::::new(0.0, 7.612, 0.0), - ) - } - - const JD_J2000: f64 = 2_451_545.0; - - fn jd_j2000() -> Epoch { - JulianDate::new(JD_J2000) - } - - #[test] - fn two_body_acceleration_has_expected_sign() { - let f = TwoBodyForce::earth(); - let a = f.acceleration(&leo_state(), jd_j2000()); - // At [r, 0, 0], gravity points in −x direction. - assert!( - a.0[0] < 0.0, - "x-component of two-body accel must be negative" - ); - assert!(a.0[1].abs() < 1e-12, "y-component must be zero by symmetry"); - assert!(a.0[2].abs() < 1e-12, "z-component must be zero by symmetry"); - } - - #[test] - fn two_body_partials_available() { - let f = TwoBodyForce::earth(); - let p = f.partials(&leo_state(), jd_j2000()); - assert!(p.is_some(), "TwoBodyForce must provide analytic partials"); - } - - #[test] - fn j2_adds_out_of_plane_component() { - let s = CartesianState::new( - jd_j2000().to_j2000s(), - // Off-equatorial position - Position::::new(6_371.0, 0.0, 500.0), - Velocity::::new(0.0, 7.612, 0.0), - ); - let f = J2PerturbationForce::earth(); - let a = f.acceleration(&s, jd_j2000()); - // J2 has a z-component for off-equatorial positions. - assert!( - a.0[2].abs() > 1e-15, - "J2 z-accel must be non-zero off-equator" - ); - } - - #[test] - fn j2_partials_available() { - let f = J2PerturbationForce::earth(); - let p = f.partials(&leo_state(), jd_j2000()); - assert!( - p.is_some(), - "J2PerturbationForce must provide analytic partials" - ); - } - - #[test] - fn drag_produces_nonzero_deceleration() { - let f = DragForce::new( - DragCoefficient::new(2.2), - AreaToMass::new(0.01), - Arc::new(ExponentialAtmosphere::LEO_500KM), - ); - // State at ~500 km altitude - let s = CartesianState::new( - jd_j2000().to_j2000s(), - Position::::new(6_871.0, 0.0, 0.0), - Velocity::::new(0.0, 7.612, 0.0), - ); - let a = f.acceleration(&s, jd_j2000()); - assert!( - a.magnitude() > 0.0, - "drag at 500 km must yield non-zero deceleration" - ); - assert!(!f.is_variational(), "drag has no analytic partials"); - } - - #[test] - fn srp_returns_finite_acceleration() { - let f = SolarRadiationPressureForce::new(SrpCoefficient::new(1.5), AreaToMass::new(0.02)); - let a = f.acceleration(&leo_state(), jd_j2000()); - assert!( - a.0.iter().all(|v| v.is_finite()), - "SRP acceleration must be finite" - ); - } - - #[test] - fn registry_total_acceleration_sums_contributions() { - let two_body = TwoBodyForce::earth(); - let a_single = two_body.acceleration(&leo_state(), jd_j2000()); - - let reg = ForceModelRegistry::new().push(Box::new(TwoBodyForce::earth())); - let a_reg = reg.total_acceleration(&leo_state(), jd_j2000()); - assert!( - (a_single.0[0] - a_reg.0[0]).abs() < 1e-20, - "Registry must sum contributions faithfully" - ); - } - - #[test] - fn registry_total_partials_none_when_no_variational_models() { - let reg = ForceModelRegistry::new().push(Box::new(SolarRadiationPressureForce::new( - SrpCoefficient::new(1.5), - AreaToMass::new(0.02), - ))); - assert!( - reg.total_partials(&leo_state(), jd_j2000()).is_none(), - "No variational models → total_partials must be None" - ); - } - - #[test] - fn registry_total_partials_some_with_variational_models() { - let reg = ForceModelRegistry::new() - .push(Box::new(TwoBodyForce::earth())) - .push(Box::new(J2PerturbationForce::earth())); - let p = reg.total_partials(&leo_state(), jd_j2000()); - assert!(p.is_some(), "TwoBody + J2 → total_partials must be Some"); - } - - #[test] - fn cylindrical_shadow_in_shadow() { - // Satellite behind Earth (anti-sun side, within shadow cylinder) - let r_sat = [-7_000.0_f64, 0.0, 0.0]; - // Sun at +x direction - let r_sun = [AU_KM, 0.0, 0.0]; - assert_eq!( - cylindrical_shadow_nu(r_sat, r_sun), - 0.0, - "satellite directly behind Earth must be in shadow" - ); - } - - #[test] - fn cylindrical_shadow_in_sunlight() { - // Satellite on sun side - let r_sat = [7_000.0_f64, 0.0, 0.0]; - let r_sun = [AU_KM, 0.0, 0.0]; - assert_eq!( - cylindrical_shadow_nu(r_sat, r_sun), - 1.0, - "satellite on sun side must be fully illuminated" - ); - } -} diff --git a/src/dynamics/integrator_adapter.rs b/src/dynamics/integrator_adapter.rs deleted file mode 100644 index ed0319f..0000000 --- a/src/dynamics/integrator_adapter.rs +++ /dev/null @@ -1,143 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Typed integrator adapter. -//! -//! Re-exports of [`crate::dynamics::Integrator`] (and the concrete -//! Rk4/Dopri5/Dop853 wrappers) live in the crate root. This module provides -//! the POD-level convenience helpers that take a [`SpacecraftState`] (or -//! [`OrbitState`]) plus a registry-built composite force model and return -//! the propagated state. -//! -//! No new integrator math is invented here — every call delegates to the -//! upstream propagator. - -use siderust::astro::dynamics::{DynamicsContext, OrbitState, SpacecraftState}; -use siderust::pod::force::SiderustAccelerationModel; -use siderust::qtty::Second; - -use crate::dynamics::Integrator; - -use super::pod_error::PodDynamicsError; - -/// Propagate a bare [`OrbitState`] forward by `dt` under `force` using the -/// supplied [`Integrator`]. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::{propagate_orbit, Rk4Integrator}; -/// use siderust::astro::dynamics::forces::TwoBody; -/// use siderust::astro::dynamics::{OrbitState, DynamicsContext}; -/// use siderust::astro::dynamics::{Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// use siderust::qtty::Second; -/// -/// let s0 = OrbitState::new( -/// JulianDate::new(2_451_545.0).to_j2000s(), -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.5450, 0.0), -/// ); -/// let s1 = propagate_orbit( -/// &Rk4Integrator { step: Second::new(10.0) }, -/// &TwoBody::new(siderust::astro::dynamics::GM_EARTH), s0, Second::new(60.0), &DynamicsContext::empty(), -/// ).unwrap(); -/// assert!((s1.epoch - s0.epoch).value() > 0.0); -/// ``` -pub fn propagate_orbit( - integrator: &I, - force: &F, - state: OrbitState, - dt: Second, - ctx: &DynamicsContext, -) -> Result -where - I: Integrator, - F: SiderustAccelerationModel, -{ - Ok(integrator.propagate(force, state, dt, ctx)?) -} - -/// Propagate a [`SpacecraftState`] (orbit + properties) forward by `dt`. -/// -/// Spacecraft properties (`C_D`, `C_R`, `A/m`, mass) are passed through -/// unchanged — finite-burn / mass-flow integration is the responsibility of -/// the upstream `crate::dynamics::thrust` module, not this adapter. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::{propagate_spacecraft, Rk4Integrator}; -/// use siderust::astro::dynamics::forces::TwoBody; -/// use siderust::astro::dynamics::{ -/// DynamicsContext, OrbitState, SpacecraftProperties, SpacecraftState, -/// }; -/// use siderust::astro::dynamics::{Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// use siderust::qtty::Second; -/// -/// let orbit = OrbitState::new( -/// JulianDate::new(2_451_545.0).to_j2000s(), -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.5450, 0.0), -/// ); -/// let sc = SpacecraftState { orbit, properties: SpacecraftProperties::demo_leo() }; -/// let sc1 = propagate_spacecraft( -/// &Rk4Integrator { step: Second::new(10.0) }, -/// &TwoBody::new(siderust::astro::dynamics::GM_EARTH), sc, Second::new(60.0), &DynamicsContext::empty(), -/// ).unwrap(); -/// assert_eq!(sc1.properties, sc.properties); -/// ``` -pub fn propagate_spacecraft( - integrator: &I, - force: &F, - state: SpacecraftState, - dt: Second, - ctx: &DynamicsContext, -) -> Result -where - I: Integrator, - F: SiderustAccelerationModel, -{ - let orbit = integrator.propagate(force, state.orbit, dt, ctx)?; - Ok(SpacecraftState { - orbit, - properties: state.properties, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::dynamics::Rk4Integrator; - use siderust::astro::dynamics::forces::TwoBody; - use siderust::astro::dynamics::{Position, Velocity}; - use siderust::coordinates::frames::GCRS; - use siderust::time::JulianDate; - - fn s0() -> OrbitState { - OrbitState::new( - JulianDate::new(2_451_545.0).to_j2000s(), - Position::::new(7_000.0, 0.0, 0.0), - Velocity::::new(0.0, 7.5450, 0.0), - ) - } - - #[test] - fn orbit_advances() { - let integ = Rk4Integrator { - step: Second::new(10.0), - }; - let s1 = propagate_orbit( - &integ, - &TwoBody::new(siderust::astro::dynamics::GM_EARTH), - s0(), - Second::new(60.0), - &DynamicsContext::empty(), - ) - .unwrap(); - assert!((s1.epoch - s0().epoch).value() > 0.0); - } -} diff --git a/src/dynamics/integrators.rs b/src/dynamics/integrators.rs deleted file mode 100644 index c471c40..0000000 --- a/src/dynamics/integrators.rs +++ /dev/null @@ -1,273 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Uniform [`Integrator`] trait over the upstream RK4 / DOPRI5 / DOP853 -//! integrators. -//! -//! ## Why -//! -//! Upstream Siderust exposes [`siderust::principia::Stepper`] and -//! [`siderust::principia::AdaptiveStepper`] as two -//! *different* traits, because RK4 needs a fixed sub-step and the adaptive -//! Hairer-style integrators carry a tolerance configuration. That distinction -//! matters at the algorithm boundary, but most callers — POD batch windows, -//! thrust-arc propagation, validation harnesses — only want a single -//! `propagate(force, state, dt)` interface that they can hand a runtime- -//! selected integrator to. -//! -//! [`Integrator`] is exactly that thin uniform interface. Each impl -//! delegates to the canonical upstream [`rk4_propagate`] / -//! [`dopri5_propagate`] / [`dop853_propagate`] functions and re-raises any -//! upstream [`siderust::astro::dynamics::errors::DynamicsError`] through the -//! crate-local [`DynamicsError`]. - -use siderust::astro::dynamics::{DynamicsContext, OrbitState}; -use siderust::pod::force::SiderustAccelerationModel; -use siderust::principia::{ - dop853_propagate, dopri5_propagate, rk4_propagate, IntegratorTolerances, -}; -use siderust::qtty::Second; - -use super::error::DynamicsError; - -/// Uniform integrator interface. -/// -/// Implementors map a single `propagate(force, state, dt, ctx)` call to the -/// corresponding upstream propagator. `dt` may be negative for backward -/// propagation when the underlying algorithm supports it (DOPRI5 / DOP853). -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::{Integrator, Dop853Integrator, OrbitState, Position, Velocity, -/// DynamicsContext, TwoBody}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// use siderust::principia::IntegratorTolerances; -/// use siderust::qtty::Second; -/// -/// let s0 = OrbitState::new( -/// JulianDate::new(2_451_545.0).to_j2000s(), -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.5450, 0.0), -/// ); -/// let integ = Dop853Integrator { -/// tolerances: IntegratorTolerances::uniform(1e-9, 1e-6, 1e-9), -/// }; -/// let s1 = integ.propagate(&TwoBody::new(siderust::astro::dynamics::GM_EARTH), s0, Second::new(60.0), &DynamicsContext::empty()).unwrap(); -/// assert!((s1.epoch - s0.epoch).value() > 0.0); -/// ``` -pub trait Integrator { - /// Propagate `state` by `dt` seconds under `force` and `ctx`. - fn propagate( - &self, - force: &FM, - state: OrbitState, - dt: Second, - ctx: &DynamicsContext, - ) -> Result; -} - -/// Fixed-step classical Runge-Kutta 4th-order integrator. -/// -/// Uses [`siderust::principia::rk4_propagate`] under the -/// hood. The configured `step` is treated as a magnitude; the wrapper -/// computes `n_steps = ceil(|dt| / step)` so the *effective* sub-step never -/// exceeds `step` in absolute value. Forward propagation is supported (RK4 -/// is fixed-step and not naturally signed; pass a positive `dt`). -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::{Integrator, Rk4Integrator, OrbitState, Position, Velocity, -/// DynamicsContext, TwoBody}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// use siderust::qtty::Second; -/// -/// let s0 = OrbitState::new( -/// JulianDate::new(2_451_545.0).to_j2000s(), -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.5450, 0.0), -/// ); -/// let integ = Rk4Integrator { step: Second::new(10.0) }; -/// let s1 = integ.propagate(&TwoBody::new(siderust::astro::dynamics::GM_EARTH), s0, Second::new(60.0), &DynamicsContext::empty()).unwrap(); -/// assert!((s1.epoch - s0.epoch).value() > 0.0); -/// ``` -#[derive(Debug, Clone, Copy)] -pub struct Rk4Integrator { - /// Maximum sub-step magnitude in seconds. Must be strictly positive. - pub step: Second, -} - -impl Integrator for Rk4Integrator { - fn propagate( - &self, - force: &FM, - state: OrbitState, - dt: Second, - ctx: &DynamicsContext, - ) -> Result { - let dt_s = dt.value(); - if dt_s == 0.0 { - return Ok(state); - } - let step = self.step.value().abs(); - if step <= 0.0 || step.is_nan() { - return Err(DynamicsError::Upstream( - siderust::astro::dynamics::errors::DynamicsError::InvalidStepRequest { - reason: "Rk4Integrator.step must be strictly positive", - }, - )); - } - let n_steps = (dt_s.abs() / step).ceil().max(1.0) as usize; - let h = Second::new(dt_s / n_steps as f64); - Ok(rk4_propagate(force, state, h, dt, ctx)?) - } -} - -/// Adaptive Dormand-Prince 5(4) integrator (DOPRI5). -/// -/// Wraps [`siderust::principia::dopri5_propagate`]. -/// Suitable for general-purpose LEO/MEO propagation when 5th-order accuracy -/// is enough. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::{Integrator, Dopri5Integrator}; -/// use siderust::qtty::IntegratorTolerances; -/// let _ = Dopri5Integrator { -/// tolerances: IntegratorTolerances::uniform(1e-9, 1e-6, 1e-9), -/// }; -/// ``` -#[derive(Debug, Clone, Copy)] -pub struct Dopri5Integrator { - /// Adaptive-step tolerances forwarded to the upstream propagator. - pub tolerances: IntegratorTolerances, -} - -impl Integrator for Dopri5Integrator { - fn propagate( - &self, - force: &FM, - state: OrbitState, - dt: Second, - ctx: &DynamicsContext, - ) -> Result { - Ok(dopri5_propagate(force, state, dt, self.tolerances, ctx)?) - } -} - -/// Adaptive Hairer DOP853 8th-order integrator. -/// -/// Wraps [`siderust::principia::dop853_propagate`]. This -/// is the high-precision choice for POD batch windows and finite-burn -/// integration where 1e-9 relative tolerance is the working point. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::{Integrator, Dop853Integrator}; -/// use siderust::qtty::IntegratorTolerances; -/// let _ = Dop853Integrator { -/// tolerances: IntegratorTolerances::uniform(1e-9, 1e-6, 1e-9), -/// }; -/// ``` -#[derive(Debug, Clone, Copy)] -pub struct Dop853Integrator { - /// Adaptive-step tolerances forwarded to the upstream propagator. - pub tolerances: IntegratorTolerances, -} - -impl Integrator for Dop853Integrator { - fn propagate( - &self, - force: &FM, - state: OrbitState, - dt: Second, - ctx: &DynamicsContext, - ) -> Result { - Ok(dop853_propagate(force, state, dt, self.tolerances, ctx)?) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use siderust::astro::dynamics::forces::TwoBody; - use siderust::astro::dynamics::{Position, Velocity}; - use siderust::coordinates::frames::GCRS; - use siderust::time::JulianDate; - - fn s0() -> OrbitState { - OrbitState::new( - JulianDate::new(2_451_545.0).to_j2000s(), - Position::::new(7_000.0, 0.0, 0.0), - Velocity::::new(0.0, 7.5450, 0.0), - ) - } - - #[test] - fn rk4_zero_dt_is_identity() { - let integ = Rk4Integrator { - step: Second::new(10.0), - }; - let s = integ - .propagate( - &TwoBody::new(siderust::astro::dynamics::GM_EARTH), - s0(), - Second::new(0.0), - &DynamicsContext::empty(), - ) - .unwrap(); - let dt_total = s.epoch - s0().epoch; - assert!(dt_total.value().abs() < 1e-15); - } - - #[test] - fn rk4_invalid_step_rejected() { - let integ = Rk4Integrator { - step: Second::new(0.0), - }; - let r = integ.propagate( - &TwoBody::new(siderust::astro::dynamics::GM_EARTH), - s0(), - Second::new(60.0), - &DynamicsContext::empty(), - ); - assert!(matches!( - r, - Err(DynamicsError::Upstream( - siderust::astro::dynamics::errors::DynamicsError::InvalidStepRequest { .. } - )) - )); - } - - #[test] - fn dop853_and_dopri5_agree_on_short_arc() { - let tol = IntegratorTolerances::uniform(1e-12, 1e-9, 1e-12); - let dt = Second::new(120.0); - let ctx = DynamicsContext::empty(); - let a = Dop853Integrator { tolerances: tol } - .propagate( - &TwoBody::new(siderust::astro::dynamics::GM_EARTH), - s0(), - dt, - &ctx, - ) - .unwrap(); - let b = Dopri5Integrator { tolerances: tol } - .propagate( - &TwoBody::new(siderust::astro::dynamics::GM_EARTH), - s0(), - dt, - &ctx, - ) - .unwrap(); - let dx = (a.position.x().value() - b.position.x().value()).abs(); - let dy = (a.position.y().value() - b.position.y().value()).abs(); - let dz = (a.position.z().value() - b.position.z().value()).abs(); - assert!(dx + dy + dz < 1e-3, "dx+dy+dz = {}", dx + dy + dz); - } -} diff --git a/src/dynamics/low_thrust.rs b/src/dynamics/low_thrust.rs deleted file mode 100644 index b7b0e7c..0000000 --- a/src/dynamics/low_thrust.rs +++ /dev/null @@ -1,259 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Low-thrust ΔV bookkeeping. -//! -//! This module accumulates the per-arc and total propellant cost of a -//! sequence of constant-thrust [`ThrustArc`]s using the ideal Tsiolkovsky -//! rocket equation: -//! -//! ```text -//! ṁ = F / (Isp · g₀) [kg/s] -//! Δm = ṁ · Δt [kg] -//! Δv = Isp · g₀ · ln(m₀ / (m₀ − Δm)) [m/s] -//! ``` -//! -//! The bookkeeping assumes the spacecraft mass at the start of each arc is -//! the running mass (i.e. arcs deplete the same propellant tank). This is -//! the convention POD uses when reconciling estimated ΔV against telemetry, -//! and is what mission-design pipelines need before they hand off to a -//! detailed Sims-Flanagan or Pontryagin solver (which lives elsewhere). -//! -//! For thrust-direction modelling and the per-step acceleration vector, see -//! the [`crate::dynamics::thrust`] module. - -use super::thrust::{mass_flow_rate, ManeuverError, ThrustArc, G0_M_PER_S2}; - -/// One log entry recording the propellant cost of a single arc. -/// -/// `dry_mass_kg_after = wet_mass_kg_before - propellant_used_kg`. -#[derive(Debug, Clone, PartialEq)] -pub struct LowThrustRecord { - /// Spacecraft mass at the start of the arc, in kilograms. - pub wet_mass_kg_before: f64, - /// Propellant consumed during the arc, in kilograms. - pub propellant_used_kg: f64, - /// Ideal Tsiolkovsky ΔV imparted by the arc, in metres per second. - pub delta_v_m_per_s: f64, - /// Burn duration in seconds (`stop - start`). - pub duration_s: f64, -} - -/// Cumulative low-thrust propellant ledger. -/// -/// Append [`ThrustArc`]s in chronological order; each call updates the -/// running spacecraft mass and appends a [`LowThrustRecord`]. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::{LowThrustLog, ThrustArc}; -/// use siderust::qtty::force::Newtons; -/// use siderust::tempoch::{Time, UTC}; -/// use chrono::{TimeZone, Utc}; -/// -/// let t0 = Time::::try_from_chrono(Utc.timestamp_opt(946_728_000, 0).single().unwrap()).unwrap(); -/// let t1 = Time::::try_from_chrono(Utc.timestamp_opt(946_728_600, 0).single().unwrap()).unwrap(); -/// let arc = ThrustArc::with_normalised_direction( -/// t0, t1, Newtons::new(0.5), 1500.0, [1.0, 0.0, 0.0], -/// ).unwrap(); -/// -/// let mut log = LowThrustLog::new(100.0); -/// log.append(&arc).unwrap(); -/// assert!(log.total_delta_v_m_per_s() > 0.0); -/// assert!(log.current_mass_kg() < 100.0); -/// ``` -#[derive(Debug, Clone)] -pub struct LowThrustLog { - current_mass_kg: f64, - total_delta_v_m_per_s: f64, - total_propellant_kg: f64, - records: Vec, -} - -impl LowThrustLog { - /// Create a new empty ledger seeded with the spacecraft wet mass. - /// - /// # Errors - /// - /// Returns [`ManeuverError::NonPositiveMass`] if `initial_wet_mass_kg` - /// is non-positive or non-finite. - pub fn try_new(initial_wet_mass_kg: f64) -> Result { - if !initial_wet_mass_kg.is_finite() || initial_wet_mass_kg <= 0.0 { - return Err(ManeuverError::NonPositiveMass(initial_wet_mass_kg)); - } - Ok(Self { - current_mass_kg: initial_wet_mass_kg, - total_delta_v_m_per_s: 0.0, - total_propellant_kg: 0.0, - records: Vec::new(), - }) - } - - /// Convenience constructor that panics on a non-positive mass. - /// - /// Prefer [`LowThrustLog::try_new`] in library code. - pub fn new(initial_wet_mass_kg: f64) -> Self { - Self::try_new(initial_wet_mass_kg).expect("initial wet mass must be > 0") - } - - /// Spacecraft mass after every appended arc has been deducted. - pub fn current_mass_kg(&self) -> f64 { - self.current_mass_kg - } - - /// Cumulative ideal Tsiolkovsky ΔV across all appended arcs, in m/s. - pub fn total_delta_v_m_per_s(&self) -> f64 { - self.total_delta_v_m_per_s - } - - /// Cumulative propellant consumed across all appended arcs, in kg. - pub fn total_propellant_kg(&self) -> f64 { - self.total_propellant_kg - } - - /// Per-arc records, in append order. - pub fn records(&self) -> &[LowThrustRecord] { - &self.records - } - - /// Append `arc`, deducting its propellant from the running mass and - /// adding its Tsiolkovsky ΔV to the total. - /// - /// # Errors - /// - /// * [`ManeuverError::NonPositiveIsp`] if the arc's specific impulse - /// is non-positive. - /// * [`ManeuverError::EmptyInterval`] if the arc has `stop <= start` - /// (also caught at construction by - /// [`ThrustArc::with_normalised_direction`]). - /// * [`ManeuverError::NonPositiveMass`] if the arc would deplete the - /// propellant tank below zero (i.e. `Δm >= m_current`). - pub fn append(&mut self, arc: &ThrustArc) -> Result<&LowThrustRecord, ManeuverError> { - let duration_s = duration_seconds(arc)?; - let mdot = mass_flow_rate(arc.thrust, arc.isp_seconds)?; - let propellant_used_kg = mdot * duration_s; - let wet_mass_kg_before = self.current_mass_kg; - let mass_after = wet_mass_kg_before - propellant_used_kg; - if mass_after <= 0.0 || mass_after.is_nan() { - return Err(ManeuverError::NonPositiveMass(mass_after)); - } - let delta_v_m_per_s = - arc.isp_seconds * G0_M_PER_S2 * (wet_mass_kg_before / mass_after).ln(); - self.current_mass_kg = mass_after; - self.total_delta_v_m_per_s += delta_v_m_per_s; - self.total_propellant_kg += propellant_used_kg; - self.records.push(LowThrustRecord { - wet_mass_kg_before, - propellant_used_kg, - delta_v_m_per_s, - duration_s, - }); - Ok(self.records.last().expect("just pushed")) - } -} - -fn duration_seconds(arc: &ThrustArc) -> Result { - let dt = arc.stop - arc.start; - let secs = dt.value(); - if secs <= 0.0 || secs.is_nan() { - return Err(ManeuverError::EmptyInterval { - start: arc.start, - stop: arc.stop, - }); - } - Ok(secs) -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::{TimeZone, Utc}; - use siderust::qtty::force::Newtons; - use siderust::tempoch::{Time, UTC}; - - fn epoch(secs: i64) -> Time { - Time::::try_from_chrono(Utc.timestamp_opt(946_728_000 + secs, 0).single().unwrap()) - .unwrap() - } - - fn arc(start: i64, stop: i64, thrust_n: f64, isp: f64) -> ThrustArc { - ThrustArc::with_normalised_direction( - epoch(start), - epoch(stop), - Newtons::new(thrust_n), - isp, - [1.0, 0.0, 0.0], - ) - .unwrap() - } - - #[test] - fn empty_log_is_zero() { - let log = LowThrustLog::new(100.0); - assert_eq!(log.current_mass_kg(), 100.0); - assert_eq!(log.total_delta_v_m_per_s(), 0.0); - assert_eq!(log.total_propellant_kg(), 0.0); - assert!(log.records().is_empty()); - } - - #[test] - fn try_new_rejects_non_positive_mass() { - assert!(matches!( - LowThrustLog::try_new(0.0), - Err(ManeuverError::NonPositiveMass(_)) - )); - assert!(matches!( - LowThrustLog::try_new(-1.0), - Err(ManeuverError::NonPositiveMass(_)) - )); - } - - #[test] - fn single_arc_matches_tsiolkovsky() { - let mut log = LowThrustLog::new(100.0); - let a = arc(0, 600, 0.5, 1500.0); - log.append(&a).unwrap(); - let mdot = 0.5 / (1500.0 * G0_M_PER_S2); - let dm = mdot * 600.0; - let expected_dv = 1500.0 * G0_M_PER_S2 * (100.0 / (100.0 - dm)).ln(); - assert!( - (log.total_delta_v_m_per_s() - expected_dv).abs() < 1e-6, - "got {} expected {}", - log.total_delta_v_m_per_s(), - expected_dv, - ); - assert!((log.current_mass_kg() - (100.0 - dm)).abs() < 1e-9); - assert!((log.total_propellant_kg() - dm).abs() < 1e-9); - assert_eq!(log.records().len(), 1); - } - - #[test] - fn multi_arc_sums_correctly() { - let mut log = LowThrustLog::new(100.0); - log.append(&arc(0, 600, 0.5, 1500.0)).unwrap(); - log.append(&arc(600, 1200, 0.5, 1500.0)).unwrap(); - assert_eq!(log.records().len(), 2); - // ΔV should be very nearly 2× the single-arc ΔV (slightly larger - // because mass decreases between arcs). - let single = LowThrustLog::new(100.0) - .also_append(arc(0, 600, 0.5, 1500.0)) - .total_delta_v_m_per_s(); - assert!(log.total_delta_v_m_per_s() > 2.0 * single - 1e-9); - } - - #[test] - fn rejects_propellant_overdraw() { - let mut log = LowThrustLog::new(0.5); - let r = log.append(&arc(0, 600, 100.0, 100.0)); - assert!(matches!(r, Err(ManeuverError::NonPositiveMass(_)))); - } - - // small fluent helper kept inside the test module - impl LowThrustLog { - fn also_append(mut self, arc: ThrustArc) -> Self { - self.append(&arc).unwrap(); - self - } - } -} diff --git a/src/dynamics/mod.rs b/src/dynamics/mod.rs deleted file mode 100644 index f15f5f6..0000000 --- a/src/dynamics/mod.rs +++ /dev/null @@ -1,75 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Astrodynamics and POD-dynamics composition layer. -//! -//! This module merges the former `siderust-dynamics` (domain-agnostic -//! astrodynamics primitives) and `spod::dynamics` (POD-specific -//! force-model composition) into a unified surface. -//! -//! ## Contents -//! -//! - [`integrators`] — uniform [`Integrator`] trait over RK4/DOPRI5/DOP853 -//! - [`thrust`] — finite-burn thrust-arc physical model -//! - [`low_thrust`] — Tsiolkovsky ΔV bookkeeping -//! - [`validation`] — STM finite-difference validation harness -//! - [`empirical_periodic`] — 1-CPR / 2-CPR periodic empirical accelerations -//! - [`force_config`] — configurable force-model spec -//! - [`forces`] — force-model implementations -//! - [`integrator_adapter`] — typed integrator adapters -//! - [`process_noise`] — Q-matrix construction for EKF -//! - [`registry`] — named force-model registry -//! - [`thrust_arc`] — thrust-arc parameter declaration -//! - [`variational`] — variational/STM propagator - -pub mod empirical_periodic; -pub mod error; -pub mod force_config; -pub mod forces; -pub mod integrator_adapter; -pub mod integrators; -pub mod low_thrust; -pub mod pod_error; -pub mod process_noise; -pub mod registry; -pub mod thrust; -pub mod thrust_arc; -pub mod validation; -pub mod variational; - -// Re-exports from the low-level layer (former siderust-dynamics public surface) -pub use error::DynamicsError; -pub use integrators::{Dop853Integrator, Dopri5Integrator, Integrator, Rk4Integrator}; -pub use low_thrust::{LowThrustLog, LowThrustRecord}; -pub use thrust::{mass_flow_rate, thrust_acceleration, ManeuverError, ThrustArc, G0_M_PER_S2}; - -// Re-exports from the POD dynamics layer (former spod::dynamics public surface) -pub use empirical_periodic::{EmpiricalPeriodicAcceleration, PeriodicHarmonic}; -pub use force_config::ForceModelConfig; -pub use forces::{ - AccelPartials, Acceleration3, CartesianState, DragForce, Epoch, ForceModel, - ForceModelRegistry as EvaluatingForceModelRegistry, J2PerturbationForce, - SolarRadiationPressureForce, TwoBodyForce, -}; -pub use integrator_adapter::{propagate_orbit, propagate_spacecraft}; -pub use pod_error::PodDynamicsError; -pub use process_noise::{ - GaussMarkovParams, PiecewiseSegment, ProcessNoise, ProcessNoiseModel, WhiteAccelPsd, -}; -pub use registry::{ForceModelFactory, ForceModelParams, ForceModelRegistry, ForceModelSpec}; -pub use thrust_arc::ThrustArcConfig; -pub use variational::{ - param_partials_central_diff, ParamColumn, ParamStmReport, PropagatedArc, VariationalPropagator, -}; - -// Convenience re-exports from the upstream canonical siderust types -#[doc(no_inline)] -pub use siderust::astro::dynamics::{ - forces::{TwoBody, J2}, - DynamicsContext, OrbitState, Position, Propagator, PropagatorConfig, StateTransitionMatrix, - Velocity, -}; -pub use siderust::pod::force::{ - SiderustAccelerationModel as SiderustForceModel, SiderustCompositeModel as CompositeForce, -}; -pub use siderust::principia::propagate_stm; diff --git a/src/dynamics/pod_error.rs b/src/dynamics/pod_error.rs deleted file mode 100644 index c9ec8a3..0000000 --- a/src/dynamics/pod_error.rs +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Unified error enum for `spod::dynamics`. -//! -//! Wraps the failure surfaces this crate exposes: -//! -//! * Upstream [`crate::dynamics::DynamicsError`] for any integrator, -//! variational, or force-model failure surfaced through the typed adapter. -//! * Registry / configuration validation errors raised by -//! [`crate::dynamics::registry::ForceModelRegistry`] and friends. -//! * Process-noise input validation. -//! * Explicit *feature-not-implemented* signal for stubs that the registry -//! recognises by name but cannot yet build (e.g. boxwing SRP). -//! -//! No `anyhow` is used; every variant is an explicit case so consumers can -//! pattern-match. - -use thiserror::Error; - -/// Top-level error type for `spod::dynamics`. -/// -/// # Examples -/// -/// ``` -/// use spod::dynamics::PodDynamicsError; -/// -/// let err = PodDynamicsError::UnknownModel("nonsense".into()); -/// assert_eq!(err.to_string(), "unknown force model name: 'nonsense'"); -/// ``` -#[derive(Debug, Error)] -pub enum PodDynamicsError { - /// Error from the underlying `siderust-dynamics` integrator / STM / - /// force-model stack. - #[error(transparent)] - Dynamics(#[from] crate::dynamics::DynamicsError), - - /// The registry was asked for a name it does not recognise. - #[error("unknown force model name: '{0}'")] - UnknownModel(String), - - /// The registry was asked for a model with parameters that do not match - /// the factory's expected shape. - #[error("force model '{name}' rejected the supplied parameters: {reason}")] - InvalidParameters { - /// Registered name of the model. - name: String, - /// Free-form description of why the params were rejected. - reason: &'static str, - }, - - /// The caller requested a model that is recognised by name but whose - /// implementation has not been wired up in this release. Used in lieu of - /// `unimplemented!()` to keep `scripts/check_no_todos.sh` happy and to - /// give consumers a typed error variant they can fall back from. - #[error("force model '{0}' is recognised but not yet implemented in this build")] - FeatureNotImplemented(&'static str), - - /// Process-noise configuration was internally inconsistent - /// (e.g. negative sigma, non-positive τ). - #[error("invalid process-noise configuration: {0}")] - InvalidProcessNoise(&'static str), - - /// Parameter STM finite-difference perturbation request was invalid - /// (e.g. zero step size). - #[error("invalid parameter STM step: {0}")] - InvalidParamStep(&'static str), -} diff --git a/src/dynamics/process_noise.rs b/src/dynamics/process_noise.rs deleted file mode 100644 index 55e617c..0000000 --- a/src/dynamics/process_noise.rs +++ /dev/null @@ -1,513 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Process-noise (`Q`) matrix construction for the sequential filter. -//! -//! The sequential EKF in `spod::estimation` consumes a discrete-time -//! process-noise covariance `Q(Δt)` block-diagonal in: -//! -//! 1. Cartesian position/velocity (driven by white acceleration noise). -//! 2. A drag-scale parameter `Cd_scale` modelled as a first-order -//! Gauss–Markov process. -//! 3. An SRP-scale parameter `Crp_scale` modelled likewise. -//! 4. White empirical-acceleration coefficients (constant RTN by default). -//! -//! ## Equations -//! -//! For continuous-time white-acceleration PSD `q` (units (km/s²)²/Hz) the -//! standard van-Loan formulas yield, per axis, -//! -//! ```text -//! Q_rr = q · Δt³ / 3 Q_rv = q · Δt² / 2 Q_vv = q · Δt -//! ``` -//! -//! For a Gauss–Markov scale parameter with steady-state σ and time -//! constant τ, -//! -//! ```text -//! Q_pp(Δt) = σ² · (1 − exp(−2 Δt / τ)) -//! ``` -//! -//! For white empirical accelerations the RTN block is `σ² · Δt` per axis. -//! -//! All time inputs are typed [`Second`] from `qtty` (the typed time -//! quantity exposed by both `qtty` and `tempoch`). - -use siderust::qtty::{KmPerSecondsSquared, Second}; - -use super::pod_error::PodDynamicsError; - -/// Continuous-time white-acceleration PSD per axis, `(km/s²)²/Hz`. -/// -/// Stored as a per-axis array so radial / cross-track noise can differ when -/// the filter is rotated into a body or RTN frame downstream. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct WhiteAccelPsd(pub [f64; 3]); - -impl WhiteAccelPsd { - /// Build an isotropic PSD by squaring a typed `KmPerSecondsSquared` - /// sigma (interpreted as a one-sigma white-acceleration amplitude with - /// 1 Hz reference bandwidth — the value is squared into PSD units). - pub fn isotropic(sigma: KmPerSecondsSquared) -> Self { - let q = sigma.value().powi(2); - Self([q, q, q]) - } -} - -/// First-order Gauss–Markov parameters. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct GaussMarkovParams { - /// Steady-state one-sigma magnitude (dimensionless for scale parameters). - pub sigma: f64, - /// Correlation time constant (typed seconds). - pub tau: Second, -} - -impl GaussMarkovParams { - /// Discrete-time variance after `dt`. - /// - /// Returns `σ² (1 − exp(−2 Δt / τ))`. - /// - /// # Errors - /// - /// * [`PodDynamicsError::InvalidProcessNoise`] if `tau ≤ 0` or `sigma < 0`. - pub fn variance_after(&self, dt: Second) -> Result { - if self.sigma < 0.0 || !self.sigma.is_finite() { - return Err(PodDynamicsError::InvalidProcessNoise( - "sigma must be ≥ 0 and finite", - )); - } - let tau = self.tau.value(); - if tau <= 0.0 || !tau.is_finite() { - return Err(PodDynamicsError::InvalidProcessNoise( - "tau must be > 0 and finite", - )); - } - let arg = -2.0 * dt.value() / tau; - Ok(self.sigma.powi(2) * (1.0 - arg.exp())) - } -} - -/// Top-level process-noise specification. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::{ProcessNoiseModel, WhiteAccelPsd, GaussMarkovParams}; -/// use siderust::qtty::{KmPerSecondsSquared, Second}; -/// let m = ProcessNoiseModel { -/// position_velocity: WhiteAccelPsd::isotropic(KmPerSecondsSquared::new(1e-9)), -/// drag_scale: Some(GaussMarkovParams { sigma: 0.1, tau: Second::new(3600.0) }), -/// srp_scale: None, -/// empirical_white: None, -/// }; -/// let q = m.q_matrix(Second::new(60.0)).unwrap(); -/// // 6-state cartesian + 1 drag-scale = 7×7 -/// assert_eq!(q.len(), 7); -/// assert_eq!(q[0].len(), 7); -/// ``` -#[derive(Debug, Clone, Copy)] -pub struct ProcessNoiseModel { - /// White-acceleration PSD driving the position/velocity 6×6 block. - pub position_velocity: WhiteAccelPsd, - /// Optional drag-scale Gauss–Markov term. - pub drag_scale: Option, - /// Optional SRP-scale Gauss–Markov term. - pub srp_scale: Option, - /// Optional white empirical-acceleration sigma (per RTN axis, treated - /// as `σ² · Δt`). Stored as typed sigma values. - pub empirical_white: Option<[KmPerSecondsSquared; 3]>, -} - -impl ProcessNoiseModel { - /// Total dimension `n = 6 + (drag?) + (srp?) + 3·(empirical?)`. - pub fn dim(&self) -> usize { - 6 + usize::from(self.drag_scale.is_some()) - + usize::from(self.srp_scale.is_some()) - + if self.empirical_white.is_some() { 3 } else { 0 } - } - - /// Construct the discrete-time process-noise covariance for the - /// requested time step `dt`. - /// - /// Layout of the returned matrix is, in order: - /// - /// 1. `0..3` — position (km²) - /// 2. `3..6` — velocity (km²/s²) - /// 3. (optional) drag scale (dimensionless²) - /// 4. (optional) SRP scale (dimensionless²) - /// 5. (optional) 3 × empirical RTN (km/s²)² - /// - /// # Errors - /// - /// Returns [`PodDynamicsError::InvalidProcessNoise`] when any tau ≤ 0 - /// or any sigma is negative or non-finite. - pub fn q_matrix(&self, dt: Second) -> Result>, PodDynamicsError> { - let dt_s = dt.value(); - if dt_s < 0.0 || !dt_s.is_finite() { - return Err(PodDynamicsError::InvalidProcessNoise( - "dt must be ≥ 0 and finite", - )); - } - let n = self.dim(); - let mut q = vec![vec![0.0_f64; n]; n]; - - // Position/velocity block (white acceleration noise). - let dt2 = dt_s * dt_s; - let dt3 = dt2 * dt_s; - for i in 0..3 { - let qi = self.position_velocity.0[i]; - if qi < 0.0 || !qi.is_finite() { - return Err(PodDynamicsError::InvalidProcessNoise( - "white-acceleration PSD must be ≥ 0 and finite", - )); - } - q[i][i] = qi * dt3 / 3.0; - q[3 + i][3 + i] = qi * dt_s; - let cross = qi * dt2 / 2.0; - q[i][3 + i] = cross; - q[3 + i][i] = cross; - } - - let mut idx = 6; - if let Some(gm) = self.drag_scale { - q[idx][idx] = gm.variance_after(dt)?; - idx += 1; - } - if let Some(gm) = self.srp_scale { - q[idx][idx] = gm.variance_after(dt)?; - idx += 1; - } - if let Some(emp) = self.empirical_white { - for k in 0..3 { - let s = emp[k].value(); - if s < 0.0 || !s.is_finite() { - return Err(PodDynamicsError::InvalidProcessNoise( - "empirical sigma must be ≥ 0 and finite", - )); - } - q[idx + k][idx + k] = s * s * dt_s; - } - } - Ok(q) - } - - /// Return `true` iff the matrix produced by `q_matrix(dt)` is positive - /// semi-definite. Implemented by Cholesky-with-pivot of the symmetric - /// matrix; for the small dimensions we deal with (≤ 12) it is exact and - /// allocation-free w.r.t. the result, and avoids pulling in a heavy - /// dependency. - /// - /// # Example - /// - /// ``` - /// use spod::dynamics::{ProcessNoiseModel, WhiteAccelPsd, GaussMarkovParams}; - /// use siderust::qtty::{KmPerSecondsSquared, Second}; - /// let m = ProcessNoiseModel { - /// position_velocity: WhiteAccelPsd::isotropic(KmPerSecondsSquared::new(1e-9)), - /// drag_scale: Some(GaussMarkovParams { sigma: 0.1, tau: Second::new(3600.0) }), - /// srp_scale: None, - /// empirical_white: None, - /// }; - /// assert!(m.is_psd(Second::new(60.0)).unwrap()); - /// ``` - pub fn is_psd(&self, dt: Second) -> Result { - let q = self.q_matrix(dt)?; - Ok(matrix_is_psd(&q)) - } -} - -/// Test a small symmetric matrix for positive semi-definiteness via a -/// pivoted Cholesky-style factorisation with a small absolute tolerance. -fn matrix_is_psd(a: &[Vec]) -> bool { - let n = a.len(); - let mut l = vec![vec![0.0_f64; n]; n]; - let tol = 1e-18; - for i in 0..n { - let mut s = a[i][i]; - s -= l[i][..i].iter().map(|x| x * x).sum::(); - if s < -tol { - return false; - } - let lii = s.max(0.0).sqrt(); - l[i][i] = lii; - if lii == 0.0 { - // Whole row must be zero for PSD with zero pivot. - for j in (i + 1)..n { - let s2 = a[j][i] - - l[j][..i] - .iter() - .zip(l[i][..i].iter()) - .map(|(a, b)| a * b) - .sum::(); - if s2.abs() > 1e-12 { - return false; - } - l[j][i] = 0.0; - } - } else { - for j in (i + 1)..n { - let s2 = a[j][i] - - l[j][..i] - .iter() - .zip(l[i][..i].iter()) - .map(|(a, b)| a * b) - .sum::(); - l[j][i] = s2 / lii; - } - } - } - true -} - -// ───────────────────────────────────────────────────────────────────────────── -// ProcessNoise enum (POD-layer high-level API) -// ───────────────────────────────────────────────────────────────────────────── - -/// One interval in a piecewise-constant noise schedule. -/// -/// Units are **SI** (meters / m/s) rather than km / km/s so that the values -/// align with typical OD covariance budgets expressed in SI. -/// -/// # Example -/// -/// ``` -/// use siderust::qtty::Second; -/// use spod::dynamics::process_noise::PiecewiseSegment; -/// -/// let seg = PiecewiseSegment { -/// duration: Second::new(300.0), -/// sigma_pos_m: 1.0, -/// sigma_vel_mps: 0.001, -/// }; -/// assert_eq!(seg.duration.value(), 300.0); -/// ``` -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct PiecewiseSegment { - /// Duration of this noise interval (seconds). - pub duration: Second, - /// One-sigma position noise amplitude (metres). - pub sigma_pos_m: f64, - /// One-sigma velocity noise amplitude (metres per second). - pub sigma_vel_mps: f64, -} - -/// High-level process-noise specification for a single filter update step. -/// -/// Wraps the common noise configurations in a convenient enum so that -/// downstream filter code can dispatch without inspecting struct fields. -/// Differs from [`ProcessNoiseModel`] in that: -/// -/// * Units are SI (metres, m/s) rather than km, km/s. -/// * The type is an `enum` for ergonomic matching rather than a struct. -/// * Drag/SRP scale and empirical-acceleration terms are handled by -/// [`ProcessNoiseModel`]; this enum covers the position/velocity noise budget. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::process_noise::{PiecewiseSegment, ProcessNoise}; -/// use siderust::qtty::Second; -/// -/// // No process noise at all. -/// let n = ProcessNoise::None; -/// assert!(!n.is_active()); -/// -/// // Isotropic white noise. -/// let w = ProcessNoise::WhiteNoise { sigma_pos_m: 10.0, sigma_vel_mps: 0.01 }; -/// assert!(w.is_active()); -/// -/// // Two-segment piecewise-constant schedule. -/// let p = ProcessNoise::PiecewiseConstant { -/// intervals: vec![ -/// PiecewiseSegment { duration: Second::new(60.0), sigma_pos_m: 5.0, sigma_vel_mps: 0.005 }, -/// PiecewiseSegment { duration: Second::new(300.0), sigma_pos_m: 10.0, sigma_vel_mps: 0.01 }, -/// ], -/// }; -/// assert!(p.is_active()); -/// if let ProcessNoise::PiecewiseConstant { intervals } = &p { -/// assert_eq!(intervals.len(), 2); -/// } -/// ``` -#[derive(Debug, Clone, PartialEq)] -pub enum ProcessNoise { - /// No process noise: the covariance is evolved by the STM only. - None, - - /// Isotropic white-noise injection with fixed sigma values for all steps. - /// - /// Units: metres (position) and metres per second (velocity). - WhiteNoise { - /// One-sigma position noise (metres). - sigma_pos_m: f64, - /// One-sigma velocity noise (metres per second). - sigma_vel_mps: f64, - }, - - /// Piecewise-constant noise schedule: a different sigma level applies to - /// each time interval. The interval at index `i` applies for a step whose - /// elapsed arc time falls within `sum(durations[0..i])..sum(durations[0..=i])`. - /// - /// Intervals are matched by cumulative elapsed arc time. If the current - /// time lies beyond the last interval, the last segment's sigma values are - /// used (constant extension). - PiecewiseConstant { - /// Ordered list of intervals (first interval applies earliest). - intervals: Vec, - }, -} - -impl ProcessNoise { - /// `true` iff this variant injects any noise (i.e. is not [`ProcessNoise::None`]). - /// - /// # Example - /// - /// ``` - /// use spod::dynamics::process_noise::ProcessNoise; - /// assert!(!ProcessNoise::None.is_active()); - /// assert!(ProcessNoise::WhiteNoise { sigma_pos_m: 1.0, sigma_vel_mps: 0.01 }.is_active()); - /// ``` - pub fn is_active(&self) -> bool { - !matches!(self, ProcessNoise::None) - } - - /// Look up the noise sigmas applicable at cumulative elapsed time `elapsed`. - /// - /// Returns `(sigma_pos_m, sigma_vel_mps)`: - /// - /// * [`ProcessNoise::None`] → `(0.0, 0.0)`. - /// * [`ProcessNoise::WhiteNoise`] → the fixed sigma pair. - /// * [`ProcessNoise::PiecewiseConstant`] → sigma of the interval that - /// contains `elapsed`; falls back to the last interval for times beyond - /// the schedule. Returns `(0.0, 0.0)` for an empty schedule. - /// - /// # Example - /// - /// ``` - /// use spod::dynamics::process_noise::{PiecewiseSegment, ProcessNoise}; - /// use siderust::qtty::Second; - /// - /// let p = ProcessNoise::PiecewiseConstant { - /// intervals: vec![ - /// PiecewiseSegment { duration: Second::new(60.0), sigma_pos_m: 5.0, sigma_vel_mps: 0.005 }, - /// PiecewiseSegment { duration: Second::new(300.0), sigma_pos_m: 10.0, sigma_vel_mps: 0.01 }, - /// ], - /// }; - /// // Before first boundary (30 s < 60 s). - /// assert_eq!(p.sigma_at(Second::new(30.0)), (5.0, 0.005)); - /// // After first boundary (100 s in 60..360 range). - /// assert_eq!(p.sigma_at(Second::new(100.0)), (10.0, 0.01)); - /// // Beyond schedule → last segment. - /// assert_eq!(p.sigma_at(Second::new(1000.0)), (10.0, 0.01)); - /// ``` - pub fn sigma_at(&self, elapsed: Second) -> (f64, f64) { - match self { - ProcessNoise::None => (0.0, 0.0), - ProcessNoise::WhiteNoise { - sigma_pos_m, - sigma_vel_mps, - } => (*sigma_pos_m, *sigma_vel_mps), - ProcessNoise::PiecewiseConstant { intervals } => { - if intervals.is_empty() { - return (0.0, 0.0); - } - let mut cumulative = 0.0; - for seg in intervals { - cumulative += seg.duration.value(); - if elapsed.value() < cumulative { - return (seg.sigma_pos_m, seg.sigma_vel_mps); - } - } - // Elapsed is past the last boundary — use last segment. - let last = intervals.last().expect("non-empty checked above"); - (last.sigma_pos_m, last.sigma_vel_mps) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn dimensions_track_options() { - let m = ProcessNoiseModel { - position_velocity: WhiteAccelPsd::isotropic(KmPerSecondsSquared::new(1e-9)), - drag_scale: Some(GaussMarkovParams { - sigma: 0.1, - tau: Second::new(3600.0), - }), - srp_scale: Some(GaussMarkovParams { - sigma: 0.05, - tau: Second::new(7200.0), - }), - empirical_white: Some([KmPerSecondsSquared::new(1e-10); 3]), - }; - assert_eq!(m.dim(), 6 + 1 + 1 + 3); - let q = m.q_matrix(Second::new(60.0)).unwrap(); - assert_eq!(q.len(), 11); - } - - #[test] - fn rejects_negative_sigma() { - let m = ProcessNoiseModel { - position_velocity: WhiteAccelPsd::isotropic(KmPerSecondsSquared::new(1e-9)), - drag_scale: Some(GaussMarkovParams { - sigma: -1.0, - tau: Second::new(60.0), - }), - srp_scale: None, - empirical_white: None, - }; - assert!(matches!( - m.q_matrix(Second::new(10.0)).unwrap_err(), - PodDynamicsError::InvalidProcessNoise(_) - )); - } - - #[test] - fn process_noise_none_is_inactive() { - assert!(!ProcessNoise::None.is_active()); - assert_eq!(ProcessNoise::None.sigma_at(Second::new(0.0)), (0.0, 0.0)); - } - - #[test] - fn process_noise_white_noise_is_active() { - let n = ProcessNoise::WhiteNoise { - sigma_pos_m: 5.0, - sigma_vel_mps: 0.01, - }; - assert!(n.is_active()); - assert_eq!(n.sigma_at(Second::new(100.0)), (5.0, 0.01)); - } - - #[test] - fn piecewise_constant_dispatches_by_elapsed_time() { - let p = ProcessNoise::PiecewiseConstant { - intervals: vec![ - PiecewiseSegment { - duration: Second::new(60.0), - sigma_pos_m: 1.0, - sigma_vel_mps: 0.001, - }, - PiecewiseSegment { - duration: Second::new(300.0), - sigma_pos_m: 10.0, - sigma_vel_mps: 0.01, - }, - ], - }; - assert_eq!(p.sigma_at(Second::new(0.0)), (1.0, 0.001)); - assert_eq!(p.sigma_at(Second::new(59.9)), (1.0, 0.001)); - assert_eq!(p.sigma_at(Second::new(60.0)), (10.0, 0.01)); - // Beyond schedule → last segment. - assert_eq!(p.sigma_at(Second::new(9_999.0)), (10.0, 0.01)); - } - - #[test] - fn piecewise_constant_empty_schedule_returns_zero() { - let p = ProcessNoise::PiecewiseConstant { intervals: vec![] }; - assert_eq!(p.sigma_at(Second::new(0.0)), (0.0, 0.0)); - } -} diff --git a/src/dynamics/registry.rs b/src/dynamics/registry.rs deleted file mode 100644 index e530f26..0000000 --- a/src/dynamics/registry.rs +++ /dev/null @@ -1,543 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Named force-model registry. -//! -//! The POD service layer ingests a YAML/JSON config and needs to translate -//! string keys (`"two_body"`, `"j2"`, `"drag"`, …) into concrete -//! acceleration-model instances. The registry centralises that mapping so that: -//! -//! * Built-in models (two-body, J2, geopotential, Sun/Moon third body, -//! cannonball SRP, drag, central-body relativity, constant/1-CPR/2-CPR -//! empirical accelerations) are always available out of the box. -//! * Downstream crates can plug in additional factories without forking -//! this crate. -//! -//! Each factory implements the [`ForceModelFactory`] trait. A -//! [`ForceModelSpec`] (`name + ForceModelParams`) is consumed by the -//! registry to produce a heap-allocated model ready for insertion into a -//! [`SiderustCompositeModel`]. -//! -//! # Example -//! -//! ``` -//! use spod::dynamics::registry::{ForceModelRegistry, ForceModelSpec}; -//! let mut reg = ForceModelRegistry::with_builtins(); -//! let composite = reg -//! .build(&[ForceModelSpec::named("two_body"), ForceModelSpec::named("j2")]) -//! .unwrap(); -//! assert_eq!(composite.len(), 2); -//! ``` - -use std::collections::BTreeMap; - -use siderust::astro::dynamics::density::DensityProvider; -use siderust::astro::dynamics::forces::{ - CannonballSrp, CentralBodyRelativity1Pn, Conical, Cylindrical, DragForce, - EmpiricalAcceleration, Geopotential, NoEclipse, ShadowModel, ThirdBody, TwoBody, J2, -}; -use siderust::astro::dynamics::{EARTH_J2, GM_EARTH, R_EARTH}; -use siderust::pod::force::{DynSiderustForceModel, SiderustCompositeModel}; -use siderust::qtty::{AreaToMass, DragCoefficient, KmPerSecondsSquared, Second, SrpCoefficient}; -use siderust::time::JulianDate; - -use super::empirical_periodic::{EmpiricalPeriodicAcceleration, PeriodicHarmonic}; -use super::pod_error::PodDynamicsError; - -/// Concrete parameter payload supplied with a [`ForceModelSpec`]. -/// -/// Each variant matches the parameter shape expected by the corresponding -/// built-in factory. Custom factories typically use [`ForceModelParams::None`] -/// or [`ForceModelParams::Custom`]. -#[non_exhaustive] -#[derive(Debug, Clone)] -pub enum ForceModelParams { - /// Factory needs no parameters (e.g. two-body, J2, relativity). - None, - /// Spherical-harmonic geopotential truncation `(degree, order)`. - Geopotential { - /// Maximum degree N. - degree: usize, - /// Maximum order M (≤ N). - order: usize, - }, - /// Cannonball drag parameters. - Drag { - /// Drag coefficient `C_D`. - cd: DragCoefficient, - /// Effective area-to-mass ratio (m²/kg). - area_to_mass: AreaToMass, - }, - /// Cannonball SRP parameters. - SrpCannonball { - /// SRP reflectivity coefficient `C_R`. - cr: SrpCoefficient, - /// Effective area-to-mass ratio (m²/kg). - area_to_mass: AreaToMass, - /// Earth shadow model. - shadow: ShadowModel, - }, - /// Constant RTN empirical acceleration. - EmpiricalConstant { - /// Radial component. - radial: KmPerSecondsSquared, - /// Transverse (along-track) component. - transverse: KmPerSecondsSquared, - /// Normal (cross-track) component. - normal: KmPerSecondsSquared, - }, - /// Periodic empirical acceleration (1-CPR or 2-CPR). - EmpiricalPeriodic { - /// Harmonic order. - harmonic: PeriodicHarmonic, - /// Reference epoch defining `θ = 0`. - epoch_ref: JulianDate, - /// Orbital period `T_orbit`. - period: Second, - /// `[r_cos, r_sin, t_cos, t_sin, n_cos, n_sin]` typed coefficients. - coeffs: [KmPerSecondsSquared; 6], - }, - /// Free-form custom payload, opaque to the built-in registry. - Custom(String), -} - -/// Declarative spec consumed by [`ForceModelRegistry::build`]. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::registry::ForceModelSpec; -/// let s = ForceModelSpec::named("j2"); -/// assert_eq!(s.name, "j2"); -/// ``` -#[derive(Debug, Clone)] -pub struct ForceModelSpec { - /// Registry key (e.g. `"two_body"`). - pub name: String, - /// Parameters consumed by the matching factory. - pub params: ForceModelParams, -} - -impl ForceModelSpec { - /// Build a no-parameter spec (factory must accept [`ForceModelParams::None`]). - pub fn named(name: impl Into) -> Self { - Self { - name: name.into(), - params: ForceModelParams::None, - } - } - - /// Build a spec with explicit parameters. - pub fn with_params(name: impl Into, params: ForceModelParams) -> Self { - Self { - name: name.into(), - params, - } - } -} - -/// Trait implemented by registry factories. -pub trait ForceModelFactory: Send + Sync { - /// Stable string key under which this factory is registered. - fn name(&self) -> &'static str; - - /// Construct an instance from the supplied parameters. - fn build( - &self, - params: &ForceModelParams, - ) -> Result, PodDynamicsError>; -} - -/// String-keyed force-model registry. -/// -/// Use [`ForceModelRegistry::with_builtins`] to get a registry pre-populated -/// with the built-in factories listed in the crate-level docs, then -/// [`ForceModelRegistry::register`] to add custom factories. -pub struct ForceModelRegistry { - factories: BTreeMap>, -} - -impl Default for ForceModelRegistry { - fn default() -> Self { - Self::with_builtins() - } -} - -impl ForceModelRegistry { - /// Create an empty registry. - pub fn new() -> Self { - Self { - factories: BTreeMap::new(), - } - } - - /// Create a registry pre-populated with the built-in factories: - /// `two_body`, `j2`, `geopotential`, `third_body_sun`, `third_body_moon`, - /// `third_body_sun_moon`, `drag`, `srp_cannonball`, `srp_boxwing` (stub), - /// `relativity`, `empirical_constant`, `empirical_1cpr`, `empirical_2cpr`. - /// - /// # Example - /// - /// ``` - /// use spod::dynamics::registry::ForceModelRegistry; - /// let reg = ForceModelRegistry::with_builtins(); - /// assert!(reg.is_registered("two_body")); - /// assert!(reg.is_registered("empirical_2cpr")); - /// ``` - pub fn with_builtins() -> Self { - let mut r = Self::new(); - r.register(Box::new(TwoBodyFactory)); - r.register(Box::new(J2Factory)); - r.register(Box::new(GeopotentialFactory)); - r.register(Box::new(ThirdBodySunFactory)); - r.register(Box::new(ThirdBodyMoonFactory)); - r.register(Box::new(ThirdBodySunMoonFactory)); - r.register(Box::new(DragFactory)); - r.register(Box::new(SrpCannonballFactory)); - r.register(Box::new(SrpBoxwingFactory)); - r.register(Box::new(RelativityFactory)); - r.register(Box::new(EmpiricalConstantFactory)); - r.register(Box::new(Empirical1CprFactory)); - r.register(Box::new(Empirical2CprFactory)); - r - } - - /// Register or replace a factory. - pub fn register(&mut self, f: Box) { - self.factories.insert(f.name().to_string(), f); - } - - /// Return `true` iff a factory with `name` is currently registered. - pub fn is_registered(&self, name: &str) -> bool { - self.factories.contains_key(name) - } - - /// All currently-registered names, sorted lexicographically. - pub fn names(&self) -> Vec<&str> { - self.factories.keys().map(|s| s.as_str()).collect() - } - - /// Resolve a single spec into a boxed acceleration model. - /// - /// # Errors - /// - /// * [`PodDynamicsError::UnknownModel`] if `spec.name` is not registered. - /// * Any error returned by the factory. - pub fn build_one( - &self, - spec: &ForceModelSpec, - ) -> Result, PodDynamicsError> { - let f = self - .factories - .get(&spec.name) - .ok_or_else(|| PodDynamicsError::UnknownModel(spec.name.clone()))?; - f.build(&spec.params) - } - - /// Resolve a batch of specs into a [`SiderustCompositeModel`] in declared order. - /// - /// # Example - /// - /// ``` - /// use spod::dynamics::registry::{ForceModelRegistry, ForceModelSpec, ForceModelParams}; - /// let reg = ForceModelRegistry::with_builtins(); - /// let composite = reg.build(&[ - /// ForceModelSpec::named("two_body"), - /// ForceModelSpec::with_params( - /// "geopotential", - /// ForceModelParams::Geopotential { degree: 4, order: 4 }, - /// ), - /// ]).unwrap(); - /// assert_eq!(composite.len(), 2); - /// ``` - pub fn build( - &self, - specs: &[ForceModelSpec], - ) -> Result { - let mut out = SiderustCompositeModel::empty(); - for s in specs { - out = out.push(self.build_one(s)?); - } - Ok(out) - } -} - -// ───────────────────────────── built-in factories ────────────────────────── - -struct TwoBodyFactory; -impl ForceModelFactory for TwoBodyFactory { - fn name(&self) -> &'static str { - "two_body" - } - fn build(&self, _p: &ForceModelParams) -> Result, PodDynamicsError> { - Ok(Box::new(TwoBody::new(GM_EARTH))) - } -} - -struct J2Factory; -impl ForceModelFactory for J2Factory { - fn name(&self) -> &'static str { - "j2" - } - fn build(&self, _p: &ForceModelParams) -> Result, PodDynamicsError> { - Ok(Box::new(J2::new(GM_EARTH, R_EARTH, EARTH_J2))) - } -} - -struct GeopotentialFactory; -impl ForceModelFactory for GeopotentialFactory { - fn name(&self) -> &'static str { - "geopotential" - } - fn build(&self, p: &ForceModelParams) -> Result, PodDynamicsError> { - match p { - ForceModelParams::Geopotential { degree, order } => { - Ok(Box::new(Geopotential::new(*degree, *order))) - } - _ => Err(PodDynamicsError::InvalidParameters { - name: "geopotential".into(), - reason: "expected ForceModelParams::Geopotential { degree, order }", - }), - } - } -} - -struct ThirdBodySunFactory; -impl ForceModelFactory for ThirdBodySunFactory { - fn name(&self) -> &'static str { - "third_body_sun" - } - fn build(&self, _p: &ForceModelParams) -> Result, PodDynamicsError> { - Ok(Box::new(ThirdBody::new().with_sun())) - } -} - -struct ThirdBodyMoonFactory; -impl ForceModelFactory for ThirdBodyMoonFactory { - fn name(&self) -> &'static str { - "third_body_moon" - } - fn build(&self, _p: &ForceModelParams) -> Result, PodDynamicsError> { - Ok(Box::new(ThirdBody::new().with_moon())) - } -} - -struct ThirdBodySunMoonFactory; -impl ForceModelFactory for ThirdBodySunMoonFactory { - fn name(&self) -> &'static str { - "third_body_sun_moon" - } - fn build(&self, _p: &ForceModelParams) -> Result, PodDynamicsError> { - Ok(Box::new(ThirdBody::sun_and_moon())) - } -} - -struct DragFactory; -impl ForceModelFactory for DragFactory { - fn name(&self) -> &'static str { - "drag" - } - fn build(&self, p: &ForceModelParams) -> Result, PodDynamicsError> { - match p { - ForceModelParams::Drag { cd, area_to_mass } => { - Ok(Box::new(DragForce::new(*cd, *area_to_mass))) - } - _ => Err(PodDynamicsError::InvalidParameters { - name: "drag".into(), - reason: "expected ForceModelParams::Drag { cd, area_to_mass }", - }), - } - } -} - -struct SrpCannonballFactory; -impl ForceModelFactory for SrpCannonballFactory { - fn name(&self) -> &'static str { - "srp_cannonball" - } - fn build(&self, p: &ForceModelParams) -> Result, PodDynamicsError> { - match p { - ForceModelParams::SrpCannonball { - cr, - area_to_mass, - shadow, - } => { - let force: Box = match shadow { - ShadowModel::None => { - Box::new(CannonballSrp::::new(*cr, *area_to_mass)) - } - ShadowModel::Cylindrical => { - Box::new(CannonballSrp::::new(*cr, *area_to_mass)) - } - ShadowModel::Conical => { - Box::new(CannonballSrp::::new(*cr, *area_to_mass)) - } - }; - Ok(force) - } - _ => Err(PodDynamicsError::InvalidParameters { - name: "srp_cannonball".into(), - reason: "expected ForceModelParams::SrpCannonball { cr, area_to_mass, shadow }", - }), - } - } -} - -struct SrpBoxwingFactory; -impl ForceModelFactory for SrpBoxwingFactory { - fn name(&self) -> &'static str { - "srp_boxwing" - } - fn build(&self, _p: &ForceModelParams) -> Result, PodDynamicsError> { - Err(PodDynamicsError::FeatureNotImplemented("srp_boxwing")) - } -} - -struct RelativityFactory; -impl ForceModelFactory for RelativityFactory { - fn name(&self) -> &'static str { - "relativity" - } - fn build(&self, _p: &ForceModelParams) -> Result, PodDynamicsError> { - Ok(Box::new(CentralBodyRelativity1Pn::earth())) - } -} - -struct EmpiricalConstantFactory; -impl ForceModelFactory for EmpiricalConstantFactory { - fn name(&self) -> &'static str { - "empirical_constant" - } - fn build(&self, p: &ForceModelParams) -> Result, PodDynamicsError> { - match p { - ForceModelParams::EmpiricalConstant { - radial, - transverse, - normal, - } => Ok(Box::new(EmpiricalAcceleration::rtn( - *radial, - *transverse, - *normal, - ))), - _ => Err(PodDynamicsError::InvalidParameters { - name: "empirical_constant".into(), - reason: - "expected ForceModelParams::EmpiricalConstant { radial, transverse, normal }", - }), - } - } -} - -fn build_periodic( - expected: PeriodicHarmonic, - label: &'static str, - p: &ForceModelParams, -) -> Result, PodDynamicsError> { - match p { - ForceModelParams::EmpiricalPeriodic { - harmonic, - epoch_ref, - period, - coeffs, - } if *harmonic == expected => Ok(Box::new(EmpiricalPeriodicAcceleration::new( - *harmonic, *epoch_ref, *period, coeffs[0], coeffs[1], coeffs[2], coeffs[3], coeffs[4], - coeffs[5], - ))), - _ => Err(PodDynamicsError::InvalidParameters { - name: label.into(), - reason: "expected ForceModelParams::EmpiricalPeriodic with matching harmonic", - }), - } -} - -struct Empirical1CprFactory; -impl ForceModelFactory for Empirical1CprFactory { - fn name(&self) -> &'static str { - "empirical_1cpr" - } - fn build(&self, p: &ForceModelParams) -> Result, PodDynamicsError> { - build_periodic(PeriodicHarmonic::OncePerRev, "empirical_1cpr", p) - } -} - -struct Empirical2CprFactory; -impl ForceModelFactory for Empirical2CprFactory { - fn name(&self) -> &'static str { - "empirical_2cpr" - } - fn build(&self, p: &ForceModelParams) -> Result, PodDynamicsError> { - build_periodic(PeriodicHarmonic::TwicePerRev, "empirical_2cpr", p) - } -} - -/// Lightweight type alias for atmosphere density providers consumable by the -/// the drag factory indirectly through [`siderust::astro::dynamics::context::DynamicsContext`]. -/// -/// Built-in models such as -/// [`siderust::astro::dynamics::density::ExponentialAtmosphere`] and -/// [`siderust::astro::dynamics::density::Nrlmsise00LiteApprox`] implement -/// this trait directly, and downstream crates can plug in MSIS-86, JB2008, … -/// by implementing [`DensityProvider`]. -pub type AtmosphereDensityProvider = dyn DensityProvider + Send + Sync; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn builtins_have_expected_names() { - let r = ForceModelRegistry::with_builtins(); - for name in [ - "two_body", - "j2", - "geopotential", - "third_body_sun", - "third_body_moon", - "third_body_sun_moon", - "drag", - "srp_cannonball", - "srp_boxwing", - "relativity", - "empirical_constant", - "empirical_1cpr", - "empirical_2cpr", - ] { - assert!(r.is_registered(name), "missing builtin: {name}"); - } - } - - #[test] - fn unknown_model_errors() { - let r = ForceModelRegistry::with_builtins(); - let e = r.build_one(&ForceModelSpec::named("nope")).err().unwrap(); - assert!(matches!(e, PodDynamicsError::UnknownModel(ref s) if s == "nope")); - } - - #[test] - fn srp_boxwing_returns_feature_not_implemented() { - let r = ForceModelRegistry::with_builtins(); - let e = r - .build_one(&ForceModelSpec::named("srp_boxwing")) - .err() - .unwrap(); - assert!(matches!( - e, - PodDynamicsError::FeatureNotImplemented("srp_boxwing") - )); - } - - #[test] - fn drag_requires_typed_params() { - let r = ForceModelRegistry::with_builtins(); - let e = r.build_one(&ForceModelSpec::named("drag")).err().unwrap(); - assert!(matches!(e, PodDynamicsError::InvalidParameters { .. })); - let ok = r.build_one(&ForceModelSpec::with_params( - "drag", - ForceModelParams::Drag { - cd: DragCoefficient::new(2.2), - area_to_mass: AreaToMass::new(0.01), - }, - )); - assert!(ok.is_ok()); - } -} diff --git a/src/dynamics/thrust.rs b/src/dynamics/thrust.rs deleted file mode 100644 index b5a8639..0000000 --- a/src/dynamics/thrust.rs +++ /dev/null @@ -1,234 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Finite-burn / thrust-arc physical model. -//! -//! This module provides a minimal but unit-safe thrust model usable both -//! from precise-orbit-determination workflows (`spod::dynamics`, -//! where the constant scale factor / delta-v components are estimable) -//! and from future mission-design workflows. -//! -//! The model is intentionally simple — constant thrust over a closed -//! interval, expressed in a 3-component unit vector — and avoids -//! dragging in the full mission-design machinery (Sims-Flanagan, -//! Pontryagin, low-thrust optimisation), which lives in future -//! `siderust-mission-design` / `siderust-low-thrust` crates. - -use siderust::qtty::force::Newtons; -use siderust::tempoch::{Time, UTC}; - -/// Standard sea-level gravitational acceleration `g₀` used to relate -/// specific impulse to mass flow rate, in m / s². -/// -/// IUPAC / ISO 80000 standard value. -pub const G0_M_PER_S2: f64 = 9.80665; - -/// A constant-thrust arc applied over a closed UTC interval. -/// -/// `direction` is a unit 3-vector expressed in the spacecraft frame -/// chosen by the consuming pipeline (typically VNC, RTN, or body). -/// The pipeline is responsible for projecting the resulting acceleration -/// into the propagation frame. -#[derive(Clone, Debug)] -pub struct ThrustArc { - /// Start time of the thrust arc. - pub start: Time, - /// Stop time of the thrust arc. - pub stop: Time, - /// Thrust magnitude in Newtons. - pub thrust: Newtons, - /// Specific impulse in seconds. - pub isp_seconds: f64, - /// Unit thrust direction `[x, y, z]` in the consuming pipeline's frame. - /// Magnitude is *not* validated by `ThrustArc` itself — see - /// [`ThrustArc::with_normalised_direction`]. - pub direction: [f64; 3], -} - -/// Errors produced when evaluating a thrust arc. -#[derive(Debug, thiserror::Error, PartialEq)] -pub enum ManeuverError { - /// Thrust direction has non-finite or zero magnitude. - #[error("thrust direction has non-finite or zero magnitude (got {0:?})")] - InvalidDirection([f64; 3]), - /// Specific impulse must be strictly positive. - #[error("specific impulse must be strictly positive (got {0})")] - NonPositiveIsp(f64), - /// Spacecraft mass must be strictly positive. - #[error("spacecraft mass must be strictly positive (got {0} kg)")] - NonPositiveMass(f64), - /// Thrust arc is empty: start >= stop. - #[error("thrust arc is empty: start {start:?} >= stop {stop:?}")] - EmptyInterval { - /// Start time of the arc. - start: Time, - /// Stop time of the arc. - stop: Time, - }, -} - -impl ThrustArc { - /// Construct a thrust arc, normalising the direction vector and - /// rejecting zero / non-finite directions, non-positive Isp, and - /// empty intervals. - pub fn with_normalised_direction( - start: Time, - stop: Time, - thrust: Newtons, - isp_seconds: f64, - direction: [f64; 3], - ) -> Result { - if !isp_seconds.is_finite() || isp_seconds <= 0.0 { - return Err(ManeuverError::NonPositiveIsp(isp_seconds)); - } - // `!(stop > start)` deliberately rejects NaN-bearing instants too; - // a literal `stop <= start` would silently accept them. - #[allow(clippy::neg_cmp_op_on_partial_ord)] - if !(stop > start) { - return Err(ManeuverError::EmptyInterval { start, stop }); - } - let [x, y, z] = direction; - if !x.is_finite() || !y.is_finite() || !z.is_finite() { - return Err(ManeuverError::InvalidDirection(direction)); - } - let mag = (x * x + y * y + z * z).sqrt(); - // Reject zero, negative, and NaN magnitudes in one expression. - #[allow(clippy::neg_cmp_op_on_partial_ord)] - if !(mag > 0.0) { - return Err(ManeuverError::InvalidDirection(direction)); - } - Ok(Self { - start, - stop, - thrust, - isp_seconds, - direction: [x / mag, y / mag, z / mag], - }) - } - - /// True iff `epoch ∈ [start, stop)`. - pub fn is_active(&self, epoch: Time) -> bool { - epoch >= self.start && epoch < self.stop - } -} - -/// Thrust acceleration in m / s², or `[0; 3]` outside the active interval. -/// -/// Mass must be strictly positive; otherwise [`ManeuverError::NonPositiveMass`] -/// is returned. -pub fn thrust_acceleration( - arc: &ThrustArc, - spacecraft_mass_kg: f64, - epoch: Time, -) -> Result<[f64; 3], ManeuverError> { - if !spacecraft_mass_kg.is_finite() || spacecraft_mass_kg <= 0.0 { - return Err(ManeuverError::NonPositiveMass(spacecraft_mass_kg)); - } - if !arc.is_active(epoch) { - return Ok([0.0, 0.0, 0.0]); - } - let f_n = arc.thrust.value(); - let a = f_n / spacecraft_mass_kg; - let [ux, uy, uz] = arc.direction; - Ok([a * ux, a * uy, a * uz]) -} - -/// Mass-flow rate `ṁ = F / (Isp · g₀)` in kg / s. -/// -/// Returns [`ManeuverError::NonPositiveIsp`] for a non-positive Isp. -pub fn mass_flow_rate(thrust: Newtons, isp_seconds: f64) -> Result { - if !isp_seconds.is_finite() || isp_seconds <= 0.0 { - return Err(ManeuverError::NonPositiveIsp(isp_seconds)); - } - Ok(thrust.value() / (isp_seconds * G0_M_PER_S2)) -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::{TimeZone, Utc}; - - fn epoch(secs_from_j2000: i64) -> Time { - let dt = Utc - .timestamp_opt(946_728_000 + secs_from_j2000, 0) - .single() - .unwrap(); - Time::::try_from_chrono(dt).unwrap() - } - - fn arc() -> ThrustArc { - ThrustArc::with_normalised_direction( - epoch(0), - epoch(60), - Newtons::new(0.5), - 1500.0, - [1.0, 0.0, 0.0], - ) - .unwrap() - } - - #[test] - fn zero_thrust_outside_interval() { - let a = thrust_acceleration(&arc(), 100.0, epoch(120)).unwrap(); - assert_eq!(a, [0.0, 0.0, 0.0]); - } - - #[test] - fn acceleration_is_force_over_mass_in_direction() { - let a = thrust_acceleration(&arc(), 100.0, epoch(10)).unwrap(); - // 0.5 N / 100 kg = 0.005 m/s² along +X - assert!((a[0] - 0.005).abs() < 1e-15); - assert_eq!(a[1], 0.0); - assert_eq!(a[2], 0.0); - } - - #[test] - fn mass_flow_rate_uses_g0() { - let mdot = mass_flow_rate(Newtons::new(0.5), 1500.0).unwrap(); - let expected = 0.5 / (1500.0 * G0_M_PER_S2); - assert!( - (mdot - expected).abs() < 1e-18, - "mdot={mdot} expected={expected}" - ); - } - - #[test] - fn rejects_zero_direction() { - let r = ThrustArc::with_normalised_direction( - epoch(0), - epoch(10), - Newtons::new(1.0), - 300.0, - [0.0, 0.0, 0.0], - ); - assert!(matches!(r, Err(ManeuverError::InvalidDirection(_)))); - } - - #[test] - fn rejects_non_positive_isp() { - let r = mass_flow_rate(Newtons::new(1.0), 0.0); - assert!(matches!(r, Err(ManeuverError::NonPositiveIsp(_)))); - } - - #[test] - fn rejects_non_positive_mass() { - let r = thrust_acceleration(&arc(), 0.0, epoch(5)); - assert!(matches!(r, Err(ManeuverError::NonPositiveMass(_)))); - } - - #[test] - fn direction_is_normalised() { - let a = ThrustArc::with_normalised_direction( - epoch(0), - epoch(1), - Newtons::new(1.0), - 300.0, - [3.0, 0.0, 4.0], - ) - .unwrap(); - let mag = (a.direction[0].powi(2) + a.direction[1].powi(2) + a.direction[2].powi(2)).sqrt(); - assert!((mag - 1.0).abs() < 1e-15); - assert!((a.direction[0] - 0.6).abs() < 1e-15); - assert!((a.direction[2] - 0.8).abs() < 1e-15); - } -} diff --git a/src/dynamics/thrust_arc.rs b/src/dynamics/thrust_arc.rs deleted file mode 100644 index 3922b17..0000000 --- a/src/dynamics/thrust_arc.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! Declarative thrust-arc configuration that surfaces estimable parameters. -//! -//! The physical thrust model (acceleration, mass-flow rate) belongs in the -//! reusable `siderust-dynamics::thrust` module (Phase 5). This module only -//! describes a thrust arc and the parameter knobs that the estimator may -//! adjust: a scalar thrust scale and an optional 3-component delta-v at -//! arc start. - -use crate::core::parameter::{Parameter, ParameterKind}; - -/// Declarative thrust arc. -#[derive(Debug, Clone, PartialEq)] -pub struct ThrustArcConfig { - /// Caller-defined arc identifier. - pub id: String, - /// Arc start in seconds since J2000 TDB. - pub start_seconds_tdb: f64, - /// Arc stop in seconds since J2000 TDB. - pub stop_seconds_tdb: f64, - /// Estimate a multiplicative thrust-magnitude scale around 1.0. - pub estimate_thrust_scale: bool, - /// Estimate an unmodelled delta-v applied at arc start (m/s). - pub estimate_delta_v: bool, -} - -impl ThrustArcConfig { - /// Emit the parameters this arc contributes to the estimator vector. - pub fn parameters(&self) -> Vec { - let mut out = Vec::new(); - if self.estimate_thrust_scale { - out.push(Parameter { - kind: ParameterKind::Custom(format!("thrust_scale[{}]", self.id)), - initial_value: 1.0, - apriori_sigma: Some(0.1), - }); - } - if self.estimate_delta_v { - for axis in ['X', 'Y', 'Z'] { - out.push(Parameter { - kind: ParameterKind::Custom(format!("delta_v_{axis}[{}]", self.id)), - initial_value: 0.0, - apriori_sigma: Some(0.01), - }); - } - } - out - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn emits_expected_parameters() { - let arc = ThrustArcConfig { - id: "burn1".into(), - start_seconds_tdb: 0.0, - stop_seconds_tdb: 60.0, - estimate_thrust_scale: true, - estimate_delta_v: true, - }; - assert_eq!(arc.parameters().len(), 4); - } -} diff --git a/src/dynamics/validation.rs b/src/dynamics/validation.rs deleted file mode 100644 index dfd85bf..0000000 --- a/src/dynamics/validation.rs +++ /dev/null @@ -1,204 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! STM finite-difference validation harness. -//! -//! The variational STM produced by -//! [`siderust::principia::propagate_stm`] is the -//! production code path. This module wraps the canonical -//! "perturb-propagate-compare" validation that asserts -//! `Φ · δy₀ ≈ y(t; y₀ + δy₀) − y(t; y₀)` to within a tight tolerance. -//! -//! For Phase 2.3 the maturity plan requires agreement better than `1e-7` -//! (relative). With a 1-m perturbation on a 7000 km orbit, the second-order -//! linearisation error scales as `(|δy₀|/r)² ≈ 2e-14`, so the achievable -//! floor is bounded by RK4 truncation noise rather than by the -//! linearisation, and `< 1e-7` is reached comfortably (typically `< 1e-8`). -//! -//! The function is force-model-agnostic: pass [`siderust::astro::dynamics::forces::TwoBody`], -//! [`siderust::astro::dynamics::forces::J2`], or any acceleration model whose -//! analytic partials are wired up. - -use siderust::astro::dynamics::{DynamicsContext, OrbitState}; -use siderust::pod::force::SiderustAccelerationModel; -use siderust::principia::{propagate_stm, rk4_propagate}; -use siderust::qtty::Second; - -use super::error::DynamicsError; - -/// Maximum relative error between the variational-STM-predicted state -/// perturbation and the directly-propagated finite-difference perturbation. -/// -/// Protocol: -/// -/// 1. Compute `Φ` over `dt` using -/// [`siderust::principia::propagate_stm`]. -/// 2. Propagate `state` and `state + δy₀` forward by `dt` with RK4 at -/// `step` sub-step. -/// 3. Compare `y(perturb) − y(nominal)` (the *finite-difference* truth) to -/// `Φ · δy₀` element-wise; return the max relative error -/// `|actual − predicted| / max(|actual|, eps)`. -/// -/// `delta_y0` must be expressed as `[δrx, δry, δrz, δvx, δvy, δvz]` in -/// km and km/s respectively. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::validation::max_rel_stm_predict_error; -/// use spod::dynamics::{DynamicsContext, OrbitState, Position, Velocity, TwoBody}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// use siderust::qtty::Second; -/// -/// let s0 = OrbitState::new( -/// JulianDate::new(2_451_545.0).to_j2000s(), -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.5450, 0.0), -/// ); -/// let err = max_rel_stm_predict_error( -/// &TwoBody::new(siderust::astro::dynamics::GM_EARTH), s0, -/// Second::new(60.0), Second::new(0.6), -/// [1e-3, 0.0, 0.0, 0.0, 0.0, 0.0], -/// &DynamicsContext::empty(), -/// ).unwrap(); -/// assert!(err < 1e-7, "STM predict error {err:.3e} exceeds 1e-7"); -/// ``` -pub fn max_rel_stm_predict_error( - force: &FM, - state: OrbitState, - dt: Second, - step: Second, - delta_y0: [f64; 6], - ctx: &DynamicsContext, -) -> Result { - let dt_s = dt.value(); - let step_s = step.value().abs().max(1e-12); - let n_steps = (dt_s.abs() / step_s).ceil().max(1.0) as usize; - let h = Second::new(dt_s / n_steps as f64); - - let (_, phi) = propagate_stm(force, state, dt, ctx)?; - let phi_arr = *phi.as_array(); - - let nominal = rk4_propagate(force, state, h, dt, ctx)?; - let perturbed = rk4_propagate(force, perturb_state(&state, &delta_y0), h, dt, ctx)?; - - let n = state_vec(&nominal); - let p = state_vec(&perturbed); - let actual = [ - p[0] - n[0], - p[1] - n[1], - p[2] - n[2], - p[3] - n[3], - p[4] - n[4], - p[5] - n[5], - ]; - let predicted = phi_apply(&phi_arr, &delta_y0); - - let mut max = 0.0_f64; - let scale = actual - .iter() - .map(|x| x.abs()) - .fold(0.0_f64, f64::max) - .max(1e-30); - for i in 0..6 { - let r = (actual[i] - predicted[i]).abs() / scale; - if r > max { - max = r; - } - } - Ok(max) -} - -fn state_vec(s: &OrbitState) -> [f64; 6] { - [ - s.position.x().value(), - s.position.y().value(), - s.position.z().value(), - s.velocity.x().value(), - s.velocity.y().value(), - s.velocity.z().value(), - ] -} - -fn perturb_state(s: &OrbitState, d: &[f64; 6]) -> OrbitState { - use siderust::astro::dynamics::{Position, Velocity}; - use siderust::coordinates::frames::GCRS; - OrbitState::new( - s.epoch, - Position::::new( - s.position.x().value() + d[0], - s.position.y().value() + d[1], - s.position.z().value() + d[2], - ), - Velocity::::new( - s.velocity.x().value() + d[3], - s.velocity.y().value() + d[4], - s.velocity.z().value() + d[5], - ), - ) -} - -fn phi_apply(phi: &[[f64; 6]; 6], dy0: &[f64; 6]) -> [f64; 6] { - let mut out = [0.0_f64; 6]; - for (i, row) in phi.iter().enumerate() { - let mut s = 0.0; - for (j, v) in row.iter().enumerate() { - s += v * dy0[j]; - } - out[i] = s; - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - use siderust::astro::dynamics::forces::{TwoBody, J2}; - use siderust::astro::dynamics::{Position, Velocity}; - use siderust::coordinates::frames::GCRS; - use siderust::time::JulianDate; - - fn s0() -> OrbitState { - OrbitState::new( - JulianDate::new(2_451_545.0).to_j2000s(), - Position::::new(7_000.0, 0.0, 0.0), - Velocity::::new(0.0, 7.5450, 0.0), - ) - } - - #[test] - fn two_body_variational_matches_finite_diff() { - let err = max_rel_stm_predict_error( - &TwoBody::new(siderust::astro::dynamics::GM_EARTH), - s0(), - Second::new(60.0), - Second::new(0.6), - [1e-3, 0.0, 0.0, 0.0, 0.0, 0.0], - &DynamicsContext::empty(), - ) - .unwrap(); - assert!( - err < 1e-7, - "two-body STM predict err {err:.3e} exceeds 1e-7" - ); - } - - #[test] - fn j2_variational_matches_finite_diff() { - let err = max_rel_stm_predict_error( - &J2::new( - siderust::astro::dynamics::GM_EARTH, - siderust::astro::dynamics::R_EARTH, - siderust::astro::dynamics::EARTH_J2, - ), - s0(), - Second::new(60.0), - Second::new(0.6), - [0.0, 1e-3, 0.0, 0.0, 0.0, 0.0], - &DynamicsContext::empty(), - ) - .unwrap(); - assert!(err < 1e-7, "J2 STM predict err {err:.3e} exceeds 1e-7"); - } -} diff --git a/src/dynamics/variational.rs b/src/dynamics/variational.rs deleted file mode 100644 index 1697a0e..0000000 --- a/src/dynamics/variational.rs +++ /dev/null @@ -1,411 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Variational equations for **estimable parameters** beyond the 6×6 state STM. -//! -//! Upstream `siderust::astro::dynamics::variational` integrates the state -//! transition matrix `Φ(t,t₀) = ∂y(t)/∂y(t₀)` with analytic partials. POD -//! also needs the *parameter* partials `S(t) = ∂y(t)/∂p` for each estimable -//! force-model parameter `p` (drag scale Cd_scale, SRP scale Crp_scale, -//! empirical-acceleration coefficients, …). -//! -//! Some parameters do not have closed-form partials of `∂a/∂p` exposed by -//! the upstream force-model trait. The robust, model-agnostic fallback used -//! here is **central finite differences**: -//! -//! ```text -//! ∂y(t)/∂p ≈ [y(t; p + ε) − y(t; p − ε)] / (2 ε) -//! ``` -//! -//! The caller supplies a closure that maps a perturbed parameter value to a -//! freshly-built acceleration model. This keeps the harness completely -//! force-model agnostic — drag scale, SRP scale, and empirical-acceleration -//! amplitudes all reduce to "vary one scalar, rebuild the force, re-propagate". -//! -//! # Validation gate -//! -//! Phase 4 requires that the central-difference column for the drag-scale -//! parameter agree with a *Richardson-refined* estimate to better than -//! `1e-6` relative. [`param_partials_central_diff`] returns the column at -//! step `ε`; the test in `tests/` calls it twice (with `ε` and `ε/2`) and -//! checks that the two estimates agree. - -use siderust::astro::dynamics::{DynamicsContext, OrbitState, StateTransitionMatrix}; -use siderust::coordinates::frames::GCRS; -use siderust::pod::force::SiderustAccelerationModel; -use siderust::qtty::Second; - -use crate::dynamics::Integrator; -use siderust::affn::matrix6::FrameMatrix6; - -use super::pod_error::PodDynamicsError; - -/// Six-component parameter sensitivity column `∂y/∂p`. -/// -/// Layout: `[∂rx/∂p, ∂ry/∂p, ∂rz/∂p, ∂vx/∂p, ∂vy/∂p, ∂vz/∂p]` in km / unit -/// of `p` and km/s / unit of `p`. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct ParamColumn(pub [f64; 6]); - -impl ParamColumn { - /// Element-wise `|self - other|.max()` divided by the max magnitude in - /// `other`. Returns `0.0` when `other` is identically zero. - pub fn max_relative_difference(&self, other: &ParamColumn) -> f64 { - let scale = other.0.iter().map(|x| x.abs()).fold(0.0_f64, f64::max); - let scale = if scale > 0.0 { scale } else { 1.0 }; - self.0 - .iter() - .zip(other.0.iter()) - .map(|(a, b)| (a - b).abs() / scale) - .fold(0.0_f64, f64::max) - } -} - -/// Diagnostic report bundling a coarse and a refined finite-difference -/// estimate of `∂y/∂p`, plus the relative agreement between them. Used by -/// the validation tests as a Richardson convergence check. -#[derive(Debug, Clone, Copy)] -pub struct ParamStmReport { - /// Column estimated at step `ε`. - pub coarse: ParamColumn, - /// Column estimated at step `ε / 2`. - pub refined: ParamColumn, - /// Maximum element-wise relative difference between `coarse` and `refined`. - pub max_rel_diff: f64, -} - -/// Compute the parameter-sensitivity column `∂y(t)/∂p` by central -/// finite differences. -/// -/// `build_force(p)` must rebuild the force model from a scalar parameter -/// `p`; the function evaluates it at `p ± ε` and integrates each branch -/// from `state` over `dt` with the supplied [`Integrator`]. -/// -/// # Arguments -/// -/// * `integrator` — fixed-step or adaptive integrator (any [`Integrator`]). -/// * `state` — initial [`OrbitState`] at `t₀`. -/// * `dt` — propagation interval. -/// * `ctx` — dynamics context (atmosphere/ephemeris/gravity providers). -/// * `p_nominal` — nominal value of the parameter. -/// * `epsilon` — perturbation step. Must be strictly positive and finite. -/// * `build_force` — closure `(p) -> impl ForceModel` that builds the -/// force-model composite at parameter value `p`. -/// -/// # Errors -/// -/// * [`PodDynamicsError::InvalidParamStep`] if `epsilon ≤ 0` or non-finite. -/// * Any [`PodDynamicsError::Dynamics`] raised by the integrator. -/// -/// # Example -/// -/// ``` -/// use spod::dynamics::{param_partials_central_diff, Rk4Integrator}; -/// use siderust::astro::dynamics::forces::TwoBody; -/// use siderust::astro::dynamics::{OrbitState, DynamicsContext}; -/// use siderust::astro::dynamics::{Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// use siderust::qtty::Second; -/// // Trivial parameter: scale GM (no estimable physical meaning, just a smoke test). -/// let s0 = OrbitState::new( -/// JulianDate::new(2_451_545.0).to_j2000s(), -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.5450, 0.0), -/// ); -/// let col = param_partials_central_diff( -/// &Rk4Integrator { step: Second::new(10.0) }, -/// s0, Second::new(60.0), &DynamicsContext::empty(), -/// 1.0, 1e-6, -/// |_scale| TwoBody::new(siderust::astro::dynamics::GM_EARTH), -/// ).unwrap(); -/// assert!(col.0.iter().all(|v| v.is_finite())); -/// ``` -pub fn param_partials_central_diff( - integrator: &I, - state: OrbitState, - dt: Second, - ctx: &DynamicsContext, - p_nominal: f64, - epsilon: f64, - mut build_force: B, -) -> Result -where - I: Integrator, - FM: SiderustAccelerationModel, - B: FnMut(f64) -> FM, -{ - if !epsilon.is_finite() || epsilon <= 0.0 { - return Err(PodDynamicsError::InvalidParamStep( - "epsilon must be strictly positive and finite", - )); - } - let force_plus = build_force(p_nominal + epsilon); - let force_minus = build_force(p_nominal - epsilon); - let y_plus = integrator.propagate(&force_plus, state, dt, ctx)?; - let y_minus = integrator.propagate(&force_minus, state, dt, ctx)?; - let inv_2eps = 1.0 / (2.0 * epsilon); - let col = [ - (y_plus.position.x().value() - y_minus.position.x().value()) * inv_2eps, - (y_plus.position.y().value() - y_minus.position.y().value()) * inv_2eps, - (y_plus.position.z().value() - y_minus.position.z().value()) * inv_2eps, - (y_plus.velocity.x().value() - y_minus.velocity.x().value()) * inv_2eps, - (y_plus.velocity.y().value() - y_minus.velocity.y().value()) * inv_2eps, - (y_plus.velocity.z().value() - y_minus.velocity.z().value()) * inv_2eps, - ]; - Ok(ParamColumn(col)) -} - -// ───────────────────────────────────────────────────────────────────────────── -// PropagatedArc -// ───────────────────────────────────────────────────────────────────────────── - -/// A propagated orbit arc with a state-transition matrix accumulated from -/// the initial epoch. -/// -/// Each element `(state, Phi)` of [`PropagatedArc::steps`] represents the -/// orbit state and the *cumulative* STM `Φ(tₖ, t₀)` at the k-th step time. -/// Step `0` (index `0`) corresponds to `t₀ + step` with `Φ(t₁, t₀)`. -/// -/// # Example -/// -/// ``` -/// use siderust::astro::dynamics::forces::TwoBody; -/// use siderust::astro::dynamics::{DynamicsContext, OrbitState, Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::qtty::Second; -/// use siderust::time::JulianDate; -/// use spod::dynamics::variational::{PropagatedArc, VariationalPropagator}; -/// -/// let s0 = OrbitState::new( -/// JulianDate::new(2_451_545.0).to_j2000s(), -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.545, 0.0), -/// ); -/// let prop = VariationalPropagator { step: Second::new(30.0) }; -/// let arc = prop.propagate(&TwoBody::new(siderust::astro::dynamics::GM_EARTH), s0, 3, &DynamicsContext::empty()).unwrap(); -/// assert_eq!(arc.steps.len(), 3); -/// // Cumulative STM at first step — diagonal must remain close to 1. -/// let phi1 = arc.steps[0].1.as_array(); -/// assert!((phi1[0][0] - 1.0).abs() < 0.01); -/// ``` -#[derive(Debug, Clone)] -pub struct PropagatedArc { - /// Ordered list of `(state, Φ(tₖ, t₀))` for each integration step. - pub steps: Vec<(OrbitState, StateTransitionMatrix)>, -} - -impl PropagatedArc { - /// Number of propagated steps. - pub fn len(&self) -> usize { - self.steps.len() - } - - /// `true` iff no steps were accumulated. - pub fn is_empty(&self) -> bool { - self.steps.is_empty() - } - - /// Final orbit state at the end of the arc (last step), or `None` when - /// the arc is empty. - pub fn final_state(&self) -> Option<&OrbitState> { - self.steps.last().map(|(s, _)| s) - } - - /// Final cumulative STM `Φ(t_end, t₀)`, or `None` when the arc is empty. - pub fn final_stm(&self) -> Option<&StateTransitionMatrix> { - self.steps.last().map(|(_, phi)| phi) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// VariationalPropagator -// ───────────────────────────────────────────────────────────────────────────── - -/// Propagates an orbit arc step-by-step, accumulating the state-transition -/// matrix `Φ(tₖ, t₀)` at each step. -/// -/// Uses [`siderust::principia::propagate_stm`] for each sub-step. -/// The cumulative STM is updated by left-multiplying the step STM: -/// `Φ(tₖ₊₁, t₀) = Φ(tₖ₊₁, tₖ) · Φ(tₖ, t₀)`. -/// -/// # Example -/// -/// ``` -/// use siderust::astro::dynamics::forces::TwoBody; -/// use siderust::astro::dynamics::{DynamicsContext, OrbitState, Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::qtty::Second; -/// use siderust::time::JulianDate; -/// use spod::dynamics::variational::VariationalPropagator; -/// -/// let s0 = OrbitState::new( -/// JulianDate::new(2_451_545.0).to_j2000s(), -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.545, 0.0), -/// ); -/// let prop = VariationalPropagator { step: Second::new(60.0) }; -/// let arc = prop.propagate(&TwoBody::new(siderust::astro::dynamics::GM_EARTH), s0, 5, &DynamicsContext::empty()).unwrap(); -/// assert_eq!(arc.len(), 5); -/// assert!(arc.final_state().is_some()); -/// ``` -pub struct VariationalPropagator { - /// Fixed time step per integration interval. - pub step: Second, -} - -impl VariationalPropagator { - /// Propagate `initial` state forward by `n_steps` × `self.step` seconds, - /// accumulating the cumulative STM at every step. - /// - /// The force model must implement the upstream - /// `siderust::astro::dynamics::forces::ForceModel` trait and must supply - /// analytic partial derivatives (returned by `ForceModel::partials`) for - /// the STM to be meaningful. Models without partials produce an identity - /// STM at every step. - /// - /// # Errors - /// - /// Returns [`PodDynamicsError::Dynamics`] if `propagate_stm` fails at any step. - pub fn propagate( - &self, - force: &FM, - initial: OrbitState, - n_steps: usize, - ctx: &DynamicsContext, - ) -> Result - where - FM: SiderustAccelerationModel, - { - let mut state = initial; - let mut phi_total: StateTransitionMatrix = FrameMatrix6::identity(); - let mut steps = Vec::with_capacity(n_steps); - - for _ in 0..n_steps { - let (new_state, phi_step) = - siderust::principia::propagate_stm(force, state, self.step, ctx).map_err( - |e: siderust::principia::PrincipiaError| PodDynamicsError::Dynamics(e.into()), - )?; - phi_total = phi_step * phi_total; - steps.push((new_state, phi_total)); - state = new_state; - } - - Ok(PropagatedArc { steps }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::dynamics::Rk4Integrator; - use siderust::astro::dynamics::forces::TwoBody; - use siderust::astro::dynamics::{Position, Velocity}; - use siderust::coordinates::frames::GCRS; - use siderust::time::JulianDate; - - fn s0() -> OrbitState { - OrbitState::new( - JulianDate::new(2_451_545.0).to_j2000s(), - Position::::new(7_000.0, 0.0, 0.0), - Velocity::::new(0.0, 7.5450, 0.0), - ) - } - - #[test] - fn rejects_bad_epsilon() { - let r = param_partials_central_diff( - &Rk4Integrator { - step: Second::new(10.0), - }, - s0(), - Second::new(60.0), - &DynamicsContext::empty(), - 1.0, - 0.0, - |_| TwoBody::new(siderust::astro::dynamics::GM_EARTH), - ); - assert!(matches!(r, Err(PodDynamicsError::InvalidParamStep(_)))); - } - - #[test] - fn invariant_force_yields_zero_column() { - let col = param_partials_central_diff( - &Rk4Integrator { - step: Second::new(10.0), - }, - s0(), - Second::new(60.0), - &DynamicsContext::empty(), - 1.0, - 1e-6, - |_| TwoBody::new(siderust::astro::dynamics::GM_EARTH), - ) - .unwrap(); - assert!(col.0.iter().all(|v| v.abs() < 1e-12)); - } - - #[test] - fn variational_propagator_accumulates_steps() { - let s = s0(); - let prop = VariationalPropagator { - step: Second::new(30.0), - }; - let arc = prop - .propagate( - &TwoBody::new(siderust::astro::dynamics::GM_EARTH), - s, - 4, - &DynamicsContext::empty(), - ) - .unwrap(); - assert_eq!(arc.len(), 4); - assert!(!arc.is_empty()); - assert!(arc.final_state().is_some()); - assert!(arc.final_stm().is_some()); - } - - #[test] - fn variational_propagator_stm_close_to_identity_for_short_arc() { - let s = s0(); - let prop = VariationalPropagator { - step: Second::new(1.0), - }; - let arc = prop - .propagate( - &TwoBody::new(siderust::astro::dynamics::GM_EARTH), - s, - 1, - &DynamicsContext::empty(), - ) - .unwrap(); - let phi = arc.steps[0].1.as_array(); - // For dt = 1 s the diagonal of the STM should be very close to 1. - for (i, row) in phi.iter().enumerate() { - assert!( - (row[i] - 1.0).abs() < 1e-2, - "diagonal entry [{i}][{i}] = {} should be ≈ 1", - row[i] - ); - } - } - - #[test] - fn prop_arc_empty_when_zero_steps() { - let s = s0(); - let prop = VariationalPropagator { - step: Second::new(60.0), - }; - let arc = prop - .propagate( - &TwoBody::new(siderust::astro::dynamics::GM_EARTH), - s, - 0, - &DynamicsContext::empty(), - ) - .unwrap(); - assert!(arc.is_empty()); - assert!(arc.final_state().is_none()); - assert!(arc.final_stm().is_none()); - } -} diff --git a/src/estimation/mod.rs b/src/estimation/mod.rs deleted file mode 100644 index f625a39..0000000 --- a/src/estimation/mod.rs +++ /dev/null @@ -1,46 +0,0 @@ -#![allow(clippy::needless_range_loop, clippy::inconsistent_digit_grouping)] -//! # spod estimation -//! -//! ## Scientific scope -//! -//! This crate collects the numerical estimation kernels used by the POD -//! workspace: batch weighted least squares, nonlinear Gauss-Newton -//! iteration, and a sequential Kalman-style update path. The scientific -//! target is short-arc orbit determination where measurement models and -//! force models are supplied by neighboring crates. -//! -//! These routines assume callers provide already-linearized residual rows -//! or propagation closures at the appropriate orbit-determination epoch. -//! They do not introduce their own measurement corrections, force laws, or -//! time-scale conversions. -//! -//! ## Technical scope -//! -//! The crate re-exports `gauss_newton`, `NormalEquations`, `OrbitEkf`, and the -//! parameter descriptors that define estimator-state ordering. Public APIs -//! mainly consume scalar residuals, Jacobians, and covariance-like matrices -//! in solver space, while typed orbital and temporal quantities remain -//! owned by `siderust` and the observation/service crates. -//! -//! This module is the numerical core only: file formats, synthetic data -//! generation, and product writing are intentionally delegated to sibling -//! crates. -//! -//! ## References -//! -//! - Tapley, B. D., Schutz, B. E., & Born, G. H. (2004). Statistical Orbit -//! Determination. Elsevier Academic Press. -//! - Vallado, D. A. (2013). Fundamentals of Astrodynamics and Applications -//! (4th ed.). Microcosm Press. -#![forbid(unsafe_code)] -#![warn(missing_docs)] - -pub mod nonlinear; -pub mod parameter; -pub mod sequential; -pub mod wls; - -pub use nonlinear::{gauss_newton, NonlinearError, NonlinearOptions, NonlinearReport}; -pub use parameter::{Parameter, ParameterKind}; -pub use sequential::{EkfError, InnovationRecord, OrbitEkf}; -pub use wls::{NormalEquations, WlsResult, WlsSolverError}; diff --git a/src/estimation/nonlinear.rs b/src/estimation/nonlinear.rs deleted file mode 100644 index 0ea9ade..0000000 --- a/src/estimation/nonlinear.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! # Gauss-Newton nonlinear estimation -//! -//! ## Scientific scope -//! -//! This module implements a classic Gauss-Newton outer loop for mildly -//! nonlinear POD problems, where the design matrix must be rebuilt around -//! the current best estimate at each iteration. It is appropriate for -//! short-arc orbit-state corrections and similar parameter updates when the -//! residual surface is locally well behaved. -//! -//! The algorithm assumes the caller can re-linearize the problem after each -//! update. It does not perform trust-region control, robust editing, or -//! global convergence safeguards beyond simple stopping criteria on step -//! size and reduced chi-square change. -//! -//! ## Technical scope -//! -//! The entry points are `NonlinearOptions`, `NonlinearReport`, -//! `NonlinearError`, and `gauss_newton`. Callers provide a closure that -//! assembles fresh `NormalEquations` from the current parameter vector, and -//! the solver returns an updated parameter vector plus iteration -//! diagnostics in raw solver coordinates. -//! -//! This module does not own measurement units or force-model semantics. -//! Those stay with the upstream model code that produces the normal -//! equations. -//! -//! ## References -//! -//! - Tapley, B. D., Schutz, B. E., & Born, G. H. (2004). Statistical Orbit -//! Determination. Elsevier Academic Press. -//! - Vallado, D. A. (2013). Fundamentals of Astrodynamics and Applications -//! (4th ed.). Microcosm Press. -use super::wls::{NormalEquations, WlsResult, WlsSolverError}; -use thiserror::Error; - -/// Convergence options for [`gauss_newton`]. -#[derive(Debug, Clone, Copy)] -pub struct NonlinearOptions { - /// Maximum number of iterations. - pub max_iter: usize, - /// Relative-update convergence tolerance (parameter L2-norm). - pub tol_rel: f64, - /// Relative-χ² convergence tolerance. - pub tol_chi2_rel: f64, -} - -impl Default for NonlinearOptions { - fn default() -> Self { - Self { - max_iter: 12, - tol_rel: 1e-9, - tol_chi2_rel: 1e-6, - } - } -} - -/// Errors during nonlinear iteration. -#[derive(Debug, Error)] -pub enum NonlinearError { - /// WLS solve failed. - #[error(transparent)] - Solver(#[from] WlsSolverError), - /// Reached `max_iter` without convergence. - #[error("did not converge in {0} iterations")] - DidNotConverge(usize), -} - -/// Result of a nonlinear iteration loop. -#[derive(Debug, Clone)] -pub struct NonlinearReport { - /// Final parameter vector. - pub parameters: Vec, - /// Final WLS solve. - pub last: WlsResult, - /// Number of iterations performed. - pub iterations: usize, -} - -/// Run Gauss-Newton iterations. -/// -/// `assemble(params) -> NormalEquations` is invoked at every iteration -/// with the *current* best parameter vector and is responsible for -/// producing prefit residuals and partials around that point. -pub fn gauss_newton( - initial: Vec, - opts: NonlinearOptions, - mut assemble: F, -) -> Result -where - F: FnMut(&[f64]) -> Result, -{ - let mut params = initial; - let mut last_chi2 = f64::INFINITY; - #[allow(unused_assignments)] - let mut last_result: Option = None; - for it in 0..opts.max_iter { - let ne = assemble(¶ms)?; - let result = ne.solve()?; - let mut max_rel = 0.0_f64; - for (i, dp) in result.update.iter().enumerate() { - let scale = params[i].abs().max(1.0); - let rel = dp.abs() / scale; - if rel > max_rel { - max_rel = rel; - } - params[i] += dp; - } - let chi2 = result.reduced_chi2(); - let chi2_rel = if last_chi2.is_finite() && last_chi2 > 0.0 { - (chi2 - last_chi2).abs() / last_chi2 - } else { - f64::INFINITY - }; - last_chi2 = chi2; - last_result = Some(result); - if max_rel < opts.tol_rel && chi2_rel < opts.tol_chi2_rel { - return Ok(NonlinearReport { - parameters: params, - last: last_result.unwrap(), - iterations: it + 1, - }); - } - } - Err(NonlinearError::DidNotConverge(opts.max_iter)) -} diff --git a/src/estimation/parameter.rs b/src/estimation/parameter.rs deleted file mode 100644 index ab684fc..0000000 --- a/src/estimation/parameter.rs +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! # Estimator parameter descriptors -//! -//! ## Scientific scope -//! -//! Estimator state vectors in POD mix physically different quantities such -//! as Cartesian position, Cartesian velocity, clock biases, and carrier -//! ambiguities. This module names those quantities explicitly so linear -//! algebra over the parameter vector retains a documented physical meaning. -//! -//! The regime is bookkeeping rather than modelling: it does not decide how -//! parameters evolve dynamically or how they are observed. It only -//! describes what each slot in the solved-for vector represents. -//! -//! ## Technical scope -//! -//! The public types are `ParameterKind` and `Parameter`. `ParameterKind` -//! enumerates supported solve-for categories, while `Parameter` stores an a -//! priori value and one-sigma scale in the native units of that quantity. -//! -//! No propagation, residual formation, or covariance transport occurs here. -//! The module is consumed by estimation and service code that needs a -//! stable parameter ordering. -//! -//! ## References -//! -//! - Tapley, B. D., Schutz, B. E., & Born, G. H. (2004). Statistical Orbit -//! Determination. Elsevier Academic Press. -//! - Vallado, D. A. (2013). Fundamentals of Astrodynamics and Applications -//! (4th ed.). Microcosm Press. -/// Kinds of parameter the batch/EKF estimator can solve for. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ParameterKind { - /// Inertial position component (km), index 0–2. - Position(u8), - /// Inertial velocity component (km/s), index 0–2. - Velocity(u8), - /// Drag coefficient (`Cd`). - DragCoefficient, - /// Solar-radiation pressure coefficient (`Cr`). - SrpCoefficient, - /// Receiver clock bias (s). - ReceiverClockBias, - /// Float carrier ambiguity (cycles), keyed by satellite + signal id. - FloatAmbiguity { - /// PRN / satellite identifier. - sat_id: u32, - /// Signal index (e.g. 0 = L1, 1 = L2). - signal: u8, - }, -} - -/// A single parameter in the estimation problem. -#[derive(Debug, Clone, Copy)] -pub struct Parameter { - /// What this parameter is. - pub kind: ParameterKind, - /// A priori value. - pub a_priori: f64, - /// A priori standard deviation (same unit as the parameter). - pub a_priori_sigma: f64, -} diff --git a/src/estimation/sequential.rs b/src/estimation/sequential.rs deleted file mode 100644 index 43d0f48..0000000 --- a/src/estimation/sequential.rs +++ /dev/null @@ -1,276 +0,0 @@ -//! # Sequential Kalman estimation -//! -//! ## Scientific scope -//! -//! This module provides a sequential estimator shaped like an Extended -//! Kalman Filter for POD-style state updates. It is intended for settings -//! where observations arrive one at a time and the caller can supply both a -//! transition model and scalar measurement linearization. -//! -//! The implementation targets compact, deterministic replay scenarios -//! rather than full operational filtering. Process-noise modelling, -//! smoothing, and advanced numerical stabilization are intentionally kept -//! minimal in the current MVP stage. -//! -//! ## Technical scope -//! -//! The main public items are `OrbitEkf`, `EkfError`, and `InnovationRecord`. -//! `OrbitEkf` is specialised for 6D orbit POD and stores [`OrbitState`] and -//! [`StateCovariance`] as first-class fields. -//! -//! ## References -//! -//! - Tapley, B. D., Schutz, B. E., & Born, G. H. (2004). Statistical Orbit -//! Determination. Elsevier Academic Press. -//! - Vallado, D. A. (2013). Fundamentals of Astrodynamics and Applications -//! (4th ed.). Microcosm Press. -use faer::Mat; -use siderust::affn::Displacement; -use siderust::astro::dynamics::covariance::StateCovariance; -use siderust::astro::dynamics::{OrbitState, Velocity}; -use siderust::coordinates::frames::GCRS; -use siderust::qtty::unit::Kilometer; -use siderust::qtty::Kilometers; -use siderust::qtty::KmPerSeconds; -use thiserror::Error; - -/// EKF error type. -#[derive(Debug, Error)] -pub enum EkfError { - /// Singular innovation covariance (S ≤ 0 for a scalar measurement). - #[error("singular innovation covariance: {0}")] - Singular(f64), -} - -/// Per-measurement innovation/NIS record produced by `update_scalar`. -#[derive(Debug, Clone, Copy)] -pub struct InnovationRecord { - /// Measurement minus prediction. - pub innovation: f64, - /// Innovation variance S = HPHᵀ + R. - pub variance: f64, - /// Normalised innovation squared (innovation² / S). - pub nis: f64, -} - -/// Typed 6D orbit EKF specialised for position + velocity state. -/// -/// Stores the filter state directly as siderust/affn types: -/// -/// - [`OrbitState`] for the mean (position, velocity, epoch) -/// - [`StateCovariance`] for the 6×6 covariance split into typed 3×3 -/// blocks -/// -/// All internal linear algebra is performed on a `faer::Mat` extracted -/// from the typed containers via [`StateCovariance::to_row_major`] and packed -/// back with [`StateCovariance::from_blocks`]. -#[derive(Debug, Clone)] -pub struct OrbitEkf { - state: OrbitState, - cov: StateCovariance, -} - -impl OrbitEkf { - /// New filter with explicit typed state and covariance. - pub fn new(state: OrbitState, cov: StateCovariance) -> Self { - Self { state, cov } - } - - /// New filter with diagonal covariance built from per-axis 1-σ position - /// (km) and velocity (km/s) standard deviations. - pub fn from_stddevs(state: OrbitState, sigma_pos: [f64; 3], sigma_vel: [f64; 3]) -> Self { - let sigma_pos_typed = [ - Kilometers::new(sigma_pos[0]), - Kilometers::new(sigma_pos[1]), - Kilometers::new(sigma_pos[2]), - ]; - let sigma_vel_typed = [ - KmPerSeconds::new(sigma_vel[0]), - KmPerSeconds::new(sigma_vel[1]), - KmPerSeconds::new(sigma_vel[2]), - ]; - Self { - state, - cov: StateCovariance::::diagonal_from_sigmas(sigma_pos_typed, sigma_vel_typed), - } - } - - /// Current orbit state (position + velocity + epoch). - pub fn state(&self) -> &OrbitState { - &self.state - } - - /// Current 6×6 covariance in GCRS. - pub fn covariance(&self) -> &StateCovariance { - &self.cov - } - - /// Time update: replace the mean with `state_pred` and propagate covariance - /// as `P ← Φ P Φᵀ + Q`. - /// - /// `phi` is the 6×6 state-transition matrix in row-major order. - /// `q` is an optional process-noise covariance added after the propagation. - pub fn predict( - &mut self, - state_pred: OrbitState, - phi: [[f64; 6]; 6], - q: Option>, - ) { - let phi_m: Mat = Mat::from_fn(6, 6, |i, j| phi[i][j]); - let p = self.cov.to_row_major(); - let p_m: Mat = Mat::from_fn(6, 6, |i, j| p[i][j]); - // P ← Φ P Φᵀ (+ Q) - let phi_p = &phi_m * &p_m; - let mut p_next = &phi_p * phi_m.transpose(); - if let Some(q_cov) = q { - let q = q_cov.to_row_major(); - let q_m: Mat = Mat::from_fn(6, 6, |i, j| q[i][j]); - p_next += q_m; - } - self.state = state_pred; - self.cov = StateCovariance::from_row_major(std::array::from_fn(|i| { - std::array::from_fn(|j| p_next[(i, j)]) - })); - } - - /// Scalar measurement update. - /// - /// `h` is the 6-element row of partial derivatives ∂y/∂x. - /// `r` is the measurement variance σ². - /// Returns the innovation record or an error if the innovation variance - /// is not strictly positive. - pub fn update_scalar( - &mut self, - h: [f64; 6], - innovation: f64, - r: f64, - ) -> Result { - let p = self.cov.to_row_major(); - let p_m: Mat = Mat::from_fn(6, 6, |i, j| p[i][j]); - let h_col: Mat = Mat::from_fn(6, 1, |i, _| h[i]); - - // P h - let ph = &p_m * &h_col; - - // S = hᵀ P h + R - let mut s = r; - for i in 0..6 { - s += h[i] * ph[(i, 0)]; - } - if s.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { - return Err(EkfError::Singular(s)); - } - - // K = P h / S - let k: Mat = Mat::from_fn(6, 1, |i, _| ph[(i, 0)] / s); - - // x ← x + K * innovation (typed arithmetic) - let pos_delta = Displacement::::new( - k[(0, 0)] * innovation, - k[(1, 0)] * innovation, - k[(2, 0)] * innovation, - ); - let vel_delta = Velocity::::new( - k[(3, 0)] * innovation, - k[(4, 0)] * innovation, - k[(5, 0)] * innovation, - ); - let new_pos = self.state.position + pos_delta; - let new_vel = self.state.velocity + vel_delta; - self.state = OrbitState::new(self.state.epoch, new_pos, new_vel); - - // P ← P − K (hᵀ P) then symmetrise - let htp: Mat = - Mat::from_fn(1, 6, |_, j| (0..6).map(|i| h[i] * p_m[(i, j)]).sum::()); - let mut p_next = p_m - &k * &htp; - // Symmetrise - for i in 0..6 { - for j in (i + 1)..6 { - let v = 0.5 * (p_next[(i, j)] + p_next[(j, i)]); - p_next[(i, j)] = v; - p_next[(j, i)] = v; - } - } - self.cov = StateCovariance::from_row_major(std::array::from_fn(|i| { - std::array::from_fn(|j| p_next[(i, j)]) - })); - let nis = innovation * innovation / s; - Ok(InnovationRecord { - innovation, - variance: s, - nis, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use siderust::astro::dynamics::Position; - use siderust::time::JulianDate; - - fn make_orbit_state() -> OrbitState { - let epoch = JulianDate::new(2_451_545.0); - let pos = Position::::new(7000.0, 0.0, 0.0); - let vel = Velocity::::new(0.0, 7.5, 0.0); - OrbitState::new(epoch.to_j2000s(), pos, vel) - } - - #[test] - fn orbit_ekf_update_reduces_variance() { - let s0 = make_orbit_state(); - let mut f = OrbitEkf::from_stddevs(s0, [1.0, 1.0, 1.0], [1e-3, 1e-3, 1e-3]); - let p_before = f.covariance().to_row_major(); - // Observe x-component directly (h = [1,0,0,0,0,0]). - let rec = f - .update_scalar([1.0, 0.0, 0.0, 0.0, 0.0, 0.0], 0.5, 0.01) - .unwrap(); - let p_after = f.covariance().to_row_major(); - assert!(p_after[0][0] < p_before[0][0], "pos variance must shrink"); - assert!(rec.nis > 0.0); - // State should move toward measurement. - assert!(f.state().position.x().value() > 7000.0); - } - - #[test] - fn orbit_ekf_predict_propagates_covariance() { - let s0 = make_orbit_state(); - let mut f = OrbitEkf::from_stddevs(s0, [0.1, 0.1, 0.1], [1e-4, 1e-4, 1e-4]); - let p_before = f.covariance().to_row_major(); - // Identity STM: covariance stays the same without Q. - let mut phi = [[0.0f64; 6]; 6]; - for i in 0..6 { - phi[i][i] = 1.0; - } - f.predict(s0, phi, None); - let p_after_no_q = f.covariance().to_row_major(); - for i in 0..6 { - assert!( - (p_after_no_q[i][i] - p_before[i][i]).abs() < 1e-12, - "identity phi should leave diagonal unchanged" - ); - } - // Add process noise: diagonal must inflate. - let q_pos = [ - Kilometers::new(0.01), - Kilometers::new(0.01), - Kilometers::new(0.01), - ]; - let q_vel = [ - KmPerSeconds::new(1e-5), - KmPerSeconds::new(1e-5), - KmPerSeconds::new(1e-5), - ]; - let q = StateCovariance::::diagonal_from_sigmas(q_pos, q_vel); - f.predict(s0, phi, Some(q)); - let p_inflated = f.covariance().to_row_major(); - assert!( - p_inflated[0][0] > p_after_no_q[0][0], - "Q must inflate pos variance" - ); - assert!( - p_inflated[3][3] > p_after_no_q[3][3], - "Q must inflate vel variance" - ); - } -} diff --git a/src/estimation/wls.rs b/src/estimation/wls.rs deleted file mode 100644 index 5c6214d..0000000 --- a/src/estimation/wls.rs +++ /dev/null @@ -1,258 +0,0 @@ -//! # Weighted least-squares normal equations -//! -//! ## Scientific scope -//! -//! Batch POD commonly reduces to solving a symmetric positive-definite -//! normal system assembled from many scalar observation equations. This -//! module implements that algebraic core for short deterministic estimation -//! arcs where the caller has already formed residuals and design-matrix -//! coefficients. -//! -//! The numerical method is dense Cholesky factorization, which is -//! appropriate for the small-to-medium problems exercised by the current -//! workspace. It is not intended as a sparse or square-root information -//! solver for very large operational networks. -//! -//! ## Technical scope -//! -//! The public surface centers on `NormalEquations`, `WlsResult`, and -//! `WlsSolverError`. Callers stream rows into the accumulator, then solve -//! for the parameter update and inverse normal matrix in raw `f64` form. -//! -//! Observation modelling, parameter semantics, and any typed quantity -//! handling remain outside this module. It only owns the linear algebra and -//! bookkeeping of the solve. -//! -//! ## References -//! -//! - Tapley, B. D., Schutz, B. E., & Born, G. H. (2004). Statistical Orbit -//! Determination. Elsevier Academic Press. -//! - Vallado, D. A. (2013). Fundamentals of Astrodynamics and Applications -//! (4th ed.). Microcosm Press. -use faer::linalg::solvers::Solve; -use faer::{Mat, Side}; -use siderust::affn::matrix3::{FrameMatrix3, SymmetricFrameMatrix3}; -use siderust::astro::dynamics::covariance::StateCovariance; -use siderust::coordinates::frames::GCRS; -use thiserror::Error; - -/// Errors emerging from WLS assembly or solve. -#[derive(Debug, Error)] -pub enum WlsSolverError { - /// Cholesky failed (matrix indefinite / rank deficient). - #[error("normal equations not positive definite: {0}")] - NotPositiveDefinite(String), - /// Index out of bounds for the configured number of parameters. - #[error("invalid parameter index {index} (n_params = {n_params})")] - InvalidIndex { - /// Offending index. - index: usize, - /// Total parameter count. - n_params: usize, - }, - /// Wraps an underlying propagation/STM/IO failure that prevents row assembly. - #[error("solver row assembly failed: {0}")] - Other(String), -} - -impl WlsSolverError { - /// Convenience constructor for [`WlsSolverError::Other`]. - pub fn other>(msg: S) -> Self { - Self::Other(msg.into()) - } -} - -/// Accumulator for the normal equations. -#[derive(Debug, Clone)] -pub struct NormalEquations { - n: usize, - /// Upper triangle is filled; the matrix is symmetrised on solve. - pub n_matrix: Mat, - /// Right-hand side, shape (n_params, 1). - pub b: Mat, - /// Weighted χ²: Σ (rᵢ / σᵢ)². - pub chi2: f64, - /// Number of measurement rows accumulated. - pub n_obs: usize, -} - -impl NormalEquations { - /// Allocate for `n_params` total parameters. - pub fn new(n_params: usize) -> Self { - Self { - n: n_params, - n_matrix: Mat::zeros(n_params, n_params), - b: Mat::zeros(n_params, 1), - chi2: 0.0, - n_obs: 0, - } - } - - /// Total parameter count. - pub fn n_params(&self) -> usize { - self.n - } - - /// Add one weighted measurement row. - /// - /// `partials` is a sparse vector of `(index, value)` pairs, `residual` - /// is the measurement minus prediction, and `sigma` is its standard - /// deviation (must be > 0). - pub fn add_row( - &mut self, - partials: &[(usize, f64)], - residual: f64, - sigma: f64, - ) -> Result<(), WlsSolverError> { - if sigma <= 0.0 { - return Err(WlsSolverError::NotPositiveDefinite(format!( - "sigma must be > 0 (got {sigma})" - ))); - } - let w = 1.0 / (sigma * sigma); - for &(i, hi) in partials { - if i >= self.n { - return Err(WlsSolverError::InvalidIndex { - index: i, - n_params: self.n, - }); - } - // Accumulate into the upper triangle; symmetrise at solve time. - self.b[(i, 0)] += w * hi * residual; - for &(j, hj) in partials { - if j < i { - continue; - } - self.n_matrix[(i, j)] += w * hi * hj; - } - } - self.chi2 += w * residual * residual; - self.n_obs += 1; - Ok(()) - } - - /// Solve the normal equations and return parameter update + covariance. - pub fn solve(self) -> Result { - // Symmetrise. - let mut a = self.n_matrix.clone(); - for i in 0..self.n { - for j in i + 1..self.n { - let v = a[(i, j)]; - a[(j, i)] = v; - } - } - - let llt = a - .as_ref() - .llt(Side::Lower) - .map_err(|e| WlsSolverError::NotPositiveDefinite(format!("{e:?}")))?; - - // Δp = N⁻¹ b. - let dp = llt.solve(&self.b); - - // Covariance = N⁻¹ via solving against identity. - let mut id = Mat::::zeros(self.n, self.n); - for i in 0..self.n { - id[(i, i)] = 1.0; - } - let cov = llt.solve(&id); - - let mut update = vec![0.0; self.n]; - for i in 0..self.n { - update[i] = dp[(i, 0)]; - } - let mut cov_arr = vec![vec![0.0f64; self.n]; self.n]; - for i in 0..self.n { - for j in 0..self.n { - cov_arr[i][j] = cov[(i, j)]; - } - } - Ok(WlsResult { - update, - covariance: cov_arr, - chi2: self.chi2, - n_obs: self.n_obs, - n_params: self.n, - }) - } -} - -/// Output of one WLS iteration. -#[derive(Debug, Clone)] -pub struct WlsResult { - /// Parameter update vector, length `n_params`. - pub update: Vec, - /// Posterior covariance, `n_params × n_params` (row-major). - pub covariance: Vec>, - /// Weighted χ² of the prefit residuals used in this iteration. - pub chi2: f64, - /// Number of observation rows accumulated. - pub n_obs: usize, - /// Total parameter count. - pub n_params: usize, -} - -impl WlsResult { - /// Reduced χ² = χ² / max(1, n_obs − n_params). - pub fn reduced_chi2(&self) -> f64 { - let dof = self.n_obs.saturating_sub(self.n_params).max(1) as f64; - self.chi2 / dof - } - - /// Return the posterior covariance as a typed [`StateCovariance`] - /// when `n_params == 6` (position + velocity solution). - /// - /// Returns `None` for any other parameter count. - pub fn to_state_covariance(&self) -> Option> { - if self.n_params != 6 { - return None; - } - let c = &self.covariance; - let rr = SymmetricFrameMatrix3::::from_upper([ - [c[0][0], c[0][1], c[0][2]], - [c[1][0], c[1][1], c[1][2]], - [c[2][0], c[2][1], c[2][2]], - ]); - let rv = FrameMatrix3::::from_array([ - [c[0][3], c[0][4], c[0][5]], - [c[1][3], c[1][4], c[1][5]], - [c[2][3], c[2][4], c[2][5]], - ]); - let vv = SymmetricFrameMatrix3::::from_upper([ - [c[3][3], c[3][4], c[3][5]], - [c[4][3], c[4][4], c[4][5]], - [c[5][3], c[5][4], c[5][5]], - ]); - Some(StateCovariance::::from_blocks(rr, rv, vv)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn solves_simple_2d_system() { - // Fit y = a + b*x to (0,1), (1,2), (2,3) with σ=1. - // Truth: a=1, b=1. - let mut ne = NormalEquations::new(2); - for (x, y) in [(0.0, 1.0), (1.0, 2.0), (2.0, 3.0)] { - ne.add_row(&[(0, 1.0), (1, x)], y, 1.0).unwrap(); - } - let r = ne.solve().unwrap(); - assert!((r.update[0] - 1.0).abs() < 1e-12); - assert!((r.update[1] - 1.0).abs() < 1e-12); - } - - #[test] - fn flags_singular_system() { - // Two identical rows ⇒ rank deficient. - let mut ne = NormalEquations::new(2); - ne.add_row(&[(0, 1.0)], 1.0, 1.0).unwrap(); - ne.add_row(&[(0, 1.0)], 1.0, 1.0).unwrap(); - assert!(matches!( - ne.solve(), - Err(WlsSolverError::NotPositiveDefinite(_)) - )); - } -} diff --git a/src/io/lisa.rs b/src/io/lisa.rs index 9d3601c..e60d14c 100644 --- a/src/io/lisa.rs +++ b/src/io/lisa.rs @@ -15,7 +15,7 @@ //! typed [`LisaOrbit`]. //! - [`LisaOrbitSet`]: holds the three loaded spacecraft orbits together. //! - [`LisaEphemerisProvider`][]: implements -//! [`crate::core::providers::EphemerisProvider`] via **cubic Hermite** +//! [`crate::service::providers::EphemerisProvider`] via **cubic Hermite** //! interpolation over the tabulated state vectors. //! //! ## Interpolation @@ -48,7 +48,7 @@ //! use super::{oem::read_oem, PodIoError}; -use crate::core::providers::EphemerisProvider; +use crate::service::providers::EphemerisProvider; use siderust::affn::cartesian; use siderust::affn::centers::{AffineCenter, ReferenceCenter}; use siderust::affn::frames::EME2000; @@ -440,7 +440,7 @@ impl LisaOrbitSet { /// /// ``` /// use spod::io::lisa::{LisaOrbitReader, LisaOrbitSet, LisaEphemerisProvider, LisaSpacecraftId}; -/// use spod::core::providers::EphemerisProvider; +/// use spod::service::providers::EphemerisProvider; /// /// let root = concat!(env!("CARGO_MANIFEST_DIR"), "/test-data/lisa"); /// let load = |name: &str, sc: LisaSpacecraftId| { diff --git a/src/io/omm.rs b/src/io/omm.rs index 55209eb..312ed19 100644 --- a/src/io/omm.rs +++ b/src/io/omm.rs @@ -1,4 +1,4 @@ -//! # OMM reader/writer bridging `siderust-tle` +//! # 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 @@ -12,7 +12,7 @@ use super::PodIoError; use crate::tle::omm::{kvn, xml}; use std::io::{Read, Write}; -/// Re-export of the `siderust-tle` OMM record. +/// Re-export of the `spod::tle` OMM record. /// /// See [`crate::tle::omm::Omm`] for field documentation. /// diff --git a/src/lambert/mod.rs b/src/lambert/mod.rs index 2be7d31..32e71f7 100644 --- a/src/lambert/mod.rs +++ b/src/lambert/mod.rs @@ -1,8 +1,8 @@ -//! `siderust-lambert` — typed 0/N-revolution Lambert solver. +//! `spod::lambert` — typed 0/N-revolution Lambert solver. //! //! ## Overview //! -//! This crate solves Lambert's two-point boundary-value problem (find +//! 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`) @@ -48,8 +48,8 @@ //! //! ## Out of scope (kept in higher-level crates) //! -//! * Mission-design optimisation, porkchop search, transfer-window -//! enumeration → future `siderust-mission-design`. +//! * 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. diff --git a/src/lib.rs b/src/lib.rs index 7dab50f..a8488d2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Vallés Puig, Ramon -//! `spod` — Precise Orbit Determination toolkit. +//! `spod` — service application for Siderust POD workflows. //! -//! This crate provides a complete POD pipeline built on the -//! [`siderust`] and its public scientific re-exports. +//! This crate owns service configuration, orchestration, transport, and +//! artifact handling. Reusable POD science is provided directly by +//! [`siderust::pod`]; `spod` deliberately does not expose a compatibility +//! facade for those APIs. //! //! ## Modules //! @@ -14,13 +16,7 @@ //! | [`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 | -//! | [`core`] | POD domain primitives: errors, providers, parameters, covariance | //! | [`io`] | File format parsers/writers: SP3, RINEX, ANTEX, OEM, EOP, … | -//! | [`dynamics`] | Astrodynamics primitives and POD force-model composition | -//! | [`observations`] | Measurement models: GNSS, SLR, LISA (feature-gated) | -//! | [`estimation`] | WLS, Gauss-Newton, EKF estimators | -//! | [`qc`] | Quality-control and validation products | -//! | [`products`] | SP3, OEM, residual CSV/Parquet, manifest writers | //! | [`service`] | Pipeline orchestration and job runner | //! //! ## Binary entry-points @@ -30,14 +26,8 @@ #![forbid(unsafe_code)] -pub mod core; -pub mod dynamics; -pub mod estimation; pub mod io; pub mod lambert; -pub mod observations; -pub mod products; -pub mod qc; pub mod service; pub mod sgp4; pub mod spice; diff --git a/src/observations/batch.rs b/src/observations/batch.rs deleted file mode 100644 index b262c56..0000000 --- a/src/observations/batch.rs +++ /dev/null @@ -1,226 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Ordered collection of observations grouped by epoch. - -use siderust::time::JulianDate; - -use super::error::PodObservationsError; -use crate::observations::obs_trait::{AnyObservation, CartesianState, ObsResidual}; -use crate::observations::provider_bundle::ProviderBundle; - -/// Ordered collection of heterogeneous observations, typically spanning one -/// processing arc. -/// -/// Observations are stored as trait objects so that pseudorange, carrier-phase, -/// SLR, and inter-satellite measurements can coexist in the same batch. -/// [`residuals_at_epoch`](ObservationBatch::residuals_at_epoch) filters and -/// evaluates all observations at a given epoch. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::batch::ObservationBatch; -/// use spod::observations::provider_bundle::NullProviderBundle; -/// use siderust::time::JulianDate; -/// -/// let batch = ObservationBatch::new(); -/// assert!(batch.is_empty()); -/// -/// let epoch = JulianDate::new(2_451_545.0); -/// let residuals = batch.residuals_at_epoch( -/// epoch, -/// // A real use case passes an estimated CartesianState here; we skip it -/// // in the doc-test. -/// ); -/// assert!(residuals.is_empty()); -/// ``` -pub struct ObservationBatch { - obs: Vec>, -} - -impl ObservationBatch { - /// Create an empty batch. - pub fn new() -> Self { - Self { obs: Vec::new() } - } - - /// Append an observation. - pub fn push(&mut self, o: Box) { - self.obs.push(o); - } - - /// Number of observations in the batch. - pub fn len(&self) -> usize { - self.obs.len() - } - - /// Returns `true` if the batch contains no observations. - pub fn is_empty(&self) -> bool { - self.obs.is_empty() - } - - /// All epochs present in the batch (unsorted, may contain duplicates). - pub fn epochs(&self) -> Vec { - self.obs.iter().map(|o| o.epoch()).collect() - } - - /// Compute O−C residuals for all observations whose epoch equals `epoch` - /// within a tolerance of 0.5 µs (≈ 5.8e-12 days). - /// - /// Returns one entry per matching observation; each entry is either the - /// typed [`ObsResidual`] or a [`PodObservationsError`]. - /// - /// # Examples - /// - /// ``` - /// use spod::observations::batch::ObservationBatch; - /// use spod::observations::obs_trait::{ObsType, Observation, CartesianState}; - /// use spod::observations::provider_bundle::{NullProviderBundle, ProviderBundle}; - /// use spod::observations::PodObservationsError; - /// use siderust::astro::dynamics::{Position, Velocity}; - /// use siderust::coordinates::frames::GCRS; - /// use siderust::time::JulianDate; - /// - /// struct ZeroObs(JulianDate); - /// impl Observation for ZeroObs { - /// type Residual = f64; - /// fn modeled_value(&self, _: &CartesianState, _: &dyn ProviderBundle) - /// -> Result { Ok(0.0) } - /// fn obs_type(&self) -> ObsType { ObsType::GnssPseudorange } - /// fn epoch(&self) -> JulianDate { self.0 } - /// fn sigma(&self) -> f64 { 1.0 } - /// } - /// - /// let epoch = JulianDate::new(2_451_545.0); - /// let state: CartesianState = CartesianState::new( - /// epoch.to_j2000s(), - /// Position::::new(7_000.0, 0.0, 0.0), - /// Velocity::::new(0.0, 7.5, 0.0), - /// ); - /// - /// let mut batch = ObservationBatch::new(); - /// batch.push(Box::new(ZeroObs(epoch))); - /// - /// let results = batch.residuals_at_epoch_with(epoch, &state, &NullProviderBundle); - /// assert_eq!(results.len(), 1); - /// assert!(results[0].is_ok()); - /// ``` - pub fn residuals_at_epoch( - &self, - epoch: JulianDate, - ) -> Vec> { - // Filter only — useful for pre-checking which observations match. - self.obs - .iter() - .filter(|o| epochs_match(o.epoch(), epoch)) - .map(|_| Err(PodObservationsError::LightTimeNotConverged)) - .collect() - } - - /// Compute residuals at `epoch`, evaluating each matching observation - /// against `state` and `providers`. - pub fn residuals_at_epoch_with( - &self, - epoch: JulianDate, - state: &CartesianState, - providers: &dyn ProviderBundle, - ) -> Vec> { - self.obs - .iter() - .filter(|o| epochs_match(o.epoch(), epoch)) - .map(|o| o.any_modeled_value(state, providers)) - .collect() - } -} - -impl Default for ObservationBatch { - fn default() -> Self { - Self::new() - } -} - -/// Epochs match within 0.5 µs (≈ 5.8e-12 JD days). -fn epochs_match(a: JulianDate, b: JulianDate) -> bool { - const TOL_DAYS: f64 = 5.8e-12; - (a.value() - b.value()).abs() < TOL_DAYS -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::observations::obs_trait::{ObsType, Observation}; - use crate::observations::provider_bundle::NullProviderBundle; - use siderust::astro::dynamics::{Position, Velocity}; - use siderust::coordinates::frames::GCRS; - - struct ConstObs { - epoch: JulianDate, - residual: f64, - } - impl Observation for ConstObs { - type Residual = f64; - fn modeled_value( - &self, - _: &CartesianState, - _: &dyn ProviderBundle, - ) -> Result { - Ok(self.residual) - } - fn obs_type(&self) -> ObsType { - ObsType::GnssPseudorange - } - fn epoch(&self) -> JulianDate { - self.epoch - } - fn sigma(&self) -> f64 { - 1.0 - } - } - - fn make_state(epoch: JulianDate) -> CartesianState { - CartesianState::new( - epoch.to_j2000s(), - Position::::new(7_000.0, 0.0, 0.0), - Velocity::::new(0.0, 7.5, 0.0), - ) - } - - #[test] - fn batch_residuals_correct_epoch() { - let epoch = JulianDate::new(2_451_545.0); - let other = JulianDate::new(2_451_546.0); - let state = make_state(epoch); - let mut batch = ObservationBatch::new(); - batch.push(Box::new(ConstObs { - epoch, - residual: 1.0, - })); - batch.push(Box::new(ConstObs { - epoch: other, - residual: 0.0, - })); - - let res = batch.residuals_at_epoch_with(epoch, &state, &NullProviderBundle); - assert_eq!(res.len(), 1); - if let Ok(ObsResidual::Scalar(v)) = &res[0] { - assert!((*v - 1.0).abs() < 1e-10); - } else { - panic!("Expected Scalar residual"); - } - } - - #[test] - fn batch_empty_for_unknown_epoch() { - let epoch = JulianDate::new(2_451_545.0); - let query = JulianDate::new(2_451_600.0); - let state = make_state(epoch); - let mut batch = ObservationBatch::new(); - batch.push(Box::new(ConstObs { - epoch, - residual: 1.0, - })); - let res = batch.residuals_at_epoch_with(query, &state, &NullProviderBundle); - assert!(res.is_empty()); - } -} diff --git a/src/observations/corrections.rs b/src/observations/corrections.rs deleted file mode 100644 index 81cabbc..0000000 --- a/src/observations/corrections.rs +++ /dev/null @@ -1,380 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Range corrections applied sequentially by a [`CorrectionRegistry`]. -//! -//! ## Scientific scope -//! -//! Observation models often need a chain of additive range corrections that -//! do not fit neatly inside a single sensor model (phase-centre offsets, -//! relativistic delays, solid-earth tides). This module provides a -//! composable registry pattern so the corrections are explicit, auditable, and -//! easily toggled by a configuration layer. -//! -//! ## References -//! -//! - Petit, G., Luzum, B. (Eds.) (2010). *IERS Conventions (2010)*, Chapter 9. -//! - Shapiro, I. I. (1964). Fourth test of general relativity. -//! *Physical Review Letters*, 13(26), 789–791. -//! - Zhu, S. Y., et al. (1997). Accurate determination of laser-ranging station -//! coordinates by means of simultaneous observations. -//! *Bulletin géodésique*, 70(1–2), 73–87. - -/// Additive range correction applied by the [`CorrectionRegistry`]. -/// -/// Each correction receives the current range (in metres) and the GCRS -/// positions of the receiver and the satellite/reflector, and returns a new -/// range that includes the correction. Corrections are applied in the order -/// they were registered. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::corrections::Correction; -/// -/// struct ZeroCorrection; -/// impl Correction for ZeroCorrection { -/// fn name(&self) -> &str { "zero" } -/// fn apply_to_range(&self, range_m: f64, _rx: [f64; 3], _sat: [f64; 3]) -> f64 { -/// range_m -/// } -/// } -/// -/// let c = ZeroCorrection; -/// assert_eq!(c.apply_to_range(1_000.0, [0.0; 3], [0.0; 3]), 1_000.0); -/// ``` -pub trait Correction: Send + Sync { - /// Descriptive name used in logs and API snapshots. - fn name(&self) -> &str; - - /// Apply the correction to `range_m` and return the corrected range. - /// - /// * `range_m` — current accumulated range (metres). - /// * `rx_gcrs_km` — receiver GCRS position (km). - /// * `sat_gcrs_km` — satellite / reflector GCRS position (km). - fn apply_to_range(&self, range_m: f64, rx_gcrs_km: [f64; 3], sat_gcrs_km: [f64; 3]) -> f64; -} - -// ─── Registry ──────────────────────────────────────────────────────────────── - -/// Sequential pipeline of [`Correction`] objects. -/// -/// Corrections are applied in insertion order, each receiving the output of -/// the previous one. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::corrections::{ -/// CorrectionRegistry, ShapiroDelay, -/// }; -/// -/// let mut reg = CorrectionRegistry::new(); -/// reg.push(Box::new(ShapiroDelay)); -/// assert_eq!(reg.len(), 1); -/// -/// let rx = [6_378.0, 0.0, 0.0_f64]; -/// let sat = [26_560.0, 0.0, 0.0_f64]; -/// let corrected = reg.apply(20_182_000.0, rx, sat); -/// assert!(corrected > 20_182_000.0); // Shapiro adds a positive delay -/// ``` -pub struct CorrectionRegistry { - corrections: Vec>, -} - -impl CorrectionRegistry { - /// Create an empty registry. - pub fn new() -> Self { - Self { - corrections: Vec::new(), - } - } - - /// Append a correction to the end of the pipeline. - pub fn push(&mut self, c: Box) { - self.corrections.push(c); - } - - /// Number of registered corrections. - pub fn len(&self) -> usize { - self.corrections.len() - } - - /// Returns `true` if no corrections are registered. - pub fn is_empty(&self) -> bool { - self.corrections.is_empty() - } - - /// Apply all corrections in order, returning the final corrected range. - pub fn apply(&self, range_m: f64, rx_gcrs_km: [f64; 3], sat_gcrs_km: [f64; 3]) -> f64 { - let mut r = range_m; - for c in &self.corrections { - r = c.apply_to_range(r, rx_gcrs_km, sat_gcrs_km); - } - r - } - - /// Names of all registered corrections (for diagnostics). - pub fn names(&self) -> Vec<&str> { - self.corrections.iter().map(|c| c.name()).collect() - } -} - -impl Default for CorrectionRegistry { - fn default() -> Self { - Self::new() - } -} - -// ─── Built-in corrections ──────────────────────────────────────────────────── - -/// GNSS antenna phase-centre offset (PCO) correction. -/// -/// The PCO vector (in the GCRS frame, km) is projected onto the -/// receiver-to-satellite line-of-sight and subtracted from the raw -/// geometric range. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::corrections::{Correction, PhaseCenterOffset}; -/// -/// // 1.5 mm offset along the receiver radial direction (X in GCRS here) -/// let pco = PhaseCenterOffset::new([0.0000015, 0.0, 0.0]); -/// let rx = [7_000.0, 0.0, 0.0_f64]; -/// let sat = [26_560.0, 0.0, 0.0_f64]; -/// let corrected = pco.apply_to_range(20_000_000.0, rx, sat); -/// // The correction should be about ±1.5 mm along the LOS -/// assert!((corrected - 20_000_000.0).abs() < 0.01); -/// ``` -pub struct PhaseCenterOffset { - /// PCO vector in GCRS frame, kilometres. - offset_gcrs_km: [f64; 3], -} - -impl PhaseCenterOffset { - /// Construct from a GCRS offset vector given in kilometres. - pub fn new(offset_gcrs_km: [f64; 3]) -> Self { - Self { offset_gcrs_km } - } - - /// Construct from a GCRS offset vector given in metres. - pub fn from_metres(offset_m: [f64; 3]) -> Self { - Self { - offset_gcrs_km: [ - offset_m[0] / 1000.0, - offset_m[1] / 1000.0, - offset_m[2] / 1000.0, - ], - } - } -} - -impl Correction for PhaseCenterOffset { - fn name(&self) -> &str { - "PhaseCenterOffset" - } - - fn apply_to_range(&self, range_m: f64, rx_gcrs_km: [f64; 3], sat_gcrs_km: [f64; 3]) -> f64 { - let los = [ - sat_gcrs_km[0] - rx_gcrs_km[0], - sat_gcrs_km[1] - rx_gcrs_km[1], - sat_gcrs_km[2] - rx_gcrs_km[2], - ]; - let los_mag = (los[0] * los[0] + los[1] * los[1] + los[2] * los[2]).sqrt(); - if los_mag == 0.0 { - return range_m; - } - let los_hat = [los[0] / los_mag, los[1] / los_mag, los[2] / los_mag]; - let pco_dot = self.offset_gcrs_km[0] * los_hat[0] - + self.offset_gcrs_km[1] * los_hat[1] - + self.offset_gcrs_km[2] * los_hat[2]; - // PCO is subtracted: the antenna phase centre is shifted toward - // the satellite, reducing the apparent range. - range_m - pco_dot * 1000.0 - } -} - -// ─── Shapiro delay ─────────────────────────────────────────────────────────── - -/// General-relativistic Shapiro delay for signal propagation near Earth. -/// -/// The formula is (Shapiro 1964): -/// `ΔR = (2·GM/c²) · ln((r_rx + r_sat + ρ) / (r_rx + r_sat − ρ))` -/// -/// For GPS geometry the correction is typically 5–20 mm. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::corrections::{Correction, ShapiroDelay}; -/// -/// let sd = ShapiroDelay; -/// let rx = [6_378.0, 0.0, 0.0_f64]; // km -/// let sat = [26_560.0, 0.0, 0.0_f64]; -/// let rho_m = (sat[0] - rx[0]) * 1_000.0; // 20 182 km → 20_182_000 m -/// let corrected = sd.apply_to_range(rho_m, rx, sat); -/// let delay = corrected - rho_m; -/// // Shapiro delay is positive and of order 10 mm. -/// assert!(delay > 0.001 && delay < 0.05, "delay={delay}"); -/// ``` -pub struct ShapiroDelay; - -/// Earth gravitational parameter (m³/s²). -const GM_M3_S2: f64 = 3.986_004_418e14; -/// Speed of light (m/s). -const C_M_S: f64 = 299_792_458.0; -/// Pre-computed 2·GM/c² (m). -const TWO_GM_OVER_C2_M: f64 = 2.0 * GM_M3_S2 / (C_M_S * C_M_S); - -impl Correction for ShapiroDelay { - fn name(&self) -> &str { - "ShapiroDelay" - } - - fn apply_to_range(&self, range_m: f64, rx_gcrs_km: [f64; 3], sat_gcrs_km: [f64; 3]) -> f64 { - let r_rx = (rx_gcrs_km[0] * rx_gcrs_km[0] - + rx_gcrs_km[1] * rx_gcrs_km[1] - + rx_gcrs_km[2] * rx_gcrs_km[2]) - .sqrt() - * 1_000.0; // km → m - let r_sat = (sat_gcrs_km[0] * sat_gcrs_km[0] - + sat_gcrs_km[1] * sat_gcrs_km[1] - + sat_gcrs_km[2] * sat_gcrs_km[2]) - .sqrt() - * 1_000.0; - let sum = r_rx + r_sat; - let den = sum - range_m; - if den > 0.0 { - range_m + TWO_GM_OVER_C2_M * ((sum + range_m) / den).ln() - } else { - range_m - } - } -} - -// ─── Earth-tide displacement ───────────────────────────────────────────────── - -/// Solid-earth tidal displacement projected onto the receiver-to-satellite -/// line-of-sight. -/// -/// The displacement vector must be computed externally (e.g. using the IERS -/// Conventions Step-1 solid-tide formulae with lunar/solar positions) and -/// supplied at construction time. The correction subtracts the LOS component -/// of the displacement from the range, reflecting the fact that the receiver -/// has been physically displaced by the tide. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::corrections::{Correction, EarthTideDisplacement}; -/// -/// // 3 cm radial (vertical) uplift -/// let rx = [6_378.0, 0.0, 0.0_f64]; // km, radial direction = X -/// let sat = [26_560.0, 0.0, 0.0_f64]; -/// let etd = EarthTideDisplacement::from_metres(0.03, 0.0, 0.0); -/// let corrected = etd.apply_to_range(20_182_000.0, rx, sat); -/// // 3 cm displacement along LOS → range decreases by ~3 cm -/// assert!((corrected - (20_182_000.0 - 0.03)).abs() < 1e-6); -/// ``` -pub struct EarthTideDisplacement { - /// Station/receiver displacement in GCRS, kilometres. - displacement_gcrs_km: [f64; 3], -} - -impl EarthTideDisplacement { - /// Construct from a GCRS displacement vector (km). - pub fn from_km(dx: f64, dy: f64, dz: f64) -> Self { - Self { - displacement_gcrs_km: [dx, dy, dz], - } - } - - /// Construct from a GCRS displacement vector (m). - pub fn from_metres(dx_m: f64, dy_m: f64, dz_m: f64) -> Self { - Self::from_km(dx_m / 1000.0, dy_m / 1000.0, dz_m / 1000.0) - } - - /// Zero displacement (identity correction). - pub fn zero() -> Self { - Self::from_km(0.0, 0.0, 0.0) - } -} - -impl Correction for EarthTideDisplacement { - fn name(&self) -> &str { - "EarthTideDisplacement" - } - - fn apply_to_range(&self, range_m: f64, rx_gcrs_km: [f64; 3], sat_gcrs_km: [f64; 3]) -> f64 { - let los = [ - sat_gcrs_km[0] - rx_gcrs_km[0], - sat_gcrs_km[1] - rx_gcrs_km[1], - sat_gcrs_km[2] - rx_gcrs_km[2], - ]; - let los_mag = (los[0] * los[0] + los[1] * los[1] + los[2] * los[2]).sqrt(); - if los_mag == 0.0 { - return range_m; - } - let los_hat = [los[0] / los_mag, los[1] / los_mag, los[2] / los_mag]; - let disp_los = self.displacement_gcrs_km[0] * los_hat[0] - + self.displacement_gcrs_km[1] * los_hat[1] - + self.displacement_gcrs_km[2] * los_hat[2]; - // A positive displacement toward the satellite reduces the apparent range. - range_m - disp_los * 1000.0 - } -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn shapiro_positive_and_small() { - let rx = [6_378.0, 0.0, 0.0_f64]; - let sat = [26_560.0, 0.0, 0.0_f64]; - let rho_m = (sat[0] - rx[0]) * 1_000.0; - let corrected = ShapiroDelay.apply_to_range(rho_m, rx, sat); - let delay = corrected - rho_m; - assert!(delay > 0.005 && delay < 0.025, "Shapiro delay={delay:.4}m"); - } - - #[test] - fn earth_tide_zero_is_noop() { - let rx = [7_000.0, 0.0, 0.0_f64]; - let sat = [26_560.0, 1_000.0, 500.0_f64]; - let rho_m = 19_000_000.0; - let etd = EarthTideDisplacement::zero(); - assert_eq!(etd.apply_to_range(rho_m, rx, sat), rho_m); - } - - #[test] - fn registry_applies_in_order() { - struct Add(f64); - impl Correction for Add { - fn name(&self) -> &str { - "add" - } - fn apply_to_range(&self, r: f64, _: [f64; 3], _: [f64; 3]) -> f64 { - r + self.0 - } - } - let mut reg = CorrectionRegistry::new(); - reg.push(Box::new(Add(1.0))); - reg.push(Box::new(Add(2.0))); - assert_eq!(reg.apply(0.0, [0.0; 3], [0.0; 3]), 3.0); - assert_eq!(reg.len(), 2); - } - - #[test] - fn pco_along_los_subtracts_offset() { - // Receiver at origin, satellite along +X. PCO offset 1 km along +X. - let pco = PhaseCenterOffset::new([0.001, 0.0, 0.0]); // 1 m in km - let rx = [0.0, 0.0, 0.0_f64]; - let sat = [1_000.0, 0.0, 0.0_f64]; - let corrected = pco.apply_to_range(1_000_000.0, rx, sat); - assert!((corrected - (1_000_000.0 - 1.0)).abs() < 1e-9); - } -} diff --git a/src/observations/error.rs b/src/observations/error.rs deleted file mode 100644 index 0d395d3..0000000 --- a/src/observations/error.rs +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Error type for the observation-model crate. - -/// Unified error type for all observation-model failures. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::PodObservationsError; -/// -/// let e = PodObservationsError::MissingSatelliteState { -/// prn: "G01".to_string(), -/// epoch_jd: 2_451_545.0, -/// }; -/// assert!(e.to_string().contains("G01")); -/// ``` -#[derive(Debug, thiserror::Error)] -pub enum PodObservationsError { - /// Satellite state (position/velocity) is not available for the requested - /// PRN and epoch. - #[error("satellite state not available for PRN {prn} at JD {epoch_jd:.6}")] - MissingSatelliteState { - /// GNSS PRN or body identifier. - prn: String, - /// Julian date of the request. - epoch_jd: f64, - }, - - /// Station position is not available for the requested station identifier. - #[error("station position not available for station '{station_id}'")] - MissingStationPosition { - /// Station identifier (e.g. DOMES number or 4-char code). - station_id: String, - }, - - /// Computed elevation is below the atmospheric model cutoff. - #[error("elevation {el_deg:.1}° is below the atmospheric model cutoff")] - ElevationTooLow { - /// Elevation in degrees. - el_deg: f64, - }, - - /// Light-time iteration failed to converge within the allowed iterations. - #[error("light-time iteration did not converge")] - LightTimeNotConverged, - - /// An I/O or provider error occurred when querying the LISA ephemeris. - #[cfg(feature = "lisa")] - #[error("LISA ephemeris provider error: {0}")] - LisaProvider(String), -} diff --git a/src/observations/frame_transform.rs b/src/observations/frame_transform.rs deleted file mode 100644 index eb67049..0000000 --- a/src/observations/frame_transform.rs +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! # Earth-fixed to inertial frame-transform provider -//! -//! ## Scientific scope -//! -//! Accurate range and carrier modelling requires moving consistently -//! between terrestrial station coordinates and inertial spacecraft states. -//! This module defines the provider seam for the full ITRF-GCRF chain -//! driven by Earth-orientation inputs and standard precession, nutation, -//! Earth rotation, and polar-motion conventions. -//! -//! The science here is limited to the interface contract: callers supply TT -//! and UT1 epochs because different pieces of the transformation depend on -//! different time scales. The module does not hard-code a specific -//! convention realization. -//! -//! ## Technical scope -//! -//! The public items are `FrameTransformProvider` and `FrameTransformError`. -//! Implementations return `Rotation3` transforms for ITRF-to-GCRF and the -//! inverse direction, leaving the choice of EOP source and astronomical -//! model to the concrete provider. -//! -//! Observation models consume this trait but do not own the realization of -//! the Earth-orientation chain. -//! -//! ## 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. -use siderust::affn::Rotation3; -use siderust::time::JulianDate; -use thiserror::Error; - -/// Error returned by [`FrameTransformProvider`]. -#[derive(Debug, Error)] -pub enum FrameTransformError { - /// The provider could not compute the rotation at the requested epoch. - #[error("frame transform failed: {0}")] - Failed(String), -} - -/// Provider for frame rotations between Earth-fixed and inertial frames. -/// -/// The `at(jd_tt, jd_ut1)` API takes both TT and UT1 because the precession- -/// nutation rotation is parameterised by TT while ERA is parameterised by UT1. -pub trait FrameTransformProvider: Send + Sync { - /// Rotation from ITRF to GCRF at the given TT/UT1 epoch. - fn itrf_to_gcrf( - &self, - jd_tt: JulianDate, - jd_ut1: JulianDate, - ) -> Result; - - /// Rotation from GCRF to ITRF at the given TT/UT1 epoch. - fn gcrf_to_itrf( - &self, - jd_tt: JulianDate, - jd_ut1: JulianDate, - ) -> Result { - Ok(self.itrf_to_gcrf(jd_tt, jd_ut1)?.inverse()) - } -} diff --git a/src/observations/gnss.rs b/src/observations/gnss.rs deleted file mode 100644 index 3538250..0000000 --- a/src/observations/gnss.rs +++ /dev/null @@ -1,216 +0,0 @@ -//! # GNSS code and carrier observation models -//! -//! ## Scientific scope -//! -//! This module models one-way GNSS geometry between a spacecraft receiver -//! state and known transmitter states. The current regime is intentionally -//! compact: geometric range, receiver clock bias, carrier ambiguity, and a -//! Sagnac-style Earth-rotation correction for short light times typical of -//! LEO and MEO POD scenarios. -//! -//! It does not yet include full light-time iteration, relativistic clock -//! terms, troposphere, ionosphere, antenna phase-centre modelling, or -//! ambiguity fixing. Those omissions are deliberate and documented so the -//! current use stays within the MVP synthetic and controlled-data paths. -//! -//! ## Technical scope -//! -//! The public surface provides `PseudorangeObs`, `CarrierPhaseObs`, -//! `GnssCodeModel`, and `GnssCarrierModel`. Models consume orbit states and -//! caller-supplied auxiliary parameters, then return scalar predictions and -//! design-matrix partials in the ordering expected by the estimation layer. -//! -//! File parsing and orbit propagation are not handled here; they are -//! supplied by the IO and service crates. -//! -//! ## References -//! -//! - Misra, P., & Enge, P. (2012). Global Positioning System: Signals, -//! Measurements, and Performance (2nd ed.). Ganga-Jamuna Press. -//! - Tapley, B. D., Schutz, B. E., & Born, G. H. (2004). Statistical Orbit -//! Determination. Elsevier Academic Press. -use super::model::{MeasurementModel, Partials, Prediction}; -use siderust::astro::dynamics::forces::OMEGA_EARTH_RAD_S; -use siderust::astro::dynamics::state::VelocityUnit; -use siderust::astro::dynamics::{OrbitState, Position, Velocity}; -use siderust::coordinates::frames::GCRS; -use siderust::qtty::unit::{Kilometer, Second}; -use siderust::qtty::velocity::C; -use siderust::qtty::Per; - -/// Pseudorange observation between a LEO receiver and a GPS satellite. -#[derive(Debug, Clone, Copy)] -pub struct PseudorangeObs { - /// GPS satellite GCRF position at signal-emission instant, km. - pub gps_pos_km: Position, - /// GPS satellite GCRF velocity, km/s. Used for the relativistic correction. - pub gps_vel_km_s: Velocity, - /// Measured pseudorange, metres. - pub measured_m: f64, - /// Measurement standard deviation, metres. - pub sigma_m: f64, -} - -/// Carrier-phase observation in metres (already scaled by wavelength). -#[derive(Debug, Clone, Copy)] -pub struct CarrierPhaseObs { - /// GPS satellite GCRF position, km. - pub gps_pos_km: Position, - /// GPS satellite GCRF velocity, km/s. - pub gps_vel_km_s: Velocity, - /// Measured carrier-phase range, metres. - pub measured_m: f64, - /// Measurement standard deviation, metres. - pub sigma_m: f64, -} - -/// GNSS pseudorange model. Carries indices into the parameter vector for the -/// receiver clock bias. -#[derive(Debug, Clone)] -pub struct GnssCodeModel { - /// Observation data. - pub obs: PseudorangeObs, - /// Index, in `extra_params`, of the receiver clock bias (metres). - pub clock_bias_index: usize, -} - -/// GNSS carrier-phase model. Carries indices into the parameter vector for -/// the receiver clock bias and the per-pass float ambiguity. -#[derive(Debug, Clone)] -pub struct GnssCarrierModel { - /// Observation data. - pub obs: CarrierPhaseObs, - /// Index, in `extra_params`, of the receiver clock bias (metres). - pub clock_bias_index: usize, - /// Index, in `extra_params`, of the float ambiguity (metres). - pub ambiguity_index: usize, -} - -fn sagnac_km(gps_pos_km: Position, rx_pos_km: Position) -> f64 { - let c_km_s = C.to::>().value(); - OMEGA_EARTH_RAD_S.value() - * (gps_pos_km.x().value() * rx_pos_km.y().value() - - gps_pos_km.y().value() * rx_pos_km.x().value()) - / c_km_s -} - -fn relativistic_gps_clock_m(pos_km: Position, vel_km_s: Velocity) -> f64 { - let dot = pos_km.x().value() * vel_km_s.x().value() - + pos_km.y().value() * vel_km_s.y().value() - + pos_km.z().value() * vel_km_s.z().value(); - -2.0 * dot * 1_000.0 / C.value() -} - -/// Predict a pseudorange in metres given the current LEO state and clock bias. -fn predict_range_m( - state: &OrbitState, - gps_pos_km: Position, - gps_vel_km_s: Velocity, -) -> (f64, [f64; 3]) { - let los = state.position - gps_pos_km; - let geom_km = los.magnitude().value(); - let geom_m = geom_km * 1_000.0; - let sagnac_m = sagnac_km(gps_pos_km, state.position) * 1_000.0; - let rel_m = relativistic_gps_clock_m(gps_pos_km, gps_vel_km_s); - let u = if geom_km > 0.0 { - [ - los.x().value() / geom_km, - los.y().value() / geom_km, - los.z().value() / geom_km, - ] - } else { - [0.0; 3] - }; - (geom_m + sagnac_m + rel_m, u) -} - -impl MeasurementModel for GnssCodeModel { - fn predict(&self, state: &OrbitState, extra_params: &[f64]) -> Prediction { - let clk_m = extra_params - .get(self.clock_bias_index) - .copied() - .unwrap_or(0.0); - let (range_m, u) = predict_range_m(state, self.obs.gps_pos_km, self.obs.gps_vel_km_s); - let value = range_m + clk_m; - // Partials w.r.t. position (km → metres factor): d/dr (geom_m) = u * 1000. - let partials = Partials::from_pairs(vec![ - (0, u[0] * 1_000.0), - (1, u[1] * 1_000.0), - (2, u[2] * 1_000.0), - (self.clock_bias_index + 6, 1.0), - ]); - Prediction { value, partials } - } - fn sigma(&self) -> f64 { - self.obs.sigma_m - } -} - -impl MeasurementModel for GnssCarrierModel { - fn predict(&self, state: &OrbitState, extra_params: &[f64]) -> Prediction { - let clk_m = extra_params - .get(self.clock_bias_index) - .copied() - .unwrap_or(0.0); - let amb_m = extra_params - .get(self.ambiguity_index) - .copied() - .unwrap_or(0.0); - let (range_m, u) = predict_range_m(state, self.obs.gps_pos_km, self.obs.gps_vel_km_s); - let value = range_m + clk_m + amb_m; - let partials = Partials::from_pairs(vec![ - (0, u[0] * 1_000.0), - (1, u[1] * 1_000.0), - (2, u[2] * 1_000.0), - (self.clock_bias_index + 6, 1.0), - (self.ambiguity_index + 6, 1.0), - ]); - Prediction { value, partials } - } - fn sigma(&self) -> f64 { - self.obs.sigma_m - } -} - -#[cfg(test)] -mod tests { - use super::*; - use siderust::astro::dynamics::{Position, Velocity}; - use siderust::time::JulianDate; - - #[test] - fn pseudorange_partials_match_finite_difference() { - let state = OrbitState::new( - JulianDate::new(2_451_545.0).to_j2000s(), - Position::new(7000.0, 0.0, 0.0), - Velocity::new(0.0, 7.5, 0.0), - ); - let gps_pos = Position::::new(26_000.0, 1_000.0, 5_000.0); - let gps_vel = Velocity::::new(0.0, 3.0, 0.0); - let model = GnssCodeModel { - obs: PseudorangeObs { - gps_pos_km: gps_pos, - gps_vel_km_s: gps_vel, - measured_m: 0.0, - sigma_m: 1.0, - }, - clock_bias_index: 0, - }; - let extra = [0.0]; - let p0 = model.predict(&state, &extra); - let h = 1e-3; - for i in 0..3 { - let rx = state.position.x().value() + if i == 0 { h } else { 0.0 }; - let ry = state.position.y().value() + if i == 1 { h } else { 0.0 }; - let rz = state.position.z().value() + if i == 2 { h } else { 0.0 }; - let s = OrbitState::new(state.epoch, Position::new(rx, ry, rz), state.velocity); - let p1 = model.predict(&s, &extra); - let fd = (p1.value - p0.value) / h; - let analytic = p0.partials.entries.iter().find(|(j, _)| *j == i).unwrap().1; - assert!( - (fd - analytic).abs() / analytic.abs() < 1e-3, - "partial[{i}] fd={fd} analytic={analytic}" - ); - } - } -} diff --git a/src/observations/gnss_obs.rs b/src/observations/gnss_obs.rs deleted file mode 100644 index 6d11aef..0000000 --- a/src/observations/gnss_obs.rs +++ /dev/null @@ -1,697 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! GNSS pseudorange and carrier-phase observation models. -//! -//! ## Scientific scope -//! -//! ### Pseudorange -//! `P = ρ + c·(dt_r − dt_s) + T + I + ε` -//! -//! | Term | Source | -//! |------|--------| -//! | ρ | Geometric range via typed `affn` position difference | -//! | c·dt_r | Receiver clock bias from [`ProviderBundle`] | -//! | c·dt_s | Satellite clock bias from [`ProviderBundle`] | -//! | T | Saastamoinen tropospheric model (dry + wet, elevation-dependent) | -//! | I | Klobuchar or iono-free combination | -//! | Sagnac | Earth-rotation correction (LEO/MEO regime) | -//! | Rel | Relativistic GPS clock term (−2·r·v/c) | -//! -//! ### Carrier phase -//! `L = ρ + c·(dt_r − dt_s) + T − I + λ·N + ε` -//! -//! Same geometric and atmospheric models as pseudorange; ionosphere enters -//! with the opposite sign. The integer ambiguity `N` is stored as `i32` -//! and resolved externally. -//! -//! ## References -//! -//! - Misra, P., & Enge, P. (2012). *Global Positioning System: Signals, -//! Measurements, and Performance* (2nd ed.). Ganga-Jamuna Press. -//! - Saastamoinen, J. (1972). Atmospheric correction for the troposphere and -//! stratosphere in radio ranging satellites. -//! *Geophysical Monograph Series*, 15, 247–251. -//! - Klobuchar, J. A. (1987). Ionospheric time-delay algorithm for -//! single-frequency GPS users. -//! *IEEE Transactions on Aerospace and Electronic Systems*, 23(3), 325–331. - -use siderust::astro::dynamics::forces::OMEGA_EARTH_RAD_S; -use siderust::astro::dynamics::state::VelocityUnit; -use siderust::astro::dynamics::{Position, Velocity}; -use siderust::coordinates::frames::GCRS; -use siderust::time::JulianDate; - -use siderust::qtty::unit::{Kilometer, Second}; -use siderust::qtty::velocity::C; -use siderust::qtty::Per; - -use super::error::PodObservationsError; -use super::obs_trait::{CartesianState, ObsType, Observation, PhaseResidual}; -use crate::observations::provider_bundle::ProviderBundle; - -// ─── Speed of light ────────────────────────────────────────────────────────── - -/// Speed of light in m/s (exact SI definition). -const C_M_S: f64 = 299_792_458.0; - -// ─── Ionosphere model ──────────────────────────────────────────────────────── - -/// Parameters for the Klobuchar single-frequency ionospheric model. -/// -/// Coefficients are broadcast in the GPS navigation message. -/// Receiver position and geometry must be supplied for each epoch. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::gnss_obs::KlobucharParams; -/// -/// let p = KlobucharParams::gps_default(); -/// assert_eq!(p.alpha[0], 2.0e-8); -/// ``` -#[derive(Debug, Clone, Copy)] -pub struct KlobucharParams { - /// GPS α coefficients [s, s/sc, s/sc², s/sc³]. - pub alpha: [f64; 4], - /// GPS β coefficients [s, s/sc, s/sc², s/sc³]. - pub beta: [f64; 4], - /// Receiver geodetic latitude in semi-circles (−0.5 to +0.5). - pub receiver_lat_sc: f64, - /// Receiver longitude in semi-circles (0 to 2). - pub receiver_lon_sc: f64, - /// Elevation angle to satellite in semi-circles (0 to 0.5). - pub elevation_sc: f64, - /// Azimuth angle from receiver to satellite in semi-circles (0 to 2). - pub azimuth_sc: f64, - /// UTC time of day in seconds. - pub utc_seconds: f64, -} - -impl KlobucharParams { - /// GPS default coefficients (zero-deviation ionosphere). - pub fn gps_default() -> Self { - Self { - alpha: [2.0e-8, 1.490e-8, -5.960e-8, -5.960e-8], - beta: [90_112.0, 65_536.0, -131_072.0, -131_072.0], - receiver_lat_sc: 0.0, - receiver_lon_sc: 0.0, - elevation_sc: 0.25, - azimuth_sc: 0.0, - utc_seconds: 0.0, - } - } -} - -/// Ionospheric delay model selection. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::gnss_obs::{IonoModel, KlobucharParams}; -/// -/// let _iono_free = IonoModel::IonoFree; -/// let _klob = IonoModel::Klobuchar(KlobucharParams::gps_default()); -/// let _none = IonoModel::None; -/// ``` -#[derive(Debug, Clone)] -pub enum IonoModel { - /// Iono-free linear combination already applied to the observable; `I = 0`. - IonoFree, - /// Klobuchar broadcast model (single-frequency users). - Klobuchar(KlobucharParams), - /// No ionospheric correction (single-frequency, no model available). - None, -} - -/// Tropospheric delay model selection. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::gnss_obs::TropModel; -/// -/// let _saas = TropModel::Saastamoinen { elevation_rad: std::f64::consts::FRAC_PI_2 }; -/// let _none = TropModel::None; -/// ``` -#[derive(Debug, Clone, Copy)] -pub enum TropModel { - /// Saastamoinen standard-atmosphere model. The elevation angle (rad) is - /// the angle from the local horizontal to the satellite. - Saastamoinen { - /// Elevation angle in radians (0 = horizontal, π/2 = zenith). - elevation_rad: f64, - }, - /// No tropospheric correction (applicable for LEO receivers above the - /// troposphere or when a pre-corrected observable is supplied). - None, -} - -// ─── Atmosphere models ─────────────────────────────────────────────────────── - -/// Saastamoinen tropospheric delay (dry + wet) in metres. -/// -/// Uses standard-atmosphere values: P = 1013.25 hPa, T = 288.15 K, -/// e = 11.691 hPa (≈ 50 % relative humidity at sea level). -/// -/// Reference: Saastamoinen (1972). -fn saastamoinen_m(elevation_rad: f64) -> f64 { - let sin_e = elevation_rad.sin().max(0.07); // clamp to avoid blow-up below ~4° - // Dry: 0.002277 * P / sin(E) - let dry = 0.002_277 * 1013.25 / sin_e; - // Wet: 0.002277 * (1255/T + 0.05) * e / sin(E) - let wet = 0.002_277 * (1255.0 / 288.15 + 0.05) * 11.691 / sin_e; - dry + wet -} - -/// Klobuchar L1 ionospheric delay in metres. -/// -/// Reference: Klobuchar (1987), IS-GPS-200 section 20.3.3.5. -fn klobuchar_m(p: &KlobucharParams) -> f64 { - let e = p.elevation_sc; // elevation in semi-circles - - // Earth central angle (semi-circles) - let psi = 0.0137 / (e + 0.11) - 0.022; - - // Sub-ionospheric latitude (semi-circles), clamped to ±0.416 sc - let phi_i = (p.receiver_lat_sc + psi * (p.azimuth_sc * std::f64::consts::PI).cos()) - .clamp(-0.416, 0.416); - - // Sub-ionospheric longitude (semi-circles) - let cos_phi = (phi_i * std::f64::consts::PI).cos(); - let lambda_i = if cos_phi.abs() > 1e-9 { - p.receiver_lon_sc + psi * (p.azimuth_sc * std::f64::consts::PI).sin() / cos_phi - } else { - p.receiver_lon_sc - }; - - // Geomagnetic latitude (semi-circles) - let phi_m = phi_i + 0.064 * ((lambda_i - 1.617) * std::f64::consts::PI).cos(); - - // Local time (seconds) - let mut t = 4.32e4 * lambda_i + p.utc_seconds; - t -= (t / 86400.0).floor() * 86400.0; // wrap to [0, 86400) - - // Period (seconds) - let period_raw: f64 = p.beta[0] - + p.beta[1] * phi_m - + p.beta[2] * phi_m * phi_m - + p.beta[3] * phi_m * phi_m * phi_m; - let period = period_raw.max(72_000.0); - - // Amplitude (seconds) - let amp_raw: f64 = p.alpha[0] - + p.alpha[1] * phi_m - + p.alpha[2] * phi_m * phi_m - + p.alpha[3] * phi_m * phi_m * phi_m; - let amp = amp_raw.max(0.0); - - // Obliquity factor - let obliquity = 1.0 + 16.0 * (0.53 - e).powi(3); - - // Phase argument - let x = 2.0 * std::f64::consts::PI * (t - 50_400.0) / period; - - // Vertical delay (seconds) - let i_v = if x.abs() < std::f64::consts::FRAC_PI_2 { - 5.0e-9 + amp * x.cos() - } else { - 5.0e-9 - }; - - // Slant delay (metres) - obliquity * i_v * C_M_S -} - -// ─── Internal range helpers ────────────────────────────────────────────────── - -/// Sagnac (Earth-rotation) range correction in km. -fn sagnac_km(gps_pos_km: Position, rx_pos_km: Position) -> f64 { - let c_km_s = C.to::>().value(); - OMEGA_EARTH_RAD_S.value() - * (gps_pos_km.x().value() * rx_pos_km.y().value() - - gps_pos_km.y().value() * rx_pos_km.x().value()) - / c_km_s -} - -/// Relativistic GPS satellite clock correction in metres. -fn relativistic_gps_clock_m(pos_km: Position, vel_km_s: Velocity) -> f64 { - let dot = pos_km.x().value() * vel_km_s.x().value() - + pos_km.y().value() * vel_km_s.y().value() - + pos_km.z().value() * vel_km_s.z().value(); - -2.0 * dot * 1_000.0 / C.value() -} - -/// Compute geometric range (m) and receiver-to-satellite unit vector. -fn geometric_range_m(state: &CartesianState, sat_pos_km: Position) -> (f64, [f64; 3]) { - let los = state.position - sat_pos_km; - let rho_km = los.magnitude().value(); - let rho_m = rho_km * 1_000.0; - let hat = if rho_km > 0.0 { - [ - los.x().value() / rho_km, - los.y().value() / rho_km, - los.z().value() / rho_km, - ] - } else { - [0.0; 3] - }; - (rho_m, hat) -} - -// ─── GnssPseudorangeObs ────────────────────────────────────────────────────── - -/// GNSS pseudorange observation between a spacecraft receiver and one GNSS -/// transmitter. -/// -/// The measurement equation is: -/// ```text -/// P = ρ + c·(dt_r − dt_s) + T + I + Sagnac + Rel + ε -/// ``` -/// -/// `modeled_value` returns `measured_m − modelled_m` (O−C residual, metres). -/// -/// # Examples -/// -/// ``` -/// use spod::observations::gnss_obs::{GnssPseudorangeObs, IonoModel, TropModel}; -/// use spod::observations::obs_trait::{CartesianState, Observation}; -/// use spod::observations::provider_bundle::NullProviderBundle; -/// use siderust::astro::dynamics::{Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// -/// let epoch = JulianDate::new(2_451_545.0); -/// let state: CartesianState = CartesianState::new( -/// epoch.to_j2000s(), -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.5, 0.0), -/// ); -/// let sat_pos = [26_560.0, 0.0, 0.0_f64]; -/// let sat_vel = [0.0, 3.87, 0.0_f64]; -/// -/// // Build a pseudorange obs with known geometry -/// let obs = GnssPseudorangeObs { -/// prn: "G01".to_string(), -/// epoch, -/// measured_m: 19_560_000.0, -/// sigma: 1.0, -/// sat_pos_gcrs_km: sat_pos, -/// sat_vel_gcrs_km_s: sat_vel, -/// trop: TropModel::None, -/// iono: IonoModel::IonoFree, -/// frequency_hz: 1_575_420_000.0, -/// }; -/// let residual = obs.modeled_value(&state, &NullProviderBundle).unwrap(); -/// // Residual should be small (depends on exact geometry + Sagnac + Rel) -/// assert!(residual.abs() < 1.0); -/// ``` -#[derive(Debug, Clone)] -pub struct GnssPseudorangeObs { - /// GNSS satellite PRN identifier (e.g. `"G01"`). - pub prn: String, - /// Observation epoch (TT Julian date). - pub epoch: JulianDate, - /// Measured pseudorange (metres). - pub measured_m: f64, - /// Assumed measurement standard deviation (metres). - pub sigma: f64, - /// GNSS satellite GCRS position at signal-emission epoch (km). - pub sat_pos_gcrs_km: [f64; 3], - /// GNSS satellite GCRS velocity at signal-emission epoch (km/s). - pub sat_vel_gcrs_km_s: [f64; 3], - /// Tropospheric delay model. - pub trop: TropModel, - /// Ionospheric delay model. - pub iono: IonoModel, - /// Signal carrier frequency in Hz (used when Klobuchar I ≠ zero). - pub frequency_hz: f64, -} - -impl Observation for GnssPseudorangeObs { - type Residual = f64; - - fn modeled_value( - &self, - state: &CartesianState, - providers: &dyn ProviderBundle, - ) -> Result { - let sat_pos = Position::::new( - self.sat_pos_gcrs_km[0], - self.sat_pos_gcrs_km[1], - self.sat_pos_gcrs_km[2], - ); - let sat_vel = Velocity::::new( - self.sat_vel_gcrs_km_s[0], - self.sat_vel_gcrs_km_s[1], - self.sat_vel_gcrs_km_s[2], - ); - - let (rho_m, _) = geometric_range_m(state, sat_pos); - let sagnac_m = sagnac_km(sat_pos, state.position) * 1_000.0; - let rel_m = relativistic_gps_clock_m(sat_pos, sat_vel); - - let trop_m = match self.trop { - TropModel::None => 0.0, - TropModel::Saastamoinen { elevation_rad } => saastamoinen_m(elevation_rad), - }; - - let iono_m = match &self.iono { - IonoModel::IonoFree => 0.0, - IonoModel::None => 0.0, - IonoModel::Klobuchar(p) => { - let iono_l1 = klobuchar_m(p); - // Scale to other frequencies: I ∝ 1/f² - let f_l1_hz = 1_575_420_000.0_f64; - if self.frequency_hz > 0.0 && (self.frequency_hz - f_l1_hz).abs() > 1e6 { - iono_l1 * (f_l1_hz / self.frequency_hz).powi(2) - } else { - iono_l1 - } - } - }; - - let epoch_jd = self.epoch.value(); - let dt_r = providers.receiver_clock_m(); - let dt_s = providers.gnss_satellite_clock_m(&self.prn, epoch_jd); - - let modelled = rho_m + dt_r - dt_s + trop_m + iono_m + sagnac_m + rel_m; - Ok(self.measured_m - modelled) - } - - fn obs_type(&self) -> ObsType { - ObsType::GnssPseudorange - } - - fn epoch(&self) -> JulianDate { - self.epoch - } - - fn sigma(&self) -> f64 { - self.sigma - } -} - -// ─── GnssCarrierPhaseObs ───────────────────────────────────────────────────── - -/// GNSS carrier-phase observation between a spacecraft receiver and one GNSS -/// transmitter. -/// -/// The measurement equation is: -/// ```text -/// L = ρ + c·(dt_r − dt_s) + T − I + λ·N + ε -/// ``` -/// -/// `modeled_value` returns a [`PhaseResidual`] carrying the O−C in metres and -/// in carrier cycles. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::gnss_obs::{GnssCarrierPhaseObs, IonoModel, TropModel}; -/// use spod::observations::obs_trait::{CartesianState, Observation}; -/// use spod::observations::provider_bundle::NullProviderBundle; -/// use siderust::astro::dynamics::{Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// -/// let epoch = JulianDate::new(2_451_545.0); -/// let state: CartesianState = CartesianState::new( -/// epoch.to_j2000s(), -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.5, 0.0), -/// ); -/// -/// let obs = GnssCarrierPhaseObs { -/// prn: "G01".to_string(), -/// epoch, -/// measured_m: 19_560_000.0, -/// sigma: 0.003, -/// sat_pos_gcrs_km: [26_560.0, 0.0, 0.0], -/// sat_vel_gcrs_km_s: [0.0, 3.87, 0.0], -/// trop: TropModel::None, -/// iono: IonoModel::IonoFree, -/// frequency_hz: 1_575_420_000.0, -/// integer_ambiguity: 0, -/// }; -/// let r = obs.modeled_value(&state, &NullProviderBundle).unwrap(); -/// assert!(r.residual_m.abs() < 1.0); -/// ``` -#[derive(Debug, Clone)] -pub struct GnssCarrierPhaseObs { - /// GNSS satellite PRN identifier. - pub prn: String, - /// Observation epoch (TT Julian date). - pub epoch: JulianDate, - /// Measured carrier-phase range (metres, scaled by wavelength externally). - pub measured_m: f64, - /// Assumed measurement standard deviation (metres; typically ≈ λ/100). - pub sigma: f64, - /// GNSS satellite GCRS position at signal-emission epoch (km). - pub sat_pos_gcrs_km: [f64; 3], - /// GNSS satellite GCRS velocity at signal-emission epoch (km/s). - pub sat_vel_gcrs_km_s: [f64; 3], - /// Tropospheric delay model. - pub trop: TropModel, - /// Ionospheric delay model (enters with opposite sign vs pseudorange). - pub iono: IonoModel, - /// Signal carrier frequency in Hz (used to compute wavelength λ = c/f). - pub frequency_hz: f64, - /// Integer phase ambiguity N (resolved externally; set to 0 for float). - pub integer_ambiguity: i32, -} - -impl Observation for GnssCarrierPhaseObs { - type Residual = PhaseResidual; - - fn modeled_value( - &self, - state: &CartesianState, - providers: &dyn ProviderBundle, - ) -> Result { - let sat_pos = Position::::new( - self.sat_pos_gcrs_km[0], - self.sat_pos_gcrs_km[1], - self.sat_pos_gcrs_km[2], - ); - let sat_vel = Velocity::::new( - self.sat_vel_gcrs_km_s[0], - self.sat_vel_gcrs_km_s[1], - self.sat_vel_gcrs_km_s[2], - ); - - let (rho_m, _) = geometric_range_m(state, sat_pos); - let sagnac_m = sagnac_km(sat_pos, state.position) * 1_000.0; - let rel_m = relativistic_gps_clock_m(sat_pos, sat_vel); - - let trop_m = match self.trop { - TropModel::None => 0.0, - TropModel::Saastamoinen { elevation_rad } => saastamoinen_m(elevation_rad), - }; - - let iono_m = match &self.iono { - IonoModel::IonoFree => 0.0, - IonoModel::None => 0.0, - IonoModel::Klobuchar(p) => { - let iono_l1 = klobuchar_m(p); - let f_l1_hz = 1_575_420_000.0_f64; - if self.frequency_hz > 0.0 && (self.frequency_hz - f_l1_hz).abs() > 1e6 { - iono_l1 * (f_l1_hz / self.frequency_hz).powi(2) - } else { - iono_l1 - } - } - }; - - let epoch_jd = self.epoch.value(); - let dt_r = providers.receiver_clock_m(); - let dt_s = providers.gnss_satellite_clock_m(&self.prn, epoch_jd); - - let wavelength_m = if self.frequency_hz > 0.0 { - C_M_S / self.frequency_hz - } else { - 0.1903 // GPS L1 ≈ 19.03 cm - }; - let ambiguity_m = self.integer_ambiguity as f64 * wavelength_m; - - // Phase: L = ρ + c·(dt_r - dt_s) + T - I + λN - let modelled = rho_m + dt_r - dt_s + trop_m - iono_m + sagnac_m + rel_m + ambiguity_m; - let residual_m = self.measured_m - modelled; - - Ok(PhaseResidual { - residual_m, - cycles: residual_m / wavelength_m, - }) - } - - fn obs_type(&self) -> ObsType { - ObsType::GnssCarrierPhase - } - - fn epoch(&self) -> JulianDate { - self.epoch - } - - fn sigma(&self) -> f64 { - self.sigma - } -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use crate::observations::provider_bundle::NullProviderBundle; - use approx::assert_abs_diff_eq; - use siderust::astro::dynamics::{Position, Velocity}; - - const EPOCH: fn() -> JulianDate = || JulianDate::new(2_451_545.0); - - fn leo_state() -> CartesianState { - CartesianState::new( - EPOCH().to_j2000s(), - Position::::new(7_000.0, 0.0, 0.0), - Velocity::::new(0.0, 7.5, 0.0), - ) - } - - fn gps_sat() -> ([f64; 3], [f64; 3]) { - ([26_560.0, 0.0, 0.0], [0.0, 3.87, 0.0]) - } - - #[test] - fn pseudorange_residual_near_zero_for_consistent_obs() { - let (sat_pos, sat_vel) = gps_sat(); - let state = leo_state(); - // Build an obs whose measured_m equals the computed modelled value. - let obs_probe = GnssPseudorangeObs { - prn: "G01".to_string(), - epoch: EPOCH(), - measured_m: 0.0, - sigma: 1.0, - sat_pos_gcrs_km: sat_pos, - sat_vel_gcrs_km_s: sat_vel, - trop: TropModel::None, - iono: IonoModel::IonoFree, - frequency_hz: 1_575_420_000.0, - }; - // First get the modelled value - let neg_residual = obs_probe - .modeled_value(&state, &NullProviderBundle) - .unwrap(); - // neg_residual = 0 - modelled → modelled = -neg_residual - let modelled = -neg_residual; - - let obs = GnssPseudorangeObs { - measured_m: modelled, - ..obs_probe - }; - let r = obs.modeled_value(&state, &NullProviderBundle).unwrap(); - assert_abs_diff_eq!(r, 0.0, epsilon = 1e-6); - } - - #[test] - fn pseudorange_positive_trop_increases_range() { - let (sat_pos, sat_vel) = gps_sat(); - let state = leo_state(); - let make = |trop| GnssPseudorangeObs { - prn: "G01".to_string(), - epoch: EPOCH(), - measured_m: 0.0, - sigma: 1.0, - sat_pos_gcrs_km: sat_pos, - sat_vel_gcrs_km_s: sat_vel, - trop, - iono: IonoModel::IonoFree, - frequency_hz: 1_575_420_000.0, - }; - let r_none = make(TropModel::None) - .modeled_value(&state, &NullProviderBundle) - .unwrap(); - let r_trop = make(TropModel::Saastamoinen { - elevation_rad: std::f64::consts::FRAC_PI_2, - }) - .modeled_value(&state, &NullProviderBundle) - .unwrap(); - // Troposphere adds to the range → modelled_trop > modelled_none → - // residual_trop (= 0 − modelled) is more negative than residual_none. - assert!(r_trop < r_none, "trop should increase modelled range"); - } - - #[test] - fn klobuchar_delay_positive() { - let delay = klobuchar_m(&KlobucharParams::gps_default()); - assert!(delay > 0.0 && delay < 100.0, "klobuchar delay={delay:.3}m"); - } - - #[test] - fn saastamoinen_zenith_approx_2p4m() { - let d = saastamoinen_m(std::f64::consts::FRAC_PI_2); - // Zenith delay ~2.4 m for standard atmosphere - assert!(d > 2.0 && d < 3.0, "zenith trop={d:.3}m"); - } - - #[test] - fn carrier_phase_iono_opposite_sign_to_pseudorange() { - let (sat_pos, sat_vel) = gps_sat(); - let state = leo_state(); - let klob = IonoModel::Klobuchar(KlobucharParams::gps_default()); - - let pr = GnssPseudorangeObs { - prn: "G01".to_string(), - epoch: EPOCH(), - measured_m: 0.0, - sigma: 1.0, - sat_pos_gcrs_km: sat_pos, - sat_vel_gcrs_km_s: sat_vel, - trop: TropModel::None, - iono: klob.clone(), - frequency_hz: 1_575_420_000.0, - }; - let cp = GnssCarrierPhaseObs { - prn: "G01".to_string(), - epoch: EPOCH(), - measured_m: 0.0, - sigma: 0.003, - sat_pos_gcrs_km: sat_pos, - sat_vel_gcrs_km_s: sat_vel, - trop: TropModel::None, - iono: klob, - frequency_hz: 1_575_420_000.0, - integer_ambiguity: 0, - }; - - let r_pr = pr.modeled_value(&state, &NullProviderBundle).unwrap(); - let r_cp = cp.modeled_value(&state, &NullProviderBundle).unwrap(); - - // Pseudorange: residual = 0 - (rho + I + ...) → negative - // Carrier: residual = 0 - (rho - I + ...) → less negative than pseudorange - // Thus r_cp > r_pr (less negative) - assert!( - r_cp.residual_m > r_pr, - "carrier more negative than expected" - ); - } - - #[test] - fn carrier_phase_residual_in_cycles_consistent() { - let (sat_pos, sat_vel) = gps_sat(); - let state = leo_state(); - let obs = GnssCarrierPhaseObs { - prn: "G01".to_string(), - epoch: EPOCH(), - measured_m: 0.0, - sigma: 0.003, - sat_pos_gcrs_km: sat_pos, - sat_vel_gcrs_km_s: sat_vel, - trop: TropModel::None, - iono: IonoModel::IonoFree, - frequency_hz: 1_575_420_000.0, - integer_ambiguity: 0, - }; - let r = obs.modeled_value(&state, &NullProviderBundle).unwrap(); - let wavelength = C_M_S / 1_575_420_000.0; - assert_abs_diff_eq!(r.cycles, r.residual_m / wavelength, epsilon = 1e-9); - } -} diff --git a/src/observations/inter_sat.rs b/src/observations/inter_sat.rs deleted file mode 100644 index b713151..0000000 --- a/src/observations/inter_sat.rs +++ /dev/null @@ -1,193 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Inter-satellite range observation model for the LISA mission. -//! -//! This module is compiled only when the `lisa` feature is enabled. -//! -//! ## Scientific scope -//! -//! LISA measures the distance between pairs of spacecraft using laser -//! interferometry. The measurement equation for one range observable is: -//! -//! ```text -//! ρ_meas = |r_A(t_recv) − r_B(t_emit)| + ε -//! ``` -//! -//! where `t_emit = t_recv − ρ/c` (one-way light-time, one Newton iteration). -//! No atmospheric corrections are applied (inter-satellite / deep space). -//! -//! Positions are provided by `LisaEphemerisProvider` via cubic Hermite -//! interpolation over ESA OEM ephemeris files. -//! -//! ## References -//! -//! - Danzmann, K. et al. (2017). *LISA: Laser Interferometer Space Antenna.* -//! ESA/SRE(2017)1. - -#[cfg(feature = "lisa")] -mod inner { - use crate::core::providers::EphemerisProvider; - use crate::io::lisa::{LisaEphemerisProvider, LisaSpacecraftId}; - use crate::observations::error::PodObservationsError; - use crate::observations::obs_trait::{CartesianState, ObsType, Observation}; - use crate::observations::provider_bundle::ProviderBundle; - use siderust::time::JulianDate; - use std::sync::Arc; - - /// Speed of light in km/s. - const C_KM_S: f64 = 299_792.458; - - /// J2000.0 Julian date (TT ≈ TDB at the sub-ms level for light-time). - const JD_J2000: f64 = 2_451_545.0; - - // Convert TT Julian date to approximate TDB seconds since J2000.0. - fn jd_to_j2000_s(jd: JulianDate) -> f64 { - (jd.value() - JD_J2000) * 86_400.0 - } - - /// Inter-satellite range observation between two LISA spacecraft. - /// - /// Positions are queried from [`LisaEphemerisProvider`] at the appropriate - /// light-time-corrected epochs. One Newton iteration is applied: - /// `t_emit = t_recv − ρ₀/c`. - /// - /// `modeled_value` returns `measured_m − modelled_m` (O−C residual, metres). - /// - /// # Examples - /// - /// ```no_run - /// use spod::observations::inter_sat::InterSatRangeObs; - /// use spod::observations::obs_trait::{CartesianState, Observation}; - /// use spod::observations::provider_bundle::NullProviderBundle; - /// use spod::io::lisa::{LisaEphemerisProvider, LisaOrbitReader, LisaOrbitSet, LisaSpacecraftId}; - /// use siderust::astro::dynamics::{Position, Velocity}; - /// use siderust::coordinates::frames::GCRS; - /// use siderust::time::JulianDate; - /// use std::sync::Arc; - /// - /// // In a real scenario, provider is built from OEM files. - /// # fn doc_only() -> Result<(), Box> { - /// let root = concat!(env!("CARGO_MANIFEST_DIR"), "/src/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 = Arc::new(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), - /// })); - /// - /// let epoch = JulianDate::new(2_451_545.0 + 1_139_702_400.0 / 86_400.0); - /// let state: CartesianState = CartesianState::new( - /// epoch.to_j2000s(), - /// Position::::new(0.0, 0.0, 0.0), - /// Velocity::::new(0.0, 0.0, 0.0), - /// ); - /// - /// let obs = InterSatRangeObs { - /// sc_a: LisaSpacecraftId::SC1, - /// sc_b: LisaSpacecraftId::SC2, - /// epoch, - /// measured_m: 2.5e9, - /// sigma: 1e-9, - /// provider, - /// }; - /// let residual = obs.modeled_value(&state, &NullProviderBundle)?; - /// # Ok(()) - /// # } - /// ``` - #[derive(Clone)] - pub struct InterSatRangeObs { - /// Receiving spacecraft (state at `epoch`). - pub sc_a: LisaSpacecraftId, - /// Transmitting spacecraft (state at `epoch − ρ/c`). - pub sc_b: LisaSpacecraftId, - /// Observation epoch (TT Julian date, ≈ TDB for light-time purposes). - pub epoch: JulianDate, - /// Measured one-way range (metres). - pub measured_m: f64, - /// Assumed measurement standard deviation (metres; ≈ pm-level for LISA). - pub sigma: f64, - /// Shared LISA ephemeris provider. - pub provider: Arc, - } - - impl std::fmt::Debug for InterSatRangeObs { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("InterSatRangeObs") - .field("sc_a", &self.sc_a) - .field("sc_b", &self.sc_b) - .field("epoch_jd", &self.epoch.value()) - .field("measured_m", &self.measured_m) - .field("sigma", &self.sigma) - .finish() - } - } - - impl Observation for InterSatRangeObs { - type Residual = f64; - - fn modeled_value( - &self, - _state: &CartesianState, - _providers: &dyn ProviderBundle, - ) -> Result { - let t_recv = jd_to_j2000_s(self.epoch); - let naif_a = self.sc_a.naif_id(); - let naif_b = self.sc_b.naif_id(); - - // State of receiver spacecraft (SC_A) at reception epoch - let pt_a = self - .provider - .state(naif_a, t_recv) - .map_err(|e| PodObservationsError::LisaProvider(e.to_string()))?; - - // Initial one-way range estimate: use SC_B at the same epoch - let pt_b0 = self - .provider - .state(naif_b, t_recv) - .map_err(|e| PodObservationsError::LisaProvider(e.to_string()))?; - - let pos_a = pt_a.position; - let pos_b0 = pt_b0.position; - - let dx0 = pos_a.x().value() - pos_b0.x().value(); - let dy0 = pos_a.y().value() - pos_b0.y().value(); - let dz0 = pos_a.z().value() - pos_b0.z().value(); - let rho0_km = (dx0 * dx0 + dy0 * dy0 + dz0 * dz0).sqrt(); - - // One Newton light-time iteration - let t_emit = t_recv - rho0_km / C_KM_S; - let pt_b1 = self - .provider - .state(naif_b, t_emit) - .map_err(|e| PodObservationsError::LisaProvider(e.to_string()))?; - - let pos_b1 = pt_b1.position; - let dx1 = pos_a.x().value() - pos_b1.x().value(); - let dy1 = pos_a.y().value() - pos_b1.y().value(); - let dz1 = pos_a.z().value() - pos_b1.z().value(); - let rho_km = (dx1 * dx1 + dy1 * dy1 + dz1 * dz1).sqrt(); - let rho_m = rho_km * 1_000.0; - - Ok(self.measured_m - rho_m) - } - - fn obs_type(&self) -> ObsType { - ObsType::InterSatRange - } - - fn epoch(&self) -> JulianDate { - self.epoch - } - - fn sigma(&self) -> f64 { - self.sigma - } - } -} - -#[cfg(feature = "lisa")] -pub use inner::InterSatRangeObs; diff --git a/src/observations/mod.rs b/src/observations/mod.rs deleted file mode 100644 index ce9ac63..0000000 --- a/src/observations/mod.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! # spod observation models -//! -//! ## Scientific scope -//! -//! This crate houses the measurement equations that connect estimated -//! spacecraft states to tracked observables. The current scientific focus -//! covers: -//! -//! - **GNSS** pseudorange and carrier-phase (code + phase with Saastamoinen -//! troposphere and Klobuchar ionosphere). -//! - **SLR** normal-point two-way range (Marini-Murray troposphere, Shapiro -//! relativistic delay). -//! - **LISA** inter-satellite range (feature-gated; light-time-iterated range -//! from ESA OEM ephemerides). -//! - **Generic** typed `Observation` trait with object-safe batching, a -//! `ProviderBundle` abstraction for clock/atmosphere providers, and a -//! `CorrectionRegistry` pipeline (PCO, Shapiro, EarthTide). -//! -//! ## Technical scope -//! -//! The crate re-exports the generic `MeasurementModel` abstraction together -//! with GNSS and SLR observation/model structs and the frame-transform -//! provider trait. These APIs return scalar predictions plus partial -//! derivatives, ready for assembly by the estimation layer. -//! -//! Parsing of raw data files and orchestration of estimation loops are -//! outside this crate. -//! -//! ## Crate features -//! -//! | Feature | Adds | -//! |---------|------| -//! | `lisa` | `inter_sat::InterSatRangeObs`, requires `spod::io` | -//! -//! ## References -//! -//! - Misra, P., & Enge, P. (2012). *Global Positioning System: Signals, -//! Measurements, and Performance* (2nd ed.). Ganga-Jamuna Press. -//! - Tapley, B. D., Schutz, B. E., & Born, G. H. (2004). *Statistical Orbit -//! Determination*. Elsevier Academic Press. -#![forbid(unsafe_code)] -#![warn(missing_docs)] - -// ── New typed observation layer ─────────────────────────────────────────────── - -pub mod batch; -pub mod corrections; -pub mod error; -pub mod gnss_obs; -pub mod inter_sat; -pub mod obs_trait; -pub mod provider_bundle; -pub mod slr_obs; - -pub use batch::ObservationBatch; -pub use corrections::{ - Correction, CorrectionRegistry, EarthTideDisplacement, PhaseCenterOffset, ShapiroDelay, -}; -pub use error::PodObservationsError; -pub use gnss_obs::{ - GnssCarrierPhaseObs, GnssPseudorangeObs, IonoModel, KlobucharParams, TropModel, -}; -pub use obs_trait::{ - AnyObservation, CartesianState, ObsResidual, ObsType, Observation, PhaseResidual, -}; -pub use provider_bundle::{NullProviderBundle, ProviderBundle}; -pub use slr_obs::SlrNormalPointObs; - -#[cfg(feature = "lisa")] -pub use inter_sat::InterSatRangeObs; - -// ── Legacy MVP observation layer (keep unchanged) ───────────────────────────── - -pub mod frame_transform; -pub mod gnss; -pub mod model; -pub mod slr; - -pub use frame_transform::{FrameTransformError, FrameTransformProvider}; -pub use gnss::{CarrierPhaseObs, GnssCarrierModel, GnssCodeModel, PseudorangeObs}; -pub use model::{MeasurementModel, Partials, Prediction}; -pub use slr::{SlrRangeModel, SlrRangeObs}; diff --git a/src/observations/model.rs b/src/observations/model.rs deleted file mode 100644 index b2cd94d..0000000 --- a/src/observations/model.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! # Generic scalar measurement-model interface -//! -//! ## Scientific scope -//! -//! Orbit-determination measurements share a common algebraic pattern: -//! predict a scalar observable from the current state, then supply partial -//! derivatives with respect to solved-for parameters. This module captures -//! that shared structure without committing to a specific sensor type. -//! -//! The scientific validity is inherited from the concrete model -//! implementations. By design, this layer is about the estimation interface -//! rather than about any one measurement physics. -//! -//! ## Technical scope -//! -//! The public items are `Prediction`, `Partials`, and the -//! `MeasurementModel` trait. Implementors return a scalar predicted value -//! and sparse partial derivatives, which the estimation layer can assemble -//! into normal equations or sequential updates. -//! -//! This module deliberately avoids owning file formats, propagation, or -//! bias models so that GNSS, SLR, and future sensors can share the same -//! estimator seam. -//! -//! ## References -//! -//! - Misra, P., & Enge, P. (2012). Global Positioning System: Signals, -//! Measurements, and Performance (2nd ed.). Ganga-Jamuna Press. -//! - Tapley, B. D., Schutz, B. E., & Born, G. H. (2004). Statistical Orbit -//! Determination. Elsevier Academic Press. -use siderust::astro::dynamics::OrbitState; - -/// Predicted observation value with attached partial-derivative row. -#[derive(Debug, Clone)] -pub struct Prediction { - /// Predicted observable in the same units as the measurement. - pub value: f64, - /// Partial derivatives. - pub partials: Partials, -} - -/// Sparse partial-derivative row for one measurement. -/// -/// Indexing matches the parameter ordering chosen by the estimator. The -/// 6 leading entries correspond to the orbit state ([rx, ry, rz, vx, vy, vz]); -/// later entries correspond to additional parameters (clock bias, float -/// ambiguities, …) appended by the model. -#[derive(Debug, Clone, Default)] -pub struct Partials { - /// Sparse `(parameter_index, value)` pairs. - pub entries: Vec<(usize, f64)>, -} - -impl Partials { - /// Convenience constructor. - pub fn from_pairs(pairs: impl IntoIterator) -> Self { - Self { - entries: pairs.into_iter().collect(), - } - } -} - -/// A model that predicts one scalar measurement value. -pub trait MeasurementModel { - /// Predict the measurement value and partials at the given orbit state and - /// `extra_params` slice (which contains every non-state parameter the - /// estimator currently exposes, in the same order it appended them). - fn predict(&self, state: &OrbitState, extra_params: &[f64]) -> Prediction; - - /// Standard deviation of the assumed measurement noise (same units as - /// the observable). - fn sigma(&self) -> f64; -} diff --git a/src/observations/obs_trait.rs b/src/observations/obs_trait.rs deleted file mode 100644 index 635f381..0000000 --- a/src/observations/obs_trait.rs +++ /dev/null @@ -1,239 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Core observation trait, type aliases, and residual types. - -use siderust::astro::dynamics::OrbitState; -use siderust::coordinates::centers::Geocentric; -use siderust::coordinates::frames::GCRS; -use siderust::time::JulianDate; - -use super::error::PodObservationsError; -use super::provider_bundle::ProviderBundle; - -// ─── Type aliases ──────────────────────────────────────────────────────────── - -/// Geocentric inertial Cartesian orbit state, position + velocity in GCRS. -/// -/// Equivalent to `OrbitState` from `siderust`. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::obs_trait::CartesianState; -/// use siderust::astro::dynamics::{Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// -/// let state: CartesianState = CartesianState::new( -/// JulianDate::new(2_451_545.0).to_j2000s(), -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.5, 0.0), -/// ); -/// assert!((state.position.x().value() - 7_000.0).abs() < 1e-10); -/// ``` -pub type CartesianState = OrbitState; - -// ─── Sensor taxonomy ───────────────────────────────────────────────────────── - -/// Observable type tag. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::obs_trait::ObsType; -/// assert_ne!(ObsType::GnssPseudorange, ObsType::SlrNormalPoint); -/// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ObsType { - /// GNSS pseudorange (code-based one-way range, metres). - GnssPseudorange, - /// GNSS carrier-phase range (phase-based, metres + integer ambiguity). - GnssCarrierPhase, - /// SLR two-way normal-point range (metres). - SlrNormalPoint, - /// Inter-satellite range (metres), e.g. LISA arm lengths. - InterSatRange, - /// Doppler/range-rate (m/s). - Doppler, -} - -// ─── Residual types ────────────────────────────────────────────────────────── - -/// Carrier-phase residual carrying both the metric residual and the fractional -/// cycle count. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::obs_trait::PhaseResidual; -/// -/// let r = PhaseResidual { residual_m: 0.5, cycles: 0.5 / 0.1903 }; -/// assert!((r.residual_m).abs() < 1.0); -/// ``` -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct PhaseResidual { - /// Residual (observed − modelled) in metres. - pub residual_m: f64, - /// Residual expressed in carrier cycles (residual_m / wavelength_m). - pub cycles: f64, -} - -/// Type-erased residual for use in heterogeneous [`crate::observations::batch::ObservationBatch`] -/// collections. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::obs_trait::{ObsResidual, PhaseResidual}; -/// -/// let r = ObsResidual::Scalar(3.0); -/// let p = ObsResidual::Phase(PhaseResidual { residual_m: 0.1, cycles: 0.5 }); -/// assert!(matches!(r, ObsResidual::Scalar(_))); -/// assert!(matches!(p, ObsResidual::Phase(_))); -/// ``` -#[derive(Debug, Clone)] -pub enum ObsResidual { - /// Scalar residual (pseudorange, SLR, Doppler) in metres. - Scalar(f64), - /// Carrier-phase residual. - Phase(PhaseResidual), -} - -impl From for ObsResidual { - fn from(v: f64) -> Self { - ObsResidual::Scalar(v) - } -} - -impl From for ObsResidual { - fn from(p: PhaseResidual) -> Self { - ObsResidual::Phase(p) - } -} - -// ─── Observation trait ─────────────────────────────────────────────────────── - -/// Typed observation model that connects a tracked spacecraft state to one -/// observable. -/// -/// Implementors embed all measurement-specific data (observed value, noise -/// model, atmospheric parameters, satellite ephemeris, …) and expose a single -/// entry point that computes the O−C residual given the current estimated -/// state and an optional provider bundle for dynamic look-ups (clock biases, -/// station positions, …). -/// -/// # Examples -/// -/// ``` -/// use spod::observations::obs_trait::{CartesianState, Observation, ObsType}; -/// use spod::observations::provider_bundle::NullProviderBundle; -/// use spod::observations::PodObservationsError; -/// use siderust::astro::dynamics::{Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// -/// struct ConstantObs; -/// impl Observation for ConstantObs { -/// type Residual = f64; -/// fn modeled_value( -/// &self, -/// _state: &CartesianState, -/// _providers: &dyn spod::observations::provider_bundle::ProviderBundle, -/// ) -> Result { -/// Ok(0.0) -/// } -/// fn obs_type(&self) -> ObsType { ObsType::GnssPseudorange } -/// fn epoch(&self) -> JulianDate { JulianDate::new(2_451_545.0) } -/// fn sigma(&self) -> f64 { 1.0 } -/// } -/// let state: CartesianState = CartesianState::new( -/// JulianDate::new(2_451_545.0).to_j2000s(), -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.5, 0.0), -/// ); -/// let r = ConstantObs.modeled_value(&state, &NullProviderBundle).unwrap(); -/// assert_eq!(r, 0.0); -/// ``` -pub trait Observation: Send + Sync { - /// Residual type (e.g. `f64` for range, [`PhaseResidual`] for carrier-phase). - type Residual; - - /// Compute the O−C residual at the given estimated state. - /// - /// The `providers` argument is consulted for dynamic auxiliary data such as - /// satellite clock biases, station positions, and receiver clock biases. - fn modeled_value( - &self, - state: &CartesianState, - providers: &dyn ProviderBundle, - ) -> Result; - - /// Observable type (sensor taxonomy). - fn obs_type(&self) -> ObsType; - - /// Epoch at which the observation was collected (TT Julian date). - fn epoch(&self) -> JulianDate; - - /// Assumed measurement standard deviation (same units as the residual). - fn sigma(&self) -> f64; -} - -// ─── Object-safe erasure ───────────────────────────────────────────────────── - -/// Object-safe version of [`Observation`] used inside -/// [`crate::observations::batch::ObservationBatch`]. -/// -/// This trait is blanket-implemented for every `T: Observation` whose -/// `Residual` type can be converted into [`ObsResidual`]. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::obs_trait::{AnyObservation, ObsResidual}; -/// // See ObservationBatch for a usage example. -/// let _: &dyn AnyObservation; -/// ``` -pub trait AnyObservation: Send + Sync { - /// Compute the type-erased O−C residual. - fn any_modeled_value( - &self, - state: &CartesianState, - providers: &dyn ProviderBundle, - ) -> Result; - - /// Observable type. - fn obs_type(&self) -> ObsType; - - /// Epoch. - fn epoch(&self) -> JulianDate; - - /// Standard deviation. - fn sigma(&self) -> f64; -} - -impl AnyObservation for T -where - T: Observation + 'static, - ObsResidual: From, -{ - fn any_modeled_value( - &self, - state: &CartesianState, - providers: &dyn ProviderBundle, - ) -> Result { - self.modeled_value(state, providers).map(ObsResidual::from) - } - - fn obs_type(&self) -> ObsType { - Observation::obs_type(self) - } - - fn epoch(&self) -> JulianDate { - Observation::epoch(self) - } - - fn sigma(&self) -> f64 { - Observation::sigma(self) - } -} diff --git a/src/observations/provider_bundle.rs b/src/observations/provider_bundle.rs deleted file mode 100644 index b0389b2..0000000 --- a/src/observations/provider_bundle.rs +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! Provider bundle trait for dynamic data look-ups inside observation models. - -/// Bundled dynamic data sources consumed by observation model implementations. -/// -/// The trait is deliberately minimal and object-safe so that observation models -/// can receive it as `&dyn ProviderBundle`. Concrete implementations are -/// expected to wrap clock-product files, SP3 ephemeris readers, etc. -/// -/// Every method has a reasonable default (zero clock bias, `None` state) so -/// that models that do not need a particular service can use -/// [`NullProviderBundle`] or a partial implementation. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::provider_bundle::{ProviderBundle, NullProviderBundle}; -/// -/// fn check(pb: &dyn ProviderBundle) { -/// assert_eq!(pb.receiver_clock_m(), 0.0); -/// assert!(pb.gnss_satellite_state_gcrs("G01", 2_451_545.0).is_none()); -/// } -/// check(&NullProviderBundle); -/// ``` -pub trait ProviderBundle: Send + Sync { - /// GCRS position (km) and velocity (km/s) of a GNSS transmitter at the - /// given TT Julian date. Returns `None` if the satellite is unknown or - /// the epoch is outside the available data window. - fn gnss_satellite_state_gcrs(&self, prn: &str, epoch_jd: f64) -> Option<([f64; 3], [f64; 3])>; - - /// Satellite clock bias in metres (positive = satellite clock ahead of - /// GPS time). Returns `0.0` if no clock product is available. - fn gnss_satellite_clock_m(&self, prn: &str, epoch_jd: f64) -> f64; - - /// Receiver clock bias in metres. Returns `0.0` if not estimated. - fn receiver_clock_m(&self) -> f64; - - /// GCRS position (km) of a ground station at the given TT Julian date. - /// Used by SLR observation models. Returns `None` if the station is - /// unknown. - fn station_gcrs_km(&self, station_id: &str, epoch_jd: f64) -> Option<[f64; 3]>; -} - -// ─── Null implementation ───────────────────────────────────────────────────── - -/// A [`ProviderBundle`] that returns zero biases and no satellite/station -/// states. Useful for unit tests and scenarios where the geometric range -/// is known in advance. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::provider_bundle::{ProviderBundle, NullProviderBundle}; -/// -/// assert_eq!(NullProviderBundle.receiver_clock_m(), 0.0); -/// assert!(NullProviderBundle.gnss_satellite_state_gcrs("G05", 2_451_545.0).is_none()); -/// assert_eq!(NullProviderBundle.gnss_satellite_clock_m("G05", 2_451_545.0), 0.0); -/// assert!(NullProviderBundle.station_gcrs_km("7840", 2_451_545.0).is_none()); -/// ``` -pub struct NullProviderBundle; - -impl ProviderBundle for NullProviderBundle { - fn gnss_satellite_state_gcrs( - &self, - _prn: &str, - _epoch_jd: f64, - ) -> Option<([f64; 3], [f64; 3])> { - None - } - - fn gnss_satellite_clock_m(&self, _prn: &str, _epoch_jd: f64) -> f64 { - 0.0 - } - - fn receiver_clock_m(&self) -> f64 { - 0.0 - } - - fn station_gcrs_km(&self, _station_id: &str, _epoch_jd: f64) -> Option<[f64; 3]> { - None - } -} diff --git a/src/observations/slr.rs b/src/observations/slr.rs deleted file mode 100644 index 9b29cf9..0000000 --- a/src/observations/slr.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! # Satellite laser ranging observation model -//! -//! ## Scientific scope -//! -//! Satellite laser ranging measures a two-way light-time between a ground -//! station and a spacecraft retroreflector. This module provides a minimal -//! physically interpretable range model suitable for self-consistency -//! checks and early POD validation against synthetic or controlled CRD -//! data. -//! -//! The regime is intentionally limited: the model omits full atmospheric -//! delay, relativity, station eccentricity, and advanced timing -//! calibrations. It is therefore appropriate for integration tests and -//! architectural plumbing, not for millimetre-accurate operational SLR -//! analysis. -//! -//! ## Technical scope -//! -//! The public items are `SlrRangeObs` and `SlrRangeModel`. Given an -//! inertial spacecraft state, an Earth-fixed station location, and frame- -//! transform support, the model returns a scalar two-way range prediction -//! and the corresponding partials for the estimation layer. -//! -//! Station data ingestion, CRD parsing, and QC summarization are handled by -//! the IO and QC crates rather than here. -//! -//! ## References -//! -//! - Degnan, J. J. (1993). Millimeter accuracy satellite laser ranging: a -//! review. Contributions of Space Geodesy to Geodynamics: Technology, 25, -//! 133-162. -//! - Pearlman, M. R., Noll, C. E., et al. (2019). The ILRS: Current status -//! and future prospects. Journal of Geodesy, 93, 2161-2180. -use super::model::{MeasurementModel, Partials, Prediction}; -use siderust::affn::cartesian::Displacement; -use siderust::astro::dynamics::{OrbitState, Position}; -use siderust::coordinates::frames::GCRS; -use siderust::qtty::unit::{Kilometer, Second}; -use siderust::qtty::velocity::C; -use siderust::qtty::Per; -/// One SLR range observation (two-way time-of-flight converted to metres). -#[derive(Debug, Clone)] -pub struct SlrRangeObs { - /// Two-way range, metres. - pub range_m: f64, - /// Standard deviation of the observation, metres. - pub sigma_m: f64, -} - -/// SLR two-way range model. -#[derive(Debug, Clone)] -pub struct SlrRangeModel { - /// Station position in the same inertial frame as the satellite state, km. - pub station_inertial_km: Position, - /// Constant tropospheric range delay (metres, applied symmetrically). - pub trop_bias_m: f64, - /// Assumed measurement standard deviation (metres). - pub sigma_m: f64, - /// Index of an additive station-bias parameter inside `extra_params`, - /// or `None` if no station bias is being estimated. - pub station_bias_index: Option, -} - -impl SlrRangeModel { - /// New model with no bias estimation. - pub fn new(station_inertial_km: Position, trop_bias_m: f64, sigma_m: f64) -> Self { - Self { - station_inertial_km, - trop_bias_m, - sigma_m, - station_bias_index: None, - } - } -} - -impl MeasurementModel for SlrRangeModel { - fn predict(&self, state: &OrbitState, extra: &[f64]) -> Prediction { - let c_km_s = C.to::>().value(); - - let r_sat = state.position; - let vx = state.velocity.x().value(); - let vy = state.velocity.y().value(); - let vz = state.velocity.z().value(); - let r_sta = self.station_inertial_km; - - let mut down_dt = 0.0_f64; - for _ in 0..3 { - let r_bounce = r_sat - - Displacement::::new(vx * down_dt, vy * down_dt, vz * down_dt); - let d_km = (r_bounce - r_sta).magnitude().value(); - down_dt = d_km / c_km_s; - } - let r_bounce = - r_sat - Displacement::::new(vx * down_dt, vy * down_dt, vz * down_dt); - - let mut up_dt = 0.0_f64; - for _ in 0..3 { - let r_arrive = - r_bounce + Displacement::::new(vx * up_dt, vy * up_dt, vz * up_dt); - let d_km = (r_arrive - r_sta).magnitude().value(); - up_dt = d_km / c_km_s; - } - let r_arrive = - r_bounce + Displacement::::new(vx * up_dt, vy * up_dt, vz * up_dt); - - let down_vec = r_bounce - r_sta; - let up_vec = r_arrive - r_sta; - let down_km = down_vec.magnitude().value(); - let up_km = up_vec.magnitude().value(); - let mut value = (down_km + up_km) * 1000.0 + 2.0 * self.trop_bias_m; - - let mut entries = Vec::with_capacity(4); - let los_down = if down_km > 0.0 { - [ - down_vec.x().value() / down_km, - down_vec.y().value() / down_km, - down_vec.z().value() / down_km, - ] - } else { - [0.0; 3] - }; - let los_up = if up_km > 0.0 { - [ - up_vec.x().value() / up_km, - up_vec.y().value() / up_km, - up_vec.z().value() / up_km, - ] - } else { - [0.0; 3] - }; - for i in 0..3 { - // ∂range/∂r_sat — sum of downlink and uplink unit vectors, - // converted to per-km because state position is in km. - let g = (los_down[i] + los_up[i]) * 1000.0; - entries.push((i, g)); - } - - if let Some(idx) = self.station_bias_index { - let global = 6 + idx; - value += extra.get(idx).copied().unwrap_or(0.0); - entries.push((global, 1.0)); - } - - Prediction { - value, - partials: Partials { entries }, - } - } - - fn sigma(&self) -> f64 { - self.sigma_m - } -} - -#[cfg(test)] -mod tests { - use super::*; - use siderust::astro::dynamics::{Position, Velocity}; - use siderust::time::JulianDate; - - #[test] - fn predicts_two_way_range_at_rest() { - let s = OrbitState::new( - JulianDate::new(2_451_545.0).to_j2000s(), - Position::new(0.0, 0.0, 7378.137), - Velocity::new(0.0, 0.0, 0.0), - ); - let m = SlrRangeModel::new(Position::::new(0.0, 0.0, 6378.137), 0.0, 0.01); - let p = m.predict(&s, &[]); - assert!((p.value - 2_000_000.0).abs() < 1e-3); - assert_eq!(p.partials.entries.len(), 3); - } - - #[test] - fn partial_matches_finite_difference() { - let s = OrbitState::new( - JulianDate::new(2_451_545.0).to_j2000s(), - Position::new(7100.0, 200.0, 50.0), - Velocity::new(0.5, 7.5, 0.1), - ); - let m = SlrRangeModel::new(Position::::new(6378.0, 100.0, 0.0), 0.0, 0.01); - let pred = m.predict(&s, &[]); - let h = 1e-3; - for (axis, idx) in [(0, 0), (1, 1), (2, 2)] { - let mut up = s; - let mut dn = s; - let rx = s.position.x().value(); - let ry = s.position.y().value(); - let rz = s.position.z().value(); - match axis { - 0 => { - up.position = Position::::new(rx + h, ry, rz); - dn.position = Position::::new(rx - h, ry, rz); - } - 1 => { - up.position = Position::::new(rx, ry + h, rz); - dn.position = Position::::new(rx, ry - h, rz); - } - _ => { - up.position = Position::::new(rx, ry, rz + h); - dn.position = Position::::new(rx, ry, rz - h); - } - } - let fd = (m.predict(&up, &[]).value - m.predict(&dn, &[]).value) / (2.0 * h); - let analytic = pred - .partials - .entries - .iter() - .find(|(i, _)| *i == idx) - .unwrap() - .1; - assert!( - (fd - analytic).abs() < 1e-2, - "axis {axis}: fd={fd}, analytic={analytic}" - ); - } - } -} diff --git a/src/observations/slr_obs.rs b/src/observations/slr_obs.rs deleted file mode 100644 index d35a16d..0000000 --- a/src/observations/slr_obs.rs +++ /dev/null @@ -1,332 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! SLR (Satellite Laser Ranging) normal-point observation model. -//! -//! ## Scientific scope -//! -//! A normal point condenses a burst of individual SLR return pulses into a -//! single compressed range value. The two-way time-of-flight (corrected to -//! range in metres) is affected by: -//! -//! | Correction | Model | -//! |-----------|-------| -//! | Troposphere | Marini-Murray (1973) for visible-wavelength lasers | -//! | Relativistic | Schwarzschild (Shapiro) logarithmic delay | -//! | Center-of-mass | Applied externally before storing `measured_m` | -//! -//! ### Two-way range equation -//! ```text -//! P₂w = 2·ρ + ΔT_trop + ΔR_rel + ε -//! ``` -//! where ρ is the one-way geometric range at mid-epoch. -//! -//! ### Marini-Murray tropospheric model -//! ```text -//! ΔR_one_way = f(λ) · A / (sin E + B/(tan E + C)) -//! ``` -//! with `A = 0.002277·P`, `B = 0.0014`, `C = 0.045` and -//! `f(λ)` the wavelength-dependent factor. -//! -//! ### Light-time iteration -//! One Newton iteration is performed: -//! `t_emit = t_recv − ρ/c`, -//! where `ρ` is the one-way range. Satellite state at `t_emit` must be -//! supplied externally in `sat_pos_gcrs_km`. -//! -//! ## References -//! -//! - Marini, J. W., & Murray, C. W. (1973). *Correction of Laser Range -//! Tracking Data for Atmospheric Refraction at Elevations Above 10 Degrees.* -//! NASA Technical Memorandum X-70555. -//! - Petit, G., & Luzum, B. (Eds.) (2010). *IERS Conventions 2010.* -//! IERS Technical Note 36, Chapter 9. - -use siderust::astro::dynamics::state::VelocityUnit; -use siderust::astro::dynamics::{Position, Velocity}; -use siderust::coordinates::frames::GCRS; -use siderust::time::JulianDate; - -use super::error::PodObservationsError; -use super::obs_trait::{CartesianState, ObsType, Observation}; -use crate::observations::provider_bundle::ProviderBundle; - -/// Speed of light in m/s (exact SI). -const C_M_S: f64 = 299_792_458.0; - -/// GM for Earth (EGM96/IERS 2010, m³/s²). -const GM_M3_S2: f64 = 3.986_004_418e14; - -// ─── Marini-Murray troposphere ─────────────────────────────────────────────── - -/// Wavelength-dependent Marini-Murray factor `f(λ_μm)`. -/// -/// Valid for visible/NIR pulsed-laser wavelengths (0.355–1.064 μm). -fn marini_murray_f_lambda(lambda_um: f64) -> f64 { - 0.9650 + 0.0164 / (lambda_um * lambda_um) + 0.000_228 / (lambda_um.powi(4)) -} - -/// One-way Marini-Murray tropospheric delay in metres. -/// -/// Uses standard-atmosphere approximation: P = 1013.25 hPa, T = 15 °C. -/// -/// `elevation_rad` is the elevation angle of the satellite above the local -/// horizontal (radians). Clamped to ≥ 3° to avoid divergence. -fn marini_murray_one_way_m(elevation_rad: f64, lambda_um: f64) -> f64 { - let e = elevation_rad.max(3.0_f64.to_radians()); - let sin_e = e.sin(); - let tan_e = e.tan(); - let f_lambda = marini_murray_f_lambda(lambda_um); - // A = 0.002277 * P (P in hPa) - let a = 0.002_277 * 1013.25; - let b = 0.0014; - let c = 0.045; - f_lambda * a / (sin_e + b / (tan_e + c)) -} - -// ─── Relativistic (Schwarzschild/Shapiro) correction ──────────────────────── - -/// Two-way Schwarzschild (Shapiro) delay in metres for SLR. -/// -/// ```text -/// ΔR_rel = 2 · (2GM/c²) · ln((r_A + r_B + ρ) / (r_A + r_B − ρ)) -/// ``` -/// -/// Both `r_rx` and `r_sat` are geocentric distances in kilometres; `rho_km` -/// is the one-way geometric range in kilometres. -fn shapiro_slr_m(r_rx_km: f64, r_sat_km: f64, rho_km: f64) -> f64 { - let two_gm_c2 = 2.0 * GM_M3_S2 / (C_M_S * C_M_S); // metres - let numer = r_rx_km * 1_000.0 + r_sat_km * 1_000.0 + rho_km * 1_000.0; - let denom = r_rx_km * 1_000.0 + r_sat_km * 1_000.0 - rho_km * 1_000.0; - if denom > 1.0 { - 2.0 * two_gm_c2 * (numer / denom).ln() - } else { - 0.0 - } -} - -// ─── SlrNormalPointObs ─────────────────────────────────────────────────────── - -/// SLR normal-point observation: two-way range between a ground station and -/// an orbiting satellite. -/// -/// The measurement equation is: -/// ```text -/// P₂w = 2·ρ + ΔT_trop + ΔR_rel -/// ``` -/// -/// `modeled_value` returns `measured_m − modelled_m` (O−C, metres). -/// -/// ## Center-of-mass correction -/// -/// The center-of-mass correction (satellite-specific) should be applied -/// **before** storing `measured_m`. This struct does not apply it internally. -/// -/// # Examples -/// -/// ``` -/// use spod::observations::slr_obs::SlrNormalPointObs; -/// use spod::observations::obs_trait::{CartesianState, Observation}; -/// use spod::observations::provider_bundle::NullProviderBundle; -/// use siderust::astro::dynamics::{Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// -/// let epoch = JulianDate::new(2_451_545.0); -/// let state: CartesianState = CartesianState::new( -/// epoch.to_j2000s(), -/// Position::::new(7_000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.5, 0.0), -/// ); -/// -/// let obs = SlrNormalPointObs { -/// station_id: "7840".to_string(), -/// epoch, -/// measured_m: 0.0, -/// sigma: 0.02, -/// station_gcrs_km: [6_378.0, 0.0, 0.0], -/// sat_pos_gcrs_km: [7_000.0, 0.0, 0.0], -/// sat_vel_gcrs_km_s: [0.0, 7.5, 0.0], -/// elevation_rad: std::f64::consts::FRAC_PI_2, -/// wavelength_um: 0.532, -/// }; -/// let residual = obs.modeled_value(&state, &NullProviderBundle).unwrap(); -/// // Two-way range between station and satellite at 7000 km geocentric -/// // minus station at 6378 km → 622 km one-way → ~1 244 000 m two-way. -/// // Residual = 0 - modelled → should be around -1.24e6 m. -/// assert!(residual < 0.0); -/// ``` -#[derive(Debug, Clone)] -pub struct SlrNormalPointObs { - /// ILRS station identifier (e.g. `"7840"` for Herstmonceux). - pub station_id: String, - /// Normal-point epoch (TT Julian date, midpoint of the pass segment). - pub epoch: JulianDate, - /// Measured two-way range (metres, CoM-corrected). - pub measured_m: f64, - /// Assumed measurement standard deviation (metres; typically 5–20 mm). - pub sigma: f64, - /// Station GCRS position at epoch (km). - pub station_gcrs_km: [f64; 3], - /// Satellite GCRS position at signal-emission epoch (km). - /// Should be iterated for light-time externally; one additional - /// internal Newton step is applied. - pub sat_pos_gcrs_km: [f64; 3], - /// Satellite GCRS velocity at signal-emission epoch (km/s). - pub sat_vel_gcrs_km_s: [f64; 3], - /// Elevation angle from station to satellite (radians). - pub elevation_rad: f64, - /// Laser wavelength in micrometres (e.g. 0.532 for Nd:YAG green). - pub wavelength_um: f64, -} - -impl Observation for SlrNormalPointObs { - type Residual = f64; - - fn modeled_value( - &self, - _state: &CartesianState, - _providers: &dyn ProviderBundle, - ) -> Result { - // Station position - let r_st = Position::::new( - self.station_gcrs_km[0], - self.station_gcrs_km[1], - self.station_gcrs_km[2], - ); - // Satellite position (at emission epoch, pre-iterated) - let r_sat = Position::::new( - self.sat_pos_gcrs_km[0], - self.sat_pos_gcrs_km[1], - self.sat_pos_gcrs_km[2], - ); - let v_sat = Velocity::::new( - self.sat_vel_gcrs_km_s[0], - self.sat_vel_gcrs_km_s[1], - self.sat_vel_gcrs_km_s[2], - ); - - // One-way geometric range - let los = r_st - r_sat; - let rho_km = los.magnitude().value(); - - // One Newton light-time iteration: refine satellite position - let dt_s = rho_km / (C_M_S / 1_000.0); // seconds - let sat_pos_refined = Position::::new( - r_sat.x().value() - v_sat.x().value() * dt_s, - r_sat.y().value() - v_sat.y().value() * dt_s, - r_sat.z().value() - v_sat.z().value() * dt_s, - ); - let los2 = r_st - sat_pos_refined; - let rho_km = los2.magnitude().value(); - let rho_m = rho_km * 1_000.0; - - // Two-way geometric range - let two_way_m = 2.0 * rho_m; - - // Marini-Murray troposphere (two-way) - let trop_m = 2.0 * marini_murray_one_way_m(self.elevation_rad, self.wavelength_um); - - // Shapiro relativistic correction (two-way SLR) - let r_rx_km = { - let x = r_st.x().value(); - let y = r_st.y().value(); - let z = r_st.z().value(); - (x * x + y * y + z * z).sqrt() - }; - let r_sat_km = { - let x = sat_pos_refined.x().value(); - let y = sat_pos_refined.y().value(); - let z = sat_pos_refined.z().value(); - (x * x + y * y + z * z).sqrt() - }; - let shapiro_m = shapiro_slr_m(r_rx_km, r_sat_km, rho_km); - - let modelled = two_way_m + trop_m + shapiro_m; - Ok(self.measured_m - modelled) - } - - fn obs_type(&self) -> ObsType { - ObsType::SlrNormalPoint - } - - fn epoch(&self) -> JulianDate { - self.epoch - } - - fn sigma(&self) -> f64 { - self.sigma - } -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use crate::observations::provider_bundle::NullProviderBundle; - use approx::assert_abs_diff_eq; - use siderust::astro::dynamics::{Position, Velocity}; - - const EPOCH: fn() -> JulianDate = || JulianDate::new(2_451_545.0); - - fn make_state(pos_km: [f64; 3]) -> CartesianState { - CartesianState::new( - EPOCH().to_j2000s(), - Position::::new(pos_km[0], pos_km[1], pos_km[2]), - Velocity::::new(0.0, 7.5, 0.0), - ) - } - - #[test] - fn two_way_range_exact_for_consistent_obs() { - let sat_pos = [7_000.0, 0.0, 0.0_f64]; - let sta_pos = [6_378.0, 0.0, 0.0_f64]; - let state = make_state(sat_pos); - - // Probe: compute modelled value, then set measured = modelled - let obs_probe = SlrNormalPointObs { - station_id: "7840".to_string(), - epoch: EPOCH(), - measured_m: 0.0, - sigma: 0.02, - station_gcrs_km: sta_pos, - sat_pos_gcrs_km: sat_pos, - sat_vel_gcrs_km_s: [0.0, 7.5, 0.0], - elevation_rad: std::f64::consts::FRAC_PI_2, - wavelength_um: 0.532, - }; - let neg_res = obs_probe - .modeled_value(&state, &NullProviderBundle) - .unwrap(); - let modelled = -neg_res; - - let obs = SlrNormalPointObs { - measured_m: modelled, - ..obs_probe - }; - let r = obs.modeled_value(&state, &NullProviderBundle).unwrap(); - assert_abs_diff_eq!(r, 0.0, epsilon = 1e-6); - } - - #[test] - fn zenith_trop_delay_near_5m_two_way() { - let d = 2.0 * marini_murray_one_way_m(std::f64::consts::FRAC_PI_2, 0.532); - // Two-way zenith delay ≈ 4–6 m for standard atmosphere - assert!(d > 4.0 && d < 6.0, "two-way zenith trop = {d:.3} m"); - } - - #[test] - fn marini_murray_wavelength_factor_532nm() { - let f = marini_murray_f_lambda(0.532); - // Expected ≈ 1.026 - assert!(f > 1.02 && f < 1.04, "f(532 nm) = {f:.4}"); - } - - #[test] - fn shapiro_delay_positive_and_small() { - // LEO satellite at ~7000 km, station at ~6378 km, range ~622 km - let d = shapiro_slr_m(6_378.0, 7_000.0, 622.0); - assert!(d > 0.0 && d < 0.1, "Shapiro = {d:.6} m"); - } -} diff --git a/src/products/error.rs b/src/products/error.rs deleted file mode 100644 index 6c96ae0..0000000 --- a/src/products/error.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! # Product-crate error type -//! -//! ## Technical scope -//! -//! [`PodProductsError`] is the single unified error type returned by every -//! public writer in this crate. It wraps lower-layer errors from -//! `spod::io`, `serde_json`, and `std::io` under a typed enum so -//! callers can pattern-match without pulling in lower-layer error types -//! directly. -//! -//! ## References -//! -//! - Blandy, J., Orendorff, J., & Tindall, L. F. S. (2021). *Programming Rust* -//! (2nd ed.). O'Reilly Media. - -use thiserror::Error; - -/// Unified error returned by all writers in `spod::products`. -/// -/// # Examples -/// -/// ``` -/// use spod::products::PodProductsError; -/// -/// let err: PodProductsError = -/// std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe").into(); -/// assert!(format!("{err}").contains("I/O")); -/// ``` -#[derive(Debug, Error)] -pub enum PodProductsError { - /// Underlying I/O failure. - #[error("I/O error: {0}")] - Io(#[from] std::io::Error), - - /// SP3 serialisation error propagated from `spod::io`. - #[error("SP3 error: {0}")] - Sp3(#[from] crate::io::sp3::Sp3Error), - - /// Generic pod-io error propagated from `spod::io`. - #[error("pod-io error: {0}")] - PodIo(#[from] crate::io::PodIoError), - - /// JSON serialisation error. - #[error("JSON error: {0}")] - Json(#[from] serde_json::Error), - - /// Parquet serialisation error (only constructed when feature `parquet` is active). - #[error("Parquet error: {0}")] - Parquet(String), -} diff --git a/src/products/manifest.rs b/src/products/manifest.rs deleted file mode 100644 index 5bc3d3c..0000000 --- a/src/products/manifest.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! # Run manifest JSON writer -//! -//! ## Scientific scope -//! -//! Every POD run must be reproducible and auditable. The run manifest records -//! the complete provenance of a run — input files with content hashes, output -//! product paths, configuration hash, software version, and wall-clock -//! interval — so that any downstream consumer can verify exactly what was -//! computed and how. -//! -//! The scientific validity of outputs depends on correct provenance recording; -//! a manifest with wrong hashes cannot be distinguished from a forged result -//! at the file-system level. -//! -//! ## Technical scope -//! -//! [`ManifestWriter`] serialises a [`RunManifest`] to a deterministic, -//! pretty-printed JSON document and writes it to any [`std::io::Write`] sink. -//! "Deterministic" means that two calls with identical `RunManifest` values -//! produce byte-identical output regardless of the order in which `inputs` or -//! `outputs` were pushed onto the struct. -//! -//! The canonical order is obtained by calling -//! [`RunManifest::canonicalize`] before serialisation. -//! -//! ## References -//! -//! - Bray, T. (2017). The JavaScript Object Notation (JSON) Data Interchange -//! Format. RFC 8259. -//! - Fisher, M. (2020). Reproducibility in Data Science. O'Reilly Media. - -use super::error::PodProductsError; -use crate::core::manifest::RunManifest; -use std::io::Write; - -/// Writer that serialises a [`RunManifest`] to deterministic JSON. -/// -/// # Examples -/// -/// ``` -/// use spod::core::manifest::RunManifest; -/// use spod::products::manifest::ManifestWriter; -/// -/// let manifest = RunManifest { -/// run_id: "test-run".into(), -/// tool_version: "spod 0.0.0".into(), -/// config_sha256: "0".repeat(64), -/// inputs: vec![], -/// outputs: vec![], -/// started_at: "2026-01-01T00:00:00Z".into(), -/// finished_at: "2026-01-01T00:01:00Z".into(), -/// }; -/// let mut buf = Vec::new(); -/// ManifestWriter::write(&mut buf, &manifest).unwrap(); -/// let text = String::from_utf8(buf).unwrap(); -/// assert!(text.contains("run_id")); -/// assert!(text.contains("test-run")); -/// ``` -pub struct ManifestWriter; - -impl ManifestWriter { - /// Serialise `manifest` to pretty-printed JSON and write it to `w`. - /// - /// Inputs and outputs are sorted into canonical order before serialisation - /// so that the output is deterministic regardless of insertion order. - /// - /// The output is terminated with a single newline. - /// - /// # Errors - /// - /// Returns [`PodProductsError::Json`] if `serde_json` serialisation fails - /// (in practice this can only happen for non-finite floats in the struct, - /// which the `RunManifest` type does not contain). - /// Returns [`PodProductsError::Io`] if writing to `w` fails. - /// - /// # Examples - /// - /// ``` - /// use spod::core::manifest::RunManifest; - /// use spod::products::manifest::ManifestWriter; - /// - /// let m = RunManifest { - /// run_id: "00000000-0000-0000-0000-000000000000".into(), - /// tool_version: "spod 0.0.0".into(), - /// config_sha256: "a".repeat(64), - /// inputs: vec![], - /// outputs: vec![], - /// started_at: "2026-01-01T00:00:00Z".into(), - /// finished_at: "2026-01-01T00:00:01Z".into(), - /// }; - /// let mut buf = Vec::new(); - /// ManifestWriter::write(&mut buf, &m).unwrap(); - /// assert!(String::from_utf8(buf).unwrap().ends_with('\n')); - /// ``` - pub fn write(w: &mut W, manifest: &RunManifest) -> Result<(), PodProductsError> { - let json = manifest.to_json_pretty()?; - w.write_all(json.as_bytes())?; - w.write_all(b"\n")?; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample_manifest() -> RunManifest { - RunManifest { - run_id: "00000000-0000-0000-0000-000000000000".into(), - tool_version: "spod 0.0.0".into(), - config_sha256: "0".repeat(64), - inputs: vec![], - outputs: vec![], - started_at: "2026-01-01T00:00:00Z".into(), - finished_at: "2026-01-01T00:00:01Z".into(), - } - } - - #[test] - fn write_produces_valid_json() { - let mut buf = Vec::new(); - ManifestWriter::write(&mut buf, &sample_manifest()).unwrap(); - let text = String::from_utf8(buf).unwrap(); - let v: serde_json::Value = serde_json::from_str(&text).unwrap(); - assert_eq!( - v["run_id"].as_str().unwrap(), - "00000000-0000-0000-0000-000000000000" - ); - } - - #[test] - fn write_is_deterministic() { - let mut a = Vec::new(); - let mut b = Vec::new(); - ManifestWriter::write(&mut a, &sample_manifest()).unwrap(); - ManifestWriter::write(&mut b, &sample_manifest()).unwrap(); - assert_eq!(a, b); - } - - #[test] - fn write_ends_with_newline() { - let mut buf = Vec::new(); - ManifestWriter::write(&mut buf, &sample_manifest()).unwrap(); - assert!(buf.ends_with(b"\n")); - } - - #[test] - fn write_contains_all_required_fields() { - let mut buf = Vec::new(); - ManifestWriter::write(&mut buf, &sample_manifest()).unwrap(); - let text = String::from_utf8(buf).unwrap(); - for field in [ - "run_id", - "tool_version", - "config_sha256", - "inputs", - "outputs", - "started_at", - "finished_at", - ] { - assert!(text.contains(field), "missing field: {field}"); - } - } -} diff --git a/src/products/mod.rs b/src/products/mod.rs deleted file mode 100644 index d800993..0000000 --- a/src/products/mod.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! # spod product writers -//! -//! ## Scientific scope -//! -//! This crate turns in-memory estimation results into interchange and -//! reporting artifacts. The scientific content is inherited from the -//! upstream orbit solution and residual statistics; this layer focuses on -//! packaging those results into standard or workspace-defined product -//! shapes. -//! -//! Current outputs target deterministic MVP workflows: precise orbit -//! histories, CCSDS ephemerides, grouped QC JSON, tabular residual -//! exports, Parquet residual tables (feature-gated), and run manifests. -//! -//! ## Technical scope -//! -//! | Module | Surface | -//! |---|---| -//! | [`orbit`] | `write_sp3_from_states`, `write_oem_from_states`, `write_oem_from_spacecraft_states`, `Sp3ProductWriter`, `OemProductWriter` | -//! | [`naming`] | IGS Long File Name helpers: `igs_lfn`, `sp3_filename`, `sp3_lfn`, `sp3_short_filename`, `clk_filename`, … | -//! | [`residuals_csv`] | `ResidualRecord`, `ResidualCsvWriter` (streaming); `ResidualRow`, `write_residuals_csv` (batch legacy) | -//! | `residuals_parquet` | Siderust's `ResidualParquetWriter` (feature `parquet`) | -//! | [`manifest`] | `ManifestWriter` for [`RunManifest`][crate::core::manifest::RunManifest] | -//! | [`qc_json`] | `QcDocument`, `write_qc_json` | -//! | [`error`] | [`PodProductsError`] | -//! -//! No estimation, propagation, or measurement modelling is performed here. -//! -//! ## References -//! -//! - Consultative Committee for Space Data Systems. (2010). Orbit Data -//! Messages, CCSDS 502.0-B-2 / 502.0-B-3. -//! - International GNSS Service. (2020). SP3-c / SP3-d Orbit Format -//! Specification. -#![forbid(unsafe_code)] -#![warn(missing_docs)] - -pub mod error; -pub mod manifest; -pub mod naming; -pub mod orbit; -pub mod qc_json; -pub mod residuals_csv; -#[cfg(feature = "parquet")] -pub mod residuals_parquet; - -pub use error::PodProductsError; -pub use manifest::ManifestWriter; -pub use orbit::{ - write_oem_from_spacecraft_states, write_oem_from_states, write_sp3_from_states, - OemProductWriter, Sp3ProductWriter, -}; -pub use qc_json::write_qc_json; -pub use residuals_csv::{write_residuals_csv, ResidualCsvWriter, ResidualRecord, ResidualRow}; diff --git a/src/products/naming.rs b/src/products/naming.rs deleted file mode 100644 index 8529660..0000000 --- a/src/products/naming.rs +++ /dev/null @@ -1,464 +0,0 @@ -//! # IGS Long File Name (LFN) helpers -//! -//! ## Scientific scope -//! -//! The International GNSS Service defines a Long File Name convention that -//! encodes analysis centre, solution type, time span, and sample rate into -//! a single deterministic filename. Correct naming is essential for IGS -//! product archives, downstream automation, and multi-agency product -//! comparisons. -//! -//! This module provides helpers for SP3, RINEX observation, and clock file -//! naming. It does **not** validate the scientific content of the files. -//! -//! ## Technical scope -//! -//! The IGS LFN format (v2) is: -//! -//! ```text -//! {AAA}{V}{NNNNNN}_{YYYY}{DOY}{HH}{MM}_{LEN}_{SMP}_{FTY}.{EXT} -//! ``` -//! -//! - `AAA` – 3-char analysis centre (e.g. `IGS`, `GFZ`, `EUR`). -//! - `V` – 1-char version (`0`–`9` or `a`–`z`). -//! - `NNNNNN` – 6-char campaign/solution token (e.g. `OPSFIN`, `OPSRAP`). -//! - `YYYY` – 4-digit year. -//! - `DOY` – 3-digit day-of-year (001–366). -//! - `HH` – 2-digit UTC hour (00–23). -//! - `MM` – 2-digit UTC minute (00–59). -//! - `LEN` – 3-char time span, e.g. `01D`, `24H`, `15M`. -//! - `SMP` – 3-char sample rate, e.g. `15M`, `30S`, `05S`. -//! - `FTY` – 3-char file type, e.g. `ORB`, `CLK`, `ATT`. -//! - `EXT` – file extension without leading dot, e.g. `SP3`, `CLK`, `rnx`. -//! -//! ## References -//! -//! - International GNSS Service. (2020). IGS File and Product Naming -//! Conventions. Version 2. -//! - International GNSS Service. (2020). SP3-c / SP3-d Orbit Format -//! Specification. - -use chrono::{Datelike, NaiveDate}; -use std::path::PathBuf; - -// ─── SP3 type/version markers ─────────────────────────────────────────────── - -/// SP3 file content type: position-only or position-and-velocity. -/// -/// Determines the IGS LFN file-type tag and the SP3 header type character. -/// -/// # Examples -/// -/// ``` -/// use spod::products::naming::Sp3FileType; -/// -/// assert_eq!(Sp3FileType::PositionOnly.lfn_file_type_tag(), "ORB"); -/// assert_eq!(Sp3FileType::PositionVelocity.lfn_file_type_tag(), "OBV"); -/// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Sp3FileType { - /// File contains position records only (`P` records; SP3 header type `P`). - PositionOnly, - /// File contains position and velocity records (`P`+`V`; SP3 header type `V`). - PositionVelocity, -} - -impl Sp3FileType { - /// Returns the 3-character IGS LFN file-type tag for this content type. - /// - /// # Examples - /// - /// ``` - /// use spod::products::naming::Sp3FileType; - /// - /// assert_eq!(Sp3FileType::PositionOnly.lfn_file_type_tag(), "ORB"); - /// ``` - pub fn lfn_file_type_tag(self) -> &'static str { - match self { - Self::PositionOnly => "ORB", - Self::PositionVelocity => "OBV", - } - } -} - -/// SP3 format version marker. -/// -/// Identifies which revision of the SP3 standard a file conforms to. -/// -/// # Examples -/// -/// ``` -/// use spod::products::naming::Sp3Version; -/// -/// assert_eq!(Sp3Version::D.lfn_version_char(), 'd'); -/// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Sp3Version { - /// SP3-a — legacy format; header `#a`. - A, - /// SP3-c — current standard; header `#c`. - C, - /// SP3-d — latest standard; header `#d`. - D, -} - -impl Sp3Version { - /// Returns the version character used in the IGS LFN `V` field. - /// - /// # Examples - /// - /// ``` - /// use spod::products::naming::Sp3Version; - /// - /// assert_eq!(Sp3Version::C.lfn_version_char(), 'c'); - /// ``` - pub fn lfn_version_char(self) -> char { - match self { - Self::A => 'a', - Self::C => 'c', - Self::D => 'd', - } - } -} - -// ─── Sample-rate encoding ──────────────────────────────────────────────────── - -/// Encode a sample interval (seconds) as a 3-character IGS LFN sample-rate token. -/// -/// | Range | Format | Example | -/// |---|---|---| -/// | `< 60 s` | `NNS` | `30` → `"30S"` | -/// | `60 – 3 599 s` | `NNM` | `900` → `"15M"` | -/// | `≥ 3 600 s` | `NNH` | `3600` → `"01H"` | -/// -/// # Examples -/// -/// ``` -/// use spod::products::naming::sample_rate_to_lfn; -/// -/// assert_eq!(sample_rate_to_lfn(30), "30S"); -/// assert_eq!(sample_rate_to_lfn(900), "15M"); -/// assert_eq!(sample_rate_to_lfn(3600), "01H"); -/// ``` -pub fn sample_rate_to_lfn(sample_rate_sec: u32) -> String { - if sample_rate_sec < 60 { - format!("{:02}S", sample_rate_sec) - } else if sample_rate_sec < 3600 { - format!("{:02}M", sample_rate_sec / 60) - } else { - format!("{:02}H", sample_rate_sec / 3600) - } -} - -// ─── High-level SP3 naming ─────────────────────────────────────────────────── - -/// Build an IGS LFN SP3 orbit product filename from high-level parameters. -/// -/// Encodes the analysis-centre code (`satellite`), calendar `date`, -/// `file_type`, `sample_rate_sec`, and `version` into the IGS Long File -/// Name convention. The campaign token defaults to `"OPSFIN"` (operational -/// final), span to `"01D"` (one day), and start time to `00:00` UTC. -/// -/// Use [`sp3_lfn`] for full control over every LFN field. -/// -/// # Examples -/// -/// ``` -/// use chrono::NaiveDate; -/// use spod::products::naming::{Sp3FileType, Sp3Version, sp3_filename}; -/// -/// let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); -/// let p = sp3_filename("IGS", date, Sp3FileType::PositionOnly, 900, Sp3Version::D); -/// assert_eq!( -/// p.file_name().unwrap().to_str().unwrap(), -/// "IGSdOPSFIN_20240010000_01D_15M_ORB.SP3", -/// ); -/// ``` -pub fn sp3_filename( - satellite: &str, - date: NaiveDate, - file_type: Sp3FileType, - sample_rate_sec: u32, - version: Sp3Version, -) -> PathBuf { - let year = date.year(); - let doy = date.ordinal(); - let v = version.lfn_version_char(); - let smp = sample_rate_to_lfn(sample_rate_sec); - let fty = file_type.lfn_file_type_tag(); - PathBuf::from(format!( - "{satellite}{v}OPSFIN_{year:04}{doy:03}0000_01D_{smp}_{fty}.SP3" - )) -} - -/// Build an IGS short-name SP3 filename (pre-LFN convention). -/// -/// The traditional IGS short name encodes the GPS week and day-of-week: -/// `{ac}{gpsweek:04}{dow}.sp3`. The GPS epoch is 1980-01-06 (Sunday); -/// `dow` counts from Sunday (0) to Saturday (6). -/// -/// Use [`sp3_filename`] for the current IGS Long File Name convention. -/// -/// # Examples -/// -/// ``` -/// use chrono::NaiveDate; -/// use spod::products::naming::{Sp3Version, sp3_short_filename}; -/// -/// // 2024-01-01 falls on GPS week 2295, day-of-week 1 (Monday). -/// let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); -/// let p = sp3_short_filename("igs", date, Sp3Version::D); -/// assert_eq!(p.to_str().unwrap(), "igs22951.sp3"); -/// ``` -pub fn sp3_short_filename(ac: &str, date: NaiveDate, _version: Sp3Version) -> PathBuf { - let gps_epoch = NaiveDate::from_ymd_opt(1980, 1, 6).expect("GPS epoch is valid"); - let days = (date - gps_epoch).num_days(); - let gps_week = days / 7; - let dow = days % 7; - PathBuf::from(format!("{ac}{gps_week:04}{dow}.sp3")) -} - -// ─── Full-control LFN builder ──────────────────────────────────────────────── - -/// Build a generic IGS Long File Name with full control over every field. -/// -/// Prefer the typed wrappers ([`sp3_filename`], [`sp3_lfn`], [`clk_filename`], -/// [`rnx_obs_filename`]) for routine use. -/// -/// # Arguments -/// -/// * `ac` – Analysis centre, exactly 3 ASCII chars (e.g. `"IGS"`). -/// * `version` – Single version digit or letter (e.g. `'0'`). -/// * `campaign` – Campaign / solution token, exactly 6 ASCII chars (e.g. `"OPSFIN"`). -/// * `year` – 4-digit year. -/// * `doy` – 1-based day-of-year (1–366). -/// * `hour` – UTC hour (0–23). -/// * `minute` – UTC minute (0–59). -/// * `span` – Time span string, exactly 3 chars (e.g. `"01D"`). -/// * `sample` – Sample rate string, exactly 3 chars (e.g. `"15M"`). -/// * `file_type` – File-type tag, exactly 3 chars (e.g. `"ORB"`). -/// * `ext` – File extension without leading dot (e.g. `"SP3"`). -/// -/// # Examples -/// -/// ``` -/// use spod::products::naming::igs_lfn; -/// -/// let p = igs_lfn("IGS", '0', "OPSFIN", 2024, 1, 0, 0, "01D", "15M", "ORB", "SP3"); -/// assert_eq!(p.file_name().unwrap().to_str().unwrap(), -/// "IGS0OPSFIN_20240010000_01D_15M_ORB.SP3"); -/// ``` -#[allow(clippy::too_many_arguments)] -pub fn igs_lfn( - ac: &str, - version: char, - campaign: &str, - year: i32, - doy: u32, - hour: u32, - minute: u32, - span: &str, - sample: &str, - file_type: &str, - ext: &str, -) -> PathBuf { - PathBuf::from(format!( - "{ac}{version}{campaign}_{year:04}{doy:03}{hour:02}{minute:02}_{span}_{sample}_{file_type}.{ext}", - )) -} - -/// Build an IGS SP3 orbit product filename with full LFN field control. -/// -/// Convenience wrapper around [`igs_lfn`] that fixes `file_type` to `"ORB"` -/// and `ext` to `"SP3"`. Use [`sp3_filename`] for the simpler high-level API. -/// -/// # Examples -/// -/// ``` -/// use spod::products::naming::sp3_lfn; -/// -/// let p = sp3_lfn("IGS", '0', "OPSFIN", 2024, 1, 0, 0, "01D", "15M"); -/// assert_eq!( -/// p.file_name().unwrap().to_str().unwrap(), -/// "IGS0OPSFIN_20240010000_01D_15M_ORB.SP3", -/// ); -/// ``` -#[allow(clippy::too_many_arguments)] -pub fn sp3_lfn( - ac: &str, - version: char, - campaign: &str, - year: i32, - doy: u32, - hour: u32, - minute: u32, - span: &str, - sample: &str, -) -> PathBuf { - igs_lfn( - ac, version, campaign, year, doy, hour, minute, span, sample, "ORB", "SP3", - ) -} - -/// Build an IGS clock product filename. -/// -/// Convenience wrapper that fixes `file_type` to `"CLK"` and `ext` to `"CLK"`. -/// -/// # Examples -/// -/// ``` -/// use spod::products::naming::clk_filename; -/// -/// let p = clk_filename("GFZ", '0', "OPSRAP", 2024, 1, 0, 0, "01D", "30S"); -/// assert_eq!( -/// p.file_name().unwrap().to_str().unwrap(), -/// "GFZ0OPSRAP_20240010000_01D_30S_CLK.CLK", -/// ); -/// ``` -#[allow(clippy::too_many_arguments)] -pub fn clk_filename( - ac: &str, - version: char, - campaign: &str, - year: i32, - doy: u32, - hour: u32, - minute: u32, - span: &str, - sample: &str, -) -> PathBuf { - igs_lfn( - ac, version, campaign, year, doy, hour, minute, span, sample, "CLK", "CLK", - ) -} - -/// Build an IGS RINEX observation filename. -/// -/// Convenience wrapper that fixes `file_type` to `"MO"` (mixed observation) -/// and `ext` to `"rnx"` (RINEX 3+ lowercase convention). -/// -/// # Examples -/// -/// ``` -/// use spod::products::naming::rnx_obs_filename; -/// -/// let p = rnx_obs_filename("EUR", '0', "MGXFIN", 2024, 1, 0, 0, "01D", "30S"); -/// assert_eq!( -/// p.file_name().unwrap().to_str().unwrap(), -/// "EUR0MGXFIN_20240010000_01D_30S_MO.rnx", -/// ); -/// ``` -#[allow(clippy::too_many_arguments)] -pub fn rnx_obs_filename( - ac: &str, - version: char, - campaign: &str, - year: i32, - doy: u32, - hour: u32, - minute: u32, - span: &str, - sample: &str, -) -> PathBuf { - igs_lfn( - ac, version, campaign, year, doy, hour, minute, span, sample, "MO", "rnx", - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn igs_lfn_format_matches_spec() { - let p = igs_lfn( - "IGS", '0', "OPSFIN", 2024, 1, 0, 0, "01D", "15M", "ORB", "SP3", - ); - assert_eq!( - p.to_str().unwrap(), - "IGS0OPSFIN_20240010000_01D_15M_ORB.SP3" - ); - } - - #[test] - fn sp3_lfn_matches_igs_lfn() { - let a = sp3_lfn("GFZ", '0', "OPSRAP", 2024, 32, 12, 0, "01D", "05M"); - let b = igs_lfn( - "GFZ", '0', "OPSRAP", 2024, 32, 12, 0, "01D", "05M", "ORB", "SP3", - ); - assert_eq!(a, b); - } - - #[test] - fn sp3_filename_high_level() { - let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); - let p = sp3_filename("IGS", date, Sp3FileType::PositionOnly, 900, Sp3Version::D); - assert_eq!( - p.to_str().unwrap(), - "IGSdOPSFIN_20240010000_01D_15M_ORB.SP3" - ); - } - - #[test] - fn sp3_filename_velocity_type() { - let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); - let p = sp3_filename( - "GFZ", - date, - Sp3FileType::PositionVelocity, - 30, - Sp3Version::C, - ); - let s = p.to_str().unwrap(); - assert!(s.contains("OBV"), "expected OBV file-type tag, got {s}"); - assert!(s.contains("30S"), "expected 30S sample rate, got {s}"); - } - - #[test] - fn sp3_short_filename_gps_week() { - // 2024-01-01 = GPS week 2295, DOW 1 (Monday). - let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); - let p = sp3_short_filename("igs", date, Sp3Version::D); - assert_eq!(p.to_str().unwrap(), "igs22951.sp3"); - } - - #[test] - fn sp3_short_filename_gps_epoch() { - // GPS epoch itself = GPS week 0, DOW 0 (Sunday). - let date = NaiveDate::from_ymd_opt(1980, 1, 6).unwrap(); - let p = sp3_short_filename("igs", date, Sp3Version::A); - assert_eq!(p.to_str().unwrap(), "igs00000.sp3"); - } - - #[test] - fn clk_filename_correct_extension() { - let p = clk_filename("GFZ", '0', "OPSRAP", 2024, 1, 0, 0, "01D", "30S"); - let s = p.to_str().unwrap(); - assert!(s.ends_with(".CLK"), "expected .CLK, got {s}"); - } - - #[test] - fn rnx_obs_filename_correct_extension() { - let p = rnx_obs_filename("EUR", '0', "MGXFIN", 2024, 1, 0, 0, "01D", "30S"); - let s = p.to_str().unwrap(); - assert!(s.ends_with(".rnx"), "expected .rnx, got {s}"); - } - - #[test] - fn doy_zero_padded() { - let p = sp3_lfn("IGS", '0', "OPSFIN", 2024, 7, 0, 0, "01D", "15M"); - assert!( - p.to_str().unwrap().contains("2024007"), - "DOY must be zero-padded to 3 digits" - ); - } - - #[test] - fn sample_rate_encoding() { - assert_eq!(sample_rate_to_lfn(5), "05S"); - assert_eq!(sample_rate_to_lfn(30), "30S"); - assert_eq!(sample_rate_to_lfn(60), "01M"); - assert_eq!(sample_rate_to_lfn(900), "15M"); - assert_eq!(sample_rate_to_lfn(3600), "01H"); - } -} diff --git a/src/products/orbit.rs b/src/products/orbit.rs deleted file mode 100644 index 19cda66..0000000 --- a/src/products/orbit.rs +++ /dev/null @@ -1,435 +0,0 @@ -//! # Orbit product assembly -//! -//! ## Scientific scope -//! -//! Precise orbit determination ultimately produces a time history of -//! spacecraft states that must be exchanged in community-standard orbit -//! formats. This module bridges that scientific result into SP3 and OEM -//! products without changing the underlying trajectory meaning. -//! -//! The validity of the output therefore depends entirely on the supplied -//! state series and metadata. The module does not smooth, interpolate, or -//! otherwise alter the orbit solution. -//! -//! ## Technical scope -//! -//! The public entry points are `write_sp3_from_states` and -//! `write_oem_from_states`. They accept a sequence of `OrbitState` values -//! and the minimal metadata needed by the target file format, then delegate -//! low-level serialization to the IO crate. -//! -//! A convenience wrapper `write_oem_from_spacecraft_states` accepts -//! `SpacecraftState` values directly by extracting the inner `orbit` field. -//! -//! Residual statistics, manifests, and QC summaries are intentionally -//! handled elsewhere. -//! -//! ## References -//! -//! - Consultative Committee for Space Data Systems. (2010). Orbit Data -//! Messages, CCSDS 502.0-B-2 / 502.0-B-3. -//! - International GNSS Service. (2020). SP3-c / SP3-d Orbit Format -//! Specification. -use crate::io::oem::{write_oem, OemMetadata, OemState}; -use crate::io::sp3::{write_sp3, EarthCenter, Sp3Epoch, Sp3Position, Sp3Record}; -use crate::io::PodIoError; -use siderust::affn::cartesian; -use siderust::astro::dynamics::state::SpacecraftState; -use siderust::astro::dynamics::OrbitState; -use siderust::coordinates::frames::GCRS; -use siderust::qtty::time::Microseconds; -use siderust::qtty::{unit::Kilometer, Days}; -use siderust::tempoch::{JulianDate, Time, TT, UTC}; -use std::io::Write; -use std::path::Path; - -use super::error::PodProductsError; - -/// Build an SP3 record from an in-memory state series and write it to `w`. -/// -/// # Examples -/// -/// ``` -/// use spod::products::orbit::write_sp3_from_states; -/// use siderust::astro::dynamics::{OrbitState, Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// -/// let states: Vec = (0..3).map(|i| { -/// OrbitState::new( -/// JulianDate::new(2_451_545.0 + i as f64 * 30.0 / 86_400.0).to_j2000s(), -/// Position::::new(7000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.5, 0.0), -/// ) -/// }).collect(); -/// let mut buf = Vec::new(); -/// write_sp3_from_states(&mut buf, "L01", &states).unwrap(); -/// let text = String::from_utf8(buf).unwrap(); -/// assert_eq!(text.lines().filter(|l| l.starts_with("PL01")).count(), 3); -/// ``` -pub fn write_sp3_from_states( - w: &mut W, - sat_id: &str, - states: &[OrbitState], -) -> Result<(), crate::io::sp3::Sp3Error> { - let header = vec![ - format!( - "#dP2024 1 1 0 0 0.00000000 {:5} ORBIT IGS20 HLM POD", - states.len() - ), - "## 2295 0.00000000 900.00000000 60310 0.0000000000000".to_string(), - format!("+ 1 {sat_id:<3} "), - "++ 5 " - .to_string(), - "%c L cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc".to_string(), - "%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc".to_string(), - "%f 1.2500000 1.025000000 0.00000000000 0.000000000000000".to_string(), - "%f 0.0000000 0.000000000 0.00000000000 0.000000000000000".to_string(), - "%i 0 0 0 0 0 0 0 0 0".to_string(), - "%i 0 0 0 0 0 0 0 0 0".to_string(), - "/* spod orbit product".to_string(), - ]; - let epochs = states - .iter() - .map(|s| { - // Bridge the two tempoch versions (siderust uses crates.io tempoch, - // spod::io uses the local path version) via the raw f64 JD. - let epoch_utc: Time = - JulianDate::::try_new(Days::new(s.epoch.to::().value())) - .expect("OrbitState epoch must be finite") - .to_j2000s() - .to_scale::(); - Sp3Epoch { - time: epoch_utc, - positions: vec![Sp3Position { - sat_id: sat_id.to_string(), - // Sp3Position.position is cartesian::Position - // where EarthCenter is crate::io::sp3::EarthCenter (Params = ()). - position: cartesian::Position::::new( - s.position.x().value(), - s.position.y().value(), - s.position.z().value(), - ), - clock: Microseconds::new(999_999.999_999), - }], - } - }) - .collect(); - let rec = Sp3Record { header, epochs }; - write_sp3(w, &rec) -} - -/// Write an OEM product from an `OrbitState` series. -/// -/// Converts each [`OrbitState`] into an [`OemState`] (km/km·s⁻¹, TT Julian -/// Date) and delegates to `spod::io`'s `write_oem`. -/// -/// # Examples -/// -/// ``` -/// use spod::products::orbit::write_oem_from_states; -/// use siderust::astro::dynamics::{OrbitState, Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// -/// let states: Vec = (0..3).map(|i| { -/// OrbitState::new( -/// JulianDate::new(2_451_545.0 + i as f64 * 30.0 / 86_400.0).to_j2000s(), -/// Position::::new(7000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.5, 0.0), -/// ) -/// }).collect(); -/// let mut buf = Vec::new(); -/// write_oem_from_states(&mut buf, "1900-001A", "TEST", &states).unwrap(); -/// let text = String::from_utf8(buf).unwrap(); -/// assert_eq!(text.lines().filter(|l| l.starts_with("2000-")).count(), 3); -/// ``` -pub fn write_oem_from_states( - w: &mut W, - object_id: &str, - object_name: &str, - states: &[OrbitState], -) -> Result<(), PodIoError> { - let meta = OemMetadata { - object_id: object_id.into(), - object_name: object_name.into(), - ref_frame: "EME2000".into(), - time_system: "TT".into(), - center_name: "EARTH".into(), - }; - let oem_states: Vec = states - .iter() - .map(|s| { - OemState::new( - s.epoch.to::().value(), - [ - s.position.x().value(), - s.position.y().value(), - s.position.z().value(), - ], - [ - s.velocity.x().value(), - s.velocity.y().value(), - s.velocity.z().value(), - ], - ) - }) - .collect(); - write_oem(w, &meta, &oem_states) -} - -/// Write an OEM product from a `SpacecraftState` series. -/// -/// Convenience wrapper that extracts the inner `orbit` field from each -/// [`SpacecraftState`] and delegates to [`write_oem_from_states`]. -/// -/// # Examples -/// -/// ``` -/// use spod::products::orbit::write_oem_from_spacecraft_states; -/// use siderust::astro::dynamics::state::{SpacecraftProperties, SpacecraftState}; -/// use siderust::astro::dynamics::{OrbitState, Position, Velocity}; -/// use siderust::coordinates::frames::GCRS; -/// use siderust::time::JulianDate; -/// -/// let orbit = OrbitState::new( -/// JulianDate::new(2_451_545.0).to_j2000s(), -/// Position::::new(7000.0, 0.0, 0.0), -/// Velocity::::new(0.0, 7.5, 0.0), -/// ); -/// let sc = SpacecraftState { orbit, properties: SpacecraftProperties::demo_leo() }; -/// let mut buf = Vec::new(); -/// write_oem_from_spacecraft_states(&mut buf, "1900-001A", "TEST", &[sc]).unwrap(); -/// assert!(String::from_utf8(buf).unwrap().contains("OBJECT_ID")); -/// ``` -pub fn write_oem_from_spacecraft_states( - w: &mut W, - object_id: &str, - object_name: &str, - states: &[SpacecraftState], -) -> Result<(), PodIoError> { - let orbits: Vec = states.iter().map(|s| s.orbit).collect(); - write_oem_from_states(w, object_id, object_name, &orbits) -} - -// ─── Writer wrappers ───────────────────────────────────────────────────────── - -/// Wrapper around the SP3 writer that writes to a file path. -/// -/// Carries the satellite identifier so the same writer instance can be -/// reused for multiple output paths. To write to an existing -/// [`std::io::Write`] sink use [`write_sp3_from_states`] directly. -/// -/// # Examples -/// -/// ``` -/// use spod::products::orbit::Sp3ProductWriter; -/// -/// let w = Sp3ProductWriter::new("G01"); -/// drop(w); -/// ``` -pub struct Sp3ProductWriter { - sat_id: String, -} - -impl Sp3ProductWriter { - /// Create a new writer for the given satellite identifier. - /// - /// `sat_id` is the 3-character SP3 satellite identifier (e.g. `"L01"`). - /// - /// # Examples - /// - /// ``` - /// use spod::products::orbit::Sp3ProductWriter; - /// - /// let w = Sp3ProductWriter::new("G01"); - /// drop(w); - /// ``` - pub fn new(sat_id: impl Into) -> Self { - Self { - sat_id: sat_id.into(), - } - } - - /// Write an SP3 product to `path` from the given `SpacecraftState` slice. - /// - /// Creates or truncates the file at `path`, then serialises all states. - /// - /// # Errors - /// - /// Returns [`PodProductsError::Io`] if the file cannot be created or - /// written, or [`PodProductsError::Sp3`] if SP3 serialisation fails. - /// - /// # Examples - /// - /// ```no_run - /// use spod::products::orbit::Sp3ProductWriter; - /// use std::path::Path; - /// - /// // Given `states: Vec`: - /// // Sp3ProductWriter::new("L01") - /// // .write_from_states(Path::new("orbit.sp3"), &states) - /// // .unwrap(); - /// ``` - pub fn write_from_states( - &self, - path: &Path, - states: &[SpacecraftState], - ) -> Result<(), PodProductsError> { - let orbits: Vec = states.iter().map(|s| s.orbit).collect(); - let mut f = std::fs::File::create(path)?; - write_sp3_from_states(&mut f, &self.sat_id, &orbits)?; - Ok(()) - } -} - -/// Wrapper around the OEM writer that writes to a file path. -/// -/// Carries the CCSDS object metadata so the same writer instance can be -/// reused for multiple output paths. To write to an existing -/// [`std::io::Write`] sink use [`write_oem_from_spacecraft_states`] directly. -/// -/// # Examples -/// -/// ``` -/// use spod::products::orbit::OemProductWriter; -/// -/// let w = OemProductWriter::new("2024-001A", "MYSAT"); -/// drop(w); -/// ``` -pub struct OemProductWriter { - object_id: String, - object_name: String, -} - -impl OemProductWriter { - /// Create a new writer with the given CCSDS object identifiers. - /// - /// # Examples - /// - /// ``` - /// use spod::products::orbit::OemProductWriter; - /// - /// let w = OemProductWriter::new("2024-001A", "MYSAT"); - /// drop(w); - /// ``` - pub fn new(object_id: impl Into, object_name: impl Into) -> Self { - Self { - object_id: object_id.into(), - object_name: object_name.into(), - } - } - - /// Write an OEM product to `path` from the given `SpacecraftState` slice. - /// - /// Creates or truncates the file at `path`, then serialises all states. - /// - /// # Errors - /// - /// Returns [`PodProductsError::Io`] if the file cannot be created or - /// written, or [`PodProductsError::PodIo`] if OEM serialisation fails. - /// - /// # Examples - /// - /// ```no_run - /// use spod::products::orbit::OemProductWriter; - /// use std::path::Path; - /// - /// // Given `states: Vec`: - /// // OemProductWriter::new("2024-001A", "MYSAT") - /// // .write_from_states(Path::new("orbit.oem"), &states) - /// // .unwrap(); - /// ``` - pub fn write_from_states( - &self, - path: &Path, - states: &[SpacecraftState], - ) -> Result<(), PodProductsError> { - let mut f = std::fs::File::create(path)?; - write_oem_from_spacecraft_states(&mut f, &self.object_id, &self.object_name, states)?; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use siderust::astro::dynamics::state::{Position, SpacecraftProperties, Velocity}; - use siderust::time::JulianDate; - - fn fake_orbit_states() -> Vec { - (0..5) - .map(|i| { - OrbitState::new( - JulianDate::new(2_451_545.0 + i as f64 * 30.0 / 86_400.0).to_j2000s(), - Position::::new(7000.0 + i as f64, 0.0, 0.0), - Velocity::::new(0.0, 7.5, 0.0), - ) - }) - .collect() - } - - fn fake_spacecraft_states() -> Vec { - fake_orbit_states() - .into_iter() - .map(|orbit| SpacecraftState { - orbit, - properties: SpacecraftProperties::demo_leo(), - }) - .collect() - } - - #[test] - fn sp3_writer_emits_n_epochs() { - let s = fake_orbit_states(); - let mut buf = Vec::new(); - write_sp3_from_states(&mut buf, "L01", &s).unwrap(); - let text = String::from_utf8(buf).unwrap(); - assert_eq!(text.lines().filter(|l| l.starts_with("PL01")).count(), 5); - } - - #[test] - fn oem_writer_emits_data_lines() { - let s = fake_orbit_states(); - let mut buf = Vec::new(); - write_oem_from_states(&mut buf, "1900-001A", "TEST", &s).unwrap(); - let text = String::from_utf8(buf).unwrap(); - assert_eq!(text.lines().filter(|l| l.starts_with("2000-")).count(), 5); - } - - #[test] - fn oem_from_spacecraft_states_writes_object_id() { - let scs = fake_spacecraft_states(); - let mut buf = Vec::new(); - write_oem_from_spacecraft_states(&mut buf, "2024-001A", "MYSAT", &scs).unwrap(); - let text = String::from_utf8(buf).unwrap(); - assert!(text.contains("OBJECT_ID = 2024-001A")); - assert!(text.contains("OBJECT_NAME = MYSAT")); - } - - #[test] - fn sp3_product_writer_roundtrip() { - let dir = std::env::temp_dir(); - let path = dir.join("test_sp3_product_writer.sp3"); - let scs = fake_spacecraft_states(); - Sp3ProductWriter::new("L01") - .write_from_states(&path, &scs) - .unwrap(); - let text = std::fs::read_to_string(&path).unwrap(); - assert_eq!(text.lines().filter(|l| l.starts_with("PL01")).count(), 5); - let _ = std::fs::remove_file(&path); - } - - #[test] - fn oem_product_writer_roundtrip() { - let dir = std::env::temp_dir(); - let path = dir.join("test_oem_product_writer.oem"); - let scs = fake_spacecraft_states(); - OemProductWriter::new("2024-001A", "MYSAT") - .write_from_states(&path, &scs) - .unwrap(); - let text = std::fs::read_to_string(&path).unwrap(); - assert!(text.contains("OBJECT_ID = 2024-001A")); - let _ = std::fs::remove_file(&path); - } -} diff --git a/src/products/qc_json.rs b/src/products/qc_json.rs deleted file mode 100644 index 41546e1..0000000 --- a/src/products/qc_json.rs +++ /dev/null @@ -1,102 +0,0 @@ -//! # QC JSON product writer -//! -//! ## Scientific scope -//! -//! Quality-control summaries condense orbit-determination diagnostics into -//! a machine-readable product that downstream automation can archive or -//! render. This module defines that lightweight document shape for grouped -//! residual and validation statistics. -//! -//! Its scope is descriptive rather than inferential: the statistics are -//! assumed to have been computed upstream, and the writer only preserves -//! them in a stable JSON layout. -//! -//! ## Technical scope -//! -//! The public items are `QcDocument` and `write_qc_json`. Callers provide -//! serializable summary payloads and run metadata, and the module emits a -//! deterministic JSON document suitable for REST delivery, artifact -//! storage, or HTML rendering. -//! -//! No statistical aggregation or residual computation happens here. -//! -//! ## References -//! -//! - Ben-Kiki, O., Evans, C., & d'Otremont, I. (2021). YAML Ain't Markup -//! Language (YAML) Version 1.2.2. -//! - Bray, T. (2017). The JavaScript Object Notation (JSON) Data -//! Interchange Format. RFC 8259. -use serde::Serialize; -use std::io::Write; - -/// Top-level shape of `qc.json`. -/// -/// # Examples -/// -/// ``` -/// use spod::products::qc_json::{QcDocument, write_qc_json}; -/// -/// let doc = QcDocument { -/// schema_version: "qc.v1".into(), -/// run_id: "test-run".into(), -/// software_version: "0.0.0".into(), -/// n_obs: 1000, -/// n_params: 6, -/// reduced_chi2: 1.02, -/// iterations: 3, -/// residuals: serde_json::json!({"C1C": {"rms_m": 0.45}}), -/// }; -/// let mut buf = Vec::new(); -/// write_qc_json(&mut buf, &doc).unwrap(); -/// assert!(String::from_utf8(buf).unwrap().contains("reduced_chi2")); -/// ``` -#[derive(Debug, Clone, Serialize)] -pub struct QcDocument { - /// Schema version of the QC document. - pub schema_version: String, - /// Run identifier (matches the run manifest). - pub run_id: String, - /// Software version that produced the document. - pub software_version: String, - /// Total number of measurements processed. - pub n_obs: usize, - /// Number of estimated parameters. - pub n_params: usize, - /// Reduced χ² of the converged solution. - pub reduced_chi2: f64, - /// Number of estimator iterations. - pub iterations: usize, - /// Per-group residual statistics (typically grouped by measurement kind). - pub residuals: S, -} - -/// Write a `qc.json` file pretty-printed. -/// -/// # Examples -/// -/// ``` -/// use spod::products::qc_json::{QcDocument, write_qc_json}; -/// -/// let doc = QcDocument { -/// schema_version: "qc.v1".into(), -/// run_id: "r1".into(), -/// software_version: "0.0.0".into(), -/// n_obs: 500, -/// n_params: 6, -/// reduced_chi2: 0.99, -/// iterations: 2, -/// residuals: Vec::<()>::new(), -/// }; -/// let mut buf = Vec::new(); -/// write_qc_json(&mut buf, &doc).unwrap(); -/// assert!(buf.ends_with(b"\n")); -/// ``` -pub fn write_qc_json( - w: &mut W, - doc: &QcDocument, -) -> Result<(), std::io::Error> { - let bytes = serde_json::to_vec_pretty(doc)?; - w.write_all(&bytes)?; - w.write_all(b"\n")?; - Ok(()) -} diff --git a/src/products/residuals_csv.rs b/src/products/residuals_csv.rs deleted file mode 100644 index f8f3482..0000000 --- a/src/products/residuals_csv.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! # Residual CSV writer -//! -//! ## Scientific scope -//! -//! Residual time series are a standard diagnostic product in POD, allowing -//! analysts to inspect measurement fit quality by epoch, sensor, and -//! observable type. This module serializes those diagnostics into a simple -//! flat table for downstream plotting or audit workflows. -//! -//! It assumes residuals and sigmas have already been computed by the -//! service and QC layers. The writer does not reinterpret or normalize the -//! measurements. -//! -//! ## Technical scope -//! -//! Two CSV surfaces are offered: -//! -//! - **Legacy**: [`ResidualRow`] + [`write_residuals_csv`] — a simple -//! in-memory batch writer for backwards compatibility. -//! -//! - **Streaming**: [`ResidualRecord`] + [`ResidualCsvWriter`] — a -//! streaming writer using the standard POD column format -//! `(epoch_jd_tt, obs_type, satellite, residual_m, sigma_m, rejected)`. -//! The writer flushes to the sink after each record for crash-safety. -//! -//! Formatting is intentionally simple and stable; statistical aggregation -//! remains the responsibility of QC modules. -//! -//! ## References -//! -//! - Tapley, B. D., Schutz, B. E., & Born, G. H. (2004). *Statistical Orbit -//! Determination*. Elsevier Academic Press. -//! - Vallado, D. A. (2013). *Fundamentals of Astrodynamics and Applications* -//! (4th ed.). Microcosm Press. - -use super::error::PodProductsError; -use serde::{Deserialize, Serialize}; -use std::io::Write; - -pub use siderust::pod::product::residuals_csv::ResidualRecord; - -/// One residual row (legacy API). -/// -/// For new code prefer [`ResidualRecord`] and [`ResidualCsvWriter`]. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ResidualRow { - /// Epoch (Julian Date, TT scale). - pub jd_tt: f64, - /// Measurement type tag (e.g. `"code-G01"` or `"phase-G05"`). - pub kind: String, - /// Measured value (metres). - pub measured_m: f64, - /// Predicted value (metres). - pub predicted_m: f64, - /// Residual (measured − predicted), metres. - pub residual_m: f64, - /// Standard deviation used (metres). - pub sigma_m: f64, -} - -/// Write a residuals CSV file (legacy batch API). -/// -/// # Examples -/// -/// ``` -/// use spod::products::residuals_csv::{ResidualRow, write_residuals_csv}; -/// -/// let rows = vec![ResidualRow { -/// jd_tt: 2_451_545.0, -/// kind: "code-G01".into(), -/// measured_m: 20_000_000.0, -/// predicted_m: 20_000_001.0, -/// residual_m: -1.0, -/// sigma_m: 1.0, -/// }]; -/// let mut buf = Vec::new(); -/// write_residuals_csv(&mut buf, &rows).unwrap(); -/// let text = String::from_utf8(buf).unwrap(); -/// assert!(text.starts_with("jd_tt,kind,")); -/// ``` -pub fn write_residuals_csv( - w: &mut W, - rows: &[ResidualRow], -) -> Result<(), std::io::Error> { - writeln!(w, "jd_tt,kind,measured_m,predicted_m,residual_m,sigma_m")?; - for r in rows { - writeln!( - w, - "{:.9},{},{:.6},{:.6},{:.6},{:.6}", - r.jd_tt, r.kind, r.measured_m, r.predicted_m, r.residual_m, r.sigma_m - )?; - } - Ok(()) -} - -// ─── Streaming API ────────────────────────────────────────────────────────── - -/// Streaming CSV writer for POD residuals. -/// -/// Writes one [`ResidualRecord`] at a time and flushes the underlying sink -/// after every record so that a crash preserves all records written so far. -/// -/// The header is written on construction. -/// -/// # Examples -/// -/// ``` -/// use spod::products::residuals_csv::{ResidualCsvWriter, ResidualRecord}; -/// -/// let mut buf = Vec::::new(); -/// let mut w = ResidualCsvWriter::new(&mut buf).unwrap(); -/// w.write_record(&ResidualRecord { -/// epoch_jd_tt: 2_451_545.0, -/// obs_type: "C1C".into(), -/// satellite: "G01".into(), -/// residual_m: -0.15, -/// sigma_m: 0.30, -/// rejected: false, -/// }).unwrap(); -/// w.flush().unwrap(); -/// let text = String::from_utf8(buf).unwrap(); -/// assert!(text.lines().next().unwrap().starts_with("epoch_jd_tt,")); -/// assert_eq!(text.lines().count(), 2); -/// ``` -pub struct ResidualCsvWriter { - writer: W, -} - -impl ResidualCsvWriter { - /// Create a new writer, immediately writing the CSV header line. - /// - /// # Errors - /// - /// Returns [`PodProductsError::Io`] if writing or flushing the header - /// to `writer` fails. - /// - /// # Examples - /// - /// ``` - /// use spod::products::residuals_csv::ResidualCsvWriter; - /// - /// let mut buf = Vec::::new(); - /// let w = ResidualCsvWriter::new(&mut buf).unwrap(); - /// drop(w); - /// assert!(String::from_utf8(buf).unwrap().starts_with("epoch_jd_tt,")); - /// ``` - pub fn new(mut writer: W) -> Result { - writeln!( - writer, - "epoch_jd_tt,obs_type,satellite,residual_m,sigma_m,rejected" - )?; - writer.flush()?; - Ok(Self { writer }) - } - - /// Write one residual record and flush the sink. - /// - /// The flush after each record guarantees that a process crash cannot - /// silently drop already-written residuals. - /// - /// # Errors - /// - /// Returns [`PodProductsError::Io`] if the write or flush fails. - /// - /// # Examples - /// - /// ``` - /// use spod::products::residuals_csv::{ResidualCsvWriter, ResidualRecord}; - /// - /// let mut buf = Vec::::new(); - /// let mut w = ResidualCsvWriter::new(&mut buf).unwrap(); - /// w.write_record(&ResidualRecord { - /// epoch_jd_tt: 2_451_545.0, - /// obs_type: "L1C".into(), - /// satellite: "E11".into(), - /// residual_m: 0.02, - /// sigma_m: 0.01, - /// rejected: false, - /// }).unwrap(); - /// assert_eq!(String::from_utf8(buf).unwrap().lines().count(), 2); - /// ``` - pub fn write_record(&mut self, r: &ResidualRecord) -> Result<(), PodProductsError> { - writeln!( - self.writer, - "{:.9},{},{},{:.6},{:.6},{}", - r.epoch_jd_tt, r.obs_type, r.satellite, r.residual_m, r.sigma_m, r.rejected, - )?; - self.writer.flush()?; - Ok(()) - } - - /// Manually flush the underlying writer. - /// - /// Normally not needed because [`write_record`][Self::write_record] already - /// flushes, but useful when the caller wants an explicit sync point. - /// - /// # Examples - /// - /// ``` - /// use spod::products::residuals_csv::ResidualCsvWriter; - /// - /// let mut buf = Vec::::new(); - /// let mut w = ResidualCsvWriter::new(&mut buf).unwrap(); - /// w.flush().unwrap(); - /// ``` - pub fn flush(&mut self) -> Result<(), PodProductsError> { - self.writer.flush().map_err(PodProductsError::Io) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn legacy_writes_header_plus_rows() { - let rows = vec![ResidualRow { - jd_tt: 2_451_545.0, - kind: "code-G01".into(), - measured_m: 20_000_000.0, - predicted_m: 20_000_001.0, - residual_m: -1.0, - sigma_m: 1.0, - }]; - let mut buf = Vec::new(); - write_residuals_csv(&mut buf, &rows).unwrap(); - let text = String::from_utf8(buf).unwrap(); - assert!(text.starts_with("jd_tt,kind,")); - assert!(text.lines().nth(1).unwrap().contains("code-G01")); - } - - #[test] - fn streaming_writer_header() { - let mut buf = Vec::::new(); - ResidualCsvWriter::new(&mut buf).unwrap(); - let text = String::from_utf8(buf).unwrap(); - assert_eq!( - text.trim(), - "epoch_jd_tt,obs_type,satellite,residual_m,sigma_m,rejected" - ); - } - - #[test] - fn streaming_writer_record_count() { - let mut buf = Vec::::new(); - let mut w = ResidualCsvWriter::new(&mut buf).unwrap(); - for i in 0..3 { - w.write_record(&ResidualRecord { - epoch_jd_tt: 2_451_545.0 + i as f64, - obs_type: "C1C".into(), - satellite: "G01".into(), - residual_m: 0.0, - sigma_m: 1.0, - rejected: false, - }) - .unwrap(); - } - let text = String::from_utf8(buf).unwrap(); - // 1 header + 3 data rows - assert_eq!(text.lines().count(), 4); - } - - #[test] - fn streaming_writer_rejected_field() { - let mut buf = Vec::::new(); - let mut w = ResidualCsvWriter::new(&mut buf).unwrap(); - w.write_record(&ResidualRecord { - epoch_jd_tt: 2_451_545.0, - obs_type: "P2".into(), - satellite: "G03".into(), - residual_m: 10.0, - sigma_m: 1.0, - rejected: true, - }) - .unwrap(); - let text = String::from_utf8(buf).unwrap(); - assert!( - text.contains(",true"), - "rejected=true must appear in output" - ); - } -} diff --git a/src/products/residuals_parquet.rs b/src/products/residuals_parquet.rs deleted file mode 100644 index 6e895f6..0000000 --- a/src/products/residuals_parquet.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Parquet residual support provided by Siderust. -//! -//! The writer is re-exported unchanged so this service does not maintain a -//! second Parquet implementation. - -pub use siderust::pod::product::residuals_csv::ResidualRecord; -pub use siderust::pod::product::residuals_parquet::ResidualParquetWriter; - -#[cfg(test)] -mod tests { - use super::ResidualParquetWriter; - use crate::products::ResidualRecord; - use std::io::Cursor; - - #[test] - fn public_residual_record_is_accepted_by_parquet_writer() { - let mut writer = ResidualParquetWriter::new(Cursor::new(Vec::::new())); - writer.push(ResidualRecord { - epoch_jd_tt: 2_451_545.0, - obs_type: "C1C".into(), - satellite: "G01".into(), - residual_m: -0.15, - sigma_m: 0.30, - rejected: false, - }); - - let output = writer.finish().expect("valid residual record"); - assert!(!output.into_inner().is_empty()); - } -} diff --git a/src/qc/html.rs b/src/qc/html.rs deleted file mode 100644 index e4bb8bd..0000000 --- a/src/qc/html.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! # HTML QC report rendering -//! -//! ## Scientific scope -//! -//! Human-readable QC artifacts are useful for quickly inspecting orbit- -//! determination output outside a notebook or plotting stack. This module -//! renders a compact HTML summary from the workspace QC JSON structure. -//! -//! The renderer is intentionally presentation-only. It preserves the -//! upstream statistics verbatim and adds no new scientific interpretation. -//! -//! ## Technical scope -//! -//! The main entry point is `render_html`, which accepts a -//! `serde_json::Value` compatible with the workspace QC schema and returns -//! a self-contained HTML string. The output is static and dependency-free -//! so it can be archived as a standalone artifact. -//! -//! It does not compute statistics, fetch assets, or depend on browser-side -//! JavaScript. -//! -//! ## References -//! -//! - Fielding, R., Nottingham, M., & Reschke, J. (2022). HTTP Semantics. -//! RFC 9110. -//! - Bray, T. (2017). The JavaScript Object Notation (JSON) Data -//! Interchange Format. RFC 8259. -use std::fmt::Write; - -/// Render a `qc.json`-shaped JSON value to HTML. -pub fn render_html(qc: &serde_json::Value) -> String { - let mut s = String::new(); - s.push_str("\n"); - s.push_str("spod — QC report"); - s.push_str("

spod — QC report

"); - - if let Some(run_id) = qc.get("run_id").and_then(|v| v.as_str()) { - let _ = write!(s, "

Run ID: {}

", html_escape(run_id)); - } - if let Some(version) = qc.get("schema_version").and_then(|v| v.as_str()) { - let _ = write!( - s, - "

Schema version: {}

", - html_escape(version) - ); - } - - if let Some(residuals) = qc.get("residuals") { - s.push_str("

Residuals

"); - render_residuals(&mut s, residuals); - } - - if let Some(extra) = qc.as_object() { - for (k, v) in extra { - if matches!(k.as_str(), "run_id" | "schema_version" | "residuals") { - continue; - } - let _ = write!(s, "

{}

", html_escape(k)); - let _ = write!( - s, - "
{}
", - html_escape(&serde_json::to_string_pretty(v).unwrap_or_default()) - ); - } - } - - s.push_str(""); - s -} - -fn render_residuals(s: &mut String, r: &serde_json::Value) { - if let Some(overall) = r.get("overall") { - s.push_str("

Overall

"); - render_stats_table(s, overall); - } - if let Some(by_group) = r.get("by_group").and_then(|v| v.as_object()) { - s.push_str("

By group

"); - s.push_str(""); - for (k, v) in by_group { - let _ = write!( - s, - "", - html_escape(k), - v.get("n").and_then(|x| x.as_u64()).unwrap_or(0), - f(v, "mean"), - f(v, "rms"), - f(v, "std"), - f(v, "min"), - f(v, "max"), - ); - } - s.push_str("
groupnmeanrmsstdminmax
{}{}{:.4e}{:.4e}{:.4e}{:.4e}{:.4e}
"); - } -} - -fn render_stats_table(s: &mut String, v: &serde_json::Value) { - s.push_str(""); - for k in ["n", "mean", "std", "rms", "min", "max"] { - let val = match v.get(k) { - Some(j) if j.is_u64() => format!("{}", j.as_u64().unwrap()), - Some(j) if j.is_f64() => format!("{:.6e}", j.as_f64().unwrap()), - Some(j) => j.to_string(), - None => continue, - }; - let _ = write!(s, "", k, html_escape(&val)); - } - s.push_str("
statisticvalue
{}{}
"); -} - -fn f(v: &serde_json::Value, key: &str) -> f64 { - v.get(key).and_then(|x| x.as_f64()).unwrap_or(0.0) -} - -fn html_escape(s: &str) -> String { - s.replace('&', "&") - .replace('<', "<") - .replace('>', ">") -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn renders_minimal_html() { - let qc = json!({ - "run_id": "demo", - "schema_version": "1.0.0", - "residuals": { - "overall": {"n": 3, "mean": 0.0, "std": 0.1, "rms": 0.1, "min": -0.1, "max": 0.1}, - "by_group": {"code": {"n": 2, "mean": 0.0, "std": 0.05, "rms": 0.05, "min": -0.05, "max": 0.05}} - } - }); - let h = render_html(&qc); - assert!(h.starts_with("")); - assert!(h.contains("demo")); - assert!(h.contains("By group")); - } -} diff --git a/src/qc/mod.rs b/src/qc/mod.rs deleted file mode 100644 index 098c693..0000000 --- a/src/qc/mod.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! # spod quality control -//! -//! ## Scientific scope -//! -//! This crate collects post-fit diagnostics for POD workflows: residual -//! summaries, orbit-to-orbit comparisons in local frames, and SLR -//! validation summaries. The scientific intent is to quantify solution -//! quality without modifying the estimated orbit itself. -//! -//! The current regime emphasizes compact deterministic reports suitable for -//! regression tests and MVP product generation. It does not attempt to -//! replace a full operational analysis toolchain. -//! -//! ## Technical scope -//! -//! The crate re-exports orbit-comparison helpers, grouped residual -//! statistics, HTML rendering, and SLR validation report types. Inputs are -//! already computed trajectories, residual lists, or QC JSON payloads -//! prepared by upstream stages. -//! -//! This crate does not read raw measurement files or perform estimation. -//! -//! ## References -//! -//! - Tapley, B. D., Schutz, B. E., & Born, G. H. (2004). Statistical Orbit -//! Determination. Elsevier Academic Press. -//! - Vallado, D. A. (2013). Fundamentals of Astrodynamics and Applications -//! (4th ed.). Microcosm Press. -#![forbid(unsafe_code)] -#![warn(missing_docs)] - -pub mod html; -pub mod orbit_compare; -pub mod residuals; -pub mod slr_validation; - -pub use html::render_html; -pub use orbit_compare::{rtn_diff, rtn_summary, RtnDiff, RtnSummary}; -pub use residuals::{ResidualStats, ResidualsByGroup}; -pub use slr_validation::{SlrResidual, SlrValidationReport}; diff --git a/src/qc/orbit_compare.rs b/src/qc/orbit_compare.rs deleted file mode 100644 index f805bf3..0000000 --- a/src/qc/orbit_compare.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! # RTN orbit-to-orbit comparison -//! -//! ## Scientific scope -//! -//! Comparing an estimated trajectory against a reference orbit is most -//! interpretable in the local radial-transverse-normal frame, where along- -//! track, radial, and cross-track errors separate naturally. This module -//! computes that frame-aware difference for matched epochs. -//! -//! It assumes both trajectories are already sampled at identical epochs and -//! expressed in compatible inertial coordinates. Interpolation and frame -//! reconciliation are intentionally left to caller code. -//! -//! ## Technical scope -//! -//! The public items are `RtnDiff`, `RtnSummary`, `rtn_diff`, and -//! `rtn_summary`. Callers pass slices of `OrbitState` values and receive -//! per-epoch RTN differences plus a condensed summary for QC reporting. -//! -//! This module does not estimate the orbit or ingest external product files -//! directly. -//! -//! ## References -//! -//! - Tapley, B. D., Schutz, B. E., & Born, G. H. (2004). Statistical Orbit -//! Determination. Elsevier Academic Press. -//! - Vallado, D. A. (2013). Fundamentals of Astrodynamics and Applications -//! (4th ed.). Microcosm Press. -use siderust::affn::cartesian::Displacement; -use siderust::affn::frames::GCRS; -use siderust::astro::dynamics::frames::RTN; -use siderust::astro::dynamics::OrbitState; -use siderust::qtty::unit::Kilometer; - -/// Per-epoch RTN difference between an estimated and reference state. -#[derive(Debug, Clone)] -pub struct RtnDiff { - /// Julian Date (TT) of the comparison epoch. - pub jd_tt: f64, - /// Radial difference (m). - pub r_m: f64, - /// Along-track difference (m). - pub t_m: f64, - /// Cross-track difference (m). - pub n_m: f64, -} - -impl RtnDiff { - /// Position difference as a typed displacement in the RTN frame (kilometres). - pub fn position_rtn_km(&self) -> Displacement { - Displacement::new(self.r_m * 1e-3, self.t_m * 1e-3, self.n_m * 1e-3) - } -} - -/// Aggregate RTN statistics over a comparison run. -#[derive(Debug, Clone)] -pub struct RtnSummary { - /// Number of compared epochs. - pub n: usize, - /// 3-D RMS, metres. - pub rms_3d_m: f64, - /// RMS of radial component, metres. - pub rms_r_m: f64, - /// RMS of along-track, metres. - pub rms_t_m: f64, - /// RMS of cross-track, metres. - pub rms_n_m: f64, -} - -/// Pairwise RTN difference between estimated and reference states; epochs are -/// matched by index (caller is responsible for alignment and time-tag check). -pub fn rtn_diff(estimated: &[OrbitState], reference: &[OrbitState]) -> Vec { - let n = estimated.len().min(reference.len()); - let mut out = Vec::with_capacity(n); - for i in 0..n { - let e = estimated[i]; - let r = reference[i]; - // Position difference in GCRS (typed Displacement). - let d_gcrs: Displacement = e.position - r.position; - let frame = siderust::astro::dynamics::frames::LocalOrbitalFrame::::try_from_state(&r) - .expect("RTN frame from reference state"); - let d_rtn = frame.to_local(d_gcrs); - out.push(RtnDiff { - jd_tt: e.epoch.to::().value(), - r_m: d_rtn.x().value() * 1000.0, - t_m: d_rtn.y().value() * 1000.0, - n_m: d_rtn.z().value() * 1000.0, - }); - } - out -} - -/// Aggregate RMS statistics over a list of RTN differences. -pub fn rtn_summary(diffs: &[RtnDiff]) -> RtnSummary { - let n = diffs.len(); - if n == 0 { - return RtnSummary { - n: 0, - rms_3d_m: 0.0, - rms_r_m: 0.0, - rms_t_m: 0.0, - rms_n_m: 0.0, - }; - } - let mut sr = 0.0; - let mut st = 0.0; - let mut sn = 0.0; - for d in diffs { - sr += d.r_m * d.r_m; - st += d.t_m * d.t_m; - sn += d.n_m * d.n_m; - } - let rms_r = (sr / n as f64).sqrt(); - let rms_t = (st / n as f64).sqrt(); - let rms_n = (sn / n as f64).sqrt(); - let rms_3d = (rms_r * rms_r + rms_t * rms_t + rms_n * rms_n).sqrt(); - RtnSummary { - n, - rms_3d_m: rms_3d, - rms_r_m: rms_r, - rms_t_m: rms_t, - rms_n_m: rms_n, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use siderust::astro::dynamics::{Position, Velocity}; - use siderust::time::JulianDate; - - #[test] - fn rtn_zero_for_identical_orbits() { - let s = OrbitState::new( - JulianDate::new(2_451_545.0).to_j2000s(), - Position::new(7000.0, 0.0, 0.0), - Velocity::new(0.0, 7.5, 0.0), - ); - let d = rtn_diff(&[s], &[s]); - assert_eq!(d.len(), 1); - assert!(d[0].r_m.abs() < 1e-9); - let summary = rtn_summary(&d); - assert!(summary.rms_3d_m < 1e-9); - assert_eq!(summary.n, 1); - } - - #[test] - fn radial_offset_only_appears_in_r_component() { - let r = OrbitState::new( - JulianDate::new(2_451_545.0).to_j2000s(), - Position::new(7000.0, 0.0, 0.0), - Velocity::new(0.0, 7.5, 0.0), - ); - let e = OrbitState::new( - JulianDate::new(2_451_545.0).to_j2000s(), - Position::new(7000.001, 0.0, 0.0), - Velocity::new(0.0, 7.5, 0.0), - ); - let d = rtn_diff(&[e], &[r]); - assert!((d[0].r_m - 1.0).abs() < 1e-9); - assert!(d[0].t_m.abs() < 1e-9); - assert!(d[0].n_m.abs() < 1e-9); - } -} diff --git a/src/qc/residuals.rs b/src/qc/residuals.rs deleted file mode 100644 index 2f93a43..0000000 --- a/src/qc/residuals.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! # Residual statistics -//! -//! ## Scientific scope -//! -//! Residual statistics provide the first compact view of whether a POD -//! solution is fitting its measurements consistently. This module groups -//! scalar residuals and derives summary quantities such as RMS, mean, and -//! extrema for QC reporting. -//! -//! The statistics are classical descriptive measures; there is no outlier -//! rejection or stochastic modelling in this layer. Their interpretation -//! depends on the observation model and data editing done upstream. -//! -//! ## Technical scope -//! -//! The public types are `ResidualStats` and `ResidualsByGroup`, which -//! organize scalar residual collections into report-friendly summaries. -//! Callers feed already-computed residual values and group labels into this -//! layer. -//! -//! Residual formation, parameter estimation, and product serialization are -//! outside its scope. -//! -//! ## References -//! -//! - Tapley, B. D., Schutz, B. E., & Born, G. H. (2004). Statistical Orbit -//! Determination. Elsevier Academic Press. -//! - Vallado, D. A. (2013). Fundamentals of Astrodynamics and Applications -//! (4th ed.). Microcosm Press. -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; - -/// Summary statistics for a single residual stream. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ResidualStats { - /// Number of residuals. - pub n: usize, - /// Mean (signed) residual. - pub mean: f64, - /// Standard deviation (sample, n-1 denominator if n > 1). - pub std: f64, - /// Root-mean-square value. - pub rms: f64, - /// Min value. - pub min: f64, - /// Max value. - pub max: f64, -} - -impl ResidualStats { - /// Compute statistics for a slice. - pub fn from_slice(values: &[f64]) -> Self { - let n = values.len(); - if n == 0 { - return Self::default(); - } - let sum: f64 = values.iter().sum(); - let mean = sum / n as f64; - let mut var = 0.0; - let mut sq = 0.0; - let mut min = f64::INFINITY; - let mut max = f64::NEG_INFINITY; - for &v in values { - let d = v - mean; - var += d * d; - sq += v * v; - if v < min { - min = v; - } - if v > max { - max = v; - } - } - let std = if n > 1 { - (var / (n as f64 - 1.0)).sqrt() - } else { - 0.0 - }; - let rms = (sq / n as f64).sqrt(); - Self { - n, - mean, - std, - rms, - min, - max, - } - } -} - -/// Stats grouped by an arbitrary string key (e.g. measurement type). -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ResidualsByGroup { - /// Per-group statistics. - pub groups: BTreeMap, - /// Overall stats across every group. - pub overall: ResidualStats, -} - -impl ResidualsByGroup { - /// Compute grouped + overall stats from `(group, value)` pairs. - pub fn from_pairs<'a, I>(items: I) -> Self - where - I: IntoIterator, - { - let mut buckets: BTreeMap> = BTreeMap::new(); - let mut all = Vec::new(); - for (g, v) in items { - buckets.entry(g.to_string()).or_default().push(v); - all.push(v); - } - let groups = buckets - .into_iter() - .map(|(k, v)| (k, ResidualStats::from_slice(&v))) - .collect(); - Self { - groups, - overall: ResidualStats::from_slice(&all), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn basic_stats() { - let s = ResidualStats::from_slice(&[1.0, -1.0, 1.0, -1.0]); - assert_eq!(s.n, 4); - assert!((s.mean).abs() < 1e-12); - assert!((s.rms - 1.0).abs() < 1e-12); - } - - #[test] - fn grouped() { - let g = ResidualsByGroup::from_pairs(vec![ - ("code", 0.5), - ("code", -0.5), - ("phase", 0.01), - ("phase", -0.01), - ]); - assert_eq!(g.groups.len(), 2); - assert!((g.groups["code"].rms - 0.5).abs() < 1e-12); - assert!((g.overall.n) == 4); - } -} diff --git a/src/qc/slr_validation.rs b/src/qc/slr_validation.rs deleted file mode 100644 index 48a9f5f..0000000 --- a/src/qc/slr_validation.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! # SLR validation summaries -//! -//! ## Scientific scope -//! -//! SLR serves as an external geometric check on an orbit solution because -//! it observes two-way range using instrumentation independent from GNSS -//! tracking. This module packages SLR observed-minus-computed residuals -//! into a compact validation report for the current workspace. -//! -//! Its regime is intentionally lightweight and follows the simplified SLR -//! measurement model implemented upstream. Operational-grade bias -//! calibration, atmospheric correction modelling, and station eccentricity -//! handling are beyond the present scope. -//! -//! ## Technical scope -//! -//! The public types are `SlrResidual` and `SlrValidationReport`. Callers -//! provide already modelled residual values and receive a report object -//! ready for QC JSON output or test assertions. -//! -//! Numerical light-time modelling is delegated to `spod- -//! observations::slr`. -//! -//! ## References -//! -//! - Degnan, J. J. (1993). Millimeter accuracy satellite laser ranging: a -//! review. Contributions of Space Geodesy to Geodynamics: Technology, 25, -//! 133-162. -//! - Pearlman, M. R., Noll, C. E., et al. (2019). The ILRS: Current status -//! and future prospects. Journal of Geodesy, 93, 2161-2180. -use super::residuals::ResidualStats; - -/// One O−C residual at an SLR observation epoch. -#[derive(Debug, Clone)] -pub struct SlrResidual { - /// Julian Date (TT) of the bounce epoch. - pub jd_tt: f64, - /// O−C residual, metres. - pub residual_m: f64, -} - -/// Aggregate SLR validation report. -#[derive(Debug, Clone)] -pub struct SlrValidationReport { - /// Per-pass residuals. - pub residuals: Vec, - /// Aggregate statistics. - pub stats: ResidualStats, -} - -impl SlrValidationReport { - /// Build the report from a list of `(jd_tt, residual_m)` pairs. - pub fn from_pairs>(pairs: I) -> Self { - let residuals: Vec = pairs - .into_iter() - .map(|(t, r)| SlrResidual { - jd_tt: t, - residual_m: r, - }) - .collect(); - let values: Vec = residuals.iter().map(|r| r.residual_m).collect(); - let stats = ResidualStats::from_slice(&values); - Self { residuals, stats } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn aggregates_zero_residuals() { - let r = SlrValidationReport::from_pairs((0..5).map(|i| (i as f64, 0.0))); - assert_eq!(r.residuals.len(), 5); - assert!(r.stats.rms.abs() < 1e-12); - } -} diff --git a/src/schema/run_manifest.v1.json b/src/schema/run_manifest.v1.json deleted file mode 100644 index ed8bd94..0000000 --- a/src/schema/run_manifest.v1.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://schemas.siderust.io/pod/run_manifest.v1.json", - "title": "RunManifest", - "description": "Reproducible record of a single spod run. Inputs and outputs are sorted by (kind, path) for deterministic byte-identical serialisation.", - "type": "object", - "required": [ - "run_id", - "tool_version", - "config_sha256", - "inputs", - "outputs", - "started_at", - "finished_at" - ], - "additionalProperties": false, - "properties": { - "run_id": { "type": "string", "minLength": 1 }, - "tool_version": { "type": "string", "minLength": 1 }, - "config_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, - "inputs": { "type": "array", "items": { "$ref": "#/definitions/DatasetRef" } }, - "outputs": { "type": "array", "items": { "$ref": "#/definitions/DatasetRef" } }, - "started_at": { "type": "string", "format": "date-time" }, - "finished_at": { "type": "string", "format": "date-time" } - }, - "definitions": { - "DatasetRef": { - "type": "object", - "required": ["path", "kind", "bytes", "sha256"], - "additionalProperties": false, - "properties": { - "path": { "type": "string", "minLength": 1 }, - "kind": { "type": "string", "minLength": 1 }, - "bytes": { "type": "integer", "minimum": 0 }, - "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } - } - } - } -} diff --git a/src/service/config.rs b/src/service/config.rs index 8eabf20..927124a 100644 --- a/src/service/config.rs +++ b/src/service/config.rs @@ -66,7 +66,7 @@ pub struct ForcesConfig { pub two_body: bool, /// J2 oblateness. pub j2: bool, - /// Sun + Moon third-body. + /// Sun + Moon third-body gravity. Currently rejected by the synthetic-only runner. pub third_body: bool, } @@ -89,6 +89,11 @@ impl RunConfig { if !(self.forces.two_body) { return Err("two_body force must be enabled".into()); } + if self.forces.third_body { + return Err( + "third_body force is not implemented in the current synthetic-only service".into(), + ); + } Ok(()) } } diff --git a/src/service/manifest.rs b/src/service/manifest.rs deleted file mode 100644 index 282de84..0000000 --- a/src/service/manifest.rs +++ /dev/null @@ -1,178 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Copyright (C) 2026 Vallés Puig, Ramon - -//! # Deterministic run manifest -//! -//! ## Scientific scope -//! -//! Reproducible POD requires more than an orbit solution: it also requires -//! a stable record of which inputs, outputs, and software version produced -//! that solution. This module defines the hash-anchored manifest used to -//! capture that provenance for each run. -//! -//! The science represented by the manifest is indirect. It does not model -//! the orbit itself; instead it records the data dependencies and artifacts -//! from which the orbit and diagnostics can be reproduced. -//! -//! ## Technical scope -//! -//! The public items are `DatasetRef`, `RunManifest`, and `canonical_json`. -//! Callers create dataset references from on-disk files, populate manifest -//! fields, and serialize them into a deterministic JSON ordering suitable -//! for hashing and regression checks. -//! -//! Actual estimation, file parsing, and product generation remain in other -//! `spod` modules. -//! -//! ## References -//! -//! - Ben-Kiki, O., Evans, C., & d'Otremont, I. (2021). YAML Ain't Markup -//! Language (YAML) Version 1.2.2. -//! - Bray, T. (2017). The JavaScript Object Notation (JSON) Data -//! Interchange Format. RFC 8259. -use sha2::{Digest, Sha256}; - -/// Reference to a single dataset consumed by a run. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DatasetRef { - /// Logical role inside the run (e.g. `"gnss-obs"`, `"antex"`, `"sp3-precise"`). - pub role: String, - /// File-format identifier (`"SP3"`, `"RINEX-OBS"`, `"ANTEX"`, …). - pub format: String, - /// Resolved on-disk path. - pub path: String, - /// Lowercased hex SHA-256 of the file content. - pub sha256: String, -} - -impl DatasetRef { - /// Compute the SHA-256 of a file and build a `DatasetRef`. - pub fn from_path(role: &str, format: &str, path: &str) -> std::io::Result { - let bytes = std::fs::read(path)?; - let mut h = Sha256::new(); - h.update(&bytes); - Ok(Self { - role: role.to_string(), - format: format.to_string(), - path: path.to_string(), - sha256: hex::encode(h.finalize()), - }) - } -} - -/// Run manifest: enough to reproduce a run, byte for byte (modulo wallclock -/// timestamps which are themselves recorded as parameters). -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct RunManifest { - /// Manifest schema version. - pub schema_version: String, - /// Configuration file hash (SHA-256, lowercase hex). - pub config_sha256: String, - /// Inputs. - pub inputs: Vec, - /// Output product files, with hashes. - pub outputs: Vec, - /// Software version (semver of `spod::service`). - pub software_version: String, - /// Optional free-form notes. - pub notes: Option, -} - -impl RunManifest { - /// New empty manifest pinned to a software version. - pub fn new(software_version: impl Into) -> Self { - Self { - schema_version: "1.0.0".into(), - software_version: software_version.into(), - ..Default::default() - } - } - - /// Compute the canonical SHA-256 of the manifest using the deterministic - /// formatter in [`canonical_json`]. - pub fn canonical_sha256(&self) -> String { - let mut h = Sha256::new(); - h.update(canonical_json(self).as_bytes()); - hex::encode(h.finalize()) - } -} - -/// Deterministic JSON encoder for [`RunManifest`]. Keys are emitted in fixed -/// order; floats are formatted with `{:.17e}` to be byte-stable across -/// platforms. -pub fn canonical_json(m: &RunManifest) -> String { - let mut s = String::new(); - s.push_str("{\n"); - push_kv_str(&mut s, "schema_version", &m.schema_version, false); - push_kv_str(&mut s, "software_version", &m.software_version, false); - push_kv_str(&mut s, "config_sha256", &m.config_sha256, false); - s.push_str(" \"inputs\": [\n"); - for (i, d) in m.inputs.iter().enumerate() { - push_dataset(&mut s, d, i + 1 == m.inputs.len()); - } - s.push_str(" ],\n"); - s.push_str(" \"outputs\": [\n"); - for (i, d) in m.outputs.iter().enumerate() { - push_dataset(&mut s, d, i + 1 == m.outputs.len()); - } - s.push_str(" ],\n"); - match &m.notes { - Some(n) => push_kv_str(&mut s, "notes", n, true), - None => s.push_str(" \"notes\": null\n"), - } - s.push('}'); - s -} - -fn push_kv_str(s: &mut String, k: &str, v: &str, last: bool) { - s.push_str(" \""); - s.push_str(k); - s.push_str("\": "); - s.push('"'); - s.push_str(&escape(v)); - s.push('"'); - s.push_str(if last { "\n" } else { ",\n" }); -} - -fn push_dataset(s: &mut String, d: &DatasetRef, last: bool) { - s.push_str(" { \"role\": \""); - s.push_str(&escape(&d.role)); - s.push_str("\", \"format\": \""); - s.push_str(&escape(&d.format)); - s.push_str("\", \"path\": \""); - s.push_str(&escape(&d.path)); - s.push_str("\", \"sha256\": \""); - s.push_str(&d.sha256); - s.push_str("\" }"); - s.push_str(if last { "\n" } else { ",\n" }); -} - -fn escape(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - match c { - '\\' => out.push_str("\\\\"), - '"' => out.push_str("\\\""), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), - c => out.push(c), - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn canonical_is_deterministic() { - let m = RunManifest::new("0.0.0"); - let a = canonical_json(&m); - let b = canonical_json(&m); - assert_eq!(a, b); - assert_eq!(m.canonical_sha256(), m.canonical_sha256()); - } -} diff --git a/src/service/mod.rs b/src/service/mod.rs index d2ec9bd..611f8f7 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -15,15 +15,15 @@ //! //! ## Technical scope //! -//! The public surface re-exports run configuration, manifest helpers, -//! synthetic-arc generation, the batch pipeline, and the top-level `run` -//! helper, along with selected orbit-state types used by downstream -//! interfaces. Inputs are configuration objects and filesystem roots; -//! outputs are reports and artifact paths. +//! The public surface exposes run configuration, synthetic-arc generation, +//! the batch pipeline, and the top-level `run` helper. Reusable scientific +//! types are imported directly from Siderust rather than re-exported through +//! this service namespace. Inputs are configuration objects and filesystem +//! roots; outputs are reports and artifact paths. //! //! HTTP transport and command-line dispatch live in the `spod-rest` and //! `spod` binaries, while -//! numerical estimation remains in `spod::estimation`. +//! reusable numerical estimation remains in `siderust::pod::estimation`. //! //! ## References //! @@ -35,19 +35,15 @@ #![warn(missing_docs)] pub mod config; -pub mod manifest; pub mod pipeline; +pub mod providers; pub mod rest; pub mod runner; pub mod synth; pub use config::RunConfig; -pub use manifest::{canonical_json, DatasetRef, RunManifest}; -pub use pipeline::{run_synth, ArcEpoch, GpsSatellite, PipelineError, PipelineReport}; +pub use pipeline::{ + run_synth, ArcEpoch, GpsSatellite, PipelineError, PipelineReport, RunProvenance, +}; pub use runner::{run, RunReport}; pub use synth::{generate, SyntheticArc, SyntheticArcConfig}; - -// Re-export the core orbit state types so the binaries can use the service -// facade without adding another public dependency surface. -pub use siderust::astro::dynamics::state::VelocityUnit; -pub use siderust::astro::dynamics::{OrbitState, Position, Velocity}; diff --git a/src/service/pipeline.rs b/src/service/pipeline.rs index fce7188..444e733 100644 --- a/src/service/pipeline.rs +++ b/src/service/pipeline.rs @@ -31,32 +31,24 @@ //! Determination. Elsevier Academic Press. //! - Consultative Committee for Space Data Systems. (2010). Orbit Data //! Messages, CCSDS 502.0-B-2 / 502.0-B-3. -use super::manifest::{canonical_json, DatasetRef, RunManifest}; use super::synth::SyntheticArc; +use super::synth::SyntheticProviders; +use chrono::{SecondsFormat, Utc}; use siderust::astro::dynamics::context::DynamicsContext; use siderust::astro::dynamics::forces::{TwoBody, J2}; use siderust::astro::dynamics::{OrbitState, Position, Velocity, EARTH_J2, GM_EARTH, R_EARTH}; -use siderust::pod::force::{SiderustAccelerationModel, SiderustCompositeModel}; -use siderust::principia::integrators::rk4_propagate_series; -// `finite_diff_stm_series` is upstream-deprecated in favour of the -// variational `propagate_stm`, but the latter only returns Φ at the final -// epoch. Batch least-squares assembly here needs Φ at every measurement -// epoch, which is exactly the use-case the upstream deprecation note -// explicitly preserves the series API for ("no direct variational -// equivalent ... retained for validation workflows that need the STM at -// every intermediate step"). Switching to per-step `propagate_stm` calls -// would re-do the same 12 perturbation propagations per epoch. -use crate::estimation::{ +use siderust::pod::estimation::{ gauss_newton, NonlinearError, NonlinearOptions, NonlinearReport, NormalEquations, }; -use crate::observations::gnss::{CarrierPhaseObs, PseudorangeObs}; -use crate::observations::model::{MeasurementModel, Prediction}; -use crate::products::qc_json::QcDocument; -use crate::products::{ - write_oem_from_states, write_qc_json, write_residuals_csv, write_sp3_from_states, ResidualRow, -}; -use crate::qc::ResidualsByGroup; -use siderust::principia::finite_diff_stm_series; +use siderust::pod::force::{SiderustAccelerationModel, SiderustCompositeModel}; +use siderust::pod::observation::obs_trait::{ObsResidual, Observation}; +use siderust::pod::product::qc_json::{write_qc_json, QcDocument}; +use siderust::pod::product::residuals_csv::{ResidualCsvWriter, ResidualRecord}; +use siderust::pod::product::{write_oem_from_states, write_sp3_from_states}; +use siderust::pod::propagation::{PropagatedArc, VariationalPropagator}; +use siderust::pod::qc::ResidualsByGroup; +use siderust::pod::run::dataset::DatasetRef; +use siderust::pod::run::manifest::RunManifest; use siderust::qtty::Second; use std::fs; use std::path::{Path, PathBuf}; @@ -77,9 +69,15 @@ pub struct ArcEpoch { /// Index of the corresponding state in the propagated series. pub state_index: usize, /// Code observations. - pub code: Vec<(GpsSatellite, PseudorangeObs)>, + pub code: Vec<( + GpsSatellite, + siderust::pod::observation::gnss_obs::GnssPseudorangeObs, + )>, /// Carrier observations. - pub carrier: Vec<(GpsSatellite, CarrierPhaseObs)>, + pub carrier: Vec<( + GpsSatellite, + siderust::pod::observation::gnss_obs::GnssCarrierPhaseObs, + )>, } /// Pipeline errors. @@ -116,6 +114,29 @@ pub struct PipelineReport { pub output_dir: PathBuf, } +/// Service provenance supplied to a synthetic run. +#[derive(Debug, Clone)] +pub struct RunProvenance { + /// SHA-256 of the configuration document used for the run. + pub config_sha256: String, + /// Configuration and external input references consumed by the run. + pub inputs: Vec, +} + +impl RunProvenance { + /// Build provenance from the canonical configuration dataset reference. + pub fn from_config(config: DatasetRef, inputs: Vec) -> Self { + let config_sha256 = config.sha256.clone(); + let mut all_inputs = Vec::with_capacity(inputs.len() + 1); + all_inputs.push(config); + all_inputs.extend(inputs); + Self { + config_sha256, + inputs: all_inputs, + } + } +} + /// Run MVP-1 against a synthetic arc and write all artifacts to `output_dir`. pub fn run_synth( arc: &SyntheticArc, @@ -124,11 +145,12 @@ pub fn run_synth( output_dir: &Path, run_id: &str, enable_j2: bool, + provenance: RunProvenance, ) -> Result { + let started_at = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true); let dt_s = step_size(arc); let n_steps = arc.epochs.len().saturating_sub(1); - let n_sats = arc.gps_sats.len(); - let n_params = 6 + 1 + n_sats; // state + clock + per-sat float ambiguity + let n_params = 7; // state + receiver clock; carrier ambiguity is fixed in the service MVP let mut params = vec![0.0_f64; n_params]; params[0] = initial_guess.position.x().value(); @@ -138,7 +160,6 @@ pub fn run_synth( params[4] = initial_guess.velocity.y().value(); params[5] = initial_guess.velocity.z().value(); params[6] = initial_clock_guess_m; - // Ambiguities (params[7..]) start at zero. let opts = NonlinearOptions { max_iter: 20, @@ -171,15 +192,10 @@ pub fn run_synth( ), ); let clk = report.parameters[6]; - let estimated_states = rk4_propagate_series( - &force, - estimated_initial, - Second::new(dt_s), - n_steps, - &DynamicsContext::empty(), - ) - .map_err(|e| std::io::Error::other(format!("propagation failed: {e:?}")))?; - let estimated_final = *estimated_states.last().unwrap(); + let estimated_arc = propagate_with_stm(&force, estimated_initial, dt_s, n_steps) + .map_err(|e| std::io::Error::other(format!("propagation failed: {e}")))?; + let estimated_states = states_with_initial(estimated_initial, &estimated_arc); + let estimated_final = *estimated_states.last().unwrap_or(&estimated_initial); // Postfit residuals. let residual_rows = postfit_residuals(arc, &estimated_states, &report.parameters); @@ -188,7 +204,7 @@ pub fn run_synth( let groups = ResidualsByGroup::from_pairs( residual_rows .iter() - .map(|r| (residual_kind(&r.kind), r.residual_m)), + .map(|r| (r.obs_type.as_str(), r.residual_m)), ); fs::create_dir_all(output_dir.join("products"))?; @@ -198,17 +214,25 @@ pub fn run_synth( // SP3. { let mut f = fs::File::create(output_dir.join("products/orbit.sp3"))?; - write_sp3_from_states(&mut f, "L01", &estimated_states)?; + write_sp3_from_states(&mut f, "L01", &estimated_states) + .map_err(|e| PipelineError::Io(std::io::Error::other(e.to_string())))?; } // OEM. { let mut f = fs::File::create(output_dir.join("products/orbit.oem"))?; - write_oem_from_states(&mut f, "1900-001A", "POD-LEO", &estimated_states)?; + write_oem_from_states(&mut f, "1900-001A", "POD-LEO", &estimated_states) + .map_err(|e| PipelineError::Io(std::io::Error::other(e.to_string())))?; } // Residuals CSV. { let mut f = fs::File::create(output_dir.join("residuals/residuals.csv"))?; - write_residuals_csv(&mut f, &residual_rows)?; + let mut writer = + ResidualCsvWriter::new(&mut f).map_err(|e| std::io::Error::other(e.to_string()))?; + for row in &residual_rows { + writer + .write_record(row) + .map_err(|e| std::io::Error::other(e.to_string()))?; + } } // qc.json. { @@ -227,30 +251,26 @@ pub fn run_synth( } // Manifest. - let mut manifest = RunManifest::new(env!("CARGO_PKG_VERSION")); - manifest.config_sha256 = String::new(); - manifest.outputs.push(DatasetRef::from_path( - "orbit-sp3", - "SP3", - &output_dir.join("products/orbit.sp3").to_string_lossy(), - )?); - manifest.outputs.push(DatasetRef::from_path( - "orbit-oem", - "OEM", - &output_dir.join("products/orbit.oem").to_string_lossy(), - )?); - manifest.outputs.push(DatasetRef::from_path( - "residuals", - "CSV", - &output_dir.join("residuals/residuals.csv").to_string_lossy(), - )?); - manifest.outputs.push(DatasetRef::from_path( - "qc", - "JSON", - &output_dir.join("qc/qc.json").to_string_lossy(), - )?); + let mut manifest = RunManifest { + run_id: run_id.into(), + tool_version: env!("CARGO_PKG_VERSION").into(), + config_sha256: provenance.config_sha256, + inputs: provenance.inputs, + outputs: vec![ + DatasetRef::from_file(output_dir.join("products/orbit.sp3"), "orbit-sp3")?, + DatasetRef::from_file(output_dir.join("products/orbit.oem"), "orbit-oem")?, + DatasetRef::from_file(output_dir.join("residuals/residuals.csv"), "residuals")?, + DatasetRef::from_file(output_dir.join("qc/qc.json"), "qc")?, + ], + started_at, + finished_at: Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + }; + manifest.canonicalize(); let manifest_path = output_dir.join("run.manifest.json"); - fs::write(&manifest_path, canonical_json(&manifest))?; + fs::write( + &manifest_path, + manifest.to_json_pretty().map_err(std::io::Error::other)?, + )?; Ok(PipelineReport { estimator: report, @@ -291,86 +311,126 @@ fn assemble_normal_equations( dt_s: f64, n_steps: usize, force: &F, -) -> Result { +) -> Result { let s0 = OrbitState::new( arc.truth_states[0].epoch, Position::new(params[0], params[1], params[2]), Velocity::new(params[3], params[4], params[5]), ); - let states = rk4_propagate_series( - force, - s0, - Second::new(dt_s), - n_steps, - &DynamicsContext::empty(), - ) - .map_err(|e| crate::estimation::WlsSolverError::other(format!("propagation failed: {e:?}")))?; - let stms = { - #[allow(deprecated)] - finite_diff_stm_series( - force, - s0, - Second::new(dt_s), - n_steps, - &DynamicsContext::empty(), - ) - .map_err(|e| crate::estimation::WlsSolverError::other(format!("STM failed: {e:?}")))? - }; - let n_sats = arc.gss_count(); - let n_params = 6 + 1 + n_sats; + let propagated = propagate_with_stm(force, s0, dt_s, n_steps).map_err(|e| { + siderust::pod::estimation::WlsSolverError::other(format!("propagation failed: {e}")) + })?; + let states = states_with_initial(s0, &propagated); + let n_params = 7; let mut ne = NormalEquations::new(n_params); - let extras_for_state = ¶ms[6..]; + let providers = SyntheticProviders { + receiver_clock_m: params[6], + }; for ep in &arc.epochs { let s = &states[ep.state_index]; - let phi_mat = *stms[ep.state_index].as_array(); - let phi = &phi_mat; + let phi = if ep.state_index == 0 { + identity_stm() + } else { + *propagated.steps[ep.state_index - 1].1.as_array() + }; for (_sat, obs) in &ep.code { - let model = crate::observations::gnss::GnssCodeModel { - obs: *obs, - clock_bias_index: 0, - }; - let pred = model.predict(s, extras_for_state); - let row = chain_row_sparse(&pred, phi); - let resid = obs.measured_m - pred.value; - ne.add_row(&row, resid, obs.sigma_m)?; + let resid = obs + .residual(s, &providers) + .map_err(|e| siderust::pod::estimation::WlsSolverError::other(e.to_string()))?; + let row = observation_row(obs, s, &providers, &phi, 6); + ne.add_row(&row, resid, obs.sigma.value())?; } - for (sat, obs) in &ep.carrier { - let model = crate::observations::gnss::GnssCarrierModel { - obs: *obs, - clock_bias_index: 0, - ambiguity_index: 1 + sat.slot, - }; - let pred = model.predict(s, extras_for_state); - let row = chain_row_sparse(&pred, phi); - let resid = obs.measured_m - pred.value; - ne.add_row(&row, resid, obs.sigma_m)?; + for (_sat, obs) in &ep.carrier { + let resid = obs + .residual(s, &providers) + .map_err(|e| siderust::pod::estimation::WlsSolverError::other(e.to_string()))? + .residual_m; + let row = observation_row(obs, s, &providers, &phi, 6); + ne.add_row(&row, resid, obs.sigma.value())?; } } Ok(ne) } -fn chain_row_sparse(pred: &Prediction, phi: &[[f64; 6]; 6]) -> Vec<(usize, f64)> { - let mut state_partials = [0.0_f64; 6]; - let mut extras: Vec<(usize, f64)> = Vec::new(); - for (j, v) in pred.partials.entries.iter() { - if *j < 6 { - state_partials[*j] = *v; - } else { - extras.push((*j, *v)); - } +fn propagate_with_stm( + force: &F, + initial: OrbitState, + dt_s: f64, + n_steps: usize, +) -> Result { + VariationalPropagator { + step: Second::new(dt_s), + } + .propagate(force, initial, n_steps, &DynamicsContext::empty()) +} + +fn states_with_initial(initial: OrbitState, propagated: &PropagatedArc) -> Vec { + let mut states = Vec::with_capacity(propagated.steps.len() + 1); + states.push(initial); + states.extend(propagated.steps.iter().map(|(state, _)| *state)); + states +} + +fn identity_stm() -> [[f64; 6]; 6] { + [ + [1.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 1.0], + ] +} + +fn observation_row( + obs: &O, + state: &OrbitState, + providers: &SyntheticProviders, + phi: &[[f64; 6]; 6], + clock_index: usize, +) -> Vec<(usize, f64)> +where + ObsResidual: From, +{ + let h = [1e-3, 1e-3, 1e-3, 1e-6, 1e-6, 1e-6]; + let mut local = [0.0; 6]; + for i in 0..6 { + let mut p = *state; + let mut q = *state; + let mut pv = [ + p.position.x().value(), + p.position.y().value(), + p.position.z().value(), + p.velocity.x().value(), + p.velocity.y().value(), + p.velocity.z().value(), + ]; + let mut qv = pv; + pv[i] += h[i]; + qv[i] -= h[i]; + p = OrbitState::new( + p.epoch, + Position::new(pv[0], pv[1], pv[2]), + Velocity::new(pv[3], pv[4], pv[5]), + ); + q = OrbitState::new( + q.epoch, + Position::new(qv[0], qv[1], qv[2]), + Velocity::new(qv[3], qv[4], qv[5]), + ); + let rp = observation_residual(obs, &p, providers); + let rq = observation_residual(obs, &q, providers); + local[i] = -(rp - rq) / (2.0 * h[i]); } - let mut out = Vec::with_capacity(6 + extras.len()); + let mut out = Vec::with_capacity(7); for k in 0..6 { - let mut acc = 0.0; - for i in 0..6 { - acc += state_partials[i] * phi[i][k]; - } - if acc != 0.0 { - out.push((k, acc)); + let value: f64 = (0..6).map(|i| local[i] * phi[i][k]).sum(); + if value != 0.0 { + out.push((k, value)); } } - out.extend(extras); + out.push((clock_index, 1.0)); out } @@ -378,58 +438,52 @@ fn postfit_residuals( arc: &SyntheticArc, states: &[OrbitState], params: &[f64], -) -> Vec { - let extras = ¶ms[6..]; +) -> Vec { + let providers = SyntheticProviders { + receiver_clock_m: params[6], + }; let mut out = Vec::new(); for ep in &arc.epochs { let s = &states[ep.state_index]; for (sat, obs) in &ep.code { - let model = crate::observations::gnss::GnssCodeModel { - obs: *obs, - clock_bias_index: 0, - }; - let p = model.predict(s, extras); - out.push(ResidualRow { - jd_tt: s.epoch.to::().value(), - kind: format!("code-{}", sat.id), - measured_m: obs.measured_m, - predicted_m: p.value, - residual_m: obs.measured_m - p.value, - sigma_m: obs.sigma_m, + let residual = obs.residual(s, &providers).expect("postfit observation"); + out.push(ResidualRecord { + epoch_jd_tt: s.epoch.to::().value(), + obs_type: "code".into(), + satellite: sat.id.clone(), + residual_m: residual, + sigma_m: obs.sigma.value(), + rejected: false, }); } for (sat, obs) in &ep.carrier { - let model = crate::observations::gnss::GnssCarrierModel { - obs: *obs, - clock_bias_index: 0, - ambiguity_index: 1 + sat.slot, - }; - let p = model.predict(s, extras); - out.push(ResidualRow { - jd_tt: s.epoch.to::().value(), - kind: format!("phase-{}", sat.id), - measured_m: obs.measured_m, - predicted_m: p.value, - residual_m: obs.measured_m - p.value, - sigma_m: obs.sigma_m, + let residual = obs + .residual(s, &providers) + .expect("postfit observation") + .residual_m; + out.push(ResidualRecord { + epoch_jd_tt: s.epoch.to::().value(), + obs_type: "phase".into(), + satellite: sat.id.clone(), + residual_m: residual, + sigma_m: obs.sigma.value(), + rejected: false, }); } } out } -fn residual_kind(name: &str) -> &str { - if name.starts_with("code-") { - "code" - } else if name.starts_with("phase-") { - "phase" - } else { - "other" - } -} - -impl SyntheticArc { - fn gss_count(&self) -> usize { - self.gps_sats.len() +fn observation_residual(obs: &O, state: &OrbitState, providers: &SyntheticProviders) -> f64 +where + O: Observation, + ObsResidual: From, +{ + match ObsResidual::from( + obs.residual(state, providers) + .expect("synthetic observation"), + ) { + ObsResidual::Scalar(value) => value, + ObsResidual::Phase(value) => value.residual_m, } } diff --git a/src/service/providers.rs b/src/service/providers.rs new file mode 100644 index 0000000..3c1772d --- /dev/null +++ b/src/service/providers.rs @@ -0,0 +1,17 @@ +//! 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/service/rest.rs b/src/service/rest.rs index 573011e..58856bb 100644 --- a/src/service/rest.rs +++ b/src/service/rest.rs @@ -30,16 +30,19 @@ #![warn(missing_docs)] use std::collections::HashMap; -use std::path::PathBuf; +use std::fs; +use std::path::{Path as FsPath, PathBuf}; use std::sync::{Arc, Mutex}; -use crate::service::{generate, run_synth, OrbitState, Position, SyntheticArcConfig, Velocity}; +use crate::service::{generate, run_synth, SyntheticArcConfig}; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; +use siderust::astro::dynamics::{OrbitState, Position, Velocity}; +use siderust::pod::run::dataset::DatasetRef; use uuid::Uuid; /// Job status snapshot. @@ -80,7 +83,7 @@ impl AppState { } } -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Serialize, Deserialize, Default)] struct JobRequest { #[serde(default)] enable_j2: bool, @@ -111,7 +114,7 @@ async fn submit_job( let id_clone = id.clone(); let state_clone = state.clone(); tokio::task::spawn_blocking(move || { - run_job(state_clone, id_clone, req.enable_j2); + run_job(state_clone, id_clone, req); }); (StatusCode::ACCEPTED, Json(JobAccepted { id })) } @@ -124,7 +127,25 @@ async fn job_status(State(state): State, Path(id): Path) -> im } } -fn run_job(state: AppState, id: String, enable_j2: bool) { +fn persist_request(output_dir: &FsPath, req: &JobRequest) -> std::io::Result { + fs::create_dir_all(output_dir)?; + let request_path = output_dir.join("request.json"); + let request_bytes = serde_json::to_vec(req).map_err(std::io::Error::other)?; + fs::write(&request_path, request_bytes)?; + DatasetRef::from_file(request_path, "configuration") +} + +fn fail_job(state: &AppState, id: &str, error: impl ToString) { + let mut table = state.inner.lock().unwrap(); + table.insert( + id.to_owned(), + JobStatus::Failed { + error: error.to_string(), + }, + ); +} + +fn run_job(state: AppState, id: String, req: JobRequest) { { let mut t = state.inner.lock().unwrap(); t.insert(id.clone(), JobStatus::Running); @@ -146,7 +167,22 @@ fn run_job(state: AppState, id: String, enable_j2: bool) { ), ); let out = state.output_root.join(&id); - let result = run_synth(&arc, init, 0.0, &out, &id, enable_j2); + let request_ref = match persist_request(&out, &req) { + Ok(request_ref) => request_ref, + Err(error) => { + fail_job(&state, &id, error); + return; + } + }; + let result = run_synth( + &arc, + init, + 0.0, + &out, + &id, + req.enable_j2, + crate::service::RunProvenance::from_config(request_ref, Vec::new()), + ); let mut t = state.inner.lock().unwrap(); match result { Ok(report) => { @@ -176,3 +212,39 @@ pub fn router(state: AppState) -> Router { .route("/jobs/:id", get(job_status)) .with_state(state) } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn persisted_request_is_a_real_hashed_manifest_input() { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "spod-rest-request-test-{}-{suffix}", + std::process::id() + )); + let req = JobRequest { enable_j2: true }; + + let dataset = persist_request(&root, &req).unwrap(); + let request_path = root.join("request.json"); + + assert_eq!(dataset.path, request_path); + assert_eq!(dataset.kind, "configuration"); + assert!(dataset.path.is_file()); + + let actual = DatasetRef::from_file(&dataset.path, "configuration").unwrap(); + assert_eq!(dataset.bytes, actual.bytes); + assert_eq!(dataset.sha256, actual.sha256); + + let persisted: JobRequest = + serde_json::from_slice(&fs::read(&dataset.path).unwrap()).unwrap(); + assert_eq!(persisted.enable_j2, req.enable_j2); + + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src/service/runner.rs b/src/service/runner.rs index 648a38a..0ba6000 100644 --- a/src/service/runner.rs +++ b/src/service/runner.rs @@ -27,9 +27,10 @@ //! - Consultative Committee for Space Data Systems. (2010). Orbit Data //! Messages, CCSDS 502.0-B-2 / 502.0-B-3. use super::config::RunConfig; -use super::pipeline::run_synth; +use super::pipeline::{run_synth, RunProvenance}; use super::synth::{generate, SyntheticArcConfig}; use siderust::astro::dynamics::{OrbitState, Position, Velocity}; +use siderust::pod::run::dataset::DatasetRef; use std::path::PathBuf; /// Outcome of a run. @@ -41,25 +42,43 @@ pub struct RunReport { pub manifest_path: PathBuf, /// Number of integration steps. pub n_steps: usize, + /// Number of nonlinear estimation iterations. + pub estimator_iterations: usize, + /// Final reduced chi-squared statistic. + pub reduced_chi2: f64, } /// Run a configuration end-to-end. -pub fn run(cfg: &RunConfig, _config_path: &str) -> std::io::Result { +pub fn run(cfg: &RunConfig, config_path: &str) -> std::io::Result { cfg.validate() .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; let output_dir = PathBuf::from(&cfg.output_dir); - if cfg.inputs.sp3.is_some() || cfg.inputs.rinex_obs.is_some() { - // Refuse rather than silently propagate-and-ignore. + let configured_inputs = [ + ("sp3", cfg.inputs.sp3.as_ref()), + ("rinex_obs", cfg.inputs.rinex_obs.as_ref()), + ("rinex_nav", cfg.inputs.rinex_nav.as_ref()), + ("antex", cfg.inputs.antex.as_ref()), + ] + .into_iter() + .filter_map(|(name, value)| value.as_ref().map(|_| name)) + .collect::>(); + + if !configured_inputs.is_empty() { return Err(std::io::Error::new( std::io::ErrorKind::Unsupported, - "not implemented: real GNSS ingestion (SP3 / RINEX OBS → estimator) is scheduled \ - for milestone M9; see plan.md §13.3. The current build only supports the \ - synthetic-arc MVP-1 pipeline (`inputs.sp3` and `inputs.rinex_obs` both null).", + format!( + "real-data ingestion is not implemented in the current synthetic-only service; \ + unsupported configured inputs: {}. Set all entries under `inputs` to null.", + configured_inputs.join(", ") + ), )); } + let config_ref = DatasetRef::from_file(config_path, "configuration")?; + let provenance = RunProvenance::from_config(config_ref, Vec::new()); + // Synthetic-arc MVP-1 pipeline. let synth_cfg = SyntheticArcConfig::default(); let arc = generate(&synth_cfg); @@ -77,11 +96,21 @@ pub fn run(cfg: &RunConfig, _config_path: &str) -> std::io::Result { t0.velocity.z().value() + 5e-5, ), ); - let report = run_synth(&arc, init, 0.0, &output_dir, &cfg.run_id, cfg.forces.j2) - .map_err(std::io::Error::other)?; + let report = run_synth( + &arc, + init, + 0.0, + &output_dir, + &cfg.run_id, + cfg.forces.j2, + provenance, + ) + .map_err(std::io::Error::other)?; Ok(RunReport { final_state: report.estimated_final, manifest_path: report.manifest_path, n_steps: arc.epochs.len(), + estimator_iterations: report.estimator.iterations, + reduced_chi2: report.estimator.last.reduced_chi2(), }) } diff --git a/src/service/synth.rs b/src/service/synth.rs index fb29e6f..e06e61a 100644 --- a/src/service/synth.rs +++ b/src/service/synth.rs @@ -29,15 +29,18 @@ //! - Consultative Committee for Space Data Systems. (2010). Orbit Data //! Messages, CCSDS 502.0-B-2 / 502.0-B-3. use super::pipeline::{ArcEpoch, GpsSatellite}; -use crate::observations::gnss::{CarrierPhaseObs, GnssCodeModel, PseudorangeObs}; -use crate::observations::model::MeasurementModel; use siderust::astro::dynamics::context::DynamicsContext; use siderust::astro::dynamics::forces::TwoBody; use siderust::astro::dynamics::state::VelocityUnit; use siderust::astro::dynamics::{OrbitState, Position, Velocity}; use siderust::coordinates::frames::GCRS; +use siderust::pod::observation::gnss_obs::{ + GnssCarrierPhaseObs, GnssPseudorangeObs, IonoModel, TropModel, +}; +use siderust::pod::observation::obs_trait::Observation; +use siderust::pod::observation::provider_bundle::ProviderBundle; use siderust::principia::integrators::rk4_propagate_series; -use siderust::qtty::Second; +use siderust::qtty::{Hertzs, Meters, Second}; use siderust::time::JulianDate; /// Configuration for a synthetic arc. @@ -57,8 +60,6 @@ pub struct SyntheticArcConfig { pub carrier_sigma_m: f64, /// Receiver clock bias (truth), metres. pub clock_bias_m: f64, - /// Carrier float ambiguity per satellite (truth), metres. - pub ambiguity_m: f64, /// Random seed. pub seed: u64, } @@ -79,7 +80,6 @@ impl Default for SyntheticArcConfig { code_sigma_m: 0.5, carrier_sigma_m: 0.005, clock_bias_m: 12_345.6, - ambiguity_m: 0.0, seed: 0xC0FFEE, } } @@ -98,6 +98,30 @@ pub struct SyntheticArc { pub truth_clock_bias_m: f64, } +#[derive(Clone, Copy)] +pub(crate) struct SyntheticProviders { + pub(crate) receiver_clock_m: f64, +} + +impl ProviderBundle for SyntheticProviders { + fn gnss_satellite_state_gcrs( + &self, + _prn: &str, + _epoch: JulianDate, + ) -> Option<(Position, Velocity)> { + None + } + fn gnss_satellite_clock_m(&self, _prn: &str, _epoch: JulianDate) -> Meters { + Meters::new(0.0) + } + fn receiver_clock_m(&self) -> Meters { + Meters::new(self.receiver_clock_m) + } + fn station_gcrs_km(&self, _station_id: &str, _epoch: JulianDate) -> Option> { + None + } +} + fn gps_state_at( jd: JulianDate, slot: usize, @@ -175,29 +199,46 @@ pub fn generate(cfg: &SyntheticArcConfig) -> SyntheticArc { sat.slot, cfg.n_gps_sats, ); - // Use the analytic prediction at *truth* state and add noise + - // truth clock bias to obtain the synthetic measurement. - let geom = code_truth_m(s, gps_pos, gps_vel) + cfg.clock_bias_m; - let measured_code = geom + cfg.code_sigma_m * rng.normal(); - let measured_phase = geom + cfg.ambiguity_m + cfg.carrier_sigma_m * rng.normal(); - code.push(( - sat.clone(), - PseudorangeObs { - gps_pos_km: gps_pos, - gps_vel_km_s: gps_vel, - measured_m: measured_code, - sigma_m: cfg.code_sigma_m, - }, - )); - carrier.push(( - sat.clone(), - CarrierPhaseObs { - gps_pos_km: gps_pos, - gps_vel_km_s: gps_vel, - measured_m: measured_phase, - sigma_m: cfg.carrier_sigma_m, - }, - )); + let epoch = s.epoch.to::(); + let providers = SyntheticProviders { + receiver_clock_m: cfg.clock_bias_m, + }; + let mut code_obs = GnssPseudorangeObs { + prn: sat.id.clone(), + epoch, + measured_m: Meters::new(0.0), + sigma: Meters::new(cfg.code_sigma_m), + sat_pos_gcrs_km: gps_pos, + sat_vel_gcrs_km_s: gps_vel, + trop: TropModel::None, + iono: IonoModel::IonoFree, + frequency_hz: Hertzs::new(1_575_420_000.0), + }; + let mut phase_obs = GnssCarrierPhaseObs { + prn: sat.id.clone(), + epoch, + measured_m: Meters::new(0.0), + sigma: Meters::new(cfg.carrier_sigma_m), + sat_pos_gcrs_km: gps_pos, + sat_vel_gcrs_km_s: gps_vel, + trop: TropModel::None, + iono: IonoModel::IonoFree, + frequency_hz: Hertzs::new(1_575_420_000.0), + integer_ambiguity: 0, + }; + let code_truth = -code_obs + .residual(s, &providers) + .expect("synthetic observation"); + let phase_truth = -phase_obs + .residual(s, &providers) + .expect("synthetic observation") + .residual_m; + let measured_code = code_truth + cfg.code_sigma_m * rng.normal(); + let measured_phase = phase_truth + cfg.carrier_sigma_m * rng.normal(); + code_obs.measured_m = Meters::new(measured_code); + phase_obs.measured_m = Meters::new(measured_phase); + code.push((sat.clone(), code_obs)); + carrier.push((sat.clone(), phase_obs)); } epochs.push(ArcEpoch { state_index: epochs.len(), @@ -212,21 +253,3 @@ pub fn generate(cfg: &SyntheticArcConfig) -> SyntheticArc { truth_clock_bias_m: cfg.clock_bias_m, } } - -fn code_truth_m( - state: &OrbitState, - gps_pos: Position, - gps_vel: Velocity, -) -> f64 { - let model = GnssCodeModel { - obs: PseudorangeObs { - gps_pos_km: gps_pos, - gps_vel_km_s: gps_vel, - measured_m: 0.0, - sigma_m: 1.0, - }, - clock_bias_index: 0, - }; - let p = model.predict(state, &[0.0]); - p.value -} diff --git a/src/sgp4/mod.rs b/src/sgp4/mod.rs index bc27a89..9033765 100644 --- a/src/sgp4/mod.rs +++ b/src/sgp4/mod.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Vallés Puig, Ramon -//! `siderust-sgp4` — strongly typed SGP4 / SDP4 propagator producing TEME +//! `spod::sgp4` — strongly typed SGP4 / SDP4 propagator producing TEME //! Cartesian states. //! //! ## What this crate is diff --git a/src/spice/mod.rs b/src/spice/mod.rs index 1aafafe..e10d618 100644 --- a/src/spice/mod.rs +++ b/src/spice/mod.rs @@ -1,15 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Vallés Puig, Ramon -//! `siderust-spice` — DAF/SPK kernel reader and `SpiceEphemerisProvider` -//! adapter on top of [`siderust::formats::spice::{daf, spk}`]. +//! 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 //! -//! This crate fills the gap between the upstream `siderust::formats::spice::{daf, spk}` -//! Type-2 reader and the production-grade SPK feature set required by POD. -//! It re-exports the upstream DAF parser and Type-2 segment reader, and -//! adds: +//! 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). @@ -19,7 +21,7 @@ //! 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::core::providers::EphemerisProvider`]. Center selection +//! [`crate::service::providers::EphemerisProvider`]. Center selection //! is **explicit** at construction time and is propagated as a typed //! `affn` reference center on the returned [`SpiceState`]. //! @@ -76,26 +78,3 @@ 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}; - -/// Re-export of the upstream DAF container parser. -/// -/// `siderust::formats::spice::daf` provides a strict, panic-free DAF file parser -/// that this crate uses as the foundation for SPK segment indexing. It -/// is re-exported here so downstream consumers can manipulate raw DAF -/// summaries without depending on `siderust` directly. -pub mod daf { - pub use siderust::formats::spice::daf::{Daf, Summary}; -} - -/// Re-export of the upstream SPK Type 2 reader. -/// -/// The upstream `siderust::formats::spice::spk` module already exposes a -/// production-quality Type 2 / Type 3 raw reader (`read_type2_segment`, -/// `parse_bsp`) plus the standard NAIF body-id constants. They are -/// re-exported here for callers that prefer the legacy entry points. -pub mod spk { - pub use siderust::formats::spice::spk::{ - parse_bsp, read_type2_segment, BspSegments, SegmentData, EMB_CENTER, EMB_TARGET, - MOON_CENTER, MOON_TARGET, SUN_CENTER, SUN_TARGET, - }; -} diff --git a/src/spice/provider.rs b/src/spice/provider.rs index 2007f56..2acc173 100644 --- a/src/spice/provider.rs +++ b/src/spice/provider.rs @@ -1,8 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Vallés Puig, Ramon -//! [`SpiceEphemerisProvider`] — typed adapter implementing -//! [`crate::core::providers::EphemerisProvider`]. +//! [`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. @@ -14,7 +14,7 @@ use std::marker::PhantomData; use std::sync::Arc; -use crate::core::providers::EphemerisProvider; +use crate::service::providers::EphemerisProvider; use siderust::affn::cartesian::Position; use siderust::coordinates::centers::ReferenceCenter; use siderust::coordinates::frames::ICRS; @@ -53,7 +53,7 @@ pub struct SpiceState> { /// ```rust,no_run /// use spod::spice::{SpkKernel, SpiceEphemerisProvider, well_known}; /// use siderust::coordinates::centers::Barycentric; -/// use spod::core::providers::EphemerisProvider; +/// use spod::service::providers::EphemerisProvider; /// /// let kernel = SpkKernel::open("de440.bsp")?; /// let provider: SpiceEphemerisProvider = diff --git a/src/spice/segment.rs b/src/spice/segment.rs index 20f7029..6d43dbd 100644 --- a/src/spice/segment.rs +++ b/src/spice/segment.rs @@ -176,7 +176,8 @@ impl ChebSegment { /// # Examples /// /// ```rust -/// use spod::spice::{daf::{Daf, Summary}, segment_for_summary, SpiceError}; +/// 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. diff --git a/src/tle/builder.rs b/src/tle/builder.rs index b82994f..a3572cb 100644 --- a/src/tle/builder.rs +++ b/src/tle/builder.rs @@ -4,7 +4,7 @@ //! Programmatic constructor for [`Tle`] records. //! //! [`TleBuilder`] is the synthesis counterpart of the parser in -//! [`crate::parse_tle`]. It is used by `siderust-sgp4` tests and by any +//! [`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). //! diff --git a/src/tle/error.rs b/src/tle/error.rs index 11cfe37..6b6bf35 100644 --- a/src/tle/error.rs +++ b/src/tle/error.rs @@ -1,13 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Vallés Puig, Ramon -//! Unified error taxonomy for `siderust-tle`. +//! 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 `siderust-tle`. +/// 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 diff --git a/src/tle/mod.rs b/src/tle/mod.rs index 4ad3ad8..36ece2b 100644 --- a/src/tle/mod.rs +++ b/src/tle/mod.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Vallés Puig, Ramon -//! `siderust-tle` — Two-Line Element / 3LE / OMM parser & writer. +//! `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 @@ -17,7 +17,7 @@ //! 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 -//! `siderust-sgp4` regression tests), +//! `spod::sgp4` regression tests), //! * single error taxonomy via [`TleError`] (no `anyhow`). //! //! ## Design notes @@ -37,7 +37,7 @@ //! //! Out-of-scope: //! -//! * SGP4/SDP4 propagation lives in `siderust-sgp4`. +//! * SGP4/SDP4 propagation lives in `spod::sgp4`. //! //! # Examples //! diff --git a/tests/service_synthetic.rs b/tests/service_synthetic.rs new file mode 100644 index 0000000..593149a --- /dev/null +++ b/tests/service_synthetic.rs @@ -0,0 +1,141 @@ +//! End-to-end coverage for the service-owned synthetic run boundary. + +use chrono::DateTime; +use siderust::pod::run::dataset::DatasetRef; +use siderust::pod::run::manifest::{RunManifest, RUN_MANIFEST_SCHEMA_V1}; +use spod::service::config::{ForcesConfig, InputsConfig}; +use spod::service::{run, RunConfig}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn base_config(output_dir: &Path) -> RunConfig { + RunConfig { + schema_version: "1.0.0".into(), + run_id: "integration-test".into(), + inputs: InputsConfig { + sp3: None, + rinex_obs: None, + rinex_nav: None, + antex: None, + }, + output_dir: output_dir.display().to_string(), + forces: ForcesConfig { + two_body: true, + j2: false, + third_body: false, + }, + } +} + +#[test] +fn synthetic_service_produces_valid_manifest_and_products() { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = + std::env::temp_dir().join(format!("spod-service-test-{}-{suffix}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + let config_path = root.join("run.yaml"); + let output_dir = root.join("output"); + fs::write( + &config_path, + format!( + "schema_version: 1.0.0\nrun_id: integration-test\ninputs:\n sp3: null\n rinex_obs: null\n rinex_nav: null\n antex: null\noutput_dir: {}\nforces:\n two_body: true\n j2: false\n third_body: false\n", + output_dir.display() + ), + ) + .unwrap(); + + let config: RunConfig = RunConfig::from_yaml_file(config_path.to_str().unwrap()).unwrap(); + let report = run(&config, config_path.to_str().unwrap()).unwrap(); + assert!(report.estimator_iterations > 0); + assert!(report.reduced_chi2.is_finite()); + + let manifest_text = fs::read_to_string(&report.manifest_path).unwrap(); + let manifest: RunManifest = serde_json::from_str(&manifest_text).unwrap(); + assert_eq!(manifest.run_id, "integration-test"); + assert_eq!(manifest.config_sha256.len(), 64); + assert!(manifest + .config_sha256 + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); + assert!(DateTime::parse_from_rfc3339(&manifest.started_at).is_ok()); + assert!(DateTime::parse_from_rfc3339(&manifest.finished_at).is_ok()); + + assert_eq!(manifest.inputs.len(), 1); + let config_input = &manifest.inputs[0]; + assert_eq!(config_input.kind, "configuration"); + assert_eq!(config_input.sha256, manifest.config_sha256); + let actual_config = DatasetRef::from_file(&config_path, "configuration").unwrap(); + assert_eq!(config_input.bytes, actual_config.bytes); + assert_eq!(config_input.sha256, actual_config.sha256); + + let schema: serde_json::Value = serde_json::from_str(RUN_MANIFEST_SCHEMA_V1).unwrap(); + for field in [ + "run_id", + "tool_version", + "config_sha256", + "inputs", + "outputs", + "started_at", + "finished_at", + ] { + assert!(schema["required"] + .as_array() + .unwrap() + .iter() + .any(|v| v == field)); + } + for output in &manifest.outputs { + let path = if output.path.is_absolute() { + output.path.clone() + } else { + PathBuf::from(&output.path) + }; + assert!(path.is_file(), "missing output {}", path.display()); + let actual = DatasetRef::from_file(&path, output.kind.clone()).unwrap(); + assert_eq!(actual.bytes, output.bytes); + assert_eq!(actual.sha256, output.sha256); + } + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn synthetic_service_rejects_every_real_data_input() { + let output_dir = std::env::temp_dir().join("spod-unsupported-input-test"); + + for input_name in ["sp3", "rinex_obs", "rinex_nav", "antex"] { + let mut cfg = base_config(&output_dir); + match input_name { + "sp3" => cfg.inputs.sp3 = Some("unused.sp3".into()), + "rinex_obs" => cfg.inputs.rinex_obs = Some("unused.obs".into()), + "rinex_nav" => cfg.inputs.rinex_nav = Some("unused.nav".into()), + "antex" => cfg.inputs.antex = Some("unused.atx".into()), + _ => unreachable!(), + } + + let err = run(&cfg, "unused-config.yaml").unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::Unsupported); + assert!( + err.to_string().contains(input_name), + "diagnostic for {input_name} did not identify the unsupported input: {err}" + ); + } +} + +#[test] +fn synthetic_service_rejects_unsupported_third_body_force() { + let output_dir = std::env::temp_dir().join("spod-unsupported-third-body-test"); + let mut cfg = base_config(&output_dir); + cfg.forces.third_body = true; + + let err = run(&cfg, "unused-config.yaml").unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert!( + err.to_string().contains("third_body"), + "diagnostic did not identify the unsupported third-body force: {err}" + ); +}