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 types;
144pub mod velocity;
145
146pub use crate::dop::PositionCovariance;
147pub use auto_init::{
148    solve_ppp_auto_init_fixed, solve_ppp_auto_init_fixed_with_strategy, solve_ppp_auto_init_float,
149    solve_ppp_auto_init_float_with_strategy, PppAutoInitError, PppAutoInitOptions,
150    PppAutoInitStrategy, PppInitialGuess,
151};
152pub use cycle_slip::{
153    detect_cycle_slips, geometry_free_m, melbourne_wubbena_cycles, update_geometry_free,
154    update_melbourne_wubbena, CycleSlipConfig, CycleSlipConfigError, CycleSlipDetectorState,
155    CycleSlipError, CycleSlipFlagEpoch, CycleSlipFlagObservation, CycleSlipStateKey,
156    GeometryFreeUpdate, MelbourneWubbenaUpdate, RunningMeanVariance, SatelliteCycleSlipState,
157    DEFAULT_MINIMUM_ARC_LENGTH, DEFAULT_RUNNING_STATISTIC_K_FACTOR,
158};
159pub(crate) use fixed::run_fixed_from_float;
160pub use fixed::solve_fixed_from_float;
161#[cfg(test)]
162use float::initial_ambiguities;
163pub(crate) use float::run_float_epochs;
164pub use float::{solve_float_epoch, solve_float_epochs};
165pub use kinematic::{
166    correct_kinematic_state, predict_kinematic_state, solve_kinematic_ppp, KinematicConfig,
167    KinematicEpochSolution, KinematicEpochStatus, KinematicMotionModel,
168    KinematicPositionProcessNoise, KinematicProcessNoise, KinematicSolveError, KinematicState,
169    KinematicUpdateSummary,
170};
171pub use prep::{
172    prepare_widelane_fixed_epochs, split_float_cycle_slip_epochs, DualFrequencyEpoch,
173    DualFrequencyObservation, FloatCycleSlipEpoch, FloatCycleSlipObservation,
174    FloatCycleSlipTaggedEpoch, FloatCycleSlipTaggedObservation, PppSplitArc, PreparedFloatEpoch,
175    PreparedFloatObservation, WideLanePrepError, WideLanePrepOptions, WideLanePrepResult,
176};
177pub use raim::{
178    solve_float_epoch_with_raim, ProtectionLevels, RaimConfig, RaimError, RaimFdeError,
179    RaimFdeResult, RaimFdeStatus, RaimGeometryRow, RaimIdentification, RaimResult, RaimStatus,
180    SatelliteTestStatistic,
181};
182pub use tec::{
183    code_geometry_free_m, estimate_code_slant_tec, estimate_phase_slant_tec, estimate_tec,
184    ionospheric_pierce_point, level_slant_tec_arc, phase_geometry_free_m,
185    slant_tec_from_code_geometry_free_m, slant_tec_from_phase_geometry_free_m,
186    thin_shell_mapping_function, vertical_tec_from_slant_tec, CodeSlantTecEstimate,
187    IonosphericPiercePoint, LeveledTecSample, PhaseSlantTecEstimate, TecConfig, TecEpoch, TecError,
188    TecEstimate, TecEstimateSample, TecLevelingResult, TecLevelingSample, TecObservation,
189    TecSatelliteArc, DEFAULT_IONOSPHERIC_SHELL_HEIGHT_M, ELECTRONS_PER_TECU_M2,
190    TEC_GROUP_DELAY_COEFFICIENT,
191};
192pub use types::*;
193pub use velocity::{
194    predict_range_rate_m_s, solve_velocity, RangeRatePrediction, ReceiverVelocityState,
195    VelocityConfig, VelocityObservation, VelocityRobustConfig, VelocitySolution,
196    VelocitySolveError,
197};
198
199pub use crate::ambiguity::CycleSlipPolicy;
200
201/// Canonical static-PPP solver defaults.
202///
203/// Single source of truth for the per-binding static-PPP option literals that
204/// previously drifted across the Elixir, Python, and WASM bindings. A thin
205/// binding builds a [`FloatSolveOptions`] / [`FixedAmbiguityOptions`] from these
206/// constants instead of carrying its own numbers (or uses
207/// [`FloatSolveOptions::default`], which reads them).
208///
209/// The values are the ones the core's PPP tests run with: every state tolerance
210/// is `1.0e-4` m and the iterated float/fixed solve runs `8` iterations.
211/// `RATIO_THRESHOLD` is the LAMBDA acceptance ratio (RTKLIB-demo5 `pos2-arthres`
212/// default `3.0`). Exposing these does not change any solve; the solvers still
213/// read the values from the caller's config.
214pub mod defaults {
215    /// Canonical receiver-position state-step convergence tolerance, metres.
216    ///
217    /// Feeds [`super::FloatSolveOptions::position_tolerance_m`].
218    pub const POSITION_TOLERANCE_M: f64 = 1.0e-4;
219
220    /// Canonical receiver-clock state-step convergence tolerance, metres.
221    ///
222    /// Feeds [`super::FloatSolveOptions::clock_tolerance_m`].
223    pub const CLOCK_TOLERANCE_M: f64 = 1.0e-4;
224
225    /// Canonical ambiguity state-step convergence tolerance, metres.
226    ///
227    /// Feeds [`super::FloatSolveOptions::ambiguity_tolerance_m`].
228    pub const AMBIGUITY_TOLERANCE_M: f64 = 1.0e-4;
229
230    /// Canonical zenith-total-delay state-step convergence tolerance, metres.
231    ///
232    /// Feeds [`super::FloatSolveOptions::ztd_tolerance_m`].
233    pub const ZTD_TOLERANCE_M: f64 = 1.0e-4;
234
235    /// Canonical maximum iterations for the static-PPP float/fixed solve.
236    ///
237    /// Feeds [`super::FloatSolveOptions::max_iterations`]. This is the value the
238    /// core PPP goldens run with and the bindings hardcode.
239    pub const MAX_ITERATIONS: usize = 8;
240
241    /// Canonical LAMBDA acceptance ratio threshold for fixed PPP (dimensionless).
242    ///
243    /// Feeds [`super::FixedAmbiguityOptions::ratio_threshold`]. This is the
244    /// RTKLIB-demo5 `pos2-arthres` default of 3.0, which the bindings hardcode
245    /// and the core fixed-PPP goldens use.
246    pub const RATIO_THRESHOLD: f64 = 3.0;
247}
248
249use std::collections::BTreeMap;
250
251use crate::constants::F_L1_HZ;
252use crate::estimation::recipe::NormalRecipe;
253use crate::observables::{ObservableEphemerisSource, ObservablesError, PredictOptions};
254use crate::ppp_corrections::{
255    self, PppCorrectionEpoch, PppCorrectionObservation, PppCorrectionsError, PppCorrectionsOptions,
256};
257use crate::sp3::Sp3;
258use crate::validate::{self, FieldError};
259
260const MAX_PPP_ITERATIONS: usize = 10_000;
261
262/// Build indexed PPP correction lookups at the static-arc seed position.
263pub fn build_ppp_lookup(
264    sp3: &Sp3,
265    epochs: &[FloatEpoch],
266    receiver_ecef_m: [f64; 3],
267    options: &PppCorrectionsOptions,
268) -> Result<PppCorrectionLookup, PppCorrectionsError> {
269    let ppp_epochs: Vec<PppCorrectionEpoch> = epochs
270        .iter()
271        .map(|epoch| PppCorrectionEpoch {
272            epoch: epoch.epoch,
273            t_rx_j2000_s: epoch.t_rx_j2000_s,
274            observations: epoch
275                .observations
276                .iter()
277                .map(|obs| PppCorrectionObservation {
278                    sat: obs.sat,
279                    freq1_hz: obs.freq1_hz,
280                    freq2_hz: obs.freq2_hz,
281                    glonass_channel: obs.glonass_channel,
282                })
283                .collect(),
284        })
285        .collect();
286    let corrections = ppp_corrections::build(sp3, &ppp_epochs, receiver_ecef_m, options)?;
287    Ok(PppCorrectionLookup::from_options(corrections, options))
288}
289
290impl FloatState {
291    fn default_for_epochs(epochs: &[FloatEpoch]) -> Self {
292        Self {
293            position_m: [0.0; 3],
294            clocks_m: vec![0.0; epochs.len()],
295            ambiguities_m: BTreeMap::new(),
296            ztd_m: 0.0,
297        }
298    }
299}
300
301/// Measurement-model invariants shared across a whole static PPP solve: the
302/// ephemeris source, measurement weighting, troposphere controls, the precomputed
303/// range corrections, and the normal-equation operation-order recipe. Bundled so
304/// the iterated solve, residual, and finalize helpers take one context argument
305/// instead of repeating the same parameters everywhere; carrying `normal` here
306/// lets the resolved recipe reach the solve seam without threading a parameter
307/// through every iterate/screen helper.
308#[derive(Clone, Copy)]
309struct ModelContext<'a> {
310    source: &'a dyn ObservableEphemerisSource,
311    weights: MeasurementWeights,
312    tropo: TroposphereOptions,
313    corrections: &'a RangeCorrections,
314    normal: NormalRecipe,
315}
316
317fn predict_default(
318    _source: &dyn ObservableEphemerisSource,
319    _obs: &FloatObservation,
320) -> Result<PredictOptions, FloatSolveError> {
321    Ok(PredictOptions {
322        carrier_hz: F_L1_HZ,
323        light_time: true,
324        sagnac: true,
325    })
326}
327
328fn no_ephemeris(obs: &FloatObservation, error: ObservablesError) -> FloatSolveError {
329    FloatSolveError::NoEphemeris {
330        satellite_id: obs.satellite_id.clone(),
331        reason: match error {
332            ObservablesError::NoEphemeris => NoEphemerisReason::NoEphemeris,
333            ObservablesError::InvalidInput { .. } => NoEphemerisReason::Reason(error.to_string()),
334            ObservablesError::Ephemeris(err) => NoEphemerisReason::Reason(err.to_string()),
335            ObservablesError::Media(err) => NoEphemerisReason::Reason(err.to_string()),
336        },
337    }
338}
339
340fn missing_satellite_clock(obs: &FloatObservation) -> FloatSolveError {
341    FloatSolveError::NoEphemeris {
342        satellite_id: obs.satellite_id.clone(),
343        reason: NoEphemerisReason::MissingSatelliteClock,
344    }
345}
346
347fn missing_correction(obs: &FloatObservation, correction: MissingCorrection) -> FloatSolveError {
348    FloatSolveError::MissingCorrection {
349        satellite_id: obs.satellite_id.clone(),
350        correction,
351    }
352}
353
354fn invalid_clock_count(expected: usize, actual: usize) -> FloatSolveError {
355    FloatSolveError::InvalidClockCount { expected, actual }
356}
357
358fn invalid_solve_option(field: &'static str, reason: &'static str) -> FloatSolveError {
359    FloatSolveError::InvalidSolveOption { field, reason }
360}
361
362pub(super) fn invalid_input(error: FieldError) -> FloatSolveError {
363    invalid_input_field(error.field(), error.reason())
364}
365
366fn invalid_input_field(field: &'static str, reason: &'static str) -> FloatSolveError {
367    FloatSolveError::InvalidInput { field, reason }
368}
369
370fn invalid_fixed_input(error: FieldError) -> FixedSolveError {
371    FixedSolveError::Float(invalid_input(error))
372}
373
374pub(super) fn validate_float_solve_boundary(
375    epochs: &[FloatEpoch],
376    state: &FloatState,
377    config: &FloatSolveConfig,
378) -> Result<(), FloatSolveError> {
379    validate_epochs(epochs)?;
380    validate_float_state(state, epochs.len())?;
381    validate_float_config(config)
382}
383
384pub(super) fn validate_fixed_solve_boundary(
385    epochs: &[FloatEpoch],
386    solution: &FloatSolution,
387    config: &FixedSolveConfig,
388) -> Result<(), FixedSolveError> {
389    validate_epochs(epochs).map_err(FixedSolveError::Float)?;
390    validate_float_solution(solution, epochs.len())?;
391    validate_fixed_config(config)
392}
393
394fn validate_epochs(epochs: &[FloatEpoch]) -> Result<(), FloatSolveError> {
395    for epoch in epochs {
396        validate_epoch(epoch)?;
397    }
398    Ok(())
399}
400
401fn validate_epoch(epoch: &FloatEpoch) -> Result<(), FloatSolveError> {
402    validate::civil_datetime_with_second_policy(
403        epoch.epoch.year as i64,
404        epoch.epoch.month as i64,
405        epoch.epoch.day as i64,
406        epoch.epoch.hour as i64,
407        epoch.epoch.minute as i64,
408        epoch.epoch.second,
409        validate::CivilSecondPolicy::Continuous,
410    )
411    .map_err(invalid_input)?;
412    validate::finite(epoch.jd_whole, "ppp epoch jd_whole").map_err(invalid_input)?;
413    validate::finite(epoch.jd_fraction, "ppp epoch jd_fraction").map_err(invalid_input)?;
414    validate::finite(epoch.t_rx_j2000_s, "ppp epoch t_rx_j2000_s").map_err(invalid_input)?;
415    for obs in &epoch.observations {
416        validate_observation(obs)?;
417    }
418    Ok(())
419}
420
421fn validate_observation(obs: &FloatObservation) -> Result<(), FloatSolveError> {
422    validate::finite(obs.code_m, "ppp observation code_m").map_err(invalid_input)?;
423    validate::finite(obs.phase_m, "ppp observation phase_m").map_err(invalid_input)?;
424    validate::finite(obs.freq1_hz, "ppp observation freq1_hz").map_err(invalid_input)?;
425    validate::finite(obs.freq2_hz, "ppp observation freq2_hz").map_err(invalid_input)?;
426    Ok(())
427}
428
429fn validate_float_state(state: &FloatState, n_epochs: usize) -> Result<(), FloatSolveError> {
430    validate_state_clock_count(state, n_epochs)?;
431    validate::finite_vec3(state.position_m, "ppp state position_m").map_err(invalid_input)?;
432    validate::finite_slice(&state.clocks_m, "ppp state clocks_m").map_err(invalid_input)?;
433    for value in state.ambiguities_m.values() {
434        validate::finite(*value, "ppp state ambiguities_m").map_err(invalid_input)?;
435    }
436    validate::finite(state.ztd_m, "ppp state ztd_m").map_err(invalid_input)?;
437    Ok(())
438}
439
440fn validate_float_solution(
441    solution: &FloatSolution,
442    n_epochs: usize,
443) -> Result<(), FixedSolveError> {
444    validate_solution_clock_count(solution, n_epochs)?;
445    validate::finite_vec3(solution.position_m, "ppp float_solution position_m")
446        .map_err(invalid_fixed_input)?;
447    validate_position_covariance(
448        &solution.position_covariance,
449        "ppp float_solution position_covariance",
450        false,
451    )
452    .map_err(FixedSolveError::Float)?;
453    validate_position_covariance(
454        &solution.formal_position_covariance,
455        "ppp float_solution formal_position_covariance",
456        true,
457    )
458    .map_err(FixedSolveError::Float)?;
459    validate::finite_nonneg(
460        solution.posterior_variance_factor,
461        "ppp float_solution posterior_variance_factor",
462    )
463    .map_err(invalid_fixed_input)?;
464    validate::finite_nonneg(
465        solution.position_covariance_scale_factor,
466        "ppp float_solution position_covariance_scale_factor",
467    )
468    .map_err(invalid_fixed_input)?;
469    validate::finite_slice(
470        &solution.epoch_clocks_m,
471        "ppp float_solution epoch_clocks_m",
472    )
473    .map_err(invalid_fixed_input)?;
474    for value in solution.ambiguities_m.values() {
475        validate::finite(*value, "ppp float_solution ambiguities_m")
476            .map_err(invalid_fixed_input)?;
477    }
478    if let Some(ztd_m) = solution.ztd_residual_m {
479        validate::finite(ztd_m, "ppp float_solution ztd_residual_m")
480            .map_err(invalid_fixed_input)?;
481    }
482    for residual in &solution.residuals_m {
483        validate::finite(residual.code_m, "ppp float_solution residual code_m")
484            .map_err(invalid_fixed_input)?;
485        validate::finite(residual.phase_m, "ppp float_solution residual phase_m")
486            .map_err(invalid_fixed_input)?;
487        validate::finite(
488            residual.code_weight,
489            "ppp float_solution residual code_weight",
490        )
491        .map_err(invalid_fixed_input)?;
492        validate::finite(
493            residual.phase_weight,
494            "ppp float_solution residual phase_weight",
495        )
496        .map_err(invalid_fixed_input)?;
497    }
498    validate::finite_nonneg(solution.code_rms_m, "ppp float_solution code_rms_m")
499        .map_err(invalid_fixed_input)?;
500    validate::finite_nonneg(solution.phase_rms_m, "ppp float_solution phase_rms_m")
501        .map_err(invalid_fixed_input)?;
502    validate::finite_nonneg(solution.weighted_rms_m, "ppp float_solution weighted_rms_m")
503        .map_err(invalid_fixed_input)?;
504    Ok(())
505}
506
507pub(super) fn validate_float_solution_output(
508    solution: &FloatSolution,
509    n_epochs: usize,
510) -> Result<(), FloatSolveError> {
511    validate_float_solution_clock_count(solution, n_epochs)?;
512    validate::finite_vec3(solution.position_m, "ppp float_solution position_m")
513        .map_err(invalid_input)?;
514    validate_position_covariance(
515        &solution.position_covariance,
516        "ppp float_solution position_covariance",
517        false,
518    )?;
519    validate_position_covariance(
520        &solution.formal_position_covariance,
521        "ppp float_solution formal_position_covariance",
522        true,
523    )?;
524    validate::finite_nonneg(
525        solution.posterior_variance_factor,
526        "ppp float_solution posterior_variance_factor",
527    )
528    .map_err(invalid_input)?;
529    validate::finite_nonneg(
530        solution.position_covariance_scale_factor,
531        "ppp float_solution position_covariance_scale_factor",
532    )
533    .map_err(invalid_input)?;
534    validate::finite_slice(
535        &solution.epoch_clocks_m,
536        "ppp float_solution epoch_clocks_m",
537    )
538    .map_err(invalid_input)?;
539    for value in solution.ambiguities_m.values() {
540        validate::finite(*value, "ppp float_solution ambiguities_m").map_err(invalid_input)?;
541    }
542    if let Some(ztd_m) = solution.ztd_residual_m {
543        validate::finite(ztd_m, "ppp float_solution ztd_residual_m").map_err(invalid_input)?;
544    }
545    for residual in &solution.residuals_m {
546        validate::finite(residual.code_m, "ppp float_solution residual code_m")
547            .map_err(invalid_input)?;
548        validate::finite(residual.phase_m, "ppp float_solution residual phase_m")
549            .map_err(invalid_input)?;
550        validate::finite(
551            residual.code_weight,
552            "ppp float_solution residual code_weight",
553        )
554        .map_err(invalid_input)?;
555        validate::finite(
556            residual.phase_weight,
557            "ppp float_solution residual phase_weight",
558        )
559        .map_err(invalid_input)?;
560    }
561    validate::finite_nonneg(solution.code_rms_m, "ppp float_solution code_rms_m")
562        .map_err(invalid_input)?;
563    validate::finite_nonneg(solution.phase_rms_m, "ppp float_solution phase_rms_m")
564        .map_err(invalid_input)?;
565    validate::finite_nonneg(solution.weighted_rms_m, "ppp float_solution weighted_rms_m")
566        .map_err(invalid_input)?;
567    Ok(())
568}
569
570fn validate_position_covariance(
571    covariance: &crate::dop::PositionCovariance,
572    label: &'static str,
573    require_positive_diagonal: bool,
574) -> Result<(), FloatSolveError> {
575    validate_covariance_matrix(
576        covariance.ecef_m2,
577        label,
578        "ecef_m2",
579        require_positive_diagonal,
580    )?;
581    validate_covariance_matrix(
582        covariance.enu_m2,
583        label,
584        "enu_m2",
585        require_positive_diagonal,
586    )
587}
588
589fn validate_covariance_matrix(
590    matrix: [[f64; 3]; 3],
591    label: &'static str,
592    frame: &'static str,
593    require_positive_diagonal: bool,
594) -> Result<(), FloatSolveError> {
595    for row in matrix {
596        validate::finite_slice(&row, label).map_err(invalid_input)?;
597    }
598    for (idx, row) in matrix.iter().enumerate() {
599        if require_positive_diagonal {
600            validate::finite_positive(row[idx], label).map_err(invalid_input)?;
601        } else {
602            validate::finite_nonneg(row[idx], label).map_err(invalid_input)?;
603        }
604        for (jdx, other_row) in matrix.iter().enumerate().skip(idx + 1) {
605            let scale = row[jdx].abs().max(other_row[idx].abs()).max(1.0);
606            if (row[jdx] - other_row[idx]).abs() > 1.0e-10 * scale {
607                return Err(FloatSolveError::InvalidInput {
608                    field: label,
609                    reason: frame,
610                });
611            }
612        }
613    }
614    Ok(())
615}
616
617fn validate_float_config(config: &FloatSolveConfig) -> Result<(), FloatSolveError> {
618    validate_common_config(
619        config.weights,
620        config.tropo,
621        &config.corrections,
622        config.opts,
623    )
624}
625
626fn validate_fixed_config(config: &FixedSolveConfig) -> Result<(), FixedSolveError> {
627    validate_common_config(
628        config.weights,
629        config.tropo,
630        &config.corrections,
631        config.opts,
632    )
633    .map_err(FixedSolveError::Float)?;
634    validate_fixed_ambiguity_options(&config.ambiguity)
635}
636
637fn validate_common_config(
638    weights: MeasurementWeights,
639    tropo: TroposphereOptions,
640    corrections: &RangeCorrections,
641    opts: FloatSolveOptions,
642) -> Result<(), FloatSolveError> {
643    validate_measurement_weights(weights)?;
644    validate_troposphere_options(tropo)?;
645    validate_range_corrections(corrections)?;
646    validate_float_solve_options(opts)
647}
648
649fn validate_measurement_weights(weights: MeasurementWeights) -> Result<(), FloatSolveError> {
650    validate::finite_positive(weights.code, "ppp measurement weight code")
651        .map_err(invalid_input)?;
652    validate::finite_positive(weights.phase, "ppp measurement weight phase")
653        .map_err(invalid_input)?;
654    Ok(())
655}
656
657fn validate_troposphere_options(tropo: TroposphereOptions) -> Result<(), FloatSolveError> {
658    if !tropo.enabled {
659        return Ok(());
660    }
661    validate::finite_positive(tropo.met.pressure_hpa, "ppp tropo pressure_hpa")
662        .map_err(invalid_input)?;
663    validate::finite_positive(tropo.met.temperature_k, "ppp tropo temperature_k")
664        .map_err(invalid_input)?;
665    validate::fraction(tropo.met.relative_humidity, "ppp tropo relative_humidity")
666        .map_err(invalid_input)?;
667    Ok(())
668}
669
670fn validate_range_corrections(corrections: &RangeCorrections) -> Result<(), FloatSolveError> {
671    if let Some(receiver) = &corrections.receiver_antenna {
672        validate::finite_positive(receiver.freq1_hz, "ppp receiver antenna freq1_hz")
673            .map_err(invalid_input)?;
674        validate::finite_positive(receiver.freq2_hz, "ppp receiver antenna freq2_hz")
675            .map_err(invalid_input)?;
676        if receiver.freq1_hz == receiver.freq2_hz {
677            return Err(invalid_input_field(
678                "ppp receiver antenna frequency pair",
679                "must differ",
680            ));
681        }
682        for frequency in &receiver.frequencies {
683            validate_receiver_antenna_frequency(frequency)?;
684        }
685    }
686    if let Some(clock) = &corrections.satellite_clock {
687        for records in clock.series.values() {
688            validate::require_strictly_increasing(
689                records.iter().map(|&(t_gps_s, _)| t_gps_s),
690                "ppp satellite clock epoch_s",
691            )
692            .map_err(invalid_input)?;
693            for &(t_gps_s, bias_s) in records {
694                validate::finite(t_gps_s, "ppp satellite clock epoch_s").map_err(invalid_input)?;
695                validate::finite(bias_s, "ppp satellite clock bias_s").map_err(invalid_input)?;
696            }
697        }
698    }
699    for vector in corrections.ppp.tide.values() {
700        validate::finite_vec3(*vector, "ppp correction tide vector_m").map_err(invalid_input)?;
701    }
702    for vector in corrections.ppp.pole_tide.values() {
703        validate::finite_vec3(*vector, "ppp correction pole_tide vector_m")
704            .map_err(invalid_input)?;
705    }
706    for vector in corrections.ppp.ocean_loading.values() {
707        validate::finite_vec3(*vector, "ppp correction ocean_loading vector_m")
708            .map_err(invalid_input)?;
709    }
710    for value in corrections.ppp.windup_m.values() {
711        validate::finite(*value, "ppp correction windup_m").map_err(invalid_input)?;
712    }
713    for vector in corrections.ppp.sat_pco_ecef.values() {
714        validate::finite_vec3(*vector, "ppp correction sat_pco_ecef").map_err(invalid_input)?;
715    }
716    for value in corrections.ppp.sat_pcv_m.values() {
717        validate::finite(*value, "ppp correction sat_pcv_m").map_err(invalid_input)?;
718    }
719    for value in corrections.ppp.code_bias_m.values() {
720        validate::finite(*value, "ppp correction code_bias_m").map_err(invalid_input)?;
721    }
722    for value in corrections.ppp.ssr_code_bias_m.values() {
723        validate::finite(*value, "ppp correction ssr_code_bias_m").map_err(invalid_input)?;
724    }
725    for value in corrections.ppp.phase_bias_m.values() {
726        validate::finite(*value, "ppp correction phase_bias_m").map_err(invalid_input)?;
727    }
728    Ok(())
729}
730
731fn validate_receiver_antenna_frequency(
732    frequency: &ReceiverAntennaFrequency,
733) -> Result<(), FloatSolveError> {
734    validate::finite_vec3(frequency.pco_m, "ppp receiver antenna pco_m").map_err(invalid_input)?;
735    for sample in &frequency.pcv_samples {
736        validate_pcv_sample(sample)?;
737    }
738    Ok(())
739}
740
741fn validate_pcv_sample(sample: &PcvSample) -> Result<(), FloatSolveError> {
742    if let Some(azimuth_deg) = sample.azimuth_deg {
743        validate::finite(azimuth_deg, "ppp receiver antenna pcv azimuth_deg")
744            .map_err(invalid_input)?;
745    }
746    validate::finite_in_range(
747        sample.zenith_deg,
748        0.0,
749        180.0,
750        "ppp receiver antenna pcv zenith_deg",
751    )
752    .map_err(invalid_input)?;
753    validate::finite(sample.value_m, "ppp receiver antenna pcv value_m").map_err(invalid_input)?;
754    Ok(())
755}
756
757fn validate_fixed_ambiguity_options(
758    ambiguity: &FixedAmbiguityOptions,
759) -> Result<(), FixedSolveError> {
760    validate::finite_nonneg(
761        ambiguity.ratio_threshold,
762        "ppp fixed ambiguity ratio_threshold",
763    )
764    .map_err(invalid_fixed_input)?;
765    for value in ambiguity.wavelengths_m.values() {
766        validate::finite_positive(*value, "ppp fixed ambiguity wavelength_m")
767            .map_err(invalid_fixed_input)?;
768    }
769    for value in ambiguity.offsets_m.values() {
770        validate::finite(*value, "ppp fixed ambiguity offset_m").map_err(invalid_fixed_input)?;
771    }
772    Ok(())
773}
774
775fn validate_float_solve_options(opts: FloatSolveOptions) -> Result<(), FloatSolveError> {
776    if opts.max_iterations == 0 {
777        return Err(invalid_solve_option("max_iterations", "must be positive"));
778    }
779    if opts.max_iterations > MAX_PPP_ITERATIONS {
780        return Err(invalid_solve_option(
781            "max_iterations",
782            "exceeds the PPP iteration cap",
783        ));
784    }
785    validate_tolerance("position_tolerance_m", opts.position_tolerance_m)?;
786    validate_tolerance("clock_tolerance_m", opts.clock_tolerance_m)?;
787    validate_tolerance("ambiguity_tolerance_m", opts.ambiguity_tolerance_m)?;
788    validate_tolerance("ztd_tolerance_m", opts.ztd_tolerance_m)
789}
790
791fn validate_tolerance(field: &'static str, value: f64) -> Result<(), FloatSolveError> {
792    if validate::finite(value, field).is_err() {
793        return Err(invalid_solve_option(field, "must be finite"));
794    }
795    if value <= 0.0 {
796        return Err(invalid_solve_option(field, "must be positive"));
797    }
798    Ok(())
799}
800
801fn validate_state_clock_count(state: &FloatState, n_epochs: usize) -> Result<(), FloatSolveError> {
802    if state.clocks_m.len() == n_epochs {
803        Ok(())
804    } else {
805        Err(invalid_clock_count(n_epochs, state.clocks_m.len()))
806    }
807}
808
809fn validate_solution_clock_count(
810    solution: &FloatSolution,
811    n_epochs: usize,
812) -> Result<(), FixedSolveError> {
813    if solution.epoch_clocks_m.len() == n_epochs {
814        Ok(())
815    } else {
816        Err(FixedSolveError::Float(invalid_clock_count(
817            n_epochs,
818            solution.epoch_clocks_m.len(),
819        )))
820    }
821}
822
823fn validate_float_solution_clock_count(
824    solution: &FloatSolution,
825    n_epochs: usize,
826) -> Result<(), FloatSolveError> {
827    if solution.epoch_clocks_m.len() == n_epochs {
828        Ok(())
829    } else {
830        Err(invalid_clock_count(n_epochs, solution.epoch_clocks_m.len()))
831    }
832}
833
834fn state_from_solution(solution: &FloatSolution, prior: &FloatState) -> FloatState {
835    FloatState {
836        position_m: solution.position_m,
837        clocks_m: solution.epoch_clocks_m.clone(),
838        ambiguities_m: solution.ambiguities_m.clone(),
839        ztd_m: solution.ztd_residual_m.unwrap_or(prior.ztd_m),
840    }
841}
842
843fn estimates_ztd(tropo: TroposphereOptions) -> bool {
844    tropo.enabled && tropo.estimate_ztd
845}
846
847fn ztd_unknown_count(tropo: TroposphereOptions) -> usize {
848    usize::from(estimates_ztd(tropo))
849}
850
851fn rms(values: &[f64]) -> f64 {
852    if values.is_empty() {
853        return 0.0;
854    }
855    (values.iter().map(|v| v * v).sum::<f64>() / values.len() as f64).sqrt()
856}
857
858fn weighted_rms(rows: &[FloatResidual], weights: MeasurementWeights) -> f64 {
859    let mut values = Vec::with_capacity(rows.len() * 2);
860    for row in rows {
861        values.push(row.code_m * row.code_weight);
862        values.push(row.phase_m * row.phase_weight);
863    }
864    if values.is_empty() {
865        rms(&[0.0 * weights.code, 0.0 * weights.phase])
866    } else {
867        rms(&values)
868    }
869}
870
871fn max_abs(xs: &[f64]) -> f64 {
872    xs.iter().map(|x| x.abs()).fold(0.0, f64::max)
873}
874
875#[cfg(test)]
876mod tests;