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
448#[derive(Debug, Clone)]
449struct PreparedEpoch {
450    input_index: usize,
451    inputs: SolveInputs,
452    used: Vec<GnssSatelliteId>,
453    rejected: Vec<RejectedSat>,
454    systems: Vec<GnssSystem>,
455    clock_offset: usize,
456    obs_by_id: Vec<(GnssSatelliteId, f64)>,
457}
458
459#[derive(Debug, Clone, Copy)]
460struct RowRef {
461    epoch_index: usize,
462    satellite_id: GnssSatelliteId,
463    base_weight: f64,
464}
465
466#[derive(Debug, Clone)]
467struct PreparedStatic {
468    epochs: Vec<PreparedEpoch>,
469    rows: Vec<RowRef>,
470    base_weights: Vec<f64>,
471    x0: DVector<f64>,
472    n_params: usize,
473}
474
475#[derive(Debug, Clone)]
476struct CoreStaticSolution {
477    position: ItrfPositionM,
478    geodetic: Option<Wgs84Geodetic>,
479    per_epoch_clock: Vec<StaticClockBias>,
480    covariance: StaticCovariance,
481    geometry_quality: GeometryQuality,
482    residuals_m: Vec<StaticResidual>,
483    used_sats: Vec<Vec<GnssSatelliteId>>,
484    rejected_sats: Vec<Vec<RejectedSat>>,
485    metadata: StaticSolutionMetadata,
486}
487
488impl CoreStaticSolution {
489    fn into_public(
490        self,
491        per_epoch_influence: Vec<StaticEpochInfluence>,
492        per_satellite_influence: Vec<StaticSatelliteInfluence>,
493        per_satellite_batch_influence: Vec<StaticSatelliteBatchInfluence>,
494    ) -> StaticSolution {
495        StaticSolution {
496            position: self.position,
497            geodetic: self.geodetic,
498            per_epoch_clock: self.per_epoch_clock,
499            covariance: self.covariance,
500            per_epoch_influence,
501            per_satellite_influence,
502            per_satellite_batch_influence,
503            geometry_quality: self.geometry_quality,
504            residuals_m: self.residuals_m,
505            used_sats: self.used_sats,
506            rejected_sats: self.rejected_sats,
507            metadata: self.metadata,
508        }
509    }
510}
511
512fn solve_static_core(
513    eph: &dyn EphemerisSource,
514    epochs: &[StaticEpoch],
515    options: StaticSolveOptions,
516) -> Result<CoreStaticSolution, StaticSolveError> {
517    validate_static_options(options)?;
518    if epochs.is_empty() {
519        return Err(StaticSolveError::EmptyEpochs);
520    }
521    let model = SppModelRecipe::reference();
522    let prepared = prepare_static(eph, epochs, options, model)?;
523
524    let lost = Cell::new(None::<(usize, GnssSatelliteId)>);
525    let residual = |x: &DVector<f64>| -> DVector<f64> {
526        match residual_static_unweighted(eph, &prepared, x.as_slice(), model) {
527            Ok(values) => DVector::from_vec(values),
528            Err((epoch_index, satellite)) => {
529                lost.set(Some((epoch_index, satellite)));
530                DVector::from_vec(vec![0.0; prepared.rows.len()])
531            }
532        }
533    };
534
535    let opts = SolveOptions {
536        gtol: STATIC_SOLVER_GTOL,
537        ftol: STATIC_SOLVER_FTOL,
538        xtol: STATIC_SOLVER_XTOL,
539        max_nfev: STATIC_SOLVER_MAX_NFEV,
540    };
541    let base_weights = DVector::from_row_slice(&prepared.base_weights);
542    let problem = LeastSquaresProblem::with_weights(&residual, prepared.x0.clone(), base_weights);
543    let report_result = solve_trf_with(&problem, &opts, TrustRegionSolve::NalgebraLu);
544    if let Some((epoch_index, satellite)) = lost.get() {
545        return Err(StaticSolveError::EphemerisLost {
546            epoch_index,
547            satellite,
548        });
549    }
550    let mut report = report_result.map_err(StaticSolveError::Singular)?;
551
552    let mut final_weights = prepared.base_weights.clone();
553    let mut outer_iterations = 0usize;
554    let mut final_robust_scale_m = None;
555
556    if let Some(robust) = options.robust {
557        for _ in 0..robust.max_outer.saturating_sub(1) {
558            let post = residual_static_unweighted(eph, &prepared, report.x.as_slice(), model)
559                .map_err(|(epoch_index, satellite)| StaticSolveError::EphemerisLost {
560                    epoch_index,
561                    satellite,
562                })?;
563            let scale = mad_scale(&post, robust.scale_floor_m).map_err(map_robust_error)?;
564            let effective: Vec<f64> = post
565                .iter()
566                .zip(prepared.base_weights.iter())
567                .map(|(&r, &base)| base * huber_weight(r / scale, robust.huber_k))
568                .collect();
569            let weights = DVector::from_row_slice(&effective);
570            let x_prev = report.x.clone();
571            let problem = LeastSquaresProblem::with_weights(&residual, x_prev.clone(), weights);
572            let next = solve_trf_with(&problem, &opts, TrustRegionSolve::NalgebraLu);
573            if let Some((epoch_index, satellite)) = lost.get() {
574                return Err(StaticSolveError::EphemerisLost {
575                    epoch_index,
576                    satellite,
577                });
578            }
579            report = next.map_err(StaticSolveError::Singular)?;
580            final_weights = effective;
581            outer_iterations += 1;
582            final_robust_scale_m = Some(scale);
583            let dpos = ((report.x[0] - x_prev[0]).powi(2)
584                + (report.x[1] - x_prev[1]).powi(2)
585                + (report.x[2] - x_prev[2]).powi(2))
586            .sqrt();
587            if dpos < robust.outer_tol_m {
588                break;
589            }
590        }
591    }
592
593    finish_static(FinishStaticInput {
594        eph,
595        prepared: &prepared,
596        options,
597        model,
598        x: report.x.as_slice(),
599        jacobian: &report.jacobian,
600        iterations: report.iterations,
601        status: report.status,
602        outer_iterations,
603        final_robust_scale_m,
604        final_weights: &final_weights,
605    })
606}
607
608fn prepare_static(
609    eph: &dyn EphemerisSource,
610    epochs: &[StaticEpoch],
611    options: StaticSolveOptions,
612    model: SppModelRecipe,
613) -> Result<PreparedStatic, StaticSolveError> {
614    let mut prepared_epochs = Vec::with_capacity(epochs.len());
615    let mut rows = Vec::new();
616    let mut base_weights = Vec::new();
617    let mut x0 = vec![
618        options.initial_position_m[0],
619        options.initial_position_m[1],
620        options.initial_position_m[2],
621    ];
622    let mut clock_offset = 3usize;
623
624    for (epoch_index, epoch) in epochs.iter().enumerate() {
625        validate_epoch_weights(epoch)?;
626        if let Some(satellite) = duplicate_satellite(&epoch.measurements) {
627            return Err(StaticSolveError::DuplicateObservation {
628                epoch_index,
629                satellite,
630            });
631        }
632        if epoch.corrections.ionosphere {
633            if let Some(satellite) = epoch
634                .measurements
635                .iter()
636                .map(|m| m.satellite_id)
637                .find(|sat| spp_iono_frequency_hz(*sat, &epoch.glonass_channels).is_none())
638            {
639                return Err(StaticSolveError::IonosphereUnsupported {
640                    epoch_index,
641                    satellite,
642                });
643            }
644        }
645
646        let inputs = solve_inputs_for_epoch(epoch, options);
647        validate_solve_inputs(&inputs).map_err(|source| StaticSolveError::EpochInput {
648            epoch_index,
649            source,
650        })?;
651        let selection = select_sats(eph, &inputs, model);
652        let systems = clock_systems(&selection.used);
653        let weight_by_sat = measurement_weight_map(epoch);
654        let obs_by_id: Vec<(GnssSatelliteId, f64)> = inputs
655            .observations
656            .iter()
657            .map(|m| (m.satellite_id, m.pseudorange_m))
658            .collect();
659
660        for (row_idx, &satellite_id) in selection.used.iter().enumerate() {
661            let multiplier = weight_by_sat.get(&satellite_id).copied().unwrap_or(1.0);
662            let base_weight = selection.weights[row_idx] * multiplier;
663            rows.push(RowRef {
664                epoch_index,
665                satellite_id,
666                base_weight,
667            });
668            base_weights.push(base_weight);
669        }
670
671        if !systems.is_empty() {
672            x0.push(epoch.clock_initial_m);
673            x0.extend(std::iter::repeat_n(0.0, systems.len().saturating_sub(1)));
674        }
675
676        prepared_epochs.push(PreparedEpoch {
677            input_index: epoch_index,
678            inputs,
679            used: selection.used,
680            rejected: selection.rejected,
681            systems,
682            clock_offset,
683            obs_by_id,
684        });
685        clock_offset += prepared_epochs
686            .last()
687            .expect("prepared epoch just pushed")
688            .systems
689            .len();
690    }
691
692    let n_params = x0.len();
693    if rows.len() < n_params {
694        return Err(StaticSolveError::TooFewMeasurements {
695            used: rows.len(),
696            required: n_params,
697        });
698    }
699
700    Ok(PreparedStatic {
701        epochs: prepared_epochs,
702        rows,
703        base_weights,
704        x0: DVector::from_vec(x0),
705        n_params,
706    })
707}
708
709fn solve_inputs_for_epoch(epoch: &StaticEpoch, options: StaticSolveOptions) -> SolveInputs {
710    SolveInputs {
711        observations: epoch.measurements.clone(),
712        t_rx_j2000_s: epoch.t_rx_j2000_s,
713        t_rx_second_of_day_s: epoch.t_rx_second_of_day_s,
714        day_of_year: epoch.day_of_year,
715        initial_guess: [
716            options.initial_position_m[0],
717            options.initial_position_m[1],
718            options.initial_position_m[2],
719            epoch.clock_initial_m,
720        ],
721        corrections: epoch.corrections,
722        klobuchar: epoch.klobuchar,
723        beidou_klobuchar: epoch.beidou_klobuchar,
724        galileo_nequick: epoch.galileo_nequick,
725        sbas_iono: epoch.sbas_iono.clone(),
726        glonass_channels: epoch.glonass_channels.clone(),
727        met: epoch.met,
728        robust: None,
729    }
730}
731
732struct FinishStaticInput<'a> {
733    eph: &'a dyn EphemerisSource,
734    prepared: &'a PreparedStatic,
735    options: StaticSolveOptions,
736    model: SppModelRecipe,
737    x: &'a [f64],
738    jacobian: &'a DMatrix<f64>,
739    iterations: usize,
740    status: Status,
741    outer_iterations: usize,
742    final_robust_scale_m: Option<f64>,
743    final_weights: &'a [f64],
744}
745
746fn finish_static(input: FinishStaticInput<'_>) -> Result<CoreStaticSolution, StaticSolveError> {
747    let FinishStaticInput {
748        eph,
749        prepared,
750        options,
751        model,
752        x,
753        jacobian,
754        iterations,
755        status,
756        outer_iterations,
757        final_robust_scale_m,
758        final_weights,
759    } = input;
760    let position = ItrfPositionM::new(x[0], x[1], x[2]).expect("valid static position");
761    let receiver_geodetic = geodetic_from_ecef(model.frame, [x[0], x[1], x[2]]);
762    let geodetic = if options.with_geodetic {
763        Some(receiver_geodetic)
764    } else {
765        None
766    };
767    let per_epoch_clock = epoch_clocks(prepared, x);
768    let residual_values = residual_static_unweighted(eph, prepared, x, model).map_err(
769        |(epoch_index, satellite)| StaticSolveError::EphemerisLost {
770            epoch_index,
771            satellite,
772        },
773    )?;
774    let residuals_m = residual_values
775        .iter()
776        .zip(prepared.rows.iter())
777        .zip(final_weights.iter())
778        .map(|((&residual_m, row), &effective_weight)| StaticResidual {
779            epoch_index: row.epoch_index,
780            satellite_id: row.satellite_id,
781            residual_m,
782            base_weight: row.base_weight,
783            effective_weight,
784            robust_weight_ratio: effective_weight / row.base_weight,
785        })
786        .collect::<Vec<_>>();
787
788    let covariance_matrix = normal_covariance(jacobian, 1.0).map_err(StaticSolveError::Singular)?;
789    let covariance = static_covariance(&covariance_matrix, receiver_geodetic)?;
790    let svd = jacobian.clone().svd(false, false);
791    let diagnostics = singular_value_diagnostics(
792        svd.singular_values.as_slice(),
793        jacobian.nrows(),
794        jacobian.ncols(),
795    );
796    if diagnostics.rank < prepared.n_params {
797        return Err(StaticSolveError::Singular(
798            least_squares::SolveError::SingularJacobian,
799        ));
800    }
801    let gdop = covariance_trace(&covariance_matrix).sqrt();
802    let redundancy = prepared.rows.len() as isize - prepared.n_params as isize;
803    let geometry_quality = classify(
804        diagnostics.rank,
805        prepared.n_params,
806        redundancy as i32,
807        diagnostics.condition_number,
808        gdop,
809        false,
810        GeometryQualityThresholds::default(),
811    );
812    let converged = matches!(
813        status,
814        Status::GradientTolerance | Status::CostTolerance | Status::StepTolerance
815    );
816
817    Ok(CoreStaticSolution {
818        position,
819        geodetic,
820        per_epoch_clock,
821        covariance,
822        geometry_quality,
823        residuals_m,
824        used_sats: prepared
825            .epochs
826            .iter()
827            .map(|epoch| epoch.used.clone())
828            .collect(),
829        rejected_sats: prepared
830            .epochs
831            .iter()
832            .map(|epoch| epoch.rejected.clone())
833            .collect(),
834        metadata: StaticSolutionMetadata {
835            iterations,
836            converged,
837            status,
838            outer_iterations,
839            final_robust_scale_m,
840            used_measurements: prepared.rows.len(),
841            n_parameters: prepared.n_params,
842            redundancy,
843        },
844    })
845}
846
847fn residual_static_unweighted(
848    eph: &dyn EphemerisSource,
849    prepared: &PreparedStatic,
850    x: &[f64],
851    model: SppModelRecipe,
852) -> Result<Vec<f64>, (usize, GnssSatelliteId)> {
853    let mut out = Vec::with_capacity(prepared.rows.len());
854    for epoch in &prepared.epochs {
855        if epoch.used.is_empty() {
856            continue;
857        }
858        let mut local = Vec::with_capacity(3 + epoch.systems.len());
859        local.extend_from_slice(&x[0..3]);
860        for clock_idx in 0..epoch.systems.len() {
861            local.push(x[epoch.clock_offset + clock_idx]);
862        }
863        let residuals = residual_unweighted(
864            eph,
865            &epoch.used,
866            &epoch.obs_by_id,
867            &local,
868            &epoch.inputs,
869            model,
870        )
871        .map_err(|satellite| (epoch.input_index, satellite))?;
872        out.extend(residuals);
873    }
874    Ok(out)
875}
876
877fn static_covariance(
878    covariance: &DMatrix<f64>,
879    receiver: Wgs84Geodetic,
880) -> Result<StaticCovariance, StaticSolveError> {
881    let state_m2 = matrix_to_rows(covariance);
882    let position_ecef_m2 = [
883        [covariance[(0, 0)], covariance[(0, 1)], covariance[(0, 2)]],
884        [covariance[(1, 0)], covariance[(1, 1)], covariance[(1, 2)]],
885        [covariance[(2, 0)], covariance[(2, 1)], covariance[(2, 2)]],
886    ];
887    let position_enu_m2 = rotate_covariance_ecef_to_enu_m2(position_ecef_m2, receiver)
888        .map_err(|_| StaticSolveError::Singular(least_squares::SolveError::SingularJacobian))?;
889    Ok(StaticCovariance {
890        state_m2,
891        position_ecef_m2,
892        position_enu_m2,
893    })
894}
895
896fn epoch_clocks(prepared: &PreparedStatic, x: &[f64]) -> Vec<StaticClockBias> {
897    let mut clocks = Vec::new();
898    for epoch in &prepared.epochs {
899        for (clock_idx, &system) in epoch.systems.iter().enumerate() {
900            clocks.push(StaticClockBias {
901                epoch_index: epoch.input_index,
902                system,
903                clock_s: x[epoch.clock_offset + clock_idx] / C_M_S,
904            });
905        }
906    }
907    clocks
908}
909
910fn validate_static_options(options: StaticSolveOptions) -> Result<(), StaticSolveError> {
911    validate::finite_slice(&options.initial_position_m, "initial_position_m")
912        .map_err(map_static_input_error)?;
913    if let Some(robust) = options.robust {
914        if robust.max_outer == 0 {
915            return Err(StaticSolveError::InvalidInput {
916                field: "robust.max_outer",
917                kind: SppInputErrorKind::NotPositive,
918            });
919        }
920        validate::finite_positive(robust.huber_k, "robust.huber_k")
921            .map_err(map_static_input_error)?;
922        validate::finite_positive(robust.scale_floor_m, "robust.scale_floor_m")
923            .map_err(map_static_input_error)?;
924        validate::finite_positive(robust.outer_tol_m, "robust.outer_tol_m")
925            .map_err(map_static_input_error)?;
926    }
927    Ok(())
928}
929
930fn validate_epoch_weights(epoch: &StaticEpoch) -> Result<(), StaticSolveError> {
931    if let Some(weights) = &epoch.weights {
932        if weights.len() != epoch.measurements.len() {
933            return Err(StaticSolveError::InvalidInput {
934                field: "epoch.weights",
935                kind: SppInputErrorKind::OutOfRange,
936            });
937        }
938        for &weight in weights {
939            validate::finite_positive(weight, "epoch.weights").map_err(map_static_input_error)?;
940        }
941    }
942    Ok(())
943}
944
945fn measurement_weight_map(epoch: &StaticEpoch) -> BTreeMap<GnssSatelliteId, f64> {
946    epoch
947        .weights
948        .as_ref()
949        .map(|weights| {
950            epoch
951                .measurements
952                .iter()
953                .zip(weights.iter())
954                .map(|(measurement, &weight)| (measurement.satellite_id, weight))
955                .collect()
956        })
957        .unwrap_or_default()
958}
959
960fn duplicate_satellite(measurements: &[Observation]) -> Option<GnssSatelliteId> {
961    let mut ids: Vec<GnssSatelliteId> = measurements.iter().map(|m| m.satellite_id).collect();
962    ids.sort_unstable();
963    ids.windows(2)
964        .find(|pair| pair[0] == pair[1])
965        .map(|pair| pair[0])
966}
967
968fn build_influence(
969    eph: &dyn EphemerisSource,
970    epochs: &[StaticEpoch],
971    options: StaticSolveOptions,
972    full: &CoreStaticSolution,
973) -> (
974    Vec<StaticEpochInfluence>,
975    Vec<StaticSatelliteInfluence>,
976    Vec<StaticSatelliteBatchInfluence>,
977) {
978    let epoch_influence = (0..epochs.len())
979        .map(|epoch_index| {
980            let mut subset = epochs.to_vec();
981            let omitted_measurements = subset[epoch_index].measurements.len();
982            subset.remove(epoch_index);
983            let result = solve_static_core(eph, &subset, options);
984            let (status, position_delta_m, position_delta_norm_m, residual_rms_m) =
985                influence_result(full.position.as_array(), result);
986            StaticEpochInfluence {
987                epoch_index,
988                omitted_measurements,
989                status,
990                position_delta_m,
991                position_delta_norm_m,
992                residual_rms_m,
993                min_robust_weight_ratio: min_epoch_weight_ratio(full, epoch_index),
994            }
995        })
996        .collect();
997
998    let satellite_ids = full
999        .residuals_m
1000        .iter()
1001        .map(|row| row.satellite_id)
1002        .collect::<BTreeSet<_>>();
1003    let satellite_batch_influence = satellite_ids
1004        .into_iter()
1005        .map(|satellite_id| {
1006            let subset = omit_satellite_all_epochs(epochs, satellite_id);
1007            let result = solve_static_core(eph, &subset, options);
1008            let (status, position_delta_m, position_delta_norm_m, residual_rms_m) =
1009                influence_result(full.position.as_array(), result);
1010            StaticSatelliteBatchInfluence {
1011                satellite_id,
1012                omitted_measurements: full
1013                    .residuals_m
1014                    .iter()
1015                    .filter(|row| row.satellite_id == satellite_id)
1016                    .count(),
1017                status,
1018                position_delta_m,
1019                position_delta_norm_m,
1020                residual_rms_m,
1021                min_robust_weight_ratio: min_satellite_weight_ratio(full, satellite_id),
1022            }
1023        })
1024        .collect();
1025
1026    let satellite_influence = full
1027        .residuals_m
1028        .iter()
1029        .map(|row| {
1030            let subset = omit_satellite(epochs, row.epoch_index, row.satellite_id);
1031            let result = solve_static_core(eph, &subset, options);
1032            let (status, position_delta_m, position_delta_norm_m, residual_rms_m) =
1033                influence_result(full.position.as_array(), result);
1034            StaticSatelliteInfluence {
1035                epoch_index: row.epoch_index,
1036                satellite_id: row.satellite_id,
1037                status,
1038                position_delta_m,
1039                position_delta_norm_m,
1040                residual_rms_m,
1041                residual_m: row.residual_m,
1042                base_weight: row.base_weight,
1043                effective_weight: row.effective_weight,
1044                robust_weight_ratio: row.robust_weight_ratio,
1045            }
1046        })
1047        .collect();
1048
1049    (
1050        epoch_influence,
1051        satellite_influence,
1052        satellite_batch_influence,
1053    )
1054}
1055
1056fn omit_satellite(
1057    epochs: &[StaticEpoch],
1058    epoch_index: usize,
1059    satellite_id: GnssSatelliteId,
1060) -> Vec<StaticEpoch> {
1061    let mut subset = epochs.to_vec();
1062    remove_satellite_from_epoch(&mut subset[epoch_index], satellite_id);
1063    subset
1064}
1065
1066fn omit_satellite_all_epochs(
1067    epochs: &[StaticEpoch],
1068    satellite_id: GnssSatelliteId,
1069) -> Vec<StaticEpoch> {
1070    let mut subset = epochs.to_vec();
1071    for epoch in &mut subset {
1072        remove_satellite_from_epoch(epoch, satellite_id);
1073    }
1074    subset
1075}
1076
1077fn remove_satellite_from_epoch(epoch: &mut StaticEpoch, satellite_id: GnssSatelliteId) {
1078    let old_measurements = std::mem::take(&mut epoch.measurements);
1079    let old_weights = epoch.weights.take();
1080    let mut measurements = Vec::with_capacity(old_measurements.len());
1081    let mut weights = old_weights
1082        .as_ref()
1083        .map(|_| Vec::with_capacity(old_measurements.len()));
1084    for (idx, measurement) in old_measurements.into_iter().enumerate() {
1085        if measurement.satellite_id == satellite_id {
1086            continue;
1087        }
1088        measurements.push(measurement);
1089        if let (Some(old), Some(new_weights)) = (&old_weights, &mut weights) {
1090            new_weights.push(old[idx]);
1091        }
1092    }
1093    epoch.measurements = measurements;
1094    epoch.weights = weights;
1095}
1096
1097fn influence_result(
1098    full_position: [f64; 3],
1099    result: Result<CoreStaticSolution, StaticSolveError>,
1100) -> (
1101    StaticInfluenceStatus,
1102    Option<[f64; 3]>,
1103    Option<f64>,
1104    Option<f64>,
1105) {
1106    match result {
1107        Ok(solution) => {
1108            let position = solution.position.as_array();
1109            let delta = [
1110                position[0] - full_position[0],
1111                position[1] - full_position[1],
1112                position[2] - full_position[2],
1113            ];
1114            let norm = (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt();
1115            let residual_values = solution
1116                .residuals_m
1117                .iter()
1118                .map(|row| row.residual_m)
1119                .collect::<Vec<_>>();
1120            (
1121                StaticInfluenceStatus::Solved,
1122                Some(delta),
1123                Some(norm),
1124                Some(residual_rms(&residual_values)),
1125            )
1126        }
1127        Err(error) => (influence_status(&error), None, None, None),
1128    }
1129}
1130
1131fn influence_status(error: &StaticSolveError) -> StaticInfluenceStatus {
1132    match error {
1133        StaticSolveError::TooFewMeasurements { .. } | StaticSolveError::EmptyEpochs => {
1134            StaticInfluenceStatus::TooFewMeasurements
1135        }
1136        StaticSolveError::Singular(_) => StaticInfluenceStatus::SingularGeometry,
1137        StaticSolveError::InvalidInput { .. }
1138        | StaticSolveError::EpochInput { .. }
1139        | StaticSolveError::DuplicateObservation { .. }
1140        | StaticSolveError::IonosphereUnsupported { .. } => StaticInfluenceStatus::InvalidInput,
1141        StaticSolveError::EphemerisLost { .. } => StaticInfluenceStatus::EphemerisUnavailable,
1142    }
1143}
1144
1145fn min_epoch_weight_ratio(full: &CoreStaticSolution, epoch_index: usize) -> f64 {
1146    full.residuals_m
1147        .iter()
1148        .filter(|row| row.epoch_index == epoch_index)
1149        .map(|row| row.robust_weight_ratio)
1150        .fold(1.0, f64::min)
1151}
1152
1153fn min_satellite_weight_ratio(full: &CoreStaticSolution, satellite_id: GnssSatelliteId) -> f64 {
1154    full.residuals_m
1155        .iter()
1156        .filter(|row| row.satellite_id == satellite_id)
1157        .map(|row| row.robust_weight_ratio)
1158        .fold(1.0, f64::min)
1159}
1160
1161fn matrix_to_rows(matrix: &DMatrix<f64>) -> Vec<Vec<f64>> {
1162    (0..matrix.nrows())
1163        .map(|row| (0..matrix.ncols()).map(|col| matrix[(row, col)]).collect())
1164        .collect()
1165}
1166
1167fn covariance_trace(matrix: &DMatrix<f64>) -> f64 {
1168    (0..matrix.nrows().min(matrix.ncols()))
1169        .map(|idx| matrix[(idx, idx)])
1170        .sum()
1171}
1172
1173fn residual_rms(residuals: &[f64]) -> f64 {
1174    if residuals.is_empty() {
1175        return 0.0;
1176    }
1177    let sum_sq = residuals.iter().map(|r| r * r).sum::<f64>();
1178    (sum_sq / residuals.len() as f64).sqrt()
1179}
1180
1181fn map_static_input_error(error: validate::FieldError) -> StaticSolveError {
1182    StaticSolveError::InvalidInput {
1183        field: error.field(),
1184        kind: SppInputErrorKind::from(&error),
1185    }
1186}
1187
1188fn map_robust_error(error: RobustError) -> StaticSolveError {
1189    let field = match error.field() {
1190        "scale_floor" => "robust.scale_floor_m",
1191        "residuals" | "values" => "robust.residuals",
1192        other => other,
1193    };
1194    let kind = match error.reason() {
1195        "not finite" => SppInputErrorKind::NonFinite,
1196        "not positive" => SppInputErrorKind::NotPositive,
1197        "negative" => SppInputErrorKind::Negative,
1198        "out of range" => SppInputErrorKind::OutOfRange,
1199        _ => SppInputErrorKind::OutOfRange,
1200    };
1201    StaticSolveError::InvalidInput { field, kind }
1202}
1203
1204#[cfg(test)]
1205mod tests;