Skip to main content

sidereon_core/
static_positioning.rs

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