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