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