Skip to main content

sidereon_core/
static_positioning.rs

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