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