Skip to main content

sidereon_core/
orbit_determination.rs

1//! SP3-anchored numerical orbit determination and residual ledgers.
2//!
3//! The fit estimates one inertial Cartesian initial state per satellite from a
4//! batch of precise ephemeris samples. Input positions follow the SP3 convention:
5//! ITRS/ECEF meters at scale-tagged epochs. They are transformed to GCRS
6//! kilometers with the shared frame pipeline before the numerical propagator is
7//! evaluated. Reported residuals are projected into the propagated state's RTN
8//! frame and accumulated per satellite and per constellation.
9
10use std::cell::RefCell;
11use std::collections::BTreeMap;
12
13use nalgebra::{DMatrix, DVector};
14
15use crate::astro::covariance::{rtn_to_eci_rotation, RtnFrameError};
16use crate::astro::error::PropagationError;
17use crate::astro::forces::{DragParameters, SpaceWeatherSource};
18use crate::astro::frames::orientation::{EarthOrientation, EarthOrientationProvider};
19use crate::astro::frames::transforms::{
20    gcrs_to_itrs_compute, itrs_to_gcrs_compute, FrameTransformError,
21};
22use crate::astro::iod;
23use crate::astro::math::least_squares::{
24    self, singular_value_diagnostics, solve_trf_with, LeastSquaresProblem, SolveError,
25    SolveOptions, Status, TrustRegionSolve,
26};
27use crate::astro::propagator::{
28    ForceModelKind, IntegratorKind, IntegratorOptions, PropagationContext, StatePropagator,
29};
30use crate::astro::state::CartesianState;
31use crate::astro::time::civil::{civil_from_j2000_seconds, j2000_seconds_from_split};
32use crate::astro::time::model::{Instant, TimeScale};
33use crate::astro::time::scales::TimeScales;
34use crate::constants::{M_PER_KM, SECONDS_PER_DAY};
35use crate::geometry_quality::{classify, GeometryQuality, GeometryQualityThresholds};
36use crate::sp3::{sp3_ecef_state_to_eci, PreciseEphemerisSample, PreciseEphemerisStateSample, Sp3};
37use crate::{GnssSatelliteId, GnssSystem};
38
39const STATE_PARAM_COUNT: usize = 6;
40const MIN_SEED_SAMPLES: usize = 2;
41const DEFAULT_MIN_LEDGER_SAMPLES: usize = 3;
42
43/// Options controlling a precise-orbit fit.
44#[derive(Debug, Clone)]
45pub struct OrbitFitOptions {
46    /// Force model used by the numerical propagator.
47    pub force_model: ForceModelKind,
48    /// Integrator used by the numerical propagator.
49    pub integrator: IntegratorKind,
50    /// Step-size and tolerance controls for propagation.
51    pub integrator_options: IntegratorOptions,
52    /// Nonlinear least-squares stopping tolerances and evaluation budget.
53    pub solver_options: SolveOptions,
54    /// Dense subproblem solve used by the least-squares iteration.
55    pub linear_solve: TrustRegionSolve,
56    /// Geometry classifier thresholds for the final design matrix.
57    pub geometry_thresholds: GeometryQualityThresholds,
58    /// Minimum residual count before a ledger entry is no longer marked low-n.
59    pub min_ledger_samples: usize,
60    /// Optional atmospheric drag parameters layered on the selected force model.
61    pub drag: Option<DragParameters>,
62    /// Optional per-epoch space-weather source for drag.
63    pub space_weather: Option<SpaceWeatherSource>,
64    /// Propagation context shared with force models during fitting. Tide
65    /// force models require a body-fixed frame provider here.
66    pub propagation_context: PropagationContext,
67}
68
69impl Default for OrbitFitOptions {
70    fn default() -> Self {
71        Self {
72            force_model: ForceModelKind::earth_phase_a(None),
73            integrator: IntegratorKind::Dp54,
74            integrator_options: IntegratorOptions::default(),
75            solver_options: SolveOptions {
76                gtol: 1.0e-12,
77                ftol: 1.0e-12,
78                xtol: 1.0e-12,
79                max_nfev: 500,
80            },
81            linear_solve: TrustRegionSolve::OwnedGaussianFirstTie,
82            geometry_thresholds: GeometryQualityThresholds::default(),
83            min_ledger_samples: DEFAULT_MIN_LEDGER_SAMPLES,
84            drag: None,
85            space_weather: None,
86            propagation_context: PropagationContext::default(),
87        }
88    }
89}
90
91/// State-covariance result for a fitted initial state.
92#[derive(Debug, Clone, PartialEq)]
93pub enum OrbitFitCovariance {
94    /// Estimated row-major covariance for `[x_km, y_km, z_km, vx_km_s,
95    /// vy_km_s, vz_km_s]`.
96    Estimated {
97        /// Row-major state covariance matrix.
98        matrix: Box<[[f64; STATE_PARAM_COUNT]; STATE_PARAM_COUNT]>,
99    },
100    /// The arc has no positive residual degrees of freedom, so no finite
101    /// residual-scaled covariance can be inferred.
102    Unbounded,
103}
104
105/// Initial-state fit result for one satellite.
106#[derive(Debug, Clone, PartialEq)]
107pub struct OrbitFitSolution {
108    /// Satellite fitted by this solution.
109    pub satellite: GnssSatelliteId,
110    /// Estimated inertial initial state.
111    pub initial_state: CartesianState,
112    /// Fitted state covariance, or an unbounded marker for short arcs.
113    pub covariance: OrbitFitCovariance,
114    /// Singular-value geometry diagnostics for the final design matrix.
115    pub geometry_quality: GeometryQuality,
116    /// Three-dimensional RMS residual of the automatically seeded state, meters.
117    pub seed_rms_3d_m: f64,
118    /// Three-dimensional RMS residual of the fitted state, meters.
119    pub fit_rms_3d_m: f64,
120    /// Accepted nonlinear least-squares iterations.
121    pub iterations: usize,
122}
123
124/// Arc span covered by a residual ledger.
125#[derive(Debug, Clone, Copy, PartialEq)]
126pub struct OrbitArcSpan {
127    /// Time scale shared by all residual epochs.
128    pub time_scale: TimeScale,
129    /// First residual epoch, seconds since J2000 in [`Self::time_scale`].
130    pub start_j2000_s: f64,
131    /// Last residual epoch, seconds since J2000 in [`Self::time_scale`].
132    pub end_j2000_s: f64,
133    /// `end_j2000_s - start_j2000_s`, seconds.
134    pub duration_s: f64,
135}
136
137/// RTN residual RMS summary.
138#[derive(Debug, Clone, Copy, PartialEq)]
139pub struct OrbitResidualStats {
140    /// Radial RMS residual, meters.
141    pub radial_rms_m: f64,
142    /// Along-track RMS residual, meters.
143    pub along_rms_m: f64,
144    /// Cross-track RMS residual, meters.
145    pub cross_rms_m: f64,
146    /// Three-dimensional RMS residual, meters.
147    pub rms_3d_m: f64,
148    /// Number of residual epochs accumulated into this entry.
149    pub n: usize,
150    /// Whether `n < OrbitFitOptions::min_ledger_samples` for this run.
151    pub low_sample_count: bool,
152}
153
154/// Residual RMS ledger, grouped by satellite and constellation.
155#[derive(Debug, Clone, PartialEq)]
156pub struct OrbitResidualLedger {
157    /// Per-satellite RTN residual RMS values.
158    pub per_sat: BTreeMap<GnssSatelliteId, OrbitResidualStats>,
159    /// Per-constellation RTN residual RMS values.
160    pub per_constellation: BTreeMap<GnssSystem, OrbitResidualStats>,
161    /// Time span covered by all residuals in this ledger.
162    pub arc_span: OrbitArcSpan,
163}
164
165/// Batch orbit-fit report for one or more satellites.
166#[derive(Debug, Clone, PartialEq)]
167pub struct OrbitFitReport {
168    /// One fitted initial state per requested satellite.
169    pub fits: BTreeMap<GnssSatelliteId, OrbitFitSolution>,
170    /// RTN residual RMS ledger from the fitted states.
171    pub ledger: OrbitResidualLedger,
172}
173
174/// One ECEF SP3 state sample paired with the Earth orientation for that epoch.
175///
176/// This is the state-sample fit input: the sample supplies ITRF position and
177/// velocity, and the orientation supplies the cacheable GCRF/ITRF DCM plus
178/// transport term for the same epoch.
179#[derive(Debug, Clone, Copy, PartialEq)]
180pub struct OrientedPreciseEphemerisStateSample {
181    /// ECEF SP3 position and velocity sample.
182    pub sample: PreciseEphemerisStateSample,
183    /// Earth orientation evaluated at `sample.epoch`.
184    pub orientation: EarthOrientation,
185}
186
187impl OrientedPreciseEphemerisStateSample {
188    /// Pair an SP3 ECEF state sample with its evaluated Earth orientation.
189    pub const fn new(sample: PreciseEphemerisStateSample, orientation: EarthOrientation) -> Self {
190        Self {
191            sample,
192            orientation,
193        }
194    }
195}
196
197/// Error returned by precise-orbit fitting.
198#[derive(Debug, Clone, thiserror::Error)]
199pub enum OrbitFitError {
200    /// No satellite was requested.
201    #[error("no satellites selected for precise-orbit fitting")]
202    EmptySelection,
203    /// An option value is outside its accepted domain.
204    #[error("invalid orbit-fit {field}: {reason}")]
205    InvalidOption {
206        /// Option field name.
207        field: &'static str,
208        /// Validation failure reason.
209        reason: &'static str,
210    },
211    /// A satellite does not have enough samples to seed a state.
212    #[error("satellite {satellite} has {got} samples; need at least {required}")]
213    TooFewSamples {
214        /// Satellite being fitted.
215        satellite: GnssSatelliteId,
216        /// Number of samples available.
217        got: usize,
218        /// Required sample count.
219        required: usize,
220    },
221    /// A satellite's epochs are not strictly increasing.
222    #[error("satellite {satellite} sample epochs are not strictly increasing")]
223    NonMonotonicEpochs {
224        /// Satellite being fitted.
225        satellite: GnssSatelliteId,
226    },
227    /// Samples selected for one batch carry more than one time scale.
228    #[error("precise-orbit fit samples carry mixed time scales")]
229    MixedTimeScales,
230    /// A sample epoch cannot be represented on the J2000 axis or time-scale
231    /// bridge.
232    #[error("satellite {satellite} has an invalid epoch: {reason}")]
233    InvalidEpoch {
234        /// Satellite being fitted.
235        satellite: GnssSatelliteId,
236        /// Validation failure reason.
237        reason: String,
238    },
239    /// A sample position or fit state was non-finite.
240    #[error("satellite {satellite} has an invalid observation: {reason}")]
241    InvalidObservation {
242        /// Satellite being fitted.
243        satellite: GnssSatelliteId,
244        /// Validation failure reason.
245        reason: &'static str,
246    },
247    /// A frame conversion failed.
248    #[error("satellite {satellite} frame transform failed: {source}")]
249    Frame {
250        /// Satellite being fitted.
251        satellite: GnssSatelliteId,
252        /// Source frame-transform error.
253        source: FrameTransformError,
254    },
255    /// Propagation failed during the fit.
256    #[error("satellite {satellite} propagation failed: {source}")]
257    Propagation {
258        /// Satellite being fitted.
259        satellite: GnssSatelliteId,
260        /// Source propagation error.
261        source: PropagationError,
262    },
263    /// Least-squares failed before a usable fit report was produced.
264    #[error("satellite {satellite} least-squares failed: {source}")]
265    LeastSquares {
266        /// Satellite being fitted.
267        satellite: GnssSatelliteId,
268        /// Source least-squares error.
269        source: SolveError,
270    },
271    /// The final design matrix is rank deficient.
272    #[error("satellite {satellite} has rank-deficient fit geometry")]
273    SingularGeometry {
274        /// Satellite being fitted.
275        satellite: GnssSatelliteId,
276        /// Geometry diagnostics from the singular design.
277        geometry_quality: GeometryQuality,
278    },
279    /// The nonlinear least-squares iteration exhausted its evaluation budget.
280    #[error("satellite {satellite} fit did not converge after {iterations} iterations")]
281    DidNotConverge {
282        /// Satellite being fitted.
283        satellite: GnssSatelliteId,
284        /// Accepted iterations before termination.
285        iterations: usize,
286    },
287    /// The RTN frame was undefined for a propagated state.
288    #[error("satellite {satellite} RTN frame failed: {reason:?}")]
289    RtnFrame {
290        /// Satellite being fitted.
291        satellite: GnssSatelliteId,
292        /// RTN-frame failure reason.
293        reason: RtnFrameError,
294    },
295}
296
297/// Fit one satellite from a parsed SP3 product.
298pub fn fit_sp3_precise_orbit(
299    product: &Sp3,
300    satellite: GnssSatelliteId,
301    options: &OrbitFitOptions,
302) -> Result<OrbitFitReport, OrbitFitError> {
303    fit_sp3_precise_orbits(product, &[satellite], options)
304}
305
306/// Fit one satellite from a parsed SP3 product with a caller-supplied arc-start
307/// initial-state seed.
308pub fn fit_sp3_precise_orbit_with_initial_state(
309    product: &Sp3,
310    satellite: GnssSatelliteId,
311    initial_state: CartesianState,
312    options: &OrbitFitOptions,
313) -> Result<OrbitFitReport, OrbitFitError> {
314    let samples = product.precise_ephemeris_samples();
315    fit_precise_ephemeris_sample_orbit_with_initial_state(
316        &samples,
317        satellite,
318        initial_state,
319        options,
320    )
321}
322
323/// Fit selected satellites from a parsed SP3 product.
324pub fn fit_sp3_precise_orbits(
325    product: &Sp3,
326    satellites: &[GnssSatelliteId],
327    options: &OrbitFitOptions,
328) -> Result<OrbitFitReport, OrbitFitError> {
329    let samples = product.precise_ephemeris_samples();
330    fit_precise_ephemeris_sample_orbits(&samples, satellites, options)
331}
332
333/// Fit every satellite declared in a parsed SP3 product.
334pub fn fit_all_sp3_precise_orbits(
335    product: &Sp3,
336    options: &OrbitFitOptions,
337) -> Result<OrbitFitReport, OrbitFitError> {
338    fit_sp3_precise_orbits(product, product.satellites(), options)
339}
340
341/// Fit one satellite from a parsed ECEF SP3 product using an Earth-orientation
342/// provider.
343///
344/// Position records are transformed with the provider's ITRF to GCRF matrix at
345/// each epoch. Products with real SP3 velocity records additionally use
346/// [`sp3_ecef_state_to_eci`] at the matching epochs so the initial velocity seed
347/// can include the rotating-frame transport term without dropping position-only
348/// epochs from a mixed product.
349pub fn fit_sp3_ecef_precise_orbit(
350    product: &Sp3,
351    satellite: GnssSatelliteId,
352    orientation_provider: &dyn EarthOrientationProvider,
353    options: &OrbitFitOptions,
354) -> Result<OrbitFitReport, OrbitFitError> {
355    fit_sp3_ecef_precise_orbits(product, &[satellite], orientation_provider, options)
356}
357
358/// Fit selected satellites from a parsed ECEF SP3 product using an
359/// Earth-orientation provider.
360///
361/// This is the parsed-product entry for real SP3 orbit products whose position
362/// and optional velocity records are expressed in the ITRF/IGS Earth-fixed
363/// frame. The returned residual ledger is computed against the original ECEF
364/// observations. Its arc span is reported on the TDB axis used by the provider
365/// and numerical propagator.
366pub fn fit_sp3_ecef_precise_orbits(
367    product: &Sp3,
368    satellites: &[GnssSatelliteId],
369    orientation_provider: &dyn EarthOrientationProvider,
370    options: &OrbitFitOptions,
371) -> Result<OrbitFitReport, OrbitFitError> {
372    validate_options(options)?;
373    if satellites.is_empty() {
374        return Err(OrbitFitError::EmptySelection);
375    }
376
377    let position_samples = product.precise_ephemeris_samples();
378    let state_samples = product.precise_ephemeris_state_samples();
379    let mut fits = BTreeMap::new();
380    let mut residuals = Vec::new();
381    let mut time_scale = None;
382    for &satellite in satellites {
383        let work = fit_one_sp3_ecef_arc(
384            &position_samples,
385            &state_samples,
386            satellite,
387            orientation_provider,
388            options,
389        )?;
390        for residual in &work.residuals {
391            match time_scale {
392                None => time_scale = Some(residual.time_scale),
393                Some(scale) if scale == residual.time_scale => {}
394                Some(_) => return Err(OrbitFitError::MixedTimeScales),
395            }
396        }
397        residuals.extend(work.residuals);
398        fits.insert(satellite, work.solution);
399    }
400
401    let ledger = build_ledger(
402        residuals,
403        time_scale.ok_or(OrbitFitError::EmptySelection)?,
404        options.min_ledger_samples,
405    )?;
406    Ok(OrbitFitReport { fits, ledger })
407}
408
409/// Fit every satellite declared in a parsed ECEF SP3 product using an
410/// Earth-orientation provider.
411pub fn fit_all_sp3_ecef_precise_orbits(
412    product: &Sp3,
413    orientation_provider: &dyn EarthOrientationProvider,
414    options: &OrbitFitOptions,
415) -> Result<OrbitFitReport, OrbitFitError> {
416    fit_sp3_ecef_precise_orbits(product, product.satellites(), orientation_provider, options)
417}
418
419/// Fit one satellite from precise ephemeris samples.
420pub fn fit_precise_ephemeris_sample_orbit(
421    samples: &[PreciseEphemerisSample],
422    satellite: GnssSatelliteId,
423    options: &OrbitFitOptions,
424) -> Result<OrbitFitReport, OrbitFitError> {
425    fit_precise_ephemeris_sample_orbits(samples, &[satellite], options)
426}
427
428/// Fit one satellite from precise ephemeris samples with a caller-supplied
429/// arc-start initial-state seed.
430pub fn fit_precise_ephemeris_sample_orbit_with_initial_state(
431    samples: &[PreciseEphemerisSample],
432    satellite: GnssSatelliteId,
433    initial_state: CartesianState,
434    options: &OrbitFitOptions,
435) -> Result<OrbitFitReport, OrbitFitError> {
436    validate_options(options)?;
437    let work = fit_one_sample_arc(samples, satellite, options, Some(initial_state))?;
438    let time_scale = work
439        .residuals
440        .first()
441        .map(|residual| residual.time_scale)
442        .ok_or(OrbitFitError::EmptySelection)?;
443    let ledger = build_ledger(work.residuals, time_scale, options.min_ledger_samples)?;
444    let mut fits = BTreeMap::new();
445    fits.insert(satellite, work.solution);
446    Ok(OrbitFitReport { fits, ledger })
447}
448
449/// Fit one satellite from ECEF state samples paired with Earth orientation.
450///
451/// The ECEF positions remain the residual observations. The ECEF velocities are
452/// converted through [`sp3_ecef_state_to_eci`] and used as the arc-start inertial
453/// seed, including the `omega x r` transport term.
454pub fn fit_precise_ephemeris_state_sample_orbit(
455    samples: &[OrientedPreciseEphemerisStateSample],
456    satellite: GnssSatelliteId,
457    options: &OrbitFitOptions,
458) -> Result<OrbitFitReport, OrbitFitError> {
459    fit_precise_ephemeris_state_sample_orbits(samples, &[satellite], options)
460}
461
462/// Fit selected satellites from ECEF state samples paired with Earth
463/// orientation.
464pub fn fit_precise_ephemeris_state_sample_orbits(
465    samples: &[OrientedPreciseEphemerisStateSample],
466    satellites: &[GnssSatelliteId],
467    options: &OrbitFitOptions,
468) -> Result<OrbitFitReport, OrbitFitError> {
469    validate_options(options)?;
470    if satellites.is_empty() {
471        return Err(OrbitFitError::EmptySelection);
472    }
473
474    let mut fits = BTreeMap::new();
475    let mut residuals = Vec::new();
476    let mut time_scale = None;
477    for &satellite in satellites {
478        let work = fit_one_state_sample_arc(samples, satellite, options)?;
479        for residual in &work.residuals {
480            match time_scale {
481                None => time_scale = Some(residual.time_scale),
482                Some(scale) if scale == residual.time_scale => {}
483                Some(_) => return Err(OrbitFitError::MixedTimeScales),
484            }
485        }
486        residuals.extend(work.residuals);
487        fits.insert(satellite, work.solution);
488    }
489
490    let ledger = build_ledger(
491        residuals,
492        time_scale.ok_or(OrbitFitError::EmptySelection)?,
493        options.min_ledger_samples,
494    )?;
495    Ok(OrbitFitReport { fits, ledger })
496}
497
498/// Fit selected satellites from precise ephemeris samples.
499pub fn fit_precise_ephemeris_sample_orbits(
500    samples: &[PreciseEphemerisSample],
501    satellites: &[GnssSatelliteId],
502    options: &OrbitFitOptions,
503) -> Result<OrbitFitReport, OrbitFitError> {
504    validate_options(options)?;
505    if satellites.is_empty() {
506        return Err(OrbitFitError::EmptySelection);
507    }
508
509    let mut fits = BTreeMap::new();
510    let mut residuals = Vec::new();
511    let mut time_scale = None;
512    for &satellite in satellites {
513        let work = fit_one_sample_arc(samples, satellite, options, None)?;
514        for residual in &work.residuals {
515            match time_scale {
516                None => time_scale = Some(residual.time_scale),
517                Some(scale) if scale == residual.time_scale => {}
518                Some(_) => return Err(OrbitFitError::MixedTimeScales),
519            }
520        }
521        residuals.extend(work.residuals);
522        fits.insert(satellite, work.solution);
523    }
524
525    let ledger = build_ledger(
526        residuals,
527        time_scale.ok_or(OrbitFitError::EmptySelection)?,
528        options.min_ledger_samples,
529    )?;
530    Ok(OrbitFitReport { fits, ledger })
531}
532
533fn validate_options(options: &OrbitFitOptions) -> Result<(), OrbitFitError> {
534    if options.min_ledger_samples == 0 {
535        return Err(OrbitFitError::InvalidOption {
536            field: "min_ledger_samples",
537            reason: "not positive",
538        });
539    }
540    Ok(())
541}
542
543struct FitWork {
544    solution: OrbitFitSolution,
545    residuals: Vec<RtnResidual>,
546}
547
548fn fit_one_sample_arc(
549    samples: &[PreciseEphemerisSample],
550    satellite: GnssSatelliteId,
551    options: &OrbitFitOptions,
552    initial_seed: Option<CartesianState>,
553) -> Result<FitWork, OrbitFitError> {
554    let observations = collect_observations(samples, satellite)?;
555    fit_one_observation_arc(satellite, observations, options, initial_seed)
556}
557
558fn fit_one_state_sample_arc(
559    samples: &[OrientedPreciseEphemerisStateSample],
560    satellite: GnssSatelliteId,
561    options: &OrbitFitOptions,
562) -> Result<FitWork, OrbitFitError> {
563    let observations = collect_state_observations(samples, satellite)?;
564    fit_one_observation_arc(satellite, observations, options, None)
565}
566
567fn fit_one_sp3_ecef_arc(
568    position_samples: &[PreciseEphemerisSample],
569    state_samples: &[PreciseEphemerisStateSample],
570    satellite: GnssSatelliteId,
571    orientation_provider: &dyn EarthOrientationProvider,
572    options: &OrbitFitOptions,
573) -> Result<FitWork, OrbitFitError> {
574    let observations = collect_provider_sp3_observations(
575        position_samples,
576        state_samples,
577        satellite,
578        orientation_provider,
579    )?;
580    fit_one_observation_arc(satellite, observations, options, None)
581}
582
583fn fit_one_observation_arc(
584    satellite: GnssSatelliteId,
585    observations: Vec<OrbitObservation>,
586    options: &OrbitFitOptions,
587    initial_seed: Option<CartesianState>,
588) -> Result<FitWork, OrbitFitError> {
589    let seed = match initial_seed {
590        Some(seed) => validate_initial_seed(satellite, seed, observations.as_slice())?,
591        None => seed_initial_state(satellite, &observations, options)?,
592    };
593    let seed_vector = state_to_vector(seed);
594    let param_scales = parameter_scales(&seed_vector);
595    let seed_residual =
596        residual_vector_for_params(satellite, &seed_vector, &observations, options)?;
597    let seed_rms_3d_m = residual_rms_3d_m(seed_residual.as_slice());
598
599    let residual_error = RefCell::new(None);
600    let observations_for_closure = observations.clone();
601    let residual = |x: &DVector<f64>| -> DVector<f64> {
602        let physical = unscale_params(x.as_slice(), &param_scales);
603        match residual_vector_for_params(satellite, &physical, &observations_for_closure, options) {
604            Ok(values) => DVector::from_vec(values),
605            Err(error) => {
606                *residual_error.borrow_mut() = Some(error);
607                DVector::from_element(observations_for_closure.len() * 3, f64::NAN)
608            }
609        }
610    };
611
612    let problem = LeastSquaresProblem::new(
613        residual,
614        DVector::from_vec(scale_params(&seed_vector, &param_scales).to_vec()),
615    );
616    let report = match solve_trf_with(&problem, &options.solver_options, options.linear_solve) {
617        Ok(report) => report,
618        Err(SolveError::SingularJacobian) => {
619            let geometry_quality = singular_geometry_quality(observations.len(), options);
620            return Err(OrbitFitError::SingularGeometry {
621                satellite,
622                geometry_quality,
623            });
624        }
625        Err(error) => {
626            if let Some(source) = residual_error.into_inner() {
627                return Err(source);
628            }
629            return Err(OrbitFitError::LeastSquares {
630                satellite,
631                source: error,
632            });
633        }
634    };
635
636    if matches!(report.status, Status::MaxEvaluations) {
637        return Err(OrbitFitError::DidNotConverge {
638            satellite,
639            iterations: report.iterations,
640        });
641    }
642
643    let physical_jacobian = physical_jacobian(&report.jacobian, &param_scales);
644    let geometry_quality = classify_fit_geometry(&physical_jacobian, options);
645    if geometry_quality.rank < STATE_PARAM_COUNT {
646        return Err(OrbitFitError::SingularGeometry {
647            satellite,
648            geometry_quality,
649        });
650    }
651
652    let covariance = fit_covariance(satellite, &physical_jacobian, report.cost)?;
653    let final_params = unscale_params(report.x.as_slice(), &param_scales);
654    let initial_state = CartesianState::new(
655        observations[0].epoch_j2000_s,
656        [final_params[0], final_params[1], final_params[2]],
657        [final_params[3], final_params[4], final_params[5]],
658    );
659    let fit_residuals = rtn_residuals_for_state(satellite, initial_state, &observations, options)?;
660    let fit_rms_3d_m = ledger_rms_3d_m(&fit_residuals);
661
662    Ok(FitWork {
663        solution: OrbitFitSolution {
664            satellite,
665            initial_state,
666            covariance,
667            geometry_quality,
668            seed_rms_3d_m,
669            fit_rms_3d_m,
670            iterations: report.iterations,
671        },
672        residuals: fit_residuals,
673    })
674}
675
676fn fit_covariance(
677    satellite: GnssSatelliteId,
678    jacobian: &DMatrix<f64>,
679    cost: f64,
680) -> Result<OrbitFitCovariance, OrbitFitError> {
681    if jacobian.nrows() <= jacobian.ncols() {
682        return Ok(OrbitFitCovariance::Unbounded);
683    }
684    let covariance = least_squares::covariance_from_jacobian(jacobian, cost)
685        .map_err(|source| OrbitFitError::LeastSquares { satellite, source })?;
686    Ok(OrbitFitCovariance::Estimated {
687        matrix: Box::new(matrix6(&covariance)),
688    })
689}
690
691#[derive(Debug, Clone)]
692struct OrbitObservation {
693    epoch_j2000_s: f64,
694    time_scale: TimeScale,
695    time_scales: TimeScales,
696    orientation: Option<EarthOrientation>,
697    observed_itrs_km: [f64; 3],
698    observed_gcrs_km: [f64; 3],
699    observed_gcrs_velocity_km_s: Option<[f64; 3]>,
700}
701
702fn collect_observations(
703    samples: &[PreciseEphemerisSample],
704    satellite: GnssSatelliteId,
705) -> Result<Vec<OrbitObservation>, OrbitFitError> {
706    let mut observations = Vec::new();
707    for sample in samples.iter().filter(|sample| sample.sat == satellite) {
708        validate_position(sample.position_ecef_m, satellite)?;
709        let epoch_j2000_s = instant_j2000_seconds(sample.epoch, satellite)?;
710        let ts = time_scales_from_instant(sample.epoch, epoch_j2000_s, satellite)?;
711        let [x_m, y_m, z_m] = sample.position_ecef_m;
712        let (x, y, z) = itrs_to_gcrs_compute(x_m / M_PER_KM, y_m / M_PER_KM, z_m / M_PER_KM, &ts)
713            .map_err(|source| OrbitFitError::Frame { satellite, source })?;
714        observations.push(OrbitObservation {
715            epoch_j2000_s,
716            time_scale: sample.epoch.scale,
717            time_scales: ts,
718            orientation: None,
719            observed_itrs_km: [x_m / M_PER_KM, y_m / M_PER_KM, z_m / M_PER_KM],
720            observed_gcrs_km: [x, y, z],
721            observed_gcrs_velocity_km_s: None,
722        });
723    }
724    validate_observations(satellite, observations)
725}
726
727fn collect_state_observations(
728    samples: &[OrientedPreciseEphemerisStateSample],
729    satellite: GnssSatelliteId,
730) -> Result<Vec<OrbitObservation>, OrbitFitError> {
731    let mut observations = Vec::new();
732    for oriented in samples
733        .iter()
734        .filter(|oriented| oriented.sample.sat == satellite)
735    {
736        validate_position(oriented.sample.position_ecef_m, satellite)?;
737        validate_velocity(oriented.sample.velocity_ecef_m_s, satellite)?;
738        let inertial = sp3_ecef_state_to_eci(&oriented.sample, &oriented.orientation)
739            .map_err(|source| OrbitFitError::Frame { satellite, source })?;
740        let [x_m, y_m, z_m] = oriented.sample.position_ecef_m;
741        observations.push(OrbitObservation {
742            epoch_j2000_s: inertial.epoch_tdb_seconds,
743            time_scale: oriented.sample.epoch.scale,
744            time_scales: oriented.orientation.time_scales(),
745            orientation: Some(oriented.orientation),
746            observed_itrs_km: [x_m / M_PER_KM, y_m / M_PER_KM, z_m / M_PER_KM],
747            observed_gcrs_km: inertial.position_array(),
748            observed_gcrs_velocity_km_s: Some(inertial.velocity_array()),
749        });
750    }
751    validate_observations(satellite, observations)
752}
753
754fn collect_provider_sp3_observations(
755    samples: &[PreciseEphemerisSample],
756    state_samples: &[PreciseEphemerisStateSample],
757    satellite: GnssSatelliteId,
758    orientation_provider: &dyn EarthOrientationProvider,
759) -> Result<Vec<OrbitObservation>, OrbitFitError> {
760    let mut observations = Vec::new();
761    for sample in samples.iter().filter(|sample| sample.sat == satellite) {
762        validate_position(sample.position_ecef_m, satellite)?;
763        let epoch_tdb_s = tdb_seconds_from_instant(sample.epoch, satellite)?;
764        let orientation = orientation_provider
765            .orientation_at_tdb_seconds(epoch_tdb_s)
766            .map_err(|source| OrbitFitError::Frame { satellite, source })?;
767        let [x_m, y_m, z_m] = sample.position_ecef_m;
768        let position_itrf_km = [x_m / M_PER_KM, y_m / M_PER_KM, z_m / M_PER_KM];
769        let observed_gcrs_km = orientation
770            .itrf_to_gcrf_position_km(position_itrf_km)
771            .map_err(|source| OrbitFitError::Frame { satellite, source })?;
772        let observed_gcrs_velocity_km_s =
773            matching_state_sample(state_samples, sample).map_or(Ok(None), |state_sample| {
774                validate_velocity(state_sample.velocity_ecef_m_s, satellite)?;
775                let state_at_position_epoch = PreciseEphemerisStateSample {
776                    sat: sample.sat,
777                    epoch: sample.epoch,
778                    position_ecef_m: sample.position_ecef_m,
779                    velocity_ecef_m_s: state_sample.velocity_ecef_m_s,
780                    clock_s: sample.clock_s,
781                    clock_rate_s_s: state_sample.clock_rate_s_s,
782                    clock_event: sample.clock_event,
783                };
784                let inertial = sp3_ecef_state_to_eci(&state_at_position_epoch, &orientation)
785                    .map_err(|source| OrbitFitError::Frame { satellite, source })?;
786                Ok(Some(inertial.velocity_array()))
787            })?;
788        observations.push(OrbitObservation {
789            epoch_j2000_s: epoch_tdb_s,
790            time_scale: TimeScale::Tdb,
791            time_scales: orientation.time_scales(),
792            orientation: Some(orientation),
793            observed_itrs_km: position_itrf_km,
794            observed_gcrs_km,
795            observed_gcrs_velocity_km_s,
796        });
797    }
798    validate_observations(satellite, observations)
799}
800
801fn matching_state_sample<'a>(
802    state_samples: &'a [PreciseEphemerisStateSample],
803    sample: &PreciseEphemerisSample,
804) -> Option<&'a PreciseEphemerisStateSample> {
805    state_samples
806        .iter()
807        .find(|state_sample| state_sample.sat == sample.sat && state_sample.epoch == sample.epoch)
808}
809
810fn validate_observations(
811    satellite: GnssSatelliteId,
812    mut observations: Vec<OrbitObservation>,
813) -> Result<Vec<OrbitObservation>, OrbitFitError> {
814    observations.sort_by(|a, b| a.epoch_j2000_s.total_cmp(&b.epoch_j2000_s));
815    if observations.len() < MIN_SEED_SAMPLES {
816        return Err(OrbitFitError::TooFewSamples {
817            satellite,
818            got: observations.len(),
819            required: MIN_SEED_SAMPLES,
820        });
821    }
822    if observations
823        .windows(2)
824        .any(|window| window[1].epoch_j2000_s <= window[0].epoch_j2000_s)
825    {
826        return Err(OrbitFitError::NonMonotonicEpochs { satellite });
827    }
828    if observations
829        .windows(2)
830        .any(|window| window[1].time_scale != window[0].time_scale)
831    {
832        return Err(OrbitFitError::MixedTimeScales);
833    }
834    Ok(observations)
835}
836
837fn validate_position(
838    position_ecef_m: [f64; 3],
839    satellite: GnssSatelliteId,
840) -> Result<(), OrbitFitError> {
841    if position_ecef_m.iter().all(|value| value.is_finite()) {
842        Ok(())
843    } else {
844        Err(OrbitFitError::InvalidObservation {
845            satellite,
846            reason: "position components must be finite",
847        })
848    }
849}
850
851fn validate_velocity(
852    velocity_ecef_m_s: [f64; 3],
853    satellite: GnssSatelliteId,
854) -> Result<(), OrbitFitError> {
855    if velocity_ecef_m_s.iter().all(|value| value.is_finite()) {
856        Ok(())
857    } else {
858        Err(OrbitFitError::InvalidObservation {
859            satellite,
860            reason: "velocity components must be finite",
861        })
862    }
863}
864
865fn instant_j2000_seconds(
866    instant: Instant,
867    satellite: GnssSatelliteId,
868) -> Result<f64, OrbitFitError> {
869    let jd = instant
870        .julian_date()
871        .ok_or_else(|| OrbitFitError::InvalidEpoch {
872            satellite,
873            reason: "epoch is not a split Julian date".to_string(),
874        })?;
875    let seconds = j2000_seconds_from_split(jd.jd_whole, jd.fraction);
876    if seconds.is_finite() {
877        Ok(seconds)
878    } else {
879        Err(OrbitFitError::InvalidEpoch {
880            satellite,
881            reason: "J2000 seconds are not finite".to_string(),
882        })
883    }
884}
885
886fn time_scales_from_instant(
887    instant: Instant,
888    epoch_j2000_s: f64,
889    satellite: GnssSatelliteId,
890) -> Result<TimeScales, OrbitFitError> {
891    let whole = epoch_j2000_s.floor();
892    if whole < i64::MIN as f64 || whole > i64::MAX as f64 {
893        return Err(OrbitFitError::InvalidEpoch {
894            satellite,
895            reason: "J2000 seconds are outside calendar range".to_string(),
896        });
897    }
898    let fraction = epoch_j2000_s - whole;
899    let (year, month, day, hour, minute, second) = civil_from_j2000_seconds(whole as i64);
900    TimeScales::from_scale(
901        instant.scale,
902        year as i32,
903        month as i32,
904        day as i32,
905        hour as i32,
906        minute as i32,
907        second as f64 + fraction,
908    )
909    .map_err(|error| OrbitFitError::InvalidEpoch {
910        satellite,
911        reason: error.to_string(),
912    })
913}
914
915fn tdb_seconds_from_instant(
916    instant: Instant,
917    satellite: GnssSatelliteId,
918) -> Result<f64, OrbitFitError> {
919    let epoch_j2000_s = instant_j2000_seconds(instant, satellite)?;
920    let ts = time_scales_from_instant(instant, epoch_j2000_s, satellite)?;
921    let tdb_seconds = j2000_seconds_from_split(ts.jd_whole, ts.tdb_fraction);
922    if tdb_seconds.is_finite() {
923        Ok(tdb_seconds)
924    } else {
925        Err(OrbitFitError::InvalidEpoch {
926            satellite,
927            reason: "TDB J2000 seconds are not finite".to_string(),
928        })
929    }
930}
931
932fn seed_initial_state(
933    satellite: GnssSatelliteId,
934    observations: &[OrbitObservation],
935    options: &OrbitFitOptions,
936) -> Result<CartesianState, OrbitFitError> {
937    if let Some(velocity) = observations[0].observed_gcrs_velocity_km_s {
938        return Ok(CartesianState::new(
939            observations[0].epoch_j2000_s,
940            observations[0].observed_gcrs_km,
941            velocity,
942        ));
943    }
944
945    if observations.len() >= 3 {
946        let r1 = observations[0].observed_gcrs_km;
947        let r2 = observations[1].observed_gcrs_km;
948        let r3 = observations[2].observed_gcrs_km;
949        let jd1 = observations[0].epoch_j2000_s / SECONDS_PER_DAY;
950        let jd2 = observations[1].epoch_j2000_s / SECONDS_PER_DAY;
951        let jd3 = observations[2].epoch_j2000_s / SECONDS_PER_DAY;
952        if let Ok((v2, _, _, _)) = iod::hgibbs(&r1, &r2, &r3, jd1, jd2, jd3) {
953            let midpoint = CartesianState::new(observations[1].epoch_j2000_s, r2, v2);
954            if let Ok(result) = build_propagator(midpoint, options).propagate_to_with_context(
955                observations[0].epoch_j2000_s,
956                &options.propagation_context,
957            ) {
958                return Ok(result.final_state);
959            }
960        }
961    }
962
963    let first = &observations[0];
964    let second = &observations[1];
965    let dt = second.epoch_j2000_s - first.epoch_j2000_s;
966    if !dt.is_finite() || dt <= 0.0 {
967        return Err(OrbitFitError::NonMonotonicEpochs { satellite });
968    }
969    let velocity = [
970        (second.observed_gcrs_km[0] - first.observed_gcrs_km[0]) / dt,
971        (second.observed_gcrs_km[1] - first.observed_gcrs_km[1]) / dt,
972        (second.observed_gcrs_km[2] - first.observed_gcrs_km[2]) / dt,
973    ];
974    Ok(CartesianState::new(
975        first.epoch_j2000_s,
976        first.observed_gcrs_km,
977        velocity,
978    ))
979}
980
981fn validate_initial_seed(
982    satellite: GnssSatelliteId,
983    seed: CartesianState,
984    observations: &[OrbitObservation],
985) -> Result<CartesianState, OrbitFitError> {
986    if seed.epoch_tdb_seconds != observations[0].epoch_j2000_s {
987        return Err(OrbitFitError::InvalidEpoch {
988            satellite,
989            reason: "initial-state seed epoch must match the first sample".to_string(),
990        });
991    }
992    let params = state_to_vector(seed);
993    if params.iter().all(|value| value.is_finite()) {
994        Ok(seed)
995    } else {
996        Err(OrbitFitError::InvalidObservation {
997            satellite,
998            reason: "initial-state seed components must be finite",
999        })
1000    }
1001}
1002
1003fn state_to_vector(state: CartesianState) -> [f64; STATE_PARAM_COUNT] {
1004    [
1005        state.position_km.x,
1006        state.position_km.y,
1007        state.position_km.z,
1008        state.velocity_km_s.x,
1009        state.velocity_km_s.y,
1010        state.velocity_km_s.z,
1011    ]
1012}
1013
1014fn parameter_scales(params: &[f64; STATE_PARAM_COUNT]) -> [f64; STATE_PARAM_COUNT] {
1015    let position_scale = (params[0] * params[0] + params[1] * params[1] + params[2] * params[2])
1016        .sqrt()
1017        .max(1.0);
1018    let velocity_scale = (params[3] * params[3] + params[4] * params[4] + params[5] * params[5])
1019        .sqrt()
1020        .max(1.0);
1021    [
1022        position_scale,
1023        position_scale,
1024        position_scale,
1025        velocity_scale,
1026        velocity_scale,
1027        velocity_scale,
1028    ]
1029}
1030
1031fn scale_params(
1032    params: &[f64; STATE_PARAM_COUNT],
1033    scales: &[f64; STATE_PARAM_COUNT],
1034) -> [f64; STATE_PARAM_COUNT] {
1035    [
1036        params[0] / scales[0],
1037        params[1] / scales[1],
1038        params[2] / scales[2],
1039        params[3] / scales[3],
1040        params[4] / scales[4],
1041        params[5] / scales[5],
1042    ]
1043}
1044
1045fn unscale_params(params: &[f64], scales: &[f64; STATE_PARAM_COUNT]) -> [f64; STATE_PARAM_COUNT] {
1046    [
1047        params[0] * scales[0],
1048        params[1] * scales[1],
1049        params[2] * scales[2],
1050        params[3] * scales[3],
1051        params[4] * scales[4],
1052        params[5] * scales[5],
1053    ]
1054}
1055
1056fn physical_jacobian(
1057    scaled_jacobian: &DMatrix<f64>,
1058    scales: &[f64; STATE_PARAM_COUNT],
1059) -> DMatrix<f64> {
1060    let mut jacobian = scaled_jacobian.clone();
1061    for col in 0..STATE_PARAM_COUNT {
1062        for row in 0..jacobian.nrows() {
1063            jacobian[(row, col)] /= scales[col];
1064        }
1065    }
1066    jacobian
1067}
1068
1069fn residual_vector_for_params(
1070    satellite: GnssSatelliteId,
1071    params: &[f64],
1072    observations: &[OrbitObservation],
1073    options: &OrbitFitOptions,
1074) -> Result<Vec<f64>, OrbitFitError> {
1075    if params.len() != STATE_PARAM_COUNT {
1076        return Err(OrbitFitError::InvalidObservation {
1077            satellite,
1078            reason: "state parameter length mismatch",
1079        });
1080    }
1081    if !params.iter().all(|value| value.is_finite()) {
1082        return Err(OrbitFitError::InvalidObservation {
1083            satellite,
1084            reason: "state parameters must be finite",
1085        });
1086    }
1087    let initial = CartesianState::new(
1088        observations[0].epoch_j2000_s,
1089        [params[0], params[1], params[2]],
1090        [params[3], params[4], params[5]],
1091    );
1092    let states = propagate_to_observations(satellite, initial, observations, options)?;
1093    let mut residual = Vec::with_capacity(observations.len() * 3);
1094    for (state, observation) in states.iter().zip(observations) {
1095        let predicted_itrs =
1096            predicted_itrs_position(satellite, state.position_array(), observation)?;
1097        residual.push(predicted_itrs[0] - observation.observed_itrs_km[0]);
1098        residual.push(predicted_itrs[1] - observation.observed_itrs_km[1]);
1099        residual.push(predicted_itrs[2] - observation.observed_itrs_km[2]);
1100    }
1101    Ok(residual)
1102}
1103
1104fn propagate_to_observations(
1105    satellite: GnssSatelliteId,
1106    initial: CartesianState,
1107    observations: &[OrbitObservation],
1108    options: &OrbitFitOptions,
1109) -> Result<Vec<CartesianState>, OrbitFitError> {
1110    let epochs: Vec<f64> = observations
1111        .iter()
1112        .map(|observation| observation.epoch_j2000_s)
1113        .collect();
1114    build_propagator(initial, options)
1115        .ephemeris_with_context(&epochs, &options.propagation_context)
1116        .map_err(|source| OrbitFitError::Propagation { satellite, source })
1117}
1118
1119fn build_propagator(initial: CartesianState, options: &OrbitFitOptions) -> StatePropagator {
1120    StatePropagator {
1121        initial,
1122        force_model: options.force_model,
1123        integrator: options.integrator,
1124        options: options.integrator_options,
1125        drag: options.drag,
1126        space_weather: options.space_weather.clone(),
1127    }
1128}
1129
1130fn residual_rms_3d_m(residual_km: &[f64]) -> f64 {
1131    let n = residual_km.len() / 3;
1132    let sumsq_m2 = residual_km
1133        .iter()
1134        .map(|value| {
1135            let meters = value * M_PER_KM;
1136            meters * meters
1137        })
1138        .sum::<f64>();
1139    (sumsq_m2 / n as f64).sqrt()
1140}
1141
1142fn singular_geometry_quality(
1143    observation_count: usize,
1144    options: &OrbitFitOptions,
1145) -> GeometryQuality {
1146    classify(
1147        0,
1148        STATE_PARAM_COUNT,
1149        observation_count as i32 * 3 - STATE_PARAM_COUNT as i32,
1150        f64::INFINITY,
1151        f64::INFINITY,
1152        false,
1153        options.geometry_thresholds,
1154    )
1155}
1156
1157fn classify_fit_geometry(jacobian: &DMatrix<f64>, options: &OrbitFitOptions) -> GeometryQuality {
1158    let singular = jacobian.clone().svd(false, false).singular_values;
1159    let diagnostics =
1160        singular_value_diagnostics(singular.as_slice(), jacobian.nrows(), jacobian.ncols());
1161    let gdop = least_squares::normal_covariance(jacobian, 1.0)
1162        .map(|cofactor| {
1163            (0..cofactor.nrows())
1164                .map(|index| cofactor[(index, index)])
1165                .sum::<f64>()
1166                .sqrt()
1167        })
1168        .unwrap_or(f64::INFINITY);
1169    classify(
1170        diagnostics.rank,
1171        STATE_PARAM_COUNT,
1172        jacobian.nrows() as i32 - STATE_PARAM_COUNT as i32,
1173        diagnostics.condition_number,
1174        gdop,
1175        false,
1176        options.geometry_thresholds,
1177    )
1178}
1179
1180fn matrix6(matrix: &DMatrix<f64>) -> [[f64; STATE_PARAM_COUNT]; STATE_PARAM_COUNT] {
1181    let mut out = [[0.0_f64; STATE_PARAM_COUNT]; STATE_PARAM_COUNT];
1182    for row in 0..STATE_PARAM_COUNT {
1183        for col in 0..STATE_PARAM_COUNT {
1184            out[row][col] = matrix[(row, col)];
1185        }
1186    }
1187    out
1188}
1189
1190#[derive(Debug, Clone, Copy)]
1191struct RtnResidual {
1192    satellite: GnssSatelliteId,
1193    time_scale: TimeScale,
1194    epoch_j2000_s: f64,
1195    radial_m: f64,
1196    along_m: f64,
1197    cross_m: f64,
1198}
1199
1200fn rtn_residuals_for_state(
1201    satellite: GnssSatelliteId,
1202    initial: CartesianState,
1203    observations: &[OrbitObservation],
1204    options: &OrbitFitOptions,
1205) -> Result<Vec<RtnResidual>, OrbitFitError> {
1206    let states = propagate_to_observations(satellite, initial, observations, options)?;
1207    let mut residuals = Vec::with_capacity(observations.len());
1208    for (state, observation) in states.iter().zip(observations) {
1209        let rot = rtn_to_eci_rotation(state.position_array(), state.velocity_array())
1210            .map_err(|reason| OrbitFitError::RtnFrame { satellite, reason })?;
1211        let predicted_itrs =
1212            predicted_itrs_position(satellite, state.position_array(), observation)?;
1213        let diff_itrs = [
1214            predicted_itrs[0] - observation.observed_itrs_km[0],
1215            predicted_itrs[1] - observation.observed_itrs_km[1],
1216            predicted_itrs[2] - observation.observed_itrs_km[2],
1217        ];
1218        let diff = itrs_residual_to_gcrs(satellite, diff_itrs, observation)?;
1219        let radial_km = diff[0] * rot[0][0] + diff[1] * rot[1][0] + diff[2] * rot[2][0];
1220        let along_km = diff[0] * rot[0][1] + diff[1] * rot[1][1] + diff[2] * rot[2][1];
1221        let cross_km = diff[0] * rot[0][2] + diff[1] * rot[1][2] + diff[2] * rot[2][2];
1222        residuals.push(RtnResidual {
1223            satellite,
1224            time_scale: observation.time_scale,
1225            epoch_j2000_s: observation.epoch_j2000_s,
1226            radial_m: radial_km * M_PER_KM,
1227            along_m: along_km * M_PER_KM,
1228            cross_m: cross_km * M_PER_KM,
1229        });
1230    }
1231    Ok(residuals)
1232}
1233
1234fn predicted_itrs_position(
1235    satellite: GnssSatelliteId,
1236    position_gcrs_km: [f64; 3],
1237    observation: &OrbitObservation,
1238) -> Result<[f64; 3], OrbitFitError> {
1239    if let Some(orientation) = observation.orientation {
1240        return orientation
1241            .gcrf_to_itrf_position_km(position_gcrs_km)
1242            .map_err(|source| OrbitFitError::Frame { satellite, source });
1243    }
1244
1245    let predicted = gcrs_to_itrs_compute(
1246        position_gcrs_km[0],
1247        position_gcrs_km[1],
1248        position_gcrs_km[2],
1249        &observation.time_scales,
1250        false,
1251    )
1252    .map_err(|source| OrbitFitError::Frame { satellite, source })?;
1253    Ok([predicted.0, predicted.1, predicted.2])
1254}
1255
1256fn itrs_residual_to_gcrs(
1257    satellite: GnssSatelliteId,
1258    diff_itrs_km: [f64; 3],
1259    observation: &OrbitObservation,
1260) -> Result<[f64; 3], OrbitFitError> {
1261    if let Some(orientation) = observation.orientation {
1262        return orientation
1263            .itrf_to_gcrf_position_km(diff_itrs_km)
1264            .map_err(|source| OrbitFitError::Frame { satellite, source });
1265    }
1266
1267    let diff_gcrs = itrs_to_gcrs_compute(
1268        diff_itrs_km[0],
1269        diff_itrs_km[1],
1270        diff_itrs_km[2],
1271        &observation.time_scales,
1272    )
1273    .map_err(|source| OrbitFitError::Frame { satellite, source })?;
1274    Ok([diff_gcrs.0, diff_gcrs.1, diff_gcrs.2])
1275}
1276
1277fn ledger_rms_3d_m(residuals: &[RtnResidual]) -> f64 {
1278    let mut sumsq = 0.0;
1279    for residual in residuals {
1280        sumsq += residual.radial_m * residual.radial_m;
1281        sumsq += residual.along_m * residual.along_m;
1282        sumsq += residual.cross_m * residual.cross_m;
1283    }
1284    (sumsq / residuals.len() as f64).sqrt()
1285}
1286
1287#[derive(Default)]
1288struct ResidualAccum {
1289    radial_sumsq_m2: f64,
1290    along_sumsq_m2: f64,
1291    cross_sumsq_m2: f64,
1292    n: usize,
1293}
1294
1295impl ResidualAccum {
1296    fn push(&mut self, residual: RtnResidual) {
1297        self.radial_sumsq_m2 += residual.radial_m * residual.radial_m;
1298        self.along_sumsq_m2 += residual.along_m * residual.along_m;
1299        self.cross_sumsq_m2 += residual.cross_m * residual.cross_m;
1300        self.n += 1;
1301    }
1302
1303    fn finish(&self, min_ledger_samples: usize) -> OrbitResidualStats {
1304        let n = self.n as f64;
1305        OrbitResidualStats {
1306            radial_rms_m: (self.radial_sumsq_m2 / n).sqrt(),
1307            along_rms_m: (self.along_sumsq_m2 / n).sqrt(),
1308            cross_rms_m: (self.cross_sumsq_m2 / n).sqrt(),
1309            rms_3d_m: ((self.radial_sumsq_m2 + self.along_sumsq_m2 + self.cross_sumsq_m2) / n)
1310                .sqrt(),
1311            n: self.n,
1312            low_sample_count: self.n < min_ledger_samples,
1313        }
1314    }
1315}
1316
1317fn build_ledger(
1318    residuals: Vec<RtnResidual>,
1319    time_scale: TimeScale,
1320    min_ledger_samples: usize,
1321) -> Result<OrbitResidualLedger, OrbitFitError> {
1322    if residuals.is_empty() {
1323        return Err(OrbitFitError::EmptySelection);
1324    }
1325    let mut per_sat_accum: BTreeMap<GnssSatelliteId, ResidualAccum> = BTreeMap::new();
1326    let mut per_constellation_accum: BTreeMap<GnssSystem, ResidualAccum> = BTreeMap::new();
1327    let mut start = f64::INFINITY;
1328    let mut end = f64::NEG_INFINITY;
1329    for residual in residuals {
1330        start = start.min(residual.epoch_j2000_s);
1331        end = end.max(residual.epoch_j2000_s);
1332        per_sat_accum
1333            .entry(residual.satellite)
1334            .or_default()
1335            .push(residual);
1336        per_constellation_accum
1337            .entry(residual.satellite.system)
1338            .or_default()
1339            .push(residual);
1340    }
1341
1342    let per_sat = per_sat_accum
1343        .iter()
1344        .map(|(&sat, accum)| (sat, accum.finish(min_ledger_samples)))
1345        .collect();
1346    let per_constellation = per_constellation_accum
1347        .iter()
1348        .map(|(&system, accum)| (system, accum.finish(min_ledger_samples)))
1349        .collect();
1350
1351    Ok(OrbitResidualLedger {
1352        per_sat,
1353        per_constellation,
1354        arc_span: OrbitArcSpan {
1355            time_scale,
1356            start_j2000_s: start,
1357            end_j2000_s: end,
1358            duration_s: end - start,
1359        },
1360    })
1361}