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;
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_precise_ephemeris_sample_orbit(
339 samples: &[PreciseEphemerisSample],
340 satellite: GnssSatelliteId,
341 options: &OrbitFitOptions,
342) -> Result<OrbitFitReport, OrbitFitError> {
343 fit_precise_ephemeris_sample_orbits(samples, &[satellite], options)
344}
345
346pub fn fit_precise_ephemeris_sample_orbit_with_initial_state(
349 samples: &[PreciseEphemerisSample],
350 satellite: GnssSatelliteId,
351 initial_state: CartesianState,
352 options: &OrbitFitOptions,
353) -> Result<OrbitFitReport, OrbitFitError> {
354 validate_options(options)?;
355 let work = fit_one_sample_arc(samples, satellite, options, Some(initial_state))?;
356 let time_scale = work
357 .residuals
358 .first()
359 .map(|residual| residual.time_scale)
360 .ok_or(OrbitFitError::EmptySelection)?;
361 let ledger = build_ledger(work.residuals, time_scale, options.min_ledger_samples)?;
362 let mut fits = BTreeMap::new();
363 fits.insert(satellite, work.solution);
364 Ok(OrbitFitReport { fits, ledger })
365}
366
367pub fn fit_precise_ephemeris_state_sample_orbit(
373 samples: &[OrientedPreciseEphemerisStateSample],
374 satellite: GnssSatelliteId,
375 options: &OrbitFitOptions,
376) -> Result<OrbitFitReport, OrbitFitError> {
377 fit_precise_ephemeris_state_sample_orbits(samples, &[satellite], options)
378}
379
380pub fn fit_precise_ephemeris_state_sample_orbits(
383 samples: &[OrientedPreciseEphemerisStateSample],
384 satellites: &[GnssSatelliteId],
385 options: &OrbitFitOptions,
386) -> Result<OrbitFitReport, OrbitFitError> {
387 validate_options(options)?;
388 if satellites.is_empty() {
389 return Err(OrbitFitError::EmptySelection);
390 }
391
392 let mut fits = BTreeMap::new();
393 let mut residuals = Vec::new();
394 let mut time_scale = None;
395 for &satellite in satellites {
396 let work = fit_one_state_sample_arc(samples, satellite, options)?;
397 for residual in &work.residuals {
398 match time_scale {
399 None => time_scale = Some(residual.time_scale),
400 Some(scale) if scale == residual.time_scale => {}
401 Some(_) => return Err(OrbitFitError::MixedTimeScales),
402 }
403 }
404 residuals.extend(work.residuals);
405 fits.insert(satellite, work.solution);
406 }
407
408 let ledger = build_ledger(
409 residuals,
410 time_scale.ok_or(OrbitFitError::EmptySelection)?,
411 options.min_ledger_samples,
412 )?;
413 Ok(OrbitFitReport { fits, ledger })
414}
415
416pub fn fit_precise_ephemeris_sample_orbits(
418 samples: &[PreciseEphemerisSample],
419 satellites: &[GnssSatelliteId],
420 options: &OrbitFitOptions,
421) -> Result<OrbitFitReport, OrbitFitError> {
422 validate_options(options)?;
423 if satellites.is_empty() {
424 return Err(OrbitFitError::EmptySelection);
425 }
426
427 let mut fits = BTreeMap::new();
428 let mut residuals = Vec::new();
429 let mut time_scale = None;
430 for &satellite in satellites {
431 let work = fit_one_sample_arc(samples, satellite, options, None)?;
432 for residual in &work.residuals {
433 match time_scale {
434 None => time_scale = Some(residual.time_scale),
435 Some(scale) if scale == residual.time_scale => {}
436 Some(_) => return Err(OrbitFitError::MixedTimeScales),
437 }
438 }
439 residuals.extend(work.residuals);
440 fits.insert(satellite, work.solution);
441 }
442
443 let ledger = build_ledger(
444 residuals,
445 time_scale.ok_or(OrbitFitError::EmptySelection)?,
446 options.min_ledger_samples,
447 )?;
448 Ok(OrbitFitReport { fits, ledger })
449}
450
451fn validate_options(options: &OrbitFitOptions) -> Result<(), OrbitFitError> {
452 if options.min_ledger_samples == 0 {
453 return Err(OrbitFitError::InvalidOption {
454 field: "min_ledger_samples",
455 reason: "not positive",
456 });
457 }
458 Ok(())
459}
460
461struct FitWork {
462 solution: OrbitFitSolution,
463 residuals: Vec<RtnResidual>,
464}
465
466fn fit_one_sample_arc(
467 samples: &[PreciseEphemerisSample],
468 satellite: GnssSatelliteId,
469 options: &OrbitFitOptions,
470 initial_seed: Option<CartesianState>,
471) -> Result<FitWork, OrbitFitError> {
472 let observations = collect_observations(samples, satellite)?;
473 fit_one_observation_arc(satellite, observations, options, initial_seed)
474}
475
476fn fit_one_state_sample_arc(
477 samples: &[OrientedPreciseEphemerisStateSample],
478 satellite: GnssSatelliteId,
479 options: &OrbitFitOptions,
480) -> Result<FitWork, OrbitFitError> {
481 let observations = collect_state_observations(samples, satellite)?;
482 fit_one_observation_arc(satellite, observations, options, None)
483}
484
485fn fit_one_observation_arc(
486 satellite: GnssSatelliteId,
487 observations: Vec<OrbitObservation>,
488 options: &OrbitFitOptions,
489 initial_seed: Option<CartesianState>,
490) -> Result<FitWork, OrbitFitError> {
491 let seed = match initial_seed {
492 Some(seed) => validate_initial_seed(satellite, seed, observations.as_slice())?,
493 None => seed_initial_state(satellite, &observations, options)?,
494 };
495 let seed_vector = state_to_vector(seed);
496 let param_scales = parameter_scales(&seed_vector);
497 let seed_residual =
498 residual_vector_for_params(satellite, &seed_vector, &observations, options)?;
499 let seed_rms_3d_m = residual_rms_3d_m(seed_residual.as_slice());
500
501 let residual_error = RefCell::new(None);
502 let observations_for_closure = observations.clone();
503 let residual = |x: &DVector<f64>| -> DVector<f64> {
504 let physical = unscale_params(x.as_slice(), ¶m_scales);
505 match residual_vector_for_params(satellite, &physical, &observations_for_closure, options) {
506 Ok(values) => DVector::from_vec(values),
507 Err(error) => {
508 *residual_error.borrow_mut() = Some(error);
509 DVector::from_element(observations_for_closure.len() * 3, f64::NAN)
510 }
511 }
512 };
513
514 let problem = LeastSquaresProblem::new(
515 residual,
516 DVector::from_vec(scale_params(&seed_vector, ¶m_scales).to_vec()),
517 );
518 let report = match solve_trf_with(&problem, &options.solver_options, options.linear_solve) {
519 Ok(report) => report,
520 Err(SolveError::SingularJacobian) => {
521 let geometry_quality = singular_geometry_quality(observations.len(), options);
522 return Err(OrbitFitError::SingularGeometry {
523 satellite,
524 geometry_quality,
525 });
526 }
527 Err(error) => {
528 if let Some(source) = residual_error.into_inner() {
529 return Err(source);
530 }
531 return Err(OrbitFitError::LeastSquares {
532 satellite,
533 source: error,
534 });
535 }
536 };
537
538 if matches!(report.status, Status::MaxEvaluations) {
539 return Err(OrbitFitError::DidNotConverge {
540 satellite,
541 iterations: report.iterations,
542 });
543 }
544
545 let physical_jacobian = physical_jacobian(&report.jacobian, ¶m_scales);
546 let geometry_quality = classify_fit_geometry(&physical_jacobian, options);
547 if geometry_quality.rank < STATE_PARAM_COUNT {
548 return Err(OrbitFitError::SingularGeometry {
549 satellite,
550 geometry_quality,
551 });
552 }
553
554 let covariance = fit_covariance(satellite, &physical_jacobian, report.cost)?;
555 let final_params = unscale_params(report.x.as_slice(), ¶m_scales);
556 let initial_state = CartesianState::new(
557 observations[0].epoch_j2000_s,
558 [final_params[0], final_params[1], final_params[2]],
559 [final_params[3], final_params[4], final_params[5]],
560 );
561 let fit_residuals = rtn_residuals_for_state(satellite, initial_state, &observations, options)?;
562 let fit_rms_3d_m = ledger_rms_3d_m(&fit_residuals);
563
564 Ok(FitWork {
565 solution: OrbitFitSolution {
566 satellite,
567 initial_state,
568 covariance,
569 geometry_quality,
570 seed_rms_3d_m,
571 fit_rms_3d_m,
572 iterations: report.iterations,
573 },
574 residuals: fit_residuals,
575 })
576}
577
578fn fit_covariance(
579 satellite: GnssSatelliteId,
580 jacobian: &DMatrix<f64>,
581 cost: f64,
582) -> Result<OrbitFitCovariance, OrbitFitError> {
583 if jacobian.nrows() <= jacobian.ncols() {
584 return Ok(OrbitFitCovariance::Unbounded);
585 }
586 let covariance = least_squares::covariance_from_jacobian(jacobian, cost)
587 .map_err(|source| OrbitFitError::LeastSquares { satellite, source })?;
588 Ok(OrbitFitCovariance::Estimated {
589 matrix: Box::new(matrix6(&covariance)),
590 })
591}
592
593#[derive(Debug, Clone)]
594struct OrbitObservation {
595 epoch_j2000_s: f64,
596 time_scale: TimeScale,
597 time_scales: TimeScales,
598 observed_itrs_km: [f64; 3],
599 observed_gcrs_km: [f64; 3],
600 observed_gcrs_velocity_km_s: Option<[f64; 3]>,
601}
602
603fn collect_observations(
604 samples: &[PreciseEphemerisSample],
605 satellite: GnssSatelliteId,
606) -> Result<Vec<OrbitObservation>, OrbitFitError> {
607 let mut observations = Vec::new();
608 for sample in samples.iter().filter(|sample| sample.sat == satellite) {
609 validate_position(sample.position_ecef_m, satellite)?;
610 let epoch_j2000_s = instant_j2000_seconds(sample.epoch, satellite)?;
611 let ts = time_scales_from_instant(sample.epoch, epoch_j2000_s, satellite)?;
612 let [x_m, y_m, z_m] = sample.position_ecef_m;
613 let (x, y, z) = itrs_to_gcrs_compute(x_m / M_PER_KM, y_m / M_PER_KM, z_m / M_PER_KM, &ts)
614 .map_err(|source| OrbitFitError::Frame { satellite, source })?;
615 observations.push(OrbitObservation {
616 epoch_j2000_s,
617 time_scale: sample.epoch.scale,
618 time_scales: ts,
619 observed_itrs_km: [x_m / M_PER_KM, y_m / M_PER_KM, z_m / M_PER_KM],
620 observed_gcrs_km: [x, y, z],
621 observed_gcrs_velocity_km_s: None,
622 });
623 }
624 validate_observations(satellite, observations)
625}
626
627fn collect_state_observations(
628 samples: &[OrientedPreciseEphemerisStateSample],
629 satellite: GnssSatelliteId,
630) -> Result<Vec<OrbitObservation>, OrbitFitError> {
631 let mut observations = Vec::new();
632 for oriented in samples
633 .iter()
634 .filter(|oriented| oriented.sample.sat == satellite)
635 {
636 validate_position(oriented.sample.position_ecef_m, satellite)?;
637 validate_velocity(oriented.sample.velocity_ecef_m_s, satellite)?;
638 let inertial = sp3_ecef_state_to_eci(&oriented.sample, &oriented.orientation)
639 .map_err(|source| OrbitFitError::Frame { satellite, source })?;
640 let [x_m, y_m, z_m] = oriented.sample.position_ecef_m;
641 observations.push(OrbitObservation {
642 epoch_j2000_s: inertial.epoch_tdb_seconds,
643 time_scale: oriented.sample.epoch.scale,
644 time_scales: oriented.orientation.time_scales(),
645 observed_itrs_km: [x_m / M_PER_KM, y_m / M_PER_KM, z_m / M_PER_KM],
646 observed_gcrs_km: inertial.position_array(),
647 observed_gcrs_velocity_km_s: Some(inertial.velocity_array()),
648 });
649 }
650 validate_observations(satellite, observations)
651}
652
653fn validate_observations(
654 satellite: GnssSatelliteId,
655 mut observations: Vec<OrbitObservation>,
656) -> Result<Vec<OrbitObservation>, OrbitFitError> {
657 observations.sort_by(|a, b| a.epoch_j2000_s.total_cmp(&b.epoch_j2000_s));
658 if observations.len() < MIN_SEED_SAMPLES {
659 return Err(OrbitFitError::TooFewSamples {
660 satellite,
661 got: observations.len(),
662 required: MIN_SEED_SAMPLES,
663 });
664 }
665 if observations
666 .windows(2)
667 .any(|window| window[1].epoch_j2000_s <= window[0].epoch_j2000_s)
668 {
669 return Err(OrbitFitError::NonMonotonicEpochs { satellite });
670 }
671 if observations
672 .windows(2)
673 .any(|window| window[1].time_scale != window[0].time_scale)
674 {
675 return Err(OrbitFitError::MixedTimeScales);
676 }
677 Ok(observations)
678}
679
680fn validate_position(
681 position_ecef_m: [f64; 3],
682 satellite: GnssSatelliteId,
683) -> Result<(), OrbitFitError> {
684 if position_ecef_m.iter().all(|value| value.is_finite()) {
685 Ok(())
686 } else {
687 Err(OrbitFitError::InvalidObservation {
688 satellite,
689 reason: "position components must be finite",
690 })
691 }
692}
693
694fn validate_velocity(
695 velocity_ecef_m_s: [f64; 3],
696 satellite: GnssSatelliteId,
697) -> Result<(), OrbitFitError> {
698 if velocity_ecef_m_s.iter().all(|value| value.is_finite()) {
699 Ok(())
700 } else {
701 Err(OrbitFitError::InvalidObservation {
702 satellite,
703 reason: "velocity components must be finite",
704 })
705 }
706}
707
708fn instant_j2000_seconds(
709 instant: Instant,
710 satellite: GnssSatelliteId,
711) -> Result<f64, OrbitFitError> {
712 let jd = instant
713 .julian_date()
714 .ok_or_else(|| OrbitFitError::InvalidEpoch {
715 satellite,
716 reason: "epoch is not a split Julian date".to_string(),
717 })?;
718 let seconds = j2000_seconds_from_split(jd.jd_whole, jd.fraction);
719 if seconds.is_finite() {
720 Ok(seconds)
721 } else {
722 Err(OrbitFitError::InvalidEpoch {
723 satellite,
724 reason: "J2000 seconds are not finite".to_string(),
725 })
726 }
727}
728
729fn time_scales_from_instant(
730 instant: Instant,
731 epoch_j2000_s: f64,
732 satellite: GnssSatelliteId,
733) -> Result<TimeScales, OrbitFitError> {
734 let whole = epoch_j2000_s.floor();
735 if whole < i64::MIN as f64 || whole > i64::MAX as f64 {
736 return Err(OrbitFitError::InvalidEpoch {
737 satellite,
738 reason: "J2000 seconds are outside calendar range".to_string(),
739 });
740 }
741 let fraction = epoch_j2000_s - whole;
742 let (year, month, day, hour, minute, second) = civil_from_j2000_seconds(whole as i64);
743 TimeScales::from_scale(
744 instant.scale,
745 year as i32,
746 month as i32,
747 day as i32,
748 hour as i32,
749 minute as i32,
750 second as f64 + fraction,
751 )
752 .map_err(|error| OrbitFitError::InvalidEpoch {
753 satellite,
754 reason: error.to_string(),
755 })
756}
757
758fn seed_initial_state(
759 satellite: GnssSatelliteId,
760 observations: &[OrbitObservation],
761 options: &OrbitFitOptions,
762) -> Result<CartesianState, OrbitFitError> {
763 if let Some(velocity) = observations[0].observed_gcrs_velocity_km_s {
764 return Ok(CartesianState::new(
765 observations[0].epoch_j2000_s,
766 observations[0].observed_gcrs_km,
767 velocity,
768 ));
769 }
770
771 if observations.len() >= 3 {
772 let r1 = observations[0].observed_gcrs_km;
773 let r2 = observations[1].observed_gcrs_km;
774 let r3 = observations[2].observed_gcrs_km;
775 let jd1 = observations[0].epoch_j2000_s / SECONDS_PER_DAY;
776 let jd2 = observations[1].epoch_j2000_s / SECONDS_PER_DAY;
777 let jd3 = observations[2].epoch_j2000_s / SECONDS_PER_DAY;
778 if let Ok((v2, _, _, _)) = iod::hgibbs(&r1, &r2, &r3, jd1, jd2, jd3) {
779 let midpoint = CartesianState::new(observations[1].epoch_j2000_s, r2, v2);
780 if let Ok(result) =
781 build_propagator(midpoint, options).propagate_to(observations[0].epoch_j2000_s)
782 {
783 return Ok(result.final_state);
784 }
785 }
786 }
787
788 let first = &observations[0];
789 let second = &observations[1];
790 let dt = second.epoch_j2000_s - first.epoch_j2000_s;
791 if !dt.is_finite() || dt <= 0.0 {
792 return Err(OrbitFitError::NonMonotonicEpochs { satellite });
793 }
794 let velocity = [
795 (second.observed_gcrs_km[0] - first.observed_gcrs_km[0]) / dt,
796 (second.observed_gcrs_km[1] - first.observed_gcrs_km[1]) / dt,
797 (second.observed_gcrs_km[2] - first.observed_gcrs_km[2]) / dt,
798 ];
799 Ok(CartesianState::new(
800 first.epoch_j2000_s,
801 first.observed_gcrs_km,
802 velocity,
803 ))
804}
805
806fn validate_initial_seed(
807 satellite: GnssSatelliteId,
808 seed: CartesianState,
809 observations: &[OrbitObservation],
810) -> Result<CartesianState, OrbitFitError> {
811 if seed.epoch_tdb_seconds != observations[0].epoch_j2000_s {
812 return Err(OrbitFitError::InvalidEpoch {
813 satellite,
814 reason: "initial-state seed epoch must match the first sample".to_string(),
815 });
816 }
817 let params = state_to_vector(seed);
818 if params.iter().all(|value| value.is_finite()) {
819 Ok(seed)
820 } else {
821 Err(OrbitFitError::InvalidObservation {
822 satellite,
823 reason: "initial-state seed components must be finite",
824 })
825 }
826}
827
828fn state_to_vector(state: CartesianState) -> [f64; STATE_PARAM_COUNT] {
829 [
830 state.position_km.x,
831 state.position_km.y,
832 state.position_km.z,
833 state.velocity_km_s.x,
834 state.velocity_km_s.y,
835 state.velocity_km_s.z,
836 ]
837}
838
839fn parameter_scales(params: &[f64; STATE_PARAM_COUNT]) -> [f64; STATE_PARAM_COUNT] {
840 let position_scale = (params[0] * params[0] + params[1] * params[1] + params[2] * params[2])
841 .sqrt()
842 .max(1.0);
843 let velocity_scale = (params[3] * params[3] + params[4] * params[4] + params[5] * params[5])
844 .sqrt()
845 .max(1.0);
846 [
847 position_scale,
848 position_scale,
849 position_scale,
850 velocity_scale,
851 velocity_scale,
852 velocity_scale,
853 ]
854}
855
856fn scale_params(
857 params: &[f64; STATE_PARAM_COUNT],
858 scales: &[f64; STATE_PARAM_COUNT],
859) -> [f64; STATE_PARAM_COUNT] {
860 [
861 params[0] / scales[0],
862 params[1] / scales[1],
863 params[2] / scales[2],
864 params[3] / scales[3],
865 params[4] / scales[4],
866 params[5] / scales[5],
867 ]
868}
869
870fn unscale_params(params: &[f64], scales: &[f64; STATE_PARAM_COUNT]) -> [f64; STATE_PARAM_COUNT] {
871 [
872 params[0] * scales[0],
873 params[1] * scales[1],
874 params[2] * scales[2],
875 params[3] * scales[3],
876 params[4] * scales[4],
877 params[5] * scales[5],
878 ]
879}
880
881fn physical_jacobian(
882 scaled_jacobian: &DMatrix<f64>,
883 scales: &[f64; STATE_PARAM_COUNT],
884) -> DMatrix<f64> {
885 let mut jacobian = scaled_jacobian.clone();
886 for col in 0..STATE_PARAM_COUNT {
887 for row in 0..jacobian.nrows() {
888 jacobian[(row, col)] /= scales[col];
889 }
890 }
891 jacobian
892}
893
894fn residual_vector_for_params(
895 satellite: GnssSatelliteId,
896 params: &[f64],
897 observations: &[OrbitObservation],
898 options: &OrbitFitOptions,
899) -> Result<Vec<f64>, OrbitFitError> {
900 if params.len() != STATE_PARAM_COUNT {
901 return Err(OrbitFitError::InvalidObservation {
902 satellite,
903 reason: "state parameter length mismatch",
904 });
905 }
906 if !params.iter().all(|value| value.is_finite()) {
907 return Err(OrbitFitError::InvalidObservation {
908 satellite,
909 reason: "state parameters must be finite",
910 });
911 }
912 let initial = CartesianState::new(
913 observations[0].epoch_j2000_s,
914 [params[0], params[1], params[2]],
915 [params[3], params[4], params[5]],
916 );
917 let states = propagate_to_observations(satellite, initial, observations, options)?;
918 let mut residual = Vec::with_capacity(observations.len() * 3);
919 for (state, observation) in states.iter().zip(observations) {
920 let predicted_itrs = gcrs_to_itrs_compute(
921 state.position_km.x,
922 state.position_km.y,
923 state.position_km.z,
924 &observation.time_scales,
925 false,
926 )
927 .map_err(|source| OrbitFitError::Frame { satellite, source })?;
928 residual.push(predicted_itrs.0 - observation.observed_itrs_km[0]);
929 residual.push(predicted_itrs.1 - observation.observed_itrs_km[1]);
930 residual.push(predicted_itrs.2 - observation.observed_itrs_km[2]);
931 }
932 Ok(residual)
933}
934
935fn propagate_to_observations(
936 satellite: GnssSatelliteId,
937 initial: CartesianState,
938 observations: &[OrbitObservation],
939 options: &OrbitFitOptions,
940) -> Result<Vec<CartesianState>, OrbitFitError> {
941 let epochs: Vec<f64> = observations
942 .iter()
943 .map(|observation| observation.epoch_j2000_s)
944 .collect();
945 build_propagator(initial, options)
946 .ephemeris(&epochs)
947 .map_err(|source| OrbitFitError::Propagation { satellite, source })
948}
949
950fn build_propagator(initial: CartesianState, options: &OrbitFitOptions) -> StatePropagator {
951 StatePropagator {
952 initial,
953 force_model: options.force_model,
954 integrator: options.integrator,
955 options: options.integrator_options,
956 drag: options.drag,
957 space_weather: options.space_weather.clone(),
958 }
959}
960
961fn residual_rms_3d_m(residual_km: &[f64]) -> f64 {
962 let n = residual_km.len() / 3;
963 let sumsq_m2 = residual_km
964 .iter()
965 .map(|value| {
966 let meters = value * M_PER_KM;
967 meters * meters
968 })
969 .sum::<f64>();
970 (sumsq_m2 / n as f64).sqrt()
971}
972
973fn singular_geometry_quality(
974 observation_count: usize,
975 options: &OrbitFitOptions,
976) -> GeometryQuality {
977 classify(
978 0,
979 STATE_PARAM_COUNT,
980 observation_count as i32 * 3 - STATE_PARAM_COUNT as i32,
981 f64::INFINITY,
982 f64::INFINITY,
983 false,
984 options.geometry_thresholds,
985 )
986}
987
988fn classify_fit_geometry(jacobian: &DMatrix<f64>, options: &OrbitFitOptions) -> GeometryQuality {
989 let singular = jacobian.clone().svd(false, false).singular_values;
990 let diagnostics =
991 singular_value_diagnostics(singular.as_slice(), jacobian.nrows(), jacobian.ncols());
992 let gdop = least_squares::normal_covariance(jacobian, 1.0)
993 .map(|cofactor| {
994 (0..cofactor.nrows())
995 .map(|index| cofactor[(index, index)])
996 .sum::<f64>()
997 .sqrt()
998 })
999 .unwrap_or(f64::INFINITY);
1000 classify(
1001 diagnostics.rank,
1002 STATE_PARAM_COUNT,
1003 jacobian.nrows() as i32 - STATE_PARAM_COUNT as i32,
1004 diagnostics.condition_number,
1005 gdop,
1006 false,
1007 options.geometry_thresholds,
1008 )
1009}
1010
1011fn matrix6(matrix: &DMatrix<f64>) -> [[f64; STATE_PARAM_COUNT]; STATE_PARAM_COUNT] {
1012 let mut out = [[0.0_f64; STATE_PARAM_COUNT]; STATE_PARAM_COUNT];
1013 for row in 0..STATE_PARAM_COUNT {
1014 for col in 0..STATE_PARAM_COUNT {
1015 out[row][col] = matrix[(row, col)];
1016 }
1017 }
1018 out
1019}
1020
1021#[derive(Debug, Clone, Copy)]
1022struct RtnResidual {
1023 satellite: GnssSatelliteId,
1024 time_scale: TimeScale,
1025 epoch_j2000_s: f64,
1026 radial_m: f64,
1027 along_m: f64,
1028 cross_m: f64,
1029}
1030
1031fn rtn_residuals_for_state(
1032 satellite: GnssSatelliteId,
1033 initial: CartesianState,
1034 observations: &[OrbitObservation],
1035 options: &OrbitFitOptions,
1036) -> Result<Vec<RtnResidual>, OrbitFitError> {
1037 let states = propagate_to_observations(satellite, initial, observations, options)?;
1038 let mut residuals = Vec::with_capacity(observations.len());
1039 for (state, observation) in states.iter().zip(observations) {
1040 let rot = rtn_to_eci_rotation(state.position_array(), state.velocity_array())
1041 .map_err(|reason| OrbitFitError::RtnFrame { satellite, reason })?;
1042 let predicted_itrs = gcrs_to_itrs_compute(
1043 state.position_km.x,
1044 state.position_km.y,
1045 state.position_km.z,
1046 &observation.time_scales,
1047 false,
1048 )
1049 .map_err(|source| OrbitFitError::Frame { satellite, source })?;
1050 let diff_itrs = [
1051 predicted_itrs.0 - observation.observed_itrs_km[0],
1052 predicted_itrs.1 - observation.observed_itrs_km[1],
1053 predicted_itrs.2 - observation.observed_itrs_km[2],
1054 ];
1055 let diff_gcrs = itrs_to_gcrs_compute(
1056 diff_itrs[0],
1057 diff_itrs[1],
1058 diff_itrs[2],
1059 &observation.time_scales,
1060 )
1061 .map_err(|source| OrbitFitError::Frame { satellite, source })?;
1062 let diff = [diff_gcrs.0, diff_gcrs.1, diff_gcrs.2];
1063 let radial_km = diff[0] * rot[0][0] + diff[1] * rot[1][0] + diff[2] * rot[2][0];
1064 let along_km = diff[0] * rot[0][1] + diff[1] * rot[1][1] + diff[2] * rot[2][1];
1065 let cross_km = diff[0] * rot[0][2] + diff[1] * rot[1][2] + diff[2] * rot[2][2];
1066 residuals.push(RtnResidual {
1067 satellite,
1068 time_scale: observation.time_scale,
1069 epoch_j2000_s: observation.epoch_j2000_s,
1070 radial_m: radial_km * M_PER_KM,
1071 along_m: along_km * M_PER_KM,
1072 cross_m: cross_km * M_PER_KM,
1073 });
1074 }
1075 Ok(residuals)
1076}
1077
1078fn ledger_rms_3d_m(residuals: &[RtnResidual]) -> f64 {
1079 let mut sumsq = 0.0;
1080 for residual in residuals {
1081 sumsq += residual.radial_m * residual.radial_m;
1082 sumsq += residual.along_m * residual.along_m;
1083 sumsq += residual.cross_m * residual.cross_m;
1084 }
1085 (sumsq / residuals.len() as f64).sqrt()
1086}
1087
1088#[derive(Default)]
1089struct ResidualAccum {
1090 radial_sumsq_m2: f64,
1091 along_sumsq_m2: f64,
1092 cross_sumsq_m2: f64,
1093 n: usize,
1094}
1095
1096impl ResidualAccum {
1097 fn push(&mut self, residual: RtnResidual) {
1098 self.radial_sumsq_m2 += residual.radial_m * residual.radial_m;
1099 self.along_sumsq_m2 += residual.along_m * residual.along_m;
1100 self.cross_sumsq_m2 += residual.cross_m * residual.cross_m;
1101 self.n += 1;
1102 }
1103
1104 fn finish(&self, min_ledger_samples: usize) -> OrbitResidualStats {
1105 let n = self.n as f64;
1106 OrbitResidualStats {
1107 radial_rms_m: (self.radial_sumsq_m2 / n).sqrt(),
1108 along_rms_m: (self.along_sumsq_m2 / n).sqrt(),
1109 cross_rms_m: (self.cross_sumsq_m2 / n).sqrt(),
1110 rms_3d_m: ((self.radial_sumsq_m2 + self.along_sumsq_m2 + self.cross_sumsq_m2) / n)
1111 .sqrt(),
1112 n: self.n,
1113 low_sample_count: self.n < min_ledger_samples,
1114 }
1115 }
1116}
1117
1118fn build_ledger(
1119 residuals: Vec<RtnResidual>,
1120 time_scale: TimeScale,
1121 min_ledger_samples: usize,
1122) -> Result<OrbitResidualLedger, OrbitFitError> {
1123 if residuals.is_empty() {
1124 return Err(OrbitFitError::EmptySelection);
1125 }
1126 let mut per_sat_accum: BTreeMap<GnssSatelliteId, ResidualAccum> = BTreeMap::new();
1127 let mut per_constellation_accum: BTreeMap<GnssSystem, ResidualAccum> = BTreeMap::new();
1128 let mut start = f64::INFINITY;
1129 let mut end = f64::NEG_INFINITY;
1130 for residual in residuals {
1131 start = start.min(residual.epoch_j2000_s);
1132 end = end.max(residual.epoch_j2000_s);
1133 per_sat_accum
1134 .entry(residual.satellite)
1135 .or_default()
1136 .push(residual);
1137 per_constellation_accum
1138 .entry(residual.satellite.system)
1139 .or_default()
1140 .push(residual);
1141 }
1142
1143 let per_sat = per_sat_accum
1144 .iter()
1145 .map(|(&sat, accum)| (sat, accum.finish(min_ledger_samples)))
1146 .collect();
1147 let per_constellation = per_constellation_accum
1148 .iter()
1149 .map(|(&system, accum)| (system, accum.finish(min_ledger_samples)))
1150 .collect();
1151
1152 Ok(OrbitResidualLedger {
1153 per_sat,
1154 per_constellation,
1155 arc_span: OrbitArcSpan {
1156 time_scale,
1157 start_j2000_s: start,
1158 end_j2000_s: end,
1159 duration_s: end - start,
1160 },
1161 })
1162}