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