Skip to main content

sidereon_core/
orbit_determination.rs

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