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