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