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