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