Skip to main content

sidereon_core/precise_positioning/
mod.rs

1//! Static multi-epoch PPP float/fixed positioning.
2//!
3//! This module owns the language-independent static PPP orchestration: float
4//! carrier ambiguities, integer ambiguity resolution, and the fixed-ambiguity
5//! re-solve from SP3-backed ionosphere-free code and phase observations. The
6//! observation-only wide-lane/narrow-lane and cycle-slip preparation that runs
7//! ahead of the solve lives in the [`prep`] leaf submodule.
8//!
9//! ```
10//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
11//! # use std::collections::BTreeMap;
12//! # use sidereon_core::constants::F_L1_HZ;
13//! # use sidereon_core::observables::{
14//! #     predict, ObservableEphemerisSource, ObservableState, ObservablesError, PredictOptions,
15//! # };
16//! # use sidereon_core::ppp_corrections::CivilDateTime;
17//! # use sidereon_core::precise_positioning::{
18//! #     solve_kinematic_ppp, FloatEpoch, FloatObservation, KinematicConfig, KinematicState,
19//! # };
20//! # use sidereon_core::{GnssSatelliteId, GnssSystem};
21//! #
22//! # struct Source {
23//! #     states: BTreeMap<GnssSatelliteId, [f64; 3]>,
24//! # }
25//! #
26//! # impl ObservableEphemerisSource for Source {
27//! #     fn observable_state_at_j2000_s(
28//! #         &self,
29//! #         sat: GnssSatelliteId,
30//! #         _t_j2000_s: f64,
31//! #     ) -> Result<ObservableState, ObservablesError> {
32//! #         Ok(ObservableState {
33//! #             position_ecef_m: *self.states.get(&sat).ok_or(ObservablesError::NoEphemeris)?,
34//! #             clock_s: Some(0.0),
35//! #         })
36//! #     }
37//! # }
38//! #
39//! # fn diagonal_covariance(dimension: usize, variance_m2: f64) -> Vec<Vec<f64>> {
40//! #     let mut covariance = vec![vec![0.0; dimension]; dimension];
41//! #     for (idx, row) in covariance.iter_mut().enumerate() {
42//! #         row[idx] = variance_m2;
43//! #     }
44//! #     covariance
45//! # }
46//! #
47//! # let sats = [
48//! #     (1u8, [20_200_000.0, 13_000_000.0, 21_500_000.0]),
49//! #     (2, [-21_300_000.0, 14_500_000.0, 20_700_000.0]),
50//! #     (3, [15_200_000.0, -22_000_000.0, 19_500_000.0]),
51//! #     (4, [-18_200_000.0, -16_000_000.0, 21_000_000.0]),
52//! #     (5, [22_000_000.0, -12_000_000.0, 20_200_000.0]),
53//! # ];
54//! # let ids = sats
55//! #     .iter()
56//! #     .map(|(prn, _)| GnssSatelliteId::new(GnssSystem::Gps, *prn))
57//! #     .collect::<Result<Vec<_>, _>>()?;
58//! # let source = Source {
59//! #     states: ids
60//! #         .iter()
61//! #         .zip(sats.iter())
62//! #         .map(|(id, (_, position))| (*id, *position))
63//! #         .collect(),
64//! # };
65//! # let truth = [3_512_900.0, 780_500.0, 5_248_700.0];
66//! # let clock_m = 12.5;
67//! # let ambiguities_m = ids
68//! #     .iter()
69//! #     .enumerate()
70//! #     .map(|(idx, id)| (id.to_string(), 0.25 + idx as f64 * 0.1))
71//! #     .collect::<BTreeMap<_, _>>();
72//! # let observations = ids
73//! #     .iter()
74//! #     .map(|id| {
75//! #         let prediction = predict(
76//! #             &source,
77//! #             *id,
78//! #             truth,
79//! #             0.0,
80//! #             PredictOptions {
81//! #                 carrier_hz: F_L1_HZ,
82//! #                 light_time: true,
83//! #                 sagnac: true,
84//! #             },
85//! #         )?;
86//! #         let code_m = prediction.geometric_range_m + clock_m;
87//! #         let ambiguity_m = ambiguities_m.get(&id.to_string()).copied().unwrap();
88//! #         Ok(FloatObservation {
89//! #             sat: *id,
90//! #             satellite_id: id.to_string(),
91//! #             ambiguity_id: id.to_string(),
92//! #             code_m,
93//! #             phase_m: code_m + ambiguity_m,
94//! #             freq1_hz: 0.0,
95//! #             freq2_hz: 0.0,
96//! #             glonass_channel: None,
97//! #         })
98//! #     })
99//! #     .collect::<Result<Vec<_>, ObservablesError>>()?;
100//! # let epoch = FloatEpoch {
101//! #     epoch: CivilDateTime {
102//! #         year: 2020,
103//! #         month: 6,
104//! #         day: 24,
105//! #         hour: 12,
106//! #         minute: 0,
107//! #         second: 0.0,
108//! #     },
109//! #     jd_whole: 2_459_024.5,
110//! #     jd_fraction: 0.5,
111//! #     t_rx_j2000_s: 0.0,
112//! #     observations,
113//! # };
114//! # let initial_state = KinematicState {
115//! #     position_m: [truth[0] + 5.0, truth[1] - 4.0, truth[2] + 3.0],
116//! #     clock_m: 0.0,
117//! #     ztd_residual_m: 0.0,
118//! #     ambiguities_m,
119//! # };
120//! # let config = KinematicConfig {
121//! #     initial_covariance_m2: diagonal_covariance(initial_state.dimension(), 1.0e8),
122//! #     initial_state,
123//! #     ..KinematicConfig::default()
124//! # };
125//! let solutions = solve_kinematic_ppp(&source, &[epoch], config)?;
126//! assert_eq!(solutions.len(), 1);
127//! assert!(solutions[0].innovation_rms_m.is_finite());
128//! # Ok(())
129//! # }
130//! ```
131
132pub mod auto_init;
133pub mod cycle_slip;
134mod fixed;
135mod float;
136mod kinematic;
137mod model;
138mod normal;
139mod prep;
140pub mod raim;
141mod rows;
142pub mod tec;
143mod temporal;
144mod types;
145pub mod velocity;
146
147pub use crate::dop::PositionCovariance;
148pub use auto_init::{
149    solve_ppp_auto_init_fixed, solve_ppp_auto_init_fixed_with_strategy, solve_ppp_auto_init_float,
150    solve_ppp_auto_init_float_with_strategy, PppAutoInitError, PppAutoInitOptions,
151    PppAutoInitStrategy, PppInitialGuess,
152};
153pub use cycle_slip::{
154    detect_cycle_slips, geometry_free_m, melbourne_wubbena_cycles, update_geometry_free,
155    update_melbourne_wubbena, CycleSlipConfig, CycleSlipConfigError, CycleSlipDetectorState,
156    CycleSlipError, CycleSlipFlagEpoch, CycleSlipFlagObservation, CycleSlipStateKey,
157    GeometryFreeUpdate, MelbourneWubbenaUpdate, RunningMeanVariance, SatelliteCycleSlipState,
158    DEFAULT_MINIMUM_ARC_LENGTH, DEFAULT_RUNNING_STATISTIC_K_FACTOR,
159};
160pub(crate) use fixed::run_fixed_from_float;
161pub use fixed::solve_fixed_from_float;
162#[cfg(test)]
163use float::initial_ambiguities;
164pub(crate) use float::run_float_epochs;
165pub use float::{solve_float_epoch, solve_float_epochs};
166pub use kinematic::{
167    correct_kinematic_state, predict_kinematic_state, solve_kinematic_ppp, KinematicConfig,
168    KinematicEpochSolution, KinematicEpochStatus, KinematicMotionModel,
169    KinematicPositionProcessNoise, KinematicProcessNoise, KinematicSolveError, KinematicState,
170    KinematicUpdateSummary,
171};
172pub use prep::{
173    prepare_widelane_fixed_epochs, split_float_cycle_slip_epochs, DualFrequencyEpoch,
174    DualFrequencyObservation, FloatCycleSlipEpoch, FloatCycleSlipObservation,
175    FloatCycleSlipTaggedEpoch, FloatCycleSlipTaggedObservation, PppSplitArc, PreparedFloatEpoch,
176    PreparedFloatObservation, WideLanePrepError, WideLanePrepOptions, WideLanePrepResult,
177};
178pub use raim::{
179    solve_float_epoch_with_raim, ProtectionLevels, RaimConfig, RaimError, RaimFdeError,
180    RaimFdeResult, RaimFdeStatus, RaimGeometryRow, RaimIdentification, RaimResult, RaimStatus,
181    SatelliteTestStatistic,
182};
183pub use tec::{
184    code_geometry_free_m, estimate_code_slant_tec, estimate_phase_slant_tec, estimate_tec,
185    ionospheric_pierce_point, level_slant_tec_arc, phase_geometry_free_m,
186    slant_tec_from_code_geometry_free_m, slant_tec_from_phase_geometry_free_m,
187    thin_shell_mapping_function, vertical_tec_from_slant_tec, CodeSlantTecEstimate,
188    IonosphericPiercePoint, LeveledTecSample, PhaseSlantTecEstimate, TecConfig, TecEpoch, TecError,
189    TecEstimate, TecEstimateSample, TecLevelingResult, TecLevelingSample, TecObservation,
190    TecSatelliteArc, DEFAULT_IONOSPHERIC_SHELL_HEIGHT_M, ELECTRONS_PER_TECU_M2,
191    TEC_GROUP_DELAY_COEFFICIENT,
192};
193pub use types::*;
194pub use velocity::{
195    predict_range_rate_m_s, solve_velocity, RangeRatePrediction, ReceiverVelocityState,
196    VelocityConfig, VelocityObservation, VelocityRobustConfig, VelocitySolution,
197    VelocitySolveError,
198};
199
200pub use crate::ambiguity::CycleSlipPolicy;
201
202/// Canonical static-PPP solver defaults.
203///
204/// Single source of truth for the per-binding static-PPP option literals that
205/// previously drifted across the Elixir, Python, and WASM bindings. A thin
206/// binding builds a [`FloatSolveOptions`] / [`FixedAmbiguityOptions`] from these
207/// constants instead of carrying its own numbers (or uses
208/// [`FloatSolveOptions::default`], which reads them).
209///
210/// The values are the ones the core's PPP tests run with: every state tolerance
211/// is `1.0e-4` m and the iterated float/fixed solve runs `8` iterations.
212/// `RATIO_THRESHOLD` is the LAMBDA acceptance ratio (RTKLIB-demo5 `pos2-arthres`
213/// default `3.0`). Exposing these does not change any solve; the solvers still
214/// read the values from the caller's config.
215pub mod defaults {
216    /// Canonical receiver-position state-step convergence tolerance, metres.
217    ///
218    /// Feeds [`super::FloatSolveOptions::position_tolerance_m`].
219    pub const POSITION_TOLERANCE_M: f64 = 1.0e-4;
220
221    /// Canonical receiver-clock state-step convergence tolerance, metres.
222    ///
223    /// Feeds [`super::FloatSolveOptions::clock_tolerance_m`].
224    pub const CLOCK_TOLERANCE_M: f64 = 1.0e-4;
225
226    /// Canonical ambiguity state-step convergence tolerance, metres.
227    ///
228    /// Feeds [`super::FloatSolveOptions::ambiguity_tolerance_m`].
229    pub const AMBIGUITY_TOLERANCE_M: f64 = 1.0e-4;
230
231    /// Canonical zenith-total-delay state-step convergence tolerance, metres.
232    ///
233    /// Feeds [`super::FloatSolveOptions::ztd_tolerance_m`].
234    pub const ZTD_TOLERANCE_M: f64 = 1.0e-4;
235
236    /// Canonical maximum iterations for the static-PPP float/fixed solve.
237    ///
238    /// Feeds [`super::FloatSolveOptions::max_iterations`]. This is the value the
239    /// core PPP goldens run with and the bindings hardcode.
240    pub const MAX_ITERATIONS: usize = 8;
241
242    /// Canonical LAMBDA acceptance ratio threshold for fixed PPP (dimensionless).
243    ///
244    /// Feeds [`super::FixedAmbiguityOptions::ratio_threshold`]. This is the
245    /// RTKLIB-demo5 `pos2-arthres` default of 3.0, which the bindings hardcode
246    /// and the core fixed-PPP goldens use.
247    pub const RATIO_THRESHOLD: f64 = 3.0;
248}
249
250use std::collections::{BTreeMap, BTreeSet};
251
252use crate::constants::F_L1_HZ;
253use crate::estimation::recipe::NormalRecipe;
254use crate::observables::{predict, ObservableEphemerisSource, ObservablesError, PredictOptions};
255use crate::ppp_corrections::{
256    self, PppCorrectionEpoch, PppCorrectionObservation, PppCorrectionsError, PppCorrectionsOptions,
257};
258use crate::sp3::Sp3;
259use crate::validate::{self, FieldError};
260
261const MAX_PPP_ITERATIONS: usize = 10_000;
262
263/// Build indexed PPP correction lookups at the static-arc seed position.
264pub fn build_ppp_lookup(
265    sp3: &Sp3,
266    epochs: &[FloatEpoch],
267    receiver_ecef_m: [f64; 3],
268    options: &PppCorrectionsOptions,
269) -> Result<PppCorrectionLookup, PppCorrectionsError> {
270    let ppp_epochs: Vec<PppCorrectionEpoch> = epochs
271        .iter()
272        .map(|epoch| PppCorrectionEpoch {
273            epoch: epoch.epoch,
274            t_rx_j2000_s: epoch.t_rx_j2000_s,
275            observations: epoch
276                .observations
277                .iter()
278                .map(|obs| PppCorrectionObservation {
279                    sat: obs.sat,
280                    freq1_hz: obs.freq1_hz,
281                    freq2_hz: obs.freq2_hz,
282                    glonass_channel: obs.glonass_channel,
283                })
284                .collect(),
285        })
286        .collect();
287    let corrections = ppp_corrections::build(sp3, &ppp_epochs, receiver_ecef_m, options)?;
288    Ok(PppCorrectionLookup::from_options(corrections, options))
289}
290
291impl FloatState {
292    fn default_for_epochs(epochs: &[FloatEpoch]) -> Self {
293        Self {
294            position_m: [0.0; 3],
295            clocks_m: vec![0.0; epochs.len()],
296            ambiguities_m: BTreeMap::new(),
297            ztd_m: 0.0,
298            tropo_gradient_north_m: 0.0,
299            tropo_gradient_east_m: 0.0,
300            residual_ionosphere_m: BTreeMap::new(),
301        }
302    }
303}
304
305/// Measurement-model invariants shared across a whole static PPP solve: the
306/// ephemeris source, measurement weighting, troposphere controls, the precomputed
307/// range corrections, and the normal-equation operation-order recipe. Bundled so
308/// the iterated solve, residual, and finalize helpers take one context argument
309/// instead of repeating the same parameters everywhere; carrying `normal` here
310/// lets the resolved recipe reach the solve seam without threading a parameter
311/// through every iterate/screen helper.
312#[derive(Clone, Copy)]
313struct ModelContext<'a> {
314    source: &'a dyn ObservableEphemerisSource,
315    weights: MeasurementWeights,
316    tropo: TroposphereOptions,
317    corrections: &'a RangeCorrections,
318    normal: NormalRecipe,
319    estimate_residual_ionosphere: bool,
320}
321
322fn predict_default(
323    _source: &dyn ObservableEphemerisSource,
324    _obs: &FloatObservation,
325) -> Result<PredictOptions, FloatSolveError> {
326    Ok(PredictOptions {
327        carrier_hz: F_L1_HZ,
328        light_time: true,
329        sagnac: true,
330    })
331}
332
333fn no_ephemeris(obs: &FloatObservation, error: ObservablesError) -> FloatSolveError {
334    FloatSolveError::NoEphemeris {
335        satellite_id: obs.satellite_id.clone(),
336        reason: match error {
337            ObservablesError::NoEphemeris => NoEphemerisReason::NoEphemeris,
338            ObservablesError::InvalidInput { .. } => NoEphemerisReason::Reason(error.to_string()),
339            ObservablesError::Ephemeris(err) => NoEphemerisReason::Reason(err.to_string()),
340            ObservablesError::Media(err) => NoEphemerisReason::Reason(err.to_string()),
341        },
342    }
343}
344
345fn missing_satellite_clock(obs: &FloatObservation) -> FloatSolveError {
346    FloatSolveError::NoEphemeris {
347        satellite_id: obs.satellite_id.clone(),
348        reason: NoEphemerisReason::MissingSatelliteClock,
349    }
350}
351
352fn missing_correction(obs: &FloatObservation, correction: MissingCorrection) -> FloatSolveError {
353    FloatSolveError::MissingCorrection {
354        satellite_id: obs.satellite_id.clone(),
355        correction,
356    }
357}
358
359fn invalid_clock_count(expected: usize, actual: usize) -> FloatSolveError {
360    FloatSolveError::InvalidClockCount { expected, actual }
361}
362
363fn invalid_solve_option(field: &'static str, reason: &'static str) -> FloatSolveError {
364    FloatSolveError::InvalidSolveOption { field, reason }
365}
366
367fn insufficient_after_elevation_cutoff(
368    cutoff_deg: f64,
369    retained_observations: usize,
370    required_observations: usize,
371) -> FloatSolveError {
372    FloatSolveError::InsufficientObservationsAfterElevationCutoff {
373        cutoff_deg,
374        retained_observations,
375        required_observations,
376    }
377}
378
379pub(super) fn invalid_input(error: FieldError) -> FloatSolveError {
380    invalid_input_field(error.field(), error.reason())
381}
382
383fn invalid_input_field(field: &'static str, reason: &'static str) -> FloatSolveError {
384    FloatSolveError::InvalidInput { field, reason }
385}
386
387fn invalid_fixed_input(error: FieldError) -> FixedSolveError {
388    FixedSolveError::Float(invalid_input(error))
389}
390
391pub(super) fn validate_float_solve_boundary(
392    epochs: &[FloatEpoch],
393    state: &FloatState,
394    config: &FloatSolveConfig,
395) -> Result<(), FloatSolveError> {
396    validate_epochs(epochs)?;
397    validate_float_state(state, epochs.len())?;
398    validate_float_config(config)
399}
400
401pub(super) fn validate_fixed_solve_boundary(
402    epochs: &[FloatEpoch],
403    solution: &FloatSolution,
404    config: &FixedSolveConfig,
405) -> Result<(), FixedSolveError> {
406    validate_epochs(epochs).map_err(FixedSolveError::Float)?;
407    validate_float_solution(solution, epochs.len())?;
408    validate_fixed_config(config)
409}
410
411fn validate_epochs(epochs: &[FloatEpoch]) -> Result<(), FloatSolveError> {
412    for epoch in epochs {
413        validate_epoch(epoch)?;
414    }
415    Ok(())
416}
417
418fn validate_epoch(epoch: &FloatEpoch) -> Result<(), FloatSolveError> {
419    validate::civil_datetime_with_second_policy(
420        epoch.epoch.year as i64,
421        epoch.epoch.month as i64,
422        epoch.epoch.day as i64,
423        epoch.epoch.hour as i64,
424        epoch.epoch.minute as i64,
425        epoch.epoch.second,
426        validate::CivilSecondPolicy::Continuous,
427    )
428    .map_err(invalid_input)?;
429    validate::finite(epoch.jd_whole, "ppp epoch jd_whole").map_err(invalid_input)?;
430    validate::finite(epoch.jd_fraction, "ppp epoch jd_fraction").map_err(invalid_input)?;
431    validate::finite(epoch.t_rx_j2000_s, "ppp epoch t_rx_j2000_s").map_err(invalid_input)?;
432    for obs in &epoch.observations {
433        validate_observation(obs)?;
434    }
435    Ok(())
436}
437
438fn validate_observation(obs: &FloatObservation) -> Result<(), FloatSolveError> {
439    validate::finite(obs.code_m, "ppp observation code_m").map_err(invalid_input)?;
440    validate::finite(obs.phase_m, "ppp observation phase_m").map_err(invalid_input)?;
441    validate::finite(obs.freq1_hz, "ppp observation freq1_hz").map_err(invalid_input)?;
442    validate::finite(obs.freq2_hz, "ppp observation freq2_hz").map_err(invalid_input)?;
443    Ok(())
444}
445
446fn validate_float_state(state: &FloatState, n_epochs: usize) -> Result<(), FloatSolveError> {
447    validate_state_clock_count(state, n_epochs)?;
448    validate::finite_vec3(state.position_m, "ppp state position_m").map_err(invalid_input)?;
449    validate::finite_slice(&state.clocks_m, "ppp state clocks_m").map_err(invalid_input)?;
450    for value in state.ambiguities_m.values() {
451        validate::finite(*value, "ppp state ambiguities_m").map_err(invalid_input)?;
452    }
453    for value in state.residual_ionosphere_m.values() {
454        validate::finite(*value, "ppp state residual_ionosphere_m").map_err(invalid_input)?;
455    }
456    validate::finite(state.ztd_m, "ppp state ztd_m").map_err(invalid_input)?;
457    validate::finite(
458        state.tropo_gradient_north_m,
459        "ppp state tropo_gradient_north_m",
460    )
461    .map_err(invalid_input)?;
462    validate::finite(
463        state.tropo_gradient_east_m,
464        "ppp state tropo_gradient_east_m",
465    )
466    .map_err(invalid_input)?;
467    Ok(())
468}
469
470fn validate_float_solution(
471    solution: &FloatSolution,
472    n_epochs: usize,
473) -> Result<(), FixedSolveError> {
474    validate_solution_clock_count(solution, n_epochs)?;
475    validate::finite_vec3(solution.position_m, "ppp float_solution position_m")
476        .map_err(invalid_fixed_input)?;
477    validate_position_covariance(
478        &solution.position_covariance,
479        "ppp float_solution position_covariance",
480        false,
481    )
482    .map_err(FixedSolveError::Float)?;
483    validate_position_covariance(
484        &solution.formal_position_covariance,
485        "ppp float_solution formal_position_covariance",
486        true,
487    )
488    .map_err(FixedSolveError::Float)?;
489    validate::finite_nonneg(
490        solution.posterior_variance_factor,
491        "ppp float_solution posterior_variance_factor",
492    )
493    .map_err(invalid_fixed_input)?;
494    validate::finite_nonneg(
495        solution.position_covariance_scale_factor,
496        "ppp float_solution position_covariance_scale_factor",
497    )
498    .map_err(invalid_fixed_input)?;
499    validate_position_covariance(
500        &solution.temporal_position_covariance,
501        "ppp float_solution temporal_position_covariance",
502        true,
503    )
504    .map_err(FixedSolveError::Float)?;
505    validate::finite_nonneg(
506        solution.temporal_position_covariance_scale_factor,
507        "ppp float_solution temporal_position_covariance_scale_factor",
508    )
509    .map_err(invalid_fixed_input)?;
510    validate_temporal_correlation(
511        solution.temporal_correlation,
512        "ppp float_solution temporal_correlation",
513    )
514    .map_err(FixedSolveError::Float)?;
515    validate::finite_slice(
516        &solution.epoch_clocks_m,
517        "ppp float_solution epoch_clocks_m",
518    )
519    .map_err(invalid_fixed_input)?;
520    for value in solution.ambiguities_m.values() {
521        validate::finite(*value, "ppp float_solution ambiguities_m")
522            .map_err(invalid_fixed_input)?;
523    }
524    for value in solution.residual_ionosphere_m.values() {
525        validate::finite(*value, "ppp float_solution residual_ionosphere_m")
526            .map_err(invalid_fixed_input)?;
527    }
528    if let Some(ztd_m) = solution.ztd_residual_m {
529        validate::finite(ztd_m, "ppp float_solution ztd_residual_m")
530            .map_err(invalid_fixed_input)?;
531    }
532    validate_optional_tropo_gradient_solution(solution).map_err(FixedSolveError::Float)?;
533    for residual in &solution.residuals_m {
534        validate::finite(residual.code_m, "ppp float_solution residual code_m")
535            .map_err(invalid_fixed_input)?;
536        validate::finite(residual.phase_m, "ppp float_solution residual phase_m")
537            .map_err(invalid_fixed_input)?;
538        validate::finite(
539            residual.code_weight,
540            "ppp float_solution residual code_weight",
541        )
542        .map_err(invalid_fixed_input)?;
543        validate::finite(
544            residual.phase_weight,
545            "ppp float_solution residual phase_weight",
546        )
547        .map_err(invalid_fixed_input)?;
548    }
549    validate::finite_nonneg(solution.code_rms_m, "ppp float_solution code_rms_m")
550        .map_err(invalid_fixed_input)?;
551    validate::finite_nonneg(solution.phase_rms_m, "ppp float_solution phase_rms_m")
552        .map_err(invalid_fixed_input)?;
553    validate::finite_nonneg(solution.weighted_rms_m, "ppp float_solution weighted_rms_m")
554        .map_err(invalid_fixed_input)?;
555    Ok(())
556}
557
558pub(super) fn validate_float_solution_output(
559    solution: &FloatSolution,
560    n_epochs: usize,
561) -> Result<(), FloatSolveError> {
562    validate_float_solution_clock_count(solution, n_epochs)?;
563    validate::finite_vec3(solution.position_m, "ppp float_solution position_m")
564        .map_err(invalid_input)?;
565    validate_position_covariance(
566        &solution.position_covariance,
567        "ppp float_solution position_covariance",
568        false,
569    )?;
570    validate_position_covariance(
571        &solution.formal_position_covariance,
572        "ppp float_solution formal_position_covariance",
573        true,
574    )?;
575    validate::finite_nonneg(
576        solution.posterior_variance_factor,
577        "ppp float_solution posterior_variance_factor",
578    )
579    .map_err(invalid_input)?;
580    validate::finite_nonneg(
581        solution.position_covariance_scale_factor,
582        "ppp float_solution position_covariance_scale_factor",
583    )
584    .map_err(invalid_input)?;
585    validate_position_covariance(
586        &solution.temporal_position_covariance,
587        "ppp float_solution temporal_position_covariance",
588        true,
589    )?;
590    validate::finite_nonneg(
591        solution.temporal_position_covariance_scale_factor,
592        "ppp float_solution temporal_position_covariance_scale_factor",
593    )
594    .map_err(invalid_input)?;
595    validate_temporal_correlation(
596        solution.temporal_correlation,
597        "ppp float_solution temporal_correlation",
598    )?;
599    validate::finite_slice(
600        &solution.epoch_clocks_m,
601        "ppp float_solution epoch_clocks_m",
602    )
603    .map_err(invalid_input)?;
604    for value in solution.ambiguities_m.values() {
605        validate::finite(*value, "ppp float_solution ambiguities_m").map_err(invalid_input)?;
606    }
607    for value in solution.residual_ionosphere_m.values() {
608        validate::finite(*value, "ppp float_solution residual_ionosphere_m")
609            .map_err(invalid_input)?;
610    }
611    if let Some(ztd_m) = solution.ztd_residual_m {
612        validate::finite(ztd_m, "ppp float_solution ztd_residual_m").map_err(invalid_input)?;
613    }
614    validate_optional_tropo_gradient_solution(solution)?;
615    for residual in &solution.residuals_m {
616        validate::finite(residual.code_m, "ppp float_solution residual code_m")
617            .map_err(invalid_input)?;
618        validate::finite(residual.phase_m, "ppp float_solution residual phase_m")
619            .map_err(invalid_input)?;
620        validate::finite(
621            residual.code_weight,
622            "ppp float_solution residual code_weight",
623        )
624        .map_err(invalid_input)?;
625        validate::finite(
626            residual.phase_weight,
627            "ppp float_solution residual phase_weight",
628        )
629        .map_err(invalid_input)?;
630    }
631    validate::finite_nonneg(solution.code_rms_m, "ppp float_solution code_rms_m")
632        .map_err(invalid_input)?;
633    validate::finite_nonneg(solution.phase_rms_m, "ppp float_solution phase_rms_m")
634        .map_err(invalid_input)?;
635    validate::finite_nonneg(solution.weighted_rms_m, "ppp float_solution weighted_rms_m")
636        .map_err(invalid_input)?;
637    Ok(())
638}
639
640fn validate_optional_tropo_gradient_solution(
641    solution: &FloatSolution,
642) -> Result<(), FloatSolveError> {
643    match (
644        solution.tropo_gradient_north_m,
645        solution.tropo_gradient_east_m,
646        solution.tropo_gradient_covariance_m2,
647        solution.formal_tropo_gradient_covariance_m2,
648    ) {
649        (None, None, None, None) => Ok(()),
650        (Some(north), Some(east), Some(covariance), Some(formal_covariance)) => {
651            validate::finite(north, "ppp float_solution tropo_gradient_north_m")
652                .map_err(invalid_input)?;
653            validate::finite(east, "ppp float_solution tropo_gradient_east_m")
654                .map_err(invalid_input)?;
655            validate_2x2_covariance_matrix(
656                covariance,
657                "ppp float_solution tropo_gradient_covariance_m2",
658                false,
659            )?;
660            validate_2x2_covariance_matrix(
661                formal_covariance,
662                "ppp float_solution formal_tropo_gradient_covariance_m2",
663                true,
664            )
665        }
666        _ => Err(FloatSolveError::InvalidInput {
667            field: "ppp float_solution tropo gradients",
668            reason: "must be all present or all absent",
669        }),
670    }
671}
672
673fn validate_2x2_covariance_matrix(
674    matrix: [[f64; 2]; 2],
675    label: &'static str,
676    require_positive_diagonal: bool,
677) -> Result<(), FloatSolveError> {
678    for row in matrix {
679        validate::finite_slice(&row, label).map_err(invalid_input)?;
680    }
681    for (idx, row) in matrix.iter().enumerate() {
682        if require_positive_diagonal {
683            validate::finite_positive(row[idx], label).map_err(invalid_input)?;
684        } else {
685            validate::finite_nonneg(row[idx], label).map_err(invalid_input)?;
686        }
687        for (jdx, other_row) in matrix.iter().enumerate().skip(idx + 1) {
688            let scale = row[jdx].abs().max(other_row[idx].abs()).max(1.0);
689            if (row[jdx] - other_row[idx]).abs() > 1.0e-10 * scale {
690                return Err(FloatSolveError::InvalidInput {
691                    field: label,
692                    reason: "symmetric",
693                });
694            }
695        }
696    }
697    Ok(())
698}
699
700fn validate_position_covariance(
701    covariance: &crate::dop::PositionCovariance,
702    label: &'static str,
703    require_positive_diagonal: bool,
704) -> Result<(), FloatSolveError> {
705    validate_covariance_matrix(
706        covariance.ecef_m2,
707        label,
708        "ecef_m2",
709        require_positive_diagonal,
710    )?;
711    validate_covariance_matrix(
712        covariance.enu_m2,
713        label,
714        "enu_m2",
715        require_positive_diagonal,
716    )
717}
718
719fn validate_covariance_matrix(
720    matrix: [[f64; 3]; 3],
721    label: &'static str,
722    frame: &'static str,
723    require_positive_diagonal: bool,
724) -> Result<(), FloatSolveError> {
725    for row in matrix {
726        validate::finite_slice(&row, label).map_err(invalid_input)?;
727    }
728    for (idx, row) in matrix.iter().enumerate() {
729        if require_positive_diagonal {
730            validate::finite_positive(row[idx], label).map_err(invalid_input)?;
731        } else {
732            validate::finite_nonneg(row[idx], label).map_err(invalid_input)?;
733        }
734        for (jdx, other_row) in matrix.iter().enumerate().skip(idx + 1) {
735            let scale = row[jdx].abs().max(other_row[idx].abs()).max(1.0);
736            if (row[jdx] - other_row[idx]).abs() > 1.0e-10 * scale {
737                return Err(FloatSolveError::InvalidInput {
738                    field: label,
739                    reason: frame,
740                });
741            }
742        }
743    }
744    Ok(())
745}
746
747fn validate_temporal_correlation(
748    temporal: TemporalCorrelationSummary,
749    label: &'static str,
750) -> Result<(), FloatSolveError> {
751    validate::finite_nonneg(temporal.lag1_autocorrelation, label).map_err(invalid_input)?;
752    if temporal.lag1_autocorrelation > 0.99 {
753        return Err(FloatSolveError::InvalidInput {
754            field: label,
755            reason: "lag1_autocorrelation",
756        });
757    }
758    validate::finite_nonneg(temporal.decorrelation_time_epochs, label).map_err(invalid_input)?;
759    if let Some(seconds) = temporal.decorrelation_time_s {
760        validate::finite_nonneg(seconds, label).map_err(invalid_input)?;
761    }
762    validate::finite_nonneg(temporal.effective_sample_count, label).map_err(invalid_input)?;
763    validate::finite_nonneg(temporal.variance_inflation_factor, label).map_err(invalid_input)?;
764    if temporal.variance_inflation_factor < 1.0 {
765        return Err(FloatSolveError::InvalidInput {
766            field: label,
767            reason: "variance_inflation_factor",
768        });
769    }
770    if temporal.effective_sample_count > temporal.nominal_sample_count as f64 + 1.0e-9 {
771        return Err(FloatSolveError::InvalidInput {
772            field: label,
773            reason: "effective_sample_count",
774        });
775    }
776    Ok(())
777}
778
779fn validate_float_config(config: &FloatSolveConfig) -> Result<(), FloatSolveError> {
780    validate_common_config(
781        config.weights,
782        config.tropo,
783        &config.corrections,
784        config.opts,
785        config.elevation_cutoff_deg,
786    )
787}
788
789fn validate_fixed_config(config: &FixedSolveConfig) -> Result<(), FixedSolveError> {
790    validate_common_config(
791        config.weights,
792        config.tropo,
793        &config.corrections,
794        config.opts,
795        config.elevation_cutoff_deg,
796    )
797    .map_err(FixedSolveError::Float)?;
798    validate_fixed_ambiguity_options(&config.ambiguity)
799}
800
801fn validate_common_config(
802    weights: MeasurementWeights,
803    tropo: TroposphereOptions,
804    corrections: &RangeCorrections,
805    opts: FloatSolveOptions,
806    elevation_cutoff_deg: Option<f64>,
807) -> Result<(), FloatSolveError> {
808    validate_measurement_weights(weights)?;
809    validate_troposphere_options(tropo)?;
810    validate_range_corrections(corrections)?;
811    validate_elevation_cutoff(elevation_cutoff_deg)?;
812    validate_float_solve_options(opts)
813}
814
815fn validate_elevation_cutoff(cutoff_deg: Option<f64>) -> Result<(), FloatSolveError> {
816    if let Some(cutoff_deg) = cutoff_deg {
817        validate::finite_in_range(cutoff_deg, -90.0, 90.0, "elevation_cutoff_deg")
818            .map_err(invalid_input)?;
819    }
820    Ok(())
821}
822
823fn validate_measurement_weights(weights: MeasurementWeights) -> Result<(), FloatSolveError> {
824    validate::finite_positive(weights.code, "ppp measurement weight code")
825        .map_err(invalid_input)?;
826    validate::finite_positive(weights.phase, "ppp measurement weight phase")
827        .map_err(invalid_input)?;
828    Ok(())
829}
830
831fn validate_troposphere_options(tropo: TroposphereOptions) -> Result<(), FloatSolveError> {
832    if !tropo.enabled {
833        return Ok(());
834    }
835    validate::finite_positive(tropo.met.pressure_hpa, "ppp tropo pressure_hpa")
836        .map_err(invalid_input)?;
837    validate::finite_positive(tropo.met.temperature_k, "ppp tropo temperature_k")
838        .map_err(invalid_input)?;
839    validate::fraction(tropo.met.relative_humidity, "ppp tropo relative_humidity")
840        .map_err(invalid_input)?;
841    Ok(())
842}
843
844fn validate_range_corrections(corrections: &RangeCorrections) -> Result<(), FloatSolveError> {
845    if let Some(receiver) = &corrections.receiver_antenna {
846        validate::finite_positive(receiver.freq1_hz, "ppp receiver antenna freq1_hz")
847            .map_err(invalid_input)?;
848        validate::finite_positive(receiver.freq2_hz, "ppp receiver antenna freq2_hz")
849            .map_err(invalid_input)?;
850        if receiver.freq1_hz == receiver.freq2_hz {
851            return Err(invalid_input_field(
852                "ppp receiver antenna frequency pair",
853                "must differ",
854            ));
855        }
856        for frequency in &receiver.frequencies {
857            validate_receiver_antenna_frequency(frequency)?;
858        }
859    }
860    if let Some(clock) = &corrections.satellite_clock {
861        for records in clock.series.values() {
862            validate::require_strictly_increasing(
863                records.iter().map(|&(t_gps_s, _)| t_gps_s),
864                "ppp satellite clock epoch_s",
865            )
866            .map_err(invalid_input)?;
867            for &(t_gps_s, bias_s) in records {
868                validate::finite(t_gps_s, "ppp satellite clock epoch_s").map_err(invalid_input)?;
869                validate::finite(bias_s, "ppp satellite clock bias_s").map_err(invalid_input)?;
870            }
871        }
872    }
873    for vector in corrections.ppp.tide.values() {
874        validate::finite_vec3(*vector, "ppp correction tide vector_m").map_err(invalid_input)?;
875    }
876    for vector in corrections.ppp.pole_tide.values() {
877        validate::finite_vec3(*vector, "ppp correction pole_tide vector_m")
878            .map_err(invalid_input)?;
879    }
880    for vector in corrections.ppp.ocean_loading.values() {
881        validate::finite_vec3(*vector, "ppp correction ocean_loading vector_m")
882            .map_err(invalid_input)?;
883    }
884    for value in corrections.ppp.windup_m.values() {
885        validate::finite(*value, "ppp correction windup_m").map_err(invalid_input)?;
886    }
887    for vector in corrections.ppp.sat_pco_ecef.values() {
888        validate::finite_vec3(*vector, "ppp correction sat_pco_ecef").map_err(invalid_input)?;
889    }
890    for value in corrections.ppp.sat_pcv_m.values() {
891        validate::finite(*value, "ppp correction sat_pcv_m").map_err(invalid_input)?;
892    }
893    for value in corrections.ppp.code_bias_m.values() {
894        validate::finite(*value, "ppp correction code_bias_m").map_err(invalid_input)?;
895    }
896    for value in corrections.ppp.ssr_code_bias_m.values() {
897        validate::finite(*value, "ppp correction ssr_code_bias_m").map_err(invalid_input)?;
898    }
899    for value in corrections.ppp.phase_bias_m.values() {
900        validate::finite(*value, "ppp correction phase_bias_m").map_err(invalid_input)?;
901    }
902    Ok(())
903}
904
905fn validate_receiver_antenna_frequency(
906    frequency: &ReceiverAntennaFrequency,
907) -> Result<(), FloatSolveError> {
908    validate::finite_vec3(frequency.pco_m, "ppp receiver antenna pco_m").map_err(invalid_input)?;
909    for sample in &frequency.pcv_samples {
910        validate_pcv_sample(sample)?;
911    }
912    Ok(())
913}
914
915fn validate_pcv_sample(sample: &PcvSample) -> Result<(), FloatSolveError> {
916    if let Some(azimuth_deg) = sample.azimuth_deg {
917        validate::finite(azimuth_deg, "ppp receiver antenna pcv azimuth_deg")
918            .map_err(invalid_input)?;
919    }
920    validate::finite_in_range(
921        sample.zenith_deg,
922        0.0,
923        180.0,
924        "ppp receiver antenna pcv zenith_deg",
925    )
926    .map_err(invalid_input)?;
927    validate::finite(sample.value_m, "ppp receiver antenna pcv value_m").map_err(invalid_input)?;
928    Ok(())
929}
930
931fn validate_fixed_ambiguity_options(
932    ambiguity: &FixedAmbiguityOptions,
933) -> Result<(), FixedSolveError> {
934    validate::finite_nonneg(
935        ambiguity.ratio_threshold,
936        "ppp fixed ambiguity ratio_threshold",
937    )
938    .map_err(invalid_fixed_input)?;
939    for value in ambiguity.wavelengths_m.values() {
940        validate::finite_positive(*value, "ppp fixed ambiguity wavelength_m")
941            .map_err(invalid_fixed_input)?;
942    }
943    for value in ambiguity.offsets_m.values() {
944        validate::finite(*value, "ppp fixed ambiguity offset_m").map_err(invalid_fixed_input)?;
945    }
946    Ok(())
947}
948
949fn validate_float_solve_options(opts: FloatSolveOptions) -> Result<(), FloatSolveError> {
950    if opts.max_iterations == 0 {
951        return Err(invalid_solve_option("max_iterations", "must be positive"));
952    }
953    if opts.max_iterations > MAX_PPP_ITERATIONS {
954        return Err(invalid_solve_option(
955            "max_iterations",
956            "exceeds the PPP iteration cap",
957        ));
958    }
959    validate_tolerance("position_tolerance_m", opts.position_tolerance_m)?;
960    validate_tolerance("clock_tolerance_m", opts.clock_tolerance_m)?;
961    validate_tolerance("ambiguity_tolerance_m", opts.ambiguity_tolerance_m)?;
962    validate_tolerance("ztd_tolerance_m", opts.ztd_tolerance_m)
963}
964
965fn validate_tolerance(field: &'static str, value: f64) -> Result<(), FloatSolveError> {
966    if validate::finite(value, field).is_err() {
967        return Err(invalid_solve_option(field, "must be finite"));
968    }
969    if value <= 0.0 {
970        return Err(invalid_solve_option(field, "must be positive"));
971    }
972    Ok(())
973}
974
975fn validate_state_clock_count(state: &FloatState, n_epochs: usize) -> Result<(), FloatSolveError> {
976    if state.clocks_m.len() == n_epochs {
977        Ok(())
978    } else {
979        Err(invalid_clock_count(n_epochs, state.clocks_m.len()))
980    }
981}
982
983fn validate_solution_clock_count(
984    solution: &FloatSolution,
985    n_epochs: usize,
986) -> Result<(), FixedSolveError> {
987    if solution.epoch_clocks_m.len() == n_epochs {
988        Ok(())
989    } else {
990        Err(FixedSolveError::Float(invalid_clock_count(
991            n_epochs,
992            solution.epoch_clocks_m.len(),
993        )))
994    }
995}
996
997fn validate_float_solution_clock_count(
998    solution: &FloatSolution,
999    n_epochs: usize,
1000) -> Result<(), FloatSolveError> {
1001    if solution.epoch_clocks_m.len() == n_epochs {
1002        Ok(())
1003    } else {
1004        Err(invalid_clock_count(n_epochs, solution.epoch_clocks_m.len()))
1005    }
1006}
1007
1008fn state_from_solution(solution: &FloatSolution, prior: &FloatState) -> FloatState {
1009    FloatState {
1010        position_m: solution.position_m,
1011        clocks_m: solution.epoch_clocks_m.clone(),
1012        ambiguities_m: solution.ambiguities_m.clone(),
1013        ztd_m: solution.ztd_residual_m.unwrap_or(prior.ztd_m),
1014        tropo_gradient_north_m: solution
1015            .tropo_gradient_north_m
1016            .unwrap_or(prior.tropo_gradient_north_m),
1017        tropo_gradient_east_m: solution
1018            .tropo_gradient_east_m
1019            .unwrap_or(prior.tropo_gradient_east_m),
1020        residual_ionosphere_m: if solution.residual_ionosphere_m.is_empty() {
1021            prior.residual_ionosphere_m.clone()
1022        } else {
1023            solution.residual_ionosphere_m.clone()
1024        },
1025    }
1026}
1027
1028fn estimates_ztd(tropo: TroposphereOptions) -> bool {
1029    tropo.enabled && tropo.estimate_ztd
1030}
1031
1032fn estimates_tropo_gradients(tropo: TroposphereOptions) -> bool {
1033    tropo.enabled && tropo.estimate_tropo_gradients
1034}
1035
1036fn ztd_unknown_count(tropo: TroposphereOptions) -> usize {
1037    usize::from(estimates_ztd(tropo))
1038}
1039
1040/// Apply the optional PPP elevation cutoff at the solve boundary: after input
1041/// validation and before active ambiguity ids, residual rows, normal rows, or
1042/// fixed ambiguity-search covariance rows are assembled.
1043fn apply_elevation_cutoff(
1044    source: &dyn ObservableEphemerisSource,
1045    epochs: &[FloatEpoch],
1046    state: &FloatState,
1047    cutoff_deg: f64,
1048    tropo: TroposphereOptions,
1049    estimate_residual_ionosphere: bool,
1050) -> Result<Vec<FloatEpoch>, FloatSolveError> {
1051    let mut retained = Vec::with_capacity(epochs.len());
1052    for (epoch_idx, epoch) in epochs.iter().enumerate() {
1053        let mut observations = Vec::with_capacity(epoch.observations.len());
1054        for obs in &epoch.observations {
1055            let options = predict_default(source, obs)?;
1056            let pred = predict(
1057                source,
1058                obs.sat,
1059                state.position_m,
1060                epoch.t_rx_j2000_s,
1061                options,
1062            )
1063            .map_err(|e| no_ephemeris(obs, e))?;
1064            validate::finite(pred.elevation_deg, "ppp predicted elevation_deg")
1065                .map_err(invalid_input)?;
1066            if pred.elevation_deg >= cutoff_deg {
1067                observations.push(obs.clone());
1068            }
1069        }
1070        let mut epoch = epoch.clone();
1071        epoch.observations = observations;
1072        retained.push(epoch);
1073        debug_assert_eq!(retained.len(), epoch_idx + 1);
1074    }
1075    validate_elevation_cutoff_retained(&retained, cutoff_deg, tropo, estimate_residual_ionosphere)?;
1076    Ok(retained)
1077}
1078
1079fn validate_elevation_cutoff_retained(
1080    epochs: &[FloatEpoch],
1081    cutoff_deg: f64,
1082    tropo: TroposphereOptions,
1083    estimate_residual_ionosphere: bool,
1084) -> Result<(), FloatSolveError> {
1085    let retained_observations = epochs.iter().map(|e| e.observations.len()).sum::<usize>();
1086    let active_sats = epochs
1087        .iter()
1088        .flat_map(|e| e.observations.iter().map(|o| o.ambiguity_id.as_str()))
1089        .collect::<BTreeSet<_>>();
1090    let unknowns = crate::estimation::substrate::parameters::ParameterLayout::ppp(
1091        epochs.len(),
1092        ztd_unknown_count(tropo),
1093        tropo_gradient_unknown_count(tropo),
1094        residual_ionosphere_unknown_count(estimate_residual_ionosphere, active_sats.len()),
1095        active_sats.len(),
1096    )
1097    .dim();
1098    let required_observations = unknowns.div_ceil(2).max(4);
1099    if active_sats.len() < 4 || retained_observations < required_observations {
1100        return Err(insufficient_after_elevation_cutoff(
1101            cutoff_deg,
1102            retained_observations,
1103            required_observations,
1104        ));
1105    }
1106    Ok(())
1107}
1108
1109fn tropo_gradient_unknown_count(tropo: TroposphereOptions) -> usize {
1110    if estimates_tropo_gradients(tropo) {
1111        2
1112    } else {
1113        0
1114    }
1115}
1116
1117fn residual_ionosphere_unknown_count(estimate: bool, ambiguity_count: usize) -> usize {
1118    if estimate {
1119        ambiguity_count
1120    } else {
1121        0
1122    }
1123}
1124
1125fn rms(values: &[f64]) -> f64 {
1126    if values.is_empty() {
1127        return 0.0;
1128    }
1129    (values.iter().map(|v| v * v).sum::<f64>() / values.len() as f64).sqrt()
1130}
1131
1132fn weighted_rms(rows: &[FloatResidual], weights: MeasurementWeights) -> f64 {
1133    let mut values = Vec::with_capacity(rows.len() * 2);
1134    for row in rows {
1135        values.push(row.code_m * row.code_weight);
1136        values.push(row.phase_m * row.phase_weight);
1137    }
1138    if values.is_empty() {
1139        rms(&[0.0 * weights.code, 0.0 * weights.phase])
1140    } else {
1141        rms(&values)
1142    }
1143}
1144
1145fn max_abs(xs: &[f64]) -> f64 {
1146    xs.iter().map(|x| x.abs()).fold(0.0, f64::max)
1147}
1148
1149#[cfg(test)]
1150mod tests;