"]
[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
-
+
-> **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