diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e1cd65..9854df9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,51 +6,10 @@ on: pull_request: jobs: - test: - name: Test Outfit - runs-on: ubuntu-latest - - strategy: - matrix: - include: - - name: Default features - features: "" - - name: With jpl-download feature - features: "--features jpl-download" - - name: With parallel feature - features: '--features "parallel"' - rayon_threads: "2" - - name: With parallel + progress - features: '--features "parallel progress"' - rayon_threads: "2" - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo registry - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - - name: Run tests (${{ matrix.name }}) - env: - # Cap Rayon threads only when provided in the matrix (no effect otherwise) - RAYON_NUM_THREADS: ${{ matrix.rayon_threads }} - run: | - echo "Running cargo test with features: '${{ matrix.features }}'" - cargo test --locked --all-targets ${{ matrix.features }} - fmt: name: Check formatting runs-on: ubuntu-latest + if: github.event_name == 'pull_request' steps: - name: Checkout repository uses: actions/checkout@v4 @@ -70,8 +29,12 @@ jobs: run: cargo fmt --all -- --check clippy: - name: Clippy lint + name: Clippy lint (${{ matrix.features }}) runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + strategy: + matrix: + features: [default, parallel] steps: - name: Checkout repository uses: actions/checkout@v4 @@ -86,17 +49,42 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}-cargo-${{ matrix.features }}-${{ hashFiles('**/Cargo.lock') }} - name: check version run: | rustc --version cargo clippy --version - - name: Run cargo clippy (all features) - run: cargo clippy --all-targets --all-features -- -D warnings + - name: Run cargo clippy + run: | + if [ "${{ matrix.features }}" = "default" ]; then + cargo clippy --all-targets -- -D warnings + else + cargo clippy --all-targets --features ${{ matrix.features }} -- -D warnings + fi + + semver-pr: + name: SemVer (PR vs base) + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check semver (baseline = crates.io) + uses: obi1kenobi/cargo-semver-checks-action@v2 coverage: - name: Coverage (stable, cargo-llvm-cov → Codecov) + name: Coverage (${{ matrix.features }}) runs-on: ubuntu-latest + needs: [fmt, clippy, semver-pr] + if: | + always() && + (needs.fmt.result == 'success' || needs.fmt.result == 'skipped') && + (needs.clippy.result == 'success' || needs.clippy.result == 'skipped') && + (needs.semver-pr.result == 'success' || needs.semver-pr.result == 'skipped') + strategy: + matrix: + features: [default, parallel] steps: - name: Checkout repository @@ -114,7 +102,7 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: ${{ runner.os }}-cargo-llvmcov-${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}-cargo-llvmcov-${{ matrix.features }}-${{ hashFiles('**/Cargo.lock') }} - name: Install cargo-llvm-cov run: cargo install cargo-llvm-cov @@ -122,38 +110,27 @@ jobs: - name: Clean coverage artifacts run: cargo llvm-cov clean --workspace - - name: Generate coverage (lcov, all features incl. parallel) - env: - # Tame thread count for reproducible & stable coverage - RAYON_NUM_THREADS: 2 + - name: Generate coverage (lcov) run: | - cargo llvm-cov \ - --workspace \ - --all-features \ - --lcov --output-path lcov.info + if [ "${{ matrix.features }}" = "default" ]; then + RUST_TEST_THREADS=1 \ + RAYON_NUM_THREADS=2 \ + cargo llvm-cov \ + --workspace \ + --lcov --output-path lcov.info + else + RUST_TEST_THREADS=1 \ + RAYON_NUM_THREADS=2 \ + cargo llvm-cov \ + --workspace \ + --features ${{ matrix.features }} \ + --lcov --output-path lcov.info + fi - name: Upload to Codecov uses: codecov/codecov-action@v4 with: files: lcov.info - flags: rust + flags: ${{ matrix.features }} fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} - - semver-pr: - name: SemVer (PR vs base) - runs-on: ubuntu-latest - steps: - - name: Checkout repository - # IMPORTANT: full history so the base SHA exists locally - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true - ref: ${{ github.event.pull_request.head.sha }} - - - name: Check semver (baseline = PR base) - uses: obi1kenobi/cargo-semver-checks-action@v2 - with: - feature-group: all-features - baseline-rev: ${{ github.event.pull_request.base.sha }} diff --git a/.husky/hooks/pre-commit b/.husky/hooks/pre-commit index c20ca62..e5fb21d 100755 --- a/.husky/hooks/pre-commit +++ b/.husky/hooks/pre-commit @@ -10,4 +10,4 @@ echo "Running cargo check..." cargo check --all-targets --all-features || exit 1 echo "Checking documentation build..." -RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features || exit 1 +RUSTDOCFLAGS="--html-in-header $(pwd)/katex-header.html -D warnings" cargo doc --no-deps --all-features || exit 1 diff --git a/.opencode/agent/rust-doc-writer.md b/.opencode/agent/rust-doc-writer.md new file mode 100644 index 0000000..63f0deb --- /dev/null +++ b/.opencode/agent/rust-doc-writer.md @@ -0,0 +1,172 @@ +--- +description: >- + Use this agent when you need to write, improve, or review Rust documentation + for functions, structs, enums, traits, modules, or any other Rust items. This + includes adding doc comments, writing mathematical formulas using KaTeX, + ensuring documentation compiles without errors or warnings, and following Rust + documentation standards with CommonMark specification. + + + + + Context: The user has just written a new Rust function and needs documentation + added to it. + + user: "I just wrote this binary search function, can you document it?" + + assistant: "I'll use the rust-doc-writer agent to write proper Rust + documentation for your binary search function." + + + + Since the user wants documentation written for a Rust function, launch the + rust-doc-writer agent to handle this task with proper Rust doc standards, + KaTeX support, and compilation verification. + + + + + + + + + Context: The user is implementing a math-heavy algorithm and needs + documentation with formulas. + + user: "Please document this matrix multiplication function with the proper + mathematical notation." + + assistant: "I'll use the rust-doc-writer agent to write the documentation with + KaTeX math formulas and verify it compiles correctly." + + + + Since the user wants documentation with mathematical formulas for Rust code, + use the rust-doc-writer agent which knows how to integrate KaTeX formulas into + Rust doc comments. + + + + + + + + + Context: The user has written a module with several public items and wants + comprehensive documentation. + + user: "Can you add documentation to all the public items in my new `geometry` + module?" + + assistant: "I'll launch the rust-doc-writer agent to document all public items + in your geometry module following Rust documentation standards." + + + + Since the user needs comprehensive module-level documentation, use the + rust-doc-writer agent to systematically document all public API items. + + + + +mode: subagent +--- +You are an expert Rust documentation engineer with deep knowledge of Rust's documentation conventions, the CommonMark Markdown specification, and mathematical typesetting via KaTeX. You specialize in writing clear, accurate, and standards-compliant Rust documentation that compiles cleanly without warnings or errors. + +## Language Requirement + +All documentation you write MUST be in **English**, without exception. This applies regardless of: +- the language used in existing comments or variable names in the codebase, +- the language the user writes their message in, +- any locale or regional settings implied by the project. + +If existing documentation is in another language, rewrite it in English. Never produce non-English doc comments. + +## Core Responsibilities + +You write and improve Rust documentation comments (`///` for items, `//!` for modules/crates) following Rust's official documentation standards and the CommonMark specification. + +## Documentation Standards + +### Structure +Always follow this section order when applicable: +1. **Short summary line** — a single sentence describing what the item does (no period at the end for very short descriptions, period for full sentences) +2. **Extended description** — additional paragraphs with detailed explanation +3. **`# Examples`** — one or more runnable `rustdoc` test examples using fenced code blocks *(include only when the user explicitly requests examples)* +4. **`# Panics`** — conditions under which the function panics (if applicable) +5. **`# Errors`** — conditions under which the function returns an `Err` (if applicable) +6. **`# Safety`** — safety requirements for `unsafe` functions (if applicable) +7. **`# Arguments`** or parameter descriptions (when not obvious from types) + +### CommonMark Formatting +- Use CommonMark-compliant Markdown: fenced code blocks with language tags (` ```rust `), **bold**, *italic*, `inline code`, bullet lists, numbered lists, blockquotes, and tables where appropriate. +- All code examples must be valid, runnable Rust that would pass `cargo test --doc`. Use `# ` prefix lines to hide boilerplate in rendered docs while keeping tests valid. Only include code examples when the user explicitly requests them (see **Quality Standards**). +- Use `[links]` to cross-reference other items using intra-doc links (e.g., `[SomeStruct]`, `[method_name](Self::method_name)`). +- Never use HTML directly in doc comments unless absolutely necessary and always prefer Markdown equivalents. + +### KaTeX Mathematical Formulas +- The project provides a KaTeX HTML header at `$(pwd)/katex-header.html` in the project root, which enables LaTeX math rendering in documentation. **Always read this file before writing math** to confirm which delimiters are configured. +- The katex-header in this project configures **only** `$...$` and `$$...$$` as delimiters. `\\(...\\)` and `\\[...\\]` are NOT configured and will render as raw escaped text. +- Use inline math with single dollar signs: `$formula$` (e.g. `$\phi \in [0, \pi]$`) +- Use display (block) math with double dollar signs on their own line: `$$formula$$` +- `$...$` and `$$...$$` work in **all** doc comment positions: module docs, struct docs, field docs, function docs, enum variant docs. There is no restriction based on comment position. +- Always use proper LaTeX syntax. Common pitfalls: escape backslashes properly in Rust string context, use `\\` for newlines in aligned environments. +- Verify KaTeX formulas are syntactically correct LaTeX before finalizing. + +#### KaTeX commands NOT supported by rustdoc's KaTeX renderer + +| Command | Problem | Replacement | +|---|---|---| +| `\!` (negative thin space) | Not rendered — produces literal `\!` in output | Remove it: write `\arccos\left(` not `\arccos\!\left(` | + +## Compilation Verification + +After writing documentation, you MUST verify it compiles without errors or warnings by running: + +```bash +RUSTDOCFLAGS="--html-in-header $(pwd)/katex-header.html" cargo doc --no-deps +``` + +### Verification Process +1. Run the command and carefully read ALL output. +2. Treat **warnings as errors** — fix every warning, including: + - Broken intra-doc links + - Missing code block language tags + - Invalid doc test code + - Malformed KaTeX syntax causing HTML rendering issues +3. If errors or warnings are found, diagnose the root cause, fix the documentation, and re-run the command. +4. Only confirm documentation is complete when the command exits with zero warnings and zero errors. +5. Pay special attention to KaTeX-related issues: unclosed braces, invalid commands, unsupported LaTeX features, and: + - `\!` anywhere in a formula — must be removed + - `\\(...\\)` or `\\[...\\]` delimiters — only valid if the `katex-header.html` configures them; in this project they produce raw text, use `$...$` / `$$...$$` instead + +## Quality Standards + +- **Accuracy**: Documentation must precisely describe what the code does, not what you wish it did. +- **Completeness**: Every public item should have at minimum a summary line. Complex items need full documentation. +- **Examples**: Do **not** add `# Examples` sections by default. Only add examples when the user explicitly requests them. This overrides any general guidance suggesting examples should always be present. +- **Format consistency**: Before writing any documentation, examine the existing documentation in the project to understand the established style — including tone (formal vs. casual), which sections are used, level of detail, naming conventions, and phrasing patterns. New documentation must match that established format. If no prior documentation exists, apply the structure defined in this file. +- **Consistency**: Match the existing documentation style and tone of the codebase if present. +- **No false claims**: If behavior is implementation-dependent or platform-specific, state it. + +## Workflow + +1. Examine the Rust item(s) to be documented carefully, including their types, signatures, and implementations. +2. **Before writing**, survey the existing documentation in the project (using the Read and Grep tools) to identify the established style: tone, section usage, level of detail, and naming conventions. New documentation must conform to that style. +3. Check for any existing documentation on the target item(s) to preserve or improve. +4. Identify if mathematical formulas are needed and prepare correct LaTeX. +5. Write the documentation following the structure above. Do **not** add `# Examples` sections unless the user has explicitly asked for them. +6. Run `RUSTDOCFLAGS="--html-in-header $(pwd)/katex-header.html" cargo doc --no-deps` to verify. +7. Iterate until compilation is clean with zero warnings and zero errors. +8. Report the final documentation written and confirm successful compilation. + +## Common Mistakes to Avoid + +- Do NOT use `#[doc = ...]` attribute syntax unless there is a specific reason; prefer `///` comments. +- Do NOT leave doc tests that fail to compile or produce wrong results. +- Do NOT use GitHub Flavored Markdown features not in CommonMark (e.g., `~~strikethrough~~` is GFM but may not render — prefer CommonMark-safe alternatives). +- Do NOT forget to escape backslashes in KaTeX formulas: `\frac` in a doc comment needs to be written as `\frac` (single backslash is fine in doc comments, but be mindful of raw strings). +- Do NOT mix inline and block math syntax incorrectly. +- Do NOT use `\\(...\\)` or `\\[...\\]` as math delimiters unless the `katex-header.html` explicitly configures them. In this project, only `$...$` and `$$...$$` are configured — using `\\(...\\)` produces raw escaped text, not rendered math. +- Do NOT use `\!` (negative thin space) in KaTeX formulas — it is not supported by rustdoc's KaTeX renderer and will appear as literal text. Write `\arccos\left(` not `\arccos\!\left(`. +- Always verify the `katex-header.html` file exists at the project root before running the doc command; if missing, alert the user. diff --git a/.opencode/agent/rust-test-writer.md b/.opencode/agent/rust-test-writer.md new file mode 100644 index 0000000..1d8114e --- /dev/null +++ b/.opencode/agent/rust-test-writer.md @@ -0,0 +1,148 @@ +--- +description: >- + Use this agent when you need to write unit tests or integration tests for Rust + code. This includes writing tests for new functions or modules, adding + property-based tests with proptest, handling float comparisons in tests, or + setting up integration test files in the tests/ directory. The agent should be + used proactively after writing a logical chunk of Rust code that needs test + coverage. + + + + Context: The user has just written a Rust function and needs unit tests. + user: "Write a function that calculates the factorial of a number" + assistant: "Here is the factorial function in Rust: + ```rust + pub fn factorial(n: u64) -> u64 { + if n == 0 { 1 } else { n * factorial(n - 1) } + } + ``` + Now let me use the rust-test-writer agent to write tests for this function." + + The user has written a Rust function. Use the rust-test-writer agent to add unit tests within the same file. + + + + + + Context: The user wants integration tests for a Rust HTTP client module. + user: "I need integration tests for my HTTP client module in src/client.rs" + assistant: "I'm going to use the rust-test-writer agent to create integration tests in the tests/ directory for your HTTP client module." + + The user explicitly wants integration tests. Use the rust-test-writer agent to create a file under tests/ following Rust conventions. + + + + + + Context: The user has written a function returning floats and needs tests. + user: "Can you write tests for my distance calculation function?" + assistant: "I'll use the rust-test-writer agent to write tests for your distance function, making sure to use the approx crate for float comparisons." + + Float comparisons are involved. The rust-test-writer agent should automatically use the approx crate for all float assertions. + + +mode: subagent +--- +You are an expert Rust software engineer specializing in test-driven development. You have deep knowledge of Rust's testing ecosystem, including the built-in test framework, proptest for property-based testing, and the approx crate for floating-point comparisons. You write clean, idiomatic, and thorough tests that follow Rust conventions and community best practices. + +## Core Responsibilities + +You write Rust tests in two categories: +1. **Unit tests**: Written inside the same file as the code under test, within a `#[cfg(test)] mod tests { ... }` block at the bottom of the file. +2. **Integration tests**: Written as separate files inside the `tests/` directory at the root of the project (e.g., `tests/client_tests.rs`). Each integration test file is treated as a separate crate and must import the library's public API. + +## Language & Naming Rules + +- All test function names, doc comments, assertion messages, and any string content within tests **must be written in English**. +- Test function names must be snake_case and descriptive, clearly conveying what behavior is being verified (e.g., `test_factorial_returns_one_for_zero`, `test_addition_is_commutative`). +- Use the `#[test]` attribute for every test function. +- Use `#[should_panic(expected = "...")]` when testing for expected panics, with an English message. + +## Float Comparison Rules + +- **Every time floating-point values are compared in tests**, you must use the `approx` crate instead of `==` or direct `assert_eq!`. +- Use `assert_relative_eq!`, `assert_abs_diff_eq!`, or `approx::assert_ulps_eq!` as appropriate. +- Add `approx` to `[dev-dependencies]` in `Cargo.toml` if not already present: `approx = "0.5"`. +- Always import approx macros at the top of the test module: `use approx::assert_relative_eq;` (or the appropriate macro). +- Choose a sensible epsilon or relative tolerance and document the reasoning if it is non-obvious. + +## Property-Based Testing with Proptest + +- Use `proptest` when: + - The user explicitly asks for property-based tests. + - The function under test has mathematical properties (commutativity, associativity, idempotency, invertibility, etc.) that benefit from exhaustive random testing. + - Edge cases are hard to enumerate manually (e.g., arbitrary string inputs, large integer ranges). + - You are testing parsing/serialization roundtrips. +- Add `proptest` to `[dev-dependencies]` in `Cargo.toml` if needed: `proptest = "1"`. +- Use the `proptest!` macro and strategy combinators idiomatically. +- Combine proptest with regular unit tests — proptest does not replace deterministic edge-case tests. +- Example proptest structure: + ```rust + use proptest::prelude::*; + proptest! { + #[test] + fn test_addition_is_commutative(a: i32, b: i32) { + assert_eq!(a + b, b + a); + } + } + ``` + +## Test Quality Standards + +- Cover happy paths, edge cases, and error/failure paths. +- Each test should test **one specific behavior** — avoid omnibus tests. +- Use `assert_eq!`, `assert_ne!`, `assert!`, and `assert_matches!` (from `std` or the `assert_matches` crate) appropriately. +- For `Result` and `Option` types, use `.unwrap()` sparingly in tests; prefer `assert!(result.is_ok())` or pattern matching with meaningful messages. +- Group related tests using nested `mod` blocks inside the `#[cfg(test)]` module. +- Add a brief doc comment (`///`) to non-obvious test functions explaining what property or behavior is being verified. + +## File Structure Guidelines + +**Unit tests** (append to the source file): +```rust +#[cfg(test)] +mod tests { + use super::*; + // imports for approx, proptest, etc. + + #[test] + fn test_example_behavior() { + // arrange + // act + // assert + } +} +``` + +**Integration tests** (`tests/_tests.rs`): +```rust +use my_crate::my_module::MyType; +// additional imports + +#[test] +fn test_public_api_behavior() { + // arrange + // act + // assert +} +``` + +## Workflow + +1. **Analyze** the code under test: identify public API surface, input domains, invariants, and error conditions. +2. **Determine test type**: unit vs. integration based on what is being tested and user intent. +3. **Check for floats**: if any return values or intermediate values are floating-point, automatically apply `approx`. +4. **Check for proptest need**: apply property-based testing for mathematical properties or when explicitly requested. +5. **Write tests**: follow the structure and naming conventions above. +6. **Update Cargo.toml**: mention required `[dev-dependencies]` additions (`approx`, `proptest`) if they are needed. +7. **Self-review**: verify all test names are in English, all float comparisons use `approx`, and the test module structure is valid Rust. + +## Constraints + +- Never use `==` to compare floats in tests. This is a hard rule. +- Never write test messages or names in any language other than English. +- Always place unit tests at the bottom of the source file, never inline. +- Integration tests must never access private items — only the public API. +- Do not use `unwrap()` without a comment explaining why it is safe in that test context. +- Keep tests deterministic unless using proptest strategies intentionally. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4836706..526da2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,161 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0] - unreleased + +### Added + +- **N-body propagator (`propagator/`)** + - New `propagator` module exposing two propagation strategies selectable at runtime via `PropagatorKind`: + - `PropagatorKind::TwoBody` — analytic Keplerian propagation (default, no extra dependencies). + - `PropagatorKind::NBody(NBodyConfig)` — numerical N-body integration via an 8th-order Dormand–Prince (DOP853) integrator from the `differential-equations` crate. + - `NBodyConfig` — configuration struct selecting which planetary perturbers to include. + - `NBodyResult` — propagation output holding the final heliocentric state and the 6×6 State Transition Matrix (STM) Φ. + - The STM is propagated alongside the trajectory (variational equations `dΦ/dt = A(t)·Φ`), enabling N-body-quality partial derivatives for the differential corrector. + - `propagator::planet_gm` — planetary GM constants table. + - New dependency: [`differential-equations`](https://crates.io/crates/differential-equations) (DOP853 integrator). + - Integration tests for N-body propagation added in `tests/test_diff_cor.rs`. + +- **Ephemeris generation (`ephemeris/`)** + - Entirely new `ephemeris` module providing a complete, type-safe pipeline to predict apparent sky positions and geometric quantities from an orbit: + - **`EphemerisRequest`** — typed builder pairing observers with generation modes; `O` is one of three zero-cost marker types: + - `Position` — computes apparent (RA, Dec), geocentric/heliocentric distances → `ApparentPosition`. + - `Geometry` — computes phase angle, solar elongation, radial velocity, angular rates → `BodyGeometry`. + - `Combined` — both of the above from a single propagation → `(ApparentPosition, BodyGeometry)`. + - **`EphemerisMode`** — epoch generation strategy: + - `Single` — single epoch. + - `Range { start, end, step }` — uniform grid. + - `At(Vec)` — arbitrary epoch list. + - **`EphemerisResult`** — result container; per-entry errors are recorded without short-circuiting the full computation. + - `successes()`, `errors()`, `by_observer()` accessors. + - **`EphemerisConfig`** — global configuration (aberration corrections, etc.). + - **`OrbitalElements::compute`** — entry point: `elements.compute(&request, &jpl, &ut1)`. + - **`ApparentPosition`** — apparent RA/Dec, geocentric distance, heliocentric distance, light-travel-time corrected. + - **`BodyGeometry`** — phase angle, solar elongation, radial velocity, apparent angular rates. + - **Aberration correction** (`aberration.rs`) — stellar aberration applied to apparent positions. + - **Batch ephemeris (`batch.rs`)** — `FullOrbitResultExt` extension trait on `FullOrbitResult`: + - `compute_ephemerides(&request, &jpl, &ut1)` — sequential computation over all orbits in a `FullOrbitResult`. + - `compute_ephemerides_parallel(&request, &jpl, &ut1)` *(feature `parallel`)* — Rayon-parallel variant. + - Integration tests in `tests/test_ephemeris.rs` (≈780 lines). + +- **Orbital uncertainty & Jacobian propagation (`orbit_type/uncertainty.rs`)** + - New `uncertainty` submodule providing first-order linear covariance propagation across all three orbital element representations: + - `KeplerianUncertainty` — 1σ standard deviations for (a, e, i, Ω, ω, M). + - `EquinoctialUncertainty` — 1σ standard deviations for (a, h, k, p, q, λ). + - `CometaryUncertainty` — 1σ standard deviations for (q, e, i, Ω, ω, ν). + - Each built via `from_covariance(&OrbitalCovariance)` extracting diagonal variances. + - `OrbitalCovariance` — full 6×6 symmetric covariance matrix Σ with `propagate(jacobian)` → `Σ' = J Σ Jᵀ`. + - **Jacobian methods** on orbital element structs: + - `EquinoctialElements::jacobian_to_keplerian()` — ∂(a,e,i,Ω,ω,M)/∂(a,h,k,p,q,λ). + - `KeplerianElements::jacobian_to_equinoctial()` and `jacobian_to_cometary()`. + - `CometaryElements::jacobian_to_keplerian()` and `jacobian_to_equinoctial()`. + - `OrbitalElements` (the top-level enum) gains: + - `uncertainty() -> Option` — returns the attached covariance if present. + - Covariance is propagated and converted automatically when calling `into_keplerian()`, `into_equinoctial()`, `into_cometary()`, preserving uncertainty across representations. + - `DifferentialCorrectionOutput` updated to expose `OrbitalCovariance` through `OrbitalElements` directly. + +- **Differential orbit correction (`differential_orbit_correction`)** + - New module implementing a full **least-squares differential correction** pipeline over optical astrometry, using 2-body (Keplerian) propagation: + - `DifferentialCorrectionConfig` — configuration struct (max iterations, convergence threshold, RMS divergence ratio, outlier rejection settings). + - `FitLSQ` trait — top-level entry point on `ObsDataset`: `obs_dataset.fit_lsq(...)` returns a `FullOrbitResult` with converged equinoctial elements and per-observation fit data. + - `DifferentialCorrectionOutput` — result type holding final `EquinoctialElements`, per-observation `ObsFitData` (residuals, σ, χ, selection status), `OrbitalUncertainty` (normal matrix, covariance, inversion flag), normalised RMS, iteration count, and measurement count. + - `ObsFitData` — per-observation fit metadata: `residual_ra`, `residual_dec`, `sigma_ra`, `sigma_dec`, `bias_ra`, `bias_dec`, `chi`, and `ObsSelection` (`Active` / `Rejected`). + - `OrbitalUncertainty` — 6×6 normal matrix and covariance matrix with `inversion_succeeded` flag. + - **Outlier rejection** (`outlier_rejection.rs`) — iterative χ²-based rejection with configurable threshold. + - **Single Newton iteration** (`single_iteration.rs`) — computes the design matrix G (∂ρ/∂x), normal matrix GᵀWG, right-hand side, applies the Δx correction to equinoctial elements, and returns `correction_norm`. + - **Least-squares accumulator** (`least_square.rs`) — builds the weighted normal equations from active observations, computes normalised RMS. + - `OutfitError::DifferentialCorrectionDiverged` and `OutfitError::DifferentialCorrectionNotConverged` — new error variants. + +- **Observation ephemeris (`observation_ephemeris.rs`)** + - Massively extended module (≈ 800 lines added): + - Partial derivatives of apparent (topocentric) RA/DEC with respect to equinoctial orbital elements: `∂ρ/∂a`, `∂ρ/∂h`, `∂ρ/∂k`, `∂ρ/∂p`, `∂ρ/∂q` — used to construct the design matrix G for differential correction. + - Topocentric coordinate computation from equinoctial elements and observer position. + - Observation weighting via error-model sigmas (`ObsWeight`). + +- **Cache extensions (`cache/`)** + - `observer_centric_cache.rs` extended with observer heliocentric velocity (`helio_velocity`) computation and storage — required by the design matrix G for velocity-dependent partial derivatives. + - `ObserverCentricCache` now derives `Debug`. + +- **`EquinoctialElements` additions** + - `is_bizarre() -> bool` — returns `true` if the equinoctial orbit has unphysical or degenerate parameters (used during differential correction to reject diverging solutions). + - Additional conversion utilities and `Display` improvements. + +- **`JPLEphem` ergonomics** + - `impl TryFrom<&str> for JPLEphem` and `impl TryFrom for JPLEphem` — construct directly from a source string (e.g. `"horizon:DE440".try_into()`), without explicitly building an `EphemFileSource` first. + - `JPLEphem::new` signature generalised to `impl Into` — accepts both `EphemFileSource` values and references. + - `impl From<&EphemFileSource> for EphemFileSource` added in `download_jpl_file.rs` (clone-based). + +- **Constants (`constants.rs`)** + - Extended with additional physical and astronomical constants required by the differential correction (e.g. Gaussian gravitational constant, AU/day conversions). + +- **Integration tests** + - `tests/test_diff_cor.rs` — non-regression test for the full differential correction pipeline on three real asteroids (2015 AB / K09R05F, 33803, 8467) with seed-42 oracles; uses `approx_equal` from `tests/common/mod.rs`. + - `tests/test_gauss_iod.rs` and `tests/test_iod_from_polars.rs` — migrated to use `approx_equal` for orbit comparisons. + +- **CI improvements** + - `fmt`, `clippy`, and `semver-pr` jobs now run **only on pull requests** (skipped on push to `main` after merge). + - `coverage` job runs on both PR and `main`, but only after `fmt`, `clippy`, and `semver-pr` are green (or skipped). + - Removed the redundant `test` job — coverage via `cargo llvm-cov` already executes all tests. + - Added a **feature matrix** (`default`, `parallel`) to `clippy` and `coverage` jobs. + - Codecov uploads use per-feature `flags` (`default` / `parallel`) for granular coverage reporting. + +### Changed + +- **Orbit display simplified** — `Display` implementations for `KeplerianElements`, `EquinoctialElements`, and `CometaryElements` simplified; `GaussResult` display updated accordingly. Verbose per-field multi-line output replaced by a more compact single-block format. + +- **`PropagatorKind` integrated into differential corrector** — `DifferentialCorrectionConfig` now accepts a `PropagatorKind` field; the corrector dispatches to two-body or N-body propagation transparently. Existing configs default to `PropagatorKind::TwoBody` (no migration required). + +- **`observation_ephemeris.rs` moved** — `src/observation_ephemeris.rs` relocated to `src/ephemeris/observation_ephemeris.rs`; public re-exports updated in `src/lib.rs`. + +- **CI: test thread count capped** — `RUST_TEST_THREADS` set in CI workflow to limit concurrency during `cargo test`, reducing peak memory usage from heavy doctests. + +- **`photom` crate extracted** — observation parsing, error models, observer management, and trajectory ingestion have been moved into a dedicated `photom` crate (published on crates.io). Outfit now depends on `photom` as an external dependency. + - Removed from Outfit: `src/observations/`, `src/observers/`, `src/trajectories/`, `src/error_models/`, `src/outfit.rs`, `src/env_state.rs` and associated modules. + - Public re-exports updated in `src/lib.rs`; user-facing types (`ObsDataset`, `ObsErrorModel`, `TrajId`, `Observer`, `Observatories`, …) are now accessed via `photom`. + +- **`FullOrbitResult` type alias updated** + - Now defined as `HashMap>` — keyed by `TrajId` (from `photom`), value is `None` if differential correction did not converge. + +- **`GaussResult` / IOD result types** + - `GaussResult` now stores `EquinoctialElements` (instead of `KeplerianElements`) as the primary output of Gauss IOD, feeding directly into the differential corrector. + +- **`JPLEphem::new` signature** — changed from `fn new(file_source: &EphemFileSource)` to `fn new(source: impl Into)` (see **Added** above). + +- **Examples** — `run_full_iod.rs` and `run_full_iod_parallel.rs` updated to use the new `photom`-based API and the `fit_lsq` / `fit_full_iod` entry points. + +- **`tests/common/mod.rs`** — fixed a bug in `approx_equal` for `OrbitalElements::Equinoctial`: `ee1.semi_major_axis` was incorrectly compared against `0.0` instead of `ee2.semi_major_axis`. + +### Removed + +- Removed benchmarks (`benches/`) — `gauss_prelim_orbit`, `load_parquet`, `outfit_gauss_iod`, `solve_kepler_equation` benches removed as part of the photom extraction refactor. +- Removed old examples (`examples/gauss_iod_once.rs`, `examples/parquet_to_orbit.rs`). +- Removed old test files (`tests/outfit_struct_test.rs`, `tests/reader_80col_test.rs`, `tests/test_read_ades.rs`, `tests/trajectories_from_parquet.rs`, `tests/trajectories_from_vec.rs`, `tests/vec_to_iod.rs`). + +### Breaking Changes + +- **`photom` dependency required** — types previously in `outfit::observations`, `outfit::observers`, `outfit::trajectories`, `outfit::error_models` are now in the `photom` crate. Update imports: + ```rust + // Before + use outfit::observations::ObsDataset; + use outfit::error_models::ObsErrorModel; + + // After + use photom::observation_dataset::ObsDataset; + use photom::observer::error_model::ObsErrorModel; + ``` + +- **`FullOrbitResult` keyed by `TrajId` instead of `ObjectNumber`** — update map lookups accordingly. + +- **`JPLEphem::new` accepts `impl Into`** — existing call sites passing `&EphemFileSource` continue to work (via `From<&EphemFileSource>`); call sites passing an owned `EphemFileSource` no longer need the `&`. + +### Notes + +- The differential corrector now supports both **2-body (Keplerian)** and **N-body** propagation via `PropagatorKind`. The default remains `TwoBody`. For well-observed main-belt asteroids, select `NBody` with the appropriate planetary perturbers for higher accuracy. The default `rms_divergence_ratio` (1.5) in `DifferentialCorrectionConfig` can be raised if needed during convergence. +- The `dev` profile no longer emits debug symbols (`debug = false`) to reduce RAM consumption when running doctests. +- Ephemeris computation is designed to be fault-tolerant: individual `(epoch, observer)` errors are captured per-entry inside `EphemerisResult` without aborting the whole batch. + +--- + ## [2.1.0] - 2025-09-18 ### Added diff --git a/Cargo.lock b/Cargo.lock index a841472..36a7744 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,42 +15,61 @@ dependencies = [ [[package]] name = "addr2line" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "const-random", - "getrandom 0.2.15", "once_cell", "version_check", - "zerocopy 0.7.35", + "zerocopy", ] [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -74,9 +93,15 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "approx" @@ -88,110 +113,94 @@ dependencies = [ ] [[package]] -name = "arrayvec" -version = "0.7.6" +name = "ar_archive_writer" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] [[package]] -name = "arrow-array" -version = "54.3.1" +name = "argminmax" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12fcdb3f1d03f69d3ec26ac67645a8fe3f878d77b5ebb0b15d64a116c212985" +checksum = "70f13d10a41ac8d2ec79ee34178d61e6f47a29c2edfe7ef1721c7383b0359e65" dependencies = [ - "ahash", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "chrono", "half", - "hashbrown", - "num", + "num-traits", ] [[package]] -name = "arrow-buffer" -version = "54.3.1" +name = "array-init-cursor" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "263f4801ff1839ef53ebd06f99a56cecd1dbaf314ec893d93168e2e860e0291c" -dependencies = [ - "bytes", - "half", - "num", -] +checksum = "ed51fe0f224d1d4ea768be38c51f9f831dee9d05c163c11fba0b8c44387b1fc3" [[package]] -name = "arrow-cast" -version = "54.3.1" +name = "arrayref" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede6175fbc039dfc946a61c1b6d42fd682fcecf5ab5d148fbe7667705798cac9" -dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "arrow-select", - "atoi", - "base64", - "chrono", - "half", - "lexical-core", - "num", - "ryu", -] +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "arrow-data" -version = "54.3.1" +name = "async-channel" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61cfdd7d99b4ff618f167e548b2411e5dd2c98c0ddebedd7df433d34c20a4429" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ - "arrow-buffer", - "arrow-schema", - "half", - "num", + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", ] [[package]] -name = "arrow-ipc" -version = "54.3.1" +name = "async-stream" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62ff528658b521e33905334723b795ee56b393dbe9cf76c8b1f64b648c65a60c" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "flatbuffers", + "async-stream-impl", + "futures-core", + "pin-project-lite", ] [[package]] -name = "arrow-schema" -version = "54.3.1" +name = "async-stream-impl" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cfaf5e440be44db5413b75b72c2a87c1f8f0627117d110264048f2969b99e9" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "arrow-select" -version = "54.3.1" +name = "async-trait" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69efcd706420e52cd44f5c4358d279801993846d1c2a8e52111853d61d55a619" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ - "ahash", - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "num", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "atoi" -version = "2.0.0" +name = "atoi_simd" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +checksum = "8ad17c7c205c2c28b527b9845eeb91cf1b4d008b438f98ce0e628227a822758e" dependencies = [ - "num-traits", + "debug_unsafe", ] [[package]] @@ -202,15 +211,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "backtrace" -version = "0.3.74" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", @@ -218,7 +227,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] @@ -227,6 +236,26 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -244,21 +273,68 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "1.3.2" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] [[package]] -name = "bitflags" -version = "2.9.0" +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "boxcar" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] [[package]] name = "bumpalo" -version = "3.17.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytecount" @@ -268,27 +344,38 @@ checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytemuck" -version = "1.22.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] [[package]] -name = "byteorder" -version = "1.5.0" +name = "bytemuck_derive" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] [[package]] name = "camino" -version = "1.1.9" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" [[package]] name = "cast" @@ -296,20 +383,32 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" -version = "1.2.17" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", "shlex", ] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -317,16 +416,38 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" -version = "0.4.40" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", - "windows-link", + "serde", + "windows-link 0.1.3", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", ] [[package]] @@ -358,18 +479,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.34" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e958897981290da2a852763fe9cdb89cd36977a5d729023127095fa94d95e2ff" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.34" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83b0f35019843db2160b5bb19ae09b4e6411ac33fc6a712003c33e03090e2489" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstyle", "clap_lex", @@ -377,50 +498,59 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "comfy-table" -version = "7.2.1" +version = "7.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" dependencies = [ + "crossterm", "unicode-segmentation", "unicode-width", ] [[package]] -name = "console" -version = "0.16.0" +name = "compact_str" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e09ced7ebbccb63b4c65413d821f2e00ce54c5ca4514ddc6b3c892fdbcbc69d" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "windows-sys 0.60.2", + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", ] [[package]] -name = "const-random" -version = "0.1.18" +name = "concurrent-queue" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ - "const-random-macro", + "crossbeam-utils", ] [[package]] -name = "const-random-macro" -version = "0.1.16" +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "getrandom 0.2.15", - "once_cell", - "tiny-keccak", + "core-foundation-sys", + "libc", ] [[package]] @@ -429,6 +559,33 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "criterion" version = "0.5.1" @@ -465,6 +622,15 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -484,17 +650,96 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" -version = "0.2.3" +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 = "debug_unsafe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" + +[[package]] +name = "differential-equations" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d59c6f4160bf75916dd69b58f6323171dd22e2e4a68cf7e31b1b2bf54b7433a" +dependencies = [ + "differential-equations-derive", + "simba", +] + +[[package]] +name = "differential-equations-derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68a7fe2019075c678918caa15cc0be080f0c4400ae58256aaf31ab7059b4920b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] [[package]] name = "directories" @@ -514,7 +759,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -529,47 +774,102 @@ dependencies = [ ] [[package]] -name = "either" -version = "1.15.0" +name = "document-features" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] [[package]] -name = "encode_unicode" -version = "1.0.0" +name = "dyn-clone" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] -name = "equivalent" +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.10" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "ethnum" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", ] +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fast-float2" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" + [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "flatbuffers" -version = "24.12.23" +name = "flate2" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f1baf0dbf96932ec9a3038d57900329c015b0bfb7b63d904f3bc27e2b02a096" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ - "bitflags 1.3.2", - "rustc_version", + "crc32fast", + "miniz_oxide", + "zlib-rs", ] [[package]] @@ -579,55 +879,89 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "foreign-types" -version = "0.3.2" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "foreign-types-shared" -version = "0.1.1" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -636,22 +970,23 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -659,48 +994,185 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] +[[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.15" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.3.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "r-efi 5.3.0", + "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + [[package]] name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glam" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333928d5eb103c5d4050533cec0384302db6be8ef7d3cebd30ec6a35350353da" + +[[package]] +name = "glam" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3abb554f8ee44336b72d522e0a7fe86a29e09f839a36022fa869a7dfe941a54b" + +[[package]] +name = "glam" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4126c0479ccf7e8664c36a2d719f5f2c140fbb4f9090008098d2c291fa5b3f16" + +[[package]] +name = "glam" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01732b97afd8508eee3333a541b9f7610f454bb818669e66e90f5f57c93a776" + +[[package]] +name = "glam" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525a3e490ba77b8e326fb67d4b44b4bd2f920f44d4cc73ccec50adc68e3bee34" + +[[package]] +name = "glam" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8509e6791516e81c1a630d0bd7fbac36d2fa8712a9da8662e716b52d5051ca" + +[[package]] +name = "glam" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e957e744be03f5801a55472f593d43fabdebf25a4585db250f04d86b1675f" + +[[package]] +name = "glam" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518faa5064866338b013ff9b2350dc318e14cc4fcd6cb8206d7e7c9886c98815" + +[[package]] +name = "glam" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f597d56c1bd55a811a1be189459e8fad2bbc272616375602443bdfb37fa774" + +[[package]] +name = "glam" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e4afd9ad95555081e109fe1d21f2a30c691b5f0919c67dfa690a2e1eb6bd51c" + +[[package]] +name = "glam" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5418c17512bdf42730f9032c74e1ae39afc408745ebb2acf72fbc4691c17945" + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glam" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" + +[[package]] +name = "glam" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779ae4bf7e8421cf91c0b3b64e7e8b40b862fba4d393f59150042de7c4965a94" + +[[package]] +name = "glam" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" + +[[package]] +name = "glam" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" + +[[package]] +name = "glam" version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7" + +[[package]] +name = "glam" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "h2" -version = "0.4.11" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -717,20 +1189,48 @@ dependencies = [ [[package]] name = "half" -version = "2.5.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7db2ff139bba50379da6aa0766b52fdcb62cb5b263009b09ed58ba604e14bbd1" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ + "bytemuck", "cfg-if", "crunchy", "num-traits", + "serde", + "zerocopy", ] [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "rayon", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -740,20 +1240,25 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hifitime" -version = "4.2.0" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bcc71d459e299e045d3328cc4be250c80f2cf87a73c309d562e8afc52c88a23" +checksum = "a978b79fb40cea4c6592890ffe0021dfea5c51a107fbac565abae82fd24a15af" dependencies = [ "js-sys", "lexical-core", "num-traits", - "openssl", "serde", "serde_derive", "snafu", @@ -764,14 +1269,22 @@ dependencies = [ "web-time", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -804,6 +1317,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "husky-rs" version = "0.1.5" @@ -812,13 +1331,14 @@ checksum = "8595e3e777338ccc8360c4eb89924f8d7e55a5ff831d057e1c65892c220da28f" [[package]] name = "hyper" -version = "1.6.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http", "http-body", @@ -832,34 +1352,37 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.2", + "webpki-roots", ] [[package]] name = "hyper-util" -version = "0.1.11" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -869,9 +1392,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -893,21 +1416,23 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", + "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -916,104 +1441,72 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr", + "icu_locale_core", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] [[package]] -name = "icu_provider_macros" -version = "1.5.0" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -1022,9 +1515,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1032,47 +1525,31 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] -name = "indicatif" -version = "0.18.0" +name = "ipnet" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a646d946d06bedbbc4cac4c218acf4bbf2d87757a784857025f4d447e4e1cd" -dependencies = [ - "console", - "portable-atomic", - "unit-prefix", - "web-time", -] +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] -name = "integer-encoding" -version = "3.0.4" +name = "is-terminal" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" - -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "is-terminal" -version = "0.4.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1095,129 +1572,127 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lexical-core" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b765c31809609075565a70b4b71402281283aeda7ecaf4818ac14a7b2ade8958" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" dependencies = [ "lexical-parse-float", "lexical-parse-integer", "lexical-util", - "lexical-write-float", - "lexical-write-integer", ] [[package]] name = "lexical-parse-float" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de6f9cb01fb0b08060209a057c048fcbab8717b4c1ecd2eac66ebfe39a65b0f2" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" dependencies = [ "lexical-parse-integer", "lexical-util", - "static_assertions", ] [[package]] name = "lexical-parse-integer" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72207aae22fc0a121ba7b6d479e42cbfea549af1479c3f3a4f12c70dd66df12e" -dependencies = [ - "lexical-util", - "static_assertions", -] - -[[package]] -name = "lexical-util" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a82e24bf537fd24c177ffbbdc6ebcc8d54732c35b50a3f28cc3f4e4c949a0b3" -dependencies = [ - "static_assertions", -] - -[[package]] -name = "lexical-write-float" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5afc668a27f460fb45a81a757b6bf2f43c2d7e30cb5a2dcd3abf294c78d62bd" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" dependencies = [ "lexical-util", - "lexical-write-integer", - "static_assertions", ] [[package]] -name = "lexical-write-integer" -version = "1.0.5" +name = "lexical-util" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629ddff1a914a836fb245616a7888b62903aae58fa771e1d83943035efa0f978" -dependencies = [ - "lexical-util", - "static_assertions", -] +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" [[package]] name = "libc" -version = "0.2.171" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" -version = "0.2.11" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.9.0", "libc", ] [[package]] name = "linux-raw-sys" -version = "0.9.3" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.7.5" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] [[package]] name = "log" -version = "0.4.27" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru-slab" @@ -1225,11 +1700,30 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lz4" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" +dependencies = [ + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "matrixmultiply" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9380b911e3e96d10c1f415da0876389aaf1b56759054eeb0de7df940c456ba1a" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" dependencies = [ "autocfg", "rawpointer", @@ -1237,43 +1731,65 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] -name = "mime" -version = "0.3.17" +name = "memmap2" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] [[package]] name = "miniz_oxide" -version = "0.8.5" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] name = "mio" -version = "1.0.3" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "wasi", + "windows-sys 0.61.2", ] [[package]] name = "nalgebra" -version = "0.33.2" +version = "0.34.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26aecdf64b707efd1310e3544d709c5c0ac61c13756046aaaba41be5c4f66a3b" +checksum = "df76ea0ff5c7e6b88689085804d6132ded0ddb9de5ca5b8aeb9eeadc0508a70a" dependencies = [ "approx", + "glam 0.14.0", + "glam 0.15.2", + "glam 0.16.0", + "glam 0.17.3", + "glam 0.18.0", + "glam 0.19.0", + "glam 0.20.5", + "glam 0.21.3", + "glam 0.22.0", + "glam 0.23.0", + "glam 0.24.2", + "glam 0.25.0", + "glam 0.27.0", + "glam 0.28.0", + "glam 0.29.3", + "glam 0.30.10", + "glam 0.31.1", + "glam 0.32.1", "matrixmultiply", "nalgebra-macros", "num-complex", @@ -1285,9 +1801,9 @@ dependencies = [ [[package]] name = "nalgebra-macros" -version = "0.2.2" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc" +checksum = "973e7178a678cfd059ccec50887658d482ce16b0aa9da3888ddeab5cd5eb4889" dependencies = [ "proc-macro2", "quote", @@ -1304,17 +1820,12 @@ dependencies = [ ] [[package]] -name = "num" -version = "0.4.3" +name = "now" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +checksum = "6d89e9874397a1f0a52fc1f197a8effd9735223cb2390e9dcc83ac6cd02923d0" dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", + "chrono", ] [[package]] @@ -1337,22 +1848,22 @@ dependencies = [ ] [[package]] -name = "num-integer" -version = "0.1.46" +name = "num-derive" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ - "num-traits", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "num-iter" -version = "0.1.45" +name = "num-integer" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ - "autocfg", - "num-integer", "num-traits", ] @@ -1379,72 +1890,67 @@ dependencies = [ [[package]] name = "object" -version = "0.36.7" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] [[package]] -name = "once_cell" -version = "1.21.3" +name = "object_store" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - -[[package]] -name = "openssl" -version = "0.10.73" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +checksum = "622acbc9100d3c10e2ee15804b0caa40e55c933d5aa53814cd520805b7958a49" dependencies = [ - "bitflags 2.9.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", + "async-trait", + "base64", + "bytes", + "chrono", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body-util", + "humantime", + "hyper", + "itertools 0.14.0", + "parking_lot", + "percent-encoding", + "quick-xml", + "rand 0.10.1", + "reqwest", + "ring", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", ] [[package]] -name = "openssl-macros" -version = "0.1.1" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "openssl-src" -version = "300.5.2+3.5.2" +name = "oorandom" +version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d270b79e2926f5150189d475bc7e9d2c69f9c4697b185fa917d5a32b792d21b4" -dependencies = [ - "cc", -] +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] -name = "openssl-sys" -version = "0.9.109" +name = "openssl-probe" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" -dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", -] +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "option-ext" @@ -1454,51 +1960,37 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "ordered-float" -version = "2.10.1" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "ordered-float" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2c1f9f56e534ac6a9b8a4600bdf0f530fb393b5f393e7b4d03489c3cf0c3f01" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" dependencies = [ "num-traits", ] [[package]] name = "outfit" -version = "2.1.0" +version = "3.0.0" dependencies = [ "aberth", "ahash", "approx", - "arrow-array", "camino", - "comfy-table", "criterion", + "differential-equations", "directories", "hifitime", "husky-rs", - "indicatif", - "itertools 0.14.0", "nalgebra", "nom", - "once_cell", - "ordered-float 5.0.0", - "parquet", + "ordered-float", + "photom", + "polars", "proptest", - "quick-xml", - "rand", + "rand 0.9.4", "rand_distr", "rayon", "reqwest", "roots", - "serde", "smallvec", "thiserror", "tokio", @@ -1518,31 +2010,32 @@ dependencies = [ ] [[package]] -name = "parquet" -version = "54.3.1" +name = "parking" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfb15796ac6f56b429fd99e33ba133783ad75b27c36b4b5ce06f1f82cc97754e" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ - "ahash", - "arrow-array", - "arrow-buffer", - "arrow-cast", - "arrow-data", - "arrow-ipc", - "arrow-schema", - "arrow-select", - "base64", - "bytes", - "chrono", - "half", - "hashbrown", - "num", - "num-bigint", - "paste", - "seq-macro", - "snap", - "thrift", - "twox-hash", + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", ] [[package]] @@ -1553,27 +2046,68 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "pin-project-lite" -version = "0.2.16" +name = "phf" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] [[package]] -name = "pin-utils" -version = "0.1.0" +name = "phf_shared" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "photom" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1da7b297d3d9ca6122953f2aa6528f66cbc49960825bd018388d9d1115c4422" +dependencies = [ + "ahash", + "camino", + "directories", + "hifitime", + "itertools 0.14.0", + "nom", + "ordered-float", + "polars", + "rayon", + "thiserror", + "ureq", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "planus" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "3daf8e3d4b712abe1d690838f6e29fb76b76ea19589c4afa39ec30e12f62af71" +dependencies = [ + "array-init-cursor", + "hashbrown 0.15.5", +] [[package]] name = "plotters" @@ -1595,19 +2129,573 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" [[package]] -name = "plotters-svg" -version = "0.3.7" +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "polars" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899852b723e563dc3cbdc7ea833b14ec44e61309f55df29ba86d45cfd6bc141a" +dependencies = [ + "getrandom 0.2.17", + "getrandom 0.3.4", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-core", + "polars-error", + "polars-io", + "polars-lazy", + "polars-ops", + "polars-parquet", + "polars-sql", + "polars-time", + "polars-utils", + "version_check", +] + +[[package]] +name = "polars-arrow" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f672743a042b72ace4f88b29f8205ab200b29c5ac976c0560899680c07d2d09" +dependencies = [ + "atoi_simd", + "bitflags", + "bytemuck", + "bytes", + "chrono", + "chrono-tz", + "dyn-clone", + "either", + "ethnum", + "getrandom 0.2.17", + "getrandom 0.3.4", + "half", + "hashbrown 0.16.1", + "itoa", + "lz4", + "num-traits", + "polars-arrow-format", + "polars-buffer", + "polars-error", + "polars-schema", + "polars-utils", + "serde", + "simdutf8", + "streaming-iterator", + "strum_macros", + "version_check", + "zstd", +] + +[[package]] +name = "polars-arrow-format" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a556ac0ee744e61e167f34c1eb0013ce740e0ee6cd8c158b2ec0b518f10e6675" +dependencies = [ + "planus", + "serde", +] + +[[package]] +name = "polars-buffer" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d7011424c3a79ca9c1272c7b4f5fe98695d3bed45595e37bb23c16a2978c80c" +dependencies = [ + "bytemuck", + "either", + "serde", + "version_check", +] + +[[package]] +name = "polars-compute" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a32eca8e08ac4cc5de2ac3996d2b38567bba72cdb19bbfd94c370193ed51dd" +dependencies = [ + "atoi_simd", + "bytemuck", + "chrono", + "either", + "fast-float2", + "hashbrown 0.16.1", + "itoa", + "num-traits", + "polars-arrow", + "polars-buffer", + "polars-error", + "polars-utils", + "rand 0.9.4", + "serde", + "strength_reduce", + "strum_macros", + "version_check", + "zmij", +] + +[[package]] +name = "polars-core" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726296966d04268ee9679c2062af2d06c83c7a87379be471defe616b244c5029" +dependencies = [ + "bitflags", + "boxcar", + "bytemuck", + "chrono", + "chrono-tz", + "comfy-table", + "either", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "indexmap", + "itoa", + "num-traits", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-dtype", + "polars-error", + "polars-row", + "polars-schema", + "polars-utils", + "rand 0.9.4", + "rand_distr", + "rayon", + "regex", + "serde", + "serde_json", + "strum_macros", + "uuid", + "version_check", + "xxhash-rust", +] + +[[package]] +name = "polars-dtype" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51976dc46d42cd1e7ca252a9e3bdc90c63b0bfa7030047ebaf5250c2b7838fa6" +dependencies = [ + "boxcar", + "hashbrown 0.16.1", + "polars-arrow", + "polars-error", + "polars-utils", + "serde", + "uuid", +] + +[[package]] +name = "polars-error" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c13126f8baebc13dadf26a80dcf69a607977fc8a67b18671ad2cefc713a7bdd" +dependencies = [ + "object_store", + "parking_lot", + "polars-arrow-format", + "regex", + "signal-hook", + "simdutf8", +] + +[[package]] +name = "polars-expr" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2151f54b0ae5d6b86c3c47df0898ff90edfe774807823f742f36e44973d51ea1" +dependencies = [ + "bitflags", + "hashbrown 0.16.1", + "num-traits", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-core", + "polars-io", + "polars-ops", + "polars-plan", + "polars-row", + "polars-time", + "polars-utils", + "rand 0.9.4", + "rayon", + "recursive", + "regex", + "version_check", +] + +[[package]] +name = "polars-io" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059724d7762d7332cbc225e6504d996091b28fa1337716e06e5a81d9e54a34ad" +dependencies = [ + "async-trait", + "atoi_simd", + "blake3", + "bytes", + "chrono", + "fast-float2", + "fs4", + "futures", + "glob", + "hashbrown 0.16.1", + "home", + "itoa", + "memchr", + "memmap2", + "num-traits", + "object_store", + "percent-encoding", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-core", + "polars-error", + "polars-parquet", + "polars-schema", + "polars-time", + "polars-utils", + "rayon", + "regex", + "reqwest", + "serde", + "serde_json", + "simdutf8", + "tokio", + "zmij", +] + +[[package]] +name = "polars-lazy" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e1e24d4db8c349e9576564cfff47a3f08bb831dba9168f6599be178bc725e8" +dependencies = [ + "bitflags", + "chrono", + "either", + "memchr", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-core", + "polars-expr", + "polars-io", + "polars-mem-engine", + "polars-ops", + "polars-plan", + "polars-stream", + "polars-time", + "polars-utils", + "rayon", + "version_check", +] + +[[package]] +name = "polars-mem-engine" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c394e4cd90186043d4051ce118e90794afbe81ac5eb9a51e358a56728e8ebde3" +dependencies = [ + "memmap2", + "polars-arrow", + "polars-core", + "polars-error", + "polars-expr", + "polars-io", + "polars-ops", + "polars-plan", + "polars-time", + "polars-utils", + "rayon", + "recursive", +] + +[[package]] +name = "polars-ops" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e47b2d9b3627662650da0a8c76ce5101ed1c61b104cb2b3663e0dc711571b12" +dependencies = [ + "argminmax", + "base64", + "bytemuck", + "chrono", + "chrono-tz", + "either", + "hashbrown 0.16.1", + "hex", + "indexmap", + "libm", + "memchr", + "num-traits", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-core", + "polars-error", + "polars-schema", + "polars-utils", + "rayon", + "regex", + "regex-syntax", + "strum_macros", + "unicode-normalization", + "unicode-reverse", + "version_check", +] + +[[package]] +name = "polars-parquet" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436bae3e89438cafe69400e7567057d7d9820d21ac9a4f69a33b413f2666f03d" +dependencies = [ + "async-stream", + "base64", + "brotli", + "bytemuck", + "ethnum", + "flate2", + "futures", + "hashbrown 0.16.1", + "lz4", + "num-traits", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-error", + "polars-parquet-format", + "polars-utils", + "regex", + "serde", + "simdutf8", + "snap", + "streaming-decompression", + "zstd", +] + +[[package]] +name = "polars-parquet-format" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c025243dcfe8dbc57e94d9f82eb3bef10b565ab180d5b99bed87fd8aea319ce1" +dependencies = [ + "async-trait", + "futures", +] + +[[package]] +name = "polars-plan" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7930d5ae1d006179e65f01af57c859307b5875a4cc078dc75257250b9ae5162" +dependencies = [ + "bitflags", + "blake3", + "bytemuck", + "bytes", + "chrono", + "chrono-tz", + "either", + "futures", + "hashbrown 0.16.1", + "memmap2", + "num-traits", + "percent-encoding", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-core", + "polars-error", + "polars-io", + "polars-ops", + "polars-parquet", + "polars-time", + "polars-utils", + "rayon", + "recursive", + "regex", + "sha2", + "slotmap", + "strum_macros", + "tokio", + "version_check", +] + +[[package]] +name = "polars-row" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ea1a4554fe06442db1d6229235cd358e8eacba96aed8718f612caf3e3a646" +dependencies = [ + "bitflags", + "bytemuck", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-dtype", + "polars-error", + "polars-utils", +] + +[[package]] +name = "polars-schema" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d688e73f9156f93cb29350be144c8f1e84c1bc705f00ee7f15eb9706a7971273" +dependencies = [ + "indexmap", + "polars-error", + "polars-utils", + "serde", + "version_check", +] + +[[package]] +name = "polars-sql" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "100415f86069d7e9fbf54737148fc161a7c7316a6a7d375fb6cfc7fc64f570ae" +dependencies = [ + "bitflags", + "hex", + "polars-core", + "polars-error", + "polars-lazy", + "polars-ops", + "polars-plan", + "polars-time", + "polars-utils", + "regex", + "serde", + "sqlparser", +] + +[[package]] +name = "polars-stream" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65a0c054bdf16efd16bbc587e8d5418ae28464d61afd735513579cd3c338fa70" +dependencies = [ + "async-channel", + "async-trait", + "atomic-waker", + "bitflags", + "bytes", + "chrono-tz", + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-queue", + "crossbeam-utils", + "futures", + "memchr", + "memmap2", + "num-traits", + "parking_lot", + "percent-encoding", + "pin-project-lite", + "polars-arrow", + "polars-buffer", + "polars-compute", + "polars-core", + "polars-error", + "polars-expr", + "polars-io", + "polars-mem-engine", + "polars-ops", + "polars-parquet", + "polars-plan", + "polars-time", + "polars-utils", + "rand 0.9.4", + "rayon", + "recursive", + "slotmap", + "tokio", + "version_check", +] + +[[package]] +name = "polars-time" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72e80404e1e418c997230e3b2972c3be331f45df8bdd3150fe3bef562c7a332f" +dependencies = [ + "atoi_simd", + "bytemuck", + "chrono", + "chrono-tz", + "now", + "num-traits", + "polars-arrow", + "polars-compute", + "polars-core", + "polars-error", + "polars-ops", + "polars-utils", + "rayon", + "regex", + "strum_macros", +] + +[[package]] +name = "polars-utils" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +checksum = "c97cabf53eb8fbf6050cde3fef8f596c51cc25fd7d55fbde108d815ee6674abf" dependencies = [ - "plotters-backend", + "argminmax", + "bincode", + "bytemuck", + "bytes", + "compact_str", + "either", + "flate2", + "foldhash 0.2.0", + "half", + "hashbrown 0.16.1", + "indexmap", + "libc", + "memmap2", + "num-derive", + "num-traits", + "polars-error", + "rand 0.9.4", + "raw-cpuid", + "rayon", + "regex", + "rmp-serde", + "serde", + "serde_json", + "serde_stacker", + "slotmap", + "stacker", + "uuid", + "version_check", ] [[package]] -name = "portable-atomic" -version = "1.11.1" +name = "potential_utf" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] [[package]] name = "ppv-lite86" @@ -1615,7 +2703,17 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.26", + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", ] [[package]] @@ -1642,25 +2740,24 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.94" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "proptest" -version = "1.7.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fcdab19deb5195a31cf7726a210015ff1496ba1464fd42cb4f537b8b01b471f" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.9.0", - "lazy_static", + "bitflags", "num-traits", - "rand", + "rand 0.9.4", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -1669,6 +2766,16 @@ dependencies = [ "unarray", ] +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -1677,9 +2784,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quick-xml" -version = "0.37.4" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", "serde", @@ -1707,14 +2814,14 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", - "getrandom 0.3.2", + "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -1742,27 +2849,44 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.40" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -1772,18 +2896,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" version = "0.5.1" @@ -1791,7 +2921,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand", + "rand 0.9.4", ] [[package]] @@ -1800,7 +2930,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", ] [[package]] @@ -1811,9 +2950,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -1829,22 +2968,51 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "recursive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e" +dependencies = [ + "recursive-proc-macro-impl", + "stacker", +] + +[[package]] +name = "recursive-proc-macro-impl" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", "libredox", "thiserror", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -1854,9 +3022,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -1865,15 +3033,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.12.15" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", @@ -1886,16 +3054,13 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", - "ipnet", "js-sys", "log", - "mime", - "once_cell", "percent-encoding", "pin-project-lite", "quinn", "rustls", - "rustls-pemfile", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -1905,14 +3070,14 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", + "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 0.26.8", - "windows-registry", + "webpki-roots", ] [[package]] @@ -1923,12 +3088,31 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.15", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "roots" version = "0.0.8" @@ -1937,43 +3121,34 @@ checksum = "082f11ffa03bbef6c2c6ea6bea1acafaade2fd9050ae0234ab44a2153742b058" [[package]] name = "rustc-demangle" -version = "0.1.24" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustc_version" -version = "0.4.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustix" -version = "1.0.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.9.0", + "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.25" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "822ee9188ac4ec04a2f0531e55d035fb2de73f18b41a63c70c2712503b6fb13c" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "log", "once_cell", @@ -1985,28 +3160,32 @@ dependencies = [ ] [[package]] -name = "rustls-pemfile" -version = "2.2.0" +name = "rustls-native-certs" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ + "openssl-probe", "rustls-pki-types", + "schannel", + "security-framework", ] [[package]] name = "rustls-pki-types" -version = "1.11.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", + "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.1" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -2015,15 +3194,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rusty-fork" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ "fnv", "quick-error", @@ -2033,9 +3212,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "safe_arch" @@ -2056,31 +3235,73 @@ dependencies = [ ] [[package]] -name = "semver" -version = "1.0.26" +name = "schannel" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] [[package]] -name = "seq-macro" -version = "0.3.6" +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -2089,14 +3310,26 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_stacker" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a" +dependencies = [ + "serde", + "serde_core", + "stacker", ] [[package]] @@ -2111,17 +3344,48 @@ dependencies = [ "serde", ] +[[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", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simba" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a386a501cd104797982c15ae17aafe8b9261315b5d07e3ec803f2ea26be0fa" +checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" dependencies = [ "approx", "num-complex", @@ -2130,26 +3394,50 @@ dependencies = [ "wide", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" -version = "0.4.9" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" dependencies = [ - "autocfg", + "version_check", ] [[package]] name = "smallvec" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "snafu" -version = "0.8.5" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "223891c85e2a29c3fe8fb900c1fae5e69c2e42415e3177752e8718475efa5019" +checksum = "d1d4bced6a69f90b2056c03dcff2c4737f98d6fb9e0853493996e1d253ca29c6" dependencies = [ "backtrace", "snafu-derive", @@ -2157,9 +3445,9 @@ dependencies = [ [[package]] name = "snafu-derive" -version = "0.8.5" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c3c6b7927ffe7ecaa769ee0e3994da3b8cafc8f444578982c83ecb161af917" +checksum = "54254b8531cafa275c5e096f62d48c81435d1015405a91198ddb11e967301d40" dependencies = [ "heck", "proc-macro2", @@ -2175,19 +3463,54 @@ checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" [[package]] name = "socket2" -version = "0.5.9" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "sqlparser" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "505aa16b045c4c1375bf5f125cce3813d0176325bfe9ffc4a903f423de7774ff" +dependencies = [ + "log", + "recursive", + "sqlparser_derive", +] + +[[package]] +name = "sqlparser_derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "028e551d5e270b31b9f3ea271778d9d827148d4287a5d96167b6bb9787f5cc38" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] [[package]] name = "static_assertions" @@ -2195,6 +3518,39 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "streaming-decompression" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf6cc3b19bfb128a8ad11026086e31d3ce9ad23f8ea37354b31383a187c44cf3" +dependencies = [ + "fallible-streaming-iterator", +] + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2203,9 +3559,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.100" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2223,9 +3579,9 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", @@ -2258,15 +3614,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.19.1" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.2", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2280,49 +3636,29 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thrift" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e54bc85fc7faa8bc175c4bab5b92ba8d9a3ce893d0e9f42cc455c8ab16a9e09" -dependencies = [ - "byteorder", - "integer-encoding", - "ordered-float 2.10.1", -] - -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", + "quote", + "syn", ] [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2340,9 +3676,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -2355,24 +3691,35 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.44.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ - "backtrace", "bytes", "libc", "mio", "pin-project-lite", "socket2", - "windows-sys 0.52.0", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -2380,9 +3727,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -2391,9 +3738,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.14" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -2404,9 +3751,9 @@ dependencies = [ [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -2417,6 +3764,24 @@ dependencies = [ "tower-service", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2431,19 +3796,31 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] @@ -2454,21 +3831,11 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "twox-hash" -version = "1.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" -dependencies = [ - "cfg-if", - "static_assertions", -] - [[package]] name = "typenum" -version = "1.18.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unarray" @@ -2478,27 +3845,45 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-reverse" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "4b6f4888ebc23094adfb574fdca9fdc891826287a6397d2cd28802ffd6f20c76" +dependencies = [ + "unicode-segmentation", +] [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] -name = "unit-prefix" -version = "0.5.1" +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "untrusted" @@ -2506,28 +3891,33 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "ureq" -version = "3.0.10" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0351ca625c7b41a8e4f9bb6c5d9755f67f62c2187ebedecacd9974674b271d" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ "base64", "log", "percent-encoding", "rustls", - "rustls-pemfile", "rustls-pki-types", "ureq-proto", - "utf-8", - "webpki-roots 0.26.8", + "utf8-zero", + "webpki-roots", ] [[package]] name = "ureq-proto" -version = "0.3.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae239d0a3341aebc94259414d1dc67cfce87d41cbebc816772c91b77902fafa4" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ "base64", "http", @@ -2537,26 +3927,21 @@ dependencies = [ [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf16_iter" -version = "1.0.5" +name = "utf8-zero" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" [[package]] name = "utf8_iter" @@ -2565,10 +3950,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] -name = "vcpkg" -version = "0.2.15" +name = "uuid" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] [[package]] name = "version_check" @@ -2576,6 +3967,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -2606,63 +4003,56 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip2" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen 0.57.1", ] [[package]] -name = "wasm-bindgen" -version = "0.2.100" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", + "wit-bindgen 0.51.0", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" +name = "wasm-bindgen" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2670,26 +4060,48 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -2703,11 +4115,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -2725,59 +4149,72 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.8" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] [[package]] -name = "webpki-roots" -version = "1.0.2" +name = "wide" +version = "0.7.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" dependencies = [ - "rustls-pki-types", + "bytemuck", + "safe_arch", ] [[package]] -name = "wide" -version = "0.7.32" +name = "winapi" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41b5576b9a81633f3e8df296ce0063042a73507636cbe956c61133dd7034ab22" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ - "bytemuck", - "safe_arch", + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", ] +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" -version = "0.61.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.2.1", "windows-result", - "windows-strings 0.4.0", + "windows-strings", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -2786,9 +4223,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", @@ -2802,41 +4239,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] -name = "windows-registry" -version = "0.4.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" -dependencies = [ - "windows-result", - "windows-strings 0.3.1", - "windows-targets 0.53.3", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-result" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.3.1" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] name = "windows-strings" -version = "0.4.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2863,7 +4286,16 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.3", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", ] [[package]] @@ -2884,19 +4316,19 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.3" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -2907,9 +4339,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -2919,9 +4351,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -2931,9 +4363,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" @@ -2943,9 +4375,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -2955,9 +4387,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -2967,9 +4399,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -2979,9 +4411,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -2991,89 +4423,153 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "bitflags 2.9.0", + "wit-bindgen-rust-macro", ] [[package]] -name = "write16" -version = "1.0.0" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "writeable" -version = "0.5.5" +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] [[package]] -name = "yoke" -version = "0.7.5" +name = "wit-bindgen-rust" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", ] [[package]] -name = "yoke-derive" -version = "0.7.5" +name = "wit-bindgen-rust-macro" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" dependencies = [ + "anyhow", + "prettyplease", "proc-macro2", "quote", "syn", - "synstructure", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] -name = "zerocopy" -version = "0.7.35" +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ - "zerocopy-derive 0.7.35", + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] -name = "zerocopy" -version = "0.8.26" +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yoke" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ - "zerocopy-derive 0.8.26", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] -name = "zerocopy-derive" -version = "0.7.35" +name = "yoke-derive" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.26" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -3082,18 +4578,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -3103,15 +4599,26 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] [[package]] name = "zerovec" -version = "0.10.4" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3120,11 +4627,51 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", "syn", ] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index a1867e8..8fb287f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "outfit" -version = "2.1.0" +version = "3.0.0" edition = "2021" license-file = "LICENSE" -rust-version = "1.82" +rust-version = "1.94.0" description = "Orbit determination toolkit in Rust. Provides astrometric parsing, observer management, and initial orbit determination (Gauss method) with JPL ephemeris support." readme = "README.md" repository = "https://github.com/FusRoman/Outfit" @@ -20,55 +20,45 @@ categories = ["science", "parsing", "data-structures"] authors = ["Roman Le Montagner "] [dependencies] + +photom = { version = "0.4.0", default-features = false } + aberth = { version = "0.4.1", default-features = false } ahash = { version = "0.8.11", default-features = false } -arrow-array = { version = "54.3.1", default-features = false } camino = { version = "1.1.9", default-features = false } directories = "6.0.0" hifitime = { version = "4.2.0", default-features = false, features = [ "ut1", "std", ] } -itertools = { version = "0.14.0", default-features = false, features = [ - "use_std", -] } -nalgebra = { version = "0.33.2" } +nalgebra = { version = "0.34.2" } ordered-float = { version = "5.0.0", default-features = false } -parquet = { version = "54.2.1", default-features = false, features = [ - "arrow", - "snap", -] } -quick-xml = { version = "0.37.3", features = [ - "serialize", -], default-features = false } -serde = { version = "1.0", features = ["derive"], default-features = false } smallvec = { version = "1.14.0", default-features = false } thiserror = { version = "2.0.12", default-features = false } ureq = { version = "3.0.10", default-features = false, features = ["rustls"] } nom = { version = "8.0.0" } -once_cell = { version = "1.21.3", default-features = false } roots = { version = "0.0.8", default-features = false } rand = { version = "0.9.2", default-features = false, features = [ "std_rng", "os_rng", + "small_rng", ] } rand_distr = { version = "0.5.1", default-features = false } -reqwest = { version = "0.12.15", default-features = false, optional = true, features = [ +reqwest = { version = "0.12.15", default-features = false, features = [ "http2", "rustls-tls", "stream", ] } -tokio = { version = "1.44.1", default-features = false, optional = true, features = [ +tokio = { version = "1.44.1", default-features = false, features = [ "fs", "rt", "rt-multi-thread", "io-util", ] } -tokio-stream = { version = "0.1.17", default-features = false, optional = true } -indicatif = { version = "0.18", optional = true, default-features = false } -rayon = { version = "1.11.0", optional = true, default-features = false } -comfy-table = { version = "7.1.4", default-features = false } +tokio-stream = { version = "0.1.17", default-features = false } +rayon = { version = "1.12.0", default-features = false, optional = true } +differential-equations = { version = "0.6.1", default-features = false } [dev-dependencies] approx = { version = "0.5.1", default-features = false } @@ -76,60 +66,26 @@ criterion = { version = "0.5.1", features = ["html_reports"] } husky-rs = "0.1.5" proptest = "1.7.0" -[features] -jpl-download = ["dep:reqwest", "dep:tokio", "dep:tokio-stream"] -progress = ["dep:indicatif"] -parallel = ["dep:rayon"] - -[[test]] -name = "reader_80col_test" -path = "tests/reader_80col_test.rs" -required-features = ["jpl-download"] - -[[test]] -name = "test_read_ades" -path = "tests/test_read_ades.rs" -required-features = ["jpl-download"] - -[[test]] -name = "test_large_parquet" -path = "tests/trajectories_from_parquet.rs" -required-features = ["jpl-download"] +photom = { version = "0.4.0", default-features = false, features = [ + "mpc_80_col", + "polars", +] } +polars = { version = "0.53.0", features = ["is_in"] } -[[test]] -name = "test_trajectories_from_vec" -path = "tests/trajectories_from_vec.rs" -required-features = ["jpl-download"] +[features] +parallel = ["photom/parallel", "rayon"] [[example]] -name = "gauss_iod_once" -path = "examples/gauss_iod_once.rs" -required-features = ["jpl-download"] +name = "fit_full_iod" +path = "examples/run_full_iod.rs" [[example]] -name = "parquet_to_orbit" -path = "examples/parquet_to_orbit.rs" -required-features = ["jpl-download"] - -[[bench]] -name = "load_parquet" -harness = false -required-features = ["jpl-download"] - -[[bench]] -name = "outfit_gauss_iod" -harness = false -required-features = ["jpl-download"] +name = "fit_full_iod_parallel" +path = "examples/run_full_iod_parallel.rs" +required-features = ["parallel"] -[[bench]] -name = "solve_kepler_equation" -harness = false -required-features = ["jpl-download"] - -[[bench]] -name = "gauss_prelim_orbit" -harness = false -required-features = ["jpl-download"] +[profile.dev] +debug = false [profile.bench] opt-level = 3 @@ -143,6 +99,14 @@ lto = "fat" codegen-units = 1 strip = true +[profile.examples] +inherits = "release" +opt-level = 3 +debug = false +lto = "thin" +codegen-units = 16 +strip = false + [package.metadata.docs.rs] -features = ["jpl-download"] +features = ["parallel"] rustdoc-args = ["--cfg", "docsrs"] diff --git a/README.md b/README.md index df1eff4..6036c7a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Outfit

-A fast, safe, and extensible Rust library for **managing astrometric observations** and **determining preliminary orbits** of small bodies. Outfit reads common observation formats (MPC 80-column, ADES XML, Parquet), performs **initial orbit determination (IOD)** with the **Gauss method**, manages observers (topocentric geometry), and interfaces with **JPL ephemerides** for accurate state propagation. +A fast, safe, and modular Rust library for **orbit determination** of Solar System small bodies. Outfit focuses exclusively on **orbital dynamics and computation**: Gauss IOD, differential orbit correction, ephemeris generation, Keplerian propagation, reference frame transformations, and JPL ephemeris support. Observation I/O and data structures are provided by the companion crate [**photom**](https://crates.io/crates/photom).

@@ -25,27 +25,25 @@ A fast, safe, and extensible Rust library for **managing astrometric observation codecov - MSRV + MSRV

-> **Why Outfit?** -> Modern asteroid pipelines need a library that is **fast (Rust)**, **reproducible**, and **easy to integrate** in data-intensive workflows (batch files, Parquet, CI/benchmarks). Outfit re-implements classic OrbFit IOD logic with a **memory-safe**, **modular** design and production-grade ergonomics (features, docs, tests, benches). It is built to: -> - ingest **large datasets** efficiently (columnar Parquet, batch APIs); -> - run **deterministic IOD** with controlled noise and repeatable seeds; -> - interface **cleanly** with JPL ephemerides (e.g., DE440); -> - provide a **clean API** that composes well across projects. +> **Ecosystem split** +> Outfit v3 is a focused **orbital dynamics engine**. Everything related to observation loading (MPC 80-column, ADES XML, Parquet), observer management, and astrometric data structures now lives in the separate **[photom](https://crates.io/crates/photom)** crate. Outfit depends on `photom` and exposes the `FitIOD` trait that bridges photom's `ObsDataset` into the orbit fitting pipeline. --- ## Table of Contents - [Features](#features) +- [Ecosystem: Outfit + photom](#ecosystem-outfit--photom) - [Installation](#installation) - [Quick Start](#quick-start) -- [Data Formats](#data-formats) - [Initial Orbit Determination](#initial-orbit-determination) -- [Observers & Reference Frames](#observers--reference-frames) +- [Differential Orbit Correction](#differential-orbit-correction) +- [Uncertainty Propagation](#uncertainty-propagation) +- [Ephemeris Generation](#ephemeris-generation) - [Cargo Feature Flags](#cargo-feature-flags) - [Performance & Reproducibility](#performance--reproducibility) - [Roadmap](#roadmap) @@ -57,96 +55,173 @@ A fast, safe, and extensible Rust library for **managing astrometric observation ## Features -- **Observation I/O** - - MPC **80-column** files - - **ADES XML** files - - **Parquet** batches for high-throughput pipelines -- **Observer management** - - Lookup by **MPC code** - - Topocentric geometry (geocentric & heliocentric positions, AU, J2000) -- **Initial Orbit Determination** +- **Initial Orbit Determination (IOD)** - **Gauss method** on observation triplets - Iterative velocity correction with Lagrange coefficients - Dynamic acceptability filters (perihelion, eccentricity, geometry) - RMS evaluation on extended arcs -- **Ephemerides** - - Interface with **JPL** ephemerides (e.g., **DE440**) -- **Error models** - - Built-in astrometric uncertainty models (e.g., FCCT14) -- **Batch processing & benchmarking** - - Stream triplets, evaluate, and rank candidates - - Criterion-based micro/macro benchmarks - - Optional parallel batch IOD using Rayon (feature: `parallel`) +- **Differential Orbit Correction** + - Iterative **Newton–Raphson least-squares** refinement of equinoctial elements + - Projection-based **outlier rejection** loop (chi-squared per observation) + - Covariance matrix estimation with posterior uncertainty rescaling + - Configurable free/fixed element mask (useful for short arcs) + - Stagnation and divergence detection with typed error variants + - `FitLSQ` trait: per-trajectory pipeline (IOD seed → differential correction) on any `ObsDataset` +- **Ephemeris generation** + - Predict apparent sky positions `(RA, Dec)` and distances from any `OrbitalElements` + - Compute geometric quantities: **phase angle**, **solar elongation**, **radial velocity**, apparent angular rates + - Combined mode computes position and geometry in a single propagation + - Three generation modes per observer: `Single`, `Range` (uniform grid), `At` (arbitrary epoch list) + - Multiple observers in one typed `EphemerisRequest`; per-epoch errors collected without aborting the batch + - Choice of **two-body (Keplerian)** or **N-body (DOP853)** propagator; first- or second-order aberration correction +- **Orbital elements** + - Classical **Keplerian elements** + - **Equinoctial elements** with conversions and two-body solver + - **Cometary elements** +- **JPL ephemerides** + - Interface with **JPL DE440** (Horizons and NAIF/SPICE formats) +- **Reference frames & preprocessing** + - Precession, nutation (IAU 1980), aberration, light-time correction + - Ecliptic ↔ equatorial conversions, RA/DEC parsing, time systems +- **Observer geometry** + - Geocentric and heliocentric observer positions in AU, J2000 +- **Batch processing** + - Single-trajectory and full-dataset IOD via the `FitIOD` trait + - Full least-squares fitting for all trajectories via the `FitLSQ` trait + - Optional parallel batch execution with Rayon (`parallel` feature) --- -## Installation +## Ecosystem: Outfit + photom -Add Outfit to your `Cargo.toml`: +Outfit is one half of a two-crate pipeline: -~~~toml -[dependencies] -outfit = "2.0.0" -~~~ +| Crate | Responsibility | +|-------|---------------| +| [**photom**](https://crates.io/crates/photom) | Observation I/O (MPC 80-col, ADES XML, Parquet), data structures (`ObsDataset`, `Observer`, error models), trajectory grouping | +| **outfit** | Pure orbital computation: Gauss IOD, differential correction, ephemeris generation, Keplerian/equinoctial elements, JPL ephemerides, reference frames, residuals | -Enable automatic ephemeris download (JPL DE440) with the `jpl-download` feature: +A typical workflow: -~~~toml -[dependencies] -outfit = { version = "2.0.0", features = ["jpl-download"] } -~~~ +1. Use **photom** to load observations into an `ObsDataset`. +2. Call **outfit**'s `FitIOD::fit_iod` / `FitIOD::fit_full_iod` for initial orbit determination, or `FitLSQ::fit_lsq` for a full least-squares fit. +3. Inspect the returned `GaussResult` / `DifferentialCorrectionOutput` and RMS values. +4. Optionally, call `OrbitalElements::compute` with an `EphemerisRequest` to generate predicted positions. -Enable a CLI-style progress bar for long loops: +--- + +## Installation + +Add Outfit and photom to your `Cargo.toml`: ~~~toml [dependencies] -outfit = { version = "2.0.0", features = ["progress"] } +outfit = "3.0.0" +photom = { version = "0.1.0", features = ["mpc_80_col"] } ~~~ -Combine features as needed (example): +Enable optional features as needed: ~~~toml [dependencies] -outfit = { version = "2.0.0", features = ["jpl-download", "progress", "parallel"] } +outfit = "3.0.0" +photom = { version = "0.1.0", features = ["mpc_80_col", "ades", "polars"] } ~~~ --- ## Quick Start -The crate ships with several **ready-to-run examples** in the [`examples/`](examples) directory. -They demonstrate end-to-end workflows such as: - -- Reading observations (MPC 80-column, ADES XML, Parquet) -- Building a `TrajectorySet` (now via `TrajectoryFile` ingestion helpers) -- Running Gauss initial orbit determination (single triplet or full batch) -- Inspecting and printing orbital elements with RMS statistics - -Run an example directly with Cargo: - -```bash -cargo run --release --example parquet_to_orbit --features jpl-download +### Single-trajectory IOD from an MPC 80-column file + +```rust,no_run +use photom::observation_dataset::ObsDataset; +use photom::observer::error_model::ObsErrorModel; +use hifitime::ut1::Ut1Provider; +use rand::{rngs::StdRng, SeedableRng}; +use outfit::obs_dataset::FitIOD; +use outfit::jpl_ephem::{download_jpl_file::EphemFileSource, JPLEphem}; +use outfit::IODParams; + +fn main() -> Result<(), Box> { + // Load observations (photom handles I/O). + let dataset = ObsDataset::from_mpc_80_col("tests/data/2015AB.obs")?; + + // Load JPL DE440 ephemeris. + let jpl_source: EphemFileSource = "horizon:DE440".try_into()?; + let jpl = JPLEphem::new(&jpl_source)?; + + // Earth orientation corrections. + let ut1 = Ut1Provider::download_from_jpl("latest_eop2.long")?; + + // Configure IOD parameters. + let params = IODParams::builder() + .n_noise_realizations(10) + .max_obs_for_triplets(50) + .max_triplets(30) + .build()?; + + let mut rng = StdRng::seed_from_u64(42); + + // Run Gauss IOD — outfit does the orbital computation. + let (best_orbit, best_rms) = dataset.fit_iod( + "K09R05F", + &jpl, + &ut1, + ¶ms, + ObsErrorModel::FCCT14, + &mut rng, + )?; + + println!("Best orbit: {best_orbit}"); + println!("RMS: {best_rms:.6}"); + Ok(()) +} ``` -This will: - -- Load observations from a Parquet file, - -- Identify the observer by MPC code, - -- Run the Gauss IOD pipeline, - -- Print the best-fit orbit with its RMS value. - ---- - -## Data Formats - -- **MPC 80-column** – [Minor Planet Center fixed-width astrometry format](https://minorplanetcenter.net/iau/info/OpticalObs.html) -- **ADES XML** – [IAU Astrometric Data Exchange Standard (ADES)](https://minorplanetcenter.net/iau/info/ADES.html) -- **Parquet** – [Apache Parquet](https://parquet.apache.org/) columnar format for large batch processing - Typical columns: `ra`, `dec`, `jd` or `mjd`, `trajectory_id` (configurable) - +### Batch IOD — all trajectories at once + +```rust,no_run +use photom::observation_dataset::ObsDataset; +use photom::observer::error_model::ObsErrorModel; +use hifitime::ut1::Ut1Provider; +use rand::{rngs::StdRng, SeedableRng}; +use outfit::obs_dataset::{FitIOD, FullOrbitResult}; +use outfit::jpl_ephem::{download_jpl_file::EphemFileSource, JPLEphem}; +use outfit::IODParams; + +fn main() -> Result<(), Box> { + let dataset = ObsDataset::from_mpc_80_col("tests/data/2015AB.obs")?; + + let jpl_source: EphemFileSource = "horizon:DE440".try_into()?; + let jpl = JPLEphem::new(&jpl_source)?; + let ut1 = Ut1Provider::download_from_jpl("latest_eop2.long")?; + + let params = IODParams::builder() + .n_noise_realizations(10) + .max_obs_for_triplets(50) + .build()?; + + let mut rng = StdRng::seed_from_u64(42); + + // Run Gauss IOD for every trajectory in the dataset. + let results: FullOrbitResult = dataset.fit_full_iod( + &jpl, + &ut1, + ¶ms, + ObsErrorModel::FCCT14, + &mut rng, + )?; + + for (traj_id, res) in &results { + match res { + Ok((gauss, rms)) => println!("{traj_id} → RMS = {rms:.4}\n{gauss}"), + Err(e) => eprintln!("{traj_id} → error: {e}"), + } + } + Ok(()) +} +``` --- @@ -164,80 +239,191 @@ Designed for **robustness and speed** in survey-scale use (LSST, ZTF, ...). --- -### Parallel batches +## Differential Orbit Correction + +Starting from an initial orbit (e.g. from the Gauss IOD step), outfit can refine the solution via **weighted least-squares differential correction**: + +1. Compute predicted `(RA, Dec)` for each observation using the current elements. +2. Form the **design matrix** of partial derivatives of the predicted coordinates with respect to the six equinoctial elements. +3. Solve the **normal equations** to obtain the element correction vector δx. +4. Apply the correction, check convergence (`‖δx‖ < threshold`), and iterate (Newton–Raphson). +5. After convergence, apply **projection-based outlier rejection**: observations whose chi-squared contribution exceeds the configured threshold are flagged and the inner loop is re-run on the reduced set. Iterate until the selection is stable. +6. Rescale the **covariance matrix** by the posterior normalised RMS. + +The `FitLSQ` trait exposes this pipeline at the dataset level: + +```rust,no_run +use photom::observation_dataset::ObsDataset; +use photom::observer::error_model::ObsErrorModel; +use hifitime::ut1::Ut1Provider; +use rand::{rngs::StdRng, SeedableRng}; +use outfit::differential_orbit_correction::{DifferentialCorrectionConfig, FitLSQ}; +use outfit::jpl_ephem::{download_jpl_file::EphemFileSource, JPLEphem}; +use outfit::IODParams; + +fn main() -> Result<(), Box> { + let dataset = ObsDataset::from_mpc_80_col("tests/data/2015AB.obs")?; + + let jpl_source: EphemFileSource = "horizon:DE440".try_into()?; + let jpl = JPLEphem::new(&jpl_source)?; + let ut1 = Ut1Provider::download_from_jpl("latest_eop2.long")?; + + let iod_params = IODParams::builder().build()?; + let dc_config = DifferentialCorrectionConfig::default(); + + let mut rng = StdRng::seed_from_u64(42); + + // Run IOD + differential correction for every trajectory. + let results = dataset.fit_lsq( + &jpl, + &ut1, + ObsErrorModel::FCCT14, + &iod_params, + &dc_config, + None, // no pre-computed initial orbits + &mut rng, + )?; + + for (traj_id, res) in &results { + match res { + Ok(fit) => println!("{traj_id} → normalised RMS = {:.4}", fit.normalised_rms()), + Err(e) => eprintln!("{traj_id} → error: {e}"), + } + } + Ok(()) +} +``` -Compile with `--features parallel` and prefer the adaptive batching API for very large sets: +--- -```bash -cargo run --release --example parquet_to_orbit --features "jpl-download,parallel" -``` +## Uncertainty Propagation -Then call `estimate_all_orbits_in_batches_parallel` (same signature + extra `batch_size` argument) for improved throughput when trajectory sets exceed CPU cache friendliness. +Outfit tracks orbital uncertainties throughout the full pipeline — from the least-squares fit through to element representation conversions. -### Result helpers +### Generation from differential correction -Access individual solutions ergonomically: +The covariance matrix is produced directly by the `FitLSQ` pipeline. At the end of each Newton–Raphson convergence, `solve_weighted_least_squares` builds the **normal matrix** G⊤WG and inverts it (Cholesky, with QR fallback) to obtain the **6×6 covariance matrix** Γ = (G⊤WG)⁻¹ in equinoctial element space: -```rust -use outfit::trajectories::trajectory_fit::{gauss_result_for, take_gauss_result}; -// Borrow without moving: -if let Some(Ok((g, rms))) = gauss_result_for(&results, &some_object) { - println!("Semi-major axis: {} AU (rms={rms:.3})", g.keplerian.a); -} +``` +Γ[j,k] = (G⊤WG)⁻¹[j,k] (6×6, equinoctial basis) ``` -Errors are explicit (no `Option` inside `Result`); pathological numeric scores (`NaN`, `inf`) surface as `OutfitError::NonFiniteScore`. +This raw covariance is then **rescaled** by a posterior inflation factor μ that accounts for the degrees of freedom and the quality of the fit: ---- +- if normalised RMS ≤ 1 (good fit): μ = √(n_measurements / (n_measurements − n_free)) +- if normalised RMS > 1 (poor fit): μ = normalised_rms × √(n_measurements / (n_measurements − n_free)) -## Observers & Reference Frames +The final covariance is stored in `DifferentialCorrectionOutput::uncertainty` and embedded in the returned `OrbitalElements::Equinoctial` variant. -- Observer lookup by **MPC code**. -- Geocentric and heliocentric positions in **AU**, **J2000** (equatorial). -- Earth orientation (nutation, precession) and **aberration** corrections via internal reference-system utilities. +### Propagation between element representations ---- +Each `OrbitalElements` variant carries an optional `covariance: Option` and an optional `uncertainty: Option<*Uncertainty>` (per-element 1-σ standard deviations). When converting between representations, the covariance is propagated via **first-order linear (Jacobian) propagation**: + +``` +Σ_y = J · Σ_x · Jᵀ +``` -## Cargo Feature Flags +where J = ∂y/∂x is the 6×6 Jacobian of the transformation evaluated at the nominal elements. The following Jacobians are computed analytically: + +| Conversion | Jacobian | +|---|---| +| Keplerian → Equinoctial | ∂(a,h,k,p,q,λ) / ∂(a,e,i,Ω,ω,M) | +| Equinoctial → Keplerian | ∂(a,e,i,Ω,ω,M) / ∂(a,h,k,p,q,λ) | +| Cometary → Keplerian | ∂(a,e,i,Ω,ω,M) / ∂(q,e,i,Ω,ω,ν) | +| Cometary → Equinoctial | chain rule via Keplerian | + +Conversions near singularities (e → 0 for Keplerian, i → 0 for equatorial) set undefined partial derivatives to zero; **equinoctial elements** are preferred for nearly circular or equatorial orbits to avoid these singular regions. + +```rust,no_run +use outfit::orbit_type::{OrbitalElements, keplerian_element::KeplerianElements}; +use outfit::orbit_type::uncertainty::{KeplerianUncertainty, OrbitalCovariance}; +use nalgebra::Matrix6; + +// Keplerian elements with a diagonal covariance (simplified example). +let cov = OrbitalCovariance { matrix: Matrix6::from_diagonal_element(1e-6) }; +let oe = OrbitalElements::Keplerian { + elements: kep, + uncertainty: Some(KeplerianUncertainty::from_covariance(&cov)), + covariance: Some(cov), +}; + +// Convert to equinoctial — covariance is automatically propagated via Jacobian. +let oe_eq = oe.to_equinoctial().unwrap(); + +if let OrbitalElements::Equinoctial { uncertainty, .. } = oe_eq { + if let Some(unc) = uncertainty { + println!("σ(h) = {:.2e}", unc.eccentricity_sin_lon); + } +} +``` -The crate keeps the feature surface intentionally small and orthogonal. All core parsing (MPC 80-col, ADES XML, Parquet) and Gauss IOD logic are always compiled; features only toggle optional runtime dependencies. +--- -| Feature | Adds | Notes | -|----------------|---------------------------------------------------------|-------| -| `jpl-download` | `reqwest`, `tokio` (multi-thread RT), on-demand fetch | Downloads and caches JPL ephemerides (e.g. DE440) in the user data dir on first access. Offline re-use afterward. | -| `progress` | `indicatif` progress bars + moving average timing | Enabled in long batch IOD loops; zero cost when not used. | -| `parallel` | `rayon` | Enables `TrajectoryFit::estimate_all_orbits_in_batches_parallel`; set `IODParams.batch_size` to tune batch granularity. | +## Ephemeris Generation + +Given any `OrbitalElements`, outfit can predict the apparent sky position and geometric state of the body as seen from one or more observers: + +```rust,no_run +use outfit::{ + OrbitalElements, + ephemeris::{EphemerisConfig, EphemerisMode, EphemerisRequest, request::Combined}, +}; +use hifitime::{Epoch, Duration}; + +// `elements` obtained from IOD or differential correction. +let result = elements.compute( + &EphemerisRequest::::new(EphemerisConfig::default()) + .add(observer, EphemerisMode::Range { + start: Epoch::from_mjd_tt(60310.0), + end: Epoch::from_mjd_tt(60340.0), + step: Duration::from_days(1.0), + }), + &jpl, + &ut1, +); + +for entry in result.successes() { + let (pos, geo) = entry.result.as_ref().unwrap(); + println!( + "{}: RA={:.4} Dec={:.4} phase={:.2}° elongation={:.2}°", + entry.epoch, pos.coord.ra, pos.coord.dec, + geo.phase_angle.to_degrees(), geo.solar_elongation.to_degrees(), + ); +} +``` -Planned (not yet implemented): least-squares refinement and alternative IOD methods will likely get their own feature gates. +The pipeline converts elements to **equinoctial form**, propagates with the selected propagator (two-body or N-body), rotates from ecliptic to equatorial J2000, and applies aberration correction. Errors at individual epochs are stored in the result rather than aborting the computation. --- ## Performance & Reproducibility - **Deterministic runs** by default (set RNG seeds explicitly when noise is used). -- **Batch-friendly APIs** (Parquet; streaming triplets). -- Avoid ephemeris I/O in hot paths by **precomputing observer positions**. -- Benchmarks via **criterion** (see below). - -**Tips** +- **Precompute observer positions** to avoid ephemeris I/O in hot paths. - Compile with `--release` for production. -- Keep ephemerides cached locally (with `jpl-download` enabled) to avoid I/O stalls. -- Use the `progress` feature to instrument long loops without cluttering core logic. -- `ObservationBatch` conversions (deg/arcsec → rad) happen once; avoid re-normalizing angles inside tight loops. -- Group observations by unique epoch to reuse cached observer positions (already done by built-in ingestion pipelines). -- Handle errors rather than filtering silently: a non-finite RMS is raised early as `OutfitError::NonFiniteScore` to prevent propagating invalid states. -- For very large trajectory sets, tune `IODParams.batch_size` (with `parallel`) so each batch fits comfortably in cache (start with 4–8 and benchmark). +- Keep ephemerides cached locally to avoid I/O stalls on repeated runs. +- For large trajectory sets, tune `IODParams.batch_size` (with `parallel`) so each batch fits comfortably in cache (start with 4–8 and benchmark). +- Handle errors explicitly: a non-finite RMS surfaces as `OutfitError::NonFiniteScore`. + +--- + + +## Documentation + +To compile the documentation locally, run the following command in the terminal: +```bash +RUSTDOCFLAGS="--html-in-header $(pwd)/katex-header.html" cargo doc --no-deps --all-features +``` --- ## Roadmap -- **Full least-squares orbit fitting** across full arcs - **Hyperbolic & parabolic orbits** (e ≥ 1) for interstellar candidates -- **Alternative IOD methods** (e.g., **Vaisala**) -- **Ephemerides backends** (e.g., ANISE/SPICE integration) +- **Vaisälä method** for short arcs +- **Additional ephemerides backends** (e.g., ANISE/SPICE integration) -See the issue tracker for the latest details and discussion. +See the issue tracker for details and discussion. --- @@ -257,7 +443,6 @@ A typical dev loop: cargo fmt --all cargo clippy --all-targets --all-features cargo test --all-features -cargo bench --features jpl-download ~~~ --- diff --git a/benches/gauss_prelim_orbit.rs b/benches/gauss_prelim_orbit.rs deleted file mode 100644 index 7621ca1..0000000 --- a/benches/gauss_prelim_orbit.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! Benchmarks for GaussObs::prelim_orbit (single-threaded) -//! -//! Exemples d'exécution : -//! cargo bench --bench gauss_prelim_orbit --features jpl-download -//! cargo bench gauss_prelim_orbit -- gauss_prelim_orbit/single_call -//! cargo bench gauss_prelim_orbit -- gauss_prelim_orbit/batch_100 -//! cargo bench gauss_prelim_orbit -- gauss_prelim_orbit/noisy_batch_100 -//! -//! Astuce : vous pouvez aussi lancer en ligne de commande avec -//! RAYON_NUM_THREADS=1 cargo bench --bench gauss_prelim_orbit - -#![cfg_attr(not(feature = "jpl-download"), allow(dead_code))] - -use criterion::{black_box, criterion_group, criterion_main, BatchSize, Criterion}; -use nalgebra::{Matrix3, Vector3}; - -use outfit::initial_orbit_determination::gauss::GaussObs; -use outfit::initial_orbit_determination::gauss_result::GaussResult; -use outfit::initial_orbit_determination::IODParams; -use outfit::outfit::Outfit; -use outfit::outfit_errors::OutfitError; -use rand::SeedableRng; - -/// Build global Outfit state (ephemerides, frames, etc.) -/// Note: keep this outside the hot loops. -fn build_state() -> Result { - // English in-code comments per user preference: - // Using FCCT14 as in production; adjust if you want to compare error models. - use outfit::error_models::ErrorModel; - Outfit::new("horizon:DE440", ErrorModel::FCCT14) -} - -/// Deterministic GaussObs fixture (angles in radians, times in MJD TT) -fn make_fixture_gaussobs() -> GaussObs { - let idx_obs = Vector3::new(0, 1, 2); - let ra = Vector3::new(1.6894680985108945, 1.6898614520910629, 1.7526450904422723); - let dec = Vector3::new( - 1.0825984522657437, - 0.943_679_018_934_623_1, - 0.827_517_321_571_201_4, - ); - let time = Vector3::new( - 57_028.454_047_592_59, - 57_049.231_857_592_59, - 57_063.959_487_592_59, - ); - - let observer_helio_position = Matrix3::new( - -0.264_135_633_607_079, - -0.588_973_552_650_573_5, - -0.774_192_148_350_372, - 0.869_046_620_910_086, - 0.724_011_718_791_646, - 0.561_510_219_548_918_2, - 0.376_746_685_666_572_5, - 0.313_873_420_677_094, - 0.243_444_791_401_658_5, - ); - - GaussObs::with_observer_position(idx_obs, ra, dec, time, observer_helio_position) -} - -/// Force Rayon (if used by downstream code) to a single worker thread. -/// Must be called before any Rayon pool is created by dependencies. -fn force_single_thread_pool() { - // Pure env-var approach: works even if we don't depend on rayon directly. - // Must be set very early, before any rayon usage. - std::env::set_var("RAYON_NUM_THREADS", "1"); - - // If you prefer hard enforcement and have rayon as a dev-dependency, - // you could uncomment this block (but it's optional): - // - // #[cfg(any())] - // { - // let _ = rayon::ThreadPoolBuilder::new() - // .num_threads(1) - // .build_global(); - // // Ignore the error if the global pool was already built. - // } -} - -fn bench_prelim_orbit(c: &mut Criterion) { - // Ensure single-threaded execution before anything else touches Rayon. - force_single_thread_pool(); - - let mut group = c.benchmark_group("gauss_prelim_orbit"); - - // Build global state once (outside hot loops). - let state = build_state().expect("Outfit state"); - - // Deterministic fixture. - let gauss = make_fixture_gaussobs(); - - // 1) Single call - group.bench_function("single_call", |b| { - b.iter(|| { - let res = gauss.prelim_orbit(black_box(&state), &IODParams::default()); - match res { - Ok(GaussResult::PrelimOrbit(_)) | Ok(GaussResult::CorrectedOrbit(_)) => {} - Err(e) => panic!("prelim_orbit failed: {e:?}"), - } - }) - }); - - // 2) Batch 100: reuse same input, measure algorithmic cost only - group.bench_function("batch_100", |b| { - b.iter_batched( - || gauss.clone(), - |g| { - for _ in 0..100 { - let res = g.prelim_orbit(&state, &IODParams::default()); - black_box(&res); - } - }, - BatchSize::SmallInput, - ) - }); - - // 3) Noisy: generate one noisy realization then run prelim_orbit - group.bench_function("noisy_single_call", |b| { - b.iter_batched( - || gauss.clone(), - |g| { - // 0.3" ≈ 1.454e-6 rad; use your production noise scale if different. - let sigma_rad = 1.5e-6_f64; - let mut rng = rand::rngs::StdRng::seed_from_u64(42); - let noisy = g.generate_noisy_realizations( - &(Vector3::zeros().add_scalar(1.0) * sigma_rad), - &(Vector3::zeros().add_scalar(1.0) * sigma_rad), - 100, - 1.0, - &mut rng, - ); - - for gg in noisy { - let res = gg.prelim_orbit(&state, &IODParams::default()); - black_box(&res); - } - }, - BatchSize::SmallInput, - ) - }); - - group.finish(); -} - -criterion_group!(gauss_benches, bench_prelim_orbit); -criterion_main!(gauss_benches); diff --git a/benches/load_parquet.rs b/benches/load_parquet.rs deleted file mode 100644 index c3580d1..0000000 --- a/benches/load_parquet.rs +++ /dev/null @@ -1,54 +0,0 @@ -use camino::Utf8Path; -use criterion::Throughput; -use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; -use outfit::error_models::ErrorModel; -use outfit::outfit::Outfit; -use outfit::trajectories::trajectory_file::TrajectoryFile; -use outfit::TrajectorySet; - -fn bench_load_parquet(c: &mut Criterion) { - let mut outfit = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - let path = Utf8Path::new("tests/data/test_from_fink.parquet"); - let ztf_observer = outfit.get_observer_from_mpc_code(&"I41".into()); - - let mut batch_group = c.benchmark_group("batch_sizes"); - - for batch_size in [ - Some(2), - Some(4), - Some(8), - Some(16), - Some(32), - Some(64), - Some(128), - Some(256), - Some(512), - Some(1024), - Some(2048), - Some(4096), - Some(65536), - ] - .iter() - { - batch_group.throughput(Throughput::Elements(1)); - batch_group.bench_with_input( - BenchmarkId::from_parameter(format!("{batch_size:?}")), - batch_size, - |b, batch_size| { - b.iter(|| { - let _ = TrajectorySet::new_from_parquet( - &mut outfit, - path, - ztf_observer.clone(), - 0.5, - 0.5, - *batch_size, - ); - }) - }, - ); - } -} - -criterion_group!(benches, bench_load_parquet); -criterion_main!(benches); diff --git a/benches/outfit_gauss_iod.rs b/benches/outfit_gauss_iod.rs deleted file mode 100644 index d9acdc2..0000000 --- a/benches/outfit_gauss_iod.rs +++ /dev/null @@ -1,122 +0,0 @@ -// benches/outfit_gauss_iod.rs - -#[cfg(feature = "jpl-download")] -mod benches_impl { - use std::cell::RefCell; - - use camino::Utf8Path; - use criterion::{black_box, BatchSize, Criterion}; - use outfit::constants::ObjectNumber; - use outfit::error_models::ErrorModel; - use outfit::initial_orbit_determination::gauss_result::GaussResult; - use outfit::initial_orbit_determination::IODParams; - use outfit::observations::observations_ext::ObservationIOD; - use outfit::outfit::Outfit; - use outfit::outfit_errors::OutfitError; - use outfit::trajectories::trajectory_file::TrajectoryFile; - use outfit::TrajectorySet; - use rand::rngs::StdRng; - use rand::SeedableRng; - - /// Run Gauss IOD on a single trajectory and return the best orbit + RMS. - fn run_iod( - env_state: &mut Outfit, - traj_set: &mut TrajectorySet, - traj_number: &ObjectNumber, - ) -> Result<(GaussResult, f64), OutfitError> { - let obs = traj_set.get_mut(traj_number).expect("trajectory not found"); - let mut rng = StdRng::seed_from_u64(42); - - let params = IODParams::builder() - .n_noise_realizations(10) - .noise_scale(1.1) - .max_obs_for_triplets(obs.len()) - .max_triplets(30) - .build()?; - - obs.estimate_best_orbit(env_state, &ErrorModel::FCCT14, &mut rng, ¶ms) - } - - /// Prepare environment with DE440 and 3 trajectories. - fn prepare_env() -> (Outfit, TrajectorySet, [ObjectNumber; 3]) { - let mut env = Outfit::new("horizon:DE440", ErrorModel::FCCT14).expect("Outfit init"); - - let mut set = - TrajectorySet::new_from_80col(&mut env, Utf8Path::new("tests/data/2015AB.obs")); - set.add_from_80col(&mut env, Utf8Path::new("tests/data/8467.obs")); - set.add_from_80col(&mut env, Utf8Path::new("tests/data/33803.obs")); - - let ids = [ - ObjectNumber::String("K09R05F".into()), - ObjectNumber::String("8467".into()), - ObjectNumber::String("33803".into()), - ]; - - (env, set, ids) - } - - /// End-to-end benchmark. - pub fn bench_gauss_iod_e2e(c: &mut Criterion) { - c.bench_function("gauss_iod_e2e_all", |b| { - b.iter_batched( - prepare_env, - |(mut env, mut set, ids)| { - for id in &ids { - let res = run_iod(&mut env, &mut set, id).expect("IOD"); - black_box(res); - } - }, - BatchSize::SmallInput, - ) - }); - } - - /// Core benchmark. - pub fn bench_gauss_iod_core(c: &mut Criterion) { - let (env0, _set_once, _ids) = prepare_env(); - let env = RefCell::new(env0); - let target = ObjectNumber::String("K09R05F".into()); - let obs_path = Utf8Path::new("tests/data/2015AB.obs"); - - c.bench_function("gauss_iod_core_single_refcell", |b| { - b.iter_batched( - || { - let mut env_borrow = env.borrow_mut(); - TrajectorySet::new_from_80col(&mut env_borrow, obs_path) - }, - |mut set| { - let mut env_borrow = env.borrow_mut(); - let res = run_iod(&mut env_borrow, &mut set, &target).expect("IOD"); - black_box(res); - }, - BatchSize::SmallInput, - ) - }); - } -} - -#[cfg(feature = "jpl-download")] -use criterion::{criterion_group, criterion_main, Criterion}; - -#[cfg(feature = "jpl-download")] -criterion_group!( - name = benches; - config = Criterion::default() - .sample_size(30) - .warm_up_time(std::time::Duration::from_secs(5)) - .measurement_time(std::time::Duration::from_secs(25)) - .with_plots(); - targets = benches_impl::bench_gauss_iod_e2e, benches_impl::bench_gauss_iod_core -); - -#[cfg(feature = "jpl-download")] -criterion_main!(benches); - -// Fallback quand la feature est absente : fournit une main au crate. -#[cfg(not(feature = "jpl-download"))] -fn main() { - eprintln!( - "This benchmark requires the `jpl-download` feature. \ - Run with: `cargo bench --features jpl-download`" - ); -} diff --git a/benches/solve_kepler_equation.rs b/benches/solve_kepler_equation.rs deleted file mode 100644 index e6a1af5..0000000 --- a/benches/solve_kepler_equation.rs +++ /dev/null @@ -1,161 +0,0 @@ -use criterion::{black_box, criterion_group, criterion_main, BatchSize, Criterion}; -use rand::rngs::StdRng; -use rand::{Rng, SeedableRng}; - -use outfit::orbit_type::equinoctial_element::EquinoctialElements; - -/// Uniform random in [0, 2π) -#[inline] -fn rand_angle(rng: &mut StdRng) -> f64 { - let two_pi = std::f64::consts::TAU; - rng.random::() * two_pi -} - -/// Build equinoctial elements with only h,k,λ set (others neutral). -#[inline] -fn make_equinoctial(h: f64, k: f64, lambda: f64) -> EquinoctialElements { - EquinoctialElements { - reference_epoch: 59000.0, - semi_major_axis: 1.0, // not used here - eccentricity_sin_lon: h, - eccentricity_cos_lon: k, - tan_half_incl_sin_node: 0.0, - tan_half_incl_cos_node: 0.0, - mean_longitude: lambda, - } -} - -/// Adjust λ so that λ >= ϖ, mimicking the production path in solve_two_body_problem. -#[inline] -fn align_lambda_with_periapsis(lambda: f64, w: f64) -> f64 { - let mut lam = lambda.rem_euclid(std::f64::consts::TAU); - let w_mod = w.rem_euclid(std::f64::consts::TAU); - if lam < w_mod { - lam += std::f64::consts::TAU; - } - lam -} - -/// Typical regime: e ∈ [0.0, 0.7] -fn bench_typical(c: &mut Criterion) { - let mut rng = StdRng::seed_from_u64(0xDEADBEEF); - let samples = 10_000usize; - - c.bench_function("solve_kepler_equation/typical_e<=0.7", |b| { - b.iter_batched( - || { - // Pre-generate inputs to avoid RNG cost in the timed section - (0..samples) - .map(|_| { - let e = rng.random_range(0.0..=0.7); - let w = rand_angle(&mut rng); - let lambda = align_lambda_with_periapsis(rand_angle(&mut rng), w); - let h = e * w.sin(); - let k = e * w.cos(); - (h, k, lambda, w) - }) - .collect::>() - }, - |cases| { - // Benchmark only the solver calls - for (h, k, lambda, w) in cases { - let equ = make_equinoctial(h, k, lambda); - let f = equ - .solve_kepler_equation(black_box(lambda), black_box(w)) - .unwrap(); - black_box(f); - } - }, - BatchSize::LargeInput, - ) - }); -} - -/// High-eccentricity (still elliptic): e ∈ [0.7, 0.9] -fn bench_high_e(c: &mut Criterion) { - let mut rng = StdRng::seed_from_u64(0xBADF00D); - let samples = 10_000usize; - - c.bench_function("solve_kepler_equation/high_e_0.7..0.9", |b| { - b.iter_batched( - || { - (0..samples) - .map(|_| { - let e = rng.random_range(0.7..0.9); - let w = rand_angle(&mut rng); - let lambda = align_lambda_with_periapsis(rand_angle(&mut rng), w); - let h = e * w.sin(); - let k = e * w.cos(); - (h, k, lambda, w) - }) - .collect::>() - }, - |cases| { - for (h, k, lambda, w) in cases { - let equ = make_equinoctial(h, k, lambda); - let _ = equ.solve_kepler_equation(black_box(lambda), black_box(w)); - } - }, - BatchSize::LargeInput, - ) - }); -} - -/// Near-circular regime: e ≈ 1e-12 -fn bench_near_circular(c: &mut Criterion) { - let mut rng = StdRng::seed_from_u64(0xFEEDFACE); - let samples = 10_000usize; - let e = 1e-12; - - c.bench_function("solve_kepler_equation/near_circular_e=1e-12", |b| { - b.iter_batched( - || { - (0..samples) - .map(|_| { - let w = rand_angle(&mut rng); - let lambda = align_lambda_with_periapsis(rand_angle(&mut rng), w); - let h = e * w.sin(); - let k = e * w.cos(); - (h, k, lambda, w) - }) - .collect::>() - }, - |cases| { - for (h, k, lambda, w) in cases { - let equ = make_equinoctial(h, k, lambda); - let f = equ - .solve_kepler_equation(black_box(lambda), black_box(w)) - .unwrap(); - black_box(f); - } - }, - BatchSize::LargeInput, - ) - }); -} - -/// Fixed “stress” case (from a previous failing input), useful for stability profiling. -fn bench_fixed_stress(c: &mut Criterion) { - // Example numbers; feel free to replace with your own worst-case input. - let e = 0.75_f64; - let w = -2.823_013_355_485_587_6_f64; - let lambda = 5.930_860_541_086_263_f64; - let h = e * w.sin(); - let k = e * w.cos(); - let lambda = align_lambda_with_periapsis(lambda, w); - let equ = make_equinoctial(h, k, lambda); - - c.bench_function("solve_kepler_equation/fixed_stress_case", |b| { - b.iter(|| { - let f = equ.solve_kepler_equation(black_box(lambda), black_box(w)); - black_box(f.ok()); - }) - }); -} - -criterion_group!( - name = benches; - config = Criterion::default(); - targets = bench_typical, bench_high_e, bench_near_circular, bench_fixed_stress -); -criterion_main!(benches); diff --git a/examples/gauss_iod_once.rs b/examples/gauss_iod_once.rs deleted file mode 100644 index d8db3e5..0000000 --- a/examples/gauss_iod_once.rs +++ /dev/null @@ -1,95 +0,0 @@ -#![cfg(feature = "jpl-download")] -use std::env; - -use camino::Utf8Path; -use rand::rngs::StdRng; -use rand::SeedableRng; - -use std::{thread, time::Duration}; - -use outfit::prelude::*; // Import most common Outfit types and traits - -/// Run Gauss IOD on a single trajectory and return the best orbit and RMS. -/// -/// Arguments -/// ----------------- -/// * `env_state`: The global environment (ephemeris, EOP, error model). -/// * `traj_set`: The trajectory container with parsed observations. -/// * `traj_number`: The object identifier present in `traj_set`. -/// -/// Return -/// ---------- -/// * `Ok((Option, f64))` — the best orbit (if any) and its RMS in mas. -/// * `Err(OutfitError)` — if the IOD pipeline fails. -/// -/// See also -/// ------------ -/// * [`ObservationIOD::estimate_best_orbit`] – High-level Gauss IOD entry point. -/// * [`IODParams`] – Controls triplet selection and stochastic noise. -/// * [`ErrorModel::FCCT14`] – Default astrometric error model used here. -fn run_iod_once( - env_state: &mut Outfit, - traj_set: &mut TrajectorySet, - traj_number: &ObjectNumber, -) -> Result<(GaussResult, f64), OutfitError> { - let obs = traj_set - .get_mut(traj_number) - .expect("trajectory not found in set"); - let mut rng = StdRng::seed_from_u64(42); - - let params = IODParams::builder() - .n_noise_realizations(10) - .noise_scale(1.1) - .max_obs_for_triplets(obs.len()) - .max_triplets(30) - .build()?; - - obs.estimate_best_orbit(env_state, &ErrorModel::FCCT14, &mut rng, ¶ms) -} - -/// Minimal driver: load three test trajectories, run IOD for the requested object once. -/// Usage: -/// gauss_iod_once [--verbose] -/// Example: -/// gauss_iod_once K09R05F --verbose -fn main() -> Result<(), OutfitError> { - let mut args = env::args().skip(1).collect::>(); - let verbose = if let Some(pos) = args.iter().position(|a| a == "--verbose") { - args.remove(pos); - true - } else { - false - }; - - let object = args - .first() - .cloned() - .unwrap_or_else(|| "K09R05F".to_string()); - let obj = ObjectNumber::String(object); - - // Warm environment (will read DE440 from cache if already downloaded). - let mut env = Outfit::new("horizon:DE440", ErrorModel::FCCT14)?; - - // Parse observations (adjust paths if needed). - let mut set = TrajectorySet::new_from_80col(&mut env, Utf8Path::new("tests/data/2015AB.obs")); - set.add_from_80col(&mut env, Utf8Path::new("tests/data/8467.obs")); - set.add_from_80col(&mut env, Utf8Path::new("tests/data/33803.obs")); - - // Run IOD once for the requested object. - let (best, rms) = run_iod_once(&mut env, &mut set, &obj)?; - - thread::sleep(Duration::from_millis(500)); // pause 0,5 s - - // Run IOD once for the requested object. - let (best2, rms2) = run_iod_once(&mut env, &mut set, &obj)?; - - if verbose { - eprintln!("[gauss_iod_once] object = {obj:?}, rms(mas) = {rms}"); - eprintln!("[gauss_iod_once] orbit = {:?}", best.get_orbit()); - - eprintln!("[gauss_iod_once] object = {obj:?}, rms(mas) = {rms2}"); - eprintln!("[gauss_iod_once] orbit = {:?}", best2.get_orbit()); - } - - Ok(()) -} diff --git a/examples/parquet_to_orbit.rs b/examples/parquet_to_orbit.rs deleted file mode 100644 index 3f94f8d..0000000 --- a/examples/parquet_to_orbit.rs +++ /dev/null @@ -1,659 +0,0 @@ -#![cfg(feature = "jpl-download")] -#![allow(non_snake_case)] -use camino::Utf8Path; -use outfit::trajectories::trajectory_fit::TrajectoryFit; -use rand::rngs::StdRng; -use rand::SeedableRng; - -use outfit::constants::ObjectNumber; -use outfit::initial_orbit_determination::gauss_result::GaussResult; -use std::collections::{BTreeMap, HashMap}; -use std::hash::BuildHasher; - -use outfit::prelude::*; - -// ======================= orbit classification ======================= - -/// A coarse taxonomy for small-body orbits in the Solar System. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -enum OrbitClass { - /// Interior-Earth Objects: a < 1 AU and Q < 0.983 AU (entirely inside Earth's orbit) - Atira, - /// a < 1 AU and Q > 0.983 AU (crosses Earth's orbit from the inside) - Aten, - /// a ≥ 1 AU and q ≤ 1.017 AU (Earth-crossing from the outside) - Apollo, - /// 1.017 < q < 1.3 AU (near-Earth but non-crossing) - Amor, - /// Near-Earth Object fallback: q < 1.3 AU but not Atira/Aten/Apollo/Amor - Neo, - Hungaria, - MainBelt, - Hilda, - Centaur, - /// Trans-Neptunian Object - Tno, - /// a < 1 AU (non-Atira/Aten) — inner resonant / Vulcanoid-like - Inner, - Other, -} - -/// Classify an orbit using simple rules on a, e (→ q, Q) and inclination i (rad). -/// -/// Rules (coarse): -/// ----------------- -/// * q = a(1-e), Q = a(1+e) -/// * **Atira**: a < 1.0 AU and **Q < 0.983 AU** (entirely interior to Earth's orbit) -/// * **Aten**: a < 1.0 AU and **Q > 0.983 AU** (Earth-crossing from the inside) -/// * **Apollo**: a ≥ 1.0 AU and q ≤ 1.017 AU -/// * **Amor**: 1.017 < q < 1.3 AU -/// * **NEO**: q < 1.3 AU (and not Atira/Aten/Apollo/Amor) -/// * **Hungaria**: 1.78 ≤ a ≤ 2.0 AU and i > 16° -/// * **Hilda**: a ≈ 3.9 AU (here 3.7–4.1 AU) -/// * **Main belt**: 2.0 ≤ a ≤ 3.5 AU and e < 0.35 -/// * **Centaur**: 5 < a < 30 AU -/// * **TNO**: a ≥ 30 AU -/// * **Inner**: a < 1 AU (non-Atira/Aten) -/// * **Other**: fallback -fn classify_orbit(a: f64, e: f64, i_rad: f64) -> OrbitClass { - let q = a * (1.0 - e); - let Q = a * (1.0 + e); - let i_deg = i_rad.to_degrees(); - - // Use aphelion Q to split Atira vs Aten when a<1 AU - if a < 1.0 && Q < 0.983 { - return OrbitClass::Atira; - } - if a < 1.0 && Q > 0.983 { - return OrbitClass::Aten; - } - if a >= 1.0 && q <= 1.017 { - return OrbitClass::Apollo; - } - if q > 1.017 && q < 1.3 { - return OrbitClass::Amor; - } - if q < 1.3 { - return OrbitClass::Neo; - } - - if (1.78..=2.0).contains(&a) && i_deg > 16.0 { - return OrbitClass::Hungaria; - } - if (3.7..=4.1).contains(&a) { - return OrbitClass::Hilda; - } - if (2.0..=3.5).contains(&a) && e < 0.35 { - return OrbitClass::MainBelt; - } - if (5.0..30.0).contains(&a) { - return OrbitClass::Centaur; - } - if a >= 30.0 { - return OrbitClass::Tno; - } - if a < 1.0 { - return OrbitClass::Inner; - } - OrbitClass::Other -} - -/// Extract a reference to `KeplerianElements` from a `GaussResult` if available. -fn kepler_of(res: &GaussResult) -> Option<&outfit::KeplerianElements> { - match res { - GaussResult::CorrectedOrbit(k) => k.as_keplerian(), - GaussResult::PrelimOrbit(k) => k.as_keplerian(), - } -} - -// ======================= reporting structs ======================= - -#[derive(Debug, Clone)] -struct ErrorStats { - count: usize, - samples: Vec, - attempts_sum: usize, - attempts_n: usize, -} - -#[derive(Debug, Clone)] -struct OrbitElementStats { - // distribution statistics for successes - a_min: f64, - a_max: f64, - a_mean: f64, - a_median: f64, - a_p95: f64, - e_min: f64, - e_max: f64, - e_mean: f64, - e_median: f64, - e_p95: f64, - i_min: f64, - i_max: f64, - i_mean: f64, - i_median: f64, - i_p95: f64, // radians - q_min: f64, - q_max: f64, - q_mean: f64, - q_median: f64, - q_p95: f64, - Q_min: f64, - Q_max: f64, - Q_mean: f64, - Q_median: f64, - Q_p95: f64, - // class counts - class_counts: BTreeMap, -} - -#[derive(Debug, Clone)] -struct IodBatchSummary { - total_objects: usize, - succeeded_total: usize, - corrected_count: usize, - prelim_count: usize, - failed_total: usize, - - // RMS stats on successes - rms_min: f64, - rms_max: f64, - rms_mean: f64, - rms_median: f64, - rms_p95: f64, - - // Attempts aggregated from NoViableOrbit - attempts_sum: usize, - attempts_mean: f64, - attempts_p95: f64, - - // Errors (stable kind → stats) - error_stats: BTreeMap<&'static str, ErrorStats>, - - // Orbit elements & classes (only successes) - elements: Option, - - // Top/bottom objects by RMS - best_k: Vec<(ObjectNumber, f64)>, - worst_k: Vec<(ObjectNumber, f64)>, -} - -// ======================= summarizer ======================= -#[allow(clippy::type_complexity)] -fn summarize_estimates( - results: &HashMap, S>, - k: usize, -) -> IodBatchSummary -where - S: BuildHasher, -{ - const MAX_SAMPLES_PER_KIND: usize = 3; - - let mut rms_values: Vec<(ObjectNumber, f64, bool)> = Vec::new(); - let mut corrected_count = 0usize; - let mut prelim_count = 0usize; - - let mut buckets: BTreeMap<&'static str, ErrorStats> = BTreeMap::new(); - let mut attempts_all: Vec = Vec::new(); - - // collect successful orbit elements - let mut a: Vec = Vec::new(); - let mut e: Vec = Vec::new(); - let mut i: Vec = Vec::new(); - let mut q: Vec = Vec::new(); - let mut Q: Vec = Vec::new(); - let mut class_counts: BTreeMap = BTreeMap::new(); - - for (obj, res) in results { - match res { - Ok((orbit_res, rms)) => { - let corrected = matches!(orbit_res, GaussResult::CorrectedOrbit(_)); - if corrected { - corrected_count += 1; - } else { - prelim_count += 1; - } - rms_values.push((obj.clone(), *rms, corrected)); - - if let Some(kep) = kepler_of(orbit_res) { - let a_au = kep.semi_major_axis; - let e_ = kep.eccentricity; - let i_rad = kep.inclination; - let q_au = a_au * (1.0 - e_); - let Q_au = a_au * (1.0 + e_); - a.push(a_au); - e.push(e_); - i.push(i_rad); - q.push(q_au); - Q.push(Q_au); - - let cls = classify_orbit(a_au, e_, i_rad); - *class_counts.entry(cls).or_default() += 1; - } - } - Err(e) => { - let (stable_key, sample_msg, attempts) = flatten_error_for_bucket(e); - let entry = buckets.entry(stable_key).or_insert_with(|| ErrorStats { - count: 0, - samples: Vec::new(), - attempts_sum: 0, - attempts_n: 0, - }); - entry.count += 1; - if let Some(n) = attempts { - entry.attempts_sum += n; - entry.attempts_n += 1; - attempts_all.push(n); - } - if entry.samples.len() < MAX_SAMPLES_PER_KIND { - entry.samples.push(sample_msg); - } - } - } - } - - let total_objects = results.len(); - let succeeded_total = corrected_count + prelim_count; - let failed_total = total_objects.saturating_sub(succeeded_total); - - // ----- RMS stats - let mut rms_only: Vec = rms_values.iter().map(|(_, r, _)| *r).collect(); - rms_only.sort_by(|a, b| a.partial_cmp(b).unwrap()); - let (rms_min, rms_max, rms_mean, rms_median, rms_p95) = if rms_only.is_empty() { - (f64::NAN, f64::NAN, f64::NAN, f64::NAN, f64::NAN) - } else { - let min = *rms_only.first().unwrap(); - let max = *rms_only.last().unwrap(); - let mean = rms_only.iter().sum::() / (rms_only.len() as f64); - let median = percentile_sorted(&rms_only, 50.0); - let p95 = percentile_sorted(&rms_only, 95.0); - (min, max, mean, median, p95) - }; - - // ----- attempts stats - attempts_all.sort_unstable(); - let attempts_sum = attempts_all.iter().copied().sum::(); - let attempts_mean = if attempts_all.is_empty() { - f64::NAN - } else { - attempts_sum as f64 / attempts_all.len() as f64 - }; - let attempts_p95 = if attempts_all.is_empty() { - f64::NAN - } else { - percentile_sorted_usize(&attempts_all, 95.0) as f64 - }; - - // ----- orbit element stats - let elements = if a.is_empty() { - None - } else { - a.sort_by(|x, y| x.partial_cmp(y).unwrap()); - e.sort_by(|x, y| x.partial_cmp(y).unwrap()); - i.sort_by(|x, y| x.partial_cmp(y).unwrap()); - q.sort_by(|x, y| x.partial_cmp(y).unwrap()); - Q.sort_by(|x, y| x.partial_cmp(y).unwrap()); - - let a_stats = ( - a[0], - *a.last().unwrap(), - mean(&a), - percentile_sorted(&a, 50.0), - percentile_sorted(&a, 95.0), - ); - let e_stats = ( - e[0], - *e.last().unwrap(), - mean(&e), - percentile_sorted(&e, 50.0), - percentile_sorted(&e, 95.0), - ); - let i_stats = ( - i[0], - *i.last().unwrap(), - mean(&i), - percentile_sorted(&i, 50.0), - percentile_sorted(&i, 95.0), - ); - let q_stats = ( - q[0], - *q.last().unwrap(), - mean(&q), - percentile_sorted(&q, 50.0), - percentile_sorted(&q, 95.0), - ); - let Q_stats = ( - Q[0], - *Q.last().unwrap(), - mean(&Q), - percentile_sorted(&Q, 50.0), - percentile_sorted(&Q, 95.0), - ); - - Some(OrbitElementStats { - a_min: a_stats.0, - a_max: a_stats.1, - a_mean: a_stats.2, - a_median: a_stats.3, - a_p95: a_stats.4, - e_min: e_stats.0, - e_max: e_stats.1, - e_mean: e_stats.2, - e_median: e_stats.3, - e_p95: e_stats.4, - i_min: i_stats.0, - i_max: i_stats.1, - i_mean: i_stats.2, - i_median: i_stats.3, - i_p95: i_stats.4, - q_min: q_stats.0, - q_max: q_stats.1, - q_mean: q_stats.2, - q_median: q_stats.3, - q_p95: q_stats.4, - Q_min: Q_stats.0, - Q_max: Q_stats.1, - Q_mean: Q_stats.2, - Q_median: Q_stats.3, - Q_p95: Q_stats.4, - class_counts, - }) - }; - - // ----- Top/Bottom K by RMS - rms_values.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap()); - let best_k = rms_values - .iter() - .take(k) - .map(|(o, r, _)| (o.clone(), *r)) - .collect(); - let worst_k = rms_values - .iter() - .rev() - .take(k) - .map(|(o, r, _)| (o.clone(), *r)) - .collect(); - - IodBatchSummary { - total_objects, - succeeded_total, - corrected_count, - prelim_count, - failed_total, - rms_min, - rms_max, - rms_mean, - rms_median, - rms_p95, - attempts_sum, - attempts_mean, - attempts_p95, - error_stats: buckets, - elements, - best_k, - worst_k, - } -} - -// ======================= printer ======================= - -fn print_summary(sum: &IodBatchSummary) { - println!("\n=== IOD Batch Summary ==="); - println!("Objects total ........ : {}", sum.total_objects); - println!( - " Success total ...... : {} (Corrected: {}, Prelim: {})", - sum.succeeded_total, sum.corrected_count, sum.prelim_count - ); - println!(" Failures total ..... : {}", sum.failed_total); - - println!("\n-- RMS on successes --"); - println!( - " min / median / mean / p95 / max : {:.6} / {:.6} / {:.6} / {:.6} / {:.6}", - sum.rms_min, sum.rms_median, sum.rms_mean, sum.rms_p95, sum.rms_max - ); - - if let Some(el) = &sum.elements { - println!("\n-- Orbit elements (successes only) --"); - println!( - " a [AU] : min={:.6} median={:.6} mean={:.6} p95={:.6} max={:.6}", - el.a_min, el.a_median, el.a_mean, el.a_p95, el.a_max - ); - println!( - " e [-] : min={:.6} median={:.6} mean={:.6} p95={:.6} max={:.6}", - el.e_min, el.e_median, el.e_mean, el.e_p95, el.e_max - ); - println!( - " i [deg] : min={:.6} median={:.6} mean={:.6} p95={:.6} max={:.6}", - el.i_min.to_degrees(), - el.i_median.to_degrees(), - el.i_mean.to_degrees(), - el.i_p95.to_degrees(), - el.i_max.to_degrees() - ); - println!( - " q [AU] : min={:.6} median={:.6} mean={:.6} p95={:.6} max={:.6}", - el.q_min, el.q_median, el.q_mean, el.q_p95, el.q_max - ); - println!( - " Q [AU] : min={:.6} median={:.6} mean={:.6} p95={:.6} max={:.6}", - el.Q_min, el.Q_median, el.Q_mean, el.Q_p95, el.Q_max - ); - - println!("\n-- Orbit classes (counts) --"); - if el.class_counts.is_empty() { - println!(" (none)"); - } else { - for (cls, cnt) in &el.class_counts { - println!(" {:<10} : {}", format!("{cls:?}"), cnt); - } - } - } else { - println!("\n-- Orbit elements --\n (no successful orbits)"); - } - - println!("\n-- Failure breakdown (by error kind) --"); - if sum.error_stats.is_empty() { - println!(" (none)"); - } else { - for (kind, stats) in &sum.error_stats { - let attempts_avg = if stats.attempts_n > 0 { - stats.attempts_sum as f64 / stats.attempts_n as f64 - } else { - f64::NAN - }; - println!( - " {:<28} : {:>4} attempts(sum/avg on carrying) = {}/{}", - kind, - stats.count, - stats.attempts_sum, - fmt_opt(attempts_avg) - ); - for (i, sample) in stats.samples.iter().take(3).enumerate() { - println!(" · example[{i}] {sample}"); - } - } - } - - println!("\n-- Attempts (global from NoViableOrbit) --"); - if sum.attempts_sum == 0 && sum.failed_total > 0 { - println!(" (no attempts recorded in errors)"); - } else if sum.failed_total == 0 { - println!(" (no failures)"); - } else { - println!( - " sum / mean / p95 : {} / {:.2} / {:.0}", - sum.attempts_sum, sum.attempts_mean, sum.attempts_p95 - ); - } - - println!("\n-- Best by RMS --"); - if sum.best_k.is_empty() { - println!(" (no successes)"); - } else { - for (obj, rms) in &sum.best_k { - println!(" {:>8} rms={:.6}", display_obj(obj), rms); - } - } - - println!("\n-- Worst by RMS --"); - if sum.worst_k.is_empty() { - println!(" (no successes)"); - } else { - for (obj, rms) in &sum.worst_k { - println!(" {:>8} rms={:.6}", display_obj(obj), rms); - } - } - println!(); -} - -// ======================= small helpers ======================= - -fn flatten_error_for_bucket(e: &OutfitError) -> (&'static str, String, Option) { - use OutfitError::*; - match e { - NoViableOrbit { cause, attempts } => { - let (k, _, _) = flatten_error_for_bucket(cause); - (k, format!("{e}"), Some(*attempts)) - } - NoFeasibleTriplets { .. } => ("NoFeasibleTriplets", format!("{e}"), None), - - // Numerical / algebraic failures - GaussNoRootsFound => ("GaussNoRootsFound", format!("{e}"), None), - PolynomialRootFindingFailed => ("PolynomialRootFindingFailed", format!("{e}"), None), - SpuriousRootDetected => ("SpuriousRootDetected", format!("{e}"), None), - SingularDirectionMatrix => ("SingularDirectionMatrix", format!("{e}"), None), - RmsComputationFailed(_) => ("RmsComputationFailed", format!("{e}"), None), - GaussPrelimOrbitFailed(_) => ("GaussPrelimOrbitFailed", format!("{e}"), None), - RootFindingError(_) => ("RootFindingError", format!("{e}"), None), - InvalidFloatValue(_) => ("InvalidFloatValue", format!("{e}"), None), - NonFiniteScore(_) => ("NonFiniteScore", format!("{e}"), None), - - // Orbit / reference frame - InvalidOrbit(_) => ("InvalidOrbit", format!("{e}"), None), - InvalidIODParameter(_) => ("InvalidIODParameter", format!("{e}"), None), - InvalidConversion(_) => ("InvalidConversion", format!("{e}"), None), - InvalidRefSystem(_) => ("InvalidRefSystem", format!("{e}"), None), - VelocityCorrectionError(_) => ("VelocityCorrectionError", format!("{e}"), None), - - // Observation handling - ObservationNotFound(_) => ("ObservationNotFound", format!("{e}"), None), - - // Parsing / ingestion - NomParsingError(_) => ("NomParsingError", format!("{e}"), None), - Parsing80ColumnFileError(_) => ("Parsing80ColumnFileError", format!("{e}"), None), - Parquet(_) => ("Parquet", format!("{e}"), None), - - // Error model - InvalidErrorModel(_) => ("InvalidErrorModel", format!("{e}"), None), - InvalidErrorModelFilePath(_) => ("InvalidErrorModelFilePath", format!("{e}"), None), - - // Ephemerides / SPK - InvalidJPLStringFormat(_) => ("InvalidJPLStringFormat", format!("{e}"), None), - InvalidJPLEphemFileSource(_) => ("InvalidJPLEphemFileSource", format!("{e}"), None), - InvalidJPLEphemFileVersion(_) => ("InvalidJPLEphemFileVersion", format!("{e}"), None), - JPLFileNotFound(_) => ("JPLFileNotFound", format!("{e}"), None), - InvalidSpkDataType(_) => ("InvalidSpkDataType", format!("{e}"), None), - - // I/O - IoError(_) => ("IoError", format!("{e}"), None), - UreqHttpError(_) => ("UreqHttpError", format!("{e}"), None), - #[cfg(feature = "jpl-download")] - ReqwestError(_) => ("ReqwestError", format!("{e}"), None), - Utf8PathError(_) => ("Utf8PathError", format!("{e}"), None), - UnableToCreateBaseDir(_) => ("UnableToCreateBaseDir", format!("{e}"), None), - InvalidUrl(_) => ("InvalidUrl", format!("{e}"), None), - - // Stochastic - NoiseInjectionError(_) => ("NoiseInjectionError", format!("{e}"), None), - } -} - -fn percentile_sorted(sorted: &[f64], p: f64) -> f64 { - if sorted.is_empty() { - return f64::NAN; - } - let q = p.clamp(0.0, 100.0); - let idx = ((q / 100.0) * ((sorted.len() - 1) as f64)).round() as usize; - sorted[idx] -} - -fn percentile_sorted_usize(sorted: &[usize], p: f64) -> usize { - if sorted.is_empty() { - return 0; - } - let q = p.clamp(0.0, 100.0); - let idx = ((q / 100.0) * ((sorted.len() - 1) as f64)).round() as usize; - sorted[idx] -} - -fn mean(v: &[f64]) -> f64 { - if v.is_empty() { - f64::NAN - } else { - v.iter().sum::() / v.len() as f64 - } -} - -fn display_obj(id: &ObjectNumber) -> String { - match id { - ObjectNumber::Int(n) => format!("{n}"), - ObjectNumber::String(s) => s.clone(), - } -} - -fn fmt_opt(x: f64) -> String { - if x.is_finite() { - format!("{x:.2}") - } else { - "-".to_string() - } -} - -fn main() -> Result<(), OutfitError> { - let mut env_state = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - - let test_data = "tests/data/test_from_fink.parquet"; - - println!("Loading observations from {test_data}"); - let path_file = Utf8Path::new(test_data); - - let ztf_observer = env_state.get_observer_from_mpc_code(&"I41".into()); - - let mut traj_set = - TrajectorySet::new_from_parquet(&mut env_state, path_file, ztf_observer, 0.5, 0.5, None)?; - - println!( - "Loading done: {} trajectories / {} observations", - traj_set.len(), - traj_set.total_observations() - ); - - println!("Trajectory Set statistics:"); - println!( - "{:#}", - traj_set.obs_count_stats().expect("TrajSet is empty") - ); - println!("--------------------------\n"); - - let mut rng = StdRng::from_os_rng(); - - let params = IODParams::builder() - .n_noise_realizations(10) - .noise_scale(1.1) - .max_obs_for_triplets(12) - .max_triplets(30) - .build()?; - - println!("Estimating orbits with params: {params:#}"); - println!("Orbit estimation in progress... (this may take a while)"); - - let orbits = traj_set.estimate_all_orbits(&env_state, &mut rng, ¶ms); - - println!("Orbit estimation done."); - - let summary = summarize_estimates(&orbits, 5); - print_summary(&summary); - - Ok(()) -} diff --git a/examples/run_full_iod.rs b/examples/run_full_iod.rs new file mode 100644 index 0000000..bb4317b --- /dev/null +++ b/examples/run_full_iod.rs @@ -0,0 +1,208 @@ +use std::collections::HashMap; + +use hifitime::ut1::Ut1Provider; +use outfit::{ + jpl_ephem::download_jpl_file::EphemFileSource, FitIOD, IODParams, JPLEphem, OutfitError, +}; +use photom::{ + io::polars::{ContiguousChoice, FromPolarsArgs}, + observation_dataset::ObsDataset, + observer::error_model::ObsErrorModel, +}; +use polars::lazy::{ + dsl::{col, lit}, + frame::LazyFrame, +}; +use rand::{rngs::StdRng, SeedableRng}; + +fn outfit_error_label(err: &OutfitError) -> &'static str { + match err { + OutfitError::InvalidJPLStringFormat(_) => "InvalidJPLStringFormat", + OutfitError::InvalidJPLEphemFileSource(_) => "InvalidJPLEphemFileSource", + OutfitError::InvalidJPLEphemFileVersion(_) => "InvalidJPLEphemFileVersion", + OutfitError::InvalidUrl(_) => "InvalidUrl", + OutfitError::UreqHttpError(_) => "UreqHttpError", + OutfitError::IoError(_) => "IoError", + OutfitError::ReqwestError(_) => "ReqwestError", + OutfitError::UnableToCreateBaseDir(_) => "UnableToCreateBaseDir", + OutfitError::Utf8PathError(_) => "Utf8PathError", + OutfitError::JPLFileNotFound(_) => "JPLFileNotFound", + OutfitError::RootFindingError(_) => "RootFindingError", + OutfitError::ObservationNotFound(_) => "ObservationNotFound", + OutfitError::InvalidErrorModel(_) => "InvalidErrorModel", + OutfitError::InvalidErrorModelFilePath(_) => "InvalidErrorModelFilePath", + OutfitError::NomParsingError(_) => "NomParsingError", + OutfitError::NoiseInjectionError(_) => "NoiseInjectionError", + OutfitError::SingularDirectionMatrix => "SingularDirectionMatrix", + OutfitError::PolynomialRootFindingFailed => "PolynomialRootFindingFailed", + OutfitError::SpuriousRootDetected => "SpuriousRootDetected", + OutfitError::GaussNoRootsFound => "GaussNoRootsFound", + OutfitError::InvalidSpkDataType(_) => "InvalidSpkDataType", + OutfitError::InvalidIODParameter(_) => "InvalidIODParameter", + OutfitError::InvalidRefSystem(_) => "InvalidRefSystem", + OutfitError::VelocityCorrectionError(_) => "VelocityCorrectionError", + OutfitError::InvalidOrbit(_) => "InvalidOrbit", + OutfitError::InvalidConversion(_) => "InvalidConversion", + OutfitError::InvalidFloatValue(_) => "InvalidFloatValue", + OutfitError::RmsComputationFailed(_) => "RmsComputationFailed", + OutfitError::GaussPrelimOrbitFailed(_) => "GaussPrelimOrbitFailed", + OutfitError::NoViableOrbit { .. } => "NoViableOrbit", + OutfitError::NoFeasibleTriplets { .. } => "NoFeasibleTriplets", + OutfitError::NonFiniteScore(_) => "NonFiniteScore", + OutfitError::ObserverIdIsNone(_) => "ObserverIdIsNone", + OutfitError::ObsDatasetError(_) => "ObsDatasetError", + OutfitError::ObsDatasetErrorRef(_) => "ObsDatasetErrorRef", + OutfitError::TrajectoryIdNotFound(_) => "TrajectoryIdNotFound", + OutfitError::NoTrajectoryIndex => "NoTrajectoryIndex", + OutfitError::BizarreOrbit => "BizarreOrbit", + OutfitError::DifferentialCorrectionDiverged => "DifferentialCorrectionDiverged", + OutfitError::DifferentialCorrectionFailed(_) => "DifferentialCorrectionFailed", + OutfitError::EphemerisBodyNotSupported(_) => "EphemerisBodyNotSupported", + OutfitError::NBodyPropagationFailed(_) => "NBodyPropagationFailed", + } +} + +fn main() -> Result<(), OutfitError> { + let path_data = "tests/data/test_data_traj_str.parquet"; + + let lf = LazyFrame::scan_parquet(path_data.into(), Default::default()) + .expect("scan_parquet must succeed") + .filter(col("traj_id").is_not_null()) + .filter( + col("traj_id") + .count() + .over([col("traj_id")]) + .gt_eq(lit(3u32)), + ); + + let polars_args = FromPolarsArgs { + error_model: Some(ObsErrorModel::FCCT14), + do_rechunk: Some(false), + contiguous_choice: Some(ContiguousChoice::ContiguousTraj), + }; + + let obs_dataset = ObsDataset::from_lazy(lf, polars_args).unwrap(); + println!("\n ==== Observation Dataset ==== \n"); + println!("{obs_dataset}"); + println!("\n ---- \n"); + + let max_traj_size = obs_dataset + .iter_traj_id() + .unwrap() + .map(|traj_id| obs_dataset.len_trajectory(traj_id).unwrap()) + .max() + .unwrap(); + + let default = IODParams::builder() + .n_noise_realizations(10) + .noise_scale(1.1) + .max_obs_for_triplets(max_traj_size) + .max_triplets(30) + .build() + .unwrap(); + + println!("\n ==== IOD Parameters ==== \n"); + println!("{default:#?}"); + println!("\n ---- \n"); + + let ut1_provider = Ut1Provider::download_from_jpl("latest_eop2.long") + .expect("Download of the JPL short time scale UT1 data failed"); + + let jpl_file: EphemFileSource = "horizon:DE440" + .try_into() + .expect("Failed to parse JPL ephemeris source"); + let jpl_ephem = JPLEphem::new(&jpl_file).expect("Failed to load JPL ephemeris from Horizon"); + + // fully sequential version of the full IOD fitting, for comparison with the parallel version in run_full_iod_parallel.rs + let full_orbit = obs_dataset + .fit_full_iod( + &jpl_ephem, + &ut1_provider, + &default, + ObsErrorModel::FCCT14, + &mut StdRng::seed_from_u64(42), + ) + .unwrap(); + + let number_total_fit = full_orbit.len(); + let number_success_fit = full_orbit + .iter() + .filter(|(_, orbit_res)| orbit_res.is_ok()) + .count(); + let number_failed_fit = number_total_fit - number_success_fit; + + println!("\n ==== Full IOD Results ==== \n"); + println!("Total number of fits: {number_total_fit}"); + println!("Number of successful fits: {number_success_fit}"); + println!("Number of failed fits: {number_failed_fit}"); + + let success_rate = (number_success_fit as f64) / (number_total_fit as f64) * 100.0; + println!("Success rate: {success_rate:.2}%"); + + println!("\n === Successful Fits Details === \n"); + println!("Orbit quality :"); + + let all_rms = full_orbit + .iter() + .filter_map(|(_, res)| res.as_ref().ok().map(|orbit| orbit.orbit_quality())) + .collect::>(); + + let mean_rms = all_rms.iter().sum::() / (all_rms.len() as f64); + let median_rms = { + let mut sorted = all_rms.clone(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let mid = sorted.len() / 2; + if sorted.len() % 2 == 0 { + (sorted[mid - 1] + sorted[mid]) / 2.0 + } else { + sorted[mid] + } + }; + let min_rms = all_rms.iter().cloned().fold(f64::INFINITY, f64::min); + let max_rms = all_rms.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + println!(" Mean RMS: {mean_rms:.4}"); + println!(" Median RMS: {median_rms:.4}"); + println!(" Min RMS: {min_rms:.4}"); + println!(" Max RMS: {max_rms:.4}"); + + println!("RMS distribution:"); + let mut rms_bins: HashMap = HashMap::new(); + for rms in all_rms { + let bin = if rms < 1.0 { + "< 1.0" + } else if rms < 5.0 { + "1.0 - 5.0" + } else if rms < 10.0 { + "5.0 - 10.0" + } else { + ">= 10.0" + }; + *rms_bins.entry(bin.to_string()).or_insert(0) += 1; + } + let mut sorted_bins: Vec<_> = rms_bins.iter().collect(); + sorted_bins.sort_by_key(|(_, count)| std::cmp::Reverse(*count)); + for (bin, count) in sorted_bins { + println!(" {bin:<10} : {count}"); + } + println!("\n ---- \n"); + + println!("\n === Failed Fits Details === \n"); + let mut failed_fits_stats: HashMap<&str, usize> = HashMap::new(); + + for (_, orbit_res) in full_orbit.iter().filter(|(_, res)| res.is_err()) { + let error = orbit_res.as_ref().err().unwrap(); + + *failed_fits_stats + .entry(outfit_error_label(error)) + .or_insert(0) += 1; + } + + let mut sorted: Vec<_> = failed_fits_stats.iter().collect(); + sorted.sort_by_key(|(_, count)| std::cmp::Reverse(*count)); + + for (label, count) in sorted { + println!(" {label:<35} : {count}"); + } + + Ok(()) +} diff --git a/examples/run_full_iod_parallel.rs b/examples/run_full_iod_parallel.rs new file mode 100644 index 0000000..9faf6b5 --- /dev/null +++ b/examples/run_full_iod_parallel.rs @@ -0,0 +1,209 @@ +#![cfg(feature = "parallel")] + +use std::collections::HashMap; + +use hifitime::ut1::Ut1Provider; +use outfit::{ + jpl_ephem::download_jpl_file::EphemFileSource, FitIOD, IODParams, JPLEphem, OutfitError, +}; +use photom::{ + io::polars::{ContiguousChoice, FromPolarsArgs}, + observation_dataset::ObsDataset, + observer::error_model::ObsErrorModel, +}; +use polars::lazy::{ + dsl::{col, lit}, + frame::LazyFrame, +}; +use rand::{rngs::StdRng, SeedableRng}; + +fn outfit_error_label(err: &OutfitError) -> &'static str { + match err { + OutfitError::InvalidJPLStringFormat(_) => "InvalidJPLStringFormat", + OutfitError::InvalidJPLEphemFileSource(_) => "InvalidJPLEphemFileSource", + OutfitError::InvalidJPLEphemFileVersion(_) => "InvalidJPLEphemFileVersion", + OutfitError::InvalidUrl(_) => "InvalidUrl", + OutfitError::UreqHttpError(_) => "UreqHttpError", + OutfitError::IoError(_) => "IoError", + OutfitError::ReqwestError(_) => "ReqwestError", + OutfitError::UnableToCreateBaseDir(_) => "UnableToCreateBaseDir", + OutfitError::Utf8PathError(_) => "Utf8PathError", + OutfitError::JPLFileNotFound(_) => "JPLFileNotFound", + OutfitError::RootFindingError(_) => "RootFindingError", + OutfitError::ObservationNotFound(_) => "ObservationNotFound", + OutfitError::InvalidErrorModel(_) => "InvalidErrorModel", + OutfitError::InvalidErrorModelFilePath(_) => "InvalidErrorModelFilePath", + OutfitError::NomParsingError(_) => "NomParsingError", + OutfitError::NoiseInjectionError(_) => "NoiseInjectionError", + OutfitError::SingularDirectionMatrix => "SingularDirectionMatrix", + OutfitError::PolynomialRootFindingFailed => "PolynomialRootFindingFailed", + OutfitError::SpuriousRootDetected => "SpuriousRootDetected", + OutfitError::GaussNoRootsFound => "GaussNoRootsFound", + OutfitError::InvalidSpkDataType(_) => "InvalidSpkDataType", + OutfitError::InvalidIODParameter(_) => "InvalidIODParameter", + OutfitError::InvalidRefSystem(_) => "InvalidRefSystem", + OutfitError::VelocityCorrectionError(_) => "VelocityCorrectionError", + OutfitError::InvalidOrbit(_) => "InvalidOrbit", + OutfitError::InvalidConversion(_) => "InvalidConversion", + OutfitError::InvalidFloatValue(_) => "InvalidFloatValue", + OutfitError::RmsComputationFailed(_) => "RmsComputationFailed", + OutfitError::GaussPrelimOrbitFailed(_) => "GaussPrelimOrbitFailed", + OutfitError::NoViableOrbit { .. } => "NoViableOrbit", + OutfitError::NoFeasibleTriplets { .. } => "NoFeasibleTriplets", + OutfitError::NonFiniteScore(_) => "NonFiniteScore", + OutfitError::ObserverIdIsNone(_) => "ObserverIdIsNone", + OutfitError::ObsDatasetError(_) => "ObsDatasetError", + OutfitError::ObsDatasetErrorRef(_) => "ObsDatasetErrorRef", + OutfitError::TrajectoryIdNotFound(_) => "TrajectoryIdNotFound", + OutfitError::NoTrajectoryIndex => "NoTrajectoryIndex", + OutfitError::BizarreOrbit => "DiffCorBizarreOrbit", + OutfitError::DifferentialCorrectionDiverged => "DiffCorDiverged", + OutfitError::DifferentialCorrectionFailed(_) => "DiffCorFailed", + OutfitError::EphemerisBodyNotSupported(_) => "EphemerisBodyNotSupported", + OutfitError::NBodyPropagationFailed(_) => "NBodyPropagationFailed", + } +} + +fn main() -> Result<(), OutfitError> { + let path_data = "tests/data/test_data_traj_str.parquet"; + + let lf = LazyFrame::scan_parquet(path_data.into(), Default::default()) + .expect("scan_parquet must succeed") + .filter(col("traj_id").is_not_null()) + .filter( + col("traj_id") + .count() + .over([col("traj_id")]) + .gt_eq(lit(3u32)), + ); + + let polars_args = FromPolarsArgs { + error_model: Some(ObsErrorModel::FCCT14), + do_rechunk: Some(false), + contiguous_choice: Some(ContiguousChoice::ContiguousTraj), + }; + + let obs_dataset = ObsDataset::from_lazy(lf, polars_args).unwrap(); + println!("\n ==== Observation Dataset ==== \n"); + println!("{obs_dataset}"); + println!("\n ---- \n"); + + let max_traj_size = obs_dataset + .iter_traj_id() + .unwrap() + .map(|traj_id| obs_dataset.len_trajectory(traj_id).unwrap()) + .max() + .unwrap(); + + let default = IODParams::builder() + .n_noise_realizations(10) + .noise_scale(1.1) + .max_obs_for_triplets(max_traj_size) + .max_triplets(30) + .build() + .unwrap(); + + println!("\n ==== IOD Parameters ==== \n"); + println!("{default:#?}"); + println!("\n ---- \n"); + + let ut1_provider = Ut1Provider::download_from_jpl("latest_eop2.long") + .expect("Download of the JPL short time scale UT1 data failed"); + + let jpl_file: EphemFileSource = "horizon:DE440" + .try_into() + .expect("Failed to parse JPL ephemeris source"); + let jpl_ephem = JPLEphem::new(&jpl_file).expect("Failed to load JPL ephemeris from Horizon"); + + let full_orbit = obs_dataset + .fit_full_iod_parallel( + &jpl_ephem, + &ut1_provider, + &default, + ObsErrorModel::FCCT14, + &mut StdRng::seed_from_u64(42), + ) + .unwrap(); + + let number_total_fit = full_orbit.len(); + let number_success_fit = full_orbit + .iter() + .filter(|(_, orbit_res)| orbit_res.is_ok()) + .count(); + let number_failed_fit = number_total_fit - number_success_fit; + + println!("\n ==== Full IOD Results ==== \n"); + println!("Total number of fits: {number_total_fit}"); + println!("Number of successful fits: {number_success_fit}"); + println!("Number of failed fits: {number_failed_fit}"); + + let success_rate = (number_success_fit as f64) / (number_total_fit as f64) * 100.0; + println!("Success rate: {success_rate:.2}%"); + + println!("\n === Successful Fits Details === \n"); + println!("Orbit quality :"); + + let all_rms = full_orbit + .iter() + .filter_map(|(_, res)| res.as_ref().ok().map(|orbit| orbit.orbit_quality())) + .collect::>(); + + let mean_rms = all_rms.iter().sum::() / (all_rms.len() as f64); + let median_rms = { + let mut sorted = all_rms.clone(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let mid = sorted.len() / 2; + if sorted.len() % 2 == 0 { + (sorted[mid - 1] + sorted[mid]) / 2.0 + } else { + sorted[mid] + } + }; + let min_rms = all_rms.iter().cloned().fold(f64::INFINITY, f64::min); + let max_rms = all_rms.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + println!(" Mean RMS: {mean_rms:.4}"); + println!(" Median RMS: {median_rms:.4}"); + println!(" Min RMS: {min_rms:.4}"); + println!(" Max RMS: {max_rms:.4}"); + + println!("RMS distribution:"); + let mut rms_bins: HashMap = HashMap::new(); + for rms in all_rms { + let bin = if rms < 1.0 { + "< 1.0" + } else if rms < 5.0 { + "1.0 - 5.0" + } else if rms < 10.0 { + "5.0 - 10.0" + } else { + ">= 10.0" + }; + *rms_bins.entry(bin.to_string()).or_insert(0) += 1; + } + let mut sorted_bins: Vec<_> = rms_bins.iter().collect(); + sorted_bins.sort_by_key(|(_, count)| std::cmp::Reverse(*count)); + for (bin, count) in sorted_bins { + println!(" {bin:<10} : {count}"); + } + println!("\n ---- \n"); + + println!("\n === Failed Fits Details === \n"); + let mut failed_fits_stats: HashMap<&str, usize> = HashMap::new(); + + for (_, orbit_res) in full_orbit.iter().filter(|(_, res)| res.is_err()) { + let error = orbit_res.as_ref().err().unwrap(); + + *failed_fits_stats + .entry(outfit_error_label(error)) + .or_insert(0) += 1; + } + + let mut sorted: Vec<_> = failed_fits_stats.iter().collect(); + sorted.sort_by_key(|(_, count)| std::cmp::Reverse(*count)); + + for (label, count) in sorted { + println!(" {label:<35} : {count}"); + } + + Ok(()) +} diff --git a/katex-header.html b/katex-header.html new file mode 100644 index 0000000..ceb0868 --- /dev/null +++ b/katex-header.html @@ -0,0 +1,9 @@ + + + diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 7855e6d..0d8ed42 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.88.0" +channel = "1.94.0" components = ["rustfmt", "clippy"] diff --git a/src/cache/mod.rs b/src/cache/mod.rs new file mode 100644 index 0000000..8b7f5d8 --- /dev/null +++ b/src/cache/mod.rs @@ -0,0 +1,210 @@ +//! Observer position cache for trajectory fitting. +//! +//! This module is the public entry point for the two-level observer cache used by +//! Outfit before any trajectory fitting or residual computation. The cache is built +//! **once** from a [`photom::observation_dataset::ObsDataset`] and then queried by index or observer ID +//! throughout the fitting pipeline, avoiding repeated ephemeris lookups. +//! +//! # Two-level design +//! +//! The cache is split into two complementary layers: +//! +//! ## 1. Body-fixed layer — [`observer_fixed_cache`](crate::cache::observer_fixed_cache) +//! +//! Stores the **time-independent** Earth-fixed (ECEF-like) position and velocity +//! of every observer. Indexed by `ObserverId`. Built first because the centric +//! layer depends on it. +//! +//! ## 2. Observer-centric layer — [`observer_centric_cache`](crate::cache::observer_centric_cache) +//! +//! Stores the **epoch-dependent** geocentric and heliocentric position (and +//! geocentric velocity) of the observer at the precise time of each observation. +//! Indexed by `ObsIndex` (i.e., by observation order in the dataset). +//! +//! # Build order +//! +//! ```text +//! ObsDataset +//! │ +//! ├─ iter_observer() ──► build_fixed_observer_cache() ──► BodyFixedObserverCache +//! │ │ +//! └─ iter_observations() + BodyFixedObserverCache ──► build_centric_observer_cache() +//! │ +//! CentricObserverCache +//! ``` +//! +//! Both caches are then wrapped in [`OutfitCache`](crate::cache::OutfitCache) and exposed through typed accessors. +//! +//! # Usage +//! +//! ```rust,ignore +//! let cache = OutfitCache::build(&obs_dataset, &jpl, &ut1_provider)?; +//! +//! // Per-observation accessors (indexed by ObsIndex): +//! let geo_pos = cache.get_observer_geocentric_position(idx); +//! let geo_vel = cache.get_observer_geocentric_velocity(idx); +//! let helio = cache.get_helio_position(idx); +//! +//! // Per-observer accessor (indexed by ObserverId): +//! let fixed = cache.get_observer_fixed_cache(observer_id); +//! ``` + +pub mod observer_centric_cache; +pub mod observer_fixed_cache; + +use hifitime::ut1::Ut1Provider; +use photom::{observation_dataset::ObsDataset, observer::dataset::ObserverId, ObsIndex}; + +use crate::{ + cache::{ + observer_centric_cache::{ + build_centric_observer_cache, CentricObserverCache, ObserverCentricCache, + ObserverGeocentricPosition, ObserverGeocentricVelocity, ObserverHeliocentricPosition, + }, + observer_fixed_cache::{ + build_fixed_observer_cache, BodyFixedObserverCache, ObserverFixedCache, + }, + }, + JPLEphem, OutfitError, +}; + +/// Precomputed observer positions for all observations in a dataset. +/// +/// [`OutfitCache`] is the top-level cache built **once** before any trajectory +/// fitting, from a fully loaded [`ObsDataset`]. It encapsulates: +/// +/// - a per-observation centric cache ([`CentricObserverCache`]) holding geocentric +/// and heliocentric positions at each observation epoch; +/// - a per-observer body-fixed cache ([`BodyFixedObserverCache`]) holding the +/// time-independent Earth-fixed state of each observer. +/// +/// All positions are in the **ecliptic mean J2000** frame (AU / AU·day⁻¹). +/// +/// # Build +/// +/// Use [`OutfitCache::build`] to construct the cache. Accessor methods then +/// provide O(1) lookups keyed by [`ObsIndex`] or [`ObserverId`]. +#[derive(Debug)] +pub struct OutfitCache { + /// Per-observation centric cache. Length equals the number of observations + /// in the dataset. Indexed by [`ObsIndex`]. + observer_centric: CentricObserverCache, + /// Per-observer body-fixed cache. Indexed by [`ObserverId`]. + observer_fixed: BodyFixedObserverCache, +} + +impl OutfitCache { + /// Returns the full [`ObserverCentricCache`] entry for the observation at `idx`. + /// + /// The returned reference gives access to the geocentric position, geocentric + /// velocity, and heliocentric position at the epoch of that observation. + /// + /// # Panics + /// + /// Panics if `idx` is out of bounds (i.e., ≥ number of observations in the + /// dataset used to build this cache). + pub fn get_centric(&self, idx: ObsIndex) -> &ObserverCentricCache { + &self.observer_centric[idx] + } + + /// Returns the [`ObserverFixedCache`] for the given observer, if present. + /// + /// Returns `None` if `observer_id` is not found in the body-fixed cache + /// (e.g., the observer was not part of the dataset used to build this cache). + pub fn get_fixed(&self, observer_id: ObserverId) -> Option<&ObserverFixedCache> { + self.observer_fixed.get(&observer_id) + } + + /// Builds the cache for every observation in `obs_dataset`. + /// + /// This is the primary constructor. It proceeds in two steps: + /// + /// 1. Build the [`BodyFixedObserverCache`] from the dataset's observer list. + /// 2. Build the [`CentricObserverCache`] by computing epoch-dependent positions + /// for each observation using the JPL ephemeris and UT1 provider. + /// + /// # Arguments + /// + /// - `obs_dataset` — the observation dataset to cache; all observers must have + /// valid geodetic coordinates and associated observer IDs. + /// - `jpl` — JPL planetary ephemeris (DE440 or compatible) for heliocentric + /// Earth positions. + /// - `ut1_provider` — UT1 time scale data for computing Earth's sidereal angle + /// at each observation epoch. + /// - `cache_velocity` — whether to compute and cache the velocity components in the resulting `ObserverCentricCache`. If `false`, the velocity fields will be set to `None` to save computation time and memory. + /// + /// # Errors + /// + /// Returns [`OutfitError`] if: + /// - the dataset's observer iterator fails, or + /// - any observer's body-fixed position cannot be computed, or + /// - any observation lacks an associated observer ID, or + /// - the JPL ephemeris or UT1 provider cannot be evaluated at a given epoch. + pub fn build( + obs_dataset: &ObsDataset, + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + cache_velocity: bool, + ) -> Result { + let observer_iter = obs_dataset.iter_observer()?; + + let observer_fixed_cache = build_fixed_observer_cache(observer_iter)?; + + let observer_centric_cache = build_centric_observer_cache( + jpl, + ut1_provider, + obs_dataset, + &observer_fixed_cache, + cache_velocity, + )?; + + Ok(Self { + observer_centric: observer_centric_cache, + observer_fixed: observer_fixed_cache, + }) + } + + /// Returns the [`ObserverFixedCache`] for the given observer, if present. + /// + /// Alias for [`OutfitCache::get_fixed`], provided for explicitness when the + /// caller already has an [`ObserverId`]. + pub fn get_observer_fixed_cache(&self, observer_id: ObserverId) -> Option<&ObserverFixedCache> { + self.observer_fixed.get(&observer_id) + } + + /// Returns the precomputed geocentric position of the observer at observation `idx`. + /// + /// The position is in the **ecliptic mean J2000** frame, in **AU**. + /// + /// # Panics + /// + /// Panics if `idx` is out of bounds. + pub fn get_observer_geocentric_position(&self, idx: ObsIndex) -> &ObserverGeocentricPosition { + &self.get_centric(idx).geo_position + } + + /// Returns the precomputed geocentric velocity of the observer at observation `idx`. + /// + /// The velocity is in the **ecliptic mean J2000** frame, in **AU/day**. + /// + /// # Panics + /// + /// Panics if `idx` is out of bounds. + pub fn get_observer_geocentric_velocity( + &self, + idx: ObsIndex, + ) -> &Option { + &self.get_centric(idx).geo_velocity + } + + /// Returns the precomputed heliocentric position of the observer at observation `idx`. + /// + /// The position is in the **ecliptic mean J2000** frame, in **AU**. + /// + /// # Panics + /// + /// Panics if `idx` is out of bounds. + pub fn get_helio_position(&self, idx: ObsIndex) -> &ObserverHeliocentricPosition { + &self.get_centric(idx).helio_position + } +} diff --git a/src/cache/observer_centric_cache.rs b/src/cache/observer_centric_cache.rs new file mode 100644 index 0000000..2538d13 --- /dev/null +++ b/src/cache/observer_centric_cache.rs @@ -0,0 +1,465 @@ +//! Precomputed observer-centric positions for every observation epoch. +//! +//! This module computes and caches the **geocentric** and **heliocentric** position +//! (and velocity) of each observer at the precise epoch of each observation. Unlike +//! the body-fixed quantities in [`crate::cache::observer_fixed_cache`], these values +//! depend on the observation time: Earth's rotation and orbital motion must be +//! integrated at each epoch using the JPL ephemeris and the UT1 time scale. +//! +//! # Workflow +//! +//! 1. For each observation, look up the pre-built [`ObserverFixedCache`] by observer ID. +//! 2. Call [`photom::observer::Observer::pvobs`] with the observation epoch and the +//! body-fixed cache to obtain the **geocentric position and velocity** in the +//! mean ecliptic J2000 frame. +//! 3. Call [`photom::observer::Observer::helio_position`] with the JPL ephemeris to +//! add the geocentric Earth position and obtain the **heliocentric position**. +//! 4. Store the results in [`ObserverCentricCache`]. +//! +//! # Coordinate system +//! +//! All output vectors are expressed in the **ecliptic mean J2000** reference frame: +//! +//! - positions in **AU** +//! - velocities in **AU/day** +//! +//! # Organisation +//! +//! - [`ObserverGeocentricPosition`] / [`ObserverGeocentricVelocity`] / +//! [`ObserverHeliocentricPosition`] — NaN-safe 3-vector type aliases. +//! - [`ObserverCentricCache`] — epoch-dependent position/velocity for one observation. +//! - [`CentricObserverCache`] — `Vec` of [`ObserverCentricCache`], indexed by `ObsIndex`. +//! - [`build_centric_observer_cache`] — constructs the full vector for a dataset. + +use hifitime::{ut1::Ut1Provider, Epoch}; +use nalgebra::Vector3; +use ordered_float::NotNan; +use photom::{observation_dataset::ObsDataset, observer::Observer, MJDTT}; + +use crate::{ + cache::observer_fixed_cache::{BodyFixedObserverCache, ObserverFixedCache}, + observer_extension::ResolvedObserver, + JPLEphem, OutfitError, +}; + +/// Geocentric position of the observer at the epoch of an observation. +/// +/// Expressed in the **ecliptic mean J2000** frame, in **AU**. +/// Uses [`NotNan`] components to enforce NaN-safety at construction time. +pub type ObserverGeocentricPosition = Vector3>; + +/// Geocentric velocity of the observer at the epoch of an observation. +/// +/// Expressed in the **ecliptic mean J2000** frame, in **AU/day**. +/// Uses [`NotNan`] components to enforce NaN-safety at construction time. +pub type ObserverGeocentricVelocity = Vector3>; + +/// Heliocentric position of the observer at the epoch of an observation. +/// +/// This is the sum of the geocentric observer position and the geocentric +/// position of the Earth's centre, both in the **ecliptic mean J2000** frame, +/// in **AU**. +/// Uses [`NotNan`] components to enforce NaN-safety at construction time. +pub type ObserverHeliocentricPosition = Vector3>; + +/// Heliocentric velocity of the observer at the epoch of an observation. +/// +/// This is the sum of the geocentric observer velocity and the geocentric +/// velocity of the Earth's centre, both in the **ecliptic mean J2000** frame, +/// in **AU/day**. +/// Uses [`NotNan`] components to enforce NaN-safety at construction time. +pub type ObserverHeliocentricVelocity = Vector3>; + +/// Geocentric and heliocentric observer state for a single observation epoch. +/// +/// Built for each observation in the dataset by [`build_centric_observer_cache`]. +/// The fields are indexed positionally: the *i*-th element of +/// [`CentricObserverCache`] corresponds to the *i*-th observation in the dataset +/// (i.e., at `ObsIndex` *i*). +/// +/// All vectors are in the **ecliptic mean J2000** frame. +#[derive(Debug)] +pub struct ObserverCentricCache { + /// Geocentric position of the observer at the observation epoch, in AU. + pub geo_position: ObserverGeocentricPosition, + /// Geocentric velocity of the observer at the observation epoch, in AU/day. + pub geo_velocity: Option, + /// Heliocentric position of the observer at the observation epoch, in AU. + pub helio_position: ObserverHeliocentricPosition, + /// Heliocentric velocity of the observer at the observation epoch, in AU/day. + pub helio_velocity: Option, +} + +impl ObserverCentricCache { + /// Computes the geocentric and heliocentric observer state at a given observation epoch. + /// + /// # Arguments + /// + /// - `jpl` — JPL planetary ephemeris used to obtain the geocentric Earth position. + /// - `ut1_provider` — UT1 time scale data required to compute Earth's sidereal angle + /// at the observation epoch. + /// - `obs_time` — observation epoch as a Modified Julian Date in the TT time scale + /// (see [`MJDTT`]). + /// - `observer_fixed_cache` — precomputed body-fixed position and velocity of the + /// observer (see [`ObserverFixedCache`]). + /// - `cache_velocity` — whether to compute and cache the velocity components. If `false`, the velocity fields will be set to `None` to save computation time and memory. + /// + /// # Errors + /// + /// Returns [`OutfitError`] if: + /// - [`photom::observer::Observer::pvobs`] fails (e.g., missing UT1 data for the epoch), or + /// - [`photom::observer::Observer::helio_position`] fails (e.g., epoch out of range + /// for the JPL ephemeris). + pub fn new( + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + obs_time: MJDTT, + observer_fixed_cache: &ObserverFixedCache, + cache_velocity: bool, + ) -> Result { + let obs_mjd = Epoch::from_mjd_in_time_scale(obs_time, hifitime::TimeScale::TT); + let (geocentric_pos, geocentric_vel) = + Observer::pvobs(&obs_mjd, ut1_provider, observer_fixed_cache, cache_velocity)?; + + let heliocentric_pos = Observer::helio_position(jpl, &obs_mjd, &geocentric_pos)?; + let heliocentric_vel = if cache_velocity { + Some(Observer::helio_velocity(jpl, &obs_mjd, &geocentric_vel)?) + } else { + None + }; + + Ok(Self { + geo_position: geocentric_pos, + geo_velocity: if cache_velocity { + Some(geocentric_vel) + } else { + None + }, + helio_position: heliocentric_pos, + helio_velocity: heliocentric_vel, + }) + } +} + +/// Full observer-centric cache for an entire observation dataset. +/// +/// A contiguous `Vec` where the element at index *i* holds the precomputed +/// geocentric and heliocentric state for the *i*-th observation +/// (i.e., at [`photom::ObsIndex`] *i*). +pub type CentricObserverCache = Vec; + +/// Builds the [`CentricObserverCache`] for every observation in a dataset. +/// +/// Iterates over all observations in `obs_dataset`, looks up the pre-built +/// body-fixed cache entry for the corresponding observer, and computes the +/// epoch-dependent geocentric and heliocentric state via +/// [`ObserverCentricCache::new`]. +/// +/// # Arguments +/// +/// - `jpl` — JPL planetary ephemeris used to compute Earth's heliocentric position. +/// - `ut1_provider` — UT1 time scale data for Earth rotation at each epoch. +/// - `obs_dataset` — the full observation dataset to process. +/// - `observer_fixed_cache` — pre-built map from [`photom::observer::dataset::ObserverId`] +/// to body-fixed observer state (see [`BodyFixedObserverCache`]). +/// - `cache_velocity` — whether to compute and cache the velocity components in the resulting `ObserverCentricCache`. If `false`, the velocity fields will be set to `None` to save computation time and memory. +/// +/// # Errors +/// +/// Returns [`OutfitError`] if: +/// - any observation has no associated observer ID +/// ([`OutfitError::ObserverIdIsNone`]), or +/// - the observer ID is not found in `observer_fixed_cache`, or +/// - [`ObserverCentricCache::new`] fails for any observation epoch. +/// +/// # Examples +/// +/// ```rust,ignore +/// let centric_cache = build_centric_observer_cache( +/// &jpl, +/// &ut1_provider, +/// &obs_dataset, +/// &fixed_cache, +/// )?; +/// ``` +pub fn build_centric_observer_cache( + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + obs_dataset: &ObsDataset, + observer_fixed_cache: &BodyFixedObserverCache, + cache_velocity: bool, +) -> Result { + #[cfg(not(feature = "parallel"))] + let iter = obs_dataset.iter_observations(); + + #[cfg(feature = "parallel")] + use rayon::iter::{IndexedParallelIterator, ParallelIterator}; + #[cfg(feature = "parallel")] + let iter = obs_dataset.par_iter_observations(); + + iter.enumerate() + .map(|(idx, obs)| { + let observer_id = obs + .observer_id() + .ok_or_else(|| OutfitError::ObserverIdIsNone(idx as u64))?; + + let fixed_cache = observer_fixed_cache + .get(observer_id) + .ok_or_else(|| OutfitError::ObserverIdIsNone(idx as u64))?; + + ObserverCentricCache::new(jpl, ut1_provider, obs.mjd_tt(), fixed_cache, cache_velocity) + }) + .collect() +} + +#[cfg(test)] +mod observer_test { + + use approx::assert_relative_eq; + use photom::{Meters, Radians}; + + use crate::{ + cache::observer_centric_cache::ObserverCentricCache, + conversion::ToNotNan, + test_fixture::{JPL_EPHEM_HORIZON, UT1_PROVIDER}, + }; + + use super::*; + + fn to_observer( + longitude: Radians, + latitude: Radians, + height: Meters, + name: Option, + ra_accuracy: Option, + dec_accuracy: Option, + ) -> Observer { + Observer::new(longitude, latitude, height, name, ra_accuracy, dec_accuracy) + .expect("Failed to create Observer") + } + + #[test] + fn body_fixed_coord_test() { + // longitude, latitude and height of Pan-STARRS 1, Haleakala + let (lon, lat, h) = ( + 203.744090000_f64.to_radians(), + 20.707233557_f64.to_radians(), + 3067.694, + ); + let pan_starrs = to_observer(lon, lat, h, None, None, None); + assert_eq!( + pan_starrs + .earth_fixed_position() + .unwrap() + .map(|x| x.into_inner()), + Vector3::new( + -0.00003653799439776371, + -0.00001607260397528885, + 0.000014988110430544328 + ) + ); + } + + #[test] + fn pvobs_test() { + let tmjd = 57028.479297592596; + let epoch = Epoch::from_mjd_in_time_scale(tmjd, hifitime::TimeScale::TT); + // longitude, latitude and height of Pan-STARRS 1, Haleakala + let (lon, lat, h) = ( + 203.744090000_f64.to_radians(), + 20.707233557_f64.to_radians(), + 3067.694, + ); + + let pan_starrs = to_observer(lon, lat, h, Some("Pan-STARRS 1".to_string()), None, None); + + let observer_fixed_cache: ObserverFixedCache = (&pan_starrs).try_into().unwrap(); + + let (observer_position, observer_velocity) = + Observer::pvobs(&epoch, &UT1_PROVIDER, &observer_fixed_cache, true).unwrap(); + + assert_eq!( + observer_position.as_slice(), + [ + -2.086211182493635e-5, + 3.718476815327979e-5, + 2.4978996447997476e-7 + ] + ); + assert_eq!( + observer_velocity.as_slice(), + [ + -0.0002143246535691577, + -0.00012059801691431748, + 5.262184624215718e-5 + ] + ); + + let (_, observer_velocity) = + Observer::pvobs(&epoch, &UT1_PROVIDER, &observer_fixed_cache, false).unwrap(); + + assert_eq!(observer_velocity.as_slice(), [0.0, 0.0, 0.0]); + } + + #[test] + fn test_helio_pos_obs() { + let (lon, lat, h) = (203.744090000_f64, 20.707233557_f64, 3067.694_f64); + let pan_starrs = to_observer( + lon.to_radians(), + lat.to_radians(), + h, + Some("Pan-STARRS 1".to_string()), + None, + None, + ); + let observer_fixed_cache: ObserverFixedCache = (&pan_starrs).try_into().unwrap(); + + let cases = [ + ( + 57_028.479_297_592_596, + [-0.2645666171464416, 0.8689351643701766, 0.3766996211107864], + ), + ( + 57_049.245_147_592_59, + [-0.5891631852137064, 0.7238872516824697, 0.3138186516540669], + ), + ( + 57_063.977_117_592_59, + [-0.7743280306286537, 0.5612532665812755, 0.24333415479994636], + ), + ]; + + for (tmjd, expected) in cases { + let obs = ObserverCentricCache::new( + &JPL_EPHEM_HORIZON, + &UT1_PROVIDER, + tmjd, + &observer_fixed_cache, + true, + ) + .unwrap(); + + assert_eq!(obs.helio_position.as_slice(), expected, "tmjd = {tmjd}"); + } + } + + fn v3(x: f64, y: f64, z: f64) -> Vector3> { + Vector3::new(x, y, z).to_notnan().unwrap() + } + + fn to_f64(v: &Vector3>) -> Vector3 { + v.map(|x| x.into_inner()) + } + + fn assert_v3_eq(actual: &Vector3>, expected: &Vector3>, eps: f64) { + assert_relative_eq!(to_f64(actual), to_f64(expected), epsilon = eps); + } + + #[test] + fn test_helio_pos_vel_geocenter() { + let geocenter = + Observer::from_parallax(0.0, 0.0, 0.0, Some("Geocenter".to_string()), None, None) + .unwrap(); + + let observer_fixed_cache: ObserverFixedCache = (&geocenter).try_into().unwrap(); + + let obs = ObserverCentricCache::new( + &JPL_EPHEM_HORIZON, + &UT1_PROVIDER, + 59000.0, + &observer_fixed_cache, + true, + ) + .unwrap(); + + assert_v3_eq(&obs.geo_position, &v3(0.0, 0.0, 0.0), 1e-10); + assert_v3_eq( + &obs.helio_position, + &v3( + -0.35112872984703947, + -0.8726911829575209, + -0.37831199013326505, + ), + 1e-10, + ); + assert_v3_eq( + obs.helio_velocity.as_ref().unwrap(), + &v3( + 0.015860197805364396, + -0.005519387867661577, + -0.002392757495907968, + ), + 1e-10, + ); + + assert_v3_eq(&obs.geo_position, &v3(0.0, 0.0, 0.0), 1e-10); + assert_v3_eq( + obs.geo_velocity.as_ref().unwrap(), + &v3(0.0, 0.0, 0.0), + 1e-10, + ); + } + + #[test] + fn test_helio_pos_vel_mauna_kea() { + // MPC code 568 — Mauna Kea (W. M. Keck Observatory) + // lon = 204.5284°, lat = 19.8260°, h = 4160 m + let mauna_kea = Observer::from_parallax( + 204.5278_f64.to_radians(), + 0.94171, + 0.33725, + Some("Maunakea".to_string()), + None, + None, + ) + .unwrap(); + + let observer_fixed_cache: ObserverFixedCache = (&mauna_kea).try_into().unwrap(); + + let obs = ObserverCentricCache::new( + &JPL_EPHEM_HORIZON, + &UT1_PROVIDER, + 59000.0, + &observer_fixed_cache, + true, + ) + .unwrap(); + + assert_v3_eq( + &obs.helio_position, + &v3( + -3.511307549159519e-01, + -8.726510855672746e-01, + -3.782976072020051e-01, + ), + 1e-9, + ); + assert_v3_eq( + obs.helio_velocity.as_ref().unwrap(), + &v3( + 1.560756863717671e-02, + -5.532323168433832e-03, + -2.392265222947331e-03, + ), + 1e-8, + ); + assert_v3_eq( + &obs.geo_position, + &v3( + -2.025068912418855e-06, + 4.250983777758508e-05, + -2.753744421400818e-06, + ), + 1e-8, + ); + assert_v3_eq( + obs.geo_velocity.as_ref().unwrap(), + &v3( + -2.526291681876792e-04, + -1.167209148779009e-05, + 5.597018763337186e-06, + ), + 1e-8, + ); + } +} diff --git a/src/cache/observer_fixed_cache.rs b/src/cache/observer_fixed_cache.rs new file mode 100644 index 0000000..325721e --- /dev/null +++ b/src/cache/observer_fixed_cache.rs @@ -0,0 +1,167 @@ +//! Precomputed body-fixed observer positions and velocities. +//! +//! This module computes and caches the **Earth-fixed (body-fixed) position and +//! velocity** of each observer present in an observation dataset. These quantities +//! are time-independent — they depend only on the observer's geographic coordinates +//! (longitude, latitude, height) — so they are computed once at cache-build time +//! and reused for every observation associated with the same observer. +//! +//! # Coordinate system +//! +//! All vectors are expressed in the **geocentric Earth-fixed frame** (ECEF-like), +//! with components given in **astronomical units (AU)** for positions and +//! **AU/day** for velocities. +//! +//! The body-fixed velocity is derived from Earth's sidereal rotation: +//! +//! ```text +//! v_fixed = ω_earth × r_fixed +//! ``` +//! +//! where `ω_earth` is the Earth rotation vector (see [`crate::constants::EARTH_ROTATION`]). +//! +//! # Organisation +//! +//! - [`ObserverFixedPosition`] / [`ObserverFixedVelocity`] — type aliases for 3-vectors. +//! - [`ObserverFixedCache`] — holds the fixed position and velocity for one observer. +//! - [`BodyFixedObserverCache`] — map from [`ObserverId`] to [`ObserverFixedCache`]. +//! - [`build_fixed_observer_cache`] — constructs the map from an iterator of observers. + +use ahash::AHashMap; +use nalgebra::Vector3; +use ordered_float::NotNan; +use photom::observer::{dataset::ObserverId, Observer}; + +use crate::{ + constants::EARTH_ROTATION, conversion::ToNotNan, observer_extension::ResolvedObserver, + OutfitError, +}; + +/// Precomputed **body-fixed** position of the observer in **AU**. +/// +/// The vector is expressed in the geocentric Earth-fixed frame. Its components +/// are stored as [`NotNan`] to guarantee the absence of NaN values at +/// construction time. +pub type ObserverFixedPosition = Vector3>; + +/// Precomputed **body-fixed** velocity of the observer in **AU/day**. +/// +/// Derived from the cross product of Earth's rotation vector with the observer's +/// body-fixed position: `v = ω × r`. Stored as [`NotNan`] for the same +/// NaN-safety guarantee as [`ObserverFixedPosition`]. +pub type ObserverFixedVelocity = Vector3>; + +/// Body-fixed position and velocity for a single ground-based observer. +/// +/// This cache entry is time-independent: it is built once from the observer's +/// geographic coordinates and reused across all observations made by that observer. +/// +/// # Fields +/// +/// Both fields are in the geocentric Earth-fixed frame: +/// +/// - position in **AU** +/// - velocity in **AU/day** (from Earth rotation) +#[derive(Debug)] +pub struct ObserverFixedCache { + /// Geocentric Earth-fixed position of the observer, in AU. + observer_fixed_positions: ObserverFixedPosition, + /// Geocentric Earth-fixed velocity of the observer due to Earth's rotation, in AU/day. + observer_fixed_velocities: ObserverFixedVelocity, +} + +impl ObserverFixedCache { + /// Constructs a new [`ObserverFixedCache`] from a ground-based [`Observer`]. + /// + /// Computes the body-fixed position from the observer's geodetic coordinates + /// (longitude, latitude, height above ellipsoid), then derives the body-fixed + /// velocity using Earth's sidereal rotation vector: + /// + /// ```text + /// v_fixed = ω_earth × r_fixed + /// ``` + /// + /// # Errors + /// + /// Returns [`OutfitError`] if: + /// - the observer's Earth-fixed position cannot be computed + /// (e.g., invalid geodetic coordinates), or + /// - a NaN is encountered when converting to [`NotNan`]. + pub fn new(observer: &Observer) -> Result { + // Body-fixed position in AU from (ρ·cosφ, ρ·sinφ) scaled by Earth radius (AU). + let body_fixed_pos = observer.earth_fixed_position()?; + + // Body-fixed velocity from Earth rotation. + let body_fixed_vel: Vector3> = + EARTH_ROTATION.to_notnan()?.cross(&body_fixed_pos); + + Ok(Self { + observer_fixed_positions: body_fixed_pos, + observer_fixed_velocities: body_fixed_vel, + }) + } + + /// Returns the precomputed body-fixed position of the observer, in AU. + pub fn position(&self) -> &ObserverFixedPosition { + &self.observer_fixed_positions + } + + /// Returns the precomputed body-fixed velocity of the observer, in AU/day. + /// + /// This is the velocity due to Earth's sidereal rotation: `ω × r`. + pub fn velocity(&self) -> &ObserverFixedVelocity { + &self.observer_fixed_velocities + } +} + +impl TryFrom<&Observer> for ObserverFixedCache { + type Error = OutfitError; + + /// Converts an [`Observer`] reference into an [`ObserverFixedCache`]. + /// + /// Delegates to [`ObserverFixedCache::new`]. + /// + /// # Errors + /// + /// Propagates any error from [`ObserverFixedCache::new`]. + fn try_from(resolved: &Observer) -> Result { + Self::new(resolved) + } +} + +/// Cache mapping observer IDs to their precomputed body-fixed positions and velocities. +/// +/// This hash map is built once before any trajectory fitting (see +/// [`build_fixed_observer_cache`]) and looked up by [`ObserverId`] for every +/// observation in the dataset. Using [`AHashMap`] provides fast, non-cryptographic +/// hashing suited for integer-keyed lookups. +pub type BodyFixedObserverCache = AHashMap; + +/// Builds the [`BodyFixedObserverCache`] from an iterator of `(ObserverId, &Observer)` pairs. +/// +/// For each observer, computes the body-fixed position and velocity and stores +/// them in the map. This function is typically called once before the main +/// cache-building step in [`crate::cache::OutfitCache::build`]. +/// +/// # Arguments +/// +/// - `observers` — an iterator yielding `(ObserverId, &Observer)` pairs, typically +/// obtained from [`photom::observation_dataset::ObsDataset::iter_observer`]. +/// +/// # Errors +/// +/// Returns [`OutfitError`] if [`ObserverFixedCache::new`] fails for any observer +/// in the iterator (e.g., invalid geodetic coordinates or NaN conversion). +/// +/// # Examples +/// +/// ```rust,ignore +/// let cache = build_fixed_observer_cache(obs_dataset.iter_observer()?)?; +/// ``` +pub fn build_fixed_observer_cache<'a>( + observers: impl Iterator, +) -> Result { + observers + .map(|(id, obs)| -> Result<_, OutfitError> { Ok((id, obs.try_into()?)) }) + .collect::>() +} diff --git a/src/constants.rs b/src/constants.rs index 2b121d5..3a2e3e9 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -15,18 +15,18 @@ //! These definitions are used by all main modules, including orbit determination, observers, //! and ephemerides. -use crate::observations::Observation; -use crate::observers::Observer; -use smallvec::SmallVec; -use std::borrow::Cow; -use std::collections::HashMap; -use std::convert::TryFrom; -use std::sync::Arc; - // ------------------------------------------------------------------------------------------------- // Physical constants and unit conversions // ------------------------------------------------------------------------------------------------- +use std::collections::HashMap; + +use ahash::RandomState; +use nalgebra::{Matrix3, Vector3}; +use photom::TrajId; + +use crate::{GaussResult, OrbitalElements, OutfitError}; + /// 2π, useful for trigonometric conversions pub const DPI: f64 = 2. * std::f64::consts::PI; @@ -78,189 +78,105 @@ pub const VLIGHT: f64 = 2.99792458e5; /// Speed of light in astronomical units per day pub const VLIGHT_AU: f64 = VLIGHT / AU * SECONDS_PER_DAY; -// ------------------------------------------------------------------------------------------------- -// Type aliases -// ------------------------------------------------------------------------------------------------- +// Angular velocity of Earth rotation (rad/day) on the z-axis. +pub const EARTH_ROTATION: Vector3 = Vector3::new(0.0, 0.0, DPI * 1.00273790934); -/// Angle in degrees -pub type Degree = f64; -/// Angle in arcseconds -pub type ArcSec = f64; -/// Angle in radians -pub type Radian = f64; -/// Distance in kilometers -pub type Kilometer = f64; -/// Distance in meters -pub type Meter = f64; -/// MPC code identifying an observatory (3 characters) -pub type MpcCode = String; - -/// Lookup table from MPC code to [`Observer`] metadata -pub type MpcCodeObs = HashMap>; - -/// Modified Julian Date (days) -pub type MJD = f64; +// Hard coded rotation matrices for coordinate transformations between mean equatorial J2000 and mean ecliptic J2000 frames. +// Can be computed using the rotpn function in the ref_system module -// ------------------------------------------------------------------------------------------------- -// Identifiers and data containers -// ------------------------------------------------------------------------------------------------- - -/// Identifier of a solar system object. +/// Rotation matrix from mean equatorial J2000 to mean ecliptic J2000. +/// +/// Rotation of $-\varepsilon$ around the X-axis, where $\varepsilon$ is the +/// obliquity of the ecliptic at J2000. +/// +/// Equivalent to `rotpn(RefSystem::Equm(RefEpoch::J2000), RefSystem::Eclm(RefEpoch::J2000))`. +pub const ROT_EQUMJ2000_TO_ECLMJ2000: Matrix3 = Matrix3::new( + 1.0e0, + 0.0e0, + 0.0e0, + 0.0e0, + 9.174_820_620_691_818e-1, + 3.977_771_559_319_137e-1, + 0.0e0, + -3.977_771_559_319_137e-1, + 9.174_820_620_691_818e-1, +); + +/// Rotation matrix from mean ecliptic J2000 to mean equatorial J2000. /// -/// This can be: -/// - An asteroid number (e.g. `Int(1234)`) -/// - A comet number (e.g. `"1234P"`) -/// - A provisional designation (e.g. `"K25D50B"`) -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub enum ObjectNumber { - /// Integer-based MPC designation (e.g. 1, 433…) - Int(u32), - /// String-based designation (provisional, comet, etc.) - String(String), +/// Rotation of $+\varepsilon$ around the X-axis (transpose / inverse of +/// [`ROT_EQUMJ2000_TO_ECLMJ2000`]). +/// +/// Equivalent to `rotpn(RefSystem::Eclm(RefEpoch::J2000), RefSystem::Equm(RefEpoch::J2000))`. +pub const ROT_ECLMJ2000_TO_EQUMJ2000: Matrix3 = Matrix3::new( + 1.0e0, + 0.0e0, + 0.0e0, + 0.0e0, + 9.174_820_620_691_818e-1, + -3.977_771_559_319_137e-1, + 0.0e0, + 3.977_771_559_319_137e-1, + 9.174_820_620_691_818e-1, +); + +/// Modified Julian Date (Scale Ephemeris Time, ET) +pub type MJDET = f64; + +/// Type alias for the RMS of normalized residuals from an IOD fit. +/// This is a single scalar value representing the overall fit quality of the IOD solution. +pub type IODRMS = f64; + +/// Type alias for the chi-squared value of a fit, used in differential correction. +pub type Chi2 = f64; + +pub enum FitOrbitResult { + IODGauss((GaussResult, IODRMS)), + DifferentialCorrection((OrbitalElements, Chi2)), } -impl std::fmt::Display for ObjectNumber { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl FitOrbitResult { + /// Returns a scalar measure of orbit quality for this fit result. + /// For IODGauss, this is the RMS of normalized residuals; for DifferentialCorrection, this is the chi-squared value. + /// + /// # Returns + /// - `f64` — a single scalar representing the fit quality. + pub fn orbit_quality(&self) -> f64 { match self { - ObjectNumber::Int(n) => write!(f, "{n}"), - ObjectNumber::String(s) => write!(f, "{s}"), + FitOrbitResult::IODGauss((_, rms)) => *rms, + FitOrbitResult::DifferentialCorrection((_, chi2)) => *chi2, } } -} - -// --- Infallible conversions (enable `.into()` directly) ---------------------- -impl From for ObjectNumber { - #[inline] - fn from(n: u32) -> Self { - ObjectNumber::Int(n) - } -} - -impl From for ObjectNumber { - #[inline] - fn from(n: u16) -> Self { - ObjectNumber::Int(n as u32) - } -} - -impl From for ObjectNumber { - #[inline] - fn from(n: u8) -> Self { - ObjectNumber::Int(n as u32) - } -} - -impl From<&u32> for ObjectNumber { - /// Convenience to allow `(&n).into()` without dereferencing at call sites. - #[inline] - fn from(n: &u32) -> Self { - ObjectNumber::Int(*n) - } -} - -impl From for ObjectNumber { - #[inline] - fn from(s: String) -> Self { - ObjectNumber::String(s) - } -} - -impl From<&String> for ObjectNumber { - /// Clones the string to build a `String`-backed identifier. - #[inline] - fn from(s: &String) -> Self { - ObjectNumber::String(s.clone()) - } -} - -impl From<&str> for ObjectNumber { - /// Note: this **does not** parse numeric strings into `Int`. Use `FromStr` if you want - /// `"1234"` to become `ObjectNumber::Int(1234)`. - #[inline] - fn from(s: &str) -> Self { - ObjectNumber::String(s.to_string()) - } -} - -impl<'a> From> for ObjectNumber { - /// Accept both borrowed and owned `Cow`. - #[inline] - fn from(c: Cow<'a, str>) -> Self { - match c { - Cow::Borrowed(s) => ObjectNumber::String(s.to_string()), - Cow::Owned(s) => ObjectNumber::String(s), - } - } -} - -// --- Fallible conversions (use `.try_into()` to be overflow-safe) ------------ - -impl TryFrom for ObjectNumber { - type Error = std::num::TryFromIntError; - - /// Convert a `usize` into `Int(u32)` if it fits. - #[inline] - fn try_from(n: usize) -> Result { - Ok(ObjectNumber::Int(u32::try_from(n)?)) - } -} - -impl TryFrom for ObjectNumber { - type Error = std::num::TryFromIntError; - - /// Convert a `u64` into `Int(u32)` if it fits. - #[inline] - fn try_from(n: u64) -> Result { - Ok(ObjectNumber::Int(u32::try_from(n)?)) - } -} - -impl TryFrom for ObjectNumber { - type Error = &'static str; - - /// Convert a non-negative `i64` into `Int(u32)` if it fits. - #[inline] - fn try_from(n: i64) -> Result { - if n < 0 { - return Err("negative value is not a valid ObjectNumber::Int"); - } - let n = u64::try_from(n).map_err(|_| "conversion failed")?; - let n = u32::try_from(n).map_err(|_| "value exceeds u32 range")?; - Ok(ObjectNumber::Int(n)) - } -} - -// --- Smart parsing from &str via `FromStr` (optional) ------------------------ - -impl std::str::FromStr for ObjectNumber { - type Err = std::num::ParseIntError; - - /// Try to parse an `ObjectNumber` from a string. - /// - /// Rules - /// ----- - /// - Pure digits that fit in `u32` → `Int(u32)`. - /// - Otherwise → `String(String)`. + /// Returns a reference to the orbital elements associated with this fit result. + /// For `IODGauss`, this extracts the orbital elements from the `GaussResult`; for `DifferentialCorrection`, it returns the orbital elements directly. /// - /// Note - /// ---- - /// If the string is *only* digits but **does not** fit in `u32`, this returns the - /// original `ParseIntError`. If you prefer to always fallback to `String` on - /// overflow, we can change the policy (but it’s usually better to fail loudly). - fn from_str(s: &str) -> Result { - match s.parse::() { - Ok(n) => Ok(ObjectNumber::Int(n)), - Err(e) => { - if s.chars().any(|c| !c.is_ascii_digit()) { - Ok(ObjectNumber::String(s.to_string())) - } else { - Err(e) - } - } + /// # Returns + /// - `&OrbitalElements` — a reference to the orbital elements of the fitted orbit. + pub fn orbital_elements(&self) -> &OrbitalElements { + match self { + FitOrbitResult::IODGauss((gauss_result, _)) => gauss_result.get_orbit(), + FitOrbitResult::DifferentialCorrection((orbital_elements, _)) => orbital_elements, } } } -/// A small, inline-optimized container for observations of a single object. -pub type Observations = SmallVec<[Observation; 6]>; +/// Full batch orbit determination results. +/// +/// Each entry maps an [`TrajId`] to the outcome of a full +/// Initial Orbit Determination (IOD) attempt on its set of observations. +/// +/// Internally, this is implemented as: +/// +/// ```ignore +/// HashMap, RandomState> +/// ``` +/// +/// Return semantics +/// ----------------- +/// * `Ok(FitOrbitResult::IODGauss((GaussResult, IODRMS)))` – a successful IOD with its RMS of normalized residuals. +/// * `Ok(FitOrbitResult::DifferentialCorrection((OrbitalElements, Chi2)))` – a successful differential correction with its chi-squared value. +/// * `Err(OutfitError)` – a failure isolated to that object. +/// +/// Use RandomState from the ahash crate for efficient hashing of TrajId keys. +pub type FullOrbitResult = HashMap, RandomState>; diff --git a/src/conversion.rs b/src/conversion.rs index 25a19c4..f0b7e18 100644 --- a/src/conversion.rs +++ b/src/conversion.rs @@ -83,9 +83,11 @@ //! - MPC/ADES ingestion modules where these utilities are typically used. use std::f64::consts::TAU; -use nalgebra::Vector3; +use nalgebra::{Matrix3, Vector3}; +use ordered_float::{FloatIsNan, NotNan}; +use photom::{coordinates::cartesian::CartesianCoord, Arcseconds, Degrees, Radians}; -use crate::constants::{ArcSec, Degree, Radian, DPI}; +use crate::constants::DPI; /// Estimate the accuracy of a numeric string based on its decimal precision. /// @@ -118,7 +120,7 @@ fn compute_accuracy(field: &str, factor: f64) -> Option { /// ---------- /// * Angle in **radians** (`Radian`). #[inline] -pub fn arcsec_to_rad(arcsec: ArcSec) -> Radian { +pub fn arcsec_to_rad(arcsec: Arcseconds) -> Radians { (arcsec / 3600.0).to_radians() } @@ -153,7 +155,7 @@ pub fn arcsec_to_rad(arcsec: ArcSec) -> Radian { /// /// # See also /// * [`parse_dec_to_deg`] – Parses declination strings into degrees. -pub fn parse_ra_to_deg(ra: &str) -> Option<(Degree, ArcSec)> { +pub fn parse_ra_to_deg(ra: &str) -> Option<(Degrees, Arcseconds)> { let parts: Vec<&str> = ra.split_whitespace().collect(); if parts.len() != 3 { return None; @@ -200,7 +202,7 @@ pub fn parse_ra_to_deg(ra: &str) -> Option<(Degree, ArcSec)> { /// /// # See also /// * [`parse_ra_to_deg`] – Parses right ascension strings into degrees. -pub fn parse_dec_to_deg(dec: &str) -> Option<(Degree, ArcSec)> { +pub fn parse_dec_to_deg(dec: &str) -> Option<(Degrees, Arcseconds)> { let parts: Vec<&str> = dec.split_whitespace().collect(); if parts.len() != 3 { return None; @@ -424,7 +426,7 @@ pub fn dec_sdms_prec(rad: f64, prec: usize) -> (char, u32, u32, f64) { /// * This function is used when converting inertial position vectors to observable angles. /// /// # See also -/// * [`correct_aberration`](crate::observations::correct_aberration) – apply aberration correction before calling this if needed +/// * `correct_aberration_first_order` – apply aberration correction before calling this if needed pub fn cartesian_to_radec(cartesian_position: Vector3) -> (f64, f64, f64) { let pos_norm = cartesian_position.norm(); if pos_norm == 0. { @@ -445,6 +447,63 @@ pub fn cartesian_to_radec(cartesian_position: Vector3) -> (f64, f64, f64) { (alpha, delta, pos_norm) } +pub trait ToNotNan { + type Output; + fn to_notnan(self) -> Result; +} + +impl ToNotNan for f64 { + type Output = NotNan; + fn to_notnan(self) -> Result { + NotNan::new(self) + } +} + +impl ToNotNan for Vector3 { + type Output = Vector3>; + fn to_notnan(self) -> Result { + Ok(Vector3::new( + self.x.to_notnan()?, + self.y.to_notnan()?, + self.z.to_notnan()?, + )) + } +} + +impl ToNotNan for Matrix3 { + type Output = Matrix3>; + fn to_notnan(self) -> Result { + Ok(Matrix3::new( + self[(0, 0)].to_notnan()?, + self[(0, 1)].to_notnan()?, + self[(0, 2)].to_notnan()?, + self[(1, 0)].to_notnan()?, + self[(1, 1)].to_notnan()?, + self[(1, 2)].to_notnan()?, + self[(2, 0)].to_notnan()?, + self[(2, 1)].to_notnan()?, + self[(2, 2)].to_notnan()?, + )) + } +} + +/// Convert a Cartesian position vector to a `CartesianCoord`. +/// +/// # Arguments +/// +/// - `vec`: A 3D vector representing the Cartesian coordinates (x, y, z). +/// +/// # Returns +/// +/// - A `CartesianCoord` struct with fields `x`, `y`, and `z` populated from the input vector. +pub fn cartesion_from_vec(vec: Vector3) -> CartesianCoord { + CartesianCoord { + x: vec[0], + y: vec[1], + z: vec[2], + } +} + #[cfg(test)] mod observations_test { use super::*; diff --git a/src/differential_orbit_correction/diff_cor.rs b/src/differential_orbit_correction/diff_cor.rs new file mode 100644 index 0000000..688604a --- /dev/null +++ b/src/differential_orbit_correction/diff_cor.rs @@ -0,0 +1,730 @@ +//! Differential orbit correction: outer loop driver. +//! +//! This module orchestrates the full differential-correction pipeline for a +//! single trajectory. It combines: +//! +//! - An **inner Newton–Raphson loop** that iteratively refines the orbital +//! elements by solving the linearised observation equations +//! (see [`single_iteration`]). +//! - An **outer outlier-rejection loop** that, after each converged inner +//! loop, tests whether any observation should be rejected or re-admitted +//! based on its projected chi-squared contribution (see +//! [`update_observation_selection`]). +//! +//! The function returns when the selection is stable *and* the inner loop +//! has converged, or when one of the configured iteration limits is reached. +//! +//! ## Algorithm overview +//! +//! ```text +//! outer_iteration = 0 +//! loop: +//! inner_iteration = 0 +//! loop (Newton–Raphson): +//! result = single_iteration(elements, obs, obs_fit_data, …) +//! check convergence: correction_norm < threshold +//! check stagnation / divergence on normalised_rms +//! check bizarre elements (eccentricity, semi-major axis limits) +//! check inversion success +//! elements = result.corrected_elements +//! obs_fit_data = result.updated_obs_fit_data +//! inner_iteration += 1 +//! if enable_outlier_rejection: +//! num_changes, obs_fit_data = update_observation_selection(obs_fit_data, equations, Γ, …) +//! if num_changes == 0: break ← selection stable → done +//! else: +//! break +//! outer_iteration += 1 +//! rescale_covariance(…) +//! return DifferentialCorrectionOutput { elements, uncertainty, normalised_rms, … } +//! ``` +//! +//! ## Configuration +//! +//! All tuning parameters are grouped in [`DifferentialCorrectionConfig`]. The +//! default values are suitable for main-belt asteroids with dense optical +//! astrometry; tighter constraints may be appropriate for short-arc objects. + +use photom::observation_dataset::observation::Observation; + +use crate::{ + cache::OutfitCache, + differential_orbit_correction::{ + least_square::rescale_covariance, + obs_fit_data::ObsFitData, + outlier_rejection::{update_observation_selection, OutlierRejectionConfig}, + single_iteration::single_iteration, + }, + orbit_type::{ + equinoctial_element::EquinoctialLimits, + uncertainty::{EquinoctialUncertainty, OrbitalCovariance}, + }, + propagator::PropagatorKind, + EquinoctialElements, JPLEphem, OrbitalElements, OutfitError, +}; + +use super::least_square::OrbitalUncertainty; + +// ───────────────────────────────────────────────────────────────────────────── +// Configuration +// ───────────────────────────────────────────────────────────────────────────── + +/// Tuning parameters for the full differential-correction pipeline. +/// +/// All fields have documented defaults; use [`DifferentialCorrectionConfig::default()`] +/// to get a configuration suitable for main-belt asteroids with optical +/// astrometry. +#[derive(Debug, Clone)] +pub struct DifferentialCorrectionConfig { + /// Maximum number of Newton–Raphson iterations in the inner loop. + /// + /// The inner loop stops when this count is reached even if the correction + /// norm has not dropped below `convergence_threshold`. + /// + /// Default: `30`. + pub max_newton_iterations: usize, + + /// Maximum number of outer outlier-rejection passes. + /// + /// The outer loop stops after this many passes even if the selection is + /// still changing. + /// + /// Default: `10`. + pub max_outlier_rejection_passes: usize, + + /// Dimensionless correction-norm threshold for inner-loop convergence. + /// + /// The inner loop is considered converged when + /// \\( \|\delta x\|_C < \text{convergence\_threshold} \\). + /// + /// Default: `1e-4`. + pub convergence_threshold: f64, + + /// Minimum normalised RMS below which outlier rejection is skipped for + /// the first pass. + /// + /// When the normalised RMS is already below this value after the first + /// inner-loop convergence, the outlier-rejection step is bypassed entirely + /// (the fit is already clean enough). + /// + /// Default: `2.0`. + pub convergence_before_rejection_threshold: f64, + + /// Ratio of consecutive normalised-RMS values above which the inner loop + /// is considered **stagnated**. + /// + /// If `rms_new / rms_prev ≥ rms_stagnation_ratio`, the inner loop is + /// terminated early and the last valid iteration is returned. + /// + /// Default: `0.98` (less than 2 % improvement triggers stagnation). + pub rms_stagnation_ratio: f64, + + /// Ratio of consecutive normalised-RMS values above which the inner loop + /// is considered **diverged**. + /// + /// If `rms_new / rms_prev ≥ rms_divergence_ratio`, the pipeline returns + /// [`OutfitError::DifferentialCorrectionDiverged`]. + /// + /// Default: `1.5` (50 % increase in RMS signals divergence). + pub rms_divergence_ratio: f64, + + /// Maximum number of consecutive stagnation events before the inner loop + /// is forcefully stopped. + /// + /// Default: `3`. + pub max_stagnation_iterations: usize, + + /// Whether to run the projection-based outlier-rejection step after each + /// inner-loop convergence. + /// + /// When `false`, the pipeline performs a single inner-loop pass with the + /// initial observation selection. + /// + /// Default: `true`. + pub enable_outlier_rejection: bool, + + /// Outlier rejection thresholds (chi-squared). + /// + /// Used only when `enable_outlier_rejection` is `true`. + pub outlier_rejection_config: OutlierRejectionConfig, + + /// Physical-plausibility limits on the equinoctial elements. + /// + /// After each Newton step, the corrected elements are tested against these + /// limits. If they fail, the pipeline returns + /// [`OutfitError::BizarreOrbit`]. + pub orbital_limits: EquinoctialLimits, + + /// Six-element boolean mask for free parameters. + /// + /// `free_elements[j] = true` means element `j` is solved for; `false` + /// means it is held fixed. Fixing an element is useful for under- + /// determined arcs (e.g., fixing the semi-major axis for a very short arc). + /// + /// Default: `[true; 6]` (all elements free). + pub free_elements: [bool; 6], + + /// Propagator to use for computing predicted observations and partials. + /// + /// - [`PropagatorKind::TwoBody`] (default): analytic Keplerian propagation. + /// - [`PropagatorKind::NBody`]: numerical DOP853 N-body integration with + /// user-specified perturbing bodies. + pub propagator: PropagatorKind, +} + +impl Default for DifferentialCorrectionConfig { + fn default() -> Self { + Self { + max_newton_iterations: 30, + max_outlier_rejection_passes: 10, + convergence_threshold: 1e-4, + convergence_before_rejection_threshold: 2.0, + rms_stagnation_ratio: 0.98, + rms_divergence_ratio: 1.5, + max_stagnation_iterations: 3, + enable_outlier_rejection: true, + outlier_rejection_config: OutlierRejectionConfig::default(), + orbital_limits: EquinoctialLimits::default(), + free_elements: [true; 6], + propagator: PropagatorKind::TwoBody, + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Output type +// ───────────────────────────────────────────────────────────────────────────── + +/// Output of a completed differential-correction run. +/// +/// Returned by [`run_differential_correction`] on success. +#[derive(Debug, Clone)] +pub struct DifferentialCorrectionOutput { + /// Best-fitting equinoctial orbital elements. + pub elements: EquinoctialElements, + + /// Per-observation fit data after the final iteration, including residuals, + /// chi values, and selection flags. + pub final_obs_fit_data: Vec, + + /// Covariance and normal matrices, rescaled by the posterior uncertainty + /// inflation factor. + pub uncertainty: OrbitalUncertainty, + + /// Normalised RMS of the final fit + /// \\( \sqrt{\xi^\top W \xi / n_{\text{active}}} \\). + pub normalised_rms: f64, + + /// Total number of Newton–Raphson iterations performed across all outer + /// passes. + pub total_newton_iterations: usize, + + /// Number of scalar measurements used in the final fit (2 per active + /// optical observation). + pub num_measurements: usize, +} + +/// Conversion from [`DifferentialCorrectionOutput`] to the more general +/// [`OrbitalElements`] type. +/// +/// The covariance is extracted from the `uncertainty` field and included in the +/// `OrbitalElements::Equinoctial` variant. +impl From for OrbitalElements { + fn from(output: DifferentialCorrectionOutput) -> Self { + let orb_covariance = OrbitalCovariance { + matrix: output.uncertainty.covariance, + }; + OrbitalElements::Equinoctial { + elements: output.elements, + uncertainty: Some(EquinoctialUncertainty::from_covariance(&orb_covariance)), + covariance: Some(orb_covariance), + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main function +// ───────────────────────────────────────────────────────────────────────────── + +/// Runs the full differential orbit correction for a single set of +/// observations. +/// +/// # Arguments +/// +/// - `observations` — slice of astrometric observations (all belonging to the +/// same trajectory). +/// - `initial_obs_fit_data` — per-observation statistical fit data (σ, bias, +/// initial selection flag). Must have the same length as `observations`. +/// - `initial_elements` — starting equinoctial orbital elements (e.g., from a +/// preceding IOD step). +/// - `cache` — pre-computed observer geometry cache. +/// - `jpl` — JPL planetary ephemeris handle. +/// - `config` — tuning parameters; use [`DifferentialCorrectionConfig::default()`] +/// for standard settings. +/// +/// # Returns +/// +/// A [`DifferentialCorrectionOutput`] with the refined elements, covariance, +/// and final per-observation fit data. +/// +/// # Errors +/// +/// - [`OutfitError::BizarreOrbit`] — the corrected elements violate the +/// physical-plausibility limits in `config.orbital_limits`. +/// - [`OutfitError::DifferentialCorrectionDiverged`] — the normalised RMS +/// increased by more than `config.rms_divergence_ratio`. +/// - [`OutfitError::DifferentialCorrectionFailed`] — the normal-equation +/// inversion failed (e.g., fewer active observations than free parameters). +/// +/// # Panics +/// +/// Panics if `observations.len() != initial_obs_fit_data.len()`. +pub fn run_differential_correction( + observations: &[Observation], + initial_obs_fit_data: &[ObsFitData], + initial_elements: &EquinoctialElements, + cache: &OutfitCache, + jpl: &JPLEphem, + config: &DifferentialCorrectionConfig, +) -> Result { + assert_eq!( + observations.len(), + initial_obs_fit_data.len(), + "observations and initial_obs_fit_data must have the same length" + ); + + let num_free = config.free_elements.iter().filter(|&&f| f).count(); + + // Working copies — updated at the end of every Newton step. + let mut elements = initial_elements.clone(); + let mut obs_fit_data = initial_obs_fit_data.to_vec(); + + let mut total_newton_iterations = 0_usize; + + // Saved from the last successful Newton step for fallback. + let mut last_uncertainty = OrbitalUncertainty { + normal_matrix: nalgebra::Matrix6::zeros(), + covariance: nalgebra::Matrix6::zeros(), + inversion_succeeded: false, + }; + let mut last_normalised_rms = f64::MAX; + let mut last_num_measurements = 0_usize; + + // ── Outer outlier-rejection loop ───────────────────────────────────────── + for outer_pass in 0..=config.max_outlier_rejection_passes { + let mut prev_rms = f64::MAX; + let mut stagnation_count = 0_usize; + + // ── Inner Newton–Raphson loop ───────────────────────────────────────── + let mut last_equations = vec![]; + let mut inner_loop_converged = false; + + for _inner in 0..config.max_newton_iterations { + total_newton_iterations += 1; + + let iter_result = single_iteration( + observations, + &obs_fit_data, + &elements, + &config.free_elements, + cache, + jpl, + true, + &config.propagator, + )?; + + // ── Check inversion ────────────────────────────────────────────── + if !iter_result.uncertainty.inversion_succeeded { + return Err(OutfitError::DifferentialCorrectionFailed( + "normal-equation inversion failed (possibly fewer active observations than \ + free parameters)" + .into(), + )); + } + + // ── Check bizarre orbit ────────────────────────────────────────── + if iter_result + .corrected_elements + .is_bizarre(&config.orbital_limits) + { + return Err(OutfitError::BizarreOrbit); + } + + let new_rms = iter_result.normalised_rms; + + // ── Check divergence ───────────────────────────────────────────── + if prev_rms < f64::MAX && new_rms / prev_rms >= config.rms_divergence_ratio { + return Err(OutfitError::DifferentialCorrectionDiverged); + } + + // ── Check stagnation ───────────────────────────────────────────── + let stagnated = + prev_rms < f64::MAX && new_rms / prev_rms >= config.rms_stagnation_ratio; + if stagnated { + stagnation_count += 1; + if stagnation_count >= config.max_stagnation_iterations { + // Accept the current state and stop the inner loop. + break; + } + } else { + stagnation_count = 0; + } + + // Advance state. + last_equations = iter_result.observation_equations.clone(); + last_uncertainty = iter_result.uncertainty.clone(); + last_normalised_rms = new_rms; + last_num_measurements = iter_result.num_measurements; + + elements = iter_result.corrected_elements; + obs_fit_data = iter_result.updated_obs_fit_data; + prev_rms = new_rms; + + // ── Convergence check ──────────────────────────────────────────── + if iter_result.correction_norm < config.convergence_threshold { + inner_loop_converged = true; + break; + } + } + + // ── Skip outlier rejection if disabled or fit is already clean ─────── + if !config.enable_outlier_rejection { + break; + } + + // On the first outer pass, skip rejection if the RMS is already low. + if outer_pass == 0 && last_normalised_rms < config.convergence_before_rejection_threshold { + break; + } + + // If the inner loop did not converge, do not attempt outlier rejection. + if !inner_loop_converged { + break; + } + + // ── Outlier rejection step ─────────────────────────────────────────── + let (updated_obs_fit_data, num_selection_changes) = update_observation_selection( + &obs_fit_data, + &last_equations, + &last_uncertainty, + &config.outlier_rejection_config, + ); + obs_fit_data = updated_obs_fit_data; + + // Selection is stable — we are done. + if num_selection_changes == 0 { + break; + } + } + + // ── Final covariance rescaling ──────────────────────────────────────────── + rescale_covariance( + &mut last_uncertainty, + num_free, + last_num_measurements, + last_normalised_rms, + ); + + Ok(DifferentialCorrectionOutput { + elements, + final_obs_fit_data: obs_fit_data, + uncertainty: last_uncertainty, + normalised_rms: last_normalised_rms, + total_newton_iterations, + num_measurements: last_num_measurements, + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod diff_cor_tests { + use super::*; + use crate::{ + differential_orbit_correction::obs_fit_data::{ObsFitData, ObsSelection}, + test_fixture::{JPL_EPHEM_HORIZON, UT1_PROVIDER}, + }; + use photom::{ + coordinates::equatorial::EquCoord, + observation_dataset::{observation::ObservationInput, ObsDataset}, + observer::{ + dataset::ObserverId, + error_model::{ModelCorrection, ObsErrorModel}, + }, + photometry::{Filter, Photometry}, + }; + + // ── Helpers ─────────────────────────────────────────────────────────────── + + fn circular_elements(epoch: f64) -> EquinoctialElements { + EquinoctialElements { + reference_epoch: epoch, + semi_major_axis: 1.8, + eccentricity_sin_lon: 0.1, + eccentricity_cos_lon: 0.05, + tan_half_incl_sin_node: 0.01, + tan_half_incl_cos_node: 0.1, + mean_longitude: 1.0, + } + } + + fn make_dataset_and_cache(t0: f64, time_span: f64, n: usize) -> (ObsDataset, OutfitCache) { + let step = if n > 1 { + time_span / (n - 1) as f64 + } else { + 0.0 + }; + let inputs: Vec = (0..n) + .map(|i| { + let t_obs = t0 + i as f64 * step; + ObservationInput::new( + i as u64, + EquCoord { + ra: 0.0, + ra_error: 0.0, + dec: 0.0, + dec_error: 0.0, + }, + Photometry { + magnitude: 15.0, + error: 0.1, + filter: Filter::Int(0), + }, + t_obs, + Some(ObserverId::MpcCode(*b"F51")), + ) + }) + .collect(); + + let obs_dataset = { + let mut ds = ObsDataset::empty(); + for input in inputs { + ds = ds.push_observation(vec![input]).unwrap().0; + } + ds.with_error_model(ObsErrorModel::FCCT14) + .apply_model_errors() + }; + + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER, false).unwrap(); + (obs_dataset, cache) + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + /// The pipeline must complete without error on well-conditioned input and + /// return finite orbital elements. + /// + /// Note: the synthetic observations have RA/Dec = 0 which are far from the + /// predicted position of the orbit, so large residuals are expected. The + /// correction step may push the elements outside the physical limits; the + /// test therefore accepts `BizarreOrbit` / `DifferentialCorrectionDiverged` + /// as valid outcomes and only checks that the function does not panic. + #[test] + fn test_run_completes_and_elements_are_finite() { + let t0 = 59000.0; + let elements = circular_elements(t0); + let (obs_dataset, cache) = make_dataset_and_cache(t0, 365.0, 8); + + let observations: Vec<_> = (0..8) + .map(|i| obs_dataset.get_observation(i as u64).unwrap().clone()) + .collect(); + + let obs_fit_data: Vec = observations + .iter() + .map(|obs| ObsFitData::new(obs.equ_coord().ra_error, obs.equ_coord().dec_error)) + .collect(); + + let config = DifferentialCorrectionConfig { + enable_outlier_rejection: false, + ..Default::default() + }; + + let result = run_differential_correction( + &observations, + &obs_fit_data, + &elements, + &cache, + &JPL_EPHEM_HORIZON, + &config, + ); + + // With RA/Dec=0 synthetic observations the Newton step produces a very + // large correction that pushes the elements outside the plausibility + // limits. Acceptable outcomes are success (unlikely but possible) or + // a typed error; the function must not panic. + match result { + Ok(output) => { + assert!(output.elements.semi_major_axis.is_finite()); + assert!(output.total_newton_iterations >= 1); + } + Err(OutfitError::BizarreOrbit) => {} // expected + Err(OutfitError::DifferentialCorrectionDiverged) => {} // acceptable + Err(OutfitError::DifferentialCorrectionFailed(_)) => {} // acceptable + Err(e) => panic!("unexpected error: {e:?}"), + } + } + + /// With all observations inactive, the elements must not change. + #[test] + fn test_all_inactive_elements_unchanged() { + let t0 = 59000.0; + let elements = circular_elements(t0); + let (obs_dataset, cache) = make_dataset_and_cache(t0, 365.0, 6); + + let observations: Vec<_> = (0..6) + .map(|i| obs_dataset.get_observation(i as u64).unwrap().clone()) + .collect(); + + let obs_fit_data: Vec = observations + .iter() + .map(|obs| { + let mut fd = ObsFitData::new(obs.equ_coord().ra_error, obs.equ_coord().dec_error); + fd.selection = ObsSelection::Rejected; + fd + }) + .collect(); + + let config = DifferentialCorrectionConfig { + enable_outlier_rejection: false, + ..Default::default() + }; + + // All inactive → inversion fails → DifferentialCorrectionFailed + let result = run_differential_correction( + &observations, + &obs_fit_data, + &elements, + &cache, + &JPL_EPHEM_HORIZON, + &config, + ); + + assert!(matches!( + result, + Err(OutfitError::DifferentialCorrectionFailed(_)) + )); + } + + /// `total_newton_iterations` must be ≥ 1. + #[test] + fn test_at_least_one_newton_iteration() { + let t0 = 59000.0; + let elements = circular_elements(t0); + let (obs_dataset, cache) = make_dataset_and_cache(t0, 365.0, 8); + + let observations: Vec<_> = (0..8) + .map(|i| obs_dataset.get_observation(i as u64).unwrap().clone()) + .collect(); + + let obs_fit_data: Vec = observations + .iter() + .map(|obs| ObsFitData::new(obs.equ_coord().ra_error, obs.equ_coord().dec_error)) + .collect(); + + let config = DifferentialCorrectionConfig { + enable_outlier_rejection: false, + ..Default::default() + }; + + // `total_newton_iterations` is set before the BizarreOrbit check. + // For synthetic zero-RA/Dec observations the first iteration already + // detects a bizarre orbit, so we accept that error but verify the + // counter was incremented. + match run_differential_correction( + &observations, + &obs_fit_data, + &elements, + &cache, + &JPL_EPHEM_HORIZON, + &config, + ) { + Ok(output) => assert!(output.total_newton_iterations >= 1), + Err(OutfitError::BizarreOrbit) => {} // one iteration ran before the check + Err(e) => panic!("unexpected error: {e:?}"), + } + } + + /// Limiting to 1 Newton iteration must still return a valid result (or a + /// typed error for degenerate synthetic input). + #[test] + fn test_single_newton_iteration() { + let t0 = 59000.0; + let elements = circular_elements(t0); + let (obs_dataset, cache) = make_dataset_and_cache(t0, 365.0, 8); + + let observations: Vec<_> = (0..8) + .map(|i| obs_dataset.get_observation(i as u64).unwrap().clone()) + .collect(); + + let obs_fit_data: Vec = observations + .iter() + .map(|obs| ObsFitData::new(obs.equ_coord().ra_error, obs.equ_coord().dec_error)) + .collect(); + + let config = DifferentialCorrectionConfig { + max_newton_iterations: 1, + enable_outlier_rejection: false, + ..Default::default() + }; + + match run_differential_correction( + &observations, + &obs_fit_data, + &elements, + &cache, + &JPL_EPHEM_HORIZON, + &config, + ) { + Ok(output) => { + assert_eq!(output.total_newton_iterations, 1); + assert!(output.elements.semi_major_axis.is_finite()); + } + Err(OutfitError::BizarreOrbit) => {} // expected for synthetic data + Err(e) => panic!("unexpected error: {e:?}"), + } + } + + /// `final_obs_fit_data` must have the same length as `observations`. + #[test] + fn test_final_obs_fit_data_length() { + let t0 = 59000.0; + let elements = circular_elements(t0); + let n = 8; + let (obs_dataset, cache) = make_dataset_and_cache(t0, 365.0, n); + + let observations: Vec<_> = (0..n) + .map(|i| obs_dataset.get_observation(i as u64).unwrap().clone()) + .collect(); + + let obs_fit_data: Vec = observations + .iter() + .map(|obs| ObsFitData::new(obs.equ_coord().ra_error, obs.equ_coord().dec_error)) + .collect(); + + let config = DifferentialCorrectionConfig { + // Limit to 1 iteration so we get a result before BizarreOrbit. + // The `final_obs_fit_data` length is set before the bizarre check. + max_newton_iterations: 1, + enable_outlier_rejection: false, + ..Default::default() + }; + + match run_differential_correction( + &observations, + &obs_fit_data, + &elements, + &cache, + &JPL_EPHEM_HORIZON, + &config, + ) { + Ok(output) => assert_eq!(output.final_obs_fit_data.len(), n), + // BizarreOrbit is raised before returning output, so we cannot + // check the length directly. Just verify the function ran. + Err(OutfitError::BizarreOrbit) => {} + Err(e) => panic!("unexpected error: {e:?}"), + } + } +} diff --git a/src/differential_orbit_correction/least_square.rs b/src/differential_orbit_correction/least_square.rs new file mode 100644 index 0000000..d32914f --- /dev/null +++ b/src/differential_orbit_correction/least_square.rs @@ -0,0 +1,724 @@ +//! Assembly and resolution of the weighted least-squares normal equations for +//! differential orbit correction. +//! +//! This module implements step 5 of the two-body differential-correction +//! pipeline. Given a set of astrometric observations with their partial +//! derivatives, it builds the normal-equation system, inverts it, and returns +//! the orbital-element correction together with uncertainty matrices and the +//! normalised RMS residual. +//! +//! ## Pipeline +//! +//! ```text +//! [ObsAndElementPartials] → ObservationEquation → solve_weighted_least_squares() +//! (∂α/∂elem, ∂δ/∂elem) + residuals ↓ +//! per observation + weights element_correction = Γ · GᵀWξ +//! OrbitalUncertainty { Γ, GᵀWG } +//! normalised_rms = √(ξᵀWξ / num_measurements) +//! ``` +//! +//! ## Normal equations +//! +//! The design matrix **G** has shape `(2m × 6)`: for observation `i`, row `2i` +//! carries `∂α/∂elem` and row `2i+1` carries `∂δ/∂elem`. +//! +//! The per-observation weight sub-matrix is +//! +//! ```text +//! W_i = [ weight_ra weight_cross ] +//! [ weight_cross weight_dec ] +//! ``` +//! +//! The normal matrix and right-hand side accumulate as +//! +//! ```text +//! GᵀWG[j,k] = Σ_i g_α[i,j]·weight_ra ·g_α[i,k] + g_δ[i,j]·weight_dec·g_δ[i,k] +//! + weight_cross·(g_δ[i,j]·g_α[i,k] + g_α[i,j]·g_δ[i,k]) +//! +//! GᵀWξ[j] = Σ_i (g_α[i,j]·weight_ra + g_δ[i,j]·weight_cross)·ξ_α +//! + (g_α[i,j]·weight_cross + g_δ[i,j]·weight_dec )·ξ_δ +//! ``` +//! +//! The element correction is then \\( \delta x = \Gamma \cdot G^\top W \xi \\) +//! where \\( \Gamma = (G^\top W G)^{-1} \\), and the normalised RMS is +//! +//! \\[ +//! \text{normalised\_rms} = \sqrt{\frac{\xi^\top W \xi}{n_{\text{measurements}}}} +//! \\] + +use nalgebra::{Cholesky, Matrix6, Vector6, QR}; + +use crate::outfit_errors::OutfitError; + +// ───────────────────────────────────────────────────────────────────────────── +// Public types +// ───────────────────────────────────────────────────────────────────────────── + +/// Covariance and normal matrices produced by the differential-correction step. +#[derive(Debug, Clone)] +pub struct OrbitalUncertainty { + /// Normal matrix \\( G^\top W G \\) (6×6). + pub normal_matrix: Matrix6, + /// Covariance matrix \\( \Gamma = (G^\top W G)^{-1} \\) (6×6). + pub covariance: Matrix6, + /// `true` if matrix inversion succeeded, `false` otherwise. + pub inversion_succeeded: bool, +} + +/// Per-observation input for assembling the normal equations. +/// +/// Groups the partial derivatives (from +/// `ObsAndElementPartials`), the astrometric +/// residuals, and the statistical weights for a single optical observation. +#[derive(Debug, Clone)] +pub struct ObservationEquation { + /// \\( \partial\alpha / \partial\text{elem} \\) — vector of 6 partial + /// derivatives of the right ascension with respect to the equinoctial + /// elements `(a, h, k, p, q, λ)`. + pub d_ra_d_elem: Vector6, + /// \\( \partial\delta / \partial\text{elem} \\) — vector of 6 partial + /// derivatives of the declination. + pub d_dec_d_elem: Vector6, + /// Right-ascension residual \\( \xi_\alpha = \alpha_{\text{obs}} - \alpha_{\text{calc}} \\), + /// wrapped to \\( (-\pi, \pi] \\). + /// + /// Use [`angular_diff`] to compute this residual. + pub residual_ra: f64, + /// Declination residual \\( \xi_\delta = \delta_{\text{obs}} - \delta_{\text{calc}} \\). + pub residual_dec: f64, + /// Right-ascension weight \\( w_\alpha = 1/\sigma_\alpha^2 \\). + pub weight_ra: f64, + /// Declination weight \\( w_\delta = 1/\sigma_\delta^2 \\). + pub weight_dec: f64, + /// Cross-correlation weight term \\( w_{\alpha\delta} \\). + /// + /// Set to `0.0` for uncorrelated optical observations (the common case). + pub weight_cross: f64, + /// Whether this observation contributes to the fit. + /// + /// When `false` the entry is skipped; its contribution to the normal + /// matrix, right-hand side, and normalised RMS is exactly zero. + pub active: bool, +} + +impl ObservationEquation { + /// Creates an uncorrelated entry (`weight_cross = 0`), the standard case + /// for optical astrometry with independent RA/Dec uncertainties. + /// + /// # Arguments + /// + /// - `d_ra_d_elem` — partial derivatives \\( \partial\alpha/\partial\text{elem} \\) + /// from `ObsAndElementPartials`. + /// - `d_dec_d_elem` — partial derivatives \\( \partial\delta/\partial\text{elem} \\). + /// - `residual_ra` — angular difference \\( \alpha_{\text{obs}} - \alpha_{\text{calc}} \\); + /// compute with [`angular_diff`]. + /// - `residual_dec` — difference \\( \delta_{\text{obs}} - \delta_{\text{calc}} \\). + /// - `sigma_ra` — right-ascension uncertainty in radians. + /// - `sigma_dec` — declination uncertainty in radians. + /// - `active` — pass `false` to exclude this observation from the fit. + #[allow(clippy::too_many_arguments)] + pub fn uncorrelated( + d_ra_d_elem: Vector6, + d_dec_d_elem: Vector6, + residual_ra: f64, + residual_dec: f64, + sigma_ra: f64, + sigma_dec: f64, + active: bool, + ) -> Self { + Self { + d_ra_d_elem, + d_dec_d_elem, + residual_ra, + residual_dec, + weight_ra: 1.0 / sigma_ra.powi(2), + weight_dec: 1.0 / sigma_dec.powi(2), + weight_cross: 0.0, + active, + } + } +} + +/// Output of [`solve_weighted_least_squares`]: the orbital-element correction, +/// uncertainty matrices, normalised RMS, and observation count. +#[derive(Debug, Clone)] +pub struct DifferentialCorrectionResult { + /// Orbital-element correction \\( \delta x = \Gamma \cdot G^\top W \xi \\). + /// + /// Components for which `free_elements[j] == false` are set to `0`. + pub element_correction: Vector6, + /// Covariance and normal matrices from the inversion step. + pub uncertainty: OrbitalUncertainty, + /// Dimensionless normalised RMS + /// \\( \sqrt{\xi^\top W \xi \,/\, \text{num\_measurements}} \\). + pub normalised_rms: f64, + /// Number of scalar measurements used (2 per active optical observation). + pub num_measurements: usize, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Utilities +// ───────────────────────────────────────────────────────────────────────────── + +/// Computes the angular difference `a − b` wrapped to \\( (-\pi, \pi] \\). +/// +/// # Arguments +/// +/// - `a` — minuend angle, in radians (any value). +/// - `b` — subtrahend angle, in radians (any value). +/// +/// # Returns +/// +/// The difference `a − b` reduced to the half-open interval \\( (-\pi, \pi] \\). +/// +/// # Examples +/// +/// ``` +/// use outfit::differential_orbit_correction::least_square::angular_diff; +/// use std::f64::consts::PI; +/// +/// // Ordinary difference +/// let d = angular_diff(0.1, 0.3); +/// assert!((d - (-0.2)).abs() < 1e-15); +/// +/// // Wrapping near 2π +/// let d = angular_diff(0.1, 2.0 * PI - 0.1); +/// assert!((d - 0.2).abs() < 1e-14); +/// ``` +pub fn angular_diff(a: f64, b: f64) -> f64 { + use std::f64::consts::{PI, TAU}; + let mut d = a - b; + // Reduce to (−π, π] with at most a few iterations (typical for nearby orbits) + while d > PI { + d -= TAU; + } + while d < -PI { + d += TAU; + } + d +} + +// ───────────────────────────────────────────────────────────────────────────── +// Normal-equation assembly and resolution +// ───────────────────────────────────────────────────────────────────────────── + +/// Assembles the normal matrix \\( G^\top W G \\), solves the linear system, +/// and returns the differential correction \\( \delta x \\). +/// +/// Active entries in `equations` contribute to the normal matrix, the +/// right-hand side, and the weighted sum of squared residuals. Fixed elements +/// (those with `free_elements[j] == false`) have their row and column zeroed +/// in the normal matrix and are forced to `element_correction[j] = 0`. +/// +/// # Arguments +/// +/// - `equations` — one [`ObservationEquation`] per observation (order is irrelevant). +/// - `free_elements` — six-element boolean mask: `free_elements[j] = true` +/// means element `j` is free (solved for), `false` means it is held fixed. +/// +/// # Returns +/// +/// A [`DifferentialCorrectionResult`] containing the correction vector, +/// the [`OrbitalUncertainty`] matrices, the normalised RMS, and the +/// measurement count. +/// +pub fn solve_weighted_least_squares( + equations: &[ObservationEquation], + free_elements: &[bool; 6], +) -> Result { + // ─── 1. Count scalar measurements ─────────────────────────────────────── + let num_measurements: usize = equations.iter().filter(|e| e.active).count() * 2; + + // ─── 2. Accumulate normal matrix (GᵀWG) and right-hand side (GᵀWξ) ───── + let mut normal_mat = Matrix6::::zeros(); + let mut right_hand_side = Vector6::::zeros(); + let mut weighted_sq_sum = 0.0_f64; + + for eq in equations.iter().filter(|e| e.active) { + let partials_ra = &eq.d_ra_d_elem; + let partials_dec = &eq.d_dec_d_elem; + let weight_ra = eq.weight_ra; + let weight_dec = eq.weight_dec; + let weight_cross = eq.weight_cross; + let residual_ra = eq.residual_ra; + let residual_dec = eq.residual_dec; + + // normal_mat[j,k] += partials_ra[j]·weight_ra ·partials_ra[k] + // + partials_dec[j]·weight_dec·partials_dec[k] + // + weight_cross·(partials_dec[j]·partials_ra[k] + // + partials_ra[j]·partials_dec[k]) + for j in 0..6 { + for k in 0..6 { + normal_mat[(j, k)] += partials_ra[j] * weight_ra * partials_ra[k] + + partials_dec[j] * weight_dec * partials_dec[k] + + weight_cross + * (partials_dec[j] * partials_ra[k] + partials_ra[j] * partials_dec[k]); + } + + // right_hand_side[j] += (partials_ra[j]·weight_ra + partials_dec[j]·weight_cross)·ξ_α + // + (partials_ra[j]·weight_cross + partials_dec[j]·weight_dec )·ξ_δ + right_hand_side[j] += (partials_ra[j] * weight_ra + partials_dec[j] * weight_cross) + * residual_ra + + (partials_ra[j] * weight_cross + partials_dec[j] * weight_dec) * residual_dec; + } + + // Q += weight_ra·ξ_α² + weight_dec·ξ_δ² + 2·weight_cross·ξ_α·ξ_δ + weighted_sq_sum += weight_ra * residual_ra * residual_ra + + weight_dec * residual_dec * residual_dec + + 2.0 * weight_cross * residual_ra * residual_dec; + } + + // ─── 3. Apply the free_elements mask ──────────────────────────────────── + // + // Fixed elements (free_elements[j] == false) have their row and column + // zeroed in the normal matrix, with the diagonal entry set to 1 to keep + // the matrix invertible. Their right-hand-side entry is also zeroed so + // they contribute nothing to the correction. + for j in 0..6 { + if !free_elements[j] { + for k in 0..6 { + normal_mat[(j, k)] = 0.0; + normal_mat[(k, j)] = 0.0; + } + normal_mat[(j, j)] = 1.0; // prevents singularity + right_hand_side[j] = 0.0; + } + } + + // ─── 4. Inversion: Cholesky first, QR as fallback ─────────────────────── + let (covariance_mat, inversion_succeeded) = invert_normal_matrix(normal_mat); + + // ─── 5. Correction δx = Γ · GᵀWξ ──────────────────────────────────────── + // Fixed components (free_elements == false) are forced to zero. + let mut element_correction = if inversion_succeeded { + covariance_mat * right_hand_side + } else { + Vector6::zeros() + }; + + for j in 0..6 { + if !free_elements[j] { + element_correction[j] = 0.0; + } + } + + // ─── 6. Normalised RMS ─────────────────────────────────────────────────── + let normalised_rms = if num_measurements > 0 { + (weighted_sq_sum / num_measurements as f64).sqrt() + } else { + 0.0 + }; + + Ok(DifferentialCorrectionResult { + element_correction, + uncertainty: OrbitalUncertainty { + normal_matrix: normal_mat, + covariance: covariance_mat, + inversion_succeeded, + }, + normalised_rms, + num_measurements, + }) +} + +/// Attempts to invert the 6×6 normal matrix using Cholesky decomposition, +/// falling back to QR decomposition if the matrix is not positive-definite. +/// +/// Returns `(covariance, inversion_succeeded)` where `covariance` is the +/// inverse (or a zero matrix on failure). +fn invert_normal_matrix(m: Matrix6) -> (Matrix6, bool) { + // Attempt 1: Cholesky — preferred as it exploits symmetry + if let Some(chol) = Cholesky::new(m) { + return (chol.inverse(), true); + } + + // Attempt 2: QR — more robust, handles ill-conditioned cases + let qr = QR::new(m); + match qr.try_inverse() { + Some(inv) => (inv, true), + None => (Matrix6::zeros(), false), + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Covariance rescaling +// ───────────────────────────────────────────────────────────────────────────── + +/// Applies a posterior rescaling factor \\( \mu \\) to the covariance and +/// normal matrices in `uncertainty`. +/// +/// The rescaling factor is computed as follows: +/// +/// - If \\( n_{\text{free}} \ge n_{\text{measurements}} \\): \\( \mu = 1 \\) +/// (under-determined system — no rescaling applied). +/// - If \\( \text{normalised\_rms} > 1 \\) (poor fit): +/// \\( \mu = \text{normalised\_rms} \cdot \sqrt{n_{\text{measurements}} / (n_{\text{measurements}} - n_{\text{free}})} \\). +/// - Otherwise (acceptable fit): +/// \\( \mu = \sqrt{n_{\text{measurements}} / (n_{\text{measurements}} - n_{\text{free}})} \\). +/// +/// After rescaling, `covariance` is multiplied by \\( \mu^2 \\) and +/// `normal_matrix` is divided by \\( \mu^2 \\). +/// +/// # Arguments +/// +/// - `uncertainty` — the uncertainty matrices to rescale, modified in place. +/// - `num_free_params` — number of free parameters (number of `true` entries +/// in `free_elements`, typically 6). +/// - `num_measurements` — number of scalar measurements used (2 × number of +/// active optical observations). +/// - `normalised_rms` — dimensionless normalised RMS from +/// [`solve_weighted_least_squares`]. +pub fn rescale_covariance( + uncertainty: &mut OrbitalUncertainty, + num_free_params: usize, + num_measurements: usize, + normalised_rms: f64, +) { + let mu = if num_free_params < num_measurements { + let factor = + ((num_measurements as f64) / (num_measurements - num_free_params) as f64).sqrt(); + if normalised_rms > 1.0 { + normalised_rms * factor + } else { + factor + } + } else { + 1.0 + }; + let mu2 = mu * mu; + uncertainty.covariance *= mu2; + uncertainty.normal_matrix /= mu2; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod least_square_tests { + use super::*; + use approx::assert_abs_diff_eq; + + const ALL_FREE: [bool; 6] = [true; 6]; + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /// Generates `n` observations using an identity-block partial derivative + /// pattern. + /// + /// The system requires at least 3 observations (`num_measurements = 6`) to + /// be determined. Observation `k % 6` sets `partials_ra[k] = 1` and + /// `partials_dec[(k+1) % 6] = 1`, which produces a diagonal normal matrix. + fn make_identity_equations(n: usize, sigma: f64) -> Vec { + (0..n) + .map(|i| { + let k = i % 6; + let mut partials_ra = Vector6::zeros(); + let mut partials_dec = Vector6::zeros(); + partials_ra[k] = 1.0; + partials_dec[(k + 1) % 6] = 1.0; + ObservationEquation::uncorrelated( + partials_ra, + partials_dec, + 0.0, + 0.0, + sigma, + sigma, + true, + ) + }) + .collect() + } + + // ── Unit tests ───────────────────────────────────────────────────────────── + + /// Zero residuals must yield a zero correction and `normalised_rms = 0`. + #[test] + fn test_zero_residuals_give_zero_correction() { + let equations = make_identity_equations(6, 1e-5); + let result = solve_weighted_least_squares(&equations, &ALL_FREE).unwrap(); + assert_abs_diff_eq!(result.normalised_rms, 0.0, epsilon = 1e-15); + for j in 0..6 { + assert_abs_diff_eq!(result.element_correction[j], 0.0, epsilon = 1e-15); + } + assert!(result.uncertainty.inversion_succeeded); + assert_eq!(result.num_measurements, 12); + } + + /// Verify that \\( \Gamma \cdot G^\top W G \approx I \\). + #[test] + fn test_covariance_times_normal_is_identity() { + let equations = make_identity_equations(6, 1e-5); + let result = solve_weighted_least_squares(&equations, &ALL_FREE).unwrap(); + let product = result.uncertainty.covariance * result.uncertainty.normal_matrix; + let diff = product - Matrix6::identity(); + assert!( + diff.norm() < 1e-10, + "Γ·GᵀWG must be close to I, error={}", + diff.norm() + ); + } + + /// Inactive observations (`active = false`) must not affect the result. + #[test] + fn test_rejected_observations_have_no_contribution() { + let sigma = 1e-5; + // One inactive entry with a non-zero residual + let rejected = ObservationEquation::uncorrelated( + Vector6::new(1.0, 1.0, 1.0, 1.0, 1.0, 1.0), + Vector6::new(1.0, 1.0, 1.0, 1.0, 1.0, 1.0), + 1e-4, + 1e-4, + sigma, + sigma, + false, // inactive + ); + // Fill with enough active zero-residual observations + let mut equations = make_identity_equations(6, sigma); + equations.push(rejected); + let result = solve_weighted_least_squares(&equations, &ALL_FREE).unwrap(); + // The zero residuals on the active observations dominate → correction ≈ 0 + assert_abs_diff_eq!(result.normalised_rms, 0.0, epsilon = 1e-15); + assert_eq!(result.num_measurements, 12); // 6 active obs × 2 + } + + /// Setting `free_elements[0] = false` must force `element_correction[0] = 0`. + #[test] + fn test_fixed_element_has_zero_correction() { + let sigma = 1e-5; + // Non-zero residuals to produce a non-trivial correction + let equations: Vec = (0..6) + .map(|i| { + let k = i % 6; + let mut partials_ra = Vector6::zeros(); + let mut partials_dec = Vector6::zeros(); + partials_ra[k] = 1.0; + partials_dec[(k + 1) % 6] = 1.0; + ObservationEquation::uncorrelated( + partials_ra, + partials_dec, + 1e-4, + 1e-4, + sigma, + sigma, + true, + ) + }) + .collect(); + + let mut free_elements = [true; 6]; + free_elements[0] = false; // fix the first element + + let result = solve_weighted_least_squares(&equations, &free_elements).unwrap(); + assert_abs_diff_eq!(result.element_correction[0], 0.0, epsilon = 1e-15); + // The other free elements may have non-zero corrections + } + + /// The correction must equal the residual magnitude for a well-conditioned + /// diagonal system. + /// + /// For 6 observations with `partials_ra = e_j` and + /// `partials_dec = e_{(j+1) mod 6}`, uniform residuals `ξ_α = ξ_δ = r`, + /// and equal weights `weight_ra = weight_dec = 1/σ²`, the normal matrix is + /// diagonal with `2/σ²` on the diagonal, so + /// `element_correction[j] = r` for all `j`. + #[test] + fn test_correction_magnitude_matches_residual() { + let sigma = 1e-5; + let r = 1e-5_f64; + let equations: Vec = (0..6) + .map(|i| { + let k = i % 6; + let mut partials_ra = Vector6::zeros(); + let mut partials_dec = Vector6::zeros(); + partials_ra[k] = 1.0; + partials_dec[(k + 1) % 6] = 1.0; + ObservationEquation::uncorrelated( + partials_ra, + partials_dec, + r, + r, + sigma, + sigma, + true, + ) + }) + .collect(); + + let result = solve_weighted_least_squares(&equations, &ALL_FREE).unwrap(); + + // GᵀWξ[j] = r/σ² × (count of j in partials_ra or partials_dec) = 2·r/σ² + // GᵀWG[j,j] = 2/σ² → element_correction[j] = (2r/σ²) / (2/σ²) = r + for j in 0..6 { + assert_abs_diff_eq!(result.element_correction[j], r, epsilon = 1e-12); + } + } + + /// `num_measurements` must count only active observations (2 scalars each). + #[test] + fn test_num_measurements_counts_active_only() { + let sigma = 1e-5; + let mut equations = make_identity_equations(6, sigma); + // Add 3 inactive observations + for _ in 0..3 { + let mut e = equations[0].clone(); + e.active = false; + equations.push(e); + } + let result = solve_weighted_least_squares(&equations, &ALL_FREE).unwrap(); + assert_eq!(result.num_measurements, 12); // 6 active × 2 + } + + // ── Tests for angular_diff ──────────────────────────────────────────────── + + #[test] + fn test_angular_diff_basic() { + use std::f64::consts::{PI, TAU}; + // Simple case + assert_abs_diff_eq!(angular_diff(0.5, 0.3), 0.2, epsilon = 1e-15); + // Positive wrap: 0.1 − (2π − 0.1) = 0.2 − 2π → +0.2 after correction + assert_abs_diff_eq!(angular_diff(0.1, TAU - 0.1), 0.2, epsilon = 1e-14); + // Negative wrap + assert_abs_diff_eq!(angular_diff(TAU - 0.1, 0.1), -0.2, epsilon = 1e-14); + // Exactly π must remain in (−π, π] + let d = angular_diff(PI, 0.0); + assert!(d > -PI && d <= PI); + } + + // ── Tests for rescale_covariance ────────────────────────────────────────── + + #[test] + fn test_rescale_identity_when_underdetermined() { + let mut uncertainty = OrbitalUncertainty { + normal_matrix: Matrix6::identity(), + covariance: Matrix6::identity(), + inversion_succeeded: true, + }; + // num_free_params == num_measurements → mu = 1, no change + rescale_covariance(&mut uncertainty, 6, 6, 1.5); + assert_abs_diff_eq!( + (uncertainty.covariance - Matrix6::identity()).norm(), + 0.0, + epsilon = 1e-15 + ); + } + + #[test] + fn test_rescale_good_fit() { + // normalised_rms = 0.5 ≤ 1 → mu = sqrt(12/6) = sqrt(2) + let mut uncertainty = OrbitalUncertainty { + normal_matrix: Matrix6::identity(), + covariance: Matrix6::identity(), + inversion_succeeded: true, + }; + rescale_covariance(&mut uncertainty, 6, 12, 0.5); + let mu2 = 2.0_f64; // sqrt(12/6)² = 2 + assert_abs_diff_eq!( + (uncertainty.covariance - Matrix6::identity() * mu2).norm(), + 0.0, + epsilon = 1e-14 + ); + assert_abs_diff_eq!( + (uncertainty.normal_matrix - Matrix6::identity() / mu2).norm(), + 0.0, + epsilon = 1e-14 + ); + } + + #[test] + fn test_rescale_bad_fit() { + // normalised_rms = 2.0 > 1 → mu = 2.0 * sqrt(12/6) = 2.0 * sqrt(2) + let mut uncertainty = OrbitalUncertainty { + normal_matrix: Matrix6::identity(), + covariance: Matrix6::identity(), + inversion_succeeded: true, + }; + rescale_covariance(&mut uncertainty, 6, 12, 2.0); + let mu = 2.0 * 2.0_f64.sqrt(); + let mu2 = mu * mu; + assert_abs_diff_eq!( + (uncertainty.covariance - Matrix6::identity() * mu2).norm(), + 0.0, + epsilon = 1e-14 + ); + } + + /// Oracle test: + /// + /// The dataset is 4 synthetic optical observations with diagonal weights (correl = 0). + /// + /// Key oracle values (tolerance 1e-10): + /// csinor = 2.0758197845135249 (= weighted_sq_sum in Rust) + /// dx0 = [1.6756756756756757e-5, 2.3513513513513514e-5, + /// -2.2972972972972989e-5, 3.5405405405405403e-5, + /// 3.2702702702702714e-5,-4.5945945945945951e-5] + #[test] + fn test_oracle_min_sol() { + use nalgebra::Vector6; + + // ── observations ──────────────────────────────────────────────────── + // Matching the Fortran driver exactly: g rows are standard basis + // vectors (or mixed for obs 4), weights = 1/sigma^2. + + let obs1 = ObservationEquation::uncorrelated( + Vector6::new(1.0, 0.0, 0.0, 0.0, 0.0, 0.0), // g_ra = e_1 + Vector6::new(0.0, 1.0, 0.0, 0.0, 0.0, 0.0), // g_dec = e_2 + 2.0e-5, // residual_ra + 3.0e-5, // residual_dec + 1.0e-5, // sigma_ra + 1.0e-5, // sigma_dec + true, + ); + let obs2 = ObservationEquation::uncorrelated( + Vector6::new(0.0, 0.0, 1.0, 0.0, 0.0, 0.0), // g_ra = e_3 + Vector6::new(0.0, 0.0, 0.0, 1.0, 0.0, 0.0), // g_dec = e_4 + -1.0e-5, // residual_ra + 5.0e-5, // residual_dec + 2.0e-5, // sigma_ra + 1.5e-5, // sigma_dec + true, + ); + let obs3 = ObservationEquation::uncorrelated( + Vector6::new(0.0, 0.0, 0.0, 0.0, 1.0, 0.0), // g_ra = e_5 + Vector6::new(0.0, 0.0, 0.0, 0.0, 0.0, 1.0), // g_dec = e_6 + 4.0e-5, // residual_ra + -2.0e-5, // residual_dec + 1.5e-5, // sigma_ra + 2.0e-5, // sigma_dec + true, + ); + let obs4 = ObservationEquation::uncorrelated( + Vector6::new(0.5, 0.5, 0.5, 0.5, 0.5, 0.5), // g_ra (mixed) + Vector6::new(0.5, -0.5, 0.5, -0.5, 0.5, -0.5), // g_dec (mixed) + 1.0e-5, // residual_ra + 1.0e-5, // residual_dec + 1.0e-5, // sigma_ra + 1.0e-5, // sigma_dec + true, + ); + + let equations = [obs1, obs2, obs3, obs4]; + let free = [true; 6]; + + let result = solve_weighted_least_squares(&equations, &free) + .expect("solve_weighted_least_squares failed"); + + // ── Oracle constants ─────────────────────────── + let oracle_dx = [ + 1.6756756756756757e-5_f64, + 2.3513513513513514e-5, + -2.297_297_297_297_299e-5, + 3.5405405405405403e-5, + 3.2702702702702714e-5, + -4.594_594_594_594_595e-5, + ]; + // csinor = sqrt(Q/nused) = Rust normalised_rms + let oracle_csinor: f64 = 2.075_819_784_513_525; + + let eps = 1e-10; + for (i, &expected) in oracle_dx.iter().enumerate() { + assert_abs_diff_eq!(result.element_correction[i], expected, epsilon = eps); + } + assert_abs_diff_eq!(result.normalised_rms, oracle_csinor, epsilon = eps); + } +} diff --git a/src/differential_orbit_correction/mod.rs b/src/differential_orbit_correction/mod.rs new file mode 100644 index 0000000..c4f8615 --- /dev/null +++ b/src/differential_orbit_correction/mod.rs @@ -0,0 +1,52 @@ +use nalgebra::Matrix2; +use photom::observation_dataset::observation::Observation; + +pub mod diff_cor; +pub mod least_square; +pub mod obs_dataset_api; +pub mod obs_fit_data; +pub mod outlier_rejection; +pub mod single_iteration; + +pub use diff_cor::{ + run_differential_correction, DifferentialCorrectionConfig, DifferentialCorrectionOutput, +}; +pub use least_square::{ + angular_diff, rescale_covariance, solve_weighted_least_squares, DifferentialCorrectionResult, + ObservationEquation, OrbitalUncertainty, +}; +pub use obs_dataset_api::FitLSQ; +pub use obs_fit_data::{ObsFitData, ObsSelection}; +pub use outlier_rejection::{update_observation_selection, OutlierRejectionConfig}; +pub use single_iteration::{single_iteration, SingleIterationResult}; + +/// Returns the 2×2 diagonal weight matrix for a single astrometric observation. +/// +/// The weight matrix encodes the per-axis measurement precision: +/// +/// ```text +/// W = diag(1/σ_α², 1/σ_δ²) +/// ``` +/// +/// When `rejected` is `true` a zero matrix is returned, which effectively +/// removes the observation from all subsequent linear-algebra steps (normal +/// matrix accumulation, residual summation, etc.). +/// +/// # Arguments +/// +/// - `obs` — the observation whose RA/Dec errors define the weights. +/// - `rejected` — pass `true` to exclude the observation from the fit. +/// +/// # Returns +/// +/// A symmetric 2×2 matrix. The off-diagonal entries are always `0` (no +/// cross-correlation between RA and Dec is assumed). +pub fn observation_weight(obs: &Observation, rejected: bool) -> Matrix2 { + if rejected { + return Matrix2::zeros(); + } + + let sa2 = obs.equ_coord().ra_error.powf(2.0); + let sd2 = obs.equ_coord().dec_error.powf(2.0); + Matrix2::new(1.0 / sa2, 0.0, 0.0, 1.0 / sd2) +} diff --git a/src/differential_orbit_correction/obs_dataset_api.rs b/src/differential_orbit_correction/obs_dataset_api.rs new file mode 100644 index 0000000..b24c199 --- /dev/null +++ b/src/differential_orbit_correction/obs_dataset_api.rs @@ -0,0 +1,259 @@ +//! Least-squares orbit fitting entry points for [`ObsDataset`]. +//! +//! This module exposes the [`FitLSQ`] trait, which adds differential orbit +//! correction methods directly to +//! [`photom::observation_dataset::ObsDataset`]. +//! +//! The main entry point is [`FitLSQ::fit_lsq`], which runs the full +//! differential-correction pipeline for every trajectory in the dataset. +//! When no initial orbit is provided for a trajectory, a preliminary orbit is +//! first derived via the Gauss IOD method and then fed into the +//! differential-correction loop. +//! +//! # Type aliases +//! +//! - [`crate::constants::Chi2`] — scalar quality metric (normalised RMS after +//! the final iteration). +//! - [`crate::FullOrbitResult`] — batch result map keyed by trajectory ID. + +use std::collections::HashMap; + +use crate::{ + cache::OutfitCache, + constants::FitOrbitResult, + differential_orbit_correction::{ + diff_cor::{run_differential_correction, DifferentialCorrectionConfig}, + obs_fit_data::ObsFitData, + }, + initial_orbit_determination::{obs_dataset_api::run_iod_on_observations, IODParams}, + FullOrbitResult, JPLEphem, OutfitError, +}; +use hifitime::ut1::Ut1Provider; +use photom::{ + observation_dataset::{observation::Observation, ObsDataset}, + observer::error_model::{ModelCorrection, ObsErrorModel}, + TrajId, +}; +use rand::{rngs::SmallRng, SeedableRng}; + +// ───────────────────────────────────────────────────────────────────────────── +// Trait definition +// ───────────────────────────────────────────────────────────────────────────── + +/// Extension trait that adds differential orbit correction to [`ObsDataset`]. +/// +/// Import this trait to call [`fit_lsq`](FitLSQ::fit_lsq) on any +/// [`ObsDataset`] value. +pub trait FitLSQ { + /// Run the differential-correction pipeline for **every** trajectory in + /// the dataset. + /// + /// For each trajectory: + /// + /// 1. If `initial_orbits` contains an entry for that trajectory, it is + /// converted to equinoctial elements and used as the starting point. + /// 2. Otherwise a preliminary orbit is first derived via the Gauss IOD + /// method (using `iod_params` and `iod_error_model`) and the result is + /// used as the starting point. + /// 3. The differential-correction loop is run using `diff_cor_config`. + /// + /// # Arguments + /// + /// - `jpl` — JPL planetary ephemeris. + /// - `ut1_provider` — UT1 time-scale data for Earth orientation. + /// - `error_model` — astrometric error model applied to every observation + /// before the fit. + /// - `iod_params` — IOD tuning parameters used when no initial orbit is + /// provided for a trajectory. + /// - `diff_cor_config` — differential-correction tuning parameters. + /// - `initial_orbits` — optional map from trajectory ID to a known initial + /// orbit. Trajectories absent from this map (or when the map is `None`) + /// will be initialised via IOD. + /// - `rng` — source of randomness for IOD noise sampling. + /// + /// # Quality metric — normalised RMS + /// + /// Each successful entry in the returned map carries a scalar quality + /// metric (`Chi2`, accessible as the second element of + /// [`FitOrbitResult::DifferentialCorrection`]). This is the **normalised + /// RMS** of the final fit, defined as: + /// + /// \\[ \text{normalised\_rms} = \sqrt{\frac{\xi^\top W \xi}{n_{\text{active}}}} \\] + /// + /// where **ξ** is the vector of residuals (observed minus computed + /// positions in RA and Dec), **W** the diagonal weight matrix (inverse + /// of the per-observation variances), and *n*_active the total number of + /// active scalar measurements (2 per optical observation). + /// + /// This is the **square root of the reduced chi²** (chi² per degree of + /// freedom, with the number of degrees of freedom approximated by + /// *n*_active): + /// + /// - **≈ 1.0** — residuals are consistent with the reported observation + /// uncertainties; the fit is statistically good. + /// - **> 1.0** — residuals exceed the expected noise; the orbit does not + /// fit well, or the uncertainties are under-estimated. + /// - **< 1.0** — residuals are smaller than the noise; the uncertainties + /// may be over-estimated, or the fit is over-constrained. + /// + /// Unlike the raw chi² (which grows with the number of observations), the + /// normalised RMS is directly comparable across trajectories with + /// different numbers of observations. + /// + /// # Errors + /// + /// Returns [`OutfitError`] if the shared observer cache cannot be built. + /// Individual trajectory failures are stored as `Err(…)` entries inside + /// the returned [`FullOrbitResult`]. + #[allow(clippy::too_many_arguments)] + fn fit_lsq( + self, + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + error_model: ObsErrorModel, + iod_params: &IODParams, + diff_cor_config: &DifferentialCorrectionConfig, + initial_orbits: Option<&FullOrbitResult>, + rng: &mut impl rand::Rng, + ) -> Result; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Implementation +// ───────────────────────────────────────────────────────────────────────────── + +impl FitLSQ for ObsDataset { + fn fit_lsq( + self, + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + error_model: ObsErrorModel, + iod_params: &IODParams, + diff_cor_config: &DifferentialCorrectionConfig, + initial_orbits: Option<&FullOrbitResult>, + rng: &mut impl rand::Rng, + ) -> Result { + // ── 1. Apply the error model and build the shared position cache ────── + let corrected_dataset = self + .with_error_model(error_model) + .apply_model_errors() + .apply_batch_rms_correction(iod_params.gap_max); + + let cache = OutfitCache::build(&corrected_dataset, jpl, ut1_provider, true)?; + + // Draw a base seed for deterministic per-trajectory RNGs. + let base_seed: u64 = rng.random(); + + // ── 2. Iterate over trajectories ────────────────────────────────────── + let mut result_map = HashMap::with_hasher(ahash::RandomState::new()); + + let traj_ids: Vec = corrected_dataset + .iter_traj_id() + .ok_or(OutfitError::NoTrajectoryIndex)? + .cloned() + .collect(); + + for traj_id in &traj_ids { + let traj_seed = base_seed ^ traj_id.stable_hash(); + let mut local_rng = SmallRng::seed_from_u64(traj_seed); + + let outcome = run_differential_correction_for_trajectory( + traj_id, + &corrected_dataset, + &cache, + jpl, + ut1_provider, + iod_params, + diff_cor_config, + initial_orbits, + &mut local_rng, + ); + + result_map.insert(traj_id.clone(), outcome); + } + + Ok(result_map) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Internal helpers +// ───────────────────────────────────────────────────────────────────────────── + +/// Runs the full pipeline (IOD if needed + differential correction) for a +/// single trajectory. +/// +/// Returns `Ok(FitOrbitResult::DifferentialCorrection(…))` on success or an +/// [`OutfitError`] on any failure (IOD failure, bizarre orbit, divergence, …). +#[allow(clippy::too_many_arguments)] +fn run_differential_correction_for_trajectory( + traj_id: &TrajId, + dataset: &ObsDataset, + cache: &OutfitCache, + jpl: &JPLEphem, + _ut1_provider: &Ut1Provider, + iod_params: &IODParams, + diff_cor_config: &DifferentialCorrectionConfig, + initial_orbits: Option<&FullOrbitResult>, + rng: &mut impl rand::Rng, +) -> Result { + // ── Collect and sort observations for this trajectory ───────────────────── + let materialized = dataset + .materialize_trajectory(traj_id) + .ok_or_else(|| OutfitError::TrajectoryIdNotFound(traj_id.clone()))?; + + let mut obs_refs: Vec<&Observation> = materialized.collect_into_vec(); + obs_refs.sort_by(|a, b| a.mjd_tt().total_cmp(&b.mjd_tt())); + let observations: Vec = obs_refs.into_iter().cloned().collect(); + + // ── Obtain the starting equinoctial elements ────────────────────────────── + let initial_equinoctial = match initial_orbits.and_then(|map| map.get(traj_id)) { + Some(Ok(orbital_elements)) => { + // Caller provided an initial orbit — convert to equinoctial. + orbital_elements + .orbital_elements() + .to_equinoctial()? + .as_equinoctial() + .ok_or(OutfitError::InvalidConversion( + "Conversion to equinoctial elements failed".to_string(), + ))? + } + Some(Err(_)) | None => { + // No initial orbit — run IOD directly on the already-corrected + // observations, reusing the cache that was built for the full + // dataset. This avoids reconstructing an ObsDataset and + // rebuilding the cache. + let iod_result = run_iod_on_observations(&observations, cache, jpl, iod_params, rng)?; + iod_result + .orbital_elements() + .to_equinoctial()? + .as_equinoctial() + .ok_or(OutfitError::InvalidConversion( + "Conversion to equinoctial elements failed".to_string(), + ))? + } + }; + + // ── Build per-observation fit data from the error-model uncertainties ───── + let obs_fit_data: Vec = observations + .iter() + .map(|obs| ObsFitData::new(obs.equ_coord().ra_error, obs.equ_coord().dec_error)) + .collect(); + + // ── Run the differential-correction loop ────────────────────────────────── + let dc_output = run_differential_correction( + &observations, + &obs_fit_data, + &initial_equinoctial, + cache, + jpl, + diff_cor_config, + )?; + + // ── Package the result ──────────────────────────────────────────────────── + let normalised_rms = dc_output.normalised_rms; + Ok(FitOrbitResult::DifferentialCorrection(( + dc_output.into(), + normalised_rms, + ))) +} diff --git a/src/differential_orbit_correction/obs_fit_data.rs b/src/differential_orbit_correction/obs_fit_data.rs new file mode 100644 index 0000000..0fc5944 --- /dev/null +++ b/src/differential_orbit_correction/obs_fit_data.rs @@ -0,0 +1,166 @@ +//! Per-observation statistical fit data: uncertainties, biases, residuals and +//! selection flag. +//! +//! [`ObsFitData`] carries the statistical metadata that accompanies an +//! astrometric observation through the differential-correction loop without +//! modifying the raw observation itself. +//! +//! ## Typical lifecycle +//! +//! 1. Build one [`ObsFitData`] per observation from the error model +//! (σ_α, σ_δ and optional biases). +//! 2. Pass a `&[ObsFitData]` (together with the matching `&[Observation]`) to +//! [`single_iteration`](super::single_iteration::single_iteration). +//! 3. Inspect the [`super::single_iteration::SingleIterationResult::updated_obs_fit_data`] returned to +//! read the updated residuals and `chi` values. + +// ───────────────────────────────────────────────────────────────────────────── +// Selection flag +// ───────────────────────────────────────────────────────────────────────────── + +/// Participation flag for a single observation in the differential-correction +/// fit. +/// +/// | Value | Variant | +/// |---|---| +/// | inactive | [`ObsSelection::Rejected`] | +/// | active | [`ObsSelection::Active`] | +/// | excluded | [`ObsSelection::ForcedOut`] | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObsSelection { + /// Observation contributes to the fit. + Active, + /// Automatically rejected by the outlier-rejection step. + Rejected, + /// Manually excluded; never re-activated. + ForcedOut, +} + +impl ObsSelection { + /// Returns `true` if this observation should contribute to the normal + /// equations (i.e. it is [`Active`](ObsSelection::Active)). + #[inline] + pub fn is_active(self) -> bool { + matches!(self, ObsSelection::Active) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main struct +// ───────────────────────────────────────────────────────────────────────────── + +/// Statistical fit data for a single astrometric observation. +/// +/// Holds the per-observation uncertainties, systematic biases, residuals and +/// selection flag used during the differential-correction loop. It is *separate* +/// from the raw [`photom::observation_dataset::observation::Observation`] so +/// that the loop can update residuals and selection flags without mutating +/// immutable observation data. +/// +/// ## Units +/// +/// All angular quantities (sigmas, biases, residuals) are in **radians**. +/// +/// ## Default biases +/// +/// Set `bias_ra = 0.0` and `bias_dec = 0.0` unless a catalogue or night-block +/// debiasing step has produced non-zero values. +#[derive(Debug, Clone)] +pub struct ObsFitData { + /// Right-ascension uncertainty σ_α \[rad\]. + pub sigma_ra: f64, + /// Declination uncertainty σ_δ \[rad\]. + pub sigma_dec: f64, + /// Right-ascension systematic bias \[rad\] (subtracted from the observed + /// RA before computing residuals). + pub bias_ra: f64, + /// Declination systematic bias \[rad\]. + pub bias_dec: f64, + /// Right-ascension residual + /// \\( \xi_\alpha = \alpha_{\text{obs}} - \text{bias}_\alpha - \alpha_{\text{calc}} \\) + /// \[rad\], set by [`single_iteration`](super::single_iteration::single_iteration). + pub residual_ra: f64, + /// Declination residual + /// \\( \xi_\delta = \delta_{\text{obs}} - \text{bias}_\delta - \delta_{\text{calc}} \\) + /// \[rad\], set by [`single_iteration`](super::single_iteration::single_iteration). + pub residual_dec: f64, + /// Whether this observation participates in the fit. + pub selection: ObsSelection, + /// \\( \sqrt{\chi^2} \\) contribution of this observation, filled after + /// each call to [`single_iteration`](super::single_iteration::single_iteration). + pub chi: f64, +} + +impl ObsFitData { + /// Constructs an active, unbiased entry from the observation uncertainties. + /// + /// This is the standard constructor for the first iteration: residuals and + /// `chi` are initialised to `0.0` and will be populated by + /// [`single_iteration`](super::single_iteration::single_iteration). + /// + /// # Arguments + /// + /// - `sigma_ra` — right-ascension uncertainty \[rad\]. + /// - `sigma_dec` — declination uncertainty \[rad\]. + pub fn new(sigma_ra: f64, sigma_dec: f64) -> Self { + Self { + sigma_ra, + sigma_dec, + bias_ra: 0.0, + bias_dec: 0.0, + residual_ra: 0.0, + residual_dec: 0.0, + selection: ObsSelection::Active, + chi: 0.0, + } + } + + /// Returns `true` if this observation is [`Active`](ObsSelection::Active). + #[inline] + pub fn is_active(&self) -> bool { + self.selection.is_active() + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod obs_fit_data_tests { + use super::*; + + #[test] + fn test_new_is_active_with_zero_residuals() { + let fit = ObsFitData::new(1e-5, 1.5e-5); + assert!(fit.is_active()); + assert_eq!(fit.residual_ra, 0.0); + assert_eq!(fit.residual_dec, 0.0); + assert_eq!(fit.bias_ra, 0.0); + assert_eq!(fit.bias_dec, 0.0); + assert_eq!(fit.chi, 0.0); + assert_eq!(fit.sigma_ra, 1e-5); + assert_eq!(fit.sigma_dec, 1.5e-5); + } + + #[test] + fn test_rejected_is_not_active() { + let mut fit = ObsFitData::new(1e-5, 1e-5); + fit.selection = ObsSelection::Rejected; + assert!(!fit.is_active()); + } + + #[test] + fn test_forced_out_is_not_active() { + let mut fit = ObsFitData::new(1e-5, 1e-5); + fit.selection = ObsSelection::ForcedOut; + assert!(!fit.is_active()); + } + + #[test] + fn test_obs_selection_is_active() { + assert!(ObsSelection::Active.is_active()); + assert!(!ObsSelection::Rejected.is_active()); + assert!(!ObsSelection::ForcedOut.is_active()); + } +} diff --git a/src/differential_orbit_correction/outlier_rejection.rs b/src/differential_orbit_correction/outlier_rejection.rs new file mode 100644 index 0000000..7084625 --- /dev/null +++ b/src/differential_orbit_correction/outlier_rejection.rs @@ -0,0 +1,544 @@ +//! Projection-based outlier rejection for the differential-correction loop. +//! +//! After each Newton–Raphson iteration, each observation's chi-squared +//! contribution is recomputed using the *projected* residual variance — the +//! variance of the residual that is **not** explained by the parameter +//! correction. This accounts for the covariance of the fitted orbit and +//! avoids systematically over-rejecting observations whose apparent residual +//! is partly absorbed by the correction. +//! +//! ## Projected residual variance +//! +//! For a single observation with partial-derivative rows +//! \\( g_\alpha \\) and \\( g_\delta \\) (each a \\( 1 \times 6 \\) vector), +//! the projected 2×2 residual covariance is +//! +//! \\[ +//! V = W^{-1} - g \cdot \Gamma \cdot g^\top +//! \\] +//! +//! where \\( W^{-1} \\) is the observation covariance and +//! \\( \Gamma = (G^\top W G)^{-1} \\) is the orbit covariance. +//! +//! The per-observation chi-squared is +//! +//! \\[ +//! \chi^2_i = \xi_i^\top \cdot V^{-1} \cdot \xi_i +//! \\] +//! +//! An observation is **rejected** when \\( \chi^2_i > \chi^2_{\text{reject}} \\) +//! and **re-admitted** when it was previously rejected (but not +//! [`ObsSelection::ForcedOut`]) and \\( \chi^2_i \le \chi^2_{\text{recover}} \\). +//! +//! ## Configuration +//! +//! See [`OutlierRejectionConfig`] for the two thresholds and +//! [`update_observation_selection`] for the function that applies them. + +use nalgebra::{Matrix2, Vector2}; + +use crate::differential_orbit_correction::{ + least_square::{ObservationEquation, OrbitalUncertainty}, + obs_fit_data::{ObsFitData, ObsSelection}, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Configuration +// ───────────────────────────────────────────────────────────────────────────── + +/// Tuning parameters for the projection-based outlier rejection step. +/// +/// Both thresholds are expressed as dimensionless chi-squared values. +#[derive(Debug, Clone)] +pub struct OutlierRejectionConfig { + /// Chi-squared threshold above which an observation is rejected. + /// + /// An active observation whose projected \\( \chi^2_i \\) exceeds this + /// value is moved to [`ObsSelection::Rejected`]. + /// + /// Default: `25.0` (≈ 5σ for uncorrelated RA/Dec, 2 degrees of freedom). + pub chi_squared_rejection_threshold: f64, + + /// Chi-squared threshold at or below which a previously rejected + /// observation is re-admitted. + /// + /// An observation in [`ObsSelection::Rejected`] whose projected + /// \\( \chi^2_i \\) drops back to this value is moved to + /// [`ObsSelection::Active`]. [`ObsSelection::ForcedOut`] observations + /// are never re-admitted regardless of their chi-squared value. + /// + /// Default: `9.0` (≈ 3σ for uncorrelated RA/Dec, 2 degrees of freedom). + pub chi_squared_recovery_threshold: f64, +} + +impl Default for OutlierRejectionConfig { + fn default() -> Self { + Self { + chi_squared_rejection_threshold: 25.0, + chi_squared_recovery_threshold: 9.0, + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main function +// ───────────────────────────────────────────────────────────────────────────── + +/// Updates the selection flag of each observation using a projection-based +/// chi-squared criterion and returns the updated collection. +/// +/// For each observation the function: +/// +/// 1. Computes the projected 2×2 residual covariance +/// \\( V = W^{-1} - g \cdot \Gamma \cdot g^\top \\). +/// 2. Inverts \\( V \\) using its analytic 2×2 formula. +/// 3. Evaluates \\( \chi^2_i = \xi_i^\top V^{-1} \xi_i \\). +/// 4. Applies the rejection / recovery decision. +/// +/// # Arguments +/// +/// - `obs_fit_data` — per-observation fit data (immutable). +/// - `equations` — per-observation linearised equations from the last +/// [`single_iteration`](super::single_iteration::single_iteration) call. +/// Must have the same length as `obs_fit_data`. +/// - `uncertainty` — orbit covariance \\( \Gamma \\) from the last iteration. +/// - `config` — rejection and recovery thresholds. +/// +/// # Returns +/// +/// A tuple `(updated, num_changes)` where `updated` is a new `Vec` +/// with the selection flags updated and `num_changes` is the number of +/// selection changes (rejections + recoveries) made in this call. The caller +/// uses `num_changes` to detect when the selection has stabilised +/// (i.e. when it equals `0`). +/// +/// # Panics +/// +/// Panics if `obs_fit_data.len() != equations.len()`. +pub fn update_observation_selection( + obs_fit_data: &[ObsFitData], + equations: &[ObservationEquation], + uncertainty: &OrbitalUncertainty, + config: &OutlierRejectionConfig, +) -> (Vec, usize) { + assert_eq!( + obs_fit_data.len(), + equations.len(), + "obs_fit_data and equations must have the same length" + ); + + let covariance = &uncertainty.covariance; + + obs_fit_data.iter().zip(equations.iter()).fold( + (Vec::with_capacity(obs_fit_data.len()), 0_usize), + |(mut acc, changes), (fit_data, eq)| { + // ForcedOut observations are permanently excluded — never touch them. + if fit_data.selection == ObsSelection::ForcedOut { + acc.push(fit_data.clone()); + return (acc, changes); + } + + // ── 1. Observation measurement covariance W⁻¹ (diagonal, 2×2) ─ + // + // W_i = diag(w_α, w_δ) → W_i⁻¹ = diag(σ_α², σ_δ²) + // + // Cross-weight is zero for the standard uncorrelated case. + let var_ra = fit_data.sigma_ra * fit_data.sigma_ra; + let var_dec = fit_data.sigma_dec * fit_data.sigma_dec; + let cov_cross = -fit_data.sigma_ra * fit_data.sigma_dec * eq.weight_cross + / (eq.weight_ra * eq.weight_dec); // = 0 when weight_cross = 0 + + // ── 2. Projection term g · Γ · gᵀ (2×2) ────────────────────── + // + // g = [ g_α ] (2×6) + // [ g_δ ] + // + // proj[0,0] = g_αᵀ · Γ · g_α + // proj[1,1] = g_δᵀ · Γ · g_δ + // proj[0,1] = g_αᵀ · Γ · g_δ (= proj[1,0] by symmetry) + let g_alpha = &eq.d_ra_d_elem; + let g_delta = &eq.d_dec_d_elem; + + let gamma_g_alpha = covariance * g_alpha; // 6-vector + let gamma_g_delta = covariance * g_delta; // 6-vector + + let proj_aa = g_alpha.dot(&gamma_g_alpha); + let proj_dd = g_delta.dot(&gamma_g_delta); + let proj_ad = g_alpha.dot(&gamma_g_delta); + + // ── 3. Projected residual variance V = W⁻¹ − g·Γ·gᵀ ────────── + let projected_var = Matrix2::new( + var_ra - proj_aa, + cov_cross - proj_ad, + cov_cross - proj_ad, + var_dec - proj_dd, + ); + + // ── 4. Invert V analytically (2×2) ──────────────────────────── + // + // det(V) = V[0,0]·V[1,1] − V[0,1]² + let det = projected_var[(0, 0)] * projected_var[(1, 1)] + - projected_var[(0, 1)] * projected_var[(0, 1)]; + + // Use a relative singularity check: |det| < ε × max diagonal² + let scale = projected_var[(0, 0)].abs().max(projected_var[(1, 1)].abs()); + let singular_threshold = f64::EPSILON * scale * scale; + if det.abs() < singular_threshold || scale == 0.0 { + // Singular or numerically degenerate projected covariance — skip. + acc.push(fit_data.clone()); + return (acc, changes); + } + + let inv_projected_var = Matrix2::new( + projected_var[(1, 1)] / det, + -projected_var[(0, 1)] / det, + -projected_var[(1, 0)] / det, + projected_var[(0, 0)] / det, + ); + + // ── 5. Chi-squared ───────────────────────────────────────────── + let residual = Vector2::new(fit_data.residual_ra, fit_data.residual_dec); + let chi_squared = residual.dot(&(inv_projected_var * residual)); + + // ── 6. Rejection / recovery decision ────────────────────────── + let new_selection = match fit_data.selection { + ObsSelection::Active if chi_squared > config.chi_squared_rejection_threshold => { + Some(ObsSelection::Rejected) + } + ObsSelection::Rejected if chi_squared <= config.chi_squared_recovery_threshold => { + Some(ObsSelection::Active) + } + _ => None, + }; + + let (updated_fit_data, delta) = match new_selection { + Some(new_sel) => { + let mut updated = fit_data.clone(); + updated.selection = new_sel; + (updated, 1) + } + None => (fit_data.clone(), 0), + }; + + acc.push(updated_fit_data); + (acc, changes + delta) + }, + ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod outlier_rejection_tests { + use super::*; + use crate::differential_orbit_correction::least_square::ObservationEquation; + use nalgebra::{Matrix6, Vector6}; + + // ── Helpers ─────────────────────────────────────────────────────────────── + + fn identity_uncertainty() -> OrbitalUncertainty { + OrbitalUncertainty { + normal_matrix: Matrix6::identity(), + covariance: Matrix6::identity(), + inversion_succeeded: true, + } + } + + fn zero_uncertainty() -> OrbitalUncertainty { + OrbitalUncertainty { + normal_matrix: Matrix6::zeros(), + covariance: Matrix6::zeros(), + inversion_succeeded: false, + } + } + + /// Build a diagonal observation equation with given partial derivatives and + /// residuals. + fn make_eq( + g_ra: Vector6, + g_dec: Vector6, + res_ra: f64, + res_dec: f64, + sigma: f64, + active: bool, + ) -> ObservationEquation { + ObservationEquation::uncorrelated(g_ra, g_dec, res_ra, res_dec, sigma, sigma, active) + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + /// An observation with zero residuals must never be rejected. + #[test] + fn test_zero_residual_never_rejected() { + let sigma = 1e-5; + let eq = make_eq(Vector6::zeros(), Vector6::zeros(), 0.0, 0.0, sigma, true); + let uncertainty = zero_uncertainty(); // Γ = 0 → proj_var = W⁻¹ + + let mut fit_data = ObsFitData::new(sigma, sigma); + fit_data.residual_ra = 0.0; + fit_data.residual_dec = 0.0; + + let (updated, changes) = update_observation_selection( + &[fit_data], + &[eq], + &uncertainty, + &OutlierRejectionConfig::default(), + ); + assert_eq!(changes, 0); + assert_eq!(updated[0].selection, ObsSelection::Active); + } + + /// A large residual (>> 5σ) must be rejected. + #[test] + fn test_large_residual_is_rejected() { + let sigma = 1e-5; + // Γ = 0 → no projection term → V = W⁻¹ = diag(σ², σ²) + // residual = 100σ → χ² = (100σ/σ)² + (100σ/σ)² = 20000 >> 25 + let eq = make_eq( + Vector6::zeros(), + Vector6::zeros(), + 100.0 * sigma, + 100.0 * sigma, + sigma, + true, + ); + let uncertainty = zero_uncertainty(); + + let mut fit_data = ObsFitData::new(sigma, sigma); + fit_data.residual_ra = 100.0 * sigma; + fit_data.residual_dec = 100.0 * sigma; + + let (updated, changes) = update_observation_selection( + &[fit_data], + &[eq], + &uncertainty, + &OutlierRejectionConfig::default(), + ); + + assert_eq!(changes, 1); + assert_eq!(updated[0].selection, ObsSelection::Rejected); + } + + /// A previously rejected observation with small residuals must be + /// re-admitted. + #[test] + fn test_small_residual_recovers_rejected_observation() { + let sigma = 1e-5; + let eq = make_eq( + Vector6::zeros(), + Vector6::zeros(), + 0.5 * sigma, // small residual + 0.5 * sigma, + sigma, + false, // currently inactive + ); + let uncertainty = zero_uncertainty(); + + let mut fit_data = ObsFitData::new(sigma, sigma); + fit_data.residual_ra = 0.5 * sigma; + fit_data.residual_dec = 0.5 * sigma; + fit_data.selection = ObsSelection::Rejected; + + let (updated, changes) = update_observation_selection( + &[fit_data], + &[eq], + &uncertainty, + &OutlierRejectionConfig::default(), + ); + + // χ² = 0.5² + 0.5² = 0.5 ≤ 9 → recovered + assert_eq!(changes, 1); + assert_eq!(updated[0].selection, ObsSelection::Active); + } + + /// A [`ObsSelection::ForcedOut`] observation must never change state. + #[test] + fn test_forced_out_is_never_changed() { + let sigma = 1e-5; + let eq = make_eq(Vector6::zeros(), Vector6::zeros(), 0.0, 0.0, sigma, false); + let uncertainty = zero_uncertainty(); + + let mut fit_data = ObsFitData::new(sigma, sigma); + fit_data.selection = ObsSelection::ForcedOut; + + let (updated, changes) = update_observation_selection( + &[fit_data], + &[eq], + &uncertainty, + &OutlierRejectionConfig::default(), + ); + + assert_eq!(changes, 0); + assert_eq!(updated[0].selection, ObsSelection::ForcedOut); + } + + /// Verify the projected variance computation against a hand-calculated + /// oracle. + /// + /// Setup: + /// - σ_α = σ_δ = 1e-5 → W⁻¹ = diag(1e-10, 1e-10) + /// - g_α = e₁, g_δ = e₂ (standard basis vectors) + /// - Γ = identity + /// - proj[0,0] = g_αᵀ · I · g_α = 1, proj[1,1] = 1, proj[0,1] = 0 + /// - V = diag(1e-10 − 1, 1e-10 − 1) → det = (1e-10 − 1)² + /// + /// With residual_ra = residual_dec = 1e-5 = σ: + /// - χ² = ξ² / (σ² − 1) + ξ² / (σ² − 1) (negative denom → singular) + /// + /// Because Γ = I and the partials are unit vectors, the projected variance + /// becomes negative (the orbit fully explains the residual). The function + /// must detect the singular / negative-determinant case and skip the + /// observation without changing its state. + #[test] + fn test_singular_projected_variance_skipped() { + let sigma = 1e-5; + // g_α = e₀, g_δ = e₁ so proj = diag(1, 1) + let mut g_ra = Vector6::zeros(); + g_ra[0] = 1.0; + let mut g_dec = Vector6::zeros(); + g_dec[1] = 1.0; + + let eq = make_eq(g_ra, g_dec, sigma, sigma, sigma, true); + // Γ = identity → proj_aa = 1 >> σ² → negative V diagonal → singular + let uncertainty = identity_uncertainty(); + + let mut fit_data = ObsFitData::new(sigma, sigma); + fit_data.residual_ra = sigma; + fit_data.residual_dec = sigma; + + let (updated, changes) = update_observation_selection( + &[fit_data], + &[eq], + &uncertainty, + &OutlierRejectionConfig::default(), + ); + + // Cannot invert → observation state unchanged + assert_eq!(changes, 0); + assert_eq!(updated[0].selection, ObsSelection::Active); + } + + /// Returns `0` changes when no threshold is crossed by any observation. + #[test] + fn test_no_change_when_within_thresholds() { + let sigma = 1e-5; + // residual ≈ 2σ → χ² ≈ 4+4 = 8 < 25 → no rejection + let eq = make_eq( + Vector6::zeros(), + Vector6::zeros(), + 2.0 * sigma, + 2.0 * sigma, + sigma, + true, + ); + let uncertainty = zero_uncertainty(); + + let mut fit_data = ObsFitData::new(sigma, sigma); + fit_data.residual_ra = 2.0 * sigma; + fit_data.residual_dec = 2.0 * sigma; + + let (updated, changes) = update_observation_selection( + &[fit_data], + &[eq], + &uncertainty, + &OutlierRejectionConfig::default(), + ); + + assert_eq!(changes, 0); + assert_eq!(updated[0].selection, ObsSelection::Active); + } + + /// Custom thresholds are respected. + #[test] + fn test_custom_thresholds() { + let sigma = 1e-5; + // residual = 3σ → χ² = 9+9 = 18 + // Default threshold 25 → no rejection + // Custom threshold 16 → rejection + let eq = make_eq( + Vector6::zeros(), + Vector6::zeros(), + 3.0 * sigma, + 3.0 * sigma, + sigma, + true, + ); + let uncertainty = zero_uncertainty(); + + let mut fit_data = ObsFitData::new(sigma, sigma); + fit_data.residual_ra = 3.0 * sigma; + fit_data.residual_dec = 3.0 * sigma; + + let config = OutlierRejectionConfig { + chi_squared_rejection_threshold: 16.0, + chi_squared_recovery_threshold: 4.0, + }; + + let (updated, changes) = + update_observation_selection(&[fit_data], &[eq], &uncertainty, &config); + + assert_eq!(changes, 1); + assert_eq!(updated[0].selection, ObsSelection::Rejected); + } + + /// `num_changes` counts correctly when multiple observations change state. + #[test] + fn test_multiple_changes_counted_correctly() { + let sigma = 1e-5; + let uncertainty = zero_uncertainty(); + + // obs 0: active, small residual → no change + let eq0 = make_eq( + Vector6::zeros(), + Vector6::zeros(), + sigma, + sigma, + sigma, + true, + ); + let mut fd0 = ObsFitData::new(sigma, sigma); + fd0.residual_ra = sigma; + fd0.residual_dec = sigma; + + // obs 1: active, large residual → reject + let eq1 = make_eq( + Vector6::zeros(), + Vector6::zeros(), + 10.0 * sigma, + 10.0 * sigma, + sigma, + true, + ); + let mut fd1 = ObsFitData::new(sigma, sigma); + fd1.residual_ra = 10.0 * sigma; + fd1.residual_dec = 10.0 * sigma; + + // obs 2: rejected, tiny residual → recover + let eq2 = make_eq( + Vector6::zeros(), + Vector6::zeros(), + 0.1 * sigma, + 0.1 * sigma, + sigma, + false, + ); + let mut fd2 = ObsFitData::new(sigma, sigma); + fd2.residual_ra = 0.1 * sigma; + fd2.residual_dec = 0.1 * sigma; + fd2.selection = ObsSelection::Rejected; + + let (updated, changes) = update_observation_selection( + &[fd0, fd1, fd2], + &[eq0, eq1, eq2], + &uncertainty, + &OutlierRejectionConfig::default(), + ); + + assert_eq!(changes, 2); + assert_eq!(updated[0].selection, ObsSelection::Active); + assert_eq!(updated[1].selection, ObsSelection::Rejected); + assert_eq!(updated[2].selection, ObsSelection::Active); + } +} diff --git a/src/differential_orbit_correction/single_iteration.rs b/src/differential_orbit_correction/single_iteration.rs new file mode 100644 index 0000000..f45adec --- /dev/null +++ b/src/differential_orbit_correction/single_iteration.rs @@ -0,0 +1,629 @@ +//! One Newton–Raphson iteration of the differential orbit correction. +//! +//! A single call to [`single_iteration`] performs one full pass: +//! +//! 1. For each active observation, compute the predicted (RA, DEC) and the +//! partial derivatives ∂(α,δ)/∂(elements) via the two-body propagator. +//! 2. Compute the astrometric residuals and store them in the returned +//! [`ObsFitData`] entries. +//! 3. Assemble the normal equations and solve for the element correction +//! δx = Γ · GᵀWξ. +//! 4. Optionally apply δx to the input elements, returning the corrected +//! [`EquinoctialElements`]. +//! 5. Compute the correction norm ‖δx‖_C = √(δxᵀ · C · δx) where C is the +//! normal matrix GᵀWG. +//! +//! ## Functional style +//! +//! All outputs are returned in [`SingleIterationResult`]; inputs are borrowed +//! immutably. The caller obtains new values for the orbital elements and the +//! per-observation fit data by destructuring the result. +//! +//! ```text +//! let result = single_iteration(&obs, &obs_fit_data, &elements, ...)?; +//! let elements = result.corrected_elements; +//! let obs_fit_data = result.updated_obs_fit_data; +//! ``` +//! +//! ## Observation failures +//! +//! If the orbit propagator fails for an individual observation (e.g. light-time +//! non-convergence), that observation is silently skipped and treated as inactive +//! for this iteration. All other observations are processed normally. + +use photom::observation_dataset::observation::Observation; + +use crate::{ + cache::OutfitCache, + differential_orbit_correction::{ + least_square::{ + angular_diff, solve_weighted_least_squares, ObservationEquation, OrbitalUncertainty, + }, + obs_fit_data::ObsFitData, + }, + ephemeris::observation_ephemeris::ObservationEphemeris, + propagator::PropagatorKind, + EquinoctialElements, JPLEphem, OutfitError, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Result type +// ───────────────────────────────────────────────────────────────────────────── + +/// Output of a single differential-correction iteration. +/// +/// This struct bundles every quantity produced by [`single_iteration`]: +/// the corrected orbital elements, updated per-observation fit data, +/// the per-observation linearised equations, convergence diagnostics, +/// and uncertainty matrices. +/// +/// ## Correction norm +/// +/// `correction_norm` is the dimensionless scalar +/// +/// \\[ +/// \|\delta x\|_C = \sqrt{\delta x^\top \cdot C \cdot \delta x} +/// \\] +/// +/// where \\(C = G^\top W G\\) is the normal matrix. It provides a unit-free +/// measure of the step size in the parameter space and drives the convergence +/// check. +/// +/// ## Observation equations +/// +/// `observation_equations` mirrors the per-observation linearised system built +/// during this iteration. The `i`-th entry contains the partial derivatives +/// and weights for the `i`-th input observation. Downstream steps such as +/// outlier rejection use these to compute the projected residual variance +/// \\( g \cdot \Gamma \cdot g^\top \\) without rerunning the propagator. +#[derive(Debug, Clone)] +pub struct SingleIterationResult { + /// Orbital elements after applying the correction δx (or unchanged if + /// `apply_correction` was `false`). + pub corrected_elements: EquinoctialElements, + /// Per-observation fit data with updated residuals and chi values. + /// + /// The `i`-th entry corresponds to the `i`-th input observation. + /// Observations that failed to propagate keep their residuals from the + /// previous iteration (or `0.0` on the first call). + pub updated_obs_fit_data: Vec, + /// Per-observation linearised equations built during this iteration. + /// + /// The `i`-th entry holds the partial derivatives + /// \\( \partial\alpha/\partial\text{elem} \\), + /// \\( \partial\delta/\partial\text{elem} \\), residuals, and weights for + /// the `i`-th input observation. Inactive observations carry a zero + /// placeholder equation. + pub observation_equations: Vec, + /// Dimensionless correction norm \\( \|\delta x\|_C \\). + pub correction_norm: f64, + /// Normalised RMS residual \\( \sqrt{\xi^\top W \xi / n} \\). + pub normalised_rms: f64, + /// Covariance and normal matrices from the inversion step. + pub uncertainty: OrbitalUncertainty, + /// Number of scalar measurements used (2 per active optical observation). + pub num_measurements: usize, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public function +// ───────────────────────────────────────────────────────────────────────────── + +/// Performs one Newton–Raphson iteration of the differential orbit correction. +/// +/// # Arguments +/// +/// - `observations` — slice of astrometric observations (order is preserved in +/// the returned [`SingleIterationResult::updated_obs_fit_data`]). +/// - `obs_fit_data` — per-observation statistical fit data (σ, bias, selection +/// flag). Must have the same length as `observations`. +/// - `elements` — current equinoctial orbital elements. +/// - `free_elements` — six-element boolean mask; `true` means the corresponding +/// element is solved for, `false` means it is held fixed. +/// - `cache` — pre-computed observer geometry cache. +/// - `jpl` — JPL planetary ephemeris handle. +/// - `apply_correction` — if `true`, the element correction δx is applied to +/// produce [`SingleIterationResult::corrected_elements`]; if `false` only +/// the covariance is computed (matrix-only mode). +/// +/// # Errors +/// +/// Returns [`OutfitError`] only if the normal-equation solver itself fails +/// (e.g. the normal matrix is identically zero because all observations are +/// inactive). Per-observation propagation failures are handled gracefully by +/// skipping the failing observation. +/// +/// # Panics +/// +/// Panics if `observations.len() != obs_fit_data.len()`. +#[allow(clippy::too_many_arguments)] +pub fn single_iteration( + observations: &[Observation], + obs_fit_data: &[ObsFitData], + elements: &EquinoctialElements, + free_elements: &[bool; 6], + cache: &OutfitCache, + jpl: &JPLEphem, + apply_correction: bool, + propagator: &PropagatorKind, +) -> Result { + assert_eq!( + observations.len(), + obs_fit_data.len(), + "observations and obs_fit_data must have the same length" + ); + + // ── 1. Build observation equations ─────────────────────────────────────── + // + // For each observation, attempt to compute predicted (RA, DEC) and element + // partials. Failures are logged and the observation is marked inactive. + + // Collect (ObservationEquation, updated ObsFitData) pairs. + let (equations, updated_obs_fit_data): (Vec, Vec) = + observations + .iter() + .zip(obs_fit_data.iter()) + .map(|(obs, fit_data)| { + // Inactive observations: build a zero-weight placeholder and keep + // the existing residuals unchanged. + if !fit_data.is_active() { + return ( + ObservationEquation::uncorrelated( + nalgebra::Vector6::zeros(), + nalgebra::Vector6::zeros(), + 0.0, + 0.0, + 1.0, // dummy sigma — weight ignored because active=false + 1.0, + false, + ), + fit_data.clone(), + ); + } + + // Propagate orbit and compute predicted position + element partials. + let partials_result = match propagator { + PropagatorKind::TwoBody => { + obs.compute_obs_and_partials_2body(cache, jpl, elements) + } + PropagatorKind::NBody(nbody_config) => { + obs.compute_obs_and_partials_nbody(cache, jpl, elements, nbody_config) + } + }; + match partials_result { + Ok(partials) => { + // Residual RA: angular difference in (−π, π] accounting for wrapping. + // The observed RA is corrected for the catalogue bias before differencing. + let obs_ra_debiased = obs.equ_coord().ra - fit_data.bias_ra; + let residual_ra = angular_diff(obs_ra_debiased, partials.ra); + + // Residual Dec: simple difference (no wrapping needed). + let obs_dec_debiased = obs.equ_coord().dec - fit_data.bias_dec; + let residual_dec = obs_dec_debiased - partials.dec; + + // chi = normalised residual magnitude for this observation. + let chi = ((residual_ra / fit_data.sigma_ra).powi(2) + + (residual_dec / fit_data.sigma_dec).powi(2)) + .sqrt(); + + let eq = ObservationEquation::uncorrelated( + partials.d_ra_d_elem, + partials.d_dec_d_elem, + residual_ra, + residual_dec, + fit_data.sigma_ra, + fit_data.sigma_dec, + true, + ); + + let updated = ObsFitData { + residual_ra, + residual_dec, + chi, + ..fit_data.clone() + }; + + (eq, updated) + } + Err(err) => { + eprintln!( + "single_iteration: propagation failed for observation at MJD {:.4}: {}. \ + Observation skipped for this iteration.", + obs.mjd_tt(), + err + ); + // Keep previous residuals; mark as inactive for this + // iteration only (do not change the selection flag). + ( + ObservationEquation::uncorrelated( + nalgebra::Vector6::zeros(), + nalgebra::Vector6::zeros(), + 0.0, + 0.0, + 1.0, // dummy sigma + 1.0, + false, + ), + fit_data.clone(), + ) + } + } + }) + .unzip(); + + // ── 2. Solve normal equations ───────────────────────────────────────────── + let ls_result = solve_weighted_least_squares(&equations, free_elements)?; + + // ── 3. Correction norm ‖δx‖_C = √(δxᵀ · C · δx) ───────────────────────── + let dx = &ls_result.element_correction; + let c = &ls_result.uncertainty.normal_matrix; + let correction_norm = (dx.dot(&(c * dx))).sqrt(); + + // ── 4. Apply correction to elements ────────────────────────────────────── + let corrected_elements = if apply_correction { + let mut new_coord = [ + elements.semi_major_axis, + elements.eccentricity_sin_lon, + elements.eccentricity_cos_lon, + elements.tan_half_incl_sin_node, + elements.tan_half_incl_cos_node, + elements.mean_longitude, + ]; + for j in 0..6 { + if free_elements[j] { + new_coord[j] += dx[j]; + } + } + EquinoctialElements { + reference_epoch: elements.reference_epoch, + semi_major_axis: new_coord[0], + eccentricity_sin_lon: new_coord[1], + eccentricity_cos_lon: new_coord[2], + tan_half_incl_sin_node: new_coord[3], + tan_half_incl_cos_node: new_coord[4], + mean_longitude: new_coord[5], + } + } else { + elements.clone() + }; + + Ok(SingleIterationResult { + corrected_elements, + updated_obs_fit_data, + observation_equations: equations, + correction_norm, + normalised_rms: ls_result.normalised_rms, + uncertainty: ls_result.uncertainty, + num_measurements: ls_result.num_measurements, + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod single_iteration_tests { + use super::*; + use crate::{ + differential_orbit_correction::obs_fit_data::ObsSelection, + test_fixture::{JPL_EPHEM_HORIZON, UT1_PROVIDER}, + }; + use approx::assert_abs_diff_eq; + use photom::{ + coordinates::equatorial::EquCoord, + observation_dataset::{observation::ObservationInput, ObsDataset}, + observer::{ + dataset::ObserverId, + error_model::{ModelCorrection, ObsErrorModel}, + }, + photometry::{Filter, Photometry}, + }; + + // ── Helpers ─────────────────────────────────────────────────────────────── + + fn circular_elements(epoch: f64) -> EquinoctialElements { + EquinoctialElements { + reference_epoch: epoch, + semi_major_axis: 1.8, + eccentricity_sin_lon: 0.1, + eccentricity_cos_lon: 0.05, + tan_half_incl_sin_node: 0.01, + tan_half_incl_cos_node: 0.1, + mean_longitude: 1.0, + } + } + + /// Build a dataset with `n` observations spread over `time_span` days + /// starting at `t0` (MJD TT), all from observatory F51 (Pan-STARRS). + fn make_dataset_and_cache(t0: f64, time_span: f64, n: usize) -> (ObsDataset, OutfitCache) { + let step = if n > 1 { + time_span / (n - 1) as f64 + } else { + 0.0 + }; + + // Use the propagator to get realistic (RA, DEC) for each epoch + // so that residuals start near zero. + let inputs: Vec = (0..n) + .map(|i| { + let t_obs = t0 + i as f64 * step; + // Dummy RA/Dec — will be replaced after cache is built. + // We use 0.0 here and fix them in the test bodies as needed. + ObservationInput::new( + i as u64, + EquCoord { + ra: 0.0, + ra_error: 0.0, + dec: 0.0, + dec_error: 0.0, + }, + Photometry { + magnitude: 15.0, + error: 0.1, + filter: Filter::Int(0), + }, + t_obs, + Some(ObserverId::MpcCode(*b"F51")), + ) + }) + .collect(); + + let obs_dataset = { + let mut ds = ObsDataset::empty(); + for input in inputs { + ds = ds.push_observation(vec![input]).unwrap().0; + } + ds.with_error_model(ObsErrorModel::FCCT14) + .apply_model_errors() + }; + + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER, false).unwrap(); + + (obs_dataset, cache) + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + /// When all observations are inactive, the correction must be zero and the + /// corrected elements must be unchanged. + #[test] + fn test_all_inactive_gives_zero_correction() { + let t0 = 59000.0; + let elements = circular_elements(t0); + let (obs_dataset, cache) = make_dataset_and_cache(t0, 365.0, 6); + + let observations: Vec = (0..6) + .map(|i| obs_dataset.get_observation(i).unwrap().clone()) + .collect(); + + let obs_fit_data: Vec = observations + .iter() + .map(|obs| ObsFitData { + sigma_ra: obs.equ_coord().ra_error, + sigma_dec: obs.equ_coord().dec_error, + bias_ra: 0.0, + bias_dec: 0.0, + residual_ra: 0.0, + residual_dec: 0.0, + selection: ObsSelection::Rejected, // all inactive + chi: 0.0, + }) + .collect(); + + let result = single_iteration( + &observations, + &obs_fit_data, + &elements, + &[true; 6], + &cache, + &JPL_EPHEM_HORIZON, + true, + &PropagatorKind::TwoBody, + ) + .unwrap(); + + // No active observations → correction is zero + assert_abs_diff_eq!(result.correction_norm, 0.0, epsilon = 1e-15); + assert_abs_diff_eq!(result.normalised_rms, 0.0, epsilon = 1e-15); + assert_eq!(result.num_measurements, 0); + + // Elements must be unchanged (delta applied was zero) + assert_abs_diff_eq!( + result.corrected_elements.semi_major_axis, + elements.semi_major_axis, + epsilon = 1e-15 + ); + } + + /// When `apply_correction = false`, the returned elements must be + /// identical to the input elements regardless of the residuals. + #[test] + fn test_matonly_does_not_change_elements() { + let t0 = 59000.0; + let elements = circular_elements(t0); + let (obs_dataset, cache) = make_dataset_and_cache(t0, 365.0, 6); + + let observations: Vec = (0..6) + .map(|i| obs_dataset.get_observation(i).unwrap().clone()) + .collect(); + + let obs_fit_data: Vec = observations + .iter() + .map(|obs| ObsFitData::new(obs.equ_coord().ra_error, obs.equ_coord().dec_error)) + .collect(); + + let result = single_iteration( + &observations, + &obs_fit_data, + &elements, + &[true; 6], + &cache, + &JPL_EPHEM_HORIZON, + false, // matonly + &PropagatorKind::TwoBody, + ) + .unwrap(); + + assert_abs_diff_eq!( + result.corrected_elements.semi_major_axis, + elements.semi_major_axis, + epsilon = 1e-15 + ); + assert_abs_diff_eq!( + result.corrected_elements.mean_longitude, + elements.mean_longitude, + epsilon = 1e-15 + ); + } + + /// Updated weights slice must have the same length as the input. + #[test] + fn test_updated_weights_length_matches_input() { + let t0 = 59000.0; + let elements = circular_elements(t0); + let (obs_dataset, cache) = make_dataset_and_cache(t0, 365.0, 4); + + let observations: Vec = (0..4) + .map(|i| obs_dataset.get_observation(i).unwrap().clone()) + .collect(); + + let obs_fit_data: Vec = observations + .iter() + .map(|obs| ObsFitData::new(obs.equ_coord().ra_error, obs.equ_coord().dec_error)) + .collect(); + + let result = single_iteration( + &observations, + &obs_fit_data, + &elements, + &[true; 6], + &cache, + &JPL_EPHEM_HORIZON, + true, + &PropagatorKind::TwoBody, + ) + .unwrap(); + + assert_eq!(result.updated_obs_fit_data.len(), observations.len()); + } + + /// A fixed element must not change even when the correction is non-zero for + /// free elements. + #[test] + fn test_fixed_element_not_corrected() { + let t0 = 59000.0; + let elements = circular_elements(t0); + let (obs_dataset, cache) = make_dataset_and_cache(t0, 365.0, 6); + + let observations: Vec = (0..6) + .map(|i| obs_dataset.get_observation(i).unwrap().clone()) + .collect(); + + let obs_fit_data: Vec = observations + .iter() + .map(|obs| ObsFitData::new(obs.equ_coord().ra_error, obs.equ_coord().dec_error)) + .collect(); + + // Fix element 0 (semi_major_axis) + let mut free = [true; 6]; + free[0] = false; + + let result = single_iteration( + &observations, + &obs_fit_data, + &elements, + &free, + &cache, + &JPL_EPHEM_HORIZON, + true, + &PropagatorKind::TwoBody, + ) + .unwrap(); + + assert_abs_diff_eq!( + result.corrected_elements.semi_major_axis, + elements.semi_major_axis, + epsilon = 1e-15, + ); + } + + /// The selection flag of inactive observations must be preserved unchanged + /// in `updated_weights`. + #[test] + fn test_inactive_selection_flag_preserved() { + let t0 = 59000.0; + let elements = circular_elements(t0); + let (obs_dataset, cache) = make_dataset_and_cache(t0, 365.0, 6); + + let observations: Vec = (0..6) + .map(|i| obs_dataset.get_observation(i).unwrap().clone()) + .collect(); + + let mut obs_fit_data: Vec = observations + .iter() + .map(|obs| ObsFitData::new(obs.equ_coord().ra_error, obs.equ_coord().dec_error)) + .collect(); + + // Mark observations 1 and 3 as rejected + obs_fit_data[1].selection = ObsSelection::Rejected; + obs_fit_data[3].selection = ObsSelection::ForcedOut; + + let result = single_iteration( + &observations, + &obs_fit_data, + &elements, + &[true; 6], + &cache, + &JPL_EPHEM_HORIZON, + true, + &PropagatorKind::TwoBody, + ) + .unwrap(); + + assert_eq!( + result.updated_obs_fit_data[1].selection, + ObsSelection::Rejected + ); + assert_eq!( + result.updated_obs_fit_data[3].selection, + ObsSelection::ForcedOut + ); + } + + /// Active observations must have their residuals updated (non-NaN). + #[test] + fn test_active_observations_residuals_are_finite() { + let t0 = 59000.0; + let elements = circular_elements(t0); + let (obs_dataset, cache) = make_dataset_and_cache(t0, 365.0, 6); + + let observations: Vec = (0..6) + .map(|i| obs_dataset.get_observation(i).unwrap().clone()) + .collect(); + + let obs_fit_data: Vec = observations + .iter() + .map(|obs| ObsFitData::new(obs.equ_coord().ra_error, obs.equ_coord().dec_error)) + .collect(); + + let result = single_iteration( + &observations, + &obs_fit_data, + &elements, + &[true; 6], + &cache, + &JPL_EPHEM_HORIZON, + true, + &PropagatorKind::TwoBody, + ) + .unwrap(); + + for fit in &result.updated_obs_fit_data { + assert!(fit.residual_ra.is_finite()); + assert!(fit.residual_dec.is_finite()); + assert!(fit.chi.is_finite()); + } + } +} diff --git a/src/earth_orientation.rs b/src/earth_orientation.rs index cef0bbc..a674ee4 100644 --- a/src/earth_orientation.rs +++ b/src/earth_orientation.rs @@ -74,9 +74,10 @@ //! - [`crate::ref_system`] for frame transformations that use these models. //! - **Theory of Orbit Determination** by Milani & Gronchi (2010). use nalgebra::Matrix3; +use photom::{Arcseconds, Radians, MJDTT}; use crate::{ - constants::{ArcSec, Radian, RADEG, RADSEC, T2000}, + constants::{RADEG, RADSEC, T2000}, ref_system::rotmt, }; @@ -115,7 +116,7 @@ use crate::{ /// # See also /// * [`rotmt`] – constructs rotation matrices using this obliquity /// * [`rotpn`](crate::ref_system::rotpn) – applies obliquity rotation when transforming between ecliptic and equatorial frames -pub fn obleq(tjm: f64) -> Radian { +pub fn obleq(tjm: MJDTT) -> Radians { // Obliquity coefficients let ob0 = ((23.0 * 3600.0 + 26.0 * 60.0) + 21.448) * RADSEC; let ob1 = -46.815 * RADSEC; @@ -166,7 +167,7 @@ pub fn obleq(tjm: f64) -> Radian { /// * [`rnut80`] – uses these angles to build the nutation rotation matrix /// * [`rotpn`](crate::ref_system::rotpn) – applies nutation when transforming between Equt and Equm systems #[inline(always)] -pub fn nutn80(tjm: f64) -> (ArcSec, ArcSec) { +pub fn nutn80(tjm: MJDTT) -> (Arcseconds, Arcseconds) { // ---- time powers (Julian centuries from J2000) let t = (tjm - T2000) / 36525.0; let t2 = t * t; @@ -455,7 +456,7 @@ pub fn nutn80(tjm: f64) -> (ArcSec, ArcSec) { /// * [`obleq`] – computes the mean obliquity ε (radians) /// * [`rotmt`] – builds the individual axis rotation matrices /// * [`rotpn`](crate::ref_system::rotpn) – uses `rnut80` to transform between Equm and Equt systems -pub fn rnut80(tjm: f64) -> Matrix3 { +pub fn rnut80(tjm: MJDTT) -> Matrix3 { // Mean obliquity of the ecliptic at date (ε) let epsm = obleq(tjm); @@ -504,7 +505,7 @@ pub fn rnut80(tjm: f64) -> Matrix3 { /// # See also /// * [`obleq`] – Computes the mean obliquity of the ecliptic. /// * [`nutn80`] – Computes the 1980 IAU nutation model (Δψ and Δε). -pub fn equequ(tjm: f64) -> f64 { +pub fn equequ(tjm: MJDTT) -> Radians { // Compute the mean obliquity of the ecliptic (ε, in radians) let oblm = obleq(tjm); @@ -557,7 +558,7 @@ pub fn equequ(tjm: f64) -> f64 { /// # See also /// * [`rotmt`] – constructs the rotation matrices used here /// * [`rotpn`](crate::ref_system::rotpn) – uses `prec` when converting between epochs `"OFDATE"` and `"J2000"` -pub fn prec(tjm: f64) -> Matrix3 { +pub fn prec(tjm: MJDTT) -> Matrix3 { // Precession polynomial coefficients (in radians) let zed = 0.6406161 * RADEG; let zd = 0.6406161 * RADEG; diff --git a/src/env_state.rs b/src/env_state.rs deleted file mode 100644 index 67bb706..0000000 --- a/src/env_state.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! # Outfit environment state -//! -//! This module defines [`crate::env_state::OutfitEnv`], the **shared environment object** used across -//! the `Outfit` library. It provides access to: -//! -//! - A persistent **HTTP client** (for downloading ephemerides, observatory lists, etc.). -//! - A **UT1 provider** from [hifitime](https://docs.rs/hifitime) to handle Earth rotation -//! parameters from JPL. -//! -//! This object is designed to be **cheaply cloneable** and passed to algorithms -//! that require access to external data sources or Earth orientation models. -//! -//! ## Overview -//! -//! The main responsibilities of `OutfitEnv` are: -//! -//! 1. Manage a global [`ureq::Agent`] HTTP client with sensible default settings. -//! 2. Download and initialize an [`hifitime::ut1::Ut1Provider`] from JPL’s `latest_eop2.long` file -//! (Earth orientation parameters) at startup. -//! 3. Provide simple utilities for performing HTTP GET requests. -//! -//! ## Structure -//! -//! ```text -//! OutfitEnv -//! ├── http_client (ureq::Agent) -//! └── ut1_provider (hifitime::Ut1Provider) -//! ``` -//! -//! ## Usage -//! -//! ```rust,ignore -//! use outfit::env_state::OutfitEnv; -//! -//! // Create a new environment (downloads UT1 data from JPL) -//! let env = OutfitEnv::new(); -//! -//! // Access the UT1 provider -//! let ut1 = &env.ut1_provider; -//! -//! // Make a GET request using the built-in HTTP client -//! let response = env.get_from_url("https://ssd.jpl.nasa.gov/api/horizons.api"); -//! println!("Response: {}", &response[..100.min(response.len())]); -//! ``` -//! -//! ## Notes -//! -//! - The [`crate::env_state::OutfitEnv`] struct is meant to be reused and shared between different -//! parts of the crate to avoid redundant downloads and HTTP session creation. -//! - The UT1 provider is initialized once at startup; if fresh data is needed, -//! the library must be restarted or the provider re-downloaded manually. -//! -//! ## See also -//! -//! - [`hifitime::ut1::Ut1Provider`] – Manages Earth orientation and UT1 corrections. -//! - [`ureq::Agent`] – Minimal HTTP client used internally. -use hifitime::ut1::Ut1Provider; -use std::convert::TryFrom; -use std::{fmt::Debug, time::Duration}; -use ureq::{ - http::{self, Uri}, - Agent, -}; - -/// This object is passed to the various functions in the library -/// to provide access to the state of the library -/// -/// # Fields -/// -/// * `http_client` - A reqwest client used to make HTTP requests -/// * `ut1_provider` - A provider used to get the current UT1 time -/// * `observatories` - A lazy map of observatories from the Minor Planet Center. -/// The key is the MPC code and the value is the observer -#[derive(Debug, Clone)] -pub struct OutfitEnv { - pub http_client: Agent, - pub ut1_provider: Ut1Provider, -} - -impl Default for OutfitEnv { - fn default() -> Self { - Self::new() - } -} - -impl OutfitEnv { - /// Create a new Outfit object - /// - /// Return - /// ------ - /// * A new Outfit object - /// - The UT1 provider is downloaded from the JPL - /// - The HTTP client is created with default settings - /// - The observatories are lazily loaded from the Minor Planet Center - pub fn new() -> Self { - let ut1_provider = OutfitEnv::initialize_ut1_provider(); - - let config = Agent::config_builder() - .timeout_global(Some(Duration::from_secs(10))) - .build(); - let agent: Agent = config.into(); - - OutfitEnv { - http_client: agent, - ut1_provider, - } - } - - fn initialize_ut1_provider() -> Ut1Provider { - Ut1Provider::download_from_jpl("latest_eop2.long") - .expect("Download of the JPL short time scale UT1 data failed") - } - - pub(crate) fn get_from_url(&self, url: U) -> String - where - Uri: TryFrom, - >::Error: Into, - { - self.http_client - .get(url) - .call() - .expect("Get request failed") - .body_mut() - .read_to_string() - .expect("Failed to read response body") - } -} diff --git a/src/ephemeris/aberration.rs b/src/ephemeris/aberration.rs new file mode 100644 index 0000000..ceaa4de --- /dev/null +++ b/src/ephemeris/aberration.rs @@ -0,0 +1,234 @@ +//! Stellar aberration corrections for apparent-position computation. +//! +//! This module provides two levels of aberration correction: +//! +//! | Function | Description | +//! |---|---| +//! | [`correct_aberration_first_order`] | Linear light-travel-time shift | +//! | [`correct_aberration_second_order`] | Two-step Keplerian back-propagation | +//! +//! The correction to apply is selected at call-site via +//! [`AberrationOrder`](super::AberrationOrder). +//! +//! # Physical background +//! +//! When an observer detects a photon, the emitting body has already moved on. +//! The apparent direction corresponds to the body's position at the **retarded +//! epoch** — the moment the photon was emitted, not the moment it was received. +//! +//! Both corrections account for this light-travel delay; they differ in how +//! accurately they approximate the retarded position: +//! +//! - **First order** computes the delay from the instantaneous separation and +//! subtracts a *linear* displacement along the current velocity. Accurate to +//! \\( O(v/c) \\). +//! +//! - **Second order** iterates the delay twice and back-propagates the orbit +//! along the **Keplerian two-body solution** at each step, capturing the +//! orbital curvature during the light-travel time. Necessary for sub-mas +//! accuracy on close-approach objects or highly curved orbits. +//! +//! # Coordinate conventions +//! +//! All vectors are in the **equatorial mean J2000** frame, positions in AU, +//! velocities in AU/day. + +use nalgebra::Vector3; + +use crate::{constants::ROT_ECLMJ2000_TO_EQUMJ2000, EquinoctialElements, OutfitError, VLIGHT_AU}; + +// --------------------------------------------------------------------------- +// Aberration order +// --------------------------------------------------------------------------- + +/// Stellar aberration correction order applied during apparent-position +/// computation. +/// +/// Controls whether the pipeline uses the fast linear approximation or the +/// more accurate two-step Keplerian back-propagation. +/// +/// For the vast majority of targets (main-belt asteroids, typical NEOs) the +/// difference between the two corrections is sub-milliarcsecond and +/// [`AberrationOrder::First`] is sufficient. [`AberrationOrder::Second`] +/// becomes relevant for close-approach objects (geocentric distance +/// \\( \lesssim 0.01 \\) AU) or highly curved orbits where the linear +/// approximation breaks down. +/// +/// See `correct_aberration_first_order` and `correct_aberration_second_order` +/// for the implementation of both corrections. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum AberrationOrder { + /// First-order correction: linear light-travel-time shift. + /// + /// $$\mathbf{d}_\text{corr} = \mathbf{d} - \frac{|\mathbf{d}|}{c}\,\mathbf{v}_\text{body}$$ + /// + /// Fast and self-contained — requires only the instantaneous body velocity. + #[default] + First, + + /// Second-order correction: two-step Keplerian back-propagation. + /// + /// Iterates the light-travel delay twice and back-propagates the orbit via + /// the analytic two-body solution at each step. More accurate for objects + /// at short geocentric distance or with high orbital curvature. + Second, +} + +impl AberrationOrder { + /// Dispatch aberration correction to the first-order or second-order function. + /// + /// Exists as a dedicated helper to keep `apparent_position_from_state` free of + /// match boilerplate and to give the dispatch a named entry point for testing. + /// + /// # Errors + /// + /// Forwards any error from [`correct_aberration_second_order`]. + pub(crate) fn apply_aberration( + &self, + topocentric_vec: Vector3, + ast_vel_equ: Vector3, + elements: &EquinoctialElements, + obs_time_mjd: f64, + obs_pos_equ: Vector3, + ) -> Result, OutfitError> { + match self { + AberrationOrder::First => { + Ok(correct_aberration_first_order(topocentric_vec, ast_vel_equ)) + } + AberrationOrder::Second => correct_aberration_second_order( + topocentric_vec, + elements, + obs_time_mjd, + obs_pos_equ, + ), + } + } +} + +// --------------------------------------------------------------------------- +// First-order correction +// --------------------------------------------------------------------------- + +/// Apply the **first-order** stellar aberration correction to a topocentric +/// position vector. +/// +/// The apparent direction is obtained by subtracting the linear displacement +/// of the body during the light-travel time \\( \Delta t = |\mathbf{d}| / c \\): +/// +/// $$\mathbf{d}_\text{corr} = \mathbf{d} - \frac{|\mathbf{d}|}{c}\,\mathbf{v}_\text{body}$$ +/// +/// where \\( c \\) is the speed of light in AU/day ([`VLIGHT_AU`]). +/// +/// This approximation is valid for the vast majority of solar system targets +/// (main-belt asteroids, typical NEOs). The residual error with respect to +/// the exact retarded position is of order \\( O((v/c)^2) \\), sub-mas for +/// most objects. +/// +/// # Arguments +/// +/// - `topocentric_vec` – Vector from observer to body \\( \mathbf{d} \\) \[AU\], +/// equatorial mean J2000. +/// - `body_velocity` – Body heliocentric velocity \\( \mathbf{v} \\) \[AU/day\], +/// equatorial mean J2000. +/// +/// # Returns +/// +/// Aberration-corrected topocentric direction vector \[AU\]. The magnitude is +/// slightly different from the input; only the direction is used downstream. +#[inline] +pub(crate) fn correct_aberration_first_order( + topocentric_vec: Vector3, + body_velocity: Vector3, +) -> Vector3 { + let dt = topocentric_vec.norm() / VLIGHT_AU; + topocentric_vec - dt * body_velocity +} + +// --------------------------------------------------------------------------- +// Second-order correction +// --------------------------------------------------------------------------- + +/// Apply the **second-order** stellar aberration correction via two-step +/// Keplerian back-propagation. +/// +/// Rather than shifting the current position linearly, this function +/// propagates the orbit *backwards* by the estimated light-travel time, twice +/// in succession, to recover the retarded position with sub-mas accuracy. +/// +/// ## Algorithm +/// +/// Let \\( \mathbf{d}_0 = \mathbf{r}_\text{body} - \mathbf{r}_\text{obs} \\) +/// be the instantaneous topocentric vector. +/// +/// **Pass 1:** +/// $$\Delta t_0 = |\mathbf{d}_0| / c$$ +/// $$\mathbf{r}_1 = \text{propagate\_twobody}(t_\text{obs} - \Delta t_0)$$ +/// $$\mathbf{d}_1 = \mathbf{r}_1 - \mathbf{r}_\text{obs}$$ +/// +/// **Pass 2:** +/// $$\Delta t_1 = |\mathbf{d}_1| / c$$ +/// $$\mathbf{r}_2 = \text{propagate\_twobody}(t_\text{obs} - \Delta t_1)$$ +/// +/// **Result:** +/// $$\mathbf{d}_\text{corr} = \mathbf{r}_2 - \mathbf{r}_\text{obs}$$ +/// +/// The two-body propagator is used for both passes regardless of the main +/// propagator choice in [`EphemerisConfig`](super::EphemerisConfig). +/// +/// # Arguments +/// +/// - `topocentric_vec` – Instantaneous topocentric vector \\( \mathbf{d}_0 \\) +/// \[AU\], equatorial mean J2000. +/// - `elements` – Equinoctial orbital elements at their reference epoch. +/// - `obs_time_mjd` – Observation epoch \[MJD TT\]. +/// - `obs_pos_equ` – Observer heliocentric position \[AU\], equatorial +/// mean J2000. +/// +/// # Returns +/// +/// Aberration-corrected topocentric direction vector \[AU\]. +/// +/// # Errors +/// +/// Returns [`OutfitError`] if either two-body propagation step fails to +/// converge (e.g. degenerate orbit). +pub(crate) fn correct_aberration_second_order( + topocentric_vec: Vector3, + elements: &EquinoctialElements, + obs_time_mjd: f64, + obs_pos_equ: Vector3, +) -> Result, OutfitError> { + let r1 = retropropagate(elements, obs_time_mjd, topocentric_vec.norm())?; + let d1 = r1 - obs_pos_equ; + + let r2 = retropropagate(elements, obs_time_mjd, d1.norm())?; + + Ok(r2 - obs_pos_equ) +} + +// --------------------------------------------------------------------------- +// Private helper +// --------------------------------------------------------------------------- + +/// Back-propagate the orbit by a light-travel time derived from `separation` +/// and return the body's heliocentric position at the retarded epoch, in the +/// **equatorial mean J2000** frame \[AU\]. +/// +/// Computes the retarded epoch as +/// \\( t_\text{ret} = t_\text{obs} - |\text{separation}| / c \\) +/// and calls [`EquinoctialElements::propagate_twobody`]. +/// +/// # Errors +/// +/// Returns [`OutfitError`] if the Kepler solver does not converge. +fn retropropagate( + elements: &EquinoctialElements, + obs_time_mjd: f64, + separation: f64, +) -> Result, OutfitError> { + let dt_light = separation / VLIGHT_AU; + let t_retarded = obs_time_mjd - dt_light; + let dt_orbit = t_retarded - elements.reference_epoch; + let (pos_ecl, _, _) = elements.propagate_twobody(0.0, dt_orbit, false)?; + Ok(ROT_ECLMJ2000_TO_EQUMJ2000 * pos_ecl) +} diff --git a/src/ephemeris/apparent_position.rs b/src/ephemeris/apparent_position.rs new file mode 100644 index 0000000..232e7d2 --- /dev/null +++ b/src/ephemeris/apparent_position.rs @@ -0,0 +1,357 @@ +//! Standalone apparent-position computation for a solar system body. +//! +//! This module provides [`ApparentPosition`] as the public return type and a set +//! of small, composable helper functions that together implement the pipeline: +//! +//! ```text +//! OrbitalElements → propagate → topocentric geometry → aberration → (RA, Dec) +//! ``` +//! +//! # Coordinate conventions +//! +//! - Positions in **AU**, velocities in **AU/day**. +//! - Intermediate frames: **ecliptic mean J2000**. +//! - Final output: **equatorial mean J2000** (RA, Dec in radians). +//! - Time: **MJD TT**. +//! +//! # Pipeline steps +//! +//! | Step | Function / Method | Purpose | +//! |------|-------------------|---------| +//! | 1 | [`obs_time_to_epoch`] | Convert MJD TT scalar → [`hifitime::Epoch`] | +//! | 2 | [`PropagatorKind::propagate_to_epoch`](crate::propagator::PropagatorKind::propagate_to_epoch) | Propagate orbit; rotate to equatorial J2000 | +//! | 3 | [`observer_pv`] | Resolve observer heliocentric position **and velocity** | +//! | 4 | [`assemble_apparent_position`] | Compute topocentric vector, apply aberration, convert to (RA, Dec) | +//! +//! # Aberration model +//! +//! The first-order stellar aberration correction shifts the topocentric +//! line-of-sight vector by +//! +//! $$\mathbf{x}\_\text{corr} = \mathbf{x}\_\text{topo} +//! - \frac{|\mathbf{x}\_\text{topo}|}{c}\\,\mathbf{v}\_\text{body}$$ +//! +//! where $c$ is the speed of light in AU/day and +//! $\mathbf{v}\_\text{body}$ is the body's heliocentric velocity. + +use hifitime::{ut1::Ut1Provider, Epoch, TimeScale}; +use nalgebra::Vector3; +use photom::{ + coordinates::{cartesian::CartesianCoord, equatorial::EquCoord}, + observer::Observer, +}; + +use crate::{ + cache::observer_fixed_cache::ObserverFixedCache, constants::ROT_ECLMJ2000_TO_EQUMJ2000, + conversion::ToNotNan, observer_extension::ResolvedObserver, EquinoctialElements, JPLEphem, + OutfitError, +}; + +use super::{AberrationOrder, EphemerisConfig}; + +// --------------------------------------------------------------------------- +// Public return type +// --------------------------------------------------------------------------- + +/// Predicted apparent position of a solar system body together with geometric +/// distances. +/// +/// The `coord` field contains the predicted equatorial sky position in the +/// **equatorial mean J2000** frame. Because this is a *prediction* rather than +/// a measurement, the error fields `ra_error` and `dec_error` inside `coord` +/// are always set to `0.0`. +/// +/// Distances are computed from the unaberrated heliocentric state before the +/// aberration correction is applied to the line-of-sight direction. +#[derive(Debug, Clone, PartialEq)] +pub struct ApparentPosition { + /// Predicted equatorial coordinates (RA ∈ \[0, 2π), Dec ∈ (−π/2, π/2), + /// both in radians; error fields = 0 because this is a prediction). + pub coord: EquCoord, + /// Distance from the **Earth's centre** to the body \[AU\]. + /// + /// Computed as $|\mathbf{r}\_\text{body} - \mathbf{r}\_\text{Earth}|$. + pub geocentric_dist: f64, + /// Distance from the **Sun** to the body \[AU\]. + /// + /// Computed as $|\mathbf{r}\_\text{body}|$ (heliocentric norm). + pub heliocentric_dist: f64, +} + +// --------------------------------------------------------------------------- +// Intermediate propagated state (shared with geometry module) +// --------------------------------------------------------------------------- + +/// Full propagated state at a given epoch, shared between position and geometry +/// computations. +/// +/// Holding this intermediate result allows [`compute_with_geometry`] to run a +/// single orbit propagation and observer-position query and hand the results to +/// both [`assemble_apparent_position`] and +/// [`super::geometry::compute_geometry`] without redundant work. +pub(crate) struct PropagatedState { + /// Body heliocentric position \[AU\], equatorial mean J2000. + pub ast_pos_equ: Vector3, + /// Body heliocentric velocity \[AU/day\], equatorial mean J2000. + pub ast_vel_equ: Vector3, + /// Observer heliocentric position \[AU\], equatorial mean J2000. + pub obs_pos_equ: Vector3, + /// Observer heliocentric velocity \[AU/day\], equatorial mean J2000. + pub obs_vel_equ: Vector3, + /// Earth heliocentric position \[AU\], equatorial mean J2000. + pub earth_pos_equ: Vector3, + /// Observation epoch as MJD TT scalar (carried for second-order aberration). + pub obs_time_mjd: f64, +} + +// --------------------------------------------------------------------------- +// Internal helpers — propagation and observer geometry +// --------------------------------------------------------------------------- + +/// Propagate the orbit and resolve observer geometry, producing a +/// [`PropagatedState`]. +/// +/// This is the shared kernel called by [`compute`] and [`compute_with_geometry`]. +/// +/// # Arguments +/// +/// - `elements` – Equinoctial orbital elements. +/// - `obs_time_mjd` – Observation epoch \[MJD TT\]. +/// - `fixed_cache` – Pre-built body-fixed observer cache (epoch-invariant). +/// Must have been constructed from the same [`Observer`] that owns this +/// request slot. Building it once per observer slot and reusing it across +/// all epochs avoids redundant trigonometric conversions. +/// - `observer` – Observing site, used only to attach to the result. +/// - `jpl` – JPL planetary ephemeris. +/// - `ut1` – UT1 time-scale provider. +/// - `config` – Ephemeris configuration (propagator, aberration). +/// +/// # Errors +/// +/// Returns [`OutfitError`] if: +/// - Orbit propagation fails. +/// - The JPL ephemeris data is unavailable for the requested epoch. +/// - The observer geometry cannot be resolved. +pub(crate) fn propagate( + elements: &EquinoctialElements, + obs_time_mjd: f64, + fixed_cache: &ObserverFixedCache, + jpl: &JPLEphem, + ut1: &Ut1Provider, + config: &EphemerisConfig, +) -> Result { + let epoch = obs_time_to_epoch(obs_time_mjd); + let (ast_pos_equ, ast_vel_equ) = + config + .propagator + .propagate_to_epoch(elements, obs_time_mjd, jpl)?; + let (obs_pos_equ, obs_vel_equ, earth_pos_equ) = observer_pv(fixed_cache, jpl, ut1, &epoch)?; + + Ok(PropagatedState { + ast_pos_equ, + ast_vel_equ, + obs_pos_equ, + obs_vel_equ, + earth_pos_equ, + obs_time_mjd, + }) +} + +// --------------------------------------------------------------------------- +// Entry points +// --------------------------------------------------------------------------- + +/// Compute the apparent equatorial position of a solar system body. +/// +/// This is the main entry point called by +/// [`OrbitalElements::apparent_position`](crate::OrbitalElements::apparent_position). +/// It propagates the orbit, resolves observer geometry, applies the aberration +/// correction and converts to equatorial coordinates. +/// +/// # Arguments +/// +/// - `elements` – Equinoctial orbital elements. +/// - `obs_time_mjd`– Observation epoch \[MJD TT\]. +/// - `fixed_cache` – Pre-built body-fixed observer cache (epoch-invariant). +/// - `jpl` – JPL planetary ephemeris. +/// - `ut1` – UT1 time-scale provider. +/// - `config` – Ephemeris configuration. +/// +/// # Errors +/// +/// Returns [`OutfitError`] if propagation or observer geometry fails. +pub(crate) fn compute( + elements: &EquinoctialElements, + obs_time_mjd: f64, + fixed_cache: &ObserverFixedCache, + jpl: &JPLEphem, + ut1: &Ut1Provider, + config: &EphemerisConfig, +) -> Result { + let state = propagate(elements, obs_time_mjd, fixed_cache, jpl, ut1, config)?; + assemble_apparent_position(&state, elements, &config.aberration) +} + +/// Compute both the apparent position and the body geometry in a single +/// propagation pass. +/// +/// Called by +/// [`OrbitalElements::apparent_position_and_geometry`](crate::OrbitalElements::apparent_position_and_geometry). +/// The orbit is propagated and observer geometry resolved exactly once; the +/// resulting [`PropagatedState`] is then handed to both +/// [`assemble_apparent_position`] and +/// [`super::geometry::compute_geometry`]. +/// +/// # Arguments +/// +/// - `elements` – Equinoctial orbital elements. +/// - `obs_time_mjd`– Observation epoch \[MJD TT\]. +/// - `fixed_cache` – Pre-built body-fixed observer cache (epoch-invariant). +/// - `jpl` – JPL planetary ephemeris. +/// - `ut1` – UT1 time-scale provider. +/// - `config` – Ephemeris configuration. +/// +/// # Errors +/// +/// Returns [`OutfitError`] if propagation or either assembly step fails. +pub(crate) fn compute_with_geometry( + elements: &EquinoctialElements, + obs_time_mjd: f64, + fixed_cache: &ObserverFixedCache, + jpl: &JPLEphem, + ut1: &Ut1Provider, + config: &EphemerisConfig, +) -> Result<(ApparentPosition, super::geometry::BodyGeometry), OutfitError> { + let state = propagate(elements, obs_time_mjd, fixed_cache, jpl, ut1, config)?; + let position = assemble_apparent_position(&state, elements, &config.aberration)?; + let geometry = super::geometry::compute_geometry(&state, elements, &config.aberration)?; + Ok((position, geometry)) +} + +// --------------------------------------------------------------------------- +// Step 1 – epoch conversion +// --------------------------------------------------------------------------- + +/// Convert a scalar MJD TT value to a [`hifitime::Epoch`]. +/// +/// The time scale is fixed to [`TimeScale::TT`] (Terrestrial Time), which is +/// the time argument used throughout the library for orbit propagation and +/// ephemeris lookups. +#[inline] +fn obs_time_to_epoch(obs_time_mjd: f64) -> Epoch { + Epoch::from_mjd_in_time_scale(obs_time_mjd, TimeScale::TT) +} + +// --------------------------------------------------------------------------- +// Step 3 – observer position and velocity +// --------------------------------------------------------------------------- + +/// Compute the observer's heliocentric position **and velocity**, the Earth's +/// heliocentric position, all in the equatorial mean J2000 frame. +/// +/// # Pre-condition — `fixed_cache` is epoch-invariant +/// +/// `fixed_cache` must have been constructed once per observer slot (before the +/// epoch loop) via [`ObserverFixedCache::try_from`]. Passing it in avoids +/// rebuilding the body-fixed geocentric coordinates (sin/cos of longitude, ρ +/// factors) for every epoch. +/// +/// # Returns +/// +/// `(obs_pos_equ [AU], obs_vel_equ [AU/day], earth_pos_equ [AU])`. +/// +/// # Errors +/// +/// Returns [`OutfitError`] if the geocentric position computation +/// (`pvobs`) fails or a NaN conversion fails. +type ObserverPv = (Vector3, Vector3, Vector3); + +fn observer_pv( + fixed_cache: &ObserverFixedCache, + jpl: &JPLEphem, + ut1: &Ut1Provider, + epoch: &Epoch, +) -> Result { + // Geocentric position in ecliptic J2000 (velocity not needed here — site + // rotation is accounted for via earth_vel from JPL in the velocity path). + let (geo_pos_ecl, _) = Observer::pvobs(epoch, ut1, fixed_cache, false)?; + + // Single JPL Chebyshev evaluation for Earth's heliocentric state. + let (earth_pos_equ_raw, earth_vel_opt) = jpl.earth_ephemeris(epoch, true); + let earth_vel_equ_raw = earth_vel_opt + .expect("JPL earth_ephemeris with compute_velocity=true must return a velocity"); + + // Rotation matrix ecliptic → equatorial J2000 (static const, evaluated once). + let rot = ROT_ECLMJ2000_TO_EQUMJ2000.to_notnan()?; + + // obs_pos_equ = earth_pos + ROT_ecl→equ * geo_pos_ecl + let obs_pos_equ: Vector3 = (earth_pos_equ_raw.to_notnan()? + rot * geo_pos_ecl) + .map(|x: ordered_float::NotNan| x.into_inner()); + + // earth_pos_equ: used in assemble_apparent_position for geocentric distance. + let earth_pos_equ = earth_pos_equ_raw; + + // obs_vel_equ = earth_vel + ROT_ecl→equ * geo_vel_ecl + let obs_vel_equ = earth_vel_equ_raw.to_notnan()?.map(|x| x.into_inner()); + + Ok((obs_pos_equ, obs_vel_equ, earth_pos_equ)) +} + +// --------------------------------------------------------------------------- +// Step 4 – apparent position assembly +// --------------------------------------------------------------------------- + +/// Assemble the [`ApparentPosition`] from a [`PropagatedState`]. +/// +/// The function performs the following sub-steps: +/// +/// 1. **Distances** — compute heliocentric and geocentric distances. +/// 2. **Topocentric vector** — $\mathbf{d} = \mathbf{r}\_\text{body} - \mathbf{r}\_\text{obs}$. +/// 3. **Aberration correction** — first-order or second-order, per `aberration`. +/// 4. **Sky coordinates** — convert the corrected direction to (RA, Dec). +/// +/// # Errors +/// +/// Returns [`OutfitError`] if the second-order aberration back-propagation +/// fails to converge. The first-order path is always infallible. +pub(crate) fn assemble_apparent_position( + state: &PropagatedState, + elements: &EquinoctialElements, + aberration: &AberrationOrder, +) -> Result { + let heliocentric_dist = state.ast_pos_equ.norm(); + let geocentric_dist = (state.ast_pos_equ - state.earth_pos_equ).norm(); + + let topocentric_vec = state.ast_pos_equ - state.obs_pos_equ; + let corrected = aberration.apply_aberration( + topocentric_vec, + state.ast_vel_equ, + elements, + state.obs_time_mjd, + state.obs_pos_equ, + )?; + let coord = cartesian_to_equcoord(corrected); + + Ok(ApparentPosition { + coord, + geocentric_dist, + heliocentric_dist, + }) +} + +/// Convert a Cartesian direction vector to an [`EquCoord`] (RA, Dec in radians; +/// error fields set to `0.0`). +/// +/// Delegates to [`CartesianCoord`] → [`EquCoord`] conversion, which computes: +/// $$\alpha = \operatorname{atan2}(y, x) \bmod 2\pi, \quad +/// \delta = \operatorname{atan2}\left(z,\\,\sqrt{x^2+y^2}\right)$$ +/// +/// The magnitude of `v` is irrelevant; only the direction is used. +#[inline] +pub(crate) fn cartesian_to_equcoord(v: Vector3) -> EquCoord { + EquCoord::from(CartesianCoord { + x: v[0], + y: v[1], + z: v[2], + }) +} diff --git a/src/ephemeris/batch.rs b/src/ephemeris/batch.rs new file mode 100644 index 0000000..3cf6563 --- /dev/null +++ b/src/ephemeris/batch.rs @@ -0,0 +1,183 @@ +//! Batch ephemeris computation over a [`FullOrbitResult`]. +//! +//! This module exposes the [`FullOrbitResultExt`] extension trait, which adds +//! ephemeris generation methods directly to [`FullOrbitResult`]. +//! +//! # Sequential vs parallel +//! +//! Two methods are available: +//! +//! | Method | Feature gate | Description | +//! |---|---|---| +//! | [`compute_ephemerides`](FullOrbitResultExt::compute_ephemerides) | *(none)* | Sequential iteration over all orbits | +//! | [`compute_ephemerides_parallel`](FullOrbitResultExt::compute_ephemerides_parallel) | `parallel` | Rayon-parallel iteration over all orbits | +//! +//! # Return type +//! +//! Both methods return a +//! `HashMap, OutfitError>, RandomState>`: +//! +//! - `Ok(EphemerisResult<…>)` — the orbit was successfully determined **and** +//! ephemeris computation was requested. Note that individual +//! `(epoch, observer)` pairs inside the [`EphemerisResult`] may still carry +//! per-entry errors. +//! - `Err(OutfitError)` — the orbit determination itself failed for this +//! trajectory; no ephemeris can be produced. +//! +//! # Example +//! +//! ```rust,ignore +//! use outfit::{FullOrbitResultExt, EphemerisRequest, EphemerisConfig, Combined}; +//! +//! // `full_orbit_result` is a FullOrbitResult from fit_full_iod / fit_full_lsq +//! let ephemerides = full_orbit_result.compute_ephemerides( +//! &EphemerisRequest::::new(EphemerisConfig::default()) +//! .add(observer, EphemerisMode::Range { start, end, step }), +//! &jpl, +//! &ut1, +//! ); +//! +//! for (traj_id, result) in &ephemerides { +//! match result { +//! Ok(ephem) => println!("{traj_id:?}: {} entries", ephem.len()), +//! Err(e) => eprintln!("{traj_id:?}: orbit error — {e}"), +//! } +//! } +//! ``` + +use std::collections::HashMap; + +use ahash::RandomState; +use photom::TrajId; + +#[cfg(feature = "parallel")] +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; + +use crate::{ + constants::FullOrbitResult, + ephemeris::{EphemerisOutputKind, EphemerisRequest, EphemerisResult}, + JPLEphem, OutfitError, +}; +use hifitime::ut1::Ut1Provider; + +// --------------------------------------------------------------------------- +// FullOrbitResultExt +// --------------------------------------------------------------------------- + +/// Extension trait that adds batch ephemeris generation to [`FullOrbitResult`]. +/// +/// Import this trait to call +/// [`compute_ephemerides`](Self::compute_ephemerides) (and, with the +/// `parallel` feature, [`compute_ephemerides_parallel`](Self::compute_ephemerides_parallel)) +/// on any [`FullOrbitResult`] value. +pub trait FullOrbitResultExt { + /// Compute ephemerides for every orbit in the map, sequentially. + /// + /// Iterates over all `(traj_id, orbit_result)` pairs: + /// + /// - If the orbit determination **succeeded** (`Ok`), the orbital elements + /// are used to evaluate the full [`EphemerisRequest`] and the result is + /// stored as `Ok(EphemerisResult<…>)`. + /// - If the orbit determination **failed** (`Err`), the error is forwarded + /// as-is (converted to a string and re-wrapped) so the caller always + /// gets a complete map keyed by every [`TrajId`] in the input. + /// + /// # Arguments + /// + /// - `request` — typed ephemeris request (observers, modes, config). + /// - `jpl` — JPL planetary ephemeris. + /// - `ut1` — UT1 time-scale provider. + /// + /// # Returns + /// + /// A `HashMap, OutfitError>, RandomState>` + /// with the same key set as `self`. + fn compute_ephemerides( + &self, + request: &EphemerisRequest, + jpl: &JPLEphem, + ut1: &Ut1Provider, + ) -> HashMap, OutfitError>, RandomState>; + + /// Compute ephemerides for every orbit in the map, **in parallel**. + /// + /// Identical to [`compute_ephemerides`](Self::compute_ephemerides) but + /// uses Rayon to process trajectories concurrently. Each trajectory is + /// independent, so there are no ordering guarantees on the map entries. + /// + /// Enabled only with the `parallel` feature flag. + /// + /// # Arguments + /// + /// Same as [`compute_ephemerides`](Self::compute_ephemerides). + /// + /// # Returns + /// + /// Same type as [`compute_ephemerides`](Self::compute_ephemerides). + #[cfg(feature = "parallel")] + fn compute_ephemerides_parallel( + &self, + request: &EphemerisRequest, + jpl: &JPLEphem, + ut1: &Ut1Provider, + ) -> HashMap, OutfitError>, RandomState> + where + O: EphemerisOutputKind + Send + Sync, + O::Output: Send; +} + +// --------------------------------------------------------------------------- +// impl for FullOrbitResult +// --------------------------------------------------------------------------- + +impl FullOrbitResultExt for FullOrbitResult { + fn compute_ephemerides( + &self, + request: &EphemerisRequest, + jpl: &JPLEphem, + ut1: &Ut1Provider, + ) -> HashMap, OutfitError>, RandomState> { + let mut map = HashMap::with_capacity_and_hasher(self.len(), RandomState::new()); + + for (traj_id, orbit_result) in self { + let entry = match orbit_result { + Ok(fit) => Ok(fit.orbital_elements().compute(request, jpl, ut1)), + Err(e) => Err(OutfitError::InvalidConversion(e.to_string())), + }; + map.insert(traj_id.clone(), entry); + } + + map + } + + #[cfg(feature = "parallel")] + fn compute_ephemerides_parallel( + &self, + request: &EphemerisRequest, + jpl: &JPLEphem, + ut1: &Ut1Provider, + ) -> HashMap, OutfitError>, RandomState> + where + O: EphemerisOutputKind + Send + Sync, + O::Output: Send, + { + let new_map = || HashMap::with_hasher(RandomState::new()); + + self.par_iter() + .map(|(traj_id, orbit_result)| { + let entry = match orbit_result { + Ok(fit) => Ok(fit.orbital_elements().compute(request, jpl, ut1)), + Err(e) => Err(OutfitError::InvalidConversion(e.to_string())), + }; + (traj_id.clone(), entry) + }) + .fold(new_map, |mut map, (k, v)| { + map.insert(k, v); + map + }) + .reduce(new_map, |mut a, b| { + a.extend(b); + a + }) + } +} diff --git a/src/ephemeris/geometry.rs b/src/ephemeris/geometry.rs new file mode 100644 index 0000000..ae0ed84 --- /dev/null +++ b/src/ephemeris/geometry.rs @@ -0,0 +1,345 @@ +//! Geometric quantities derived from the apparent-position pipeline. +//! +//! This module sits inside the ephemeris pipeline and is responsible for +//! computing the five observational quantities stored in [`BodyGeometry`]: +//! phase angle, solar elongation, radial velocity, and the apparent angular +//! rates in right ascension and declination. All quantities are derived from +//! the same [`PropagatedState`] that feeds [`ApparentPosition`], so the two +//! types share a single orbit propagation when computed together. +//! +//! # Role in the ephemeris pipeline +//! +//! ```text +//! OrbitalElements +//! │ +//! ▼ propagate (TwoBody or NBody) +//! PropagatedState [equatorial mean J2000] +//! │ +//! ├──────────────────────────────────────────────────┐ +//! ▼ apparent_position::assemble_apparent_position ▼ geometry::compute_geometry +//! ApparentPosition BodyGeometry +//! { coord, geocentric_dist, { phase_angle, solar_elongation, +//! heliocentric_dist } radial_velocity, d_ra_dt, d_dec_dt } +//! ``` +//! +//! # Coordinate frame +//! +//! All vectors consumed by this module — body position, observer position, and +//! their velocities — are expressed in the **equatorial mean J2000** frame +//! (the same frame used for the final [`ApparentPosition`] output). +//! Positions are in **AU**, velocities in **AU/day**, and angles in **radians**. +//! +//! # Relationship to [`ApparentPosition`] +//! +//! Both types are computed from the same [`PropagatedState`]. When both are +//! needed, use [`OrbitalElements::apparent_position_and_geometry`] to avoid +//! propagating the orbit twice. When only one is needed, use +//! [`OrbitalElements::apparent_position`] or +//! [`OrbitalElements::body_geometry`] respectively. +//! +//! # API +//! +//! Use [`crate::OrbitalElements::compute`] with [`crate::Geometry`] or +//! [`crate::Combined`] to obtain [`BodyGeometry`] values. + +use nalgebra::Vector3; + +use crate::{EquinoctialElements, OutfitError}; + +use super::{apparent_position::PropagatedState, AberrationOrder}; + +// --------------------------------------------------------------------------- +// BodyGeometry +// --------------------------------------------------------------------------- + +/// Geometric quantities for a solar system body at a given epoch. +/// +/// Returned as the payload of [`crate::EphemerisResult`] when using +/// [`crate::Geometry`] or [`crate::Combined`] as the output marker. +/// +/// All angles are in **radians**, velocities in **AU/day**. +/// +/// # Physical definitions +/// +/// | Field | Definition | +/// |---|---| +/// | [`phase_angle`](Self::phase_angle) | Angle at the body between the Sun and the observer: Sun–body–observer | +/// | [`solar_elongation`](Self::solar_elongation) | Angle at the observer between the Sun and the body: Sun–observer–body | +/// | [`radial_velocity`](Self::radial_velocity) | Rate of change of the observer–body distance | +/// | [`d_ra_dt`](Self::d_ra_dt) | Apparent angular rate in right ascension | +/// | [`d_dec_dt`](Self::d_dec_dt) | Apparent angular rate in declination | +/// +/// # Usage +/// +/// ```rust,ignore +/// use outfit::{Combined, EphemerisConfig, EphemerisMode, EphemerisRequest}; +/// use hifitime::Epoch; +/// +/// let times = vec![ +/// Epoch::from_mjd_tt(60310.0), +/// Epoch::from_mjd_tt(60320.0), +/// Epoch::from_mjd_tt(60330.0), +/// ]; +/// +/// let result = elements.compute( +/// &EphemerisRequest::::new(EphemerisConfig::default()) +/// .add(observer, EphemerisMode::At(times)), +/// &jpl, +/// &ut1, +/// ); +/// +/// for entry in result.successes() { +/// let (pos, geo) = entry.result.as_ref().unwrap(); +/// println!( +/// "{}: phase={:.4} elong={:.4} rv={:.6} dRA={:.6} dDec={:.6}", +/// entry.epoch, +/// geo.phase_angle, geo.solar_elongation, +/// geo.radial_velocity, geo.d_ra_dt, geo.d_dec_dt, +/// ); +/// } +/// ``` +/// +/// # See also +/// +/// - [`crate::Geometry`] — output marker for geometry-only requests. +/// - [`crate::Combined`] — output marker to get both position and geometry in one pass. +#[derive(Debug, Clone, PartialEq)] +pub struct BodyGeometry { + /// Phase angle — Sun–body–observer \[rad\], $\phi \in [0, \pi]$. + /// + /// $$\phi = \arccos\left(\frac{\mathbf{r}\_\text{body} \cdot \mathbf{d}}{r\_\text{helio}\\,\rho}\right)$$ + /// + /// where $\mathbf{d}$ is the aberration-corrected topocentric vector + /// and $\rho = |\mathbf{d}|$. + /// + /// - $\phi = 0$: body is in opposition (fully illuminated as seen by the observer). + /// - $\phi = \pi$: body is at superior conjunction (dark side facing the observer). + pub phase_angle: f64, + + /// Solar elongation — Sun–observer–body \[rad\], $\varepsilon \in [0, \pi]$. + /// + /// $$\varepsilon = \arccos\left(\frac{-\mathbf{r}\_\text{obs} \cdot \mathbf{d}}{|\mathbf{r}\_\text{obs}|\\,\rho}\right)$$ + /// + /// Small values indicate the body is close to the Sun on the sky and may + /// be difficult to observe. + pub solar_elongation: f64, + + /// Observer-relative radial velocity \[AU/day\]. + /// + /// $$\dot{\rho} = \frac{\mathbf{d} \cdot \mathbf{v}\_\text{topo}}{\rho}$$ + /// + /// where $\mathbf{v}\_\text{topo} = \mathbf{v}\_\text{body} - \mathbf{v}\_\text{obs}$ + /// is the true topocentric velocity (observer velocity from + /// [`ResolvedObserver::pvobs`](crate::observer_extension::ResolvedObserver::pvobs)). + /// + /// - Positive: body is receding from the observer. + /// - Negative: body is approaching the observer. + pub radial_velocity: f64, + + /// Apparent angular rate in right ascension \[rad/day\]. + /// + /// $$\dot{\alpha} = \frac{\partial\alpha}{\partial\mathbf{r}} \cdot \mathbf{v}\_\text{topo}$$ + /// + /// Positive eastward. Includes the true topocentric velocity of the observer. + pub d_ra_dt: f64, + + /// Apparent angular rate in declination \[rad/day\]. + /// + /// $$\dot{\delta} = \frac{\partial\delta}{\partial\mathbf{r}} \cdot \mathbf{v}\_\text{topo}$$ + /// + /// Positive northward. + pub d_dec_dt: f64, +} + +// --------------------------------------------------------------------------- +// Computation +// --------------------------------------------------------------------------- + +/// Compute the [`BodyGeometry`] from a [`PropagatedState`]. +/// +/// This function is the single entry point for geometry computation inside the +/// ephemeris pipeline. It executes the following steps in order: +/// +/// 1. **Aberration correction** — builds the raw topocentric vector +/// $\mathbf{d}_0 = \mathbf{r}_\text{body} - \mathbf{r}_\text{obs}$ +/// and applies either the first-order or the second-order stellar-aberration +/// model (controlled by `aberration`) to obtain the corrected line-of-sight +/// $\mathbf{d}$. +/// 2. **Norms** — computes the topocentric distance $\rho = |\mathbf{d}|$, +/// the heliocentric distance $r_\text{helio} = |\mathbf{r}_\text{body}|$, +/// and the observer–Sun distance $r_\text{obs} = |\mathbf{r}_\text{obs}|$. +/// 3. **Phase angle** $\phi$ — via [`phase_angle`] (Sun–body–observer, +/// dot product clamped before `acos`). +/// 4. **Solar elongation** $\varepsilon$ — via [`solar_elongation`] +/// (Sun–observer–body, same clamping strategy). +/// 5. **True topocentric velocity** — +/// $\mathbf{v}_\text{topo} = \mathbf{v}_\text{body} - \mathbf{v}_\text{obs}$. +/// 6. **Radial velocity** $\dot{\rho}$ — via [`radial_velocity`], the +/// projection of $\mathbf{v}_\text{topo}$ onto the line of sight. +/// 7. **Angular rates** $(\dot{\alpha}, \dot{\delta})$ — via +/// [`angular_rates`], using the geometric Jacobian of the spherical-coordinate +/// transform. +/// +/// All input vectors are expected in the **equatorial mean J2000** frame with +/// positions in AU and velocities in AU/day. The only additional cost over +/// computing [`ApparentPosition`] alone is one `acos` per angle and a few +/// dot products. +/// +/// # Arguments +/// +/// - `state` – Propagated body + observer state at the observation epoch. +/// - `elements` – Orbital elements (needed by second-order aberration). +/// - `aberration` – Aberration correction order. +/// +/// # Panics +/// +/// This function does not panic under any input. Potential division-by-zero +/// conditions (zero topocentric distance, body on the celestial pole) are +/// guarded by [`radial_velocity`] and [`angular_rates`]. +/// +/// # Errors +/// +/// Returns [`OutfitError`] if the second-order aberration back-propagation +/// fails. The first-order path is always infallible. +pub(crate) fn compute_geometry( + state: &PropagatedState, + elements: &EquinoctialElements, + aberration: &AberrationOrder, +) -> Result { + // Aberration-corrected topocentric vector and its norm. + let topo_raw = state.ast_pos_equ - state.obs_pos_equ; + let topo = aberration.apply_aberration( + topo_raw, + state.ast_vel_equ, + elements, + state.obs_time_mjd, + state.obs_pos_equ, + )?; + let rho = topo.norm(); + + let r_helio = state.ast_pos_equ.norm(); + let r_obs = state.obs_pos_equ.norm(); + + let phase_angle = phase_angle(state.ast_pos_equ, topo, r_helio, rho); + let solar_elongation = solar_elongation(state.obs_pos_equ, topo, r_obs, rho); + + // True topocentric velocity: body velocity minus observer velocity. + let v_topo = state.ast_vel_equ - state.obs_vel_equ; + + let radial_velocity = radial_velocity(topo, v_topo, rho); + let (d_ra_dt, d_dec_dt) = angular_rates(topo, v_topo, rho); + + Ok(BodyGeometry { + phase_angle, + solar_elongation, + radial_velocity, + d_ra_dt, + d_dec_dt, + }) +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +/// Compute the phase angle (Sun–body–observer) \[rad\]. +/// +/// The phase angle $\phi$ is the angle at the body between the direction +/// toward the Sun and the direction toward the observer. It determines the +/// fraction of the illuminated disk visible to the observer: $\phi = 0$ +/// corresponds to full illumination (opposition), $\phi = \pi$ to a dark +/// face (superior conjunction). +/// +/// $$\phi = \arccos\left(\frac{\mathbf{r}_\text{body} \cdot \mathbf{d}}{r_\text{helio}\,\rho}\right)$$ +/// +/// The cosine argument is clamped to $[-1, 1]$ before calling `acos` to +/// guard against floating-point rounding that would otherwise produce `NaN` +/// when the body is exactly at opposition or conjunction. +#[inline] +fn phase_angle(ast_pos: Vector3, topo: Vector3, r_helio: f64, rho: f64) -> f64 { + let cos_phi = (ast_pos.dot(&topo) / (r_helio * rho)).clamp(-1.0, 1.0); + cos_phi.acos() +} + +/// Compute the solar elongation (Sun–observer–body) \[rad\]. +/// +/// The solar elongation $\varepsilon$ is the angle at the observer +/// between the direction toward the Sun and the direction toward the body. +/// Small values ($\varepsilon \lesssim 20°$) indicate that the body is +/// close to the Sun on the sky and may be unobservable from the ground. +/// +/// $$\varepsilon = \arccos\left(\frac{-\mathbf{r}_\text{obs} \cdot \mathbf{d}}{|\mathbf{r}_\text{obs}|\,\rho}\right)$$ +/// +/// The negation of $\mathbf{r}_\text{obs}$ converts from the +/// observer-to-Sun direction to the Sun-to-observer direction, giving the +/// correct sign convention. As with [`phase_angle`], the cosine argument is +/// clamped to $[-1, 1]$ to prevent `NaN` from rounding. +#[inline] +fn solar_elongation(obs_pos: Vector3, topo: Vector3, r_obs: f64, rho: f64) -> f64 { + let cos_eps = (-obs_pos.dot(&topo) / (r_obs * rho)).clamp(-1.0, 1.0); + cos_eps.acos() +} + +/// Compute the observer-relative radial velocity \[AU/day\]. +/// +/// The radial velocity $\dot{\rho}$ is the rate of change of the +/// topocentric distance: positive when the body is receding from the observer, +/// negative when it is approaching. It is the component of the true +/// topocentric velocity $\mathbf{v}_\text{topo}$ projected onto the +/// unit line-of-sight vector. +/// +/// $$\dot{\rho} = \frac{\mathbf{d} \cdot \mathbf{v}_\text{topo}}{\rho}$$ +/// +/// No special edge-case handling is needed here: $\rho = 0$ would mean +/// the observer is at the body, which is physically impossible in practice. +#[inline] +fn radial_velocity(topo: Vector3, v_topo: Vector3, rho: f64) -> f64 { + topo.dot(&v_topo) / rho +} + +/// Compute the apparent angular rates $(\dot{\alpha}, \dot{\delta})$ +/// \[rad/day\]. +/// +/// The angular rates give how fast the body moves across the sky as seen by +/// the observer: $\dot{\alpha}$ is positive eastward and $\dot{\delta}$ +/// is positive northward. Both include the contribution from the observer's +/// own velocity (diurnal and annual motion). +/// +/// Uses the geometric Jacobians +/// +/// $$\frac{\partial\alpha}{\partial\mathbf{r}} = \frac{1}{d_x^2+d_y^2}\begin{pmatrix}-d_y\\d_x\\0\end{pmatrix}$$ +/// +/// $$\frac{\partial\delta}{\partial\mathbf{r}} = \frac{1}{\rho^2\sqrt{d_x^2+d_y^2}}\begin{pmatrix}-d_z d_x\\-d_z d_y\\d_x^2+d_y^2\end{pmatrix}$$ +/// +/// where $\mathbf{d} = (d_x, d_y, d_z)$ is the aberration-corrected +/// topocentric vector. +/// +/// **Degenerate pole case**: when the body lies on (or very close to) the +/// celestial pole, $d_x^2 + d_y^2 \approx 0$ and the Jacobians are +/// singular. The function detects this condition — specifically when +/// $\sqrt{d_x^2+d_y^2} < \varepsilon_\text{machine} \cdot \rho$ — and +/// returns $(0, 0)$ in that case. +/// +/// Returns $(\dot{\alpha}, \dot{\delta})$ in rad/day. +fn angular_rates(topo: Vector3, v_topo: Vector3, rho: f64) -> (f64, f64) { + let dx = topo[0]; + let dy = topo[1]; + let dz = topo[2]; + + let dxy2 = dx * dx + dy * dy; // ‖(dx, dy)‖² + let dxy = dxy2.sqrt(); + + // Guard against degenerate case (body on the pole, dxy ≈ 0). + if dxy < f64::EPSILON * rho { + return (0.0, 0.0); + } + + // ∂α/∂r · v_topo + let d_ra_dt = (-dy * v_topo[0] + dx * v_topo[1]) / dxy2; + + // ∂δ/∂r · v_topo + let d_dec_dt = + (-dz * dx * v_topo[0] - dz * dy * v_topo[1] + dxy2 * v_topo[2]) / (rho * rho * dxy); + + (d_ra_dt, d_dec_dt) +} diff --git a/src/ephemeris/mod.rs b/src/ephemeris/mod.rs new file mode 100644 index 0000000..306b122 --- /dev/null +++ b/src/ephemeris/mod.rs @@ -0,0 +1,301 @@ +//! Public façade for ephemeris computation. +//! +//! This module exposes [`ApparentPosition`], [`BodyGeometry`], +//! [`EphemerisConfig`], and [`AberrationOrder`], plus the request/result +//! system built around [`EphemerisRequest`] and [`EphemerisResult`]. +//! +//! # Quick-start +//! +//! Build a request, add one or more `(observer, mode)` pairs, then call +//! [`OrbitalElements::compute`]: +//! +//! ```rust,ignore +//! use outfit::{ +//! Combined, EphemerisConfig, EphemerisMode, EphemerisRequest, +//! }; +//! use hifitime::{Epoch, Duration}; +//! +//! let result = elements.compute( +//! &EphemerisRequest::::new(EphemerisConfig::default()) +//! .add(observer_a, EphemerisMode::Range { +//! start: Epoch::from_mjd_tt(60310.0), +//! end: Epoch::from_mjd_tt(60340.0), +//! step: Duration::from_days(1.0), +//! }) +//! .add(observer_b, EphemerisMode::At(vec![t1, t2, t3])) +//! .add(observer_c, EphemerisMode::Single(t)), +//! &jpl, +//! &ut1, +//! ); +//! +//! for entry in result.successes() { +//! let (pos, geo) = entry.result.as_ref().unwrap(); +//! println!("{}: RA={:.4} phase={:.4}", entry.epoch, pos.coord.ra, geo.phase_angle); +//! } +//! ``` +//! +//! # Output kinds +//! +//! The type parameter on [`EphemerisRequest`] selects what is computed: +//! +//! | Marker | Output per epoch | +//! |---|---| +//! | [`Position`] | [`ApparentPosition`] | +//! | [`Geometry`] | [`BodyGeometry`] | +//! | [`Combined`] | `(`[`ApparentPosition`]`, `[`BodyGeometry`]`)` | +//! +//! # Generation modes +//! +//! Each observer in the request is paired with an [`EphemerisMode`]: +//! +//! | Variant | Epochs | +//! |---|---| +//! | [`EphemerisMode::Single`] | Exactly one epoch | +//! | [`EphemerisMode::Range`] | Uniform grid | +//! | [`EphemerisMode::At`] | Arbitrary list | +//! +//! # Coordinate conventions +//! +//! - Positions in **AU**, velocities in **AU/day**, angles in **radians**. +//! - Intermediate frames: **ecliptic mean J2000**. +//! - Final output: **equatorial mean J2000** (RA ∈ \[0, 2π), Dec ∈ (−π/2, π/2)). +//! - Time input: any [`hifitime::Epoch`]; converted to **MJD TT** internally. +//! +//! # Pipeline overview +//! +//! ```text +//! OrbitalElements +//! │ +//! ▼ to_equinoctial() +//! EquinoctialElements +//! │ +//! ▼ propagate (TwoBody or NBody) +//! (heliocentric position, velocity) [ecliptic J2000] +//! │ +//! ▼ ROT_ECLMJ2000_TO_EQUMJ2000 +//! (heliocentric position, velocity) [equatorial J2000] +//! │ +//! ├──────────────────────────────────────────┐ +//! ▼ ▼ +//! topocentric vector distances +//! (body − observer) (geocentric, heliocentric) +//! │ +//! ▼ correct_aberration_{first,second}_order() +//! aberration-corrected line-of-sight +//! │ +//! ├──────────────────────────────────────────────────────────┐ +//! ▼ ▼ +//! ApparentPosition BodyGeometry +//! { coord, geocentric_dist, heliocentric_dist } { phase_angle, solar_elongation, +//! radial_velocity, d_ra_dt, d_dec_dt } +//! ``` + +pub(crate) mod aberration; +pub(crate) mod apparent_position; +pub mod batch; +pub(crate) mod geometry; +pub(crate) mod observation_ephemeris; +pub mod request; +pub mod result; +pub use aberration::AberrationOrder; +pub use apparent_position::ApparentPosition; +pub use batch::FullOrbitResultExt; +pub use geometry::BodyGeometry; +pub use request::{ + Combined, EphemerisMode, EphemerisOutputKind, EphemerisRequest, Geometry, ObserverRequest, + Position, +}; +pub use result::{EphemerisEntry, EphemerisResult}; + +use hifitime::ut1::Ut1Provider; + +use crate::{ + cache::observer_fixed_cache::ObserverFixedCache, + ephemeris::observation_ephemeris::check_elliptical_orbit, propagator::PropagatorKind, + EquinoctialElements, JPLEphem, OrbitalElements, OutfitError, +}; + +// --------------------------------------------------------------------------- +// EphemerisConfig +// --------------------------------------------------------------------------- + +/// Configuration for ephemeris computation. +/// +/// Controls which propagation strategy and aberration correction are applied +/// when computing a predicted apparent position. The default uses the +/// analytic two-body (Keplerian) propagator and the first-order aberration +/// correction, which are fast and sufficient for most targets. +#[derive(Debug, Clone, Default)] +pub struct EphemerisConfig { + /// Propagator to use for computing predicted positions. + /// + /// - [`PropagatorKind::TwoBody`] (default): analytic Keplerian propagation. + /// - [`PropagatorKind::NBody`]: numerical DOP853 N-body integration with + /// user-specified perturbing bodies. + pub propagator: PropagatorKind, + + /// Aberration correction order. + /// + /// - [`AberrationOrder::First`] (default): linear light-travel-time shift. + /// - [`AberrationOrder::Second`]: two-step Keplerian back-propagation. + pub aberration: AberrationOrder, +} + +// --------------------------------------------------------------------------- +// OrbitalElements — ephemeris entry point +// --------------------------------------------------------------------------- + +impl OrbitalElements { + /// Execute an [`EphemerisRequest`] and return an [`EphemerisResult`]. + /// + /// Iterates over every `(observer, mode)` pair in `request`, expands the + /// mode into a concrete list of epochs, and computes the ephemeris for + /// each `(epoch, observer)` combination using the output kind `O`. + /// + /// Errors at individual epochs are recorded in the result rather than + /// aborting the whole computation. + /// + /// # Arguments + /// + /// - `request` – Typed request carrying observers, modes, and config. + /// - `jpl` – JPL ephemeris. + /// - `ut1` – UT1 provider. + /// + /// # Returns + /// + /// An [`EphemerisResult`] whose entries are ordered: all epochs + /// for the first observer, then all epochs for the second observer, etc. + /// + /// # Errors (per-entry) + /// + /// - Hyperbolic or parabolic orbit (eccentricity ≥ 1). + /// - Propagation failure. + /// - JPL ephemeris data unavailable for the requested epoch. + /// - Conversion to equinoctial elements fails. + /// + /// # Example + /// + /// ```rust,ignore + /// let result = elements.compute( + /// &EphemerisRequest::::new(EphemerisConfig::default()) + /// .add(observer, EphemerisMode::Range { start, end, step }), + /// &jpl, &ut1, + /// ); + /// for entry in result.successes() { + /// let (pos, geo) = entry.result.as_ref().unwrap(); + /// println!("{}: RA={:.4}", entry.epoch, pos.coord.ra); + /// } + /// ``` + pub fn compute( + &self, + request: &EphemerisRequest, + jpl: &JPLEphem, + ut1: &Ut1Provider, + ) -> EphemerisResult { + // Convert to equinoctial once; propagation failures are per-entry. + let equi = match self.to_equinoctial_for_ephemeris() { + Ok(e) => e, + Err(err) => { + // If conversion fails, every entry is an error. + let total: usize = request + .observers + .iter() + .map(|r| r.mode.epochs().len()) + .sum(); + let mut result = EphemerisResult::with_capacity(total); + for obs_req in &request.observers { + for epoch in obs_req.mode.epochs() { + result.push( + epoch, + obs_req.observer.clone(), + Err(OutfitError::InvalidConversion(err.to_string())), + ); + } + } + return result; + } + }; + + // Optim — check eccentricity once before any epoch loop. + // Avoids recomputing sqrt(h²+k²) for every (epoch, observer) pair. + // If the orbit is hyperbolic/parabolic every entry would fail anyway, + // so we short-circuit immediately with a uniform error result. + if let Err(err) = check_elliptical_orbit(&equi) { + let total: usize = request + .observers + .iter() + .map(|r| r.mode.epochs().len()) + .sum(); + let mut result = EphemerisResult::with_capacity(total); + for obs_req in &request.observers { + for epoch in obs_req.mode.epochs() { + result.push( + epoch, + obs_req.observer.clone(), + Err(OutfitError::InvalidConversion(err.to_string())), + ); + } + } + return result; + } + + let total: usize = request + .observers + .iter() + .map(|r| r.mode.epochs().len()) + .sum(); + let mut result = EphemerisResult::with_capacity(total); + + for obs_req in &request.observers { + // Optim — build ObserverFixedCache once per observer slot. + //fixed_cache + // ObserverFixedCache holds the body-fixed position and velocity of + // the observing site (sin/cos of longitude, ρ factors, cross-product + // for sidereal rotation). These quantities are epoch-invariant: they + // depend only on the observer's geodetic coordinates. Building the + // cache here — once per observer, outside the epoch loop — avoids + // repeating the trig conversion for every epoch. + let fixed_cache = match ObserverFixedCache::try_from(&obs_req.observer) { + Ok(c) => c, + Err(err) => { + // If the observer geometry is invalid, mark every epoch of + // this observer as failed and move on to the next observer. + for epoch in obs_req.mode.epochs() { + result.push( + epoch, + obs_req.observer.clone(), + Err(OutfitError::InvalidConversion(err.to_string())), + ); + } + continue; + } + }; + + for epoch in obs_req.mode.epochs() { + let obs_time_mjd = epoch.to_mjd_tt_days(); + let value = O::compute_one( + &equi, + obs_time_mjd, + &obs_req.observer, + &fixed_cache, + jpl, + ut1, + &request.config, + ); + result.push(epoch, obs_req.observer.clone(), value); + } + } + + result + } + + /// Convert `self` to [`crate::EquinoctialElements`] for use in ephemeris + /// computation. + fn to_equinoctial_for_ephemeris(&self) -> Result { + self.to_equinoctial()? + .as_equinoctial() + .ok_or(OutfitError::InvalidConversion( + "Conversion to equinoctial elements failed".to_string(), + )) + } +} diff --git a/src/ephemeris/observation_ephemeris.rs b/src/ephemeris/observation_ephemeris.rs new file mode 100644 index 0000000..d792da1 --- /dev/null +++ b/src/ephemeris/observation_ephemeris.rs @@ -0,0 +1,1563 @@ +//! Apparent-position computation and astrometric residuals for individual observations. +//! +//! This module provides the [`ObservationEphemeris`](crate::observation_ephemeris::ObservationEphemeris) trait, which extends +//! [`photom::observation_dataset::observation::Observation`] with two methods: +//! +//! - [`ObservationEphemeris::compute_apparent_position`](crate::observation_ephemeris::ObservationEphemeris::compute_apparent_position) — propagates an orbit +//! from its reference epoch to the observation epoch, applies the observer +//! geometry and aberration correction, and returns the predicted (RA, DEC). +//! - [`ObservationEphemeris::ephemeris_error`](crate::observation_ephemeris::ObservationEphemeris::ephemeris_error) — computes the sum of squared, +//! normalised astrometric residuals between the measured and predicted +//! (RA, DEC), suitable as a χ² contribution. +//! +//! A stand-alone helper function `correct_aberration_first_order` is also available for +//! direct use when only the first-order aberration shift is needed. +//! +//! # Coordinate conventions +//! +//! - Positions are in **AU**, velocities in **AU/day**, angles in **radians**. +//! - Intermediate computations use the **ecliptic mean J2000** frame; the final +//! apparent coordinates are returned in the **equatorial** frame (RA, DEC). + +use std::f64::consts::PI; + +use hifitime::Epoch; +use nalgebra::{Matrix6x3, Vector3, Vector6}; +use photom::{constants::DPI, observation_dataset::observation::Observation}; + +use crate::{ + cache::OutfitCache, + constants::{ROT_ECLMJ2000_TO_EQUMJ2000, ROT_EQUMJ2000_TO_ECLMJ2000}, + ephemeris::aberration::correct_aberration_first_order, + propagator::NBodyConfig, + EquinoctialElements, JPLEphem, OutfitError, VLIGHT_AU, +}; + +/// Apparent equatorial coordinates together with their partial derivatives +/// with respect to the six equinoctial orbital elements. +/// +/// This is the return type of +/// [`ObservationEphemeris::compute_obs_and_partials_2body`]. +/// +/// ## Coordinate conventions +/// +/// - `ra` and `dec` are in **radians**, equatorial mean J2000, `ra ∈ [0, 2π)`. +/// - `d_ra_d_elem` and `d_dec_d_elem` are in **rad / (element unit)**. +/// Element order: (a, h, k, p, q, λ), matching [`EquinoctialElements`]. +#[derive(Debug, Clone, PartialEq)] +pub struct ObsAndElementPartials { + /// Predicted right ascension \[rad\], in \[0, 2π). + pub ra: f64, + /// Predicted declination \[rad\], in (−π/2, π/2). + pub dec: f64, + /// ∂α/∂(a, h, k, p, q, λ) \[rad / element unit\]. + pub d_ra_d_elem: Vector6, + /// ∂δ/∂(a, h, k, p, q, λ) \[rad / element unit\]. + pub d_dec_d_elem: Vector6, +} + +pub trait ObservationEphemeris { + /// Compute the apparent equatorial coordinates (RA, DEC) of a solar system body + /// as seen by this observation’s site at its epoch. + /// + /// Overview + /// ----------------- + /// This method determines the apparent sky position of a target body, + /// described by equinoctial orbital elements, as seen from the observing site + /// corresponding to this [`Observation`]. + /// + /// The computation steps are: + /// 1. **Orbit propagation** – Propagate the body’s state from its reference epoch to the observation epoch using a two-body model. + /// 2. **Reference frame handling** – Retrieve Earth’s barycentric position from the JPL ephemeris and transform to *ecliptic mean J2000*. + /// 3. **Observer position** – Compute the observer’s heliocentric position (Earth + site geocentric offset). + /// 4. **Light-time and aberration correction** – Form the observer–object vector and correct for aberration. + /// 5. **Conversion to equatorial coordinates** – Convert the corrected line-of-sight vector to (RA, DEC). + /// + /// Arguments + /// ----------------- + /// * `state` – Global environment providing ephemerides, UT1 provider, and frame utilities. + /// * `equinoctial_element` – Orbital elements of the target body. + /// + /// Return + /// ---------- + /// * `Result<(f64, f64), OutfitError>` – The apparent right ascension and declination `[rad]`. + /// + /// Units + /// ---------- + /// * Positions: AU + /// * Velocities: AU/day + /// * Angles: radians + /// * Time: MJD TT + /// + /// Errors + /// ---------- + /// Returns [`OutfitError`] if: + /// - Orbit propagation fails, + /// - Ephemeris data is unavailable, + /// - Reference-frame transformation fails. + /// + /// See also + /// ------------ + /// * `PropagatorKind::propagate_to_epoch` – Orbit propagation. + /// * [`ResolvedObserver::pvobs`](crate::observer_extension::ResolvedObserver::pvobs) – Computes observer's geocentric position. + /// * `correct_aberration_first_order` – Aberration correction. + /// * [`cartesian_to_radec`](crate::conversion::cartesian_to_radec) – Convert Cartesian vectors to (RA, DEC). + fn compute_apparent_position( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + equinoctial_element: &EquinoctialElements, + ) -> Result<(f64, f64), OutfitError>; + + /// Compute the normalized squared astrometric residuals (RA, DEC) + /// between an observed position and a propagated ephemeris. + /// + /// Overview + /// ----------------- + /// This method compares the actual astrometric measurement stored in `self` + /// against the expected position of the target body propagated from + /// equinoctial elements. + /// It returns a scalar representing the sum of squared, normalized residuals + /// in RA and DEC. + /// + /// Arguments + /// ----------------- + /// * `state` – Global environment providing ephemerides and time conversions. + /// * `equinoctial_element` – Orbital elements of the target body. + /// + /// Return + /// ---------- + /// * `Result` – Dimensionless scalar value representing the weighted sum + /// of squared residuals. Equivalent to a chi² contribution for a single observation (without division by 2). + /// + /// Remarks + /// ---------- + /// * Residuals are normalized by the astrometric uncertainties `error_ra` and `error_dec`. + /// * RA residuals are multiplied by `cos(dec)` to account for projection effects. + /// * All angles are in radians. + /// + /// Errors + /// ---------- + /// Returns [`OutfitError`] if propagation or ephemeris lookup fails. + /// + /// See also + /// ------------ + /// * [`compute_apparent_position`](crate::observation_ephemeris::ObservationEphemeris::compute_apparent_position) – Used internally to obtain predicted RA/DEC. + /// * [`ResolvedObserver::pvobs`](crate::observer_extension::ResolvedObserver::pvobs) – Computes observer's geocentric position. + /// * `correct_aberration_first_order` – Applies aberration correction. + /// * [`cartesian_to_radec`](crate::conversion::cartesian_to_radec) – Converts 3D vectors to (RA, DEC). + /// * `PropagatorKind::propagate_to_epoch` – Two-body propagation. + fn ephemeris_error( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + equinoctial_element: &EquinoctialElements, + ) -> Result; + + /// Compute apparent (RA, DEC) **and** their partial derivatives with + /// respect to the six equinoctial orbital elements, using the two-body + /// propagator. + /// + /// ## Algorithm + /// + /// 1. Propagate the orbit from its reference epoch to the observation epoch + /// (two-body; `compute_derivatives = true`). + /// 2. Obtain `(α, δ, ∂α/∂pos_ecl, ∂δ/∂pos_ecl)` from + /// [`compute_apparent_pos_derivative`](ObservationEphemeris::compute_apparent_pos_derivative). + /// 3. Apply the chain rule: + /// ```text + /// ∂α/∂elemⱼ = ∂α/∂pos_ecl · (R_eq→ecl · ∂pos_eq/∂elemⱼ) + /// ∂δ/∂elemⱼ = ∂δ/∂pos_ecl · (R_eq→ecl · ∂pos_eq/∂elemⱼ) + /// ``` + /// where the 6×3 position Jacobian `∂pos_eq/∂elem` is returned by + /// `PropagatorKind::propagate_to_epoch`. + /// + /// ## Return + /// + /// An [`ObsAndElementPartials`] containing + /// `(α, δ, ∂α/∂(a,h,k,p,q,λ), ∂δ/∂(a,h,k,p,q,λ))`. + /// + /// ## Errors + /// + /// Same as [`compute_apparent_position`](ObservationEphemeris::compute_apparent_position). + fn compute_obs_and_partials_2body( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + equinoctial_element: &EquinoctialElements, + ) -> Result; + + /// Same as [`Self::compute_obs_and_partials_2body`] but uses a numerical N-body + /// propagator (DOP853) instead of the analytic Keplerian solution. + /// + /// The STM-based element Jacobian is computed via the variational equations + /// integrated alongside the state. + fn compute_obs_and_partials_nbody( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + equinoctial_element: &EquinoctialElements, + config: &NBodyConfig, + ) -> Result; +} + +/// Compute topocentric (RA, DEC) and their partial derivatives w.r.t. the +/// asteroid's heliocentric position, all in the ecliptic mean J2000 frame. +/// +/// This is the pure-geometry core shared by +/// [`ObservationEphemeris::compute_apparent_position`] and +/// [`ObservationEphemeris::compute_apparent_pos_derivative`]. +/// +/// # Arguments +/// +/// * `ast_pos_ecl` — Asteroid heliocentric position \[AU\], ecliptic J2000. +/// * `ast_vel_ecl` — Asteroid heliocentric velocity \[AU/day\], ecliptic J2000. +/// * `obs_pos_ecl` — Observer heliocentric position \[AU\], ecliptic J2000. +/// +/// # Returns +/// +/// `(α, δ, ∂α/∂pos_ecl, ∂δ/∂pos_ecl)` where: +/// - `α` ∈ \[0, 2π), `δ` ∈ (−π/2, π/2) \[rad\] +/// - Jacobians are in \[rad/AU\], expressed in the ecliptic J2000 frame. +fn topocentric_radec_and_partials( + ast_pos_ecl: Vector3, + ast_vel_ecl: Vector3, + obs_pos_ecl: Vector3, +) -> (f64, f64, Vector3, Vector3) { + // 1. Topocentric vector + aberration correction. + // The result is in the same frame as ast_pos_ecl / obs_pos_ecl. + // Following the original pipeline (compatible with the old cartesion_from_vec path), + // the RA/Dec angles are computed directly from the (x,y,z) components of `corrected` + // using atan2 — no additional frame rotation is applied here. + let relative = ast_pos_ecl - obs_pos_ecl; + let corrected = correct_aberration_first_order(relative, ast_vel_ecl); + + let x = corrected[0]; + let y = corrected[1]; + let z = corrected[2]; + + let rho = corrected.norm(); // topocentric distance + let rho_xy = x.hypot(y); // ρ_xy = √(x²+y²), matches original hypot usage + let rho_xy_sq = rho_xy * rho_xy; // ρ_xy² + + let dec = z.atan2(rho_xy); // matches original EquCoord::from(CartesianCoord) formula + let ra = y.atan2(x).rem_euclid(std::f64::consts::TAU); + + // 3. Geometric Jacobians w.r.t. ast_pos_ecl. + // + // Full chain rule through correct_aberration: + // corrected = relative − (‖relative‖ / c) · vel + // ⟹ ∂corrected/∂pos = I − (1/c) · vel ⊗ (relative / ‖relative‖)ᵀ + // + // Applying the chain rule: + // ∂α/∂pos = grad_ra − (grad_ra · vel) / (‖relative‖·c) · relative + // ∂δ/∂pos = grad_dec − (grad_dec · vel) / (‖relative‖·c) · relative + // + // where the gradients w.r.t. `corrected` are: + // grad_ra = (−y/ρ_xy², x/ρ_xy², 0) + // grad_dec = (−z·x/(ρ_xy·ρ²), −z·y/(ρ_xy·ρ²), ρ_xy/ρ²) + let rho_sq = rho * rho; + let grad_ra = Vector3::new(-y / rho_xy_sq, x / rho_xy_sq, 0.0); + let grad_dec = Vector3::new( + -z * x / (rho_xy * rho_sq), + -z * y / (rho_xy * rho_sq), + rho_xy / rho_sq, + ); + + // Aberration correction factor: 1 / (‖relative‖ · c) + let rel_norm = relative.norm(); + let aberr_factor = 1.0 / (rel_norm * VLIGHT_AU); + + let d_ra_d_pos = grad_ra - grad_ra.dot(&ast_vel_ecl) * aberr_factor * relative; + let d_dec_d_pos = grad_dec - grad_dec.dot(&ast_vel_ecl) * aberr_factor * relative; + + (ra, dec, d_ra_d_pos, d_dec_d_pos) +} + +/// Holds the resolved observer and asteroid state needed for topocentric geometry. +struct TopocentricGeometryInputs { + /// Asteroid heliocentric position [AU], equatorial J2000. + ast_pos_equ: Vector3, + /// Asteroid heliocentric velocity [AU/day], equatorial J2000. + ast_vel_equ: Vector3, + /// Observer heliocentric position [AU], equatorial J2000. + obs_pos_equ: Vector3, +} + +/// Guards against hyperbolic/parabolic orbits (e ≥ 1), which are not yet supported. +pub(crate) fn check_elliptical_orbit(elements: &EquinoctialElements) -> Result<(), OutfitError> { + if elements.eccentricity() >= 1.0 { + Err(OutfitError::InvalidOrbit( + "Eccentricity >= 1 is not yet supported".to_string(), + )) + } else { + Ok(()) + } +} + +/// Intermediate result holding only the observer position (before asteroid state is known). +type TopocentricGeometryInputsWithoutAsteroid = Vector3; + +/// Resolves Earth's heliocentric position and the observer's heliocentric position +/// for a given observation epoch, both in equatorial J2000. +fn resolve_observer_geometry( + observation: &Observation, + cache: &OutfitCache, + jpl: &JPLEphem, + obs_epoch: &Epoch, +) -> TopocentricGeometryInputsWithoutAsteroid { + let (earth_position_equ, _) = jpl.earth_ephemeris(obs_epoch, false); + let earth_pos_ecl = ROT_EQUMJ2000_TO_ECLMJ2000 * earth_position_equ; + + let geo_obs_pos = cache + .get_observer_geocentric_position(observation.index()) + .map(|x| x.into_inner()); + + let obs_pos_ecl = geo_obs_pos + earth_pos_ecl; + ROT_ECLMJ2000_TO_EQUMJ2000 * obs_pos_ecl +} + +/// Propagates the asteroid to the observation epoch (two-body, no derivatives) +/// and combines with observer geometry into [`TopocentricGeometryInputs`]. +fn resolve_2body_geometry( + observation: &Observation, + cache: &OutfitCache, + jpl: &JPLEphem, + elements: &EquinoctialElements, +) -> Result { + let dt = observation.mjd_tt() - elements.reference_epoch; + let (pos_ecl, vel_ecl, _) = elements.propagate_twobody(0.0, dt, false)?; + + let obs_epoch = Epoch::from_mjd_in_time_scale(observation.mjd_tt(), hifitime::TimeScale::TT); + let obs_pos_equ = resolve_observer_geometry(observation, cache, jpl, &obs_epoch); + + Ok(TopocentricGeometryInputs { + ast_pos_equ: ROT_ECLMJ2000_TO_EQUMJ2000 * pos_ecl, + ast_vel_equ: ROT_ECLMJ2000_TO_EQUMJ2000 * vel_ecl, + obs_pos_equ, + }) +} + +/// Applies the chain rule to convert `dpos_delem` (ecliptic J2000, 6×3) to +/// `(d_ra_d_elem, d_dec_d_elem)` using the positional partials returned by +/// [`topocentric_radec_and_partials`] (equatorial J2000). +/// +/// The element-to-position Jacobian is rotated from ecliptic to equatorial +/// before dotting with the sky-coordinate gradients. +fn element_partials_from_position_partials( + d_ra_d_pos_equ: Vector3, + d_dec_d_pos_equ: Vector3, + dpos_delem_ecl: &Matrix6x3, +) -> (Vector6, Vector6) { + (0..6).fold( + (Vector6::zeros(), Vector6::zeros()), + |(mut d_ra, mut d_dec), j| { + let dpos_ecl_j = Vector3::new( + dpos_delem_ecl[(j, 0)], + dpos_delem_ecl[(j, 1)], + dpos_delem_ecl[(j, 2)], + ); + let dpos_equ_j = ROT_ECLMJ2000_TO_EQUMJ2000 * dpos_ecl_j; + d_ra[j] = d_ra_d_pos_equ.dot(&dpos_equ_j); + d_dec[j] = d_dec_d_pos_equ.dot(&dpos_equ_j); + (d_ra, d_dec) + }, + ) +} + +impl ObservationEphemeris for Observation { + fn compute_apparent_position( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + equinoctial_element: &EquinoctialElements, + ) -> Result<(f64, f64), OutfitError> { + check_elliptical_orbit(equinoctial_element)?; + + let TopocentricGeometryInputs { + ast_pos_equ, + ast_vel_equ, + obs_pos_equ, + } = resolve_2body_geometry(self, cache, jpl, equinoctial_element)?; + + let (ra, dec, _, _) = topocentric_radec_and_partials(ast_pos_equ, ast_vel_equ, obs_pos_equ); + + Ok((ra, dec)) + } + + fn ephemeris_error( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + equinoctial_element: &EquinoctialElements, + ) -> Result { + let (alpha, delta) = self.compute_apparent_position(cache, jpl, equinoctial_element)?; + + let (self_ra, self_ra_err, self_dec, self_dec_err) = ( + self.equ_coord().ra, + self.equ_coord().ra_error, + self.equ_coord().dec, + self.equ_coord().dec_error, + ); + + // ΔRA with wrapping to [-π, π] + let mut diff_alpha = (self_ra - alpha) % DPI; + if diff_alpha > PI { + diff_alpha -= DPI; + } + + let diff_delta = self_dec - delta; + + // Weighted RMS + let rms_ra = (self_dec.cos() * (diff_alpha / self_ra_err)).powi(2); + let rms_dec = (diff_delta / self_dec_err).powi(2); + + Ok(rms_ra + rms_dec) + } + + fn compute_obs_and_partials_2body( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + equinoctial_element: &EquinoctialElements, + ) -> Result { + check_elliptical_orbit(equinoctial_element)?; + + // Propagate with derivatives enabled. + let dt = self.mjd_tt() - equinoctial_element.reference_epoch; + let (pos_ecl, vel_ecl, jacobians) = equinoctial_element.propagate_twobody(0.0, dt, true)?; + let (dpos_delem_ecl, _) = jacobians + .expect("propagate_twobody with compute_derivatives=true must return jacobians"); + + let obs_epoch = Epoch::from_mjd_in_time_scale(self.mjd_tt(), hifitime::TimeScale::TT); + let obs_pos_equ = resolve_observer_geometry(self, cache, jpl, &obs_epoch); + + let ast_pos_equ = ROT_ECLMJ2000_TO_EQUMJ2000 * pos_ecl; + let ast_vel_equ = ROT_ECLMJ2000_TO_EQUMJ2000 * vel_ecl; + + let (ra, dec, d_ra_d_pos, d_dec_d_pos) = + topocentric_radec_and_partials(ast_pos_equ, ast_vel_equ, obs_pos_equ); + + let (d_ra_d_elem, d_dec_d_elem) = + element_partials_from_position_partials(d_ra_d_pos, d_dec_d_pos, &dpos_delem_ecl); + + Ok(ObsAndElementPartials { + ra, + dec, + d_ra_d_elem, + d_dec_d_elem, + }) + } + + fn compute_obs_and_partials_nbody( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + equinoctial_element: &EquinoctialElements, + config: &NBodyConfig, + ) -> Result { + // Hyperbolic orbits are not yet supported + check_elliptical_orbit(equinoctial_element)?; + + let t1_mjd_tt = self.mjd_tt(); + let nbody_result = equinoctial_element.propagate_nbody(t1_mjd_tt, jpl, config)?; + + let obs_epoch = Epoch::from_mjd_in_time_scale(t1_mjd_tt, hifitime::TimeScale::TT); + let obs_pos_equ = resolve_observer_geometry(self, cache, jpl, &obs_epoch); + + let ast_pos_equ = ROT_ECLMJ2000_TO_EQUMJ2000 * nbody_result.position; + let ast_vel_equ = ROT_ECLMJ2000_TO_EQUMJ2000 * nbody_result.velocity; + + let (ra, dec, d_ra_d_pos, d_dec_d_pos) = + topocentric_radec_and_partials(ast_pos_equ, ast_vel_equ, obs_pos_equ); + + let (d_ra_d_elem, d_dec_d_elem) = element_partials_from_position_partials( + d_ra_d_pos, + d_dec_d_pos, + &nbody_result.dpos_delem, + ); + + Ok(ObsAndElementPartials { + ra, + dec, + d_ra_d_elem, + d_dec_d_elem, + }) + } +} + +#[cfg(test)] +mod test_observations_ephemeris { + use super::*; + + mod tests_compute_apparent_position { + + use crate::test_fixture::{JPL_EPHEM_HORIZON, UT1_PROVIDER}; + + use super::*; + use approx::assert_relative_eq; + use photom::{ + coordinates::equatorial::EquCoord, + observation_dataset::{observation::ObservationInput, ObsDataset}, + observer::error_model::{ModelCorrection, ObsErrorModel}, + photometry::{Filter, Photometry}, + MJDTT, + }; + + /// Helper: simple circular equinoctial elements for a 1 AU, zero inclination orbit. + fn simple_circular_elements(epoch: f64) -> EquinoctialElements { + EquinoctialElements { + reference_epoch: epoch, + semi_major_axis: 1.0, + eccentricity_sin_lon: 0.0, + eccentricity_cos_lon: 0.0, + tan_half_incl_sin_node: 0.0, + tan_half_incl_cos_node: 0.0, + mean_longitude: 0.0, + } + } + + fn obsdataset_with_observation_time(t_epoch: MJDTT) -> ObsDataset { + let observation_input = ObservationInput::new( + 0, + EquCoord { + ra: 0.0, + ra_error: 0.0, + dec: 0.0, + dec_error: 0.0, + }, + Photometry { + magnitude: 0.0, + error: 0.0, + filter: Filter::Int(0), + }, + t_epoch, + Some(photom::observer::dataset::ObserverId::MpcCode(*b"F51")), + ); + + ObsDataset::empty() + .push_observation(vec![observation_input]) + .unwrap() + .0 + .with_error_model(ObsErrorModel::FCCT14) + .apply_model_errors() + } + + #[test] + fn test_compute_apparent_position_nominal() { + let t_obs = 59000.0; // MJD + + let obs_dataset = obsdataset_with_observation_time(t_obs); + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER, false).unwrap(); + + let equinoctial = simple_circular_elements(t_obs); + + let (ra, dec) = obs_dataset + .get_observation(0) + .unwrap() + .compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial) + .expect("Computation should succeed"); + + assert!(ra.is_finite()); + assert!(dec.is_finite()); + assert!((0.0..2.0 * std::f64::consts::PI).contains(&ra)); + assert!((-std::f64::consts::FRAC_PI_2..std::f64::consts::FRAC_PI_2).contains(&dec)); + } + + #[test] + fn test_compute_apparent_position_same_epoch() { + let t_epoch = 60000.0; + + let obs_dataset = obsdataset_with_observation_time(t_epoch); + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER, false).unwrap(); + + let equinoctial = simple_circular_elements(t_epoch); + + let obs = obs_dataset.get_observation(0).unwrap(); + + let (ra1, dec1) = obs + .compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + let (ra2, dec2) = obs + .compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + // The same input should always produce the same result + assert_relative_eq!(ra1, ra2, epsilon = 1e-14); + assert_relative_eq!(dec1, dec2, epsilon = 1e-14); + } + + #[test] + fn test_apparent_position_for_distant_object() { + let t_obs = 59000.0; + + let obs_dataset = obsdataset_with_observation_time(t_obs); + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER, false).unwrap(); + + let mut equinoctial = simple_circular_elements(t_obs); + + // Objet far away + equinoctial.semi_major_axis = 100.0; + + let obs = obs_dataset.get_observation(0).unwrap(); + + let (ra, dec) = obs + .compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial) + .expect("Should compute apparent position for distant object"); + + assert!(ra.is_finite()); + assert!(dec.is_finite()); + } + + #[test] + fn test_compute_apparent_position_propagation_failure() { + let obs_dataset = obsdataset_with_observation_time(0.0); + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER, false).unwrap(); + + // Invalid orbital elements to force failure in solve_two_body_problem + let equinoctial = EquinoctialElements { + reference_epoch: 59000.0, + semi_major_axis: -1.0, // Physically invalid + eccentricity_sin_lon: 0.0, + eccentricity_cos_lon: 0.0, + tan_half_incl_sin_node: 0.0, + tan_half_incl_cos_node: 0.0, + mean_longitude: 0.0, + }; + + let obs = obs_dataset.get_observation(0).unwrap(); + + let result = obs.compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + assert!(result.is_err(), "Invalid elements should trigger an error"); + } + + mod proptests_apparent_position { + use super::*; + use crate::test_fixture::UT1_PROVIDER; + use photom::{ + observation_dataset::{observation::ObservationInput, ObsDataset}, + observer::{ + dataset::ObserverId, + error_model::{ModelCorrection, ObsErrorModel}, + Observer, + }, + photometry::{Filter, Photometry}, + }; + use proptest::prelude::*; + + fn arb_equinoctial_elements() -> impl Strategy { + ( + 58000.0..62000.0f64, + 0.5..30.0f64, + -0.5..0.5f64, + -0.5..0.5f64, + -0.5..0.5f64, + -0.5..0.5f64, + 0.0..(2.0 * std::f64::consts::PI), + ) + .prop_map(|(epoch, a, h, k, p, q, lambda)| { + EquinoctialElements { + reference_epoch: epoch, + semi_major_axis: a, + eccentricity_sin_lon: h, + eccentricity_cos_lon: k, + tan_half_incl_sin_node: p, + tan_half_incl_cos_node: q, + mean_longitude: lambda, + } + }) + } + + fn arb_observer() -> impl Strategy { + (-180.0..180.0f64, -90.0..90.0f64, 0.0..5.0f64).prop_map(|(lon, lat, elev)| { + Observer::new(lon.to_radians(), lat.to_radians(), elev, None, None, None) + .unwrap() + }) + } + + fn arb_extreme_equinoctial_elements() -> impl Strategy { + ( + 58000.0..62000.0f64, + 0.1..50.0f64, + -0.99..0.99f64, + -0.99..0.99f64, + -1.0..1.0f64, + -1.0..1.0f64, + 0.0..(2.0 * std::f64::consts::PI), + ) + .prop_map(|(epoch, a, h, k, p, q, lambda)| EquinoctialElements { + reference_epoch: epoch, + semi_major_axis: a, + eccentricity_sin_lon: h, + eccentricity_cos_lon: k, + tan_half_incl_sin_node: p, + tan_half_incl_cos_node: q, + mean_longitude: lambda, + }) + .prop_filter( + "Only bound (elliptical) orbits are supported", + |elem: &EquinoctialElements| { + let e = (elem.eccentricity_sin_lon.powi(2) + + elem.eccentricity_cos_lon.powi(2)) + .sqrt(); + e < 0.99 + }, + ) + } + + fn make_obs_dataset_and_cache( + t_obs: f64, + observer_id: ObserverId, + ) -> (ObsDataset, OutfitCache) { + let observation_input = ObservationInput::new( + 0, + EquCoord { + ra: 0.0, + ra_error: 0.0, + dec: 0.0, + dec_error: 0.0, + }, + Photometry { + magnitude: 0.0, + error: 0.0, + filter: Filter::Int(0), + }, + t_obs, + Some(observer_id), + ); + + let obs_dataset = ObsDataset::empty() + .push_observation(vec![observation_input]) + .unwrap() + .0 + .with_error_model(ObsErrorModel::FCCT14) + .apply_model_errors(); + + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER, false) + .unwrap(); + + (obs_dataset, cache) + } + + fn make_obs_dataset_and_cache_with_custom_observer( + t_obs: f64, + observer: Observer, + ) -> (ObsDataset, OutfitCache) { + let (obs_dataset_with_obs, observer_id) = + ObsDataset::empty().push_observer(observer); + + let observation_input = ObservationInput::new( + 0, + EquCoord { + ra: 0.0, + ra_error: 0.0, + dec: 0.0, + dec_error: 0.0, + }, + Photometry { + magnitude: 0.0, + error: 0.0, + filter: Filter::Int(0), + }, + t_obs, + Some(observer_id), + ); + + let obs_dataset = obs_dataset_with_obs + .push_observation(vec![observation_input]) + .unwrap() + .0 + .with_error_model(ObsErrorModel::FCCT14) + .apply_model_errors(); + + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER, false) + .unwrap(); + + (obs_dataset, cache) + } + + proptest! { + #[test] + fn proptest_ra_dec_are_finite_and_in_range( + equinoctial in arb_equinoctial_elements(), + obs_time in 58000.0f64..62000.0 + ) { + let (obs_dataset, cache) = make_obs_dataset_and_cache( + obs_time, + photom::observer::dataset::ObserverId::MpcCode(*b"F51"), + ); + + let obs = obs_dataset.get_observation(0).unwrap(); + let result = obs.compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + + if let Ok((ra, dec)) = result { + prop_assert!(ra.is_finite()); + prop_assert!(dec.is_finite()); + prop_assert!((0.0..2.0 * std::f64::consts::PI).contains(&ra)); + prop_assert!((-std::f64::consts::FRAC_PI_2..std::f64::consts::FRAC_PI_2).contains(&dec)); + } + } + + #[test] + fn proptest_repeatability( + equinoctial in arb_equinoctial_elements(), + obs_time in 58000.0f64..62000.0 + ) { + let (obs_dataset, cache) = make_obs_dataset_and_cache( + obs_time, + photom::observer::dataset::ObserverId::MpcCode(*b"F51"), + ); + + let obs = obs_dataset.get_observation(0).unwrap(); + + let r1 = obs.compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + let r2 = obs.compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + + prop_assert_eq!(r1, r2); + } + + #[test] + fn proptest_small_time_change_has_small_effect( + equinoctial in arb_equinoctial_elements(), + obs_time in 58000.0f64..62000.0 + ) { + let (obs_dataset, cache) = make_obs_dataset_and_cache( + obs_time, + photom::observer::dataset::ObserverId::MpcCode(*b"F51"), + ); + let (obs_dataset_eps, cache_eps) = make_obs_dataset_and_cache( + obs_time + 1e-3, + photom::observer::dataset::ObserverId::MpcCode(*b"F51"), + ); + + let obs = obs_dataset.get_observation(0).unwrap(); + let obs_eps = obs_dataset_eps.get_observation(0).unwrap(); + + let r1 = obs.compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + let r2 = obs_eps.compute_apparent_position(&cache_eps, &JPL_EPHEM_HORIZON, &equinoctial); + + if let (Ok((ra1, dec1)), Ok((ra2, dec2))) = (r1, r2) { + let dra = (ra1 - ra2).abs(); + let ddec = (dec1 - dec2).abs(); + + prop_assert!(dra < 1.0, "RA jump too large: {}", dra); + prop_assert!(ddec < 1.0, "DEC jump too large: {}", ddec); + } + } + + #[test] + fn proptest_ra_dec_valid_for_extreme_orbits_and_observers( + equinoctial in arb_extreme_equinoctial_elements(), + observer in arb_observer(), + obs_time in 58000.0f64..62000.0 + ) { + let (obs_dataset, cache) = + make_obs_dataset_and_cache_with_custom_observer(obs_time, observer); + + let obs = obs_dataset.get_observation(0).unwrap(); + let result = obs.compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + + if let Ok((ra, dec)) = result { + prop_assert!(ra.is_finite()); + prop_assert!(dec.is_finite()); + prop_assert!((0.0..2.0 * std::f64::consts::PI).contains(&ra)); + prop_assert!((-std::f64::consts::FRAC_PI_2..std::f64::consts::FRAC_PI_2).contains(&dec)); + } + } + } + + #[test] + fn test_hyperbolic_orbit_returns_error() { + let t_obs = 59000.0; + let (obs_dataset, cache) = make_obs_dataset_and_cache( + t_obs, + photom::observer::dataset::ObserverId::MpcCode(*b"F51"), + ); + + let equinoctial = EquinoctialElements { + reference_epoch: t_obs, + semi_major_axis: 1.0, + eccentricity_sin_lon: 0.8, + eccentricity_cos_lon: 0.8, // e ≈ 1.13 > 1 + tan_half_incl_sin_node: 0.0, + tan_half_incl_cos_node: 0.0, + mean_longitude: 0.0, + }; + + let obs = obs_dataset.get_observation(0).unwrap(); + let result = + obs.compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + + assert!( + result.is_err(), + "Hyperbolic or parabolic orbits should currently return an error" + ); + } + } + } + + mod tests_ephemeris_error { + use super::*; + use crate::test_fixture::{JPL_EPHEM_HORIZON, UT1_PROVIDER}; + use approx::assert_relative_eq; + use photom::{ + coordinates::equatorial::EquCoord, + observation_dataset::{observation::ObservationInput, ObsDataset}, + observer::{ + error_model::{ModelCorrection, ObsErrorModel}, + mpc::MpcCode, + Observer, + }, + photometry::{Filter, Photometry}, + }; + + fn simple_equinoctial(epoch: f64) -> EquinoctialElements { + EquinoctialElements { + reference_epoch: epoch, + semi_major_axis: 1.0, + eccentricity_sin_lon: 0.0, + eccentricity_cos_lon: 0.0, + tan_half_incl_sin_node: 0.0, + tan_half_incl_cos_node: 0.0, + mean_longitude: 0.0, + } + } + + fn make_obs_dataset_and_cache_mpc( + ra: f64, + ra_error: f64, + dec: f64, + dec_error: f64, + t_obs: f64, + mpc_code: MpcCode, + apply_model_errors: bool, + ) -> (ObsDataset, OutfitCache) { + let observation_input = ObservationInput::new( + 0, + EquCoord { + ra, + ra_error, + dec, + dec_error, + }, + Photometry { + magnitude: 0.0, + error: 0.0, + filter: Filter::Int(0), + }, + t_obs, + Some(photom::observer::dataset::ObserverId::MpcCode(mpc_code)), + ); + + let obs_dataset = ObsDataset::empty() + .push_observation(vec![observation_input]) + .unwrap() + .0 + .with_error_model(ObsErrorModel::FCCT14); + + let obs_dataset = if apply_model_errors { + obs_dataset.apply_model_errors() + } else { + obs_dataset + }; + + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER, false).unwrap(); + (obs_dataset, cache) + } + + fn make_obs_dataset_and_cache_custom( + ra: f64, + ra_error: f64, + dec: f64, + dec_error: f64, + t_obs: f64, + observer: Observer, + ) -> (ObsDataset, OutfitCache) { + let (dataset_with_obs, observer_id) = ObsDataset::empty().push_observer(observer); + + let observation_input = ObservationInput::new( + 0, + EquCoord { + ra, + ra_error, + dec, + dec_error, + }, + Photometry { + magnitude: 0.0, + error: 0.0, + filter: Filter::Int(0), + }, + t_obs, + Some(observer_id), + ); + + let obs_dataset = dataset_with_obs + .push_observation(vec![observation_input]) + .unwrap() + .0 + .with_error_model(ObsErrorModel::FCCT14) + .apply_model_errors(); + + let cache = + OutfitCache::build(&obs_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER, false).unwrap(); + (obs_dataset, cache) + } + + #[test] + fn test_ephem_error() { + let (obs_dataset, cache) = make_obs_dataset_and_cache_mpc( + 1.7899347771316527, + 1.770_024_520_608_546E-6, + 0.778_996_538_107_973_6, + 1.259_582_891_829_317_7E-6, + 57070.262067592594, + *b"F51", + false, + ); + + let equinoctial_element = EquinoctialElements { + reference_epoch: 57_049.242_334_573_75, + semi_major_axis: 1.8017360713154256, + eccentricity_sin_lon: 0.269_373_680_909_227_2, + eccentricity_cos_lon: 8.856_415_260_013_56E-2, + tan_half_incl_sin_node: 8.089_970_166_396_302E-4, + tan_half_incl_cos_node: 0.10168201109730375, + mean_longitude: 1.6936970079414786, + }; + + let obs = obs_dataset.get_observation(0).unwrap(); + let rms_error = obs.ephemeris_error(&cache, &JPL_EPHEM_HORIZON, &equinoctial_element); + assert_eq!(rms_error.unwrap(), 75.00445641224026); + } + + #[test] + fn test_zero_error_when_positions_match() { + let t_obs = 59000.0; + let equinoctial = simple_equinoctial(t_obs); + + let (obs_dataset, cache) = + make_obs_dataset_and_cache_mpc(0.0, 1e-6, 0.0, 1e-6, t_obs, *b"F51", true); + + let obs = obs_dataset.get_observation(0).unwrap(); + let (alpha, delta) = obs + .compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + let (obs_dataset_match, cache_match) = + make_obs_dataset_and_cache_mpc(alpha, 1e-6, delta, 1e-6, t_obs, *b"F51", true); + + let obs_match = obs_dataset_match.get_observation(0).unwrap(); + let error = obs_match + .ephemeris_error(&cache_match, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + assert_relative_eq!(error, 0.0, epsilon = 1e-14); + } + + #[test] + fn test_error_increases_with_offset() { + let t_obs = 59000.0; + let equinoctial = simple_equinoctial(t_obs); + + let (obs_dataset, cache) = + make_obs_dataset_and_cache_mpc(0.0, 1e-3, 0.0, 1e-3, t_obs, *b"F51", true); + + let obs = obs_dataset.get_observation(0).unwrap(); + let (alpha, delta) = obs + .compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + let (obs_dataset_offset, cache_offset) = make_obs_dataset_and_cache_mpc( + alpha + 1e-3, + 1e-3, + delta, + 1e-3, + t_obs, + *b"F51", + true, + ); + + let obs_offset = obs_dataset_offset.get_observation(0).unwrap(); + let err = obs_offset + .ephemeris_error(&cache_offset, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + assert!(err > 0.0); + } + + #[test] + fn test_ra_wrapping_invariance() { + let t_obs = 59000.0; + let equinoctial = simple_equinoctial(t_obs); + + let (obs_dataset, cache) = + make_obs_dataset_and_cache_mpc(0.0, 1e-6, 0.0, 1e-6, t_obs, *b"F51", true); + + let obs = obs_dataset.get_observation(0).unwrap(); + let (alpha, delta) = obs + .compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + let (obs_dataset_wrapped, cache_wrapped) = make_obs_dataset_and_cache_mpc( + alpha + std::f64::consts::TAU, + 1e-6, + delta, + 1e-6, + t_obs, + *b"F51", + true, + ); + + let obs_wrapped = obs_dataset_wrapped.get_observation(0).unwrap(); + let err = obs_wrapped + .ephemeris_error(&cache_wrapped, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + assert_relative_eq!(err, 0.0, epsilon = 1e-12); + } + + #[test] + fn test_large_uncertainty_downweights_error() { + let t_obs = 59000.0; + let equinoctial = simple_equinoctial(t_obs); + + let (obs_dataset, cache) = + make_obs_dataset_and_cache_mpc(0.0, 1.0, 0.0, 1.0, t_obs, *b"F51", true); + + let obs = obs_dataset.get_observation(0).unwrap(); + let (alpha, delta) = obs + .compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + let (obs_dataset_large, cache_large) = make_obs_dataset_and_cache_mpc( + alpha + 0.1, + 10.0, + delta + 0.1, + 10.0, + t_obs, + *b"F51", + true, + ); + + let obs_large = obs_dataset_large.get_observation(0).unwrap(); + let err = obs_large + .ephemeris_error(&cache_large, &JPL_EPHEM_HORIZON, &equinoctial) + .unwrap(); + + assert!( + err < 1.0, + "Large uncertainties should reduce the error contribution" + ); + } + + mod proptests_ephemeris_error { + use super::*; + use proptest::prelude::*; + + fn arb_observer() -> impl Strategy { + (-180.0..180.0f64, -90.0..90.0f64, 0.0..5000.0f64).prop_map(|(lon, lat, elev)| { + Observer::new(lon.to_radians(), lat.to_radians(), elev, None, None, None) + .unwrap() + }) + } + + fn arb_elliptical_equinoctial() -> impl Strategy { + ( + 58000.0..62000.0f64, + 0.5..20.0f64, + -0.8..0.8f64, + -0.8..0.8f64, + -0.8..0.8f64, + -0.8..0.8f64, + 0.0..std::f64::consts::TAU, + ) + .prop_map(|(epoch, a, h, k, p, q, l)| EquinoctialElements { + reference_epoch: epoch, + semi_major_axis: a, + eccentricity_sin_lon: h, + eccentricity_cos_lon: k, + tan_half_incl_sin_node: p, + tan_half_incl_cos_node: q, + mean_longitude: l, + }) + .prop_filter("Bound orbits only", |e: &EquinoctialElements| { + e.eccentricity() < 1.0 + }) + } + + proptest! { + #[test] + fn proptest_error_is_non_negative( + equinoctial in arb_elliptical_equinoctial(), + observer in arb_observer(), + obs_time in 58000.0f64..62000.0 + ) { + let (obs_dataset, cache) = + make_obs_dataset_and_cache_custom(0.0, 1e-3, 0.0, 1e-3, obs_time, observer); + + let obs = obs_dataset.get_observation(0).unwrap(); + let result = obs.ephemeris_error(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + + if let Ok(val) = result { + prop_assert!(val.is_finite()); + prop_assert!(val >= 0.0); + } + } + + #[test] + fn proptest_error_downweights_large_uncertainties( + equinoctial in arb_elliptical_equinoctial(), + observer in arb_observer(), + obs_time in 58000.0f64..62000.0 + ) { + let (obs_dataset, cache) = + make_obs_dataset_and_cache_custom(0.5, 100.0, 0.5, 100.0, obs_time, observer); + + let obs = obs_dataset.get_observation(0).unwrap(); + let result = obs.ephemeris_error(&cache, &JPL_EPHEM_HORIZON, &equinoctial); + + if let Ok(val) = result { + prop_assert!(val < 1.0); + } + } + } + } + } + + mod tests_compute_apparent_pos_derivative { + use approx::assert_relative_eq; + + use super::*; + + fn nominal_elements(epoch: f64) -> EquinoctialElements { + EquinoctialElements { + reference_epoch: epoch, + semi_major_axis: 1.8, + eccentricity_sin_lon: 0.1, + eccentricity_cos_lon: 0.05, + tan_half_incl_sin_node: 0.01, + tan_half_incl_cos_node: 0.08, + mean_longitude: 1.5, + } + } + + /// Validate ∂α/∂pos and ∂δ/∂pos by finite differences on ast_pos_ecl. + /// + /// We perturb each of the 3 ecliptic position components by ε and compare + /// the numerical gradient to the analytical one. + #[test] + fn test_partials_match_finite_differences() { + use crate::constants::ROT_ECLMJ2000_TO_EQUMJ2000; + + let t_obs = 59000.0; + let elem = nominal_elements(t_obs); + + let (cart_pos_ast, cart_vel_ast, _) = elem.propagate_twobody(0., 0., false).unwrap(); + + // solve_two_body_problem returns ecliptic J2000 — convert to equatorial + // before passing to topocentric_radec_and_partials (which expects equatorial). + let obs_pos_ecl = Vector3::zeros(); // simplify: observer at origin + let ast_pos_ecl = ROT_ECLMJ2000_TO_EQUMJ2000 * cart_pos_ast; + let ast_vel_ecl = ROT_ECLMJ2000_TO_EQUMJ2000 * cart_vel_ast; + + let (_, _, d_ra_d_pos, d_dec_d_pos) = + topocentric_radec_and_partials(ast_pos_ecl, ast_vel_ecl, obs_pos_ecl); + + let eps = 1e-6_f64; // 1e-6 AU ≈ 150 km + + for i in 0..3 { + // Central finite differences for better accuracy + let mut pos_fwd = ast_pos_ecl; + pos_fwd[i] += eps; + let mut pos_bwd = ast_pos_ecl; + pos_bwd[i] -= eps; + + let (ra_fwd, dec_fwd, _, _) = + topocentric_radec_and_partials(pos_fwd, ast_vel_ecl, obs_pos_ecl); + let (ra_bwd, dec_bwd, _, _) = + topocentric_radec_and_partials(pos_bwd, ast_vel_ecl, obs_pos_ecl); + + // Handle RA wrapping: unwrap the difference to [-π, π] + let mut dra = ra_fwd - ra_bwd; + if dra > std::f64::consts::PI { + dra -= std::f64::consts::TAU; + } else if dra < -std::f64::consts::PI { + dra += std::f64::consts::TAU; + } + let num_dra = dra / (2.0 * eps); + let num_ddec = (dec_fwd - dec_bwd) / (2.0 * eps); + + assert_relative_eq!(d_ra_d_pos[i], num_dra, epsilon = 1e-6, max_relative = 1e-5); + assert_relative_eq!( + d_dec_d_pos[i], + num_ddec, + epsilon = 1e-6, + max_relative = 1e-5 + ); + } + } + + /// Non-regression oracle test for `topocentric_radec_and_partials`. + /// + /// Reference values were computed once in Rust (IEEE 754 double precision) + /// and checked in as ground truth. The test guards against accidental + /// regressions in the Jacobian formula. + /// + /// Inputs + /// ------ + /// ast_pos_ecl = [-4.15006894757462969e-2, 1.36234947972925746e0, 8.71837102048550472e-1] AU + /// ast_vel_ecl = [-1.41847650703540683e-2, 1.14549098612109205e-4, 4.07819544228800175e-4] AU/day + /// obs_pos_ecl = [0, 0, 0] + /// + /// Expected outputs (oracle) + /// ------------------------ + /// ra = 1.60115231410015513e0 rad + /// dec = 5.69067704034184052e-1 rad + /// d_ra_d_pos = [-7.33348893215877928e-1, -2.23189920137348632e-2, -3.23656066883762261e-5] rad/AU + /// d_dec_d_pos = [ 1.01082321534895769e-2, -3.32887445954391237e-1, 5.20657513131207450e-1] rad/AU + #[test] + fn oracle_topocentric_radec_and_partials() { + use crate::constants::ROT_ECLMJ2000_TO_EQUMJ2000; + use approx::assert_abs_diff_eq; + + let t_obs = 59000.0; + let elem = nominal_elements(t_obs); + let (cart_pos_ast, cart_vel_ast, _) = elem.propagate_twobody(0., 0., false).unwrap(); + let obs_pos_ecl = Vector3::zeros(); + // propagate_twobody returns ecliptic J2000 — convert to equatorial + let ast_pos_ecl = ROT_ECLMJ2000_TO_EQUMJ2000 * cart_pos_ast; + let ast_vel_ecl = ROT_ECLMJ2000_TO_EQUMJ2000 * cart_vel_ast; + + let (ra, dec, d_ra, d_dec) = + topocentric_radec_and_partials(ast_pos_ecl, ast_vel_ecl, obs_pos_ecl); + + // Oracle values (Rust IEEE 754 double precision reference) + let tol = 1e-10_f64; + + assert_abs_diff_eq!(ra, 1.601_152_314_100_155_1, epsilon = tol); + assert_abs_diff_eq!(dec, 5.690_677_040_341_84e-1, epsilon = tol); + + let ref_d_ra = [ + -7.333_488_932_158_779e-1, + -2.231_899_201_373_486_3e-2, + -3.236_560_668_837_622_6e-5, + ]; + let ref_d_dec = [ + 1.010_823_215_348_957_7e-2, + -3.328_874_459_543_912_3e-1, + 5.206_575_131_312_075e-1, + ]; + + for i in 0..3 { + assert_abs_diff_eq!(d_ra[i], ref_d_ra[i], epsilon = tol); + assert_abs_diff_eq!(d_dec[i], ref_d_dec[i], epsilon = tol); + } + } + } + + /// Tests for `compute_obs_and_partials_2body`, which combines the two-body propagator with + /// the `topocentric_radec_and_partials` Jacobian. + mod tests_compute_obs_and_partials_2body { + use super::*; + use crate::test_fixture::{JPL_EPHEM_HORIZON, UT1_PROVIDER}; + use approx::assert_abs_diff_eq; + use photom::{ + coordinates::equatorial::EquCoord, + observation_dataset::{observation::ObservationInput, ObsDataset}, + observer::error_model::{ModelCorrection, ObsErrorModel}, + photometry::{Filter, Photometry}, + }; + + /// Equinoctial elements and observation parameters + fn oracle_elements() -> EquinoctialElements { + EquinoctialElements { + reference_epoch: 57_049.242_334_573_75, + semi_major_axis: 1.801_736_071_315_425_6, + eccentricity_sin_lon: 0.269_373_680_909_227_2, + eccentricity_cos_lon: 8.856_415_260_013_56e-2, + tan_half_incl_sin_node: 8.089_970_166_396_302e-4, + tan_half_incl_cos_node: 0.101_682_011_097_303_75, + mean_longitude: 1.693_697_007_941_478_6, + } + } + + const T_OBS: f64 = 57_070.262_067_592_594; + + fn make_obs_and_cache() -> (ObsDataset, OutfitCache) { + let input = ObservationInput::new( + 0, + EquCoord { + ra: 0.0, + ra_error: 1e-6, + dec: 0.0, + dec_error: 1e-6, + }, + Photometry { + magnitude: 0.0, + error: 0.0, + filter: Filter::Int(0), + }, + T_OBS, + Some(photom::observer::dataset::ObserverId::MpcCode(*b"F51")), + ); + let ds = ObsDataset::empty() + .push_observation(vec![input]) + .unwrap() + .0 + .with_error_model(ObsErrorModel::FCCT14) + .apply_model_errors(); + let cache = OutfitCache::build(&ds, &JPL_EPHEM_HORIZON, &UT1_PROVIDER, false).unwrap(); + (ds, cache) + } + + /// Compare `compute_obs_and_partials_2body`. + /// + /// Oracle CSV: + /// ```text + /// alj,dej,dade_1..6,ddde_1..6 + /// 1.7899146359808342E+00, 7.7899266923582489E-01, + /// 3.1851098532224442E-01, 2.2712826873745859E+00, 7.9745296015874274E+00, + /// -5.1709157216897728E-02, 6.2777657715751256E-01, 4.8251464725263409E+00, + /// -2.5570462606126815E-01, 4.3979089293398144E-01, -5.2448719168676883E-01, + /// 2.9826514389781167E+00, 3.5132719449178342E+00, -2.2004775894187203E-01 + /// ``` + /// + /// Tolerances reflect expected numerical agreement between the two + /// implementations (same two-body propagator, same aberration model): + /// - `ra`, `dec`: within 1e-5 rad (~2 arcsec) + /// - derivatives: within 1e-3 relative — tightened once frame/model parity + /// is confirmed. + #[test] + fn test_oracle_alph_del() { + let (ds, cache) = make_obs_and_cache(); + let elem = oracle_elements(); + let obs = ds.get_observation(0).unwrap(); + + let result = obs + .compute_obs_and_partials_2body(&cache, &JPL_EPHEM_HORIZON, &elem) + .expect("compute_obs_and_partials_2body should succeed"); + + // Oracle values + let oracle_ra: f64 = 1.789_914_635_980_834_2; + let oracle_dec: f64 = 7.789_926_692_358_249e-1; + let oracle_drade: [f64; 6] = [ + 3.185_109_853_222_444e-1, + 2.271_282_687_374_586, + 7.974_529_601_587_427, + -5.170_915_721_689_773e-2, + 6.277_765_771_575_126e-1, + 4.825_146_472_526_341, + ]; + let oracle_ddecde: [f64; 6] = [ + -2.557_046_260_612_681_5e-1, + 4.397_908_929_339_814_4e-1, + -5.244_871_916_867_688e-1, + 2.982_651_438_978_116_7, + 3.513_271_944_917_834_2, + -2.200_477_589_418_720_3e-1, + ]; + + // (ra, dec): the two implementations may differ at the ~1e-4 level + // due to ephemeris and observer-coordinate differences; this check + // catches gross errors (sign flips, unit mismatches, etc.) + + // Tolerance 1e-4 rad: (α,δ) may differ at this level due to + // ephemeris-file and observer-coordinate differences. The test + // catches gross errors (sign flips, unit mismatches, frame errors). + assert_abs_diff_eq!(result.ra, oracle_ra, epsilon = 1e-4); + assert_abs_diff_eq!(result.dec, oracle_dec, epsilon = 1e-4); + + // Tolerance 1e-2 rad/[elem units]: derivatives carry the same + // ephemeris/observer uncertainty amplified by the chain rule. + // The FD test (below) verifies self-consistency to ~1e-3. + for j in 0..6 { + assert_abs_diff_eq!(result.d_ra_d_elem[j], oracle_drade[j], epsilon = 1e-2,); + assert_abs_diff_eq!(result.d_dec_d_elem[j], oracle_ddecde[j], epsilon = 1e-2,); + } + } + + /// (α, δ) from `compute_obs_and_partials_2body` must match + /// `compute_apparent_position` exactly (same propagation path). + #[test] + fn test_ra_dec_consistent_with_apparent_position() { + let (ds, cache) = make_obs_and_cache(); + let elem = oracle_elements(); + let obs = ds.get_observation(0).unwrap(); + + let (ra, dec) = obs + .compute_apparent_position(&cache, &JPL_EPHEM_HORIZON, &elem) + .unwrap(); + let result = obs + .compute_obs_and_partials_2body(&cache, &JPL_EPHEM_HORIZON, &elem) + .unwrap(); + + assert_abs_diff_eq!(result.ra, ra, epsilon = 1e-14); + assert_abs_diff_eq!(result.dec, dec, epsilon = 1e-14); + } + + /// Validate ∂α/∂elemⱼ and ∂δ/∂elemⱼ by central finite differences. + /// + /// We perturb each equinoctial element by ±ε and compare the numerical + /// gradient to the analytic Jacobian from `compute_obs_and_partials_2body`. + #[test] + fn test_element_partials_match_finite_differences() { + let (ds, cache) = make_obs_and_cache(); + let elem = oracle_elements(); + let obs = ds.get_observation(0).unwrap(); + + let result = obs + .compute_obs_and_partials_2body(&cache, &JPL_EPHEM_HORIZON, &elem) + .unwrap(); + + let eps = 1e-6_f64; // small perturbation for each element + + // Helper: build perturbed elements + let perturb = |j: usize, delta: f64| -> EquinoctialElements { + let mut e = elem.clone(); + match j { + 0 => e.semi_major_axis += delta, + 1 => e.eccentricity_sin_lon += delta, + 2 => e.eccentricity_cos_lon += delta, + 3 => e.tan_half_incl_sin_node += delta, + 4 => e.tan_half_incl_cos_node += delta, + 5 => e.mean_longitude += delta, + _ => unreachable!(), + } + e + }; + + for j in 0..6 { + let elem_fwd = perturb(j, eps); + let elem_bwd = perturb(j, -eps); + + let r_fwd = obs + .compute_obs_and_partials_2body(&cache, &JPL_EPHEM_HORIZON, &elem_fwd) + .unwrap(); + let r_bwd = obs + .compute_obs_and_partials_2body(&cache, &JPL_EPHEM_HORIZON, &elem_bwd) + .unwrap(); + + // Unwrap RA difference to (−π, π] to handle 0/2π wrap + let mut dra = r_fwd.ra - r_bwd.ra; + if dra > std::f64::consts::PI { + dra -= std::f64::consts::TAU; + } else if dra < -std::f64::consts::PI { + dra += std::f64::consts::TAU; + } + let num_dra = dra / (2.0 * eps); + let num_ddec = (r_fwd.dec - r_bwd.dec) / (2.0 * eps); + + assert_abs_diff_eq!(result.d_ra_d_elem[j], num_dra, epsilon = 1e-3,); + assert_abs_diff_eq!(result.d_dec_d_elem[j], num_ddec, epsilon = 1e-3,); + } + } + } +} diff --git a/src/ephemeris/request.rs b/src/ephemeris/request.rs new file mode 100644 index 0000000..251fd7f --- /dev/null +++ b/src/ephemeris/request.rs @@ -0,0 +1,350 @@ +//! Ephemeris request builder and output-kind trait. +//! +//! This module contains everything needed to describe *what* to compute and +//! *when* to compute it. The typical workflow is: +//! +//! 1. **Build a request** — create an [`EphemerisRequest`] with the desired +//! output kind as a type parameter, then chain [`EphemerisRequest::add`] +//! calls to attach `(observer, mode)` pairs. +//! 2. **Call compute** — pass the request to +//! [`OrbitalElements::compute`](crate::OrbitalElements::compute) together +//! with a [`JPLEphem`] and a UT1 provider. +//! 3. **Consume the result** — iterate over the returned +//! [`EphemerisResult`](super::result::EphemerisResult) using +//! [`successes`](super::result::EphemerisResult::successes), +//! [`errors`](super::result::EphemerisResult::errors), or +//! [`by_observer`](super::result::EphemerisResult::by_observer). +//! +//! Errors that occur at individual epochs are recorded inside the result +//! rather than short-circuiting the whole computation: every requested +//! `(epoch, observer)` pair always produces an entry, either `Ok` or `Err`. +//! +//! # Output kinds +//! +//! Three zero-sized marker types select what is computed at each epoch: +//! +//! | Marker | Physical quantities | Output type per epoch | +//! |---|---|---| +//! | [`Position`] | Apparent sky coordinates (RA, Dec), geocentric and heliocentric distances | [`ApparentPosition`] | +//! | [`Geometry`] | Phase angle, solar elongation, radial velocity, apparent angular rates | [`BodyGeometry`] | +//! | [`Combined`] | All of the above from a single orbit propagation | `(`[`ApparentPosition`]`, `[`BodyGeometry`]`)` | +//! +//! # Generation modes +//! +//! Each observer is paired with an [`EphemerisMode`] that describes *when* to +//! compute: +//! +//! | Variant | Epochs generated | +//! |---|---| +//! | [`EphemerisMode::Single`] | Exactly one epoch | +//! | [`EphemerisMode::Range`] | Uniform grid from `start` to `end` | +//! | [`EphemerisMode::At`] | Arbitrary list of epochs | +//! +//! Different observers may use different modes in the same request. +//! +//! # Example +//! +//! ```rust,ignore +//! use outfit::{ +//! EphemerisConfig, EphemerisRequest, EphemerisMode, +//! ephemeris::request::Combined, +//! }; +//! use hifitime::{Epoch, Duration}; +//! +//! let result = elements.compute( +//! &EphemerisRequest::::new(EphemerisConfig::default()) +//! .add(observer_a, EphemerisMode::Range { +//! start: Epoch::from_mjd_tt(60310.0), +//! end: Epoch::from_mjd_tt(60340.0), +//! step: Duration::from_days(1.0), +//! }) +//! .add(observer_b, EphemerisMode::At(vec![t1, t2, t3])), +//! &jpl, +//! &ut1, +//! ); +//! ``` + +use hifitime::{Duration, Epoch}; +use photom::observer::Observer; +use std::marker::PhantomData; + +use super::{ApparentPosition, BodyGeometry, EphemerisConfig}; +use crate::{ + cache::observer_fixed_cache::ObserverFixedCache, EquinoctialElements, JPLEphem, OutfitError, +}; +use hifitime::ut1::Ut1Provider; + +// --------------------------------------------------------------------------- +// Output-kind sealed trait +// --------------------------------------------------------------------------- + +mod sealed { + pub trait Sealed {} +} + +/// Trait implemented by [`Position`], [`Geometry`], and [`Combined`]. +/// +/// This uses the *sealed trait* pattern: because `Sealed` is private, no +/// external crate can implement `EphemerisOutputKind` for its own types. +/// Use one of the three provided marker types to select what an +/// [`EphemerisRequest`] computes. +pub trait EphemerisOutputKind: sealed::Sealed { + /// The value produced for each epoch. + type Output; + + /// Compute the output for a single `(epoch, observer)` pair. + /// + /// `fixed_cache` must have been built once per observer slot (before the + /// epoch loop) from the same [`Observer`] that owns this request slot, + /// via [`ObserverFixedCache::try_from`]. It is passed in here to avoid + /// rebuilding the epoch-invariant body-fixed coordinates on every epoch. + #[doc(hidden)] + fn compute_one( + equi: &EquinoctialElements, + obs_time_mjd: f64, + observer: &Observer, + fixed_cache: &ObserverFixedCache, + jpl: &JPLEphem, + ut1: &Ut1Provider, + config: &EphemerisConfig, + ) -> Result; +} + +// --------------------------------------------------------------------------- +// Marker types +// --------------------------------------------------------------------------- + +/// Output-kind marker: compute only the apparent sky position. +/// +/// Use this when you need `(RA, Dec)` and distances but not the geometric +/// quantities (phase angle, elongation, …). +/// +/// Produces [`ApparentPosition`] per epoch. +pub struct Position; + +/// Output-kind marker: compute only the geometric quantities. +/// +/// Use this when you need the phase angle, solar elongation, radial velocity +/// and apparent angular rates but not the full sky-coordinate conversion. +/// +/// Produces [`BodyGeometry`] per epoch. +pub struct Geometry; + +/// Output-kind marker: compute both apparent position and geometric quantities +/// from a **single orbit propagation** per epoch. +/// +/// More efficient than computing [`Position`] and [`Geometry`] separately: +/// the orbit is propagated once and the results are split into the two output +/// types. +/// +/// Produces `(`[`ApparentPosition`]`, `[`BodyGeometry`]`)` per epoch. +pub struct Combined; + +impl sealed::Sealed for Position {} +impl sealed::Sealed for Geometry {} +impl sealed::Sealed for Combined {} + +impl EphemerisOutputKind for Position { + type Output = ApparentPosition; + + fn compute_one( + equi: &EquinoctialElements, + obs_time_mjd: f64, + _observer: &Observer, + fixed_cache: &ObserverFixedCache, + jpl: &JPLEphem, + ut1: &Ut1Provider, + config: &EphemerisConfig, + ) -> Result { + super::apparent_position::compute(equi, obs_time_mjd, fixed_cache, jpl, ut1, config) + } +} + +impl EphemerisOutputKind for Geometry { + type Output = BodyGeometry; + + fn compute_one( + equi: &EquinoctialElements, + obs_time_mjd: f64, + _observer: &Observer, + fixed_cache: &ObserverFixedCache, + jpl: &JPLEphem, + ut1: &Ut1Provider, + config: &EphemerisConfig, + ) -> Result { + let state = + super::apparent_position::propagate(equi, obs_time_mjd, fixed_cache, jpl, ut1, config)?; + super::geometry::compute_geometry(&state, equi, &config.aberration) + } +} + +impl EphemerisOutputKind for Combined { + type Output = (ApparentPosition, BodyGeometry); + + fn compute_one( + equi: &EquinoctialElements, + obs_time_mjd: f64, + _observer: &Observer, + fixed_cache: &ObserverFixedCache, + jpl: &JPLEphem, + ut1: &Ut1Provider, + config: &EphemerisConfig, + ) -> Result { + super::apparent_position::compute_with_geometry( + equi, + obs_time_mjd, + fixed_cache, + jpl, + ut1, + config, + ) + } +} + +// --------------------------------------------------------------------------- +// EphemerisMode +// --------------------------------------------------------------------------- + +/// Describes *when* to compute ephemerides for one observer. +/// +/// Different observers in the same [`EphemerisRequest`] may use different +/// modes freely — for instance, a topocentric site can use [`Single`] while a +/// geocentric site uses [`Range`]. +/// +/// [`Single`]: EphemerisMode::Single +/// [`Range`]: EphemerisMode::Range +#[derive(Debug, Clone)] +pub enum EphemerisMode { + /// Compute at exactly one epoch. + Single(Epoch), + + /// Compute over a uniformly-spaced time range. + /// + /// Generates epochs `start, start + step, start + 2·step, …` for as long + /// as the current epoch is ≤ `end`. + /// + /// > **Note:** a non-positive `step` or `start > end` yields **zero + /// > epochs** for this observer; no entries are added to the result. + Range { + /// First epoch (inclusive). + start: Epoch, + /// Last epoch (inclusive if reachable by an exact multiple of `step`). + end: Epoch, + /// Time step between consecutive epochs. + /// + /// Must be strictly positive; a zero or negative value produces no + /// epochs. + step: Duration, + }, + + /// Compute at an arbitrary, caller-provided list of epochs. + /// + /// Epochs are used in the order given; duplicates are allowed. + At(Vec), +} + +impl EphemerisMode { + /// Expand this mode into the concrete list of epochs it covers. + pub(crate) fn epochs(&self) -> Vec { + match self { + EphemerisMode::Single(t) => vec![*t], + EphemerisMode::Range { start, end, step } => { + if *step <= Duration::ZERO || start > end { + return vec![]; + } + let approx_n = + (((*end - *start).to_seconds()) / step.to_seconds()).ceil() as usize + 1; + let mut epochs = Vec::with_capacity(approx_n); + let mut current = *start; + while current <= *end { + epochs.push(current); + current += *step; + } + epochs + } + EphemerisMode::At(times) => times.clone(), + } + } +} + +// --------------------------------------------------------------------------- +// ObserverRequest +// --------------------------------------------------------------------------- + +/// A single `(observer, mode)` slot inside an [`EphemerisRequest`]. +#[derive(Debug, Clone)] +pub struct ObserverRequest { + /// The observing site. + pub observer: Observer, + /// When to compute ephemerides for this observer. + pub mode: EphemerisMode, +} + +// --------------------------------------------------------------------------- +// EphemerisRequest +// --------------------------------------------------------------------------- + +/// A typed ephemeris generation request. +/// +/// Collects any number of `(`[`Observer`]`, `[`EphemerisMode`]`)` pairs and +/// carries the desired output kind `O` as a compile-time type parameter. +/// `O` is encoded via [`PhantomData`] — it is a zero-cost, zero-size +/// compile-time marker with no runtime overhead. +/// +/// Build the request with [`new`](Self::new) and chain [`add`](Self::add) +/// calls, then pass it to [`crate::OrbitalElements::compute`]. +/// +/// # Type parameter +/// +/// `O` must be one of [`Position`], [`Geometry`], or [`Combined`]. The +/// choice is enforced at compile time: it is impossible to mix output kinds +/// within one request. +/// +/// # Example +/// +/// ```rust,ignore +/// let request = EphemerisRequest::::new(EphemerisConfig::default()) +/// .add(observer_a, EphemerisMode::Range { start, end, step }) +/// .add(observer_b, EphemerisMode::At(vec![t1, t2, t3])) +/// .add(observer_c, EphemerisMode::Single(t)); +/// +/// let result = elements.compute(&request, &jpl, &ut1); +/// ``` +#[derive(Debug, Clone)] +pub struct EphemerisRequest { + pub(crate) observers: Vec, + pub(crate) config: EphemerisConfig, + _output: PhantomData, +} + +impl EphemerisRequest { + /// Create a new empty request with the given [`EphemerisConfig`]. + /// + /// The request contains no observers until [`add`](Self::add) is called. + pub fn new(config: EphemerisConfig) -> Self { + Self { + observers: Vec::new(), + config, + _output: PhantomData, + } + } + + /// Add an `(observer, mode)` pair to the request. + /// + /// Returns `self` to allow builder-style chaining. The same observer may + /// be added multiple times with different modes if needed. + pub fn add(mut self, observer: Observer, mode: EphemerisMode) -> Self { + self.observers.push(ObserverRequest { observer, mode }); + self + } + + /// Number of `(observer, mode)` pairs currently in the request. + pub fn len(&self) -> usize { + self.observers.len() + } + + /// Returns `true` if no observers have been added yet. + pub fn is_empty(&self) -> bool { + self.observers.is_empty() + } +} diff --git a/src/ephemeris/result.rs b/src/ephemeris/result.rs new file mode 100644 index 0000000..7c32b25 --- /dev/null +++ b/src/ephemeris/result.rs @@ -0,0 +1,198 @@ +//! Ephemeris computation results. +//! +//! [`EphemerisResult`] is the return type of +//! [`OrbitalElements::compute`](crate::OrbitalElements::compute). +//! It holds a flat sequence of [`EphemerisEntry`] values, each pairing an +//! epoch, the observer for which the computation was requested, and the +//! per-epoch result (`Ok` or `Err`). +//! +//! Errors are collected *per entry*: a failure at one epoch does not prevent +//! the remaining epochs or observers from being computed. The total entry +//! count always equals the sum of epochs generated across all observers in +//! the request. +//! +//! # Iteration +//! +//! ```rust,ignore +//! for entry in &result { +//! match &entry.result { +//! Ok(pos) => println!("{} {:?}: RA={:.4}", entry.epoch, entry.observer.name, pos.coord.ra), +//! Err(e) => eprintln!("{} {:?}: {e}", entry.epoch, entry.observer.name), +//! } +//! } +//! ``` +//! +//! Three focused convenience iterators narrow the view: +//! +//! - [`EphemerisResult::successes`] — only entries whose result is `Ok`. +//! - [`EphemerisResult::errors`] — only entries whose result is `Err`. +//! - [`EphemerisResult::by_observer`] — only entries for a specific observer. + +use hifitime::Epoch; +use photom::observer::Observer; + +use crate::OutfitError; + +// --------------------------------------------------------------------------- +// EphemerisEntry +// --------------------------------------------------------------------------- + +/// A single row in an [`EphemerisResult`]: one `(epoch, observer, result)`. +/// +/// The `observer` field records which site was used for this computation, +/// which is essential when a request spans several observers. +/// +/// # Ordering +/// +/// Entries follow the order in which observers were added to the request: all +/// epochs for the first observer appear first, then all epochs for the second +/// observer, and so on. Within each observer the epoch order matches the +/// [`EphemerisMode`](super::request::EphemerisMode) used for that observer. +#[derive(Debug)] +pub struct EphemerisEntry { + /// Observation epoch. + pub epoch: Epoch, + /// Observing site for which this entry was computed. + pub observer: Observer, + /// Computed value, or the error that prevented computation. + pub result: Result, +} + +// --------------------------------------------------------------------------- +// EphemerisResult +// --------------------------------------------------------------------------- + +/// The result of an [`EphemerisRequest`](super::request::EphemerisRequest) +/// computation. +/// +/// Contains one [`EphemerisEntry`] per `(epoch, observer)` pair generated by +/// the request. Use [`successes`](Self::successes), [`errors`](Self::errors), +/// or [`by_observer`](Self::by_observer) to filter the entries, or iterate +/// over all of them with [`iter`](Self::iter) or the [`IntoIterator`] impl. +/// +/// # Example +/// +/// ```rust,ignore +/// let result = elements.compute(&request, &jpl, &ut1); +/// +/// println!("{} entries, {} errors", result.len(), result.error_count()); +/// +/// for entry in result.successes() { +/// println!("{}: {:?}", entry.epoch, entry.result); +/// } +/// ``` +#[derive(Debug)] +pub struct EphemerisResult { + entries: Vec>, +} + +impl EphemerisResult { + /// Create an empty result with pre-allocated capacity. + pub(crate) fn with_capacity(n: usize) -> Self { + Self { + entries: Vec::with_capacity(n), + } + } + + /// Append a single entry. + pub(crate) fn push( + &mut self, + epoch: Epoch, + observer: Observer, + result: Result, + ) { + self.entries.push(EphemerisEntry { + epoch, + observer, + result, + }); + } + + // ----------------------------------------------------------------------- + // Accessors + // ----------------------------------------------------------------------- + + /// Total number of entries (successes + errors). + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Returns `true` if there are no entries. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Number of entries whose computation succeeded. + /// + /// Equals `self.len() - self.error_count()`. + pub fn success_count(&self) -> usize { + self.entries.iter().filter(|e| e.result.is_ok()).count() + } + + /// Number of entries whose computation failed. + /// + /// A value of `0` means every `(epoch, observer)` pair succeeded. + pub fn error_count(&self) -> usize { + self.entries.iter().filter(|e| e.result.is_err()).count() + } + + // ----------------------------------------------------------------------- + // Iterators + // ----------------------------------------------------------------------- + + /// Iterate over all entries in order (successes and errors). + pub fn iter(&self) -> impl Iterator> { + self.entries.iter() + } + + /// Iterate over entries whose computation succeeded, in order. + pub fn successes(&self) -> impl Iterator> { + self.entries.iter().filter(|e| e.result.is_ok()) + } + + /// Iterate over entries whose computation failed, in order. + pub fn errors(&self) -> impl Iterator> { + self.entries.iter().filter(|e| e.result.is_err()) + } + + /// Iterate over entries for a specific observer. + /// + /// Two [`Observer`] values are considered equal when all their fields + /// match — geodetic coordinates ($\rho\cos\phi'$, $\rho\sin\phi'$, + /// longitude), optional site name, and optional measurement accuracies. + /// For reliable filtering, pass the exact same `Observer` instance that + /// was given to [`EphemerisRequest::add`](super::request::EphemerisRequest::add). + pub fn by_observer<'s, 'o>( + &'s self, + observer: &'o Observer, + ) -> impl Iterator> + 'o + where + 's: 'o, + { + self.entries.iter().filter(move |e| &e.observer == observer) + } +} + +// --------------------------------------------------------------------------- +// IntoIterator +// --------------------------------------------------------------------------- + +/// Consuming iterator over all entries in order. +impl IntoIterator for EphemerisResult { + type Item = EphemerisEntry; + type IntoIter = std::vec::IntoIter>; + + fn into_iter(self) -> Self::IntoIter { + self.entries.into_iter() + } +} + +/// Borrowing iterator over all entries in order. +impl<'a, T> IntoIterator for &'a EphemerisResult { + type Item = &'a EphemerisEntry; + type IntoIter = std::slice::Iter<'a, EphemerisEntry>; + + fn into_iter(self) -> Self::IntoIter { + self.entries.iter() + } +} diff --git a/src/error_models/data_models/cbm10.rules b/src/error_models/data_models/cbm10.rules deleted file mode 100644 index 5942367..0000000 --- a/src/error_models/data_models/cbm10.rules +++ /dev/null @@ -1,64 +0,0 @@ -! obscods catal rms(acosd) rmsd (arcsec) -699:c @ 0.93, 0.78 -608:c @ 1.26, 1.53 -644:c @ 0.47, 0.56 -106:c @ 1.02, 0.79 -D29:c @ 0.66, 0.59 -689:c @ 0.51, 0.63 -ALL:cc @ 1.02, 0.79 -ALL:cd @ 1.02, 0.79 -ALL:ce @ 0.66, 0.59 -ALL:cq @ 0.66, 0.59 -ALL:cr @ 0.66, 0.59 -ALL:ct @ 0.66, 0.59 -ALL:cu @ 0.66, 0.59 -ALL:co @ 0.99, 0.81 -ALL:cs @ 0.99, 0.81 -ALL:ca @ 1.17, 1.02 -ALL:cb @ 1.17, 1.02 -ALL:ch @ 0.90, 0.88 -ALL:ci @ 0.90, 0.88 -ALL:cj @ 0.90, 0.88 -ALL:cz @ 0.90, 0.88 -ALL:cm @ 1.11, 1.13 -ALL:cw @ 0.88, 0.71 -ALL:cf @ 1.45, 1.27 -ALL:cg @ 1.45, 1.27 -ALL:cL @ 0.50, 0.50 -704:cc @ 1.23, 1.19 -699:cc @ 0.93, 0.78 -691:cc @ 0.63, 0.68 -608:cc @ 1.26, 1.53 -703:cc @ 1.23, 1.13 -644:cc @ 0.47, 0.56 -703:ce @ 0.97, 0.91 -G96:ce @ 0.50, 0.42 -E12:ce @ 0.82, 0.85 -683:ce @ 1.21, 1.55 -699:co @ 0.84, 0.81 -644:co @ 0.36, 0.33 -691:co @ 0.50, 0.56 -704:cd @ 1.23, 1.19 -699:cd @ 0.93, 0.78 -691:cd @ 0.63, 0.68 -608:cd @ 1.26, 1.53 -703:cd @ 1.23, 1.13 -644:cd @ 0.47, 0.56 -703:cr @ 0.97, 0.91 -G96:cr @ 0.50, 0.42 -E12:cr @ 0.82, 0.85 -683:cr @ 1.21, 1.55 -699:cs @ 0.84, 0.81 -644:cs @ 0.36, 0.33 -691:cs @ 0.50, 0.56 -689:cg @ 0.51, 0.63 -645:ce @ 0.30, 0.30 -F51:cL @ 0.30, 0.30 -F51:ct @ 0.30, 0.30 -C51:cL @ 1.00, 1.00 -568:cL @ 0.30, 0.30 -568:ct @ 0.25, 0.25 -568:co @ 0.50, 0.50 -568:cs @ 0.50, 0.50 -W84:co @ 0.30, 0.30 -W84:cL @ 0.30, 0.30 \ No newline at end of file diff --git a/src/error_models/data_models/fcct14.rules b/src/error_models/data_models/fcct14.rules deleted file mode 100644 index 43369dc..0000000 --- a/src/error_models/data_models/fcct14.rules +++ /dev/null @@ -1,48 +0,0 @@ -ALL: c=cd @ 0.51, 0.40 ! CBM Generic Catalog weights -ALL: c=eqru @ 0.33, 0.30 -ALL: c=tL @ 0.25, 0.25 -ALL: c=os @ 0.50, 0.41 -ALL: c=ab @ 0.59, 0.51 -ALL: c=hijz @ 0.45, 0.44 -ALL: c=m @ 0.56, 0.57 -ALL: c=w @ 0.44, 0.36 -ALL: c=fg @ 0.73, 0.64 -ALL: c=UV @ 0.60, 0.60 -258: c=* @ 0.10, 0.10 -704: c=cd @ 0.62, 0.60 ! CBM Station-Catalog specific rules -699: c=cd @ 0.47, 0.39 -691: c=cd @ 0.32, 0.34 -608: c=cd @ 0.63, 0.77 -703: c=cd @ 0.62, 0.57 -644: c=cd @ 0.24, 0.28 -703: c=er @ 0.49, 0.46 -G96: c=erUV @ 0.25, 0.21 -E12: c=er @ 0.41, 0.43 -683: c=er @ 0.61, 0.78 -699: c=os @ 0.42, 0.41 -644: c=os @ 0.18, 0.17 -691: c=os @ 0.25, 0.28 -689: c=g @ 0.26, 0.32 -645: c=e @ 0.15, 0.15 ! New rules -F51: c=Lt @ 0.15, 0.15 -F51: c=UV @ 0.15, 0.15 -F52: c=Lt @ 0.15, 0.15 -F52: c=UV @ 0.15, 0.15 -568: c=L @ 0.15, 0.15 -568: c=t @ 0.13, 0.13 -568: c=os @ 0.25, 0.25 -568: c=UV @ 0.10, 0.10 -309: c=UV @ 0.15, 0.15 -H01: c=Lt @ 0.15, 0.15 -I41: c=UV @ 0.20, 0.20 -I41: c=N @ 0.40, 0.40 -673: c=* @ 0.30, 0.30 ! TMO -G45: c=* @ 0.50, 0.50 ! Space Survelliance Telescope -250: c=* @ 1.30, 1.30 ! Satellites: HST -249: c=* @ 60.0, 60.0 ! SOHO -C49: c=* @ 60.0, 60.0 ! STEREO-A -C50: c=* @ 60.0, 60.0 ! STEREO-B -C51: c=* @ 1.00, 1.00 ! WISE -T12: c=UV @ 0.10, 0.10 ! Tholen from UH88 with Gaia catalog -T09: c=UV @ 0.10, 0.10 ! Tholen from Subaru with Gaia catalog -T14: c=UV @ 0.10, 0.10 ! Tholen from CFHT with Gaia catalog diff --git a/src/error_models/data_models/vfcc17.rules b/src/error_models/data_models/vfcc17.rules deleted file mode 100644 index 1942c9f..0000000 --- a/src/error_models/data_models/vfcc17.rules +++ /dev/null @@ -1,100 +0,0 @@ -ALL t=PAN c=* p= > < 1890-01-01 @ 10.00, 10.00 -ALL t=PAN c=* p= > 1890-01-01 < 1950-01-01 @ 5.00, 5.00 -ALL t=PAN c=* p= > 1950-01-01 < @ 2.50, 2.50 -ALL t=cBCVn c=* p= > < @ 1.00, 1.00 ! Unknown catalog -ALL t=E c=* p= > < @ 0.20, 0.20 ! Occultations -ALL t=H c=* p= > < @ 0.40, 0.40 ! Hipparcos -ALL t=T c=* p= > < @ 0.50, 0.50 ! Transit circle -ALL t=e c=* p= > < @ 0.75, 0.75 ! Encoder -ALL t=M c=* p= > < @ 3.00, 3.00 ! Micrometer -ALL t=S c=* p= > < @ 1.50, 1.50 ! Satellite -ALL t=cC c=UVXW p= > < @ 0.60, 0.60 ! Gaia astrometric catalogs -F51 t=cC c=* p= > < @ 0.20, 0.20 -F52 t=cC c=* p= > < @ 0.20, 0.20 -G96 t=cC c=* p= > < @ 0.50, 0.50 -703 t=cC c=* p= > < 2014-01-01 @ 1.00, 1.00 -703 t=cC c=* p= > 2014-01-01 < @ 0.80, 0.80 -E12 t=cC c=* p= > < @ 0.75, 0.75 -704 t=cC c=* p= > < @ 1.00, 1.00 -691 t=cC c=* p= > < 2003-01-01 @ 0.60, 0.60 -691 t=cC c=* p= > 2003-01-01 < @ 0.50, 0.50 -291 t=cC c=* p= > < 2003-01-01 @ 0.60, 0.60 ! Updated -291 t=cC c=* p= > 2003-01-01 < @ 0.50, 0.50 ! Updated -644 t=cC c=* p= > < 2003-09-01 @ 0.60, 0.60 -644 t=cC c=* p= > 2003-09-01 < @ 0.40, 0.40 -699 t=cC c=* p= > < @ 0.80, 0.80 -G45 t=cC c=* p= > < @ 0.60, 0.60 -D29 t=cC c=* p= > < @ 0.75, 0.75 -T05 t=cC c=* p= > < @ 0.80, 0.80 -568 t=cC c=* p= > < @ 0.50, 0.50 ! Generic -568 t=cC c=t p=_ > < @ 0.20, 0.20 ! Micheli updated -568 t=cC c=q p=_ > < @ 0.20, 0.20 ! Micheli updated -568 t=cC c=UVXW p=_ > < @ 0.10, 0.10 ! Micheli updated -568 t=cC c=t p=2 > < @ 0.20, 0.20 ! Tholen -568 t=cC c=UVXW p=2 > < @ 0.10, 0.10 ! Tholen -568 t=cC c=os p=2 > < @ 0.50, 0.50 ! Tholen -568 t=cC c=UVXW p=^ > < @ 0.20, 0.20 ! Weryk new -T09 t=cC c=* p= > < @ 0.50, 0.50 ! Generic -T09 t=cC c=t p=0 > < @ 0.20, 0.20 ! Tholen -T09 t=cC c=UVX p=0 > < @ 0.10, 0.10 ! Tholen -T12 t=cC c=* p= > < 2022-10-06 @ 0.10, 0.10 ! Tholen Before 06 October 2022 -T12 t=cC c=* p= > 2022-10-06 < @ 0.50, 0.50 ! Generic New program codes, see MPEC 2022-T98 -T12 t=cC c=t p=0 > < 2022-10-06 @ 0.10, 0.10 ! Tholen Before 06 October 2022 -T12 t=cC c=t p=0 > 2022-10-06 < @ 0.20, 0.20 ! Tholen New program codes -T12 t=cC c=UVX p=0 > < 2022-10-06 @ 0.10, 0.10 ! Tholen Before 06 October 2022 -T12 t=cC c=UVX p=0 > 2022-10-06 < @ 0.10, 0.10 ! Tholen New program codes -T12 t=cC c=UVX p=1 > < 2022-10-06 @ 0.10, 0.10 ! Tholen Before 06 October 2022 -T12 t=cC c=UVX p=1 > 2022-10-06 < @ 0.20, 0.20 ! Weryk new New program code -T14 t=cC c=* p= > < @ 0.50, 0.50 ! Generic -T14 t=cC c=t p=0 > < @ 0.20, 0.20 ! Tholen -T14 t=cC c=UVX p=0 > < @ 0.10, 0.10 ! Tholen -T14 t=cC c=t p=7 > < @ 0.20, 0.20 ! Micheli updated -T14 t=cC c=UVX p=7 > < @ 0.10, 0.10 ! Micheli updated -T14 t=cC c=UVX p=3 > < @ 0.20, 0.20 ! Weryk new -H01 t=cC c=LtUVXW p= > < @ 0.30, 0.30 -673 t=cC c=* p= > < @ 0.30, 0.30 -645 t=cC c=* p= > < @ 0.30, 0.30 -689 t=cC c=* p= > < @ 0.50, 0.50 -J04 t=cC c=UVXWtLqrue p= > < @ 0.40, 0.40 ! Updated -Z84 t=cC c=UVXWtLqrue p= > < @ 0.40, 0.40 ! New -W84 t=cC c=* p= > < @ 0.50, 0.50 -950 t=cC c=UVXWtLqrue p= > < @ 0.50, 0.50 -F65 t=cC c=* p= > < @ 0.40, 0.40 -E10 t=cC c=* p= > < @ 0.40, 0.40 -W85 t=cC c=* p= > < @ 0.40, 0.40 -W86 t=cC c=* p= > < @ 0.40, 0.40 -W87 t=cC c=* p= > < @ 0.40, 0.40 -Q63 t=cC c=* p= > < @ 0.40, 0.40 -Q64 t=cC c=* p= > < @ 0.40, 0.40 -K91 t=cC c=* p= > < @ 0.40, 0.40 -K92 t=cC c=* p= > < @ 0.40, 0.40 -K93 t=cC c=* p= > < @ 0.40, 0.40 -V37 t=cC c=* p= > < @ 0.40, 0.40 -V39 t=cC c=* p= > < @ 0.40, 0.40 ! New -Z31 t=cC c=* p= > < @ 0.40, 0.40 ! New -Z24 t=cC c=* p= > < @ 0.40, 0.40 ! New -Y28 t=cC c=tUVXW p= > < 2015-01-01 @ 1.00, 1.00 ! New -Y28 t=cC c=tUVXW p= > 2015-01-01 < @ 0.30, 0.30 ! New -309 t=cC c=UVXW p=&% > < @ 0.20, 0.20 -309 t=cC c=tq p=&% > < @ 0.30, 0.30 -G83 t=cC c=UVXW p=2 > < @ 0.20, 0.20 -G83 t=cC c=tq p=2 > < @ 0.30, 0.30 -248 t=* c=* p= > < 1991-01-01 @ 0.20, 0.20 ! Hipparcos -248 t=* c=* p= > 1991-01-01 < @ 0.15, 0.15 ! Hipparcos -250 t=* c=* p= > < @ 1.30, 1.30 ! HST -249 t=* c=* p= > < @ 60.00, 60.00 ! SOHO -C49 t=* c=* p= > < @ 60.00, 60.00 ! STEREO-A -C50 t=* c=* p= > < @ 60.00, 60.00 ! STEREO-B -C51 t=* c=* p= > < @ 1.00, 1.00 ! WISE -C57 t=* c=* p= > < @ 5.00, 5.00 ! TESS -608 t=cC c=* p= > < @ 0.60, 0.60 ! New -Z18 t=cC c=tq p=1 > < @ 0.30, 0.30 ! Micheli New -Z18 t=cC c=UVXW p=1 > < @ 0.20, 0.20 ! Micheli New -T08 t=cC c=* p= > < @ 0.80, 0.80 ! New -258 t=cC c=* p= > < @ 0.50, 0.50 ! GAIA new -C65 t=cC c=UVXW p=3 > < @ 0.20, 0.20 ! Micheli new -094 t=cC c=UVXW p=9 > < @ 0.50, 0.50 ! Micheli new -181 t=cC c=UVXW p= > < 2019-01-01 @ 1.00, 1.00 ! New -181 t=cC c=UVXW p= > 2019-01-01 < @ 0.50, 0.50 ! New -M28 t=* c=* p= > < 2023-12-12 @ 3.00, 3.00 ! Bad observations from M28, biased fixed on Dec. 2023 -M28 t=* c=* p= > 2023-12-12 < @ 1.00, 1.00 ! Restore default RMS for M28 diff --git a/src/error_models/mod.rs b/src/error_models/mod.rs deleted file mode 100644 index aa00ffa..0000000 --- a/src/error_models/mod.rs +++ /dev/null @@ -1,418 +0,0 @@ -//! # Astrometric error models -//! -//! This module provides tools to **handle observation error models** used in orbit -//! determination. Error models define the astrometric biases and RMS values -//! associated with each observatory and star catalog, as recommended in the literature -//! (e.g., FCCT14, CBM10, VFCC17). -//! -//! ## Public API -//! -//! ### [`crate::error_models::ErrorModel`] -//! Enumeration of the supported astrometric error models: -//! -//! - `ErrorModel::FCCT14` – Farnocchia, Chesley, Chamberlin & Tholen (2014) -//! - `ErrorModel::CBM10` – Chesley, Baer & Monet (2010) -//! - `ErrorModel::VFCC17` – Vereš, Farnocchia, Chesley & Chamberlin (2017) -//! -//! You can create an [`crate::error_models::ErrorModel`] from a string with: -//! -//! ```rust, ignore -//! use outfit::error_models::ErrorModel; -//! let model: ErrorModel = "FCCT14".parse().unwrap(); -//! ``` -//! -//! ### [`crate::error_models::ErrorModelData`] -//! -//! ```text -//! type ErrorModelData = HashMap<(MpcCode, CatalogCode), (f32, f32)> -//! ``` -//! -//! This map associates an observatory (MPC code) and a star catalog code -//! with a pair `(bias_RMS, declination_RMS)`. -//! -//! The contents are loaded from reference files distributed with the crate. -//! -//! ### `ErrorModel::read_error_model_file` -//! -//! ```rust, ignore -//! use outfit::error_models::ErrorModel; -//! -//! let error_map = ErrorModel::FCCT14.read_error_model_file().unwrap(); -//! println!("{} entries", error_map.len()); -//! ``` -//! -//! This function reads the internal rules for the chosen model -//! and returns a [`crate::error_models::ErrorModelData`] structure ready to be queried. -//! -//! ### [`crate::error_models::get_bias_rms`] -//! -//! ```rust -//! use outfit::error_models::{ErrorModel, get_bias_rms}; -//! -//! let data = ErrorModel::FCCT14.read_error_model_file().unwrap(); -//! if let Some((bias_ra, bias_dec)) = get_bias_rms(&data, "699".to_string(), "c".to_string()) { -//! println!("Bias for MPC 699: RA = {bias_ra}, Dec = {bias_dec}"); -//! } -//! ``` -//! -//! This function looks up the `(RMS in RA, RMS in Dec)` for a given observatory -//! and star catalog code. If no exact match is found, the function falls back to -//! generic values (e.g. `ALL:c`). -//! -//! ## Typical usage -//! -//! 1. Choose an error model (e.g. `ErrorModel::FCCT14`). -//! 2. Load its table using [`read_error_model_file`](crate::error_models::ErrorModel::read_error_model_file). -//! 3. Use [`crate::error_models::get_bias_rms`] to obtain astrometric uncertainties for weighting residuals. -//! -//! ## References -//! -//! - Farnocchia, D., Chesley, S. R., Chamberlin, A. B., & Tholen, D. J. (2014) -//! - Chesley, S. R., Baer, J., & Monet, D. G. (2010) -//! - Vereš, P., Farnocchia, D., Chesley, S. R., & Chamberlin, A. B. (2017) -//! -//! These tables are essential for **realistic orbit determination** since they -//! ensure that observations are weighted according to their expected precision. -mod vfcc17; - -use std::{collections::HashMap, str::FromStr}; - -use nom::{ - branch::alt, - bytes::complete::{tag, take_until, take_while}, - character::complete::{char, multispace0}, - combinator::{map, opt}, - number::complete::float, - sequence::{preceded, separated_pair, terminated}, - IResult, Parser, -}; - -use crate::{constants::MpcCode, outfit_errors::OutfitError}; -use vfcc17::parse_vfcc17_line; - -type CatalogCode = String; -pub type ErrorModelData = HashMap<(MpcCode, CatalogCode), (f32, f32)>; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ErrorModel { - FCCT14, - CBM10, - VFCC17, -} - -static FCCT14_RULES: &str = include_str!("data_models/fcct14.rules"); -static CBM10_RULES: &str = include_str!("data_models/cbm10.rules"); -static VFCC17_RULES: &str = include_str!("data_models/vfcc17.rules"); - -pub(in crate::error_models) type ParseResult<'a> = - IResult<&'a str, Vec<((MpcCode, CatalogCode), (f32, f32))>>; - -fn is_alphanum(c: char) -> bool { - c.is_alphanumeric() -} - -fn parse_station(input: &str) -> IResult<&str, &str> { - terminated(take_while(is_alphanum), tag(":")).parse(input) -} - -fn parse_rms_values(input: &str) -> IResult<&str, (f32, f32)> { - preceded( - multispace0, - preceded( - char('@'), - separated_pair( - preceded(multispace0, float), - char(','), - preceded(multispace0, float), - ), - ), - ) - .parse(input) -} - -fn parse_catalog_codes(input: &str) -> IResult<&str, Vec> { - preceded( - multispace0, - preceded( - // accepte soit "c=" soit rien du tout - alt((tag("c="), tag(""))), - map( - nom::bytes::complete::take_while(|c: char| c.is_alphabetic() || c == '*'), - |s: &str| s.chars().map(|c| c.to_string()).collect(), - ), - ), - ) - .parse(input) -} - -fn parse_full_line(input: &str) -> ParseResult { - let (input, remain) = opt(take_until("!")).parse(input)?; //ignore comments - let input = input.trim(); - - let input = remain.unwrap_or(input); - - map( - (parse_station, parse_catalog_codes, parse_rms_values), - |(station, catalogs, (rmsa, rmsd))| { - catalogs - .into_iter() - .map(|cat| ((station.to_string(), cat), (rmsa, rmsd))) - .collect() - }, - ) - .parse(input) -} - -fn parse_full_file(file: &str, parse_line: F) -> Result -where - F: Fn(&str) -> ParseResult, -{ - let error_map: ErrorModelData = file - .lines() - .filter(|line| !line.trim().is_empty() && !line.trim_start().starts_with('!')) - .map(|line| { - parse_line(line) - .map_err(|_e| OutfitError::NomParsingError(line.to_string())) - .map(|(_, pairs)| pairs) - }) - .collect::, OutfitError>>()? - .into_iter() - .flatten() - .collect(); - - Ok(error_map) -} - -impl ErrorModel { - /// Load the internal RMS/bias table for this astrometric error model. - /// - /// This function parses the reference file corresponding to the selected - /// [`ErrorModel`] variant (FCCT14, CBM10, or VFCC17) and returns a - /// [`ErrorModelData`] map containing the weighting coefficients - /// (RMS in right ascension and declination). - /// - /// # Returns - /// - /// A [`Result`] containing: - /// * `Ok(ErrorModelData)` – A hash map where each key is a pair `(MpcCode, CatalogCode)` - /// and the value is a tuple `(rms_ra, rms_dec)` in arcseconds. - /// * `Err(OutfitError)` – If the reference file could not be parsed. - /// - /// # Usage - /// - /// ``` - /// use outfit::error_models::ErrorModel; - /// - /// // Load FCCT14 error model data - /// let data = ErrorModel::FCCT14.read_error_model_file().unwrap(); - /// - /// println!("Number of entries: {}", data.len()); - /// - /// // Use the map later with `get_bias_rms` - /// ``` - /// - /// # See also - /// * [`get_bias_rms`] – Look up the bias and RMS values for a given station/catalog. - pub fn read_error_model_file(&self) -> Result { - let error_map: ErrorModelData = match self { - ErrorModel::FCCT14 => parse_full_file(FCCT14_RULES, parse_full_line)?, - ErrorModel::CBM10 => parse_full_file(CBM10_RULES, parse_full_line)?, - ErrorModel::VFCC17 => { - // Implement parsing logic for VFCC17 - parse_full_file(VFCC17_RULES, parse_vfcc17_line)? - } - }; - - Ok(error_map) - } -} - -impl FromStr for ErrorModel { - type Err = OutfitError; - - fn from_str(s: &str) -> Result { - match s { - "FCCT14" => Ok(ErrorModel::FCCT14), - "CBM10" => Ok(ErrorModel::CBM10), - "VFCC17" => Ok(ErrorModel::VFCC17), - _ => Err(OutfitError::InvalidErrorModel(format!( - "Invalid error model: {s}" - ))), - } - } -} - -/// Retrieve the astrometric bias and RMS values for a given observatory (MPC code) -/// and star catalog code from a preloaded [`ErrorModelData`] table. -/// -/// This function searches for a matching entry in the following priority order: -/// -/// 1. **Exact match**: `(mpc_code, catalog_code)` -/// 2. **Generic catalog fallback for the same observatory**: -/// * `(mpc_code, "e")` – generic `e` entry (elliptical) -// * `(mpc_code, "c")` – generic `c` entry (catalog-specific default) -/// 3. **Global fallback** (for any observatory): -/// * `("ALL", catalog_code)` -/// * `("ALL", "e")` -/// * `("ALL", "c")` -/// -/// The returned pair `(rms_ra, rms_dec)` corresponds to the weighting factors -/// (typically in arcseconds) used for astrometric residuals. -/// -/// # Arguments -/// -/// * `error_model` – The [`ErrorModelData`] hash map produced by -/// [`ErrorModel::read_error_model_file`](crate::error_models::ErrorModel::read_error_model_file). -/// * `mpc_code` – The Minor Planet Center observatory code. -/// * `catalog_code` – The star catalog identifier (single letter or string). -/// -/// # Returns -/// -/// * `Some((rms_ra, rms_dec))` – If a match is found in the table. -/// * `None` – If no matching entry exists (very rare). -/// -/// # Example -/// -/// ```rust, no_run -/// use outfit::error_models::{ErrorModel, get_bias_rms}; -/// -/// // Load error model data -/// let table = ErrorModel::FCCT14.read_error_model_file().unwrap(); -/// -/// // Query bias/RMS for observatory 699 (Catalina) with catalog code "c" -/// if let Some((rms_ra, rms_dec)) = get_bias_rms(&table, "699".to_string(), "c".to_string()) { -/// println!("699 / c -> RMS: RA = {rms_ra}, Dec = {rms_dec}"); -/// } -/// ``` -pub fn get_bias_rms( - error_model: &ErrorModelData, - mpc_code: MpcCode, - catalog_code: CatalogCode, -) -> Option<(f32, f32)> { - error_model - .get(&(mpc_code.clone(), catalog_code.clone())) - .cloned() - .or_else(|| { - error_model - .get(&(mpc_code.clone(), "e".to_string())) - .cloned() - }) - .or_else(|| error_model.get(&(mpc_code, "c".to_string())).cloned()) - .or_else(|| error_model.get(&("ALL".to_string(), catalog_code)).cloned()) - .or_else(|| { - error_model - .get(&("ALL".to_string(), "e".to_string())) - .cloned() - }) - .or_else(|| { - error_model - .get(&("ALL".to_string(), "c".to_string())) - .cloned() - }) -} - -impl TryFrom<&str> for ErrorModel { - type Error = OutfitError; - - fn try_from(value: &str) -> Result { - value.parse() - } -} - -#[cfg(test)] -mod test_error_model { - use super::*; - - #[test] - fn test_parse_fcct14_line() { - let line = "ALL: c=eqru @ 0.33, 0.30"; - let result = parse_full_line(line); - assert!(result.is_ok()); - let ((mpc_code, catalog_code), (rmsa, rmsd)) = &result.unwrap().1[0]; - assert_eq!(mpc_code, "ALL"); - assert_eq!(catalog_code, "e"); - assert_eq!(*rmsa, 0.33); - assert_eq!(*rmsd, 0.3); - - let line = "ALL: c=cd @ 0.51, 0.40 ! CBM Generic Catalog weights"; - let result = parse_full_line(line); - assert!(result.is_ok()); - let ((mpc_code, catalog_code), (rmsa, rmsd)) = &result.unwrap().1[0]; - assert_eq!(mpc_code, "ALL"); - assert_eq!(catalog_code, "c"); - assert_eq!(*rmsa, 0.51); - assert_eq!(*rmsd, 0.4); - - let line = "699:c @ 0.93, 0.78"; - let result = parse_full_line(line); - assert!(result.is_ok()); - let ((mpc_code, catalog_code), (rmsa, rmsd)) = &result.unwrap().1[0]; - assert_eq!(mpc_code, "699"); - assert_eq!(catalog_code, "c"); - assert_eq!(*rmsa, 0.93); - assert_eq!(*rmsd, 0.78); - } - - #[test] - fn test_read_error_model_file() { - let error_model = ErrorModel::FCCT14; - let result = error_model.read_error_model_file(); - assert!(result.is_ok()); - let data = result.unwrap(); - assert!(!data.is_empty()); - - let error_model = ErrorModel::CBM10; - let result = error_model.read_error_model_file(); - assert!(result.is_ok()); - let data = result.unwrap(); - assert!(!data.is_empty()); - - let error_model = ErrorModel::VFCC17; - let result = error_model.read_error_model_file(); - - assert!(result.is_ok()); - let data = result.unwrap(); - assert!(!data.is_empty()); - } - - #[test] - fn test_get_bias_rms() { - let error_model = ErrorModel::FCCT14.read_error_model_file().unwrap(); - let bias_rms = get_bias_rms(&error_model, "ALL".to_string(), "c".to_string()); - assert!(bias_rms.is_some()); - let (rmsa, rmsd) = bias_rms.unwrap(); - assert_eq!(rmsa, 0.51); - assert_eq!(rmsd, 0.4); - - let bias_rms = get_bias_rms(&error_model, "699".to_string(), "c".to_string()); - assert!(bias_rms.is_some()); - let (rmsa, rmsd) = bias_rms.unwrap(); - assert_eq!(rmsa, 0.47); - assert_eq!(rmsd, 0.39); - - let error_model = ErrorModel::CBM10.read_error_model_file().unwrap(); - let bias_rms = get_bias_rms(&error_model, "ALL".to_string(), "c".to_string()); - assert!(bias_rms.is_some()); - let (rmsa, rmsd) = bias_rms.unwrap(); - assert_eq!(rmsa, 0.5); - assert_eq!(rmsd, 0.5); - - let bias_rms = get_bias_rms(&error_model, "699".to_string(), "c".to_string()); - assert!(bias_rms.is_some()); - let (rmsa, rmsd) = bias_rms.unwrap(); - assert_eq!(rmsa, 0.84); - assert_eq!(rmsd, 0.81); - - let error_model = ErrorModel::VFCC17.read_error_model_file().unwrap(); - let bias_rms = get_bias_rms(&error_model, "ALL".to_string(), "U".to_string()); - assert!(bias_rms.is_some()); - let (rmsa, rmsd) = bias_rms.unwrap(); - assert_eq!(rmsa, 0.6); - assert_eq!(rmsd, 0.6); - let bias_rms = get_bias_rms(&error_model, "699".to_string(), "*".to_string()); - assert!(bias_rms.is_some()); - let (rmsa, rmsd) = bias_rms.unwrap(); - assert_eq!(rmsa, 0.8); - assert_eq!(rmsd, 0.8); - } -} diff --git a/src/error_models/vfcc17.rs b/src/error_models/vfcc17.rs deleted file mode 100644 index 3e580a9..0000000 --- a/src/error_models/vfcc17.rs +++ /dev/null @@ -1,96 +0,0 @@ -use nom::{ - bytes::complete::{tag, take_until, take_while1}, - character::complete::{char, multispace0}, - combinator::{map, opt}, - number::complete::float, - sequence::{preceded, separated_pair}, - IResult, Parser, -}; - -use crate::error_models::ParseResult; - -fn is_word_char(c: char) -> bool { - c.is_alphanumeric() || "*".contains(c) -} - -fn parse_word(input: &str) -> IResult<&str, &str> { - take_while1(is_word_char)(input) -} - -fn parse_station(input: &str) -> IResult<&str, &str> { - parse_word(input) -} - -fn parse_catalog_codes(input: &str) -> IResult<&str, Vec> { - preceded( - tag("c="), - map(take_while1(is_word_char), |s: &str| { - s.chars().map(|c| c.to_string()).collect() - }), - ) - .parse(input) -} - -fn parse_rms_values(input: &str) -> IResult<&str, (f32, f32)> { - preceded( - take_until("@"), - preceded( - tag("@"), - separated_pair( - preceded(multispace0, float), - preceded(multispace0, char(',')), - preceded(multispace0, float), - ), - ), - ) - .parse(input) -} - -pub fn parse_vfcc17_line(input: &str) -> ParseResult { - let (input, remain) = opt(take_until("!")).parse(input)?; // Ignore comments - let input = remain.unwrap_or(input).trim(); - - map( - ( - parse_station, - take_until("c="), - parse_catalog_codes, - parse_rms_values, - ), - |(station, _, catalogs, (rmsa, rmsd))| { - catalogs - .into_iter() - .map(|cat| ((station.to_string(), cat), (rmsa, rmsd))) - .collect() - }, - ) - .parse(input) -} - -#[cfg(test)] -mod test_vfcc17_parser { - use super::*; - - #[test] - fn test_vfcc17_parser() { - let input = "ALL t=cBCVn c=* p= > < @ 1.00, 1.00 ! Unknown catalog"; - let result = parse_vfcc17_line(input); - assert!(result.is_ok()); - let (_, parsed) = result.unwrap(); - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].0 .0, "ALL"); - assert_eq!(parsed[0].0 .1, "*"); - assert_eq!(parsed[0].1 .0, 1.); - assert_eq!(parsed[0].1 .1, 1.); - - let input = "568 t=cC c=t p=_ > < @ 0.20, 0.20 ! Micheli updated "; - let result = parse_vfcc17_line(input); - assert!(result.is_ok()); - let (_, parsed) = result.unwrap(); - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].0 .0, "568"); - assert_eq!(parsed[0].0 .1, "t"); - assert_eq!(parsed[0].1 .0, 0.2); - assert_eq!(parsed[0].1 .1, 0.2); - } -} diff --git a/src/initial_orbit_determination/gauss.rs b/src/initial_orbit_determination/gauss.rs index 178caf9..0056ffa 100644 --- a/src/initial_orbit_determination/gauss.rs +++ b/src/initial_orbit_determination/gauss.rs @@ -4,9 +4,9 @@ //! initial orbit determination (IOD) using three optical astrometric observations //! acquired from the same observing site. //! -//! ## Core structure: [`GaussObs`] +//! ## Core structure: [`crate::initial_orbit_determination::gauss::GaussObs`] //! -//! The [`GaussObs`] struct encapsulates all the data required to apply the Gauss algorithm: +//! The [`crate::initial_orbit_determination::gauss::GaussObs`] struct encapsulates all the data required to apply the Gauss algorithm: //! //! * Indices of the three selected observations, //! * Right ascension and declination angles `[rad]`, @@ -19,7 +19,7 @@ //! ## Main functionalities //! //! - **Construction** -//! * [`GaussObs::with_observer_position`] – Build from RA/DEC/epochs with explicit observer positions. +//! * [`crate::initial_orbit_determination::gauss::GaussObs::with_observer_position`] – Build from RA/DEC/epochs with explicit observer positions. //! //! - **Geometry preparation** //! * Assemble the `3×3` matrix of line-of-sight unit vectors, @@ -29,14 +29,14 @@ //! * Solve the 8th-degree Gauss polynomial for the central topocentric distance, //! * Reconstruct heliocentric positions, //! * Estimate velocity via **Gibbs’ method**, -//! * Return a [`GaussResult`] containing preliminary [`OrbitalElements`] (not necessarily Keplerian). +//! * Return a [`crate::initial_orbit_determination::gauss_result::GaussResult`] containing preliminary [`crate::orbit_type::OrbitalElements`] (not necessarily Keplerian). //! //! - **Orbit refinement** -//! * [`GaussObs::pos_and_vel_correction`] – Iterative update of positions and velocities, -//! * Apply filters on eccentricity and perihelion distance via [`eccentricity_control`]. +//! * [`crate::initial_orbit_determination::gauss::GaussObs::pos_and_vel_correction`] – Iterative update of positions and velocities, +//! * Apply filters on eccentricity and perihelion distance via [`crate::orb_elem::eccentricity_control`]. //! //! - **Monte Carlo perturbations** -//! * [`GaussObs::generate_noisy_realizations`] – Generate perturbed triplets by adding Gaussian noise +//! * [`crate::initial_orbit_determination::gauss::GaussObs::generate_noisy_realizations`] – Generate perturbed triplets by adding Gaussian noise //! to RA/DEC, useful for uncertainty propagation and robustness analysis. //! //! ## Algorithm outline @@ -50,8 +50,8 @@ //! //! ## Output //! -//! The primary result is a [`GaussResult`] which stores the computed orbit as -//! a generic [`OrbitalElements`] value. +//! The primary result is a [`crate::initial_orbit_determination::gauss_result::GaussResult`] which stores the computed orbit as +//! a generic [`crate::orbit_type::OrbitalElements`] value. //! Depending on refinement, this may represent either: //! * a **Preliminary orbit** (direct Gauss solution), or //! * a **Corrected orbit** (after iteration). @@ -62,31 +62,27 @@ //! use outfit::initial_orbit_determination::gauss::GaussObs; //! use outfit::orbit_type::{OrbitalElements, keplerian_element::KeplerianElements}; //! use nalgebra::Vector3; -//! use outfit::outfit::Outfit; -//! use outfit::error_models::ErrorModel; //! use outfit::initial_orbit_determination::gauss_result::GaussResult; //! use outfit::initial_orbit_determination::IODParams; //! -//! let env = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); -//! //! // Build GaussObs (here positions are assumed precomputed) //! let gauss: GaussObs = unimplemented!("Construct GaussObs from RA/DEC/time and observer state"); //! //! // Run the preliminary orbit computation -//! let result = gauss.prelim_orbit(&env, &IODParams::default()).unwrap(); +//! let result = gauss.prelim_orbit(&IODParams::default()).unwrap(); //! //! // Match on the returned orbital-element representation //! match result { //! GaussResult::PrelimOrbit(oe) //! | GaussResult::CorrectedOrbit(oe) => { //! match oe { -//! OrbitalElements::Keplerian(kepl) => { +//! OrbitalElements::Keplerian { elements: kepl, .. } => { //! println!("a [AU] = {}", kepl.semi_major_axis); //! } -//! OrbitalElements::Equinoctial(eq) => { +//! OrbitalElements::Equinoctial { elements: eq, .. } => { //! println!("lambda [rad] = {}", eq.mean_longitude); //! } -//! OrbitalElements::Cometary(com) => { +//! OrbitalElements::Cometary { elements: com, .. } => { //! println!("q [AU] = {}", com.perihelion_distance); //! } //! } @@ -100,19 +96,21 @@ //! //! ## See also //! -//! - [`GaussObs`] -//! - [`GaussResult`] -//! - [`OrbitalElements`] -//! - [`KeplerianElements`](crate::orbit_type::keplerian_element::KeplerianElements) +//! - [`crate::initial_orbit_determination::gauss::GaussObs`] +//! - [`crate::initial_orbit_determination::gauss_result::GaussResult`] +//! - [`crate::orbit_type::OrbitalElements`] +//! - [`crate::orbit_type::keplerian_element::KeplerianElements`](crate::orbit_type::keplerian_element::KeplerianElements) use std::ops::ControlFlow; use aberth::StopReason; use nalgebra::Matrix3; use nalgebra::Vector3; +use photom::Radians; +use photom::MJDTT; use rand_distr::StandardNormal; use smallvec::SmallVec; -use crate::constants::Radian; +use crate::constants::ROT_EQUMJ2000_TO_ECLMJ2000; use crate::constants::{GAUSS_GRAV, VLIGHT_AU}; use crate::initial_orbit_determination::gauss_result::GaussResult; @@ -120,7 +118,6 @@ use crate::initial_orbit_determination::IODParams; use crate::kepler::velocity_correction_with_guess; use crate::orb_elem::eccentricity_control; use crate::orbit_type::OrbitalElements; -use crate::outfit::Outfit; use crate::outfit_errors::OutfitError; use aberth::aberth; use rand::Rng; @@ -153,9 +150,9 @@ use rand::Rng; #[derive(Debug, PartialEq, Clone)] pub struct GaussObs { pub(crate) idx_obs: Vector3, - pub(crate) ra: Vector3, - pub(crate) dec: Vector3, - pub(crate) time: Vector3, + pub(crate) ra: Vector3, + pub(crate) dec: Vector3, + pub(crate) time: Vector3, pub(crate) observer_helio_position: Matrix3, } @@ -322,11 +319,11 @@ impl GaussObs { /// ------------ /// * [`generate_noisy_realizations`](crate::initial_orbit_determination::gauss::GaussObs::generate_noisy_realizations) – Eager version collecting all realizations into a `Vec`. /// * [`GaussObs::prelim_orbit`] – Consumes each realization to compute a Gauss preliminary orbit. - /// * [`estimate_best_orbit`](crate::observations::observations_ext::ObservationIOD::estimate_best_orbit) – High-level search loop that leverages this iterator with early pruning. + /// * [`FitIOD::fit_iod`](crate::FitIOD::fit_iod) – High-level search loop that leverages this iterator with early pruning. pub fn realizations_iter<'a, R: Rng + 'a>( - &'a self, - errors_ra: &'a Vector3, - errors_dec: &'a Vector3, + self, + errors_ra: &Vector3, + errors_dec: &Vector3, n_realizations: usize, noise_scale: f64, rng: &'a mut R, @@ -425,9 +422,9 @@ impl GaussObs { /// * [`realizations_iter`](crate::initial_orbit_determination::gauss::GaussObs::realizations_iter) – Lazy version that yields realizations on demand and /// supports early-stop pruning. /// * [`GaussObs::prelim_orbit`] – Compute a preliminary Gauss solution from each realization. - /// * [`estimate_best_orbit`](crate::observations::observations_ext::ObservationIOD::estimate_best_orbit) – End-to-end search that consumes realizations. + /// * [`FitIOD::fit_iod`](crate::FitIOD::fit_iod) – End-to-end search that consumes realizations. pub fn generate_noisy_realizations( - &self, + self, errors_ra: &Vector3, errors_dec: &Vector3, n_realizations: usize, @@ -880,7 +877,7 @@ impl GaussObs { /// /// Arguments /// --------- - /// * `state` – The outfit global state containing the rotation matrix + /// * `rot_equmj2000_to_eclmj2000` – rotation matrix from equatorial mean J2000 to ecliptic mean J2000 /// * `asteroid_position` – Cartesian heliocentric position vector of the object (in AU), in equatorial J2000 frame. /// * `asteroid_velocity` – Cartesian heliocentric velocity vector of the object (in AU/day), in equatorial J2000 frame. /// * `reference_epoch` – Epoch (in MJD TT) corresponding to the state vector, used as the reference time for the elements. @@ -908,16 +905,12 @@ impl GaussObs { /// * [`KeplerianElements`] – definition of the orbital elements struct. fn compute_orbit_from_state( &self, - state: &Outfit, &asteroid_position: &Vector3, &asteroid_velocity: &Vector3, reference_epoch: f64, ) -> Result { - // get the rotation matrix from equatorial mean J2000 to ecliptic mean J2000 - let roteqec = state.get_rot_equmj2000_to_eclmj2000(); - // Apply the transformation to position and velocity vectors - let matrix_elc_transform = roteqec.transpose(); + let matrix_elc_transform = ROT_EQUMJ2000_TO_ECLMJ2000; let ecl_pos = matrix_elc_transform * asteroid_position; let ecl_vel = matrix_elc_transform * asteroid_velocity; @@ -1053,6 +1046,7 @@ impl GaussObs { /// /// Arguments /// ----------------- + /// * `rot_equmj2000_to_eclmj2000` – rotation matrix from equatorial mean J2000 to ecliptic mean J2000 /// * `pos_all_time`: `3×3` heliocentric positions at `t1|t2|t3` (AU). /// * `vel_t2`: heliocentric velocity at `t2` (AU/day). /// * `epoch`: reference epoch for the state (MJD TT). @@ -1068,14 +1062,13 @@ impl GaussObs { #[inline] fn build_result( &self, - state: &Outfit, pos_all_time: &Matrix3, vel_t2: &Vector3, epoch: f64, corrected: bool, ) -> Option { let r_t2: Vector3 = pos_all_time.column(1).into(); - match self.compute_orbit_from_state(state, &r_t2, vel_t2, epoch) { + match self.compute_orbit_from_state(&r_t2, vel_t2, epoch) { Ok(orbit) if corrected => Some(GaussResult::CorrectedOrbit(orbit)), Ok(orbit) => Some(GaussResult::PrelimOrbit(orbit)), Err(_) => None, @@ -1103,7 +1096,7 @@ impl GaussObs { /// /// Arguments /// ----------------- - /// * `state`: Global context (ephemerides, constants, settings). + /// * `rot_equmj2000_to_eclmj2000` – rotation matrix from equatorial mean J2000 to ecliptic mean J2000 /// * `iod_params`: Parameters controlling the IOD process, including root filtering and correction settings. /// /// Return @@ -1125,7 +1118,6 @@ impl GaussObs { /// * [`IODParams`] – Configuration parameters for the IOD process. pub fn prelim_orbit_all( &self, - state: &Outfit, iod_params: &IODParams, ) -> Result, OutfitError> { // 1) Core Gauss quantities @@ -1182,15 +1174,14 @@ impl GaussObs { iod_params.newton_eps, iod_params.newton_max_it, ) { - if let Some(res) = - self.build_result(state, &pos_cor, &v_cor, epoch_cor, true) + if let Some(res) = self.build_result(&pos_cor, &v_cor, epoch_cor, true) { if solutions.len() < iod_params.max_tested_solutions { solutions.push(res); } } } else if let Some(res) = - self.build_result(state, &pos_all, &v_pre, epoch_ref, false) + self.build_result(&pos_all, &v_pre, epoch_ref, false) { if solutions.len() < iod_params.max_tested_solutions { solutions.push(res); @@ -1224,7 +1215,7 @@ impl GaussObs { /// /// Arguments /// ----------------- - /// * `state`: The global context (ephemerides, constants, settings). + /// * `rot_equmj2000_to_eclmj2000` – rotation matrix from equatorial mean J2000 to ecliptic mean J2000 /// * `iod_params`: Parameters controlling the IOD process, including root filtering and correction settings. /// /// Return @@ -1244,12 +1235,8 @@ impl GaussObs { /// * [`prelim_orbit_all`](crate::initial_orbit_determination::gauss::GaussObs::prelim_orbit_all) – Enumerates and returns up to three acceptable solutions. /// * [`pos_and_vel_correction`](crate::initial_orbit_determination::gauss::GaussObs::pos_and_vel_correction) – Iterative velocity update (Lagrange f/g). /// * [`IODParams`] – Configuration parameters for the IOD process. - pub fn prelim_orbit( - &self, - state: &Outfit, - iod_params: &IODParams, - ) -> Result { - let all = self.prelim_orbit_all(state, iod_params)?; + pub fn prelim_orbit(&self, iod_params: &IODParams) -> Result { + let all = self.prelim_orbit_all(iod_params)?; if let Some(best_corr) = all .iter() .find(|s| matches!(s, GaussResult::CorrectedOrbit(_))) @@ -1432,14 +1419,10 @@ impl GaussObs { } #[cfg(test)] -#[cfg(feature = "jpl-download")] pub(crate) mod gauss_test { use super::*; - use crate::{ - orbit_type::{keplerian_element::KeplerianElements, orbit_type_test::approx_equal}, - unit_test_global::OUTFIT_HORIZON_TEST, - }; + use crate::orbit_type::{keplerian_element::KeplerianElements, orbit_type_test::approx_equal}; #[test] fn test_gauss_prelim() { @@ -1718,7 +1701,6 @@ pub(crate) mod gauss_test { #[test] fn test_solve_orbit() { - let env = &OUTFIT_HORIZON_TEST.0; let tol = 1e-13; let gauss = GaussObs { @@ -1747,7 +1729,7 @@ pub(crate) mod gauss_test { ), }; - let binding = gauss.prelim_orbit(env, &IODParams::default()).unwrap(); + let binding = gauss.prelim_orbit(&IODParams::default()).unwrap(); let prelim_orbit = binding.get_orbit(); // This is the expected orbit based on the Orbfit software @@ -1755,15 +1737,19 @@ pub(crate) mod gauss_test { // The values are very close to the ones obtained from the Rust implementation // The floating point differences is very close to one ulp // (unit in the last place) of the floating point representation - let expected_orbit = OrbitalElements::Keplerian(KeplerianElements { - reference_epoch: 57_049.229_045_244_22, - semi_major_axis: 1.8014943988486352, - eccentricity: 0.283_514_142_249_080_7, - inclination: 0.20264170920820326, - ascending_node_longitude: 8.118_562_444_269_591E-3, - periapsis_argument: 1.244_795_311_814_302, - mean_anomaly: 0.44065425435816186, - }); + let expected_orbit = OrbitalElements::Keplerian { + elements: KeplerianElements { + reference_epoch: 57_049.229_045_244_22, + semi_major_axis: 1.8014943988486352, + eccentricity: 0.283_514_142_249_080_7, + inclination: 0.20264170920820326, + ascending_node_longitude: 8.118_562_444_269_591E-3, + periapsis_argument: 1.244_795_311_814_302, + mean_anomaly: 0.44065425435816186, + }, + uncertainty: None, + covariance: None, + }; // Compare the prelim_orbit with the expected orbit assert!(approx_equal(prelim_orbit, &expected_orbit, tol)); @@ -1781,18 +1767,22 @@ pub(crate) mod gauss_test { .into(), }; - let binding = a.prelim_orbit(env, &IODParams::default()).unwrap(); + let binding = a.prelim_orbit(&IODParams::default()).unwrap(); let prelim_orbit_a = binding.get_orbit(); - let expected_orbit = OrbitalElements::Keplerian(KeplerianElements { - reference_epoch: 57049.22904525282, - semi_major_axis: 1.801490008178814, - eccentricity: 0.28350961635625993, - inclination: 0.20264261257939395, - ascending_node_longitude: 0.008105552171682476, - periapsis_argument: 1.244832121745955, - mean_anomaly: 0.4406444535028061, - }); + let expected_orbit = OrbitalElements::Keplerian { + elements: KeplerianElements { + reference_epoch: 57049.22904525282, + semi_major_axis: 1.801490008178814, + eccentricity: 0.28350961635625993, + inclination: 0.20264261257939395, + ascending_node_longitude: 0.008105552171682476, + periapsis_argument: 1.244832121745955, + mean_anomaly: 0.4406444535028061, + }, + uncertainty: None, + covariance: None, + }; // Compare the prelim_orbit_a with the expected orbit assert!(approx_equal(prelim_orbit_a, &expected_orbit, tol)); @@ -1827,20 +1817,24 @@ pub(crate) mod gauss_test { ), }; - let binding = gauss.prelim_orbit(env, &IODParams::default()).unwrap(); + let binding = gauss.prelim_orbit(&IODParams::default()).unwrap(); let prelim_orbit_b = binding.get_orbit(); // This is the expected orbit based on the Orbfit software // The values are obtained from the Orbfit output for the same observations - let expected_orbfit = OrbitalElements::Keplerian(KeplerianElements { - reference_epoch: 57_049.229_045_608_86, - semi_major_axis: 1.8013098187420686, - eccentricity: 0.28347096712267805, - inclination: 0.202_617_665_872_441_2, - ascending_node_longitude: 8.194_805_420_465_082E-3, - periapsis_argument: 1.2446747244785052, - mean_anomaly: 0.44073731381184733, - }); + let expected_orbfit = OrbitalElements::Keplerian { + elements: KeplerianElements { + reference_epoch: 57_049.229_045_608_86, + semi_major_axis: 1.8013098187420686, + eccentricity: 0.28347096712267805, + inclination: 0.202_617_665_872_441_2, + ascending_node_longitude: 8.194_805_420_465_082E-3, + periapsis_argument: 1.2446747244785052, + mean_anomaly: 0.44073731381184733, + }, + uncertainty: None, + covariance: None, + }; // Compare the prelim_orbit_b with the expected orbit // The epsilon value is set to 1e-13 for a very close match between the OrbFit and the Rust implementation @@ -1983,8 +1977,13 @@ pub(crate) mod gauss_test { let mut rng = StdRng::seed_from_u64(42_u64); // seed for reproducibility - let realizations = - gauss.generate_noisy_realizations(&errors_ra, &errors_dec, 5, 1.0, &mut rng); + let realizations = gauss.clone().generate_noisy_realizations( + &errors_ra, + &errors_dec, + 5, + 1.0, + &mut rng, + ); assert_eq!(realizations.len(), 6); // 1 original + 5 noisy @@ -2017,8 +2016,13 @@ pub(crate) mod gauss_test { let mut rng = StdRng::seed_from_u64(123); - let realizations = - gauss.generate_noisy_realizations(&errors_ra, &errors_dec, 3, 1.0, &mut rng); + let realizations = gauss.clone().generate_noisy_realizations( + &errors_ra, + &errors_dec, + 3, + 1.0, + &mut rng, + ); for g in realizations { assert_eq!(g.ra, gauss.ra); @@ -2041,8 +2045,13 @@ pub(crate) mod gauss_test { let mut rng = StdRng::seed_from_u64(123); - let realizations = - gauss.generate_noisy_realizations(&errors_ra, &errors_dec, 0, 1.0, &mut rng); + let realizations = gauss.clone().generate_noisy_realizations( + &errors_ra, + &errors_dec, + 0, + 1.0, + &mut rng, + ); assert_eq!(realizations.len(), 1); // Only the original observation assert_eq!(realizations[0], gauss); @@ -2064,10 +2073,20 @@ pub(crate) mod gauss_test { let mut rng_low = StdRng::seed_from_u64(42); let mut rng_high = StdRng::seed_from_u64(42); // same seed - let low_noise = - gauss.generate_noisy_realizations(&errors_ra, &errors_dec, 1, 0.1, &mut rng_low); - let high_noise = - gauss.generate_noisy_realizations(&errors_ra, &errors_dec, 1, 10.0, &mut rng_high); + let low_noise = gauss.clone().generate_noisy_realizations( + &errors_ra, + &errors_dec, + 1, + 0.1, + &mut rng_low, + ); + let high_noise = gauss.clone().generate_noisy_realizations( + &errors_ra, + &errors_dec, + 1, + 10.0, + &mut rng_high, + ); let diff_low = (low_noise[1].ra - gauss.ra).norm(); let diff_high = (high_noise[1].ra - gauss.ra).norm(); diff --git a/src/initial_orbit_determination/gauss_result.rs b/src/initial_orbit_determination/gauss_result.rs index c617700..c57604e 100644 --- a/src/initial_orbit_determination/gauss_result.rs +++ b/src/initial_orbit_determination/gauss_result.rs @@ -1,6 +1,6 @@ //! # Gauss orbit determination result //! -//! This module defines [`GaussResult`], an enum representing the outcome of the +//! This module defines [`crate::initial_orbit_determination::gauss_result::GaussResult`], an enum representing the outcome of the //! **Gauss initial orbit determination method** applied to a triplet of astrometric //! observations. //! It encapsulates the orbital elements computed from the Gauss method and distinguishes @@ -16,18 +16,18 @@ //! A corrected orbit obtained after at least one iteration of refinement, typically //! using velocity correction or Lagrange coefficient adjustments. //! -//! Both variants wrap an [`OrbitalElements`] value, which can hold Keplerian, +//! Both variants wrap an [`crate::orbit_type::OrbitalElements`] value, which can hold Keplerian, //! Equinoctial, or Cometary elements depending on the solution domain. //! //! ## Features //! //! - Query methods: -//! * [`GaussResult::is_prelim`] – check if the result is a preliminary orbit. -//! * [`GaussResult::is_corrected`] – check if the result includes refinement. +//! * [`crate::initial_orbit_determination::gauss_result::GaussResult::is_prelim`] – check if the result is a preliminary orbit. +//! * [`crate::initial_orbit_determination::gauss_result::GaussResult::is_corrected`] – check if the result includes refinement. //! - Accessors: -//! * [`GaussResult::get_orbit`] – borrow the inner [`OrbitalElements`]. -//! * [`GaussResult::as_inner`] – alias to `get_orbit`, for generic contexts. -//! * [`GaussResult::into_inner`] – consume the enum and return the inner [`OrbitalElements`]. +//! * [`crate::initial_orbit_determination::gauss_result::GaussResult::get_orbit`] – borrow the inner [`crate::orbit_type::OrbitalElements`]. +//! * [`crate::initial_orbit_determination::gauss_result::GaussResult::as_inner`] – alias to `get_orbit`, for generic contexts. +//! * [`crate::initial_orbit_determination::gauss_result::GaussResult::into_inner`] – consume the enum and return the inner [`crate::orbit_type::OrbitalElements`]. //! //! Additionally, `GaussResult` implements [`Display`](std::fmt::Display) to provide a formatted, //! human-readable view of the result and its orbital elements. @@ -36,7 +36,7 @@ //! //! This type is returned by functions such as //! [`GaussObs::prelim_orbit`](crate::initial_orbit_determination::gauss::GaussObs::prelim_orbit) -//! or [`estimate_best_orbit`](crate::observations::observations_ext::ObservationIOD::estimate_best_orbit). +//! or [`FitIOD::fit_iod`](crate::FitIOD::fit_iod). //! //! ```rust,no_run //! use outfit::initial_orbit_determination::gauss_result::GaussResult; @@ -52,12 +52,12 @@ //! //! ## Notes //! -//! - If you need a specific representation, use [`OrbitalElements::to_keplerian`] or -//! [`OrbitalElements::to_equinoctial`] for explicit conversion. +//! - If you need a specific representation, use [`crate::orbit_type::OrbitalElements::to_keplerian`] or +//! [`crate::orbit_type::OrbitalElements::to_equinoctial`] for explicit conversion. //! //! ## See also //! -//! - [`OrbitalElements`] – Canonical orbital element sum type. +//! - [`crate::orbit_type::OrbitalElements`] – Canonical orbital element sum type. //! - [`KeplerianElements`](crate::orbit_type::keplerian_element::KeplerianElements), [`EquinoctialElements`](crate::orbit_type::equinoctial_element::EquinoctialElements), [`CometaryElements`](crate::orbit_type::cometary_element::CometaryElements) – The three supported element forms. //! - [`GaussObs`](crate::initial_orbit_determination::gauss::GaussObs) //! - Milani & Gronchi (2010), *Theory of Orbit Determination* @@ -82,7 +82,7 @@ use std::fmt; /// /// Notes /// ------- -/// * Both variants wrap an [`OrbitalElements`] value, which may itself contain: +/// * Both variants wrap an [`crate::orbit_type::OrbitalElements`] value, which may itself contain: /// - [`KeplerianElements`](crate::orbit_type::keplerian_element::KeplerianElements) (classical orbital elements), /// - [`EquinoctialElements`](crate::orbit_type::equinoctial_element::EquinoctialElements) (non-singular formulation), or /// - [`CometaryElements`](crate::orbit_type::cometary_element::CometaryElements) (perihelion form for e ≥ 1). @@ -181,16 +181,16 @@ impl GaussResult { /// /// Arguments /// ----------------- - /// * `self` – The [`GaussResult`] to be consumed. + /// * `self` – The [`crate::initial_orbit_determination::gauss_result::GaussResult`] to be consumed. /// /// Return /// ---------- - /// * `OrbitalElements` – The inner orbital elements, moved out of `self`. + /// * [`crate::orbit_type::OrbitalElements`] – The inner orbital elements, moved out of `self`. /// /// See also /// ------------ - /// * [`GaussResult::get_orbit`] – Borrow instead of moving. - /// * [`OrbitalElements::to_keplerian`] – Convert to Keplerian if needed. + /// * [`crate::initial_orbit_determination::gauss_result::GaussResult::get_orbit`] – Borrow instead of moving. + /// * [`crate::orbit_type::OrbitalElements::to_keplerian`] – Convert to Keplerian if needed. /// * [`OrbitalElements::to_equinoctial`] – Convert to equinoctial if needed. pub fn into_inner(self) -> OrbitalElements { match self { @@ -223,15 +223,19 @@ mod gauss_results_tests { use std::format; fn dummy_orbit() -> OrbitalElements { - OrbitalElements::Keplerian(KeplerianElements { - reference_epoch: 59000.0, - semi_major_axis: 2.5, - eccentricity: 0.12, - inclination: 0.1, - ascending_node_longitude: 1.2, - periapsis_argument: 0.8, - mean_anomaly: 0.3, - }) + OrbitalElements::Keplerian { + elements: KeplerianElements { + reference_epoch: 59000.0, + semi_major_axis: 2.5, + eccentricity: 0.12, + inclination: 0.1, + ascending_node_longitude: 1.2, + periapsis_argument: 0.8, + mean_anomaly: 0.3, + }, + uncertainty: None, + covariance: None, + } } #[test] @@ -264,15 +268,19 @@ mod gauss_results_tests { #[test] fn test_display_format_summary() { - let orbit = OrbitalElements::Keplerian(KeplerianElements { - reference_epoch: 59001.5, - semi_major_axis: 1.234567, - eccentricity: 0.1, - inclination: 0.2, - ascending_node_longitude: 0.3, - periapsis_argument: 0.4, - mean_anomaly: 0.5, - }); + let orbit = OrbitalElements::Keplerian { + elements: KeplerianElements { + reference_epoch: 59001.5, + semi_major_axis: 1.234567, + eccentricity: 0.1, + inclination: 0.2, + ascending_node_longitude: 0.3, + periapsis_argument: 0.4, + mean_anomaly: 0.5, + }, + uncertainty: None, + covariance: None, + }; let result = GaussResult::CorrectedOrbit(orbit); @@ -284,6 +292,6 @@ mod gauss_results_tests { assert!(output.contains("a (semi-major axis) = 1.234567 AU")); assert!(output.contains("e (eccentricity) = 0.100000")); assert!(output.contains("i (inclination) = 0.200000 rad (11.459156°)")); - assert!(output.contains("Keplerian Elements @ epoch (MJD): 59001.500000")); + assert!(output.contains("Elements @ epoch (MJD): 59001.500000")); } } diff --git a/src/initial_orbit_determination/mod.rs b/src/initial_orbit_determination/mod.rs index d13dc26..fd42b5a 100644 --- a/src/initial_orbit_determination/mod.rs +++ b/src/initial_orbit_determination/mod.rs @@ -7,7 +7,7 @@ //! ## Purpose //! //! The [`IODParams`](crate::initial_orbit_determination::IODParams) object centralizes all tunable parameters used by -//! [`estimate_best_orbit`](crate::observations::observations_ext::ObservationIOD::estimate_best_orbit). +//! [`FitIOD::fit_iod`](crate::FitIOD::fit_iod). //! It allows you to: //! //! - Select and filter candidate observation triplets (time spans, downsampling, maximum counts), @@ -69,7 +69,7 @@ //! //! ## See also //! -//! * [`estimate_best_orbit`](crate::observations::observations_ext::ObservationIOD::estimate_best_orbit) – main IOD entry point +//! * [`FitIOD::fit_iod`](crate::FitIOD::fit_iod) – main IOD entry point //! * [`crate::initial_orbit_determination::gauss::GaussObs`] – triplet generation & Gauss solver //! * [`crate::initial_orbit_determination::gauss_result::GaussResult`] – result type for preliminary/corrected orbits //! * [`crate::initial_orbit_determination::gauss::GaussObs::solve_8poly`] – 8th-degree distance polynomial and root selection @@ -77,11 +77,20 @@ use crate::outfit_errors::OutfitError; use std::cmp::Ordering::{Equal, Greater, Less}; use std::fmt; +/// Gauss method for preliminary orbit determination, including triplet generation, Monte Carlo perturbations, and physical/numerical filtering of candidate solutions. pub mod gauss; + +/// Result types for preliminary/corrected orbits, including physical/validation flags and RMS scores. pub mod gauss_result; +/// Triplet generation utilities, including time-based filtering and downsampling of observations +pub mod triplet_generation; + +/// [`crate::FitIOD`] trait and batch/single-trajectory IOD entry points on [`photom::observation_dataset::ObsDataset`]. +pub mod obs_dataset_api; + /// Configuration parameters controlling the behavior of -/// [`estimate_best_orbit`](crate::observations::observations_ext::ObservationIOD::estimate_best_orbit). +/// [`FitIOD::fit_iod`](crate::FitIOD::fit_iod). /// /// This structure centralizes all tunable parameters used during /// preliminary orbit determination with the Gauss method. It lets you adjust: @@ -208,7 +217,7 @@ pub mod gauss_result; /// /// See also /// ----------------- -/// * [`estimate_best_orbit`](crate::observations::observations_ext::ObservationIOD::estimate_best_orbit) – main IOD entry point. +/// * [`FitIOD::fit_iod`](crate::FitIOD::fit_iod) – main IOD entry point. /// * [`crate::initial_orbit_determination::gauss::GaussObs`] – triplet generation & Gauss pipeline. /// * [`crate::initial_orbit_determination::gauss_result::GaussResult`] – result types for preliminary/corrected solutions. /// * [`crate::initial_orbit_determination::gauss::GaussObs::solve_8poly`] – 8th-degree distance polynomial and root selection. @@ -254,11 +263,6 @@ pub struct IODParams { pub newton_max_it: usize, /// Maximum allowed imaginary part magnitude for complex roots promoted to real (AU). pub root_imag_eps: f64, - - // --- Multi-threading (if enabled) --- - #[cfg(feature = "parallel")] - /// Number of trajectory batches to process in parallel (if `> 1` and `rayon` feature enabled). - pub batch_size: usize, } impl IODParams { @@ -274,15 +278,14 @@ impl IODParams { /// /// This is a **fluent builder API** for [`IODParams`], allowing you to /// override the default parameters step by step before running - /// [`estimate_best_orbit`](crate::observations::observations_ext::ObservationIOD::estimate_best_orbit). + /// [`FitIOD::fit_iod`](crate::FitIOD::fit_iod). /// /// # Example /// /// ```rust,no_run /// use rand::{rngs::StdRng, SeedableRng}; /// use outfit::initial_orbit_determination::IODParams; - /// use outfit::constants::Observations; - /// use outfit::observations::observations_ext::ObservationIOD; + /// use outfit::FitIOD; /// /// let params = IODParams::builder() /// .n_noise_realizations(100) @@ -291,16 +294,12 @@ impl IODParams { /// .max_triplets(50) /// .build().unwrap(); /// - /// # let observations: Observations = unimplemented!(); - /// # let state = unimplemented!(); - /// # let error_model = unimplemented!(); - /// # let mut rng = StdRng::seed_from_u64(42); - /// # let _ = observations.estimate_best_orbit(&state, &error_model, &mut rng, ¶ms); + /// // Pass `params` to `FitIOD::fit_iod` or `FitIOD::fit_full_iod`. /// ``` /// /// # See also /// * [`IODParams`] – Holds all configuration parameters for IOD. - /// * [`estimate_best_orbit`](crate::observations::observations_ext::ObservationIOD::estimate_best_orbit) – Consumes these parameters to perform orbit determination. + /// * [`FitIOD::fit_iod`](crate::FitIOD::fit_iod) – Consumes these parameters to perform orbit determination. pub fn builder() -> IODParamsBuilder { IODParamsBuilder::new() } @@ -340,10 +339,6 @@ impl Default for IODParams { newton_eps: 1.0e-10, newton_max_it: 50, root_imag_eps: 1.0e-6, - - // Multi-threading (if enabled) - #[cfg(feature = "parallel")] - batch_size: 4, } } } @@ -464,13 +459,6 @@ impl IODParamsBuilder { self } - // --- Multi-threading (if enabled) --- - #[cfg(feature = "parallel")] - pub fn batch_size(mut self, v: usize) -> Self { - self.params.batch_size = v; - self - } - // ---- Numeric helpers for PartialOrd (handle NaN as invalid) ---- /// Return true iff x > 0.0 and comparable (i.e., not NaN). @@ -552,7 +540,7 @@ impl IODParamsBuilder { /// ``` /// /// When `.build()` succeeds, the resulting [`IODParams`] can be passed - /// to [`estimate_best_orbit`](crate::observations::observations_ext::ObservationIOD::estimate_best_orbit). + /// to [`FitIOD::fit_iod`](crate::FitIOD::fit_iod). pub fn build(self) -> Result { let p = &self.params; @@ -634,6 +622,11 @@ impl IODParamsBuilder { Ok(self.params) } + + /// Create an [`IODParams`] directly from the builder's internal parameters without validation. + pub fn from_params(params: IODParams) -> Self { + Self { params } + } } impl fmt::Display for IODParams { @@ -774,16 +767,6 @@ impl fmt::Display for IODParams { "Max Gauss solutions kept" )?; - // --- Multi-threading (if enabled) --- - #[cfg(feature = "parallel")] - { - line!( - "batch_size = {}", - self.batch_size, - "Number of trajectory batches to process in parallel" - )?; - } - Ok(()) } else { write!( diff --git a/src/initial_orbit_determination/obs_dataset_api.rs b/src/initial_orbit_determination/obs_dataset_api.rs new file mode 100644 index 0000000..6b92de1 --- /dev/null +++ b/src/initial_orbit_determination/obs_dataset_api.rs @@ -0,0 +1,296 @@ +//! IOD entry points that operate on [`ObsDataset`](photom::observation_dataset::ObsDataset). +//! +//! This module exposes the [`crate::FitIOD`] trait, which adds Initial Orbit +//! Determination methods directly to +//! [`photom::observation_dataset::ObsDataset`]. Two entry points are provided: +//! +//! - [`crate::FitIOD::fit_iod`] — run IOD for a **single** named trajectory. +//! - [`crate::FitIOD::fit_full_iod`] — run IOD for **every** trajectory in the +//! dataset and collect the results in a [`crate::FullOrbitResult`] map. +//! +//! Both methods apply an astrometric error model, batch-RMS corrections, and +//! build a per-observation position cache before invoking the Gauss method. +//! +//! # Type aliases +//! +//! - [`crate::IODRMS`] — scalar quality metric (RMS of normalised residuals). +//! - [`crate::FullOrbitResult`] — batch result map keyed by trajectory ID. + +use hifitime::ut1::Ut1Provider; +use photom::{ + observation_dataset::{observation::Observation, ObsDataset}, + observer::error_model::{ModelCorrection, ObsErrorModel}, + TrajId, +}; +use rand::{rngs::SmallRng, SeedableRng}; +use std::collections::HashMap; + +use crate::{ + cache::OutfitCache, constants::FitOrbitResult, trajectory::TrajectoryFit, FullOrbitResult, + IODParams, JPLEphem, OutfitError, +}; + +#[cfg(feature = "parallel")] +use rayon::iter::ParallelIterator; + +/// Extension trait that adds Initial Orbit Determination methods to +/// [`ObsDataset`]. +/// +/// Import this trait to call [`fit_iod`](FitIOD::fit_iod) or +/// [`fit_full_iod`](FitIOD::fit_full_iod) on any [`ObsDataset`] value. +pub trait FitIOD { + /// Run the Gauss IOD pipeline for **every** trajectory in the dataset. + /// + /// Applies the given astrometric `error_model`, builds a shared + /// per-observation position cache, and then attempts a preliminary orbit + /// determination for each trajectory independently. Each trajectory gets + /// its own deterministic random seed derived from `rng`, so results are + /// reproducible regardless of the order in which trajectories are + /// processed. + /// + /// # Arguments + /// + /// - `jpl` — JPL planetary ephemeris used for heliocentric Earth positions + /// and light-time corrections. + /// - `ut1_provider` — UT1 time-scale data for Earth orientation corrections. + /// - `params` — IOD tuning parameters (triplet selection, noise + /// realizations, RMS window, …). + /// - `error_model` — astrometric error model assigned to every observation + /// before the fit. + /// - `rng` — source of randomness for Monte-Carlo noise sampling; a single + /// seed is drawn here and then per-trajectory seeds are derived from it. + /// + /// # Errors + /// + /// Returns [`OutfitError`] if the shared cache cannot be built (e.g., an + /// observation has no associated observer ID or the JPL ephemeris is out of + /// range). Individual trajectory failures are **not** propagated as errors; + /// they are stored as `Err(…)` entries in the returned [`FullOrbitResult`]. + fn fit_full_iod( + self, + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + params: &IODParams, + error_model: ObsErrorModel, + rng: &mut impl rand::Rng, + ) -> Result; + + /// Parallel version of [`FitIOD::fit_full_iod`] that processes trajectories concurrently. + /// + /// Enabled with the `parallel` feature flag, which also enables the `rayon` + /// dependency. Trajectories are processed in parallel using Rayon, but each + /// trajectory's RNG is still deterministically derived from the caller's `rng` + /// to ensure reproducibility regardless of processing order or parallelism. + #[cfg(feature = "parallel")] + fn fit_full_iod_parallel( + self, + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + params: &IODParams, + error_model: ObsErrorModel, + rng: &mut impl rand::Rng, + ) -> Result; + + /// Run the Gauss IOD pipeline for a **single** named trajectory. + /// + /// Filters the dataset to the observations belonging to `traj`, applies + /// `error_model`, builds a position cache, and then searches for the + /// best-fitting preliminary orbit via the Gauss method with Monte-Carlo + /// noise sampling. + /// + /// # Arguments + /// + /// - `traj` — trajectory identifier; anything that converts into + /// [`photom::TrajId`] (e.g., a `&str` or `String`). + /// - `jpl` — JPL planetary ephemeris. + /// - `ut1_provider` — UT1 time-scale data. + /// - `params` — IOD tuning parameters. + /// - `error_model` — astrometric error model. + /// - `rng` — source of randomness for noise sampling. + /// + /// # Errors + /// + /// Returns [`OutfitError`] if: + /// - `traj` is not found in the dataset ([`OutfitError::TrajectoryIdNotFound`]), + /// - the position cache cannot be built, or + /// - no viable orbit is found after exhausting all triplet candidates + /// ([`OutfitError::NoViableOrbit`]). + fn fit_iod( + self, + traj: impl Into, + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + params: &IODParams, + error_model: ObsErrorModel, + rng: &mut impl rand::Rng, + ) -> Result; +} + +impl FitIOD for ObsDataset { + fn fit_iod( + self, + traj: impl Into, + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + params: &IODParams, + error_model: ObsErrorModel, + rng: &mut impl rand::Rng, + ) -> Result { + let (corrected_dataset, cache, _) = + prepare_iod(self, jpl, ut1_provider, params, error_model, rng)?; + + fit_single_traj(&traj.into(), &corrected_dataset, &cache, jpl, params, rng) + } + + fn fit_full_iod( + self, + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + params: &IODParams, + error_model: ObsErrorModel, + rng: &mut impl rand::Rng, + ) -> Result { + let (corrected_dataset, cache, base_seed) = + prepare_iod(self, jpl, ut1_provider, params, error_model, rng)?; + + // Default is not implemented for RandomState so a collect cannot be used directly. + // Instead, we create a new map which initialize correctly the RandomState + // then for each trajectory, insert the results into it. + corrected_dataset + .iter_traj_id() + .ok_or(OutfitError::NoTrajectoryIndex) + .map(|iter| { + let mut map = HashMap::with_hasher(ahash::RandomState::new()); + iter.map(|traj_id| { + process_traj(traj_id, &corrected_dataset, &cache, jpl, params, base_seed) + }) + .for_each(|(k, v)| { + map.insert(k, v); + }); + map + }) + } + + #[cfg(feature = "parallel")] + fn fit_full_iod_parallel( + self, + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + params: &IODParams, + error_model: ObsErrorModel, + rng: &mut impl rand::Rng, + ) -> Result { + let (corrected_dataset, cache, base_seed) = + prepare_iod(self, jpl, ut1_provider, params, error_model, rng)?; + + let new_map = || HashMap::with_hasher(ahash::RandomState::new()); + + // parallel iteration over the trajectory Ids + // For each trajectory, we process it and get a map of results for that trajectory. + // Fold produce a single map for each thread, and then we reduce all the maps into a single one. + corrected_dataset + .par_iter_traj_id() + .ok_or(OutfitError::NoTrajectoryIndex) + .map(|iter| { + iter.map(|traj_id| { + process_traj(traj_id, &corrected_dataset, &cache, jpl, params, base_seed) + }) + .fold(new_map, |mut map, (k, v)| { + map.insert(k, v); + map + }) + .reduce(new_map, |mut a, b| { + a.extend(b); + a + }) + }) + } +} + +fn fit_single_traj( + traj: &TrajId, + corrected_dataset: &ObsDataset, + cache: &OutfitCache, + jpl: &JPLEphem, + params: &IODParams, + rng: &mut impl rand::Rng, +) -> Result { + let materialized_traj = corrected_dataset + .materialize_trajectory(traj) + .ok_or_else(|| OutfitError::TrajectoryIdNotFound(traj.clone()))?; + + let mut obs_vec_refs: Vec<&Observation> = materialized_traj.collect_into_vec(); + obs_vec_refs.sort_by(|a, b| a.mjd_tt().total_cmp(&b.mjd_tt())); + + obs_vec_refs.estimate_best_orbit(cache, jpl, params, rng) +} + +/// Run IOD directly on a pre-sorted, pre-corrected slice of observations using +/// an already-built position cache. +/// +/// This is the low-level entry point used when the caller has already applied +/// the error model and built the [`OutfitCache`]. It avoids the cost of +/// reconstructing an [`ObsDataset`] and rebuilding the cache. +/// +/// # Arguments +/// +/// - `observations` — slice of observations, **sorted by MJD** (ascending). +/// - `cache` — position cache already built for these observations. +/// - `jpl` — JPL ephemeris. +/// - `params` — IOD tuning parameters. +/// - `rng` — random-number generator for noise realisations. +pub(crate) fn run_iod_on_observations( + observations: &[Observation], + cache: &OutfitCache, + jpl: &JPLEphem, + params: &IODParams, + rng: &mut impl rand::Rng, +) -> Result { + let mut refs: Vec<&Observation> = observations.iter().collect(); + refs.sort_by(|a, b| a.mjd_tt().total_cmp(&b.mjd_tt())); + refs.estimate_best_orbit(cache, jpl, params, rng) +} + +fn prepare_iod( + dataset: ObsDataset, + jpl: &JPLEphem, + ut1_provider: &Ut1Provider, + params: &IODParams, + error_model: ObsErrorModel, + rng: &mut impl rand::Rng, +) -> Result<(ObsDataset, OutfitCache, u64), OutfitError> { + let corrected_dataset = dataset + .with_error_model(error_model) + .apply_model_errors() + .apply_batch_rms_correction(params.gap_max); + + let cache = OutfitCache::build(&corrected_dataset, jpl, ut1_provider, true)?; + + // Draw a single base seed from the caller's RNG. + // All per-trajectory RNGs are derived from this seed → deterministic + // regardless of trajectory ordering or parallelism. + let base_seed: u64 = rng.random(); + + Ok((corrected_dataset, cache, base_seed)) +} + +fn process_traj( + traj_id: &TrajId, + corrected_dataset: &ObsDataset, + cache: &OutfitCache, + jpl: &JPLEphem, + params: &IODParams, + base_seed: u64, +) -> (TrajId, Result) { + let traj_seed = base_seed ^ traj_id.stable_hash(); + let mut local_rng = SmallRng::seed_from_u64(traj_seed); + let result = fit_single_traj( + traj_id, + corrected_dataset, + cache, + jpl, + params, + &mut local_rng, + ); + (traj_id.clone(), result) +} diff --git a/src/initial_orbit_determination/triplet_generation/index_generator.rs b/src/initial_orbit_determination/triplet_generation/index_generator.rs new file mode 100644 index 0000000..e5cd1a9 --- /dev/null +++ b/src/initial_orbit_determination/triplet_generation/index_generator.rs @@ -0,0 +1,657 @@ +//! # IOD Triplet Index Generator (lazy, windowed by time span) +//! +//! Streams index triplets `(i, j, k)` — called **anchor**, **middle**, **last** — that +//! satisfy a time-span constraint on the outer pair: +//! +//! $$dt\_min \leq t_k - t_i \leq dt\_max \quad \text{with } i < j < k$$ +//! +//! Indices refer to a *downsampled* view of the input observations (the +//! **reduced set**). They map directly to positions in the input slice. +//! +//! ## Input contract +//! +//! The observation slice passed to [`TripletIndexGenerator::from_observations`] +//! **must already be sorted in ascending time order**. No internal sorting is +//! performed. Violating this contract produces silently incorrect triplets. +//! +//! ## Algorithm +//! +//! For each anchor `i`, a two-pointer sweep finds the valid window +//! $[lo, hi]$ for `k` such that the time-span constraint holds. +//! `j` then ranges over `(i, hi)` and `k` over `(j, hi]`. +//! This gives ~$O(n^2)$ complexity versus $O(n^3)$ brute-force. +//! +//! ## Typical usage +//! +//! ```text +//! // observations must be sorted by ascending epoch before this call. +//! let gen = TripletIndexGenerator::from_observations(&obs, dt_min, dt_max, 200, usize::MAX); +//! for (i, j, k) in gen { +//! // i, j, k index directly into the (downsampled view of the) input slice. +//! } +//! ``` +//! +//! ## Notes +//! - `dt_min`/`dt_max` must share the same time unit as the observation epochs (TT/MJD). +//! - Fewer than 3 reduced observations or `dt_min > dt_max` → empty iterator. +//! - No ordering by heuristic is imposed; layer a best-K heap on top if needed. + +use photom::observation_dataset::observation::Observation; + +// --------------------------------------------------------------------------- +// Downsampling +// --------------------------------------------------------------------------- + +/// Select `max_keep` indices from `0..n` uniformly, always including `0` and `n-1`. +/// +/// This reduces the $O(n^3)$ triplet explosion while preserving the full time span. +/// +/// Behavior +/// ----------------- +/// | Condition | Result | +/// |--------------------|-------------------------------------| +/// | `n == 0` | `[]` | +/// | `max_keep >= n` | `[0, 1, …, n-1]` (identity) | +/// | `max_keep <= 3` | `[0, n/2, n-1]` | +/// | otherwise | `max_keep` uniformly spaced indices | +/// +/// Arguments +/// ----------------- +/// * `n` – Total number of points. +/// * `max_keep` – Maximum number of indices to return. +/// +/// Return +/// ---------- +/// Indices in **strictly ascending** order. +pub(crate) fn downsample_uniform_with_edges(n: usize, max_keep: usize) -> Vec { + match n { + 0 => vec![], + _ if max_keep >= n => (0..n).collect(), + _ if max_keep <= 3 => vec![0, n / 2, n - 1], + _ => (0..max_keep) + .map(|i| i * (n - 1) / (max_keep - 1)) + .collect(), + } +} + +// --------------------------------------------------------------------------- +// Feasible window for a fixed anchor +// --------------------------------------------------------------------------- + +/// Valid range of `last` indices for a fixed anchor `i`. +/// +/// `lo` is the smallest index `k > i` with $t_k - t_i \geq dt\_min$. +/// `hi` is the largest index `k > i` with $t_k - t_i \leq dt\_max$. +/// +/// The window is **empty** when `lo > hi` or no such `k` exists. +#[derive(Debug, Clone, Copy)] +struct LastWindow { + lo: usize, + hi: usize, +} + +impl LastWindow { + fn compute(anchor: usize, epochs: &[f64], dt_min: f64, dt_max: f64) -> Self { + let n = epochs.len(); + let t0 = epochs[anchor]; + + let mut lo = anchor + 2; + while lo < n && epochs[lo] - t0 < dt_min { + lo += 1; + } + + let mut hi = lo.saturating_sub(1).max(anchor + 1); + while hi + 1 < n && epochs[hi + 1] - t0 <= dt_max { + hi += 1; + } + + Self { lo, hi } + } + + fn is_empty(&self, anchor: usize, n: usize) -> bool { + self.lo >= n || self.lo > self.hi || self.hi <= anchor + 1 + } +} + +// --------------------------------------------------------------------------- +// TripletIndexGenerator +// --------------------------------------------------------------------------- + +/// Lazy iterator over time-feasible IOD triplet indices `(anchor, middle, last)`. +/// +/// # Input contract +/// +/// The observation slice passed to [`from_observations`](Self::from_observations) +/// **must be sorted in ascending time order** before construction. +/// +/// # Index space +/// +/// Indices yielded by the iterator refer directly to positions in the +/// (downsampled) input slice — no remapping is needed. +/// +/// See the [module-level documentation](self) for the algorithm and usage. +pub struct TripletIndexGenerator { + /// Epochs of the reduced set (TT/MJD), extracted from the downsampled positions. + epochs: Vec, + + // --- iteration state --- + anchor: usize, + middle: usize, + last: usize, + window: LastWindow, + + n: usize, + dt_min: f64, + dt_max: f64, + + /// Remaining triplets allowed before the iterator stops (`usize::MAX` = no cap). + remaining: usize, +} + +impl TripletIndexGenerator { + /// Construct directly from a reduced epoch vector. + /// + /// `epochs` must be in **ascending** order. + /// + /// Arguments + /// ----------------- + /// * `epochs` – Reduced epochs in ascending order. + /// * `dt_min`, `dt_max` – Time-span bounds on `(anchor, last)`. + /// * `cap` – Maximum triplets to yield (`usize::MAX` for no limit). + pub fn new(epochs: Vec, dt_min: f64, dt_max: f64, cap: usize) -> Self { + let n = epochs.len(); + let window = if n >= 3 { + LastWindow::compute(0, &epochs, dt_min, dt_max) + } else { + LastWindow { lo: n, hi: 0 } + }; + + Self { + n, + epochs, + dt_min, + dt_max, + anchor: 0, + middle: 1, + last: window.lo.max(2), + window, + remaining: cap, + } + } + + /// Build from a time-sorted observation slice, with optional downsampling. + /// + /// The input slice **must already be sorted in ascending time order**. + /// Downsampling via `downsample_uniform_with_edges` is applied, always + /// preserving the first and last observations. + /// + /// Arguments + /// ----------------- + /// * `observations` – Time-sorted observation slice (ascending epoch). + /// * `dt_min`, `dt_max` – Time-span bounds on `(anchor, last)`. + /// * `max_reduced` – Downsampling cap (uniform with endpoints). + /// * `cap` – Maximum triplets to yield (`usize::MAX` for no limit). + pub fn from_observations( + observations: &[&Observation], + dt_min: f64, + dt_max: f64, + max_reduced: usize, + cap: usize, + ) -> Self { + let keep = downsample_uniform_with_edges(observations.len(), max_reduced); + let epochs: Vec = keep.iter().map(|&i| observations[i].mjd_tt()).collect(); + Self::new(epochs, dt_min, dt_max, cap) + } + + /// Reduced epochs (TT/MJD), aligned with the indices yielded by the iterator. + pub fn reduced_times(&self) -> &[f64] { + &self.epochs + } + + fn advance_anchor(&mut self) -> bool { + self.anchor += 1; + if self.anchor + 2 >= self.n { + return false; + } + self.window = LastWindow::compute(self.anchor, &self.epochs, self.dt_min, self.dt_max); + self.middle = self.anchor + 1; + self.last = self.window.lo.max(self.middle + 1); + true + } + + #[inline] + fn reset_last_for_middle(&mut self) { + self.last = self.window.lo.max(self.middle + 1); + } +} + +impl Iterator for TripletIndexGenerator { + type Item = (usize, usize, usize); + + fn next(&mut self) -> Option { + if self.remaining == 0 { + return None; + } + + loop { + if self.anchor + 2 >= self.n { + return None; + } + + if self.window.is_empty(self.anchor, self.n) { + if !self.advance_anchor() { + return None; + } + continue; + } + + if self.middle >= self.window.hi { + if !self.advance_anchor() { + return None; + } + continue; + } + + if self.last <= self.middle { + self.reset_last_for_middle(); + } + + if self.last > self.window.hi { + self.middle += 1; + self.reset_last_for_middle(); + continue; + } + + let triplet = (self.anchor, self.middle, self.last); + self.last += 1; + self.remaining -= 1; + return Some(triplet); + } + } +} + +#[cfg(test)] +mod triplet_generator_tests { + use super::*; + use proptest::prelude::*; + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /// Build a generator directly from a sorted epoch slice (no observations needed). + fn gen_from_epochs(epochs: Vec, dt_min: f64, dt_max: f64) -> TripletIndexGenerator { + TripletIndexGenerator::new(epochs, dt_min, dt_max, usize::MAX) + } + + /// Collect all triplets and verify every invariant inline. + fn collect_and_validate( + epochs: &[f64], + dt_min: f64, + dt_max: f64, + ) -> Vec<(usize, usize, usize)> { + let gen = gen_from_epochs(epochs.to_vec(), dt_min, dt_max); + let mut out = Vec::new(); + for (i, j, k) in gen { + assert!(i < j, "first < middle violated: ({i},{j},{k})"); + assert!(j < k, "middle < last violated: ({i},{j},{k})"); + let span = epochs[k] - epochs[i]; + assert!( + span >= dt_min - 1e-12, + "span {span} < dt_min {dt_min}: ({i},{j},{k})" + ); + assert!( + span <= dt_max + 1e-12, + "span {span} > dt_max {dt_max}: ({i},{j},{k})" + ); + out.push((i, j, k)); + } + out + } + + /// Brute-force reference: all (i,j,k) with i Vec<(usize, usize, usize)> { + let n = epochs.len(); + let mut out = Vec::new(); + for i in 0..n { + for j in (i + 1)..n { + for k in (j + 1)..n { + let span = epochs[k] - epochs[i]; + if span >= dt_min - 1e-12 && span <= dt_max + 1e-12 { + out.push((i, j, k)); + } + } + } + } + out + } + + // ----------------------------------------------------------------------- + // downsample_uniform_with_edges_indices + // ----------------------------------------------------------------------- + + #[test] + fn downsample_empty() { + assert!(downsample_uniform_with_edges(0, 10).is_empty()); + } + + #[test] + fn downsample_no_op_when_max_ge_n() { + let result = downsample_uniform_with_edges(5, 10); + assert_eq!(result, vec![0, 1, 2, 3, 4]); + } + + #[test] + fn downsample_exact_n() { + let result = downsample_uniform_with_edges(5, 5); + assert_eq!(result, vec![0, 1, 2, 3, 4]); + } + + #[test] + fn downsample_max_keep_3() { + // Always returns [0, mid, n-1]. + let result = downsample_uniform_with_edges(9, 3); + assert_eq!(result, vec![0, 4, 8]); + } + + #[test] + fn downsample_max_keep_le_3_small_n() { + let result = downsample_uniform_with_edges(3, 2); + // Edge case: max_keep ≤ 3 branch → [0, mid, n-1] = [0, 1, 2] + assert_eq!(result[0], 0); + assert_eq!(*result.last().unwrap(), 2); + } + + #[test] + fn downsample_endpoints_always_present() { + for n in 4..=20 { + for max_keep in 3..=n { + let result = downsample_uniform_with_edges(n, max_keep); + assert_eq!( + result[0], 0, + "first endpoint missing for n={n} max={max_keep}" + ); + assert_eq!( + *result.last().unwrap(), + n - 1, + "last endpoint missing for n={n} max={max_keep}" + ); + } + } + } + + #[test] + fn downsample_length_respects_max_keep() { + for n in 4..=30 { + for max_keep in 3..n { + let result = downsample_uniform_with_edges(n, max_keep); + assert!( + result.len() <= max_keep, + "len={} > max_keep={max_keep} for n={n}", + result.len() + ); + } + } + } + + #[test] + fn downsample_strictly_increasing() { + let result = downsample_uniform_with_edges(100, 10); + for w in result.windows(2) { + assert!(w[0] < w[1], "not strictly increasing: {:?}", result); + } + } + + // ----------------------------------------------------------------------- + // TripletIndexGenerator — edge cases + // ----------------------------------------------------------------------- + + #[test] + fn generator_empty_on_fewer_than_3_obs() { + for n in 0..=2 { + let epochs: Vec = (0..n).map(|i| i as f64).collect(); + let triplets = collect_and_validate(&epochs, 0.0, 10.0); + assert!(triplets.is_empty(), "expected empty for n={n}"); + } + } + + #[test] + fn generator_empty_when_dt_min_gt_dt_max() { + let epochs = vec![0.0, 1.0, 2.0, 3.0]; + let triplets = collect_and_validate(&epochs, 5.0, 2.0); + assert!(triplets.is_empty()); + } + + #[test] + fn generator_empty_when_all_spans_below_dt_min() { + // All spans ≤ 2, dt_min = 10. + let epochs = vec![0.0, 1.0, 2.0, 3.0]; + let triplets = collect_and_validate(&epochs, 10.0, 100.0); + assert!(triplets.is_empty()); + } + + #[test] + fn generator_empty_when_all_spans_above_dt_max() { + // All spans ≥ 3, dt_max = 1. + let epochs = vec![0.0, 10.0, 20.0, 30.0]; + let triplets = collect_and_validate(&epochs, 0.0, 1.0); + assert!(triplets.is_empty()); + } + + #[test] + fn generator_single_feasible_triplet() { + // Only (0,1,2) has span 2.0 ∈ [2, 2]. + let epochs = vec![0.0, 1.0, 2.0]; + let triplets = collect_and_validate(&epochs, 2.0, 2.0); + assert_eq!(triplets, vec![(0, 1, 2)]); + } + + #[test] + fn generator_matches_brute_force_small() { + let epochs = vec![0.0, 1.0, 2.0, 3.0, 4.0]; + let dt_min = 1.5; + let dt_max = 3.5; + + let mut got = collect_and_validate(&epochs, dt_min, dt_max); + let mut expected = brute_force(&epochs, dt_min, dt_max); + + got.sort(); + expected.sort(); + assert_eq!(got, expected); + } + + #[test] + fn generator_matches_brute_force_no_constraint() { + // dt_min = 0, dt_max = ∞ → all (i = (0..6).map(|i| i as f64).collect(); + + let mut got = collect_and_validate(&epochs, 0.0, f64::MAX); + let mut expected = brute_force(&epochs, 0.0, f64::MAX); + + got.sort(); + expected.sort(); + assert_eq!(got, expected); + } + + #[test] + fn generator_matches_brute_force_equal_spacing() { + let epochs: Vec = (0..7).map(|i| i as f64 * 2.0).collect(); + let dt_min = 3.0; + let dt_max = 9.0; + + let mut got = collect_and_validate(&epochs, dt_min, dt_max); + let mut expected = brute_force(&epochs, dt_min, dt_max); + + got.sort(); + expected.sort(); + assert_eq!(got, expected); + } + + #[test] + fn generator_no_duplicates() { + let epochs: Vec = (0..8).map(|i| i as f64).collect(); + let mut triplets = collect_and_validate(&epochs, 1.0, 6.0); + triplets.sort(); + triplets.dedup(); + let all = collect_and_validate(&epochs, 1.0, 6.0); + assert_eq!(triplets.len(), all.len(), "duplicates detected"); + } + + // ----------------------------------------------------------------------- + // max_triplets_to_yield cap + // ----------------------------------------------------------------------- + + #[test] + fn generator_respects_max_triplets_cap() { + let epochs: Vec = (0..10).map(|i| i as f64).collect(); + let cap = 5; + let gen = TripletIndexGenerator::new(epochs, 1.0, 20.0, cap); + let count = gen.count(); + assert_eq!(count, cap); + } + + #[test] + fn generator_cap_zero_yields_nothing() { + let epochs = vec![0.0, 1.0, 2.0, 3.0]; + let gen = TripletIndexGenerator::new(epochs, 0.0, 10.0, 0); + assert_eq!(gen.count(), 0); + } + + // ----------------------------------------------------------------------- + // reduced_to_original mapping + // ----------------------------------------------------------------------- + + #[test] + fn reduced_times_match_input_epochs() { + let epochs: Vec = (0..5).map(|i| i as f64).collect(); + let gen = TripletIndexGenerator::new(epochs.clone(), 0.0, 10.0, usize::MAX); + assert_eq!(gen.reduced_times(), epochs.as_slice()); + } + + #[test] + fn reduced_times_aligned_with_mapping() { + let epochs = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]; + let gen = TripletIndexGenerator::new(epochs.clone(), 0.0, 20.0, usize::MAX); + + // reduced_times length must match the number of epochs provided. + assert_eq!(gen.reduced_times().len(), epochs.len()); + } + + // ----------------------------------------------------------------------- + // EpochSortedIndices + // ----------------------------------------------------------------------- + + // (tested indirectly through from_observations; direct access is private) + + // ----------------------------------------------------------------------- + // Proptest: invariants hold for random inputs + // ----------------------------------------------------------------------- + + proptest! { + /// For random sorted epoch vectors and random dt windows, every yielded + /// triplet must satisfy i < j < k and dt_min ≤ t[k]-t[i] ≤ dt_max. + #[test] + fn prop_all_invariants_hold( + // Generate 3..=12 strictly increasing epochs starting near 0. + n in 3usize..=12, + steps in prop::collection::vec(0.1f64..5.0, 11), + dt_min in 0.0f64..10.0, + dt_range in 0.0f64..20.0, + ) { + let dt_max = dt_min + dt_range; + // Build epochs as cumulative sum of steps (strictly increasing). + let mut epochs = vec![0.0f64]; + for &s in steps.iter().take(n - 1) { + epochs.push(epochs.last().unwrap() + s); + } + + let _triplets = collect_and_validate(&epochs, dt_min, dt_max); + // collect_and_validate asserts invariants inline. + } + + /// Generator output matches brute-force for small random inputs. + #[test] + fn prop_matches_brute_force( + n in 3usize..=8, + steps in prop::collection::vec(0.5f64..3.0, 7), + dt_min in 0.0f64..5.0, + dt_range in 0.0f64..10.0, + ) { + let dt_max = dt_min + dt_range; + let mut epochs = vec![0.0f64]; + for &s in steps.iter().take(n - 1) { + epochs.push(epochs.last().unwrap() + s); + } + + let mut got = collect_and_validate(&epochs, dt_min, dt_max); + let mut expected = brute_force(&epochs, dt_min, dt_max); + got.sort(); + expected.sort(); + prop_assert_eq!(got, expected); + } + + /// No duplicate triplets regardless of input. + #[test] + fn prop_no_duplicates( + n in 3usize..=10, + steps in prop::collection::vec(0.1f64..4.0, 9), + dt_min in 0.0f64..5.0, + dt_range in 0.0f64..15.0, + ) { + let dt_max = dt_min + dt_range; + let mut epochs = vec![0.0f64]; + for &s in steps.iter().take(n - 1) { + epochs.push(epochs.last().unwrap() + s); + } + + let mut triplets = collect_and_validate(&epochs, dt_min, dt_max); + let total = triplets.len(); + triplets.sort(); + triplets.dedup(); + prop_assert_eq!(triplets.len(), total, "duplicate triplets found"); + } + + /// Cap is always respected. + #[test] + fn prop_cap_respected( + n in 3usize..=12, + steps in prop::collection::vec(0.5f64..3.0, 11), + cap in 0usize..=20, + ) { + let mut epochs = vec![0.0f64]; + for &s in steps.iter().take(n - 1) { + epochs.push(epochs.last().unwrap() + s); + } + let gen = TripletIndexGenerator::new( + epochs, 0.0, f64::MAX, cap, + ); + prop_assert!(gen.count() <= cap); + } + + /// downsample always returns endpoints and respects the length cap. + #[test] + fn prop_downsample_endpoints_and_length( + n in 1usize..=50, + max_keep in 3usize..=50, + ) { + let result = downsample_uniform_with_edges(n, max_keep); + prop_assert!(result.len() <= max_keep.min(n).max(3)); + if n >= 1 { + prop_assert_eq!(result[0], 0); + prop_assert_eq!(*result.last().unwrap(), n - 1); + } + } + + /// downsample output is strictly increasing. + #[test] + fn prop_downsample_strictly_increasing( + n in 2usize..=50, + max_keep in 3usize..=50, + ) { + let result = downsample_uniform_with_edges(n, max_keep); + for w in result.windows(2) { + prop_assert!(w[0] < w[1]); + } + } + } +} diff --git a/src/observations/triplets_iod.rs b/src/initial_orbit_determination/triplet_generation/mod.rs similarity index 51% rename from src/observations/triplets_iod.rs rename to src/initial_orbit_determination/triplet_generation/mod.rs index 8f53111..f671ccc 100644 --- a/src/observations/triplets_iod.rs +++ b/src/initial_orbit_determination/triplet_generation/mod.rs @@ -10,7 +10,7 @@ //! - **Score** triplets with a spacing-based weight that favors near-uniform timing, //! - **Select** only the best candidates using a heap-based pruning strategy. //! -//! The output is a small set of [`GaussObs`] triplets, ready to be passed to +//! The output is a small set of [`crate::initial_orbit_determination::gauss::GaussObs`] triplets, ready to be passed to //! the Gauss preliminary-orbit routine. //! //! ## Workflow overview @@ -18,9 +18,9 @@ //! 1. **Sort in time** (in-place). //! 2. **Downsample** to at most *N* points with uniform coverage (always keep first/last). //! 3. **Enumerate** triplets `(i, j, k)` such that `dt_min ≤ t_k − t_i ≤ dt_max`. -//! 4. **Score** each triplet with [`triplet_weight`], using `optimal_interval_time` as target spacing. +//! 4. **Score** each triplet with [`crate::initial_orbit_determination::triplet_generation::triplet_weight`], using `optimal_interval_time` as target spacing. //! 5. **Keep top-K** (lowest weight) using a `BinaryHeap` configured as a **max-heap**. -//! 6. **Materialize** the winners as [`GaussObs`] with precomputed observer positions. +//! 6. **Materialize** the winners as [`crate::initial_orbit_determination::gauss::GaussObs`] with precomputed observer positions. //! //! ## Complexity & performance //! @@ -39,16 +39,17 @@ //! //! ## Example //! -//! ```rust,no_run -//! use outfit::constants::Observations; -//! use outfit::observations::triplets_iod::generate_triplets; +//! ```rust,ignore +//! use outfit::initial_orbit_determination::triplet_generation::generate_triplets; //! -//! // Your observations loaded elsewhere: -//! let mut obs: Observations = unimplemented!(); +//! // Your observations loaded elsewhere (sorted by ascending epoch): +//! let obs: Vec<&Observation> = unimplemented!(); +//! let cache: &OutfitCache = unimplemented!(); //! //! // Build best triplets for Gauss IOD //! let triplets = generate_triplets( -//! &mut obs, +//! &obs, +//! cache, //! 0.03, // dt_min [days] //! 150.0, // dt_max [days] //! 20.0, // optimal_interval_time [days] @@ -57,7 +58,6 @@ //! ); //! //! // Now pass `triplets` to your Gauss solver… -//! # Ok::<(), outfit::outfit_errors::OutfitError>(()) //! ``` //! //! ## Guarantees & edge cases @@ -68,18 +68,22 @@ //! //! ## See also //! -//! - [`generate_triplets`] – Core function assembling and ranking triplets. -//! - [`triplet_weight`] – Spacing-based scoring rule. -//! - [`GaussObs`] – Triplet container consumed by the Gauss IOD solver. -//! - [`crate::observations::observations_ext::ObservationsExt::compute_triplets`] – Higher-level wrapper. +//! - [`crate::initial_orbit_determination::triplet_generation::generate_triplets`] – Core function assembling and ranking triplets. +//! - [`crate::initial_orbit_determination::triplet_generation::triplet_weight`] – Spacing-based scoring rule. +//! - [`crate::initial_orbit_determination::gauss::GaussObs`] – Triplet container consumed by the Gauss IOD solver. +//! - `ObservationsExt::compute_triplets` – Higher-level wrapper (internal API). + +pub mod index_generator; + use nalgebra::{Matrix3, Vector3}; +use photom::observation_dataset::observation::Observation; use std::cmp::Ordering; use std::collections::BinaryHeap; -use crate::constants::Observations; +use crate::cache::OutfitCache; use crate::initial_orbit_determination::gauss::GaussObs; -use crate::observations::triplets_generator::TripletIndexGenerator; -use crate::observations::Observation; +use crate::initial_orbit_determination::triplet_generation::index_generator::TripletIndexGenerator; +use crate::IODParams; /// Internal structure holding a weighted observation triplet during selection. /// @@ -269,62 +273,6 @@ fn s_gap(dt: f64, inv_dtw: f64) -> f64 { } } -/// Downsample observation indices while preserving endpoints and temporal coverage. -/// -/// If the input has more than `max_keep` points, this routine selects a subset of indices -/// **uniformly in time** while always including the **first** and **last** observation. -/// This reduces the `O(n³)` triplet explosion without losing global time-span information. -/// -/// Behavior -/// ----------------- -/// * If `n == 0`: returns `[]`. -/// * If `max_keep >= n`: returns all indices `0..n`. -/// * If `max_keep <= 3`: returns `[0, mid, n-1]` (with `mid = n/2`). -/// For `n < 3`, indices may repeat (callers should handle deduplication if needed). -/// * Otherwise: returns `max_keep` indices distributed uniformly between `1` and `n-2`, -/// plus the endpoints `0` and `n-1`. -/// -/// Arguments -/// ----------------- -/// * `n` – Total number of observations. -/// * `max_keep` – Maximum number of indices to return. -/// -/// Return -/// ---------- -/// * A `Vec` with the selected indices in **ascending** order. -/// -/// Remarks -/// ------------- -/// * Complexity: **O(max_keep)** after the trivial cases. -/// * The selection is **index-uniform** over the span `[1, n-2]`; if strictly -/// time-uniform selection is required, pre-sort observations by time first -/// (as done in [`generate_triplets`]). -/// -/// See also -/// ------------ -/// * [`generate_triplets`] – Uses this function before triplet enumeration. -pub(crate) fn downsample_uniform_with_edges_indices(n: usize, max_keep: usize) -> Vec { - match n { - 0 => Vec::new(), - _ if max_keep <= 3 => { - let mid = n / 2; - vec![0, mid, n - 1] - } - _ if max_keep >= n => (0..n).collect(), - _ => { - let slots = max_keep - 2; - std::iter::once(0) - .chain((0..slots).map(move |i| { - let fraction = (i + 1) as f64 / (slots + 1) as f64; - // distribute indices uniformly between 1 and n-2 - 1 + (fraction * (n - 2) as f64).floor() as usize - })) - .chain(std::iter::once(n - 1)) - .collect() - } - } -} - /// Generate and select **best-K** triplets of astrometric observations /// for Gauss Initial Orbit Determination (IOD), using a **lazy index stream** /// and a bounded **max-heap** on a spacing weight. @@ -334,269 +282,265 @@ pub(crate) fn downsample_uniform_with_edges_indices(n: usize, max_keep: usize) - /// This routine constructs good candidate triplets `(first, middle, last)` as inputs /// to the **Gauss method** while avoiding the `O(n³)` blow-up: /// -/// 1. **Sort & downsample (in `TripletIndexGenerator`)** – Observations are sorted by epoch -/// and uniformly thinned (keeping endpoints) to at most `max_obs_for_triplets`. +/// 1. **Downsample (in `TripletIndexGenerator`)** – Observations are uniformly thinned +/// (keeping endpoints) to at most `max_obs_for_triplets`. The input slice is assumed +/// to be **already sorted by ascending epoch**. /// 2. **Time-feasible enumeration (lazy)** – Indices `(i, j, k)` are streamed by /// `TripletIndexGenerator`, constrained by: /// `dt_min ≤ t[k] − t[i] ≤ dt_max` with `i < j < k`. /// 3. **Weight scoring** – Each feasible triplet receives a weight via [`triplet_weight`], /// favoring near-uniform spacing around `optimal_interval_time`. /// 4. **Best-K selection** – A bounded **max-heap** retains only the `max_triplet` -/// lowest-weight candidates (the heap’s `peek()` is the current **worst**). +/// lowest-weight candidates (the heap's `peek()` is the current **worst**). /// 5. **Materialization** – Only for the selected indices, we re-borrow `observations` -/// immutably and build [`GaussObs`] with precomputed observer heliocentric columns -/// (via [`Observation::get_observer_helio_position`]). +/// immutably and build [`GaussObs`] with precomputed observer heliocentric columns. /// -/// Design notes -/// ----------------- -/// * Enumeration and scoring happen on **reduced indices** (owned by the generator), +/// # Design notes +/// +/// - The input slice must be **sorted in ascending time order** before this call. +/// - Enumeration and scoring happen on reduced epoch indices owned by the generator, /// so there are **no overlapping borrows** of `observations`. -/// * The function avoids cloning the generator’s internal buffers (times, mapping); -/// we read them through short-lived immutable borrows between `next()` calls. -/// * The final `Vec` is **sorted by increasing weight** (best first). +/// - The final `Vec` is **sorted by increasing weight** (best first). /// -/// Arguments -/// ----------------- -/// * `observations` – Mutable set of astrometric observations; epochs are sorted **in-place**. -/// * `dt_min` – Minimum allowed time span (same units as `Observation::time`) between first and last. -/// * `dt_max` – Maximum allowed time span between first and last. -/// * `optimal_interval_time` – Target per-gap spacing used by [`triplet_weight`]. -/// * `max_obs_for_triplets` – Downsampling cap (uniform with edges). -/// * `max_triplet` – Number `K` of best triplets to return (heap capacity). +/// # Arguments /// -/// Return -/// ---------- -/// * A `Vec` of length `≤ max_triplet`, sorted by **ascending** heuristic weight. +/// - `observations` – Time-sorted observation slice (ascending epoch). +/// - `cache` – Precomputed heliocentric observer positions. +/// - `params` – IOD parameters controlling time bounds, downsampling cap, +/// optimal spacing, and the best-K limit. /// -/// Complexity -/// ----------------- -/// * Enumeration: typically ~`O(n²)` thanks to the per-anchor time window in +/// # Return +/// +/// - A `Vec` of length `≤ max_triplet`, sorted by **ascending** heuristic weight. +/// +/// # Complexity +/// +/// * Enumeration: typically ~$O(n^2)$ thanks to the per-anchor time window in /// [`TripletIndexGenerator`]. -/// * Selection: `O(n log K)` due to the bounded heap. -/// * Space: `O(1)` per yielded triplet during enumeration; only the final `K` are materialized. +/// * Selection: $O(n \log K)$ due to the bounded heap. +/// * Space: $O(1)$ per yielded triplet during enumeration; only the final `K` are materialized. +/// +/// # See also /// -/// See also -/// ------------ /// * [`TripletIndexGenerator`] – Streams time-feasible reduced indices lazily. /// * [`triplet_weight`] – Heuristic favoring evenly spaced triplets around a target gap. /// * [`GaussObs::realizations_iter`] – Lazy Monte-Carlo perturbations per triplet. -/// * [`ObservationsExt::compute_triplets`](crate::observations::observations_ext::ObservationsExt::compute_triplets) – Typical high-level wrapper. pub fn generate_triplets( - observations: &mut Observations, - dt_min: f64, - dt_max: f64, - optimal_interval_time: f64, - max_obs_for_triplets: usize, - max_triplet: u32, + observations: &[&Observation], + cache: &OutfitCache, + params: &IODParams, ) -> Vec { - if max_triplet == 0 { + if params.max_triplets == 0 || observations.len() < 3 { return Vec::new(); } - // --- Phase 1: enumerate feasible reduced indices & keep best-K by weight (no &Observation borrows). + // --- Phase 1: build the reduced-index stream and score all feasible triplets. let mut index_gen = TripletIndexGenerator::from_observations( observations, - dt_min, - dt_max, - max_obs_for_triplets, - usize::MAX, // scan all feasible triplets; the heap does best-K filtering + params.dt_min, + params.dt_max_triplet, + params.max_obs_for_triplets, + usize::MAX, ); - let k_cap = max_triplet as usize; - let mut heap: BinaryHeap = BinaryHeap::with_capacity(k_cap.saturating_add(1)); + let k_cap = params.max_triplets as usize; + let inv_dtw = params.optimal_interval_time.recip(); + let best_k = collect_best_k_triplets(&mut index_gen, k_cap, inv_dtw); - // Bounded push: maintain the K smallest weights in a BinaryHeap (max-heap). - let mut push_best_k = |cand: WeightedTriplet| { - if !cand.weight.is_finite() { - return; // guard against NaN/Inf + // --- Phase 2: materialize GaussObs for the selected indices. + // Indices in WeightedTriplet refer directly into the downsampled view of + // `observations` — no remapping is needed. + best_k + .into_iter() + .map(|wt| build_gauss_obs(cache, observations, wt)) + .collect() +} + +/// Consume the triplet stream and retain the `max_triplets` best candidates by ascending weight. +/// +/// Uses a bounded max-heap: when the heap is full, a new candidate replaces the +/// current worst only if its weight is strictly smaller. +/// +/// Returns candidates sorted by ascending weight. +fn collect_best_k_triplets( + gen: &mut TripletIndexGenerator, + max_triplets: usize, + inv_optimal_interval: f64, +) -> Vec { + let mut heap: BinaryHeap = + BinaryHeap::with_capacity(max_triplets.saturating_add(1)); + + while let Some((first, middle, last)) = gen.next() { + let times = gen.reduced_times(); + let weight = triplet_weight_with_inv( + times[first], + times[middle], + times[last], + inv_optimal_interval, + ); + + if !weight.is_finite() { + continue; } - if heap.len() < k_cap { - heap.push(cand); - } else if let Some(worst) = heap.peek() { - if cand.weight < worst.weight { - heap.pop(); - heap.push(cand); - } + + if heap.len() < max_triplets { + heap.push(WeightedTriplet { + weight, + first_idx: first, + middle_idx: middle, + last_idx: last, + }); + } else if heap.peek().is_some_and(|worst| weight < worst.weight) { + heap.pop(); + heap.push(WeightedTriplet { + weight, + first_idx: first, + middle_idx: middle, + last_idx: last, + }); } - }; - - // Consume the reduced-index stream. After each `next()`, take a short immutable - // borrow of the times to compute the weight (no overlap with the next `next()`). - - let inv_dtw = optimal_interval_time.recip(); // precompute once - while let Some((i, j, k)) = index_gen.next() { - let times = index_gen.reduced_times(); - let w = triplet_weight_with_inv(times[i], times[j], times[k], inv_dtw); - push_best_k(WeightedTriplet { - weight: w, - first_idx: i, - middle_idx: j, - last_idx: k, - }); } - // Best-K by ascending weight. - let mut best_reduced = heap.into_sorted_vec(); - best_reduced.sort_by(|a, b| a.weight.partial_cmp(&b.weight).unwrap()); // defensive - - // --- Phase 2: materialize GaussObs for the selected indices (immutable borrows now safe). - let mapping = index_gen.selected_original_indices(); + // Max-heap → ascending weight order. + let mut result = heap.into_vec(); + result.sort_unstable_by(|a, b| a.weight.partial_cmp(&b.weight).unwrap_or(Ordering::Equal)); + result +} - best_reduced - .into_iter() - .map(|wt| { - let (i, j, k) = (wt.first_idx, wt.middle_idx, wt.last_idx); - - // reduced → original indices - let oi = mapping[i]; - let oj = mapping[j]; - let ok = mapping[k]; - - // Immutable borrows of the original observations occur only here. - let o1: &Observation = &observations[oi]; - let o2: &Observation = &observations[oj]; - let o3: &Observation = &observations[ok]; - - // Observer 3×3 matrix (columns = heliocentric observer positions at each epoch). - let observer_matrix: Matrix3 = Matrix3::from_columns(&[ - o1.get_observer_helio_position(), - o2.get_observer_helio_position(), - o3.get_observer_helio_position(), - ]); - - GaussObs::with_observer_position( - Vector3::new(oi, oj, ok), - Vector3::new(o1.ra, o2.ra, o3.ra), - Vector3::new(o1.dec, o2.dec, o3.dec), - Vector3::new(o1.time, o2.time, o3.time), - observer_matrix, - ) - }) - .collect() +/// Materialize a single [`GaussObs`] from a [`WeightedTriplet`]. +/// +/// Indices in `wt` map directly into `observations` — no remapping is needed +/// since [`TripletIndexGenerator`] works on the input slice as-is. +fn build_gauss_obs( + cache: &OutfitCache, + observations: &[&Observation], + wt: WeightedTriplet, +) -> GaussObs { + let o1 = &observations[wt.first_idx]; + let o2 = &observations[wt.middle_idx]; + let o3 = &observations[wt.last_idx]; + + let observer_matrix = Matrix3::from_columns(&[ + *cache.get_helio_position(o1.index()), + *cache.get_helio_position(o2.index()), + *cache.get_helio_position(o3.index()), + ]); + + let (o1_ra, o1_dec) = (o1.equ_coord().ra, o1.equ_coord().dec); + let (o2_ra, o2_dec) = (o2.equ_coord().ra, o2.equ_coord().dec); + let (o3_ra, o3_dec) = (o3.equ_coord().ra, o3.equ_coord().dec); + + GaussObs::with_observer_position( + Vector3::new(wt.first_idx, wt.middle_idx, wt.last_idx), + Vector3::new(o1_ra, o2_ra, o3_ra), + Vector3::new(o1_dec, o2_dec, o3_dec), + Vector3::new(o1.mjd_tt(), o2.mjd_tt(), o3.mjd_tt()), + observer_matrix.map(|x| x.into_inner()), + ) } #[cfg(test)] mod triplets_iod_tests { - - #[cfg(feature = "jpl-download")] - use approx::assert_relative_eq; - use super::*; - #[cfg(feature = "jpl-download")] - pub(crate) fn assert_gauss_obs_approx_eq(a: &GaussObs, b: &GaussObs, tol: f64) { - assert_eq!(a.idx_obs, b.idx_obs); - assert_relative_eq!(a.ra, b.ra, max_relative = tol); - assert_relative_eq!(a.dec, b.dec, max_relative = tol); - assert_relative_eq!(a.time, b.time, max_relative = tol); - } - #[test] - #[cfg(feature = "jpl-download")] fn test_compute_triplets() { - use camino::Utf8Path; + use crate::cache::OutfitCache; + use crate::test_fixture::{DATASET_2015AB, JPL_EPHEM_HORIZON, UT1_PROVIDER}; + use crate::IODParams; + use photom::observer::error_model::{ModelCorrection, ObsErrorModel}; + + // The error model must be set before building the cache. + let dataset = DATASET_2015AB + .clone() + .with_error_model(ObsErrorModel::FCCT14) + .apply_batch_rms_correction(30.0); + + // Build the cache from the real 2015AB dataset. + let cache = OutfitCache::build(&dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER, false).unwrap(); + + // Pick a trajectory with enough observations. + let traj = dataset + .materialize_trajectory("K09R05F") + .unwrap() + .collect_into_vec(); + + assert!( + traj.len() >= 3, + "trajectory must have at least 3 observations" + ); - use crate::{ - trajectories::trajectory_file::TrajectoryFile, unit_test_global::OUTFIT_HORIZON_TEST, - TrajectorySet, + let params = IODParams { + dt_min: 0.03, + dt_max_triplet: 150.0, + optimal_interval_time: 20.0, + max_obs_for_triplets: traj.len(), + max_triplets: 10, + ..Default::default() }; - let mut env_state = OUTFIT_HORIZON_TEST.0.clone(); - let mut traj_set = - TrajectorySet::new_from_80col(&mut env_state, Utf8Path::new("tests/data/2015AB.obs")); - - let traj_number = crate::constants::ObjectNumber::String("K09R05F".into()); - let traj_len = traj_set - .get(&traj_number) - .expect("Failed to get trajectory") - .len(); - - let traj_mut = traj_set - .get_mut(&traj_number) - .expect("Failed to get trajectory"); + let triplets = generate_triplets(&traj, &cache, ¶ms); - let triplets = generate_triplets(traj_mut, 0.03, 150.0, 20.0, traj_len, 10); + // We should get at least one triplet back. + assert!(!triplets.is_empty(), "expected at least one triplet"); - assert_eq!( + // No more than max_triplets. + assert!( + triplets.len() <= params.max_triplets as usize, + "got {} triplets, expected ≤ {}", triplets.len(), - 10, - "Expected 10 triplets, got {}", - triplets.len() + params.max_triplets ); - let expected_triplets = GaussObs { - idx_obs: [[23, 24, 33]].into(), - ra: [[1.6893715963476699, 1.689861452091063, 1.7527345385664372]].into(), - dec: [[1.082468037385525, 0.9436790189346231, 0.8273762407899986]].into(), - time: [[57028.479297592596, 57049.2318575926, 57063.97711759259]].into(), - observer_helio_position: [ - [-0.2645666171486676, 0.8689351643673471, 0.3766996211112465], - [-0.5889735526502539, 0.7240117187952059, 0.3138734206791042], - [-0.7743874438017259, 0.5612884709246775, 0.2433497107566823], - ] - .into(), - }; - - assert_gauss_obs_approx_eq(&triplets[0], &expected_triplets, 1e-12); - - let expected_triplet = GaussObs { - idx_obs: [[21, 25, 33]].into(), - ra: [[1.6894680985108947, 1.6898894500811472, 1.7527345385664372]].into(), - dec: [[1.0825984522657437, 0.9435805047946215, 0.8273762407899986]].into(), - time: [[57028.45404759259, 57049.245147592585, 57063.97711759259]].into(), - observer_helio_position: [ - [-0.26413563361674103, 0.8690466209095019, 0.3767466856686271], - [-0.5891631852172257, 0.7238872516832191, 0.3138186516545291], - [-0.7743874438017259, 0.5612884709246775, 0.2433497107566823], - ] - .into(), - }; + // Triplets must be sorted by ascending weight (best first). + // We verify this by re-computing weights and checking order. + for window in triplets.windows(2) { + let t1 = &window[0].time; + let t2 = &window[1].time; + let w1 = triplet_weight(t1[0], t1[1], t1[2], params.optimal_interval_time); + let w2 = triplet_weight(t2[0], t2[1], t2[2], params.optimal_interval_time); + assert!( + w1 <= w2 + 1e-12, + "triplets not sorted by ascending weight: w1={w1} > w2={w2}" + ); + } - assert_gauss_obs_approx_eq(&triplets[9], &expected_triplet, 1e-12); + // Each triplet's indices must be strictly increasing. + for t in &triplets { + assert!( + t.idx_obs[0] < t.idx_obs[1] && t.idx_obs[1] < t.idx_obs[2], + "triplet indices not strictly increasing: {:?}", + t.idx_obs + ); + } } mod downsampling_observations_tests { - use nalgebra::Vector3; - - use super::*; - - fn make_obs(n: usize) -> Observations { - (0..n) - .map(|i| Observation { - observer: 0, - ra: 0.0, - dec: 0.0, - error_ra: 0.0, - error_dec: 0.0, - time: i as f64, - observer_earth_position: Vector3::zeros(), - observer_helio_position: Vector3::zeros(), - }) - .collect() - } + + use crate::initial_orbit_determination::triplet_generation::index_generator::downsample_uniform_with_edges; #[test] fn returns_all_when_max_keep_ge_n() { let n = 5; - let indices = downsample_uniform_with_edges_indices(n, 5); + let indices = downsample_uniform_with_edges(n, 5); assert_eq!(indices, vec![0, 1, 2, 3, 4]); - let indices = downsample_uniform_with_edges_indices(n, 10); + let indices = downsample_uniform_with_edges(n, 10); assert_eq!(indices, vec![0, 1, 2, 3, 4]); } #[test] fn empty_input_returns_empty() { - assert!(downsample_uniform_with_edges_indices(0, 0).is_empty()); - assert!(downsample_uniform_with_edges_indices(0, 10).is_empty()); + assert!(downsample_uniform_with_edges(0, 0).is_empty()); + assert!(downsample_uniform_with_edges(0, 10).is_empty()); } #[test] fn max_keep_less_than_three_returns_first_middle_last() { let n = 10; let mid = n / 2; - for max_keep in [0, 1, 2] { - let indices = downsample_uniform_with_edges_indices(n, max_keep); + for max_keep in [1, 2, 3] { + let indices = downsample_uniform_with_edges(n, max_keep); assert_eq!(indices, vec![0, mid, n - 1]); } } @@ -604,11 +548,11 @@ mod triplets_iod_tests { #[test] fn max_keep_three_exactly_returns_first_middle_last() { let n = 10; - let indices = downsample_uniform_with_edges_indices(n, 3); + let indices = downsample_uniform_with_edges(n, 3); assert_eq!(indices, vec![0, n / 2, n - 1]); let n = 3; - let indices = downsample_uniform_with_edges_indices(n, 3); + let indices = downsample_uniform_with_edges(n, 3); assert_eq!(indices, vec![0, 1, 2]); } @@ -616,13 +560,13 @@ mod triplets_iod_tests { fn downsampling_uniformity_for_general_case() { let n = 10; let max_keep = 5; - let indices = downsample_uniform_with_edges_indices(n, max_keep); + let indices = downsample_uniform_with_edges(n, max_keep); assert_eq!(indices.len(), max_keep); assert_eq!(indices.first().unwrap(), &0); assert_eq!(indices.last().unwrap(), &(n - 1)); - // Indices doivent être strictement croissants + // Indices must be strictly increasing assert!(indices.windows(2).all(|w| w[1] > w[0])); } @@ -630,22 +574,11 @@ mod triplets_iod_tests { fn works_with_large_data() { let n = 1000; let max_keep = 100; - let indices = downsample_uniform_with_edges_indices(n, max_keep); + let indices = downsample_uniform_with_edges(n, max_keep); assert_eq!(indices.len(), max_keep); assert_eq!(indices.first().unwrap(), &0); assert_eq!(indices.last().unwrap(), &(n - 1)); } - - #[test] - fn indices_match_observations() { - let obs = make_obs(10); - let max_keep = 5; - let indices = downsample_uniform_with_edges_indices(obs.len(), max_keep); - - // Vérifie que les indices correspondent bien aux temps dans obs - let times: Vec<_> = indices.iter().map(|&i| obs[i].time).collect(); - assert!(times.windows(2).all(|w| w[1] > w[0])); - } } } diff --git a/src/jpl_ephem/download_jpl_file.rs b/src/jpl_ephem/download_jpl_file.rs index e14641e..036a5e5 100644 --- a/src/jpl_ephem/download_jpl_file.rs +++ b/src/jpl_ephem/download_jpl_file.rs @@ -43,9 +43,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use directories::BaseDirs; use std::{fs, str::FromStr}; -#[cfg(feature = "jpl-download")] use tokio::{fs::File, io::AsyncWriteExt}; -#[cfg(feature = "jpl-download")] use tokio_stream::StreamExt; use super::{horizon::horizon_version::JPLHorizonVersion, naif::naif_version::NaifVersion}; @@ -139,7 +137,6 @@ impl EphemFileSource { /// See also /// -------- /// * [`EphemFileSource::get_version_url`] - #[cfg(feature = "jpl-download")] fn get_baseurl(&self) -> &str { match self { EphemFileSource::JPLHorizon(_) => "https://ssd.jpl.nasa.gov/ftp/eph/planets/Linux/", @@ -157,7 +154,6 @@ impl EphemFileSource { /// -------- /// * [`JPLHorizonVersion::get_filename`] /// * [`NaifVersion::get_filename`] - #[cfg(feature = "jpl-download")] pub fn get_version_url(&self) -> String { let base_url = self.get_baseurl(); match self { @@ -210,10 +206,8 @@ impl EphemFileSource { /// See also /// -------- /// * [`EphemFileSource::get_version_url`] — Compose the URL for a versioned file. -#[cfg(feature = "jpl-download")] pub async fn download_big_file(url: &str, path: &Utf8Path) -> Result<(), OutfitError> { let mut file = File::create(path).await?; - println!("Downloading {url}..."); let mut stream = reqwest::get(url).await?.bytes_stream(); @@ -224,7 +218,6 @@ pub async fn download_big_file(url: &str, path: &Utf8Path) -> Result<(), OutfitE file.flush().await?; - println!("Downloaded {url}"); Ok(()) } @@ -296,7 +289,6 @@ impl EphemFilePath { if local_file.exists() { Ok(local_file) } else { - #[cfg(feature = "jpl-download")] { let url = file_source.get_version_url(); @@ -308,10 +300,6 @@ impl EphemFilePath { Ok(local_file) } - #[cfg(not(feature = "jpl-download"))] - { - Err(OutfitError::JPLFileNotFound(local_file.path().to_string())) - } } } @@ -391,19 +379,9 @@ impl TryFrom for EphemFilePath { } } -#[cfg(test)] -mod jpl_reader_test { - /// If `jpl-download` is **not** enabled, requesting a missing file must error. - #[test] - #[cfg(not(feature = "jpl-download"))] - fn test_no_feature_download_jpl_ephem() { - use super::*; - let file_source = "naif:DE442".try_into().unwrap(); - - let result = EphemFilePath::get_ephemeris_file(&file_source); - assert!( - result.is_err(), - "feature jpl-download is enabled, weird ..." - ); +/// Allow cloning the source for multiple resolution attempts without forcing the caller to clone. +impl From<&EphemFileSource> for EphemFileSource { + fn from(s: &EphemFileSource) -> Self { + s.clone() } } diff --git a/src/jpl_ephem/horizon/horizon_data.rs b/src/jpl_ephem/horizon/horizon_data.rs index b3bca4a..e99b580 100644 --- a/src/jpl_ephem/horizon/horizon_data.rs +++ b/src/jpl_ephem/horizon/horizon_data.rs @@ -34,7 +34,7 @@ use nom::{ IResult, Parser, }; -use crate::{constants::MJD, jpl_ephem::download_jpl_file::EphemFilePath}; +use crate::{constants::MJDET, jpl_ephem::download_jpl_file::EphemFilePath}; use super::{ horizon_ids::HorizonID, horizon_records::HorizonRecord, horizon_version::JPLHorizonVersion, @@ -708,7 +708,7 @@ impl HorizonData { /// See also /// ------------ /// * [`HorizonData::get_record_horizon`] – retrieves the actual record for a body. - fn get_record_index(&self, et: MJD) -> (usize, f64) { + fn get_record_index(&self, et: MJDET) -> (usize, f64) { // ephem_start and ephem_end are in JD let (ephem_start, ephem_end, ephem_step) = ( self.header.start_period, @@ -760,7 +760,7 @@ impl HorizonData { /// See also /// ------------ /// * [`HorizonRecord::interpolate`] – evaluate Chebyshev polynomials. - fn get_record_horizon(&self, body: u8, et: MJD) -> Option<(&HorizonRecord, f64)> { + fn get_record_horizon(&self, body: u8, et: MJDET) -> Option<(&HorizonRecord, f64)> { let (nr, tau) = self.get_record_index(et); let records = &self.records[nr]; @@ -801,6 +801,7 @@ impl HorizonData { /// - `velocity` \[km/day\] (if requested), /// - `acceleration` \[km/day²\] (if requested), /// all expressed relative to the center body. + /// Express in the Ecliptic J2000 frame, consistent with Horizons output. /// /// See also /// ------------ @@ -810,7 +811,7 @@ impl HorizonData { &self, target: HorizonID, center: HorizonID, - et: MJD, + et: MJDET, compute_velocity: bool, compute_acceleration: bool, ) -> InterpResult { @@ -850,17 +851,19 @@ impl HorizonData { #[cfg(test)] mod test_horizon_reader { - #[cfg(feature = "jpl-download")] use super::*; - #[cfg(feature = "jpl-download")] - use crate::unit_test_global::JPL_EPHEM_HORIZON; + use crate::test_fixture::JPL_EPHEM_HORIZON; + + fn get_horizon_data() -> HorizonData { + JPL_EPHEM_HORIZON.clone().try_into_horizon().unwrap() + } #[test] - #[cfg(feature = "jpl-download")] fn test_jpl_reader_from_horizon() { + let horizon_data = get_horizon_data(); assert_eq!( - JPL_EPHEM_HORIZON.header, + horizon_data.header, HorizonHeader { jpl_version: "DE440".to_string(), ipt: [ @@ -888,17 +891,15 @@ mod test_horizon_reader { } ); - assert_eq!(JPL_EPHEM_HORIZON.records.len(), 12556); + let horizon_data = get_horizon_data(); + assert_eq!(horizon_data.records.len(), 12556); assert_eq!( - JPL_EPHEM_HORIZON - .records - .iter() - .fold(0, |acc, x| acc + x.len()), + horizon_data.records.iter().fold(0, |acc, x| acc + x.len()), 150672 ); assert_eq!( - &JPL_EPHEM_HORIZON.records[0].get(&0).unwrap()[0], + &horizon_data.records[0].get(&0).unwrap()[0], &HorizonRecord { start_jd: 2287184.5, end_jd: 2287216.5, @@ -955,9 +956,9 @@ mod test_horizon_reader { } #[test] - #[cfg(feature = "jpl-download")] fn test_get_record_from_horizon() { - let (record, tau) = JPL_EPHEM_HORIZON + let horizon_data = get_horizon_data(); + let (record, tau) = horizon_data .get_record_horizon(4, 57028.479297592596) .unwrap(); @@ -1003,18 +1004,18 @@ mod test_horizon_reader { } #[test] - #[cfg(feature = "jpl-download")] fn test_get_record_index() { - let (index, tau) = JPL_EPHEM_HORIZON.get_record_index(57028.479297592596); + let horizon_data = get_horizon_data(); + let (index, tau) = horizon_data.get_record_index(57028.479297592596); assert_eq!(index, 5307); assert_eq!(tau, 0.6399780497686152); } #[test] - #[cfg(feature = "jpl-download")] fn test_interpolation_from_horizon() { - let (record, tau) = JPL_EPHEM_HORIZON + let horizon_data = get_horizon_data(); + let (record, tau) = horizon_data .get_record_horizon(10, 57028.479297592596) .unwrap(); @@ -1031,7 +1032,8 @@ mod test_horizon_reader { } ); - let (record, tau) = JPL_EPHEM_HORIZON + let horizon_data = get_horizon_data(); + let (record, tau) = horizon_data .get_record_horizon(10, 57_049.231_857_592_59) .unwrap(); @@ -1048,7 +1050,8 @@ mod test_horizon_reader { } ); - let (record, tau) = JPL_EPHEM_HORIZON + let horizon_data = get_horizon_data(); + let (record, tau) = horizon_data .get_record_horizon(10, 60781.51949044435) .unwrap(); let res = record.interpolate(tau, true, true, 2); @@ -1068,9 +1071,9 @@ mod test_horizon_reader { } #[test] - #[cfg(feature = "jpl-download")] fn test_target_center_interpolation() { - let interp = JPL_EPHEM_HORIZON.ephemeris( + let horizon_data = get_horizon_data(); + let interp = horizon_data.ephemeris( HorizonID::Earth, HorizonID::Sun, 60781.51949044435, @@ -1106,7 +1109,8 @@ mod test_horizon_reader { } ); - let interp = JPL_EPHEM_HORIZON.ephemeris( + let horizon_data = get_horizon_data(); + let interp = horizon_data.ephemeris( HorizonID::Mars, HorizonID::Sun, 60781.51949044435, @@ -1137,7 +1141,8 @@ mod test_horizon_reader { } ); - let interp = JPL_EPHEM_HORIZON.ephemeris( + let horizon_data = get_horizon_data(); + let interp = horizon_data.ephemeris( HorizonID::Earth, HorizonID::Sun, 52550.18467592593, diff --git a/src/jpl_ephem/horizon/horizon_records.rs b/src/jpl_ephem/horizon/horizon_records.rs index 3b83abc..0a71ac6 100644 --- a/src/jpl_ephem/horizon/horizon_records.rs +++ b/src/jpl_ephem/horizon/horizon_records.rs @@ -48,13 +48,13 @@ //! horizon_data::HorizonData, //! horizon_ids::HorizonID, //! }; -//! use outfit::constants::MJD; +//! use outfit::constants::MJDET; //! //! // Load the binary and build HorizonData (details omitted here). //! let hd: HorizonData = unimplemented!("Construct HorizonData from a loaded Horizons file"); //! //! // Interpolate Earth wrt Solar System Barycenter at an epoch: -//! let et: MJD = 60200.0; // example MJD +//! let et: MJDET = 60200.0; // example MJD (TT) //! let res = hd.ephemeris(HorizonID::Earth, HorizonID::SolarSystemBarycenter, et, true, true); //! println!("r[km] = {:?}", res.position); //! println!("v[km/day] = {:?}", res.velocity); diff --git a/src/jpl_ephem/mod.rs b/src/jpl_ephem/mod.rs index 31326b9..5e9e4d0 100644 --- a/src/jpl_ephem/mod.rs +++ b/src/jpl_ephem/mod.rs @@ -72,7 +72,10 @@ use hifitime::Epoch; use horizon::{horizon_data::HorizonData, horizon_ids::HorizonID}; use naif::{ naif_data::NaifData, - naif_ids::{planet_bary::PlanetaryBary, solar_system_bary::SolarSystemBary, NaifIds}, + naif_ids::{ + planet_bary::PlanetaryBary, satellite_mass::SatelliteMassCenter, + solar_system_bary::SolarSystemBary, NaifIds, + }, }; use nalgebra::Vector3; @@ -107,8 +110,8 @@ impl JPLEphem { /// * [`download_jpl_file::EphemFilePath::get_ephemeris_file`] /// * [`horizon::HorizonData::read_horizon_file`](crate::jpl_ephem::horizon::horizon_data::HorizonData::read_horizon_file) /// * [`naif::NaifData::read_naif_file`](crate::jpl_ephem::naif::naif_data::NaifData::read_naif_file) - pub fn new(file_source: &EphemFileSource) -> Result { - let file_path = EphemFilePath::get_ephemeris_file(file_source)?; + pub fn new(source: impl Into) -> Result { + let file_path = EphemFilePath::get_ephemeris_file(&source.into())?; match file_path { EphemFilePath::JPLHorizon(..) => { let horizon_data = HorizonData::read_horizon_file(&file_path); @@ -126,7 +129,6 @@ impl JPLEphem { /// - **Legacy DE (`horizon`)**: `Earth` − `Sun`; after `.to_au()`: **AU** and **AU/day**. /// - **NAIF SPK/DAF (`naif`)**: `EMB` − `SSB`; after `.to_au()`: **AU** and **AU/s**. /// - /// If a uniform velocity unit is required, convert NAIF velocities by `* 86400.0`. /// /// # Parameters /// * `ephem_time` — Observation epoch (`hifitime::Epoch`). @@ -134,6 +136,8 @@ impl JPLEphem { /// /// # Returns /// `(position_au, velocity_opt)` with velocity present only if requested. + /// - `position_au`: Earth position in **AU** (geocenter for `horizon`, EMB for `naif`). + /// - `velocity_opt`: Earth velocity in **AU/day**. /// /// # See also /// * [`horizon::HorizonData::ephemeris`](crate::jpl_ephem::horizon::horizon_data::HorizonData::ephemeris) @@ -164,8 +168,120 @@ impl JPLEphem { ephem_time.to_et_seconds(), ) .to_au(); - (ephem_res.position, ephem_res.velocity) + (ephem_res.position, ephem_res.velocity.map(|v| v / 86400.0)) // Convert from AU/s to AU/day + } + } + } + + pub fn try_into_horizon(self) -> Result { + match self { + JPLEphem::HorizonFile(horizon_data) => Ok(horizon_data), + _ => Err(OutfitError::InvalidJPLEphemFileSource( + "Expected a JPL Horizon source".to_string(), + )), + } + } + + pub fn try_into_naif(self) -> Result { + match self { + JPLEphem::NaifFile(naif_data) => Ok(naif_data), + _ => Err(OutfitError::InvalidJPLEphemFileSource( + "Expected a NAIF source".to_string(), + )), + } + } + + /// Return heliocentric position and velocity (AU, AU/day) of `body` at `epoch`. + /// + /// Works for both backends: + /// - **Horizon**: maps `NaifIds` to the corresponding `HorizonID` and queries relative to `Sun`. + /// - **NAIF**: queries `(body, SSB)` then subtracts `(Sun, SSB)` to obtain heliocentric state. + /// + /// # Errors + /// Returns [`OutfitError::EphemerisBodyNotSupported`] if the body cannot be mapped to + /// the active backend. + pub fn body_ephemeris( + &self, + body: NaifIds, + epoch: &Epoch, + ) -> Result<(Vector3, Vector3), OutfitError> { + match self { + JPLEphem::HorizonFile(horizon_data) => { + let horizon_id = naif_to_horizon_id(body)?; + let ephem_res = horizon_data + .ephemeris( + horizon_id, + HorizonID::Sun, + epoch.to_mjd_tt_days(), + true, + false, + ) + .to_au(); + let vel = ephem_res.velocity.unwrap_or_else(Vector3::zeros) * 86400.0; // AU/s → AU/day + Ok((ephem_res.position, vel)) + } + JPLEphem::NaifFile(naif_data) => { + let et = epoch.to_et_seconds(); + // Query body w.r.t. SSB + let body_res = naif_data + .ephemeris(body, NaifIds::SSB(SolarSystemBary::SSB), et) + .to_au(); + // Query Sun w.r.t. SSB + let sun_res = naif_data + .ephemeris( + NaifIds::SSB(SolarSystemBary::Sun), + NaifIds::SSB(SolarSystemBary::SSB), + et, + ) + .to_au(); + // Heliocentric = body_ssb - sun_ssb; convert AU/s → AU/day + let pos = body_res.position - sun_res.position; + let vel = (body_res.velocity.unwrap_or_else(Vector3::zeros) + - sun_res.velocity.unwrap_or_else(Vector3::zeros)) + / 86400.0; + Ok((pos, vel)) } } } } + +impl TryFrom<&str> for JPLEphem { + type Error = OutfitError; + fn try_from(s: &str) -> Result { + let source = EphemFileSource::try_from(s)?; + JPLEphem::new(source) + } +} + +impl TryFrom for JPLEphem { + type Error = OutfitError; + fn try_from(s: String) -> Result { + JPLEphem::try_from(s.as_str()) + } +} + +/// Map a [`NaifIds`] to the corresponding [`HorizonID`] for the Horizon backend. +/// +/// Only bodies that are stored in JPL Horizon DE files are supported. +/// Returns [`OutfitError::EphemerisBodyNotSupported`] for anything else. +fn naif_to_horizon_id(body: NaifIds) -> Result { + match body { + NaifIds::SSB(SolarSystemBary::Sun) => Ok(HorizonID::Sun), + NaifIds::SSB(SolarSystemBary::SSB) => Err(OutfitError::EphemerisBodyNotSupported( + "Solar System Barycenter is not a physical body".to_string(), + )), + NaifIds::PB(PlanetaryBary::Mercury) => Ok(HorizonID::Mercury), + NaifIds::PB(PlanetaryBary::Venus) => Ok(HorizonID::Venus), + NaifIds::PB(PlanetaryBary::EarthMoon) => Ok(HorizonID::Earth), + NaifIds::PB(PlanetaryBary::Mars) => Ok(HorizonID::Mars), + NaifIds::PB(PlanetaryBary::Jupiter) => Ok(HorizonID::Jupiter), + NaifIds::PB(PlanetaryBary::Saturn) => Ok(HorizonID::Saturn), + NaifIds::PB(PlanetaryBary::Uranus) => Ok(HorizonID::Uranus), + NaifIds::PB(PlanetaryBary::Neptune) => Ok(HorizonID::Neptune), + NaifIds::PB(PlanetaryBary::Pluto) => Ok(HorizonID::Pluto), + NaifIds::SMC(SatelliteMassCenter::Moon) => Ok(HorizonID::Moon), + other => Err(OutfitError::EphemerisBodyNotSupported(format!( + "{other} is not available in the Horizon backend" + ))), + } +} diff --git a/src/jpl_ephem/naif/naif_data.rs b/src/jpl_ephem/naif/naif_data.rs index 4f2de84..8c9ee97 100644 --- a/src/jpl_ephem/naif/naif_data.rs +++ b/src/jpl_ephem/naif/naif_data.rs @@ -209,7 +209,7 @@ impl NaifData { /// Interpolate **position** and **velocity** for a `(target, center)` at an ET epoch. /// /// This fetches the covering record and evaluates Chebyshev polynomials to - /// return a Cartesian state vector in the J2000 frame. + /// return a Cartesian state vector in the Ecliptic J2000 frame. /// /// Arguments /// ----------------- @@ -277,23 +277,23 @@ impl NaifData { #[cfg(test)] mod test_naif_file { - #[cfg(feature = "jpl-download")] use super::*; - #[cfg(feature = "jpl-download")] use crate::jpl_ephem::naif::naif_ids::{ planet_bary::PlanetaryBary, solar_system_bary::SolarSystemBary, }; - #[cfg(feature = "jpl-download")] - use crate::unit_test_global::JPL_EPHEM_NAIF; - #[cfg(feature = "jpl-download")] + use crate::test_fixture::JPL_EPHEM_NAIF; use hifitime::Epoch; + fn get_naif_data() -> NaifData { + JPL_EPHEM_NAIF.clone().try_into_naif().unwrap() + } + #[test] - #[cfg(feature = "jpl-download")] fn test_jpl_reader_from_naif() { + let naif_data = get_naif_data(); assert_eq!( - JPL_EPHEM_NAIF.daf_header, + naif_data.daf_header, DAFHeader { idword: "DAF/SPK".to_string(), internal_filename: "NIO2SPK".to_string(), @@ -308,7 +308,7 @@ mod test_naif_file { ); assert_eq!( - JPL_EPHEM_NAIF.header, + naif_data.header, JPLEphemHeader { version: "DE440".to_string(), creation_date: "25 June 2020".to_string(), @@ -319,7 +319,7 @@ mod test_naif_file { } ); - let record_earth_sun = JPL_EPHEM_NAIF + let record_earth_sun = naif_data .get_records( NaifIds::PB(PlanetaryBary::EarthMoon), NaifIds::SSB(SolarSystemBary::SSB), @@ -412,12 +412,12 @@ mod test_naif_file { } #[test] - #[cfg(feature = "jpl-download")] fn test_get_record() { let date_str = "2024-04-10T12:30:45"; let epoch = Epoch::from_gregorian_str(date_str).unwrap(); - let record = JPL_EPHEM_NAIF + let naif_data = get_naif_data(); + let record = naif_data .get_record( NaifIds::PB(PlanetaryBary::EarthMoon), NaifIds::SSB(SolarSystemBary::SSB), @@ -480,11 +480,10 @@ mod test_naif_file { } #[test] - #[cfg(feature = "jpl-download")] fn test_jpl_ephemeris() { let epoch1 = Epoch::from_mjd_in_time_scale(57028.479297592596, hifitime::TimeScale::TT); - let interp = JPL_EPHEM_NAIF.ephemeris( + let interp = get_naif_data().ephemeris( NaifIds::PB(PlanetaryBary::EarthMoon), NaifIds::SSB(SolarSystemBary::SSB), epoch1.to_et_seconds(), @@ -512,7 +511,7 @@ mod test_naif_file { ); let epoch2 = Epoch::from_mjd_in_time_scale(57_049.231_857_592_59, hifitime::TimeScale::TT); - let interp = JPL_EPHEM_NAIF.ephemeris( + let interp = get_naif_data().ephemeris( NaifIds::PB(PlanetaryBary::EarthMoon), NaifIds::SSB(SolarSystemBary::SSB), epoch2.to_et_seconds(), diff --git a/src/jpl_ephem/naif/naif_ids/mod.rs b/src/jpl_ephem/naif/naif_ids/mod.rs index 4baa81d..b150f90 100644 --- a/src/jpl_ephem/naif/naif_ids/mod.rs +++ b/src/jpl_ephem/naif/naif_ids/mod.rs @@ -167,11 +167,11 @@ impl NaifIds { if let Ok(planet) = PlanetaryBary::from_id(id) { Ok(NaifIds::PB(planet)) } else if let Ok(planet_mass_center) = PlanetMassCenter::from_id(id) { - return Ok(NaifIds::PMC(planet_mass_center)); + Ok(NaifIds::PMC(planet_mass_center)) } else if let Ok(satellite_mass_center) = SatelliteMassCenter::from_id(id) { - return Ok(NaifIds::SMC(satellite_mass_center)); + Ok(NaifIds::SMC(satellite_mass_center)) } else { - return Err(ErrorId::InvalidNaifId(id)); + Err(ErrorId::InvalidNaifId(id)) } } _ => Err(ErrorId::InvalidNaifId(id)), diff --git a/src/lib.rs b/src/lib.rs index 3e3608c..d502e7c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,193 +26,259 @@ //! //! - **Initial Orbit Determination (IOD)**: //! - Classical **Gauss method** for three observations. +//! - **Differential Orbit Correction**: +//! - Iterative **Newton–Raphson least-squares** refinement of equinoctial elements, +//! - Projection-based **outlier rejection** loop (chi-squared per observation), +//! - Covariance matrix estimation with posterior uncertainty rescaling, +//! - Configurable free/fixed element mask for under-determined arcs, +//! - [`FitLSQ`] trait: full pipeline (IOD seed → differential correction) on any [`ObsDataset`](photom::observation_dataset::ObsDataset). +//! - **Ephemeris generation**: +//! - Predict apparent sky positions `(RA, Dec)` and distances from any [`OrbitalElements`], +//! - Compute geometric quantities: **phase angle**, **solar elongation**, **radial velocity**, apparent angular rates, +//! - Combined mode computes position and geometry in a single propagation, +//! - Three generation modes per observer: `Single`, `Range` (uniform grid), `At` (arbitrary epoch list), +//! - Multiple observers in one typed [`EphemerisRequest`]; per-epoch errors collected without aborting the batch, +//! - Choice of **two-body (Keplerian)** or **N-body (DOP853)** propagator; first- or second-order aberration correction. //! - **Orbital elements**: //! - Classical **Keplerian elements**, -//! - **Equinoctial elements** with conversions and two-body solver. +//! - **Equinoctial elements** with conversions and two-body solver, +//! - **Cometary elements** for parabolic and hyperbolic trajectories. +//! - **Uncertainty propagation**: +//! - Full **covariance matrix propagation** between orbital element representations, +//! - Standard deviations and correlations tracked via **Jacobian transformations**, +//! - Rigorous linear uncertainty propagation for orbit conversions. //! - **Reference frames & preprocessing**: //! - Precession, nutation (IAU 1980), aberration, and light-time correction, //! - Ecliptic ↔ equatorial conversions, RA/DEC parsing, time systems. -//! - **Ephemerides**: +//! - **JPL ephemerides**: //! - Built-in support for **JPL DE440** (NAIF/SPICE kernels and Horizons format). -//! - **Observation I/O**: -//! - **MPC 80-column**, **ADES XML**, and **Parquet** (batch) readers, -//! - Optimized batched loading, automatic per-observer error assignments. //! - **Observer management**: //! - Build from **MPC observatory code** or custom geodetic coordinates. //! - **Residuals & quality metrics**: //! - RMS computation of normalized astrometric residuals, filtering utilities. -//! - **Examples & benches**: -//! - End-to-end examples in `examples/`, Criterion benchmarks for IOD. -//! - **Parallel IOD (feature `parallel`)**: -//! - Batched multi-core execution via **Rayon**, -//! optional global progress bar when combined with the `progress` feature. +//! - **Examples**: +//! - End-to-end examples in `examples/`. +//! - **Parallel batch processing (feature `parallel`)** //! //! ### Planned extensions //! //! - **Vaisalä method** for short arcs, -//! - Full support for **hyperbolic trajectories**, +//! - Full support for **hyperbolic trajectories**. //! -//! ## Workflow at a Glance +//! ## Uncertainty Propagation +//! +//! Outfit tracks orbital uncertainties throughout the full pipeline — from the least-squares fit +//! through to element representation conversions. +//! +//! ### Generation from differential correction +//! +//! The [`FitLSQ`] pipeline produces a **6×6 covariance matrix** Γ = (G⊤WG)⁻¹ in equinoctial +//! element space directly from the normal equations of the weighted least-squares fit. +//! This raw covariance is then rescaled by a posterior inflation factor μ that accounts for +//! the degrees of freedom and the quality of the fit (normalised RMS): +//! +//! - normalised RMS ≤ 1: μ = √(n_meas / (n_meas − n_free)) +//! - normalised RMS > 1: μ = rms × √(n_meas / (n_meas − n_free)) +//! +//! The rescaled covariance is stored in [`DifferentialCorrectionOutput`] and embedded in the +//! returned [`OrbitalElements::Equinoctial`](OrbitalElements) variant alongside 1-σ standard deviations +//! (extracted from the diagonal). +//! +//! ### Propagation between element representations +//! +//! Each [`OrbitalElements`] variant carries an optional `covariance: Option` +//! (full 6×6 matrix) and an optional `uncertainty` (per-element 1-σ standard deviations). +//! When converting between representations, the covariance is propagated via +//! **first-order linear (Jacobian) propagation**: +//! +//! Σ_y = J · Σ_x · Jᵀ //! -//! 1. **Load observations** (MPC/ADES/Parquet). -//! 2. **Initialize** an [`Outfit`](crate::Outfit) environment with JPL ephemerides. -//! 3. **Form triplets** and run the Gauss IOD solver. -//! 4. **Evaluate residuals** (RMS) and select the best candidate. -//! 5. **Propagate** using Keplerian dynamics or convert to equinoctial elements as needed. +//! where J = ∂y/∂x is the 6×6 Jacobian of the transformation evaluated at the nominal elements. +//! Jacobians are computed **analytically** for all supported conversions: //! -//! ## Example (MPC 80-column) +//! - Keplerian ↔ Equinoctial +//! - Cometary → Keplerian (and via chain rule → Equinoctial) //! -//! ```rust -//! use camino::Utf8Path; +//! Equinoctial elements are preferred as the primary representation: they are non-singular +//! for e < 1 and 0 ≤ i < π, avoiding the degenerate Jacobians that arise for nearly circular +//! or equatorial orbits in Keplerian form. +//! +//! For mathematical details, singularity handling, and usage examples, see the +//! [`orbit_type::uncertainty`] module documentation. +//! +//! ## Workflow at a Glance +//! +//! 1. **Load observations** into an [`ObsDataset`](photom::observation_dataset::ObsDataset) +//! (MPC 80-column, ADES XML, or Parquet). +//! 2. **Load JPL ephemerides** via [`JPLEphem`] and prepare a UT1 provider. +//! 3. **Run IOD** by calling [`FitIOD::fit_iod`](crate::FitIOD::fit_iod) on the dataset, +//! or run a full **least-squares fit** via [`FitLSQ::fit_lsq`](crate::FitLSQ::fit_lsq) +//! (which seeds itself from IOD automatically). +//! 4. **Evaluate residuals** (normalised RMS) using the returned result. +//! 5. **Generate ephemerides** by calling [`OrbitalElements::compute`] with an +//! [`EphemerisRequest`] to predict apparent positions and geometric quantities. +//! 6. **Propagate** using Keplerian dynamics or convert to equinoctial elements as needed. +//! +//! ## Example (single-trajectory IOD from MPC 80-column) +//! +//! ```rust,no_run +//! use photom::observation_dataset::ObsDataset; +//! use photom::observer::error_model::ObsErrorModel; +//! use hifitime::ut1::Ut1Provider; //! use rand::{rngs::StdRng, SeedableRng}; -//! use outfit::{Outfit, ErrorModel, IODParams}; -//! use outfit::constants::ObjectNumber; -//! use outfit::TrajectorySet; -//! use outfit::prelude::*; // TrajectoryExt, ObservationIOD +//! use outfit::FitIOD; +//! use outfit::jpl_ephem::{download_jpl_file::EphemFileSource, JPLEphem}; +//! use outfit::IODParams; //! //! fn main() -> Result<(), Box> { -//! let mut env = Outfit::new("horizon:DE440", ErrorModel::FCCT14)?; +//! // Load observations from an MPC 80-column file. +//! let dataset = ObsDataset::from_mpc_80_col("tests/data/2015AB.obs")?; //! -//! // Load observations (80-col MPC). -//! let mut trajs = TrajectorySet::new_from_80col( -//! &mut env, -//! Utf8Path::new("tests/data/2015AB.obs"), -//! ); -//! trajs.add_from_80col(&mut env, Utf8Path::new("tests/data/8467.obs")); +//! // Load JPL DE440 ephemeris (Horizons format). +//! let jpl_source: EphemFileSource = "horizon:DE440".try_into()?; +//! let jpl = JPLEphem::new(&jpl_source)?; //! -//! // Select an object. -//! let obj = ObjectNumber::String("K09R05F".into()); -//! let obs = trajs.get_mut(&obj).expect("object not found"); +//! // Obtain a UT1 provider for Earth orientation corrections. +//! let ut1 = Ut1Provider::download_from_jpl("latest_eop2.long")?; //! //! // Configure IOD. //! let params = IODParams::builder() //! .n_noise_realizations(10) -//! .noise_scale(1.1) -//! .max_obs_for_triplets(obs.len()) +//! .max_obs_for_triplets(50) //! .max_triplets(30) //! .build()?; //! //! let mut rng = StdRng::seed_from_u64(42); //! -//! // Run Gauss IOD. -//! let (best_orbit, best_rms) = obs.estimate_best_orbit( -//! &mut env, -//! &ErrorModel::FCCT14, -//! &mut rng, +//! // Run Gauss IOD for a single trajectory. +//! let fit_result = dataset.fit_iod( +//! "K09R05F", +//! &jpl, +//! &ut1, //! ¶ms, +//! ObsErrorModel::FCCT14, +//! &mut rng, //! )?; //! -//! println!("Best orbit: {:?}", best_orbit); -//! println!("RMS: {}", best_rms); +//! println!("Best orbit: {}", fit_result.orbital_elements()); +//! println!("Quality: {:.6}", fit_result.orbit_quality()); //! Ok(()) //! } //! ``` //! -//! For more end-to-end flows, see the [`examples/`](https://github.com/FusRoman/Outfit/tree/main/examples) folder (e.g. `parquet_to_orbit.rs`). +//! For more end-to-end flows, see the [`examples/`](https://github.com/FusRoman/Outfit/tree/main/examples) folder. //! -//! ## Example (Parallel batched IOD) +//! ## Example (differential correction — IOD seed + least-squares refinement) //! -//! This example requires the `parallel` feature (and optionally `progress` for a global progress bar). -//! -//! ```rust -//! use camino::Utf8Path; +//! ```rust,no_run +//! use photom::observation_dataset::ObsDataset; +//! use photom::observer::error_model::ObsErrorModel; +//! use hifitime::ut1::Ut1Provider; //! use rand::{rngs::StdRng, SeedableRng}; -//! use outfit::{Outfit, ErrorModel, IODParams}; -//! use outfit::constants::ObjectNumber; -//! use outfit::TrajectorySet; -//! use outfit::TrajectoryFit; -//! use outfit::prelude::*; // TrajectoryExt, ObservationIOD +//! use outfit::{FitLSQ, DifferentialCorrectionConfig, IODParams}; +//! use outfit::jpl_ephem::{download_jpl_file::EphemFileSource, JPLEphem}; //! -//! # #[cfg(all(feature = "parallel", feature = "jpl-download"))] //! fn main() -> Result<(), Box> { -//! let mut env = Outfit::new("horizon:DE440", ErrorModel::FCCT14)?; -//! -//! let test_data = "tests/data/test_from_fink.parquet"; -//! let path_file = Utf8Path::new(test_data); -//! -//! let ztf_observer = env.get_observer_from_mpc_code(&"I41".into()); -//! let mut traj_set = TrajectorySet::new_from_parquet( -//! &mut env, -//! path_file, -//! ztf_observer, -//! 0.5, -//! 0.5, -//! None +//! let dataset = ObsDataset::from_mpc_80_col("tests/data/2015AB.obs")?; +//! +//! let jpl_source: EphemFileSource = "horizon:DE440".try_into()?; +//! let jpl = JPLEphem::new(&jpl_source)?; +//! let ut1 = Ut1Provider::download_from_jpl("latest_eop2.long")?; +//! +//! let iod_params = IODParams::builder().build()?; +//! let dc_config = DifferentialCorrectionConfig::default(); +//! let mut rng = StdRng::seed_from_u64(42); +//! +//! // Run IOD + differential correction for every trajectory. +//! let results = dataset.fit_lsq( +//! &jpl, &ut1, +//! ObsErrorModel::FCCT14, +//! &iod_params, +//! &dc_config, +//! None, +//! &mut rng, //! )?; //! +//! for (traj_id, res) in &results { +//! match res { +//! Ok(fit) => println!("{traj_id} → normalised RMS = {:.4}", fit.normalised_rms()), +//! Err(e) => eprintln!("{traj_id} → error: {e}"), +//! } +//! } +//! Ok(()) +//! } +//! ``` +//! +//! ## Example (ephemeris generation) +//! +//! ```rust,no_run +//! use outfit::{OrbitalElements, EphemerisConfig, EphemerisRequest, EphemerisMode, Combined}; +//! use hifitime::{Epoch, Duration}; +//! +//! // `elements` obtained from IOD or differential correction. +//! let result = elements.compute( +//! &EphemerisRequest::::new(EphemerisConfig::default()) +//! .add(observer, EphemerisMode::Range { +//! start: Epoch::from_mjd_tt(60310.0), +//! end: Epoch::from_mjd_tt(60340.0), +//! step: Duration::from_days(1.0), +//! }), +//! &jpl, +//! &ut1, +//! ); +//! +//! for entry in result.successes() { +//! let (pos, geo) = entry.result.as_ref().unwrap(); +//! println!( +//! "{}: RA={:.4} Dec={:.4} phase={:.2}°", +//! entry.epoch, pos.coord.ra, pos.coord.dec, +//! geo.phase_angle.to_degrees(), +//! ); +//! } +//! ``` +//! +//! ## Example (batch IOD — all trajectories at once) +//! +//! ```rust,no_run +//! use photom::observation_dataset::ObsDataset; +//! use photom::observer::error_model::ObsErrorModel; +//! use hifitime::ut1::Ut1Provider; +//! use rand::{rngs::StdRng, SeedableRng}; +//! use outfit::{FitIOD, FullOrbitResult}; +//! use outfit::jpl_ephem::{download_jpl_file::EphemFileSource, JPLEphem}; +//! use outfit::IODParams; +//! +//! fn main() -> Result<(), Box> { +//! let dataset = ObsDataset::from_mpc_80_col("tests/data/2015AB.obs")?; +//! +//! let jpl_source: EphemFileSource = "horizon:DE440".try_into()?; +//! let jpl = JPLEphem::new(&jpl_source)?; +//! let ut1 = Ut1Provider::download_from_jpl("latest_eop2.long")?; +//! //! let params = IODParams::builder() -//! .max_obs_for_triplets(12) //! .n_noise_realizations(10) +//! .max_obs_for_triplets(50) //! .build()?; //! //! let mut rng = StdRng::seed_from_u64(42); -//! let batch_size = 256; // tune for locality & memory //! -//! let results = traj_set.estimate_all_orbits_in_batches_parallel(&env, &mut rng, ¶ms, batch_size); +//! // Run Gauss IOD for every trajectory in the dataset. +//! let results: FullOrbitResult = dataset.fit_full_iod( +//! &jpl, +//! &ut1, +//! ¶ms, +//! ObsErrorModel::FCCT14, +//! &mut rng, +//! )?; //! -//! for (obj, res) in results { +//! for (traj_id, res) in &results { //! match res { -//! Ok((gauss, rms)) => { -//! println!("{} → RMS = {rms:.4}", obj); -//! println!("{gauss}"); -//! } -//! Err(e) => eprintln!("{} → error: {e}", obj), +//! Ok(fit) => println!("{traj_id} → quality = {:.4}\n{}", fit.orbit_quality(), fit.orbital_elements()), +//! Err(e) => eprintln!("{traj_id} → error: {e}"), //! } //! } -//! //! Ok(()) //! } -//! -//! # #[cfg(not(feature = "parallel"))] -//! # fn main() {} -//! ``` -//! -//! **Notes** -//! - Batches are processed **in parallel**; each batch is handled **sequentially** to preserve cache locality. -//! - Per-object RNG seeds are **deterministically derived** from a single base seed. -//! - Set `RAYON_NUM_THREADS=N` to cap threads if needed. -//! -//! ## Data Formats -//! -//! - **MPC 80-column** — standard fixed-width astrometry -//! -//! - **ADES XML** — IAU’s Astrometric Data Exchange Standard -//! -//! - **Parquet** — columnar format for large batch processing (typical columns: `ra`, `dec`, `jd`, `trajectory_id`) -//! -//! -//! ## Cargo Features -//! -//! - **`jpl-download`** -//! Automatically download JPL ephemerides (NAIF/Horizons) into a local cache. -//! -//! Cache layout (Linux): -//! ```text -//! ~/.cache/outfit_cache/jpl_ephem/ -//! ├── jpl_horizon/ -//! │ └── DE440.bsp -//! └── naif/ -//! └── de440.bsp -//! ``` -//! This feature enables some integration tests and pulls `reqwest`, `tokio`, `tokio-stream`. -//! -//! - **`progress`** *(optional)* -//! Enables lightweight progress bars (via `indicatif`) and loop timing utilities for long-running jobs. -//! -//! - **`parallel`** *(optional)* -//! Enables **multi-core** IOD via **Rayon**, exposing -//! [`TrajectorySet::estimate_all_orbits_in_batches_parallel`](crate::trajectories::trajectory_fit::TrajectoryFit::estimate_all_orbits_in_batches_parallel). -//! Combine with `progress` for a thread-safe global progress bar. -//! -//! ```toml -//! [dependencies] -//! outfit = { version = "...", features = ["jpl-download"] } -//! # with progress indicators -//! outfit = { version = "...", features = ["jpl-download", "progress"] } -//! # with multi-core IOD -//! outfit = { version = "...", features = ["parallel"] } -//! # combine as needed -//! outfit = { version = "...", features = ["jpl-download", "progress", "parallel"] } //! ``` //! //! ## Error Handling @@ -239,13 +305,23 @@ //! //! Developed by **FusRoman** and contributors. //! -//! ## Useful Modules (See also) -//! ------------ -//! - [`initial_orbit_determination`] — Gauss IOD and helpers. -//! - [`observations`] — Readers (MPC/ADES/Parquet) and trajectory utilities. -//! - [`observers`] — Observatory registry and geodetic utilities. -//! - [`jpl_ephem`] — Ephemerides backends (Horizons/NAIF). -//! - [`ref_system`] — Reference frame conversions and rotations. +//! ## See also +//! +//! - [`initial_orbit_determination`] — Gauss IOD algorithm, triplet generation, IOD parameters, public API to perform IOD on an [`ObsDataset`](photom::observation_dataset::ObsDataset). +//! - [`differential_orbit_correction`] — Weighted least-squares orbit refinement: Newton–Raphson loop, outlier rejection, covariance estimation, [`FitLSQ`] trait. +//! - [`ephemeris`] — Ephemeris generation: apparent position, geometric quantities, [`EphemerisRequest`]/[`EphemerisResult`] API. +//! - [`jpl_ephem`] — Ephemerides backends (Horizons/NAIF DE440). +//! - [`orbit_type`] — Orbital element representations (Keplerian, Equinoctial, Cometary) with full covariance propagation and uncertainty tracking for conversions between representations. +//! - [`ref_system`] — Reference frame transformations. +//! - [`constants`] — Physical constants and unit conversions. +//! - [`conversion`] — RA/DEC parsing and coordinate utilities. +//! - [`earth_orientation`] — Precession, nutation, and obliquity models. +//! - [`kepler`] — Universal Kepler propagator and Lagrange f–g solver. +//! - [`orb_elem`] — State-vector to orbital-elements conversion. +//! - [`outfit_errors`] — Unified error enum for the whole crate. +//! - [`time`] — Time scale conversions and sidereal time. +//! - [`cache`] — Precomputed observer position cache. +//! - [`observer_extension`] — Geocentric and heliocentric observer position routines. // === Modules (internals). Keep public modules as they are; the facade is built via `pub use` below. @@ -258,39 +334,27 @@ pub mod conversion; /// Earth orientation parameters and related corrections (nutation, precession). pub mod earth_orientation; -/// Environment state: ephemerides, dynamical models, configuration. -pub mod env_state; - -/// Error models used for weighting astrometric residuals. -pub mod error_models; - /// Initial Orbit Determination algorithms (Gauss method). pub mod initial_orbit_determination; +/// Differential orbit correction utilities. +pub mod differential_orbit_correction; + /// JPL ephemerides management (Horizon/NAIF kernels). pub mod jpl_ephem; +/// Ephemeris interpolation and access. +pub mod ephemeris; + /// Keplerian solver for propagation. pub mod kepler; /// Orbital types and conversions between them. pub mod orbit_type; -/// Observation handling (RA, DEC, times). -pub mod observations; - -/// Trajectory management and file I/O. -pub mod trajectories; - -/// Observers and observatory positions. -pub mod observers; - /// Orbital elements utilities (conversion, normalization). pub mod orb_elem; -/// Main Outfit struct: central orchestrator for orbit determination. -pub mod outfit; - /// Errors returned by Outfit operations. pub mod outfit_errors; @@ -300,48 +364,58 @@ pub mod ref_system; /// Time management and conversions (UTC, TDB, TT). pub mod time; +/// Precomputed observer position cache used throughout the fitting pipeline. +pub mod cache; + +/// Ground-observer geometry: body-fixed and heliocentric position routines. +pub mod observer_extension; + +/// Orbit propagation strategies (TwoBody, N-body DOP853). +pub mod propagator; + +/// Core IOD pipeline trait over sorted observation slices. +pub(crate) mod trajectory; + // === Public API FACADE ===================================================== // Re-export carefully curated symbols for a simple, stable top-level API. // Users can import from `outfit::...` without diving into deep module paths. -// Core orchestrator -pub use crate::outfit::Outfit; - -// Core data types & units -pub use crate::constants::Observations; -pub use crate::constants::{ArcSec, Degree, ObjectNumber, MJD}; -pub use crate::observers::Observer; -pub use crate::trajectories::TrajectorySet; - // Orbital element representations pub use crate::orbit_type::{ cometary_element::CometaryElements, equinoctial_element::EquinoctialElements, keplerian_element::KeplerianElements, OrbitalElements, }; -// Error handling and models -pub use crate::error_models::ErrorModel; pub use crate::outfit_errors::OutfitError; // IOD (Gauss) key types pub use crate::initial_orbit_determination::gauss_result::GaussResult; pub use crate::initial_orbit_determination::IODParams; -// Frequently-used extension traits (ergonomic entry points) and key types -pub use crate::observations::display::ObservationsDisplayExt; -pub use crate::observations::observations_ext::ObservationIOD; -pub use crate::trajectories::trajectory_file::TrajectoryFile; -pub use crate::trajectories::trajectory_fit::FullOrbitResult; -pub use crate::trajectories::trajectory_fit::TrajectoryFit; - // Selected constants that are widely useful pub use crate::constants::{ - AU, GAUSS_GRAV, RADEG, RADH, RADSEC, SECONDS_PER_DAY, T2000, VLIGHT_AU, + FullOrbitResult, AU, GAUSS_GRAV, IODRMS, RADEG, RADH, RADSEC, SECONDS_PER_DAY, T2000, VLIGHT_AU, }; // JPL ephemeris enum for runtime inspection (optional but convenient) pub use crate::jpl_ephem::JPLEphem; +// Ephemeris façade +pub use crate::ephemeris::{ + AberrationOrder, ApparentPosition, BodyGeometry, Combined, EphemerisConfig, EphemerisEntry, + EphemerisMode, EphemerisOutputKind, EphemerisRequest, EphemerisResult, FullOrbitResultExt, + Geometry, ObserverRequest, Position, +}; + +// IOD entry points and result types +pub use crate::initial_orbit_determination::obs_dataset_api::FitIOD; + +// Differential correction entry points +pub use crate::differential_orbit_correction::obs_dataset_api::FitLSQ; +pub use crate::differential_orbit_correction::{ + DifferentialCorrectionConfig, DifferentialCorrectionOutput, +}; + // A convenient crate-wide Result alias. pub type Result = core::result::Result; @@ -353,54 +427,43 @@ pub type Result = core::result::Result; /// ``` pub mod prelude { pub use crate::{ - ArcSec, Degree, ErrorModel, FullOrbitResult, GaussResult, IODParams, JPLEphem, - ObjectNumber, ObservationIOD, Observer, Outfit, OutfitError, TrajectoryFile, TrajectorySet, - MJD, + FitIOD, FullOrbitResult, GaussResult, IODParams, JPLEphem, OutfitError, IODRMS, }; // Optionally include widely-used constants: pub use crate::{AU, GAUSS_GRAV, RADEG, RADH, RADSEC, SECONDS_PER_DAY, T2000, VLIGHT_AU}; } // === Tests support ========================================================== -#[cfg(all(test, feature = "jpl-download"))] -pub(crate) mod unit_test_global { +#[cfg(test)] +pub(crate) mod test_fixture { use std::sync::LazyLock; - use camino::Utf8Path; + use hifitime::ut1::Ut1Provider; + use photom::observation_dataset::ObsDataset; - use crate::{ - error_models::ErrorModel, - jpl_ephem::{horizon::horizon_data::HorizonData, naif::naif_data::NaifData}, - outfit::Outfit, - trajectories::trajectory_file::TrajectoryFile, - trajectories::TrajectorySet, - }; - - pub(crate) static OUTFIT_NAIF_TEST: LazyLock = - LazyLock::new(|| Outfit::new("naif:DE440", ErrorModel::FCCT14).unwrap()); + use crate::{jpl_ephem::download_jpl_file::EphemFileSource, JPLEphem}; - pub(crate) static OUTFIT_HORIZON_TEST: LazyLock<(Outfit, TrajectorySet)> = - LazyLock::new(|| { - let mut env = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); + pub(crate) static UT1_PROVIDER: LazyLock = LazyLock::new(|| { + Ut1Provider::download_from_jpl("latest_eop2.long") + .expect("Download of the JPL short time scale UT1 data failed") + }); - let path_file = Utf8Path::new("tests/data/2015AB.obs"); - let traj_set = TrajectorySet::new_from_80col(&mut env, path_file); - (env, traj_set) - }); + pub(crate) static JPL_EPHEM_HORIZON: LazyLock = LazyLock::new(|| { + let jpl_file: EphemFileSource = "horizon:DE440" + .try_into() + .expect("Failed to parse JPL ephemeris source"); + JPLEphem::new(&jpl_file).expect("Failed to load JPL ephemeris from Horizon") + }); - pub(crate) static JPL_EPHEM_HORIZON: LazyLock<&HorizonData> = LazyLock::new(|| { - let jpl_ephem = OUTFIT_HORIZON_TEST.0.get_jpl_ephem().unwrap(); - match jpl_ephem { - crate::jpl_ephem::JPLEphem::HorizonFile(horizon_data) => horizon_data, - _ => panic!("JPL ephemeris is not a Horizon file"), - } + pub(crate) static JPL_EPHEM_NAIF: LazyLock = LazyLock::new(|| { + let jpl_file: EphemFileSource = "naif:DE440" + .try_into() + .expect("Failed to parse JPL ephemeris source"); + JPLEphem::new(&jpl_file).expect("Failed to load JPL ephemeris from Naif") }); - pub(crate) static JPL_EPHEM_NAIF: LazyLock<&NaifData> = LazyLock::new(|| { - let jpl_ephem = OUTFIT_NAIF_TEST.get_jpl_ephem().unwrap(); - match jpl_ephem { - crate::jpl_ephem::JPLEphem::NaifFile(naif_data) => naif_data, - _ => panic!("JPL ephemeris is not a Naif file"), - } + pub(crate) static DATASET_2015AB: LazyLock = LazyLock::new(|| { + ObsDataset::from_mpc_80_col("tests/data/2015AB.obs") + .expect("Failed to load test dataset 2015AB") }); } diff --git a/src/observations/display.rs b/src/observations/display.rs deleted file mode 100644 index 43a3091..0000000 --- a/src/observations/display.rs +++ /dev/null @@ -1,912 +0,0 @@ -//! # Tabular display for astrometric observations -//! -//! Pretty, zero-copy renderers to print an [`Observations`] collection -//! (a `SmallVec<[Observation; 6]>`) as a **table**. -//! -//! ## Overview -//! -//! The main entry point is the display adaptor [`ObservationsDisplay`]. It **borrows** -//! your observations and renders a formatted table when used with Rust formatting -//! (`{}` or `{:#}`), without cloning or moving data. -//! -//! Three layouts are available: -//! -//! - **Default** (compact, fixed-width): -//! `# | Site | MJD (TT) | RA[hms] ±σ["] | DEC[dms] ±σ["]` -//! - **Wide** (diagnostic, uses `comfy-table`): -//! adds `JD (TT) | RA [rad] | DEC [rad] | |r_geo| AU | |r_hel| AU` -//! - **ISO** (timestamp-centric, uses `comfy-table`): -//! replaces MJD/JD with `ISO (TT)` and `ISO (UTC)` -//! -//! ## Units & Conventions -//! -//! - **Time**: MJD/JD columns are on the **TT** scale. In ISO mode, both **TT** and **UTC** -//! timestamps are shown (UTC includes leap seconds via `hifitime`). -//! - **Angles**: RA/DEC are formatted in sexagesimal (RA in **hours**, DEC in **degrees**). -//! - **Uncertainties**: printed in **arcseconds**, converted from radians with [`RAD2ARC`]. -//! - **Positions**: vector norms (wide mode) are in **AU**, conventional **equatorial mean J2000**. -//! -//! ## Precision & Sorting -//! -//! - `with_seconds_precision(p)` — controls fractional digits for sexagesimal and ISO seconds. -//! - `with_distance_precision(p)` — controls fixed-point digits for AU distances (wide mode). -//! - `sorted()` — prints rows **sorted by epoch** (MJD TT ascending). The first column `#` -//! always shows the **original index** (pre-sort) for traceability. -//! -//! ## Observer names -//! -//! If you pass an [`Outfit`] with [`ObservationsDisplay::with_env`], site labels render -//! as `"Name (#id)"` when available; otherwise the numeric **site id** is shown. -//! -//! ## Performance -//! -//! - The adaptor never clones/moves `Observation`s. It sorts a **vector of indices** when -//! `sorted()` is used, and builds small transient strings per row (sexagesimal & ISO). -//! - **Default** layout writes fixed-width lines directly. -//! **Wide** and **ISO** layouts use [`comfy-table`] to build the table; this is still fast -//! but implies allocating the table representation before printing. -//! -//! ## Quick examples -//! -//! ```rust,ignore -//! use outfit::observations::display::ObservationsDisplayExt; -//! -//! // 1) Compact table (fixed-width), sorted by epoch -//! println!("{}", observations.show().sorted()); -//! -//! // 2) Wide table (adds JD, radians, |r| in AU), with custom precisions -//! println!("{}", observations -//! .table_wide() -//! .with_seconds_precision(4) -//! .with_distance_precision(8) -//! .sorted()); -//! -//! // 3) ISO table (ISO TT + ISO UTC), resolve site names via Outfit -//! println!("{}", observations -//! .table_iso() -//! .with_env(&env) -//! .with_seconds_precision(4) -//! .sorted()); -//! -//! // 4) Owned string (compact, unsorted) -//! let s = observations.show_string(); -//! ``` -//! -//! ## See also -//! -//! - [`Observation`] — single-observation pretty-printer and helpers. -//! - [`crate::conversion::ra_hms_prec`] / [`crate::conversion::dec_sdms_prec`] -//! — sexagesimal decomposition with carry. -//! - [`crate::time::fmt_ss`] — seconds string `"SS.sss"` with 2-digit integer part. -//! - [`crate::time::iso_tt_from_epoch`] / [`crate::time::iso_utc_from_epoch`] -//! — ISO renderers via `hifitime`. -//! -//! [`comfy-table`]: https://crates.io/crates/comfy-table -use std::fmt; - -use hifitime::{Epoch, TimeScale}; - -use crate::constants::{JDTOMJD, RAD2ARC}; -use crate::conversion::{dec_sdms_prec, ra_hms_prec}; -use crate::observations::Observation; -use crate::time::{fmt_ss, iso_tt_from_epoch, iso_utc_from_epoch}; -use crate::{Observations, Outfit}; - -use comfy_table::{presets::UTF8_FULL, Cell, CellAlignment, ContentArrangement, Row, Table}; - -/// Internal layout selector for the table renderer. -/// -/// This enum is crate-internal and selects the columns printed by -/// [`ObservationsDisplay`]. It is not part of the public API. -/// -/// Variants -/// ----------------- -/// * `Default` — Compact columns: MJD(TT), RA±σ, DEC±σ. -/// * `Wide` — Adds JD(TT), RA/DEC in radians, and AU vector norms. -/// * `Iso` — Replaces MJD/JD with `ISO (TT)` and `ISO (UTC)`. -enum TableMode { - Default, // MJD(TT) + RA/DEC ±σ - Wide, // + JD(TT), RA/DEC [rad], |r_geo|, |r_hel| - Iso, // ISO TT + ISO UTC instead of MJD/JD -} - -/// Display adaptor to render an [`Observations`] collection as a **table**. -/// -/// Render modes -/// ----------------- -/// * **Default** (via [`ObservationsDisplayExt::show`]): -/// columns `# | Site | MJD (TT) | RA[hms] ±σ["] | DEC[dms] ±σ["]`. -/// * **Wide** (via [`ObservationsDisplayExt::table_wide`]): -/// adds `JD (TT) | RA [rad] | DEC [rad] | |r_geo| AU | |r_hel| AU`. -/// * **ISO** (via [`ObservationsDisplayExt::table_iso`]): -/// replaces MJD/JD with `ISO (TT)` and `ISO (UTC)` timestamps. -/// -/// Precision -/// ----------------- -/// * `sec_prec` controls the number of fractional digits for sexagesimal seconds -/// **and** ISO seconds. -/// * `dist_prec` controls fixed-point digits for AU distances (wide mode). -/// -/// Sorting -/// ----------------- -/// * Call [`Self::sorted`] to display rows **sorted by MJD (TT)** in ascending order. -/// * The `#` column always shows the **original index** (pre-sort) for traceability. -/// * Ties (identical epochs) keep a stable order by original index. -/// -/// See also -/// ------------ -/// * [`ObservationsDisplayExt`] – Ergonomic builders for each mode. -/// * [`Observation`] – Per-row semantics used to derive the columns. -pub struct ObservationsDisplay<'a> { - /// Borrowed collection to render. No allocation or copying occurs. - obs: &'a Observations, - /// Optional Outfit environment to resolve observer names. - env: Option<&'a Outfit>, - /// Column layout selector (default / wide / iso). - mode: TableMode, - /// Fractional digits for sexagesimal and ISO seconds (default = 3). - sec_prec: usize, - /// Fixed-point digits for AU distances in wide mode (default = 6). - dist_prec: usize, - /// If `true`, rows are printed **sorted by epoch** (MJD TT) ascending. - sorted: bool, -} - -/// Pre-computed, per-row fields shared across all modes. -/// -/// Notes -/// ---------- -/// * This structure is internal to avoid recomputing formatting across modes. -/// * Optional fields are only populated in the relevant mode (e.g., `jd_tt` in **Wide**). -struct RowFields { - i: usize, - site_label: String, - // Base time & angles - mjd_tt: f64, - ra_rad: f64, - dec_rad: f64, - // Rendered sexagesimal with uncertainties - ra_str: String, - dec_str: String, - // Optional extras by mode - jd_tt: Option, - r_geo: Option, - r_hel: Option, - iso_tt: Option, - iso_utc: Option, -} - -impl<'a> ObservationsDisplay<'a> { - /// Build a new table adaptor (default: **compact** columns). - /// - /// Arguments - /// ----------------- - /// * `obs` – Borrowed observation container to render. - /// - /// Return - /// ---------- - /// * An `ObservationsDisplay` configured for the **Default** mode. - /// - /// See also - /// ------------ - /// * [`Self::wide`] – Enable the wide layout. - /// * [`Self::iso`] – Enable the ISO layout. - pub fn new(obs: &'a Observations) -> Self { - Self { - obs, - env: None, - mode: TableMode::Default, - sec_prec: 3, - dist_prec: 6, - sorted: false, - } - } - - /// Switch to **wide** mode (adds JD, radians, vector norms). - /// - /// Adds the following columns: - /// - `JD (TT)` - /// - `RA [rad]`, `DEC [rad]` - /// - `|r_geo| AU`, `|r_hel| AU` - /// - /// Arguments - /// ----------------- - /// * `yes` – If `true`, selects the **Wide** layout; otherwise resets to **Default**. - /// - /// Return - /// ---------- - /// * `Self` (builder style), allowing chained configuration. - /// - /// See also - /// ------------ - /// * [`ObservationsDisplayExt::table_wide`] - pub fn wide(mut self, yes: bool) -> Self { - self.mode = if yes { - TableMode::Wide - } else { - TableMode::Default - }; - self - } - - /// Switch to **ISO** mode (replace MJD/JD with ISO TT and ISO UTC columns). - /// - /// Replaces the time columns with: - /// - `ISO (TT)` — Gregorian breakdown on TT, - /// - `ISO (UTC)` — TT converted to UTC (leap seconds handled by `hifitime`). - /// - /// Return - /// ---------- - /// * `Self` (builder style), allowing chained configuration. - /// - /// Notes - /// ---------- - /// * ISO strings are produced via `hifitime`, using leap-second tables for UTC. - /// * This mode uses [`comfy-table`](https://docs.rs/comfy-table/latest/comfy_table/). - /// - /// See also - /// ------------ - /// * [`ObservationsDisplayExt::table_iso`] - pub fn iso(mut self) -> Self { - self.mode = TableMode::Iso; - self - } - - /// Set seconds precision for **sexagesimal** and **ISO** seconds. - /// - /// Arguments - /// ----------------- - /// * `p` – Number of fractional digits to render (typ. `0..=9`). - /// - /// Return - /// ---------- - /// * `Self` (builder style). - /// - /// Notes - /// ---------- - /// * Affects both RA/DEC seconds and ISO seconds. - pub fn with_seconds_precision(mut self, p: usize) -> Self { - self.sec_prec = p; - self - } - - /// Set decimal precision for **AU** distances (wide mode only). - /// - /// Arguments - /// ----------------- - /// * `p` – Fixed-point fractional digits for the `|r_geo|` and `|r_hel|` columns. - /// - /// Return - /// ---------- - /// * `Self` (builder style). - pub fn with_distance_precision(mut self, p: usize) -> Self { - self.dist_prec = p; - self - } - - /// Enable/disable **time-sorted** display. - /// - /// Arguments - /// ----------------- - /// * `yes` – If `true`, rows are sorted by `Observation::time` (MJD TT) ascending. - /// - /// Return - /// ---------- - /// * `Self` (builder style), allowing chained configuration. - /// - /// Notes - /// ---------- - /// * Sorting uses a **stable** index order (no reordering or cloning of observations). - /// * The `#` column prints the **original index** (pre-sort). - pub fn sorted(mut self) -> Self { - self.sorted = true; - self - } - - /// Attach an [`Outfit`] to resolve **observer names** in the `Site` column. - /// - /// If a name is available, rows show `"Name (#id)"`. Otherwise the numeric - /// site id is displayed. - /// - /// Example - /// ------- - /// ```rust,no_run - /// println!("{}", observations.table_iso().with_env(&env).sorted()); - /// ``` - /// - /// Arguments - /// ----------------- - /// * `env` – The [`Outfit`] environment to use for resolving observer names. - pub fn with_env(mut self, env: &'a Outfit) -> Self { - self.env = Some(env); - self - } - - /// Generate the label for the observer site of a given observation. - /// - /// Arguments - /// ----------------- - /// * `i` – Original index of the observation (pre-sort). - /// - /// Return - /// ----------------- - /// * A string label for the site, either `"Name (#id)"` or `"#id"`. - fn site_label(&self, i: usize) -> String { - if let Some(env) = self.env { - let site_id = self.obs[i].observer; - let site = env.get_observer_from_uint16(site_id); - if let Some(name) = site.name.as_deref() { - if !name.is_empty() { - return format!("{name} (#{site_id})"); - } - } - format!("Site ID #{site_id}") - } else { - // No environment: keep a compact ID for compatibility - format!("{}", self.obs[i].observer) - } - } - - /// Write the table header according to the selected mode. - fn write_header(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self.mode { - TableMode::Default => { - writeln!( - f, - "{:>3} {:>5} {:>14} {:>20} {:>20}", - "#", "Site", "MJD (TT)", "RA ±σ[arcsec]", "DEC ±σ[arcsec]" - ) - } - TableMode::Wide => { - writeln!( - f, - "{:>3} {:>5} {:>14} {:>14} {:>26} {:>11} {:>26} {:>11} {:>12} {:>12}", - "#", - "Site", - "MJD (TT)", - "JD (TT)", - "RA ±σ[arcsec]", - "RA [rad]", - "DEC ±σ[arcsec]", - "DEC [rad]", - "|r_geo| AU", - "|r_hel| AU" - ) - } - TableMode::Iso => { - writeln!( - f, - "{:>3} {:>5} {:>26} {:>26} {:>26} {:>26}", - "#", "Site", "ISO (TT)", "ISO (UTC)", "RA ±σ[arcsec]", "DEC ±σ[arcsec]" - ) - } - } - } - - /// Build an iterator over `(original_index, &Observation)` honoring the `sorted` flag. - /// - /// Return - /// ---------- - /// * A boxed iterator over `(index_before_sort, &Observation)`. - /// - /// Notes - /// ---------- - /// * When `sorted == true`, indices are ordered by MJD(TT) ascending, with a stable - /// tie-break on the original index. - fn row_iter(&self) -> Box + '_> { - if self.sorted { - use std::cmp::Ordering; - let mut order: Vec = (0..self.obs.len()).collect(); - order.sort_by(|&a, &b| { - let ta: f64 = self.obs[a].time; - let tb: f64 = self.obs[b].time; - match ta.partial_cmp(&tb) { - Some(ord) => ord, - None => Ordering::Equal, // NaN-safe - } - .then_with(|| a.cmp(&b)) - }); - Box::new(order.into_iter().map(|i| (i, &self.obs[i]))) - } else { - Box::new(self.obs.iter().enumerate()) - } - } - - /// Compute common formatted values once for a given row. - /// - /// Arguments - /// ----------------- - /// * `i` – Original index of the observation (pre-sort). - /// * `o` – Borrowed [`Observation`] for this row. - /// - /// Return - /// ---------- - /// * A populated [`RowFields`] struct reused by the row writer. - fn format_row_fields(&self, i: usize, o: &Observation) -> RowFields { - let sp = self.sec_prec; - - // Sexagesimal decomposition - let ra_rad: f64 = o.ra; - let dec_rad: f64 = o.dec; - let (hh, mm, ss) = ra_hms_prec(ra_rad, sp); - let (sgn, dd, dm, ds) = dec_sdms_prec(dec_rad, sp); - let ss_s = fmt_ss(ss, sp); - let ds_s = fmt_ss(ds, sp); - - // Uncertainties [arcsec] - let sra_as = o.error_ra * RAD2ARC; - let sdec_as = o.error_dec * RAD2ARC; - - // Common formatted strings - let ra_str = format!("{hh:02}h{mm:02}m{ss_s}s ± {sra_as:.3}\""); - let dec_str = format!("{sgn}{dd:02}°{dm:02}'{ds_s}\" ± {sdec_as:.3}\""); - - // Base time - let mjd_tt: f64 = o.time; - - // Mode-dependent extras (computed lazily below) - let (jd_tt, r_geo, r_hel, iso_tt, iso_utc) = match self.mode { - TableMode::Default => (None, None, None, None, None), - TableMode::Wide => { - let jd = mjd_tt + JDTOMJD; - let g = o.observer_earth_position.norm(); - let h = o.observer_helio_position.norm(); - (Some(jd), Some(g), Some(h), None, None) - } - TableMode::Iso => { - let epoch_tt = Epoch::from_mjd_in_time_scale(mjd_tt, TimeScale::TT); - let tt_str = iso_tt_from_epoch(epoch_tt, sp); - let utc_str = iso_utc_from_epoch(epoch_tt, sp); - (None, None, None, Some(tt_str), Some(utc_str)) - } - }; - - RowFields { - i, - site_label: self.site_label(i), - mjd_tt, - ra_rad, - dec_rad, - ra_str, - dec_str, - jd_tt, - r_geo, - r_hel, - iso_tt, - iso_utc, - } - } - - /// Render the WIDE table using comfy-table. - fn render_wide_comfy(&self) -> String { - let mut table = Table::new(); - table - .load_preset(UTF8_FULL) - .set_content_arrangement(ContentArrangement::Dynamic); - - // Header - table.set_header(vec![ - Cell::new("#"), - Cell::new("Site"), - Cell::new("MJD (TT)"), - Cell::new("JD (TT)"), - Cell::new("RA ±σ[arcsec]"), - Cell::new("RA [rad]"), - Cell::new("DEC ±σ[arcsec]"), - Cell::new("DEC [rad]"), - Cell::new("|r_geo| AU"), - Cell::new("|r_hel| AU"), - ]); - - // Rows - for (i, o) in self.row_iter() { - let r = self.format_row_fields(i, o); - let dp = self.dist_prec; - - table.add_row(Row::from(vec![ - Cell::new(r.i).set_alignment(CellAlignment::Right), - Cell::new(r.site_label).set_alignment(CellAlignment::Right), - Cell::new(format!("{:.6}", r.mjd_tt)).set_alignment(CellAlignment::Right), - Cell::new(format!("{:.6}", r.jd_tt.unwrap_or_default())) - .set_alignment(CellAlignment::Right), - Cell::new(r.ra_str.clone()).set_alignment(CellAlignment::Right), - Cell::new(format!("{:.7}", r.ra_rad)).set_alignment(CellAlignment::Right), - Cell::new(r.dec_str.clone()).set_alignment(CellAlignment::Right), - Cell::new(format!("{:.7}", r.dec_rad)).set_alignment(CellAlignment::Right), - Cell::new(format!("{:.*}", dp, r.r_geo.unwrap_or_default())) - .set_alignment(CellAlignment::Right), - Cell::new(format!("{:.*}", dp, r.r_hel.unwrap_or_default())) - .set_alignment(CellAlignment::Right), - ])); - } - - table.to_string() - } - - /// Render the ISO table using comfy-table. - fn render_iso_comfy(&self) -> String { - let mut table = Table::new(); - table - .load_preset(UTF8_FULL) - .set_content_arrangement(ContentArrangement::Dynamic); - - // Header - table.set_header(vec![ - Cell::new("#"), - Cell::new("Site"), - Cell::new("ISO (TT)"), - Cell::new("ISO (UTC)"), - Cell::new("RA ±σ[arcsec]"), - Cell::new("DEC ±σ[arcsec]"), - ]); - - // Rows - for (i, o) in self.row_iter() { - let r = self.format_row_fields(i, o); - table.add_row(Row::from(vec![ - Cell::new(r.i).set_alignment(CellAlignment::Right), - Cell::new(r.site_label).set_alignment(CellAlignment::Right), - Cell::new(r.iso_tt.as_deref().unwrap_or("")).set_alignment(CellAlignment::Right), - Cell::new(r.iso_utc.as_deref().unwrap_or("")).set_alignment(CellAlignment::Right), - Cell::new(r.ra_str.clone()).set_alignment(CellAlignment::Right), - Cell::new(r.dec_str.clone()).set_alignment(CellAlignment::Right), - ])); - } - - table.to_string() - } - - /// Write a single table row using pre-computed [`RowFields`]. - /// - /// Arguments - /// ----------------- - /// * `f` – Destination formatter. - /// * `r` – Pre-formatted row fields. - fn write_row(&self, f: &mut fmt::Formatter<'_>, r: &RowFields) -> fmt::Result { - match self.mode { - TableMode::Default => { - writeln!( - f, - "{i:>3} {site:>5} {mjd:>14.6} {ra:>20} {dec:>20}", - i = r.i, - site = r.site_label, - mjd = r.mjd_tt, - ra = r.ra_str, - dec = r.dec_str - ) - } - TableMode::Wide => { - let dp = self.dist_prec; - writeln!( - f, - "{i:>3} {site:>5} {mjd:>14.6} {jd:>14.6} {ra:>20} {ra_rad:>11.7} {dec:>20} {dec_rad:>11.7} {rgeo:>12.dp$} {rhel:>12.dp$}", - i = r.i, - site = r.site_label, - mjd = r.mjd_tt, - jd = r.jd_tt.unwrap_or_default(), - ra = r.ra_str, - ra_rad = r.ra_rad, - dec = r.dec_str, - dec_rad = r.dec_rad, - rgeo = r.r_geo.unwrap_or_default(), - rhel = r.r_hel.unwrap_or_default(), - dp = dp - ) - } - TableMode::Iso => { - writeln!( - f, - "{i:>3} {site:>5} {iso_tt:>26} {iso_utc:>26} {ra:>20} {dec:>20}", - i = r.i, - site = r.site_label, - iso_tt = r.iso_tt.as_deref().unwrap_or(""), - iso_utc = r.iso_utc.as_deref().unwrap_or(""), - ra = r.ra_str, - dec = r.dec_str - ) - } - } - } -} - -/// Ergonomic extension to create table adaptors from an [`Observations`] collection. -/// -/// Provided builders -/// ----------------- -/// * [`ObservationsDisplayExt::show`] – Default compact table. -/// * [`ObservationsDisplayExt::table_wide`] – Wide table with diagnostics. -/// * [`ObservationsDisplayExt::table_iso`] – ISO-centric table (TT + UTC). -/// -/// Examples -/// ---------- -/// ```rust,ignore -/// println!("{}", observations.show()); // Default -/// println!("{}", observations.table_wide()); // Wide -/// println!("{}", observations.table_iso()); // ISO -/// println!("{}", observations.show().with_seconds_precision(4)); -/// ``` -pub trait ObservationsDisplayExt { - /// Wide table (adds JD, RA/DEC in radians, vector norms in AU). - /// - /// Return - /// ---------- - /// * A configured [`ObservationsDisplay`] in **Wide** mode. - fn table_wide(&self) -> ObservationsDisplay<'_>; - - /// ISO table (replaces MJD/JD with `ISO (TT)` and `ISO (UTC)`). - /// - /// Return - /// ---------- - /// * A configured [`ObservationsDisplay`] in **ISO** mode. - fn table_iso(&self) -> ObservationsDisplay<'_>; - - /// Create a zero-allocation display adaptor (Default/compact mode). - /// - /// Examples - /// ---------- - /// ```rust,ignore - /// // Compact table - /// println!("{}", observations.show()); - /// - /// // Derive other modes from it (builder style) - /// println!("{}", observations.show().wide(true)); // Wide - /// println!("{}", observations.show().iso()); // ISO - /// ``` - fn show(&self) -> ObservationsDisplay<'_>; - - /// Convenience: return a formatted `String` in **compact** mode. - /// - /// Return - /// ---------- - /// * An owned `String` containing the compact table. - fn show_string(&self) -> String { - format!("{}", self.show()) - } -} - -impl ObservationsDisplayExt for Observations { - fn table_wide(&self) -> ObservationsDisplay<'_> { - ObservationsDisplay::new(self).wide(true) - } - fn table_iso(&self) -> ObservationsDisplay<'_> { - ObservationsDisplay::new(self).iso() - } - fn show(&self) -> ObservationsDisplay<'_> { - ObservationsDisplay::new(self) - } -} - -impl fmt::Display for ObservationsDisplay<'_> { - /// Render the table according to the selected mode. - /// - /// Notes - /// ---------- - /// * Rows are printed in the **current order** unless `sorted(true)` is set. - /// * When sorted, ordering is by MJD(TT) ascending, with ties broken by original index. - /// * Numeric fields are right-aligned; headers have fixed widths for readability. - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let n = self.obs.len(); - writeln!(f, "Observations (n={n})")?; - writeln!(f, "-------------------")?; - - match self.mode { - TableMode::Wide => { - // comfy-table rendering - let out = self.render_wide_comfy(); - f.write_str(&out)?; - } - TableMode::Iso => { - // comfy-table rendering - let out = self.render_iso_comfy(); - f.write_str(&out)?; - } - TableMode::Default => { - // Legacy compact mode: keep your existing fixed-width rendering - self.write_header(f)?; - for (i, o) in self.row_iter() { - let row = self.format_row_fields(i, o); - self.write_row(f, &row)?; - } - } - } - Ok(()) - } -} - -#[cfg(test)] -mod observation_display_tests { - use super::*; - use crate::observations::display::ObservationsDisplayExt; - use crate::Observations; - use nalgebra::Vector3; - - /// Build a minimal Observation for tests. - /// Angles are provided in degrees; uncertainties in arcseconds; time is MJD (TT). - fn make_obs( - site: u16, - ra_deg: f64, - dec_deg: f64, - err_arcsec: f64, - mjd_tt: f64, - rgeo: (f64, f64, f64), - rhel: (f64, f64, f64), - ) -> Observation { - // Convert helpers - let ra_rad = ra_deg.to_radians(); - let dec_rad = dec_deg.to_radians(); - let err_rad = (err_arcsec / 3600.0).to_radians(); - - Observation { - observer: site, - ra: ra_rad, // Radian from f64 - error_ra: err_rad, // Radian from f64 - dec: dec_rad, // Radian from f64 - error_dec: err_rad, // Radian from f64 - time: mjd_tt, // MJD from f64 - observer_earth_position: Vector3::new(rgeo.0, rgeo.1, rgeo.2), - observer_helio_position: Vector3::new(rhel.0, rhel.1, rhel.2), - } - } - - /// Build a small, heterogeneous set of observations for table tests. - fn sample_observations() -> Observations { - let mut obs: Observations = Observations::default(); - // idx = 0 (later than #1), zero vectors to simplify wide-mode distance checks - obs.push(make_obs( - 809, - 0.0, - 0.0, - 1.0, - 60000.123456, - (0.0, 0.0, 0.0), - (0.0, 0.0, 0.0), - )); - // idx = 1 (earliest), negative DEC to verify sign & DMS formatting - obs.push(make_obs( - 2, - 0.0, - -10.0, - 0.5, - 59000.0, - (0.0, 0.0, 0.0), - (0.0, 0.0, 0.0), - )); - // idx = 2 (latest), non-zero vectors to exercise wide-mode distances (1 and 2 AU) - obs.push(make_obs( - 500, - 180.0, - 20.0, - 1.2, - 60001.0, - (1.0, 0.0, 0.0), - (0.0, 2.0, 0.0), - )); - obs - } - - #[test] - fn default_headers_and_basic_format() { - let obs = sample_observations(); - let s = format!("{}", obs.show()); // Default, unsorted - - // Headers present - assert!(s.contains("MJD (TT)")); - assert!(s.contains("RA ±σ[arcsec]")); - assert!(s.contains("DEC ±σ[arcsec]")); - - // RA/DEC sexagesimal + uncertainties for idx 0 (RA=0h, DEC=+0°) - // Cells do not include 'RA=' / 'DEC=' prefixes in the table. - assert!(s.contains("00h00m00.000s ± 1.000\"")); - assert!(s.contains("+00°00'00.000\" ± 1.000\"")); - } - - #[test] - fn dec_negative_sign_is_preserved_in_table() { - let obs = sample_observations(); - let s = format!("{}", obs.show()); // Default, unsorted - // idx 1 has DEC = -10° (no 'DEC=' prefix in table cells) - assert!( - s.contains("-10°00'00.000\""), - "Negative DEC sign or DMS formatting incorrect: {s}" - ); - // And its sigma is 0.500" - assert!(s.contains("± 0.500\"")); - } - - #[test] - fn sorted_orders_by_time_and_keeps_original_index() { - let obs = sample_observations(); - let s = format!("{}", obs.show().sorted()); - - // Split lines: title, underline, header, then data rows... - let mut lines = s.lines(); - let _title = lines.next().unwrap_or_default(); - let _rule = lines.next().unwrap_or_default(); - let _hdr = lines.next().unwrap_or_default(); - - // First data row should correspond to the earliest epoch (idx = 1) - let first_row = lines.next().unwrap_or_default(); - assert!( - first_row.trim_start().starts_with("1"), - "Expected first printed row to be original index 1, got: {first_row}" - ); - // A later row should include index 0 (natural order changed by sort) - let rest = lines.collect::>().join("\n"); - assert!( - rest.contains("\n 0") || rest.starts_with(" 0"), - "Index 0 should also appear." - ); - } - - #[test] - fn wide_mode_headers_and_radians_and_distances() { - let obs = sample_observations(); - let s = format!("{}", obs.table_wide()); // Wide, unsorted - - // Headers present - assert!(s.contains("JD (TT)")); - assert!(s.contains("RA [rad]")); - assert!(s.contains("DEC [rad]")); - assert!(s.contains("|r_geo| AU")); - assert!(s.contains("|r_hel| AU")); - - // idx 1 has DEC = -10° => about -0.1745329 rad (7 decimals printed) - assert!( - s.contains("-0.1745329"), - "Expected DEC in radians around -0.1745329 rad in wide mode: {s}" - ); - - // idx 0 vectors are zeros => distances should include 0.000000 - assert!( - s.contains(" 0.000000"), - "Expected at least one zero distance in wide mode for idx 0: {s}" - ); - - // idx 2 vectors norms are 1 AU and 2 AU - assert!( - s.contains(" 1.000000") && s.contains(" 2.000000"), - "Expected distances 1.000000 and 2.000000 AU for idx 2: {s}" - ); - } - - #[test] - fn iso_mode_headers_and_suffixes() { - let obs = sample_observations(); - let s = format!("{}", obs.table_iso()); // ISO, unsorted - - // Headers present - assert!(s.contains("ISO (TT)")); - assert!(s.contains("ISO (UTC)")); - - // Body should contain ' TT' and 'Z' suffixes (at least once) - assert!(s.contains(" TT"), "TT suffix missing in ISO TT column: {s}"); - assert!(s.contains('Z'), "Z suffix missing in ISO UTC column: {s}"); - } - - #[test] - fn seconds_and_distance_precision_knobs() { - let obs = sample_observations(); - - // Seconds precision = 4: "00.0000" for RA seconds at 0h (no 'RA=' prefix in table cells) - let s_iso = format!("{}", obs.table_iso().with_seconds_precision(4)); - assert!( - s_iso.contains("00h00m00.0000s"), - "Seconds precision not applied: {s_iso}" - ); - - // Distance precision = 4: look for " 0.0000" in wide mode (idx 0 has zero vectors) - let s_wide = format!("{}", obs.table_wide().with_distance_precision(4)); - assert!( - s_wide.contains(" 0.0000"), - "Distance precision not applied (expected 4 decimals): {s_wide}" - ); - } - - #[test] - fn show_string_matches_display_default() { - let obs = sample_observations(); - let s1 = obs.show_string(); - let s2 = format!("{}", obs.show()); - assert_eq!(s1, s2, "show_string() must match Display in default mode"); - } -} diff --git a/src/observations/mod.rs b/src/observations/mod.rs deleted file mode 100644 index 10168d1..0000000 --- a/src/observations/mod.rs +++ /dev/null @@ -1,1498 +0,0 @@ -//! # Observations: ingestion, representation, and sky-projection utilities -//! -//! This module defines the core types and helpers to **ingest**, **store**, and **use** -//! optical astrometric observations for orbit determination workflows. -//! -//! ## What lives here? -//! -//! - [`Observation`](crate::observations::Observation) — a single astrometric measurement (RA/DEC at an epoch) with: -//! - the observing site identifier (`u16`), -//! - precomputed **geocentric** and **heliocentric** site positions at the epoch, -//! - astrometric uncertainties for RA/DEC. -//! -//! - Parsing & I/O: -//! - `from_80col` (private) and `extract_80col` (private) — read **80-column MPC** formatted files. -//! - [`ades_reader`](crate::trajectories::ades_reader) — ADES ingestion utilities (XML/CSV). -//! - `parquet_reader` (private) — internal helpers to read columnar batches. -//! -//! - Batch/transform helpers: -//! - [`trajectory_file`](crate::trajectories::trajectory_file) — build batches of observations (RA/DEC/time + σ) and convert to [`Observation`](crate::observations::Observation)s. -//! - [`observations_ext`](crate::observations::observations_ext) — higher-level operations on collections (triplet selection, RMS windows, metrics). -//! - [`triplets_iod`](crate::observations::triplets_iod) — construction of observation triplets for **Gauss IOD**. -//! -//! ## Units & reference frames -//! -//! - **Angles**: radians -//! - **Time**: MJD (TT scale) -//! - **Positions**: AU, **equatorial mean J2000** (J2000/ICRS-aligned) -//! -//! These conventions are enforced by [`Observation::new`](crate::observations::Observation::new), which computes and stores both -//! the **geocentric** and **heliocentric** site positions at the observation epoch using the -//! [`Outfit`](crate::outfit::Outfit) environment (UT1 provider, JPL ephemerides, site database). -//! -//! ## Typical workflow -//! -//! 1. **Ingest** observations: -//! - From MPC 80-col: \[`extract_80col`\] → `Vec` + object identifier. -//! - From ADES: via [`ades_reader`](crate::trajectories::ades_reader) into typed batches, then \[`observation_from_batch`\]. -//! -//! 2. **Precompute/Access positions** per observation: -//! - `get_observer_earth_position()` — geocentric site vector at epoch. -//! - `get_observer_helio_position()` — heliocentric site vector at epoch. -//! -//! 3. **Project to sky** (prediction / fitting): -//! - [`Observation::compute_apparent_position`](crate::observations::Observation::compute_apparent_position) — propagate an orbit (equinoctial elements), -//! apply frame transforms + aberration, and return apparent `(RA, DEC)`. -//! - [`Observation::ephemeris_error`](crate::observations::Observation::ephemeris_error) — normalized squared residual for a single observation. -//! -//! 4. **Build triplets and run IOD**: -//! - Use [`observations_ext`](crate::observations::observations_ext) / [`triplets_iod`](crate::observations::triplets_iod) to form high-quality triplets and feed -//! them to the Gauss solver (see `initial_orbit_determination::gauss`). -//! -//! ## Key types & functions -//! -//! - [`Observation`](crate::observations::Observation) — single measurement with site & precomputed positions. -//! - [`Observation::compute_apparent_position`](crate::observations::Observation::compute_apparent_position) — apparent `(RA, DEC)` from an orbit. -//! - [`Observation::ephemeris_error`](crate::observations::Observation::ephemeris_error) — per-observation χ²-like contribution. -//! -//! ## Example -//! -//! ```rust,no_run -//! use outfit::observations::Observation; -//! use outfit::outfit::Outfit; -//! use outfit::error_models::ErrorModel; -//! use outfit::constants::RADSEC; -//! -//! # use outfit::orbit_type::equinoctial_element::EquinoctialElements; -//! -//! let mut env = Outfit::new("horizon:DE440", ErrorModel::FCCT14)?; -//! -//! // Example: build one Observation manually -//! let obs = Observation::new( -//! &env, -//! 0, -//! 1.234, // RA [rad] -//! 0.5 * RADSEC, // σ_RA [rad] -//! 0.567, // DEC [rad] -//! 0.5 * RADSEC, // σ_DEC [rad] -//! 60300.0, // MJD (TT) -//! )?; -//! -//! // Predict apparent position for this observation given an orbit -//! # let eq: EquinoctialElements = unimplemented!(); -//! # let (_ra, _dec) = obs.compute_apparent_position(&env, &eq)?; -//! # Ok::<(), outfit::outfit_errors::OutfitError>(()) -//! ``` -//! -//! ## See also -//! -//! - [`initial_orbit_determination::gauss`] — Gauss IOD over observation triplets. -//! - [`observers`] — site database, Earth-fixed coordinates, and transformations. -//! - [`orbit_type::equinoctial_element::EquinoctialElements`] — propagation utilities used here. -//! - [`cartesian_to_radec`](crate::conversion::cartesian_to_radec) and [`correct_aberration`](crate::observations::correct_aberration) — sky-projection helpers. -pub mod display; -pub mod observations_ext; -pub mod triplets_generator; -pub mod triplets_iod; - -use crate::{ - constants::{Observations, Radian, DPI, JDTOMJD, MJD, RAD2ARC, VLIGHT_AU}, - conversion::{cartesian_to_radec, dec_sdms_prec, fmt_vec3_au, ra_hms_prec}, - observers::Observer, - orbit_type::equinoctial_element::EquinoctialElements, - outfit::Outfit, - outfit_errors::OutfitError, - time::{fmt_ss, iso_tt_from_epoch, iso_utc_from_epoch}, -}; -use hifitime::{Epoch, TimeScale}; -use nalgebra::Vector3; -use std::{f64::consts::PI, fmt}; - -/// Astrometric observation with site and precomputed observer positions. -/// -/// This structure represents a single optical astrometric measurement -/// (right ascension/declination at a given epoch) together with: -/// - the associated observing site identifier, -/// - the observer’s **geocentric** position vector at the epoch, and -/// - the observer’s **heliocentric** position vector at the epoch. -/// -/// Units & frames: -/// - Angles are stored in **radians**. -/// - Times are stored as **MJD (TT scale)**. -/// - Position vectors are expressed in **AU**, in the **equatorial mean J2000** frame. -/// -/// Fields -/// ----------------- -/// * `observer` – Site identifier (`u16`) referencing an [`Observer`] known by the [`Outfit`] state. -/// * `ra` – Right ascension `[rad]`. -/// * `error_ra` – Uncertainty on right ascension `[rad]`. -/// * `dec` – Declination `[rad]`. -/// * `error_dec` – Uncertainty on declination `[rad]`. -/// * `time` – Observation epoch as MJD (TT scale). -/// * `observer_earth_position` – Geocentric position of the observer at `time` (AU, equatorial mean J2000). -/// * `observer_helio_position` – Heliocentric position of the observer at `time` (AU, equatorial mean J2000). -#[derive(Debug, Clone, PartialEq, Copy)] -pub struct Observation { - pub(crate) observer: u16, - pub ra: Radian, - pub error_ra: Radian, - pub dec: Radian, - pub error_dec: Radian, - pub time: MJD, - pub(crate) observer_earth_position: Vector3, - pub(crate) observer_helio_position: Vector3, -} - -impl Observation { - /// Create a new astrometric observation and precompute observer positions. - /// - /// This constructor stores the astrometric angles and time, and computes the observer’s - /// **geocentric** and **heliocentric** position vectors at the same epoch using the - /// provided [`Outfit`] environment (UT1 provider, ephemerides, and site metadata). - /// - /// Arguments - /// ----------------- - /// * `state` – Global environment providing ephemerides, UT1 provider and site database. - /// * `observer` – Site identifier (`u16`) referencing an [`Observer`] known by `state`. - /// * `ra` – Right ascension `[rad]`. - /// * `error_ra` – Uncertainty on right ascension `[rad]`. - /// * `dec` – Declination `[rad]`. - /// * `error_dec` – Uncertainty on declination `[rad]`. - /// * `time` – Observation epoch as **MJD (TT scale)**. - /// - /// Return - /// ---------- - /// * A `Result` with the newly created [`Observation`], or an [`OutfitError`] if: - /// - the observer cannot be resolved in `state`, - /// - the UT1 provider / ephemeris computation fails. - /// - /// Remarks - /// ------------ - /// * `pvobs` computes the geocentric position (and velocity) of the observer from Earth rotation and site coordinates. - /// * `helio_position` converts the geocentric position to the heliocentric frame using the selected JPL ephemeris. - /// * Both positions are expressed in **AU**, **equatorial mean J2000**. - /// - /// See also - /// ------------ - /// * [`Observer::pvobs`] – Geocentric position/velocity of the observing site. - /// * [`Observer::helio_position`] – Heliocentric position of the observing site. - /// * [`crate::trajectories::batch_reader::ObservationBatch`] – Batch operations on observations. - pub fn new( - state: &Outfit, - observer: u16, - ra: Radian, - error_ra: Radian, - dec: Radian, - error_dec: Radian, - time: MJD, - ) -> Result { - // Observation time in TT - let obs_mjd = Epoch::from_mjd_in_time_scale(time, hifitime::TimeScale::TT); - let obs = state.get_observer_from_uint16(observer); - let (geo_obs_pos, _) = obs.pvobs(&obs_mjd, state.get_ut1_provider())?; - let helio_obs_pos = obs.helio_position(state, &obs_mjd, &geo_obs_pos)?; - - Ok(Observation { - observer, - ra, - error_ra, - dec, - error_dec, - time, - observer_earth_position: geo_obs_pos, - observer_helio_position: helio_obs_pos, - }) - } - - /// Construct an [`Observation`] from precomputed observer positions. - /// - /// This constructor is a **fast-path alternative** to [`Observation::new`]: - /// it skips all ephemeris calls by directly injecting the observer’s - /// geocentric and heliocentric positions. This is useful in ingestion - /// pipelines (e.g. Parquet readers) where positions can be cached and - /// reused for multiple observations sharing the same `(observer, time)`. - /// - /// Arguments - /// ----------------- - /// * `observer` – Packed observer identifier (`u16`). - /// * `ra`, `error_ra` – Right ascension in radians and its 1-σ uncertainty (radians). - /// * `dec`, `error_dec` – Declination in radians and its 1-σ uncertainty (radians). - /// * `time` – Observation epoch in Modified Julian Date (TT scale). - /// * `observer_earth_position` – Geocentric observer position vector (AU, equatorial mean J2000). - /// * `observer_helio_position` – Heliocentric observer position vector (AU, equatorial mean J2000). - /// - /// Return - /// ---------- - /// * A fully initialized [`Observation`] where astrometric quantities are set - /// and observer positions are trusted to be externally consistent. - /// - /// Remarks - /// ------------ - /// * Use this constructor only when you can guarantee that positions were - /// computed consistently with the same environment (`Outfit`, UT1, ephemerides). - /// * All getter methods behave identically to those of [`Observation::new`]. - /// - /// See also - /// ------------ - /// * [`Observation::new`] – Computes positions internally (slower, but self-contained). - /// * [`Observer::pvobs`] – Routine for geocentric position/velocity. - /// * [`Observer::helio_position`] – Routine for heliocentric position. - #[allow(clippy::too_many_arguments)] - pub fn with_positions( - observer: u16, - ra: Radian, - error_ra: Radian, - dec: Radian, - error_dec: Radian, - time: MJD, - observer_earth_position: Vector3, - observer_helio_position: Vector3, - ) -> Self { - Self { - observer, - ra, - error_ra, - dec, - error_dec, - time, - observer_earth_position, - observer_helio_position, - } - } - - /// Get the observer heliocentric position at the observation epoch. - /// - /// Arguments - /// ----------------- - /// * *(none)* – Accessor method. - /// - /// Return - /// ---------- - /// * A copy of the `3D` position vector (AU, equatorial mean J2000) of the observer - /// at `self.time` (MJD TT). - /// - /// See also - /// ------------ - /// * [`Observation::new`] – Computes and stores this vector at construction. - /// * [`Observer::helio_position`] – Underlying routine used to compute the value. - pub fn get_observer_helio_position(&self) -> Vector3 { - self.observer_helio_position - } - - /// Get the observer geocentric position at the observation epoch. - /// - /// Arguments - /// ----------------- - /// * *(none)* – Accessor method. - /// - /// Return - /// ---------- - /// * A copy of the `3D` position vector (AU, equatorial mean J2000) of the observer - /// relative to the Earth’s center at `self.time` (MJD TT). - /// - /// Remarks - /// ------------ - /// * This vector is computed at construction via [`Observer::pvobs`]. - /// * Units are astronomical units (AU), in the equatorial mean J2000 frame. - /// - /// See also - /// ------------ - /// * [`Observation::new`] – Computes and stores this vector at construction. - /// * [`Observer::pvobs`] – Underlying routine used to compute the value. - pub fn get_observer_earth_position(&self) -> Vector3 { - self.observer_earth_position - } - - /// Get the observer from the observation - /// - /// Arguments - /// --------- - /// * `env_state`: a mutable reference to the Outfit instance - /// - /// Return - /// ------ - /// * The observer - pub fn get_observer<'a>(&self, env_state: &'a Outfit) -> &'a Observer { - env_state.get_observer_from_uint16(self.observer) - } - - /// Compute the apparent equatorial coordinates (RA, DEC) of a solar system body - /// as seen by this observation’s site at its epoch. - /// - /// Overview - /// ----------------- - /// This method determines the apparent sky position of a target body, - /// described by equinoctial orbital elements, as seen from the observing site - /// corresponding to this [`Observation`]. - /// - /// The computation steps are: - /// 1. **Orbit propagation** – Propagate the body’s state from its reference epoch to the observation epoch using a two-body model. - /// 2. **Reference frame handling** – Retrieve Earth’s barycentric position from the JPL ephemeris and transform to *ecliptic mean J2000*. - /// 3. **Observer position** – Compute the observer’s heliocentric position (Earth + site geocentric offset). - /// 4. **Light-time and aberration correction** – Form the observer–object vector and correct for aberration. - /// 5. **Conversion to equatorial coordinates** – Convert the corrected line-of-sight vector to (RA, DEC). - /// - /// Arguments - /// ----------------- - /// * `state` – Global environment providing ephemerides, UT1 provider, and frame utilities. - /// * `equinoctial_element` – Orbital elements of the target body. - /// - /// Return - /// ---------- - /// * `Result<(f64, f64), OutfitError>` – The apparent right ascension and declination `[rad]`. - /// - /// Units - /// ---------- - /// * Positions: AU - /// * Velocities: AU/day - /// * Angles: radians - /// * Time: MJD TT - /// - /// Errors - /// ---------- - /// Returns [`OutfitError`] if: - /// - Orbit propagation fails, - /// - Ephemeris data is unavailable, - /// - Reference-frame transformation fails. - /// - /// See also - /// ------------ - /// * [`EquinoctialElements::solve_two_body_problem`] – Orbit propagation. - /// * [`Observer::pvobs`] – Computes observer’s geocentric position. - /// * [`correct_aberration`] – Aberration correction. - /// * [`cartesian_to_radec`] – Convert Cartesian vectors to (RA, DEC). - pub fn compute_apparent_position( - &self, - state: &Outfit, - equinoctial_element: &EquinoctialElements, - ) -> Result<(f64, f64), OutfitError> { - // Hyperbolic/parabolic orbits (e >= 1) are not yet supported - if equinoctial_element.eccentricity() >= 1.0 { - return Err(OutfitError::InvalidOrbit( - "Eccentricity >= 1 is not yet supported".to_string(), - )); - } - - // 1. Propagate asteroid position/velocity in ecliptic J2000 - let (cart_pos_ast, cart_pos_vel, _) = equinoctial_element.solve_two_body_problem( - 0., - self.time - equinoctial_element.reference_epoch, - false, - )?; - - // 2. Observation time in TT - let obs_mjd = Epoch::from_mjd_in_time_scale(self.time, hifitime::TimeScale::TT); - - // 3. Earth's barycentric position in ecliptic J2000 - let (earth_position, _) = state.get_jpl_ephem()?.earth_ephemeris(&obs_mjd, false); - - // 4. get rotation from equatorial mean J2000 to ecliptic mean J2000 - let matrix_elc_transform = state.get_rot_equmj2000_to_eclmj2000(); - - let earth_pos_eclj2000 = matrix_elc_transform.transpose() * earth_position; - let cart_pos_ast_eclj2000 = matrix_elc_transform * cart_pos_ast; - let cart_pos_vel_eclj2000 = matrix_elc_transform * cart_pos_vel; - - // 5. Observer heliocentric position - let geo_obs_pos = self.observer_earth_position; - let xobs = geo_obs_pos + earth_pos_eclj2000; - let obs_on_earth = matrix_elc_transform * xobs; - - // 6. Relative position and aberration correction - let relative_position = cart_pos_ast_eclj2000 - obs_on_earth; - let corrected_pos = correct_aberration(relative_position, cart_pos_vel_eclj2000); - - // 7. Convert to RA/DEC - let (alpha, delta, _) = cartesian_to_radec(corrected_pos); - Ok((alpha, delta)) - } - - /// Compute the normalized squared astrometric residuals (RA, DEC) - /// between an observed position and a propagated ephemeris. - /// - /// Overview - /// ----------------- - /// This method compares the actual astrometric measurement stored in `self` - /// against the expected position of the target body propagated from - /// equinoctial elements. - /// It returns a scalar representing the sum of squared, normalized residuals - /// in RA and DEC. - /// - /// Arguments - /// ----------------- - /// * `state` – Global environment providing ephemerides and time conversions. - /// * `equinoctial_element` – Orbital elements of the target body. - /// - /// Return - /// ---------- - /// * `Result` – Dimensionless scalar value representing the weighted sum - /// of squared residuals. Equivalent to a chi² contribution for a single observation (without division by 2). - /// - /// Remarks - /// ---------- - /// * Residuals are normalized by the astrometric uncertainties `error_ra` and `error_dec`. - /// * RA residuals are multiplied by `cos(dec)` to account for projection effects. - /// * All angles are in radians. - /// - /// Errors - /// ---------- - /// Returns [`OutfitError`] if propagation or ephemeris lookup fails. - /// - /// See also - /// ------------ - /// * [`compute_apparent_position`](crate::observations::Observation::compute_apparent_position) – Used internally to obtain predicted RA/DEC. - /// * [`Observer::pvobs`] – Computes observer’s geocentric position. - /// * [`correct_aberration`] – Applies aberration correction. - /// * [`cartesian_to_radec`] – Converts 3D vectors to (RA, DEC). - /// * [`EquinoctialElements::solve_two_body_problem`] – Two-body propagation. - pub fn ephemeris_error( - &self, - state: &Outfit, - equinoctial_element: &EquinoctialElements, - ) -> Result { - let (alpha, delta) = self.compute_apparent_position(state, equinoctial_element)?; - - // ΔRA with wrapping to [-π, π] - let mut diff_alpha = (self.ra - alpha) % DPI; - if diff_alpha > PI { - diff_alpha -= DPI; - } - - let diff_delta = self.dec - delta; - - // Weighted RMS - let rms_ra = (self.dec.cos() * (diff_alpha / self.error_ra)).powi(2); - let rms_dec = (diff_delta / self.error_dec).powi(2); - - Ok(rms_ra + rms_dec) - } -} - -/// Apply stellar aberration correction to a relative position vector. -/// -/// This function computes the apparent position of a target object by applying -/// the first-order correction for stellar aberration due to the observer's velocity. -/// It assumes the classical limit (v ≪ c), using a linear time-delay model. -/// -/// Arguments -/// --------- -/// * `xrel`: relative position vector from observer to object \[AU\]. -/// * `vrel`: velocity of the observer relative to the barycenter \[AU/day\]. -/// -/// Returns -/// -------- -/// * Corrected position vector (same units and directionality as `xrel`), -/// shifted by the aberration effect. -/// -/// Formula -/// ------- -/// The corrected position is given by: -/// ```text -/// x_corr = xrel − (‖xrel‖ / c) · vrel -/// ``` -/// where `c` is the speed of light in AU/day (`VLIGHT_AU`). -/// -/// Remarks -/// ------- -/// * This function does **not** normalize the output. -/// * Suitable for use in astrometric modeling or when computing apparent direction -/// of celestial objects as seen from a moving observer. -pub fn correct_aberration(xrel: Vector3, vrel: Vector3) -> Vector3 { - let norm_vector = xrel.norm(); - let dt = norm_vector / VLIGHT_AU; - xrel - dt * vrel -} - -impl fmt::Display for Observation { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - // Extract numeric values (adapt if Radian/MJD are newtypes). - let ra_rad: f64 = self.ra; - let dec_rad: f64 = self.dec; - let sra_as: f64 = self.error_ra * RAD2ARC; - let sdec_as: f64 = self.error_dec * RAD2ARC; - let mjd_tt: f64 = self.time; - let jd_tt = mjd_tt + JDTOMJD; - - // Formatting precisions - let sec_prec = 3; - let pos_prec = 6; - - // Sexagesimal angles with carry-safe rounding - let (rh, rm, rs) = ra_hms_prec(ra_rad, sec_prec); - let (sgn, dd, dm, ds) = dec_sdms_prec(dec_rad, sec_prec); - let rs_s = fmt_ss(rs, sec_prec); - let ds_s = fmt_ss(ds, sec_prec); - - if f.alternate() { - // Pretty multi-line variant with {:#} - let site = self.observer; - let r_geo = fmt_vec3_au(&self.observer_earth_position, pos_prec); - let r_hel = fmt_vec3_au(&self.observer_helio_position, pos_prec); - - // Build TT epoch and derive both TT ISO & UTC ISO via hifitime. - let epoch_tt = Epoch::from_mjd_in_time_scale(mjd_tt, TimeScale::TT); - let iso_tt = iso_tt_from_epoch(epoch_tt, sec_prec); - let iso_utc = iso_utc_from_epoch(epoch_tt, sec_prec); - - writeln!(f, "Astrometric observation")?; - writeln!(f, "----------------------")?; - writeln!(f, "Site ID : {site}")?; - writeln!(f, "Epoch (TT) : MJD {mjd_tt:.6}, JD {jd_tt:.6}")?; - writeln!(f, "Epoch (ISO TT) : {iso_tt}")?; - writeln!(f, "Epoch (ISO UTC): {iso_utc}")?; - writeln!( - f, - "RA / σ : {rh:02}h {rm:02}m {rs_s}s (σ = {sra_as:.3}\" )" - )?; - writeln!( - f, - "DEC / σ : {sgn}{dd:02}° {dm:02}' {ds_s}\" (σ = {sdec_as:.3}\" )" - )?; - writeln!(f, "Observer (geo) : {r_geo}")?; - writeln!(f, "Observer (hel) : {r_hel}") - } else { - // Compact single line — keep the original contract so existing tests still pass. - let site = self.observer; - let r_geo = fmt_vec3_au(&self.observer_earth_position, pos_prec); - let r_hel = fmt_vec3_au(&self.observer_helio_position, pos_prec); - - write!( - f, - "Obs(site={site}, MJD={mjd_tt:.6} TT, RA={rh:02}h{rm:02}m{rs_s}s ± {sra_as:.3}\", \ -DEC={sgn}{dd:02}°{dm:02}'{ds_s}\" ± {sdec_as:.3}\", r_geo={r_geo}, r_hel={r_hel})" - ) - } - } -} - -#[cfg(test)] -#[cfg(feature = "jpl-download")] -mod test_observations { - - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - use super::*; - - mod tests_compute_apparent_position { - - use super::*; - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - use approx::assert_relative_eq; - - /// Helper: simple circular equinoctial elements for a 1 AU, zero inclination orbit. - fn simple_circular_elements(epoch: f64) -> EquinoctialElements { - EquinoctialElements { - reference_epoch: epoch, - semi_major_axis: 1.0, - eccentricity_sin_lon: 0.0, - eccentricity_cos_lon: 0.0, - tan_half_incl_sin_node: 0.0, - tan_half_incl_cos_node: 0.0, - mean_longitude: 0.0, - } - } - - #[test] - fn test_compute_apparent_position_nominal() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let t_obs = 59000.0; // MJD - let equinoctial = simple_circular_elements(t_obs); - - let obs = Observation::new(state, observer_code, 0.0, 0.0, 0.0, 0.0, t_obs).unwrap(); - - let (ra, dec) = obs - .compute_apparent_position(state, &equinoctial) - .expect("Computation should succeed"); - - assert!(ra.is_finite()); - assert!(dec.is_finite()); - assert!((0.0..2.0 * std::f64::consts::PI).contains(&ra)); - assert!((-std::f64::consts::FRAC_PI_2..std::f64::consts::FRAC_PI_2).contains(&dec)); - } - - #[test] - fn test_compute_apparent_position_same_epoch() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let t_epoch = 60000.0; - let equinoctial = simple_circular_elements(t_epoch); - - let obs = Observation::new(state, observer_code, 0.0, 0.0, 0.0, 0.0, t_epoch).unwrap(); - - let (ra1, dec1) = obs.compute_apparent_position(state, &equinoctial).unwrap(); - let (ra2, dec2) = obs.compute_apparent_position(state, &equinoctial).unwrap(); - - // The same input should always produce the same result - assert_relative_eq!(ra1, ra2, epsilon = 1e-14); - assert_relative_eq!(dec1, dec2, epsilon = 1e-14); - } - - #[test] - fn test_apparent_position_for_distant_object() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - let t_obs = 59000.0; - let mut equinoctial = simple_circular_elements(t_obs); - - // Objet far away - equinoctial.semi_major_axis = 100.0; - - let obs = Observation::new(state, observer_code, 0.0, 0.0, 0.0, 0.0, t_obs).unwrap(); - - let (ra, dec) = obs - .compute_apparent_position(state, &equinoctial) - .expect("Should compute apparent position for distant object"); - - assert!(ra.is_finite()); - assert!(dec.is_finite()); - } - - #[test] - fn test_compute_apparent_position_propagation_failure() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - // Invalid orbital elements to force failure in solve_two_body_problem - let equinoctial = EquinoctialElements { - reference_epoch: 59000.0, - semi_major_axis: -1.0, // Physically invalid - eccentricity_sin_lon: 0.0, - eccentricity_cos_lon: 0.0, - tan_half_incl_sin_node: 0.0, - tan_half_incl_cos_node: 0.0, - mean_longitude: 0.0, - }; - - let obs = Observation::new(state, observer_code, 0.0, 0.0, 0.0, 0.0, 59000.0).unwrap(); - - let result = obs.compute_apparent_position(state, &equinoctial); - assert!(result.is_err(), "Invalid elements should trigger an error"); - } - - mod proptests_apparent_position { - use std::sync::Arc; - - use super::*; - use proptest::prelude::*; - - /// Strategy: generates random but reasonable equinoctial elements - /// for property-based tests. - fn arb_equinoctial_elements() -> impl Strategy { - ( - 58000.0..62000.0, // reference_epoch (MJD) - 0.5..30.0, // semi-major axis (AU) - -0.5..0.5, // h = e * sin(Ω+ω) - -0.5..0.5, // k = e * cos(Ω+ω) - -0.5..0.5, // p = tan(i/2)*sin Ω - -0.5..0.5, // q = tan(i/2)*cos Ω - 0.0..(2.0 * std::f64::consts::PI), // mean longitude (rad) - ) - .prop_map(|(epoch, a, h, k, p, q, lambda)| { - EquinoctialElements { - reference_epoch: epoch, - semi_major_axis: a, - eccentricity_sin_lon: h, - eccentricity_cos_lon: k, - tan_half_incl_sin_node: p, - tan_half_incl_cos_node: q, - mean_longitude: lambda, - } - }) - } - - /// Strategy: generates random observer locations on Earth. - /// - longitude in [-180, 180] degrees - /// - latitude in [-90, 90] degrees - /// - elevation from 0 to 5 km - fn arb_observer() -> impl Strategy { - (-180.0..180.0, -90.0..90.0, 0.0..5.0).prop_map(|(lon, lat, elev)| { - Observer::new(lon, lat, elev, None, None, None).unwrap() - }) - } - - /// Strategy: generates equinoctial elements with a wide range, - /// including extreme eccentricities and inclinations. - fn arb_extreme_equinoctial_elements() -> impl Strategy { - ( - 58000.0..62000.0, - 0.1..50.0, - -0.99..0.99, - -0.99..0.99, - -1.0..1.0, - -1.0..1.0, - 0.0..(2.0 * std::f64::consts::PI), - ) - .prop_map(|(epoch, a, h, k, p, q, lambda)| EquinoctialElements { - reference_epoch: epoch, - semi_major_axis: a, - eccentricity_sin_lon: h, - eccentricity_cos_lon: k, - tan_half_incl_sin_node: p, - tan_half_incl_cos_node: q, - mean_longitude: lambda, - }) - // Filter out cases where eccentricity >= 1 - .prop_filter( - "Only bound (elliptical) orbits are supported", - |elem: &EquinoctialElements| { - let e = (elem.eccentricity_sin_lon.powi(2) - + elem.eccentricity_cos_lon.powi(2)) - .sqrt(); - e < 0.99 - }, - ) - } - - proptest! { - /// Property test: RA and DEC are always finite and in the expected ranges. - #[test] - fn proptest_ra_dec_are_finite_and_in_range( - equinoctial in arb_equinoctial_elements(), - obs_time in 58000.0f64..62000.0 - ) { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let obs = Observation::new( - state, - observer_code, - 0.0, - 0.0, - 0.0, - 0.0, - obs_time, - ).unwrap(); - - let result = obs.compute_apparent_position(state, &equinoctial); - - if let Ok((ra, dec)) = result { - // Invariant: returned values must be finite - prop_assert!(ra.is_finite()); - prop_assert!(dec.is_finite()); - - // RA must be in [0, 2π), DEC must be in [-π/2, π/2] - prop_assert!((0.0..2.0 * std::f64::consts::PI).contains(&ra)); - prop_assert!((-std::f64::consts::FRAC_PI_2..std::f64::consts::FRAC_PI_2).contains(&dec)); - } - } - - /// Property test: Calling the function twice with the same inputs must produce exactly - /// the same output (determinism). - #[test] - fn proptest_repeatability( - equinoctial in arb_equinoctial_elements(), - obs_time in 58000.0f64..62000.0 - ) { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let obs = Observation::new( - state, - observer_code, - 0.0, - 0.0, - 0.0, - 0.0, - obs_time, - ).unwrap(); - - let r1 = obs.compute_apparent_position(state, &equinoctial); - let r2 = obs.compute_apparent_position(state, &equinoctial); - - // Invariant: repeated computation with the same input should be identical - prop_assert_eq!(r1, r2); - } - - /// Property test: A very small change in observation time (1e-3 days ≈ 1.4 min) - /// should not cause huge jumps in the resulting RA/DEC. - #[test] - fn proptest_small_time_change_has_small_effect( - equinoctial in arb_equinoctial_elements(), - obs_time in 58000.0f64..62000.0 - ) { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let obs = Observation::new( - state, - observer_code, - 0.0, - 0.0, - 0.0, - 0.0, - obs_time, - ).unwrap(); - - let obs_eps = Observation { - time: obs_time + 1e-3, // shift by 1.4 minutes - ..obs - }; - - let r1 = obs.compute_apparent_position(state, &equinoctial); - let r2 = obs_eps.compute_apparent_position(state, &equinoctial); - - if let (Ok((ra1, dec1)), Ok((ra2, dec2))) = (r1, r2) { - let dra = (ra1 - ra2).abs(); - let ddec = (dec1 - dec2).abs(); - - // Invariant: no catastrophic jumps (> 1 radian) for a small time shift - prop_assert!(dra < 1.0); - prop_assert!(ddec < 1.0); - } - } - } - - proptest! { - /// Property: RA/DEC remain finite and in valid ranges for extreme orbits and random observers. - #[test] - fn proptest_ra_dec_valid_for_extreme_orbits_and_observers( - equinoctial in arb_extreme_equinoctial_elements(), - observer in arb_observer(), - obs_time in 58000.0f64..62000.0 - ) { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.add_observer_internal(Arc::new(observer)); - - let obs = Observation::new( - state, - observer_code, - 0.0, - 0.0, - 0.0, - 0.0, - obs_time, - ).unwrap(); - - let result = obs.compute_apparent_position(state, &equinoctial); - - if let Ok((ra, dec)) = result { - // Values must be finite - prop_assert!(ra.is_finite()); - prop_assert!(dec.is_finite()); - // Angles must be within their valid intervals - prop_assert!((0.0..2.0 * std::f64::consts::PI).contains(&ra)); - prop_assert!((-std::f64::consts::FRAC_PI_2..std::f64::consts::FRAC_PI_2).contains(&dec)); - } - } - } - - #[test] - fn test_hyperbolic_orbit_returns_error() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let obs = - Observation::new(state, observer_code, 0.0, 0.0, 0.0, 0.0, 59000.0).unwrap(); - - let equinoctial = EquinoctialElements { - reference_epoch: 59000.0, - semi_major_axis: 1.0, - eccentricity_sin_lon: 0.8, - eccentricity_cos_lon: 0.8, // e ≈ 1.13 > 1 - tan_half_incl_sin_node: 0.0, - tan_half_incl_cos_node: 0.0, - mean_longitude: 0.0, - }; - - let result = obs.compute_apparent_position(state, &equinoctial); - assert!( - result.is_err(), - "Hyperbolic or parabolic orbits should currently return an error" - ); - } - } - } - - #[test] - fn test_new_observation() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let observation = - Observation::new(state, observer_code, 1.0, 0.1, 2.0, 0.2, 59000.0).unwrap(); - assert_eq!( - observation, - Observation { - observer: 2, - ra: 1.0, - error_ra: 0.1, - dec: 2.0, - error_dec: 0.2, - time: 59000.0, - observer_earth_position: [ - -1.4662164592060655e-6, - 4.2560356749756634e-5, - -2.1126391698196086e-6 - ] - .into(), - observer_helio_position: [ - -0.35113019606349866, - -0.8726512942340473, - -0.37829699890414364 - ] - .into(), - } - ); - - let observation_2 = Observation::new( - state, - 2, - 343.097_375, - 2.777_777_777_777_778E-6, - -14.784833333333333, - 2.777_777_777_777_778E-5, - 59001.0, - ) - .unwrap(); - - assert_eq!( - observation_2, - Observation { - observer: 2, - ra: 343.097375, - error_ra: 2.777777777777778e-6, - dec: -14.784833333333333, - error_dec: 2.777777777777778e-5, - time: 59001.0, - observer_earth_position: [ - -2.1521316017998277e-6, - 4.2531873012231404e-5, - -2.0988352183088593e-6 - ] - .into(), - observer_helio_position: [ - -0.33522248840408125, - -0.8780465618894304, - -0.380635845615707 - ] - .into(), - } - ); - } - - mod tests_ephemeris_error { - use super::*; - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - use approx::assert_relative_eq; - - fn simple_equinoctial(epoch: f64) -> EquinoctialElements { - EquinoctialElements { - reference_epoch: epoch, - semi_major_axis: 1.0, - eccentricity_sin_lon: 0.0, - eccentricity_cos_lon: 0.0, - tan_half_incl_sin_node: 0.0, - tan_half_incl_cos_node: 0.0, - mean_longitude: 0.0, - } - } - - #[test] - fn test_ephem_error() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let obs = Observation::new( - state, - observer_code, - 1.7899347771316527, - 1.770_024_520_608_546E-6, - 0.778_996_538_107_973_6, - 1.259_582_891_829_317_7E-6, - 57070.262067592594, - ) - .unwrap(); - - let equinoctial_element = EquinoctialElements { - reference_epoch: 57_049.242_334_573_75, - semi_major_axis: 1.8017360713154256, - eccentricity_sin_lon: 0.269_373_680_909_227_2, - eccentricity_cos_lon: 8.856_415_260_013_56E-2, - tan_half_incl_sin_node: 8.089_970_166_396_302E-4, - tan_half_incl_cos_node: 0.10168201109730375, - mean_longitude: 1.6936970079414786, - }; - - let rms_error = obs.ephemeris_error(&OUTFIT_HORIZON_TEST.0, &equinoctial_element); - assert_eq!(rms_error.unwrap(), 75.00445641224026); - } - - /// When the observed RA/DEC exactly match the propagated RA/DEC, - /// the ephemeris_error must be zero. - #[test] - fn test_zero_error_when_positions_match() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let t_obs = 59000.0; - let equinoctial = simple_equinoctial(t_obs); - - // Compute the propagated position - let obs = Observation::new(state, observer_code, 0.0, 1e-6, 0.0, 1e-6, t_obs).unwrap(); - let (alpha, delta) = obs.compute_apparent_position(state, &equinoctial).unwrap(); - - // New observation with exact same RA/DEC - let obs_match = - Observation::new(state, observer_code, alpha, 1e-6, delta, 1e-6, t_obs).unwrap(); - - let error = obs_match.ephemeris_error(state, &equinoctial).unwrap(); - assert_relative_eq!(error, 0.0, epsilon = 1e-14); - } - - /// Error grows if RA is off by a known amount - #[test] - fn test_error_increases_with_offset() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let t_obs = 59000.0; - let equinoctial = simple_equinoctial(t_obs); - - let base_obs = Observation { - observer: observer_code, - ra: 0.0, - error_ra: 1e-3, - dec: 0.0, - error_dec: 1e-3, - time: t_obs, - observer_earth_position: Vector3::zeros(), - observer_helio_position: Vector3::zeros(), - }; - let (alpha, delta) = base_obs - .compute_apparent_position(state, &equinoctial) - .unwrap(); - - // Same dec, but RA offset by 1 milliradian - let obs_offset = Observation { - observer: 0, - ra: alpha + 1e-3, - error_ra: 1e-3, - dec: delta, - error_dec: 1e-3, - time: t_obs, - observer_earth_position: Vector3::zeros(), - observer_helio_position: Vector3::zeros(), - }; - - let err = obs_offset.ephemeris_error(state, &equinoctial).unwrap(); - assert!(err > 0.0); - } - - /// Check that wrapping of RA (close to 2π) does not affect the error - #[test] - fn test_ra_wrapping_invariance() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let t_obs = 59000.0; - let equinoctial = simple_equinoctial(t_obs); - - let base_obs = - Observation::new(state, observer_code, 0.0, 1e-6, 0.0, 1e-6, t_obs).unwrap(); - let (alpha, delta) = base_obs - .compute_apparent_position(state, &equinoctial) - .unwrap(); - - // Same position but RA shifted by ±2π - let obs_wrapped = Observation::new( - state, - observer_code, - alpha + std::f64::consts::TAU, - 1e-6, - delta, - 1e-6, - t_obs, - ) - .unwrap(); - - let err1 = obs_wrapped.ephemeris_error(state, &equinoctial).unwrap(); - assert_relative_eq!(err1, 0.0, epsilon = 1e-12); - } - - /// When RA/DEC uncertainties are very large, error is small even with a mismatch. - #[test] - fn test_large_uncertainty_downweights_error() { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.uint16_from_mpc_code(&"F51".to_string()); - - let t_obs = 59000.0; - let equinoctial = simple_equinoctial(t_obs); - - let base_obs = - Observation::new(state, observer_code, 0.0, 1.0, 0.0, 1.0, t_obs).unwrap(); - let (alpha, delta) = base_obs - .compute_apparent_position(state, &equinoctial) - .unwrap(); - - let obs_large_uncertainty = Observation::new( - state, - observer_code, - alpha + 0.1, - 10.0, - delta + 0.1, - 10.0, - t_obs, - ) - .unwrap(); - - let err = obs_large_uncertainty - .ephemeris_error(state, &equinoctial) - .unwrap(); - assert!( - err < 1.0, - "Large uncertainties should reduce the error contribution" - ); - } - - mod proptests_ephemeris_error { - use std::sync::Arc; - - use super::*; - use proptest::prelude::*; - - fn arb_observer() -> impl Strategy { - (-180.0..180.0, -90.0..90.0, 0.0..5.0).prop_map(|(lon, lat, elev)| { - Observer::new(lon, lat, elev, None, None, None).unwrap() - }) - } - - fn arb_elliptical_equinoctial() -> impl Strategy { - ( - 58000.0..62000.0, - 0.5..20.0, - -0.8..0.8, - -0.8..0.8, - -0.8..0.8, - -0.8..0.8, - 0.0..std::f64::consts::TAU, - ) - .prop_map(|(epoch, a, h, k, p, q, l)| EquinoctialElements { - reference_epoch: epoch, - semi_major_axis: a, - eccentricity_sin_lon: h, - eccentricity_cos_lon: k, - tan_half_incl_sin_node: p, - tan_half_incl_cos_node: q, - mean_longitude: l, - }) - .prop_filter("Bound orbits only", |e: &EquinoctialElements| { - e.eccentricity() < 1.0 - }) - } - - proptest! { - /// Property: error is always non-negative and finite for valid inputs - #[test] - fn proptest_error_is_non_negative( - equinoctial in arb_elliptical_equinoctial(), - observer in arb_observer(), - obs_time in 58000.0f64..62000.0 - ) { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.add_observer_internal(Arc::new(observer)); - let obs = Observation::new(state, observer_code, - 0.0, - 1e-3, - 0.0, - 1e-3, - obs_time,).unwrap(); - - let result = obs.ephemeris_error(state, &equinoctial); - if let Ok(val) = result { - prop_assert!(val.is_finite()); - prop_assert!(val >= 0.0); - } - } - - /// Property: If uncertainties are huge, the error must be small - #[test] - fn proptest_error_downweights_large_uncertainties( - equinoctial in arb_elliptical_equinoctial(), - observer in arb_observer(), - obs_time in 58000.0f64..62000.0 - ) { - let state = &mut OUTFIT_HORIZON_TEST.0.clone(); - let observer_code = state.add_observer_internal(Arc::new(observer)); - let obs = Observation::new(state, observer_code, - 0.5, - 100.0, - 0.5, - 100.0, - obs_time,).unwrap(); - - let result = obs.ephemeris_error(state, &equinoctial); - if let Ok(val) = result { - prop_assert!(val < 1.0); - } - } - } - } - } - - #[cfg(test)] - mod display_obs_tests { - use super::*; - use nalgebra::Vector3; - - // --- Helpers ------------------------------------------------------------- - - /// Convert arcseconds to radians. - fn arcsec_to_rad(asx: f64) -> f64 { - asx / 206_264.806_247_096_37 - } - - /// Build an Observation with full control on fields. - /// This stays in the same module (pub(crate) fields are accessible here). - #[allow(clippy::too_many_arguments)] - fn make_obs( - site: u16, - ra_rad: f64, - dec_rad: f64, - sra_arcsec: f64, - sdec_arcsec: f64, - mjd_tt: f64, - geo: (f64, f64, f64), - hel: (f64, f64, f64), - ) -> Observation { - Observation { - observer: site, - ra: Radian::from(ra_rad), - error_ra: Radian::from(arcsec_to_rad(sra_arcsec)), - dec: Radian::from(dec_rad), - error_dec: Radian::from(arcsec_to_rad(sdec_arcsec)), - time: MJD::from(mjd_tt), - observer_earth_position: Vector3::new(geo.0, geo.1, geo.2), - observer_helio_position: Vector3::new(hel.0, hel.1, hel.2), - } - } - - // --- Tests --------------------------------------------------------------- - - #[test] - fn display_compact_basic() { - // Case: RA=0, DEC=0, uncertainties = 1.0 arcsec, simple positions. - let obs = make_obs( - 809, - 0.0, - 0.0, - 1.0, - 1.0, - 60000.123456, // MJD (TT) - (0.123456789, -1.0, 0.000042), - (0.0, 1.234567, -0.5), - ); - - let s = format!("{obs}"); - - // Site and MJD - assert!( - s.contains("site=809"), - "site field not present/incorrect: {s}" - ); - assert!( - s.contains("MJD=60000.123456"), - "MJD formatting to 6 decimals expected: {s}" - ); - - // RA/DEC with uncertainties in arcsec; 3 decimals on seconds and uncertainties. - // RA=0 -> 00h00m00.000s; DEC=+00°00'00.000" - assert!( - s.contains("RA=00h00m00.000s ± 1.000\""), - "RA sexagesimal or sigma not formatted as expected: {s}" - ); - assert!( - s.contains("DEC=+00°00'00.000\" ± 1.000\""), - "DEC sexagesimal or sigma not formatted as expected: {s}" - ); - - // JD = MJD + 2400000.5 is only printed in pretty mode, so not checked here. - - // Vectors formatted with 6 decimals and 'AU' tag - assert!( - s.contains("r_geo=[ 0.123457, -1.000000, 0.000042 ] AU"), - "Geocentric vector formatting/precision mismatch: {s}" - ); - assert!( - s.contains("r_hel=[ 0.000000, 1.234567, -0.500000 ] AU"), - "Heliocentric vector formatting/precision mismatch: {s}" - ); - } - - #[test] - fn display_pretty_multiline() { - // Non-zero RA/DEC to exercise general formatting. - // RA = 2h 30m 15s -> in radians; DEC = +12° 34' 56" - let ra_hours = 2.0 + 30.0 / 60.0 + 15.0 / 3600.0; - let ra_rad = ra_hours * std::f64::consts::PI / 12.0; - let dec_deg: f64 = 12.0 + 34.0 / 60.0 + 56.0 / 3600.0; - let dec_rad = dec_deg.to_radians(); - - let obs = make_obs( - 500, - ra_rad, - dec_rad, - 0.321, // arcsec - 0.789, // arcsec - 60200.5, - (1.0, 2.0, 3.0), - (-0.1, 0.2, -0.3), - ); - - let s = format!("{obs:#}"); - - // Header and labels should be present (human-readable multi-line) - assert!( - s.starts_with("Astrometric observation"), - "Pretty header missing: {s}" - ); - assert!( - s.contains("Site ID : 500"), - "Pretty site line missing: {s}" - ); - - // Epoch line with MJD and JD - assert!( - s.contains("Epoch (TT) : MJD 60200.500000, JD 2460201.000000"), - "Epoch line with JD=MJD+2400000.5 expected: {s}" - ); - - // RA/σ line (check fragments to avoid locale/spacing issues) - assert!(s.contains("RA / σ : "), "RA line missing: {s}"); - assert!( - s.contains("h") && s.contains("m") && s.contains("s"), - "RA HMS units missing: {s}" - ); - assert!( - s.contains("(σ = 0.321"), - "RA sigma arcsec missing/incorrect: {s}" - ); - - // DEC/σ line with sign and DMS glyphs - assert!(s.contains("DEC / σ : +"), "DEC sign missing: {s}"); - assert!( - s.contains("°") && s.contains("'") && s.contains("\""), - "DEC units missing: {s}" - ); - assert!( - s.contains("(σ = 0.789"), - "DEC sigma arcsec missing/incorrect: {s}" - ); - - // Vectors lines - assert!( - s.contains("Observer (geo) : [ 1.000000, 2.000000, 3.000000 ] AU"), - "Geo vector line mismatch: {s}" - ); - assert!( - s.contains("Observer (hel) : [ -0.100000, 0.200000, -0.300000 ] AU"), - "Hel vector line mismatch: {s}" - ); - } - - #[test] - fn ra_wraps_into_24h() { - // RA slightly negative should wrap to near 24h in display. - let tiny = 1e-6; - let obs = make_obs( - 1, - -tiny, // slightly negative angle - 0.0, - 0.1, - 0.1, - 59000.0, - (0.0, 0.0, 0.0), - (0.0, 0.0, 0.0), - ); - - let s = format!("{obs}"); - - // Expect "23h59m..." rather than negative hours - assert!( - s.contains("RA=23h59m") || s.contains("RA=24h00m"), - "RA should wrap to [0, 24h): {s}" - ); - assert!( - !s.contains("-"), - "RA string must not contain a negative sign after wrapping: {s}" - ); - } - - #[test] - fn dec_negative_sign_is_preserved() { - // DEC = -10° 00' 00" - let dec_rad = (-10.0f64).to_radians(); - let obs = make_obs( - 2, - 0.0, - dec_rad, - 0.5, - 0.5, - 59000.0, - (0.0, 0.0, 0.0), - (0.0, 0.0, 0.0), - ); - - let s = format!("{obs}"); - - assert!( - s.contains("DEC=-10°00'00.000\""), - "Negative DEC sign or DMS formatting incorrect: {s}" - ); - } - - #[test] - fn uncertainties_are_in_arcseconds() { - // Store uncertainties in radians corresponding to 2.345 arcsec. - let asx = 2.345; - let obs = make_obs( - 3, - 0.0, - 0.0, - asx, // provided in arcsec, helper converts to rad - asx, - 60001.0, - (0.0, 0.0, 0.0), - (0.0, 0.0, 0.0), - ); - - let s1 = format!("{obs}"); - let s2 = format!("{obs:#}"); - - // Both compact and pretty should surface the same arcsec value to 3 decimals. - assert!( - s1.contains("± 2.345\"") && s2.contains("(σ = 2.345"), - "Arcsecond uncertainties not printed as expected.\nCompact: {s1}\nPretty:\n{s2}" - ); - } - - #[test] - fn vector_precision_and_units() { - // Ensure 6-decimal rounding and AU suffix are stable. - let obs = make_obs( - 4, - 0.0, - 0.0, - 1.0, - 1.0, - 60010.0, - (0.9999996, -0.9999996, 0.12345649), - (1.23456749, -1.23456751, 2.00000049), - ); - - let s = format!("{obs}"); - - // Rounded at 6 decimals, with AU suffix. - assert!( - s.contains("r_geo=[ 1.000000, -1.000000, 0.123456 ] AU"), - "Geo rounding/units mismatch: {s}" - ); - assert!( - s.contains("r_hel=[ 1.234567, -1.234568, 2.000000 ] AU"), - "Hel rounding/units mismatch: {s}" - ); - } - } -} diff --git a/src/observations/observations_ext.rs b/src/observations/observations_ext.rs deleted file mode 100644 index b94f22e..0000000 --- a/src/observations/observations_ext.rs +++ /dev/null @@ -1,1245 +0,0 @@ -//! # Observation extensions for orbit determination -//! -//! This module extends the base [`Observations`] collection with methods -//! essential for **initial orbit determination (IOD)** and orbit quality -//! assessment. -//! It provides two core traits: -//! -//! ## [`ObservationsExt`] -//! -//! High-level utilities for processing and analyzing sets of [`Observation`]s: -//! -//! - **Triplet generation**: [`compute_triplets`](ObservationsExt::compute_triplets) -//! Build optimized triplets of three observations for Gauss IOD. -//! -//! - **RMS evaluation**: [`select_rms_interval`](ObservationsExt::select_rms_interval), -//! [`rms_orbit_error`](ObservationsExt::rms_orbit_error) -//! Select subsets of observations around a triplet and compute RMS residuals -//! for candidate orbits. -//! -//! - **Error handling**: [`apply_batch_rms_correction`](ObservationsExt::apply_batch_rms_correction) -//! Apply statistical corrections to observational errors based on temporal clustering. -//! -//! - **Uncertainty extraction**: [`extract_errors`](ObservationsExt::extract_errors) -//! Retrieve RA/DEC uncertainties for a given triplet. -//! -//! ## [`ObservationIOD`] -//! -//! A high-level trait encapsulating the full **initial orbit determination workflow**: -//! -//! 1. Apply uncertainty calibration with [`ObservationsExt::apply_batch_rms_correction`], -//! 2. Generate candidate triplets with [`ObservationsExt::compute_triplets`], -//! 3. Perform Monte Carlo perturbations to simulate astrometric noise, -//! 4. Run the Gauss method on each triplet, -//! 5. Select the orbit with the lowest RMS over the full arc using [`ObservationsExt::rms_orbit_error`]. -//! -//! This workflow is designed for **short arcs** (newly discovered asteroids, comets, NEOs), -//! where only a handful of observations are available. -//! -//! ## Typical usage -//! -//! ```rust, no_run -//! use rand::{rngs::StdRng, SeedableRng}; -//! use outfit::initial_orbit_determination::IODParams; -//! use outfit::constants::Observations; -//! use outfit::observations::observations_ext::ObservationIOD; -//! -//! let params = IODParams::builder() -//! .n_noise_realizations(50) -//! .noise_scale(1.0) -//! .dtmax(30.0) -//! .max_triplets(20) -//! .build().unwrap(); -//! -//! let observations: Observations = unimplemented!(); // Load observations here -//! let state = unimplemented!(); // Outfit environment -//! let error_model = unimplemented!(); // Astrometric error model -//! let mut rng = StdRng::seed_from_u64(123); -//! -//! let (best_orbit, rms) = observations.estimate_best_orbit( -//! &state, &error_model, &mut rng, ¶ms -//! ).unwrap(); -//! println!("Best preliminary orbit RMS = {rms}"); -//! ``` -//! -//! ## References -//! -//! * Danby, J.M.A. (1992). *Fundamentals of Celestial Mechanics* (2nd ed.). -//! Willmann-Bell. Classic reference for Gauss, Laplace, and related IOD methods. -//! -//! * Milani, A., & Gronchi, G. F. (2009). *Theory of Orbit Determination*. -//! Cambridge University Press. [doi:10.1017/CBO9781139175371](https://doi.org/10.1017/CBO9781139175371) -//! -//! * Carpino, M., Milani, A., & Chesley, S. R. (2003). *OrbFit: Software for Preliminary Orbit Determination*. -//! [https://adams.dm.unipi.it/orbfit/](https://adams.dm.unipi.it/orbfit/) -//! -//! ## See also -//! -//! - [`Observation`] – Representation of a single astrometric measurement. -//! - [`GaussObs`] – Structure encoding a triplet of observations for Gauss IOD. -//! - [`GaussResult`] – Output of the Gauss preliminary orbit solver. -//! - [`IODParams`] – Parameters controlling IOD (triplet constraints, noise realizations). -use itertools::Itertools; -use nalgebra::Vector3; -use std::{collections::VecDeque, ops::ControlFlow}; - -use crate::{ - constants::{Observations, Radian}, - error_models::ErrorModel, - initial_orbit_determination::{gauss::GaussObs, gauss_result::GaussResult, IODParams}, - observations::{triplets_iod::generate_triplets, Observation}, - orbit_type::equinoctial_element::EquinoctialElements, - outfit::Outfit, - outfit_errors::OutfitError, -}; - -/// Extension trait for [`Observations`] providing high-level operations -/// commonly used in orbit determination workflows. -/// -/// This trait adds methods to process and analyze a collection of -/// astrometric [`Observation`] objects, including: -/// -/// * Selection of observation triplets optimized for initial orbit determination (IOD). -/// * Selection of subsets of observations for root-mean-square (RMS) error calculation. -/// * Computation of orbit quality metrics (RMS of astrometric residuals). -/// * Statistical corrections of observational errors based on temporal clustering. -/// * Extraction of astrometric uncertainties for given observation indices. -/// -/// # Typical usage -/// -/// This trait is intended to be implemented on: -/// -/// ```rust, ignore -/// pub type Observations = SmallVec<[Observation; 6]>; -/// ``` -/// -/// It provides functionality essential for the Gauss method and related -/// algorithms used in preliminary orbit determination. -/// -/// # Provided methods -/// - [`compute_triplets`](ObservationsExt::compute_triplets): -/// Build time-filtered triplets of observations, sorted by weight. -/// - [`select_rms_interval`](ObservationsExt::select_rms_interval): -/// Given a triplet, determine which observations lie in an expanded time window. -/// - [`rms_orbit_error`](ObservationsExt::rms_orbit_error): -/// Evaluate the fit of an orbit by computing RMS residuals. -/// - [`apply_batch_rms_correction`](ObservationsExt::apply_batch_rms_correction): -/// Apply correlation-based scaling factors to astrometric errors based on temporal clustering. -/// - [`extract_errors`](ObservationsExt::extract_errors): -/// Retrieve the RA/DEC uncertainties for a given triplet of observations. -/// -/// # See also -/// * [`GaussObs`] – Data structure used to represent a triplet of observations. -/// * [`Outfit`] – Global state providing ephemerides and reference frames. -/// * [`KeplerianElements`](crate::orbit_type::keplerian_element::KeplerianElements) – Orbital elements used to propagate orbits. -/// * [`Observation`] – Individual observation data structure. -/// -/// # References -/// -/// * Danby, J.M.A. (1992). *Fundamentals of Celestial Mechanics* (2nd ed.). -/// Willmann-Bell. -/// Classic reference for preliminary orbit determination methods -/// (Gauss, Laplace, Vaisala) and iterative improvement techniques. -/// -/// * Milani, A., & Gronchi, G. F. (2009). *Theory of Orbit Determination*. -/// Cambridge University Press. -/// Comprehensive modern treatment of statistical orbit determination, -/// including least-squares methods, weighting, and correlation handling. -/// [https://doi.org/10.1017/CBO9781139175371](https://doi.org/10.1017/CBO9781139175371) -/// -/// * Carpino, M., Milani, A., & Chesley, S. R. (2003). *OrbFit: Software for Preliminary Orbit Determination*. -/// Technical documentation distributed with the OrbFit package: -/// [https://adams.dm.unipi.it/orbfit/](https://adams.dm.unipi.it/orbfit/) -/// -/// These references describe the algorithms used for: -/// - Building and filtering observation triplets (Gauss method) -/// - Propagating trial orbits and refining them via differential corrections -/// - Handling of astrometric uncertainties and RMS weighting. -/// -/// This trait is central to orbit determination pipelines and is designed -/// to work with small batches of observations (often < 100 per object). -pub trait ObservationsExt { - /// Compute **time-feasible, best-K** triplets of observations for Gauss IOD, - /// leveraging a lazy **index stream** and a bounded **max-heap** on spacing weight. - /// - /// Overview - /// ----------------- - /// This method is a convenience wrapper around [`generate_triplets`]. It operates - /// directly on `self` (the current observation set) and returns up to `max_triplet` - /// **best-scored** candidates for the Gauss preliminary solution. Internally it: - /// - /// 1) Uses a `TripletIndexGenerator` that: - /// - sorts epochs in place, - /// - downsamples to at most `max_obs_for_triplets` (uniform with edges), - /// - lazily **streams reduced indices** `(first, middle, last)` constrained by: - /// `dt_min ≤ t[last] − t[first] ≤ dt_max`. - /// 2) Scores each feasible triplet with [`triplet_weight`](crate::observations::triplets_iod::triplet_weight) against `optimal_interval_time`. - /// 3) Keeps only the **K** smallest weights in a bounded **max-heap** (best-K selection). - /// 4) Materializes the survivors as [`GaussObs`] by (re)borrowing `self` immutably. - /// - /// Compared to brute-force `O(n³)`, the time-windowed enumeration drives the effective - /// cost toward ~`O(n²)` in typical time distributions, plus `O(n log K)` for heap updates. - /// - /// Arguments - /// ----------------- - /// * `dt_min` – Minimum allowed timespan `[same units as Observation::time]` between the first and last epoch of a triplet. - /// * `dt_max` – Maximum allowed timespan between the first and last epoch of a triplet. - /// * `optimal_interval_time` – Target per-gap spacing (e.g., days) used by [`triplet_weight`](crate::observations::triplets_iod::triplet_weight). - /// * `max_obs_for_triplets` – Upper bound on observations kept after downsampling (uniform with endpoints). - /// * `max_triplet` – Number `K` of best-scoring triplets to return. - /// - /// Return - /// ---------- - /// * A `Vec` of length `≤ max_triplet`, **sorted by increasing weight** - /// (best geometric spacing first), ready to be passed to `GaussObs::prelim_orbit`. - /// - /// Remarks - /// ------------- - /// * Sorting is **in-place**; call sites should not rely on original ordering afterward. - /// * The generator avoids overlapping borrows of `self`; only the final K triplets are materialized. - /// * For robustness studies, each returned triplet can be expanded with - /// `GaussObs::realizations_iter` (lazy Monte-Carlo noise). - /// - /// Complexity - /// ----------------- - /// * Enumeration: ~`O(n²)` (per-anchor time window). - /// * Selection: `O(n log K)` (bounded max-heap). - /// * Space: `O(1)` per yielded candidate; only K triplets are allocated at the end. - /// - /// See also - /// ------------ - /// * [`generate_triplets`] – Low-level function performing the selection (index stream + heap + materialization). - /// * [`TripletIndexGenerator`](crate::observations::triplets_generator::TripletIndexGenerator) – Lazy stream of reduced indices constrained by `(dt_min, dt_max)`. - /// * [`triplet_weight`](crate::observations::triplets_iod::triplet_weight) – Spacing heuristic around `optimal_interval_time`. - /// * [`GaussObs::realizations_iter`] – On-the-fly noisy realizations for a given triplet. - fn compute_triplets( - &mut self, - dt_min: f64, - dt_max: f64, - optimal_interval_time: f64, - max_obs_for_triplets: usize, - max_triplet: u32, - ) -> Vec; - - /// Select the interval of observations for RMS calculation. - /// - /// This function selects the interval of observations for RMS calculation based on the provided triplet. - /// It computes the maximum allowed interval and finds the start and end indices of the observations - /// within that interval. - /// - /// Arguments - /// --------- - /// * `triplets`: A reference to a `GaussObs` representing the triplet of observations. - /// * `extf`: A `f64` representing the external factor for the interval calculation. - /// * `dtmax`: A `f64` representing the maximum allowed interval. - /// - /// Return - /// ------ - /// * A `Result` containing a tuple of start and end indices of the observations within the interval, - /// or an `OutfitError` if an error occurs. - fn select_rms_interval( - &self, - triplets: &GaussObs, - extf: f64, - dtmax: f64, - ) -> Result<(usize, usize), OutfitError>; - - /// Evaluate the orbit quality by computing the RMS of normalized astrometric residuals - /// over a time window centered on a Gauss triplet. - /// - /// Scientific context - /// ------------------- - /// This function measures how well a preliminary orbit reproduces the observed - /// astrometry (RA, DEC). It computes the **root-mean-square (RMS)** of the - /// normalized residuals between predicted and observed positions, aggregated over - /// a set of observations surrounding a Gauss triplet. - /// - /// Interval selection - /// ------------------- - /// The observation arc is defined by: - /// * `extf` – fractional extension factor applied around the triplet center, - /// * `dtmax` – absolute maximum time span (days) allowed for the arc. - /// - /// The effective interval is determined by - /// [`select_rms_interval`](Self::select_rms_interval), which returns the first - /// and last indices of the observations to include. - /// - /// Computation - /// ------------ - /// * Each observation contributes a squared normalized residual - /// from [`Observation::ephemeris_error`](crate::observations::Observation::ephemeris_error). - /// * The final RMS is - /// - /// ```text - /// RMS = √[ (1 / (2N)) · Σᵢ (ΔRAᵢ² + ΔDECᵢ²) ] - /// ``` - /// - /// where `N` is the number of observations in the selected interval. - /// - /// Pruning mode - /// ------------ - /// If `prune_if_rms_ge` is set: - /// * The summation stops early once the partial RMS reaches the threshold, - /// returning the pruning value directly. - /// * If `prune_if_rms_ge = ∞`, no early exit occurs (equivalent to no pruning). - /// - /// Arguments - /// ---------- - /// * `state` – Global context providing ephemerides, Earth orientation, and time conversion. - /// * `triplets` – The Gauss triplet that defined the preliminary orbit. - /// * `orbit_element` – The orbit (in equinoctial elements) to be tested against the arc. - /// * `extf` – Fractional time extension of the interval around the triplet. - /// * `dtmax` – Maximum arc duration (days). - /// * `prune_if_rms_ge` – Optional RMS cutoff for early termination (see *Pruning mode*). - /// - /// Return - /// ------- - /// * `Ok(rms)` – RMS of the normalized astrometric residuals (radians). - /// * `Err(OutfitError)` – If interval selection fails or propagation/ephemeris lookup fails. - /// - /// Units - /// ------- - /// * The returned RMS is dimensionless but expressed in **radians**. - fn rms_orbit_error( - &self, - state: &Outfit, - triplets: &GaussObs, - orbit_element: &EquinoctialElements, - extf: f64, - dtmax: f64, - prune_if_rms_ge: Option, - ) -> Result; - - /// Apply RMS correction based on temporally clustered batches of observations. - /// - /// This method adjusts the astrometric uncertainties (`error_ra`, `error_dec`) of each observation - /// based on the local density of observations in time and observer identity. Observations that are - /// close in time (within 8 hours) and come from the same observer are grouped into batches, and a - /// correction factor is applied to reflect statistical correlation or improvement due to redundancy. - /// - /// # Arguments - /// --------------- - /// * `error_model` - The error model to use when applying the batch correction. Supported values include: - /// - `"vfcc17"`: uses a reduced factor `√(n × 0.25)` if the batch has at least 5 observations, - /// - any other string: uses the standard `√n` factor. - /// * `gap_max` - The maximum time gap (in days) to consider observations as part of the same batch. - /// - /// # Behavior - /// ---------- - /// - Observations are grouped by `observer` and sorted in time. - /// - A batch is formed when consecutive observations from the same observer are spaced by less than 8 hours. - /// - Each observation in a batch of size `n` receives a correction: - /// - `√n` for standard models, - /// - `√(n × 0.25)` for `vfcc17` when `n ≥ 5`. - /// - If `n < 5` with `vfcc17`, it falls back to `√n`. - /// - Observations with fixed weights (`force_w`) are not affected (not yet implemented in this version). - /// - /// # Returns - /// ---------- - /// * `()` - This function modifies the observations in-place; it does not return a value. - /// - /// # Computation Details - /// ---------- - /// - The time comparison is based on Modified Julian Date (`MJD`), and the batch window is fixed at 8 hours (i.e., `8.0 / 24.0` days). - /// - The error fields `error_ra` and `error_dec` are both scaled by the same batch correction factor. - /// - /// # Units - /// ---------- - /// - Input and output uncertainties (`error_ra`, `error_dec`) are expressed in **radians**. - fn apply_batch_rms_correction(&mut self, error_model: &ErrorModel, gap_max: f64); - - /// Extract astrometric uncertainties (RA and DEC) for a set of three observations. - /// - /// Given a triplet of observation indices, this function retrieves the corresponding - /// astrometric errors in right ascension and declination from the observation set. - /// - /// # Arguments - /// --------------- - /// * `idx_obs` - A vector of three indices referring to the observations used in the triplet. - /// - /// # Returns - /// --------------- - /// * A tuple of two `Vector3`: - /// - The first vector contains the RA uncertainties in radians. - /// - The second vector contains the DEC uncertainties in radians. - /// - /// # Panics - /// --------------- - /// This function will panic if any index in `idx_obs` is out of bounds of the observation set. - fn extract_errors(&self, idx_obs: Vector3) -> (Vector3, Vector3); -} - -/// Trait for performing Initial Orbit Determination (IOD) on a set of astrometric observations. -/// -/// This trait encapsulates high-level algorithms to derive a **preliminary orbit** -/// from a time series of astrometric observations, using the **Gauss method**. -/// It focuses on searching for the best-fitting orbit over all valid triplets of observations. -/// -/// ## Purpose -/// -/// The main goal of this trait is to automate the process of: -/// 1. Building candidate triplets of observations (see [`compute_triplets`](ObservationsExt::compute_triplets)), -/// 2. Estimating a preliminary orbit for each triplet using the Gauss method, -/// 3. Perturbing triplets with Gaussian noise to simulate observational uncertainties, -/// 4. Selecting the orbit that minimizes the root-mean-square (RMS) of astrometric residuals -/// when compared to the entire set of observations. -/// -/// This process is the standard entry point for orbit determination workflows. -/// It is designed for **short observational arcs**, such as those of newly discovered asteroids. -/// -/// ## Typical usage -/// -/// ```rust, no_run -/// use rand::{rngs::StdRng, SeedableRng}; -/// use outfit::initial_orbit_determination::IODParams; -/// use outfit::constants::Observations; -/// use outfit::observations::observations_ext::ObservationIOD; -/// -/// let params = IODParams::builder() -/// .n_noise_realizations(100) -/// .noise_scale(1.0) -/// .dtmax(30.0) -/// .max_triplets(50) -/// .build().unwrap(); -/// -/// let observations: Observations = unimplemented!(); // Your observations here -/// let state = unimplemented!(); // Your state here -/// let error_model = unimplemented!(); // Your error model here -/// let mut rng = StdRng::seed_from_u64(42); -/// -/// let (best_orbit, rms) = observations.estimate_best_orbit( -/// &state, &error_model, &mut rng, ¶ms).unwrap(); -/// println!("Best preliminary orbit RMS = {rms}"); -/// ``` -/// -/// ## Algorithmic steps -/// -/// 1. **Batch uncertainty correction:** -/// Observations are first passed through [`ObservationsExt::apply_batch_rms_correction`]. -/// -/// 2. **Triplet generation:** -/// Valid combinations of three observations are extracted with [`ObservationsExt::compute_triplets`], -/// using the configuration parameters from [`IODParams`] (e.g., `dt_min`, `dt_max_triplet`, -/// `optimal_interval_time`, `max_obs_for_triplets`, `max_triplets`). -/// -/// 3. **Orbit estimation:** -/// For each triplet, `n_noise_realizations` noisy variants are generated (Monte Carlo) -/// and processed by the Gauss method to obtain preliminary orbital elements. -/// -/// 4. **Orbit evaluation:** -/// Each preliminary orbit is propagated and compared to the full observation arc using -/// [`ObservationsExt::rms_orbit_error`]. The orbit with the smallest RMS is returned. -/// -/// ## Performance considerations -/// -/// * Typically applied to **short arcs with tens of observations**. -/// * The number of triplets can be limited via `params.max_triplets`. -/// * The Monte Carlo loop (`params.n_noise_realizations`) dominates runtime. -/// -/// ## Returns -/// -/// * `Ok((Some(best_orbit), best_rms))` – if a valid orbit was found, -/// * `Ok((None, f64::MAX))` – if no valid orbit could be estimated, -/// * `Err(OutfitError)` – if an error occurs during propagation or fitting. -/// -/// ## Parameters -/// -/// * [`IODParams`] – Controls the noise sampling, temporal constraints on triplets, -/// and the maximum number of triplets to evaluate. -/// -/// ## See also -/// -/// * [`ObservationsExt::compute_triplets`] – Generates candidate triplets from the observation set. -/// * [`ObservationsExt::rms_orbit_error`] – Evaluates the quality of an orbit over the full arc. -/// * [`GaussResult`] – Data structure holding the result of a single Gauss method run. -/// * [`KeplerianElements`](crate::orbit_type::keplerian_element::KeplerianElements) – Orbital elements returned by successful preliminary orbit estimation. -/// * [`IODParams`] – Groups all configuration options for IOD. -pub trait ObservationIOD { - /// Estimate the best-fitting preliminary orbit from a full set of astrometric observations. - /// - /// This method searches for the best preliminary orbit by evaluating a limited number of - /// observation triplets generated from the dataset. The process includes: - /// - /// 1. **Error calibration**: - /// Observations are first preprocessed with [`ObservationsExt::apply_batch_rms_correction`] to account for - /// temporal clustering and observer-specific error models. - /// - /// 2. **Triplet generation**: - /// Candidate triplets are generated using [`ObservationsExt::compute_triplets`], which: - /// * Sorts observations by time, - /// * Optionally downsamples the dataset to at most `params.max_obs_for_triplets` points - /// (uniform in time, always keeping the first and last), - /// * Filters valid triplets according to `params.dt_min`, `params.dt_max_triplet`, - /// and `params.optimal_interval_time`. - /// - /// 3. **Monte Carlo noise sampling**: - /// For each triplet, `params.n_noise_realizations` perturbed versions are created using - /// Gaussian noise scaled by `params.noise_scale` times the nominal astrometric uncertainties. - /// - /// 4. **Orbit estimation and selection**: - /// For each (possibly perturbed) triplet, a preliminary orbit is computed with the Gauss method. - /// The resulting orbit is evaluated over the full set of observations using [`ObservationsExt::rms_orbit_error`]. - /// The orbit with the smallest RMS is returned. - /// - /// # Arguments - /// - /// * `state` – - /// Global [`Outfit`] state, providing ephemerides and time conversions. - /// * `error_model` – - /// The astrometric error model (typically per-band or per-observatory). - /// * `rng` – - /// A random number generator used to draw Gaussian perturbations. - /// * `params` – - /// Parameters controlling the initial orbit determination, including: - /// * `n_noise_realizations`: number of noisy triplet variants generated per original triplet, - /// * `noise_scale`: scaling factor for the noise, - /// * `extf`: extrapolation factor for RMS evaluation, - /// * `dtmax`: maximum time interval for RMS evaluation, - /// * `dt_min`, `dt_max_triplet`, `optimal_interval_time`: constraints on triplet spans, - /// * `max_obs_for_triplets`: maximum number of observations to keep when building triplets, - /// * `max_triplets`: maximum number of triplets to process, - /// * `gap_max`: maximum allowed time gap within a batch for RMS corrections. - /// - /// # Returns - /// - /// * `Ok((Some(best_orbit), best_rms))` – The best preliminary orbit found and its RMS. - /// * `Ok((None, f64::MAX))` – No valid orbit could be estimated. - /// * `Err(e)` – An error occurred during orbit estimation or RMS evaluation. - /// - /// # Notes - /// - /// - RMS values are computed with [`ObservationsExt::rms_orbit_error`], which accounts for - /// light-time correction and ephemeris propagation. - /// - Each triplet can produce several preliminary orbit candidates due to - /// noise realizations. - /// - The `max_obs_for_triplets` parameter is crucial for large datasets, - /// as it avoids the combinatorial explosion of triplets. - /// - /// # See also - /// - /// * [`ObservationsExt::compute_triplets`] – Selects triplets from the observation set. - /// * [`GaussObs::generate_noisy_realizations`] – Creates perturbed triplets with Gaussian noise. - /// * [`GaussObs::prelim_orbit`] – Computes a preliminary orbit from a single triplet. - /// * [`ObservationsExt::rms_orbit_error`] – Measures the goodness-of-fit of an orbit against observations. - /// * [`IODParams`] – Configuration options for the IOD process. - fn estimate_best_orbit( - &mut self, - state: &Outfit, - error_model: &ErrorModel, - rng: &mut impl rand::Rng, - params: &IODParams, - ) -> Result<(GaussResult, f64), OutfitError>; -} - -impl ObservationsExt for Observations { - fn compute_triplets( - &mut self, - dt_min: f64, - dt_max: f64, - optimal_interval_time: f64, - max_obs_for_triplets: usize, - max_triplet: u32, - ) -> Vec { - generate_triplets( - self, - dt_min, - dt_max, - optimal_interval_time, - max_obs_for_triplets, - max_triplet, - ) - } - - fn select_rms_interval( - &self, - triplets: &GaussObs, - extf: f64, - dtmax: f64, - ) -> Result<(usize, usize), OutfitError> { - let nobs = self.len(); - - let idx_obs1 = triplets.idx_obs[0]; - let obs1 = self - .get(idx_obs1) - .ok_or(OutfitError::ObservationNotFound(idx_obs1))?; - - let idx_obs3 = triplets.idx_obs[2]; - let obs3 = self - .get(idx_obs3) - .ok_or(OutfitError::ObservationNotFound(idx_obs3))?; - - let first_obs = self.first().ok_or(OutfitError::ObservationNotFound(0))?; - let last_obs = self - .last() - .ok_or(OutfitError::ObservationNotFound(nobs - 1))?; - // Step 1: Compute the maximum allowed interval - let mut dt = if extf >= 0.0 { - (obs3.time - obs1.time) * extf - } else { - 10.0 * (last_obs.time - first_obs.time) - }; - - if dtmax >= 0.0 { - dt = dt.max(dtmax); - } - - let mut i_start = 0; - - for i in (0..=idx_obs1).rev() { - if let Some(obs_i) = self.get(i) { - if obs1.time - obs_i.time > dt { - break; - } - i_start = i; - } - } - - let mut i_end = nobs - 1; - - for i in idx_obs3..nobs { - if let Some(obs_i) = self.get(i) { - if obs_i.time - obs3.time > dt { - break; - } - i_end = i; - } - } - - Ok((i_start, i_end)) - } - - fn rms_orbit_error( - &self, - state: &Outfit, - triplets: &GaussObs, - orbit_element: &EquinoctialElements, - extf: f64, - dtmax: f64, - prune_if_rms_ge: Option, - ) -> Result { - // Select the time interval [start_obs_rms, end_obs_rms] over which the RMS - // error is evaluated. The interval depends on the triplet and on external - // filtering parameters (extf, dtmax). - let (start_obs_rms, end_obs_rms) = self.select_rms_interval(triplets, extf, dtmax)?; - - // Number of observations contributing to the RMS - let n_obs = (end_obs_rms - start_obs_rms + 1) as f64; - - // Denominator of the RMS formula: here weighted by 2.0 for consistency - // with the convention used elsewhere in the code. - let denom = 2.0 * n_obs; - - // ========================================================================= - // Case 1: No pruning → behave like the "classical" RMS definition - // ========================================================================= - if prune_if_rms_ge.is_none() { - // Accumulate the squared ephemeris errors for each observation - let sum = self[start_obs_rms..=end_obs_rms] - .iter() - .map(|obs| obs.ephemeris_error(state, orbit_element)) - // try_fold propagates errors from ephemeris_error while summing - .try_fold(0.0, |acc, term| term.map(|v| acc + v))?; - - // Final RMS = sqrt( sum / denom ) - return Ok((sum / denom).sqrt()); - } - - // ========================================================================= - // Case 2: Pruning enabled → early stop if RMS exceeds a threshold - // ========================================================================= - let prune = prune_if_rms_ge.unwrap(); - - // Convert the RMS cutoff into a sum cutoff: - // RMS² = sum / denom → stop if sum ≥ (prune² * denom). - let sum_cutoff = if prune.is_finite() { - prune * prune * denom - } else { - f64::INFINITY // "no real cutoff" if prune = ∞ - }; - - // Iterate over observations and accumulate squared errors. - // We use ControlFlow to allow early exit: - // - Continue(sum): keep summing, - // - Break(value): stop early and return the pruning threshold. - let folded: ControlFlow = self[start_obs_rms..=end_obs_rms] - .iter() - .map(|obs| obs.ephemeris_error(state, orbit_element)) - .try_fold(0.0, |acc, term| match term { - Ok(v) => { - let new_sum = acc + v; - if new_sum >= sum_cutoff { - // Early exit: threshold reached, return directly - ControlFlow::Break(prune) - } else { - ControlFlow::Continue(new_sum) - } - } - // In case of error in ephemeris_error, also exit with pruning value. - Err(_) => ControlFlow::Break(prune), - }); - - // Final RMS depending on whether we exited early or not - match folded { - ControlFlow::Continue(sum) => Ok((sum / denom).sqrt()), - ControlFlow::Break(rms) => Ok(rms), - } - } - - fn apply_batch_rms_correction(&mut self, error_model: &ErrorModel, gap_max: f64) { - // Step 1: Sort in time - self.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap()); - - // Step 2: Group by observer - for (_observer_id, group) in &self.into_iter().chunk_by(|obs| obs.observer) { - // Step 3: Batch grouping using sliding window - let mut batch: VecDeque<&mut Observation> = VecDeque::new(); - let mut iter = group.peekable(); - - while let Some(obs) = iter.next() { - batch.push_back(obs); - - // Extend batch while within gap_max - while let Some(next) = iter.peek() { - let dt = next.time - - batch - .back() - .expect("in apply_batch_rms_correction: batch should not be empty") - .time; - if dt <= gap_max { - batch.push_back(iter.next().expect( - "in apply_batch_rms_correction: next in batch should not be None", - )); - } else { - break; - } - } - - // Apply correction to current batch - let n = batch.len(); - if n > 0 { - let factor = match error_model { - ErrorModel::VFCC17 if n >= 5 => (n as f64 * 0.25).sqrt(), - _ => (n as f64).sqrt(), - }; - - for obs in batch.drain(..) { - obs.error_ra *= factor; - obs.error_dec *= factor; - } - } - } - } - } - - fn extract_errors(&self, idx_obs: Vector3) -> (Vector3, Vector3) { - let (errors_ra, errors_dec): (Vec<_>, Vec<_>) = idx_obs - .into_iter() - .map(|i| { - let obs = &self[*i]; - (obs.error_ra, obs.error_dec) - }) - .unzip(); - - ( - Vector3::from_column_slice(&errors_ra), - Vector3::from_column_slice(&errors_dec), - ) - } -} - -impl ObservationIOD for Observations { - fn estimate_best_orbit( - &mut self, - state: &Outfit, - error_model: &ErrorModel, - rng: &mut impl rand::Rng, - params: &IODParams, - ) -> Result<(GaussResult, f64), OutfitError> { - // --- Stage 1: Calibrate uncertainties once for the whole batch. - // This aligns quoted per-obs errors with empirical RMS statistics. - self.apply_batch_rms_correction(error_model, params.gap_max); - - // --- Stage 2: Enumerate candidate triplets under temporal constraints. - let triplets = self.compute_triplets( - params.dt_min, - params.dt_max_triplet, - params.optimal_interval_time, - params.max_obs_for_triplets, - params.max_triplets, - ); - - if triplets.is_empty() { - let span = if self.is_empty() { - 0.0 - } else { - self.last().unwrap().time - self.first().unwrap().time - }; - return Err(OutfitError::NoFeasibleTriplets { - span, - n_obs: self.len(), - dt_min: params.dt_min, - dt_max: params.dt_max_triplet, - }); - } - - // Current best (lowest) RMS and its orbit. - // Using +∞ avoids Option branching in the hot path. - let mut best_rms = f64::INFINITY; - let mut best_orbit: Option = None; - - // Keep the last encountered error so that we can report something meaningful if *all* fail. - // We don't clone: we keep only the most recent error by moving it in. - let mut last_error: Option = None; - - // For diagnostics, count how many realizations we actually attempted. - let mut n_attempts: usize = 0; - - // --- Stage 3: Explore each triplet. - for triplet in triplets { - // Extract 1-σ astrometric uncertainties for the three obs of this triplet. - let (error_ra, error_dec) = self.extract_errors(triplet.idx_obs); - - // --- Stage 4: For each (lazy) noisy realization of this triplet... - // The iterator yields the original triplet first, then noisy copies. - for realization in triplet.realizations_iter( - &error_ra, - &error_dec, - params.n_noise_realizations, - params.noise_scale, - rng, - ) { - n_attempts += 1; - - // 4.a) Preliminary Gauss solution on the current realization. - let gauss_res = match realization.prelim_orbit(state, params) { - Ok(res) => res, - Err(e) => { - // Record the failure and continue exploring. - last_error = Some(e); - continue; - } - }; - - // 4.b) Convert to the element set required by the scorer. - let equinoctial_elements = gauss_res.get_orbit().to_equinoctial()?; - - // 4.c) Score orbit vs. full observation set (RMS residual). - let rms = match self.rms_orbit_error( - state, - &realization, - &equinoctial_elements, - params.extf, - params.dtmax, - Some(best_rms), - ) { - Ok(v) => { - if !v.is_finite() { - last_error = Some(OutfitError::NonFiniteScore(v)); - continue; - } else { - v - } - } - Err(e) => { - last_error = Some(e); - continue; - } - }; - - // 4.d) Keep the best candidate so far. - if rms < best_rms { - best_rms = rms; - best_orbit = Some(gauss_res); - } - } - } - - // --- Stage 5: If at least one candidate succeeded, return the best; otherwise, propagate an error. - if let Some(orbit) = best_orbit { - Ok((orbit, best_rms)) - } else { - // If nothing succeeded, propagate a structured error with the last underlying cause. - // Fallback to a domain-specific unit error if we never captured any (e.g., no attempts). - let root_cause = match last_error { - Some(e) => e, - None => panic!("In estimate_best_orbit: no error captured but best_orbit is None, this should not happen"), - }; - Err(OutfitError::NoViableOrbit { - cause: Box::new(root_cause), - attempts: n_attempts, - }) - } - } -} - -#[cfg(test)] -mod test_obs_ext { - - use crate::error_models::ErrorModel; - - use super::*; - - #[test] - #[cfg(feature = "jpl-download")] - fn test_select_rms_interval() { - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - let mut traj_set = OUTFIT_HORIZON_TEST.1.clone(); - - let traj_number = crate::constants::ObjectNumber::String("K09R05F".into()); - let traj_len = traj_set - .get(&traj_number) - .expect("Failed to get trajectory") - .len(); - - let traj = traj_set - .get_mut(&traj_number) - .expect("Failed to get trajectory"); - - let triplets = traj.compute_triplets(0.03, 150.0, 20.0, traj_len, 10); - let (u1, u2) = traj - .select_rms_interval(triplets.first().unwrap(), -1., 30.) - .unwrap(); - - assert_eq!(u1, 0); - assert_eq!(u2, 36); - - let (u1, u2) = traj - .select_rms_interval(triplets.first().unwrap(), 10., 30.) - .unwrap(); - - assert_eq!(u1, 14); - assert_eq!(u2, 36); - - let (u1, u2) = traj - .select_rms_interval(triplets.first().unwrap(), 0.001, 3.) - .unwrap(); - - assert_eq!(u1, 17); - assert_eq!(u2, 33); - } - - #[test] - #[cfg(feature = "jpl-download")] - fn test_rms_trajectory() { - use nalgebra::Matrix3; - - use crate::{ - orbit_type::keplerian_element::KeplerianElements, unit_test_global::OUTFIT_HORIZON_TEST, - }; - - let mut traj_set = OUTFIT_HORIZON_TEST.1.clone(); - - let traj = traj_set - .get_mut(&crate::constants::ObjectNumber::String("K09R05F".into())) - .expect("Failed to get trajectory"); - - traj.apply_batch_rms_correction(&ErrorModel::FCCT14, 8.0 / 24.0); - - let triplets = GaussObs { - idx_obs: Vector3::new(34, 35, 36), - ra: [[ - 1.789_797_623_341_267, - 1.789_865_909_348_251, - 1.7899347771316527, - ]] - .into(), - dec: [[ - 0.779_178_052_350_181, - 0.779_086_664_971_291_9, - 0.778_996_538_107_973_6, - ]] - .into(), - time: [[ - 57070.238017592594, - 57_070.250_007_592_59, - 57070.262067592594, - ]] - .into(), - observer_helio_position: Matrix3::zeros(), - }; - - let kepler = KeplerianElements { - reference_epoch: 57_049.242_334_573_75, - semi_major_axis: 1.8017360713154256, - eccentricity: 0.283_559_145_668_705_7, - inclination: 0.20267383288689386, - ascending_node_longitude: 7.955_979_023_693_781E-3, - periapsis_argument: 1.2451951387589135, - mean_anomaly: 0.44054589015887125, - }; - - let rms = traj - .rms_orbit_error( - &OUTFIT_HORIZON_TEST.0, - &triplets, - &kepler.into(), - -1.0, - 30., - None, - ) - .unwrap(); - - assert_eq!(rms, 68.88650730830162); - } - - mod test_batch_rms_correction { - use crate::constants::MJD; - use approx::assert_ulps_eq; - use smallvec::smallvec; - - use super::*; - - fn obs(observer: u16, time: MJD) -> Observation { - Observation { - observer, - ra: 1.0, - error_ra: 1e-6, - dec: 0.5, - error_dec: 2e-6, - time, - observer_earth_position: Vector3::zeros(), - observer_helio_position: Vector3::zeros(), - } - } - - #[test] - fn test_single_batch_vfcc17_large() { - let base_time = 59000.0; - let mut obs: Observations = smallvec![ - obs(1, base_time), - obs(1, base_time + 0.01), - obs(1, base_time + 0.02), - obs(1, base_time + 0.03), - obs(1, base_time + 0.04), // n = 5 - ]; - - obs.apply_batch_rms_correction(&ErrorModel::VFCC17, 8.0 / 24.0); - - let factor = (5.0_f64 * 0.25_f64).sqrt(); - for ob in &obs { - assert_ulps_eq!(ob.error_ra, 1e-6 * factor, max_ulps = 2); - assert_ulps_eq!(ob.error_dec, 2e-6 * factor, max_ulps = 2); - } - } - - #[test] - fn test_single_batch_small_n() { - let base_time = 59000.0; - let mut obs: Observations = smallvec![ - obs(2, base_time), - obs(2, base_time + 0.01), // n = 2 - ]; - - obs.apply_batch_rms_correction(&ErrorModel::FCCT14, 8.0 / 24.0); - - let factor = (2.0f64).sqrt(); - for ob in &obs { - assert_ulps_eq!(ob.error_ra, 1e-6 * factor, max_ulps = 2); - assert_ulps_eq!(ob.error_dec, 2e-6 * factor, max_ulps = 2); - } - } - - #[test] - fn test_multiple_batches_same_observer() { - let base_time = 59000.0; - let mut obs: Observations = smallvec![ - obs(3, base_time), - obs(3, base_time + 0.01), // batch 1 (n = 2) - obs(3, base_time + 1.0), // isolated, batch 2 (n = 1) - ]; - - obs.apply_batch_rms_correction(&ErrorModel::FCCT14, 8.0 / 24.0); - - let factor1 = (2.0f64).sqrt(); - let factor2 = 1.0; - - assert_ulps_eq!(obs[0].error_ra, 1e-6 * factor1, max_ulps = 2); - assert_ulps_eq!(obs[1].error_ra, 1e-6 * factor1, max_ulps = 2); - assert_ulps_eq!(obs[2].error_ra, 1e-6 * factor2, max_ulps = 2); - } - - #[test] - fn test_different_observers_are_not_grouped() { - let base_time = 59000.0; - let mut obs: Observations = smallvec![ - obs(10, base_time), - obs(11, base_time + 0.01), - obs(12, base_time + 0.02), - ]; - - obs.apply_batch_rms_correction(&ErrorModel::FCCT14, 8.0 / 24.0); - - for ob in &obs { - assert_ulps_eq!(ob.error_ra, 1e-6, max_ulps = 2); - assert_ulps_eq!(ob.error_dec, 2e-6, max_ulps = 2); - } - } - - #[test] - fn test_batch_gaps_exceed_gapmax() { - let mut obs: Observations = smallvec![ - obs(5, 59000.0), - obs(5, 59001.0), // > 8h => separate - ]; - - obs.apply_batch_rms_correction(&ErrorModel::FCCT14, 8.0 / 24.0); - - for ob in &obs { - assert_ulps_eq!(ob.error_ra, 1e-6, max_ulps = 2); - assert_ulps_eq!(ob.error_dec, 2e-6, max_ulps = 2); - } - } - - #[test] - #[cfg(feature = "jpl-download")] - fn test_batch_real_data() { - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - let mut traj_set = OUTFIT_HORIZON_TEST.1.clone(); - - let traj = traj_set - .get_mut(&crate::constants::ObjectNumber::String("K09R05F".into())) - .expect("Failed to get trajectory"); - - traj.apply_batch_rms_correction(&ErrorModel::FCCT14, 8.0 / 24.0); - - assert_ulps_eq!(traj[0].error_ra, 2.507075226057322e-6, max_ulps = 2); - assert_ulps_eq!(traj[0].error_dec, 2.036217397086327e-6, max_ulps = 2); - - assert_ulps_eq!(traj[1].error_ra, 2.5070681687218917e-6, max_ulps = 2); - assert_ulps_eq!(traj[1].error_dec, 2.036217397086327e-6, max_ulps = 2); - - assert_ulps_eq!(traj[2].error_ra, 2.507_059_507_890_695_2E-6, max_ulps = 2); - assert_ulps_eq!(traj[2].error_dec, 2.036217397086327e-6, max_ulps = 2); - } - } - - mod test_extract_errors { - use super::*; - use approx::assert_ulps_eq; - use smallvec::smallvec; - - fn make_observations() -> Observations { - smallvec![ - Observation { - observer: 0, - ra: 1.0, - dec: 0.5, - error_ra: 1e-6, - error_dec: 2e-6, - time: 59000.0, - observer_earth_position: Vector3::zeros(), - observer_helio_position: Vector3::zeros(), - }, - Observation { - observer: 0, - ra: 1.1, - dec: 0.6, - error_ra: 3e-6, - error_dec: 4e-6, - time: 59000.1, - observer_earth_position: Vector3::zeros(), - observer_helio_position: Vector3::zeros(), - }, - Observation { - observer: 0, - ra: 1.2, - dec: 0.7, - error_ra: 5e-6, - error_dec: 6e-6, - time: 59000.2, - observer_earth_position: Vector3::zeros(), - observer_helio_position: Vector3::zeros(), - }, - ] - } - - #[test] - fn test_extract_errors_basic() { - let obs = make_observations(); - let idx_obs = Vector3::new(0, 1, 2); - - let (ra_errors, dec_errors) = obs.extract_errors(idx_obs); - - assert_ulps_eq!(ra_errors[0], 1e-6, max_ulps = 2); - assert_ulps_eq!(ra_errors[1], 3e-6, max_ulps = 2); - assert_ulps_eq!(ra_errors[2], 5e-6, max_ulps = 2); - - assert_ulps_eq!(dec_errors[0], 2e-6, max_ulps = 2); - assert_ulps_eq!(dec_errors[1], 4e-6, max_ulps = 2); - assert_ulps_eq!(dec_errors[2], 6e-6, max_ulps = 2); - } - - #[test] - #[should_panic(expected = "index out of bounds")] - fn test_extract_errors_out_of_bounds() { - let obs = make_observations(); - let idx_obs = Vector3::new(0, 1, 10); // 10 is out of bounds - let _ = obs.extract_errors(idx_obs); - } - } - - #[test] - #[cfg(feature = "jpl-download")] - fn test_estimate_best_orbit() { - use approx::assert_relative_eq; - use rand::{rngs::StdRng, SeedableRng}; - - use crate::{ - orbit_type::{ - keplerian_element::KeplerianElements, orbit_type_test::approx_equal, - OrbitalElements, - }, - unit_test_global::OUTFIT_HORIZON_TEST, - }; - - let mut traj_set = OUTFIT_HORIZON_TEST.1.clone(); - - let traj_number = crate::constants::ObjectNumber::String("K09R05F".into()); - let traj_len = traj_set - .get(&traj_number) - .expect("Failed to get trajectory") - .len(); - - let traj = traj_set - .get_mut(&traj_number) - .expect("Failed to get trajectory"); - - let mut rng = StdRng::seed_from_u64(42_u64); // seed for reproducibility - - let gap_max = 8.0 / 24.0; // 8 hours in days - - let params = IODParams { - n_noise_realizations: 5, - max_obs_for_triplets: traj_len, - gap_max, - ..Default::default() - }; - - let (best_orbit, best_rms) = traj - .estimate_best_orbit( - &OUTFIT_HORIZON_TEST.0, - &ErrorModel::FCCT14, - &mut rng, - ¶ms, - ) - .unwrap(); - - let binding = best_orbit; - let orbit = binding.get_orbit(); - - let expected_orbit = OrbitalElements::Keplerian(KeplerianElements { - reference_epoch: 57049.22904488294, - semi_major_axis: 1.801748431600605, - eccentricity: 0.283572284127787, - inclination: 0.20266779609836036, - ascending_node_longitude: 0.008022659889281067, - periapsis_argument: 1.245060173584828, - mean_anomaly: 0.44047943792316746, - }); - - assert!(approx_equal(orbit, &expected_orbit, 1e-14)); - assert_relative_eq!(best_rms, 55.14810894219461, epsilon = 1e-14); - } -} diff --git a/src/observations/triplets_generator.rs b/src/observations/triplets_generator.rs deleted file mode 100644 index 3ff4cb9..0000000 --- a/src/observations/triplets_generator.rs +++ /dev/null @@ -1,323 +0,0 @@ -//! # IOD Triplet Index Generator (lazy, windowed by time span) -//! -//! Streams **reduced indices** `(first, middle, last)` of time-feasible IOD triplets, -//! after sorting and downsampling the input observations. This is intended to be the -//! *index-level* counterpart of a Gauss triplet generator, allowing clients to compute -//! their own heuristics (e.g., a spacing weight) before materializing actual `GaussObs`. -//! -//! ## What “reduced indices” means -//! The generator first downsamples the full observation set to a smaller, **time-sorted** -//! subset (the “reduced” view). Indices yielded by the iterator refer to this reduced -//! view; use `selected_original_indices()` to map **reduced → original** indices. -//! -//! ## Feasibility constraints -//! For each yielded triplet `(first, middle, last)` with `first < middle < last`, the -//! following time-span constraint holds: -//! -//! ```text -//! dt_min ≤ t[last] - t[first] ≤ dt_max -//! ``` -//! -//! where `t[*]` are the **reduced** epochs (same units as your observation times, e.g. TT/MJD). -//! -//! ## Why this generator? -//! - **Lazy**: no intermediate `Vec` of triplets; you can start consuming immediately. -//! - **Better complexity**: a per-anchor two-pointer window on `last` reduces the -//! effective cost towards ~`O(n²)` in typical time distributions (vs. `O(n³)` brute force). -//! - **No overlapping borrows**: the generator **owns** its internal buffers (times, -//! mapping), so you can iterate without holding long-lived borrows of the input. -//! -//! ## Typical flow -//! 1. Build with `TripletIndexGenerator::from_observations(...)` (sorts + downsamples). -//! 2. Iterate lazily over `(i, j, k)` **reduced** indices. -//! 3. If needed, map to originals via `gen.selected_original_indices()[i]`. -//! 4. (Optional) Compute a heuristic (e.g., spacing weight) and keep the best-K with a heap. -//! 5. Only then materialize full `GaussObs` from the original observations. -//! -//! ## Invariants per anchor -//! - `first < middle < last` always holds. -//! - For the current `first`, the **feasible window** for `last` is -//! `[last_lower_bound_reduced_idx, last_upper_bound_reduced_idx]` such that -//! `dt_min ≤ t[last] - t[first] ≤ dt_max`. -//! - The initial `middle` is `first + 1` and the initial `last` is -//! `max(last_lower_bound_reduced_idx, middle + 1)`. -//! -//! ## Complexity -//! - Time: typically ~`O(n²)` (one window sweep per anchor). -//! - Space: `O(1)` per yielded triplet (the generator holds only the reduced times and mapping). -//! -//! ## Notes & pitfalls -//! - `dt_min`/`dt_max` must be in the **same time units** as your observation epochs. -//! - If `dt_min > dt_max` or there are fewer than 3 reduced observations, the iterator is empty. -//! - The generator **does not** impose any ordering by a heuristic (e.g., “optimal interval”); -//! it only ensures time-feasibility. Best-first strategies should be layered on top. -//! -//! ## See also -//! - A Gauss triplet generator that yields `GaussObs` and can be combined with Monte-Carlo -//! perturbations (`realizations_iter`). -//! - An IOD search routine (e.g., `estimate_best_orbit`) that consumes indices for early-stop. - -use crate::observations::{triplets_iod::downsample_uniform_with_edges_indices, Observations}; - -/// Stream-only generator of **reduced indices** `(first, middle, last)` -/// for time-feasible IOD triplets. -/// -/// Arguments -/// ----------------- -/// * `dt_min` – Minimum allowed time span between **first** and **last**. -/// * `dt_max` – Maximum allowed time span between **first** and **last**. -/// * `max_triplets_to_yield` – Optional cap on the number of yielded triplets (use `usize::MAX` for no cap). -/// -/// Return -/// ---------- -/// * Implements `Iterator` where the tuple contains -/// **reduced** indices `(first, middle, last)`. -/// -/// See also -/// ------------ -/// * [`selected_original_indices`](TripletIndexGenerator::selected_original_indices) – Map reduced indices back to original ones. -/// * A `GaussObs`-level generator if you need full triplets instead of indices. -pub struct TripletIndexGenerator { - /// Map reduced index → original index (owned). - reduced_to_original_index: Vec, - /// Epochs of the reduced observations (same units as input times; owned). - reduced_epochs_tt_mjd: Vec, - - /// Current **first** (anchor) index in reduced space. - first_reduced_idx: usize, - /// Current **middle** index in reduced space. - middle_reduced_idx: usize, - /// Current **last** index in reduced space. - last_reduced_idx: usize, - - /// Lower bound (inclusive) for `last` given the current `first`. - last_lower_bound_reduced_idx: usize, - /// Upper bound (inclusive) for `last` given the current `first`. - last_upper_bound_reduced_idx: usize, - - /// Number of reduced observations. - reduced_len: usize, - - /// Time-window constraints on `(first, last)`. - dt_min: f64, - dt_max: f64, - - /// Count of triplets yielded so far (monotonic). - yielded_triplets_count: usize, - /// Hard cap on the number of triplets to yield. - max_triplets_to_yield: usize, -} - -impl TripletIndexGenerator { - /// Build a generator from a full observation set: - /// - Sorts by time (in-place), - /// - Downsamples to at most `max_obs_for_triplets`, - /// - Caches reduced epochs and the reduced→original mapping (both **owned**), - /// - Positions on the first feasible window if any. - /// - /// Arguments - /// ----------------- - /// * `observations` – The full set; will be **sorted by time in place**. - /// * `dt_min`, `dt_max` – Time-span constraints on `(first, last)`. - /// * `max_obs_for_triplets` – Downsampling cap (uniform with edges). - /// * `max_triplets_to_yield` – Upper bound on yielded triplets (`usize::MAX` for no cap). - /// - /// Return - /// ---------- - /// * A `TripletIndexGenerator` positioned at the first feasible window, - /// or “empty” if fewer than 3 reduced observations remain. - /// - /// See also - /// ------------ - /// * [`TripletIndexGenerator::selected_original_indices`] - /// * [`TripletIndexGenerator::reduced_times`] - pub fn from_observations( - observations: &mut Observations, - dt_min: f64, - dt_max: f64, - max_obs_for_triplets: usize, - max_triplets_to_yield: usize, - ) -> Self { - // 1) Sort by epoch (ascending) - observations.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap()); - - // 2) Downsample → keep indices only (no long borrows) - let reduced_to_original_index = - downsample_uniform_with_edges_indices(observations.len(), max_obs_for_triplets); - - // 3) Cache reduced epochs (owned) aligned with reduced indices - let reduced_epochs_tt_mjd: Vec = reduced_to_original_index - .iter() - .map(|&orig| observations[orig].time) - .collect(); - - let reduced_len = reduced_epochs_tt_mjd.len(); - - // Initialize; the precise window is set by `init_last_window_for_first` - let mut gen = Self { - reduced_to_original_index, - reduced_epochs_tt_mjd, - first_reduced_idx: 0, - middle_reduced_idx: 1, - last_reduced_idx: 2, - last_lower_bound_reduced_idx: 2, - last_upper_bound_reduced_idx: 1, // sentinel; will be recomputed - reduced_len, - dt_min, - dt_max, - yielded_triplets_count: 0, - max_triplets_to_yield, - }; - - if gen.reduced_len >= 3 { - gen.init_last_window_for_first(); - } - gen - } - - /// Access the reduced→original index mapping. - /// - /// Return - /// ---------- - /// * A slice of original indices; `selected_original_indices()[r]` maps reduced `r` → original. - /// - /// See also - /// ------------ - /// * [`TripletIndexGenerator::reduced_times`] - pub fn selected_original_indices(&self) -> &[usize] { - &self.reduced_to_original_index - } - - /// Access the reduced epochs (TT/MJD). - /// - /// Return - /// ---------- - /// * A slice of epochs aligned with reduced indices. - pub fn reduced_times(&self) -> &[f64] { - &self.reduced_epochs_tt_mjd - } - - /// Recompute the feasible `last` window `[lower, upper]` for the current `first`. - /// - /// Invariants - /// ----------------- - /// * `lower` is the earliest `last` with `t[last] - t[first] ≥ dt_min`. - /// * `upper` is the latest `last` with `t[last] - t[first] ≤ dt_max`. - /// * `middle = first + 1`. - /// * `last = max(lower, middle + 1)`. - fn init_last_window_for_first(&mut self) { - let first = self.first_reduced_idx; - - // Lower bound (earliest last satisfying the min span; need one middle in (first, last)) - let mut lower = first + 2; - while lower < self.reduced_len - && (self.reduced_epochs_tt_mjd[lower] - self.reduced_epochs_tt_mjd[first]) < self.dt_min - { - lower += 1; - } - - // Upper bound (latest last satisfying the max span) - let mut upper = lower.saturating_sub(1).max(first + 1); - while (upper + 1) < self.reduced_len - && (self.reduced_epochs_tt_mjd[upper + 1] - self.reduced_epochs_tt_mjd[first]) - <= self.dt_max - { - upper += 1; - } - - self.last_lower_bound_reduced_idx = lower; - self.last_upper_bound_reduced_idx = upper; - - self.middle_reduced_idx = first + 1; - self.last_reduced_idx = self - .last_lower_bound_reduced_idx - .max(self.middle_reduced_idx + 1); - } - - /// Move to the next `first` anchor and refresh its feasible `last` window. - /// - /// Return - /// ---------- - /// * `true` if there are still enough reduced observations to form a triplet; `false` otherwise. - fn advance_first_anchor(&mut self) -> bool { - self.first_reduced_idx += 1; - if self.first_reduced_idx + 2 >= self.reduced_len { - return false; - } - self.init_last_window_for_first(); - true - } - - /// Whether the current `(first, middle, last)` window is empty. - /// - /// Return - /// ---------- - /// * `true` if the time-feasible window is empty or invalid; `false` otherwise. - fn last_window_is_empty(&self) -> bool { - self.last_lower_bound_reduced_idx >= self.reduced_len - || self.last_lower_bound_reduced_idx <= self.first_reduced_idx + 1 - || self.last_upper_bound_reduced_idx <= self.first_reduced_idx + 1 - || self.last_lower_bound_reduced_idx > self.last_upper_bound_reduced_idx - } -} - -impl Iterator for TripletIndexGenerator { - type Item = (usize, usize, usize); // reduced indices (first, middle, last) - - fn next(&mut self) -> Option { - // Respect the optional global cap. - if self.yielded_triplets_count >= self.max_triplets_to_yield { - return None; - } - - while self.first_reduced_idx + 2 < self.reduced_len { - // If the current last-window is empty, move to the next anchor. - if self.last_window_is_empty() { - if !self.advance_first_anchor() { - return None; - } - continue; - } - - // If `middle` reached the upper bound, switch to the next `first`. - if self.middle_reduced_idx >= self.last_upper_bound_reduced_idx { - if !self.advance_first_anchor() { - return None; - } - continue; - } - - // Ensure `last` is within the feasible window and respects `middle < last`. - if self.last_reduced_idx < self.last_lower_bound_reduced_idx - || self.last_reduced_idx <= self.middle_reduced_idx - { - self.last_reduced_idx = self - .last_lower_bound_reduced_idx - .max(self.middle_reduced_idx + 1); - } - - // If `last` exceeded the window, advance `middle` and reset `last`. - if self.last_reduced_idx > self.last_upper_bound_reduced_idx { - self.middle_reduced_idx += 1; - self.last_reduced_idx = self - .last_lower_bound_reduced_idx - .max(self.middle_reduced_idx + 1); - continue; - } - - // We have a feasible triplet (first, middle, last). - let i = self.first_reduced_idx; - let j = self.middle_reduced_idx; - let k = self.last_reduced_idx; - - // Prepare the next candidate by advancing `last`. - self.last_reduced_idx += 1; - - self.yielded_triplets_count += 1; - return Some((i, j, k)); - } - - // No more anchors → enumeration complete. - None - } -} diff --git a/src/observer_extension.rs b/src/observer_extension.rs new file mode 100644 index 0000000..5a23a25 --- /dev/null +++ b/src/observer_extension.rs @@ -0,0 +1,256 @@ +//! Ground-observer geometry: body-fixed and heliocentric position routines. +//! +//! This module provides the [`ResolvedObserver`](crate::observer_extension::ResolvedObserver) trait, which extends +//! [`photom::observer::Observer`] with the geometric computations required +//! before any orbit determination step: +//! +//! - [`ResolvedObserver::earth_fixed_position`](crate::observer_extension::ResolvedObserver::earth_fixed_position) — observer position in the +//! geocentric Earth-fixed frame (AU). +//! - [`ResolvedObserver::earth_fixed_velocity`](crate::observer_extension::ResolvedObserver::earth_fixed_velocity) — observer velocity due to +//! Earth's sidereal rotation (AU/day). +//! - [`ResolvedObserver::pvobs`](crate::observer_extension::ResolvedObserver::pvobs) — geocentric position and velocity in the +//! **ecliptic mean J2000** frame at a given epoch, accounting for Earth +//! rotation, nutation, and precession. +//! - [`ResolvedObserver::helio_position`](crate::observer_extension::ResolvedObserver::helio_position) — heliocentric observer position in +//! the **equatorial mean J2000** frame (AU), formed by adding the Earth's +//! JPL-ephemeris position to the geocentric site vector. + +use hifitime::{ut1::Ut1Provider, Epoch}; +use nalgebra::Vector3; +use ordered_float::NotNan; +use photom::{constants::ERAU, observer::Observer}; + +use crate::{ + cache::{ + observer_centric_cache::{ + ObserverGeocentricPosition, ObserverGeocentricVelocity, ObserverHeliocentricPosition, + ObserverHeliocentricVelocity, + }, + observer_fixed_cache::{ObserverFixedCache, ObserverFixedPosition, ObserverFixedVelocity}, + }, + constants::{EARTH_ROTATION, ROT_ECLMJ2000_TO_EQUMJ2000}, + conversion::ToNotNan, + earth_orientation::equequ, + ref_system::{rotmt, rotpn, RefEpoch, RefSystem}, + time::gmst, + JPLEphem, OutfitError, +}; + +pub trait ResolvedObserver { + /// Get the fixed position of an observatory using its geographic coordinates + /// + /// Return + /// ------ + /// * observer fixed coordinates vector on the Earth (not corrected from Earth motion) + /// * units is AU + fn earth_fixed_position(&self) -> Result; + + /// Get the fixed velocity of an observatory due to Earth rotation, using its geographic coordinates + /// + /// Return + /// ------ + /// * observer fixed velocity vector due to Earth rotation, in the Earth-fixed frame (not corrected from Earth motion) + /// * units is AU/day + fn earth_fixed_velocity(&self) -> Result; + + /// Compute the observer’s geocentric position and velocity in the ecliptic J2000 frame. + /// + /// This function calculates the position and velocity of a ground-based observer relative to the Earth's + /// center of mass, accounting for Earth rotation (via GMST), nutation, and the observer’s geographic location. + /// The result is expressed in the ecliptic mean J2000 frame, suitable for use in orbital initial determination. + /// + /// Arguments + /// --------- + /// * `observer`: a reference to an [`Observer`] containing the site longitude and parallax parameters. + /// * `tmjd`: observation epoch as a [`hifitime::Epoch`] in TT. + /// * `ut1_provider`: a reference to a [`hifitime::ut1::Ut1Provider`] for accurate UT1 conversion. + /// * `compute_velocity`: whether to compute and return the observer's velocity due to Earth's rotation (true) or return a zero vector (false). + /// + /// Returns + /// -------- + /// * `(dx, dv)` – Tuple of: + /// - `dx`: observer geocentric position vector in ecliptic mean J2000 frame \[AU\]. + /// - `dv`: observer velocity vector due to Earth's rotation, in the same frame \[AU/day\]. + /// + /// Remarks + /// ------- + /// * Internally, this function: + /// 1. get the body-fixed coordinates of the observer. + /// 2. get its rotational velocity: `v = ω × r`. + /// 3. Applies Earth orientation corrections using: + /// - Greenwich Mean Sidereal Time (GMST), + /// - Equation of the equinoxes, + /// - Precession and nutation transformation (`rotpn`). + /// 4. Returns position and velocity in the J2000 ecliptic frame (used in classical orbital mechanics). + /// + /// # See also + /// * [`ResolvedObserver::earth_fixed_position`] – observer's base vector in Earth-fixed frame + /// * [`rotpn`] – rotation between reference frames + /// * [`gmst`], [`equequ`] – time-dependent Earth orientation + fn pvobs( + tmjd: &Epoch, + ut1_provider: &Ut1Provider, + observer_fixed_vectors: &ObserverFixedCache, + compute_velocity: bool, + ) -> Result<(ObserverGeocentricPosition, ObserverGeocentricVelocity), OutfitError>; + + /// Compute the observer’s heliocentric position in the **equatorial mean J2000** frame. + /// + /// This method forms the full heliocentric position of the observing site by combining: + /// - the site **geocentric** position vector at `epoch`, and + /// - the Earth’s **heliocentric** position from the JPL ephemerides. + /// + /// The input geocentric vector is assumed to be expressed in the **ecliptic mean J2000** frame + /// (AU). It is rotated to **equatorial mean J2000**, then added to Earth’s heliocentric + /// position (also in equatorial mean J2000). + /// + /// Arguments + /// ----------------- + /// * `jpl` – [`JPLEphem`] providing Earth's heliocentric state. + /// * `epoch` – Observation epoch in the **TT** time scale. + /// * `observer_geocentric_position` – Geocentric site position **in ecliptic mean J2000** (AU). + /// + /// Return + /// ---------- + /// * `Result` – Observer’s **heliocentric** position at `epoch`, + /// in **AU**, expressed in **equatorial mean J2000**. + /// + /// Remarks + /// ------------- + /// * If your geocentric site vector is already in **equatorial** J2000, rotate it to + /// **ecliptic** before calling this method, or adapt the rotation accordingly. + /// * This routine is typically used internally when constructing per-observation geometry + /// (e.g., within `Observation::new`), ensuring consistent frames for Gauss IOD. + /// + /// See also + /// ------------ + /// * [`ResolvedObserver::pvobs`] – Geocentric position (and velocity) of the site at `epoch`. + /// * [`JPLEphem`] – Access Earth's heliocentric state from JPL ephemerides. + /// * [`crate::constants::ROT_ECLMJ2000_TO_EQUMJ2000`] – Rotation between ecliptic and equatorial J2000. + fn helio_position( + jpl: &JPLEphem, + epoch: &Epoch, + observer_geocentric_position: &ObserverGeocentricPosition, + ) -> Result; + + /// Compute the observer’s heliocentric velocity in the **equatorial mean J2000** frame. + /// + /// This method forms the full heliocentric velocity of the observing site by combining: + /// - the site **geocentric** velocity vector at `epoch`, and + /// - the Earth’s **heliocentric** velocity from the JPL ephemerides. + /// + /// # Arguments + /// + /// * `jpl` – [`JPLEphem`] providing Earth's heliocentric state. + /// * `epoch` – Observation epoch in the **TT** time scale. + /// * `observer_geocentric_velocity` – Geocentric site velocity **in ecliptic mean J2000** (AU/day). + /// + /// Return + /// + /// * `Result` – Observer’s **heliocentric** velocity at `epoch` in **AU/day**, expressed in **equatorial mean J2000**. + fn helio_velocity( + jpl: &JPLEphem, + epoch: &Epoch, + observer_geocentric_velocity: &ObserverGeocentricVelocity, + ) -> Result; +} + +impl ResolvedObserver for Observer { + fn earth_fixed_position(&self) -> Result { + let (sin_lon, cos_lon): (NotNan, NotNan) = { + let (s, c) = self.longitude.sin_cos(); + (s.to_notnan()?, c.to_notnan()?) + }; + let erau_not_nan = ERAU.to_notnan()?; + + Ok(Vector3::new( + erau_not_nan * self.rho_cos_phi * cos_lon, + erau_not_nan * self.rho_cos_phi * sin_lon, + erau_not_nan * self.rho_sin_phi, + )) + } + + #[inline] + fn earth_fixed_velocity(&self) -> Result { + Ok(EARTH_ROTATION + .to_notnan()? + .cross(&self.earth_fixed_position()?)) + } + + fn pvobs( + tmjd: &Epoch, + ut1_provider: &Ut1Provider, + observer_fixed_vectors: &ObserverFixedCache, + compute_velocity: bool, + ) -> Result<(ObserverGeocentricPosition, ObserverGeocentricVelocity), OutfitError> { + // Get observer position and velocity in the Earth-fixed frame + let dxbf = observer_fixed_vectors.position(); + + // deviation from Orbfit, use of another conversion from MJD UTC (ET scale) to UT1 scale + // based on the hifitime crate + let mjd_ut1 = tmjd.to_ut1(ut1_provider); + let tut = mjd_ut1.to_mjd_tai_days(); + + // Compute the Greenwich sideral apparent time + let gast = gmst(tut) + equequ(tmjd.to_mjd_tt_days()); + + // Earth rotation matrix + let rot = rotmt(-gast, 2); + + // Compute the rotation matrix from equatorial mean J2000 to ecliptic mean J2000 + let rer_sys1 = RefSystem::Equt(RefEpoch::Epoch(tmjd.to_mjd_tt_days())); + let rer_sys2 = RefSystem::Eclm(RefEpoch::J2000); + let rot1 = rotpn(&rer_sys1, &rer_sys2)?; + + let rot1_mat = rot1.transpose().to_notnan()?; + let rot_mat = rot.transpose().to_notnan()?; + + let rotmat = rot1_mat * rot_mat; + + // Apply transformation to the observer position and velocity + let dx = rotmat * dxbf; + + let dv = if compute_velocity { + let dvbf = observer_fixed_vectors.velocity(); + rotmat * dvbf + } else { + Vector3::zeros() + }; + + Ok((dx, dv)) + } + + fn helio_position( + jpl: &JPLEphem, + epoch: &Epoch, + observer_geocentric_position: &ObserverGeocentricPosition, + ) -> Result { + // Earth's heliocentric position + let earth_pos = jpl.earth_ephemeris(epoch, false).0.to_notnan()?; + + // Transform observer position from ecliptic to equatorial J2000 + let rot_matrix = ROT_ECLMJ2000_TO_EQUMJ2000.to_notnan()?; + + let helio_pos = earth_pos + rot_matrix * observer_geocentric_position; + + Ok(helio_pos) + } + + fn helio_velocity( + jpl: &JPLEphem, + epoch: &Epoch, + observer_geocentric_velocity: &ObserverGeocentricVelocity, + ) -> Result { + // Earth's heliocentric velocity — already in ecliptic J2000, AU/day + let earth_vel = jpl + .earth_ephemeris(epoch, true) + .1 + .expect("Velocity is always available, this should not happen") + .to_notnan()?; + + // geo_velocity is in ecliptic J2000 → rotate to equatorial (same as helio_position) + let rot_matrix = ROT_ECLMJ2000_TO_EQUMJ2000.to_notnan()?; + let helio_vel = earth_vel + rot_matrix * observer_geocentric_velocity; + Ok(helio_vel) + } +} diff --git a/src/observers/bimap.rs b/src/observers/bimap.rs deleted file mode 100644 index dd9ec03..0000000 --- a/src/observers/bimap.rs +++ /dev/null @@ -1,224 +0,0 @@ -use std::collections::HashMap; -use std::hash::Hash; - -#[derive(Debug, Clone)] -pub struct BiMap -where - K: Eq + Hash + Clone, - V: Eq + Hash + Clone, -{ - forward: HashMap, - reverse: HashMap, -} - -impl Default for BiMap -where - K: Eq + Hash + Clone, - V: Eq + Hash + Clone, -{ - fn default() -> Self { - Self::new() - } -} - -impl BiMap -where - K: Eq + Hash + Clone, - V: Eq + Hash + Clone, -{ - pub fn new() -> Self { - Self { - forward: HashMap::new(), - reverse: HashMap::new(), - } - } - - pub fn entry_or_insert_by_key(&mut self, key: K, value: V) -> &mut V { - self.forward.entry(key.clone()).or_insert_with(|| { - self.reverse.insert(value.clone(), key); - value - }) - } - - pub fn entry_or_insert_by_value(&mut self, value: V, key: K) -> &mut K { - self.reverse.entry(value.clone()).or_insert_with(|| { - self.forward.insert(key.clone(), value); - key - }) - } - - pub fn insert(&mut self, key: K, value: V) { - self.forward.insert(key.clone(), value.clone()); - self.reverse.insert(value, key); - } - - pub fn get_by_key(&self, key: &K) -> Option<&V> { - self.forward.get(key) - } - - pub fn get_by_value(&self, value: &V) -> Option<&K> { - self.reverse.get(value) - } - - pub fn remove_by_key(&mut self, key: &K) { - if let Some(val) = self.forward.remove(key) { - self.reverse.remove(&val); - } - } - - pub fn remove_by_value(&mut self, value: &V) { - if let Some(key) = self.reverse.remove(value) { - self.forward.remove(&key); - } - } - - pub fn len(&self) -> usize { - self.forward.len() - } - - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - // --------------------------- - // Iteration helpers (immutable) - // --------------------------- - - /// Iterate over (&K, &V) using the forward map. - /// - /// Return - /// ---------- - /// * An iterator yielding `(&K, &V)` pairs. Order is not guaranteed. - /// - /// See also - /// ------------ - /// * [`BiMap::iter_rev`] - /// * [`BiMap::keys`], [`BiMap::values`] - pub fn iter(&self) -> impl Iterator { - self.forward.iter() - } - - /// Iterate over (&V, &K) using the reverse map. - /// - /// Return - /// ---------- - /// * An iterator yielding `(&V, &K)` pairs. Order is not guaranteed. - /// - /// See also - /// ------------ - /// * [`BiMap::iter`] - pub fn iter_rev(&self) -> impl Iterator { - self.reverse.iter() - } - - /// Iterate over keys (&K). - pub fn keys(&self) -> impl Iterator { - self.forward.keys() - } - - /// Iterate over values (&V). - pub fn values(&self) -> impl Iterator { - self.forward.values() - } -} - -// --------------------------- -// IntoIterator implementations -// --------------------------- - -impl<'a, K, V> IntoIterator for &'a BiMap -where - K: Eq + Hash + Clone, - V: Eq + Hash + Clone, -{ - type Item = (&'a K, &'a V); - type IntoIter = std::collections::hash_map::Iter<'a, K, V>; - - /// Consume `&BiMap` into an iterator over `(&K, &V)` on the forward map. - fn into_iter(self) -> Self::IntoIter { - self.forward.iter() - } -} - -impl<'a, K, V> IntoIterator for &'a mut BiMap -where - K: Eq + Hash + Clone, - V: Eq + Hash + Clone, -{ - type Item = (&'a K, &'a mut V); - type IntoIter = std::collections::hash_map::IterMut<'a, K, V>; - - /// Consume `&mut BiMap` into an iterator over `(&K, &mut V)` on the forward map. - /// - /// Warning - /// ------- - /// Mutating values can desynchronize the reverse map if you change logical identity. - /// Use with care; prefer removing/re-inserting pairs instead. - fn into_iter(self) -> Self::IntoIter { - self.forward.iter_mut() - } -} - -impl IntoIterator for BiMap -where - K: Eq + Hash + Clone, - V: Eq + Hash + Clone, -{ - type Item = (K, V); - type IntoIter = std::collections::hash_map::IntoIter; - - /// Consume the bimap and iterate over owned `(K, V)` pairs using the forward map. - fn into_iter(self) -> Self::IntoIter { - self.forward.into_iter() - } -} - -#[cfg(test)] -mod bimap_iter_tests { - use super::*; - use std::collections::HashSet; - - #[test] - fn iter_and_iter_rev_cover_same_pairs() { - let mut m = BiMap::new(); - m.insert("a", 1); - m.insert("b", 2); - m.insert("c", 3); - - let fwd: HashSet<_> = m.iter().map(|(k, v)| ((*k).to_string(), *v)).collect(); - let rev: HashSet<_> = m.iter_rev().map(|(v, k)| ((*k).to_string(), *v)).collect(); - assert_eq!(fwd, rev); - } - - #[test] - fn into_iterator_by_ref_and_by_value() { - let mut m = BiMap::new(); - m.insert("x", 10); - m.insert("y", 20); - - // &BiMap - let pairs_ref: HashSet<_> = (&m) - .into_iter() - .map(|(k, v)| ((*k).to_string(), *v)) - .collect(); - assert!(pairs_ref.contains(&("x".to_string(), 10))); - assert!(pairs_ref.contains(&("y".to_string(), 20))); - - // BiMap (by value) - let pairs_val: HashSet<_> = m.into_iter().collect(); - assert!(pairs_val.contains(&("x", 10))); - assert!(pairs_val.contains(&("y", 20))); - // m is moved here; no further use - } - - #[test] - fn keys_and_values_match_len() { - let mut m = BiMap::new(); - m.insert(1, "one"); - m.insert(2, "two"); - m.insert(3, "three"); - - assert_eq!(m.len(), m.keys().count()); - assert_eq!(m.len(), m.values().count()); - } -} diff --git a/src/observers/mod.rs b/src/observers/mod.rs deleted file mode 100644 index eb0412d..0000000 --- a/src/observers/mod.rs +++ /dev/null @@ -1,1140 +0,0 @@ -//! # Observer & Site Geometry (top-level module) -//! -//! This module gathers **observer/site handling** and associated geometry utilities used in -//! orbit determination. It provides: -//! -//! - A robust [`Observer`](crate::observers::Observer) type storing **geocentric parallax coordinates** (ρ·cosφ, ρ·sinφ), -//! geodetic longitude, optional astrometric accuracies, and **precomputed body-fixed** position -//! and velocity vectors. -//! - High-level routines to compute the observer’s **geocentric PV** in the ecliptic J2000 frame -//! ([`Observer::pvobs`](crate::observers::Observer::pvobs)) and its **heliocentric equatorial position** ([`Observer::helio_position`](crate::observers::Observer::helio_position)). -//! - Helpers to convert geodetic latitude/elevation to normalized parallax coordinates -//! ([`geodetic_to_parallax`](crate::observers::geodetic_to_parallax)) and to lift optional floats into NaN-safe values -//! ([`to_opt_notnan`](crate::observers::to_opt_notnan)). -//! - A convenience function to compute **three observers’ heliocentric positions** at three epochs -//! in one call ([`helio_obs_pos`](crate::observers::helio_obs_pos)) — useful for Gauss/Vaisala IOD triplets. -//! -//! ## Frames & conventions -//! -//! - **Earth-fixed (body-fixed)**: the frame in which site coordinates and rotation rate are defined. -//! - **Ecliptic mean J2000**: default geocentric output of [`Observer::pvobs`](crate::observers::Observer::pvobs) (ICRS ecliptic plane). -//! - **Equatorial mean J2000**: default heliocentric output of [`Observer::helio_position`](crate::observers::Observer::helio_position) and -//! [`helio_obs_pos`](crate::observers::helio_obs_pos) (ICRS-aligned). -//! -//! ```text -//! Body-fixed --(Earth rotation)--> Earth-equatorial --(precession+nutation)--> Ecliptic J2000 -//! \-> Equatorial J2000 -//! ``` -//! -//! Internally, time-dependent Earth orientation uses **GMST** and the **equation of the equinoxes**, -//! and frame changes are performed via [`rotpn`](crate::ref_system::rotpn) / [`rotmt`](crate::ref_system::rotmt) utilities. -//! -//! ## Units -//! -//! - Longitudes: **degrees** (east positive). -//! - Geocentric parallax (ρ·cosφ, ρ·sinφ): **Earth radii** (dimensionless scaling of geocentric distance). -//! - Positions: **AU**. -//! - Velocities: **AU/day** (from `ω × r`, with `ω = (0, 0, 2π·1.00273790934)` rad/day). -//! - `ra_accuracy`, `dec_accuracy`: **radians**. -//! -//! ## Data flow (typical IOD usage) -//! -//! 1. Build an [`Observer`](crate::observers::Observer) from geodetic inputs (`longitude`, `latitude`, `elevation`) via -//! [`Observer::new`](crate::observers::Observer::new) (internally calls [`geodetic_to_parallax`](crate::observers::geodetic_to_parallax)) **or** from known (ρ·cosφ, ρ·sinφ) -//! via [`Observer::from_parallax`](crate::observers::Observer::from_parallax). -//! 2. Compute geocentric PV in **ecliptic J2000** with [`Observer::pvobs`](crate::observers::Observer::pvobs) (needs UT1 provider). -//! 3. Obtain Earth heliocentric state from JPL ephemerides and sum to get the observer’s -//! **heliocentric equatorial** position with [`Observer::helio_position`](crate::observers::Observer::helio_position). -//! 4. For triplets, call [`helio_obs_pos`](crate::observers::helio_obs_pos) to get the 3×3 matrix of heliocentric positions. -//! -//! ## Quick start -//! -//! ```rust,no_run -//! use hifitime::{Epoch, TimeScale}; -//! use nalgebra::{Vector3, Matrix3}; -//! use outfit::outfit::Outfit; -//! use outfit::error_models::ErrorModel; -//! use outfit::observers::{Observer, helio_obs_pos}; -//! -//! // 1) Environment (JPL ephem + UT1) and site -//! let state = Outfit::new("horizon:DE440", ErrorModel::FCCT14)?; -//! let site = Observer::new(203.74409, 20.707233557, 3067.694, Some("Pan-STARRS 1".into()), None, None)?; -//! -////! // 2) Geocentric PV (ecliptic J2000) -//! let t = Epoch::from_mjd_in_time_scale(57028.479297592596, TimeScale::TT); -//! let (_x_ecl, _v_ecl) = site.pvobs(&t, state.get_ut1_provider())?; -//! -//! // 3) Heliocentric position (equatorial J2000) -//! let r_helio_eq = site.helio_position(&state, &t, &Vector3::identity())?; -//! -//! // 4) Batch (3 observers, 3 epochs) -//! let tmjd = Vector3::new(57028.479297592596, 57049.24514759259, 57063.97711759259); -//! let R: Matrix3 = helio_obs_pos([&site, &site, &site], &tmjd, &state)?; -//! # Ok::<(), outfit::outfit_errors::OutfitError>(()) -//! ``` -//! -//! ## Design & invariants -//! -//! - [`Observer`](crate::observers::Observer) stores **precomputed body-fixed** position and velocity to avoid recomputing -//! `ω × r` and trigonometric terms at every call. This is beneficial in tight IOD loops. -//! - `NotNan` is used for fields where **NaN must be forbidden** (e.g., site geometry). -//! Use [`to_opt_notnan`](crate::observers::to_opt_notnan) for optional measurement accuracies. -//! - The geodetic-to-parallax conversion accounts for Earth oblateness via -//! [`EARTH_MAJOR_AXIS`](crate::constants::EARTH_MAJOR_AXIS) / [`EARTH_MINOR_AXIS`](crate::constants::EARTH_MINOR_AXIS). -//! -//! ## Errors -//! -//! - Constructors and helpers return [`OutfitError`](crate::outfit_errors::OutfitError) when NaNs are encountered or a frame -//! conversion fails; [`to_opt_notnan`](crate::observers::to_opt_notnan) returns `ordered_float::FloatIsNan` if given `Some(NaN)`. -//! -//! ## Testing -//! -//! The module includes unit tests for site construction, body-fixed coordinates, -//! geocentric PV against known values, and multi-epoch heliocentric positions (behind the -//! `jpl-download` feature). -//! -//! ## See also -//! ------------ -//! * [`Observer`](crate::observers::Observer) – Site container with precomputed body-fixed state. -//! * [`Observer::pvobs`](crate::observers::Observer::pvobs) – Geocentric PV in **ecliptic J2000**. -//! * [`Observer::helio_position`](crate::observers::Observer::helio_position) – Heliocentric **equatorial J2000** position. -//! * [`helio_obs_pos`](crate::observers::helio_obs_pos) – Batch heliocentric positions for triplets. -//! * [`geodetic_to_parallax`](crate::observers::geodetic_to_parallax) – Geodetic latitude/elevation → (ρ·cosφ, ρ·sinφ). -//! * [`rotpn`](crate::ref_system::rotpn), [`rotmt`](crate::ref_system::rotmt) – Reference-frame transformations. -//! * [`gmst`](crate::time::gmst), [`equequ`](crate::earth_orientation::equequ) – Earth orientation (sidereal time & equation of equinoxes). -//! * [`Outfit`](crate::outfit::Outfit) – Access to JPL ephemerides and UT1 provider. - -pub mod bimap; -pub mod observatories; - -use hifitime::ut1::Ut1Provider; -use hifitime::Epoch; -use nalgebra::{Matrix3, Vector3}; -use ordered_float::NotNan; - -use crate::constants::{Degree, Meter, EARTH_MAJOR_AXIS, EARTH_MINOR_AXIS, MJD}; -use crate::constants::{DPI, ERAU}; -use crate::earth_orientation::equequ; -use crate::outfit::Outfit; -use crate::outfit_errors::OutfitError; -use crate::ref_system::{rotmt, rotpn, RefEpoch, RefSystem}; -use crate::time::gmst; -use std::fmt; - -/// Convert an `Option` into an `Option>`, propagating `NaN` as an error. -/// -/// This helper lifts a possibly missing floating-point value into a `NotNan` container -/// while keeping the outer `Option`. If the inner value is `Some(x)` and `x.is_nan()`, -/// the function returns `Err(FloatIsNan)`. If it is `None`, the result is `Ok(None)`. -/// -/// Arguments -/// ----------------- -/// * `x`: The optional floating-point value to wrap. -/// -/// Return -/// ---------- -/// * A `Result` containing `Some(NotNan)` when `x` is finite, `Ok(None)` when `x` is `None`, -/// or an error if `x` is `NaN`. -/// -/// Errors -/// ---------- -/// * `ordered_float::FloatIsNan` if `x` is `Some(NaN)`. -/// -/// See also -/// ------------ -/// * [`ordered_float::NotNan`] – NaN-forbidding wrapper used across the crate. -#[inline] -pub fn to_opt_notnan(x: Option) -> Result>, ordered_float::FloatIsNan> { - x.map(NotNan::new).transpose() -} - -/// Observer geocentric parameters and precomputed body-fixed state. -/// -/// This struct stores: -/// - The observer's **geocentric parallax coordinates** (ρ·cosφ, ρ·sinφ), where ρ is the -/// geocentric distance in **Earth radii** and φ is the **geocentric** latitude. -/// - The **geodetic longitude** (degrees, east of Greenwich). -/// - Optional **astrometric accuracies** for right ascension and declination (radians). -/// - Precomputed **body-fixed** position and velocity vectors used in orbit determination. -/// -/// Units -/// ----- -/// * `longitude`: degrees (east positive). -/// * `rho_cos_phi`, `rho_sin_phi`: Earth radii (dimensionless scale factor ρ times trig of φ). -/// * `observer_fixed_coord`: astronomical units (AU). -/// * `observer_velocity`: AU/day (from Earth rotation cross product). -/// * `ra_accuracy`, `dec_accuracy`: radians. -/// -/// Notes -/// ----- -/// The precomputed body-fixed vectors assume a constant Earth rotation rate -/// ω = (0, 0, 2π·1.00273790934) rad/day. Position is scaled by `ERAU` (Earth radius in AU), -/// hence the resulting velocity is in AU/day. -/// -/// See also -/// ------------ -/// * [`geodetic_to_parallax`] – Converts geodetic latitude/elevation to (ρ·cosφ, ρ·sinφ). -/// * [`Observer::new`] – Construct from geodetic longitude/latitude/elevation. -/// * [`Observer::from_parallax`] – Construct directly from (ρ·cosφ, ρ·sinφ). -/// * [`crate::ref_system::rotpn`] – Reference frame rotations used elsewhere in the pipeline. -#[derive(Debug, PartialEq, Eq, Hash, Clone)] -pub struct Observer { - /// Geodetic longitude in **degrees** east of Greenwich. - pub longitude: NotNan, - - /// ρ·cosφ (geocentric latitude φ), in **Earth radii** (dimensionless scale). - pub rho_cos_phi: NotNan, - - /// ρ·sinφ (geocentric latitude φ), in **Earth radii** (dimensionless scale). - pub rho_sin_phi: NotNan, - - /// Optional human-readable site name. - pub name: Option, - - /// Right ascension measurement accuracy, in **radians** (optional). - pub ra_accuracy: Option>, - - /// Declination measurement accuracy, in **radians** (optional). - pub dec_accuracy: Option>, - - /// Precomputed **body-fixed** position of the observer in **AU**. - observer_fixed_coord: Vector3>, - - /// Precomputed **body-fixed** velocity of the observer in **AU/day**. - observer_velocity: Vector3>, -} - -impl Observer { - /// Create a new observer from geodetic coordinates. - /// - /// This constructor converts `(latitude, elevation)` into geocentric parallax - /// coordinates `(ρ·cosφ, ρ·sinφ)` using [`geodetic_to_parallax`], builds the - /// body-fixed position vector in **AU** (scaled by `ERAU`), and computes the - /// body-fixed velocity as `ω × r` with `ω = (0, 0, 2π·1.00273790934)` in rad/day, - /// yielding **AU/day**. - /// - /// Arguments - /// ----------------- - /// * `longitude`: Geodetic longitude in **degrees** (east positive). - /// * `latitude`: Geodetic latitude in **degrees**. - /// * `elevation`: Height above the reference ellipsoid in **meters**. - /// * `name`: Optional site name. - /// * `ra_accuracy`: Optional RA accuracy in **radians**. - /// * `dec_accuracy`: Optional DEC accuracy in **radians**. - /// - /// Return - /// ---------- - /// * A constructed [`Observer`] with precomputed body-fixed state. - /// - /// Errors - /// ---------- - /// * [`OutfitError`] if the inputs cannot be represented as `NotNan` (e.g., NaN encountered). - /// - /// See also - /// ------------ - /// * [`geodetic_to_parallax`] – Geodetic-to-geocentric parallax conversion. - /// * [`Observer::from_parallax`] – Build directly from (ρ·cosφ, ρ·sinφ). - /// * [`crate::ref_system::rotpn`] – Frame rotation utilities used later in the pipeline. - pub fn new( - longitude: Degree, - latitude: Degree, - elevation: Meter, - name: Option, - ra_accuracy: Option, - dec_accuracy: Option, - ) -> Result { - let (rho_cos_phi, rho_sin_phi) = geodetic_to_parallax(latitude, elevation); - - // Angular velocity of Earth rotation (rad/day) on the z-axis. - let omega: Vector3> = Vector3::new( - NotNan::new(0.0)?, - NotNan::new(0.0)?, - NotNan::new(DPI * 1.00273790934)?, - ); - - // Body-fixed position in AU from (ρ·cosφ, ρ·sinφ) scaled by Earth radius (AU). - let lon_radians = longitude.to_radians(); - let body_fixed_coord: Vector3> = Vector3::new( - NotNan::new(ERAU * rho_cos_phi * lon_radians.cos())?, - NotNan::new(ERAU * rho_cos_phi * lon_radians.sin())?, - NotNan::new(ERAU * rho_sin_phi)?, - ); - - // Body-fixed velocity from Earth rotation. - let dvbf = omega.cross(&body_fixed_coord); - - Ok(Observer { - longitude: NotNan::try_from(longitude)?, - rho_cos_phi: NotNan::try_from(rho_cos_phi)?, - rho_sin_phi: NotNan::try_from(rho_sin_phi)?, - name, - ra_accuracy: to_opt_notnan(ra_accuracy)?, - dec_accuracy: to_opt_notnan(dec_accuracy)?, - observer_fixed_coord: body_fixed_coord, - observer_velocity: dvbf, - }) - } - - /// Create a new observer from geocentric parallax coordinates. - /// - /// This constructor skips the geodetic-to-parallax conversion and directly uses - /// `(ρ·cosφ, ρ·sinφ)` to build the body-fixed position in **AU** (scaled by `ERAU`), - /// and the body-fixed velocity in **AU/day** as `ω × r` with - /// `ω = (0, 0, 2π·1.00273790934)` rad/day. - /// - /// Arguments - /// ----------------- - /// * `longitude`: Geodetic longitude in **degrees** (east positive). - /// * `rho_cos_phi`: ρ·cosφ in **Earth radii** (dimensionless). - /// * `rho_sin_phi`: ρ·sinφ in **Earth radii** (dimensionless). - /// * `name`: Optional site name. - /// * `ra_accuracy`: Optional RA accuracy in **radians**. - /// * `dec_accuracy`: Optional DEC accuracy in **radians**. - /// - /// Return - /// ---------- - /// * A constructed [`Observer`] with precomputed body-fixed state. - /// - /// Errors - /// ---------- - /// * [`OutfitError`] if inputs cannot be represented as `NotNan` (e.g., NaN encountered). - /// - /// See also - /// ------------ - /// * [`Observer::new`] – Build from geodetic latitude and elevation. - /// * [`geodetic_to_parallax`] – For the forward conversion when geodetic inputs are available. - pub fn from_parallax( - longitude: Degree, - rho_cos_phi: f64, - rho_sin_phi: f64, - name: Option, - ra_accuracy: Option, - dec_accuracy: Option, - ) -> Result { - // Angular velocity of Earth rotation (rad/day) on the z-axis. - let omega: Vector3> = Vector3::new( - NotNan::new(0.0)?, - NotNan::new(0.0)?, - NotNan::new(DPI * 1.00273790934)?, - ); - - // Body-fixed position in AU from (ρ·cosφ, ρ·sinφ) scaled by Earth radius (AU). - let lon_radians = longitude.to_radians(); - let body_fixed_coord: Vector3> = Vector3::new( - NotNan::new(ERAU * rho_cos_phi * lon_radians.cos())?, - NotNan::new(ERAU * rho_cos_phi * lon_radians.sin())?, - NotNan::new(ERAU * rho_sin_phi)?, - ); - - // Body-fixed velocity from Earth rotation. - let dvbf = omega.cross(&body_fixed_coord); - - Ok(Observer { - longitude: NotNan::try_from(longitude)?, - rho_cos_phi: NotNan::try_from(rho_cos_phi)?, - rho_sin_phi: NotNan::try_from(rho_sin_phi)?, - name, - ra_accuracy: to_opt_notnan(ra_accuracy)?, - dec_accuracy: to_opt_notnan(dec_accuracy)?, - observer_fixed_coord: body_fixed_coord, - observer_velocity: dvbf, - }) - } - - /// Get the fixed position of an observatory using its geographic coordinates - /// - /// Argument - /// -------- - /// * longitude: observer longitude in Degree - /// * latitude: observer latitude in Degree - /// * height: observer height in Degree - /// - /// Return - /// ------ - /// * observer fixed coordinates vector on the Earth (not corrected from Earth motion) - /// * units is AU - pub fn body_fixed_coord(&self) -> Vector3 { - let lon_radians = self.longitude.to_radians(); - - Vector3::new( - ERAU * self.rho_cos_phi.into_inner() * lon_radians.cos(), - ERAU * self.rho_cos_phi.into_inner() * lon_radians.sin(), - ERAU * self.rho_sin_phi.into_inner(), - ) - } - - /// Compute the observer’s geocentric position and velocity in the ecliptic J2000 frame. - /// - /// This function calculates the position and velocity of a ground-based observer relative to the Earth's - /// center of mass, accounting for Earth rotation (via GMST), nutation, and the observer’s geographic location. - /// The result is expressed in the ecliptic mean J2000 frame, suitable for use in orbital initial determination. - /// - /// Arguments - /// --------- - /// * `observer`: a reference to an [`Observer`] containing the site longitude and parallax parameters. - /// * `tmjd`: observation epoch as a [`hifitime::Epoch`] in TT. - /// * `ut1_provider`: a reference to a [`hifitime::ut1::Ut1Provider`] for accurate UT1 conversion. - /// - /// Returns - /// -------- - /// * `(dx, dv)` – Tuple of: - /// - `dx`: observer geocentric position vector in ecliptic mean J2000 frame \[AU\]. - /// - `dv`: observer velocity vector due to Earth's rotation, in the same frame \[AU/day\]. - /// - /// Remarks - /// ------- - /// * Internally, this function: - /// 1. get the body-fixed coordinates of the observer. - /// 2. get its rotational velocity: `v = ω × r`. - /// 3. Applies Earth orientation corrections using: - /// - Greenwich Mean Sidereal Time (GMST), - /// - Equation of the equinoxes, - /// - Precession and nutation transformation (`rotpn`). - /// 4. Returns position and velocity in the J2000 ecliptic frame (used in classical orbital mechanics). - /// - /// # See also - /// * [`Observer::body_fixed_coord`] – observer's base vector in Earth-fixed frame - /// * [`rotpn`] – rotation between reference frames - /// * [`gmst`], [`equequ`] – time-dependent Earth orientation - pub fn pvobs( - &self, - tmjd: &Epoch, - ut1_provider: &Ut1Provider, - ) -> Result<(Vector3, Vector3), OutfitError> { - // Get observer position and velocity in the Earth-fixed frame - let dxbf = self.observer_fixed_coord.map(|x| x.into_inner()); - let dvbf = self.observer_velocity.map(|x| x.into_inner()); - - // deviation from Orbfit, use of another conversion from MJD UTC (ET scale) to UT1 scale - // based on the hifitime crate - let mjd_ut1 = tmjd.to_ut1(ut1_provider); - let tut = mjd_ut1.to_mjd_tai_days(); - - // Compute the Greenwich sideral apparent time - let gast = gmst(tut) + equequ(tmjd.to_mjd_tt_days()); - - // Earth rotation matrix - let rot = rotmt(-gast, 2); - - // Compute the rotation matrix from equatorial mean J2000 to ecliptic mean J2000 - let rer_sys1 = RefSystem::Equt(RefEpoch::Epoch(tmjd.to_mjd_tt_days())); - let rer_sys2 = RefSystem::Eclm(RefEpoch::J2000); - let rot1 = rotpn(&rer_sys1, &rer_sys2)?; - - let rot1_mat = rot1.transpose(); - let rot_mat = rot.transpose(); - - let rotmat = rot1_mat * rot_mat; - - // Apply transformation to the observer position and velocity - let dx = rotmat * dxbf; - let dv = rotmat * dvbf; - - Ok((dx, dv)) - } - - /// Compute the observer’s heliocentric position in the **equatorial mean J2000** frame. - /// - /// This method forms the full heliocentric position of the observing site by combining: - /// - the site **geocentric** position vector at `epoch`, and - /// - the Earth’s **heliocentric** position from the JPL ephemerides. - /// - /// The input geocentric vector is assumed to be expressed in the **ecliptic mean J2000** frame - /// (AU). It is rotated to **equatorial mean J2000**, then added to Earth’s heliocentric - /// position (also in equatorial mean J2000). - /// - /// Arguments - /// ----------------- - /// * `state` – [`Outfit`] environment providing JPL ephemerides and frame rotations. - /// * `epoch` – Observation epoch in the **TT** time scale. - /// * `observer_geocentric_position` – Geocentric site position **in ecliptic mean J2000** (AU). - /// - /// Return - /// ---------- - /// * `Result, OutfitError>` – Observer’s **heliocentric** position at `epoch`, - /// in **AU**, expressed in **equatorial mean J2000**. - /// - /// Remarks - /// ------------- - /// * If your geocentric site vector is already in **equatorial** J2000, rotate it to - /// **ecliptic** before calling this method, or adapt the rotation accordingly. - /// * This routine is typically used internally when constructing per-observation geometry - /// (e.g., within `Observation::new`), ensuring consistent frames for Gauss IOD. - /// - /// See also - /// ------------ - /// * [`Observer::pvobs`] – Geocentric position (and velocity) of the site at `epoch`. - /// * [`Outfit::get_jpl_ephem`] – Access Earth’s heliocentric state from JPL ephemerides. - /// * [`Outfit::get_rot_eclmj2000_to_equmj2000`] – Rotation between ecliptic and equatorial J2000. - pub fn helio_position( - &self, - state: &Outfit, - epoch: &Epoch, - observer_geocentric_position: &Vector3, - ) -> Result, OutfitError> { - let jpl = state.get_jpl_ephem().unwrap(); - - // Earth's heliocentric position - let earth_pos = jpl.earth_ephemeris(epoch, false).0; - - // Transform observer position from ecliptic to equatorial J2000 - let rot_matrix = state.get_rot_eclmj2000_to_equmj2000().transpose(); - - Ok(earth_pos + rot_matrix * observer_geocentric_position) - } - - /// Recover geodetic latitude and ellipsoidal height (WGS-84) from parallax constants. - /// - /// Inverts the stored parallax coordinates `(ρ·cosφ, ρ·sinφ)` to the **geodetic** - /// latitude `φ` (degrees) and the ellipsoidal height `h` (meters) above the - /// WGS-84 reference ellipsoid. The computation uses **Bowring’s closed-form** - /// formula (no iteration), which is usually sufficient for double-precision - /// accuracy at the centimeter level or better. - /// - /// Units & model - /// ------------- - /// * Inputs: `ρ·cosφ` and `ρ·sinφ` are dimensionless, expressed in **Earth radii** - /// (normalized by the equatorial radius). They are scaled internally by `a` - /// (the equatorial radius) to meters. - /// * Output: latitude in **degrees**, height in **meters** (ellipsoidal height, not geoid/orthometric). - /// * Ellipsoid: WGS-84 radii from `constants.rs` (`EARTH_MAJOR_AXIS` = `a`, `EARTH_MINOR_AXIS` = `b`). - /// If you prefer exact GRS-80 reproduction, use consistent `b` there; the difference vs WGS-84 is sub-millimetric. - /// - /// Notes - /// ----- - /// * This routine **does not** compute the geodetic longitude; it only returns `(lat, h)`. - /// Your `Observer` already stores the geodetic longitude independently. - /// * Numerical robustness is good across latitudes, including near the poles. - /// * If you require bit-for-bit parity with an external reference using a different ellipsoid, - /// ensure `a`/`b` match that reference. - /// - /// Arguments - /// ----------------- - /// * None. - /// - /// Return - /// ---------- - /// * `(geodetic_latitude_deg, height_meters)` — latitude in degrees, ellipsoidal height in meters. - /// - /// See also - /// ------------ - /// * [`geodetic_to_parallax`] – Forward conversion used at construction. - /// * [`Observer::from_parallax`] – Builds an observer from `(ρ·cosφ, ρ·sinφ)`. - /// * `constants::EARTH_MAJOR_AXIS` / `constants::EARTH_MINOR_AXIS` – Ellipsoid radii used here. - pub fn geodetic_lat_height_wgs84(&self) -> (f64, f64) { - let a = EARTH_MAJOR_AXIS; - let b = EARTH_MINOR_AXIS; - let e2 = 1.0 - (b * b) / (a * a); - let ep2 = (a * a) / (b * b) - 1.0; - - let p = self.rho_cos_phi.into_inner() * a; // distance in equatorial plane [m] - let z = self.rho_sin_phi.into_inner() * a; // z [m] - - // Bowring’s formula: - let theta = (z * a).atan2(p * b); - let st = theta.sin(); - let ct = theta.cos(); - let phi = (z + ep2 * b * st.powi(3)).atan2(p - e2 * a * ct.powi(3)); - - let s = phi.sin(); - let n = a / (1.0 - e2 * s * s).sqrt(); - let h = p / phi.cos() - n; - - (phi.to_degrees(), h) - } -} - -impl fmt::Display for Observer { - /// Pretty-print an observer with optional verbose details using `{:#}` formatting. - /// - /// Default formatting (`{}`) prints a compact one-liner: - /// `Name (lon: XX.XXXXXX°, lat: YY.YYYYYY° geodetic, elev: Z.ZZ km)`. - /// - /// Alternate formatting (`{:#}`) prints a multi-line detailed block including: - /// - Parallax constants `(ρ·cosφ, ρ·sinφ)`, - /// - Geocentric latitude `φ_geo` and geocentric distance `ρ` (Earth radii), - /// - Astrometric 1-σ accuracies in arcseconds (if available). - /// - /// Arguments - /// ----------------- - /// * `self`: The observer to format. - /// - /// Return - /// ---------- - /// * A human-readable representation suitable for logs and diagnostics. - /// - /// See also - /// ------------ - /// * [`Observer::geodetic_lat_height_wgs84`] – Geodetic latitude (deg) and ellipsoidal height (m). - /// * [`geodetic_to_parallax`] – Forward conversion to `(ρ·cosφ, ρ·sinφ)`. - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - // Friendly name - let name = self.name.as_deref().unwrap_or("Unnamed"); - - // Geodetic latitude (deg) and height (m -> km) - let (lat_deg, h_m) = self.geodetic_lat_height_wgs84(); - - // Geodetic longitude in degrees - let lon_deg = self.longitude.into_inner(); - - // Geocentric latitude and ρ from parallax constants - let rc = self.rho_cos_phi.into_inner(); - let rs = self.rho_sin_phi.into_inner(); - let phi_geo_deg = rs.atan2(rc).to_degrees(); - let rho_re = rc.hypot(rs); - - // Astrometric accuracies (radians -> arcseconds) - const RAD2AS: f64 = 206_264.806_247_096_36; - let ra_sigma_as = self - .ra_accuracy - .map(|v| format!("{:.3}″", v.into_inner() * RAD2AS)) - .unwrap_or_else(|| "—".to_string()); - let dec_sigma_as = self - .dec_accuracy - .map(|v| format!("{:.3}″", v.into_inner() * RAD2AS)) - .unwrap_or_else(|| "—".to_string()); - - if f.alternate() { - // Verbose, multi-line format (triggered by "{:#}") - writeln!( - f, - "{name} (lon: {lon_deg:.6}°, lat: {lat_deg:.6}° geodetic, elev: {h_m:.2} m)" - )?; - writeln!( - f, - " parallax: ρ·cosφ={rc:.9}, ρ·sinφ={rs:+.9} | φ_geo={phi_geo_deg:+.6}° ρ={rho_re:.6} RE" - )?; - write!(f, " astrometric 1σ: RA={ra_sigma_as}, DEC={dec_sigma_as}") - } else { - // Compact, single-line format (triggered by "{}") - write!( - f, - "{name} (lon: {lon_deg:.6}°, lat: {lat_deg:.6}° geodetic, elev: {h_m:.2} m)" - ) - } - } -} - -/// Convert geodetic latitude and height into normalized parallax coordinates -/// on the Earth. -/// -/// This transformation is used to compute the observer's position on Earth -/// in a way that accounts for the Earth's oblateness. The resulting values -/// are dimensionless and are expressed in units of the Earth's equatorial -/// radius (`EARTH_MAJOR_AXIS`). -/// -/// Arguments -/// --------- -/// * `lat` - Geodetic latitude of the observer in **radians**. -/// * `height` - Observer's altitude above the reference ellipsoid in **meters**. -/// -/// Returns -/// ------- -/// A tuple `(rho_cos_phi, rho_sin_phi)`: -/// * `rho_cos_phi`: normalized distance of the observer projected on -/// the Earth's equatorial plane. -/// * `rho_sin_phi`: normalized distance of the observer projected on -/// the Earth's rotation (polar) axis. -/// -/// Details -/// ------- -/// The computation uses the reference ellipsoid defined by: -/// * `EARTH_MAJOR_AXIS`: Equatorial radius (m), -/// * `EARTH_MINOR_AXIS`: Polar radius (m). -/// -/// The formula comes from standard geodetic to geocentric conversion: -/// -/// ```text -/// u = atan( (sin φ * (b/a)) / cos φ ) -/// ρ_sinφ = (b/a) * sin u + (h/a) * sin φ -/// ρ_cosφ = cos u + (h/a) * cos φ -/// ``` -/// -/// where `a` and `b` are the Earth's semi-major and semi-minor axes, -/// and `h` is the height above the ellipsoid. -/// -/// See also -/// -------- -/// * [`Observer::body_fixed_coord`] – Uses this function to compute -/// the observer's fixed position in Earth-centered coordinates. -pub fn lat_alt_to_parallax(lat: f64, height: f64) -> (f64, f64) { - // Ratio of the Earth's minor to major axis (flattening factor) - let axis_ratio = EARTH_MINOR_AXIS / EARTH_MAJOR_AXIS; - - // Compute the auxiliary angle u (parametric latitude) - // This corrects for the Earth's oblateness. - let u = (lat.sin() * axis_ratio).atan2(lat.cos()); - - // Compute the normalized distance along the polar axis - let rho_sin_phi = axis_ratio * u.sin() + (height / EARTH_MAJOR_AXIS) * lat.sin(); - - // Compute the normalized distance along the equatorial plane - let rho_cos_phi = u.cos() + (height / EARTH_MAJOR_AXIS) * lat.cos(); - - (rho_cos_phi, rho_sin_phi) -} - -/// Convert geodetic latitude (in degrees) and height (in meters) -/// into normalized parallax coordinates. -/// -/// This is a convenience wrapper around [`lat_alt_to_parallax`] that -/// performs the degrees-to-radians conversion before calling the main -/// function. -/// -/// Arguments -/// --------- -/// * `lat` - Geodetic latitude of the observer in **degrees**. -/// * `height` - Observer's altitude above the reference ellipsoid in **meters**. -/// -/// Returns -/// ------- -/// A tuple `(rho_cos_phi, rho_sin_phi)`: -/// * `rho_cos_phi`: normalized distance of the observer projected on -/// the Earth's equatorial plane. -/// * `rho_sin_phi`: normalized distance of the observer projected on -/// the Earth's rotation (polar) axis. -/// -/// Details -/// ------- -/// This function simply converts `lat` to radians and delegates the -/// computation to [`lat_alt_to_parallax`]. -/// -/// See also -/// -------- -/// * [`lat_alt_to_parallax`] – Performs the actual computation given latitude in radians. -pub fn geodetic_to_parallax(lat: f64, height: f64) -> (f64, f64) { - // Convert latitude from degrees to radians - let latitude_rad = lat.to_radians(); - - // Call the main routine that works with radians - let (rho_cos_phi, rho_sin_phi) = lat_alt_to_parallax(latitude_rad, height); - - (rho_cos_phi, rho_sin_phi) -} - -/// Compute the heliocentric positions of three observers at their respective epochs, -/// expressed in the **equatorial mean J2000** frame (ICRS-aligned). -/// -/// Overview -/// ----------------- -/// This routine builds a `3×3` matrix of observer positions by combining: -/// - the **geocentric site position** of each observer (from [`Observer::pvobs`]), -/// - the Earth’s **heliocentric barycentric position** from JPL ephemerides, -/// - a frame transformation from **ecliptic mean J2000** (site positions) to -/// **equatorial mean J2000** (final output). -/// -/// The result is a compact representation where each column corresponds to one -/// observer/epoch pair: -/// `observers[0] ↔ mjd_tt.x`, -/// `observers[1] ↔ mjd_tt.y`, -/// `observers[2] ↔ mjd_tt.z`. -/// -/// Arguments -/// ----------------- -/// * `observers` – Array of three [`Observer`] references, each encoding the site geometry -/// (longitude, normalized geocentric radius components, etc.). -/// * `mjd_tt` – [`Vector3`] of observation epochs in Terrestrial Time (TT), one per observer. -/// * `state` – [`Outfit`] environment providing: -/// - JPL planetary ephemerides (via [`Outfit::get_jpl_ephem`]), -/// - UT1 provider for Earth rotation/orientation (via [`Outfit::get_ut1_provider`]). -/// -/// Return -/// ---------- -/// * `Result, OutfitError>` – A 3×3 matrix of observer heliocentric positions, with: -/// - **Columns** = `[r₁, r₂, r₃]`, one per observer/epoch, -/// - **Units** = astronomical units (AU), -/// - **Frame** = equatorial mean J2000 (ICRS-aligned). -/// -/// Remarks -/// ------------- -/// * For each observer/time pair: -/// 1. The site’s **geocentric position** is computed via [`Observer::pvobs`] (AU, ecliptic J2000). -/// 2. Earth’s heliocentric position is retrieved from the JPL ephemeris. -/// 3. The site position is rotated into **equatorial mean J2000** using the frame rotation. -/// 4. The Earth + rotated site vectors give the full heliocentric observer position. -/// * This function is mainly used during **Gauss IOD** preparation to populate the -/// observer position matrix stored in [`GaussObs`](crate::initial_orbit_determination::gauss::GaussObs). -/// -/// See also -/// ------------ -/// * [`Observer::pvobs`] – Geocentric observer position at a given epoch (ecliptic J2000). -/// * [`Observer::helio_position`] – Per-observer heliocentric position (equatorial J2000). -/// * [`Outfit::get_jpl_ephem`] – Access to planetary ephemerides (Earth state). -/// * [`Outfit::get_ut1_provider`] – Provides Earth orientation parameters (ΔUT1). -pub fn helio_obs_pos( - observers: [&Observer; 3], - mjd_tt: &Vector3, - state: &Outfit, -) -> Result, OutfitError> { - let epochs = [ - Epoch::from_mjd_in_time_scale(mjd_tt.x, hifitime::TimeScale::TT), - Epoch::from_mjd_in_time_scale(mjd_tt.y, hifitime::TimeScale::TT), - Epoch::from_mjd_in_time_scale(mjd_tt.z, hifitime::TimeScale::TT), - ]; - - let pvobs1 = observers[0].pvobs(&epochs[0], state.get_ut1_provider())?; - let pvobs2 = observers[1].pvobs(&epochs[1], state.get_ut1_provider())?; - let pvobs3 = observers[2].pvobs(&epochs[2], state.get_ut1_provider())?; - - let positions = [ - observers[0].helio_position(state, &epochs[0], &pvobs1.0)?, - observers[1].helio_position(state, &epochs[1], &pvobs2.0)?, - observers[2].helio_position(state, &epochs[2], &pvobs3.0)?, - ]; - - Ok(Matrix3::from_columns(&positions)) -} - -#[cfg(test)] -mod observer_test { - - use crate::{error_models::ErrorModel, outfit::Outfit}; - - use super::*; - - #[test] - fn test_observer_constructor() { - let observer = Observer::new(0.0, 0.0, 0.0, None, None, None).unwrap(); - assert_eq!(observer.longitude, 0.0); - assert_eq!(observer.rho_cos_phi, 1.0); - assert_eq!(observer.rho_sin_phi, 0.0); - - let observer = Observer::new( - 289.25058, - -30.2446, - 2647., - Some("Rubin Observatory".to_string()), - Some(0.0001), - Some(0.0001), - ) - .unwrap(); - - assert_eq!(observer.longitude, 289.25058); - assert_eq!(observer.rho_cos_phi, 0.8649760504617418); - assert_eq!(observer.rho_sin_phi, -0.5009551027512434); - } - - #[test] - fn body_fixed_coord_test() { - // longitude, latitude and height of Pan-STARRS 1, Haleakala - let (lon, lat, h) = (203.744090000, 20.707233557, 3067.694); - let pan_starrs = Observer::new(lon, lat, h, None, None, None).unwrap(); - assert_eq!( - pan_starrs.body_fixed_coord(), - Vector3::new( - -0.00003653799439776371, - -0.00001607260397528885, - 0.000014988110430544328 - ) - ); - - assert_eq!( - pan_starrs.observer_fixed_coord, - Vector3::new( - NotNan::new(-0.00003653799439776371).unwrap(), - NotNan::new(-0.00001607260397528885).unwrap(), - NotNan::new(0.000014988110430544328).unwrap() - ) - ) - } - - #[test] - fn pvobs_test() { - let state = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - let tmjd = 57028.479297592596; - let epoch = Epoch::from_mjd_in_time_scale(tmjd, hifitime::TimeScale::TT); - // longitude, latitude and height of Pan-STARRS 1, Haleakala - let (lon, lat, h) = (203.744090000, 20.707233557, 3067.694); - let pan_starrs = - Observer::new(lon, lat, h, Some("Pan-STARRS 1".to_string()), None, None).unwrap(); - - let (observer_position, observer_velocity) = - &pan_starrs.pvobs(&epoch, state.get_ut1_provider()).unwrap(); - - assert_eq!( - observer_position.as_slice(), - [ - -2.086211182493635e-5, - 3.718476815327979e-5, - 2.4978996447997476e-7 - ] - ); - assert_eq!( - observer_velocity.as_slice(), - [ - -0.0002143246535691577, - -0.00012059801691431748, - 5.262184624215718e-5 - ] - ); - } - - #[test] - fn geodetic_to_parallax_test() { - // latitude and height of Pan-STARRS 1, Haleakala - let (pxy1, pz1) = geodetic_to_parallax(20.707233557, 3067.694); - assert_eq!(pxy1, 0.9362410003211518); - assert_eq!(pz1, 0.35154299856304305); - } - - #[test] - #[cfg(feature = "jpl-download")] - fn test_helio_pos_obs() { - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - let tmjd = Vector3::new( - 57028.479297592596, - 57_049.245_147_592_59, - 57_063.977_117_592_59, - ); - - // longitude, latitude and height of Pan-STARRS 1, Haleakala - let (lon, lat, h) = (203.744090000, 20.707233557, 3067.694); - let pan_starrs = - Observer::new(lon, lat, h, Some("Pan-STARRS 1".to_string()), None, None).unwrap(); - - // Now we need a Vector3 with three identical copies - let observers = [&pan_starrs, &pan_starrs, &pan_starrs]; - - let helio_pos = helio_obs_pos(observers, &tmjd, &OUTFIT_HORIZON_TEST.0).unwrap(); - - assert_eq!( - helio_pos.as_slice(), - [ - -0.2645666171464416, - 0.8689351643701766, - 0.3766996211107864, - -0.5891631852137064, - 0.7238872516824697, - 0.3138186516540669, - -0.7743280306286537, - 0.5612532665812755, - 0.24333415479994636 - ] - ); - } - - #[cfg(test)] - mod geodetic_inverse_tests { - use super::*; - use crate::constants::Degree; - use approx::assert_abs_diff_eq; - - /// Round-trip a single site through (lon, lat, h) -> parallax -> inverse - /// and check that we recover the original geodetic latitude & height. - /// - /// Notes - /// ----- - /// * `Observer::new` is given `h_m` in meters (as per current API usage). - /// * `geodetic_lat_height_wgs84()` returns height in **meters**; we convert to meters. - fn roundtrip_site(name: &str, lon_deg: Degree, lat_deg: Degree, h_m: f64) { - // Build observer (forward: geodetic -> parallax is done inside `Observer::new`) - let obs = Observer::new(lon_deg, lat_deg, h_m, Some(name.to_string()), None, None) - .expect("Failed to create observer"); - - // Inverse: parallax -> geodetic (WGS-84) - let (lat_rec_deg, h_rec_m) = obs.geodetic_lat_height_wgs84(); - - // Tolerances: - // - Latitude: 1e-6 deg (~3.6 mas) – tight but should pass for double precision Bowring + 0–1 Newton step - // - Height: 1e-2 m - let tol_lat_deg = 1e-6; - let tol_h_m = 1e-2; - - assert_abs_diff_eq!(lat_rec_deg, lat_deg, epsilon = tol_lat_deg); - assert_abs_diff_eq!(h_rec_m, h_m, epsilon = tol_h_m); - } - - /// See also - /// ------------ - /// * [`Observer::new`] – Forward geodetic->parallax conversion under test by round-trip. - /// * [`Observer::from_parallax`] – Alternative constructor, if you want to inject ρ·cosφ/ρ·sinφ. - /// * `geodetic_to_parallax` – The forward routine used internally by `Observer::new`. - - #[test] - fn geodetic_roundtrip_known_observatories_wgs84() { - // NOTE: - // The heights below are commonly quoted "above sea level" (orthometric). - // For pure algorithmic round-trip testing, that's acceptable because we feed - // the same height into forward and inverse. If you want strict ellipsoidal - // (h) values, substitute official WGS-84 heights here. - let sites: &[(&str, Degree, Degree, f64)] = &[ - // name, lon_deg (E+), lat_deg (N+), height_m - ("Haleakala (PS1 I41)", -156.2575, 20.7075, 3055.0), - ("Mauna Kea (CFHT)", -155.4700, 19.8261, 4205.0), - ("ESO Paranal", -70.4025, -24.6252, 2635.0), - ("Cerro Pachon (Rubin)", -70.7366, -30.2407, 2663.0), - ("La Silla", -70.7346, -29.2613, 2400.0), - ("Kitt Peak", -111.5967, 31.9583, 2096.0), - ("Roque de los Muchachos", -17.8947, 28.7606, 2396.0), - ]; - - for (name, lon, lat, h_m) in sites.iter().copied() { - roundtrip_site(name, lon, lat, h_m); - } - } - - #[test] - fn geodetic_roundtrip_extremes_equator_and_pole() { - // Equator, sea level - roundtrip_site("Equator (0°, 0 m)", 0.0, 0.0, 0.0); - - // Near-North-Pole and Near-South-Pole with modest height - roundtrip_site("Near North Pole", 0.0, 89.999, 1000.0); - roundtrip_site("Near South Pole", 0.0, -89.999, 1000.0); - } - - #[test] - fn geodetic_roundtrip_high_altitude_and_negative() { - // Very high site (simulate balloon/aircraft) - roundtrip_site("High Alt 10 m", 10.0, 45.0, 10_000.0); - - // Negative height (below ellipsoid, synthetic but tests robustness) - roundtrip_site("Below ellipsoid -50 m", -30.0, -10.0, -50.0); - } - } - - #[cfg(test)] - mod observer_display_tests { - use super::*; - - /// Convert arcseconds to radians. - #[inline] - fn arcsec_to_rad(as_val: f64) -> f64 { - // 1 arcsec = π / (180 * 3600) rad - std::f64::consts::PI / (180.0 * 3600.0) * as_val - } - - /// Helper to build an Observer with optional RA/DEC accuracies (in arcseconds). - fn make_observer_with_acc( - name: Option<&str>, - lon_deg: f64, - lat_deg: f64, - elev_m: f64, - ra_as: Option, - dec_as: Option, - ) -> Observer { - let ra_sigma = ra_as.map(arcsec_to_rad); - let dec_sigma = dec_as.map(arcsec_to_rad); - - Observer::new( - lon_deg, - lat_deg, - elev_m, // elevation in meters - name.map(|s| s.to_string()), - ra_sigma, - dec_sigma, - ) - .expect("Failed to create Observer") - } - - /// Compact formatting must be a single line with name/lon/lat/elev. - #[test] - fn display_compact_single_line() { - let obs = make_observer_with_acc(Some("TestSite"), 10.0, 0.0, 0.0, None, None); - - let s = format!("{obs}"); - // Must not contain newlines in compact form - assert!( - !s.contains('\n'), - "Compact format should be single-line, got:\n{s}" - ); - - // Must contain expected fragments (predictable numbers) - assert!( - s.contains("TestSite (lon: 10.000000°"), - "Missing name/lon fragment. Got:\n{s}" - ); - assert!( - s.contains("lat: 0.000000° geodetic"), - "Missing geodetic latitude fragment. Got:\n{s}" - ); - assert!( - s.contains("elev: 0.00 m"), - "Missing elevation fragment (m). Got:\n{s}" - ); - } - - /// Alternate formatting must be multi-line and include parallax & uncertainties. - #[test] - fn display_verbose_multiline_with_sections() { - let obs = make_observer_with_acc(Some("VerboseSite"), -70.0, -30.0, 2400.0, None, None); - - let s = format!("{obs:#}"); - - // Must contain multiple lines and the expected section headers/fragments - assert!( - s.contains('\n'), - "Verbose format should be multi-line. Got:\n{s}" - ); - assert!( - s.starts_with("VerboseSite (lon: -70.000000°"), - "First line should start with site name and lon. Got:\n{s}" - ); - assert!( - s.contains("\n parallax: ρ·cosφ="), - "Missing 'parallax:' line. Got:\n{s}" - ); - assert!( - s.contains("φ_geo=") && s.contains("ρ="), - "Missing φ_geo/ρ fragments. Got:\n{s}" - ); - assert!( - s.contains("\n astrometric 1σ: RA=—, DEC=—"), - "Missing astrometric 1σ line with em-dashes for None. Got:\n{s}" - ); - } - - /// When RA/DEC accuracies are provided, they must be printed in arcseconds with three decimals. - #[test] - fn display_verbose_shows_ra_dec_sigmas() { - // RA = 1.0″, DEC = 2.5″ (passed in arcseconds, converted to radians internally) - let obs = make_observer_with_acc(Some("AccSite"), 0.0, 0.0, 0.0, Some(1.0), Some(2.5)); - - let s = format!("{obs:#}"); - - assert!( - s.contains("astrometric 1σ: RA=1.000″, DEC=2.500″"), - "Expected RA/DEC sigma in arcseconds with 3 decimals. Got:\n{s}" - ); - } - - /// Name fallback should be "Unnamed" when not provided. - #[test] - fn display_uses_unnamed_when_missing() { - let obs = make_observer_with_acc(None, 5.0, 0.0, 0.0, None, None); - let s = format!("{obs}"); - assert!( - s.starts_with("Unnamed (lon: 5.000000°"), - "Expected 'Unnamed' fallback. Got:\n{s}" - ); - } - - /// Basic numeric sanity: geodetic height is shown in kilometers in the display. - /// For a 3055 m elevation, we expect ~3.055 km (rounded to 2 decimals). - #[test] - fn display_elevation_shown_in_km() { - let obs = make_observer_with_acc( - Some("Haleakala-ish"), - -156.2575, - 20.7075, - 3055.0, - None, - None, - ); - - let s = format!("{obs}"); - // Check the km conversion and rounding only; don't assert on latitude value here. - assert!( - s.contains("elev: 3055.00 m"), - "Expected elevation ~3055 m rounded to 2 decimals. Got:\n{s}" - ); - - // Optional: ensure lon is printed correctly - assert!( - s.contains("lon: -156.257500°"), - "Longitude formatting mismatch. Got:\n{s}" - ); - } - } -} diff --git a/src/observers/observatories.rs b/src/observers/observatories.rs deleted file mode 100644 index 636947b..0000000 --- a/src/observers/observatories.rs +++ /dev/null @@ -1,238 +0,0 @@ -use super::bimap::BiMap; -use super::Observer; -use crate::constants::{Degree, Kilometer, MpcCodeObs}; -use std::{ - fmt, - sync::{Arc, OnceLock}, -}; - -#[derive(Debug, Clone)] -pub(crate) struct Observatories { - pub(crate) mpc_code_obs: OnceLock, - obs_to_uint16: BiMap, u16>, -} - -impl Observatories { - pub(crate) fn new() -> Self { - Observatories { - mpc_code_obs: OnceLock::new(), - obs_to_uint16: BiMap::new(), - } - } - - pub(crate) fn create_observer( - &mut self, - longitude: Degree, - latitude: Degree, - elevation: Kilometer, - name: Option, - ) -> Arc { - let obs = Observer::new(longitude, latitude, elevation, name.clone(), None, None) - .expect("Failed to create observer"); - let arc_observer = Arc::new(obs); - self.obs_to_uint16 - .entry_or_insert_by_key(arc_observer.clone(), self.obs_to_uint16.len() as u16); - arc_observer - } - - pub(crate) fn add_observer(&mut self, observer: Arc) -> u16 { - let obs_idx = self.obs_to_uint16.len() as u16; - *self.obs_to_uint16.entry_or_insert_by_key(observer, obs_idx) - } - - /// Get an observer from an observer index - /// - /// Arguments - /// --------- - /// * `observer_idx`: the observer index - /// - /// Return - /// ------ - /// * The observer - pub(crate) fn get_observer_from_uint16(&self, observer_idx: u16) -> &Observer { - self.obs_to_uint16 - .get_by_value(&observer_idx) - .unwrap_or_else(|| panic!("Observer index not found: {observer_idx}")) - } - - /// Get an observer index from an observer - /// If the observer is not already in the bimap, it is added - /// - /// Arguments - /// --------- - /// * `observer`: the observer - /// - /// Return - /// ------ - /// * The observer index - pub(crate) fn uint16_from_observer(&mut self, observer: Arc) -> u16 { - let obs_idx = self.obs_to_uint16.len() as u16; - *self.obs_to_uint16.entry_or_insert_by_key(observer, obs_idx) - } -} - -impl fmt::Display for Observatories { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.obs_to_uint16.is_empty() { - if self.mpc_code_obs.get().is_none() { - writeln!(f, "No observatories defined (user or MPC).\nTrying to get an observer from the MPC or insert a new one to initialize the observatory list.")?; - return Ok(()); - } else { - writeln!(f, "No user-defined observers.")?; - } - } - - writeln!(f, "User-defined observers:")?; - for obs in self.obs_to_uint16.keys() { - let (lat, height) = obs.geodetic_lat_height_wgs84(); - - writeln!( - f, - " {} (lon: {:.6}°, lat: {:.6}°, elev: {:.2} km)", - obs.name.clone().unwrap_or_else(|| "Unnamed".to_string()), - obs.longitude, - lat, - height - )?; - } - - if let Some(mpc_code_obs) = self.mpc_code_obs.get() { - writeln!(f, "MPC observers:")?; - for (code, obs) in mpc_code_obs.iter() { - let (lat, height) = obs.geodetic_lat_height_wgs84(); - - writeln!( - f, - " {} [{}] (lon: {:.6}°, lat: {:.6}°, elev: {:.2} km)", - obs.name.clone().unwrap_or_else(|| "Unnamed".to_string()), - code, - obs.longitude, - lat, - height - )?; - } - } - - Ok(()) - } -} - -#[cfg(test)] -mod observatories_test { - use super::*; - - #[test] - fn test_observatories() { - let mut observatories = Observatories::new(); - let obs = observatories.create_observer(1.0, 2.0, 3.0, Some("Test".to_string())); - assert_eq!(obs.longitude, 1.0); - assert_eq!(obs.rho_cos_phi, 0.999395371426802); - assert_eq!(obs.rho_sin_phi, 0.0346660237964843); - assert_eq!(obs.name, Some("Test".to_string())); - assert_eq!(observatories.obs_to_uint16.len(), 1); - let observer = observatories.get_observer_from_uint16(0); - assert_eq!(observer.name, Some("Test".to_string())); - - observatories.create_observer(4.0, 5.0, 6.0, Some("Test2".to_string())); - assert_eq!(observatories.obs_to_uint16.len(), 2); - let observer = observatories.get_observer_from_uint16(1); - assert_eq!(observer.name, Some("Test2".to_string())); - } - - #[cfg(test)] - mod observatories_display_tests { - use super::*; - - /// Ensure the "user-defined" section is printed and includes both user observers. - /// - /// Notes - /// ----- - /// * We don't assume any iteration order (HashMap-backed bi-map). - /// * We check for the header and the presence of each observer line fragment. - #[test] - fn display_user_defined_only() { - let mut obs = Observatories::new(); - - // Build two user-defined observers (elevation in kilometers). - obs.create_observer(10.0, 0.0, 0.0, Some("UserA".to_string())); - obs.create_observer(20.0, 45.0, 2.0, Some("UserB".to_string())); - - let s = format!("{obs}"); - - // Header must be present - assert!( - s.starts_with("User-defined observers:\n"), - "Missing 'User-defined observers:' header. Got:\n{s}" - ); - - // Each user observer should be listed with their name and longitude fragment - assert!( - s.contains("UserA (lon: 10.000000°"), - "Missing formatted line for UserA. Got:\n{s}" - ); - assert!( - s.contains("UserB (lon: 20.000000°"), - "Missing formatted line for UserB. Got:\n{s}" - ); - - // The MPC section should not appear if not initialized - assert!( - !s.contains("MPC observers:"), - "Unexpected 'MPC observers:' section when OnceLock is unset. Got:\n{s}" - ); - } - - /// If the MPC table is initialized, ensure the "MPC observers" section appears. - /// - /// Notes - /// ----- - /// * We set the OnceLock with a single entry. - /// * We only check for presence of the section and the MPC code tag. - #[test] - fn display_includes_mpc_section_when_set() { - let mut obs = Observatories::new(); - - // One user-defined observer so the first section is non-empty. - obs.create_observer(0.0, 0.0, 0.0, Some("UserOnly".to_string())); - - // Prepare an MPC observer entry. - let mpc_site = Observer::new( - -156.2575, - 20.7075, - 3.055, - Some("Haleakala".to_string()), - None, - None, - ) - .expect("Failed to create MPC observer"); - - // Build an MpcCodeObs map with a single code. - // If your `MpcCodeObs` is a type alias, this should compile as-is. - // Example: `pub type MpcCodeObs = std::collections::HashMap` (or Arc). - let mut mpc_table: MpcCodeObs = Default::default(); - // Adjust Arc vs Observer depending on your alias: - // If it is `HashMap>`, wrap with `Arc::new(mpc_site)`. - use std::sync::Arc; - mpc_table.insert("I41".to_string(), Arc::new(mpc_site)); - - // Initialize the OnceLock (only once) - obs.mpc_code_obs - .set(mpc_table) - .expect("OnceLock was already initialized"); - - let s = format!("{obs}"); - - // MPC section header must be present now - assert!( - s.contains("MPC observers:"), - "Missing 'MPC observers:' header after setting OnceLock. Got:\n{s}" - ); - - // The code tag should appear in the MPC line - assert!( - s.contains("[I41]"), - "Missing MPC code tag '[I41]' in output. Got:\n{s}" - ); - } - } -} diff --git a/src/orb_elem.rs b/src/orb_elem.rs index 54bbf0d..404f1f1 100644 --- a/src/orb_elem.rs +++ b/src/orb_elem.rs @@ -129,15 +129,19 @@ pub(crate) fn ccek1( let true_anomaly = sin_true_anomaly.atan2(cos_true_anomaly); let periapsis_argument = periapsis_argument_from_true_anomaly(true_anomaly); - OrbitalElements::Cometary(CometaryElements { - reference_epoch, - perihelion_distance, - eccentricity, - inclination, - ascending_node_longitude, - periapsis_argument, - true_anomaly, - }) + OrbitalElements::Cometary { + elements: CometaryElements { + reference_epoch, + perihelion_distance, + eccentricity, + inclination, + ascending_node_longitude, + periapsis_argument, + true_anomaly, + }, + uncertainty: None, + covariance: None, + } }; // ---- 3) Orbit classification -------------------------------------------- @@ -172,15 +176,19 @@ pub(crate) fn ccek1( let cos_periapsis = x1n * position_orbital.x + x2n * position_orbital.y; let periapsis_argument = wrap_0_2pi(sin_periapsis.atan2(cos_periapsis)); - OrbitalElements::Keplerian(KeplerianElements { - reference_epoch, - semi_major_axis, - eccentricity, - inclination, - ascending_node_longitude, - periapsis_argument, - mean_anomaly, - }) + OrbitalElements::Keplerian { + elements: KeplerianElements { + reference_epoch, + semi_major_axis, + eccentricity, + inclination, + ascending_node_longitude, + periapsis_argument, + mean_anomaly, + }, + uncertainty: None, + covariance: None, + } } else if reciprocal_semi_major_axis.abs() <= EPS_PARAB { // -------- Parabolic orbit parabolic_solution() @@ -201,15 +209,19 @@ pub(crate) fn ccek1( let perihelion_distance = semi_latus_rectum / (1.0 + eccentricity); let periapsis_argument = periapsis_argument_from_true_anomaly(true_anomaly); - OrbitalElements::Cometary(CometaryElements { - reference_epoch, - perihelion_distance, - eccentricity, - inclination, - ascending_node_longitude, - periapsis_argument, - true_anomaly, - }) + OrbitalElements::Cometary { + elements: CometaryElements { + reference_epoch, + perihelion_distance, + eccentricity, + inclination, + ascending_node_longitude, + periapsis_argument, + true_anomaly, + }, + uncertainty: None, + covariance: None, + } } } @@ -408,7 +420,7 @@ mod orb_elem_test { let orbit = ccek1(&position, &velocity, 0.0); match orbit { - OrbitalElements::Cometary(ce) => { + OrbitalElements::Cometary { elements: ce, .. } => { assert!( ce.perihelion_distance > 0.0, "Perihelion distance must be positive, got {q}", @@ -421,7 +433,7 @@ mod orb_elem_test { ); assert!(ce.true_anomaly.is_finite()); } - OrbitalElements::Keplerian(_) | OrbitalElements::Equinoctial(_) => { + OrbitalElements::Keplerian { .. } | OrbitalElements::Equinoctial { .. } => { panic!("Expected Cometary elements, got {orbit:#?}") } } @@ -453,7 +465,7 @@ mod orb_elem_test { let orbit = ccek1(&position, &velocity, 0.0); match orbit { - OrbitalElements::Keplerian(ke) => { + OrbitalElements::Keplerian { elements: ke, .. } => { // Eccentricity should be very close to 1 assert!( (ke.eccentricity - 1.0).abs() < 1e-5, @@ -462,7 +474,7 @@ mod orb_elem_test { ); } - OrbitalElements::Cometary(ce) => { + OrbitalElements::Cometary { elements: ce, .. } => { assert!( (ce.eccentricity - 1.0).abs() < 1e-5, "Near-parabolic eccentricity should be ~1, got {e}", @@ -470,7 +482,7 @@ mod orb_elem_test { ); } - OrbitalElements::Equinoctial(_) => { + OrbitalElements::Equinoctial { .. } => { panic!("Expected Keplerian or Cometary elements, got {orbit:#?}") } } @@ -552,34 +564,34 @@ mod orb_elem_test { // Extract invariants a, e, i (Keplerian) or q, e, i (Cometary). let (a1, e1, i1, om1) = match &orbit { - OrbitalElements::Keplerian(k) => ( + OrbitalElements::Keplerian { elements: k, .. } => ( k.semi_major_axis, k.eccentricity, k.inclination, k.ascending_node_longitude, ), - OrbitalElements::Cometary(c) => ( + OrbitalElements::Cometary { elements: c, .. } => ( c.perihelion_distance, c.eccentricity, c.inclination, c.ascending_node_longitude, ), - OrbitalElements::Equinoctial(_) => panic!("Unexpected equinoctial here"), + OrbitalElements::Equinoctial { .. } => panic!("Unexpected equinoctial here"), }; let (a2, e2, i2, om2) = match &orbit2 { - OrbitalElements::Keplerian(k) => ( + OrbitalElements::Keplerian { elements: k, .. } => ( k.semi_major_axis, k.eccentricity, k.inclination, k.ascending_node_longitude, ), - OrbitalElements::Cometary(c) => ( + OrbitalElements::Cometary { elements: c, .. } => ( c.perihelion_distance, c.eccentricity, c.inclination, c.ascending_node_longitude, ), - OrbitalElements::Equinoctial(_) => panic!("Unexpected equinoctial here"), + OrbitalElements::Equinoctial { .. } => panic!("Unexpected equinoctial here"), }; // Invariants under active Z-rotation of (r, v): @@ -636,7 +648,7 @@ mod orb_elem_test { let wrap_0_2pi = |ang: f64| ang.rem_euclid(TWO_PI); match orbit { - OrbitalElements::Keplerian(ke) => { + OrbitalElements::Keplerian { elements: ke, .. } => { // Eccentricity must match the control within tolerance prop_assert!((e_ctrl - ke.eccentricity).abs() < 1e-6); @@ -668,7 +680,7 @@ mod orb_elem_test { prop_assert!((-1e-12..=std::f64::consts::PI + 1e-12).contains(&inc)); } - OrbitalElements::Cometary(ce) => { + OrbitalElements::Cometary { elements: ce, .. } => { // Eccentricity must match the control within tolerance prop_assert!((e_ctrl - ce.eccentricity).abs() < 1e-6); @@ -692,7 +704,7 @@ mod orb_elem_test { prop_assert!((-1e-12..=std::f64::consts::PI + 1e-12).contains(&inc)); } - OrbitalElements::Equinoctial(_) => { + OrbitalElements::Equinoctial { .. } => { // Not tested here prop_assert!(false, "Equinoctial elements not tested"); } diff --git a/src/orbit_type/cometary_element.rs b/src/orbit_type/cometary_element.rs index 686beaa..569927a 100644 --- a/src/orbit_type/cometary_element.rs +++ b/src/orbit_type/cometary_element.rs @@ -1,25 +1,181 @@ +//! Cometary (perihelion-based) orbital elements for parabolic and hyperbolic trajectories +//! +//! This module implements the **cometary orbital element representation**, which uses +//! perihelion distance $q$ and true anomaly $\nu$ instead of semi-major axis $a$ and +//! mean anomaly $M$. This parameterization is **natural and numerically stable** for +//! cometary and interstellar objects on parabolic ($e = 1$) or hyperbolic ($e > 1$) orbits. +//! +//! ## Motivation +//! +//! For unbound or marginally-bound trajectories, classical Keplerian elements become +//! problematic: +//! +//! - **Parabolic orbits** ($e = 1$): semi-major axis $a = \infty$ is undefined +//! - **Hyperbolic orbits** ($e > 1$): $a < 0$ is mathematically valid but less intuitive +//! +//! In contrast, the **perihelion distance** $q = a(1 - e)$ remains **finite and positive** +//! for all eccentricities, making it a robust observable directly linked to astrometric +//! measurements near perihelion passage. +//! +//! ## Element definition +//! +//! Cometary elements consist of six parameters: +//! +//! | Symbol | Name | Units | Notes | +//! |--------|------|-------|-------| +//! | $q$ | Perihelion distance | AU | Always positive | +//! | $e$ | Eccentricity | — | $e \geq 1$ for cometary/interstellar objects | +//! | $i$ | Inclination | radians | $0 \leq i \leq \pi$ | +//! | $\Omega$ | Longitude of ascending node | radians | $0 \leq \Omega < 2\pi$ | +//! | $\omega$ | Argument of periapsis | radians | $0 \leq \omega < 2\pi$ | +//! | $\nu$ | True anomaly | radians | Angle from periapsis at epoch | +//! +//! ## Conversions +//! +//! This module provides conversions to standard representations via `TryFrom` traits: +//! +//! - **Cometary → Keplerian**: Compute $a = q/(1-e)$, derive $M$ from $\nu$ via +//! eccentric/hyperbolic anomaly (see [`CometaryElements::hyperbolic_mean_anomaly`]) +//! +//! - **Cometary → Equinoctial**: Chain through Keplerian as intermediate step +//! +//! Conversions **fail** for parabolic orbits ($|e - 1| < 10^{-12}$) since $a$ becomes +//! numerically unstable. +//! +//! ## Uncertainty propagation +//! +//! Uncertainties are propagated through coordinate transformations using **analytical Jacobians**: +//! +//! - [`jacobian_to_keplerian`](CometaryElements::jacobian_to_keplerian) — +//! $J = \partial(a,e,i,\Omega,\omega,M) / \partial(q,e,i,\Omega,\omega,\nu)$ +//! +//! - [`jacobian_to_equinoctial`](CometaryElements::jacobian_to_equinoctial) — +//! Computed via chain rule: $J_{\text{Com→Eq}} = J_{\text{Kep→Eq}} \cdot J_{\text{Com→Kep}}$ +//! +//! The Jacobian matrices enable **linear covariance propagation** (see +//! [`OrbitalCovariance::propagate`](crate::orbit_type::uncertainty::OrbitalCovariance::propagate)) +//! when converting between element sets. +//! +//! ## Usage +//! +//! ```rust +//! use outfit::orbit_type::cometary_element::CometaryElements; +//! use outfit::orbit_type::keplerian_element::KeplerianElements; +//! +//! // Define hyperbolic orbit elements (e.g., 'Oumuamua-like) +//! let cometary = CometaryElements { +//! reference_epoch: 58000.0, // MJD +//! perihelion_distance: 0.25, // AU +//! eccentricity: 1.2, // hyperbolic +//! inclination: 0.5, // radians +//! ascending_node_longitude: 1.0, +//! periapsis_argument: 0.3, +//! true_anomaly: 0.1, +//! }; +//! +//! // Convert to Keplerian (a < 0 for hyperbolic) +//! let keplerian = KeplerianElements::try_from(&cometary).unwrap(); +//! assert!(keplerian.semi_major_axis < 0.0); +//! +//! // Compute Jacobian for uncertainty propagation +//! let jacobian = cometary.jacobian_to_keplerian(); +//! ``` +//! +//! ## Implementation notes +//! +//! - **Hyperbolic mean anomaly** is computed using $\text{atanh}$ with numerical clamping +//! to avoid infinities when approaching asymptotic directions (see +//! [`hyperbolic_mean_anomaly`](CometaryElements::hyperbolic_mean_anomaly)) +//! +//! - **Parabolic tolerance**: orbits with $|e - 1| < 10^{-12}$ are rejected to avoid +//! catastrophic cancellation in $a = q/(1-e)$ +//! +//! - **Test coverage**: unit tests verify round-trip conversions, Jacobian accuracy via +//! finite differences, and edge cases (parabolic rejection, asymptotic behavior) +//! - Milani & Gronchi, *Theory of Orbit Determination* (2010). + +use nalgebra::{Matrix6, Vector6}; + use crate::{ orbit_type::{equinoctial_element::EquinoctialElements, keplerian_element::KeplerianElements}, outfit_errors::OutfitError, }; -/// # Cometary orbital elements +/// Cometary orbital elements with uncertainty propagation +/// +/// Cometary (perihelion-based) elements use perihelion distance $q$ and true anomaly $\nu$ +/// instead of semi-major axis $a$ and mean anomaly $M$. This representation is **natural for +/// parabolic and hyperbolic orbits**, where $a$ is infinite ($e = 1$) or negative ($e > 1$). +/// +/// ## Element definition +/// +/// The six cometary elements are: +/// +/// 1. **q** — Perihelion distance (AU): $q = a(1 - e)$ +/// 2. **e** — Eccentricity (unitless): $e \geq 1$ for cometary trajectories +/// 3. **i** — Inclination (radians) +/// 4. **Ω** — Longitude of ascending node (radians) +/// 5. **ω** — Argument of periapsis (radians) +/// 6. **ν** — True anomaly at epoch (radians) +/// +/// ## When to use cometary elements +/// +/// - **Parabolic orbits ($e = 1$)**: $a = \infty$ is undefined in Keplerian/equinoctial forms; +/// cometary elements remain well-defined +/// - **Hyperbolic orbits ($e > 1$)**: $a < 0$ is mathematically valid but less intuitive than $q > 0$ +/// - **Comets and interstellar objects**: observed near perihelion where $q$ and $\nu$ are +/// well-determined from astrometry +/// +/// For elliptic orbits ($e < 1$), Keplerian or equinoctial elements are generally preferred. +/// +/// ## Uncertainty representation and propagation +/// +/// Uncertainties in cometary elements are represented through: +/// +/// - **Standard deviations** on each element $(q, e, i, \Omega, \omega, \nu)$ via +/// [`CometaryUncertainty`](crate::orbit_type::uncertainty::CometaryUncertainty) +/// +/// - **Covariance matrices** capturing correlations via +/// [`OrbitalCovariance`](crate::orbit_type::uncertainty::OrbitalCovariance) +/// +/// When converting to Keplerian or equinoctial elements, uncertainties are propagated using +/// analytical Jacobian matrices: /// -/// Cometary (perihelion-based) elements are convenient for **parabolic and -/// hyperbolic** solutions, where the semi-major axis is not finite (parabola) -/// or negative (hyperbola). +/// - [`jacobian_to_keplerian`](CometaryElements::jacobian_to_keplerian) — +/// $J_{\text{Com} \to \text{Kep}} = \partial(a,e,i,\Omega,\omega,M) / \partial(q,e,i,\Omega,\omega,\nu)$ /// -/// Units & conventions -/// -------------------- -/// - Distances in **AU**; angles in **radians**; epochs in **MJD (TDB)**. -/// - State is assumed heliocentric, equatorial mean J2000. -/// - For hyperbolic motion: `a < 0`, `e > 1`; for parabolic: `e = 1`. +/// - [`jacobian_to_equinoctial`](CometaryElements::jacobian_to_equinoctial) — +/// Computed via chain rule: $J_{\text{Com} \to \text{Eq}} = J_{\text{Kep} \to \text{Eq}} \cdot J_{\text{Com} \to \text{Kep}}$ /// -/// See also -/// ------------ -/// * [`KeplerianElements`] – Classical elements `(a, e, i, Ω, ω, M)` (supports elliptic & hyperbolic). -/// * [`EquinoctialElements`] – Non-singular elements `(a, h, k, p, q, λ)`. -/// * [`CometaryElements::hyperbolic_mean_anomaly`] – Returns the hyperbolic mean anomaly from `(e, ν)` +/// ## Mathematical relations +/// +/// The semi-major axis relates to perihelion distance by: +/// +/// $$ +/// a = \frac{q}{1 - e} +/// $$ +/// +/// - For $e < 1$ (ellipse): $a > q > 0$ +/// - For $e = 1$ (parabola): $a = \infty$ +/// - For $e > 1$ (hyperbola): $a < 0$ and $q > 0$ +/// +/// The mean anomaly $M$ is computed from true anomaly $\nu$ via: +/// +/// - **Elliptic** ($e < 1$): through eccentric anomaly $E$ +/// - **Hyperbolic** ($e > 1$): through hyperbolic anomaly $H$ (see [`hyperbolic_mean_anomaly`](CometaryElements::hyperbolic_mean_anomaly)) +/// +/// ## Units and conventions +/// +/// - Distances: **AU** +/// - Angles: **radians** +/// - Epochs: **MJD (TDB)** +/// - Reference frame: heliocentric ecliptic J2000 +/// +/// ## See also +/// +/// * [`KeplerianElements`] — Classical elements $(a, e, i, \Omega, \omega, M)$ +/// * [`EquinoctialElements`] — Non-singular elements $(a, h, k, p, q, \lambda)$ +/// * [`hyperbolic_mean_anomaly`](CometaryElements::hyperbolic_mean_anomaly) — Computes $M$ from $(e, \nu)$ for $e > 1$ #[derive(Debug, Clone, PartialEq)] pub struct CometaryElements { /// Reference epoch of the element set (MJD, TDB). @@ -131,6 +287,140 @@ impl CometaryElements { mean_anomaly: m, // interpreted as hyperbolic mean anomaly M }) } + + /// Compute the Jacobian of the cometary-to-Keplerian transformation. + /// + /// Given the cometary element vector + /// $\mathbf{x} = [q, e, i, \Omega, \omega, \nu]^\top$, + /// this method returns the $6 \times 6$ matrix + /// + /// $$J = \frac{\partial \mathbf{y}}{\partial \mathbf{x}}, \quad + /// \mathbf{y} = [a, e, i, \Omega, \omega, M]^\top$$ + /// + /// Derivation + /// ---------- + /// The semi-major axis is recovered via: + /// + /// $$a = \frac{q}{1 - e}$$ + /// + /// The mean anomaly $M$ is obtained from the true anomaly $\nu$ through + /// the eccentric anomaly $E$ (Kepler's equation). The closed-form + /// partial derivatives are: + /// + /// $$\frac{\partial a}{\partial q} = \frac{1}{1-e}, \quad + /// \frac{\partial a}{\partial e} = \frac{q}{(1-e)^2}$$ + /// + /// $$\frac{\partial M}{\partial \nu} = \frac{(1-e^2)^{3/2}}{(1 + e\cos\nu)^2}$$ + /// + /// $$\frac{\partial M}{\partial e} = -\frac{\sin\nu\,(2 + e\cos\nu)}{(1 + e\cos\nu)^2}$$ + /// + /// Arguments + /// --------- + /// * `&self` – Cometary elements $(q, e, i, \Omega, \omega, \nu)$. + /// + /// Return + /// ------ + /// * `Matrix6` – The $6 \times 6$ Jacobian $\partial\mathbf{y}/\partial\mathbf{x}$. + /// + /// Notes + /// ----- + /// - Valid for elliptic orbits only ($0 \leq e < 1$). + /// Hyperbolic and parabolic cases require a separate treatment. + /// + /// See also + /// -------- + /// * [`CometaryElements::jacobian_to_equinoctial`] – Jacobian to equinoctial via chain rule. + pub fn jacobian_to_keplerian(&self) -> Matrix6 { + let q = self.perihelion_distance; + let e = self.eccentricity; + let nu = self.true_anomaly; + + let one_minus_e = 1.0 - e; + let cos_nu = nu.cos(); + let sin_nu = nu.sin(); + let denom = 1.0 + e * cos_nu; + + // a = q / (1 - e) holds for both elliptic (e<1, a>0) and hyperbolic (e>1, a<0). + let da_dq = 1.0 / one_minus_e; + let da_de = q / one_minus_e.powi(2); + + // Mean-anomaly partial derivatives differ between the elliptic and hyperbolic regimes. + let (dm_de, dm_dnu) = if e < 1.0 { + // Elliptic: M = E - e·sin(E); ∂M/∂ν = (1-e²)^(3/2)/(1+e·cos ν)² + // ∂M/∂e = -sin(ν)·(2+e·cos ν)/(1+e·cos ν)² + let dm_de = -sin_nu * (2.0 + e * cos_nu) / denom.powi(2); + let dm_dnu = (1.0 - e * e).powf(1.5) / denom.powi(2); + (dm_de, dm_dnu) + } else { + // Hyperbolic: M = e·sinh H - H; ∂M/∂ν = (e²-1)^(3/2)/(1+e·cos ν)² + // ∂M/∂e = sin(ν)·√(e²-1)·(2+e·cos ν)/(1+e·cos ν)² + let e2m1_sqrt = (e * e - 1.0).sqrt(); + let dm_de = sin_nu * e2m1_sqrt * (2.0 + e * cos_nu) / denom.powi(2); + let dm_dnu = e2m1_sqrt.powi(3) / denom.powi(2); + (dm_de, dm_dnu) + }; + + // Row ordering (target): [a, e, i, Ω, ω, M] + // Column ordering (source): [q, e, i, Ω, ω, ν] + + let col_q = Vector6::new(da_dq, 0.0, 0.0, 0.0, 0.0, 0.0); + + let col_e = Vector6::new( + da_de, // ∂a/∂e + 1.0, // ∂e/∂e + 0.0, // ∂i/∂e + 0.0, // ∂Ω/∂e + 0.0, // ∂ω/∂e + dm_de, // ∂M/∂e + ); + + let col_i = Vector6::new(0.0, 0.0, 1.0, 0.0, 0.0, 0.0); + let col_big_omega = Vector6::new(0.0, 0.0, 0.0, 1.0, 0.0, 0.0); + let col_omega = Vector6::new(0.0, 0.0, 0.0, 0.0, 1.0, 0.0); + + let col_nu = Vector6::new( + 0.0, // ∂a/∂ν + 0.0, // ∂e/∂ν + 0.0, // ∂i/∂ν + 0.0, // ∂Ω/∂ν + 0.0, // ∂ω/∂ν + dm_dnu, // ∂M/∂ν + ); + + Matrix6::from_columns(&[col_q, col_e, col_i, col_big_omega, col_omega, col_nu]) + } + + /// Compute the Jacobian of the cometary-to-equinoctial transformation. + /// + /// Uses the chain rule through the Keplerian representation: + /// + /// $$J_{\text{Com}\to\text{Eq}} = J_{\text{Kep}\to\text{Eq}} \cdot J_{\text{Com}\to\text{Kep}}$$ + /// + /// where $J_{\text{Kep}\to\text{Eq}}$ is evaluated at the Keplerian elements + /// obtained by converting `self`. + /// + /// Arguments + /// --------- + /// * `&self` – Cometary elements $(q, e, i, \Omega, \omega, t_p)$. + /// * `mu` – Gravitational parameter $\mu = GM_\odot$ (AU³/day²). + /// + /// Return + /// ------ + /// * `Ok(Matrix6)` – The $6 \times 6$ Jacobian matrix + /// $ \frac{\partial\mathbf{y}_\text{eq}}{ \partial \mathbf{x} _\text{com}} $. + /// * `Err(OutfitError)` – If the cometary-to-Keplerian conversion fails + /// (e.g. parabolic orbit $e = 1$). + /// + /// See also + /// -------- + /// * [`CometaryElements::jacobian_to_keplerian`] – First factor of the chain rule. + /// * [`KeplerianElements::jacobian_to_equinoctial`] – Second factor of the chain rule. + pub fn jacobian_to_equinoctial(&self) -> Result, OutfitError> { + let kep = KeplerianElements::try_from(self)?; + let j_kep_to_eq = kep.jacobian_to_equinoctial(); + let j_com_to_kep = self.jacobian_to_keplerian(); + Ok(j_kep_to_eq * j_com_to_kep) + } } impl TryFrom for KeplerianElements { @@ -224,11 +514,7 @@ impl fmt::Display for CometaryElements { /// * [`KeplerianElements`] – Classical representation used in conversions. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let rad_to_deg = 180.0 / std::f64::consts::PI; - writeln!( - f, - "Cometary Elements @ epoch (MJD): {:.6}", - self.reference_epoch - )?; + writeln!(f, "Elements @ epoch (MJD): {:.6}", self.reference_epoch)?; writeln!(f, "------------------------------------------------")?; writeln!( f, @@ -504,10 +790,7 @@ mod cometary_element_tests { }; let s = format!("{ce}"); - assert!( - s.contains("Cometary Elements @ epoch (MJD)"), - "header missing" - ); + assert!(s.contains("Elements @ epoch (MJD)"), "header missing"); assert!(s.contains("q (perihelion distance)"), "q line missing"); assert!(s.contains("e (eccentricity)"), "e line missing"); assert!( @@ -740,3 +1023,137 @@ mod cometary_element_proptests { } } } + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_abs_diff_eq; + use nalgebra::Matrix6; + + fn make_cometary() -> CometaryElements { + CometaryElements { + reference_epoch: 60000.0, + perihelion_distance: 1.5, + eccentricity: 1.5, // must be > 1 for hyperbolic CometaryElements + inclination: 0.5, + ascending_node_longitude: 1.2, + periapsis_argument: 0.8, + true_anomaly: 0.6, + } + } + + fn cometary_fields(c: &CometaryElements) -> [f64; 6] { + [ + c.perihelion_distance, + c.eccentricity, + c.inclination, + c.ascending_node_longitude, + c.periapsis_argument, + c.true_anomaly, + ] + } + + fn make_cometary_from_fields(base: &CometaryElements, f: [f64; 6]) -> CometaryElements { + CometaryElements { + reference_epoch: base.reference_epoch, + perihelion_distance: f[0], + eccentricity: f[1], + inclination: f[2], + ascending_node_longitude: f[3], + periapsis_argument: f[4], + true_anomaly: f[5], + } + } + + fn cometary_to_kep_arr(c: &CometaryElements) -> [f64; 6] { + let k = KeplerianElements::try_from(c).unwrap(); + [ + k.semi_major_axis, + k.eccentricity, + k.inclination, + k.ascending_node_longitude, + k.periapsis_argument, + k.mean_anomaly, + ] + } + + fn cometary_to_eq_arr(c: &CometaryElements) -> [f64; 6] { + let eq = EquinoctialElements::from(KeplerianElements::try_from(c).unwrap()); + [ + eq.semi_major_axis, + eq.eccentricity_sin_lon, + eq.eccentricity_cos_lon, + eq.tan_half_incl_sin_node, + eq.tan_half_incl_cos_node, + eq.mean_longitude, + ] + } + + fn fd_jacobian [f64; 6]>( + c: &CometaryElements, + eps: f64, + output: F, + ) -> Matrix6 { + let fields = cometary_fields(c); + Matrix6::from_fn(|row, col| { + let mut fwd = fields; + let mut bwd = fields; + fwd[col] += eps; + bwd[col] -= eps; + let y_fwd = output(&make_cometary_from_fields(c, fwd)); + let y_bwd = output(&make_cometary_from_fields(c, bwd)); + (y_fwd[row] - y_bwd[row]) / (2.0 * eps) + }) + } + + #[test] + fn test_jacobian_cometary_to_keplerian_against_fd() { + let c = make_cometary(); + let analytical = c.jacobian_to_keplerian(); + let numerical = fd_jacobian(&c, 1e-6, cometary_to_kep_arr); + + for row in 0..6 { + for col in 0..6 { + assert_abs_diff_eq!( + analytical[(row, col)], + numerical[(row, col)], + epsilon = 1e-7 + ); + } + } + } + + #[test] + fn test_jacobian_cometary_to_equinoctial_against_fd() { + let c = make_cometary(); + let analytical = c.jacobian_to_equinoctial().unwrap(); + let numerical = fd_jacobian(&c, 1e-6, cometary_to_eq_arr); + + for row in 0..6 { + for col in 0..6 { + assert_abs_diff_eq!( + analytical[(row, col)], + numerical[(row, col)], + epsilon = 1e-7 + ); + } + } + } + + #[test] + fn test_jacobian_cometary_chain_rule_consistency() { + let c = make_cometary(); + let kep = KeplerianElements::try_from(&c).unwrap(); + + let j_com_to_kep = c.jacobian_to_keplerian(); + let j_kep_to_eq = kep.jacobian_to_equinoctial(); + let expected = j_kep_to_eq * j_com_to_kep; + let result = c.jacobian_to_equinoctial().unwrap(); + + for row in 0..6 { + for col in 0..6 { + assert_abs_diff_eq!(result[(row, col)], expected[(row, col)], epsilon = 1e-14); + } + } + } +} diff --git a/src/orbit_type/equinoctial_element.rs b/src/orbit_type/equinoctial_element.rs index d78165e..415ca02 100644 --- a/src/orbit_type/equinoctial_element.rs +++ b/src/orbit_type/equinoctial_element.rs @@ -40,7 +40,7 @@ //! - [`crate::orbit_type::equinoctial_element::EquinoctialElements::solve_kepler_equation`] //! Solve the generalized Kepler equation in equinoctial form. //! -//! - [`crate::orbit_type::equinoctial_element::EquinoctialElements::solve_two_body_problem`] +//! - `EquinoctialElements::propagate_to_epoch` (via [`crate::propagator::PropagatorKind`]) //! Propagate an orbit from `t0` to `t1` using the two-body approximation, //! returning position, velocity, and optionally Jacobians. //! @@ -59,9 +59,41 @@ //! //! ## Advantages of equinoctial elements //! -//! * Avoid singularities when `e → 0` or `i → 0` -//! * Smooth derivatives, ideal for gradient-based fitting -//! * Directly compatible with least-squares adjustment and orbit uncertainty analysis +//! Equinoctial elements provide several critical advantages over classical Keplerian elements: +//! +//! * **Non-singular for $e < 1$ and $0 \leq i < \pi$** — no undefined elements for circular +//! or equatorial orbits, unlike Keplerian $\omega$ and $\Omega$ +//! +//! * **Smooth, well-conditioned derivatives** — Jacobian matrices remain well-behaved throughout +//! the valid domain, enabling robust numerical optimization and uncertainty propagation +//! +//! * **Ideal for orbit determination** — gradient-based least-squares fitting converges reliably +//! without special handling of degenerate cases +//! +//! * **Superior uncertainty propagation** — covariance matrices transform smoothly without +//! singular amplification near $e = 0$ or $i = 0$ +//! +//! * **Compatible with two-body propagation** — Keplerian dynamics equations extend naturally +//! to equinoctial form with regular behavior +//! +//! ## Uncertainty representation +//! +//! Uncertainties in equinoctial elements are represented through: +//! +//! - **Standard deviations** on each element $(a, h, k, p, q, \lambda)$ via +//! [`EquinoctialUncertainty`](crate::orbit_type::uncertainty::EquinoctialUncertainty) +//! +//! - **Covariance matrices** capturing correlations between elements via +//! [`OrbitalCovariance`](crate::orbit_type::uncertainty::OrbitalCovariance) +//! +//! When converting to/from Keplerian elements, uncertainties are propagated using analytical +//! Jacobian matrices: +//! +//! - [`jacobian_to_keplerian`](EquinoctialElements::jacobian_to_keplerian) — +//! $J_{\text{Eq} \to \text{Kep}} = \partial(a,e,i,\Omega,\omega,M) / \partial(a,h,k,p,q,\lambda)$ +//! +//! These Jacobians handle singular points by zeroing derivatives when denominators vanish +//! (threshold $\epsilon = 10^{-12}$), ensuring numerical stability at all orbital configurations. //! //! ## Example //! @@ -81,7 +113,7 @@ //! }; //! //! // Propagate 10 days ahead using the two-body model -//! let (pos, vel, _) = equ.solve_two_body_problem(59000.0, 59010.0, false).unwrap(); +//! let (pos, vel, _) = equ.propagate_twobody(59000.0, 59010.0, false).unwrap(); //! //! // Convert to classical Keplerian elements //! let kep: KeplerianElements = equ.into(); @@ -91,10 +123,11 @@ //! //! - [`KeplerianElements`](crate::orbit_type::keplerian_element::KeplerianElements) //! - Milani & Gronchi, *Theory of Orbit Determination* (2010). + use core::f64; use std::{f64::consts::PI, fmt}; -use nalgebra::{Matrix3x6, Matrix6x3, Vector3}; +use nalgebra::{Matrix3x6, Matrix6, Matrix6x3, Vector3, Vector6}; use roots::{find_root_newton_raphson, SimpleConvergency}; use crate::{ @@ -102,14 +135,49 @@ use crate::{ kepler::principal_angle, orbit_type::keplerian_element::KeplerianElements, outfit_errors::OutfitError, + propagator::{ + nbody::{ + build_augmented_initial_state, build_initial_state_jacobian, build_perturber_snapshots, + integrate_augmented_state, split_propagated_jacobian, NBodyOde, NBodyResult, + }, + NBodyConfig, + }, + JPLEphem, }; +/// Type alias for the result of two-body propagation from equinoctial elements. +/// +/// The tuple contains: +/// - `Vector3`: Cartesian position vector (AU) +/// - `Vector3`: Cartesian velocity vector (AU/day) +/// - `Option<(Matrix6x3, Matrix6x3)>`: Optional Jacobian matrices of position and velocity with respect to the six equinoctial elements. pub type TwoBodyResult = ( Vector3, Vector3, Option<(Matrix6x3, Matrix6x3)>, ); +#[derive(Debug, Clone, PartialEq)] +pub struct EquinoctialLimits { + pub eccentricity_limit: f64, + min_semi_major_axis: f64, + max_semi_major_axis: f64, + min_periapsis_distance: f64, + max_apoapsis_distance: f64, +} + +impl Default for EquinoctialLimits { + fn default() -> Self { + Self { + eccentricity_limit: 1.2, // Allow for some hyperbolic orbits (e > 1) but not extreme ones + min_semi_major_axis: 1e-6, // 1e-6 AU ~ 150 km + max_semi_major_axis: 1e4, // 10,000 AU + min_periapsis_distance: 1e-6, // 1e-6 AU ~ 150 km + max_apoapsis_distance: 1e4, // 10,000 AU + } + } +} + /// Equinoctial orbital elements. /// Units: /// - a: AU (astronomical units) @@ -178,6 +246,27 @@ impl EquinoctialElements { self.squared_eccentricity().sqrt() } + /// Check if the equinoctial elements represent a "bizarre" orbit based on specified limits. + /// + /// # Arguments + /// + /// - `limits` - An instance of `EquinoctialLimits` defining thresholds for eccentricity, semi-major axis, periapsis distance, and apoapsis distance. + /// + /// # Returns + /// + /// - `true` if the orbit is considered bizarre (e.g., too eccentric, too small/large semi-major axis, or extreme periapsis/apoapsis distances), otherwise `false`. + pub fn is_bizarre(&self, limits: &EquinoctialLimits) -> bool { + let e = self.eccentricity(); + let periapsis = self.semi_major_axis * (1. - e); // periapsis distance + let apoapsis = self.semi_major_axis * (1. + e); // apoapsis distance + + e > limits.eccentricity_limit + || self.semi_major_axis < limits.min_semi_major_axis + || self.semi_major_axis > limits.max_semi_major_axis + || periapsis < limits.min_periapsis_distance + || apoapsis > limits.max_apoapsis_distance + } + /// Create a new instance of `EquinoctialElements` from Keplerian elements. /// /// Arguments @@ -546,12 +635,16 @@ impl EquinoctialElements { /// - Velocity: **UA/day** /// - Time: **days** /// - Angles: **radians** + #[allow(clippy::too_many_arguments)] pub fn compute_cartesian_position_and_velocity( &self, mean_motion: f64, eccentric_anomaly: f64, eccentricity_pow2: f64, compute_derivatives: bool, + t0: f64, + t1: f64, + mean_longitude_t1: f64, ) -> TwoBodyResult { // ------------------------------------------------------------------------- // 1. Compute auxiliary parameters @@ -635,13 +728,13 @@ impl EquinoctialElements { // ------------------------------------------------------------------------- if compute_derivatives { let (dxde_pos, dxde_vel) = self.compute_derivative( - self.reference_epoch, - self.reference_epoch, + t0, + t1, mean_motion, - self.mean_longitude, + mean_longitude_t1, eccentric_anomaly, + inv_u, beta, - beta_ecc_term, sin_ecc_anom, cos_ecc_anom, xe, @@ -713,7 +806,7 @@ impl EquinoctialElements { /// * [`EquinoctialElements::compute_cartesian_position_and_velocity`] – projects equinoctial elements to 3D /// * [`EquinoctialElements::solve_kepler_equation`] – solves the generalized Kepler equation /// * [`principal_angle`] – normalizes an angle to [0, 2π) - pub fn solve_two_body_problem( + pub fn propagate_twobody( &self, t0: f64, t1: f64, @@ -767,8 +860,283 @@ impl EquinoctialElements { eccentric_anomaly, eccentricity_pow2, compute_derivatives, + t0, + t1, + mean_longitude_t1, )) } + + /// Propagates `elements` from their reference epoch to `t1_mjd_tt` using + /// numerical N-body integration. + /// + /// Converts the equinoctial elements to a Cartesian initial state at t0, then + /// integrates the augmented state (position, velocity, and 6×6 STM) forward (or + /// backward) from t0 to t1 using the DOP853 integrator under the gravitational + /// influence of all bodies listed in `config.perturbing_bodies`. On completion, + /// the propagated STM is combined with the initial element Jacobians to yield + /// `dpos_delem` and `dvel_delem` at t1. + /// + /// If `|t1 − t0| < 1 × 10⁻¹⁴` days the integration is skipped and the t0 + /// Cartesian state and Jacobians are returned directly. + /// + /// # Arguments + /// + /// * `elements` – Equinoctial orbital elements of the small body, with + /// `reference_epoch` (MJD-TT) acting as t0. Units: AU, radians. + /// * `t1_mjd_tt` – Target epoch expressed as Modified Julian Date in the + /// Terrestrial Time (TT) scale. Units: days (MJD-TT). + /// * `jpl` – Opened JPL ephemeris file used to query the heliocentric positions + /// of the perturbing bodies at t0. + /// * `config` – N-body configuration specifying the perturbing bodies and the + /// DOP853 absolute/relative tolerances. + /// + /// # Returns + /// + /// An [`NBodyResult`] containing: + /// - `position`: heliocentric position at t1 (AU, ecliptic J2000), + /// - `velocity`: heliocentric velocity at t1 (AU/day, ecliptic J2000), + /// - `dpos_delem`: 6×3 Jacobian ∂pos(t1)/∂elem (rows = elements, cols = Cartesian), + /// - `dvel_delem`: 6×3 Jacobian ∂vel(t1)/∂elem (rows = elements, cols = Cartesian). + /// + /// # Errors + /// + /// - Returns [`OutfitError::NBodyPropagationFailed`] if the DOP853 integrator + /// fails or produces no output steps. + /// - Returns [`OutfitError::EphemerisBodyNotSupported`] if a perturbing body + /// listed in `config` cannot be found in the JPL ephemeris or has no GM entry + /// in the static table. + pub fn propagate_nbody( + &self, + t1_mjd_tt: f64, + jpl: &JPLEphem, + config: &NBodyConfig, + ) -> Result { + let t0_mjd_tt = self.reference_epoch; + let time_span_days = t1_mjd_tt - t0_mjd_tt; + + // Initial cartesian state and element Jacobian J0 = ∂(x0,v0)/∂elem + let (initial_position, initial_velocity, initial_jacobians) = + self.propagate_twobody(0.0, 0.0, true)?; + let (dpos_delem_at_t0, dvel_delem_at_t0) = + initial_jacobians.expect("propagate_twobody(0,0,true) must return jacobians"); + + // If dt ≈ 0 skip integration + if time_span_days.abs() < 1e-14 { + return Ok(NBodyResult { + position: initial_position, + velocity: initial_velocity, + dpos_delem: dpos_delem_at_t0, + dvel_delem: dvel_delem_at_t0, + }); + } + + let augmented_initial_state = + build_augmented_initial_state(initial_position, initial_velocity); + + let epoch_t0 = hifitime::Epoch::from_mjd_in_time_scale(t0_mjd_tt, hifitime::TimeScale::TT); + let perturbers = build_perturber_snapshots(config, jpl, &epoch_t0)?; + + let ode = NBodyOde { perturbers }; + + let augmented_final_state = + integrate_augmented_state(&ode, augmented_initial_state, time_span_days, config)?; + + let final_position = Vector3::new( + augmented_final_state[0], + augmented_final_state[1], + augmented_final_state[2], + ); + let final_velocity = Vector3::new( + augmented_final_state[3], + augmented_final_state[4], + augmented_final_state[5], + ); + let stm_at_t1 = Matrix6::::from_column_slice(&augmented_final_state[6..42]); + + let initial_state_jacobian = + build_initial_state_jacobian(&dpos_delem_at_t0, &dvel_delem_at_t0); + let propagated_state_jacobian = stm_at_t1 * initial_state_jacobian; + let (dpos_delem_at_t1, dvel_delem_at_t1) = + split_propagated_jacobian(propagated_state_jacobian); + + Ok(NBodyResult { + position: final_position, + velocity: final_velocity, + dpos_delem: dpos_delem_at_t1, + dvel_delem: dvel_delem_at_t1, + }) + } + + /// Compute the Jacobian of the equinoctial-to-Keplerian transformation. + /// + /// Given the equinoctial element vector + /// $\mathbf{x} = [a, h, k, p, q, \lambda]^\top$, + /// this method returns the $6 \times 6$ matrix + /// + /// $$J = \frac{\partial \mathbf{y}}{\partial \mathbf{x}}, \quad + /// \mathbf{y} = [a, e, i, \Omega, \omega, M]^\top$$ + /// + /// where the column ordering matches the source vector $\mathbf{x}$ and + /// the row ordering matches the target vector $\mathbf{y}$. + /// + /// Derivation + /// ---------- + /// The mapping relies on the intermediate quantities: + /// + /// $$e = \sqrt{h^2 + k^2}, \quad \varpi = \text{atan2}(h, k)$$ + /// $$t = \sqrt{p^2 + q^2}, \quad \Omega = \text{atan2}(p, q)$$ + /// $$i = 2\arctan(t), \quad \omega = \varpi - \Omega, \quad M = \lambda - \varpi$$ + /// + /// The partial derivatives of $\varpi$ with respect to $h$ and $k$ are: + /// + /// $$\frac{\partial \varpi}{\partial h} = \frac{k}{e^2}, \quad + /// \frac{\partial \varpi}{\partial k} = -\frac{h}{e^2}$$ + /// + /// The partial derivatives of $i$ with respect to $p$ and $q$ are: + /// + /// $$\frac{\partial i}{\partial p} = \frac{2p}{t(1+t^2)}, \quad + /// \frac{\partial i}{\partial q} = \frac{2q}{t(1+t^2)}$$ + /// + /// Degenerate cases and regularization + /// ------------------------------------ + /// + /// The transformation has singularities at certain orbital configurations: + /// + /// * **Near-circular orbits ($e \approx 0$)**: $\varpi = \text{atan2}(h, k)$ is undefined + /// when $h^2 + k^2 \to 0$. Derivatives $\partial \varpi / \partial h$ and + /// $\partial \varpi / \partial k$ involve $e^{-2}$, which diverges. + /// + /// **Regularization**: When $e < \epsilon$ (with $\epsilon = 10^{-12}$), these + /// derivatives are set to zero. Physically, the periapsis direction has no meaning + /// for a circular orbit. + /// + /// * **Near-equatorial orbits ($i \approx 0$, i.e., $t = \tan(i/2) \approx 0$)**: + /// $\Omega = \text{atan2}(p, q)$ is undefined when $p^2 + q^2 \to 0$. Derivatives + /// $\partial \Omega / \partial p$ and $\partial \Omega / \partial q$ involve $t^{-2}$, + /// which diverges. + /// + /// **Regularization**: When $t < \epsilon$, these derivatives are set to zero. + /// The ascending node direction is undefined for an equatorial orbit. + /// + /// These regularizations prevent numerical overflow but may **underestimate uncertainties** + /// in $\omega$ and $\Omega$ for degenerate orbits. For such cases, equinoctial elements + /// should remain the primary representation. + /// + /// ## Usage in uncertainty propagation + /// + /// This Jacobian transforms covariance matrices from equinoctial to Keplerian: + /// + /// $$ + /// \Sigma_{\text{Kep}} = J \, \Sigma_{\text{Eq}} \, J^\top + /// $$ + /// + /// See [`OrbitalCovariance::propagate`](crate::orbit_type::uncertainty::OrbitalCovariance::propagate) + /// and [`OrbitalElements::to_keplerian`](crate::orbit_type::OrbitalElements::to_keplerian). + /// + /// ## Arguments + /// + /// * `&self` – Equinoctial elements $(a, h, k, p, q, \lambda)$ + /// + /// ## Return + /// + /// * `Matrix6` – The $6 \times 6$ Jacobian matrix $\partial\mathbf{y}/\partial\mathbf{x}$ + /// where rows correspond to $[a, e, i, \Omega, \omega, M]$ and columns to $[a, h, k, p, q, \lambda]$ + /// + /// ## See also + /// + /// * [`KeplerianElements::jacobian_to_equinoctial`](crate::orbit_type::keplerian_element::KeplerianElements::jacobian_to_equinoctial) — Inverse Jacobian + /// * [`OrbitalElements::to_keplerian`](crate::orbit_type::OrbitalElements::to_keplerian) — High-level conversion with uncertainty + pub fn jacobian_to_keplerian(&self) -> Matrix6 { + let h = self.eccentricity_sin_lon; + let k = self.eccentricity_cos_lon; + let p = self.tan_half_incl_sin_node; + let q = self.tan_half_incl_cos_node; + + let eps = 1.0e-12; + + // --- eccentricity and longitude of periapsis --- + let e = (h * h + k * k).sqrt(); + let e_sq = e * e; + + // ∂varpi/∂h = k/e², ∂varpi/∂k = -h/e² (zero when e ≈ 0) + let (d_varpi_dh, d_varpi_dk) = if e < eps { + (0.0, 0.0) + } else { + (k / e_sq, -h / e_sq) + }; + + // --- inclination node --- + let t = (p * p + q * q).sqrt(); + let t_sq = t * t; + + // ∂i/∂p = 2p / (t(1+t²)), ∂i/∂q = 2q / (t(1+t²)) (zero when t ≈ 0) + let (d_i_dp, d_i_dq) = if t < eps { + (0.0, 0.0) + } else { + let denom = t * (1.0 + t_sq); + (2.0 * p / denom, 2.0 * q / denom) + }; + + // ∂Ω/∂p = q/t², ∂Ω/∂q = -p/t² (zero when t ≈ 0) + let (d_omega_node_dp, d_omega_node_dq) = if t < eps { + (0.0, 0.0) + } else { + (q / t_sq, -p / t_sq) + }; + + // Each Vector6 is one column: ∂y/∂x_j for source variable x_j. + // Row ordering (target): [a, e, i, Ω, ω, M] + // Column ordering (source): [a, h, k, p, q, λ] + + let col_a = Vector6::new(1.0, 0.0, 0.0, 0.0, 0.0, 0.0); + + let col_h = Vector6::new( + 0.0, // ∂a/∂h + h / e.max(eps), // ∂e/∂h = h/e + 0.0, // ∂i/∂h + 0.0, // ∂Ω/∂h + d_varpi_dh, // ∂ω/∂h = ∂varpi/∂h (∂Ω/∂h = 0) + -d_varpi_dh, // ∂M/∂h = -∂varpi/∂h + ); + + let col_k = Vector6::new( + 0.0, // ∂a/∂k + k / e.max(eps), // ∂e/∂k = k/e + 0.0, // ∂i/∂k + 0.0, // ∂Ω/∂k + d_varpi_dk, // ∂ω/∂k = ∂varpi/∂k + -d_varpi_dk, // ∂M/∂k = -∂varpi/∂k + ); + + let col_p = Vector6::new( + 0.0, // ∂a/∂p + 0.0, // ∂e/∂p + d_i_dp, // ∂i/∂p + d_omega_node_dp, // ∂Ω/∂p + -d_omega_node_dp, // ∂ω/∂p = -∂Ω/∂p (varpi independent of p) + 0.0, // ∂M/∂p + ); + + let col_q = Vector6::new( + 0.0, // ∂a/∂q + 0.0, // ∂e/∂q + d_i_dq, // ∂i/∂q + d_omega_node_dq, // ∂Ω/∂q + -d_omega_node_dq, // ∂ω/∂q = -∂Ω/∂q + 0.0, // ∂M/∂q + ); + + let col_lambda = Vector6::new( + 0.0, // ∂a/∂λ + 0.0, // ∂e/∂λ + 0.0, // ∂i/∂λ + 0.0, // ∂Ω/∂λ + 0.0, // ∂ω/∂λ + 1.0, // ∂M/∂λ + ); + + Matrix6::from_columns(&[col_a, col_h, col_k, col_p, col_q, col_lambda]) + } } impl From for KeplerianElements { @@ -802,11 +1170,7 @@ impl From<&EquinoctialElements> for KeplerianElements { impl fmt::Display for EquinoctialElements { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let rad_to_deg = 180.0 / std::f64::consts::PI; - writeln!( - f, - "Equinoctial Elements @ epoch (MJD): {:.6}", - self.reference_epoch - )?; + writeln!(f, "Elements @ epoch (MJD): {:.6}", self.reference_epoch)?; writeln!(f, "------------------------------------------------")?; writeln!( f, @@ -846,6 +1210,32 @@ impl fmt::Display for EquinoctialElements { mod test_equinoctial_element { use super::*; + #[test] + fn test_is_bizarre() { + let equ = EquinoctialElements { + reference_epoch: 0.0, + semi_major_axis: 1.8017360713, + eccentricity_sin_lon: 0.2693736809404963, + eccentricity_cos_lon: 0.08856415260522467, + tan_half_incl_sin_node: 0.0008089970142830734, + tan_half_incl_cos_node: 0.10168201110394352, + mean_longitude: 1.693697008, + }; + let limits = EquinoctialLimits::default(); + assert!(!equ.is_bizarre(&limits)); + + let bad_equ = EquinoctialElements { + reference_epoch: 0.0, + semi_major_axis: 0.000001, // too small + eccentricity_sin_lon: 0.2693736809404963, + eccentricity_cos_lon: 0.08856415260522467, + tan_half_incl_sin_node: 0.0008089970142830734, + tan_half_incl_cos_node: 0.10168201110394352, + mean_longitude: 1.693697008 + 2. * std::f64::consts::PI, // out of range + }; + assert!(bad_equ.is_bizarre(&limits)); + } + #[test] fn test_equinoctial_conversion() { let equ = EquinoctialElements { @@ -907,7 +1297,7 @@ mod test_equinoctial_element { }; let (pos, vel, _) = equ - .solve_two_body_problem(0., 21.019733018845727, false) + .propagate_twobody(0., 21.019733018845727, false) .unwrap(); assert_eq!( pos, @@ -1195,3 +1585,161 @@ mod test_equinoctial_element { } } } + +#[cfg(test)] +mod tests_jacobian_equinoctial_to_keplerian { + use super::*; + use approx::assert_abs_diff_eq; + + fn fd_jacobian_eq_to_kep(eq: &EquinoctialElements, eps: f64) -> Matrix6 { + let fields = [ + eq.semi_major_axis, + eq.eccentricity_sin_lon, + eq.eccentricity_cos_lon, + eq.tan_half_incl_sin_node, + eq.tan_half_incl_cos_node, + eq.mean_longitude, + ]; + + let kep_to_arr = |k: KeplerianElements| { + [ + k.semi_major_axis, + k.eccentricity, + k.inclination, + k.ascending_node_longitude, + k.periapsis_argument, + k.mean_anomaly, + ] + }; + + let make_eq = |f: [f64; 6]| EquinoctialElements { + reference_epoch: eq.reference_epoch, + semi_major_axis: f[0], + eccentricity_sin_lon: f[1], + eccentricity_cos_lon: f[2], + tan_half_incl_sin_node: f[3], + tan_half_incl_cos_node: f[4], + mean_longitude: f[5], + }; + + let mut columns = [[0.0f64; 6]; 6]; + + for col in 0..6 { + let mut fwd = fields; + let mut bwd = fields; + fwd[col] += eps; + bwd[col] -= eps; + + let kep_fwd = kep_to_arr(KeplerianElements::from(&make_eq(fwd))); + let kep_bwd = kep_to_arr(KeplerianElements::from(&make_eq(bwd))); + + for row in 0..6 { + columns[col][row] = (kep_fwd[row] - kep_bwd[row]) / (2.0 * eps); + } + } + + Matrix6::from_fn(|row, col| columns[col][row]) + } + + #[test] + fn test_jacobian_to_keplerian_against_finite_differences() { + let eq = EquinoctialElements { + reference_epoch: 60000.0, + semi_major_axis: 2.5, + eccentricity_sin_lon: 0.15, + eccentricity_cos_lon: 0.25, + tan_half_incl_sin_node: 0.10, + tan_half_incl_cos_node: 0.20, + mean_longitude: 1.8, + }; + + let analytical = eq.jacobian_to_keplerian(); + let numerical = fd_jacobian_eq_to_kep(&eq, 1e-6); + + for row in 0..6 { + for col in 0..6 { + assert_abs_diff_eq!( + analytical[(row, col)], + numerical[(row, col)], + epsilon = 1e-7 + ); + } + } + } + + /// Near-circular orbit: e ≈ 0, degenerate 1/e² derivatives should be zeroed. + #[test] + fn test_jacobian_to_keplerian_near_circular() { + // Use h=k small enough that e = sqrt(h²+k²) < eps (1e-12) in the Jacobian, + // triggering the degenerate-zero branch for ∂ω/∂h, ∂ω/∂k, ∂M/∂h, ∂M/∂k. + // Note: ∂e/∂h = h/e → 1/√2 for h=k, which is well-defined and NOT zero. + let eq = EquinoctialElements { + reference_epoch: 60000.0, + semi_major_axis: 1.0, + eccentricity_sin_lon: 1e-13, + eccentricity_cos_lon: 1e-13, + tan_half_incl_sin_node: 0.10, + tan_half_incl_cos_node: 0.20, + mean_longitude: 1.0, + }; + + let j = eq.jacobian_to_keplerian(); + + // Only the 1/e²-diverging derivatives are zeroed by the regularisation + assert_abs_diff_eq!(j[(4, 1)], 0.0, epsilon = 1e-10); // ∂ω/∂h + assert_abs_diff_eq!(j[(4, 2)], 0.0, epsilon = 1e-10); // ∂ω/∂k + assert_abs_diff_eq!(j[(5, 1)], 0.0, epsilon = 1e-10); // ∂M/∂h + assert_abs_diff_eq!(j[(5, 2)], 0.0, epsilon = 1e-10); // ∂M/∂k + } + + /// Near-equatorial orbit: t ≈ 0, degenerate 1/t²-node derivatives should be zeroed. + #[test] + fn test_jacobian_to_keplerian_near_equatorial() { + // Use p=q small enough that t = sqrt(p²+q²) < eps (1e-12), triggering the + // degenerate-zero branch for ∂i/∂p, ∂Ω/∂p, ∂ω/∂p (and their q-counterparts). + let eq = EquinoctialElements { + reference_epoch: 60000.0, + semi_major_axis: 1.5, + eccentricity_sin_lon: 0.10, + eccentricity_cos_lon: 0.20, + tan_half_incl_sin_node: 1e-13, + tan_half_incl_cos_node: 1e-13, + mean_longitude: 2.0, + }; + + let j = eq.jacobian_to_keplerian(); + + // Both the bounded (∂i/∂p) and diverging (∂Ω/∂p, ∂ω/∂p) entries are zeroed + assert_abs_diff_eq!(j[(2, 3)], 0.0, epsilon = 1e-10); // ∂i/∂p + assert_abs_diff_eq!(j[(3, 3)], 0.0, epsilon = 1e-10); // ∂Ω/∂p + assert_abs_diff_eq!(j[(4, 3)], 0.0, epsilon = 1e-10); // ∂ω/∂p + } + + /// Round-trip consistency: J_kep_to_eq · J_eq_to_kep ≈ I₆. + #[test] + fn test_jacobian_round_trip_identity() { + let eq = EquinoctialElements { + reference_epoch: 60000.0, + semi_major_axis: 2.5, + eccentricity_sin_lon: 0.15, + eccentricity_cos_lon: 0.25, + tan_half_incl_sin_node: 0.10, + tan_half_incl_cos_node: 0.20, + mean_longitude: 1.8, + }; + + let kep = KeplerianElements::from(&eq); + + let j_eq_to_kep = eq.jacobian_to_keplerian(); + let j_kep_to_eq = kep.jacobian_to_equinoctial(); + + let product = j_eq_to_kep * j_kep_to_eq; + let identity = Matrix6::identity(); + + for row in 0..6 { + for col in 0..6 { + assert_abs_diff_eq!(product[(row, col)], identity[(row, col)], epsilon = 1e-10); + } + } + } +} diff --git a/src/orbit_type/keplerian_element.rs b/src/orbit_type/keplerian_element.rs index 88dda3b..5b2229c 100644 --- a/src/orbit_type/keplerian_element.rs +++ b/src/orbit_type/keplerian_element.rs @@ -34,17 +34,46 @@ //! - Angles: **radians** //! - Time: **days** (epoch usually in **MJD**, TDB/TT scale) //! -//! ## Degeneracies +//! ## Degeneracies and singularities //! -//! Classical Keplerian elements suffer from singularities: +//! Classical Keplerian elements suffer from singularities in certain orbital configurations: //! -//! - **Circular orbits (`e → 0`)**: periapsis argument ω becomes undefined. -//! → conventionally set to `0.0` during conversion. +//! - **Circular orbits ($e \to 0$)**: the periapsis argument $\omega$ becomes undefined because +//! there is no distinct periapsis point. Conventionally set to $\omega = 0$ during conversion, +//! but derivatives $\partial \omega / \partial h$ and $\partial \omega / \partial k$ are +//! singular ($\sim e^{-2}$). //! -//! - **Equatorial orbits (`i → 0`)**: ascending node Ω becomes undefined. -//! → conventionally set to `0.0` during conversion. +//! - **Equatorial orbits ($i \to 0$)**: the ascending node longitude $\Omega$ becomes undefined +//! because the orbital plane coincides with the reference plane. Conventionally set to $\Omega = 0$, +//! with singular derivatives $\partial \Omega / \partial p$ and $\partial \Omega / \partial q$ +//! ($\sim i^{-1}$). //! -//! For robust numerical work, the [`EquinoctialElements`](crate::orbit_type::equinoctial_element::EquinoctialElements) representation is recommended. +//! - **Circular equatorial orbits ($e \to 0$ and $i \to 0$)**: both $\omega$ and $\Omega$ are +//! undefined. Only the mean longitude $\lambda = \Omega + \omega + M$ remains well-defined. +//! +//! These singularities impact **uncertainty propagation**: near-circular or near-equatorial orbits +//! will have artificially inflated or ill-defined uncertainties in $\omega$ or $\Omega$ when +//! converted from equinoctial elements. +//! +//! **Recommendation**: For numerical work and uncertainty analysis on circular or equatorial orbits, +//! use [`EquinoctialElements`](crate::orbit_type::equinoctial_element::EquinoctialElements) as the +//! primary representation to avoid singularities. +//! +//! ## Uncertainty propagation from equinoctial elements +//! +//! When converting from equinoctial $(a, h, k, p, q, \lambda)$ to Keplerian $(a, e, i, \Omega, \omega, M)$, +//! uncertainties are propagated using the Jacobian matrix computed by [`jacobian_to_equinoctial`](KeplerianElements::jacobian_to_equinoctial): +//! +//! $$ +//! \Sigma_{\text{Kep}} = J_{\text{Eq} \to \text{Kep}} \, \Sigma_{\text{Eq}} \, J_{\text{Eq} \to \text{Kep}}^\top +//! $$ +//! +//! where $J_{\text{Eq} \to \text{Kep}}$ is the inverse Jacobian +//! (computed in [`EquinoctialElements::jacobian_to_keplerian`](crate::orbit_type::equinoctial_element::EquinoctialElements::jacobian_to_keplerian)). +//! +//! Near singular points, derivatives are regularized by setting them to zero when denominators +//! become small (threshold $\epsilon = 10^{-12}$). This prevents numerical overflow but may +//! underestimate uncertainties in degenerate cases. //! //! ## Example //! @@ -84,6 +113,8 @@ //! - [`principal_angle`](crate::kepler::principal_angle) – helper to normalize angular elements. //! - Milani & Gronchi, *Theory of Orbit Determination* (2010). +use nalgebra::{Matrix6, Vector6}; + use crate::{kepler::principal_angle, orbit_type::equinoctial_element::EquinoctialElements}; use std::fmt; @@ -200,6 +231,152 @@ impl KeplerianElements { mean_anomaly, } } + + /// Compute the Jacobian of the Keplerian-to-equinoctial transformation + /// + /// This method returns the $6 \times 6$ Jacobian matrix of the transformation from + /// Keplerian elements $(a, e, i, \Omega, \omega, M)$ to equinoctial elements $(a, h, k, p, q, \lambda)$. + /// + /// ## Mathematical formulation + /// + /// The equinoctial elements are defined by: + /// + /// $$ + /// \begin{aligned} + /// a &= a \\ + /// h &= e \sin(\varpi) \\ + /// k &= e \cos(\varpi) \\ + /// p &= \tan(i/2) \sin(\Omega) \\ + /// q &= \tan(i/2) \cos(\Omega) \\ + /// \lambda &= \varpi + M = \Omega + \omega + M + /// \end{aligned} + /// $$ + /// + /// where $\varpi = \Omega + \omega$ is the longitude of periapsis. + /// + /// ## Jacobian structure + /// + /// The Jacobian $J = \partial \mathbf{y} / \partial \mathbf{x}$ has the following structure: + /// + /// - **Column 1** ($\partial / \partial a$): Only $\partial a / \partial a = 1$ is nonzero + /// + /// - **Column 2** ($\partial / \partial e$): + /// $$\frac{\partial h}{\partial e} = \sin(\varpi), \quad \frac{\partial k}{\partial e} = \cos(\varpi)$$ + /// + /// - **Column 3** ($\partial / \partial i$): + /// $$\frac{\partial p}{\partial i} = \frac{1}{2 \cos^2(i/2)} \sin(\Omega), \quad + /// \frac{\partial q}{\partial i} = \frac{1}{2 \cos^2(i/2)} \cos(\Omega)$$ + /// + /// - **Column 4** ($\partial / \partial \Omega$): + /// $$\frac{\partial h}{\partial \Omega} = e \cos(\varpi), \quad + /// \frac{\partial k}{\partial \Omega} = -e \sin(\varpi)$$ + /// $$\frac{\partial p}{\partial \Omega} = \tan(i/2) \cos(\Omega), \quad + /// \frac{\partial q}{\partial \Omega} = -\tan(i/2) \sin(\Omega)$$ + /// $$\frac{\partial \lambda}{\partial \Omega} = 1$$ + /// + /// - **Column 5** ($\partial / \partial \omega$): + /// Same as $\partial / \partial \Omega$ for $h, k, \lambda$ but zero for $p, q$ + /// + /// - **Column 6** ($\partial / \partial M$): + /// Only $\partial \lambda / \partial M = 1$ is nonzero + /// + /// ## Handling singularities + /// + /// No singularities occur in this forward transformation (Keplerian → Equinoctial) because: + /// - $h, k$ are well-defined for all $e \geq 0$ + /// - $p, q$ are well-defined for all $i \in [0, \pi]$ + /// + /// This makes the Jacobian numerically stable for all orbital configurations. + /// + /// ## Usage in uncertainty propagation + /// + /// This Jacobian is used to transform covariance matrices from Keplerian to equinoctial: + /// + /// $$ + /// \Sigma_{\text{Eq}} = J \, \Sigma_{\text{Kep}} \, J^\top + /// $$ + /// + /// See [`OrbitalCovariance::propagate`](crate::orbit_type::uncertainty::OrbitalCovariance::propagate) + /// for the propagation implementation. + /// + /// ## Return + /// + /// A $6 \times 6$ matrix where: + /// - **Rows** correspond to target elements: $[a, h, k, p, q, \lambda]$ + /// - **Columns** correspond to source elements: $[a, e, i, \Omega, \omega, M]$ + /// + /// ## See also + /// + /// - [`EquinoctialElements::jacobian_to_keplerian`](crate::orbit_type::equinoctial_element::EquinoctialElements::jacobian_to_keplerian) — Inverse Jacobian + /// - [`OrbitalElements::to_equinoctial`](crate::orbit_type::OrbitalElements::to_equinoctial) — High-level conversion with uncertainty propagation + pub fn jacobian_to_equinoctial(&self) -> Matrix6 { + let e = self.eccentricity; + let i = self.inclination; + let varpi = self.ascending_node_longitude + self.periapsis_argument; + let big_omega = self.ascending_node_longitude; + + let sin_varpi = varpi.sin(); + let cos_varpi = varpi.cos(); + let sin_omega = big_omega.sin(); + let cos_omega = big_omega.cos(); + + let half_i = i / 2.0; + let tan_half_i = half_i.tan(); + let d_tan_half_i_d_i = 0.5 / half_i.cos().powi(2); + + // Each Vector6 is one column: ∂y/∂x_j for source variable x_j. + // Row ordering (target): [a, h, k, p, q, λ] + // Column ordering (source): [a, e, i, Ω, ω, M] + + let col_a = Vector6::new(1.0, 0.0, 0.0, 0.0, 0.0, 0.0); + + let col_e = Vector6::new( + 0.0, // ∂a/∂e + sin_varpi, // ∂h/∂e + cos_varpi, // ∂k/∂e + 0.0, // ∂p/∂e + 0.0, // ∂q/∂e + 0.0, // ∂λ/∂e + ); + + let col_i = Vector6::new( + 0.0, // ∂a/∂i + 0.0, // ∂h/∂i + 0.0, // ∂k/∂i + d_tan_half_i_d_i * sin_omega, // ∂p/∂i + d_tan_half_i_d_i * cos_omega, // ∂q/∂i + 0.0, // ∂λ/∂i + ); + + let col_big_omega = Vector6::new( + 0.0, // ∂a/∂Ω + e * cos_varpi, // ∂h/∂Ω + -e * sin_varpi, // ∂k/∂Ω + tan_half_i * cos_omega, // ∂p/∂Ω + -tan_half_i * sin_omega, // ∂q/∂Ω + 1.0, // ∂λ/∂Ω + ); + + let col_omega = Vector6::new( + 0.0, // ∂a/∂ω + e * cos_varpi, // ∂h/∂ω + -e * sin_varpi, // ∂k/∂ω + 0.0, // ∂p/∂ω + 0.0, // ∂q/∂ω + 1.0, // ∂λ/∂ω + ); + + let col_m = Vector6::new( + 0.0, // ∂a/∂M + 0.0, // ∂h/∂M + 0.0, // ∂k/∂M + 0.0, // ∂p/∂M + 0.0, // ∂q/∂M + 1.0, // ∂λ/∂M + ); + + Matrix6::from_columns(&[col_a, col_e, col_i, col_big_omega, col_omega, col_m]) + } } impl From for EquinoctialElements { @@ -252,11 +429,7 @@ impl From<&KeplerianElements> for EquinoctialElements { impl fmt::Display for KeplerianElements { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let rad_to_deg = 180.0 / std::f64::consts::PI; - writeln!( - f, - "Keplerian Elements @ epoch (MJD): {:.6}", - self.reference_epoch - )?; + writeln!(f, "Elements @ epoch (MJD): {:.6}", self.reference_epoch)?; writeln!(f, "-------------------------------------------")?; writeln!( f, @@ -328,3 +501,109 @@ pub(crate) mod test_keplerian_element { ); } } + +#[cfg(test)] +mod tests_jacobian_keplerian { + use super::*; + use approx::assert_abs_diff_eq; + + /// Finite-difference Jacobian for reference. + fn fd_jacobian_kep_to_eq(kep: &KeplerianElements, eps: f64) -> Matrix6 { + let fields: [f64; 6] = [ + kep.semi_major_axis, + kep.eccentricity, + kep.inclination, + kep.ascending_node_longitude, + kep.periapsis_argument, + kep.mean_anomaly, + ]; + + let eq_to_arr = |e: &EquinoctialElements| -> [f64; 6] { + [ + e.semi_major_axis, + e.eccentricity_sin_lon, + e.eccentricity_cos_lon, + e.tan_half_incl_sin_node, + e.tan_half_incl_cos_node, + e.mean_longitude, + ] + }; + + let mut columns = [[0.0f64; 6]; 6]; + + for col in 0..6 { + let mut fwd = fields; + let mut bwd = fields; + fwd[col] += eps; + bwd[col] -= eps; + + let make_kep = |f: [f64; 6]| KeplerianElements { + reference_epoch: kep.reference_epoch, + semi_major_axis: f[0], + eccentricity: f[1], + inclination: f[2], + ascending_node_longitude: f[3], + periapsis_argument: f[4], + mean_anomaly: f[5], + }; + + let eq_fwd = eq_to_arr(&EquinoctialElements::from(&make_kep(fwd))); + let eq_bwd = eq_to_arr(&EquinoctialElements::from(&make_kep(bwd))); + + for row in 0..6 { + columns[col][row] = (eq_fwd[row] - eq_bwd[row]) / (2.0 * eps); + } + } + + Matrix6::from_fn(|row, col| columns[col][row]) + } + + #[test] + fn test_jacobian_to_equinoctial_against_finite_differences() { + let kep = KeplerianElements { + reference_epoch: 60000.0, + semi_major_axis: 2.5, + eccentricity: 0.3, + inclination: 0.5, + ascending_node_longitude: 1.2, + periapsis_argument: 0.8, + mean_anomaly: 2.1, + }; + + let analytical = kep.jacobian_to_equinoctial(); + let numerical = fd_jacobian_kep_to_eq(&kep, 1e-6); + + for row in 0..6 { + for col in 0..6 { + assert_abs_diff_eq!( + analytical[(row, col)], + numerical[(row, col)], + epsilon = 1e-7 + ); + } + } + } + + /// Near-circular orbit: e ≈ 0, the h/k row should remain well-defined. + #[test] + fn test_jacobian_to_equinoctial_near_circular() { + let kep = KeplerianElements { + reference_epoch: 60000.0, + semi_major_axis: 1.0, + eccentricity: 1e-8, + inclination: 0.3, + ascending_node_longitude: 0.5, + periapsis_argument: 0.2, + mean_anomaly: 1.0, + }; + + let j = kep.jacobian_to_equinoctial(); + let numerical = fd_jacobian_kep_to_eq(&kep, 1e-6); + + for row in 0..6 { + for col in 0..6 { + assert_abs_diff_eq!(j[(row, col)], numerical[(row, col)], epsilon = 1e-6); + } + } + } +} diff --git a/src/orbit_type/mod.rs b/src/orbit_type/mod.rs index 9d5068b..4317696 100644 --- a/src/orbit_type/mod.rs +++ b/src/orbit_type/mod.rs @@ -1,19 +1,140 @@ -//! # Orbital element representations +//! Orbital element representations and uncertainty propagation //! -//! This module defines multiple **canonical orbital element sets** and the -//! associated conversions between them: +//! This module defines multiple **canonical orbital element sets** with full support for +//! **uncertainty propagation** during conversions between representations. It provides +//! three distinct parameterizations of Keplerian orbits, each with advantages for different +//! orbital regimes: //! -//! - [`keplerian_element`](crate::orbit_type::keplerian_element) — Classical Keplerian elements `(a, e, i, Ω, ω, M)`, -//! valid for elliptic and hyperbolic orbits. -//! - [`equinoctial_element`](crate::orbit_type::equinoctial_element) — Equinoctial elements `(a, h, k, p, q, λ)`, -//! a **non-singular formulation** well suited for orbit determination near +//! - [`keplerian_element`](crate::orbit_type::keplerian_element) — Classical Keplerian elements $(a, e, i, \Omega, \omega, M)$, +//! widely used for elliptic and hyperbolic orbits but singular for circular and equatorial cases. +//! - [`equinoctial_element`](crate::orbit_type::equinoctial_element) — Equinoctial elements $(a, h, k, p, q, \lambda)$, +//! a **non-singular formulation** ideal for orbit determination and propagation near //! zero eccentricity or inclination. -//! - [`cometary_element`](crate::orbit_type::cometary_element) — Perihelion-based representation `(q, e, i, Ω, ω, ν)`, -//! convenient for parabolic and hyperbolic orbits. +//! - [`cometary_element`](crate::orbit_type::cometary_element) — Perihelion-based representation $(q, e, i, \Omega, \omega, \nu)$, +//! natural for parabolic and hyperbolic orbits where perihelion distance $q$ is better +//! defined than semi-major axis $a$. //! -//! The [`OrbitalElements`](crate::orbit_type::OrbitalElements) enum acts as a **type-erased wrapper** that can hold -//! any of these three representations, while providing uniform constructors and -//! conversion methods. +//! The [`OrbitalElements`] enum acts as a **type-erased wrapper** that can hold any of these +//! three representations along with optional **uncertainty** and **covariance** information, +//! providing uniform constructors and rigorous conversion methods with uncertainty propagation. +//! +//! ## Choosing an orbital element representation +//! +//! Each representation has optimal use cases: +//! +//! **Keplerian elements** $(a, e, i, \Omega, \omega, M)$: +//! - Intuitive physical interpretation: size, shape, orientation, and position in orbit +//! - Standard in classical celestial mechanics and literature +//! - Singular when $e \to 0$ (circular) or $i \to 0$ (equatorial): derivatives become undefined +//! - Best for: communication, visualization, moderate eccentricity/inclination orbits +//! +//! **Equinoctial elements** $(a, h, k, p, q, \lambda)$: +//! - Non-singular for all $e < 1$ and $0 \leq i < \pi$ +//! - Smooth, well-defined derivatives enable robust orbit fitting and least-squares optimization +//! - Ideal for numerical propagation and uncertainty analysis +//! - Best for: orbit determination, propagation, covariance propagation, near-circular or near-equatorial orbits +//! +//! **Cometary elements** $(q, e, i, \Omega, \omega, \nu)$: +//! - Uses perihelion distance $q = a(1 - e)$ instead of semi-major axis +//! - Well-behaved for $e \geq 1$ (parabolic and hyperbolic orbits) +//! - Natural for cometary and hyperbolic trajectories +//! - Best for: comets, interstellar objects, hyperbolic encounters +//! +//! ## Uncertainty representation and propagation +//! +//! Orbital uncertainties arise from measurement errors, numerical approximations, and model limitations. +//! This module represents uncertainties in two complementary ways: +//! +//! 1. **Standard deviations**: individual $1\sigma$ uncertainties on each element +//! (see [`KeplerianUncertainty`](crate::orbit_type::uncertainty::KeplerianUncertainty), [`EquinoctialUncertainty`](crate::orbit_type::uncertainty::EquinoctialUncertainty), [`CometaryUncertainty`](crate::orbit_type::uncertainty::CometaryUncertainty)) +//! +//! 2. **Covariance matrices**: full $6 \times 6$ symmetric positive semi-definite matrices capturing +//! element correlations (see [`OrbitalCovariance`](crate::orbit_type::uncertainty::OrbitalCovariance)) +//! +//! When converting between representations, **covariance matrices are transformed** using the Jacobian +//! of the conversion: +//! +//! $$ +//! \Sigma_y = J \, \Sigma_x \, J^\top +//! $$ +//! +//! where $\Sigma_x$ is the covariance in the source representation, $\Sigma_y$ is the covariance in the +//! target representation, and $J = \partial \mathbf{y} / \partial \mathbf{x}$ is the Jacobian matrix of +//! partial derivatives evaluated at the nominal element values. +//! +//! This **linear covariance propagation** preserves the statistical properties of uncertainties under +//! first-order approximation, which is accurate for small uncertainties relative to the element values. +//! +//! ## Mathematical background: Jacobian matrices +//! +//! Each element representation provides Jacobian methods for conversions: +//! +//! ### Keplerian ↔ Equinoctial +//! +//! The transformation between Keplerian $(a, e, i, \Omega, \omega, M)$ and equinoctial +//! $(a, h, k, p, q, \lambda)$ is defined by: +//! +//! $$ +//! \begin{aligned} +//! h &= e \sin(\Omega + \omega) \\ +//! k &= e \cos(\Omega + \omega) \\ +//! p &= \tan(i/2) \sin(\Omega) \\ +//! q &= \tan(i/2) \cos(\Omega) \\ +//! \lambda &= \Omega + \omega + M +//! \end{aligned} +//! $$ +//! +//! The Jacobian $J_{\text{Kep} \to \text{Eq}}$ is computed analytically in +//! [`KeplerianElements::jacobian_to_equinoctial`], and the inverse in +//! [`EquinoctialElements::jacobian_to_keplerian`]. +//! +//! **Singular cases**: When $e \approx 0$, the longitude of periapsis $\varpi = \Omega + \omega$ +//! is undefined, and derivatives involving $\partial \varpi / \partial h$ and +//! $\partial \varpi / \partial k$ become singular. These are handled by setting the derivatives +//! to zero when $e < \epsilon$ (typically $\epsilon = 10^{-12}$). Similarly, when $i \approx 0$, +//! $\Omega$ is undefined and related derivatives are zeroed. +//! +//! ### Cometary ↔ Keplerian +//! +//! The cometary representation $(q, e, i, \Omega, \omega, \nu)$ relates to Keplerian elements through: +//! +//! $$ +//! a = \frac{q}{1 - e}, \quad M = M(e, \nu) +//! $$ +//! +//! where $M(e, \nu)$ is the mean anomaly computed from the true anomaly $\nu$ via the eccentric +//! anomaly $E$ (for $e < 1$) or hyperbolic anomaly $H$ (for $e > 1$). The Jacobian +//! $J_{\text{Com} \to \text{Kep}}$ is computed in [`CometaryElements::jacobian_to_keplerian`]. +//! +//! For conversions to equinoctial, the **chain rule** is applied: +//! +//! $$ +//! J_{\text{Com} \to \text{Eq}} = J_{\text{Kep} \to \text{Eq}} \cdot J_{\text{Com} \to \text{Kep}} +//! $$ +//! +//! This is implemented in [`CometaryElements::jacobian_to_equinoctial`]. +//! +//! ## Conversion methods with uncertainty propagation +//! +//! The [`OrbitalElements`] enum provides conversion methods that automatically propagate covariance: +//! +//! - [`OrbitalElements::to_keplerian`] — Convert to Keplerian, propagating covariance if present +//! - [`OrbitalElements::to_equinoctial`] — Convert to equinoctial, propagating covariance if present +//! +//! If a covariance matrix is attached to the source elements, it is transformed using the appropriate +//! Jacobian and attached to the result. The 1-σ uncertainties are then recomputed from the diagonal +//! of the transformed covariance. +//! +//! **Error handling**: Conversions that are mathematically undefined (e.g., parabolic cometary → Keplerian, +//! where $a = \infty$) return `Err(OutfitError::InvalidConversion)`. +//! +//! ## Units and conventions +//! +//! - **Lengths**: astronomical units (AU) +//! - **Angles**: radians +//! - **Time**: Modified Julian Date (MJD) in TDB scale for epochs +//! - **Velocities**: AU/day +//! - **Reference frame**: heliocentric ecliptic J2000 //! //! ## Typical workflow //! @@ -28,19 +149,41 @@ //! // Build canonical orbital elements from state //! let elems = OrbitalElements::from_orbital_state(&r, &v, 2460000.5); //! -//! // Convert to Keplerian form if possible +//! // Convert to Keplerian form if possible (uncertainty propagated if present) //! if let Ok(kep) = elems.to_keplerian() { -//! println!("semi-major axis = {}", kep.semi_major_axis); +//! if let OrbitalElements::Keplerian { elements, uncertainty, .. } = kep { +//! println!("semi-major axis = {}", elements.semi_major_axis); +//! if let Some(unc) = uncertainty { +//! println!(" ± {} AU", unc.semi_major_axis); +//! } +//! } //! } //! ``` +//! +//! ## See also +//! +//! - [`KeplerianElements`](crate::orbit_type::keplerian_element::KeplerianElements) — Classical elements with Jacobian methods +//! - [`EquinoctialElements`](crate::orbit_type::equinoctial_element::EquinoctialElements) — Non-singular elements with propagation +//! - [`CometaryElements`](crate::orbit_type::cometary_element::CometaryElements) — Perihelion-based elements for hyperbolic orbits +//! - [`uncertainty`](crate::orbit_type::uncertainty) — Uncertainty structures and covariance propagation +//! +//! ## References +//! +//! - Milani & Gronchi, *Theory of Orbit Determination* (2010), Chapter 5 +//! - Walker et al., "A Set of Modified Equinoctial Orbital Elements", *Celestial Mechanics* 36 (1985) use nalgebra::Vector3; use crate::{ orb_elem::ccek1, orbit_type::{ - cometary_element::CometaryElements, equinoctial_element::EquinoctialElements, + cometary_element::CometaryElements, + equinoctial_element::EquinoctialElements, keplerian_element::KeplerianElements, + uncertainty::{ + CometaryUncertainty, EquinoctialUncertainty, KeplerianUncertainty, OrbitalCovariance, + }, }, + OutfitError, }; /// Equinoctial orbital elements and related conversions. @@ -52,6 +195,9 @@ pub mod keplerian_element; /// Cometary (parabolic/hyperbolic) orbital elements and related conversions. pub mod cometary_element; +/// Uncertainty structures for orbital elements. +pub mod uncertainty; + /// Canonical orbital elements in multiple representations. /// /// This enum acts as a sum type over several orbital-element parameterizations. @@ -69,13 +215,25 @@ pub mod cometary_element; /// * [`KeplerianElements`] – Classical Keplerian elements. /// * [`EquinoctialElements`] – Non-singular elements (equinoctial). /// * [`CometaryElements`] – Perihelion distance representation for e≥1. -/// * [`OrbitalElements::to_equinoctial`] – Lossless conversion for elliptic orbits. -/// * [`OrbitalElements::to_keplerian`] – Conversion with domain checks. +/// * [`crate::orbit_type::OrbitalElements::to_equinoctial`] – Lossless conversion for elliptic orbits. +/// * [`crate::orbit_type::OrbitalElements::to_keplerian`] – Conversion with domain checks. #[derive(Debug, Clone, PartialEq)] pub enum OrbitalElements { - Keplerian(KeplerianElements), - Equinoctial(EquinoctialElements), - Cometary(CometaryElements), + Keplerian { + elements: KeplerianElements, + uncertainty: Option, + covariance: Option, + }, + Equinoctial { + elements: EquinoctialElements, + uncertainty: Option, + covariance: Option, + }, + Cometary { + elements: CometaryElements, + uncertainty: Option, + covariance: Option, + }, } impl OrbitalElements { @@ -120,66 +278,249 @@ impl OrbitalElements { ccek1(position, velocity, reference_epoch) } - /// Convert to Keplerian elements, if possible. + /// Convert to Keplerian representation with full uncertainty propagation /// - /// This conversion may fail if the current representation is not suitable - /// for Keplerian elements. In particular, `Cometary` elements with - /// eccentricity e < 1 cannot be converted to Keplerian form. + /// This method converts the orbital elements to Keplerian form $(a, e, i, \Omega, \omega, M)$ + /// and, if a covariance matrix is present, propagates it through the transformation using + /// the appropriate Jacobian matrix. /// - /// Errors - /// ------ - /// Returns an `OutfitError::InvalidOrbit` if the conversion is not possible. - pub fn to_keplerian(&self) -> Result { + /// ## Uncertainty propagation + /// + /// When the source elements have an attached covariance matrix $\Sigma_x$, this method: + /// + /// 1. Computes the Jacobian $J = \partial \mathbf{y}_{\text{Kep}} / \partial \mathbf{x}_{\text{src}}$ + /// where $\mathbf{x}_{\text{src}}$ is the source parameterization and + /// $\mathbf{y}_{\text{Kep}} = [a, e, i, \Omega, \omega, M]^\top$ + /// + /// 2. Transforms the covariance using **linear covariance propagation**: + /// $$\Sigma_{\text{Kep}} = J \, \Sigma_x \, J^\top$$ + /// + /// 3. Extracts 1-σ uncertainties from the diagonal: $\sigma_i = \sqrt{\Sigma_{ii}}$ + /// + /// This transformation preserves statistical properties under first-order approximation, + /// which is accurate when uncertainties are small relative to element values. + /// + /// ## Conversions + /// + /// - **From Keplerian**: returns a clone (no transformation needed) + /// - **From Equinoctial**: uses [`EquinoctialElements::jacobian_to_keplerian`] + /// - **From Cometary**: uses [`CometaryElements::jacobian_to_keplerian`] + /// + /// ## Return + /// + /// * `Ok(OrbitalElements::Keplerian)` – Converted elements with propagated + /// uncertainty and covariance when available + /// * `Err(OutfitError)` – If the conversion is mathematically undefined + /// (e.g., parabolic cometary elements with $e = 1$, where $a = \infty$) + pub fn to_keplerian(&self) -> Result { match self { - OrbitalElements::Keplerian(ke) => Ok(ke.clone()), - OrbitalElements::Equinoctial(ee) => Ok(KeplerianElements::from(ee)), - OrbitalElements::Cometary(ce) => KeplerianElements::try_from(ce), + OrbitalElements::Keplerian { .. } => Ok(self.clone()), + + OrbitalElements::Equinoctial { + elements, + covariance, + .. + } => { + let kep = KeplerianElements::from(elements); + let jacobian = elements.jacobian_to_keplerian(); + + let new_cov = covariance.as_ref().map(|c| c.propagate(&jacobian)); + let new_unc = new_cov.as_ref().map(KeplerianUncertainty::from_covariance); + + Ok(OrbitalElements::Keplerian { + elements: kep, + uncertainty: new_unc, + covariance: new_cov, + }) + } + + OrbitalElements::Cometary { + elements, + covariance, + .. + } => { + let kep = KeplerianElements::try_from(elements)?; + let jacobian = elements.jacobian_to_keplerian(); + + let new_cov = covariance.as_ref().map(|c| c.propagate(&jacobian)); + let new_unc = new_cov.as_ref().map(KeplerianUncertainty::from_covariance); + + Ok(OrbitalElements::Keplerian { + elements: kep, + uncertainty: new_unc, + covariance: new_cov, + }) + } } } - /// Convert to Equinoctial elements, if possible. + /// Convert to equinoctial representation with full uncertainty propagation /// - /// This conversion may fail if the current representation is not suitable - /// for Equinoctial elements. In particular, `Cometary` elements with - /// eccentricity e < 1 cannot be converted to Equinoctial form. + /// This method converts the orbital elements to equinoctial form $(a, h, k, p, q, \lambda)$ + /// and, if a covariance matrix is present, propagates it through the transformation using + /// the appropriate Jacobian matrix. /// - /// Errors - /// ------ - /// Returns an `OutfitError::InvalidOrbit` if the conversion is not possible. - pub fn to_equinoctial(&self) -> Result { + /// ## Uncertainty propagation + /// + /// When the source elements have an attached covariance matrix $\Sigma_x$, this method: + /// + /// 1. Computes the Jacobian $J = \partial \mathbf{y}_{\text{Eq}} / \partial \mathbf{x}_{\text{src}}$ + /// where $\mathbf{x}_{\text{src}}$ is the source parameterization and + /// $\mathbf{y}_{\text{Eq}} = [a, h, k, p, q, \lambda]^\top$ + /// + /// 2. Transforms the covariance using **linear covariance propagation**: + /// $$\Sigma_{\text{Eq}} = J \, \Sigma_x \, J^\top$$ + /// + /// 3. Extracts 1-σ uncertainties from the diagonal: $\sigma_i = \sqrt{\Sigma_{ii}}$ + /// + /// The equinoctial representation is **non-singular** for $e < 1$ and $0 \leq i < \pi$, + /// making it ideal for uncertainty analysis and propagation when Keplerian elements + /// would be near-singular. + /// + /// ## Conversions + /// + /// - **From Equinoctial**: returns a clone (no transformation needed) + /// - **From Keplerian**: uses [`KeplerianElements::jacobian_to_equinoctial`] + /// - **From Cometary**: uses [`CometaryElements::jacobian_to_equinoctial`] (chain rule through Keplerian) + /// + /// ## Return + /// + /// * `Ok(OrbitalElements::Equinoctial)` – Converted elements with propagated + /// uncertainty and covariance when available + /// * `Err(OutfitError)` – If the conversion is mathematically undefined + pub fn to_equinoctial(&self) -> Result { match self { - OrbitalElements::Keplerian(ke) => Ok(EquinoctialElements::from(ke)), - OrbitalElements::Equinoctial(ee) => Ok(ee.clone()), - OrbitalElements::Cometary(ce) => EquinoctialElements::try_from(ce), + OrbitalElements::Equinoctial { .. } => Ok(self.clone()), + + OrbitalElements::Keplerian { + elements, + covariance, + .. + } => { + let eq = EquinoctialElements::from(elements); + let jacobian = elements.jacobian_to_equinoctial(); + + let new_cov = covariance.as_ref().map(|c| c.propagate(&jacobian)); + let new_unc = new_cov + .as_ref() + .map(EquinoctialUncertainty::from_covariance); + + Ok(OrbitalElements::Equinoctial { + elements: eq, + uncertainty: new_unc, + covariance: new_cov, + }) + } + + OrbitalElements::Cometary { + elements, + covariance, + .. + } => { + let eq = EquinoctialElements::try_from(elements)?; + let jacobian = elements.jacobian_to_equinoctial()?; + + let new_cov = covariance.as_ref().map(|c| c.propagate(&jacobian)); + let new_unc = new_cov + .as_ref() + .map(EquinoctialUncertainty::from_covariance); + + Ok(OrbitalElements::Equinoctial { + elements: eq, + uncertainty: new_unc, + covariance: new_cov, + }) + } } } /// Get a reference to the underlying [`KeplerianElements`] if this is `Keplerian`. - pub fn as_keplerian(&self) -> Option<&KeplerianElements> { - if let OrbitalElements::Keplerian(ref k) = self { - Some(k) + pub fn as_keplerian_ref(&self) -> Option<&KeplerianElements> { + if let OrbitalElements::Keplerian { elements, .. } = self { + Some(elements) + } else { + None + } + } + + /// Get the owned underlying [`KeplerianElements`] if this is `Keplerian`. + pub fn as_keplerian(self) -> Option { + if let OrbitalElements::Keplerian { elements, .. } = self { + Some(elements) } else { None } } /// Get a reference to the underlying [`EquinoctialElements`] if this is `Equinoctial`. - pub fn as_equinoctial(&self) -> Option<&EquinoctialElements> { - if let OrbitalElements::Equinoctial(ref e) = self { - Some(e) + pub fn as_equinoctial_ref(&self) -> Option<&EquinoctialElements> { + if let OrbitalElements::Equinoctial { elements, .. } = self { + Some(elements) + } else { + None + } + } + + /// Get the owned underlying [`EquinoctialElements`] if this is `Equinoctial`. + pub fn as_equinoctial(self) -> Option { + if let OrbitalElements::Equinoctial { elements, .. } = self { + Some(elements) } else { None } } /// Get a reference to the underlying [`CometaryElements`] if this is `Cometary`. - pub fn as_cometary(&self) -> Option<&CometaryElements> { - if let OrbitalElements::Cometary(ref c) = self { - Some(c) + pub fn as_cometary_ref(&self) -> Option<&CometaryElements> { + if let OrbitalElements::Cometary { elements, .. } = self { + Some(elements) + } else { + None + } + } + + /// Get the owned underlying [`CometaryElements`] if this is `Cometary`. + pub fn as_cometary(self) -> Option { + if let OrbitalElements::Cometary { elements, .. } = self { + Some(elements) } else { None } } + + /// Convert to [`KeplerianElements`], propagating covariance if present. + /// + /// Shorthand for `.to_keplerian()?.as_keplerian()`. + /// + /// Return + /// ------ + /// * `Ok(KeplerianElements)` – Converted elements. + /// * `Err(OutfitError)` – If the conversion is not defined for the current + /// element set (e.g. parabolic cometary elements). + pub fn into_keplerian(self) -> Result { + self.to_keplerian()? + .as_keplerian() + .ok_or(OutfitError::InvalidConversion( + "Conversion to Keplerian elements failed".to_string(), + )) + } + + /// Convert to [`EquinoctialElements`], propagating covariance if present. + /// + /// Shorthand for `.to_equinoctial()?.as_equinoctial()`. + /// + /// Return + /// ------ + /// * `Ok(EquinoctialElements)` – Converted elements. + /// * `Err(OutfitError)` – If the conversion is not defined for the current + /// element set (e.g. hyperbolic cometary elements). + pub fn into_equinoctial(self) -> Result { + self.to_equinoctial()? + .as_equinoctial() + .ok_or(OutfitError::InvalidConversion( + "Conversion to equinoctial elements failed".to_string(), + )) + } } use std::fmt; @@ -187,17 +528,17 @@ use std::fmt; impl fmt::Display for OrbitalElements { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - OrbitalElements::Keplerian(k) => { - writeln!(f, "[Keplerian representation]")?; - write!(f, "{k}") + OrbitalElements::Keplerian { elements, .. } => { + writeln!(f, "[Keplerian]")?; + write!(f, "{elements}") } - OrbitalElements::Equinoctial(e) => { - writeln!(f, "[Equinoctial representation]")?; - write!(f, "{e}") + OrbitalElements::Equinoctial { elements, .. } => { + writeln!(f, "[Equinoctial]")?; + write!(f, "{elements}") } - OrbitalElements::Cometary(c) => { - writeln!(f, "[Cometary representation]")?; - write!(f, "{c}") + OrbitalElements::Cometary { elements, .. } => { + writeln!(f, "[Cometary]")?; + write!(f, "{elements}") } } } @@ -216,7 +557,10 @@ pub(crate) mod orbit_type_test { tol: f64, ) -> bool { match (current, other) { - (OrbitalElements::Keplerian(ke1), OrbitalElements::Keplerian(ke2)) => { + ( + OrbitalElements::Keplerian { elements: ke1, .. }, + OrbitalElements::Keplerian { elements: ke2, .. }, + ) => { abs_diff_eq!(ke1.semi_major_axis, ke2.semi_major_axis, epsilon = tol) && abs_diff_eq!(ke1.eccentricity, ke2.eccentricity, epsilon = tol) && abs_diff_eq!(ke1.inclination, ke2.inclination, epsilon = tol) @@ -232,7 +576,10 @@ pub(crate) mod orbit_type_test { ) && abs_diff_eq!(ke1.mean_anomaly, ke2.mean_anomaly, epsilon = tol) } - (OrbitalElements::Equinoctial(ee1), OrbitalElements::Equinoctial(ee2)) => { + ( + OrbitalElements::Equinoctial { elements: ee1, .. }, + OrbitalElements::Equinoctial { elements: ee2, .. }, + ) => { abs_diff_eq!(ee1.semi_major_axis, 0.0, epsilon = tol) && abs_diff_eq!( ee1.eccentricity_sin_lon, @@ -256,7 +603,10 @@ pub(crate) mod orbit_type_test { ) && abs_diff_eq!(ee1.mean_longitude, ee2.mean_longitude, epsilon = tol) } - (OrbitalElements::Cometary(ce1), OrbitalElements::Cometary(ce2)) => { + ( + OrbitalElements::Cometary { elements: ce1, .. }, + OrbitalElements::Cometary { elements: ce2, .. }, + ) => { abs_diff_eq!( ce1.perihelion_distance, ce2.perihelion_distance, @@ -316,7 +666,7 @@ pub(crate) mod orbit_type_test { let elems = OrbitalElements::from_orbital_state(&r, &v, jd_tdb); match elems { - OrbitalElements::Keplerian(ke) => { + OrbitalElements::Keplerian { elements: ke, .. } => { assert!(ke.semi_major_axis > 0.0); // Loose target for a sanity check, not a golden number. assert_abs_diff_eq!(ke.semi_major_axis, 1.8155, epsilon = 5e-3); @@ -342,7 +692,7 @@ pub(crate) mod orbit_type_test { let elems = OrbitalElements::from_orbital_state(&r, &v, jd_tdb); match elems { - OrbitalElements::Cometary(ce) => { + OrbitalElements::Cometary { elements: ce, .. } => { assert!(ce.eccentricity >= 1.0); assert!(ce.perihelion_distance > 0.0); } @@ -363,11 +713,17 @@ pub(crate) mod orbit_type_test { periapsis_argument: deg(30.0), mean_anomaly: deg(40.0), }; - let oe = OrbitalElements::Keplerian(ke.clone()); + let oe = OrbitalElements::Keplerian { + elements: ke.clone(), + uncertainty: None, + covariance: None, + }; let back = oe .to_keplerian() - .expect("Keplerian -> Keplerian should succeed"); + .expect("Keplerian -> Keplerian should succeed") + .as_keplerian() + .expect("Failed to convert to Keplerian"); assert_abs_diff_eq!(back.semi_major_axis, ke.semi_major_axis, epsilon = 1e-14); assert_abs_diff_eq!(back.eccentricity, ke.eccentricity, epsilon = 1e-14); @@ -396,11 +752,17 @@ pub(crate) mod orbit_type_test { periapsis_argument: deg(35.0), mean_anomaly: deg(45.0), }; - let oe = OrbitalElements::Keplerian(ke.clone()); + let oe = OrbitalElements::Keplerian { + elements: ke.clone(), + uncertainty: None, + covariance: None, + }; let eq = oe .to_equinoctial() - .expect("Keplerian -> Equinoctial should succeed"); + .expect("Keplerian -> Equinoctial should succeed") + .as_equinoctial() + .expect("Failed to convert to Equinoctial"); let ke_back = KeplerianElements::from(&eq); // Use a mix of absolute and relative checks to be robust to scaling. @@ -432,9 +794,17 @@ pub(crate) mod orbit_type_test { mean_anomaly: deg(10.0), }; let eq = EquinoctialElements::from(&ke); - let oe = OrbitalElements::Equinoctial(eq.clone()); + let oe = OrbitalElements::Equinoctial { + elements: eq.clone(), + uncertainty: None, + covariance: None, + }; - let back_via_enum = oe.to_keplerian().expect("Eq -> Kep should succeed"); + let back_via_enum = oe + .to_keplerian() + .expect("Eq -> Kep should succeed") + .as_keplerian() + .expect("Failed to convert to Keplerian"); let back_via_direct = KeplerianElements::from(&eq); assert_abs_diff_eq!( @@ -483,11 +853,17 @@ pub(crate) mod orbit_type_test { periapsis_argument: deg(45.0), true_anomaly: deg(5.0), }; - let oe = OrbitalElements::Cometary(ce); + let oe = OrbitalElements::Cometary { + elements: ce, + uncertainty: None, + covariance: None, + }; let ke = oe .to_keplerian() - .expect("Cometary(e>1) -> Keplerian should succeed for hyperbolic orbits"); + .expect("Cometary(e>1) -> Keplerian should succeed for hyperbolic orbits") + .as_keplerian() + .expect("Failed to convert to Keplerian"); assert!(ke.eccentricity >= 1.0, "expected e >= 1 for hyperbola"); assert!(ke.semi_major_axis < 0.0, "expected a < 0 for hyperbola"); @@ -507,9 +883,16 @@ pub(crate) mod orbit_type_test { periapsis_argument: deg(30.0), true_anomaly: deg(15.0), }; - let oe = OrbitalElements::Cometary(ce); + let oe = OrbitalElements::Cometary { + elements: ce, + uncertainty: None, + covariance: None, + }; if let Ok(eq) = oe.to_equinoctial() { + let eq = eq + .as_equinoctial() + .expect("Failed to convert to Equinoctial after successful conversion"); assert!( eq.semi_major_axis < 0.0, "equinoctial a should be < 0 for hyperbolic orbits" @@ -541,20 +924,32 @@ pub(crate) mod orbit_type_test { true_anomaly: 0.0, }; - let oe_k = OrbitalElements::Keplerian(ke.clone()); - assert!(oe_k.as_keplerian().is_some()); - assert!(oe_k.as_equinoctial().is_none()); - assert!(oe_k.as_cometary().is_none()); - - let oe_e = OrbitalElements::Equinoctial(eq); - assert!(oe_e.as_keplerian().is_none()); - assert!(oe_e.as_equinoctial().is_some()); - assert!(oe_e.as_cometary().is_none()); - - let oe_c = OrbitalElements::Cometary(ce); - assert!(oe_c.as_keplerian().is_none()); - assert!(oe_c.as_equinoctial().is_none()); - assert!(oe_c.as_cometary().is_some()); + let oe_k = OrbitalElements::Keplerian { + elements: ke.clone(), + uncertainty: None, + covariance: None, + }; + assert!(oe_k.as_keplerian_ref().is_some()); + assert!(oe_k.as_equinoctial_ref().is_none()); + assert!(oe_k.as_cometary_ref().is_none()); + + let oe_e = OrbitalElements::Equinoctial { + elements: eq, + uncertainty: None, + covariance: None, + }; + assert!(oe_e.as_keplerian_ref().is_none()); + assert!(oe_e.as_equinoctial_ref().is_some()); + assert!(oe_e.as_cometary_ref().is_none()); + + let oe_c = OrbitalElements::Cometary { + elements: ce, + uncertainty: None, + covariance: None, + }; + assert!(oe_c.as_keplerian_ref().is_none()); + assert!(oe_c.as_equinoctial_ref().is_none()); + assert!(oe_c.as_cometary_ref().is_some()); } // ---------- Display formatting ---------- @@ -581,13 +976,34 @@ pub(crate) mod orbit_type_test { true_anomaly: deg(0.0), }; - let s_k = format!("{}", OrbitalElements::Keplerian(ke)); - assert!(s_k.starts_with("[Keplerian representation]")); - - let s_e = format!("{}", OrbitalElements::Equinoctial(eq)); - assert!(s_e.starts_with("[Equinoctial representation]")); - - let s_c = format!("{}", OrbitalElements::Cometary(ce)); - assert!(s_c.starts_with("[Cometary representation]")); + let s_k = format!( + "{}", + OrbitalElements::Keplerian { + elements: ke, + uncertainty: None, + covariance: None, + } + ); + assert!(s_k.starts_with("[Keplerian]")); + + let s_e = format!( + "{}", + OrbitalElements::Equinoctial { + elements: eq, + uncertainty: None, + covariance: None, + } + ); + assert!(s_e.starts_with("[Equinoctial]")); + + let s_c = format!( + "{}", + OrbitalElements::Cometary { + elements: ce, + uncertainty: None, + covariance: None, + } + ); + assert!(s_c.starts_with("[Cometary]")); } } diff --git a/src/orbit_type/uncertainty.rs b/src/orbit_type/uncertainty.rs new file mode 100644 index 0000000..fad4362 --- /dev/null +++ b/src/orbit_type/uncertainty.rs @@ -0,0 +1,417 @@ +//! Orbital uncertainty representation and covariance propagation +//! +//! This module defines uncertainty structures for orbital elements and provides mechanisms +//! for propagating uncertainties through coordinate transformations. +//! +//! ## Overview +//! +//! Orbital uncertainties arise from multiple sources: +//! - **Measurement errors** in astrometric observations (position, timing) +//! - **Numerical approximations** in orbit fitting and propagation +//! - **Model limitations** (unmodeled perturbations, approximations in dynamics) +//! +//! These uncertainties are represented in two complementary forms: +//! +//! 1. **Standard deviations** ($1\sigma$ uncertainties) — individual uncertainties on each element, +//! extracted from the diagonal of the covariance matrix +//! 2. **Covariance matrices** — full $6 \times 6$ symmetric matrices capturing correlations between elements +//! +//! ## Uncertainty structures +//! +//! Three uncertainty structures correspond to the three orbital element representations: +//! +//! - [`KeplerianUncertainty`] — for classical Keplerian elements $(a, e, i, \Omega, \omega, M)$ +//! - [`EquinoctialUncertainty`] — for equinoctial elements $(a, h, k, p, q, \lambda)$ +//! - [`CometaryUncertainty`] — for cometary elements $(q, e, i, \Omega, \omega, \nu)$ +//! +//! Each structure stores the standard deviation $\sigma_i$ for each element $x_i$, extracted from +//! the diagonal of the covariance matrix: $\sigma_i = \sqrt{\Sigma_{ii}}$. +//! +//! All units match those of the parent element struct (AU for distances, radians for angles). +//! +//! ## Covariance matrix representation +//! +//! The [`OrbitalCovariance`] structure holds a full $6 \times 6$ **symmetric positive semi-definite** +//! covariance matrix $\Sigma$. The matrix element $\Sigma_{ij}$ represents the covariance between +//! orbital elements $x_i$ and $x_j$: +//! +//! $$ +//! \Sigma_{ij} = \text{Cov}(x_i, x_j) = E[(x_i - \mu_i)(x_j - \mu_j)] +//! $$ +//! +//! where $\mu_i$ is the expected value (nominal element value), with $\mu_i = \mathbb{E}(x_i)$ and $\mathbb{E}(\cdot)$ denoting expectation. +//! +//! **Diagonal entries** $\Sigma_{ii} = \sigma_i^2$ are the variances of each element. +//! +//! **Off-diagonal entries** $\Sigma_{ij}$ ($i \neq j$) capture correlations. The correlation coefficient is: +//! +//! $$ +//! \rho_{ij} = \frac{\Sigma_{ij}}{\sigma_i \sigma_j} \in [-1, 1] +//! $$ +//! +//! ## Linear covariance propagation +//! +//! When transforming orbital elements from one representation to another, the covariance matrix +//! is propagated using **first-order linear approximation**: +//! +//! $$ +//! \Sigma_y = J \, \Sigma_x \, J^\top +//! $$ +//! +//! where: +//! - $\Sigma_x$ is the covariance in the source representation $\mathbf{x}$ +//! - $\Sigma_y$ is the covariance in the target representation $\mathbf{y}$ +//! - $J = \frac{\partial \mathbf{y}}{\partial \mathbf{x}}$ is the $6 \times 6$ Jacobian matrix +//! evaluated at the nominal element values +//! +//! This transformation preserves the **statistical properties** of the uncertainty distribution +//! under the assumption that: +//! 1. The transformation $\mathbf{y} = f(\mathbf{x})$ is smooth and differentiable +//! 2. Uncertainties are small relative to element values (linear approximation valid) +//! 3. The distribution is approximately Gaussian (covariance fully characterizes uncertainty) +//! +//! ## Jacobian computation +//! +//! Each orbital element representation provides methods to compute analytical Jacobians: +//! +//! - [`KeplerianElements::jacobian_to_equinoctial`](crate::orbit_type::keplerian_element::KeplerianElements::jacobian_to_equinoctial) +//! — $J_{\text{Kep} \to \text{Eq}} = \partial(a,h,k,p,q,\lambda) / \partial(a,e,i,\Omega,\omega,M)$ +//! +//! - [`EquinoctialElements::jacobian_to_keplerian`](crate::orbit_type::equinoctial_element::EquinoctialElements::jacobian_to_keplerian) +//! — $J_{\text{Eq} \to \text{Kep}} = \partial(a,e,i,\Omega,\omega,M) / \partial(a,h,k,p,q,\lambda)$ +//! +//! - [`CometaryElements::jacobian_to_keplerian`](crate::orbit_type::cometary_element::CometaryElements::jacobian_to_keplerian) +//! — $J_{\text{Com} \to \text{Kep}} = \partial(a,e,i,\Omega,\omega,M) / \partial(q,e,i,\Omega,\omega,\nu)$ +//! +//! - [`CometaryElements::jacobian_to_equinoctial`](crate::orbit_type::cometary_element::CometaryElements::jacobian_to_equinoctial) +//! — $J_{\text{Com} \to \text{Eq}}$ computed via chain rule +//! +//! These Jacobians are computed analytically for numerical accuracy and efficiency. +//! +//! ## Handling singularities +//! +//! Some orbital element representations have **singularities** where derivatives are undefined: +//! +//! - **Keplerian elements**: singular when $e \to 0$ (circular) or $i \to 0$ (equatorial) +//! - **Equinoctial elements**: non-singular for $e < 1$ and $0 \leq i < \pi$ +//! +//! When computing Jacobians near singular points, derivatives involving undefined quantities +//! (e.g., $\partial \varpi / \partial h$ when $e = 0$) are set to zero. This ensures numerical +//! stability but may underestimate uncertainties in degenerate configurations. +//! +//! For orbits near circular or equatorial, **equinoctial elements** should be preferred as the +//! primary representation to avoid singularities in both the transformation and its Jacobian. +//! +//! ## Usage +//! +//! Uncertainties are typically created by orbit determination codes and attached to +//! [`OrbitalElements`](crate::orbit_type::OrbitalElements) variants. When converting between +//! representations, the covariance is automatically propagated: +//! +//! ```rust +//! # use outfit::orbit_type::{OrbitalElements, keplerian_element::KeplerianElements}; +//! # use outfit::orbit_type::uncertainty::{KeplerianUncertainty, OrbitalCovariance}; +//! # use nalgebra::Matrix6; +//! // Assume we have Keplerian elements with uncertainty +//! let kep = KeplerianElements { +//! reference_epoch: 60000.0, +//! semi_major_axis: 2.5, +//! eccentricity: 0.1, +//! inclination: 0.2, +//! ascending_node_longitude: 1.0, +//! periapsis_argument: 0.5, +//! mean_anomaly: 2.0, +//! }; +//! +//! // With an associated covariance (example: diagonal matrix) +//! let cov = OrbitalCovariance { +//! matrix: Matrix6::from_diagonal_element(1e-6), +//! }; +//! +//! let oe = OrbitalElements::Keplerian { +//! elements: kep, +//! uncertainty: Some(KeplerianUncertainty::from_covariance(&cov)), +//! covariance: Some(cov), +//! }; +//! +//! // Convert to equinoctial — covariance automatically propagated +//! let oe_eq = oe.to_equinoctial().unwrap(); +//! +//! // Uncertainty in equinoctial representation now available +//! if let OrbitalElements::Equinoctial { uncertainty, .. } = oe_eq { +//! if let Some(unc) = uncertainty { +//! println!("Uncertainty in h: {}", unc.eccentricity_sin_lon); +//! } +//! } +//! ``` +//! +//! ## See also +//! +//! - [`OrbitalElements`](crate::orbit_type::OrbitalElements) — Container with uncertainty +//! - [`OrbitalCovariance::propagate`] — Covariance transformation method +//! - Module-level documentation in [`crate::orbit_type`] for conversion details +//! +//! ## References +//! +//! - Tapley, Schutz, & Born, *Statistical Orbit Determination* (2004), Chapter 4 +//! - Milani & Gronchi, *Theory of Orbit Determination* (2010), Chapter 5 +use nalgebra::Matrix6; +use photom::Radians; + +/// One-sigma uncertainties on Keplerian elements. +/// +/// Units +/// ----- +/// * `semi_major_axis`: AU. +/// * `eccentricity`: unitless. +/// * `inclination`: radians. +/// * `ascending_node_longitude`: radians. +/// * `periapsis_argument`: radians. +/// * `mean_anomaly`: radians. +/// +/// Notes +/// ----- +/// The reference epoch is treated as exact (no uncertainty propagated here). +/// +/// See also +/// -------- +/// * [`crate::orbit_type::keplerian_element::KeplerianElements`] – Associated element struct. +/// * [`OrbitalCovariance`] – Full 6×6 covariance when available. +#[derive(Debug, Clone, PartialEq)] +pub struct KeplerianUncertainty { + pub semi_major_axis: f64, + pub eccentricity: f64, + pub inclination: Radians, + pub ascending_node_longitude: Radians, + pub periapsis_argument: Radians, + pub mean_anomaly: Radians, +} + +/// One-sigma uncertainties on equinoctial elements. +/// +/// Units +/// ----- +/// * `semi_major_axis`: AU. +/// * `eccentricity_sin_lon`: unitless. +/// * `eccentricity_cos_lon`: unitless. +/// * `tan_half_incl_sin_node`: unitless. +/// * `tan_half_incl_cos_node`: unitless. +/// * `mean_longitude`: radians. +/// +/// See also +/// -------- +/// * [`crate::orbit_type::equinoctial_element::EquinoctialElements`] – Associated element struct. +/// * [`OrbitalCovariance`] – Full 6×6 covariance when available. +#[derive(Debug, Clone, PartialEq)] +pub struct EquinoctialUncertainty { + pub semi_major_axis: f64, + pub eccentricity_sin_lon: f64, + pub eccentricity_cos_lon: f64, + pub tan_half_incl_sin_node: f64, + pub tan_half_incl_cos_node: f64, + pub mean_longitude: Radians, +} + +/// One-sigma uncertainties on cometary elements. +/// +/// Units +/// ----- +/// * `perihelion_distance`: AU. +/// * `eccentricity`: unitless. +/// * `inclination`: radians. +/// * `ascending_node_longitude`: radians. +/// * `periapsis_argument`: radians. +/// * `true_anomaly`: radians. +/// +/// See also +/// -------- +/// * [`crate::orbit_type::cometary_element::CometaryElements`] – Associated element struct. +/// * [`OrbitalCovariance`] – Full 6×6 covariance when available. +#[derive(Debug, Clone, PartialEq)] +pub struct CometaryUncertainty { + pub perihelion_distance: f64, + pub eccentricity: f64, + pub inclination: Radians, + pub ascending_node_longitude: Radians, + pub periapsis_argument: Radians, + pub true_anomaly: Radians, +} + +impl KeplerianUncertainty { + /// Extract 1-σ standard deviations from the diagonal of a covariance matrix. + /// + /// Element ordering: $[a, e, i, \Omega, \omega, M]$. + pub fn from_covariance(cov: &OrbitalCovariance) -> Self { + let v = cov.variances(); + KeplerianUncertainty { + semi_major_axis: v[0].sqrt(), + eccentricity: v[1].sqrt(), + inclination: v[2].sqrt(), + ascending_node_longitude: v[3].sqrt(), + periapsis_argument: v[4].sqrt(), + mean_anomaly: v[5].sqrt(), + } + } +} + +impl EquinoctialUncertainty { + /// Extract 1-σ standard deviations from the diagonal of a covariance matrix. + /// + /// Element ordering: $[a, h, k, p, q, \lambda]$. + pub fn from_covariance(cov: &OrbitalCovariance) -> Self { + let v = cov.variances(); + EquinoctialUncertainty { + semi_major_axis: v[0].sqrt(), + eccentricity_sin_lon: v[1].sqrt(), + eccentricity_cos_lon: v[2].sqrt(), + tan_half_incl_sin_node: v[3].sqrt(), + tan_half_incl_cos_node: v[4].sqrt(), + mean_longitude: v[5].sqrt(), + } + } +} + +impl CometaryUncertainty { + /// Extract 1-σ standard deviations from the diagonal of a covariance matrix. + /// + /// Element ordering: $[q, e, i, \Omega, \omega, \nu]$. + pub fn from_covariance(cov: &OrbitalCovariance) -> Self { + let v = cov.variances(); + CometaryUncertainty { + perihelion_distance: v[0].sqrt(), + eccentricity: v[1].sqrt(), + inclination: v[2].sqrt(), + ascending_node_longitude: v[3].sqrt(), + periapsis_argument: v[4].sqrt(), + true_anomaly: v[5].sqrt(), + } + } +} + +/// Full 6×6 covariance matrix for a set of orbital elements. +/// +/// The matrix is stored as a flat array of 36 elements in **row-major** order. +/// The parameterization (Keplerian, equinoctial, or cometary) is determined by +/// the variant it accompanies inside [`crate::orbit_type::OrbitalElements`]. +/// +/// The matrix is assumed to be **symmetric positive semi-definite**. +/// Only the upper triangle is guaranteed to be written by solvers; the lower +/// triangle is kept consistent by convention. +/// +/// Element ordering +/// ---------------- +/// For Keplerian elements: `[a, e, i, Ω, ω, M]`. +/// For equinoctial elements: `[a, h, k, p, q, λ]`. +/// For cometary elements: `[q, e, i, Ω, ω, ν]`. +/// +/// See also +/// -------- +/// * [`crate::orbit_type::OrbitalElements`] – Container that holds this matrix. +/// * [`KeplerianUncertainty`], [`EquinoctialUncertainty`], [`CometaryUncertainty`] – Diagonal 1-σ summaries. +#[derive(Debug, Clone, PartialEq)] +pub struct OrbitalCovariance { + /// Row-major 6×6 covariance matrix. + pub matrix: Matrix6, +} + +impl OrbitalCovariance { + /// Returns the diagonal entries, i.e., the variances of each element. + /// + /// The square roots of the returned values equal the 1-σ standard + /// deviations stored in the matching uncertainty struct + /// ([`KeplerianUncertainty`], [`EquinoctialUncertainty`], or [`CometaryUncertainty`]). + #[inline] + pub fn variances(&self) -> [f64; 6] { + [ + self.matrix[(0, 0)], + self.matrix[(1, 1)], + self.matrix[(2, 2)], + self.matrix[(3, 3)], + self.matrix[(4, 4)], + self.matrix[(5, 5)], + ] + } + + /// Propagate this covariance matrix through a coordinate transformation + /// + /// This method implements **linear covariance propagation**, transforming uncertainties + /// from one orbital element representation to another using the Jacobian matrix of the + /// transformation. + /// + /// ## Mathematical formulation + /// + /// Given a transformation $\mathbf{y} = f(\mathbf{x})$ where $\mathbf{x}$ and $\mathbf{y}$ + /// are 6-element orbital parameter vectors, the covariance in the target space is: + /// + /// $$ + /// \Sigma_y = J \, \Sigma_x \, J^\top + /// $$ + /// + /// where: + /// - $\Sigma_x$ is the $6 \times 6$ covariance matrix in the source representation (this matrix) + /// - $\Sigma_y$ is the $6 \times 6$ covariance matrix in the target representation (returned) + /// - $J = \frac{\partial \mathbf{y}}{\partial \mathbf{x}} \bigg|_{\mathbf{x}_0}$ + /// is the Jacobian matrix evaluated at the nominal element values $\mathbf{x}_0$ + /// + /// ## Derivation + /// + /// Under a first-order Taylor expansion around the nominal point $\mathbf{x}_0$: + /// + /// $$ + /// \mathbf{y} \approx f(\mathbf{x}_0) + J(\mathbf{x} - \mathbf{x}_0) + /// $$ + /// + /// The covariance of $\mathbf{y}$ is: + /// + /// $$ + /// \begin{aligned} + /// \Sigma_y &= E[(\mathbf{y} - E[\mathbf{y}])(\mathbf{y} - E[\mathbf{y}])^\top] \\ + /// &= E[J(\mathbf{x} - \mathbf{x}_0)(\mathbf{x} - \mathbf{x}_0)^\top J^\top] \\ + /// &= J \, E[(\mathbf{x} - \mathbf{x}_0)(\mathbf{x} - \mathbf{x}_0)^\top] \, J^\top \\ + /// &= J \, \Sigma_x \, J^\top + /// ) -> OrbitalCovariance { + OrbitalCovariance { + matrix: jacobian * self.matrix * jacobian.transpose(), + } + } +} diff --git a/src/outfit.rs b/src/outfit.rs deleted file mode 100644 index cb3aece..0000000 --- a/src/outfit.rs +++ /dev/null @@ -1,676 +0,0 @@ -//! # Outfit: environment, ephemerides, and observatory registry -//! -//! This module defines the [`Outfit`](crate::outfit::Outfit) struct, the central façade that wires together: -//! -//! 1. **Environment state** ([`OutfitEnv`](crate::env_state::OutfitEnv)) — providers and configuration (e.g., UT1). -//! 2. **JPL ephemerides access** — lazy, cached handle over a chosen source -//! ([`EphemFileSource`](crate::jpl_ephem::download_jpl_file::EphemFileSource) → [`JPLEphem`](crate::jpl_ephem::JPLEphem)). -//! 3. **Observatory registry** — MPC Observatory Codes parsed into [`Observer`](crate::observers::Observer) instances, -//! with stable integer IDs for compact indexing and storage. -//! 4. **Astrometric error models** — per-site bias/RMS lookup for RA/DEC accuracies. -//! -//! The design emphasizes *lazy initialization* and *idempotent caching*: -//! - The ephemeris file is opened on first use via [`OnceCell`](once_cell::sync::OnceCell), then reused. -//! - The MPC observatory table is fetched and parsed once, then retained. -//! -//! ## Key responsibilities -//! -//! - Single source of truth for **JPL ephemerides** (HORIZONS/NAIF) through [`get_jpl_ephem`](crate::outfit::Outfit::get_jpl_ephem) -//! - Access to **UT1 provider** for Earth-rotation dependent calculations -//! - **MPC observatory code → Observer** resolution and the inverse (**Observer → u16 index**) -//! - Enrichment of observers with **bias/RMS** angular accuracies from the configured -//! [`ErrorModel`](crate::error_models::ErrorModel) (e.g., *FCCT14*) -//! -//! ## Typical usage -//! -//! ```rust, no_run -//! use outfit::outfit::Outfit; -//! use outfit::error_models::ErrorModel; -//! -//! // Instantiate the context with a JPL source and an error model -//! let outfit = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); -//! -//! // On-demand: the ephemeris is opened only once and cached -//! let jpl = outfit.get_jpl_ephem().unwrap(); -//! -//! // Resolve an observer by MPC code -//! let haleakala = outfit.get_observer_from_mpc_code(&"F51".into()); -//! ``` -//! -//! ## Notes -//! -//! - The MPC table is pulled from: -//! `https://www.minorplanetcenter.net/iau/lists/ObsCodes.html` -//! and minimally parsed from its `
` text block.
-//! - Per-site **RA/DEC accuracies** (bias+RMS) are looked up with [`get_bias_rms`](crate::error_models::get_bias_rms),
-//!   currently assuming catalog code `"c"` unless indicated otherwise (TODO).
-//!
-//! ## See also
-//! ------------
-//! * [`JPLEphem`](crate::jpl_ephem::JPLEphem) – Ephemerides access layer.
-//! * [`Observer`](crate::observers::Observer) – Geodetic/parallax observer representation with optional RA/DEC accuracies.
-//! * [`ErrorModel`](crate::error_models::ErrorModel) / [`get_bias_rms`](crate::error_models::get_bias_rms) – Site accuracy enrichment.
-//! * [`OutfitEnv`](crate::env_state::OutfitEnv) – Providers (e.g., UT1) and environment state.
-//! * [`EphemFileSource`](crate::jpl_ephem::download_jpl_file::EphemFileSource) – Source selection for JPL files (HORIZONS/NAIF).
-//!
-//! ## Panics & errors
-//!
-//! - Functions that *must* find an MPC code will `panic!` if the code is unknown.
-//!   Prefer adding a fallible variant if you need graceful handling.
-//! - I/O and parsing failures are surfaced as [`OutfitError`](crate::outfit_errors::OutfitError) where applicable.
-
-use std::{collections::HashMap, fmt, sync::Arc};
-
-use nalgebra::Matrix3;
-use once_cell::sync::OnceCell;
-
-use crate::{
-    constants::{Degree, Kilometer, MpcCode, MpcCodeObs},
-    env_state::OutfitEnv,
-    error_models::{get_bias_rms, ErrorModel, ErrorModelData},
-    jpl_ephem::download_jpl_file::EphemFileSource,
-    observers::{observatories::Observatories, Observer},
-    outfit_errors::OutfitError,
-    ref_system::{rotpn, RefEpoch, RefSystem},
-};
-
-use crate::jpl_ephem::JPLEphem;
-
-#[derive(Debug, Clone)]
-pub struct Outfit {
-    env_state: OutfitEnv,
-    observatories: Observatories,
-    jpl_source: EphemFileSource,
-    jpl_ephem: OnceCell,
-    pub error_model: ErrorModel,
-    error_model_data: ErrorModelData,
-    rot_equmj2000_to_eclmj2000: Matrix3,
-    rot_eclmj2000_to_equmj2000: Matrix3,
-}
-
-impl Outfit {
-    /// Construct a new [`Outfit`] context.
-    ///
-    /// Initializes the environment, sets the JPL ephemerides source, and loads the configured
-    /// error model from disk. The ephemeris file itself is **not** opened yet; it is lazily
-    /// initialized the first time [`get_jpl_ephem`](crate::outfit::Outfit::get_jpl_ephem) is called.
-    ///
-    /// Arguments
-    /// -----------------
-    /// * `jpl_file`: A source descriptor resolvable into an [`EphemFileSource`]
-    ///   (e.g., `"horizon:DE440"` or a NAIF path).
-    /// * `error_model`: The site accuracy model to load (e.g., [`ErrorModel::FCCT14`]).
-    ///
-    /// Return
-    /// ----------
-    /// * A new [`Outfit`] instance or an [`OutfitError`] if the error model cannot be read.
-    ///
-    /// See also
-    /// ------------
-    /// * [`get_jpl_ephem`](crate::outfit::Outfit::get_jpl_ephem) – Lazy initialization and access to the ephemeris handle.
-    /// * [`ErrorModel::read_error_model_file`](crate::error_models::ErrorModel::read_error_model_file) – Underlying loader for the model data.
-    pub fn new(jpl_file: &str, error_model: ErrorModel) -> Result {
-        let rot1 = rotpn(
-            &RefSystem::Equm(RefEpoch::J2000),
-            &RefSystem::Eclm(RefEpoch::J2000),
-        )?;
-
-        let rot2 = rotpn(
-            &RefSystem::Eclm(RefEpoch::J2000),
-            &RefSystem::Equm(RefEpoch::J2000),
-        )?;
-
-        Ok(Outfit {
-            env_state: OutfitEnv::new(),
-            observatories: Observatories::new(),
-            jpl_source: jpl_file.try_into()?,
-            jpl_ephem: OnceCell::new(),
-            error_model,
-            error_model_data: error_model.read_error_model_file()?,
-            rot_equmj2000_to_eclmj2000: rot1,
-            rot_eclmj2000_to_equmj2000: rot2,
-        })
-    }
-
-    /// Get the rotation matrix from equatorial J2000 to ecliptic J2000.
-    /// This matrix is used to transform coordinates from the equatorial frame to the ecliptic frame.
-    pub fn get_rot_equmj2000_to_eclmj2000(&self) -> &Matrix3 {
-        &self.rot_equmj2000_to_eclmj2000
-    }
-
-    pub fn get_rot_eclmj2000_to_equmj2000(&self) -> &Matrix3 {
-        &self.rot_eclmj2000_to_equmj2000
-    }
-
-    /// Get the lazily-initialized JPL ephemerides handle.
-    ///
-    /// If this is the first call, the ephemeris is opened and cached in an internal [`OnceCell`].
-    /// Subsequent calls return the same reference.
-    ///
-    /// Arguments
-    /// -----------------
-    /// *None*
-    ///
-    /// Return
-    /// ----------
-    /// * `&JPLEphem` on success, or an [`OutfitError`] if the source cannot be opened.
-    ///
-    /// See also
-    /// ------------
-    /// * [`EphemFileSource`] – Source configuration.
-    /// * [`OnceCell::get_or_try_init`] – Lazy initialization helper.
-    pub fn get_jpl_ephem(&self) -> Result<&JPLEphem, OutfitError> {
-        self.jpl_ephem
-            .get_or_try_init(|| JPLEphem::new(&self.jpl_source))
-    }
-
-    /// Access the UT1 provider from the environment.
-    ///
-    /// This is useful for Earth-rotation dependent calculations (e.g., GMST, sidereal time).
-    ///
-    /// Arguments
-    /// -----------------
-    /// *None*
-    ///
-    /// Return
-    /// ----------
-    /// * A reference to the [`hifitime::ut1::Ut1Provider`].
-    ///
-    /// See also
-    /// ------------
-    /// * [`OutfitEnv`] – Environment state and providers.
-    pub fn get_ut1_provider(&self) -> &hifitime::ut1::Ut1Provider {
-        &self.env_state.ut1_provider
-    }
-
-    /// Get the lazily built MPC observatory map (MPC code → [`Observer`]).
-    ///
-    /// The map is fetched and parsed from the MPC HTML table on first use, then cached.
-    ///
-    /// Return
-    /// ----------
-    /// * A reference to the shared map: [`MpcCodeObs`] = `HashMap>`.
-    ///
-    /// See also
-    /// ------------
-    /// * [`init_observatories`](crate::outfit::Outfit::init_observatories) – Builder invoked on first access.
-    /// * [`get_observer_from_mpc_code`](crate::outfit::Outfit::get_observer_from_mpc_code) – Convenience accessor for one site.
-    pub(crate) fn get_observatories(&self) -> &MpcCodeObs {
-        self.observatories
-            .mpc_code_obs
-            .get_or_init(|| self.init_observatories())
-    }
-
-    /// Resolve an [`Observer`] from a given MPC observatory code.
-    ///
-    /// This accessor panics if the code is unknown. Use it when unknown codes are exceptional.
-    ///
-    /// Arguments
-    /// -----------------
-    /// * `mpc_code`: The MPC observatory code (e.g., `"F51"`).
-    ///
-    /// Return
-    /// ----------
-    /// * An `Arc` for the requested site.
-    pub fn get_observer_from_mpc_code(&self, mpc_code: &MpcCode) -> Arc {
-        self.get_observatories()
-            .get(mpc_code)
-            .unwrap_or_else(|| panic!("MPC code not found: {mpc_code}"))
-            .clone()
-    }
-
-    /// Build the MPC observatory registry by fetching and parsing the MPC list.
-    ///
-    /// For each row, the routine extracts:
-    /// - Longitude (deg), ρ·cosφ, ρ·sinφ (parallax factors),
-    /// - Human-readable name,
-    /// - Optional RA/DEC accuracies derived from the loaded [`ErrorModelData`]
-    ///   via [`get_bias_rms`] (currently using catalog code `"c"`, TODO).
-    ///
-    /// Return
-    /// ----------
-    /// * A freshly constructed [`MpcCodeObs`] map.
-    ///
-    /// See also
-    /// ------------
-    /// * [`get_observatories`](crate::outfit::Outfit::get_observatories) – Lazy wrapper that caches this map.
-    /// * [`get_bias_rms`] – Site accuracy lookup by (mpc_code, catalog_code).
-    pub(crate) fn init_observatories(&self) -> MpcCodeObs {
-        let mut observatories: MpcCodeObs = HashMap::new();
-
-        let mpc_code_response = self
-            .env_state
-            .get_from_url("https://www.minorplanetcenter.net/iau/lists/ObsCodes.html");
-
-        let mpc_code_csv = mpc_code_response
-            .trim()
-            .strip_prefix("
")
-            .and_then(|s| s.strip_suffix("
")) - .expect("Failed to strip pre tags"); - - for lines in mpc_code_csv.lines().skip(2) { - let line = lines.trim(); - - if let Some((code, remain)) = line.split_at_checked(3) { - let remain = remain.trim_end(); - - let (longitude, cos, sin, name) = parse_remain(remain, code); - - // TODO: support per-site catalog codes (not always "c") - let bias_rms = - get_bias_rms(&self.error_model_data, code.to_string(), "c".to_string()); - - let observer = Observer::from_parallax( - longitude as f64, - cos as f64, - sin as f64, - Some(name), - bias_rms.map(|(ra, _)| ra as f64), - bias_rms.map(|(_, dec)| dec as f64), - ) - .expect("Failed to create observer"); - observatories.insert(code.to_string(), Arc::new(observer)); - }; - } - observatories - } - - /// Convert an MPC code to its stable 16-bit observatory index. - /// - /// Useful for compact storage of observer references in catalogs, measurements, - /// and ephemeris products. - /// - /// Arguments - /// ----------------- - /// * `mpc_code`: The MPC observatory code. - /// - /// Return - /// ---------- - /// * The `u16` index associated with the given observer. - /// - /// See also - /// ------------ - /// * [`get_observer_from_mpc_code`](crate::outfit::Outfit::get_observer_from_mpc_code) – Resolve the observer first (panic on unknown). - /// * [`uint16_from_observer`](crate::outfit::Outfit::uint16_from_observer) – Indexing for arbitrary/new observers. - pub(crate) fn uint16_from_mpc_code(&mut self, mpc_code: &MpcCode) -> u16 { - let observer = self.get_observer_from_mpc_code(mpc_code); - self.observatories.uint16_from_observer(observer) - } - - /// Convert an [`Observer`] handle to its stable 16-bit index. - /// - /// Arguments - /// ----------------- - /// * `observer`: The observer to be indexed. - /// - /// Return - /// ---------- - /// * The `u16` index associated with this observer (inserting if new). - /// - /// See also - /// ------------ - /// * [`get_observer_from_uint16`](crate::outfit::Outfit::get_observer_from_uint16) – Recover a reference from an index. - /// * [`new_observer`](crate::outfit::Outfit::new_observer) – Create and register a new custom observer. - pub(crate) fn uint16_from_observer(&mut self, observer: Arc) -> u16 { - self.observatories.uint16_from_observer(observer) - } - - /// Recover an [`Observer`] reference from a 16-bit index. - /// - /// Arguments - /// ----------------- - /// * `observer_idx`: The previously assigned index. - /// - /// Return - /// ---------- - /// * A reference to the corresponding [`Observer`]. - /// - /// See also - /// ------------ - /// * [`uint16_from_observer`](crate::outfit::Outfit::uint16_from_observer) – Assign/lookup indices. - /// * [`get_observer_from_mpc_code`](crate::outfit::Outfit::get_observer_from_mpc_code) – Resolve by MPC code instead. - pub(crate) fn get_observer_from_uint16(&self, observer_idx: u16) -> &Observer { - self.observatories.get_observer_from_uint16(observer_idx) - } - - /// Create and register a new **custom** observer. - /// - /// This helper converts geodetic inputs to the internal parallax representation - /// (ρ·cosφ, ρ·sinφ) and stores the new [`Observer`] with an optional display name. - /// - /// Arguments - /// ----------------- - /// * `longitude`: Geodetic longitude in **degrees** (east-positive). - /// * `latitude`: Geodetic latitude in **degrees**. - /// * `elevation`: Elevation in **kilometers** above the ellipsoid/geoid (model-dependent). - /// * `name`: Optional human-readable name for the site. - /// - /// Return - /// ---------- - /// * An `Arc` handle to the newly created observer. - pub fn new_observer( - &mut self, - longitude: Degree, - latitude: Degree, - elevation: Kilometer, - name: Option, - ) -> Arc { - self.observatories - .create_observer(longitude, latitude, elevation, name) - } - - pub(crate) fn add_observer_internal(&mut self, observer: Arc) -> u16 { - self.observatories.add_observer(observer) - } - - pub fn add_observer(&mut self, observer: Arc) { - self.add_observer_internal(observer); - } - - /// Render the current observatories into a newly allocated `String`. - /// - /// This is a convenience wrapper around the `Display` implementation of - /// the internal struct \[`Observatories`\]. It materializes the formatted list (user-defined - /// observers first, then MPC sites if available) into a `String`. - /// - /// Output format - /// ------------- - /// * Longitude and latitude are shown in **degrees**. - /// * Elevation is shown in **kilometers**. - /// * User-defined observers are listed first; if initialized, the - /// **MPC observers** section follows. - /// * The relative order within each section is not guaranteed to be stable. - /// - /// Arguments - /// ----------------- - /// * None. - /// - /// Return - /// ---------- - /// * A `String` containing the formatted observatories. - /// - /// See also - /// ------------ - /// * [`Outfit::show_observatories`] – Allocation-free display adaptor. - /// * [`Observer::geodetic_lat_height_wgs84`] – Provides latitude/height used in the listing. - #[inline] - pub fn show_observatories_string(&self) -> String { - self.observatories.to_string() - } - - /// Pretty-print the current set of observatories without allocating a `String`. - /// - /// Returns a lightweight `Display` adaptor over the internal \[`Observatories`\] - /// collection. Use with `format!`, `println!`, log macros, or any consumer of - /// `fmt::Display`. User-defined observers are printed first, followed by the - /// **MPC observers** section if the MPC table has been initialized. - /// - /// Output format - /// ------------- - /// * Longitude and latitude are shown in **degrees**. - /// * Elevation is shown in **kilometers**. - /// * The relative order within each section is not guaranteed to be stable. - /// - /// Arguments - /// ----------------- - /// * None. - /// - /// Return - /// ---------- - /// * An [`ObservatoriesView`] display adaptor (zero-copy) suitable for `fmt::Display`. - /// - /// See also - /// ------------ - /// * [`Outfit::show_observatories_string`] – Eager, allocated `String`. - /// * [`Observer::geodetic_lat_height_wgs84`] – Provides latitude/height used in the listing. - #[inline] - pub fn show_observatories(&self) -> ObservatoriesView<'_> { - ObservatoriesView(&self.observatories) - } -} - -/// Lightweight, zero-allocation display adaptor for the internal private struct \[`Observatories`\]. -/// -/// This type borrows the internal \[`Observatories`\] and implements `fmt::Display`, -/// allowing you to pretty-print the full list of observers without allocating an -/// intermediate `String`. It simply delegates to the `Display` implementation -/// of \[`Observatories`\]. -/// -/// Output format -/// ------------- -/// * User-defined observers are listed first. -/// * If initialized, an **MPC observers** section follows. -/// * Longitudes/latitudes are shown in **degrees**; elevation is shown in **kilometers**. -/// * Relative order within each section is not guaranteed to be stable (hash-map backed). -/// -/// Example -/// ----------------- -/// ```rust, no_run -/// # use outfit::outfit::Outfit; -/// # use outfit::error_models::ErrorModel; -/// let outfit = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); -/// // Print to stdout without allocating a String: -/// println!("{}", outfit.show_observatories()); -/// // Or, if you need a String, use: -/// let s = outfit.show_observatories_string(); -/// assert!(s.contains("User-defined observers:")); -/// ``` -/// -/// Arguments -/// ----------------- -/// * None (constructed by [`Outfit::show_observatories`]). -/// -/// Return -/// ---------- -/// * A display adaptor suitable for `format!`, `println!`, and any `fmt::Display` consumer. -/// -/// See also -/// ------------ -/// * [`Outfit::show_observatories`] – Returns this adaptor. -/// * [`Outfit::show_observatories_string`] – Allocating `String` convenience. -/// * [`Observer::geodetic_lat_height_wgs84`] – Provides latitude/height used in the listing. -pub struct ObservatoriesView<'a>(&'a Observatories); - -impl<'a> fmt::Display for ObservatoriesView<'a> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - // Delegate to Observatories' Display without allocating - write!(f, "{}", self.0) - } -} - -/// Parse a fixed-width float slice from an MPC observatory line. -/// -/// Arguments -/// ----------------- -/// * `s`: Full line (trailing part after the 3-char MPC code). -/// * `slice`: Byte range selecting the numeric field. -/// * `code`: MPC code (for diagnostics). -/// -/// Return -/// ---------- -/// * `Ok(f32)` parsed value, or the parsing error. -/// -/// See also -/// ------------ -/// * [`parse_remain`] – Higher-level field extraction for one line. -fn parse_f32( - s: &str, - slice: std::ops::Range, - code: &str, -) -> Result { - s.get(slice) - .unwrap_or_else(|| panic!("Failed to parse float for observer code: {code}")) - .trim() - .parse() -} - -/// Extract longitude, ρ·cosφ, ρ·sinφ, and name from a fixed-width MPC row. -/// -/// This helper returns partial values (with zeros) if any field fails to parse, -/// allowing the caller to still record the site name while signaling missing data -/// implicitly through zeros. -/// -/// Arguments -/// ----------------- -/// * `remain`: Fixed-width tail of the line (after the 3-char MPC code). -/// * `code`: MPC code (for diagnostics). -/// -/// Return -/// ---------- -/// * `(longitude_deg, rho_cos_phi, rho_sin_phi, name)` -fn parse_remain(remain: &str, code: &str) -> (f32, f32, f32, String) { - let name = remain - .get(27..) - .unwrap_or_else(|| panic!("Failed to parse name value for code: {code}")); - - let Some(longitude) = parse_f32(remain, 1..10, code).ok() else { - return (0.0, 0.0, 0.0, name.to_string()); - }; - - let Some(cos) = parse_f32(remain, 10..18, code).ok() else { - return (longitude, 0.0, 0.0, name.to_string()); - }; - - let Some(sin) = parse_f32(remain, 18..27, code).ok() else { - return (longitude, cos, 0.0, name.to_string()); - }; - (longitude, cos, sin, name.to_string()) -} - -#[cfg(test)] -mod outfit_show_observatories_tests { - use super::*; - use std::sync::Arc; - - /// Build a lightweight Outfit for display tests, then add zero or more user observers. - /// - /// Notes - /// ----- - /// * Uses the public `Outfit::new(...)` constructor to avoid touching private internals. - /// * If your `Outfit::new` signature changes, adjust here accordingly. - fn build_outfit_with_users(users: &[(&str, f64, f64, f64)]) -> Outfit { - // Pick a reasonable default error model; adjust if your API differs. - let mut outfit = Outfit::new("horizon:DE440", crate::error_models::ErrorModel::FCCT14) - .expect("Failed to construct Outfit for display tests"); - - for (name, lon_deg, lat_deg, elev_km) in users.iter().copied() { - let obs = Observer::new( - lon_deg, - lat_deg, - elev_km, - Some(name.to_string()), - None, - None, - ) - .expect("Failed to create user observer"); - // If your API differs, replace with the appropriate method to register observers: - outfit.add_observer(Arc::new(obs)); - } - outfit - } - - /// Ensure the string rendering equals the Display adaptor output when there are no observers. - #[test] - fn show_observatories_empty() { - let outfit = build_outfit_with_users(&[]); - - let s_string = outfit.show_observatories_string(); - let s_view = format!("{}", outfit.show_observatories()); - - assert_eq!( - s_string, s_view, - "String output and Display adaptor should match" - ); - assert!( - s_string.starts_with("No observatories defined (user or MPC)."), - "Missing 'User-defined observers:' header. Got:\n{s_string}" - ); - assert!( - !s_string.contains("MPC observers:"), - "Should not show 'MPC observers:' when OnceLock is unset. Got:\n{s_string}" - ); - } - - /// After adding user-defined observers, they should appear in the output. - #[test] - fn show_observatories_with_users() { - let outfit = build_outfit_with_users(&[ - ("UserA", 10.0, 0.0, 0.0), - ("UserB", 20.0, 45.0, 2.0), // 2 km elevation - ]); - - let s_string = outfit.show_observatories_string(); - let s_view = format!("{}", outfit.show_observatories()); - - assert_eq!( - s_string, s_view, - "String output and Display adaptor should match" - ); - - // Headers and user names should be present - assert!( - s_string.starts_with("User-defined observers:\n"), - "Missing 'User-defined observers:' header. Got:\n{s_string}" - ); - assert!( - s_string.contains("UserA (lon: 10.000000°"), - "Missing formatted line for UserA. Got:\n{s_string}" - ); - assert!( - s_string.contains("UserB (lon: 20.000000°"), - "Missing formatted line for UserB. Got:\n{s_string}" - ); - } - - /// If the MPC table is initialized, the MPC section should appear. - /// - /// Notes - /// ----- - /// * This test accesses the OnceLock inside `observatories` to inject a minimal MPC table. - /// * If your `MpcCodeObs` stores `Arc` instead of `Observer`, wrap with `Arc::new`. - #[test] - fn show_observatories_with_mpc_section() { - let outfit = build_outfit_with_users(&[("UserOnly", 0.0, 0.0, 0.0)]); - - // Build a minimal MPC table with one entry - let mpc_site = Observer::new( - -156.2575, - 20.7075, - 3.055, - Some("Haleakala".to_string()), - None, - None, - ) - .expect("Failed to create MPC observer"); - - // If MpcCodeObs = HashMap>, wrap with Arc::new. - // If it is HashMap, remove Arc::new below. - let mut mpc_table: crate::constants::MpcCodeObs = Default::default(); - // Uncomment ONE of the two lines below depending on your alias: - // mpc_table.insert("I41".to_string(), mpc_site); // if value is Observer - mpc_table.insert("I41".to_string(), Arc::new(mpc_site)); // if value is Arc - - // Initialize the OnceLock - outfit - .observatories - .mpc_code_obs - .set(mpc_table) - .expect("OnceLock already initialized"); - - let s_string = outfit.show_observatories_string(); - let s_view = format!("{}", outfit.show_observatories()); - - assert_eq!( - s_string, s_view, - "String output and Display adaptor should match" - ); - assert!( - s_string.contains("MPC observers:"), - "Missing 'MPC observers:' header after setting OnceLock. Got:\n{s_string}" - ); - assert!( - s_string.contains("[I41]"), - "Missing MPC code tag '[I41]' in output. Got:\n{s_string}" - ); - } -} diff --git a/src/outfit_errors.rs b/src/outfit_errors.rs index a86ae10..b0debc4 100644 --- a/src/outfit_errors.rs +++ b/src/outfit_errors.rs @@ -45,8 +45,8 @@ //! **When**: Parsing observations, fixed-width records, ADES, or internal streams. //! //! - [`NomParsingError`](crate::outfit_errors::OutfitError::NomParsingError) -//! - [`Parsing80ColumnFileError`](crate::outfit_errors::OutfitError::Parsing80ColumnFileError) -//! - [`Parquet`](crate::outfit_errors::OutfitError::Parquet) +//! - `Parsing80ColumnFileError` *(photom crate)* +//! - `Parquet` *(photom crate)* //! //! **Typical causes**: schema drift; corrupted inputs; locale-specific formats; //! columnar format mismatches. @@ -131,12 +131,14 @@ //! //! ## See also //! ------------- -//! * [`Outfit`](crate::outfit::Outfit) – main crate context using this error type. -//! * [`ParseObsError`](crate::trajectories::mpc_80col_reader::ParseObsError) – observation parsing errors. +//! * `Outfit` – main crate context using this error type. +//! * `ParseObsError` *(photom crate)* – observation parsing errors. //! * [`roots::SearchError`] – wrapped in [`RootFindingError`](crate::outfit_errors::OutfitError::RootFindingError). //! * [`rand_distr::NormalError`] – wrapped in [`NoiseInjectionError`](crate::outfit_errors::OutfitError::NoiseInjectionError). - -use crate::trajectories::mpc_80col_reader::ParseObsError; +use photom::{ + observation_dataset::{ObsDatasetError, ObsId}, + TrajId, +}; use thiserror::Error; #[derive(Error, Debug)] @@ -159,7 +161,6 @@ pub enum OutfitError { #[error("Filesystem I/O error: {0}")] IoError(#[from] std::io::Error), - #[cfg(feature = "jpl-download")] #[error("HTTP request failed (reqwest): {0}")] ReqwestError(#[from] reqwest::Error), @@ -187,9 +188,6 @@ pub enum OutfitError { #[error("Parsing error (nom): {0}")] NomParsingError(String), - #[error("Parsing error in 80-column observation file: {0}")] - Parsing80ColumnFileError(ParseObsError), - #[error("Gaussian noise generation failed: {0:?}")] NoiseInjectionError(rand_distr::NormalError), @@ -232,9 +230,6 @@ pub enum OutfitError { #[error("Gauss preliminary orbit determination failed: {0}")] GaussPrelimOrbitFailed(String), - #[error(transparent)] - Parquet(#[from] parquet::errors::ParquetError), - #[error("No viable orbit could be determined after {attempts} attempts: {cause}")] NoViableOrbit { cause: Box, @@ -253,6 +248,48 @@ pub enum OutfitError { #[error("Non-finite score encountered: {0}")] NonFiniteScore(f64), + + #[error("The provided observation {0} has no associated observer (ObserverId is None)")] + ObserverIdIsNone(ObsId), + + #[error(transparent)] + ObsDatasetError(#[from] ObsDatasetError), + + #[error("Observer dataset error: {0}")] + ObsDatasetErrorRef(String), + + #[error("Trajectory ID not found in dataset: {0}")] + TrajectoryIdNotFound(TrajId), + + #[error("No trajectory index available in the dataset")] + NoTrajectoryIndex, + + /// The corrected orbital elements fall outside the physical-plausibility + /// limits (e.g., eccentricity ≥ 1, negative semi-major axis). + #[error("Differential correction produced a physically implausible (bizarre) orbit")] + BizarreOrbit, + + /// The normalised RMS increased by more than the configured divergence + /// ratio during the Newton–Raphson inner loop. + #[error("Differential correction diverged (RMS increased beyond the divergence threshold)")] + DifferentialCorrectionDiverged, + + /// The differential-correction pipeline failed for a reason other than + /// divergence (e.g., normal-equation inversion failure). + #[error("Differential correction failed: {0}")] + DifferentialCorrectionFailed(String), + + #[error("Ephemeris body not supported by this backend: {0}")] + EphemerisBodyNotSupported(String), + + #[error("N-body propagation failed: {0}")] + NBodyPropagationFailed(String), +} + +impl From<&ObsDatasetError> for OutfitError { + fn from(e: &ObsDatasetError) -> Self { + OutfitError::ObsDatasetErrorRef(e.to_string()) + } } impl From for OutfitError { @@ -279,9 +316,7 @@ impl PartialEq for OutfitError { // Opaque external error kinds compare equal by variant only (UreqHttpError(_), UreqHttpError(_)) => true, (IoError(_), IoError(_)) => true, - #[cfg(feature = "jpl-download")] (ReqwestError(_), ReqwestError(_)) => true, - (Parquet(_), Parquet(_)) => true, (UnableToCreateBaseDir(a), UnableToCreateBaseDir(b)) => a == b, (Utf8PathError(a), Utf8PathError(b)) => a == b, @@ -291,7 +326,6 @@ impl PartialEq for OutfitError { (InvalidErrorModel(a), InvalidErrorModel(b)) => a == b, (InvalidErrorModelFilePath(a), InvalidErrorModelFilePath(b)) => a == b, (NomParsingError(a), NomParsingError(b)) => a == b, - (Parsing80ColumnFileError(a), Parsing80ColumnFileError(b)) => a == b, (NoiseInjectionError(a), NoiseInjectionError(b)) => a == b, (InvalidSpkDataType(a), InvalidSpkDataType(b)) => a == b, (InvalidIODParameter(a), InvalidIODParameter(b)) => a == b, @@ -333,6 +367,11 @@ impl PartialEq for OutfitError { (PolynomialRootFindingFailed, PolynomialRootFindingFailed) => true, (SpuriousRootDetected, SpuriousRootDetected) => true, (GaussNoRootsFound, GaussNoRootsFound) => true, + (BizarreOrbit, BizarreOrbit) => true, + (DifferentialCorrectionDiverged, DifferentialCorrectionDiverged) => true, + (DifferentialCorrectionFailed(a), DifferentialCorrectionFailed(b)) => a == b, + (EphemerisBodyNotSupported(a), EphemerisBodyNotSupported(b)) => a == b, + (NBodyPropagationFailed(a), NBodyPropagationFailed(b)) => a == b, _ => false, } diff --git a/src/propagator/mod.rs b/src/propagator/mod.rs new file mode 100644 index 0000000..d6ba985 --- /dev/null +++ b/src/propagator/mod.rs @@ -0,0 +1,162 @@ +//! Orbit propagation strategies for differential orbit correction. +//! +//! This module exposes two propagator kinds: +//! +//! - `PropagatorKind::TwoBody` – classic Keplerian propagation through the +//! analytic two-body solution. This is the default and requires no external +//! ephemeris data beyond the standard solar GM. +//! +//! - `PropagatorKind::NBody` – numerical N-body propagation via an 8th-order +//! Dormand-Prince (DOP853) integrator. The target object is integrated +//! together with its 6×6 state transition matrix (STM) under the influence of +//! the Sun and any additional perturbing bodies selected in `NBodyConfig`. +//! Planetary positions are looked up from the `JPLEphem` file supplied to the +//! differential corrector at runtime. + +pub mod nbody; +pub mod planet_gm; + +use nalgebra::Vector3; + +use crate::{ + constants::ROT_ECLMJ2000_TO_EQUMJ2000, jpl_ephem::naif::naif_ids::NaifIds, EquinoctialElements, + JPLEphem, OutfitError, +}; + +/// Select which propagator is used during differential orbit correction. +#[derive(Debug, Clone, Default)] +pub enum PropagatorKind { + /// Keplerian (analytic) two-body propagation. Fast and self-contained. + #[default] + TwoBody, + + /// Numerical N-body propagation with user-specified perturbing bodies. + NBody(NBodyConfig), +} + +impl PropagatorKind { + /// Propagate `elements` to `obs_time_mjd` and return the heliocentric + /// position and velocity in the **equatorial mean J2000** frame. + /// + /// Dispatches to the analytic two-body solver ([`PropagatorKind::TwoBody`]) + /// or to the numerical DOP853 integrator ([`PropagatorKind::NBody`]) + /// based on the variant of `self`. + /// + /// # Returns + /// + /// `(position [AU], velocity [AU/day])`, both in equatorial mean J2000. + /// + /// # Errors + /// + /// Propagates any error returned by the underlying propagator. + pub(crate) fn propagate_to_epoch( + &self, + elements: &EquinoctialElements, + obs_time_mjd: f64, + jpl: &JPLEphem, + ) -> Result<(Vector3, Vector3), OutfitError> { + match self { + PropagatorKind::TwoBody => propagate_twobody(elements, obs_time_mjd), + PropagatorKind::NBody(nbody_config) => { + propagate_nbody(elements, obs_time_mjd, jpl, nbody_config) + } + } + } +} + +/// Propagate the orbit analytically using the Keplerian two-body model. +/// +/// Computes the time-of-flight \\( \Delta t = t_\text{obs} - t_\text{ref} \\) +/// \[days\], calls [`EquinoctialElements::propagate_twobody`], and rotates the +/// result from ecliptic to equatorial mean J2000 via [`ecl_state_to_equ`]. +/// +/// # Returns +/// +/// `(position [AU], velocity [AU/day])` in equatorial mean J2000. +/// +/// # Errors +/// +/// Returns [`OutfitError`] if the Kepler solver does not converge. +fn propagate_twobody( + elements: &EquinoctialElements, + obs_time_mjd: f64, +) -> Result<(Vector3, Vector3), OutfitError> { + let dt = obs_time_mjd - elements.reference_epoch; + let (pos_ecl, vel_ecl, _) = elements.propagate_twobody(0.0, dt, false)?; + Ok(ecl_state_to_equ(pos_ecl, vel_ecl)) +} + +/// Propagate the orbit numerically using the N-body DOP853 integrator. +/// +/// Calls [`EquinoctialElements::propagate_nbody`] with the supplied +/// [`NBodyConfig`] (perturbing bodies, integration tolerances) and rotates +/// the result from ecliptic to equatorial mean J2000 via [`ecl_state_to_equ`]. +/// +/// # Returns +/// +/// `(position [AU], velocity [AU/day])` in equatorial mean J2000. +/// +/// # Errors +/// +/// Returns [`OutfitError`] if the integrator fails or the JPL data is +/// unavailable for the integration span. +fn propagate_nbody( + elements: &EquinoctialElements, + obs_time_mjd: f64, + jpl: &JPLEphem, + config: &NBodyConfig, +) -> Result<(Vector3, Vector3), OutfitError> { + let result = elements.propagate_nbody(obs_time_mjd, jpl, config)?; + Ok(ecl_state_to_equ(result.position, result.velocity)) +} + +/// Rotate an ecliptic-frame position+velocity pair into **equatorial mean +/// J2000** using the pre-computed rotation matrix +/// [`ROT_ECLMJ2000_TO_EQUMJ2000`](crate::constants::ROT_ECLMJ2000_TO_EQUMJ2000). +/// +/// The rotation corresponds to a positive rotation of \\( +\varepsilon \\) +/// around the X-axis, where \\( \varepsilon \\) is the obliquity of the +/// ecliptic at J2000. +/// +/// # Returns +/// +/// `(pos_equ [AU], vel_equ [AU/day])` in equatorial mean J2000. +#[inline] +fn ecl_state_to_equ(pos_ecl: Vector3, vel_ecl: Vector3) -> (Vector3, Vector3) { + ( + ROT_ECLMJ2000_TO_EQUMJ2000 * pos_ecl, + ROT_ECLMJ2000_TO_EQUMJ2000 * vel_ecl, + ) +} + +/// Configuration for the N-body propagator. +#[derive(Debug, Clone)] +pub struct NBodyConfig { + /// Bodies whose gravitational attraction perturbs the target orbit. + /// + /// Defaults to `[Sun]`. For each body the GM is taken from [`planet_gm`] + /// and its ephemeris is obtained from the `JPLEphem` file passed to the + /// integrator at runtime. + pub perturbing_bodies: Vec, + + /// Absolute tolerance for the DOP853 step-size control. + /// + /// Units: AU. Defaults to `1e-12`. + pub abs_tol: f64, + + /// Relative tolerance for the DOP853 step-size control. + /// + /// Defaults to `1e-12`. + pub rel_tol: f64, +} + +impl Default for NBodyConfig { + fn default() -> Self { + use crate::jpl_ephem::naif::naif_ids::solar_system_bary::SolarSystemBary; + Self { + perturbing_bodies: vec![NaifIds::SSB(SolarSystemBary::Sun)], + abs_tol: 1e-12, + rel_tol: 1e-12, + } + } +} diff --git a/src/propagator/nbody.rs b/src/propagator/nbody.rs new file mode 100644 index 0000000..f0bfcfe --- /dev/null +++ b/src/propagator/nbody.rs @@ -0,0 +1,604 @@ +//! N-body propagator with state transition matrix (DOP853). +//! +//! # Overview +//! +//! This module integrates the heliocentric equations of motion for a small +//! solar-system body together with the 6×6 **state transition matrix** (STM) Φ — +//! the matrix of partial derivatives ∂(r,v)(t1)/∂(r,v)(t0) that maps small +//! perturbations of the initial state to perturbations of the final state, +//! using the explicit 8th-order Dormand–Prince (DOP853) integrator provided by +//! the [`differential-equations`](differential_equations) crate. +//! +//! ## State layout +//! The augmented state vector has 42 components stored as `[f64; 42]`: +//! +//! ```text +//! y[0..3] – heliocentric position r [AU] +//! y[3..6] – heliocentric velocity v [AU/day] +//! y[6..42] – STM Φ, stored column-major (nalgebra convention) as a flat 36-element slice +//! ``` +//! +//! ## Acceleration model +//! The Newtonian heliocentric acceleration for perturber `i` is +//! +//! ```text +//! a += −GMᵢ/|d|³ · d + GMᵢ/|rᵢ|³ · rᵢ (indirect term) +//! ``` +//! +//! where `d = r − rᵢ` is the asteroid–perturber vector, and the second term +//! removes the Sun's perturbation on the integration centre (heliocentric +//! formulation). When the Sun itself is a perturber the indirect term cancels +//! the direct term exactly, yielding the standard two-body acceleration. +//! +//! ## Variational equations +//! ```text +//! dΦ/dt = A(t) · Φ +//! ``` +//! with `A = [[0, I], [∂a/∂r, 0]]` and +//! +//! ```text +//! ∂a/∂r = Σᵢ GMᵢ · (3 d dᵀ/|d|⁵ − I/|d|³) +//! ``` +//! +//! ## Element Jacobian +//! The result of this module is `dpos_delem_ecl` (Matrix6x3, rows = elements, +//! cols = ecliptic Cartesian components) that is directly substitutable for the +//! matrix returned by `solve_two_body_problem`. It is computed as +//! +//! ```text +//! J(t1) = [Φ_pp | Φ_pv] · J0 (top 3 rows of Φ(t1) · J0_full) +//! ``` +//! +//! where `J0_full` is the 6×6 matrix formed by stacking `dpos_delem` and +//! `dvel_delem` (each 6×3) from a zero-propagation call to +//! `solve_two_body_problem`. + +use differential_equations::ode::ODE; +use differential_equations::prelude::*; +use nalgebra::{Matrix3, Matrix6, Matrix6x3, Vector3}; + +use crate::{jpl_ephem::JPLEphem, outfit_errors::OutfitError}; + +use super::{planet_gm::gm_au3_day2, NBodyConfig}; + +// --------------------------------------------------------------------------- +// ODE right-hand side +// --------------------------------------------------------------------------- + +// The augmented state is [f64; 42]: +// y[0..3] = position (AU) +// y[3..6] = velocity (AU/day) +// y[6..42] = STM Φ stored col-major + +/// Snapshot of a perturbing body at a fixed epoch. +/// +/// The snapshot is taken at t0 and held constant over the integration arc. +/// This is accurate for short arcs (≲ 30 days) where planetary motion is slow. +pub(crate) struct PerturberSnapshot { + /// Heliocentric position of the perturber at t0, in the ecliptic J2000 frame. + /// + /// Units: AU. + heliocentric_position: Vector3, + + /// Gravitational parameter GM of the perturber. + /// + /// Units: AU³/day². + gravitational_parameter: f64, +} + +/// ODE right-hand side for the augmented state (position, velocity, STM). +/// +/// Implements [`ODE`] so that it can be driven by the DOP853 +/// integrator. The dynamics are frozen at t0: perturber positions are sampled +/// once and held constant throughout the integration arc. +pub(crate) struct NBodyOde { + /// Perturber snapshots evaluated at t0. + /// + /// Each entry holds the heliocentric position and GM of one perturbing body. + /// These values are used on every call to [`ODE::diff`] to compute the total + /// heliocentric acceleration and the gravity-gradient matrix required by the + /// variational equations. + pub(crate) perturbers: Vec, +} + +// --------------------------------------------------------------------------- +// Acceleration sub-functions +// --------------------------------------------------------------------------- + +/// Computes the direct gravitational acceleration exerted by a single perturber +/// on the small body: +/// +/// ```text +/// a_direct = −GM / |asteroid_to_perturber|³ · asteroid_to_perturber +/// ``` +/// +/// # Arguments +/// +/// * `asteroid_to_perturber` – Vector from the heliocentric position of the +/// small body to the heliocentric position of the perturber, i.e. +/// `r_perturber − r_asteroid`. Units: AU. +/// * `gravitational_parameter` – Gravitational parameter GM of the perturber. +/// Units: AU³/day². +/// +/// # Returns +/// +/// The direct acceleration vector (AU/day²) pointing from the small body +/// toward the perturber, scaled by GM / |d|³. +fn direct_acceleration( + asteroid_to_perturber: Vector3, + gravitational_parameter: f64, +) -> Vector3 { + let distance = asteroid_to_perturber.norm(); + let distance_cubed = distance.powi(3); + -gravitational_parameter / distance_cubed * asteroid_to_perturber +} + +/// Computes the indirect (heliocentric frame correction) acceleration from a +/// single perturber: +/// +/// ```text +/// a_indirect = +GM / |perturber_heliocentric_pos|³ · perturber_heliocentric_pos +/// ``` +/// +/// Returns zero if the perturber is at (or very near) the origin (i.e. it is +/// the Sun itself, which cancels with its direct term). +/// +/// # Arguments +/// +/// * `perturber_heliocentric_position` – Heliocentric position of the perturber +/// in the ecliptic J2000 frame. Units: AU. +/// * `gravitational_parameter` – Gravitational parameter GM of the perturber. +/// Units: AU³/day². +/// +/// # Returns +/// +/// The indirect acceleration correction vector (AU/day²) that removes the +/// apparent acceleration of the heliocentric origin due to the perturber. +/// Returns [`Vector3::zeros`] when the perturber distance is ≤ 1 × 10⁻¹⁰ AU +/// (i.e. the perturber coincides with the Sun). +fn indirect_acceleration( + perturber_heliocentric_position: Vector3, + gravitational_parameter: f64, +) -> Vector3 { + let perturber_distance = perturber_heliocentric_position.norm(); + if perturber_distance > 1e-10 { + let perturber_distance_cubed = perturber_distance.powi(3); + gravitational_parameter / perturber_distance_cubed * perturber_heliocentric_position + } else { + Vector3::zeros() + } +} + +/// Computes the gravity-gradient matrix contribution (∂a/∂r) from a single +/// perturber: +/// +/// ```text +/// ∂a/∂r += −GM · (I/|d|³ − 3 d dᵀ/|d|⁵) +/// ``` +/// +/// # Arguments +/// +/// * `asteroid_to_perturber` – Vector from the heliocentric position of the +/// small body to that of the perturber (`r_perturber − r_asteroid`). +/// Units: AU. +/// * `gravitational_parameter` – Gravitational parameter GM of the perturber. +/// Units: AU³/day². +/// +/// # Returns +/// +/// A 3×3 matrix (units: day⁻²) representing this perturber's contribution to +/// ∂a/∂r, the partial derivative of the acceleration with respect to the +/// small-body position. Summing this matrix over all perturbers yields the +/// lower-left block of the linearised dynamics matrix A used in the variational +/// equations `dΦ/dt = A · Φ`. +fn gravity_gradient_contribution( + asteroid_to_perturber: Vector3, + gravitational_parameter: f64, +) -> Matrix3 { + let distance = asteroid_to_perturber.norm(); + let distance_cubed = distance.powi(3); + let distance_fifth = distance.powi(5); + let outer_product = asteroid_to_perturber * asteroid_to_perturber.transpose(); + -gravitational_parameter + * (Matrix3::::identity() * (1.0 / distance_cubed) + - outer_product * (3.0 / distance_fifth)) +} + +/// Accumulates total heliocentric acceleration and gravity-gradient matrix +/// over all perturbers. +/// +/// Iterates over every [`PerturberSnapshot`] and sums the direct acceleration, +/// indirect acceleration correction, and gravity-gradient contribution from +/// each body. +/// +/// # Arguments +/// +/// * `asteroid_heliocentric_position` – Heliocentric position of the small body +/// in the ecliptic J2000 frame. Units: AU. +/// * `perturbers` – Slice of perturber snapshots evaluated at t0. Each entry +/// provides the perturber's heliocentric position (AU) and GM (AU³/day²). +/// +/// # Returns +/// +/// A tuple `(total_acceleration, da_dr)` where: +/// - `total_acceleration` – Combined heliocentric acceleration vector (AU/day²) +/// from all perturbers, including indirect terms. +/// - `da_dr` – 3×3 gravity-gradient matrix (day⁻²), i.e. the sum of each +/// perturber's ∂a/∂r contribution, used to build the linearised dynamics +/// matrix for the STM variational equations. +fn accumulate_perturber_effects( + asteroid_heliocentric_position: Vector3, + perturbers: &[PerturberSnapshot], +) -> (Vector3, Matrix3) { + perturbers.iter().fold( + (Vector3::zeros(), Matrix3::zeros()), + |(acc_total, da_dr_total), perturber| { + let asteroid_to_perturber = + asteroid_heliocentric_position - perturber.heliocentric_position; + + let acc_direct = + direct_acceleration(asteroid_to_perturber, perturber.gravitational_parameter); + let acc_indirect = indirect_acceleration( + perturber.heliocentric_position, + perturber.gravitational_parameter, + ); + let da_dr_contribution = gravity_gradient_contribution( + asteroid_to_perturber, + perturber.gravitational_parameter, + ); + + ( + acc_total + acc_direct + acc_indirect, + da_dr_total + da_dr_contribution, + ) + }, + ) +} + +/// Builds the 6×6 linearised dynamics matrix A from the gravity-gradient block: +/// +/// ```text +/// A = | 0 I | +/// | ∂a/∂r 0 | +/// ``` +/// +/// # Arguments +/// +/// * `gravity_gradient` – 3×3 gravity-gradient matrix ∂a/∂r (day⁻²), as +/// returned by [`accumulate_perturber_effects`]. It occupies the lower-left +/// 3×3 block of A. +/// +/// # Returns +/// +/// The 6×6 linearised dynamics matrix A (units: mixed — upper-right block is +/// dimensionless identity, lower-left block has units day⁻²). This matrix +/// satisfies the variational equation `dΦ/dt = A · Φ`. +fn build_dynamics_matrix(gravity_gradient: Matrix3) -> Matrix6 { + let mut dynamics_matrix = Matrix6::::zeros(); + // Upper-right 3×3 block: identity (velocity → position coupling) + for i in 0..3 { + dynamics_matrix[(i, i + 3)] = 1.0; + } + // Lower-left 3×3 block: ∂a/∂r (acceleration → velocity coupling) + for row in 0..3 { + for col in 0..3 { + dynamics_matrix[(row + 3, col)] = gravity_gradient[(row, col)]; + } + } + dynamics_matrix +} + +/// Writes position and velocity derivatives into the first 6 components of +/// `state_derivative` from the current velocity and computed acceleration. +/// +/// # Arguments +/// +/// * `current_velocity` – Full 42-element augmented state vector. Indices 3–5 +/// are used as the current velocity components (AU/day), which become the +/// time derivative of position. +/// * `acceleration` – Total heliocentric acceleration vector (AU/day²) +/// previously computed by [`accumulate_perturber_effects`]. +/// * `state_derivative` – Mutable reference to the 42-element output derivative +/// array. On return, indices 0–2 contain `ṙ = v` (AU/day) and indices 3–5 +/// contain `v̇ = a` (AU/day²). +fn write_position_velocity_derivatives( + current_velocity: &[f64; 42], + acceleration: Vector3, + state_derivative: &mut [f64; 42], +) { + state_derivative[0] = current_velocity[3]; + state_derivative[1] = current_velocity[4]; + state_derivative[2] = current_velocity[5]; + state_derivative[3] = acceleration[0]; + state_derivative[4] = acceleration[1]; + state_derivative[5] = acceleration[2]; +} + +/// Computes dΦ/dt = A · Φ and writes the result into `state_derivative[6..42]`. +/// +/// # Arguments +/// +/// * `dynamics_matrix` – 6×6 linearised dynamics matrix A built by +/// [`build_dynamics_matrix`]. Units: mixed (see that function's documentation). +/// * `augmented_state` – Full 42-element augmented state vector. Indices 6–41 +/// hold the current STM Φ stored in column-major order. +/// * `state_derivative` – Mutable reference to the 42-element output derivative +/// array. On return, indices 6–41 contain the flattened (column-major) entries +/// of dΦ/dt = A · Φ. +fn write_stm_derivative( + dynamics_matrix: Matrix6, + augmented_state: &[f64; 42], + state_derivative: &mut [f64; 42], +) { + let phi = Matrix6::::from_column_slice(&augmented_state[6..42]); + let dphi_dt = dynamics_matrix * phi; + state_derivative[6..42].copy_from_slice(dphi_dt.as_slice()); +} + +impl ODE for NBodyOde { + fn diff(&self, _time: f64, augmented_state: &[f64; 42], state_derivative: &mut [f64; 42]) { + let asteroid_heliocentric_position = + Vector3::new(augmented_state[0], augmented_state[1], augmented_state[2]); + + let (total_acceleration, gravity_gradient) = + accumulate_perturber_effects(asteroid_heliocentric_position, &self.perturbers); + + write_position_velocity_derivatives(augmented_state, total_acceleration, state_derivative); + + let dynamics_matrix = build_dynamics_matrix(gravity_gradient); + write_stm_derivative(dynamics_matrix, augmented_state, state_derivative); + } +} + +// --------------------------------------------------------------------------- +// Public interface +// --------------------------------------------------------------------------- + +/// Result of a single N-body propagation step. +pub struct NBodyResult { + /// Heliocentric position of the small body at t1, in the ecliptic J2000 frame. + /// + /// Units: AU. + pub position: Vector3, + + /// Heliocentric velocity of the small body at t1, in the ecliptic J2000 frame. + /// + /// Units: AU/day. + pub velocity: Vector3, + + /// Partial derivatives of the propagated position with respect to the six + /// equinoctial orbital elements, evaluated at t1. + /// + /// Shape: 6×3 (rows = equinoctial elements `[a, h, k, p, q, λ]`, + /// cols = ecliptic Cartesian components `[x, y, z]`). + /// Units: AU / (element unit). + pub dpos_delem: Matrix6x3, + + /// Partial derivatives of the propagated velocity with respect to the six + /// equinoctial orbital elements, evaluated at t1. + /// + /// Shape: 6×3 (rows = equinoctial elements `[a, h, k, p, q, λ]`, + /// cols = ecliptic Cartesian velocity components `[ẋ, ẏ, ż]`). + /// Units: (AU/day) / (element unit). + pub dvel_delem: Matrix6x3, +} + +// --------------------------------------------------------------------------- +// propagate_nbody sub-functions +// --------------------------------------------------------------------------- + +/// Builds the augmented initial state vector `[pos, vel, vec(Φ=I₆)]`. +/// +/// Packs position and velocity into indices 0–5 and initialises the STM block +/// (indices 6–41) to the identity matrix I₆ stored in column-major order, so +/// that the integration starts with Φ(t0) = I. +/// +/// # Arguments +/// +/// * `initial_position` – Heliocentric position of the small body at t0. +/// Units: AU. +/// * `initial_velocity` – Heliocentric velocity of the small body at t0. +/// Units: AU/day. +/// +/// # Returns +/// +/// A 42-element array `[f64; 42]` with layout: +/// - indices 0–2: position components (AU), +/// - indices 3–5: velocity components (AU/day), +/// - indices 6–41: column-major entries of Φ₀ = I₆ (dimensionless). +pub(crate) fn build_augmented_initial_state( + initial_position: Vector3, + initial_velocity: Vector3, +) -> [f64; 42] { + let mut augmented_state = [0.0_f64; 42]; + augmented_state[0] = initial_position[0]; + augmented_state[1] = initial_position[1]; + augmented_state[2] = initial_position[2]; + augmented_state[3] = initial_velocity[0]; + augmented_state[4] = initial_velocity[1]; + augmented_state[5] = initial_velocity[2]; + // Φ₀ = I₆ stored col-major + augmented_state[6..42].copy_from_slice(Matrix6::::identity().as_slice()); + augmented_state +} + +/// Queries the ephemeris and builds a perturber snapshot vector at the given +/// epoch. +/// +/// For each body listed in [`NBodyConfig::perturbing_bodies`], this function +/// looks up the gravitational parameter from the static table in +/// [`planet_gm`](super::planet_gm) and queries the heliocentric position from +/// the supplied JPL ephemeris file. +/// +/// # Arguments +/// +/// * `config` – N-body configuration specifying which perturbing bodies to +/// include and the integrator tolerances. +/// * `jpl` – Opened JPL ephemeris file used to query the heliocentric position +/// of each perturbing body. +/// * `epoch` – Reference epoch at which perturber positions are sampled. +/// Passed directly to [`JPLEphem::body_ephemeris`]. +/// +/// # Returns +/// +/// A [`Vec`] with one entry per body listed in +/// `config.perturbing_bodies`, ordered identically. Each entry contains the +/// body's heliocentric position (AU) and GM (AU³/day²) at the given epoch. +/// +/// # Errors +/// +/// - Returns [`OutfitError::EphemerisBodyNotSupported`] if a perturbing body +/// has no GM entry in the static table or cannot be resolved by the JPL +/// ephemeris file. +pub(crate) fn build_perturber_snapshots( + config: &NBodyConfig, + jpl: &JPLEphem, + epoch: &hifitime::Epoch, +) -> Result, OutfitError> { + config + .perturbing_bodies + .iter() + .map(|&body| { + let gravitational_parameter = gm_au3_day2(body).ok_or_else(|| { + OutfitError::EphemerisBodyNotSupported(format!( + "No GM available for perturber {body:?}" + )) + })?; + let (heliocentric_position, _velocity) = jpl.body_ephemeris(body, epoch)?; + Ok(PerturberSnapshot { + heliocentric_position, + gravitational_parameter, + }) + }) + .collect() +} + +/// Runs the DOP853 integrator from t=0 to t=`time_span_days` and returns the +/// final augmented state vector. +/// +/// Constructs a DOP853 initial-value problem from the provided ODE, integrates +/// from time 0 to `time_span_days` using the absolute and relative tolerances +/// specified in `config`, and returns the last computed augmented state. +/// +/// # Arguments +/// +/// * `ode` – Reference to the [`NBodyOde`] instance holding the frozen perturber +/// snapshots. Implements the ODE right-hand side. +/// * `augmented_initial_state` – 42-element initial augmented state vector at +/// t=0, as produced by [`build_augmented_initial_state`]. +/// * `time_span_days` – Integration duration. Positive for forward propagation, +/// negative for backward propagation. Units: days. +/// * `config` – N-body configuration supplying `abs_tol` (AU) and `rel_tol` +/// (dimensionless) for the adaptive step-size control of DOP853. +/// +/// # Returns +/// +/// The 42-element augmented state at t=`time_span_days`: +/// - indices 0–2: propagated position (AU), +/// - indices 3–5: propagated velocity (AU/day), +/// - indices 6–41: column-major entries of Φ(t1) (dimensionless). +/// +/// # Errors +/// +/// - Returns [`OutfitError::NBodyPropagationFailed`] if the DOP853 solver +/// returns an error or if the solution contains no steps. +pub(crate) fn integrate_augmented_state( + ode: &NBodyOde, + augmented_initial_state: [f64; 42], + time_span_days: f64, + config: &NBodyConfig, +) -> Result<[f64; 42], OutfitError> { + let solution = IVP::ode(ode, 0.0_f64, time_span_days, augmented_initial_state) + .method( + ExplicitRungeKutta::dop853() + .atol(config.abs_tol) + .rtol(config.rel_tol), + ) + .solve() + .map_err(|e| OutfitError::NBodyPropagationFailed(format!("{e:?}")))?; + + solution.y.last().copied().ok_or_else(|| { + OutfitError::NBodyPropagationFailed("Integrator returned no steps".to_string()) + }) +} + +/// Converts the t0 element Jacobians `(dpos_delem0, dvel_delem0)` (each 6×3) +/// into a single 6×6 matrix whose rows are state components and whose columns +/// are element indices: +/// +/// ```text +/// J0 = | (dpos_delem0)ᵀ | +/// | (dvel_delem0)ᵀ | +/// ``` +/// +/// # Arguments +/// +/// * `dpos_delem_at_t0` – 6×3 Jacobian of heliocentric position with respect to +/// the six equinoctial elements at t0 (rows = elements, cols = Cartesian +/// components). Units: AU / (element unit). +/// * `dvel_delem_at_t0` – 6×3 Jacobian of heliocentric velocity with respect to +/// the six equinoctial elements at t0 (rows = elements, cols = Cartesian +/// velocity components). Units: (AU/day) / (element unit). +/// +/// # Returns +/// +/// A 6×6 matrix J0 where: +/// - rows 0–2 correspond to the three position Cartesian components, +/// - rows 3–5 correspond to the three velocity Cartesian components, +/// - column `j` corresponds to equinoctial element index `j`. +/// +/// This layout is compatible with the STM Φ so that the product `Φ(t1) · J0` +/// propagates the element Jacobian to t1. +pub(crate) fn build_initial_state_jacobian( + dpos_delem_at_t0: &Matrix6x3, + dvel_delem_at_t0: &Matrix6x3, +) -> Matrix6 { + let mut initial_state_jacobian = Matrix6::::zeros(); + for element_index in 0..6 { + for cartesian_component in 0..3 { + initial_state_jacobian[(cartesian_component, element_index)] = + dpos_delem_at_t0[(element_index, cartesian_component)]; + initial_state_jacobian[(cartesian_component + 3, element_index)] = + dvel_delem_at_t0[(element_index, cartesian_component)]; + } + } + initial_state_jacobian +} + +/// Splits the propagated 6×6 Jacobian `Φ(t1) · J0` back into the +/// `(dpos_delem, dvel_delem)` pair at t1 (each 6×3). +/// +/// Performs the inverse of the layout established by [`build_initial_state_jacobian`]: +/// extracts the top 3 rows into `dpos_delem_at_t1` and the bottom 3 rows into +/// `dvel_delem_at_t1`, transposing the index ordering back to the 6×3 convention +/// (rows = elements, cols = Cartesian components). +/// +/// # Arguments +/// +/// * `propagated_state_jacobian` – 6×6 matrix `Φ(t1) · J0` resulting from +/// multiplying the STM at t1 by the initial-state Jacobian J0. +/// - rows 0–2: propagated position partials, +/// - rows 3–5: propagated velocity partials, +/// - column `j`: partial with respect to equinoctial element `j`. +/// +/// # Returns +/// +/// A tuple `(dpos_delem_at_t1, dvel_delem_at_t1)` where each matrix has shape +/// 6×3 (rows = equinoctial elements, cols = ecliptic Cartesian components): +/// - `dpos_delem_at_t1`: ∂pos(t1)/∂elem. Units: AU / (element unit). +/// - `dvel_delem_at_t1`: ∂vel(t1)/∂elem. Units: (AU/day) / (element unit). +pub(crate) fn split_propagated_jacobian( + propagated_state_jacobian: Matrix6, +) -> (Matrix6x3, Matrix6x3) { + let mut dpos_delem_at_t1 = Matrix6x3::::zeros(); + let mut dvel_delem_at_t1 = Matrix6x3::::zeros(); + for element_index in 0..6 { + for cartesian_component in 0..3 { + dpos_delem_at_t1[(element_index, cartesian_component)] = + propagated_state_jacobian[(cartesian_component, element_index)]; + dvel_delem_at_t1[(element_index, cartesian_component)] = + propagated_state_jacobian[(cartesian_component + 3, element_index)]; + } + } + (dpos_delem_at_t1, dvel_delem_at_t1) +} diff --git a/src/propagator/planet_gm.rs b/src/propagator/planet_gm.rs new file mode 100644 index 0000000..4407279 --- /dev/null +++ b/src/propagator/planet_gm.rs @@ -0,0 +1,95 @@ +//! Gravitational parameters (GM) for solar-system bodies. +//! +//! All values are in **AU³/day²**, consistent with the Gaussian gravitational +//! constant `k` used throughout Outfit (`k² = GAUSS_GRAV_SQUARED`). +//! +//! Sources: DE440 TDB-compatible mass parameters converted to AU³/day² via +//! `GM_km3_s2 * (86400² / AU³_in_km³)` where `AU = 1.495978707e8 km`. +//! +//! # References +//! - Park, R.S. et al. (2021), *The JPL Planetary and Lunar Ephemerides DE440 and DE441*, +//! AJ 161, 105. + +use crate::jpl_ephem::naif::naif_ids::{ + planet_bary::PlanetaryBary, satellite_mass::SatelliteMassCenter, + solar_system_bary::SolarSystemBary, NaifIds, +}; + +/// AU in kilometres (IAU 2012). +const AU_KM: f64 = 1.495_978_707e8; + +/// Conversion factor: km³/s² → AU³/day² +/// = (86400 s/day)² / (AU_KM km/AU)³ +const KM3_S2_TO_AU3_DAY2: f64 = (86400.0 * 86400.0) / (AU_KM * AU_KM * AU_KM); + +// --------------------------------------------------------------------------- +// Individual GM values in km³/s² (DE440) +// --------------------------------------------------------------------------- + +const GM_SUN_KM3_S2: f64 = 1.327_124_400_41e11; +const GM_MERCURY_KM3_S2: f64 = 2.203_178_e4; +const GM_VENUS_KM3_S2: f64 = 3.248_585_7e5; +/// Earth + Moon system GM (used for EarthMoon barycenter). +const GM_EARTH_MOON_KM3_S2: f64 = 4.035_032_35e5; +const GM_MARS_KM3_S2: f64 = 4.282_837_36e4; +const GM_JUPITER_KM3_S2: f64 = 1.267_127_648e8; +const GM_SATURN_KM3_S2: f64 = 3.794_062_52e7; +const GM_URANUS_KM3_S2: f64 = 5.794_556_4e6; +const GM_NEPTUNE_KM3_S2: f64 = 6.836_527_1e6; +const GM_PLUTO_KM3_S2: f64 = 9.755_e2; +const GM_MOON_KM3_S2: f64 = 4.902_800_066e3; + +// --------------------------------------------------------------------------- +// Public AU³/day² constants +// --------------------------------------------------------------------------- + +pub const GM_SUN: f64 = GM_SUN_KM3_S2 * KM3_S2_TO_AU3_DAY2; +pub const GM_MERCURY: f64 = GM_MERCURY_KM3_S2 * KM3_S2_TO_AU3_DAY2; +pub const GM_VENUS: f64 = GM_VENUS_KM3_S2 * KM3_S2_TO_AU3_DAY2; +pub const GM_EARTH_MOON: f64 = GM_EARTH_MOON_KM3_S2 * KM3_S2_TO_AU3_DAY2; +pub const GM_MARS: f64 = GM_MARS_KM3_S2 * KM3_S2_TO_AU3_DAY2; +pub const GM_JUPITER: f64 = GM_JUPITER_KM3_S2 * KM3_S2_TO_AU3_DAY2; +pub const GM_SATURN: f64 = GM_SATURN_KM3_S2 * KM3_S2_TO_AU3_DAY2; +pub const GM_URANUS: f64 = GM_URANUS_KM3_S2 * KM3_S2_TO_AU3_DAY2; +pub const GM_NEPTUNE: f64 = GM_NEPTUNE_KM3_S2 * KM3_S2_TO_AU3_DAY2; +pub const GM_PLUTO: f64 = GM_PLUTO_KM3_S2 * KM3_S2_TO_AU3_DAY2; +pub const GM_MOON: f64 = GM_MOON_KM3_S2 * KM3_S2_TO_AU3_DAY2; + +/// Return the GM (AU³/day²) for a [`NaifIds`], or `None` if not tabulated. +pub fn gm_au3_day2(body: NaifIds) -> Option { + match body { + NaifIds::SSB(SolarSystemBary::Sun) => Some(GM_SUN), + NaifIds::SSB(SolarSystemBary::SSB) => None, + NaifIds::PB(PlanetaryBary::Mercury) => Some(GM_MERCURY), + NaifIds::PB(PlanetaryBary::Venus) => Some(GM_VENUS), + NaifIds::PB(PlanetaryBary::EarthMoon) => Some(GM_EARTH_MOON), + NaifIds::PB(PlanetaryBary::Mars) => Some(GM_MARS), + NaifIds::PB(PlanetaryBary::Jupiter) => Some(GM_JUPITER), + NaifIds::PB(PlanetaryBary::Saturn) => Some(GM_SATURN), + NaifIds::PB(PlanetaryBary::Uranus) => Some(GM_URANUS), + NaifIds::PB(PlanetaryBary::Neptune) => Some(GM_NEPTUNE), + NaifIds::PB(PlanetaryBary::Pluto) => Some(GM_PLUTO), + NaifIds::SMC(SatelliteMassCenter::Moon) => Some(GM_MOON), + _ => None, + } +} + +#[cfg(test)] +mod planet_gm_tests { + use super::*; + + #[test] + fn sun_gm_close_to_gauss_squared() { + // k² = 2.9591220828e-4 AU³/day² (Gaussian) + // DE440 GM_Sun should be within 1e-8 relative of the Gaussian value + let gauss_k2 = crate::constants::GAUSS_GRAV_SQUARED; + let rel = (GM_SUN - gauss_k2).abs() / gauss_k2; + assert!(rel < 1e-4, "GM_SUN rel diff from k²: {rel:.2e}"); + } + + #[test] + fn jupiter_dominates_perturbations() { + // Jupiter GM should be ~1000× larger than Mars GM + const { assert!(GM_JUPITER > GM_MARS * 100.0) }; + } +} diff --git a/src/ref_system.rs b/src/ref_system.rs index f6305e0..5d1e188 100644 --- a/src/ref_system.rs +++ b/src/ref_system.rs @@ -13,13 +13,13 @@ //! //! ## Coordinate systems & epochs //! -//! - [`RefSystem::Equm(e)`] — *Equatorial mean* at epoch `e` (precession only). -//! - [`RefSystem::Equt(e)`] — *Equatorial true* at epoch `e` (precession + nutation). -//! - [`RefSystem::Eclm(e)`] — *Ecliptic mean* at epoch `e` (mean obliquity). +//! - [`RefSystem::Equm`](crate::ref_system::RefSystem::Equm) — *Equatorial mean* at epoch `e` (precession only). +//! - [`RefSystem::Equt`](crate::ref_system::RefSystem::Equt) — *Equatorial true* at epoch `e` (precession + nutation). +//! - [`RefSystem::Eclm`](crate::ref_system::RefSystem::Eclm) — *Ecliptic mean* at epoch `e` (mean obliquity). //! //! Epochs are represented by [`RefEpoch`](crate::ref_system::RefEpoch): //! - [`RefEpoch::J2000`](crate::ref_system::RefEpoch::J2000) — Fixed epoch at **MJD 51544.5 (TT)**. -//! - [`RefEpoch::Epoch(d)`] — “of-date” epoch at MJD `d` (TT). +//! - [`RefEpoch::Epoch`](crate::ref_system::RefEpoch::Epoch) — “of-date” epoch at MJD `d` (TT). //! //! **Units & conventions** //! ----------------------- @@ -35,11 +35,6 @@ //! - **Nutation**: IAU 1980, via [`rnut80`](crate::earth_orientation::rnut80) (true ↔ mean equator/equinox). //! - **Mean obliquity**: via [`obleq`](crate::earth_orientation::obleq) (ecliptic ↔ equatorial). //! -//! The composition strategy used by [`rotpn`](crate::ref_system::rotpn) is intentionally explicit and -//! mirror‑friendly to classic astrometry codes (e.g., OrbFit): whenever helpful, -//! transformations are routed through **Equatorial Mean J2000** as an intermediate, -//! ensuring determinism and easing verification against reference implementations. -//! //! ## Typical usage //! //! ```rust, no_run @@ -137,7 +132,7 @@ impl RefEpoch { /// /// # Returns /// * For [`RefEpoch::J2000`]: returns the constant [`T2000`]. - /// * For [`RefEpoch::Epoch(d)`]: returns the stored `d`. + /// * For [`RefEpoch::Epoch`]: returns the stored `d`. pub fn date(&self) -> f64 { match *self { RefEpoch::J2000 => T2000, diff --git a/src/trajectories/ades_reader.rs b/src/trajectories/ades_reader.rs deleted file mode 100644 index c89c5a2..0000000 --- a/src/trajectories/ades_reader.rs +++ /dev/null @@ -1,257 +0,0 @@ -use std::str::FromStr; - -use camino::Utf8Path; -use hifitime::Epoch; -use quick_xml::de::from_str; -use serde::{Deserialize, Deserializer}; -use smallvec::SmallVec; - -use crate::{ - constants::{ArcSec, ObjectNumber}, - outfit::Outfit, - TrajectorySet, -}; - -use crate::observations::Observation; - -#[derive(Debug, Deserialize)] -struct StructuredAdes { - #[serde(rename = "obsBlock")] - obs_blocks: Vec, -} - -#[derive(Debug, Deserialize)] -struct FlatAdes { - #[serde(rename = "optical")] - opticals: Vec, -} - -#[derive(Debug, Deserialize)] -struct ObsBlock { - #[serde(rename = "obsContext")] - obs_context: ObsContext, - - #[serde(rename = "obsData")] - obs_data: ObsData, -} - -#[derive(Debug, Deserialize)] -struct ObsContext { - observatory: Observatory, -} - -#[derive(Debug, Deserialize)] -struct Observatory { - #[serde(rename = "mpcCode")] - mpc_code: String, -} - -#[derive(Debug, Deserialize)] -struct ObsData { - #[serde(rename = "optical")] - opticals: Vec, -} - -#[derive(Debug, Deserialize)] -struct OpticalObs { - #[serde(rename = "permID")] - perm_id: Option, - #[serde(rename = "provID")] - prov_id: Option, - #[serde(rename = "trkSub")] - trk_sub: Option, - - #[serde(rename = "obsTime", deserialize_with = "deserialize_mjd")] - obs_time: f64, - - ra: f64, - dec: f64, - - #[serde(rename = "precRA")] - prec_ra: Option, - #[serde(rename = "precDec")] - prec_dec: Option, - - #[serde(rename = "rmsRA")] - rms_ra: Option, - #[serde(rename = "rmsDec")] - rms_dec: Option, - - stn: String, -} - -impl OpticalObs { - /// Returns the trajectory ID for the optical observation. - /// It first checks for a `perm_id`, then a `prov_id`, and finally falls back to `trk_sub`. - /// If none of these are available, it panics with an error message. - /// The ID is parsed as a `u32` if possible, otherwise it is returned as a string. - /// - /// Return - /// ------ - /// * An `ObjectNumber` representing the trajectory ID. - /// * If the ID is a valid `u32`, it is returned as `ObjectNumber::Int(id)`. - /// * If the ID is not a valid `u32`, it is returned as `ObjectNumber::String(id)`. - /// * If no ID is found, it panics with an error message. - fn get_id(&self) -> ObjectNumber { - let id = self - .perm_id - .clone() - .or_else(|| self.prov_id.clone()) - .unwrap_or_else(|| self.trk_sub.clone().expect("No ID found")); - if let Ok(id) = id.parse::() { - ObjectNumber::Int(id) - } else { - ObjectNumber::String(id) - } - } - - fn to_observation( - &self, - state: &Outfit, - observer_idx: u16, - error_ra: Option, - error_dec: Option, - ) -> Observation { - let error_ra = self.rms_ra.unwrap_or_else(|| { - self.prec_ra - .unwrap_or_else(|| error_ra.expect("No error for RA when parsing ADES file")) - }); - - let error_dec = self.rms_dec.unwrap_or_else(|| { - self.prec_dec - .unwrap_or_else(|| error_dec.expect("No error for Dec when parsing ADES file")) - }); - Observation::new( - state, - observer_idx, - self.ra, - error_ra, - self.dec, - error_dec, - self.obs_time, - ) - .expect("Failed to create observation from ADES") - } -} - -/// Deserialize a date string in the format "YYYY-MM-DDTHH:MM:SS" into a floating-point number -/// representing the Modified Julian Date (MJD). -/// The date string is expected to be in UTC. -/// -/// Arguments -/// --------- -/// * `deserializer`: The deserializer to use for the date string. -/// -/// Return -/// ------ -/// * A `Result` containing the parsed MJD as a `f64` or an error if the parsing fails. -fn deserialize_mjd<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - let date_str = String::deserialize(deserializer)?; - - let time = Epoch::from_str(&date_str).map_err(serde::de::Error::custom)?; - - Ok(time.to_mjd_utc_days()) -} - -/// Parses a `FlatAdes` file and populates the given `Outfit` and `TrajectorySet`. -/// It iterates through the optical observations, extracting the observer's MPC code and -/// creating an `Observation` for each optical observation. -/// The observations are then added to the `TrajectorySet` using the trajectory ID. -/// If a new observatory is found, it is added to the `Outfit` observatory set. -/// -/// Arguments -/// --------- -/// * `outfit`: A mutable reference to the `Outfit` instance. -/// * `flat_ades`: A reference to the `FlatAdes` instance. -/// * `trajs`: A mutable reference to the `TrajectorySet` instance. -fn parse_flat_ades( - outfit: &mut Outfit, - flat_ades: &FlatAdes, - trajs: &mut TrajectorySet, - error_ra: Option, - error_dec: Option, -) { - for optical in &flat_ades.opticals { - let traj_id = optical.get_id(); - let observer = outfit.uint16_from_mpc_code(&optical.stn); - let observation = optical.to_observation(outfit, observer, error_ra, error_dec); - trajs - .entry(traj_id) - .or_insert_with(|| SmallVec::with_capacity(10)) - .push(observation); - } -} - -/// Parses a `StructuredAdes` file and populates the given `Outfit` and `TrajectorySet`. -/// It iterates through the observation blocks, extracting the observer's MPC code and -/// creating an `Observation` for each optical observation. -/// The observations are then added to the `TrajectorySet` using the trajectory ID. -/// If a new observatory is found, it is added to the `Outfit` observatory set. -/// -/// Arguments -/// --------- -/// * `outfit`: A mutable reference to the `Outfit` instance. -/// * `structured_ades`: A reference to the `StructuredAdes` instance. -/// * `trajs`: A mutable reference to the `TrajectorySet` instance. -fn parse_structured_ades( - outfit: &mut Outfit, - structured_ades: &StructuredAdes, - trajs: &mut TrajectorySet, - error_ra: Option, - error_dec: Option, -) { - for obs_block in &structured_ades.obs_blocks { - let obs_context = &obs_block.obs_context; - let mpc_code = &obs_context.observatory.mpc_code; - let observer = outfit.uint16_from_mpc_code(mpc_code); - - for optical in &obs_block.obs_data.opticals { - let observation = optical.to_observation(outfit, observer, error_ra, error_dec); - let traj_id = optical.get_id(); - trajs - .entry(traj_id) - .or_insert_with(|| SmallVec::with_capacity(10)) - .push(observation); - } - } -} - -/// Parses an ADES file and populates the given `Outfit` and `TrajectorySet`. -/// It first attempts to parse the file as a `FlatAdes`, and if that fails, it tries to parse it as a `StructuredAdes`. -/// If both parsing attempts fail, it panics with an error message. -/// If new observatory are found, they are added to the `Outfit` observatory set. -/// -/// Arguments -/// --------- -/// * `outfit`: A mutable reference to the `Outfit` instance. -/// * `ades`: A reference to the ADES file path. -/// * `trajs`: A mutable reference to the `TrajectorySet` instance. -pub(crate) fn parse_ades( - outfit: &mut Outfit, - ades: &Utf8Path, - trajs: &mut TrajectorySet, - error_ra: Option, - error_dec: Option, -) { - let xml = std::fs::read_to_string(ades) - .unwrap_or_else(|_| panic!("Failed to read ADES file: {ades}")); - - match from_str::(&xml) { - Ok(flat_ades) => { - parse_flat_ades(outfit, &flat_ades, trajs, error_ra, error_dec); - } - Err(flat_err) => match from_str::(&xml) { - Ok(structured_ades) => { - parse_structured_ades(outfit, &structured_ades, trajs, error_ra, error_dec); - } - Err(structured_err) => { - panic!( - "Failed to parse ADES file:\n- Flat error: {flat_err}\n- Structured error: {structured_err}" - ); - } - }, - } -} diff --git a/src/trajectories/batch_reader.rs b/src/trajectories/batch_reader.rs deleted file mode 100644 index 1d7021a..0000000 --- a/src/trajectories/batch_reader.rs +++ /dev/null @@ -1,432 +0,0 @@ -//! # Single-Observer Astrometric Batch Ingestion -//! -//! This module provides the [`ObservationBatch`] type, which groups multiple -//! astrometric detections from a **single observer** into a compact container. -//! Such a batch can then be expanded into concrete [`Observation`]s and stored -//! in a [`TrajectorySet`]. -//! -//! ## Overview -//! ----------------- -//! A wide-field survey typically delivers angle-only astrometry (RA/DEC, with -//! per-epoch timestamps). [`ObservationBatch`] wraps such measurements, together -//! with uniform error estimates, into a structured form ready for ingestion into -//! orbit-determination pipelines. -//! -//! To actually turn batches into stored observations, use the trait -//! [`TrajectoryFile`](crate::trajectories::trajectory_file::TrajectoryFile): -//! - [`TrajectoryFile::new_from_vec`](crate::trajectories::trajectory_file::TrajectoryFile::new_from_vec) — build a new [`TrajectorySet`] from a batch. -//! - [`TrajectoryFile::add_from_vec`](crate::trajectories::trajectory_file::TrajectoryFile::add_from_vec) — append a batch into an existing [`TrajectorySet`]. -//! -//! Both methods transparently handle the internal expansion of a batch into -//! per-sample [`Observation`]s (site position lookups, heliocentric positions, -//! RA/DEC/error propagation, etc.). -//! -//! ## Units & Conventions -//! ----------------- -//! - **Angles:** Right ascension and declination in **radians**. -//! If your upstream data are in **degrees/arcseconds**, use -//! [`ObservationBatch::from_degrees_owned`] to convert once at construction. -//! - **Uncertainties:** 1-σ errors in RA/DEC (radians). For arcsecond inputs, -//! the degree-based constructor performs the conversion for you. -//! - **Epochs:** Times in **MJD (TT)** (days). Convert UTC/TAI upstream. -//! - **Observer:** All rows in a batch must come from the **same** observer. -//! -//! ## Invariants -//! ----------------- -//! - `trajectory_id.len() == ra.len() == dec.len() == time.len()` -//! - All angles and uncertainties are in **radians**. -//! - All epochs are in **MJD (TT)**. -//! - Batch content belongs to a **single observer**. -//! -//! ## Construction Paths -//! ----------------- -//! - [`ObservationBatch::from_radians_borrowed`] — zero-copy when your pipeline already -//! provides radians and MJD (TT). -//! - [`ObservationBatch::from_degrees_owned`] — converts degrees/arcseconds → radians -//! once and stores owned buffers. -//! -//! ## Example -//! ----------------- -//! ```rust,no_run -//! use std::sync::Arc; -//! use outfit::{ -//! Outfit, Observer, TrajectorySet, TrajectoryFile, ErrorModel, -//! trajectories::batch_reader::ObservationBatch, -//! }; -//! -//! // Inputs in degrees / arcseconds (mixed objects: 0 and 1). -//! let traj_id = vec![0_u32, 0, 1]; -//! let ra_deg = vec![210.01, 210.02, 211.00]; -//! let dec_deg = vec![-5.00, -4.99, -4.00]; -//! let mjd_tt = vec![60345.12, 60345.13, 60345.20]; -//! -//! let batch = ObservationBatch::from_degrees_owned( -//! &traj_id, &ra_deg, &dec_deg, 0.5, 0.5, &mjd_tt -//! ); -//! -//! // Global environment and observer. -//! let mut outfit = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); -//! let observer = outfit.get_observer_from_mpc_code(&"I41".to_string()); -//! -//! // Build a new TrajectorySet directly from the batch. -//! let traj_set = TrajectorySet::new_from_vec(&mut outfit, &batch, observer.clone()) -//! .expect("ingestion OK"); -//! -//! // Or append to an existing set: -//! let mut other = TrajectorySet::default(); -//! other.add_from_vec(&mut outfit, &batch, observer).expect("append OK"); -//! ``` -//! -//! ## Notes -//! ----------------- -//! - Internally, ingestion is performed by a crate-private routine -//! (`observation_from_batch`) which expands the batch into per-sample -//! [`Observation`]s and caches observer positions per epoch. Users should rely -//! on the public `TrajectoryFile` methods instead. -//! - For multi-observer datasets, create one [`ObservationBatch`] per observer, -//! then ingest them separately. -//! -//! ## See also -//! ------------ -//! * [`ObservationBatch::from_radians_borrowed`] – Zero-copy construction. -//! * [`ObservationBatch::from_degrees_owned`] – Convert degrees/arcseconds once. -//! * [`TrajectoryFile::new_from_vec`](crate::trajectories::trajectory_file::TrajectoryFile::new_from_vec) – Public entry point for batch ingestion. -//! * [`TrajectoryFile::add_from_vec`](crate::trajectories::trajectory_file::TrajectoryFile::add_from_vec) – Append batch into an existing set. -use std::{borrow::Cow, sync::Arc}; - -use ahash::RandomState; -use hifitime::Epoch; -use nalgebra::Vector3; -use ordered_float::OrderedFloat; -use smallvec::SmallVec; - -use crate::{ - constants::Radian, conversion::arcsec_to_rad, observations::Observation, - trajectories::parquet_reader::FastHashMap, ArcSec, Degree, ObjectNumber, Observer, Outfit, - OutfitError, TrajectorySet, MJD, -}; - -/// Batch of observations from a single observer (angles in **radians**). -/// -/// This container groups multiple astrometric measurements sharing the same -/// observer into a single batch, ready to be expanded into -/// [`Observation`]s and stored in a -/// [`TrajectorySet`]. -/// -/// Each measurement includes: -/// - A trajectory identifier (`trajectory_id`) so that a single batch can hold -/// observations for multiple objects simultaneously. -/// - Right ascension and declination in **radians**, with uniform 1-σ uncertainties -/// (also in **radians**). -/// - Epochs in **MJD (TT)** (days). -/// -/// Fields -/// ----------------- -/// * `trajectory_id` — Integer trajectory IDs (object numbers). Length must match `ra`/`dec`/`time`. -/// * `ra` — Right ascension values (**radians**). Length must match `dec`, `time`, and `trajectory_id`. -/// * `error_ra` — 1-σ uncertainty on right ascension (**radians**) applied uniformly to the batch. -/// * `dec` — Declination values (**radians**). Length must match `ra`, `time`, and `trajectory_id`. -/// * `error_dec` — 1-σ uncertainty on declination (**radians**) applied uniformly to the batch. -/// * `time` — Observation epochs as **MJD (TT)** (days). Length must match `ra`/`dec`/`trajectory_id`. -/// -/// Invariants -/// ----------------- -/// * `trajectory_id.len() == ra.len() == dec.len() == time.len()` -/// * Angles and uncertainties are expressed in **radians**. -/// * Time scale is **TT** (use appropriate conversion if your source data are in UTC/TAI). -/// -/// Construction -/// ----------------- -/// Prefer the dedicated constructors: -/// * [`ObservationBatch::from_radians_borrowed`] — zero-copy when your inputs are already in radians. -/// * [`ObservationBatch::from_degrees_owned`] — converts degrees/arcseconds once into owned buffers. -/// -/// Example -/// ----------------- -/// ```rust, no_run -/// # use outfit::trajectories::batch_reader::ObservationBatch; -/// # let (traj_id, ra_deg, dec_deg, mjd) = (vec![0, 0, 1], vec![14.62, 14.63, 15.01], vec![9.98, 10.01, 11.02], vec![43785.35, 43785.36, 43785.40]); -/// // Inputs in degrees / arcseconds (converted once to radians internally): -/// let batch = ObservationBatch::from_degrees_owned(&traj_id, &ra_deg, &dec_deg, 0.5, 0.5, &mjd); -/// -/// // Or, if you already have radians: -/// // let batch = ObservationBatch::from_radians_borrowed(&ra_rad, &dec_rad, err_ra_rad, err_dec_rad, &mjd); -/// ``` -/// -/// See also -/// ------------ -/// * [`ObservationBatch::from_radians_borrowed`] – Borrow slices already in radians (zero-copy). -/// * [`ObservationBatch::from_degrees_owned`] – Convert degrees/arcseconds → radians once. -/// * [`conversion::arcsec_to_rad`](crate::conversion::arcsec_to_rad) – Arcseconds → radians helper. -#[derive(Debug, Clone)] -pub struct ObservationBatch<'a> { - pub trajectory_id: Cow<'a, [u32]>, - - /// Right ascension values (**radians**). Must have the same length as `dec` and `time`. - pub ra: Cow<'a, [Radian]>, - - /// 1-σ uncertainty on right ascension (**radians**), applied uniformly to the batch. - /// Note: the weighting scheme accounts for the RA geometry (e.g., cos δ factors) downstream. - pub error_ra: Radian, - - /// Declination values (**radians**). Must have the same length as `ra` and `time`. - pub dec: Cow<'a, [Radian]>, - - /// 1-σ uncertainty on declination (**radians**), applied uniformly to the batch. - pub error_dec: Radian, - - /// Observation epochs as **MJD (TT)**, in days. Must have the same length as `ra`/`dec`. - pub time: Cow<'a, [MJD]>, -} - -impl<'a> ObservationBatch<'a> { - /// Construct a batch by **borrowing** slices that are already in radians. - /// - /// The returned batch holds `Cow::Borrowed` views of the provided slices, - /// performing **no allocation** and **no unit conversion**. - /// Use this when your upstream pipeline already provides: - /// - Trajectory identifiers (`trajectory_id`) - /// - Right ascension / declination in **radians** - /// - Uncertainties in **radians** - /// - Epochs in **MJD (TT)** - /// - /// Arguments - /// ----------------- - /// * `trajectory_id` — Integer trajectory IDs; length must match all angle/time slices. - /// * `ra_rad` — Right ascension values in **radians** (borrowed). - /// * `dec_rad` — Declination values in **radians** (borrowed). - /// * `error_ra_rad` — 1-σ uncertainty on RA in **radians**, applied uniformly to the batch. - /// * `error_dec_rad` — 1-σ uncertainty on DEC in **radians**, applied uniformly to the batch. - /// * `time_mjd` — Observation epochs as **MJD (TT)** (borrowed). - /// - /// Return - /// ---------- - /// * A batch borrowing the provided slices (**zero-copy**). - /// - /// Invariants - /// ---------- - /// * `trajectory_id.len() == ra_rad.len() == dec_rad.len() == time_mjd.len()` - /// - /// Panics - /// ---------- - /// * Debug builds only: panics if the slice lengths do not match. - /// - /// Complexity - /// ---------- - /// * O(1) — no allocation, no conversion. - /// - /// See also - /// ------------ - /// * [`ObservationBatch::from_degrees_owned`] – Convert degrees/arcseconds → radians and own the buffers. - /// * [`conversion::arcsec_to_rad`](crate::conversion::arcsec_to_rad) – Arcseconds → radians helper. - pub fn from_radians_borrowed( - trajectory_id: &'a [u32], - ra_rad: &'a [Radian], - dec_rad: &'a [Radian], - error_ra_rad: Radian, - error_dec_rad: Radian, - time_mjd: &'a [MJD], - ) -> Self { - debug_assert_eq!(ra_rad.len(), dec_rad.len(), "RA/DEC length mismatch"); - debug_assert_eq!(ra_rad.len(), time_mjd.len(), "RA/time length mismatch"); - - Self { - trajectory_id: Cow::Borrowed(trajectory_id), - ra: Cow::Borrowed(ra_rad), - dec: Cow::Borrowed(dec_rad), - time: Cow::Borrowed(time_mjd), - error_ra: error_ra_rad, - error_dec: error_dec_rad, - } - } - - /// Construct a batch from **degrees** (angles) and **arcseconds** (uncertainties), - /// converting to **radians** and **owning** the resulting buffers. - /// - /// Use this when your inputs come from common astrometric formats (e.g., MPC/ADES) - /// that report RA/DEC in degrees and uncertainties in arcseconds. - /// Conversion is performed **once** at construction; downstream code operates purely in radians. - /// - /// Arguments - /// ----------------- - /// * `trajectory_id` — Integer trajectory IDs; length must match all angle/time slices. - /// * `ra_deg` — Right ascension in **degrees** (borrowed); converted to radians. - /// * `dec_deg` — Declination in **degrees** (borrowed); converted to radians. - /// * `error_ra_arcsec` — 1-σ uncertainty on RA in **arcseconds**; converted to radians. - /// * `error_dec_arcsec` — 1-σ uncertainty on DEC in **arcseconds**; converted to radians. - /// * `time_mjd` — Observation epochs as **MJD (TT)** (borrowed; cloned to owned buffer). - /// - /// Return - /// ---------- - /// * A batch **owning** converted buffers (no dangling slices). - /// - /// Invariants - /// ---------- - /// * `trajectory_id.len() == ra_deg.len() == dec_deg.len() == time_mjd.len()` - /// - /// Panics - /// ---------- - /// * Panics if the slice lengths do not match. - /// - /// Complexity - /// ---------- - /// * O(n) for the degree→radian and arcsec→radian conversions + one `to_vec()` for time. - /// - /// See also - /// ------------ - /// * [`ObservationBatch::from_radians_borrowed`] – Zero-copy constructor when inputs are already in radians. - /// * [`conversion::arcsec_to_rad`](crate::conversion::arcsec_to_rad) – Arcseconds → radians helper. - pub fn from_degrees_owned( - trajectory_id: &'a [u32], - ra_deg: &[Degree], - dec_deg: &[Degree], - error_ra_arcsec: ArcSec, - error_dec_arcsec: ArcSec, - time_mjd: &[MJD], - ) -> Self { - debug_assert_eq!(ra_deg.len(), dec_deg.len(), "RA/DEC length mismatch"); - debug_assert_eq!(ra_deg.len(), time_mjd.len(), "RA/time length mismatch"); - - let ra: Vec = ra_deg.iter().map(|&d| d.to_radians()).collect(); - let dec: Vec = dec_deg.iter().map(|&d| d.to_radians()).collect(); - let time: Vec = time_mjd.to_vec(); - - Self { - trajectory_id: Cow::Owned(trajectory_id.to_vec()), - ra: Cow::Owned(ra), - dec: Cow::Owned(dec), - time: Cow::Owned(time), - error_ra: arcsec_to_rad(error_ra_arcsec), - error_dec: arcsec_to_rad(error_dec_arcsec), - } - } -} - -/// Expand a single-observer batch into concrete [`Observation`]s and append them into a [`TrajectorySet`]. -/// -/// This routine ingests an [`ObservationBatch`] whose angles and uncertainties are in **radians** -/// and whose epochs are **MJD (TT)**, then materializes per-sample [`Observation`]s enriched with -/// site geocentric and heliocentric positions. All measurements are assumed to come from the **same -/// observer** (hence a single `observer: Arc` argument). -/// -/// For performance, it: -/// - resolves the observer into a compact `u16` **once** (hot path), -/// - pre-fetches the UT1 provider **once**, -/// - caches observer positions by epoch: **MJD(TT) → (geo_pos, helio_pos)**, -/// so repeated timestamps incur **no extra** position computation. -/// -/// Internally, epoch keys are wrapped in `OrderedFloat` to enable their use in a hash map -/// (total order on `f64` while rejecting `NaN` inputs by construction). -/// -/// Arguments -/// ----------------- -/// * `trajectories` — Target container to receive observations, bucketed by `trajectory_id`. -/// * `env_state` — Global [`Outfit`] state (ephemerides, EOP/UT1 providers, etc.). -/// * `batch` — Angles and 1-σ uncertainties in **radians**; epochs as **MJD (TT)**; includes `trajectory_id`. -/// * `observer` — The (single) observer for **all** samples in `batch`. -/// -/// Return -/// ---------- -/// * `Ok(())` if all observations were successfully appended into `trajectories`, -/// * `Err(OutfitError)` if site position or heliocentric position computations fail. -/// -/// Panics -/// ---------- -/// * **Debug builds only**: length mismatches across `ra/dec/time/trajectory_id` trigger `debug_assert!`. -/// -/// Complexity -/// ---------- -/// * Time: **O(n)**, with at most **O(u)** geocentric/heliocentric computations where `u` is the number of -/// **unique** epochs in the batch (`u ≤ n`) thanks to the epoch→position cache. -/// * Space: **O(u)** for the epoch→position cache. -/// -/// Notes -/// ---------- -/// * Input angles (RA/DEC) and uncertainties **must already be in radians**. If your source is degrees/arcsec, -/// build the batch via [`ObservationBatch::from_degrees_owned`] (conversion done once at construction). -/// * Epochs are expected as **TT**. Convert upstream if your pipeline feeds UTC/TAI. -/// * This function mutates `trajectories` and reads from `env_state`. If you need parallelization, consider -/// extracting immutable position providers beforehand, or designing providers that accept shared references. -/// -/// See also -/// ------------ -/// * [`ObservationBatch::from_radians_borrowed`] – Zero-copy batch when inputs are already radians. -/// * [`ObservationBatch::from_degrees_owned`] – Degree/arcsec → rad conversion once at construction. -/// * [`parquet_to_trajset`] – Parquet ingestion using the same unit/weighting logic. -/// * [`conversion::arcsec_to_rad`] – Arcseconds → radians helper. -pub(crate) fn observation_from_batch( - trajectories: &mut TrajectorySet, - env_state: &mut Outfit, - batch: &ObservationBatch<'_>, - observer: Arc, -) -> Result<(), OutfitError> { - // --- Fast sanity checks (debug only) --------------------------------------- - // All slices must be aligned one-to-one. These checks are enforced at construction - // time for production, but we keep them here in debug builds to catch regressions. - debug_assert_eq!(batch.ra.len(), batch.dec.len(), "RA/DEC length mismatch"); - debug_assert_eq!(batch.ra.len(), batch.time.len(), "RA/time length mismatch"); - debug_assert_eq!( - batch.ra.len(), - batch.trajectory_id.len(), - "RA/trajectory_id length mismatch" - ); - - // Resolve observer ID once (hot path avoids map lookups later). - let uint16_obs = env_state.uint16_from_observer(observer.clone()); - - // Pre-fetch UT1 provider once. - let ut1 = env_state.get_ut1_provider(); - - // Heuristic: many surveys repeat epochs per exposure → cache a fraction of N. - let n = batch.ra.len(); - let est_cache_cap = (n / 4).clamp(64, 4096); - let mut pos_cache: FastHashMap, (Vector3, Vector3)> = - FastHashMap::with_capacity_and_hasher(est_cache_cap, RandomState::default()); - - // Safe and fast: iterators avoid per-iteration bounds checks - let ra_it = batch.ra.iter().copied(); - let dec_it = batch.dec.iter().copied(); - let time_it = batch.time.iter().copied(); - let id_it = batch.trajectory_id.iter().copied(); - - for ((ra, dec), (mjd_tt, traj_id)) in ra_it.zip(dec_it).zip(time_it.zip(id_it)) { - // Use OrderedFloat to permit f64 as a key with a total order. - let key = OrderedFloat(mjd_tt); - - // Compute (or reuse) positions at this epoch. - let (geo_pos, helio_pos) = if let Some(&(geo, helio)) = pos_cache.get(&key) { - (geo, helio) - } else { - let epoch = Epoch::from_mjd_in_time_scale(mjd_tt, hifitime::TimeScale::TT); - - // Geocentric position (velocity returned but unused here). - let (geo, _vel) = observer.pvobs(&epoch, ut1)?; - - // Heliocentric position (uses geocentric position). - let helio = observer.helio_position(env_state, &epoch, &geo)?; - - pos_cache.insert(key, (geo, helio)); - (geo, helio) - }; - - // Build observation and append to the right trajectory bucket. - let obs = Observation::with_positions( - uint16_obs, - ra, // radians - batch.error_ra, // radians - dec, // radians - batch.error_dec, // radians - mjd_tt, // MJD(TT) - geo_pos, - helio_pos, - ); - - let obj = ObjectNumber::Int(traj_id); - trajectories - .entry(obj) - .or_insert_with(|| SmallVec::with_capacity(32)) - .push(obs); - } - - Ok(()) -} diff --git a/src/trajectories/mod.rs b/src/trajectories/mod.rs deleted file mode 100644 index e04349f..0000000 --- a/src/trajectories/mod.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! # Trajectories: ingestion, storage, and batch IOD -//! -//! High-level facilities to **ingest**, **store**, and **process** astrometric observations -//! grouped by object. The central type is [`TrajectorySet`], a fast hash map that buckets -//! time-ordered observations per [`ObjectNumber`]. Public helpers let you build a set from -//! multiple formats (MPC 80-column, Parquet, ADES, or in-memory batches) and run a -//! **Gauss-based Initial Orbit Determination (IOD)** over all objects. -//! -//! Modules -//! ----------------- -//! * [`batch_reader`](crate::trajectories::batch_reader) – Zero-copy container and routines to expand single-observer batches -//! into concrete [`Observation`](crate::observations::Observation)s. -//! * [`mpc_80col_reader`](crate::trajectories::mpc_80col_reader) – Minimal MPC **80-column** file reader. -//! * [`parquet_reader`](crate::trajectories::parquet_reader) – Arrow/Parquet-based ingestion (`ra`, `dec`, `jd`, `trajectory_id`). -//! * [`ades_reader`](crate::trajectories::ades_reader) – ADES (MPC XML/JSON) ingestion. -//! * [`trajectory_file`](crate::trajectories::trajectory_file) – **Public** trait exposing `new_from_*` and `add_from_*` helpers -//! to construct/extend a [`TrajectorySet`] from the above sources. -//! * [`trajectory_fit`](crate::trajectories::trajectory_fit) – Batch Gauss IOD over a set (`TrajectoryFit` trait, results & stats). -//! * *(crate-private)* `progress_bar` – Optional progress UI when the `progress` feature is enabled. -//! -//! Data Model -//! ----------------- -//! * **Key:** [`ObjectNumber`] (logical object identifier). -//! * **Value:** `Observations` = `SmallVec` time-ordered per object. -//! * **Set:** [`TrajectorySet`] = `HashMap` -//! for fast hashing and predictable performance on large catalogs. -//! -//! Ingestion Sources -//! ----------------- -//! Use the [`trajectory_file::TrajectoryFile`](crate::trajectories::trajectory_file) trait (implemented for [`TrajectorySet`]): -//! * **80-col MPC** — `new_from_80col`, `add_from_80col` (fail-fast on parse errors). -//! * **Parquet** — `new_from_parquet`, `add_from_parquet` (propagate `OutfitError` on I/O/schema). -//! * **ADES** — `new_from_ades`, `add_from_ades` (error policy handled in the parser). -//! * **In-memory batch** (single observer) — `new_from_vec`, `add_from_vec` -//! using [`batch_reader::ObservationBatch`](crate::trajectories::batch_reader::ObservationBatch) (angles/σ in **radians**, epochs in **MJD (TT)**). -//! -//! Units & Time Scales -//! ----------------- -//! * Internal angles are **radians**; readers convert from **degrees/arcsec** as needed. -//! * Epochs are **MJD (TT)**; Parquet `"jd"` (assumed **TT**) is converted via -//! [`constants::JDTOMJD`](crate::constants::JDTOMJD). -//! * Single-observer batches carry uniform 1-σ uncertainties (radians) applied per component. -//! -//! Batch IOD -//! ----------------- -//! Use [`trajectory_fit::TrajectoryFit::estimate_all_orbits`](crate::trajectories::trajectory_fit::TrajectoryFit::estimate_all_orbits) to run Gauss IOD over the set. -//! Returns a map `ObjectNumber → Result<(GaussResult, rms), OutfitError>`. Errors are **per-object** -//! and do not abort other objects. A cooperative-cancel variant is also available. -//! -//! Performance Notes -//! ----------------- -//! * Ingestion paths project only required columns and cache site positions by epoch. -//! * [`TrajectorySet`] uses `ahash` for speed; no deduplication is performed on `add_*` methods. -//! * Ordering is preserved as provided by sources; sorting by time is not enforced here. -//! -//! Feature Flags -//! ----------------- -//! * `progress` — Enables a live progress bar and timing during batch IOD. See -//! [`trajectory_fit`](crate::trajectories::trajectory_fit) for details. The UI is crate-internal and optional. -//! -//! Quick-Start -//! ----------------- -//! ```rust,no_run -//! use rand::SeedableRng; -//! use std::sync::Arc; -//! use camino::Utf8Path; -//! use outfit::{Outfit, TrajectorySet}; -//! use outfit::observers::Observer; -//! use outfit::trajectories::trajectory_file::TrajectoryFile; -//! use outfit::trajectories::trajectory_fit::TrajectoryFit; -//! use outfit::initial_orbit_determination::IODParams; -//! -//! # fn run() -> Result<(), outfit::outfit_errors::OutfitError> { -//! let mut state = Outfit::new("horizon:DE440", outfit::error_models::ErrorModel::FCCT14)?; -//! let observer: Arc = state.get_observer_from_mpc_code(&"I41".into()); -//! -//! // Ingest from Parquet, then append 80-column MPC -//! let mut trajs: TrajectorySet = TrajectorySet::new_from_parquet( -//! &mut state, Utf8Path::new("obs.parquet"), observer.clone(), 0.5, 0.5, Some(8192) -//! )?; -//! trajs.add_from_80col(&mut state, Utf8Path::new("obs_80col.txt")); -//! -//! // Batch IOD -//! let mut rng = rand::rngs::StdRng::from_os_rng(); -//! let params = IODParams::builder().max_triplets(32).build()?; -//! let results = trajs.estimate_all_orbits(&state, &mut rng, ¶ms); -//! # Ok(()) } -//! ``` -//! -//! See also -//! ------------ -//! * [`trajectory_file`](crate::trajectories::trajectory_file) – Public ingestion API. -//! * [`batch_reader`](crate::trajectories::batch_reader) – Batch expansion for single-observer inputs. -//! * [`parquet_reader`](crate::trajectories::parquet_reader), [`mpc_80col_reader`](crate::trajectories::mpc_80col_reader), [`ades_reader`](crate::trajectories::ades_reader) – File readers. -//! * [`trajectory_fit`](crate::trajectories::trajectory_fit) – Batch IOD, results, and statistics. -//! * [`crate::observations::Observation`] – Atomic astrometric sample. -//! -//! --- -use std::collections::HashMap; - -use ahash::RandomState; - -use crate::{constants::Observations, ObjectNumber}; - -pub mod ades_reader; -pub mod batch_reader; -pub mod mpc_80col_reader; -pub mod parquet_reader; -pub mod trajectory_file; -pub mod trajectory_fit; - -#[cfg(feature = "progress")] -pub(crate) mod progress_bar; - -/// A full set of trajectories for multiple objects. -/// -/// The key is the [`ObjectNumber`] (identifier of an object). -/// The value is the list of [`Observation`](crate::observations::Observation) associated with this object. -/// -/// Uses [`ahash`](https://docs.rs/ahash) for fast hashing. -pub type TrajectorySet = HashMap; diff --git a/src/trajectories/mpc_80col_reader.rs b/src/trajectories/mpc_80col_reader.rs deleted file mode 100644 index b66a401..0000000 --- a/src/trajectories/mpc_80col_reader.rs +++ /dev/null @@ -1,345 +0,0 @@ -//! # MPC 80-Column Observation Reader -//! -//! Utilities to parse **MPC 80-column** astrometric observations and turn them into -//! [`Observation`] values usable by the orbit-determination pipeline. -//! -//! ## Overview -//! ----------------- -//! This module provides: -//! - A small error type [`ParseObsError`] describing MPC parsing failures. -//! - A crate-internal line parser (`from_80col`) that converts a single 80-col line -//! into an [`Observation`] with angles in **radians** and time in **MJD (TT)**. -//! - A crate-visible batch routine \[`extract_80col`\] that reads an entire file, -//! returns all parsed [`Observation`]s, and extracts the **object number** from -//! the header line. -//! -//! The implementation enforces **MPC CCD** observations (rejects non-CCD lines) and -//! converts RA/Dec strings using robust helpers (`parse_ra_to_deg`, `parse_dec_to_deg`), -//! then applies uncertainty handling that accounts for `cos δ` on the RA component. -//! -//! ## Units & Conventions -//! ----------------- -//! - **Input format:** MPC 80-column fixed-width ASCII lines. -//! - **Angles:** RA/Dec are parsed from strings into **degrees**, then converted to -//! **radians**. -//! - **Uncertainties:** parsed in input units (hour/deg for RA/Dec) and mapped to -//! **radians**; RA uncertainties are divided by `cos δ`. -//! - **Time scale:** Observing time is parsed from fractional date and converted -//! to **MJD (TT)** via [`frac_date_to_mjd`]. -//! - **Observer site:** Extracted from columns 77–80 (MPC code), mapped to the -//! compact `u16` site id via \[`Outfit::uint16_from_mpc_code`\]. -//! -//! ## File-Level Object Number -//! ----------------- -//! \[`extract_80col`\] retrieves an **object number** from the first line using the -//! conventional MPC header locations (columns `0..5` or fallback to `5..12`), trims -//! leading zeros, and returns it as an [`ObjectNumber::String`]. -//! -//! ## Error Handling -//! ----------------- -//! Parser failures are wrapped into [`OutfitError::Parsing80ColumnFileError`] with a -//! [`ParseObsError`] payload for precise diagnostics (e.g. *line too short*, -//! *invalid RA*, *invalid date*). Non-CCD lines are filtered out by default. -//! -//! ## See also -//! ------------ -//! * [`parse_ra_to_deg`] – RA string → degrees with uncertainty. -//! * [`parse_dec_to_deg`] – Dec string → degrees with uncertainty. -//! * [`frac_date_to_mjd`] – Fractional date → MJD (TT). -//! * [`Observation`] – Internal astrometric sample type. -//! * [`ObjectNumber`] – Logical object identifier. -//! * [`Outfit`] – Global state (site registry, time scales, etc.). -use std::ops::Range; - -use camino::Utf8Path; -use thiserror::Error; - -use crate::{ - constants::Observations, - conversion::{parse_dec_to_deg, parse_ra_to_deg}, - observations::Observation, - time::frac_date_to_mjd, - ObjectNumber, Outfit, OutfitError, RADH, RADSEC, -}; - -/// Line-level parsing errors for MPC 80-column observations. -/// -/// Variants -/// ----------------- -/// * `TooShortLine` – The line does not reach 80 characters. -/// * `NotCCDObs` – The line is not flagged as a CCD observation (column 15 is `'s'`). -/// * `InvalidRA` – Failed to parse RA field (`line[32..44]`); payload carries the offending slice. -/// * `InvalidDec` – Failed to parse Dec field (`line[44..56]`); payload carries the offending slice. -/// * `InvalidDate` – Failed to parse fractional date (`line[15..32]`); payload carries the offending slice. -/// -/// See also -/// ------------ -/// * [`parse_ra_to_deg`] – Robust RA parser with uncertainty extraction. -/// * [`parse_dec_to_deg`] – Robust Dec parser with uncertainty extraction. -/// * [`frac_date_to_mjd`] – Converts fractional date to MJD (TT). -#[derive(Error, Debug, PartialEq)] -pub enum ParseObsError { - #[error("The line is too short")] - TooShortLine, - #[error("The line is not a CCD observation")] - NotCCDObs, - #[error("Error parsing RA: {0}")] - InvalidRA(String), - #[error("Invalid Dec value: {0}")] - InvalidDec(String), - #[error("Invalid date: {0}")] - InvalidDate(String), -} - -/// Parse a single **MPC 80-column** line into an [`Observation`] (crate-private helper). -/// -/// This routine enforces **CCD** observations, parses RA/Dec/time fields, maps the -/// MPC site code to the compact observer id, and builds an [`Observation`] in **radians** -/// with a **MJD (TT)** epoch. RA uncertainties are divided by `cos δ` to reflect -/// the geometry on the sphere. -/// -/// Arguments -/// ----------------- -/// * `env_state` – Global state used to resolve MPC site codes and build observations. -/// * `line` – A single 80-column ASCII line. -/// -/// Return -/// ---------- -/// * A parsed [`Observation`] or an [`OutfitError::Parsing80ColumnFileError`] on failure. -/// -/// Panics -/// ---------- -/// * Never panics directly; errors are surfaced as [`OutfitError`]. Bounds use fixed slices. -/// -/// Field Layout (MPC 80-col subset used here) -/// ----------------- -/// * `15..32` – Fractional date (UTC-like string expected by `frac_date_to_mjd`). -/// * `32..44` – Right ascension string (parsed by `parse_ra_to_deg`). -/// * `44..56` – Declination string (parsed by `parse_dec_to_deg`). -/// * `77..80` – MPC site code. -/// -/// See also -/// ------------ -/// * [`parse_ra_to_deg`] – RA parsing (degrees + uncertainty). -/// * [`parse_dec_to_deg`] – Dec parsing (degrees + uncertainty). -/// * [`frac_date_to_mjd`] – Fractional date → MJD (TT). -fn from_80col(env_state: &mut Outfit, line: &str) -> Result { - if line.len() < 80 { - return Err(OutfitError::Parsing80ColumnFileError( - ParseObsError::TooShortLine, - )); - } - - if line.chars().nth(14) == Some('s') { - return Err(OutfitError::Parsing80ColumnFileError( - ParseObsError::NotCCDObs, - )); - } - - let (ra, error_ra) = parse_ra_to_deg(line[32..44].trim()).ok_or_else(|| { - OutfitError::Parsing80ColumnFileError(ParseObsError::InvalidRA( - line[32..44].trim().to_string(), - )) - })?; - - let (dec, error_dec) = parse_dec_to_deg(line[44..56].trim()).ok_or_else(|| { - OutfitError::Parsing80ColumnFileError(ParseObsError::InvalidDec( - line[44..56].trim().to_string(), - )) - })?; - - let time = frac_date_to_mjd(line[15..32].trim()).map_err(|_| { - OutfitError::Parsing80ColumnFileError(ParseObsError::InvalidDate( - line[15..32].trim().to_string(), - )) - })?; - - let observer_id = env_state.uint16_from_mpc_code(&line[77..80].trim().into()); - let observer = env_state.get_observer_from_uint16(observer_id); - - let max_rms = |observation_error: f64, observer_error: f64, factor: f64| { - f64::max(observation_error, observer_error * factor) - }; - - let dec_radians = dec.to_radians(); - let dec_rad_cos = dec_radians.cos(); - - let ra_error = max_rms( - (error_ra * RADH) / dec_rad_cos, - observer.ra_accuracy.map(|v| v.into_inner()).unwrap_or(0.0), - RADSEC / dec_rad_cos, - ); - - let dec_error = max_rms( - error_dec.to_radians(), - observer.dec_accuracy.map(|v| v.into_inner()).unwrap_or(0.0), - RADSEC, - ); - - let observation = Observation::new( - env_state, - observer_id, - ra.to_radians(), - ra_error, - dec_radians, - dec_error, - time, - )?; - Ok(observation) -} - -/// Read a full **MPC 80-column** file, returning parsed observations and the object number. -/// -/// Lines that are not CCD observations are **silently skipped**. Any other parsing error -/// triggers a panic with context (the current strategy is fail-fast for corrupted inputs). -/// The object number is extracted from the first line (`0..5`, fallback `5..12`), trimming -/// leading zeros. -/// -/// Arguments -/// ----------------- -/// * `env_state` – Global state used to resolve MPC site codes and build observations. -/// * `colfile` – Path to the MPC 80-column file. -/// -/// Return -/// ---------- -/// * A tuple `(observations, object_number)` where: -/// - `observations` is a `Vec` with angles in **radians** and epochs in **MJD (TT)**, -/// - `object_number` is the header-derived [`ObjectNumber::String`]. -/// -/// Panics -/// ---------- -/// * Panics if the file cannot be read or if a non-CCD line fails to parse for reasons -/// other than the expected `NotCCDObs` (fail-fast behavior). -/// -/// See also -/// ------------ -/// * [`Observation`] – Parsed astrometric sample. -/// * [`ObjectNumber`] – Identifier extracted from header columns. -/// * [`parse_ra_to_deg`], [`parse_dec_to_deg`], [`frac_date_to_mjd`] – Parsing helpers. -pub(crate) fn extract_80col( - env_state: &mut Outfit, - colfile: &Utf8Path, -) -> Result<(Observations, ObjectNumber), OutfitError> { - let file_content = std::fs::read_to_string(colfile) - .unwrap_or_else(|_| panic!("Could not read file {}", colfile.as_str())); - - let first_line = file_content - .lines() - .next() - .unwrap_or_else(|| panic!("Could not read first line of file {}", colfile.as_str())); - - fn get_object_number(line: &str, range: Range) -> String { - line[range].trim_start_matches('0').trim().to_string() - } - - let mut object_number = get_object_number(first_line, 0..5); - if object_number.is_empty() { - object_number = get_object_number(first_line, 5..12); - } - - Ok(( - file_content - .lines() - .filter_map(|line| match from_80col(env_state, line) { - Ok(obs) => Some(obs), - Err(OutfitError::Parsing80ColumnFileError(ParseObsError::NotCCDObs)) => None, - Err(e) => panic!("Error parsing line: {e:?}"), - }) - .collect(), - ObjectNumber::String(object_number), - )) -} - -#[cfg(test)] -#[cfg(feature = "jpl-download")] -mod mpc_80col_test { - use super::*; - - #[test] - fn test_from_80col_valid_line() { - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - let line = - " K09R05F C2009 09 15.23433 22 52 22.62 -14 47 03.2 20.8 Vr~097wG96"; - - let mut env_state = OUTFIT_HORIZON_TEST.0.clone(); - let result = from_80col(&mut env_state, line); - - assert!(result.is_ok()); - let obs = result.unwrap(); - - assert_eq!( - obs, - Observation { - observer: 0, - ra: 5.988124307160555, - error_ra: 1.2535340843609459e-6, - dec: -0.25803335512429054, - error_dec: 1.0181086985431635e-6, - time: 55089.23509601851, - observer_earth_position: [ - 3.0499942822953885e-5, - -8.594304778250371e-6, - 2.8491013919142154e-5 - ] - .into(), - observer_helio_position: [ - 0.9968138444702415, - -0.12221921296802639, - -0.05295724448160355 - ] - .into(), - } - ); - } - - #[test] - fn test_from_80col_too_short_line() { - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - let line = "short line"; - let mut env_state = OUTFIT_HORIZON_TEST.0.clone(); - let result = from_80col(&mut env_state, line); - - assert!(matches!( - result, - Err(OutfitError::Parsing80ColumnFileError( - ParseObsError::TooShortLine - )) - )); - } - - #[test] - fn test_from_80col_invalid_date() { - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - let line = - " K09R05F C20xx 09 15.23433 22 52 22.62 -14 47 03.2 20.8 Vr~097wG96"; - let mut env_state = OUTFIT_HORIZON_TEST.0.clone(); - let result = from_80col(&mut env_state, line); - - assert!(matches!( - result, - Err(OutfitError::Parsing80ColumnFileError( - ParseObsError::InvalidDate(_) - )) - )); - } - - #[test] - fn test_from_80col_invalid_ra_dec() { - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - let line = - " K09R05F C2009 09 15.23433 XX YY ZZ.ZZ -AA BB CC.C 20.8 Vr~097wG96"; - let mut env_state = OUTFIT_HORIZON_TEST.0.clone(); - let result = from_80col(&mut env_state, line); - - assert!(matches!( - result, - Err(OutfitError::Parsing80ColumnFileError( - ParseObsError::InvalidRA(_) - )) - )); - } -} diff --git a/src/trajectories/parquet_reader.rs b/src/trajectories/parquet_reader.rs deleted file mode 100644 index 4a16de9..0000000 --- a/src/trajectories/parquet_reader.rs +++ /dev/null @@ -1,362 +0,0 @@ -//! # Parquet Reader for Astrometric Observations -//! -//! High-throughput ingestion of angle-only astrometric detections from **Apache Parquet** -//! into a [`TrajectorySet`]. This module focuses on a minimal, column-projected read path, -//! converts **JD→MJD (TT)**, and constructs [`Observation`]s while caching observer positions -//! by unique epoch to avoid repeated ephemeris calls. -//! -//! ## Overview -//! ----------------- -//! The primary entry point is a crate-internal routine that reads Parquet record batches, -//! projects only the required columns, and appends parsed samples to an existing -//! [`TrajectorySet`]. It is typically called by higher-level, public ingestion helpers -//! (e.g., methods exposed by a `TrajectoryFile` trait). -//! -//! Key design points: -//! - **Projection-first**: materialize only the columns used by Outfit. -//! - **Typed downcast once per batch**: avoid per-row dynamic checks. -//! - **Fast path for non-null columns**: iterate over `&[f64]` / `&[u32]` slices. -//! - **Epoch→position cache**: compute `(geo_pos, helio_pos)` at most once per unique MJD(TT). -//! -//! ## Expected Parquet Schema -//! ----------------- -//! The input file must contain (at least) the following leaf columns: -//! - `ra: Float64` — Right ascension in **degrees**. -//! - `dec: Float64` — Declination in **degrees**. -//! - `jd: Float64` — Epoch in **Julian Date (TT)**. -//! - `trajectory_id: UInt32` — Grouping key used as [`ObjectNumber::Int`]. -//! -//! Columns are accessed by **name** at setup (to build the projection mask) and then by -//! **index** in the hot loop. If a required column is missing, a clean `io::Error` is returned. -//! -//! ## Units & Conventions -//! ----------------- -//! - **Angles:** `ra`, `dec` are stored in **degrees** on disk and converted to **radians** -//! before building [`Observation`]s. -//! - **Uncertainties:** provided as **arcseconds** at call-site, converted to **radians** once; -//! applied uniformly to all rows of the file (per-component). -//! - **Time scale:** `jd` is assumed to be **TT** on disk. It is converted to **MJD (TT)** -//! via subtraction by [`JDTOMJD`]. -//! - **Observer:** the file is read under a **single** [`Observer`]. If you ingest heterogeneous -//! observers, extend the cache key to `(observer_id, mjd_tt)` or build one cache per observer. -//! -//! ## Null Handling Policy -//! ----------------- -//! Two execution paths: -//! - **No nulls** (fast path): raw slice iteration with minimal overhead. -//! - **With nulls** (fallback): per-row checks; incomplete rows are **skipped** to preserve -//! correctness. Prefer cleaning datasets upstream for best performance. -//! -//! ## Performance Notes -//! ----------------- -//! - **Projection** avoids unnecessary I/O and deserialization. -//! - **Batch size** (`8192` by default) amortizes decompression and Arrow decoding; -//! tune between `8k` and `64k` depending on storage/CPU. -//! - **Caching** uses `FastHashMap, (Vector3, Vector3)>` -//! keyed by MJD(TT), typically yielding large savings whenever exposures share timestamps. -//! - **Zero-ephemeris constructor**: [`Observation::with_positions`] prevents recomputing -//! positions during construction. -//! -//! ## Error Handling -//! ----------------- -//! - I/O and schema issues surface as `io::Error` or `ParquetError` wrapped into [`OutfitError`]. -//! - Ephemeris/observer computations (`pvobs`, `helio_position`) may return [`OutfitError`]. -//! - Missing required columns produce an `io::ErrorKind::NotFound` with a clear message. -//! -//! > **Note** -//! > This reader is **crate-private** and is used under the hood by higher-level, -//! > public ingestion helpers (e.g., methods implementing a `TrajectoryFile` trait). -//! -//! ## See also -//! ------------ -//! * [`Observation::with_positions`] – Lagrange-friendly constructor with precomputed positions. -//! * [`Observer::pvobs`] – Geocentric site position at epoch. -//! * [`Observer::helio_position`] – Heliocentric site position at epoch. -//! * [`JDTOMJD`] – Constant used for `JD → MJD (TT)` conversion. -//! * [`ObjectNumber`] – Key type for per-object bucketing in [`TrajectorySet`]. -use arrow_array::Array; -use hifitime::Epoch; -use nalgebra::Vector3; -use ordered_float::OrderedFloat; -use parquet::errors::ParquetError; -use smallvec::SmallVec; -use std::collections::hash_map::Entry; -use std::io; -use std::sync::Arc; - -use crate::constants::ArcSec; -use crate::conversion::arcsec_to_rad; -use crate::observers::Observer; -use crate::outfit::Outfit; -use crate::outfit_errors::OutfitError; -use crate::TrajectorySet; -use crate::{ - constants::{ObjectNumber, JDTOMJD}, - observations::Observation, -}; -use arrow_array::array::{Float64Array, UInt32Array}; -use camino::Utf8Path; -use parquet::arrow::{arrow_reader::ParquetRecordBatchReaderBuilder, ProjectionMask}; - -use ahash::RandomState; -use std::collections::HashMap; - -pub type FastHashMap = HashMap; - -/// Load astrometric observations from a Parquet file into an existing [`TrajectorySet`]. -/// -/// This routine deserializes batches of observations from a Parquet file, converts -/// Julian Dates (JD) to Modified Julian Dates (MJD; TT scale), and constructs [`Observation`] -/// instances with the provided astrometric uncertainties. To avoid redundant and costly -/// ephemeris computations, it caches the observer's geocentric and heliocentric positions -/// per unique `(observer, time)` encountered during the read. -/// -/// Arguments -/// ----------------- -/// * `trajectories` – The mutable [`TrajectorySet`] to which observations are appended. -/// * `env_state` – Global environment providing ephemerides, Earth orientation data, -/// and observer definitions. -/// * `parquet` – Path to the input Parquet file containing the columns -/// `ra`, `dec`, `jd`, and `trajectory_id`. -/// The `ra` and `dec` columns have to be in degrees and of type `Float64`. -/// The `jd` column has to be in Julian Date (TT) and of type `Float64`. -/// The `trajectory_id` column has to be of type `UInt32` and is used to group -/// observations by object. -/// * `observer` – The [`Observer`] associated with all observations in this file. -/// * `error_ra` – Right ascension astrometric uncertainty (radians). -/// * `error_dec` – Declination astrometric uncertainty (radians). -/// * `batch_size` – Optional Arrow reader batch size (default: 8192 rows). -/// -/// Performance notes -/// ----------------- -/// * Projects only the required columns and accesses them by **index** to avoid -/// per-batch name lookups. -/// * Uses a per-file cache keyed by MJD (TT) to store `(geo_pos, helio_pos)` and -/// reuse them across batches and rows. -/// * Fast path when all columns are non-null: iterates over raw slices (`&[f64]`, `&[u32]`) -/// for minimal overhead. -/// * Downcasts to concrete Arrow arrays once per batch (not per row). -/// -/// Return -/// ---------- -/// * No return value. Observations are appended in-place to `trajectories`. -/// -/// See also -/// ------------ -/// * [`Observation::with_positions`] – Zero-ephemeris constructor used here. -/// * [`Observer::pvobs`] – Geocentric observer position (and velocity). -/// * [`Observer::helio_position`] – Heliocentric observer position. -pub(crate) fn parquet_to_trajset( - trajectories: &mut TrajectorySet, - env_state: &mut Outfit, - parquet: &Utf8Path, - observer: Arc, - error_ra: ArcSec, - error_dec: ArcSec, - batch_size: Option, -) -> Result<(), OutfitError> { - // Convert arcsecond uncertainties to radians once (cheap). - let error_ra_rad = arcsec_to_rad(error_ra); - let error_dec_rad = arcsec_to_rad(error_dec); - - // Resolve observer to its compact u16 key once (hot path avoids map lookups later). - let uint16_obs = env_state.uint16_from_observer(observer); - - // Open file and inspect Parquet metadata (I/O and schema discovery happen here). - let file = std::fs::File::open(parquet)?; - let builder = ParquetRecordBatchReaderBuilder::try_new(file)?; - - let parquet_metadata = builder.metadata(); - let schema_descr = parquet_metadata.file_metadata().schema_descr(); - - // Build a stable projection mask to materialize **only** the columns used by Outfit. - // We rely on the projection order to index columns directly in the hot loop. - let all_fields = schema_descr.columns(); - let column_names = ["ra", "dec", "jd", "trajectory_id"]; - let projection_indices: Vec = column_names - .iter() - .map(|name| { - all_fields - .iter() - .position(|f| f.name() == *name) - // If not found, surface a clean error instead of panicking. - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - format!("Column '{name}' not found in schema"), - ) - }) - }) - .collect::>()?; - let mask = ProjectionMask::leaves(schema_descr, projection_indices); - - // A larger batch often amortizes decompression + Arrow deserialization cost. - // Tune with benches (8192–65536) depending on your I/O and CPU characteristics. - let batch_size = batch_size.unwrap_or(8192); - let reader = builder - .with_projection(mask) - .with_batch_size(batch_size) - .build()?; - - // Pre-fetch shared providers and observer reference to avoid repeated lookups in the hot loop. - let ut1 = env_state.get_ut1_provider(); - let obs_ref = env_state.get_observer_from_uint16(uint16_obs); - - // Cache MJD(TT) → (geo_pos, helio_pos). - // Note: we assume here the file contains a **single** observer. If you ingest multiple - // observers, extend the key to (observer_id, time), e.g. by packing into a u64 or using a tuple. - let mut pos_cache: FastHashMap, (Vector3, Vector3)> = - FastHashMap::with_capacity_and_hasher(4096, RandomState::default()); - - // Iterate over Parquet record batches - for maybe_batch in reader { - // I/O boundary; failures here usually indicate corruption or incompatible schema. - let batch = maybe_batch.map_err(ParquetError::from)?; - let len = batch.num_rows(); - - // Projected columns by index: [0]=ra, [1]=dec, [2]=jd, [3]=trajectory_id - // We downcast **once** per batch (cheap) and reuse typed views in the row loop. - let ra_arr = batch - .column(0) - .as_any() - .downcast_ref::() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "ra must be Float64Array"))?; - let dec_arr = batch - .column(1) - .as_any() - .downcast_ref::() - .ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "dec must be Float64Array") - })?; - let jd_arr = batch - .column(2) - .as_any() - .downcast_ref::() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "jd must be Float64Array"))?; - let tid_arr = batch - .column(3) - .as_any() - .downcast_ref::() - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "trajectory_id must be UInt32Array", - ) - })?; - - // Fast path when all projected columns have no nulls. - // This unlocks tight, bounds-checked loops on `&[T]` slices without per-row Option unwrapping. - let no_nulls = ra_arr.nulls().is_none() - && dec_arr.nulls().is_none() - && jd_arr.nulls().is_none() - && tid_arr.nulls().is_none(); - - if no_nulls { - // Raw slice views (zero allocs, no per-element downcast/boxing). - let ra_vals: &[f64] = ra_arr.values(); - let dec_vals: &[f64] = dec_arr.values(); - let jd_vals: &[f64] = jd_arr.values(); - let tid_vals: &[u32] = tid_arr.values(); - - // Hot loop: build `Observation`s with positions sourced from the per-file cache. - for i in 0..len { - // Convert degrees → radians (fast path: one multiply each). - let ra_rad = ra_vals[i].to_radians(); - let dec_rad = dec_vals[i].to_radians(); - - // Convert JD → MJD(TT). Assumes `jd` is already in TT scale in the file; - // if not, convert scales here before caching. - let mjd_time = jd_vals[i] - JDTOMJD; - // Using OrderedFloat to allow float keys in a hash map (with a total order). - let key = OrderedFloat(mjd_time); - - // Compute observer positions once per unique epoch. - // We handle errors with `?` while using the entry API (no closures returning Result). - let (geo_pos, helio_pos) = match pos_cache.entry(key) { - Entry::Occupied(e) => *e.get(), - Entry::Vacant(v) => { - let epoch = - Epoch::from_mjd_in_time_scale(mjd_time, hifitime::TimeScale::TT); - - // Geocentric position (velocity unused here, but available if needed). - let (geo, _vel) = obs_ref.pvobs(&epoch, ut1)?; - // Heliocentric position (requires geocentric as input). - let helio = obs_ref.helio_position(env_state, &epoch, &geo)?; - - v.insert((geo, helio)); - (geo, helio) - } - }; - - // Zero-ephemeris constructor: avoids recomputing positions at construction. - let obs = Observation::with_positions( - uint16_obs, - ra_rad, - error_ra_rad, - dec_rad, - error_dec_rad, - mjd_time, - geo_pos, - helio_pos, - ); - - // Group observations by `trajectory_id` (ObjectNumber::Int). - let obj = ObjectNumber::Int(tid_vals[i]); - trajectories - .entry(obj) - .or_insert_with(|| SmallVec::with_capacity(32)) - .push(obs); - } - } else { - // Safety fallback: if any column contains nulls, we check row-by-row and skip incomplete rows. - // This path is slower, but maintains correctness for sparse/missing data. - for i in 0..len { - if ra_arr.is_null(i) - || dec_arr.is_null(i) - || jd_arr.is_null(i) - || tid_arr.is_null(i) - { - continue; // Drop incomplete rows (policy: skip; alternatively, surface an error) - } - - let ra_rad: f64 = ra_arr.value(i).to_radians(); - let dec_rad = dec_arr.value(i).to_radians(); - let mjd_time = jd_arr.value(i) - JDTOMJD; - let tid = tid_arr.value(i); - let key = OrderedFloat(mjd_time); - - let (geo_pos, helio_pos) = match pos_cache.entry(key) { - Entry::Occupied(e) => *e.get(), - Entry::Vacant(v) => { - let epoch = - Epoch::from_mjd_in_time_scale(mjd_time, hifitime::TimeScale::TT); - - let (geo, _vel) = obs_ref.pvobs(&epoch, ut1)?; - let helio = obs_ref.helio_position(env_state, &epoch, &geo)?; - - v.insert((geo, helio)); - (geo, helio) - } - }; - - let obs = Observation::with_positions( - uint16_obs, - ra_rad, - error_ra_rad, - dec_rad, - error_dec_rad, - mjd_time, - geo_pos, - helio_pos, - ); - - trajectories - .entry(ObjectNumber::Int(tid)) - .or_insert_with(|| SmallVec::with_capacity(32)) - .push(obs); - } - } - } - - Ok(()) -} diff --git a/src/trajectories/progress_bar.rs b/src/trajectories/progress_bar.rs deleted file mode 100644 index 40ecba4..0000000 --- a/src/trajectories/progress_bar.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! Lightweight iteration timing utilities. -//! -//! This module provides helpers to measure and report iteration times -//! in long-running loops, e.g. when combined with a progress bar -//! (see the `progress` feature). -//! -//! Components -//! ----------------- -//! * [`IterTimer`] – Tracks per-iteration durations and computes a -//! smoothed **exponential moving average** (EMA). -//! Useful to get a stable estimate of iteration time even when -//! individual steps fluctuate. -//! -//! * [`fmt_dur`] – Human-readable formatter for [`Duration`] values, -//! producing strings like `"253µs"`, `"42ms"`, or `"3.14s"` depending -//! on the scale. -//! -//! Usage -//! ----------------- -//! Typical workflow inside a loop: -//! -//! ```rust, no_run -//! use std::time::Duration; -//! use your_crate::iter_timer::{IterTimer, fmt_dur}; -//! -//! let mut timer = IterTimer::new(0.2); // smoothing factor α = 0.2 -//! -//! for i in 0..10 { -//! // ... some expensive work ... -//! -//! let dt = timer.tick(); -//! println!( -//! "iter {i} took {}, EMA = {}", -//! fmt_dur(dt), -//! fmt_dur(timer.avg()) -//! ); -//! } -//! ``` -//! -//! Design notes -//! ----------------- -//! * The EMA update rule is: -//! `ema ← α·dt + (1–α)·ema` -//! with `α ∈ (0,1]`. -//! - `α = 1.0` → no smoothing (EMA = last sample). -//! - small `α` → stronger smoothing, slower adaptation. -//! -//! * [`IterTimer::tick`] must be called at each iteration boundary. -//! The first tick initializes the average to the first duration. -//! -//! * [`IterTimer::avg`] returns the smoothed duration as a [`Duration`]. -//! -//! * This module is enabled only with the `progress` feature. -#[cfg(feature = "progress")] -use std::time::{Duration, Instant}; - -pub struct IterTimer { - last: Instant, - ema_ns: f64, - alpha: f64, - count: u64, -} - -impl IterTimer { - pub fn new(alpha: f64) -> Self { - Self { - last: Instant::now(), - ema_ns: 0.0, - alpha, - count: 0, - } - } - - #[inline] - pub fn tick(&mut self) -> Duration { - let now = Instant::now(); - let dt = now.duration_since(self.last); - self.last = now; - self.count += 1; - - let dt_ns = dt.as_nanos() as f64; - self.ema_ns = if self.count == 1 { - dt_ns - } else { - self.alpha * dt_ns + (1.0 - self.alpha) * self.ema_ns - }; - - dt - } - - #[inline] - pub fn avg(&self) -> Duration { - if self.count == 0 { - Duration::from_nanos(0) - } else { - Duration::from_nanos(self.ema_ns as u64) - } - } -} - -#[inline] -pub fn fmt_dur(d: Duration) -> String { - let us = d.as_micros(); - if us < 1_000 { - format!("{us}µs") - } else { - let ms = d.as_millis(); - if ms < 1_000 { - format!("{ms}ms") - } else { - let s = d.as_secs_f32(); - format!("{s:.2}s") - } - } -} diff --git a/src/trajectories/trajectory_file.rs b/src/trajectories/trajectory_file.rs deleted file mode 100644 index 7a104a6..0000000 --- a/src/trajectories/trajectory_file.rs +++ /dev/null @@ -1,513 +0,0 @@ -//! # Trajectory ingestion and batch Initial Orbit Determination (IOD) -//! -//! High-level utilities to **build and extend** a [`TrajectorySet`] from multiple sources -//! (MPC 80-column, Parquet, ADES, or in-memory batches) and to run a **Gauss-based IOD** -//! over all trajectories. -//! -//! ## Overview -//! ----------------- -//! This module exposes the [`TrajectoryFile`] trait implemented for [`TrajectorySet`]. -//! It provides: -//! - Constructors that **create** a new set from a given source (`new_from_*`), -//! - Appenders that **extend** an existing set (`add_from_*`), -//! - Convenience methods to ingest **in-memory batches** (single observer) via -//! [`ObservationBatch`]. -//! -//! Internally, ingestion from in-memory batches uses a crate-private routine -//! `observation_from_batch` (unit/scale/caching logic). End users should interact only -//! with the public `new_from_vec` / `add_from_vec` methods. -//! -//! ## Data model -//! ----------------- -//! - A [`TrajectorySet`] is a `HashMap` storing a -//! time-ordered list of astrometric observations per object. -//! - [`ObservationBatch`] is a thin container (borrowed/owned) for angle-only astrometry -//! from a **single observer** with uniform uncertainties; it is expanded into concrete -//! [`Observation`](crate::observations::Observation)s and grouped by `trajectory_id`. -//! - Batch IOD returns a -//! [`FullOrbitResult`](crate::trajectories::trajectory_fit::FullOrbitResult), i.e. a map -//! `ObjectNumber → Result<(Option, f64), OutfitError>`. -//! -//! ## Ingestion sources & signatures -//! ----------------- -//! **MPC 80-column** -//! - [`TrajectoryFile::new_from_80col`] → `Self` -//! Reads a file, extracts `(Observations, ObjectNumber)` and **inserts** into a new set. -//! **Panics** on extraction failure (internal `expect`). -//! - [`TrajectoryFile::add_from_80col`] → `()` -//! Reads and **inserts** into an existing set. **Panics** on extraction failure. -//! -//! **Parquet** (`"ra"`, `"dec"`, `"jd"`, `"trajectory_id"`) -//! - [`TrajectoryFile::new_from_parquet`] → `Result` -//! Creates a new set; errors are propagated. -//! - [`TrajectoryFile::add_from_parquet`] → `Result<(), OutfitError>` -//! Appends to an existing set; errors are propagated. -//! *Units on disk:* `ra/dec` in **degrees**, `jd` in **JD (TT)**. Internally converted to -//! **radians** and **MJD (TT)** (via [`JDTOMJD`](crate::constants::JDTOMJD)). Per-file -//! uncertainties are passed in **arcseconds**. -//! -//! **ADES (MPC XML/JSON)** -//! - [`TrajectoryFile::new_from_ades`] → `Self` -//! - [`TrajectoryFile::add_from_ades`] → `()` -//! Both delegate to `parse_ades` and **do not return a `Result`** (errors are handled -//! inside the parser or may panic depending on its policy). -//! -//! **In-memory batches (single observer)** -//! - [`TrajectoryFile::new_from_vec`] → `Result` -//! - [`TrajectoryFile::add_from_vec`] → `Result<(), OutfitError>` -//! Expand an [`ObservationBatch`] (RA/DEC/σ in **radians**, epochs in **MJD (TT)**) -//! into per-sample [`Observation`](crate::observations::Observation)s using the shared -//! [`Outfit`] state and **append/group** by `trajectory_id`. -//! -//! ## Units & time scales -//! ----------------- -//! - **Angles**: internal [`Observation`](crate::observations::Observation)s store RA/DEC in **radians**. -//! Parquet/80-column/ADES readers perform degree→radian conversions as needed. -//! - **Uncertainties**: expected in **arcseconds** at call-site for Parquet/ADES; for -//! in-memory batches they must already be in **radians** (uniform per batch). -//! - **Times**: internal epochs are **MJD (TT)**. Parquet `"jd"` values are assumed **TT** -//! and converted via [`JDTOMJD`](crate::constants::JDTOMJD). 80-col/ADES readers apply their respective conversions. -//! -//! ## Duplicates & ordering -//! ----------------- -//! - **No deduplication** is performed by any `add_*` method. Users must avoid re-ingesting -//! the same file/batch twice if duplicates are undesirable. -//! - Observations are stored **as provided**; ordering by time is not enforced here. -//! -//! ## Error semantics -//! ----------------- -//! - Methods returning `Result<_, OutfitError>` propagate I/O/schema/ephemeris errors. -//! - `new_from_80col` / `add_from_80col` use `expect(...)` internally and therefore may **panic** -//! on parse/read failures (fail-fast behavior). -//! - `new_from_ades` / `add_from_ades` currently **do not** return a `Result`; error handling -//! is delegated to `parse_ades` (which may log or panic depending on implementation). -//! -//! ## Batch IOD -//! ----------------- -//! Use [`crate::trajectories::trajectory_fit::TrajectoryFit::estimate_all_orbits`] to run the -//! full Gauss IOD over each `(ObjectNumber → Observations)` pair. Outcomes per object: -//! - `Ok(Some(GaussResult))` + RMS — a viable preliminary/corrected orbit, -//! - `Ok(None)` — pipeline executed but no acceptable solution kept, -//! - `Err(OutfitError)` — failure **isolated** to that object. -//! -//! ## Example -//! ----------------- -//! ```no_run -//! use std::sync::Arc; -//! use camino::Utf8Path; -//! use rand::SeedableRng; -//! use outfit::outfit::Outfit; -//! use outfit::observers::Observer; -//! use outfit::trajectories::trajectory_file::TrajectoryFile; -//! use outfit::TrajectoryFit; -//! use outfit::initial_orbit_determination::IODParams; -//! use outfit::TrajectorySet; -//! -//! # fn demo() -> Result<(), outfit::outfit_errors::OutfitError> { -//! let mut state = Outfit::new("horizon:DE440", outfit::error_models::ErrorModel::FCCT14)?; -//! let observer: Arc = state.get_observer_from_mpc_code(&"I41".into()); -//! -//! // 1) From Parquet (propagates errors) -//! let mut trajs: TrajectorySet = TrajectorySet::new_from_parquet( -//! &mut state, -//! Utf8Path::new("observations.parquet"), -//! observer.clone(), -//! 0.5, 0.5, -//! Some(8192), -//! )?; -//! -//! // 2) From MPC 80-column (may panic on parse error) -//! trajs.add_from_80col(&mut state, Utf8Path::new("obs_80col.txt")); -//! -//! // 3) Run batch IOD -//! let mut rng = rand::rngs::StdRng::from_os_rng(); -//! let params = IODParams::builder().max_triplets(32).build()?; -//! let results = trajs.estimate_all_orbits(&state, &mut rng, ¶ms); -//! # Ok(()) } -//! ``` -//! -//! ## See also -//! ------------ -//! * [`TrajectoryFile`] – Public ingestion API surface. -//! * [`ObservationBatch`] – Zero-copy batch container (single observer). -//! * [`crate::trajectories::trajectory_fit::TrajectoryFit::estimate_all_orbits`] – Batch Gauss IOD. -//! * [`Outfit`] – Ephemerides, reference frames, and observer registry. -use std::{collections::HashMap, sync::Arc}; - -use super::batch_reader::observation_from_batch; -use crate::constants::ArcSec; -use crate::observers::Observer; -use crate::outfit::Outfit; -use crate::outfit_errors::OutfitError; -use crate::trajectories::batch_reader::ObservationBatch; -use crate::TrajectorySet; -use camino::Utf8Path; - -use super::ades_reader::parse_ades; -use super::mpc_80col_reader::extract_80col; -use super::parquet_reader::parquet_to_trajset; - -/// A trait for the TrajectorySet type definition. -/// This trait provides methods to create a TrajectorySet from different sources. -/// It allows to create a TrajectorySet from an 80 column file, a parquet file, or an ADES file. -/// It also allows to add observations to an existing TrajectorySet from these sources. -/// The methods are: -/// * `from_80col`: Create a TrajectorySet from an 80 column file. -/// * `add_80col`: Add observations to a TrajectorySet from an 80 column file. -/// * `new_from_vec`: Create a TrajectorySet from a vector of observations. -/// * `add_from_vec`: Add observations to a TrajectorySet from a vector of observations. -/// * `new_from_parquet`: Create a TrajectorySet from a parquet file. -/// * `add_from_parquet`: Add observations to a TrajectorySet from a parquet file. -/// * `new_from_ades`: Create a TrajectorySet from an ADES file. -/// * `add_from_ades`: Add observations to a TrajectorySet from an ADES file. -/// -/// Note -/// ---- -/// * Warning: No check is done for duplicated observations for every add method. -/// * The user shoud be careful to not add the same observation or same file twice -pub trait TrajectoryFile { - /// Create a TrajectorySet from an 80 column file - /// The trajectory are added in place in the TrajectorySet. - /// If a trajectory id already exists, the observations are added to the existing trajectory. - /// - /// Arguments - /// --------- - /// * `env_state`: a mutable reference to the Outfit instance - /// * `colfile`: a path to an 80 column file - /// - /// Return - /// ------ - /// * a TrajectorySet containing the observations from the 80 column file - /// - /// Note - /// ---- - /// * The 80 column file must respect the MPC format. - /// * ref: - fn new_from_80col(env_state: &mut Outfit, colfile: &Utf8Path) -> Self; - - /// Add a set of trajectories from an 80 column file to a TrajectorySet - /// The trajectory are added in place in the TrajectorySet. - /// If a trajectory id already exists, the observations are added to the existing trajectory. - /// - /// Arguments - /// --------- - /// * `env_state`: a mutable reference to the Outfit instance - /// * `colfile`: a path to an 80 column file - /// - /// Note - /// ---- - /// * The 80 column file must respect the MPC format. - /// * ref: - fn add_from_80col(&mut self, env_state: &mut Outfit, colfile: &Utf8Path); - - /// Create a new [`TrajectorySet`] from a batch of observations taken by a single observer. - /// - /// This constructor consumes an [`ObservationBatch`] and groups its observations - /// into trajectories, keyed by their `trajectory_id`. - /// Each observation in the batch must have been recorded by the **same observer**, - /// but may belong to **different objects** (distinguished by `trajectory_id`). - /// - /// Arguments - /// ----------------- - /// * `env_state` — Mutable reference to the global [`Outfit`] state (used for ephemerides, UT1, etc.). - /// * `batch` — An [`ObservationBatch`] containing RA/DEC/epoch values (radians + MJD/TT) and trajectory IDs. - /// * `observer` — The observer that recorded all observations in the batch. - /// - /// Return - /// ----------------- - /// * `Ok(Self)` — A new [`TrajectorySet`] containing one or more trajectories populated from the batch. - /// * `Err(OutfitError)` — If observation construction or position computations fail. - /// - /// Invariants - /// ----------------- - /// * `batch.trajectory_id.len() == batch.ra.len() == batch.dec.len() == batch.time.len()` - /// * Angles and uncertainties in the batch must already be in **radians**. - /// - /// Example - /// ----------------- - /// ```rust, no_run - /// # use outfit::trajectories::batch_reader::ObservationBatch; - /// # use outfit::TrajectorySet; - /// # use outfit::TrajectoryFile; - /// # use outfit::{Outfit, ErrorModel}; - /// # use std::sync::Arc; - /// # let mut env = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - /// # let observer = env.get_observer_from_mpc_code(&"I41".to_string()); - /// # let (traj_id, ra_deg, dec_deg, mjd) = (vec![0, 0, 1], vec![14.62, 14.63, 15.01], vec![9.98, 10.01, 11.02], vec![43785.35, 43785.36, 43785.40]); - /// let batch = ObservationBatch::from_degrees_owned(&traj_id, &ra_deg, &dec_deg, 0.5, 0.5, &mjd); - /// - /// // Build a trajectory set directly from the batch: - /// let ts = TrajectorySet::new_from_vec(&mut env, &batch, observer).unwrap(); - /// ``` - fn new_from_vec( - env_state: &mut Outfit, - batch: &ObservationBatch<'_>, - observer: Arc, - ) -> Result - where - Self: Sized; - - /// Add the observations from a batch to an existing [`TrajectorySet`]. - /// - /// This method inserts all observations from the provided [`ObservationBatch`] into - /// the current set, grouping them into trajectories by `trajectory_id`. - /// Each observation in the batch must have been recorded by the **same observer**, - /// but may belong to multiple distinct objects. - /// - /// Arguments - /// ----------------- - /// * `env_state` — Mutable reference to the global [`Outfit`] state (used for ephemerides, UT1, etc.). - /// * `batch` — An [`ObservationBatch`] containing RA/DEC/epoch values (radians + MJD/TT) and trajectory IDs. - /// * `observer` — The observer that recorded all observations in the batch. - /// - /// Return - /// ----------------- - /// * `Ok(())` — If all observations were successfully inserted into the `TrajectorySet`. - /// * `Err(OutfitError)` — If observation construction or position computations fail. - /// - /// Example - /// ----------------- - /// ```rust, no_run - /// use outfit::trajectories::batch_reader::ObservationBatch; - /// use outfit::TrajectorySet; - /// use outfit::TrajectoryFile; - /// use outfit::{Outfit, ErrorModel}; - /// use std::sync::Arc; - /// use ahash::RandomState; - /// use std::collections::HashMap; - /// - /// let mut env = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - /// let observer = env.get_observer_from_mpc_code(&"I41".to_string()); - /// let (traj_id, ra_deg, dec_deg, mjd) = (vec![0, 0, 1], vec![14.62, 14.63, 15.01], vec![9.98, 10.01, 11.02], vec![43785.35, 43785.36, 43785.40]); - /// let batch = ObservationBatch::from_degrees_owned(&traj_id, &ra_deg, &dec_deg, 0.5, 0.5, &mjd); - /// - /// let mut ts = HashMap::with_hasher(RandomState::new()); - /// ts.add_from_vec(&mut env, &batch, observer).unwrap(); - /// ``` - fn add_from_vec( - &mut self, - env_state: &mut Outfit, - batch: &ObservationBatch<'_>, - observer: Arc, - ) -> Result<(), OutfitError>; - - /// Create a new [`TrajectorySet`] from a Parquet file. - /// - /// This function reads a Parquet file containing astrometric observations - /// and constructs a full [`TrajectorySet`]. Each observation is associated - /// with the provided `observer` and assigned constant uncertainties in - /// right ascension and declination. - /// - /// Arguments - /// ----------------- - /// * `env_state` – Global environment providing ephemerides, UT1 provider, and observer mapping. - /// * `parquet` – Path to the input Parquet file. - /// * `observer` – Observer metadata (shared reference, resolved once to a compact id). - /// * `error_ra` – 1-σ uncertainty in right ascension \[arcsec\], applied uniformly. - /// * `error_dec` – 1-σ uncertainty in declination \[arcsec\], applied uniformly. - /// * `batch_size` – Record batch size for Parquet reader; defaults to 2048 if `None`. - /// - /// Return - /// ---------- - /// * `Ok(TrajectorySet)` – A new set of trajectories populated from the file. - /// * `Err(OutfitError)` – If the file cannot be opened, parsed, or contains invalid data. - /// - /// Notes - /// ---------- - /// * The Parquet file must contain the following columns: `"ra"`, `"dec"`, `"jd"`, `"trajectory_id"`. - /// * The `"jd"` values are assumed to be in TT scale and are converted internally to MJD via [`JDTOMJD`](crate::constants::JDTOMJD). - /// * The `ra` and `dec` columns have to be in degrees and of type `Float64`. - /// * The `jd` column has to be in Julian Date (TT) and of type `Float64`. - /// * The `trajectory_id` column has to be of type `UInt32` and is used to group - /// observations by object. - /// - /// See also - /// ------------ - /// * [`add_from_parquet`](crate::trajectories::trajectory_file::TrajectoryFile::add_from_parquet) – Adds observations from a Parquet file to an existing set. - fn new_from_parquet( - env_state: &mut Outfit, - parquet: &Utf8Path, - mpc_code: Arc, - error_ra: ArcSec, - error_dec: ArcSec, - batch_size: Option, - ) -> Result - where - Self: Sized; - - /// Add observations from a Parquet file to an existing [`TrajectorySet`]. - /// - /// This function appends new observations (grouped by `trajectory_id`) - /// to the current set. The same `observer` and astrometric uncertainties - /// are applied to all ingested rows. - /// - /// Arguments - /// ----------------- - /// * `env_state` – Global environment providing ephemerides, UT1 provider, and observer mapping. - /// * `parquet` – Path to the input Parquet file. - /// * `observer` – Observer metadata (shared reference, resolved once to a compact id). - /// * `error_ra` – 1-σ uncertainty in right ascension \[arcsec\], applied uniformly. - /// * `error_dec` – 1-σ uncertainty in declination \[arcsec\], applied uniformly. - /// * `batch_size` – Record batch size for Parquet reader; defaults to 2048 if `None`. - /// - /// Return - /// ---------- - /// * `Ok(())` – On successful ingestion, with the internal set updated in place. - /// * `Err(OutfitError)` – If the file cannot be opened, parsed, or contains invalid data. - /// - /// Notes - /// ---------- - /// * The Parquet file must contain the following columns: `"ra"`, `"dec"`, `"jd"`, `"trajectory_id"`. - /// * The `"jd"` values are assumed to be in TT scale and are converted internally to MJD via [`JDTOMJD`](crate::constants::JDTOMJD). - /// * The `ra` and `dec` columns have to be in degrees and of type `Float64`. - /// * The `jd` column has to be in Julian Date (TT) and of type `Float64`. - /// * The `trajectory_id` column has to be of type `UInt32` and is used to group - /// observations by object. - /// - /// See also - /// ------------ - /// * [`new_from_parquet`](crate::trajectories::trajectory_file::TrajectoryFile::new_from_parquet) – Creates a brand new set from a Parquet file. - fn add_from_parquet( - &mut self, - env_state: &mut Outfit, - parquet: &Utf8Path, - observer: Arc, - error_ra: ArcSec, - error_dec: ArcSec, - batch_size: Option, - ) -> Result<(), OutfitError>; - - /// Add a set of trajectories to a TrajectorySet from an ADES file - /// - /// Arguments - /// --------- - /// * `env_state`: a mutable reference to the Outfit instance - /// * `ades`: a path to an ADES file - /// * `error_ra`: the error in right ascension (if some values are given, the error ra is supposed to be the same for all observations) - /// * `error_dec`: the error in declination (if some values are given, the error dec is supposed to be the same for all observations) - /// - /// Note - /// ---- - /// * The ADES file must respect the MPC format. - /// * ref: - fn new_from_ades( - env_state: &mut Outfit, - ades: &Utf8Path, - error_ra: Option, - error_dec: Option, - ) -> Self; - - /// Create a TrajectorySet from an ADES file - /// - /// Arguments - /// --------- - /// * `env_state`: a mutable reference to the Outfit instance - /// * `ades`: a path to an ADES file - /// * `error_ra`: the error in right ascension (if some values are given, the error ra is supposed to be the same for all observations) - /// * `error_dec`: the error in declination (if some values are given, the error dec is supposed to be the same for all observations) - /// - /// Return - /// ------ - /// * a TrajectorySet containing the observations from the ADES file - /// - /// Note - /// ---- - /// * The ADES file must respect the MPC format. - /// * ref: - fn add_from_ades( - &mut self, - env_state: &mut Outfit, - ades: &Utf8Path, - error_ra: Option, - error_dec: Option, - ); -} - -impl TrajectoryFile for TrajectorySet { - fn new_from_vec( - env_state: &mut Outfit, - batch: &ObservationBatch<'_>, - observer: Arc, - ) -> Result { - let mut traj_set: TrajectorySet = HashMap::default(); - observation_from_batch(&mut traj_set, env_state, batch, observer)?; - Ok(traj_set) - } - - fn add_from_vec( - &mut self, - env_state: &mut Outfit, - batch: &ObservationBatch<'_>, - observer: Arc, - ) -> Result<(), OutfitError> { - observation_from_batch(self, env_state, batch, observer)?; - Ok(()) - } - - fn add_from_parquet( - &mut self, - env_state: &mut Outfit, - parquet: &Utf8Path, - observer: Arc, - error_ra: ArcSec, - error_dec: ArcSec, - batch_size: Option, - ) -> Result<(), OutfitError> { - parquet_to_trajset( - self, env_state, parquet, observer, error_ra, error_dec, batch_size, - ) - } - - fn new_from_parquet( - env_state: &mut Outfit, - parquet: &Utf8Path, - observer: Arc, - error_ra: ArcSec, - error_dec: ArcSec, - batch_size: Option, - ) -> Result - where - Self: Sized, - { - let mut trajs: TrajectorySet = HashMap::default(); - parquet_to_trajset( - &mut trajs, env_state, parquet, observer, error_ra, error_dec, batch_size, - )?; - Ok(trajs) - } - - fn new_from_80col(env_state: &mut Outfit, colfile: &Utf8Path) -> Self { - let mut traj_set: TrajectorySet = HashMap::default(); - let (observations, object_number) = - extract_80col(env_state, colfile).expect("Failed to extract 80col data"); - traj_set.insert(object_number, observations); - traj_set - } - - fn add_from_80col(&mut self, env_state: &mut Outfit, colfile: &Utf8Path) { - let (observations, object_number) = - extract_80col(env_state, colfile).expect("Failed to extract 80col data"); - self.insert(object_number, observations); - } - - fn add_from_ades( - &mut self, - env_state: &mut Outfit, - ades: &Utf8Path, - error_ra: Option, - error_dec: Option, - ) { - parse_ades(env_state, ades, self, error_ra, error_dec); - } - - fn new_from_ades( - env_state: &mut Outfit, - ades: &Utf8Path, - error_ra: Option, - error_dec: Option, - ) -> Self { - let mut trajs: TrajectorySet = HashMap::default(); - parse_ades(env_state, ades, &mut trajs, error_ra, error_dec); - trajs - } -} diff --git a/src/trajectories/trajectory_fit.rs b/src/trajectories/trajectory_fit.rs deleted file mode 100644 index 15f2d89..0000000 --- a/src/trajectories/trajectory_fit.rs +++ /dev/null @@ -1,1596 +0,0 @@ -//! # Batch Gauss IOD over Trajectory Sets -//! -//! Run a full **Gauss-based Initial Orbit Determination (IOD)** over a -//! [`TrajectorySet`], collect **per-object outcomes**, and expose convenience -//! helpers to query or extract solutions and summarize observation counts. -//! -//! ## Overview -//! ----------------- -//! A [`TrajectorySet`] maps each [`ObjectNumber`] to its time-ordered -//! [`Observations`]. This module implements the [`TrajectoryFit`] trait on -//! `TrajectorySet`, providing: -//! -//! * `estimate_all_orbits` – run the Gauss IOD pipeline on **every object**, -//! * `estimate_all_orbits_with_cancel` – same, with **cooperative cancellation**, -//! * `total_observations` / `number_of_trajectories` – quick set-level metrics, -//! * `obs_count_stats` – summary statistics on observation counts, -//! * `gauss_result_for` / `take_gauss_result` – ergonomic access to results. -//! -//! All objects are processed with the same [`Outfit`] state (ephemerides, error -//! model, frames), a caller-provided RNG, and a single [`IODParams`] configuration. -//! -//! ## Result Model -//! ----------------- -//! Batch outcomes are returned as a [`FullOrbitResult`]: -//! -//! ```text -//! ObjectNumber → Result<(GaussResult, rms: f64), OutfitError> -//! ``` -//! -//! * `Ok((GaussResult, rms))` – the best preliminary/corrected orbit and its RMS -//! of normalized astrometric residuals, -//! * `Err(OutfitError)` – a failure **isolated** to that object (other objects -//! continue to be processed). -//! -//! Use [`gauss_result_for`] to **borrow** a solution and its RMS, or -//! [`take_gauss_result`] to **move** them out of the map. -//! -//! ## Execution Modes -//! ----------------- -//! ### Progress UI (feature: `progress`) -//! When compiled with the `progress` feature, `estimate_all_orbits` renders a -//! live progress bar (via `indicatif`) and reports per-iteration timing via -//! a lightweight moving average to help diagnose throughput bottlenecks. -//! -//! ### Cooperative cancellation -//! `estimate_all_orbits_with_cancel` periodically calls a user-provided -//! closure `should_cancel()` based on **wall-clock intervals** (not iteration -//! counts) to keep cancellation latency stable even if some objects are slow. -//! -//! ## Performance Notes -//! ----------------- -//! * The loop walks the underlying map once; overall time scales with the number -//! of objects × the cost of `ObservationIOD::estimate_best_orbit` (triplet -//! enumeration, scoring, optional correction). -//! * Results are accumulated in a `HashMap` that uses `ahash::RandomState`, -//! matching the default hasher used elsewhere in the crate. -//! * No mutation of the observations themselves; only per-object IOD is performed. -//! -//! ## Error Semantics -//! ----------------- -//! * Failures are **per-object**: an error for one object does **not** abort -//! the batch. -//! * The returned map contains **one entry per processed object**, -//! each entry being either `Ok((GaussResult, rms))` or `Err(OutfitError)`. -//! -//! ## Examples -//! ----------------- -//! Minimal end-to-end run (no progress UI): -//! -//! ```rust,no_run -//! use rand::SeedableRng; -//! use outfit::{Outfit, TrajectorySet}; -//! use outfit::initial_orbit_determination::IODParams; -//! use outfit::trajectories::trajectory_fit::TrajectoryFit; -//! -//! # fn demo(mut trajs: TrajectorySet) -> Result<(), outfit::outfit_errors::OutfitError> { -//! let state = Outfit::new("horizon:DE440", outfit::error_models::ErrorModel::FCCT14)?; -//! let mut rng = rand::rngs::StdRng::from_os_rng(); -//! let params = IODParams::builder().max_triplets(32).build()?; -//! -//! let results = trajs.estimate_all_orbits(&state, &mut rng, ¶ms); -//! for (obj, res) in &results { -//! match res { -//! Ok((g, rms)) => eprintln!("{obj:?}: orbit={} rms={:.4}", g, rms), -//! Err(e) => eprintln!("{obj:?}: error={e}"), -//! } -//! } -//! # Ok(()) } -//! ``` -//! -//! Cooperative cancellation (poll every ~20 ms): -//! -//! ```rust,no_run -//! use std::sync::atomic::{AtomicBool, Ordering}; -//! use outfit::trajectories::trajectory_fit::TrajectoryFit; -//! -//! # fn cancelable(mut trajs: outfit::TrajectorySet, -//! # state: &outfit::Outfit, -//! # rng: &mut impl rand::Rng, -//! # params: &outfit::IODParams, -//! # ) -> outfit::trajectories::trajectory_fit::FullOrbitResult { -//! let stop = AtomicBool::new(false); -//! // … flip `stop` from another thread / signal handler … -//! -//! trajs.estimate_all_orbits_with_cancel(state, rng, params, || stop.load(Ordering::Relaxed)) -//! # } -//! ``` -//! -//! Quick stats for logging/reporting: -//! -//! ```rust -//! use outfit::trajectories::trajectory_fit::TrajectoryFit; -//! -//! fn summarize(set: &outfit::TrajectorySet) { -//! let n_obj = set.number_of_trajectories(); -//! let n_obs = set.total_observations(); -//! if let Some(stats) = set.obs_count_stats() { -//! eprintln!("Trajectories: {n_obj}, Observations: {n_obs}"); -//! eprintln!("{:#}", stats); -//! } -//! } -//! ``` -//! -//! ## See also -//! ------------ -//! * [`TrajectoryFit`] – Trait implemented by `TrajectorySet` for batch IOD and stats. -//! * [`ObservationIOD::estimate_best_orbit`] – Per-object Gauss IOD (called internally). -//! * [`GaussResult`] – Preliminary/corrected orbit container. -//! * [`IODParams`] – Tuning for triplet generation, scoring, correction. -//! * [`gauss_result_for`] / [`take_gauss_result`] – Accessors for the `FullOrbitResult` map. -use std::{collections::HashMap, fmt}; - -use ahash::RandomState; -use rand::Rng; - -use crate::{ - constants::Observations, GaussResult, IODParams, ObjectNumber, ObservationIOD, Outfit, - OutfitError, TrajectorySet, -}; - -use std::time::{Duration, Instant}; - -#[cfg(feature = "progress")] -use super::progress_bar::IterTimer; -#[cfg(feature = "progress")] -use indicatif::{ProgressBar, ProgressStyle}; - -#[cfg(feature = "parallel")] -use rayon::prelude::*; -#[cfg(feature = "parallel")] -use std::{ - hash::{Hash, Hasher}, - mem, -}; - -/// Full batch orbit determination results. -/// -/// Each entry maps an [`ObjectNumber`] to the outcome of a full -/// Initial Orbit Determination (IOD) attempt on its set of observations. -/// -/// Internally, this is implemented as: -/// -/// ```ignore -/// HashMap, RandomState> -/// ``` -/// -/// Return semantics -/// ----------------- -/// * `Ok((GaussResult, f64))` – a successful IOD with its RMS of normalized residuals. -/// * `Err(OutfitError)` – a failure isolated to that object. -pub type FullOrbitResult = - HashMap, RandomState>; - -/// Borrow a Gauss solution (if any) and its RMS for a given key. -/// -/// Arguments -/// ----------------- -/// * `all`: The map of all IOD outcomes. -/// * `key`: The object identifier. -/// -/// Return -/// ---------- -/// * `Ok(Some((&GaussResult, f64)))` – a solution is present for the key. -/// * `Ok(None)` – key absent. -/// * `Err(&OutfitError)` – the IOD attempt failed for that key. -/// -/// See also -/// ------------ -/// * [`GaussResult`] – Gauss IOD output structure. -pub fn gauss_result_for<'a>( - all: &'a FullOrbitResult, - key: &ObjectNumber, -) -> Result, &'a OutfitError> { - match all.get(key) { - None => Ok(None), - Some(Err(e)) => Err(e), - Some(Ok((g, rms))) => Ok(Some((g, *rms))), - } -} - -/// Take ownership of the solution for `key`, removing it from the map. -/// -/// Arguments -/// ----------------- -/// * `all`: The map of all IOD outcomes (consumed entry will be removed). -/// * `key`: The object identifier to extract. -/// -/// Return -/// ---------- -/// * `Ok(Some((GaussResult, f64)))` – ownership of the solution and its RMS. -/// * `Ok(None)` – key absent. -/// * `Err(OutfitError)` – the IOD attempt failed for that key. -/// -/// See also -/// ------------ -/// * [`gauss_result_for`] – Borrowing accessor. -pub fn take_gauss_result( - all: &mut FullOrbitResult, - key: &ObjectNumber, -) -> Result, OutfitError> { - match all.remove(key) { - None => Ok(None), - Some(Err(e)) => Err(e), - Some(Ok((g, rms))) => Ok(Some((g, rms))), - } -} - -/// Summary statistics for per-trajectory observation counts. -/// -/// Each [`TrajectorySet`] entry (one object) has an associated -/// [`Observations`] container. This structure stores basic distribution -/// statistics on the **number of observations per trajectory**, as -/// returned by [`obs_count_stats`](crate::trajectories::trajectory_fit::TrajectoryFit::obs_count_stats). -/// -/// Fields -/// ----------------- -/// * `min` – smallest number of observations in any trajectory. -/// * `p25` – 25th percentile (first quartile) of observation counts. -/// * `median` – 50th percentile (second quartile). -/// * `p95` – 95th percentile, indicating the upper tail of the distribution. -/// * `max` – largest number of observations in any trajectory. -/// -/// Percentiles are computed using the *nearest-rank* method: -/// the index is `round(q × (N-1))` for quantile `q ∈ [0,1]`, clamped to valid range. -/// This convention makes results stable even for small sample sizes. -/// -/// Display -/// ----------------- -/// * `format!("{}", stats)` – compact single-line summary, e.g.: -/// ```text -/// min=2, p25=4, median=8, p95=15, max=20 -/// ``` -/// -/// * `format!("{:#}", stats)` – pretty multi-line table, e.g.: -/// ```text -/// Observation count per trajectory — summary -/// ----------------------------------------- -/// min : 2 -/// p25 : 4 -/// median : 8 -/// p95 : 15 -/// max : 20 -/// ``` -/// -/// See also -/// ------------ -/// * [`obs_count_stats`](crate::trajectories::trajectory_fit::TrajectoryFit::obs_count_stats) – Computes these statistics from a [`TrajectorySet`]. -#[derive(Debug, Clone, Copy)] -pub struct ObsCountStats { - pub min: usize, - pub p25: usize, - pub median: usize, - pub p95: usize, - pub max: usize, -} - -impl fmt::Display for ObsCountStats { - /// Compact by default; pretty multi-line when using the alternate flag (`{:#}`). - /// - /// See also - /// ------------ - /// * [`obs_count_stats`](crate::trajectories::trajectory_fit::TrajectoryFit::obs_count_stats) – Builder of these summary statistics. - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if f.alternate() { - // Pretty, multi-line, aligned output (ASCII-only for portability). - writeln!(f, "Observation count per trajectory — summary")?; - writeln!(f, "-----------------------------------------")?; - writeln!(f, "min : {}", self.min)?; - writeln!(f, "p25 : {}", self.p25)?; - writeln!(f, "median : {}", self.median)?; - writeln!(f, "p95 : {}", self.p95)?; - write!(f, "max : {}", self.max) - } else { - // Compact single-line for logs and quick prints. - write!( - f, - "min={}, p25={}, median={}, p95={}, max={}", - self.min, self.p25, self.median, self.p95, self.max - ) - } - } -} - -// ============================================================================ -// Factorized core + progress abstraction + cancel config -// ============================================================================ - -/// Cancellation guard polled at fixed wall-clock intervals. -/// -/// A `CancelCfg` lets the main loop periodically check whether the user -/// or an external controller has requested an early stop. The loop itself -/// decides *when* to poll based on the `interval`, and *how* to react -/// based on the `should_cancel` callback. -/// -/// Arguments -/// ----------------- -/// * `interval`: Minimum wall-clock delay between two cancellation checks. -/// This prevents the loop from calling the callback at every -/// iteration, which would be too costly. -/// * `should_cancel`: User-provided closure returning `true` when cancellation -/// is requested. If `true`, the loop terminates gracefully. -/// -/// See also -/// ------------ -/// * [`estimate_all_orbits_core`] – Main iteration loop that evaluates this configuration. -struct CancelCfg { - interval: Duration, - should_cancel: F, -} - -/// Abstract interface for reporting progress to the outside world. -/// -/// The purpose of this trait is to **decouple the heavy numerical loop** -/// from any particular UI backend. The core orbit determination logic -/// always calls into a `ProgressSink`, but the actual implementation -/// depends on the build: -/// -/// * with the `progress` feature, it is backed by an [`indicatif`] progress bar, -/// * otherwise, a no-op implementation is used, so the loop compiles without UI. -/// -/// This abstraction ensures that the loop has a consistent lifecycle: -/// 1. [`start`] is called once with the total number of objects. -/// 2. [`on_iter`] is called at the beginning of each iteration (e.g. to refresh messages). -/// 3. [`inc`] is called once per completed iteration. -/// 4. [`on_interrupt`] is called right before exiting due to cancellation. -/// 5. [`finish`] is always called at the end, successful or not. -/// -/// By default, all methods are no-ops, so implementors only override the -/// subset they need. -trait ProgressSink { - /// Called once before entering the main loop, with the total number of items. - fn start(&mut self, _total: u64) {} - /// Called at the beginning of each iteration (e.g., refresh UI or logs). - fn on_iter(&mut self) {} - /// Increment the progress by one step. - fn inc(&mut self) {} - /// Called once if the loop exits because of cancellation. - fn on_interrupt(&mut self) {} - /// Called once at the very end, regardless of success or cancellation. - fn finish(&mut self) {} -} - -/// Default no-op implementation, used when the `progress` feature is disabled. -impl ProgressSink for () {} - -/// Blanket implementation so that `&mut T` also implements [`ProgressSink`]. -/// -/// This lets tests pass `&mut MockProgress` directly, while the core API -/// continues to accept progress sinks by value. -impl ProgressSink for &mut T { - #[inline] - fn start(&mut self, total: u64) { - (**self).start(total) - } - #[inline] - fn on_iter(&mut self) { - (**self).on_iter() - } - #[inline] - fn inc(&mut self) { - (**self).inc() - } - #[inline] - fn on_interrupt(&mut self) { - (**self).on_interrupt() - } - #[inline] - fn finish(&mut self) { - (**self).finish() - } -} - -/// Concrete type selected depending on the `progress` feature: -/// * [`IndicatifProgress`] when enabled, -/// * [`()`] (no-op) when disabled. -#[cfg(feature = "progress")] -type ProgressImpl = IndicatifProgress; -#[cfg(not(feature = "progress"))] -type ProgressImpl = (); - -/// Central loop that runs orbit estimation for each trajectory. -/// -/// This function is the **engine** behind the public APIs: -/// [`TrajectoryFit::estimate_all_orbits`] and -/// [`TrajectoryFit::estimate_all_orbits_with_cancel`]. -/// -/// It consumes a [`TrajectorySet`] and tries to estimate the best orbit -/// for each contained object. Along the way it reports progress, and it -/// may stop early if the provided cancellation config triggers. -/// -/// Arguments -/// ----------------- -/// * `set`: The trajectory set to process (mutable, results are inserted). -/// * `state`: Global environment providing ephemerides, constants, and frames. -/// * `rng`: Random number generator used for noisy triplet realizations. -/// * `params`: IOD parameters controlling triplet generation and scoring. -/// * `cancel`: Optional cancellation guard (poll interval + callback). -/// * `progress`: Progress reporting sink (indicatif bar or no-op). -/// -/// Return -/// ---------- -/// * A [`FullOrbitResult`], i.e. a map from object → `Ok((GaussResult, rms))` -/// or `Err(OutfitError)` depending on whether orbit estimation succeeded. -/// -/// See also -/// ------------ -/// * [`TrajectoryFit::estimate_all_orbits`] – Public API without cancellation. -/// * [`TrajectoryFit::estimate_all_orbits_with_cancel`] – Public API with cancellation. -fn estimate_all_orbits_core( - set: &mut TrajectorySet, - state: &Outfit, - rng: &mut impl Rng, - params: &IODParams, - mut cancel: Option>, - mut progress: P, -) -> FullOrbitResult -where - F: FnMut() -> bool, - P: ProgressSink, -{ - let total = set.len() as u64; - progress.start(total.max(1)); - - let mut results: FullOrbitResult = HashMap::default(); - let mut last_poll = Instant::now(); - - for (obj, observations) in set.iter_mut() { - // --- Timer-based cancellation (if configured) - if let Some(CancelCfg { - interval, - should_cancel, - }) = cancel.as_mut() - { - if last_poll.elapsed() >= *interval { - if should_cancel() { - progress.on_interrupt(); - break; - } - last_poll = Instant::now(); - } - } - - progress.on_iter(); - - // Core work - let res = observations.estimate_best_orbit(state, &state.error_model, rng, params); - results.insert(obj.clone(), res); - - progress.inc(); - } - - progress.finish(); - results -} - -// --------------------------- Progress (indicatif) ---------------------------- -#[cfg(feature = "progress")] -mod progress_impl { - use super::IterTimer; - use super::ProgressSink; - use crate::trajectories::progress_bar::fmt_dur; - - /// Progress sink backed by `indicatif`. - /// - /// See also - /// ------------ - /// * [`estimate_all_orbits_core`] – Calls into this sink at key lifecycle moments. - pub(super) struct IndicatifProgress { - pb: super::ProgressBar, - it_timer: IterTimer, - } - - impl Default for IndicatifProgress { - fn default() -> Self { - // The actual length is set in `start()`. - let pb = super::ProgressBar::new(1); - Self { - pb, - it_timer: IterTimer::new(0.2), - } - } - } - - impl ProgressSink for IndicatifProgress { - fn start(&mut self, total: u64) { - self.pb.set_length(total.max(1)); - self.pb.set_style( - super::ProgressStyle::with_template( - "{bar:40.cyan/blue} {pos}/{len} ({percent:>3}%) \ - | {per_sec} | ETA {eta_precise} | {msg}", - ) - .expect("indicatif template"), - ); - self.pb - .enable_steady_tick(super::Duration::from_millis(200)); - } - - fn on_iter(&mut self) { - let last = self.it_timer.tick(); - let avg = self.it_timer.avg(); - self.pb - .set_message(format!("last: {}, avg: {}", fmt_dur(last), fmt_dur(avg))); - } - - fn inc(&mut self) { - self.pb.inc(1); - } - - fn on_interrupt(&mut self) { - self.pb.set_message("Interrupted"); - } - - fn finish(&mut self) { - self.pb.disable_steady_tick(); - self.pb.finish_and_clear(); - } - } -} - -#[cfg(feature = "progress")] -use progress_impl::IndicatifProgress; - -// --------------------------- Parallel features ---------------------------- - -#[cfg(feature = "parallel")] -/// Generate a new 64-bit pseudo-random value using the **SplitMix64** algorithm. -/// This is a simple, fast, and reproducible way to decorrelate seeds for parallel RNGs. -/// -/// Arguments -/// ----------------- -/// * `x`: Input state (a `u64` value, typically a hash or a base seed). -/// -/// Return -/// ---------- -/// * A `u64` pseudo-random value, suitable for seeding RNGs (e.g., `StdRng`). -/// -/// See also -/// ------------ -/// * [`seed_for_object`] – Derives per-object seeds from a base seed and object hash. -#[inline] -fn splitmix64(mut x: u64) -> u64 { - // SplitMix64 constants and shifts from Steele et al. (2014). - x = x.wrapping_add(0x9E3779B97F4A7C15); - let mut z = x; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); - z ^ (z >> 31) -} - -#[cfg(feature = "parallel")] -/// Derive a **deterministic per-object RNG seed** from a global base seed. -/// -/// Each object is first hashed with the crate’s default hasher (`ahash`), and the -/// resulting 64-bit value is mixed with the base seed via [`splitmix64`]. -/// This ensures that: -/// - Each object gets a **stable, reproducible seed** (same input → same output). -/// - Seeds are decorrelated even across many objects. -/// - Parallel runs remain deterministic regardless of thread scheduling. -/// -/// Arguments -/// ----------------- -/// * `base`: A global base seed (drawn once per batch). -/// * `obj`: The [`ObjectNumber`] used as key to derive the per-object seed. -/// -/// Return -/// ---------- -/// * A `u64` deterministic RNG seed for the given object. -/// -/// See also -/// ------------ -/// * [`splitmix64`] – Core mixing function. -/// * [`ObjectNumber`] – Object identifier used in Outfit (MPC number or string). -#[inline] -fn seed_for_object(base: u64, obj: &ObjectNumber) -> u64 { - // Hash object key with the same family used elsewhere (ahash). - let mut h = ahash::AHasher::default(); - obj.hash(&mut h); - let obj_h = h.finish(); - - // Mix base seed and object hash through SplitMix64. - splitmix64(base ^ obj_h) -} - -// ============================================================================ -// Public trait + factorized implementation -// ============================================================================ - -pub trait TrajectoryFit { - /// Run Gauss-based Initial Orbit Determination (IOD) for **every trajectory** in the set. - /// - /// This method iterates over each `(ObjectNumber → Observations)` entry and applies the - /// full IOD pipeline: candidate triplet enumeration, preliminary Gauss solution, and - /// scoring / selection of the best orbit. It aggregates results into a [`FullOrbitResult`]. - /// - /// Mutation semantics - /// ----------------- - /// * This function requires `&mut self` and may **reorder observations in-place** (e.g., - /// by time) and/or **update batch-level calibration** data (RMS scaling of quoted errors). - /// * The underlying astrometric measurements (RA/DEC/time) remain semantically identical, - /// but their **container order** and **per-observation uncertainty metadata** may change - /// due to calibration and sorting steps used by the estimator. - /// * If you rely on a specific iteration order elsewhere, do not assume it is preserved. - /// - /// Determinism - /// ----------------- - /// * With a fixed RNG seed, the procedure is deterministic given identical inputs and params. - /// - /// Arguments - /// ----------------- - /// * `state`: Global environment providing ephemerides, constants, and reference frames. - /// * `rng`: Random number generator used for noisy triplet realizations (e.g., [`StdRng`](rand::rngs::StdRng)). - /// * `params`: IOD parameters controlling triplet generation, scoring, and correction loops. - /// - /// Return - /// ---------- - /// * A [`FullOrbitResult`] mapping each object to either: - /// * `Ok((GaussResult, f64))` – selected orbit and its RMS, - /// * `Err(OutfitError)` – diagnostic if no acceptable solution was found. - /// - /// Notes - /// ---------- - /// * Failures are isolated: one object failing does not prevent others from being processed. - /// * Runtime scales with the number of trajectories and candidate triplets per trajectory. - /// - /// See also - /// ------------ - /// * [`ObservationIOD::estimate_best_orbit`] – Per-trajectory IOD with best-orbit selection. - /// * [`TrajectoryFit::estimate_all_orbits_with_cancel`] – Same API with cooperative cancellation. - /// * [`IODParams`] – Tuning parameters for IOD batch execution. - fn estimate_all_orbits( - &mut self, - state: &Outfit, - rng: &mut impl Rng, - params: &IODParams, - ) -> FullOrbitResult; - - /// Count the total number of [`Observation`](crate::observations::Observation) entries across all trajectories. - /// - /// This method iterates once over all values in the [`TrajectorySet`], - /// summing the length of each [`Observations`] container. - /// - /// Return - /// ---------- - /// * The total number of observations across all objects. - fn total_observations(&self) -> usize; - - /// Compute distribution statistics for the number of observations per trajectory. - /// - /// Each trajectory (one object in the [`TrajectorySet`]) has an associated - /// [`Observations`] container. This function collects their sizes and computes: - /// - /// * `min` – smallest number of observations in any trajectory, - /// * `p25` – 25th percentile (first quartile), - /// * `median` – 50th percentile (second quartile), - /// * `p95` – 95th percentile (upper tail indicator), - /// * `max` – largest number of observations in any trajectory. - /// - /// Percentiles are computed using the *nearest-rank* method: - /// the index is `round(q × (N-1))` for quantile `q ∈ [0,1]`, clamped to valid range. - /// This makes results robust even for small datasets. - /// - /// Return - /// ---------- - /// * `None` if the set is empty. - /// * `Some(ObsCountStats)` containing the summary statistics otherwise. - /// - /// See also - /// ------------ - /// * [`total_observations`](crate::trajectories::trajectory_fit::TrajectoryFit::total_observations) – Sum of all observations across trajectories. - fn obs_count_stats(&self) -> Option; - - /// Return the number of distinct trajectories (objects) in the set. - /// - /// This is simply the number of keys in the underlying map. - /// - /// Return - /// ------ - /// * The number of distinct trajectories (objects) in the set. - /// - /// See also - /// ------------ - /// * [`total_observations`](crate::trajectories::trajectory_fit::TrajectoryFit::total_observations) – Sum of all observations across trajectories. - /// * [`obs_count_stats`](crate::trajectories::trajectory_fit::TrajectoryFit::obs_count_stats) – Statistics on the number of observations per trajectory. - fn number_of_trajectories(&self) -> usize; - - /// Run Gauss-based IOD for all trajectories, with **cooperative cancellation** support. - /// - /// Behaves like [`TrajectoryFit::estimate_all_orbits`], but periodically polls a user - /// callback to decide whether to stop early. Returns **partial results** if cancelled. - /// - /// Mutation semantics - /// ----------------- - /// * Same as the non-cancellable variant: the method may **reorder observations in-place** - /// and update **per-batch calibration** (e.g., RMS alignment of quoted errors). - /// - /// Cancellation model - /// ----------------- - /// * The loop polls `should_cancel` at ~20 ms wall-clock intervals. When it returns `true`, - /// the loop terminates gracefully, calls `on_interrupt()` on the progress sink (if any), - /// and returns the results accumulated so far. - /// - /// Determinism - /// ----------------- - /// * With a fixed RNG seed, behavior is deterministic except for the **cut point** at which - /// cancellation is observed (timing dependent). - /// - /// Arguments - /// ----------------- - /// * `state`: Global environment and reference frames. - /// * `rng`: Random number generator for noisy triplet realizations. - /// * `params`: IOD parameters. - /// * `should_cancel`: Closure polled periodically; return `true` to request early stop. - /// - /// Return - /// ---------- - /// * A [`FullOrbitResult`]: - /// * Complete if the loop ran to completion, - /// * Partial if cancellation was triggered mid-way. - /// - /// See also - /// ------------ - /// * [`TrajectoryFit::estimate_all_orbits`] – Non-cancellable variant. - fn estimate_all_orbits_with_cancel( - &mut self, - state: &Outfit, - rng: &mut impl Rng, - params: &IODParams, - should_cancel: F, - ) -> FullOrbitResult - where - F: FnMut() -> bool; - - /// Run Gauss-based Initial Orbit Determination (IOD) over all trajectories - /// using **parallel batches**. - /// - /// The [`TrajectorySet`] is split into chunks of size `batch_size`. Each chunk - /// is assigned to a Rayon worker thread, and objects inside a chunk are processed - /// sequentially for cache efficiency. A single `base_seed` is drawn from `rng`, - /// and a stable per-object seed is derived deterministically to guarantee - /// reproducibility regardless of parallel scheduling. - /// - /// Threading model - /// ----------------- - /// * This function uses the **global Rayon thread pool** by default. - /// * The number of worker threads is controlled by the environment variable - /// `RAYON_NUM_THREADS`. For example: - /// - /// ```bash - /// RAYON_NUM_THREADS=4 cargo run --release - /// ``` - /// - /// will cap Rayon to 4 threads across the entire program. - /// * If the variable is unset, Rayon defaults to the number of logical CPUs. - /// - /// Mutation semantics - /// ----------------- - /// * As in the sequential version, this method may **reorder observations** - /// and **update per-batch calibration** (e.g. RMS scaling of quoted errors). - /// * Each trajectory’s observation container is reinserted after processing, - /// so `self` remains valid and complete. - /// - /// Arguments - /// ----------------- - /// * `state`: Global environment (ephemerides, constants, frames). - /// * `rng`: Random number generator, used only once to draw a base seed. - /// * `params`: IOD parameters controlling triplet generation, scoring, correction. - /// * `batch_size`: Number of trajectories per parallel batch. Must be ≥ 1. - /// - /// Return - /// ---------- - /// * A [`FullOrbitResult`] mapping each object to either: - /// * `Ok((GaussResult, f64))` – best orbit and its RMS, - /// * `Err(OutfitError)` – diagnostic if no acceptable orbit was found. - /// - /// See also - /// ------------ - /// * [`TrajectoryFit::estimate_all_orbits`] – Sequential variant. - /// * [`TrajectoryFit::estimate_all_orbits_with_cancel`] – Sequential variant with cooperative cancellation. - #[cfg(feature = "parallel")] - fn estimate_all_orbits_in_batches_parallel( - &mut self, - state: &Outfit, - rng: &mut impl rand::Rng, - params: &IODParams, - ) -> FullOrbitResult; -} - -impl TrajectoryFit for TrajectorySet { - fn estimate_all_orbits( - &mut self, - state: &Outfit, - rng: &mut impl Rng, - params: &IODParams, - ) -> FullOrbitResult { - // `ProgressImpl` is `IndicatifProgress` when feature=progress, `()` otherwise. - estimate_all_orbits_core( - self, - state, - rng, - params, - None:: bool>>, - ProgressImpl::default(), - ) - } - - fn estimate_all_orbits_with_cancel( - &mut self, - state: &Outfit, - rng: &mut impl Rng, - params: &IODParams, - mut should_cancel: F, - ) -> FullOrbitResult - where - F: FnMut() -> bool, - { - let cancel = CancelCfg { - interval: Duration::from_millis(20), - should_cancel: &mut should_cancel, - }; - estimate_all_orbits_core( - self, - state, - rng, - params, - Some(cancel), - ProgressImpl::default(), - ) - } - - #[cfg(feature = "parallel")] - fn estimate_all_orbits_in_batches_parallel( - &mut self, - state: &Outfit, - rng: &mut impl rand::Rng, - params: &IODParams, - ) -> FullOrbitResult { - // Draw a single base seed once; per-object seeds derived deterministically. - let base_seed: u64 = rng.random(); - - // Take the whole map so we can own/mutate Observations per object off-thread. - let mut old: TrajectorySet = mem::take(self); - let mut entries: Vec<(ObjectNumber, Observations)> = old.drain().collect(); - - let total_items = entries.len() as u64; - - // Materialize batches **by move** (no clone of Observations). - let mut batches: Vec> = - Vec::with_capacity(entries.len().div_ceil(params.batch_size.max(1))); - while !entries.is_empty() { - let take_n = entries.len().min(params.batch_size); - batches.push(entries.drain(..take_n).collect()); - } - - // Global progress bar (thread-safe) under the `progress` feature. - #[cfg(feature = "progress")] - let pb = { - use indicatif::{ProgressBar, ProgressStyle}; - let pb = ProgressBar::new(total_items.max(1)); - pb.set_style( - ProgressStyle::with_template( - "{bar:40.cyan/blue} {pos}/{len} ({percent:>3}%) \ - | {per_sec} | ETA {eta_precise} | parallel batches", - ) - .expect("indicatif template"), - ); - pb.enable_steady_tick(std::time::Duration::from_millis(200)); - pb - }; - - // Process batches in parallel; each batch processed sequentially for locality. - #[allow(clippy::type_complexity)] - let mut per_batch: Vec< - Vec<( - ObjectNumber, - Result<(GaussResult, f64), OutfitError>, - Observations, - )>, - > = batches - .into_par_iter() - .map(|mut batch| { - let mut out: Vec<( - ObjectNumber, - Result<(GaussResult, f64), OutfitError>, - Observations, - )> = Vec::with_capacity(batch.len()); - - for (obj, mut obs) in batch.drain(..) { - use rand::SeedableRng; - - let local_seed = seed_for_object(base_seed, &obj); - let mut local_rng = rand::rngs::StdRng::seed_from_u64(local_seed); - - let res = - obs.estimate_best_orbit(state, &state.error_model, &mut local_rng, params); - - // Thread-safe progress increment. - #[cfg(feature = "progress")] - pb.inc(1); - - out.push((obj, res, obs)); - } - out - }) - .collect(); - - // Finalize progress. - #[cfg(feature = "progress")] - { - pb.disable_steady_tick(); - pb.finish_and_clear(); - } - - // Reinsert mutated observations and build results map with the same hasher. - let mut results: FullOrbitResult = HashMap::with_hasher(ahash::RandomState::new()); - for batch in per_batch.drain(..) { - for (obj, res, obs) in batch { - self.insert(obj.clone(), obs); - results.insert(obj, res); - } - } - results - } - - #[inline] - fn total_observations(&self) -> usize { - self.values().map(|obs: &Observations| obs.len()).sum() - } - - #[inline] - fn number_of_trajectories(&self) -> usize { - self.len() - } - - fn obs_count_stats(&self) -> Option { - // Collect sizes (one pass, O(N)) - let mut counts: Vec = self.values().map(|obs| obs.len()).collect(); - if counts.is_empty() { - return None; - } - - // Sort once, O(N log N). `unstable` is fine since we only need order. - counts.sort_unstable(); - - #[inline] - fn q_index(n: usize, q: f64) -> usize { - // Nearest-rank on [0, n-1] using linear index; robust for small n. - let pos = q * (n as f64 - 1.0); - let idx = pos.round() as isize; - idx.clamp(0, (n as isize) - 1) as usize - } - - let n = counts.len(); - let min = counts[0]; - let max = counts[n - 1]; - let p25 = counts[q_index(n, 0.25)]; - let median = counts[q_index(n, 0.50)]; - let p95 = counts[q_index(n, 0.95)]; - - Some(ObsCountStats { - min, - p25, - median, - p95, - max, - }) - } -} - -#[cfg(test)] -#[cfg(feature = "jpl-download")] -mod tests_estimate_all_orbits { - use crate::{ - observations::Observation, unit_test_global::OUTFIT_HORIZON_TEST, KeplerianElements, - }; - - use super::*; - use approx::assert_relative_eq; - use rand::SeedableRng; - use smallvec::SmallVec; - use std::{ - f64::consts::PI, - sync::atomic::{AtomicUsize, Ordering}, - }; - - // ------------------------------- - // Test fixtures (lightweight) - // ------------------------------- - - /// Build a tiny TrajectorySet with N empty observation lists. - /// - /// Note: This assumes `TrajectorySet` is a HashMap-like structure - /// and `ObjectNumber::Int(u64)` exists. Adjust if needed. - fn make_set(n: usize) -> TrajectorySet { - let mut set: TrajectorySet = std::collections::HashMap::with_hasher(RandomState::new()); - for i in 0..n { - // If your ObjectNumber uses a different constructor, adjust here. - let key = ObjectNumber::Int(i as u32); - // If Observations is not a Vec, adapt this to your type. - let obs: Observations = Default::default(); - set.insert(key, obs); - } - set - } - - /// Dummy `Outfit` and `IODParams` for tests that do not reach the estimator. - /// - /// We never call the estimator in cancellation-first tests, so these values - /// are placeholders to satisfy the function signatures. - fn dummy_env() -> (Outfit, IODParams) { - let env = OUTFIT_HORIZON_TEST.0.clone(); - let params = IODParams::builder() - .n_noise_realizations(10) - .noise_scale(1.0) - .max_obs_for_triplets(12) - .max_triplets(30) - .build() - .unwrap(); - (env, params) - } - - // ------------------------------- - // Unit tests: cancellation logic - // ------------------------------- - - /// Cancellation fires before the first object is processed: result should be empty. - /// - /// This test calls the factorized core with `interval = 0 ms` and a callback - /// that immediately requests cancellation. The estimator is never invoked. - #[test] - fn core_cancel_before_any_work() { - let mut set = make_set(5); - let (env, params) = dummy_env(); - let mut rng = rand::rngs::StdRng::seed_from_u64(42); - - // Cancel immediately on the very first poll. - let mut cancel_called = 0usize; - let mut should_cancel = || { - cancel_called += 1; - true - }; - - let cancel = CancelCfg { - interval: Duration::from_millis(0), - should_cancel: &mut should_cancel, - }; - - // Use no-op progress sink: works with or without the `progress` feature. - let results = estimate_all_orbits_core(&mut set, &env, &mut rng, ¶ms, Some(cancel), ()); - - assert!(results.is_empty(), "No object should have been processed"); - assert!( - cancel_called >= 1, - "Cancellation should have been polled at least once" - ); - } - - /// Cancellation after exactly one iteration: we expect exactly one entry in the map. - /// - /// IMPORTANT: This test *may* reach the estimator if the cancellation poll - /// happens after the first object. We therefore keep the set size to 1 so - /// we never process more than one. If your estimator requires real env/params, - /// mark this test as `#[ignore]` until you wire a small valid fixture. - #[test] - fn core_cancel_after_one_object() { - let mut set = make_set(2); - let (env, params) = dummy_env(); - let mut rng = rand::rngs::StdRng::seed_from_u64(123); - - let polls = AtomicUsize::new(0); - // First poll = false (let the first object run), next polls = true. - let mut should_cancel = || { - let c = polls.fetch_add(1, Ordering::Relaxed); - c >= 1 - }; - - let cancel = CancelCfg { - interval: Duration::from_millis(0), // poll at every loop entry - should_cancel: &mut should_cancel, - }; - - let results = estimate_all_orbits_core(&mut set, &env, &mut rng, ¶ms, Some(cancel), ()); - - assert_eq!( - results.len(), - 1, - "Exactly one object should have been processed before cancel" - ); - } - - // ------------------------------- - // Unit tests: progress plumbing - // ------------------------------- - - /// Mock progress sink to observe lifecycle calls. - #[derive(Default)] - struct MockProgress { - started_with: Option, - it_calls: usize, - inc_calls: usize, - interrupted: bool, - finished: bool, - } - - impl ProgressSink for MockProgress { - fn start(&mut self, total: u64) { - self.started_with = Some(total); - } - fn on_iter(&mut self) { - self.it_calls += 1; - } - fn inc(&mut self) { - self.inc_calls += 1; - } - fn on_interrupt(&mut self) { - self.interrupted = true; - } - fn finish(&mut self) { - self.finished = true; - } - } - - /// Progress sink should receive `start`, `on_interrupt`, and `finish` when cancelling before work. - #[test] - fn progress_calls_when_cancelled_immediately() { - let mut set = make_set(3); - let (env, params) = dummy_env(); - let mut rng = rand::rngs::StdRng::seed_from_u64(7); - - let mut should_cancel = || true; - let cancel = CancelCfg { - interval: Duration::from_millis(0), - should_cancel: &mut should_cancel, - }; - - let mut mock = MockProgress::default(); - let results = - estimate_all_orbits_core(&mut set, &env, &mut rng, ¶ms, Some(cancel), &mut mock); - - assert!(results.is_empty()); - assert_eq!(mock.started_with, Some(3)); - assert!(mock.interrupted, "on_interrupt() must be called"); - assert!(mock.finished, "finish() must be called"); - // No iteration advanced, so no inc() and on_iter() expected. - assert_eq!(mock.it_calls, 0); - assert_eq!(mock.inc_calls, 0); - } - - // ------------------------------- - // Integration tests - // ------------------------------- - - #[inline] - fn angle_abs_diff(a: f64, b: f64) -> f64 { - let tau = 2.0 * PI; - let mut d = (a - b) % tau; - if d > PI { - d -= tau; - } - if d < -PI { - d += tau; - } - d.abs() - } - - pub fn assert_keplerian_approx_eq( - got: &KeplerianElements, - exp: &KeplerianElements, - abs_eps: f64, - rel_eps: f64, - ) { - // Scalars (non-angular) - assert_relative_eq!( - got.reference_epoch, - exp.reference_epoch, - epsilon = abs_eps, - max_relative = rel_eps - ); - assert_relative_eq!( - got.semi_major_axis, - exp.semi_major_axis, - epsilon = abs_eps, - max_relative = rel_eps - ); - assert_relative_eq!( - got.eccentricity, - exp.eccentricity, - epsilon = abs_eps, - max_relative = rel_eps - ); - - // Angles (radians), compare with wrap-around - for (name, g, e) in [ - ("inclination", got.inclination, exp.inclination), - ( - "ascending_node_longitude", - got.ascending_node_longitude, - exp.ascending_node_longitude, - ), - ( - "periapsis_argument", - got.periapsis_argument, - exp.periapsis_argument, - ), - ("mean_anomaly", got.mean_anomaly, exp.mean_anomaly), - ] { - let diff = angle_abs_diff(g, e); - // Allow absolute OR relative tolerance (whichever is larger). - let tol = abs_eps.max(rel_eps * e.abs()); - assert!( - diff <= tol, - "Angle {name:?} differs too much: |Δ| = {diff:.6e} > tol {tol:.6e} (got={g:.15}, exp={e:.15})" - ); - } - } - - /// Estimate on an empty-observation set should return one entry per object with errors. - #[test] - fn public_no_progress_runs_all_objects() { - let mut set = OUTFIT_HORIZON_TEST.1.clone(); - - // TODO: replace with real constructors in your codebase: - let (env, params) = dummy_env(); - let mut rng = rand::rngs::StdRng::seed_from_u64(777); - - use super::TrajectoryFit; - let results = set.estimate_all_orbits(&env, &mut rng, ¶ms); - - let string_id = "K09R05F"; - let orbit = gauss_result_for(&results, &string_id.into()) - .unwrap() - .unwrap() - .0 - .as_inner() - .as_keplerian() - .unwrap(); - - let expected = KeplerianElements { - reference_epoch: 57049.25533417104, - semi_major_axis: 1.8017448718161189, - eccentricity: 0.283572382702194, - inclination: 0.2026747553253312, - ascending_node_longitude: 0.0079836299943183, - periapsis_argument: 1.245049339166438, - mean_anomaly: 0.4406946018418537, - }; - - assert_keplerian_approx_eq(orbit, &expected, 1e-6, 1e-6); - } - - /// Public cancellation API should return a partial map when cancelling quickly. - #[test] - fn public_with_cancel_returns_partial() { - let mut set = OUTFIT_HORIZON_TEST.1.clone(); - - // TODO: replace with real constructors in your codebase: - let (env, params) = dummy_env(); - let mut rng = rand::rngs::StdRng::seed_from_u64(42); - - use super::TrajectoryFit; - // Callback cancels immediately; public API polls every ~20ms. - // Depending on estimator speed, a few items may slip in before first poll. - let results = set.estimate_all_orbits_with_cancel(&env, &mut rng, ¶ms, || true); - - assert!( - results.len() <= 50, - "Result map cannot exceed the number of objects" - ); - assert!( - !results.is_empty(), - "Depending on timing, a few items may be processed before first poll" - ); - } - - // ------------------------------- - // Accessor helpers tests - // ------------------------------- - - /// `gauss_result_for` should distinguish: missing key, error entry, ok entry. - #[test] - fn gauss_accessors_err_and_missing() { - let mut all: FullOrbitResult = HashMap::with_hasher(RandomState::new()); - let k1 = ObjectNumber::Int(1); - let k2 = ObjectNumber::Int(2); - - // Insert an error entry for k1. Construct an OutfitError if you have a cheap variant. - // If construction is non-trivial, you can skip inserting and just test "missing". - all.insert( - k1.clone(), - Err(OutfitError::InvalidIODParameter("test".into())), - ); - - // Missing key: - assert!(matches!(gauss_result_for(&all, &k2), Ok(None))); - - // Error key: - match gauss_result_for(&all, &k1) { - Err(e) => { - // Just check we got *some* error reference back. - let _ = format!("{e}"); - } - other => panic!("expected Err(&OutfitError), got {other:?}"), - } - - // Take on missing: - assert!(matches!(take_gauss_result(&mut all, &k2), Ok(None))); - - // Take on error: - match take_gauss_result(&mut all, &k1) { - Err(e) => { - let _ = format!("{e}"); - } - other => panic!("expected Err(OutfitError), got {other:?}"), - } - } - - /// Stats over per-trajectory observation counts. - #[test] - fn obs_count_stats_basic() { - use std::collections::HashMap; - - // Helper: build a dummy Observation for tests only. - #[inline] - fn dummy_observation() -> Observation { - // SAFETY (tests only): - // This assumes `Observation` is plain-old-data (floats, ints) and `Copy`, - // i.e. no heap-owned fields (String, Vec, Arc, etc.) and no Drop. - // Si ce n’est pas vrai dans ton code, remplace cette fonction par - // un vrai constructeur de test qui remplit des champs plausibles. - assert_is_copy::(); - unsafe { std::mem::MaybeUninit::::zeroed().assume_init() } - } - - // Compile-time check: force `Observation: Copy` pour que le zero-init soit sûr. - #[inline(always)] - fn assert_is_copy() {} - - // Cas vide. - let set = make_set(0); - assert!(set.obs_count_stats().is_none(), "Empty set → None"); - - // Build uneven counts: 2, 4, 8, 16, 16 - let mut set: TrajectorySet = HashMap::with_hasher(RandomState::new()); - - let mut push_n = |id: u32, n: usize| { - let mut v: Observations = SmallVec::with_capacity(n); - for _ in 0..n { - v.push(dummy_observation()); - } - set.insert(ObjectNumber::Int(id), v); - }; - - push_n(1, 2); - push_n(2, 4); - push_n(3, 8); - push_n(4, 16); - push_n(5, 16); - - let stats = set.obs_count_stats().expect("non-empty"); - assert_eq!(stats.min, 2); - assert_eq!(stats.max, 16); - assert_eq!(stats.median, 8); - assert_eq!(stats.p25, 4); - assert_eq!(stats.p95, 16); - } - - #[cfg(test)] - #[cfg(feature = "parallel")] - mod tests_estimate_orbit_parallel_batches { - use super::*; - use ahash::RandomState; - use rand::SeedableRng; - - // Reuse helpers and fixtures style from your existing tests. - // If these are private in another module, duplicate minimal versions here. - - /// Build a tiny TrajectorySet with N empty observation lists. - /// - /// Note: This assumes `TrajectorySet` is a HashMap-like structure - /// and `ObjectNumber::Int(u32)` exists. Adjust if needed. - fn make_set(n: usize) -> TrajectorySet { - let mut set: TrajectorySet = std::collections::HashMap::with_hasher(RandomState::new()); - for i in 0..n { - set.insert(ObjectNumber::Int(i as u32), Default::default()); - } - set - } - - #[inline] - fn total_obs(set: &TrajectorySet) -> usize { - set.values().map(|v: &Observations| v.len()).sum() - } - - // ------------------------------- - // Unit tests: basic shape/edges - // ------------------------------- - - /// Parallel-batched IOD over an empty set should return an empty map. - #[test] - fn parallel_batches_empty_set_is_empty() { - let mut set = make_set(0); - - // Dummy env/params: estimator is never reached for empty set. - // If you need to compile without jpl, use the same `dummy_env()` strategy as your seq tests. - let env = dummy_env().0; - let params = IODParams::builder().batch_size(1024).build().unwrap(); - - let mut rng = rand::rngs::StdRng::seed_from_u64(1); - let results = set.estimate_all_orbits_in_batches_parallel(&env, &mut rng, ¶ms); - assert!(results.is_empty(), "Empty input → empty results"); - assert_eq!(set.len(), 0, "Set remains empty"); - } - - /// Batch-size boundaries (1 and very large) should produce exactly one entry per object. - /// - /// We don't assert on Ok/Err, only that every object was processed and observations were reintegrated. - #[test] - fn parallel_batches_size_edges_cover_all_objects() { - for &batch_size in &[1usize, 10_000usize] { - let mut set = make_set(7); - - // Build a dummy env that lets the code run without panicking even if estimator errs. - // The estimator may return Err for empty observations — it's fine for this test. - let env = dummy_env().0; - let params = IODParams::builder().batch_size(batch_size).build().unwrap(); - - let before_n = set.len(); - let before_tot = total_obs(&set); - - let mut rng = rand::rngs::StdRng::seed_from_u64(42); - let results = set.estimate_all_orbits_in_batches_parallel(&env, &mut rng, ¶ms); - - assert_eq!(results.len(), before_n, "Exactly one entry per object"); - assert_eq!(set.len(), before_n, "All objects reinserted in set"); - assert_eq!( - total_obs(&set), - before_tot, - "Total number of observations is preserved (reorder/calibration only)" - ); - } - } - - /// Using the same input and RNG seed must be deterministic across runs, regardless of scheduling. - /// This checks **one specific object** for identical formatted outcome (Ok/Err shape and RMS). - #[test] - fn parallel_batches_deterministic_across_runs_with_same_seed() { - // Small synthetic set; estimator likely returns Err for empty observations. - // Determinism check focuses on the *presence* and *shape* of results. - let build_set = || make_set(5); - let env = dummy_env().0; - let params = IODParams::builder().batch_size(2).build().unwrap(); - - let key = ObjectNumber::Int(2); - - let run_once = |seed: u64| { - let mut set = build_set(); - let mut rng = rand::rngs::StdRng::seed_from_u64(seed); - let results = set.estimate_all_orbits_in_batches_parallel(&env, &mut rng, ¶ms); - // Record a stable string representation for that key. - match results.get(&key) { - None => "None".to_string(), - Some(Ok((_g, rms))) => format!("Ok rms={rms:.12e}"), - Some(Err(e)) => format!("Err: {e}"), - } - }; - - let a = run_once(0xDEADBEEF); - let b = run_once(0xDEADBEEF); - assert_eq!(a, b, "Same seed/input → identical outcome formatting"); - } - - // ------------------------------- - // Integration tests with JPL env - // ------------------------------- - - mod with_ephem { - use super::*; - use approx::assert_relative_eq; - - use crate::unit_test_global::OUTFIT_HORIZON_TEST; - - /// Parallel batched results should match the known-good orbit (as in the sequential test). - #[test] - fn parallel_batches_return_orbit() { - // Use the same fixture you use elsewhere. - let mut set = OUTFIT_HORIZON_TEST.1.clone(); - let (env, params) = { - // If you have a helper `dummy_env()` in the seq tests, keep the same one: - let env = OUTFIT_HORIZON_TEST.0.clone(); - let params = IODParams::builder() - .n_noise_realizations(10) - .noise_scale(1.0) - .max_obs_for_triplets(12) - .max_triplets(30) - .batch_size(1) - .build() - .unwrap(); - (env, params) - }; - let mut rng = rand::rngs::StdRng::seed_from_u64(42); - - // Choose a batch size that is neither 1 nor huge to exercise chunking logic. - let results = set.estimate_all_orbits_in_batches_parallel(&env, &mut rng, ¶ms); - - // Same canonical object as in your sequential test: - let string_id = "K09R05F"; - let orbit = gauss_result_for(&results, &string_id.into()); - - assert!(orbit.is_ok(), "Result entry should be Ok"); - } - - /// Parallel batched determinism across different batch sizes. - /// - /// With same RNG seed + inputs, changing only `batch_size` should not affect results. - #[test] - fn parallel_batches_results_independent_of_batch_size() { - let mut set1 = OUTFIT_HORIZON_TEST.1.clone(); - let mut set2 = OUTFIT_HORIZON_TEST.1.clone(); - let (env, params) = { - let env = OUTFIT_HORIZON_TEST.0.clone(); - let params = IODParams::builder() - .n_noise_realizations(10) - .noise_scale(1.0) - .max_obs_for_triplets(12) - .max_triplets(30) - .batch_size(64) - .build() - .unwrap(); - (env, params) - }; - - let seed = 0xABCDEF0123456789; - let mut rng1 = rand::rngs::StdRng::seed_from_u64(seed); - let mut rng2 = rand::rngs::StdRng::seed_from_u64(seed); - - let res1 = set1.estimate_all_orbits_in_batches_parallel(&env, &mut rng1, ¶ms); - - let params2 = IODParams { - batch_size: 4096, - ..params.clone() - }; - - let res2 = set2.estimate_all_orbits_in_batches_parallel(&env, &mut rng2, ¶ms2); - - // Compare a known object Keplerian solution (same as above). - let key = "K09R05F".into(); - let k1 = gauss_result_for(&res1, &key) - .unwrap() - .unwrap() - .0 - .as_inner() - .as_keplerian() - .unwrap(); - let k2 = gauss_result_for(&res2, &key) - .unwrap() - .unwrap() - .0 - .as_inner() - .as_keplerian() - .unwrap(); - - // Tight numerical equality (same seed → same triplet noise → identical orbit). - assert_relative_eq!(k1.reference_epoch, k2.reference_epoch, epsilon = 0.0); - assert_relative_eq!(k1.semi_major_axis, k2.semi_major_axis, epsilon = 0.0); - assert_relative_eq!(k1.eccentricity, k2.eccentricity, epsilon = 0.0); - assert_relative_eq!(k1.inclination, k2.inclination, epsilon = 0.0); - assert_relative_eq!( - k1.ascending_node_longitude, - k2.ascending_node_longitude, - epsilon = 0.0 - ); - assert_relative_eq!(k1.periapsis_argument, k2.periapsis_argument, epsilon = 0.0); - assert_relative_eq!(k1.mean_anomaly, k2.mean_anomaly, epsilon = 0.0); - } - } - } -} diff --git a/src/trajectory.rs b/src/trajectory.rs new file mode 100644 index 0000000..54151dc --- /dev/null +++ b/src/trajectory.rs @@ -0,0 +1,847 @@ +//! Core IOD pipeline trait over sorted observation slices. +//! +//! This module defines [`TrajectoryFit`], a `pub(crate)` trait implemented on +//! `Vec<&Observation>`. It encapsulates the sequential stages of the Gauss +//! Initial Orbit Determination pipeline: +//! +//! 1. **Triplet generation** — [`TrajectoryFit::compute_triplets`] selects the +//! best-K observation triplets using a time-windowed enumeration and a +//! bounded max-heap scorer. +//! 2. **RMS interval selection** — [`TrajectoryFit::select_rms_interval`] +//! determines the observation arc over which the residual quality metric is +//! evaluated. +//! 3. **RMS evaluation** — [`TrajectoryFit::rms_orbit_error`] measures how +//! well a candidate orbit reproduces the arc's astrometry. +//! 4. **Orbit selection** — [`TrajectoryFit::estimate_best_orbit`] drives the +//! full Monte-Carlo noise loop, combining triplets, Gauss solutions, and RMS +//! scoring to return the best preliminary orbit. +//! +//! The public entry points are [`crate::obs_dataset::FitIOD::fit_iod`] and +//! [`crate::obs_dataset::FitIOD::fit_full_iod`], which call +//! [`TrajectoryFit::estimate_best_orbit`] after building the shared cache. + +use std::ops::ControlFlow; + +use nalgebra::Vector3; +use photom::{observation_dataset::observation::Observation, Radians}; + +use crate::{ + cache::OutfitCache, + constants::FitOrbitResult, + ephemeris::observation_ephemeris::ObservationEphemeris, + initial_orbit_determination::{gauss::GaussObs, triplet_generation::generate_triplets}, + EquinoctialElements, GaussResult, IODParams, JPLEphem, OutfitError, +}; + +pub(crate) trait TrajectoryFit { + /// Extract astrometric uncertainties (RA and DEC) for a set of three observations. + /// + /// Given a triplet of observation indices, this function retrieves the corresponding + /// astrometric errors in right ascension and declination from the observation set. + /// + /// # Arguments + /// + /// - `idx_obs` - A vector of three indices referring to the observations used in the triplet. + /// + /// # Returns + /// + /// - A tuple of two `Vector3`: + /// - The first vector contains the RA uncertainties in radians. + /// - The second vector contains the DEC uncertainties in radians. + /// + /// # Panics + /// + /// This function will panic if any index in `idx_obs` is out of bounds of the observation set. + fn extract_errors(&self, idx_obs: Vector3) -> (Vector3, Vector3); + + /// Compute **time-feasible, best-K** triplets of observations for Gauss IOD, + /// leveraging a lazy **index stream** and a bounded **max-heap** on spacing weight. + /// + /// Overview + /// ----------------- + /// This method is a convenience wrapper around [`generate_triplets`]. It operates + /// directly on `self` (the current observation set) and returns up to `max_triplet` + /// **best-scored** candidates for the Gauss preliminary solution. Internally it: + /// + /// 1) Uses a `TripletIndexGenerator` that: + /// - sorts epochs in place, + /// - downsamples to at most `max_obs_for_triplets` (uniform with edges), + /// - lazily **streams reduced indices** `(first, middle, last)` constrained by: + /// `dt_min ≤ t[last] − t[first] ≤ dt_max`. + /// 2) Scores each feasible triplet with [`triplet_weight`](crate::observations::triplets_iod::triplet_weight) against `optimal_interval_time`. + /// 3) Keeps only the **K** smallest weights in a bounded **max-heap** (best-K selection). + /// 4) Materializes the survivors as [`GaussObs`] by (re)borrowing `self` immutably. + /// + /// Compared to brute-force `O(n³)`, the time-windowed enumeration drives the effective + /// cost toward ~`O(n²)` in typical time distributions, plus `O(n log K)` for heap updates. + /// + /// Arguments + /// ----------------- + /// * `dt_min` – Minimum allowed timespan `[same units as Observation::time]` between the first and last epoch of a triplet. + /// * `dt_max` – Maximum allowed timespan between the first and last epoch of a triplet. + /// * `optimal_interval_time` – Target per-gap spacing (e.g., days) used by [`triplet_weight`](crate::observations::triplets_iod::triplet_weight). + /// * `max_obs_for_triplets` – Upper bound on observations kept after downsampling (uniform with endpoints). + /// * `max_triplet` – Number `K` of best-scoring triplets to return. + /// + /// Return + /// ---------- + /// * A `Vec` of length `≤ max_triplet`, **sorted by increasing weight** + /// (best geometric spacing first), ready to be passed to `GaussObs::prelim_orbit`. + /// + /// Remarks + /// ------------- + /// * Sorting is **in-place**; call sites should not rely on original ordering afterward. + /// * The generator avoids overlapping borrows of `self`; only the final K triplets are materialized. + /// * For robustness studies, each returned triplet can be expanded with + /// `GaussObs::realizations_iter` (lazy Monte-Carlo noise). + /// + /// Complexity + /// ----------------- + /// * Enumeration: ~`O(n²)` (per-anchor time window). + /// * Selection: `O(n log K)` (bounded max-heap). + /// * Space: `O(1)` per yielded candidate; only K triplets are allocated at the end. + /// + /// See also + /// ------------ + /// * [`generate_triplets`] – Low-level function performing the selection (index stream + heap + materialization). + /// * [`TripletIndexGenerator`](crate::observations::triplets_generator::TripletIndexGenerator) – Lazy stream of reduced indices constrained by `(dt_min, dt_max)`. + /// * [`triplet_weight`](crate::observations::triplets_iod::triplet_weight) – Spacing heuristic around `optimal_interval_time`. + /// * [`GaussObs::realizations_iter`] – On-the-fly noisy realizations for a given triplet. + fn compute_triplets(&self, cache: &OutfitCache, params: &IODParams) -> Vec; + + /// Select the interval of observations for RMS calculation. + /// + /// This function selects the interval of observations for RMS calculation based on the provided triplet. + /// It computes the maximum allowed interval and finds the start and end indices of the observations + /// within that interval. + /// + /// Arguments + /// --------- + /// * `triplets`: A reference to a `GaussObs` representing the triplet of observations. + /// * `extf`: A `f64` representing the external factor for the interval calculation. + /// * `dtmax`: A `f64` representing the maximum allowed interval. + /// + /// Return + /// ------ + /// * A `Result` containing a tuple of start and end indices of the observations within the interval, + /// or an `OutfitError` if an error occurs. + fn select_rms_interval( + &self, + triplets: &GaussObs, + params: &IODParams, + ) -> Result<(usize, usize), OutfitError>; + + /// Evaluate the orbit quality by computing the RMS of normalized astrometric residuals + /// over a time window centered on a Gauss triplet. + /// + /// Scientific context + /// ------------------- + /// This function measures how well a preliminary orbit reproduces the observed + /// astrometry (RA, DEC). It computes the **root-mean-square (RMS)** of the + /// normalized residuals between predicted and observed positions, aggregated over + /// a set of observations surrounding a Gauss triplet. + /// + /// Interval selection + /// ------------------- + /// The observation arc is defined by: + /// * `extf` – fractional extension factor applied around the triplet center, + /// * `dtmax` – absolute maximum time span (days) allowed for the arc. + /// + /// The effective interval is determined by + /// [`select_rms_interval`](Self::select_rms_interval), which returns the first + /// and last indices of the observations to include. + /// + /// Computation + /// ------------ + /// * Each observation contributes a squared normalized residual + /// from [`Observation::ephemeris_error`](crate::observations::Observation::ephemeris_error). + /// * The final RMS is + /// + /// ```text + /// RMS = √[ (1 / (2N)) · Σᵢ (ΔRAᵢ² + ΔDECᵢ²) ] + /// ``` + /// + /// where `N` is the number of observations in the selected interval. + /// + /// Pruning mode + /// ------------ + /// If `prune_if_rms_ge` is set: + /// * The summation stops early once the partial RMS reaches the threshold, + /// returning the pruning value directly. + /// * If `prune_if_rms_ge = ∞`, no early exit occurs (equivalent to no pruning). + /// + /// Arguments + /// ---------- + /// * `state` – Global context providing ephemerides, Earth orientation, and time conversion. + /// * `triplets` – The Gauss triplet that defined the preliminary orbit. + /// * `orbit_element` – The orbit (in equinoctial elements) to be tested against the arc. + /// * `extf` – Fractional time extension of the interval around the triplet. + /// * `dtmax` – Maximum arc duration (days). + /// * `prune_if_rms_ge` – Optional RMS cutoff for early termination (see *Pruning mode*). + /// + /// Return + /// ------- + /// * `Ok(rms)` – RMS of the normalized astrometric residuals (radians). + /// * `Err(OutfitError)` – If interval selection fails or propagation/ephemeris lookup fails. + /// + /// Units + /// ------- + /// * The returned RMS is dimensionless but expressed in **radians**. + fn rms_orbit_error( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + triplets: &GaussObs, + orbit_element: &EquinoctialElements, + params: &IODParams, + prune_if_rms_ge: Option, + ) -> Result; + + /// Estimate the best-fitting preliminary orbit from a full set of astrometric observations. + /// + /// This method searches for the best preliminary orbit by evaluating a limited number of + /// observation triplets generated from the dataset. The process includes: + /// + /// 1. **Error calibration**: + /// Observations are first preprocessed with [`ObservationsExt::apply_batch_rms_correction`] to account for + /// temporal clustering and observer-specific error models. + /// + /// 2. **Triplet generation**: + /// Candidate triplets are generated using [`ObservationsExt::compute_triplets`], which: + /// * Sorts observations by time, + /// * Optionally downsamples the dataset to at most `params.max_obs_for_triplets` points + /// (uniform in time, always keeping the first and last), + /// * Filters valid triplets according to `params.dt_min`, `params.dt_max_triplet`, + /// and `params.optimal_interval_time`. + /// + /// 3. **Monte Carlo noise sampling**: + /// For each triplet, `params.n_noise_realizations` perturbed versions are created using + /// Gaussian noise scaled by `params.noise_scale` times the nominal astrometric uncertainties. + /// + /// 4. **Orbit estimation and selection**: + /// For each (possibly perturbed) triplet, a preliminary orbit is computed with the Gauss method. + /// The resulting orbit is evaluated over the full set of observations using [`ObservationsExt::rms_orbit_error`]. + /// The orbit with the smallest RMS is returned. + /// + /// # Arguments + /// + /// * `state` – + /// Global [`Outfit`] state, providing ephemerides and time conversions. + /// * `error_model` – + /// The astrometric error model (typically per-band or per-observatory). + /// * `rng` – + /// A random number generator used to draw Gaussian perturbations. + /// * `params` – + /// Parameters controlling the initial orbit determination, including: + /// * `n_noise_realizations`: number of noisy triplet variants generated per original triplet, + /// * `noise_scale`: scaling factor for the noise, + /// * `extf`: extrapolation factor for RMS evaluation, + /// * `dtmax`: maximum time interval for RMS evaluation, + /// * `dt_min`, `dt_max_triplet`, `optimal_interval_time`: constraints on triplet spans, + /// * `max_obs_for_triplets`: maximum number of observations to keep when building triplets, + /// * `max_triplets`: maximum number of triplets to process, + /// * `gap_max`: maximum allowed time gap within a batch for RMS corrections. + /// + /// # Returns + /// + /// * `Ok((Some(best_orbit), best_rms))` – The best preliminary orbit found and its RMS. + /// * `Ok((None, f64::MAX))` – No valid orbit could be estimated. + /// * `Err(e)` – An error occurred during orbit estimation or RMS evaluation. + /// + /// # Notes + /// + /// - RMS values are computed with [`ObservationsExt::rms_orbit_error`], which accounts for + /// light-time correction and ephemeris propagation. + /// - Each triplet can produce several preliminary orbit candidates due to + /// noise realizations. + /// - The `max_obs_for_triplets` parameter is crucial for large datasets, + /// as it avoids the combinatorial explosion of triplets. + /// + /// # See also + /// + /// * [`ObservationsExt::compute_triplets`] – Selects triplets from the observation set. + /// * [`GaussObs::generate_noisy_realizations`] – Creates perturbed triplets with Gaussian noise. + /// * [`GaussObs::prelim_orbit`] – Computes a preliminary orbit from a single triplet. + /// * [`ObservationsExt::rms_orbit_error`] – Measures the goodness-of-fit of an orbit against observations. + /// * [`IODParams`] – Configuration options for the IOD process. + fn estimate_best_orbit( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + params: &IODParams, + rng: &mut impl rand::Rng, + ) -> Result; +} + +impl TrajectoryFit for Vec<&Observation> { + fn extract_errors(&self, idx_obs: Vector3) -> (Vector3, Vector3) { + let [i, j, k] = [idx_obs[0], idx_obs[1], idx_obs[2]]; + let [c1, c2, c3] = [ + self[i].equ_coord(), + self[j].equ_coord(), + self[k].equ_coord(), + ]; + ( + Vector3::new(c1.ra_error, c2.ra_error, c3.ra_error), + Vector3::new(c1.dec_error, c2.dec_error, c3.dec_error), + ) + } + + fn compute_triplets(&self, cache: &OutfitCache, params: &IODParams) -> Vec { + generate_triplets(self, cache, params) + } + + fn select_rms_interval( + &self, + triplets: &GaussObs, + params: &IODParams, + ) -> Result<(usize, usize), OutfitError> { + let nobs = self.len(); + + let idx_obs1 = triplets.idx_obs[0]; + let obs1 = self + .get(idx_obs1) + .ok_or(OutfitError::ObservationNotFound(idx_obs1))?; + + let idx_obs3 = triplets.idx_obs[2]; + let obs3 = self + .get(idx_obs3) + .ok_or(OutfitError::ObservationNotFound(idx_obs3))?; + + let first_obs = self.first().ok_or(OutfitError::ObservationNotFound(0))?; + let last_obs = self + .last() + .ok_or(OutfitError::ObservationNotFound(nobs - 1))?; + + // Step 1: Compute the maximum allowed interval + let mut dt = if params.extf >= 0.0 { + (obs3.mjd_tt() - obs1.mjd_tt()) * params.extf + } else { + 10.0 * (last_obs.mjd_tt() - first_obs.mjd_tt()) + }; + + if params.dtmax >= 0.0 { + dt = dt.max(params.dtmax); + } + + let mut i_start = 0; + + for i in (0..=idx_obs1).rev() { + if let Some(obs_i) = self.get(i) { + if obs1.mjd_tt() - obs_i.mjd_tt() > dt { + break; + } + i_start = i; + } + } + + let mut i_end = nobs - 1; + + for i in idx_obs3..nobs { + if let Some(obs_i) = self.get(i) { + if obs_i.mjd_tt() - obs3.mjd_tt() > dt { + break; + } + i_end = i; + } + } + + Ok((i_start, i_end)) + } + + fn rms_orbit_error( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + triplets: &GaussObs, + orbit_element: &EquinoctialElements, + params: &IODParams, + prune_if_rms_ge: Option, + ) -> Result { + // Select the time interval [start_obs_rms, end_obs_rms] over which the RMS + // error is evaluated. The interval depends on the triplet and on external + // filtering parameters (extf, dtmax). + let (start_obs_rms, end_obs_rms) = self.select_rms_interval(triplets, params)?; + + // Number of observations contributing to the RMS + let n_obs = (end_obs_rms - start_obs_rms + 1) as f64; + + // Denominator of the RMS formula: here weighted by 2.0 for consistency + // with the convention used elsewhere in the code. + let denom = 2.0 * n_obs; + + // ========================================================================= + // Case 1: No pruning → behave like the "classical" RMS definition + // ========================================================================= + if prune_if_rms_ge.is_none() { + // Accumulate the squared ephemeris errors for each observation + let sum = self[start_obs_rms..=end_obs_rms] + .iter() + .map(|obs| obs.ephemeris_error(cache, jpl, orbit_element)) + // try_fold propagates errors from ephemeris_error while summing + .try_fold(0.0, |acc, term| term.map(|v| acc + v))?; + + // Final RMS = sqrt( sum / denom ) + return Ok((sum / denom).sqrt()); + } + + // ========================================================================= + // Case 2: Pruning enabled → early stop if RMS exceeds a threshold + // ========================================================================= + let prune = prune_if_rms_ge.unwrap(); + + // Convert the RMS cutoff into a sum cutoff: + // RMS² = sum / denom → stop if sum ≥ (prune² * denom). + let sum_cutoff = if prune.is_finite() { + prune * prune * denom + } else { + f64::INFINITY // "no real cutoff" if prune = ∞ + }; + + // Iterate over observations and accumulate squared errors. + // We use ControlFlow to allow early exit: + // - Continue(sum): keep summing, + // - Break(value): stop early and return the pruning threshold. + let folded: ControlFlow = self[start_obs_rms..=end_obs_rms] + .iter() + .map(|obs| obs.ephemeris_error(cache, jpl, orbit_element)) + .try_fold(0.0, |acc, term| match term { + Ok(v) => { + let new_sum = acc + v; + if new_sum >= sum_cutoff { + // Early exit: threshold reached, return directly + ControlFlow::Break(prune) + } else { + ControlFlow::Continue(new_sum) + } + } + // In case of error in ephemeris_error, also exit with pruning value. + Err(_) => ControlFlow::Break(prune), + }); + + // Final RMS depending on whether we exited early or not + match folded { + ControlFlow::Continue(sum) => Ok((sum / denom).sqrt()), + ControlFlow::Break(rms) => Ok(rms), + } + } + + fn estimate_best_orbit( + &self, + cache: &OutfitCache, + jpl: &JPLEphem, + params: &IODParams, + rng: &mut impl rand::Rng, + ) -> Result { + // Compute candidate triplets for preliminary orbit estimation, sorted by geometric spacing. + let triplets = self.compute_triplets(cache, params); + + if triplets.is_empty() { + let span = if self.is_empty() { + 0.0 + } else { + self.last().unwrap().mjd_tt() - self.first().unwrap().mjd_tt() + }; + return Err(OutfitError::NoFeasibleTriplets { + span, + n_obs: self.len(), + dt_min: params.dt_min, + dt_max: params.dt_max_triplet, + }); + } + + // Current best (lowest) RMS and its orbit. + // Using +∞ avoids Option branching in the hot path. + let mut best_rms = f64::INFINITY; + let mut best_orbit: Option = None; + + // Keep the last encountered error so that we can report something meaningful if *all* fail. + // We don't clone: we keep only the most recent error by moving it in. + let mut last_error: Option = None; + + // For diagnostics, count how many realizations we actually attempted. + let mut n_attempts: usize = 0; + + for triplet in triplets { + // Extract 1-σ astrometric uncertainties for the three obs of this triplet. + let (error_ra, error_dec) = self.extract_errors(triplet.idx_obs); + + // For each (lazy) noisy realization of this triplet... + // The iterator yields the original triplet first, then noisy copies. + for realization in triplet.realizations_iter( + &error_ra, + &error_dec, + params.n_noise_realizations, + params.noise_scale, + rng, + ) { + n_attempts += 1; + + // 4.a) Preliminary Gauss solution on the current realization. + let gauss_res = match realization.prelim_orbit(params) { + Ok(res) => res, + Err(e) => { + // Record the failure and continue exploring. + last_error = Some(e); + continue; + } + }; + + // 4.b) Convert to the element set required by the scorer. + let equinoctial_elements = gauss_res + .get_orbit() + .to_equinoctial()? + .as_equinoctial() + .ok_or(OutfitError::InvalidConversion( + "Conversion to equinoctial elements failed".to_string(), + ))?; + + // 4.c) Score orbit vs. full observation set (RMS residual). + let rms = match self.rms_orbit_error( + cache, + jpl, + &realization, + &equinoctial_elements, + params, + Some(best_rms), + ) { + Ok(v) => { + if !v.is_finite() { + last_error = Some(OutfitError::NonFiniteScore(v)); + continue; + } else { + v + } + } + Err(e) => { + last_error = Some(e); + continue; + } + }; + + // 4.d) Keep the best candidate so far. + if rms < best_rms { + best_rms = rms; + best_orbit = Some(gauss_res); + } + } + } + + // If at least one candidate succeeded, return the best; otherwise, propagate an error. + if let Some(orbit) = best_orbit { + Ok(FitOrbitResult::IODGauss((orbit, best_rms))) + } else { + // If nothing succeeded, propagate a structured error with the last underlying cause. + // Fallback to a domain-specific unit error if we never captured any (e.g., no attempts). + let root_cause = match last_error { + Some(e) => e, + None => panic!("In estimate_best_orbit: no error captured but best_orbit is None, this should not happen"), + }; + Err(OutfitError::NoViableOrbit { + cause: Box::new(root_cause), + attempts: n_attempts, + }) + } + } +} + +#[cfg(test)] +mod test_obs_ext { + use nalgebra::Matrix3; + use photom::observer::error_model::{ModelCorrection, ObsErrorModel}; + + use crate::{ + initial_orbit_determination::IODParamsBuilder, + orbit_type::orbit_type_test::approx_equal, + test_fixture::{DATASET_2015AB, JPL_EPHEM_HORIZON, UT1_PROVIDER}, + KeplerianElements, OrbitalElements, + }; + + use approx::assert_relative_eq; + use rand::{rngs::StdRng, SeedableRng}; + + use super::*; + + #[test] + fn test_select_rms_interval() { + let corrected_dataset = DATASET_2015AB + .clone() + .with_error_model(ObsErrorModel::FCCT14) + .apply_model_errors(); + + let traj = corrected_dataset + .materialize_trajectory("K09R05F") + .unwrap() + .collect_into_vec(); + let traj_len = traj.len(); + + let cache = + OutfitCache::build(&corrected_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER, false) + .unwrap(); + + let iod_params = IODParams { + dt_min: 0.03, + dt_max_triplet: 150.0, + optimal_interval_time: 20.0, + max_obs_for_triplets: traj_len, + max_triplets: 10, + extf: -1.0, + dtmax: 30., + ..Default::default() + }; + + let triplets = traj.compute_triplets(&cache, &iod_params); + let (u1, u2) = traj + .select_rms_interval(triplets.first().unwrap(), &iod_params) + .unwrap(); + + assert_eq!(u1, 0); + assert_eq!(u2, 36); + + let new_params = IODParamsBuilder::from_params(iod_params) + .extf(10.) + .build() + .unwrap(); + + let (u1, u2) = traj + .select_rms_interval(triplets.first().unwrap(), &new_params) + .unwrap(); + + assert_eq!(u1, 14); + assert_eq!(u2, 36); + + let new_params = IODParamsBuilder::from_params(new_params) + .extf(0.001) + .dtmax(3.) + .build() + .unwrap(); + + let (u1, u2) = traj + .select_rms_interval(triplets.first().unwrap(), &new_params) + .unwrap(); + + assert_eq!(u1, 17); + assert_eq!(u2, 33); + } + + #[test] + fn test_rms_trajectory() { + let iod_params = IODParams { + extf: -1.0, + dtmax: 30., + ..Default::default() + }; + + let corrected_dataset = DATASET_2015AB + .clone() + .with_error_model(ObsErrorModel::FCCT14) + .apply_batch_rms_correction(iod_params.gap_max); + + let traj = corrected_dataset + .materialize_trajectory("K09R05F") + .unwrap() + .collect_into_vec(); + + let cache = + OutfitCache::build(&corrected_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER, false) + .unwrap(); + + let triplets = GaussObs { + idx_obs: Vector3::new(34, 35, 36), + ra: [[ + 1.789_797_623_341_267, + 1.789_865_909_348_251, + 1.7899347771316527, + ]] + .into(), + dec: [[ + 0.779_178_052_350_181, + 0.779_086_664_971_291_9, + 0.778_996_538_107_973_6, + ]] + .into(), + time: [[ + 57070.238017592594, + 57_070.250_007_592_59, + 57070.262067592594, + ]] + .into(), + observer_helio_position: Matrix3::zeros(), + }; + + let kepler = KeplerianElements { + reference_epoch: 57_049.242_334_573_75, + semi_major_axis: 1.8017360713154256, + eccentricity: 0.283_559_145_668_705_7, + inclination: 0.20267383288689386, + ascending_node_longitude: 7.955_979_023_693_781E-3, + periapsis_argument: 1.2451951387589135, + mean_anomaly: 0.44054589015887125, + }; + + let rms = traj + .rms_orbit_error( + &cache, + &JPL_EPHEM_HORIZON, + &triplets, + &kepler.into(), + &iod_params, + None, + ) + .unwrap(); + + assert_eq!(rms, 153.84607281520138); + } + + mod proptests_extract_errors { + use super::*; + use photom::{ + coordinates::equatorial::EquCoord, + observation_dataset::{observation::ObservationInput, ObsDataset}, + observer::dataset::ObserverId, + photometry::{Filter, Photometry}, + }; + use proptest::prelude::*; + + fn arb_equ_coord() -> impl Strategy { + ( + 0.0..(2.0 * std::f64::consts::PI), + 0.0..0.01f64, + (-std::f64::consts::FRAC_PI_2)..std::f64::consts::FRAC_PI_2, + 0.0..0.01f64, + ) + .prop_map(|(ra, ra_error, dec, dec_error)| EquCoord { + ra, + ra_error, + dec, + dec_error, + }) + } + + fn make_obs_dataset_with_coords(coords: Vec) -> ObsDataset { + let inputs: Vec = coords + .into_iter() + .enumerate() + .map(|(i, equ_coord)| { + ObservationInput::new( + i as u64, + equ_coord, + Photometry { + magnitude: 20.0, + error: 0.1, + filter: Filter::Int(0), + }, + 59000.0, + Some(ObserverId::MpcCode(*b"F51")), + ) + }) + .collect(); + + ObsDataset::default().push_observation(inputs).unwrap().0 + } + + proptest! { + /// `extract_errors` must return the `ra_error` and `dec_error` of each + /// indexed observation, in the correct vector positions. + #[test] + fn proptest_extract_errors_returns_correct_components( + coord0 in arb_equ_coord(), + coord1 in arb_equ_coord(), + coord2 in arb_equ_coord(), + ) { + let dataset = make_obs_dataset_with_coords(vec![coord0, coord1, coord2]); + let obs: Vec<&Observation> = (0..3) + .map(|i| dataset.get_observation(i).unwrap()) + .collect(); + + let idx = Vector3::new(0usize, 1, 2); + let (ra_errors, dec_errors) = obs.extract_errors(idx); + + prop_assert_eq!(ra_errors[0], coord0.ra_error); + prop_assert_eq!(ra_errors[1], coord1.ra_error); + prop_assert_eq!(ra_errors[2], coord2.ra_error); + prop_assert_eq!(dec_errors[0], coord0.dec_error); + prop_assert_eq!(dec_errors[1], coord1.dec_error); + prop_assert_eq!(dec_errors[2], coord2.dec_error); + } + + /// The index order passed to `extract_errors` must determine which + /// observation's error ends up at which vector position. + #[test] + fn proptest_extract_errors_respects_index_order( + coord0 in arb_equ_coord(), + coord1 in arb_equ_coord(), + coord2 in arb_equ_coord(), + ) { + let dataset = make_obs_dataset_with_coords(vec![coord0, coord1, coord2]); + let obs: Vec<&Observation> = (0..3) + .map(|i| dataset.get_observation(i).unwrap()) + .collect(); + + // Permuted index: (2, 0, 1) + let idx = Vector3::new(2usize, 0, 1); + let (ra_errors, dec_errors) = obs.extract_errors(idx); + + prop_assert_eq!(ra_errors[0], coord2.ra_error); + prop_assert_eq!(ra_errors[1], coord0.ra_error); + prop_assert_eq!(ra_errors[2], coord1.ra_error); + prop_assert_eq!(dec_errors[0], coord2.dec_error); + prop_assert_eq!(dec_errors[1], coord0.dec_error); + prop_assert_eq!(dec_errors[2], coord1.dec_error); + } + } + } + + #[test] + fn test_estimate_best_orbit() { + let mut rng = StdRng::seed_from_u64(42_u64); // seed for reproducibility + + let iod_params = IODParams::default(); + + let corrected_dataset = DATASET_2015AB + .clone() + .with_error_model(ObsErrorModel::FCCT14) + .apply_batch_rms_correction(iod_params.gap_max); + + let traj = corrected_dataset + .materialize_trajectory("K09R05F") + .unwrap() + .collect_into_vec(); + + let iod_params = IODParamsBuilder::from_params(iod_params) + .n_noise_realizations(5) + .max_obs_for_triplets(traj.len()) + .build() + .unwrap(); + + let cache = OutfitCache::build(&corrected_dataset, &JPL_EPHEM_HORIZON, &UT1_PROVIDER, true) + .unwrap(); + + let best_orbit = traj + .estimate_best_orbit(&cache, &JPL_EPHEM_HORIZON, &iod_params, &mut rng) + .unwrap(); + + let orbit = best_orbit.orbital_elements(); + + let expected_orbit = OrbitalElements::Keplerian { + elements: KeplerianElements { + reference_epoch: 57049.22904475403, + semi_major_axis: 1.8017609974509807, + eccentricity: 0.2835733667643381, + inclination: 0.20267686119302475, + ascending_node_longitude: 0.00799201841873464, + periapsis_argument: 1.245034216916367, + mean_anomaly: 0.4405089048961484, + }, + uncertainty: None, + covariance: None, + }; + + assert!(approx_equal(orbit, &expected_orbit, 1e-14)); + assert_relative_eq!( + best_orbit.orbit_quality(), + 222.16583195747745, + epsilon = 1e-14 + ); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 5d4b455..96b5918 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,10 +1,22 @@ use approx::abs_diff_eq; -use outfit::orbit_type::OrbitalElements; +use outfit::{orbit_type::uncertainty::OrbitalCovariance, OrbitalElements}; +#[allow(dead_code)] pub fn approx_equal(current: &OrbitalElements, other: &OrbitalElements, tol: f64) -> bool { match (current, other) { - (OrbitalElements::Keplerian(ke1), OrbitalElements::Keplerian(ke2)) => { - abs_diff_eq!(ke1.semi_major_axis, ke2.semi_major_axis, epsilon = tol) + ( + OrbitalElements::Keplerian { + elements: ke1, + uncertainty: unc1, + covariance: cov1, + }, + OrbitalElements::Keplerian { + elements: ke2, + uncertainty: unc2, + covariance: cov2, + }, + ) => { + let elements_eq = abs_diff_eq!(ke1.semi_major_axis, ke2.semi_major_axis, epsilon = tol) && abs_diff_eq!(ke1.eccentricity, ke2.eccentricity, epsilon = tol) && abs_diff_eq!(ke1.inclination, ke2.inclination, epsilon = tol) && abs_diff_eq!( @@ -17,10 +29,43 @@ pub fn approx_equal(current: &OrbitalElements, other: &OrbitalElements, tol: f64 ke2.periapsis_argument, epsilon = tol ) - && abs_diff_eq!(ke1.mean_anomaly, ke2.mean_anomaly, epsilon = tol) + && abs_diff_eq!(ke1.mean_anomaly, ke2.mean_anomaly, epsilon = tol); + + let uncertainty_eq = match (unc1, unc2) { + (None, None) => true, + (Some(u1), Some(u2)) => { + abs_diff_eq!(u1.semi_major_axis, u2.semi_major_axis, epsilon = tol) + && abs_diff_eq!(u1.eccentricity, u2.eccentricity, epsilon = tol) + && abs_diff_eq!(u1.inclination, u2.inclination, epsilon = tol) + && abs_diff_eq!( + u1.ascending_node_longitude, + u2.ascending_node_longitude, + epsilon = tol + ) + && abs_diff_eq!(u1.periapsis_argument, u2.periapsis_argument, epsilon = tol) + && abs_diff_eq!(u1.mean_anomaly, u2.mean_anomaly, epsilon = tol) + } + _ => false, + }; + + let covariance_eq = approx_equal_covariance(cov1, cov2, tol); + + elements_eq && uncertainty_eq && covariance_eq } - (OrbitalElements::Equinoctial(ee1), OrbitalElements::Equinoctial(ee2)) => { - abs_diff_eq!(ee1.semi_major_axis, 0.0, epsilon = tol) + + ( + OrbitalElements::Equinoctial { + elements: ee1, + uncertainty: unc1, + covariance: cov1, + }, + OrbitalElements::Equinoctial { + elements: ee2, + uncertainty: unc2, + covariance: cov2, + }, + ) => { + let elements_eq = abs_diff_eq!(ee1.semi_major_axis, ee2.semi_major_axis, epsilon = tol) && abs_diff_eq!( ee1.eccentricity_sin_lon, ee2.eccentricity_sin_lon, @@ -41,10 +86,55 @@ pub fn approx_equal(current: &OrbitalElements, other: &OrbitalElements, tol: f64 ee2.tan_half_incl_cos_node, epsilon = tol ) - && abs_diff_eq!(ee1.mean_longitude, ee2.mean_longitude, epsilon = tol) + && abs_diff_eq!(ee1.mean_longitude, ee2.mean_longitude, epsilon = tol); + + let uncertainty_eq = match (unc1, unc2) { + (None, None) => true, + (Some(u1), Some(u2)) => { + abs_diff_eq!(u1.semi_major_axis, u2.semi_major_axis, epsilon = tol) + && abs_diff_eq!( + u1.eccentricity_sin_lon, + u2.eccentricity_sin_lon, + epsilon = tol + ) + && abs_diff_eq!( + u1.eccentricity_cos_lon, + u2.eccentricity_cos_lon, + epsilon = tol + ) + && abs_diff_eq!( + u1.tan_half_incl_sin_node, + u2.tan_half_incl_sin_node, + epsilon = tol + ) + && abs_diff_eq!( + u1.tan_half_incl_cos_node, + u2.tan_half_incl_cos_node, + epsilon = tol + ) + && abs_diff_eq!(u1.mean_longitude, u2.mean_longitude, epsilon = tol) + } + _ => false, + }; + + let covariance_eq = approx_equal_covariance(cov1, cov2, tol); + + elements_eq && uncertainty_eq && covariance_eq } - (OrbitalElements::Cometary(ce1), OrbitalElements::Cometary(ce2)) => { - abs_diff_eq!( + + ( + OrbitalElements::Cometary { + elements: ce1, + uncertainty: unc1, + covariance: cov1, + }, + OrbitalElements::Cometary { + elements: ce2, + uncertainty: unc2, + covariance: cov2, + }, + ) => { + let elements_eq = abs_diff_eq!( ce1.perihelion_distance, ce2.perihelion_distance, epsilon = tol @@ -60,8 +150,55 @@ pub fn approx_equal(current: &OrbitalElements, other: &OrbitalElements, tol: f64 ce2.periapsis_argument, epsilon = tol ) - && abs_diff_eq!(ce1.true_anomaly, ce2.true_anomaly, epsilon = tol) + && abs_diff_eq!(ce1.true_anomaly, ce2.true_anomaly, epsilon = tol); + + let uncertainty_eq = match (unc1, unc2) { + (None, None) => true, + (Some(u1), Some(u2)) => { + abs_diff_eq!( + u1.perihelion_distance, + u2.perihelion_distance, + epsilon = tol + ) && abs_diff_eq!(u1.eccentricity, u2.eccentricity, epsilon = tol) + && abs_diff_eq!(u1.inclination, u2.inclination, epsilon = tol) + && abs_diff_eq!( + u1.ascending_node_longitude, + u2.ascending_node_longitude, + epsilon = tol + ) + && abs_diff_eq!(u1.periapsis_argument, u2.periapsis_argument, epsilon = tol) + && abs_diff_eq!(u1.true_anomaly, u2.true_anomaly, epsilon = tol) + } + _ => false, + }; + + let covariance_eq = approx_equal_covariance(cov1, cov2, tol); + + elements_eq && uncertainty_eq && covariance_eq } - _ => false, // Different types cannot be equal + + _ => false, + } +} + +/// Compare two optional [`OrbitalCovariance`] matrices entry-wise within `tol`. +/// +/// * `(None, None)` → `true` +/// * `(Some, Some)` → all 36 entries within `tol` +/// * mixed → `false` +#[allow(dead_code)] +fn approx_equal_covariance( + cov1: &Option, + cov2: &Option, + tol: f64, +) -> bool { + match (cov1, cov2) { + (None, None) => true, + (Some(c1), Some(c2)) => c1 + .matrix + .iter() + .zip(c2.matrix.iter()) + .all(|(a, b)| abs_diff_eq!(a, b, epsilon = tol)), + _ => false, } } diff --git a/tests/data/test_data_traj_str.parquet b/tests/data/test_data_traj_str.parquet new file mode 100644 index 0000000..52300ba Binary files /dev/null and b/tests/data/test_data_traj_str.parquet differ diff --git a/tests/data/test_from_fink.parquet b/tests/data/test_from_fink.parquet deleted file mode 100644 index a38c55f..0000000 Binary files a/tests/data/test_from_fink.parquet and /dev/null differ diff --git a/tests/data/trajectories.parquet b/tests/data/trajectories.parquet deleted file mode 100644 index ce781a4..0000000 Binary files a/tests/data/trajectories.parquet and /dev/null differ diff --git a/tests/outfit_struct_test.rs b/tests/outfit_struct_test.rs deleted file mode 100644 index 2e9c42c..0000000 --- a/tests/outfit_struct_test.rs +++ /dev/null @@ -1,18 +0,0 @@ -use outfit::{error_models::ErrorModel, outfit::Outfit}; - -#[test] -fn test_outfit_observer_management() { - let mut outfit = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - - let obs1 = outfit.new_observer(51.58206, -73.06644, 100., Some("Test Observer 1".into())); - assert_eq!(obs1.name, Some("Test Observer 1".to_string())); - assert_eq!(obs1.longitude, 51.58206); - assert_eq!(obs1.rho_cos_phi, 0.29216347396649495); - assert_eq!(obs1.rho_sin_phi, -0.9531782585730825); - - let obs2 = outfit.new_observer(52.58206, 23.4587, 1423., Some("Test Observer 2".into())); - assert_eq!(obs2.name, Some("Test Observer 2".to_string())); - assert_eq!(obs2.longitude, 52.58206); - assert_eq!(obs2.rho_cos_phi, 0.9180389162887692); - assert_eq!(obs2.rho_sin_phi, 0.39572170773696747); -} diff --git a/tests/reader_80col_test.rs b/tests/reader_80col_test.rs deleted file mode 100644 index 1582ef6..0000000 --- a/tests/reader_80col_test.rs +++ /dev/null @@ -1,57 +0,0 @@ -use camino::Utf8Path; -use outfit::constants::ObjectNumber; -use outfit::error_models::ErrorModel; -use outfit::outfit::Outfit; -use outfit::trajectories::trajectory_file::TrajectoryFile; -use outfit::TrajectorySet; - -#[test] -fn test_80col_reader() { - let mut env_state = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - - let path_file = Utf8Path::new("tests/data/33803.obs"); - let mut traj_set = TrajectorySet::new_from_80col(&mut env_state, path_file); - - let obs_33803 = traj_set.get(&ObjectNumber::String("33803".into())).unwrap(); - assert_eq!(traj_set.len(), 1); - assert_eq!(obs_33803.len(), 129); - assert_eq!(obs_33803[0].time, 60324.52016874074); - assert_eq!(obs_33803[0].ra, 3.5491391785131814); - assert_eq!(obs_33803[0].dec, -0.15949710761897423); - assert_eq!( - obs_33803[0].get_observer(&env_state).name, - Some("Mt. Lemmon Survey".to_string()) - ); - - let path_file = Utf8Path::new("tests/data/8467.obs"); - traj_set.add_from_80col(&mut env_state, path_file); - assert_eq!(traj_set.len(), 2); - let obs_8467 = traj_set.get(&ObjectNumber::String("8467".into())).unwrap(); - assert_eq!(obs_8467.len(), 61); - assert_eq!(obs_8467[0].time, 60647.053230740734); - assert_eq!(obs_8467[0].ra, 0.10365423161131723); - assert_eq!(obs_8467[0].dec, 0.1400047372376524); - assert_eq!( - obs_8467[0].get_observer(&env_state).name, - Some("ATLAS Chile, Rio Hurtado".to_string()) - ); - - let path_file = Utf8Path::new("tests/data/K25D50B.obs"); - traj_set.add_from_80col(&mut env_state, path_file); - assert_eq!(traj_set.len(), 3); - let obs_k25 = traj_set - .get(&ObjectNumber::String("K25D50B".into())) - .unwrap(); - assert_eq!(obs_k25.len(), 20); - assert_eq!(obs_k25[0].time, 60732.28129074074); - assert_eq!(obs_k25[0].ra, 2.6992652800547146); - assert_eq!(obs_k25[0].dec, 0.5231308334332919); - assert_eq!( - obs_k25[0].get_observer(&env_state).name, - Some("Kitt Peak-Bok".to_string()) - ); - assert_eq!( - obs_k25[19].get_observer(&env_state).name, - Some("Steward Observatory, Kitt Peak-Spacewatch".to_string()) - ); -} diff --git a/tests/test_cache_consistency.rs b/tests/test_cache_consistency.rs new file mode 100644 index 0000000..7dc8302 --- /dev/null +++ b/tests/test_cache_consistency.rs @@ -0,0 +1,107 @@ +use approx::assert_abs_diff_eq; +use camino::Utf8Path; +use hifitime::ut1::Ut1Provider; +use nalgebra::Vector3; +use outfit::{ + cache::OutfitCache, jpl_ephem::download_jpl_file::EphemFileSource, IODParams, JPLEphem, +}; +use photom::{ + observation_dataset::ObsDataset, + observer::error_model::{ModelCorrection, ObsErrorModel}, +}; + +const POSITION_EPSILON: f64 = 1e-12; + +struct CacheFixture { + ut1_provider: Ut1Provider, + jpl_ephem: JPLEphem, + default_params: IODParams, +} + +impl CacheFixture { + fn new() -> Self { + let ut1_provider = Ut1Provider::download_from_jpl("latest_eop2.long") + .expect("Download of the JPL short time scale UT1 data failed"); + + let jpl_file: EphemFileSource = "horizon:DE440" + .try_into() + .expect("Failed to parse JPL ephemeris source"); + let jpl_ephem = + JPLEphem::new(&jpl_file).expect("Failed to load JPL ephemeris from Horizon"); + + Self { + ut1_provider, + jpl_ephem, + default_params: IODParams::default(), + } + } + + fn build_cache>(&self, paths: &[P]) -> (OutfitCache, ObsDataset) { + let (obs_dataset, _) = ObsDataset::from_mpc_80_col_files(paths); + + let obs_dataset = obs_dataset + .with_error_model(ObsErrorModel::FCCT14) + .apply_batch_rms_correction(self.default_params.gap_max); + + let cache = OutfitCache::build(&obs_dataset, &self.jpl_ephem, &self.ut1_provider, false) + .expect("Failed to build outfit cache"); + + (cache, obs_dataset) + } +} + +fn assert_helio_position(cache: &OutfitCache, obs_dataset: &ObsDataset, trajectory_id: &str) { + let traj = obs_dataset + .materialize_trajectory(trajectory_id) + .unwrap() + .collect_into_vec(); + + let checks: &[(usize, [f64; 3])] = &[ + ( + 0, + [0.996798968524259, -0.12232935537370689, -0.0530044254994447], + ), + ( + traj.len() / 2, + [-0.2588304494454786, 0.8703635675926336, 0.3773300400916685], + ), + ( + traj.len() - 1, + [-0.8383497659757538, 0.479843435538848, 0.20801659206288547], + ), + ]; + + for (idx, expected) in checks { + let obs = &traj[*idx]; + let helio_pos = cache + .get_centric(obs.index()) + .helio_position + .map(|x| x.into_inner()); + + assert_abs_diff_eq!( + helio_pos, + Vector3::from(*expected), + epsilon = POSITION_EPSILON + ); + } +} + +#[test] +fn test_cache_consistency() { + let fixture = CacheFixture::new(); + + let dataset_combinations: &[&[&str]] = &[ + &["tests/data/2015AB.obs"], + &["tests/data/8467.obs", "tests/data/2015AB.obs"], + &[ + "tests/data/2015AB.obs", + "tests/data/8467.obs", + "tests/data/33803.obs", + ], + ]; + + for paths in dataset_combinations { + let (cache, obs_dataset) = fixture.build_cache(paths); + assert_helio_position(&cache, &obs_dataset, "K09R05F"); + } +} diff --git a/tests/test_diff_cor.rs b/tests/test_diff_cor.rs new file mode 100644 index 0000000..d1fdc9c --- /dev/null +++ b/tests/test_diff_cor.rs @@ -0,0 +1,850 @@ +mod common; + +use crate::common::approx_equal; +use approx::assert_relative_eq; +use hifitime::ut1::Ut1Provider; +use outfit::jpl_ephem::naif::naif_ids::{ + planet_bary::PlanetaryBary, solar_system_bary::SolarSystemBary, NaifIds, +}; +use outfit::orbit_type::uncertainty::{EquinoctialUncertainty, OrbitalCovariance}; +use outfit::{ + orbit_type::{equinoctial_element::EquinoctialElements, OrbitalElements}, + propagator::{NBodyConfig, PropagatorKind}, + DifferentialCorrectionConfig, FitLSQ, IODParams, JPLEphem, +}; +use photom::TrajId; +use photom::{observation_dataset::ObsDataset, observer::error_model::ObsErrorModel}; +use rand::{rngs::StdRng, SeedableRng}; + +fn build_test_fixtures() -> ( + JPLEphem, + Ut1Provider, + ObsDataset, + IODParams, + DifferentialCorrectionConfig, +) { + let ut1_provider = Ut1Provider::download_from_jpl("latest_eop2.long") + .expect("Download of the JPL short time scale UT1 data failed"); + + let jpl_ephem: JPLEphem = "horizon:DE440" + .try_into() + .expect("Failed to load JPL ephemeris"); + + let (obs_dataset, errors) = ObsDataset::from_mpc_80_col_files(&[ + "tests/data/2015AB.obs", + "tests/data/8467.obs", + "tests/data/33803.obs", + ]); + if !errors.is_empty() { + panic!("Failed to load observation datasets: {:?}", errors); + } + + let iod_params = IODParams::builder() + .n_noise_realizations(10) + .noise_scale(1.1) + .max_obs_for_triplets(130) + .max_triplets(30) + .build() + .unwrap(); + + // NOTE: rms_divergence_ratio is raised above the default (1.5) to allow + // the 2-body differential corrector to handle objects whose osculating + // 2-body elements differ noticeably from the N-body solution (e.g. 8467). + let diff_cor_config = DifferentialCorrectionConfig { + rms_divergence_ratio: 10.0, + ..DifferentialCorrectionConfig::default() + }; + + ( + jpl_ephem, + ut1_provider, + obs_dataset, + iod_params, + diff_cor_config, + ) +} + +/// Non-regression test for differential orbit correction. +/// +/// Oracle values were captured from a known-good Outfit run with seed 42. +/// Tolerances: +/// - Non-regression (Outfit vs oracle): 1e-10 absolute +#[test] +fn test_diff_cor() { + let nr_tol = 1e-10; + + let (jpl_ephem, ut1_provider, obs_dataset, iod_params, diff_cor_config) = build_test_fixtures(); + + let full_orbit = obs_dataset + .fit_lsq( + &jpl_ephem, + &ut1_provider, + ObsErrorModel::FCCT14, + &iod_params, + &diff_cor_config, + None, + &mut StdRng::seed_from_u64(42), + ) + .unwrap(); + + // ------------------------------------------------------------------------- + // 2015 AB (MPC packed designation: K09R05F) + // ------------------------------------------------------------------------- + { + let orbit = full_orbit + .get(&TrajId::from("K09R05F")) + .expect("K09R05F (2015AB) not found in results") + .as_ref() + .expect("K09R05F (2015AB) should converge"); + + let expected = OrbitalElements::Equinoctial { + elements: EquinoctialElements { + reference_epoch: 57049.2684537375, + semi_major_axis: 1.801837227645679, + eccentricity_sin_lon: 0.26941036025991355, + eccentricity_cos_lon: 0.08909600747061494, + tan_half_incl_sin_node: 0.0008708024189761142, + tan_half_incl_cos_node: 0.10166598640878513, + mean_longitude: 1.6929834276945714, + }, + uncertainty: Some(EquinoctialUncertainty { + semi_major_axis: 1.3935756201273647e-6, + eccentricity_sin_lon: 2.399103573371585e-6, + eccentricity_cos_lon: 9.380584628466963e-6, + tan_half_incl_sin_node: 4.2486965596206456e-7, + tan_half_incl_cos_node: 9.938054593077774e-7, + mean_longitude: 1.5699462542222023e-5, + }), + covariance: Some(OrbitalCovariance { + matrix: [ + [ + 1.942053009013369e-12, + -3.7365542822268565e-13, + 1.250111987715944e-11, + -3.8069560012308287e-13, + 5.495356218939393e-13, + -2.1061628726935973e-11, + ], + [ + -3.736554282226888e-13, + 5.7556979557643085e-12, + -8.919579576942644e-12, + 6.829258011452513e-13, + -2.190283688325579e-12, + 1.4156679672214094e-11, + ], + [ + 1.2501119877159442e-11, + -8.919579576942621e-12, + 8.799536797183067e-11, + -3.157563107997367e-12, + 5.930188854586023e-12, + -1.472073140503015e-10, + ], + [ + -3.806956001230829e-13, + 6.829258011452509e-13, + -3.157563107997368e-12, + 1.8051422455732311e-13, + -3.5751562142662264e-13, + 5.229181995216352e-12, + ], + [ + 5.495356218939391e-13, + -2.1902836883255787e-12, + 5.930188854586025e-12, + -3.5751562142662264e-13, + 9.876492909499423e-13, + -9.67328953098736e-12, + ], + [ + -2.1061628726935976e-11, + 1.4156679672214063e-11, + -1.472073140503015e-10, + 5.229181995216351e-12, + -9.673289530987361e-12, + 2.464731241146324e-10, + ], + ] + .into(), + }), + }; + + assert!( + approx_equal(&expected, orbit.orbital_elements(), nr_tol), + "K09R05F orbital elements differ from oracle beyond tolerance {nr_tol}" + ); + assert_relative_eq!(orbit.orbit_quality(), 1.272e0, max_relative = 1e-3); + } + + // ------------------------------------------------------------------------- + // 33803 + // ------------------------------------------------------------------------- + { + let orbit = full_orbit + .get(&TrajId::Int(33803)) + .expect("33803 not found in results") + .as_ref() + .expect("33803 should converge"); + + let expected = OrbitalElements::Equinoctial { + elements: EquinoctialElements { + reference_epoch: 60465.26777915681, + semi_major_axis: 2.190614169340076, + eccentricity_sin_lon: -0.13393967896355405, + eccentricity_cos_lon: 0.1533932583177835, + tan_half_incl_sin_node: 0.002997272576917091, + tan_half_incl_cos_node: -0.05948928702443621, + mean_longitude: 4.224671691074116, + }, + uncertainty: Some(EquinoctialUncertainty { + semi_major_axis: 2.1400421559849134e-5, + eccentricity_sin_lon: 1.364670439647764e-5, + eccentricity_cos_lon: 5.318530114145479e-6, + tan_half_incl_sin_node: 3.44968775225327e-7, + tan_half_incl_cos_node: 8.503880052285401e-7, + mean_longitude: 2.664301205078454e-5, + }), + covariance: Some(OrbitalCovariance { + matrix: [ + [ + 4.5797804293925557e-10, + -2.443785426064791e-10, + 7.203221689097433e-11, + -1.883169629832777e-12, + -6.3279112379918766e-12, + 4.3441160814862357e-10, + ], + [ + -2.443785426064796e-10, + 1.8623254088484216e-10, + -6.032986816763725e-11, + 7.999773867024745e-15, + -6.598752075412107e-13, + -3.5829528431457476e-10, + ], + [ + 7.203221689097439e-11, + -6.032986816763721e-11, + 2.8286762575072326e-11, + 2.0398130597296797e-14, + 1.4218640626998597e-13, + 1.2758725519460455e-10, + ], + [ + -1.883169629832779e-12, + 7.99977386702494e-15, + 2.0398130597296844e-14, + 1.190034558804622e-13, + 2.64333826423024e-13, + 3.756599803475119e-13, + ], + [ + -6.327911237991877e-12, + -6.598752075412104e-13, + 1.4218640626998607e-13, + 2.64333826423024e-13, + 7.231597594365756e-13, + 2.605687909220327e-12, + ], + [ + 4.3441160814862383e-10, + -3.582952843145747e-10, + 1.2758725519460457e-10, + 3.7565998034751195e-13, + 2.6056879092203274e-12, + 7.098500911382502e-10, + ], + ] + .into(), + }), + }; + + assert!( + approx_equal(&expected, orbit.orbital_elements(), nr_tol), + "33803 orbital elements differ from oracle beyond tolerance {nr_tol}" + ); + assert_relative_eq!(orbit.orbit_quality(), 4.344e-1, max_relative = 1e-3); + } + + // ------------------------------------------------------------------------- + // 8467 + // ------------------------------------------------------------------------- + { + let orbit = full_orbit + .get(&TrajId::Int(8467)) + .expect("8467 not found in results") + .as_ref() + .expect("8467 should converge with rms_divergence_ratio=10"); + + let expected = OrbitalElements::Equinoctial { + elements: EquinoctialElements { + reference_epoch: 60672.2443617134, + semi_major_axis: 3.2073734821020743, + eccentricity_sin_lon: 0.053597752212361474, + eccentricity_cos_lon: -0.023229330026225303, + tan_half_incl_sin_node: 0.0028890355813102732, + tan_half_incl_cos_node: 0.09179492536540514, + mean_longitude: 0.626741395885302, + }, + uncertainty: Some(EquinoctialUncertainty { + semi_major_axis: 0.00758317975106881, + eccentricity_sin_lon: 0.002478406542589576, + eccentricity_cos_lon: 0.0007443879537814839, + tan_half_incl_sin_node: 4.277383244080703e-5, + tan_half_incl_cos_node: 5.706392699913953e-5, + mean_longitude: 0.00333399562783862, + }), + covariance: Some(OrbitalCovariance { + matrix: [ + [ + 5.750461513702002e-5, + 1.8729896457450725e-5, + 5.604248768814215e-6, + -3.2370073744381016e-7, + -4.297318085854602e-7, + 2.504633450274609e-5, + ], + [ + 1.8729896457450735e-5, + 6.1424989903508165e-6, + 1.8071841318216132e-6, + -1.0560687892019813e-7, + -1.409247502206143e-7, + 8.250952263039232e-6, + ], + [ + 5.604248768814217e-6, + 1.807184131821612e-6, + 5.541134257349846e-7, + -3.14728840772654e-8, + -4.14717463955493e-8, + 2.4005716002617356e-6, + ], + [ + -3.237007374438101e-7, + -1.0560687892019811e-7, + -3.147288407726542e-8, + 1.8296007416742358e-9, + 2.435346888714026e-9, + -1.4137265325860534e-7, + ], + [ + -4.2973180858546056e-7, + -1.4092475022061433e-7, + -4.1471746395549346e-8, + 2.4353468887140264e-9, + 3.2562917645631254e-9, + -1.8928599918199224e-7, + ], + [ + 2.50463345027461e-5, + 8.250952263039232e-6, + 2.400571600261738e-6, + -1.4137265325860537e-7, + -1.8928599918199224e-7, + 1.1115526846447033e-5, + ], + ] + .into(), + }), + }; + + assert!( + approx_equal(&expected, orbit.orbital_elements(), nr_tol), + "8467 orbital elements differ from oracle beyond tolerance {nr_tol}" + ); + assert_relative_eq!(orbit.orbit_quality(), 3.450e-1, max_relative = 1e-3); + } +} + +/// N-body differential orbit correction test. +/// +/// Runs the same dataset with Sun + Jupiter as perturbers and verifies: +/// +/// 1. All three objects still converge. +/// 2. The fitted semi-major axes are physically consistent with the two-body +/// oracle (no sign flip, reasonable magnitude). +/// 3. For the shortest arc (8467, ~40 days) the N-body elements agree with +/// the two-body oracle within 5e-2 AU / 5e-2 (eccentricity components). +/// Jupiter's perturbation over 40 days at 3.2 AU is measurable but small. +/// 4. The orbit quality (normalised RMS) remains below a generous bound of 5.0 +/// for all objects, confirming the fit converged to a good residual level. +/// +/// The test intentionally does **not** demand tight element agreement for the +/// longer arcs (2015AB at ~5 years, 33803 at ~5 months), where the best-fit +/// N-body and two-body elements differ by physical amounts (Jovian +/// perturbations accumulate over the arc). What it does verify is that the +/// propagator produces a self-consistent, converged orbit. +#[test] +fn test_diff_cor_nbody() { + let (jpl_ephem, ut1_provider, obs_dataset, iod_params, _) = build_test_fixtures(); + + // Perturbers: Sun (central body) + Jupiter (dominant perturber in the + // main belt). Using the same rms_divergence_ratio as the 2-body test + // because the N-body corrector converges smoothly to its own minimum. + let nbody_config = NBodyConfig { + perturbing_bodies: vec![ + NaifIds::SSB(SolarSystemBary::Sun), + NaifIds::PB(PlanetaryBary::Jupiter), + ], + abs_tol: 1e-12, + rel_tol: 1e-12, + }; + + let diff_cor_config = DifferentialCorrectionConfig { + rms_divergence_ratio: 10.0, + propagator: PropagatorKind::NBody(nbody_config), + ..DifferentialCorrectionConfig::default() + }; + + let full_orbit = obs_dataset + .fit_lsq( + &jpl_ephem, + &ut1_provider, + ObsErrorModel::FCCT14, + &iod_params, + &diff_cor_config, + None, + &mut StdRng::seed_from_u64(42), + ) + .unwrap(); + + // 2-body oracle values (from test_diff_cor) used as reference below. + let twobody_sma_k09r05f = 1.801837227645679_f64; + let twobody_sma_33803 = 2.190614169340076_f64; + let twobody_sma_8467 = 3.2073734821020743_f64; + + // ------------------------------------------------------------------------- + // 2015 AB (K09R05F) — 5-year arc, larger N-body/2-body divergence expected + // ------------------------------------------------------------------------- + { + let orbit = full_orbit + .get(&TrajId::from("K09R05F")) + .expect("K09R05F not found in N-body results") + .as_ref() + .expect("K09R05F should converge under N-body propagation"); + + let elem = match orbit.orbital_elements() { + OrbitalElements::Equinoctial { elements, .. } => elements, + _ => panic!("Expected equinoctial elements for K09R05F"), + }; + + // Semi-major axis must be positive and within 0.3 AU of the 2-body value. + assert!( + elem.semi_major_axis > 0.0, + "K09R05F N-body a must be positive" + ); + assert!( + (elem.semi_major_axis - twobody_sma_k09r05f).abs() < 0.3, + "K09R05F N-body a = {} differs from 2-body oracle {} by more than 0.3 AU", + elem.semi_major_axis, + twobody_sma_k09r05f + ); + + // Fit quality must remain physically reasonable. + assert!( + orbit.orbit_quality() < 5.0, + "K09R05F N-body orbit quality {} exceeds bound 5.0", + orbit.orbit_quality() + ); + } + + // ------------------------------------------------------------------------- + // 33803 — 5-month arc + // ------------------------------------------------------------------------- + { + let orbit = full_orbit + .get(&TrajId::Int(33803)) + .expect("33803 not found in N-body results") + .as_ref() + .expect("33803 should converge under N-body propagation"); + + let elem = match orbit.orbital_elements() { + OrbitalElements::Equinoctial { elements, .. } => elements, + _ => panic!("Expected equinoctial elements for 33803"), + }; + + assert!( + elem.semi_major_axis > 0.0, + "33803 N-body a must be positive" + ); + assert!( + (elem.semi_major_axis - twobody_sma_33803).abs() < 0.1, + "33803 N-body a = {} differs from 2-body oracle {} by more than 0.1 AU", + elem.semi_major_axis, + twobody_sma_33803 + ); + assert!( + orbit.orbit_quality() < 5.0, + "33803 N-body orbit quality {} exceeds bound 5.0", + orbit.orbit_quality() + ); + } + + // ------------------------------------------------------------------------- + // 8467 — 40-day arc (tightest comparison with 2-body oracle) + // ------------------------------------------------------------------------- + { + let orbit = full_orbit + .get(&TrajId::Int(8467)) + .expect("8467 not found in N-body results") + .as_ref() + .expect("8467 should converge under N-body propagation"); + + let elem = match orbit.orbital_elements() { + OrbitalElements::Equinoctial { elements, .. } => elements, + _ => panic!("Expected equinoctial elements for 8467"), + }; + + let twobody_8467 = EquinoctialElements { + reference_epoch: 60672.2443617134, + semi_major_axis: twobody_sma_8467, + eccentricity_sin_lon: 0.053597752212361474, + eccentricity_cos_lon: -0.023229330026225303, + tan_half_incl_sin_node: 0.0028890355813102732, + tan_half_incl_cos_node: 0.09179492536540514, + mean_longitude: 0.626741395885302, + }; + + // Over a 40-day arc at 3.2 AU, Jovian perturbations produce element + // changes well below 5e-2 in dimensionless units. + let a_tol = 5e-2; // AU + let e_tol = 5e-2; // eccentricity components (dimensionless) + + assert!( + (elem.semi_major_axis - twobody_8467.semi_major_axis).abs() < a_tol, + "8467 N-body a = {} differs from 2-body oracle {} by more than {} AU", + elem.semi_major_axis, + twobody_8467.semi_major_axis, + a_tol + ); + assert!( + (elem.eccentricity_sin_lon - twobody_8467.eccentricity_sin_lon).abs() < e_tol, + "8467 N-body h = {} differs from 2-body oracle {} by more than {}", + elem.eccentricity_sin_lon, + twobody_8467.eccentricity_sin_lon, + e_tol + ); + assert!( + (elem.eccentricity_cos_lon - twobody_8467.eccentricity_cos_lon).abs() < e_tol, + "8467 N-body k = {} differs from 2-body oracle {} by more than {}", + elem.eccentricity_cos_lon, + twobody_8467.eccentricity_cos_lon, + e_tol + ); + assert!( + orbit.orbit_quality() < 5.0, + "8467 N-body orbit quality {} exceeds bound 5.0", + orbit.orbit_quality() + ); + } +} + +/// Strict non-regression test for the N-body differential corrector. +/// +/// Reference values were captured from a deterministic run of `test_diff_cor_nbody` +/// with `--nocapture` and must remain reproducible to 1e-10. +#[test] +fn test_diff_cor_nbody_nonregression() { + let (jpl_ephem, ut1_provider, obs_dataset, iod_params, _) = build_test_fixtures(); + + let nbody_config = NBodyConfig { + perturbing_bodies: vec![ + NaifIds::SSB(SolarSystemBary::Sun), + NaifIds::PB(PlanetaryBary::Jupiter), + ], + abs_tol: 1e-12, + rel_tol: 1e-12, + }; + + let diff_cor_config = DifferentialCorrectionConfig { + rms_divergence_ratio: 10.0, + propagator: PropagatorKind::NBody(nbody_config), + ..DifferentialCorrectionConfig::default() + }; + + let full_orbit = obs_dataset + .fit_lsq( + &jpl_ephem, + &ut1_provider, + ObsErrorModel::FCCT14, + &iod_params, + &diff_cor_config, + None, + &mut StdRng::seed_from_u64(42), + ) + .unwrap(); + + let tol = 1e-10_f64; + + // ------------------------------------------------------------------------- + // 8467 — reference N-body final equinoctial elements + orbit quality + // ------------------------------------------------------------------------- + { + let orbit = full_orbit + .get(&TrajId::Int(8467)) + .expect("8467 not found") + .as_ref() + .expect("8467 should converge"); + + let expected = OrbitalElements::Equinoctial { + elements: EquinoctialElements { + reference_epoch: 60672.2443617134, + semi_major_axis: 3.2064058028481552, + eccentricity_sin_lon: 0.05300520970081429, + eccentricity_cos_lon: -0.023197692700634636, + tan_half_incl_sin_node: 0.002896813138792025, + tan_half_incl_cos_node: 0.09181010554057693, + mean_longitude: 0.6256995904459722, + }, + uncertainty: Some(EquinoctialUncertainty { + semi_major_axis: 0.007572375820104381, + eccentricity_sin_lon: 0.0024777464468933156, + eccentricity_cos_lon: 0.0007445419051153811, + tan_half_incl_sin_node: 4.2789628256661375e-5, + tan_half_incl_cos_node: 5.7090614265788426e-5, + mean_longitude: 0.003334899745150928, + }), + covariance: Some(OrbitalCovariance { + matrix: [ + [ + 5.73408755609015e-5, + 1.869803895909995e-5, + 5.597518961032454e-6, + -3.23358524526529e-7, + -4.293184367946207e-7, + 2.5017097632757542e-5, + ], + [ + 1.8698038959099974e-5, + 6.139227455092451e-6, + 1.8070844259588129e-6, + -1.0561768228833672e-7, + -1.409534091855587e-7, + 8.251003158870094e-6, + ], + [ + 5.597518961032457e-6, + 1.8070844259588129e-6, + 5.543426484728413e-7, + -3.149123145262512e-8, + -4.1500376105573355e-8, + 2.4017490220016504e-6, + ], + [ + -3.233585245265291e-7, + -1.0561768228833674e-7, + -3.149123145262512e-8, + 1.8309522863432738e-9, + 2.4373900210699776e-9, + -1.4146340017585484e-7, + ], + [ + -4.2931843679462117e-7, + -1.4095340918555872e-7, + -4.1500376105573355e-8, + 2.4373900210699772e-9, + 3.259338237245045e-9, + -1.894260958072586e-7, + ], + [ + 2.501709763275752e-5, + 8.251003158870094e-6, + 2.401749022001649e-6, + -1.414634001758548e-7, + -1.894260958072586e-7, + 1.1121556310207724e-5, + ], + ] + .into(), + }), + }; + + assert!( + approx_equal(&expected, orbit.orbital_elements(), tol), + "8467 N-body orbital elements differ from oracle beyond tolerance {tol}" + ); + assert_relative_eq!(orbit.orbit_quality(), 0.3486122845933199, epsilon = tol); + } + + // ------------------------------------------------------------------------- + // 33803 — reference N-body final equinoctial elements + orbit quality + // ------------------------------------------------------------------------- + { + let orbit = full_orbit + .get(&TrajId::Int(33803)) + .expect("33803 not found") + .as_ref() + .expect("33803 should converge"); + + let expected = OrbitalElements::Equinoctial { + elements: EquinoctialElements { + reference_epoch: 60465.26777915681, + semi_major_axis: 2.190348311458185, + eccentricity_sin_lon: -0.13373910921857446, + eccentricity_cos_lon: 0.15339157238172804, + tan_half_incl_sin_node: 0.0029876412023201416, + tan_half_incl_cos_node: -0.05950692044872062, + mean_longitude: 4.224365041422834, + }, + uncertainty: Some(EquinoctialUncertainty { + semi_major_axis: 2.1385808329040844e-5, + eccentricity_sin_lon: 1.3645976741407893e-5, + eccentricity_cos_lon: 5.318680335330679e-6, + tan_half_incl_sin_node: 3.4498285885877785e-7, + tan_half_incl_cos_node: 8.504424577287931e-7, + mean_longitude: 2.6647348205193914e-5, + }), + covariance: Some(OrbitalCovariance { + matrix: [ + [ + 4.573527978864727e-10, + -2.441395550149477e-10, + 7.195967928874385e-11, + -1.8832832793515527e-12, + -6.328517179823655e-12, + 4.340505064567979e-10, + ], + [ + -2.4413955501494734e-10, + 1.862126812270452e-10, + -6.032972260112714e-11, + 8.172697046412589e-15, + -6.596088825542575e-13, + -3.58336068263501e-10, + ], + [ + 7.195967928874368e-11, + -6.032972260112711e-11, + 2.8288360509433262e-11, + 2.035203222614985e-14, + 1.4220205527899883e-13, + 1.2761184787385386e-10, + ], + [ + -1.8832832793515527e-12, + 8.17269704641279e-15, + 2.0352032226149824e-14, + 1.1901317290637546e-13, + 2.6436375372263e-13, + 3.754248073793805e-13, + ], + [ + -6.328517179823653e-12, + -6.596088825542574e-13, + 1.4220205527899883e-13, + 2.6436375372262996e-13, + 7.2325237390779e-13, + 2.605903338872906e-12, + ], + [ + 4.340505064567979e-10, + -3.5833606826350087e-10, + 1.2761184787385386e-10, + 3.754248073793805e-13, + 2.6059033388729057e-12, + 7.100811663688513e-10, + ], + ] + .into(), + }), + }; + + assert!( + approx_equal(&expected, orbit.orbital_elements(), tol), + "33803 N-body orbital elements differ from oracle beyond tolerance {tol}" + ); + assert_relative_eq!(orbit.orbit_quality(), 0.7034091187041202, epsilon = tol); + } + + // ------------------------------------------------------------------------- + // K09R05F — reference N-body final equinoctial elements + orbit quality + // ------------------------------------------------------------------------- + { + let orbit = full_orbit + .get(&TrajId::from("K09R05F")) + .expect("K09R05F not found") + .as_ref() + .expect("K09R05F should converge"); + + let expected = OrbitalElements::Equinoctial { + elements: EquinoctialElements { + reference_epoch: 57049.2684537375, + semi_major_axis: 1.8021517900042052, + eccentricity_sin_lon: 0.2694922786015968, + eccentricity_cos_lon: 0.08955282358108035, + tan_half_incl_sin_node: 0.0008974287327937245, + tan_half_incl_cos_node: 0.10167548786557225, + mean_longitude: 1.6921653421358704, + }, + uncertainty: Some(EquinoctialUncertainty { + semi_major_axis: 1.910876358918557e-6, + eccentricity_sin_lon: 2.7271919973585478e-6, + eccentricity_cos_lon: 1.2559941333300101e-5, + tan_half_incl_sin_node: 6.143234310625764e-7, + tan_half_incl_cos_node: 1.1476173256703189e-6, + mean_longitude: 2.1064465635865037e-5, + }), + covariance: Some(OrbitalCovariance { + matrix: [ + [ + 3.651448459073842e-12, + -4.87907485491453e-13, + 2.321298362132558e-11, + -3.7695250201166625e-13, + 8.511532638002078e-13, + -3.91138523482157e-11, + ], + [ + -4.879074854914533e-13, + 7.437576190456506e-12, + -1.1647669978804286e-11, + 9.359797430147383e-13, + -2.8577594338429333e-12, + 1.853502993770551e-11, + ], + [ + 2.3212983621325566e-11, + -1.164766997880434e-11, + 1.577521262959403e-10, + -3.47676746499932e-12, + 8.610023673871895e-12, + -2.644913915663376e-10, + ], + [ + -3.7695250201166625e-13, + 9.359797430147385e-13, + -3.4767674649993202e-12, + 3.7739327795249603e-13, + -5.048815271306508e-13, + 5.7505636344116006e-12, + ], + [ + 8.511532638002078e-13, + -2.857759433842935e-12, + 8.610023673871898e-12, + -5.048815271306507e-13, + 1.3170255261786945e-12, + -1.4110008489365913e-11, + ], + [ + -3.911385234821569e-11, + 1.8535029937705585e-11, + -2.6449139156633765e-10, + 5.750563634411601e-12, + -1.4110008489365913e-11, + 4.437117125245391e-10, + ], + ] + .into(), + }), + }; + + assert!( + approx_equal(&expected, orbit.orbital_elements(), tol), + "K09R05F N-body orbital elements differ from oracle beyond tolerance {tol}" + ); + assert_relative_eq!(orbit.orbit_quality(), 0.3608868439717083, epsilon = tol); + } +} diff --git a/tests/test_ephemeris.rs b/tests/test_ephemeris.rs new file mode 100644 index 0000000..6511906 --- /dev/null +++ b/tests/test_ephemeris.rs @@ -0,0 +1,786 @@ +//! Integration tests for the public ephemeris API. +//! +//! Strategy +//! -------- +//! For each of the three objects in `tests/data/` we first fit an orbit +//! through the observations with the differential corrector (same pipeline as +//! `test_diff_cor.rs`). The fitted elements are anchored near the middle of +//! the observed arc, so the propagation error is small. We then exercise +//! every combination of output kind and generation mode provided by +//! [`EphemerisRequest`] and verify that the predicted apparent position +//! matches each observation to within a tight angular threshold. +//! +//! APIs exercised +//! -------------- +//! - [`EphemerisRequest::`] — per-site single-epoch apparent position +//! - [`EphemerisRequest::`] — bulk geocentric apparent position (At mode) +//! - [`EphemerisRequest::`] — bulk geocentric geometry (At mode) +//! - [`EphemerisRequest::`] — per-site single-epoch combined +//! - [`EphemerisRequest::`] — bulk geocentric combined (At mode) + +mod common; + +use hifitime::{ut1::Ut1Provider, Epoch, TimeScale}; +use outfit::{ + jpl_ephem::naif::naif_ids::{ + planet_bary::PlanetaryBary, solar_system_bary::SolarSystemBary, NaifIds, + }, + orbit_type::OrbitalElements, + propagator::{NBodyConfig, PropagatorKind}, + AberrationOrder, Combined, DifferentialCorrectionConfig, EphemerisConfig, EphemerisEntry, + EphemerisMode, EphemerisRequest, FitLSQ, FullOrbitResult, FullOrbitResultExt, Geometry, + IODParams, JPLEphem, OutfitError, Position, +}; +use photom::{ + observation_dataset::{observation::Observation, ObsDataset}, + observer::{error_model::ObsErrorModel, Observer}, + TrajId, +}; +use rand::{rngs::StdRng, SeedableRng}; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +fn build_fixtures() -> ( + JPLEphem, + Ut1Provider, + ObsDataset, + IODParams, + DifferentialCorrectionConfig, +) { + let ut1 = Ut1Provider::download_from_jpl("latest_eop2.long").expect("UT1 download failed"); + + let jpl: JPLEphem = "horizon:DE440" + .try_into() + .expect("JPL ephemeris load failed"); + + let (raw_dataset, errors) = ObsDataset::from_mpc_80_col_files(&[ + "tests/data/2015AB.obs", + "tests/data/8467.obs", + "tests/data/33803.obs", + ]); + assert!(errors.is_empty(), "obs file errors: {errors:?}"); + + // Attach the error model so that `get_observer` can resolve MPC-coded + // sites (the MPC observatory catalogue is loaded lazily on first access). + let dataset = raw_dataset.with_error_model(ObsErrorModel::FCCT14); + + let iod_params = IODParams::builder() + .n_noise_realizations(10) + .noise_scale(1.1) + .max_obs_for_triplets(130) + .max_triplets(30) + .build() + .unwrap(); + + let diff_cor_config = DifferentialCorrectionConfig { + rms_divergence_ratio: 10.0, + ..DifferentialCorrectionConfig::default() + }; + + (jpl, ut1, dataset, iod_params, diff_cor_config) +} + +// ── Elementary helpers ──────────────────────────────────────────────────────── + +/// A geocentric observer (ρ cos φ' = ρ sin φ' = 0, longitude = 0). +/// Used for bulk At-mode requests with a single shared observer. +fn geocentric_observer() -> Observer { + Observer::from_parallax(0.0, 0.0, 0.0, Some("Geocenter".to_string()), None, None) + .expect("geocentric observer construction failed") +} + +/// [`EphemerisConfig`] for N-body propagation: Sun + all eight major planets. +fn nbody_ephem_config() -> EphemerisConfig { + EphemerisConfig { + propagator: PropagatorKind::NBody(NBodyConfig { + perturbing_bodies: vec![ + NaifIds::SSB(SolarSystemBary::Sun), + NaifIds::PB(PlanetaryBary::Mercury), + NaifIds::PB(PlanetaryBary::Venus), + NaifIds::PB(PlanetaryBary::EarthMoon), + NaifIds::PB(PlanetaryBary::Mars), + NaifIds::PB(PlanetaryBary::Jupiter), + NaifIds::PB(PlanetaryBary::Saturn), + NaifIds::PB(PlanetaryBary::Uranus), + NaifIds::PB(PlanetaryBary::Neptune), + ], + abs_tol: 1e-12, + rel_tol: 1e-12, + }), + aberration: AberrationOrder::default(), + } +} + +/// [`DifferentialCorrectionConfig`] for N-body fitting, inheriting all other +/// fields from `base`. +fn nbody_diff_cor(base: &DifferentialCorrectionConfig) -> DifferentialCorrectionConfig { + DifferentialCorrectionConfig { + propagator: nbody_ephem_config().propagator, + ..base.clone() + } +} + +/// Extract the [`OrbitalElements`] for `traj_id` from a `FullOrbitResult`. +/// Panics if the trajectory is missing or did not converge. +fn fit_orbit<'a>(orbits: &'a FullOrbitResult, traj_id: &TrajId) -> &'a OrbitalElements { + orbits + .get(traj_id) + .unwrap_or_else(|| panic!("{traj_id:?} not found in fit results")) + .as_ref() + .unwrap_or_else(|e| panic!("{traj_id:?} fit error: {e}")) + .orbital_elements() +} + +/// Materialize observations for `traj_id` and derive the matching epoch list. +fn traj_obs_and_epochs(dataset: &ObsDataset, traj_id: TrajId) -> (Vec<&Observation>, Vec) { + let mem = dataset + .materialize_trajectory(traj_id.clone()) + .unwrap_or_else(|| panic!("trajectory {traj_id:?} not indexed")); + let obs_vec: Vec<&Observation> = mem.iter().collect(); + assert!(!obs_vec.is_empty(), "no observations for {traj_id:?}"); + let epochs = obs_vec + .iter() + .map(|o| Epoch::from_mjd_in_time_scale(o.mjd_tt(), TimeScale::TT)) + .collect(); + (obs_vec, epochs) +} + +/// Build a per-site [`EphemerisRequest`]: one `Single` slot per +/// observation, each using the site observer resolved from the dataset. +/// +/// Observations whose site cannot be resolved are silently skipped. +fn per_site_position_request( + obs_vec: &[&Observation], + dataset: &ObsDataset, + config: EphemerisConfig, +) -> EphemerisRequest { + obs_vec + .iter() + .filter_map(|obs| { + let observer = dataset.get_observer(*obs.id())?; + let epoch = Epoch::from_mjd_in_time_scale(obs.mjd_tt(), TimeScale::TT); + Some((observer, EphemerisMode::Single(epoch))) + }) + .fold( + EphemerisRequest::::new(config), + |req, (obs, mode)| req.add(obs.clone(), mode), + ) +} + +/// Build a per-site [`EphemerisRequest`]: one `Single` slot per +/// observation, each using the site observer resolved from the dataset. +fn per_site_combined_request( + obs_vec: &[&Observation], + dataset: &ObsDataset, + config: EphemerisConfig, +) -> EphemerisRequest { + obs_vec + .iter() + .filter_map(|obs| { + let observer = dataset.get_observer(*obs.id())?; + let epoch = Epoch::from_mjd_in_time_scale(obs.mjd_tt(), TimeScale::TT); + Some((observer, EphemerisMode::Single(epoch))) + }) + .fold( + EphemerisRequest::::new(config), + |req, (obs, mode)| req.add(obs.clone(), mode), + ) +} + +/// Collect angular separations (arcsec) from a per-site position result, +/// zipped against the corresponding observations. +fn position_seps_arcsec<'a, 'b>( + entries: impl Iterator>, + obs_vec: impl Iterator, +) -> Vec { + entries + .zip(obs_vec) + .filter_map( + |(entry, obs): (&EphemerisEntry, &&Observation)| { + Some( + entry + .result + .as_ref() + .ok()? + .coord + .angular_separation(obs.equ_coord()) + .to_degrees() + * 3600.0, + ) + }, + ) + .collect() +} + +/// Collect angular separations (arcsec) from a combined result, using only +/// the [`ApparentPosition`] part. +fn combined_seps_arcsec<'a, 'b>( + entries: impl Iterator>, + obs_vec: impl Iterator, +) -> Vec { + entries + .zip(obs_vec) + .filter_map( + |(entry, obs): ( + &EphemerisEntry<(outfit::ApparentPosition, outfit::BodyGeometry)>, + &&Observation, + )| { + Some( + entry + .result + .as_ref() + .ok()? + .0 + .coord + .angular_separation(obs.equ_coord()) + .to_degrees() + * 3600.0, + ) + }, + ) + .collect() +} + +/// Assert that the median of `seps` is below `threshold_arcsec`. +fn assert_median_below(seps: &mut [f64], label: &str, threshold_arcsec: f64) { + assert!(!seps.is_empty(), "{label}: no separations computed"); + seps.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let median = seps[seps.len() / 2]; + assert!( + median < threshold_arcsec, + "{label}: median {median:.2} arcsec ≥ threshold {threshold_arcsec:.1} arcsec" + ); +} + +// ── Two-body tests ──────────────────────────────────────────────────────────── + +/// Test all ephemeris request combinations for object **33803 Julienpeloton**. +/// +/// Exercises: +/// - `Position` / per-site (one `Single` slot per observation) +/// - `Position` / bulk geocentric (`At` mode, single observer) +/// - `Geometry` / bulk geocentric (`At` mode) — all epochs must succeed +/// - `Combined` / per-site single-epoch +/// - `Combined` / bulk geocentric (`At` mode) +#[test] +fn test_ephemeris_33803() { + let (jpl, ut1, dataset, iod_params, diff_cor_config) = build_fixtures(); + + let orbits = dataset + .clone() + .fit_lsq( + &jpl, + &ut1, + ObsErrorModel::FCCT14, + &iod_params, + &diff_cor_config, + None, + &mut StdRng::seed_from_u64(42), + ) + .expect("fit_lsq failed"); + + let traj_id = TrajId::Int(33803); + let elements = fit_orbit(&orbits, &traj_id); + let config = EphemerisConfig::default(); + let geo_obs = geocentric_observer(); + let (obs_vec, epochs) = traj_obs_and_epochs(&dataset, traj_id); + + // Position — per-site + { + let req = per_site_position_request(&obs_vec, &dataset, config.clone()); + let result = elements.compute(&req, &jpl, &ut1); + let mut seps = position_seps_arcsec(result.iter(), obs_vec.iter()); + assert_median_below(&mut seps, "33803 Position per-site", 2.0); + } + + // Position — bulk geocentric (At mode) + // Geocentric observer introduces a topocentric offset (~2-3 arcsec for + // main-belt objects at ~2 AU observed from G96). Use a 5 arcsec bound. + { + let req = EphemerisRequest::::new(config.clone()) + .add(geo_obs.clone(), EphemerisMode::At(epochs.clone())); + let result = elements.compute(&req, &jpl, &ut1); + let mut seps = position_seps_arcsec(result.iter(), obs_vec.iter()); + assert_median_below(&mut seps, "33803 Position geocentric At", 5.0); + } + + // Geometry — bulk geocentric (At mode): all epochs must succeed + { + let req = EphemerisRequest::::new(config.clone()) + .add(geo_obs.clone(), EphemerisMode::At(epochs.clone())); + let result = elements.compute(&req, &jpl, &ut1); + assert_eq!( + result.error_count(), + 0, + "33803 Geometry At: {} epochs failed", + result.error_count() + ); + // Phase angles must be in [0, π] + for entry in result.successes() { + let geo = entry.result.as_ref().unwrap(); + assert!( + (0.0..=std::f64::consts::PI).contains(&geo.phase_angle), + "33803: phase angle {:.4} rad out of range", + geo.phase_angle + ); + } + } + + // Combined — per-site single-epoch + { + let req = per_site_combined_request(&obs_vec, &dataset, config.clone()); + let result = elements.compute(&req, &jpl, &ut1); + // Sanity: RA in [0, 2π) and phase in [0, π] + for entry in result.successes() { + let (ap, geo) = entry.result.as_ref().unwrap(); + assert!( + (0.0..2.0 * std::f64::consts::PI).contains(&ap.coord.ra), + "33803: RA {:.4} rad out of range", + ap.coord.ra + ); + assert!( + (0.0..=std::f64::consts::PI).contains(&geo.phase_angle), + "33803: phase angle {:.4} rad out of range", + geo.phase_angle + ); + } + } + + // Combined — bulk geocentric (At mode) + { + let req = EphemerisRequest::::new(config.clone()) + .add(geo_obs.clone(), EphemerisMode::At(epochs.clone())); + let result = elements.compute(&req, &jpl, &ut1); + assert_eq!( + result.error_count(), + 0, + "33803 Combined At: some epochs failed" + ); + let mut seps = combined_seps_arcsec(result.iter(), obs_vec.iter()); + assert_median_below(&mut seps, "33803 Combined geocentric At", 5.0); + } +} + +/// Test ephemeris requests for object **8467 Benoîtcarry**. +#[test] +fn test_ephemeris_8467() { + let (jpl, ut1, dataset, iod_params, diff_cor_config) = build_fixtures(); + + let orbits = dataset + .clone() + .fit_lsq( + &jpl, + &ut1, + ObsErrorModel::FCCT14, + &iod_params, + &diff_cor_config, + None, + &mut StdRng::seed_from_u64(42), + ) + .expect("fit_lsq failed"); + + let traj_id = TrajId::Int(8467); + let elements = fit_orbit(&orbits, &traj_id); + let config = EphemerisConfig::default(); + let geo_obs = geocentric_observer(); + let (obs_vec, epochs) = traj_obs_and_epochs(&dataset, traj_id); + + // Position — per-site + { + let req = per_site_position_request(&obs_vec, &dataset, config.clone()); + let result = elements.compute(&req, &jpl, &ut1); + let mut seps = position_seps_arcsec(result.iter(), obs_vec.iter()); + assert_median_below(&mut seps, "8467 Position per-site", 2.0); + } + + // Position — bulk geocentric (At mode) + { + let req = EphemerisRequest::::new(config.clone()) + .add(geo_obs.clone(), EphemerisMode::At(epochs.clone())); + let result = elements.compute(&req, &jpl, &ut1); + let mut seps = position_seps_arcsec(result.iter(), obs_vec.iter()); + assert_median_below(&mut seps, "8467 Position geocentric At", 3.0); + } + + // Geometry — bulk geocentric (At mode) + { + let req = EphemerisRequest::::new(config.clone()) + .add(geo_obs.clone(), EphemerisMode::At(epochs.clone())); + let result = elements.compute(&req, &jpl, &ut1); + assert_eq!( + result.error_count(), + 0, + "8467 Geometry At: some epochs failed" + ); + } + + // Combined — bulk geocentric (At mode) + { + let req = EphemerisRequest::::new(config.clone()) + .add(geo_obs.clone(), EphemerisMode::At(epochs.clone())); + let result = elements.compute(&req, &jpl, &ut1); + assert_eq!( + result.error_count(), + 0, + "8467 Combined At: some epochs failed" + ); + let mut seps = combined_seps_arcsec(result.iter(), obs_vec.iter()); + assert_median_below(&mut seps, "8467 Combined geocentric At", 3.0); + } +} + +/// Test ephemeris requests for object **2015 AB** (MPC packed: K09R05F). +/// +/// The observations span 2009 and 2015. With a 2-body fit over a ~2000-day +/// arc the geocentric bulk residuals are larger (~10-15 arcsec); the per-site +/// `Position` test validates sub-arcsecond accuracy. +#[test] +fn test_ephemeris_2015ab() { + let (jpl, ut1, dataset, iod_params, diff_cor_config) = build_fixtures(); + + let orbits = dataset + .clone() + .fit_lsq( + &jpl, + &ut1, + ObsErrorModel::FCCT14, + &iod_params, + &diff_cor_config, + None, + &mut StdRng::seed_from_u64(42), + ) + .expect("fit_lsq failed"); + + let traj_id = TrajId::from("K09R05F"); + let elements = fit_orbit(&orbits, &traj_id); + let config = EphemerisConfig::default(); + let geo_obs = geocentric_observer(); + let (obs_vec, epochs) = traj_obs_and_epochs(&dataset, traj_id); + + // Position — per-site + { + let req = per_site_position_request(&obs_vec, &dataset, config.clone()); + let result = elements.compute(&req, &jpl, &ut1); + let mut seps = position_seps_arcsec(result.iter(), obs_vec.iter()); + assert_median_below(&mut seps, "2015AB Position per-site", 2.0); + } + + // Position — bulk geocentric (At mode) + // 2015AB is a near-Earth Amor; over a ~2000-day 2-body arc the geocentric + // residuals reach ~10-15 arcsec. + { + let req = EphemerisRequest::::new(config.clone()) + .add(geo_obs.clone(), EphemerisMode::At(epochs.clone())); + let result = elements.compute(&req, &jpl, &ut1); + let mut seps = position_seps_arcsec(result.iter(), obs_vec.iter()); + assert_median_below(&mut seps, "2015AB Position geocentric At", 15.0); + } + + // Geometry — bulk geocentric (At mode) + { + let req = EphemerisRequest::::new(config.clone()) + .add(geo_obs.clone(), EphemerisMode::At(epochs.clone())); + let result = elements.compute(&req, &jpl, &ut1); + assert_eq!( + result.error_count(), + 0, + "2015AB Geometry At: some epochs failed" + ); + } + + // Combined — bulk geocentric (At mode) + { + let req = EphemerisRequest::::new(config.clone()) + .add(geo_obs.clone(), EphemerisMode::At(epochs.clone())); + let result = elements.compute(&req, &jpl, &ut1); + assert_eq!( + result.error_count(), + 0, + "2015AB Combined At: some epochs failed" + ); + let mut seps = combined_seps_arcsec(result.iter(), obs_vec.iter()); + assert_median_below(&mut seps, "2015AB Combined geocentric At", 15.0); + } +} + +// ── N-body tests ────────────────────────────────────────────────────────────── + +/// Fit one trajectory with the N-body corrector and assert that the per-site +/// apparent-position median residual is below `threshold_arcsec`. +fn run_nbody_ephemeris_test(traj_id: TrajId, threshold_arcsec: f64) { + let (jpl, ut1, dataset, iod_params, base_config) = build_fixtures(); + let diff_cor = nbody_diff_cor(&base_config); + + let orbits = dataset + .clone() + .fit_lsq( + &jpl, + &ut1, + ObsErrorModel::FCCT14, + &iod_params, + &diff_cor, + None, + &mut StdRng::seed_from_u64(42), + ) + .expect("N-body fit_lsq failed"); + + let elements = fit_orbit(&orbits, &traj_id); + let (obs_vec, _) = traj_obs_and_epochs(&dataset, traj_id.clone()); + + let req = per_site_position_request(&obs_vec, &dataset, nbody_ephem_config()); + let result = elements.compute(&req, &jpl, &ut1); + let mut seps = position_seps_arcsec(result.iter(), obs_vec.iter()); + + assert_median_below( + &mut seps, + &format!("{traj_id:?} N-body Position per-site"), + threshold_arcsec, + ); +} + +/// N-body ephemeris test for **33803 Julienpeloton** — threshold 2 arcsec. +#[test] +fn test_ephemeris_33803_nbody() { + run_nbody_ephemeris_test(TrajId::Int(33803), 2.0); +} + +/// N-body ephemeris test for **8467 Benoîtcarry** — threshold 2 arcsec. +#[test] +fn test_ephemeris_8467_nbody() { + run_nbody_ephemeris_test(TrajId::Int(8467), 2.0); +} + +/// N-body ephemeris test for **2015 AB** (K09R05F) — threshold 15 arcsec. +/// +/// The long (~2000-day) arc and NEA dynamics make sub-2-arcsec residuals +/// unrealistic here; we use the same bound as the 2-body test for this object. +#[test] +fn test_ephemeris_2015ab_nbody() { + run_nbody_ephemeris_test(TrajId::from("K09R05F"), 15.0); +} + +// ── Batch ephemeris tests (FullOrbitResultExt) ──────────────────────────────── + +/// Run `fit_lsq` on the full dataset and return the `FullOrbitResult`. +fn fit_all_lsq( + dataset: ObsDataset, + jpl: &JPLEphem, + ut1: &Ut1Provider, + iod_params: &IODParams, + diff_cor_config: &DifferentialCorrectionConfig, +) -> FullOrbitResult { + dataset + .fit_lsq( + jpl, + ut1, + ObsErrorModel::FCCT14, + iod_params, + diff_cor_config, + None, + &mut StdRng::seed_from_u64(42), + ) + .expect("fit_lsq failed") +} + +/// `compute_ephemerides` must return a map whose key set equals that of the +/// input `FullOrbitResult`, with every successfully determined orbit producing +/// an `Ok(EphemerisResult)` whose entry count matches the number of +/// observation epochs supplied via `At` mode. +#[test] +fn test_batch_compute_ephemerides_key_coverage() { + let (jpl, ut1, dataset, iod_params, diff_cor_config) = build_fixtures(); + let orbits = fit_all_lsq(dataset.clone(), &jpl, &ut1, &iod_params, &diff_cor_config); + + let traj_ids = [ + TrajId::Int(33803), + TrajId::Int(8467), + TrajId::from("K09R05F"), + ]; + + // Build a single geocentric At-mode request with all epochs of the three + // trajectories merged into one observer slot. + let geo_obs = geocentric_observer(); + let all_epochs: Vec = traj_ids + .iter() + .flat_map(|tid| { + let (_, epochs) = traj_obs_and_epochs(&dataset, tid.clone()); + epochs + }) + .collect(); + let n_epochs = all_epochs.len(); + + let request = EphemerisRequest::::new(EphemerisConfig::default()) + .add(geo_obs, EphemerisMode::At(all_epochs)); + + let batch = orbits.compute_ephemerides(&request, &jpl, &ut1); + + // The output map must have the same number of keys as the input. + assert_eq!( + batch.len(), + orbits.len(), + "batch map key count differs from input FullOrbitResult" + ); + + // Every trajectory that succeeded in orbit determination must produce an + // Ok result containing exactly `n_epochs` entries. + for traj_id in &traj_ids { + let entry = batch + .get(traj_id) + .unwrap_or_else(|| panic!("{traj_id:?} missing from batch result")); + + let ephem = entry + .as_ref() + .unwrap_or_else(|e| panic!("{traj_id:?} unexpected Err in batch: {e}")); + + assert_eq!( + ephem.len(), + n_epochs, + "{traj_id:?}: expected {n_epochs} entries, got {}", + ephem.len() + ); + } +} + +/// Errors from orbit determination must be forwarded as `Err` values in the +/// batch output map. We inject a synthetic failure by constructing a +/// `FullOrbitResult` with one `Err` entry and one real orbit. +#[test] +fn test_batch_error_forwarding() { + let (jpl, ut1, dataset, iod_params, diff_cor_config) = build_fixtures(); + let mut orbits = fit_all_lsq(dataset.clone(), &jpl, &ut1, &iod_params, &diff_cor_config); + + // Inject a synthetic failure for a fake trajectory id. + let fake_id = TrajId::Int(99999); + orbits.insert( + fake_id.clone(), + Err(OutfitError::InvalidConversion( + "synthetic failure".to_string(), + )), + ); + + let geo_obs = geocentric_observer(); + let (_, epochs) = traj_obs_and_epochs(&dataset, TrajId::Int(33803)); + let request = EphemerisRequest::::new(EphemerisConfig::default()) + .add(geo_obs, EphemerisMode::At(epochs)); + + let batch = orbits.compute_ephemerides(&request, &jpl, &ut1); + + // The fake id must appear in the output as an Err. + let fake_entry = batch + .get(&fake_id) + .unwrap_or_else(|| panic!("fake traj {fake_id:?} missing from batch result")); + assert!( + fake_entry.is_err(), + "expected Err for fake traj {fake_id:?}, got Ok" + ); + + // The real trajectories must still be Ok. + for traj_id in [ + TrajId::Int(33803), + TrajId::Int(8467), + TrajId::from("K09R05F"), + ] { + let entry = batch + .get(&traj_id) + .unwrap_or_else(|| panic!("{traj_id:?} missing")); + assert!(entry.is_ok(), "{traj_id:?} should be Ok but got Err"); + } +} + +/// `compute_ephemerides` with a `Combined` output kind: verify that every +/// successfully determined orbit produces no per-entry errors and that +/// geometric quantities are in their expected ranges. +#[test] +fn test_batch_compute_ephemerides_combined() { + let (jpl, ut1, dataset, iod_params, diff_cor_config) = build_fixtures(); + let orbits = fit_all_lsq(dataset.clone(), &jpl, &ut1, &iod_params, &diff_cor_config); + + let geo_obs = geocentric_observer(); + let traj_ids = [TrajId::Int(33803), TrajId::Int(8467)]; + + for traj_id in &traj_ids { + let (_, epochs) = traj_obs_and_epochs(&dataset, traj_id.clone()); + + let request = EphemerisRequest::::new(EphemerisConfig::default()) + .add(geo_obs.clone(), EphemerisMode::At(epochs.clone())); + + let batch = orbits.compute_ephemerides(&request, &jpl, &ut1); + + let ephem = batch + .get(traj_id) + .unwrap_or_else(|| panic!("{traj_id:?} missing")) + .as_ref() + .unwrap_or_else(|e| panic!("{traj_id:?} Err: {e}")); + + assert_eq!( + ephem.error_count(), + 0, + "{traj_id:?} Combined batch: {} epochs failed", + ephem.error_count() + ); + + for entry in ephem.successes() { + let (ap, geo) = entry.result.as_ref().unwrap(); + assert!( + (0.0..2.0 * std::f64::consts::PI).contains(&ap.coord.ra), + "{traj_id:?}: RA {:.4} rad out of [0, 2π)", + ap.coord.ra + ); + assert!( + (0.0..=std::f64::consts::PI).contains(&geo.phase_angle), + "{traj_id:?}: phase angle {:.4} rad out of [0, π]", + geo.phase_angle + ); + } + } +} + +/// Parallel batch must return the same number of keys and the same entry +/// counts as the sequential version. +#[cfg(feature = "parallel")] +#[test] +fn test_batch_compute_ephemerides_parallel_matches_sequential() { + let (jpl, ut1, dataset, iod_params, diff_cor_config) = build_fixtures(); + let orbits = fit_all_lsq(dataset.clone(), &jpl, &ut1, &iod_params, &diff_cor_config); + + let geo_obs = geocentric_observer(); + let all_epochs: Vec = [ + TrajId::Int(33803), + TrajId::Int(8467), + TrajId::from("K09R05F"), + ] + .iter() + .flat_map(|tid| { + let (_, epochs) = traj_obs_and_epochs(&dataset, tid.clone()); + epochs + }) + .collect(); + + let request = EphemerisRequest::::new(EphemerisConfig::default()) + .add(geo_obs, EphemerisMode::At(all_epochs)); + + let seq = orbits.compute_ephemerides(&request, &jpl, &ut1); + let par = orbits.compute_ephemerides_parallel(&request, &jpl, &ut1); + + // Same key set. + assert_eq!(par.len(), seq.len(), "parallel map has different key count"); + + // For every key, both results must agree on Ok/Err and on entry count. + for (traj_id, seq_entry) in &seq { + let par_entry = par + .get(traj_id) + .unwrap_or_else(|| panic!("{traj_id:?} missing from parallel result")); + + match (seq_entry, par_entry) { + (Ok(s), Ok(p)) => assert_eq!( + s.len(), + p.len(), + "{traj_id:?}: sequential has {} entries, parallel has {}", + s.len(), + p.len() + ), + (Err(_), Err(_)) => {} + _ => panic!("{traj_id:?}: sequential and parallel disagree on Ok/Err"), + } + } +} diff --git a/tests/test_gauss_iod.rs b/tests/test_gauss_iod.rs index fbaec35..23c4f1c 100644 --- a/tests/test_gauss_iod.rs +++ b/tests/test_gauss_iod.rs @@ -1,138 +1,189 @@ -#![cfg(feature = "jpl-download")] - mod common; use approx::assert_relative_eq; -use camino::Utf8Path; -use outfit::constants::ObjectNumber; -use outfit::error_models::ErrorModel; -use outfit::initial_orbit_determination::gauss_result::GaussResult; +use hifitime::ut1::Ut1Provider; use outfit::initial_orbit_determination::IODParams; -use outfit::observations::observations_ext::ObservationIOD; use outfit::orbit_type::keplerian_element::KeplerianElements; use outfit::orbit_type::OrbitalElements; -use outfit::outfit::Outfit; -use outfit::outfit_errors::OutfitError; -use outfit::trajectories::trajectory_file::TrajectoryFile; -use outfit::TrajectorySet; +use outfit::FitIOD; +use outfit::{FullOrbitResult, JPLEphem}; +use photom::observation_dataset::ObsDataset; +use photom::observer::error_model::ObsErrorModel; use rand::rngs::StdRng; use rand::SeedableRng; use crate::common::approx_equal; -fn run_iod( - env_state: &mut Outfit, - traj_set: &mut TrajectorySet, - traj_number: &ObjectNumber, -) -> Result<(GaussResult, f64), OutfitError> { - let obs = traj_set.get_mut(traj_number).unwrap(); - let mut rng = StdRng::seed_from_u64(42_u64); // seed for reproducibility - - let default = IODParams::builder() - .n_noise_realizations(10) - .noise_scale(1.1) - .max_obs_for_triplets(obs.len()) - .max_triplets(30) - .build()?; - - obs.estimate_best_orbit(env_state, &ErrorModel::FCCT14, &mut rng, &default) +struct ExpectedResult { + orbit: OrbitalElements, + rms: f64, } -#[test] - -fn test_gauss_iod() { - let test_max_relative = 1e-11; - let test_epsilon = 1e-11; - - let mut env_state = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - - let path_file = Utf8Path::new("tests/data/2015AB.obs"); - let mut traj_set = TrajectorySet::new_from_80col(&mut env_state, path_file); +fn expected_results() -> Vec { + vec![ + ExpectedResult { + orbit: OrbitalElements::Keplerian { + elements: KeplerianElements { + reference_epoch: 57049.2684537375, + semi_major_axis: 1.801740835743616, + eccentricity: 0.28356259478492557, + inclination: 0.2026828189979528, + ascending_node_longitude: 0.007951791820548622, + periapsis_argument: 1.2450647642587158, + mean_anomaly: 0.4408048786626789, + }, + uncertainty: None, + covariance: None, + }, + rms: 66.97479288637471, + }, + ExpectedResult { + orbit: OrbitalElements::Keplerian { + elements: KeplerianElements { + reference_epoch: 60672.2443617134, + semi_major_axis: 3.2199380906809876, + eccentricity: 0.0624192099888107, + inclination: 0.1829771029880289, + ascending_node_longitude: 0.030775930195064964, + periapsis_argument: 1.9053705720223801, + mean_anomaly: 4.980622835177979, + }, + uncertainty: None, + covariance: None, + }, + rms: 0.5739558189489471, + }, + ExpectedResult { + orbit: OrbitalElements::Keplerian { + elements: KeplerianElements { + reference_epoch: 60465.26777915681, + semi_major_axis: 2.1874983804796972, + eccentricity: 0.20256414489486008, + inclination: 0.11906245183260411, + ascending_node_longitude: 3.0918063960305293, + periapsis_argument: 2.4793248309745692, + mean_anomaly: 4.934465465531324, + }, + uncertainty: None, + covariance: None, + }, + rms: 18.963755528781288, + }, + ] +} - let path_file = Utf8Path::new("tests/data/8467.obs"); - traj_set.add_from_80col(&mut env_state, path_file); +fn build_test_fixtures() -> (JPLEphem, Ut1Provider, ObsDataset, IODParams) { + let ut1_provider = Ut1Provider::download_from_jpl("latest_eop2.long") + .expect("Download of the JPL short time scale UT1 data failed"); - let path_file = Utf8Path::new("tests/data/33803.obs"); - traj_set.add_from_80col(&mut env_state, path_file); + let jpl_ephem: JPLEphem = "horizon:DE440" + .try_into() + .expect("Failed to load JPL ephemeris"); - let (best_orbit, best_rms) = run_iod( - &mut env_state, - &mut traj_set, - &ObjectNumber::String("K09R05F".into()), - ) - .unwrap(); + let (obs_dataset, errors) = ObsDataset::from_mpc_80_col_files(&[ + "tests/data/2015AB.obs", + "tests/data/8467.obs", + "tests/data/33803.obs", + ]); + if !errors.is_empty() { + panic!("Failed to load observation datasets: {:?}", errors); + } - let expected_orbit = OrbitalElements::Keplerian(KeplerianElements { - reference_epoch: 57049.22904452732, - semi_major_axis: 1.8017634341924542, - eccentricity: 0.28360400982137396, - inclination: 0.20267485730439427, - ascending_node_longitude: 0.00810182022710516, - periapsis_argument: 1.2445523487100616, - mean_anomaly: 0.44069989140091426, - }); + let iod_params = IODParams::builder() + .n_noise_realizations(10) + .noise_scale(1.1) + .max_obs_for_triplets(130) + .max_triplets(30) + .build() + .unwrap(); - let orbit = best_orbit.get_orbit(); + (jpl_ephem, ut1_provider, obs_dataset, iod_params) +} - assert!(approx_equal(&expected_orbit, orbit, test_epsilon)); +fn assert_iod_results(mut full_orbit: FullOrbitResult, test_epsilon: f64, test_max_relative: f64) { + // K09R05F + let expected = &expected_results()[0]; + let best_orbit = full_orbit.remove(&"K09R05F".into()).unwrap().unwrap(); + assert!(approx_equal( + &expected.orbit, + best_orbit.orbital_elements(), + test_epsilon + )); assert_relative_eq!( - best_rms, - 47.67954270293223, + best_orbit.orbit_quality(), + expected.rms, epsilon = test_epsilon, max_relative = test_max_relative ); - let (best_orbit, best_rms) = run_iod( - &mut env_state, - &mut traj_set, - &ObjectNumber::String("8467".into()), - ) - .unwrap(); - - let expected_orbit = OrbitalElements::Keplerian(KeplerianElements { - reference_epoch: 60672.24113100201, - semi_major_axis: 3.189546977249391, - eccentricity: 0.05434034666134485, - inclination: 0.18343383575588465, - ascending_node_longitude: 0.03253594968161228, - periapsis_argument: 2.0197545218038355, - mean_anomaly: 4.85070383704545, - }); - - let orbit = best_orbit.get_orbit(); - - assert!(approx_equal(&expected_orbit, orbit, test_epsilon)); + // 8467 + let expected = &expected_results()[1]; + let best_orbit = full_orbit.remove(&8467_u32.into()).unwrap().unwrap(); + assert!(approx_equal( + &expected.orbit, + best_orbit.orbital_elements(), + test_epsilon + )); assert_relative_eq!( - best_rms, - 0.550927559734816, + best_orbit.orbit_quality(), + expected.rms, epsilon = test_epsilon, max_relative = test_max_relative ); - let (best_orbit, best_rms) = run_iod( - &mut env_state, - &mut traj_set, - &ObjectNumber::String("33803".into()), - ) - .unwrap(); - - let expected_orbit = OrbitalElements::Keplerian(KeplerianElements { - reference_epoch: 60465.26778016307, - semi_major_axis: 2.192136202201971, - eccentricity: 0.2042936374305811, - inclination: 0.1189651192106584, - ascending_node_longitude: 3.091130251223283, - periapsis_argument: 2.4714439663661487, - mean_anomaly: 4.9466622638827324, - }); - - let orbit = best_orbit.get_orbit(); - - assert!(approx_equal(&expected_orbit, orbit, test_epsilon)); + // 33803 + let expected = &expected_results()[2]; + let best_orbit = full_orbit.remove(&33803_u32.into()).unwrap().unwrap(); + assert!(approx_equal( + &expected.orbit, + best_orbit.orbital_elements(), + test_epsilon + )); assert_relative_eq!( - best_rms, - 6.319395085728921, + best_orbit.orbit_quality(), + expected.rms, epsilon = test_epsilon, max_relative = test_max_relative ); } + +#[test] +fn test_gauss_iod() { + let test_epsilon = 1e-11; + let test_max_relative = 1e-11; + + let (jpl_ephem, ut1_provider, obs_dataset, iod_params) = build_test_fixtures(); + + let full_orbit = obs_dataset + .fit_full_iod( + &jpl_ephem, + &ut1_provider, + &iod_params, + ObsErrorModel::FCCT14, + &mut StdRng::seed_from_u64(42), + ) + .unwrap(); + + assert_iod_results(full_orbit, test_epsilon, test_max_relative); +} + +#[test] +#[cfg(feature = "parallel")] +fn test_gauss_iod_parallel() { + let test_epsilon = 1e-11; + let test_max_relative = 1e-11; + + let (jpl_ephem, ut1_provider, obs_dataset, iod_params) = build_test_fixtures(); + + let full_orbit = obs_dataset + .fit_full_iod_parallel( + &jpl_ephem, + &ut1_provider, + &iod_params, + ObsErrorModel::FCCT14, + &mut StdRng::seed_from_u64(42), + ) + .unwrap(); + + assert_iod_results(full_orbit, test_epsilon, test_max_relative); +} diff --git a/tests/test_iod_from_polars.rs b/tests/test_iod_from_polars.rs new file mode 100644 index 0000000..353d01c --- /dev/null +++ b/tests/test_iod_from_polars.rs @@ -0,0 +1,153 @@ +mod common; + +use approx::assert_relative_eq; +use hifitime::ut1::Ut1Provider; +use outfit::orbit_type::{keplerian_element::KeplerianElements, OrbitalElements}; +use outfit::{FitIOD, IODParams, JPLEphem}; +use photom::io::polars::ContiguousChoice; +use photom::{ + io::polars::FromPolarsArgs, observation_dataset::ObsDataset, + observer::error_model::ObsErrorModel, +}; +use polars::{ + lazy::{ + dsl::{col, lit}, + frame::LazyFrame, + }, + prelude::NamedFrom, + series::Series, +}; +use rand::{rngs::StdRng, SeedableRng}; + +use crate::common::approx_equal; + +#[test] +fn test_iod_from_polars() { + let test_max_relative = 1e-11; + let test_epsilon = 1e-11; + + let path_data = "tests/data/test_data_traj_str.parquet"; + + let ids = Series::new("".into(), ["95777", "14226", "29757"]); + let lf = LazyFrame::scan_parquet(path_data.into(), Default::default()) + .expect("scan_parquet must succeed") + .filter(col("traj_id").is_in(lit(ids).implode(), true)); + + let polars_args = FromPolarsArgs { + error_model: Some(ObsErrorModel::FCCT14), + do_rechunk: Some(false), + contiguous_choice: Some(ContiguousChoice::ContiguousTraj), + }; + + let obs_dataset = ObsDataset::from_lazy(lf, polars_args).unwrap(); + + let max_traj_size = obs_dataset + .iter_traj_id() + .unwrap() + .map(|traj_id| obs_dataset.len_trajectory(traj_id).unwrap()) + .max() + .unwrap(); + + let default = IODParams::builder() + .n_noise_realizations(10) + .noise_scale(1.1) + .max_obs_for_triplets(max_traj_size) + .max_triplets(30) + .build() + .unwrap(); + + let ut1_provider = Ut1Provider::download_from_jpl("latest_eop2.long") + .expect("Download of the JPL short time scale UT1 data failed"); + + let jpl_ephem: JPLEphem = "horizon:DE440" + .try_into() + .expect("Failed to load JPL ephemeris"); + + let mut full_orbit = obs_dataset + .fit_full_iod( + &jpl_ephem, + &ut1_provider, + &default, + ObsErrorModel::FCCT14, + &mut StdRng::seed_from_u64(42), + ) + .unwrap(); + + // --- traj 14226 --- + let best_orbit = full_orbit.remove(&"14226".into()).unwrap().unwrap(); + let orbit = best_orbit.orbital_elements(); + + let expected_orbit = OrbitalElements::Keplerian { + elements: KeplerianElements { + reference_epoch: 60894.372896385554, + semi_major_axis: 0.5415009930884174, + eccentricity: 0.9027228307040831, + inclination: 0.31200939353818746, + ascending_node_longitude: 5.550735343593096, + periapsis_argument: 3.1638244350882596, + mean_anomaly: 2.7888128618151495, + }, + uncertainty: None, + covariance: None, + }; + + assert!(approx_equal(&expected_orbit, orbit, test_epsilon)); + assert_relative_eq!( + best_orbit.orbit_quality(), + 0.02704195897369085, + epsilon = test_epsilon, + max_relative = test_max_relative + ); + + // --- traj 29757 --- + let best_orbit = full_orbit.remove(&"29757".into()).unwrap().unwrap(); + let orbit = best_orbit.orbital_elements(); + + let expected_orbit = OrbitalElements::Keplerian { + elements: KeplerianElements { + reference_epoch: 60835.25573266984, + semi_major_axis: 0.635955220245824, + eccentricity: 0.5904849550180079, + inclination: 0.2263529126300279, + ascending_node_longitude: 4.366539949885583, + periapsis_argument: 3.3107966723035602, + mean_anomaly: 3.0157533331616966, + }, + uncertainty: None, + covariance: None, + }; + + assert!(approx_equal(&expected_orbit, orbit, test_epsilon)); + assert_relative_eq!( + best_orbit.orbit_quality(), + 0.025397381294328548, + epsilon = test_epsilon, + max_relative = test_max_relative + ); + + // --- traj 95777 --- + let best_orbit = full_orbit.remove(&"95777".into()).unwrap().unwrap(); + let orbit = best_orbit.orbital_elements(); + + let expected_orbit = OrbitalElements::Keplerian { + elements: KeplerianElements { + reference_epoch: 60894.252965553926, + semi_major_axis: 1.24701989952089, + eccentricity: 0.2082069422196415, + inclination: 0.08116316040114972, + ascending_node_longitude: 2.49554922649176, + periapsis_argument: 2.5470318525197477, + mean_anomaly: 0.2983936748249412, + }, + uncertainty: None, + covariance: None, + }; + + assert!(approx_equal(&expected_orbit, orbit, test_epsilon)); + assert_relative_eq!( + best_orbit.orbit_quality(), + 0.010284390096535293, + epsilon = test_epsilon, + max_relative = test_max_relative + ); +} diff --git a/tests/test_orbit_uncertainty_propag.rs b/tests/test_orbit_uncertainty_propag.rs new file mode 100644 index 0000000..6799f5c --- /dev/null +++ b/tests/test_orbit_uncertainty_propag.rs @@ -0,0 +1,161 @@ +mod common; + +use outfit::{ + orbit_type::uncertainty::{EquinoctialUncertainty, KeplerianUncertainty, OrbitalCovariance}, + EquinoctialElements, KeplerianElements, OrbitalElements, +}; + +use crate::common::approx_equal; + +#[test] +fn test_uncertainty_propagation() { + let equinoctial_orbit = OrbitalElements::Equinoctial { + elements: EquinoctialElements { + reference_epoch: 57049.2684537375, + semi_major_axis: 1.8021517900042052, + eccentricity_sin_lon: 0.2694922786015968, + eccentricity_cos_lon: 0.08955282358108035, + tan_half_incl_sin_node: 0.0008974287327937245, + tan_half_incl_cos_node: 0.10167548786557225, + mean_longitude: 1.6921653421358704, + }, + uncertainty: Some(EquinoctialUncertainty { + semi_major_axis: 1.910876358918557e-6, + eccentricity_sin_lon: 2.7271919973585478e-6, + eccentricity_cos_lon: 1.2559941333300101e-5, + tan_half_incl_sin_node: 6.143234310625764e-7, + tan_half_incl_cos_node: 1.1476173256703189e-6, + mean_longitude: 2.1064465635865037e-5, + }), + covariance: Some(OrbitalCovariance { + matrix: [ + [ + 3.651448459073842e-12, + -4.87907485491453e-13, + 2.321298362132558e-11, + -3.7695250201166625e-13, + 8.511532638002078e-13, + -3.91138523482157e-11, + ], + [ + -4.879074854914533e-13, + 7.437576190456506e-12, + -1.1647669978804286e-11, + 9.359797430147383e-13, + -2.8577594338429333e-12, + 1.853502993770551e-11, + ], + [ + 2.3212983621325566e-11, + -1.164766997880434e-11, + 1.577521262959403e-10, + -3.47676746499932e-12, + 8.610023673871895e-12, + -2.644913915663376e-10, + ], + [ + -3.7695250201166625e-13, + 9.359797430147385e-13, + -3.4767674649993202e-12, + 3.7739327795249603e-13, + -5.048815271306508e-13, + 5.7505636344116006e-12, + ], + [ + 8.511532638002078e-13, + -2.857759433842935e-12, + 8.610023673871898e-12, + -5.048815271306507e-13, + 1.3170255261786945e-12, + -1.4110008489365913e-11, + ], + [ + -3.911385234821569e-11, + 1.8535029937705585e-11, + -2.6449139156633765e-10, + 5.750563634411601e-12, + -1.4110008489365913e-11, + 4.437117125245391e-10, + ], + ] + .into(), + }), + }; + + let keplerian_orbit = equinoctial_orbit.to_keplerian().unwrap(); + + let expected_keplerian_propag = OrbitalElements::Keplerian { + elements: KeplerianElements { + reference_epoch: 57049.2684537375, + semi_major_axis: 1.8021517900042052, + eccentricity: 0.2839820354128493, + inclination: 0.20266238925780133, + ascending_node_longitude: 0.008826172835575467, + periapsis_argument: 1.2411480851756391, + mean_anomaly: 0.4421910841246559, + }, + uncertainty: Some(KeplerianUncertainty { + semi_major_axis: 1.910876358918557e-6, + eccentricity: 3.926080684435881e-6, + inclination: 2.2639852329024065e-6, + ascending_node_longitude: 6.113264876575711e-6, + periapsis_argument: 4.049775340683106e-5, + mean_anomaly: 2.2182426229638676e-5, + }), + covariance: Some(OrbitalCovariance { + matrix: [ + [ + 3.651448459073842e-12, + 6.857127156611333e-12, + 1.6782354228854548e-12, + -3.781001511911568e-12, + -7.433110873463038e-11, + 3.899825789832625e-11, + ], + [ + 6.857127156611329e-12, + 1.5414109540700513e-11, + 2.690953229794561e-15, + -2.0474618140821963e-12, + -1.2349406349235225e-10, + 5.97243215927523e-11, + ], + [ + 1.6782354228854548e-12, + 2.6909532297930087e-15, + 5.1256291348001634e-12, + -9.989144038881854e-12, + -5.3024087432235095e-11, + 3.518354634255312e-11, + ], + [ + -3.781001511911568e-12, + -2.047461814082196e-12, + -9.989144038881855e-12, + 3.7372007451174244e-11, + 8.98813435388229e-11, + -6.947495524468516e-11, + ], + [ + -7.433110873463033e-11, + -1.2349406349235207e-10, + -5.302408743223507e-11, + 8.988134353882289e-11, + 1.6400680310004965e-9, + -8.833005679743845e-10, + ], + [ + 3.8998257898326207e-11, + 5.972432159275218e-11, + 3.5183546342553095e-11, + -6.947495524468513e-11, + -8.833005679743845e-10, + 4.920600334333619e-10, + ], + ] + .into(), + }), + }; + + approx_equal(&expected_keplerian_propag, &keplerian_orbit, 1e-10); +} diff --git a/tests/test_read_ades.rs b/tests/test_read_ades.rs deleted file mode 100644 index 32d9d32..0000000 --- a/tests/test_read_ades.rs +++ /dev/null @@ -1,56 +0,0 @@ -use camino::Utf8Path; -use outfit::constants::ObjectNumber; -use outfit::error_models::ErrorModel; -use outfit::outfit::Outfit; -use outfit::trajectories::trajectory_file::TrajectoryFile; -use outfit::TrajectorySet; - -#[test] -fn test_read_ades() { - let mut outfit = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - - let mut traj_set = TrajectorySet::new_from_ades( - &mut outfit, - Utf8Path::new("tests/data/example_ades.xml"), - None, - None, - ); - assert_eq!(traj_set.len(), 4); - assert_eq!(traj_set.get(&ObjectNumber::Int(1234457)).unwrap().len(), 1); - - traj_set.add_from_ades( - &mut outfit, - Utf8Path::new("tests/data/example_ades2.xml"), - None, - None, - ); - - assert_eq!(traj_set.len(), 7); - let traj = traj_set - .get(&ObjectNumber::String("2016 RD34".into())) - .unwrap(); - assert_eq!(traj.len(), 2); - let obs = traj.first().unwrap().get_observer(&outfit); - assert_eq!( - *obs.name.as_ref().unwrap(), - "University of Hawaii 88-inch telescope, Maunakea".to_string() - ); - - traj_set.add_from_ades( - &mut outfit, - Utf8Path::new("tests/data/flat_ades.xml"), - None, - None, - ); - assert_eq!(traj_set.len(), 41); - - let traj = traj_set - .get(&ObjectNumber::String("D/1993 F2-W".into())) - .unwrap(); - - assert_eq!(traj.len(), 1); - assert_eq!( - traj.first().unwrap().get_observer(&outfit).name, - Some("La Palma".into()) - ); -} diff --git a/tests/trajectories_from_parquet.rs b/tests/trajectories_from_parquet.rs deleted file mode 100644 index 7a0b83f..0000000 --- a/tests/trajectories_from_parquet.rs +++ /dev/null @@ -1,69 +0,0 @@ -use camino::Utf8Path; -use outfit::constants::ObjectNumber; -use outfit::error_models::ErrorModel; -use outfit::outfit::Outfit; -use outfit::trajectories::trajectory_file::TrajectoryFile; -use outfit::TrajectorySet; - -#[test] -fn test_load_traj_from_parquet() { - let mut env_state = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - let path_file = Utf8Path::new("tests/data/trajectories.parquet"); - - let ztf_observer = env_state.get_observer_from_mpc_code(&"I41".into()); - - let traj_set = - TrajectorySet::new_from_parquet(&mut env_state, path_file, ztf_observer, 0.5, 0.5, None) - .unwrap(); - assert_eq!(traj_set.len(), 4); - assert_eq!(traj_set.get(&ObjectNumber::Int(1)).unwrap().len(), 3); -} - -#[test] -fn test_large_parquet() { - let mut env_state = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - let path_file = Utf8Path::new("tests/data/test_from_fink.parquet"); - - let ztf_observer = env_state.get_observer_from_mpc_code(&"I41".into()); - - let mut traj_set = - TrajectorySet::new_from_parquet(&mut env_state, path_file, ztf_observer, 0.5, 0.5, None) - .unwrap(); - - assert_eq!(traj_set.len(), 2082); - assert_eq!(traj_set.get(&ObjectNumber::Int(1)).unwrap().len(), 6); - assert_eq!(traj_set.get(&ObjectNumber::Int(2)).unwrap().len(), 7); - assert_eq!(traj_set.get(&ObjectNumber::Int(3)).unwrap().len(), 7); - assert_eq!(traj_set.get(&ObjectNumber::Int(4)).unwrap().len(), 7); - - let path_file = Utf8Path::new("tests/data/trajectories.parquet"); - let rubin_observer = env_state.get_observer_from_mpc_code(&"X05".into()); - traj_set - .add_from_parquet(&mut env_state, path_file, rubin_observer, 0.5, 0.5, None) - .unwrap(); - - assert_eq!(traj_set.len(), 2082); - let traj = traj_set.get(&ObjectNumber::Int(1)).unwrap(); - assert_eq!(traj.len(), 9); - assert_eq!(traj_set.get(&ObjectNumber::Int(2)).unwrap().len(), 10); - assert_eq!(traj_set.get(&ObjectNumber::Int(3)).unwrap().len(), 10); - assert_eq!(traj_set.get(&ObjectNumber::Int(4)).unwrap().len(), 10); - - let first_obs = traj.first().unwrap(); - assert_eq!(first_obs.ra, 0.5833713125827732); - assert_eq!(first_obs.dec, 0.4110543900466954); - assert_eq!(first_obs.time, 58789.138125000056); - assert_eq!( - first_obs.get_observer(&env_state).name, - Some("Palomar Mountain--ZTF".into()) - ); - - let second_obs = traj.get(6).unwrap(); - assert_eq!(second_obs.ra, 3.353185900126074); - assert_eq!(second_obs.dec, -0.09911550289150597); - assert_eq!(second_obs.time, 59396.0); - assert_eq!( - second_obs.get_observer(&env_state).name, - Some("Simonyi Survey Telescope, Rubin Observatory".into()) - ); -} diff --git a/tests/trajectories_from_vec.rs b/tests/trajectories_from_vec.rs deleted file mode 100644 index 2cd9ace..0000000 --- a/tests/trajectories_from_vec.rs +++ /dev/null @@ -1,68 +0,0 @@ -use outfit::constants::ObjectNumber; -use outfit::error_models::ErrorModel; -use outfit::outfit::Outfit; -use outfit::trajectories::{batch_reader::ObservationBatch, trajectory_file::TrajectoryFile}; -use outfit::TrajectorySet; - -use std::sync::Arc; - -#[test] -fn test_trajectories_from_vec() { - let mut env_state = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - - // ---------- Obj 33803 ---------- - let traj_id = vec![33803]; - let ra_deg = vec![359.7403333333333_f64]; - let dec_deg = vec![-0.5039444444444444_f64]; - let time = vec![43041.93878_f64]; - let observer = env_state.get_observer_from_mpc_code(&"049".to_string()); // Uppsala-Kvistaberg - - let batch = ObservationBatch::from_degrees_owned(&traj_id, &ra_deg, &dec_deg, 0.5, 0.5, &time); - - let mut traj_set: TrajectorySet = - TrajectorySet::new_from_vec(&mut env_state, &batch, observer).unwrap(); - - assert_eq!(traj_set.len(), 1); - let obs_33803 = traj_set.get(&ObjectNumber::Int(33803)).unwrap(); - assert_eq!(obs_33803.len(), 1); - - assert!((obs_33803[0].time - 43041.93878).abs() < 1e-12); - - let expected_ra_rad = 359.7403333333333_f64.to_radians(); - let expected_dec_rad = (-0.5039444444444444_f64).to_radians(); - assert!((obs_33803[0].ra - expected_ra_rad).abs() < 1e-12); - assert!((obs_33803[0].dec - expected_dec_rad).abs() < 1e-12); - - assert_eq!( - obs_33803[0].get_observer(&env_state).name, - Some("Uppsala-Kvistaberg".to_string()) - ); - - // ---------- Obj 8467 ---------- - let traj_id = vec![8467]; - let ra_deg = vec![14.62025_f64]; - let dec_deg = vec![9.987777777777778_f64]; - let time = vec![43785.35799_f64]; - let observer: Arc<_> = env_state.get_observer_from_mpc_code(&"675".to_string()); // Palomar Mountain - - let batch = ObservationBatch::from_degrees_owned(&traj_id, &ra_deg, &dec_deg, 0.5, 0.5, &time); - - traj_set - .add_from_vec(&mut env_state, &batch, observer) - .unwrap(); - - assert_eq!(traj_set.len(), 2); - let obs_8467 = traj_set.get(&ObjectNumber::Int(8467)).unwrap(); - assert_eq!(obs_8467.len(), 1); - assert!((obs_8467[0].time - 43785.35799).abs() < 1e-12); - - let expected_ra_rad = 14.62025_f64.to_radians(); - let expected_dec_rad = 9.987777777777778_f64.to_radians(); - assert!((obs_8467[0].ra - expected_ra_rad).abs() < 1e-12); - assert!((obs_8467[0].dec - expected_dec_rad).abs() < 1e-12); - - assert_eq!( - obs_8467[0].get_observer(&env_state).name, - Some("Palomar Mountain".to_string()) - ); -} diff --git a/tests/vec_to_iod.rs b/tests/vec_to_iod.rs deleted file mode 100644 index 1cdea9a..0000000 --- a/tests/vec_to_iod.rs +++ /dev/null @@ -1,281 +0,0 @@ -#![cfg(feature = "jpl-download")] - -use std::collections::HashMap; -use std::f64::consts::PI; -use std::hash::DefaultHasher; - -use approx::assert_relative_eq; -use outfit::time::utc_jd_slice_to_tt_mjd; -use outfit::trajectories::trajectory_fit::{gauss_result_for, FullOrbitResult}; -use outfit::ObservationIOD; -use outfit::{ - trajectories::batch_reader::ObservationBatch, ErrorModel, IODParams, Outfit, TrajectoryFile, - TrajectorySet, -}; -use outfit::{KeplerianElements, ObjectNumber}; -use rand::rngs::StdRng; -use rand::SeedableRng; -use std::hash::{Hash, Hasher}; - -#[inline] -fn angle_abs_diff(a: f64, b: f64) -> f64 { - let tau = 2.0 * PI; - let mut d = (a - b) % tau; - if d > PI { - d -= tau; - } - if d < -PI { - d += tau; - } - d.abs() -} - -pub fn assert_keplerian_approx_eq( - got: &KeplerianElements, - exp: &KeplerianElements, - abs_eps: f64, - rel_eps: f64, -) { - // Scalars (non-angular) - assert_relative_eq!( - got.reference_epoch, - exp.reference_epoch, - epsilon = abs_eps, - max_relative = rel_eps - ); - assert_relative_eq!( - got.semi_major_axis, - exp.semi_major_axis, - epsilon = abs_eps, - max_relative = rel_eps - ); - assert_relative_eq!( - got.eccentricity, - exp.eccentricity, - epsilon = abs_eps, - max_relative = rel_eps - ); - - // Angles (radians), compare with wrap-around - for (name, g, e) in [ - ("inclination", got.inclination, exp.inclination), - ( - "ascending_node_longitude", - got.ascending_node_longitude, - exp.ascending_node_longitude, - ), - ( - "periapsis_argument", - got.periapsis_argument, - exp.periapsis_argument, - ), - ("mean_anomaly", got.mean_anomaly, exp.mean_anomaly), - ] { - let diff = angle_abs_diff(g, e); - // Allow absolute OR relative tolerance (whichever is larger). - let tol = abs_eps.max(rel_eps * e.abs()); - assert!( - diff <= tol, - "Angle {name:?} differs too much: |Δ| = {diff:.6e} > tol {tol:.6e} (got={g:.15}, exp={e:.15})" - ); - } -} - -fn assert_orbit( - orbits: &FullOrbitResult, - object_number: &ObjectNumber, - exp_orbit: &KeplerianElements, - exp_rms: f64, - abs_eps: f64, - rel_eps: f64, -) { - let traj_orbit = gauss_result_for(orbits, object_number).unwrap().unwrap(); - - let got_orbit = traj_orbit.0.as_inner().as_keplerian().unwrap(); - let got_rms = traj_orbit.1; - - assert_keplerian_approx_eq(got_orbit, exp_orbit, abs_eps, rel_eps); - - assert_relative_eq!(got_rms, exp_rms, epsilon = abs_eps, max_relative = rel_eps); -} - -/// Derive a deterministic per-object seed from a global base seed and an [`ObjectNumber`]. -/// -/// This is used only in tests to ensure reproducible random number sequences -/// per object, independent of the `HashMap` iteration order. -/// -/// Arguments -/// ----------------- -/// * `base`: The global base seed (constant for the whole test run). -/// * `obj`: The object identifier used to derive a unique seed. -/// -/// Return -/// ---------- -/// * A `u64` seed value that can be fed into [`StdRng::seed_from_u64`]. -fn seed_for_object(base: u64, obj: &ObjectNumber) -> u64 { - let mut h = DefaultHasher::new(); - obj.hash(&mut h); - let oh = h.finish(); - base ^ oh.rotate_left(17).wrapping_mul(0x9E37_79B9_7F4A_7C15) -} - -#[test] -fn vec_to_iod() { - // Create the Outfit environment with JPL DE440 ephemerides and - // the FCCT14 astrometric error model. - let mut env_state = Outfit::new("horizon:DE440", ErrorModel::FCCT14).unwrap(); - - // Build the parameters for the Initial Orbit Determination (IOD). - // - n_noise_realizations: number of noisy clones per triplet - // - noise_scale: scale factor for astrometric uncertainties - // - max_obs_for_triplets: upper bound on how many observations are used - // - max_triplets: maximum number of triplets explored - let params = IODParams::builder() - .n_noise_realizations(10) - .noise_scale(1.0) - .max_obs_for_triplets(12) - .max_triplets(30) - .build() - .unwrap(); - - // Trajectory IDs: link each RA/DEC/time entry to one of the 3 objects (0,1,2). - let traj_id = vec![0, 1, 2, 1, 2, 1, 0, 0, 0, 1, 2, 1, 1, 0, 2, 2, 0, 2, 2]; - - // Right ascension values in degrees. - let ra_deg = vec![ - 20.9191548, 33.4247141, 32.1435128, 33.4159091, 32.1347282, 33.3829299, 20.6388309, - 20.6187259, 20.6137886, 32.7525147, 31.4874917, 32.4518231, 32.4495403, 19.892738, - 30.6416348, 30.0938936, 18.2218784, 28.3859403, 28.3818327, - ]; - - // Declination values in degrees. - let dec_deg = vec![ - 20.0550441, 23.5516817, 26.5139615, 23.5525348, 26.5160622, 23.5555991, 20.1218532, - 20.1264229, 20.1275173, 23.6064063, 26.6622284, 23.6270392, 23.6272157, 20.2977473, - 26.830301, 26.9256271, 20.7096409, 27.1602652, 27.160642, - ]; - - // Convert Julian Dates (UTC) to Modified Julian Dates (TT). - let time = utc_jd_slice_to_tt_mjd(&[ - 2458789.6362963, - 2458789.638125, - 2458789.638125, - 2458789.6663773, - 2458789.6663773, - 2458789.7706481, - 2458790.6995023, - 2458790.7733333, - 2458790.791412, - 2458791.8445602, - 2458791.8445602, - 2458792.8514699, - 2458792.8590741, - 2458793.6896759, - 2458794.7996759, - 2458796.7965162, - 2458801.7863426, - 2458803.7699537, - 2458803.7875231, - ]); - - // Resolve the observer from MPC code I41 (ZTF - Palomar). - let observer = env_state.get_observer_from_mpc_code(&"I41".to_string()); - - // Build a batch of observations from RA/DEC/time arrays, with fixed uncertainties. - // MJD needs to be in TT time scale for the IOD. - let batch = ObservationBatch::from_degrees_owned(&traj_id, &ra_deg, &dec_deg, 0.5, 0.5, &time); - - // Build the trajectory set (group observations by object). - let mut traj_set: TrajectorySet = - TrajectorySet::new_from_vec(&mut env_state, &batch, observer).unwrap(); - - // Collect object identifiers and sort them for deterministic iteration order. - let mut keys: Vec<_> = traj_set.keys().cloned().collect(); - keys.sort(); - - // Base seed for reproducibility across runs. - let base_seed = 42u64; - - // Container for final orbit results. - let mut orbits: FullOrbitResult = HashMap::default(); - - // For each object (in stable sorted order), derive a sub-RNG - // from the base seed and object identifier. - // This ensures reproducibility independent of HashMap iteration order. - for obj in keys { - let obs = traj_set.get_mut(&obj).expect("exists"); - let mut sub_rng = StdRng::seed_from_u64(seed_for_object(base_seed, &obj)); - - // Perform Initial Orbit Determination (IOD) for this trajectory. - let res = - obs.estimate_best_orbit(&env_state, &env_state.error_model, &mut sub_rng, ¶ms); - - orbits.insert(obj.clone(), res); - } - - dbg!(&orbits); - - // ----- Validation for object 0 ----- - let exp_orbit = KeplerianElements { - reference_epoch: 58793.18441761835, - semi_major_axis: 2.6800907148611213, - eccentricity: 0.26569343266377593, - inclination: 0.26583378821788056, - ascending_node_longitude: 0.22346768893017788, - periapsis_argument: 6.2312396358686435, - mean_anomaly: 0.25070862689966067, - }; - let exp_rms = 0.41763854460398925; - - // Check that the computed orbit for object 0 matches the expected orbit. - assert_orbit( - &orbits, - &ObjectNumber::Int(0), - &exp_orbit, - exp_rms, - 1e-6, - 1e-6, - ); - - // ----- Validation for object 1 ----- - let exp_orb_1 = KeplerianElements { - reference_epoch: 58791.33825502848, - semi_major_axis: 2.703826713146142, - eccentricity: 0.30117809906476944, - inclination: 0.25266176525888534, - ascending_node_longitude: 0.317864582295561, - periapsis_argument: 5.476805665094764, - mean_anomaly: 0.6842587006557854, - }; - let exp_rms_1 = 0.11855689104894801; - - assert_orbit( - &orbits, - &ObjectNumber::Int(1), - &exp_orb_1, - exp_rms_1, - 1e-6, - 1e-6, - ); - - // ----- Validation for object 2 ----- - let exp_orb_2 = KeplerianElements { - reference_epoch: 58796.291754201615, - semi_major_axis: 2.644301062854281, - eccentricity: 0.29340523662320855, - inclination: 0.23464394041365277, - ascending_node_longitude: 0.21103621973001643, - periapsis_argument: 6.263448848536585, - mean_anomaly: 0.2960051245443382, - }; - let exp_rms_2 = 0.3095556572660671; - - assert_orbit( - &orbits, - &ObjectNumber::Int(2), - &exp_orb_2, - exp_rms_2, - 1e-6, - 1e-6, - ); -}