1use std::cell::Cell;
33use std::collections::{BTreeMap, BTreeSet};
34
35use nalgebra::{DMatrix, DVector};
36
37use crate::astro::math::least_squares::{
38 self, normal_covariance, singular_value_diagnostics, solve_trf_with, LeastSquaresProblem,
39 SolveOptions, Status, TrustRegionSolve,
40};
41use crate::astro::math::robust::{huber_weight, mad_scale, RobustError};
42use crate::dop::rotate_covariance_ecef_to_enu_m2;
43use crate::estimation::substrate::frames::geodetic_from_ecef;
44use crate::frame::{ItrfPositionM, Wgs84Geodetic};
45use crate::geometry_quality::{classify, GeometryQuality, GeometryQualityThresholds};
46use crate::id::{GnssSatelliteId, GnssSystem};
47use crate::sbas::SbasIonoGrid;
48use crate::spp::{
49 clock_systems, residual_unweighted, select_sats, spp_iono_frequency_hz, validate_solve_inputs,
50 Corrections, EphemerisSource, GalileoNequickCoeffs, KlobucharCoeffs, Observation, RejectedSat,
51 RobustConfig, SolveInputs, SppError, SppInputErrorKind, SppModelRecipe, SurfaceMet, C_M_S,
52};
53use crate::validate;
54
55const STATIC_SOLVER_GTOL: f64 = 1e-14;
56const STATIC_SOLVER_FTOL: f64 = 1e-15;
57const STATIC_SOLVER_XTOL: f64 = 1e-14;
58const STATIC_SOLVER_MAX_NFEV: usize = 400;
59const STATIC_POSITION_FD_MIN_STEP_M: f64 = 0.1;
66
67#[derive(Debug, Clone, PartialEq)]
74pub struct StaticEpoch {
75 pub measurements: Vec<Observation>,
77 pub weights: Option<Vec<f64>>,
80 pub t_rx_j2000_s: f64,
82 pub t_rx_second_of_day_s: f64,
84 pub day_of_year: f64,
86 pub clock_initial_m: f64,
88 pub corrections: Corrections,
90 pub klobuchar: KlobucharCoeffs,
92 pub beidou_klobuchar: Option<KlobucharCoeffs>,
94 pub galileo_nequick: Option<GalileoNequickCoeffs>,
96 pub sbas_iono: Option<SbasIonoGrid>,
98 pub glonass_channels: BTreeMap<u8, i8>,
100 pub met: SurfaceMet,
102}
103
104impl StaticEpoch {
105 pub fn from_solve_inputs(inputs: SolveInputs) -> Self {
113 Self {
114 measurements: inputs.observations,
115 weights: None,
116 t_rx_j2000_s: inputs.t_rx_j2000_s,
117 t_rx_second_of_day_s: inputs.t_rx_second_of_day_s,
118 day_of_year: inputs.day_of_year,
119 clock_initial_m: inputs.initial_guess[3],
120 corrections: inputs.corrections,
121 klobuchar: inputs.klobuchar,
122 beidou_klobuchar: inputs.beidou_klobuchar,
123 galileo_nequick: inputs.galileo_nequick,
124 sbas_iono: inputs.sbas_iono,
125 glonass_channels: inputs.glonass_channels,
126 met: inputs.met,
127 }
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq)]
133pub struct StaticSolveOptions {
134 pub initial_position_m: [f64; 3],
136 pub with_geodetic: bool,
138 pub robust: Option<RobustConfig>,
140}
141
142impl StaticSolveOptions {
143 pub fn from_solve_inputs(inputs: &SolveInputs, with_geodetic: bool) -> Self {
148 Self {
149 initial_position_m: [
150 inputs.initial_guess[0],
151 inputs.initial_guess[1],
152 inputs.initial_guess[2],
153 ],
154 with_geodetic,
155 robust: inputs.robust,
156 }
157 }
158}
159
160impl Default for StaticSolveOptions {
161 fn default() -> Self {
162 Self {
163 initial_position_m: [0.0; 3],
164 with_geodetic: false,
165 robust: None,
166 }
167 }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq)]
172pub struct StaticClockBias {
173 pub epoch_index: usize,
175 pub system: GnssSystem,
177 pub clock_s: f64,
179}
180
181#[derive(Debug, Clone, PartialEq)]
187pub struct StaticCovariance {
188 pub state_m2: Vec<Vec<f64>>,
190 pub position_ecef_m2: [[f64; 3]; 3],
192 pub position_enu_m2: [[f64; 3]; 3],
194}
195
196#[derive(Debug, Clone, Copy, PartialEq)]
198pub struct StaticResidual {
199 pub epoch_index: usize,
201 pub satellite_id: GnssSatelliteId,
203 pub residual_m: f64,
205 pub base_weight: f64,
207 pub effective_weight: f64,
209 pub robust_weight_ratio: f64,
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub enum StaticInfluenceStatus {
216 Solved,
218 TooFewMeasurements,
220 SingularGeometry,
222 InvalidInput,
224 EphemerisUnavailable,
226 SolveFailed,
228}
229
230#[derive(Debug, Clone, PartialEq)]
232pub struct StaticEpochInfluence {
233 pub epoch_index: usize,
235 pub omitted_measurements: usize,
237 pub status: StaticInfluenceStatus,
239 pub position_delta_m: Option<[f64; 3]>,
241 pub position_delta_norm_m: Option<f64>,
243 pub residual_rms_m: Option<f64>,
245 pub min_robust_weight_ratio: f64,
247}
248
249#[derive(Debug, Clone, PartialEq)]
251pub struct StaticSatelliteInfluence {
252 pub epoch_index: usize,
254 pub satellite_id: GnssSatelliteId,
256 pub status: StaticInfluenceStatus,
258 pub position_delta_m: Option<[f64; 3]>,
260 pub position_delta_norm_m: Option<f64>,
262 pub residual_rms_m: Option<f64>,
264 pub residual_m: f64,
266 pub base_weight: f64,
268 pub effective_weight: f64,
270 pub robust_weight_ratio: f64,
272}
273
274#[derive(Debug, Clone, PartialEq)]
276pub struct StaticSatelliteBatchInfluence {
277 pub satellite_id: GnssSatelliteId,
279 pub omitted_measurements: usize,
281 pub status: StaticInfluenceStatus,
283 pub position_delta_m: Option<[f64; 3]>,
285 pub position_delta_norm_m: Option<f64>,
287 pub residual_rms_m: Option<f64>,
289 pub min_robust_weight_ratio: f64,
291}
292
293#[derive(Debug, Clone, PartialEq)]
295pub struct StaticSolutionMetadata {
296 pub iterations: usize,
298 pub converged: bool,
300 pub status: Status,
302 pub outer_iterations: usize,
304 pub final_robust_scale_m: Option<f64>,
306 pub used_measurements: usize,
308 pub n_parameters: usize,
310 pub redundancy: isize,
312}
313
314#[derive(Debug, Clone, PartialEq)]
316pub struct StaticSolution {
317 pub position: ItrfPositionM,
319 pub geodetic: Option<Wgs84Geodetic>,
321 pub per_epoch_clock: Vec<StaticClockBias>,
323 pub covariance: StaticCovariance,
325 pub per_epoch_influence: Vec<StaticEpochInfluence>,
327 pub per_satellite_influence: Vec<StaticSatelliteInfluence>,
329 pub per_satellite_batch_influence: Vec<StaticSatelliteBatchInfluence>,
331 pub geometry_quality: GeometryQuality,
333 pub residuals_m: Vec<StaticResidual>,
335 pub used_sats: Vec<Vec<GnssSatelliteId>>,
337 pub rejected_sats: Vec<Vec<RejectedSat>>,
339 pub metadata: StaticSolutionMetadata,
341}
342
343impl StaticSolution {
344 pub fn residual_rms_m(&self) -> f64 {
346 residual_rms(
347 &self
348 .residuals_m
349 .iter()
350 .map(|r| r.residual_m)
351 .collect::<Vec<_>>(),
352 )
353 }
354}
355
356#[derive(Debug, Clone)]
358pub enum StaticSolveError {
359 EmptyEpochs,
361 InvalidInput {
363 field: &'static str,
365 kind: SppInputErrorKind,
367 },
368 EpochInput {
370 epoch_index: usize,
372 source: SppError,
374 },
375 DuplicateObservation {
377 epoch_index: usize,
379 satellite: GnssSatelliteId,
381 },
382 IonosphereUnsupported {
384 epoch_index: usize,
386 satellite: GnssSatelliteId,
388 },
389 TooFewMeasurements {
391 used: usize,
393 required: usize,
395 },
396 EphemerisLost {
398 epoch_index: usize,
400 satellite: GnssSatelliteId,
402 },
403 Singular(least_squares::SolveError),
405}
406
407impl core::fmt::Display for StaticSolveError {
408 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
409 match self {
410 Self::EmptyEpochs => write!(f, "no static epochs supplied"),
411 Self::InvalidInput { field, kind } => {
412 write!(f, "invalid static solve input {field}: {kind}")
413 }
414 Self::EpochInput {
415 epoch_index,
416 source,
417 } => write!(f, "invalid static epoch {epoch_index}: {source}"),
418 Self::DuplicateObservation {
419 epoch_index,
420 satellite,
421 } => write!(
422 f,
423 "static epoch {epoch_index} observes satellite {satellite} more than once"
424 ),
425 Self::IonosphereUnsupported {
426 epoch_index,
427 satellite,
428 } => write!(
429 f,
430 "static epoch {epoch_index} has no ionosphere carrier model for {satellite}"
431 ),
432 Self::TooFewMeasurements { used, required } => write!(
433 f,
434 "only {used} usable static measurements; need at least {required}"
435 ),
436 Self::EphemerisLost {
437 epoch_index,
438 satellite,
439 } => write!(
440 f,
441 "static epoch {epoch_index} satellite {satellite} lost ephemeris during the solve"
442 ),
443 Self::Singular(error) => write!(f, "static geometry is singular: {error}"),
444 }
445 }
446}
447
448impl std::error::Error for StaticSolveError {
449 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
450 match self {
451 Self::EpochInput { source, .. } => Some(source),
452 Self::Singular(error) => Some(error),
453 _ => None,
454 }
455 }
456}
457
458pub fn solve_static(
464 eph: &dyn EphemerisSource,
465 epochs: &[StaticEpoch],
466 options: StaticSolveOptions,
467) -> Result<StaticSolution, StaticSolveError> {
468 let core = solve_static_core(eph, epochs, options)?;
469 let (per_epoch_influence, per_satellite_influence, per_satellite_batch_influence) =
470 build_influence(eph, epochs, options, &core);
471 Ok(core.into_public(
472 per_epoch_influence,
473 per_satellite_influence,
474 per_satellite_batch_influence,
475 ))
476}
477
478pub(crate) fn solve_static_without_influence(
479 eph: &dyn EphemerisSource,
480 epochs: &[StaticEpoch],
481 options: StaticSolveOptions,
482) -> Result<StaticSolution, StaticSolveError> {
483 let core = solve_static_core(eph, epochs, options)?;
484 Ok(core.into_public(Vec::new(), Vec::new(), Vec::new()))
485}
486
487#[derive(Debug, Clone)]
488struct PreparedEpoch {
489 input_index: usize,
490 inputs: SolveInputs,
491 used: Vec<GnssSatelliteId>,
492 rejected: Vec<RejectedSat>,
493 systems: Vec<GnssSystem>,
494 clock_offset: usize,
495 obs_by_id: Vec<(GnssSatelliteId, f64)>,
496}
497
498#[derive(Debug, Clone, Copy)]
499struct RowRef {
500 epoch_index: usize,
501 satellite_id: GnssSatelliteId,
502 base_weight: f64,
503}
504
505#[derive(Debug, Clone)]
506struct PreparedStatic {
507 epochs: Vec<PreparedEpoch>,
508 rows: Vec<RowRef>,
509 base_weights: Vec<f64>,
510 x0: DVector<f64>,
511 n_params: usize,
512}
513
514#[derive(Debug, Clone)]
515struct CoreStaticSolution {
516 position: ItrfPositionM,
517 geodetic: Option<Wgs84Geodetic>,
518 per_epoch_clock: Vec<StaticClockBias>,
519 covariance: StaticCovariance,
520 geometry_quality: GeometryQuality,
521 residuals_m: Vec<StaticResidual>,
522 used_sats: Vec<Vec<GnssSatelliteId>>,
523 rejected_sats: Vec<Vec<RejectedSat>>,
524 metadata: StaticSolutionMetadata,
525}
526
527impl CoreStaticSolution {
528 fn into_public(
529 self,
530 per_epoch_influence: Vec<StaticEpochInfluence>,
531 per_satellite_influence: Vec<StaticSatelliteInfluence>,
532 per_satellite_batch_influence: Vec<StaticSatelliteBatchInfluence>,
533 ) -> StaticSolution {
534 StaticSolution {
535 position: self.position,
536 geodetic: self.geodetic,
537 per_epoch_clock: self.per_epoch_clock,
538 covariance: self.covariance,
539 per_epoch_influence,
540 per_satellite_influence,
541 per_satellite_batch_influence,
542 geometry_quality: self.geometry_quality,
543 residuals_m: self.residuals_m,
544 used_sats: self.used_sats,
545 rejected_sats: self.rejected_sats,
546 metadata: self.metadata,
547 }
548 }
549}
550
551fn solve_static_core(
552 eph: &dyn EphemerisSource,
553 epochs: &[StaticEpoch],
554 options: StaticSolveOptions,
555) -> Result<CoreStaticSolution, StaticSolveError> {
556 validate_static_options(options)?;
557 if epochs.is_empty() {
558 return Err(StaticSolveError::EmptyEpochs);
559 }
560 let model = SppModelRecipe::reference();
561 let prepared = prepare_static(eph, epochs, options, model)?;
562
563 let lost = Cell::new(None::<(usize, GnssSatelliteId)>);
564 let residual = |x: &DVector<f64>| -> DVector<f64> {
565 match residual_static_unweighted(eph, &prepared, x.as_slice(), model) {
566 Ok(values) => DVector::from_vec(values),
567 Err((epoch_index, satellite)) => {
568 lost.set(Some((epoch_index, satellite)));
569 DVector::from_vec(vec![0.0; prepared.rows.len()])
570 }
571 }
572 };
573
574 let opts = SolveOptions {
575 gtol: STATIC_SOLVER_GTOL,
576 ftol: STATIC_SOLVER_FTOL,
577 xtol: STATIC_SOLVER_XTOL,
578 max_nfev: STATIC_SOLVER_MAX_NFEV,
579 };
580 let base_weights = DVector::from_row_slice(&prepared.base_weights);
581 let fd_min_steps = if epochs.len() > 1 {
585 static_fd_min_steps(prepared.n_params)
586 } else {
587 DVector::zeros(prepared.n_params)
588 };
589 let problem = LeastSquaresProblem::with_weights_and_fd_min_steps(
590 &residual,
591 prepared.x0.clone(),
592 base_weights,
593 fd_min_steps.clone(),
594 );
595 let report_result = solve_trf_with(&problem, &opts, TrustRegionSolve::NalgebraLu);
596 if let Some((epoch_index, satellite)) = lost.get() {
597 return Err(StaticSolveError::EphemerisLost {
598 epoch_index,
599 satellite,
600 });
601 }
602 let mut report = report_result.map_err(StaticSolveError::Singular)?;
603
604 let mut final_weights = prepared.base_weights.clone();
605 let mut outer_iterations = 0usize;
606 let mut final_robust_scale_m = None;
607
608 if let Some(robust) = options.robust {
609 for _ in 0..robust.max_outer.saturating_sub(1) {
610 let post = residual_static_unweighted(eph, &prepared, report.x.as_slice(), model)
611 .map_err(|(epoch_index, satellite)| StaticSolveError::EphemerisLost {
612 epoch_index,
613 satellite,
614 })?;
615 let scale = mad_scale(&post, robust.scale_floor_m).map_err(map_robust_error)?;
616 let effective: Vec<f64> = post
617 .iter()
618 .zip(prepared.base_weights.iter())
619 .map(|(&r, &base)| base * huber_weight(r / scale, robust.huber_k))
620 .collect();
621 let weights = DVector::from_row_slice(&effective);
622 let x_prev = report.x.clone();
623 let problem = LeastSquaresProblem::with_weights_and_fd_min_steps(
624 &residual,
625 x_prev.clone(),
626 weights,
627 fd_min_steps.clone(),
628 );
629 let next = solve_trf_with(&problem, &opts, TrustRegionSolve::NalgebraLu);
630 if let Some((epoch_index, satellite)) = lost.get() {
631 return Err(StaticSolveError::EphemerisLost {
632 epoch_index,
633 satellite,
634 });
635 }
636 report = next.map_err(StaticSolveError::Singular)?;
637 final_weights = effective;
638 outer_iterations += 1;
639 final_robust_scale_m = Some(scale);
640 let dx = report.x[0] - x_prev[0];
641 let dy = report.x[1] - x_prev[1];
642 let dz = report.x[2] - x_prev[2];
643 let dpos = (dx * dx + dy * dy + dz * dz).sqrt();
644 if dpos < robust.outer_tol_m {
645 break;
646 }
647 }
648 }
649
650 finish_static(FinishStaticInput {
651 eph,
652 prepared: &prepared,
653 options,
654 model,
655 x: report.x.as_slice(),
656 jacobian: &report.jacobian,
657 iterations: report.iterations,
658 status: report.status,
659 outer_iterations,
660 final_robust_scale_m,
661 final_weights: &final_weights,
662 })
663}
664
665fn static_fd_min_steps(n_params: usize) -> DVector<f64> {
666 DVector::from_iterator(
667 n_params,
668 (0..n_params).map(|index| {
669 if index < 3 {
670 STATIC_POSITION_FD_MIN_STEP_M
671 } else {
672 0.0
673 }
674 }),
675 )
676}
677
678fn prepare_static(
679 eph: &dyn EphemerisSource,
680 epochs: &[StaticEpoch],
681 options: StaticSolveOptions,
682 model: SppModelRecipe,
683) -> Result<PreparedStatic, StaticSolveError> {
684 let mut prepared_epochs = Vec::with_capacity(epochs.len());
685 let mut rows = Vec::new();
686 let mut base_weights = Vec::new();
687 let mut x0 = vec![
688 options.initial_position_m[0],
689 options.initial_position_m[1],
690 options.initial_position_m[2],
691 ];
692 let mut clock_offset = 3usize;
693
694 for (epoch_index, epoch) in epochs.iter().enumerate() {
695 validate_epoch_weights(epoch)?;
696 if let Some(satellite) = duplicate_satellite(&epoch.measurements) {
697 return Err(StaticSolveError::DuplicateObservation {
698 epoch_index,
699 satellite,
700 });
701 }
702 if epoch.corrections.ionosphere {
703 if let Some(satellite) = epoch
704 .measurements
705 .iter()
706 .map(|m| m.satellite_id)
707 .find(|sat| spp_iono_frequency_hz(*sat, &epoch.glonass_channels).is_none())
708 {
709 return Err(StaticSolveError::IonosphereUnsupported {
710 epoch_index,
711 satellite,
712 });
713 }
714 }
715
716 let inputs = solve_inputs_for_epoch(epoch, options);
717 validate_solve_inputs(&inputs).map_err(|source| StaticSolveError::EpochInput {
718 epoch_index,
719 source,
720 })?;
721 let selection = select_sats(eph, &inputs, model);
722 let systems = clock_systems(&selection.used);
723 let weight_by_sat = measurement_weight_map(epoch);
724 let obs_by_id: Vec<(GnssSatelliteId, f64)> = inputs
725 .observations
726 .iter()
727 .map(|m| (m.satellite_id, m.pseudorange_m))
728 .collect();
729
730 for (row_idx, &satellite_id) in selection.used.iter().enumerate() {
731 let multiplier = weight_by_sat.get(&satellite_id).copied().unwrap_or(1.0);
732 let base_weight = selection.weights[row_idx] * multiplier;
733 rows.push(RowRef {
734 epoch_index,
735 satellite_id,
736 base_weight,
737 });
738 base_weights.push(base_weight);
739 }
740
741 if !systems.is_empty() {
742 x0.push(epoch.clock_initial_m);
743 x0.extend(std::iter::repeat_n(0.0, systems.len().saturating_sub(1)));
744 }
745
746 prepared_epochs.push(PreparedEpoch {
747 input_index: epoch_index,
748 inputs,
749 used: selection.used,
750 rejected: selection.rejected,
751 systems,
752 clock_offset,
753 obs_by_id,
754 });
755 clock_offset += prepared_epochs
756 .last()
757 .expect("prepared epoch just pushed")
758 .systems
759 .len();
760 }
761
762 let n_params = x0.len();
763 if rows.len() < n_params {
764 return Err(StaticSolveError::TooFewMeasurements {
765 used: rows.len(),
766 required: n_params,
767 });
768 }
769
770 Ok(PreparedStatic {
771 epochs: prepared_epochs,
772 rows,
773 base_weights,
774 x0: DVector::from_vec(x0),
775 n_params,
776 })
777}
778
779fn solve_inputs_for_epoch(epoch: &StaticEpoch, options: StaticSolveOptions) -> SolveInputs {
780 SolveInputs {
781 observations: epoch.measurements.clone(),
782 t_rx_j2000_s: epoch.t_rx_j2000_s,
783 t_rx_second_of_day_s: epoch.t_rx_second_of_day_s,
784 day_of_year: epoch.day_of_year,
785 initial_guess: [
786 options.initial_position_m[0],
787 options.initial_position_m[1],
788 options.initial_position_m[2],
789 epoch.clock_initial_m,
790 ],
791 corrections: epoch.corrections,
792 klobuchar: epoch.klobuchar,
793 beidou_klobuchar: epoch.beidou_klobuchar,
794 galileo_nequick: epoch.galileo_nequick,
795 sbas_iono: epoch.sbas_iono.clone(),
796 glonass_channels: epoch.glonass_channels.clone(),
797 met: epoch.met,
798 robust: None,
799 }
800}
801
802struct FinishStaticInput<'a> {
803 eph: &'a dyn EphemerisSource,
804 prepared: &'a PreparedStatic,
805 options: StaticSolveOptions,
806 model: SppModelRecipe,
807 x: &'a [f64],
808 jacobian: &'a DMatrix<f64>,
809 iterations: usize,
810 status: Status,
811 outer_iterations: usize,
812 final_robust_scale_m: Option<f64>,
813 final_weights: &'a [f64],
814}
815
816fn finish_static(input: FinishStaticInput<'_>) -> Result<CoreStaticSolution, StaticSolveError> {
817 let FinishStaticInput {
818 eph,
819 prepared,
820 options,
821 model,
822 x,
823 jacobian,
824 iterations,
825 status,
826 outer_iterations,
827 final_robust_scale_m,
828 final_weights,
829 } = input;
830 let position = ItrfPositionM::new(x[0], x[1], x[2]).expect("valid static position");
831 let receiver_geodetic = geodetic_from_ecef(model.frame, [x[0], x[1], x[2]]);
832 let geodetic = if options.with_geodetic {
833 Some(receiver_geodetic)
834 } else {
835 None
836 };
837 let per_epoch_clock = epoch_clocks(prepared, x);
838 let residual_values = residual_static_unweighted(eph, prepared, x, model).map_err(
839 |(epoch_index, satellite)| StaticSolveError::EphemerisLost {
840 epoch_index,
841 satellite,
842 },
843 )?;
844 let residuals_m = residual_values
845 .iter()
846 .zip(prepared.rows.iter())
847 .zip(final_weights.iter())
848 .map(|((&residual_m, row), &effective_weight)| StaticResidual {
849 epoch_index: row.epoch_index,
850 satellite_id: row.satellite_id,
851 residual_m,
852 base_weight: row.base_weight,
853 effective_weight,
854 robust_weight_ratio: effective_weight / row.base_weight,
855 })
856 .collect::<Vec<_>>();
857
858 let covariance_matrix = normal_covariance(jacobian, 1.0).map_err(StaticSolveError::Singular)?;
859 let covariance = static_covariance(&covariance_matrix, receiver_geodetic)?;
860 let svd = jacobian.clone().svd(false, false);
861 let diagnostics = singular_value_diagnostics(
862 svd.singular_values.as_slice(),
863 jacobian.nrows(),
864 jacobian.ncols(),
865 );
866 if diagnostics.rank < prepared.n_params {
867 return Err(StaticSolveError::Singular(
868 least_squares::SolveError::SingularJacobian,
869 ));
870 }
871 let gdop = covariance_trace(&covariance_matrix).sqrt();
872 let redundancy = prepared.rows.len() as isize - prepared.n_params as isize;
873 let geometry_quality = classify(
874 diagnostics.rank,
875 prepared.n_params,
876 redundancy as i32,
877 diagnostics.condition_number,
878 gdop,
879 false,
880 GeometryQualityThresholds::default(),
881 );
882 let converged = matches!(
883 status,
884 Status::GradientTolerance | Status::CostTolerance | Status::StepTolerance
885 );
886
887 Ok(CoreStaticSolution {
888 position,
889 geodetic,
890 per_epoch_clock,
891 covariance,
892 geometry_quality,
893 residuals_m,
894 used_sats: prepared
895 .epochs
896 .iter()
897 .map(|epoch| epoch.used.clone())
898 .collect(),
899 rejected_sats: prepared
900 .epochs
901 .iter()
902 .map(|epoch| epoch.rejected.clone())
903 .collect(),
904 metadata: StaticSolutionMetadata {
905 iterations,
906 converged,
907 status,
908 outer_iterations,
909 final_robust_scale_m,
910 used_measurements: prepared.rows.len(),
911 n_parameters: prepared.n_params,
912 redundancy,
913 },
914 })
915}
916
917fn residual_static_unweighted(
918 eph: &dyn EphemerisSource,
919 prepared: &PreparedStatic,
920 x: &[f64],
921 model: SppModelRecipe,
922) -> Result<Vec<f64>, (usize, GnssSatelliteId)> {
923 let mut out = Vec::with_capacity(prepared.rows.len());
924 for epoch in &prepared.epochs {
925 if epoch.used.is_empty() {
926 continue;
927 }
928 let mut local = Vec::with_capacity(3 + epoch.systems.len());
929 local.extend_from_slice(&x[0..3]);
930 for clock_idx in 0..epoch.systems.len() {
931 local.push(x[epoch.clock_offset + clock_idx]);
932 }
933 let residuals = residual_unweighted(
934 eph,
935 &epoch.used,
936 &epoch.obs_by_id,
937 &local,
938 &epoch.inputs,
939 model,
940 )
941 .map_err(|satellite| (epoch.input_index, satellite))?;
942 out.extend(residuals);
943 }
944 Ok(out)
945}
946
947fn static_covariance(
948 covariance: &DMatrix<f64>,
949 receiver: Wgs84Geodetic,
950) -> Result<StaticCovariance, StaticSolveError> {
951 let state_m2 = matrix_to_rows(covariance);
952 let position_ecef_m2 = [
953 [covariance[(0, 0)], covariance[(0, 1)], covariance[(0, 2)]],
954 [covariance[(1, 0)], covariance[(1, 1)], covariance[(1, 2)]],
955 [covariance[(2, 0)], covariance[(2, 1)], covariance[(2, 2)]],
956 ];
957 let position_enu_m2 = rotate_covariance_ecef_to_enu_m2(position_ecef_m2, receiver)
958 .map_err(|_| StaticSolveError::Singular(least_squares::SolveError::SingularJacobian))?;
959 Ok(StaticCovariance {
960 state_m2,
961 position_ecef_m2,
962 position_enu_m2,
963 })
964}
965
966fn epoch_clocks(prepared: &PreparedStatic, x: &[f64]) -> Vec<StaticClockBias> {
967 let mut clocks = Vec::new();
968 for epoch in &prepared.epochs {
969 for (clock_idx, &system) in epoch.systems.iter().enumerate() {
970 clocks.push(StaticClockBias {
971 epoch_index: epoch.input_index,
972 system,
973 clock_s: x[epoch.clock_offset + clock_idx] / C_M_S,
974 });
975 }
976 }
977 clocks
978}
979
980fn validate_static_options(options: StaticSolveOptions) -> Result<(), StaticSolveError> {
981 validate::finite_slice(&options.initial_position_m, "initial_position_m")
982 .map_err(map_static_input_error)?;
983 if let Some(robust) = options.robust {
984 if robust.max_outer == 0 {
985 return Err(StaticSolveError::InvalidInput {
986 field: "robust.max_outer",
987 kind: SppInputErrorKind::NotPositive,
988 });
989 }
990 validate::finite_positive(robust.huber_k, "robust.huber_k")
991 .map_err(map_static_input_error)?;
992 validate::finite_positive(robust.scale_floor_m, "robust.scale_floor_m")
993 .map_err(map_static_input_error)?;
994 validate::finite_positive(robust.outer_tol_m, "robust.outer_tol_m")
995 .map_err(map_static_input_error)?;
996 }
997 Ok(())
998}
999
1000fn validate_epoch_weights(epoch: &StaticEpoch) -> Result<(), StaticSolveError> {
1001 if let Some(weights) = &epoch.weights {
1002 if weights.len() != epoch.measurements.len() {
1003 return Err(StaticSolveError::InvalidInput {
1004 field: "epoch.weights",
1005 kind: SppInputErrorKind::OutOfRange,
1006 });
1007 }
1008 for &weight in weights {
1009 validate::finite_positive(weight, "epoch.weights").map_err(map_static_input_error)?;
1010 }
1011 }
1012 Ok(())
1013}
1014
1015fn measurement_weight_map(epoch: &StaticEpoch) -> BTreeMap<GnssSatelliteId, f64> {
1016 epoch
1017 .weights
1018 .as_ref()
1019 .map(|weights| {
1020 epoch
1021 .measurements
1022 .iter()
1023 .zip(weights.iter())
1024 .map(|(measurement, &weight)| (measurement.satellite_id, weight))
1025 .collect()
1026 })
1027 .unwrap_or_default()
1028}
1029
1030fn duplicate_satellite(measurements: &[Observation]) -> Option<GnssSatelliteId> {
1031 let mut ids: Vec<GnssSatelliteId> = measurements.iter().map(|m| m.satellite_id).collect();
1032 ids.sort_unstable();
1033 ids.windows(2)
1034 .find(|pair| pair[0] == pair[1])
1035 .map(|pair| pair[0])
1036}
1037
1038fn build_influence(
1039 eph: &dyn EphemerisSource,
1040 epochs: &[StaticEpoch],
1041 options: StaticSolveOptions,
1042 full: &CoreStaticSolution,
1043) -> (
1044 Vec<StaticEpochInfluence>,
1045 Vec<StaticSatelliteInfluence>,
1046 Vec<StaticSatelliteBatchInfluence>,
1047) {
1048 let epoch_influence = (0..epochs.len())
1049 .map(|epoch_index| {
1050 let mut subset = epochs.to_vec();
1051 let omitted_measurements = subset[epoch_index].measurements.len();
1052 subset.remove(epoch_index);
1053 let result = solve_static_core(eph, &subset, options);
1054 let (status, position_delta_m, position_delta_norm_m, residual_rms_m) =
1055 influence_result(full.position.as_array(), result);
1056 StaticEpochInfluence {
1057 epoch_index,
1058 omitted_measurements,
1059 status,
1060 position_delta_m,
1061 position_delta_norm_m,
1062 residual_rms_m,
1063 min_robust_weight_ratio: min_epoch_weight_ratio(full, epoch_index),
1064 }
1065 })
1066 .collect();
1067
1068 let satellite_ids = full
1069 .residuals_m
1070 .iter()
1071 .map(|row| row.satellite_id)
1072 .collect::<BTreeSet<_>>();
1073 let satellite_batch_influence = satellite_ids
1074 .into_iter()
1075 .map(|satellite_id| {
1076 let subset = omit_satellite_all_epochs(epochs, satellite_id);
1077 let result = solve_static_core(eph, &subset, options);
1078 let (status, position_delta_m, position_delta_norm_m, residual_rms_m) =
1079 influence_result(full.position.as_array(), result);
1080 StaticSatelliteBatchInfluence {
1081 satellite_id,
1082 omitted_measurements: full
1083 .residuals_m
1084 .iter()
1085 .filter(|row| row.satellite_id == satellite_id)
1086 .count(),
1087 status,
1088 position_delta_m,
1089 position_delta_norm_m,
1090 residual_rms_m,
1091 min_robust_weight_ratio: min_satellite_weight_ratio(full, satellite_id),
1092 }
1093 })
1094 .collect();
1095
1096 let satellite_influence = full
1097 .residuals_m
1098 .iter()
1099 .map(|row| {
1100 let subset = omit_satellite(epochs, row.epoch_index, row.satellite_id);
1101 let result = solve_static_core(eph, &subset, options);
1102 let (status, position_delta_m, position_delta_norm_m, residual_rms_m) =
1103 influence_result(full.position.as_array(), result);
1104 StaticSatelliteInfluence {
1105 epoch_index: row.epoch_index,
1106 satellite_id: row.satellite_id,
1107 status,
1108 position_delta_m,
1109 position_delta_norm_m,
1110 residual_rms_m,
1111 residual_m: row.residual_m,
1112 base_weight: row.base_weight,
1113 effective_weight: row.effective_weight,
1114 robust_weight_ratio: row.robust_weight_ratio,
1115 }
1116 })
1117 .collect();
1118
1119 (
1120 epoch_influence,
1121 satellite_influence,
1122 satellite_batch_influence,
1123 )
1124}
1125
1126fn omit_satellite(
1127 epochs: &[StaticEpoch],
1128 epoch_index: usize,
1129 satellite_id: GnssSatelliteId,
1130) -> Vec<StaticEpoch> {
1131 let mut subset = epochs.to_vec();
1132 remove_satellite_from_epoch(&mut subset[epoch_index], satellite_id);
1133 subset
1134}
1135
1136fn omit_satellite_all_epochs(
1137 epochs: &[StaticEpoch],
1138 satellite_id: GnssSatelliteId,
1139) -> Vec<StaticEpoch> {
1140 let mut subset = epochs.to_vec();
1141 for epoch in &mut subset {
1142 remove_satellite_from_epoch(epoch, satellite_id);
1143 }
1144 subset
1145}
1146
1147fn remove_satellite_from_epoch(epoch: &mut StaticEpoch, satellite_id: GnssSatelliteId) {
1148 let old_measurements = std::mem::take(&mut epoch.measurements);
1149 let old_weights = epoch.weights.take();
1150 let mut measurements = Vec::with_capacity(old_measurements.len());
1151 let mut weights = old_weights
1152 .as_ref()
1153 .map(|_| Vec::with_capacity(old_measurements.len()));
1154 for (idx, measurement) in old_measurements.into_iter().enumerate() {
1155 if measurement.satellite_id == satellite_id {
1156 continue;
1157 }
1158 measurements.push(measurement);
1159 if let (Some(old), Some(new_weights)) = (&old_weights, &mut weights) {
1160 new_weights.push(old[idx]);
1161 }
1162 }
1163 epoch.measurements = measurements;
1164 epoch.weights = weights;
1165}
1166
1167fn influence_result(
1168 full_position: [f64; 3],
1169 result: Result<CoreStaticSolution, StaticSolveError>,
1170) -> (
1171 StaticInfluenceStatus,
1172 Option<[f64; 3]>,
1173 Option<f64>,
1174 Option<f64>,
1175) {
1176 match result {
1177 Ok(solution) => {
1178 let position = solution.position.as_array();
1179 let delta = [
1180 position[0] - full_position[0],
1181 position[1] - full_position[1],
1182 position[2] - full_position[2],
1183 ];
1184 let norm = (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt();
1185 let residual_values = solution
1186 .residuals_m
1187 .iter()
1188 .map(|row| row.residual_m)
1189 .collect::<Vec<_>>();
1190 (
1191 StaticInfluenceStatus::Solved,
1192 Some(delta),
1193 Some(norm),
1194 Some(residual_rms(&residual_values)),
1195 )
1196 }
1197 Err(error) => (influence_status(&error), None, None, None),
1198 }
1199}
1200
1201fn influence_status(error: &StaticSolveError) -> StaticInfluenceStatus {
1202 match error {
1203 StaticSolveError::TooFewMeasurements { .. } | StaticSolveError::EmptyEpochs => {
1204 StaticInfluenceStatus::TooFewMeasurements
1205 }
1206 StaticSolveError::Singular(_) => StaticInfluenceStatus::SingularGeometry,
1207 StaticSolveError::InvalidInput { .. }
1208 | StaticSolveError::EpochInput { .. }
1209 | StaticSolveError::DuplicateObservation { .. }
1210 | StaticSolveError::IonosphereUnsupported { .. } => StaticInfluenceStatus::InvalidInput,
1211 StaticSolveError::EphemerisLost { .. } => StaticInfluenceStatus::EphemerisUnavailable,
1212 }
1213}
1214
1215fn min_epoch_weight_ratio(full: &CoreStaticSolution, epoch_index: usize) -> f64 {
1216 full.residuals_m
1217 .iter()
1218 .filter(|row| row.epoch_index == epoch_index)
1219 .map(|row| row.robust_weight_ratio)
1220 .fold(1.0, f64::min)
1221}
1222
1223fn min_satellite_weight_ratio(full: &CoreStaticSolution, satellite_id: GnssSatelliteId) -> f64 {
1224 full.residuals_m
1225 .iter()
1226 .filter(|row| row.satellite_id == satellite_id)
1227 .map(|row| row.robust_weight_ratio)
1228 .fold(1.0, f64::min)
1229}
1230
1231fn matrix_to_rows(matrix: &DMatrix<f64>) -> Vec<Vec<f64>> {
1232 (0..matrix.nrows())
1233 .map(|row| (0..matrix.ncols()).map(|col| matrix[(row, col)]).collect())
1234 .collect()
1235}
1236
1237fn covariance_trace(matrix: &DMatrix<f64>) -> f64 {
1238 (0..matrix.nrows().min(matrix.ncols()))
1239 .map(|idx| matrix[(idx, idx)])
1240 .sum()
1241}
1242
1243fn residual_rms(residuals: &[f64]) -> f64 {
1244 if residuals.is_empty() {
1245 return 0.0;
1246 }
1247 let sum_sq = residuals.iter().map(|r| r * r).sum::<f64>();
1248 (sum_sq / residuals.len() as f64).sqrt()
1249}
1250
1251fn map_static_input_error(error: validate::FieldError) -> StaticSolveError {
1252 StaticSolveError::InvalidInput {
1253 field: error.field(),
1254 kind: SppInputErrorKind::from(&error),
1255 }
1256}
1257
1258fn map_robust_error(error: RobustError) -> StaticSolveError {
1259 let field = match error.field() {
1260 "scale_floor" => "robust.scale_floor_m",
1261 "residuals" | "values" => "robust.residuals",
1262 other => other,
1263 };
1264 let kind = match error.reason() {
1265 "not finite" => SppInputErrorKind::NonFinite,
1266 "not positive" => SppInputErrorKind::NotPositive,
1267 "negative" => SppInputErrorKind::Negative,
1268 "out of range" => SppInputErrorKind::OutOfRange,
1269 _ => SppInputErrorKind::OutOfRange,
1270 };
1271 StaticSolveError::InvalidInput { field, kind }
1272}
1273
1274#[cfg(test)]
1275mod tests;