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