Skip to main content

sidereon_core/
static_positioning.rs

1//! Multi-epoch static positioning over stacked pseudorange epochs.
2//!
3//! The solve is sans-I/O: callers provide already formed pseudorange
4//! measurements grouped by receive epoch and receive one static receiver
5//! position. The measurement model is the existing SPP model. The only new
6//! layout is the parameter vector, ordered as a shared ECEF position followed
7//! by epoch-local receiver clocks.
8//!
9//! ```
10//! use sidereon_core::ephemeris::EphemerisSource;
11//! use sidereon_core::static_positioning::{
12//!     solve_static, StaticSolveError, StaticSolveOptions,
13//! };
14//! use sidereon_core::GnssSatelliteId;
15//!
16//! struct EmptyEphemeris;
17//!
18//! impl EphemerisSource for EmptyEphemeris {
19//!     fn position_clock_at_j2000_s(
20//!         &self,
21//!         _sat: GnssSatelliteId,
22//!         _t_j2000_s: f64,
23//!     ) -> Option<([f64; 3], f64)> {
24//!         None
25//!     }
26//! }
27//!
28//! let result = solve_static(&EmptyEphemeris, &[], StaticSolveOptions::default());
29//! assert!(matches!(result, Err(StaticSolveError::EmptyEpochs)));
30//! ```
31
32use 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;
59
60/// One receive epoch for [`solve_static`].
61///
62/// `measurements` are raw pseudorange measurements in meters. `weights`, when
63/// present, must be aligned with `measurements` and are multiplied by the
64/// existing SPP elevation weights. The clock seed is a receiver clock range
65/// bias in meters for this epoch.
66#[derive(Debug, Clone, PartialEq)]
67pub struct StaticEpoch {
68    /// Pseudorange measurements for this receive epoch.
69    pub measurements: Vec<Observation>,
70    /// Optional positive measurement-weight multipliers aligned with
71    /// [`measurements`](Self::measurements).
72    pub weights: Option<Vec<f64>>,
73    /// Receive epoch, seconds since J2000 in the ephemeris source time scale.
74    pub t_rx_j2000_s: f64,
75    /// GPS second of day for the receive epoch.
76    pub t_rx_second_of_day_s: f64,
77    /// Fractional day of year for the receive epoch.
78    pub day_of_year: f64,
79    /// Initial receiver clock range bias for this epoch, in meters.
80    pub clock_initial_m: f64,
81    /// Correction terms applied to this epoch.
82    pub corrections: Corrections,
83    /// Broadcast Klobuchar coefficients used when ionosphere correction is on.
84    pub klobuchar: KlobucharCoeffs,
85    /// Optional BeiDou-specific Klobuchar coefficients.
86    pub beidou_klobuchar: Option<KlobucharCoeffs>,
87    /// Optional Galileo-specific NeQuick-G coefficients.
88    pub galileo_nequick: Option<GalileoNequickCoeffs>,
89    /// Optional SBAS ionosphere grid.
90    pub sbas_iono: Option<SbasIonoGrid>,
91    /// GLONASS FDMA channel numbers keyed by GLONASS slot.
92    pub glonass_channels: BTreeMap<u8, i8>,
93    /// Surface meteorology used when troposphere correction is on.
94    pub met: SurfaceMet,
95}
96
97impl StaticEpoch {
98    /// Build a static epoch from existing SPP inputs.
99    ///
100    /// The SPP observations become [`measurements`](Self::measurements),
101    /// `initial_guess[3]` becomes [`clock_initial_m`](Self::clock_initial_m),
102    /// and the measurement-model fields are copied. The SPP robust option is
103    /// not copied because static robust reweighting is configured on
104    /// [`StaticSolveOptions`].
105    pub fn from_solve_inputs(inputs: SolveInputs) -> Self {
106        Self {
107            measurements: inputs.observations,
108            weights: None,
109            t_rx_j2000_s: inputs.t_rx_j2000_s,
110            t_rx_second_of_day_s: inputs.t_rx_second_of_day_s,
111            day_of_year: inputs.day_of_year,
112            clock_initial_m: inputs.initial_guess[3],
113            corrections: inputs.corrections,
114            klobuchar: inputs.klobuchar,
115            beidou_klobuchar: inputs.beidou_klobuchar,
116            galileo_nequick: inputs.galileo_nequick,
117            sbas_iono: inputs.sbas_iono,
118            glonass_channels: inputs.glonass_channels,
119            met: inputs.met,
120        }
121    }
122}
123
124/// Options for [`solve_static`].
125#[derive(Debug, Clone, Copy, PartialEq)]
126pub struct StaticSolveOptions {
127    /// Initial shared receiver ECEF position in meters.
128    pub initial_position_m: [f64; 3],
129    /// Whether to return the solved position in geodetic coordinates too.
130    pub with_geodetic: bool,
131    /// Optional Huber iteratively reweighted least-squares configuration.
132    pub robust: Option<RobustConfig>,
133}
134
135impl StaticSolveOptions {
136    /// Build static options from an existing SPP input.
137    ///
138    /// The initial position and robust configuration are copied. Static epoch
139    /// clock seeds are still taken from each [`StaticEpoch`].
140    pub fn from_solve_inputs(inputs: &SolveInputs, with_geodetic: bool) -> Self {
141        Self {
142            initial_position_m: [
143                inputs.initial_guess[0],
144                inputs.initial_guess[1],
145                inputs.initial_guess[2],
146            ],
147            with_geodetic,
148            robust: inputs.robust,
149        }
150    }
151}
152
153impl Default for StaticSolveOptions {
154    fn default() -> Self {
155        Self {
156            initial_position_m: [0.0; 3],
157            with_geodetic: false,
158            robust: None,
159        }
160    }
161}
162
163/// One solved epoch-local receiver clock.
164#[derive(Debug, Clone, Copy, PartialEq)]
165pub struct StaticClockBias {
166    /// Epoch index in the input slice.
167    pub epoch_index: usize,
168    /// GNSS system whose receiver clock column this value belongs to.
169    pub system: GnssSystem,
170    /// Receiver clock bias in seconds.
171    pub clock_s: f64,
172}
173
174/// State covariance for a static solution.
175///
176/// The state order is `[x_m, y_m, z_m, epoch0_clock0_m, ...]`, where each clock
177/// is a receiver clock range bias in meters. Clock columns are listed in
178/// [`StaticSolution::per_epoch_clock`] order.
179#[derive(Debug, Clone, PartialEq)]
180pub struct StaticCovariance {
181    /// Full state covariance in square meters.
182    pub state_m2: Vec<Vec<f64>>,
183    /// ECEF position covariance block in square meters.
184    pub position_ecef_m2: [[f64; 3]; 3],
185    /// Local ENU position covariance block in square meters.
186    pub position_enu_m2: [[f64; 3]; 3],
187}
188
189/// One post-fit residual from the static solve.
190#[derive(Debug, Clone, Copy, PartialEq)]
191pub struct StaticResidual {
192    /// Epoch index in the input slice.
193    pub epoch_index: usize,
194    /// Satellite associated with this residual.
195    pub satellite_id: GnssSatelliteId,
196    /// Unweighted observed-minus-computed pseudorange residual, in meters.
197    pub residual_m: f64,
198    /// Base row weight before robust reweighting.
199    pub base_weight: f64,
200    /// Final row weight after robust reweighting.
201    pub effective_weight: f64,
202    /// Ratio `effective_weight / base_weight`.
203    pub robust_weight_ratio: f64,
204}
205
206/// Status for a leave-one-out diagnostic solve.
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum StaticInfluenceStatus {
209    /// The leave-one-out solve completed.
210    Solved,
211    /// The omitted data left too few measurements.
212    TooFewMeasurements,
213    /// The omitted data left rank-deficient geometry.
214    SingularGeometry,
215    /// Input validation failed for the diagnostic subset.
216    InvalidInput,
217    /// Ephemeris was unavailable for the diagnostic subset.
218    EphemerisUnavailable,
219    /// The diagnostic subset failed for another solve reason.
220    SolveFailed,
221}
222
223/// Leave-one-epoch-out diagnostic.
224#[derive(Debug, Clone, PartialEq)]
225pub struct StaticEpochInfluence {
226    /// Epoch index omitted from the diagnostic solve.
227    pub epoch_index: usize,
228    /// Number of measurements omitted.
229    pub omitted_measurements: usize,
230    /// Diagnostic solve status.
231    pub status: StaticInfluenceStatus,
232    /// Difference `diagnostic_position - full_position`, in ECEF meters.
233    pub position_delta_m: Option<[f64; 3]>,
234    /// Norm of [`position_delta_m`](Self::position_delta_m), in meters.
235    pub position_delta_norm_m: Option<f64>,
236    /// Diagnostic solution residual RMS, in meters.
237    pub residual_rms_m: Option<f64>,
238    /// Minimum robust weight ratio among this epoch's used rows in the full solve.
239    pub min_robust_weight_ratio: f64,
240}
241
242/// Leave-one-satellite-out diagnostic.
243#[derive(Debug, Clone, PartialEq)]
244pub struct StaticSatelliteInfluence {
245    /// Epoch index containing the omitted satellite.
246    pub epoch_index: usize,
247    /// Satellite omitted from the diagnostic solve.
248    pub satellite_id: GnssSatelliteId,
249    /// Diagnostic solve status.
250    pub status: StaticInfluenceStatus,
251    /// Difference `diagnostic_position - full_position`, in ECEF meters.
252    pub position_delta_m: Option<[f64; 3]>,
253    /// Norm of [`position_delta_m`](Self::position_delta_m), in meters.
254    pub position_delta_norm_m: Option<f64>,
255    /// Diagnostic solution residual RMS, in meters.
256    pub residual_rms_m: Option<f64>,
257    /// Full-solve residual for this satellite, in meters.
258    pub residual_m: f64,
259    /// Base row weight before robust reweighting.
260    pub base_weight: f64,
261    /// Final row weight after robust reweighting.
262    pub effective_weight: f64,
263    /// Ratio `effective_weight / base_weight`.
264    pub robust_weight_ratio: f64,
265}
266
267/// Leave-one-satellite-out diagnostic across all epochs where a satellite appears.
268#[derive(Debug, Clone, PartialEq)]
269pub struct StaticSatelliteBatchInfluence {
270    /// Satellite omitted from every epoch where it was used.
271    pub satellite_id: GnssSatelliteId,
272    /// Number of measurements omitted across the static batch.
273    pub omitted_measurements: usize,
274    /// Diagnostic solve status.
275    pub status: StaticInfluenceStatus,
276    /// Difference `diagnostic_position - full_position`, in ECEF meters.
277    pub position_delta_m: Option<[f64; 3]>,
278    /// Norm of [`position_delta_m`](Self::position_delta_m), in meters.
279    pub position_delta_norm_m: Option<f64>,
280    /// Diagnostic solution residual RMS, in meters.
281    pub residual_rms_m: Option<f64>,
282    /// Minimum robust weight ratio among this satellite's used rows in the full solve.
283    pub min_robust_weight_ratio: f64,
284}
285
286/// Metadata describing the static solve.
287#[derive(Debug, Clone, PartialEq)]
288pub struct StaticSolutionMetadata {
289    /// Number of accepted trust-region iterations in the final inner solve.
290    pub iterations: usize,
291    /// Whether the final inner solve reached a convergence criterion.
292    pub converged: bool,
293    /// The final inner solver termination status.
294    pub status: Status,
295    /// Number of robust outer iterations performed.
296    pub outer_iterations: usize,
297    /// Final MAD robust scale in meters, when robust reweighting ran.
298    pub final_robust_scale_m: Option<f64>,
299    /// Number of measurements used by the final solve.
300    pub used_measurements: usize,
301    /// Number of fitted state parameters.
302    pub n_parameters: usize,
303    /// Degrees of freedom, `used_measurements - n_parameters`.
304    pub redundancy: isize,
305}
306
307/// Multi-epoch static receiver solution.
308#[derive(Debug, Clone, PartialEq)]
309pub struct StaticSolution {
310    /// Shared receiver position, ITRF/IGS ECEF meters.
311    pub position: ItrfPositionM,
312    /// Geodetic form of the position, when requested.
313    pub geodetic: Option<Wgs84Geodetic>,
314    /// Epoch-local receiver clocks in seconds.
315    pub per_epoch_clock: Vec<StaticClockBias>,
316    /// State covariance from the stacked normal equations.
317    pub covariance: StaticCovariance,
318    /// Leave-one-epoch-out diagnostics.
319    pub per_epoch_influence: Vec<StaticEpochInfluence>,
320    /// Leave-one-satellite-out diagnostics.
321    pub per_satellite_influence: Vec<StaticSatelliteInfluence>,
322    /// Leave-one-satellite-out diagnostics across all epochs per satellite.
323    pub per_satellite_batch_influence: Vec<StaticSatelliteBatchInfluence>,
324    /// Geometry observability and covariance validation diagnostics.
325    pub geometry_quality: GeometryQuality,
326    /// Post-fit residuals for all used measurements.
327    pub residuals_m: Vec<StaticResidual>,
328    /// Used satellites by epoch, in solver row order.
329    pub used_sats: Vec<Vec<GnssSatelliteId>>,
330    /// Rejected satellites by epoch.
331    pub rejected_sats: Vec<Vec<RejectedSat>>,
332    /// Iteration and redundancy metadata.
333    pub metadata: StaticSolutionMetadata,
334}
335
336impl StaticSolution {
337    /// Root-mean-square of the unweighted post-fit residuals.
338    pub fn residual_rms_m(&self) -> f64 {
339        residual_rms(
340            &self
341                .residuals_m
342                .iter()
343                .map(|r| r.residual_m)
344                .collect::<Vec<_>>(),
345        )
346    }
347}
348
349/// Error returned by [`solve_static`].
350#[derive(Debug, Clone)]
351pub enum StaticSolveError {
352    /// No epochs were supplied.
353    EmptyEpochs,
354    /// A public static solve input was malformed.
355    InvalidInput {
356        /// The invalid input field.
357        field: &'static str,
358        /// The validation failure category.
359        kind: SppInputErrorKind,
360    },
361    /// A per-epoch SPP input was malformed.
362    EpochInput {
363        /// Epoch index in the input slice.
364        epoch_index: usize,
365        /// Underlying SPP input error.
366        source: SppError,
367    },
368    /// The same satellite appears twice in one epoch.
369    DuplicateObservation {
370        /// Epoch index in the input slice.
371        epoch_index: usize,
372        /// Satellite that was duplicated.
373        satellite: GnssSatelliteId,
374    },
375    /// An ionosphere-corrected epoch used a satellite without a modeled carrier.
376    IonosphereUnsupported {
377        /// Epoch index in the input slice.
378        epoch_index: usize,
379        /// Satellite without a modeled carrier.
380        satellite: GnssSatelliteId,
381    },
382    /// Too few accepted measurements remained for the stacked state.
383    TooFewMeasurements {
384        /// Accepted measurement count.
385        used: usize,
386        /// Required measurement count.
387        required: usize,
388    },
389    /// A satellite lost ephemeris during the solve.
390    EphemerisLost {
391        /// Epoch index in the input slice.
392        epoch_index: usize,
393        /// Satellite whose ephemeris was unavailable.
394        satellite: GnssSatelliteId,
395    },
396    /// The stacked design is rank deficient.
397    Singular(least_squares::SolveError),
398}
399
400impl core::fmt::Display for StaticSolveError {
401    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
402        match self {
403            Self::EmptyEpochs => write!(f, "no static epochs supplied"),
404            Self::InvalidInput { field, kind } => {
405                write!(f, "invalid static solve input {field}: {kind}")
406            }
407            Self::EpochInput {
408                epoch_index,
409                source,
410            } => write!(f, "invalid static epoch {epoch_index}: {source}"),
411            Self::DuplicateObservation {
412                epoch_index,
413                satellite,
414            } => write!(
415                f,
416                "static epoch {epoch_index} observes satellite {satellite} more than once"
417            ),
418            Self::IonosphereUnsupported {
419                epoch_index,
420                satellite,
421            } => write!(
422                f,
423                "static epoch {epoch_index} has no ionosphere carrier model for {satellite}"
424            ),
425            Self::TooFewMeasurements { used, required } => write!(
426                f,
427                "only {used} usable static measurements; need at least {required}"
428            ),
429            Self::EphemerisLost {
430                epoch_index,
431                satellite,
432            } => write!(
433                f,
434                "static epoch {epoch_index} satellite {satellite} lost ephemeris during the solve"
435            ),
436            Self::Singular(error) => write!(f, "static geometry is singular: {error}"),
437        }
438    }
439}
440
441impl std::error::Error for StaticSolveError {
442    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
443        match self {
444            Self::EpochInput { source, .. } => Some(source),
445            Self::Singular(error) => Some(error),
446            _ => None,
447        }
448    }
449}
450
451/// Solve one static receiver position from multiple epochs of pseudoranges.
452///
453/// The stacked state has one shared ECEF position and epoch-local receiver
454/// clocks. If an epoch contains several GNSS clock systems, that epoch gets one
455/// clock column per system, matching the single-epoch SPP clock model.
456pub fn solve_static(
457    eph: &dyn EphemerisSource,
458    epochs: &[StaticEpoch],
459    options: StaticSolveOptions,
460) -> Result<StaticSolution, StaticSolveError> {
461    let core = solve_static_core(eph, epochs, options)?;
462    let (per_epoch_influence, per_satellite_influence, per_satellite_batch_influence) =
463        build_influence(eph, epochs, options, &core);
464    Ok(core.into_public(
465        per_epoch_influence,
466        per_satellite_influence,
467        per_satellite_batch_influence,
468    ))
469}
470
471pub(crate) fn solve_static_without_influence(
472    eph: &dyn EphemerisSource,
473    epochs: &[StaticEpoch],
474    options: StaticSolveOptions,
475) -> Result<StaticSolution, StaticSolveError> {
476    let core = solve_static_core(eph, epochs, options)?;
477    Ok(core.into_public(Vec::new(), Vec::new(), Vec::new()))
478}
479
480#[derive(Debug, Clone)]
481struct PreparedEpoch {
482    input_index: usize,
483    inputs: SolveInputs,
484    used: Vec<GnssSatelliteId>,
485    rejected: Vec<RejectedSat>,
486    systems: Vec<GnssSystem>,
487    clock_offset: usize,
488    obs_by_id: Vec<(GnssSatelliteId, f64)>,
489}
490
491#[derive(Debug, Clone, Copy)]
492struct RowRef {
493    epoch_index: usize,
494    satellite_id: GnssSatelliteId,
495    base_weight: f64,
496}
497
498#[derive(Debug, Clone)]
499struct PreparedStatic {
500    epochs: Vec<PreparedEpoch>,
501    rows: Vec<RowRef>,
502    base_weights: Vec<f64>,
503    x0: DVector<f64>,
504    n_params: usize,
505}
506
507#[derive(Debug, Clone)]
508struct CoreStaticSolution {
509    position: ItrfPositionM,
510    geodetic: Option<Wgs84Geodetic>,
511    per_epoch_clock: Vec<StaticClockBias>,
512    covariance: StaticCovariance,
513    geometry_quality: GeometryQuality,
514    residuals_m: Vec<StaticResidual>,
515    used_sats: Vec<Vec<GnssSatelliteId>>,
516    rejected_sats: Vec<Vec<RejectedSat>>,
517    metadata: StaticSolutionMetadata,
518}
519
520impl CoreStaticSolution {
521    fn into_public(
522        self,
523        per_epoch_influence: Vec<StaticEpochInfluence>,
524        per_satellite_influence: Vec<StaticSatelliteInfluence>,
525        per_satellite_batch_influence: Vec<StaticSatelliteBatchInfluence>,
526    ) -> StaticSolution {
527        StaticSolution {
528            position: self.position,
529            geodetic: self.geodetic,
530            per_epoch_clock: self.per_epoch_clock,
531            covariance: self.covariance,
532            per_epoch_influence,
533            per_satellite_influence,
534            per_satellite_batch_influence,
535            geometry_quality: self.geometry_quality,
536            residuals_m: self.residuals_m,
537            used_sats: self.used_sats,
538            rejected_sats: self.rejected_sats,
539            metadata: self.metadata,
540        }
541    }
542}
543
544fn solve_static_core(
545    eph: &dyn EphemerisSource,
546    epochs: &[StaticEpoch],
547    options: StaticSolveOptions,
548) -> Result<CoreStaticSolution, StaticSolveError> {
549    validate_static_options(options)?;
550    if epochs.is_empty() {
551        return Err(StaticSolveError::EmptyEpochs);
552    }
553    let model = SppModelRecipe::reference();
554    let prepared = prepare_static(eph, epochs, options, model)?;
555
556    let lost = Cell::new(None::<(usize, GnssSatelliteId)>);
557    let residual = |x: &DVector<f64>| -> DVector<f64> {
558        match residual_static_unweighted(eph, &prepared, x.as_slice(), model) {
559            Ok(values) => DVector::from_vec(values),
560            Err((epoch_index, satellite)) => {
561                lost.set(Some((epoch_index, satellite)));
562                DVector::from_vec(vec![0.0; prepared.rows.len()])
563            }
564        }
565    };
566
567    let opts = SolveOptions {
568        gtol: STATIC_SOLVER_GTOL,
569        ftol: STATIC_SOLVER_FTOL,
570        xtol: STATIC_SOLVER_XTOL,
571        max_nfev: STATIC_SOLVER_MAX_NFEV,
572    };
573    let base_weights = DVector::from_row_slice(&prepared.base_weights);
574    let problem = LeastSquaresProblem::with_weights(&residual, prepared.x0.clone(), base_weights);
575    let report_result = solve_trf_with(&problem, &opts, TrustRegionSolve::NalgebraLu);
576    if let Some((epoch_index, satellite)) = lost.get() {
577        return Err(StaticSolveError::EphemerisLost {
578            epoch_index,
579            satellite,
580        });
581    }
582    let mut report = report_result.map_err(StaticSolveError::Singular)?;
583
584    let mut final_weights = prepared.base_weights.clone();
585    let mut outer_iterations = 0usize;
586    let mut final_robust_scale_m = None;
587
588    if let Some(robust) = options.robust {
589        for _ in 0..robust.max_outer.saturating_sub(1) {
590            let post = residual_static_unweighted(eph, &prepared, report.x.as_slice(), model)
591                .map_err(|(epoch_index, satellite)| StaticSolveError::EphemerisLost {
592                    epoch_index,
593                    satellite,
594                })?;
595            let scale = mad_scale(&post, robust.scale_floor_m).map_err(map_robust_error)?;
596            let effective: Vec<f64> = post
597                .iter()
598                .zip(prepared.base_weights.iter())
599                .map(|(&r, &base)| base * huber_weight(r / scale, robust.huber_k))
600                .collect();
601            let weights = DVector::from_row_slice(&effective);
602            let x_prev = report.x.clone();
603            let problem = LeastSquaresProblem::with_weights(&residual, x_prev.clone(), weights);
604            let next = solve_trf_with(&problem, &opts, TrustRegionSolve::NalgebraLu);
605            if let Some((epoch_index, satellite)) = lost.get() {
606                return Err(StaticSolveError::EphemerisLost {
607                    epoch_index,
608                    satellite,
609                });
610            }
611            report = next.map_err(StaticSolveError::Singular)?;
612            final_weights = effective;
613            outer_iterations += 1;
614            final_robust_scale_m = Some(scale);
615            let dpos = ((report.x[0] - x_prev[0]).powi(2)
616                + (report.x[1] - x_prev[1]).powi(2)
617                + (report.x[2] - x_prev[2]).powi(2))
618            .sqrt();
619            if dpos < robust.outer_tol_m {
620                break;
621            }
622        }
623    }
624
625    finish_static(FinishStaticInput {
626        eph,
627        prepared: &prepared,
628        options,
629        model,
630        x: report.x.as_slice(),
631        jacobian: &report.jacobian,
632        iterations: report.iterations,
633        status: report.status,
634        outer_iterations,
635        final_robust_scale_m,
636        final_weights: &final_weights,
637    })
638}
639
640fn prepare_static(
641    eph: &dyn EphemerisSource,
642    epochs: &[StaticEpoch],
643    options: StaticSolveOptions,
644    model: SppModelRecipe,
645) -> Result<PreparedStatic, StaticSolveError> {
646    let mut prepared_epochs = Vec::with_capacity(epochs.len());
647    let mut rows = Vec::new();
648    let mut base_weights = Vec::new();
649    let mut x0 = vec![
650        options.initial_position_m[0],
651        options.initial_position_m[1],
652        options.initial_position_m[2],
653    ];
654    let mut clock_offset = 3usize;
655
656    for (epoch_index, epoch) in epochs.iter().enumerate() {
657        validate_epoch_weights(epoch)?;
658        if let Some(satellite) = duplicate_satellite(&epoch.measurements) {
659            return Err(StaticSolveError::DuplicateObservation {
660                epoch_index,
661                satellite,
662            });
663        }
664        if epoch.corrections.ionosphere {
665            if let Some(satellite) = epoch
666                .measurements
667                .iter()
668                .map(|m| m.satellite_id)
669                .find(|sat| spp_iono_frequency_hz(*sat, &epoch.glonass_channels).is_none())
670            {
671                return Err(StaticSolveError::IonosphereUnsupported {
672                    epoch_index,
673                    satellite,
674                });
675            }
676        }
677
678        let inputs = solve_inputs_for_epoch(epoch, options);
679        validate_solve_inputs(&inputs).map_err(|source| StaticSolveError::EpochInput {
680            epoch_index,
681            source,
682        })?;
683        let selection = select_sats(eph, &inputs, model);
684        let systems = clock_systems(&selection.used);
685        let weight_by_sat = measurement_weight_map(epoch);
686        let obs_by_id: Vec<(GnssSatelliteId, f64)> = inputs
687            .observations
688            .iter()
689            .map(|m| (m.satellite_id, m.pseudorange_m))
690            .collect();
691
692        for (row_idx, &satellite_id) in selection.used.iter().enumerate() {
693            let multiplier = weight_by_sat.get(&satellite_id).copied().unwrap_or(1.0);
694            let base_weight = selection.weights[row_idx] * multiplier;
695            rows.push(RowRef {
696                epoch_index,
697                satellite_id,
698                base_weight,
699            });
700            base_weights.push(base_weight);
701        }
702
703        if !systems.is_empty() {
704            x0.push(epoch.clock_initial_m);
705            x0.extend(std::iter::repeat_n(0.0, systems.len().saturating_sub(1)));
706        }
707
708        prepared_epochs.push(PreparedEpoch {
709            input_index: epoch_index,
710            inputs,
711            used: selection.used,
712            rejected: selection.rejected,
713            systems,
714            clock_offset,
715            obs_by_id,
716        });
717        clock_offset += prepared_epochs
718            .last()
719            .expect("prepared epoch just pushed")
720            .systems
721            .len();
722    }
723
724    let n_params = x0.len();
725    if rows.len() < n_params {
726        return Err(StaticSolveError::TooFewMeasurements {
727            used: rows.len(),
728            required: n_params,
729        });
730    }
731
732    Ok(PreparedStatic {
733        epochs: prepared_epochs,
734        rows,
735        base_weights,
736        x0: DVector::from_vec(x0),
737        n_params,
738    })
739}
740
741fn solve_inputs_for_epoch(epoch: &StaticEpoch, options: StaticSolveOptions) -> SolveInputs {
742    SolveInputs {
743        observations: epoch.measurements.clone(),
744        t_rx_j2000_s: epoch.t_rx_j2000_s,
745        t_rx_second_of_day_s: epoch.t_rx_second_of_day_s,
746        day_of_year: epoch.day_of_year,
747        initial_guess: [
748            options.initial_position_m[0],
749            options.initial_position_m[1],
750            options.initial_position_m[2],
751            epoch.clock_initial_m,
752        ],
753        corrections: epoch.corrections,
754        klobuchar: epoch.klobuchar,
755        beidou_klobuchar: epoch.beidou_klobuchar,
756        galileo_nequick: epoch.galileo_nequick,
757        sbas_iono: epoch.sbas_iono.clone(),
758        glonass_channels: epoch.glonass_channels.clone(),
759        met: epoch.met,
760        robust: None,
761    }
762}
763
764struct FinishStaticInput<'a> {
765    eph: &'a dyn EphemerisSource,
766    prepared: &'a PreparedStatic,
767    options: StaticSolveOptions,
768    model: SppModelRecipe,
769    x: &'a [f64],
770    jacobian: &'a DMatrix<f64>,
771    iterations: usize,
772    status: Status,
773    outer_iterations: usize,
774    final_robust_scale_m: Option<f64>,
775    final_weights: &'a [f64],
776}
777
778fn finish_static(input: FinishStaticInput<'_>) -> Result<CoreStaticSolution, StaticSolveError> {
779    let FinishStaticInput {
780        eph,
781        prepared,
782        options,
783        model,
784        x,
785        jacobian,
786        iterations,
787        status,
788        outer_iterations,
789        final_robust_scale_m,
790        final_weights,
791    } = input;
792    let position = ItrfPositionM::new(x[0], x[1], x[2]).expect("valid static position");
793    let receiver_geodetic = geodetic_from_ecef(model.frame, [x[0], x[1], x[2]]);
794    let geodetic = if options.with_geodetic {
795        Some(receiver_geodetic)
796    } else {
797        None
798    };
799    let per_epoch_clock = epoch_clocks(prepared, x);
800    let residual_values = residual_static_unweighted(eph, prepared, x, model).map_err(
801        |(epoch_index, satellite)| StaticSolveError::EphemerisLost {
802            epoch_index,
803            satellite,
804        },
805    )?;
806    let residuals_m = residual_values
807        .iter()
808        .zip(prepared.rows.iter())
809        .zip(final_weights.iter())
810        .map(|((&residual_m, row), &effective_weight)| StaticResidual {
811            epoch_index: row.epoch_index,
812            satellite_id: row.satellite_id,
813            residual_m,
814            base_weight: row.base_weight,
815            effective_weight,
816            robust_weight_ratio: effective_weight / row.base_weight,
817        })
818        .collect::<Vec<_>>();
819
820    let covariance_matrix = normal_covariance(jacobian, 1.0).map_err(StaticSolveError::Singular)?;
821    let covariance = static_covariance(&covariance_matrix, receiver_geodetic)?;
822    let svd = jacobian.clone().svd(false, false);
823    let diagnostics = singular_value_diagnostics(
824        svd.singular_values.as_slice(),
825        jacobian.nrows(),
826        jacobian.ncols(),
827    );
828    if diagnostics.rank < prepared.n_params {
829        return Err(StaticSolveError::Singular(
830            least_squares::SolveError::SingularJacobian,
831        ));
832    }
833    let gdop = covariance_trace(&covariance_matrix).sqrt();
834    let redundancy = prepared.rows.len() as isize - prepared.n_params as isize;
835    let geometry_quality = classify(
836        diagnostics.rank,
837        prepared.n_params,
838        redundancy as i32,
839        diagnostics.condition_number,
840        gdop,
841        false,
842        GeometryQualityThresholds::default(),
843    );
844    let converged = matches!(
845        status,
846        Status::GradientTolerance | Status::CostTolerance | Status::StepTolerance
847    );
848
849    Ok(CoreStaticSolution {
850        position,
851        geodetic,
852        per_epoch_clock,
853        covariance,
854        geometry_quality,
855        residuals_m,
856        used_sats: prepared
857            .epochs
858            .iter()
859            .map(|epoch| epoch.used.clone())
860            .collect(),
861        rejected_sats: prepared
862            .epochs
863            .iter()
864            .map(|epoch| epoch.rejected.clone())
865            .collect(),
866        metadata: StaticSolutionMetadata {
867            iterations,
868            converged,
869            status,
870            outer_iterations,
871            final_robust_scale_m,
872            used_measurements: prepared.rows.len(),
873            n_parameters: prepared.n_params,
874            redundancy,
875        },
876    })
877}
878
879fn residual_static_unweighted(
880    eph: &dyn EphemerisSource,
881    prepared: &PreparedStatic,
882    x: &[f64],
883    model: SppModelRecipe,
884) -> Result<Vec<f64>, (usize, GnssSatelliteId)> {
885    let mut out = Vec::with_capacity(prepared.rows.len());
886    for epoch in &prepared.epochs {
887        if epoch.used.is_empty() {
888            continue;
889        }
890        let mut local = Vec::with_capacity(3 + epoch.systems.len());
891        local.extend_from_slice(&x[0..3]);
892        for clock_idx in 0..epoch.systems.len() {
893            local.push(x[epoch.clock_offset + clock_idx]);
894        }
895        let residuals = residual_unweighted(
896            eph,
897            &epoch.used,
898            &epoch.obs_by_id,
899            &local,
900            &epoch.inputs,
901            model,
902        )
903        .map_err(|satellite| (epoch.input_index, satellite))?;
904        out.extend(residuals);
905    }
906    Ok(out)
907}
908
909fn static_covariance(
910    covariance: &DMatrix<f64>,
911    receiver: Wgs84Geodetic,
912) -> Result<StaticCovariance, StaticSolveError> {
913    let state_m2 = matrix_to_rows(covariance);
914    let position_ecef_m2 = [
915        [covariance[(0, 0)], covariance[(0, 1)], covariance[(0, 2)]],
916        [covariance[(1, 0)], covariance[(1, 1)], covariance[(1, 2)]],
917        [covariance[(2, 0)], covariance[(2, 1)], covariance[(2, 2)]],
918    ];
919    let position_enu_m2 = rotate_covariance_ecef_to_enu_m2(position_ecef_m2, receiver)
920        .map_err(|_| StaticSolveError::Singular(least_squares::SolveError::SingularJacobian))?;
921    Ok(StaticCovariance {
922        state_m2,
923        position_ecef_m2,
924        position_enu_m2,
925    })
926}
927
928fn epoch_clocks(prepared: &PreparedStatic, x: &[f64]) -> Vec<StaticClockBias> {
929    let mut clocks = Vec::new();
930    for epoch in &prepared.epochs {
931        for (clock_idx, &system) in epoch.systems.iter().enumerate() {
932            clocks.push(StaticClockBias {
933                epoch_index: epoch.input_index,
934                system,
935                clock_s: x[epoch.clock_offset + clock_idx] / C_M_S,
936            });
937        }
938    }
939    clocks
940}
941
942fn validate_static_options(options: StaticSolveOptions) -> Result<(), StaticSolveError> {
943    validate::finite_slice(&options.initial_position_m, "initial_position_m")
944        .map_err(map_static_input_error)?;
945    if let Some(robust) = options.robust {
946        if robust.max_outer == 0 {
947            return Err(StaticSolveError::InvalidInput {
948                field: "robust.max_outer",
949                kind: SppInputErrorKind::NotPositive,
950            });
951        }
952        validate::finite_positive(robust.huber_k, "robust.huber_k")
953            .map_err(map_static_input_error)?;
954        validate::finite_positive(robust.scale_floor_m, "robust.scale_floor_m")
955            .map_err(map_static_input_error)?;
956        validate::finite_positive(robust.outer_tol_m, "robust.outer_tol_m")
957            .map_err(map_static_input_error)?;
958    }
959    Ok(())
960}
961
962fn validate_epoch_weights(epoch: &StaticEpoch) -> Result<(), StaticSolveError> {
963    if let Some(weights) = &epoch.weights {
964        if weights.len() != epoch.measurements.len() {
965            return Err(StaticSolveError::InvalidInput {
966                field: "epoch.weights",
967                kind: SppInputErrorKind::OutOfRange,
968            });
969        }
970        for &weight in weights {
971            validate::finite_positive(weight, "epoch.weights").map_err(map_static_input_error)?;
972        }
973    }
974    Ok(())
975}
976
977fn measurement_weight_map(epoch: &StaticEpoch) -> BTreeMap<GnssSatelliteId, f64> {
978    epoch
979        .weights
980        .as_ref()
981        .map(|weights| {
982            epoch
983                .measurements
984                .iter()
985                .zip(weights.iter())
986                .map(|(measurement, &weight)| (measurement.satellite_id, weight))
987                .collect()
988        })
989        .unwrap_or_default()
990}
991
992fn duplicate_satellite(measurements: &[Observation]) -> Option<GnssSatelliteId> {
993    let mut ids: Vec<GnssSatelliteId> = measurements.iter().map(|m| m.satellite_id).collect();
994    ids.sort_unstable();
995    ids.windows(2)
996        .find(|pair| pair[0] == pair[1])
997        .map(|pair| pair[0])
998}
999
1000fn build_influence(
1001    eph: &dyn EphemerisSource,
1002    epochs: &[StaticEpoch],
1003    options: StaticSolveOptions,
1004    full: &CoreStaticSolution,
1005) -> (
1006    Vec<StaticEpochInfluence>,
1007    Vec<StaticSatelliteInfluence>,
1008    Vec<StaticSatelliteBatchInfluence>,
1009) {
1010    let epoch_influence = (0..epochs.len())
1011        .map(|epoch_index| {
1012            let mut subset = epochs.to_vec();
1013            let omitted_measurements = subset[epoch_index].measurements.len();
1014            subset.remove(epoch_index);
1015            let result = solve_static_core(eph, &subset, options);
1016            let (status, position_delta_m, position_delta_norm_m, residual_rms_m) =
1017                influence_result(full.position.as_array(), result);
1018            StaticEpochInfluence {
1019                epoch_index,
1020                omitted_measurements,
1021                status,
1022                position_delta_m,
1023                position_delta_norm_m,
1024                residual_rms_m,
1025                min_robust_weight_ratio: min_epoch_weight_ratio(full, epoch_index),
1026            }
1027        })
1028        .collect();
1029
1030    let satellite_ids = full
1031        .residuals_m
1032        .iter()
1033        .map(|row| row.satellite_id)
1034        .collect::<BTreeSet<_>>();
1035    let satellite_batch_influence = satellite_ids
1036        .into_iter()
1037        .map(|satellite_id| {
1038            let subset = omit_satellite_all_epochs(epochs, satellite_id);
1039            let result = solve_static_core(eph, &subset, options);
1040            let (status, position_delta_m, position_delta_norm_m, residual_rms_m) =
1041                influence_result(full.position.as_array(), result);
1042            StaticSatelliteBatchInfluence {
1043                satellite_id,
1044                omitted_measurements: full
1045                    .residuals_m
1046                    .iter()
1047                    .filter(|row| row.satellite_id == satellite_id)
1048                    .count(),
1049                status,
1050                position_delta_m,
1051                position_delta_norm_m,
1052                residual_rms_m,
1053                min_robust_weight_ratio: min_satellite_weight_ratio(full, satellite_id),
1054            }
1055        })
1056        .collect();
1057
1058    let satellite_influence = full
1059        .residuals_m
1060        .iter()
1061        .map(|row| {
1062            let subset = omit_satellite(epochs, row.epoch_index, row.satellite_id);
1063            let result = solve_static_core(eph, &subset, options);
1064            let (status, position_delta_m, position_delta_norm_m, residual_rms_m) =
1065                influence_result(full.position.as_array(), result);
1066            StaticSatelliteInfluence {
1067                epoch_index: row.epoch_index,
1068                satellite_id: row.satellite_id,
1069                status,
1070                position_delta_m,
1071                position_delta_norm_m,
1072                residual_rms_m,
1073                residual_m: row.residual_m,
1074                base_weight: row.base_weight,
1075                effective_weight: row.effective_weight,
1076                robust_weight_ratio: row.robust_weight_ratio,
1077            }
1078        })
1079        .collect();
1080
1081    (
1082        epoch_influence,
1083        satellite_influence,
1084        satellite_batch_influence,
1085    )
1086}
1087
1088fn omit_satellite(
1089    epochs: &[StaticEpoch],
1090    epoch_index: usize,
1091    satellite_id: GnssSatelliteId,
1092) -> Vec<StaticEpoch> {
1093    let mut subset = epochs.to_vec();
1094    remove_satellite_from_epoch(&mut subset[epoch_index], satellite_id);
1095    subset
1096}
1097
1098fn omit_satellite_all_epochs(
1099    epochs: &[StaticEpoch],
1100    satellite_id: GnssSatelliteId,
1101) -> Vec<StaticEpoch> {
1102    let mut subset = epochs.to_vec();
1103    for epoch in &mut subset {
1104        remove_satellite_from_epoch(epoch, satellite_id);
1105    }
1106    subset
1107}
1108
1109fn remove_satellite_from_epoch(epoch: &mut StaticEpoch, satellite_id: GnssSatelliteId) {
1110    let old_measurements = std::mem::take(&mut epoch.measurements);
1111    let old_weights = epoch.weights.take();
1112    let mut measurements = Vec::with_capacity(old_measurements.len());
1113    let mut weights = old_weights
1114        .as_ref()
1115        .map(|_| Vec::with_capacity(old_measurements.len()));
1116    for (idx, measurement) in old_measurements.into_iter().enumerate() {
1117        if measurement.satellite_id == satellite_id {
1118            continue;
1119        }
1120        measurements.push(measurement);
1121        if let (Some(old), Some(new_weights)) = (&old_weights, &mut weights) {
1122            new_weights.push(old[idx]);
1123        }
1124    }
1125    epoch.measurements = measurements;
1126    epoch.weights = weights;
1127}
1128
1129fn influence_result(
1130    full_position: [f64; 3],
1131    result: Result<CoreStaticSolution, StaticSolveError>,
1132) -> (
1133    StaticInfluenceStatus,
1134    Option<[f64; 3]>,
1135    Option<f64>,
1136    Option<f64>,
1137) {
1138    match result {
1139        Ok(solution) => {
1140            let position = solution.position.as_array();
1141            let delta = [
1142                position[0] - full_position[0],
1143                position[1] - full_position[1],
1144                position[2] - full_position[2],
1145            ];
1146            let norm = (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt();
1147            let residual_values = solution
1148                .residuals_m
1149                .iter()
1150                .map(|row| row.residual_m)
1151                .collect::<Vec<_>>();
1152            (
1153                StaticInfluenceStatus::Solved,
1154                Some(delta),
1155                Some(norm),
1156                Some(residual_rms(&residual_values)),
1157            )
1158        }
1159        Err(error) => (influence_status(&error), None, None, None),
1160    }
1161}
1162
1163fn influence_status(error: &StaticSolveError) -> StaticInfluenceStatus {
1164    match error {
1165        StaticSolveError::TooFewMeasurements { .. } | StaticSolveError::EmptyEpochs => {
1166            StaticInfluenceStatus::TooFewMeasurements
1167        }
1168        StaticSolveError::Singular(_) => StaticInfluenceStatus::SingularGeometry,
1169        StaticSolveError::InvalidInput { .. }
1170        | StaticSolveError::EpochInput { .. }
1171        | StaticSolveError::DuplicateObservation { .. }
1172        | StaticSolveError::IonosphereUnsupported { .. } => StaticInfluenceStatus::InvalidInput,
1173        StaticSolveError::EphemerisLost { .. } => StaticInfluenceStatus::EphemerisUnavailable,
1174    }
1175}
1176
1177fn min_epoch_weight_ratio(full: &CoreStaticSolution, epoch_index: usize) -> f64 {
1178    full.residuals_m
1179        .iter()
1180        .filter(|row| row.epoch_index == epoch_index)
1181        .map(|row| row.robust_weight_ratio)
1182        .fold(1.0, f64::min)
1183}
1184
1185fn min_satellite_weight_ratio(full: &CoreStaticSolution, satellite_id: GnssSatelliteId) -> f64 {
1186    full.residuals_m
1187        .iter()
1188        .filter(|row| row.satellite_id == satellite_id)
1189        .map(|row| row.robust_weight_ratio)
1190        .fold(1.0, f64::min)
1191}
1192
1193fn matrix_to_rows(matrix: &DMatrix<f64>) -> Vec<Vec<f64>> {
1194    (0..matrix.nrows())
1195        .map(|row| (0..matrix.ncols()).map(|col| matrix[(row, col)]).collect())
1196        .collect()
1197}
1198
1199fn covariance_trace(matrix: &DMatrix<f64>) -> f64 {
1200    (0..matrix.nrows().min(matrix.ncols()))
1201        .map(|idx| matrix[(idx, idx)])
1202        .sum()
1203}
1204
1205fn residual_rms(residuals: &[f64]) -> f64 {
1206    if residuals.is_empty() {
1207        return 0.0;
1208    }
1209    let sum_sq = residuals.iter().map(|r| r * r).sum::<f64>();
1210    (sum_sq / residuals.len() as f64).sqrt()
1211}
1212
1213fn map_static_input_error(error: validate::FieldError) -> StaticSolveError {
1214    StaticSolveError::InvalidInput {
1215        field: error.field(),
1216        kind: SppInputErrorKind::from(&error),
1217    }
1218}
1219
1220fn map_robust_error(error: RobustError) -> StaticSolveError {
1221    let field = match error.field() {
1222        "scale_floor" => "robust.scale_floor_m",
1223        "residuals" | "values" => "robust.residuals",
1224        other => other,
1225    };
1226    let kind = match error.reason() {
1227        "not finite" => SppInputErrorKind::NonFinite,
1228        "not positive" => SppInputErrorKind::NotPositive,
1229        "negative" => SppInputErrorKind::Negative,
1230        "out of range" => SppInputErrorKind::OutOfRange,
1231        _ => SppInputErrorKind::OutOfRange,
1232    };
1233    StaticSolveError::InvalidInput { field, kind }
1234}
1235
1236#[cfg(test)]
1237mod tests;