Skip to main content

sidereon_core/precise_positioning/
cycle_slip.rs

1//! Dual-frequency cycle-slip detection for PPP preprocessing.
2//!
3//! ```
4//! use sidereon_core::carrier_phase::SlipReason;
5//! use sidereon_core::constants::{C_M_S, F_L1_HZ, F_L2_HZ};
6//! use sidereon_core::precise_positioning::{
7//!     detect_cycle_slips, CycleSlipConfig, DualFrequencyEpoch, DualFrequencyObservation,
8//! };
9//!
10//! fn observation(epoch: usize, wide_lane_cycles: f64) -> DualFrequencyObservation {
11//!     let geometric_m = 23_000_000.0 + epoch as f64 * 100.0;
12//!     let lambda1 = C_M_S / F_L1_HZ;
13//!     let lambda2 = C_M_S / F_L2_HZ;
14//!     let lambda_wl = C_M_S / (F_L1_HZ - F_L2_HZ);
15//!     let l2_m = geometric_m + lambda_wl * wide_lane_cycles;
16//!     let l1_m = l2_m;
17//!     DualFrequencyObservation {
18//!         satellite_id: "G01".to_string(),
19//!         ambiguity_id: "G01".to_string(),
20//!         p1_m: geometric_m,
21//!         p2_m: geometric_m,
22//!         phi1_cyc: l1_m / lambda1,
23//!         phi2_cyc: l2_m / lambda2,
24//!         f1_hz: F_L1_HZ,
25//!         f2_hz: F_L2_HZ,
26//!         lli1: None,
27//!         lli2: None,
28//!     }
29//! }
30//!
31//! let epochs = (0..4)
32//!     .map(|epoch| DualFrequencyEpoch {
33//!         gap_time_s: Some(epoch as f64 * 30.0),
34//!         observations: vec![observation(epoch, if epoch >= 2 { 9.0 } else { 5.0 })],
35//!     })
36//!     .collect::<Vec<_>>();
37//! let config = CycleSlipConfig {
38//!     melbourne_wubbena_threshold_cycles: 2.0,
39//!     geometry_free_threshold_m: 0.05,
40//!     maximum_gap_s: 120.0,
41//!     ..CycleSlipConfig::default()
42//! };
43//!
44//! let flags = detect_cycle_slips(&epochs, config)?;
45//! assert!(!flags[1].observations[0].slip);
46//! assert_eq!(
47//!     flags[2].observations[0].reasons,
48//!     vec![SlipReason::MelbourneWubbena]
49//! );
50//! # Ok::<(), Box<dyn std::error::Error>>(())
51//! ```
52
53use std::collections::BTreeMap;
54
55use crate::carrier_phase::{
56    CarrierPhaseError, SlipReason, DEFAULT_GF_THRESHOLD_M, DEFAULT_MIN_ARC_GAP_S,
57    DEFAULT_MW_THRESHOLD_CYCLES,
58};
59use crate::frequencies::wavelength_for_frequency;
60use crate::tolerances::FREQUENCY_DENOMINATOR_EPS_HZ;
61
62use super::prep::DualFrequencyObservation;
63
64/// Default minimum number of usable samples required for a stable arc.
65pub const DEFAULT_MINIMUM_ARC_LENGTH: usize = 2;
66
67/// Default sigma multiplier applied to running Melbourne-Wubbena statistics.
68pub const DEFAULT_RUNNING_STATISTIC_K_FACTOR: f64 = 4.0;
69
70/// Configuration for dual-frequency PPP cycle-slip detection.
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct CycleSlipConfig {
73    /// Melbourne-Wubbena absolute slip threshold, in wide-lane cycles.
74    pub melbourne_wubbena_threshold_cycles: f64,
75    /// Geometry-free phase step threshold, in meters.
76    pub geometry_free_threshold_m: f64,
77    /// Minimum usable sample count for an initialized ambiguity arc.
78    pub minimum_arc_length: usize,
79    /// Sigma multiplier for running Melbourne-Wubbena outlier detection.
80    pub running_statistic_k_factor: f64,
81    /// Maximum time gap allowed inside one continuous ambiguity arc, in seconds.
82    pub maximum_gap_s: f64,
83}
84
85impl CycleSlipConfig {
86    /// Validate that all configured thresholds and sample counts are usable.
87    pub fn validate(&self) -> Result<(), CycleSlipConfigError> {
88        validate_positive_finite(
89            self.melbourne_wubbena_threshold_cycles,
90            CycleSlipConfigError::InvalidMelbourneWubbenaThreshold,
91        )?;
92        validate_positive_finite(
93            self.geometry_free_threshold_m,
94            CycleSlipConfigError::InvalidGeometryFreeThreshold,
95        )?;
96        validate_positive_finite(
97            self.running_statistic_k_factor,
98            CycleSlipConfigError::InvalidRunningStatisticKFactor,
99        )?;
100        validate_positive_finite(self.maximum_gap_s, CycleSlipConfigError::InvalidMaximumGap)?;
101        if self.minimum_arc_length == 0 {
102            return Err(CycleSlipConfigError::InvalidMinimumArcLength);
103        }
104        Ok(())
105    }
106}
107
108impl Default for CycleSlipConfig {
109    fn default() -> Self {
110        Self {
111            melbourne_wubbena_threshold_cycles: DEFAULT_MW_THRESHOLD_CYCLES,
112            geometry_free_threshold_m: DEFAULT_GF_THRESHOLD_M,
113            minimum_arc_length: DEFAULT_MINIMUM_ARC_LENGTH,
114            running_statistic_k_factor: DEFAULT_RUNNING_STATISTIC_K_FACTOR,
115            maximum_gap_s: DEFAULT_MIN_ARC_GAP_S,
116        }
117    }
118}
119
120/// Validation error for [`CycleSlipConfig`].
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum CycleSlipConfigError {
123    /// The Melbourne-Wubbena threshold was not positive and finite.
124    InvalidMelbourneWubbenaThreshold,
125    /// The geometry-free threshold was not positive and finite.
126    InvalidGeometryFreeThreshold,
127    /// The minimum arc length was zero.
128    InvalidMinimumArcLength,
129    /// The running-statistic sigma multiplier was not positive and finite.
130    InvalidRunningStatisticKFactor,
131    /// The maximum time gap was not positive and finite.
132    InvalidMaximumGap,
133}
134
135impl core::fmt::Display for CycleSlipConfigError {
136    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
137        match self {
138            Self::InvalidMelbourneWubbenaThreshold => {
139                write!(f, "Melbourne-Wubbena threshold must be positive and finite")
140            }
141            Self::InvalidGeometryFreeThreshold => {
142                write!(f, "geometry-free threshold must be positive and finite")
143            }
144            Self::InvalidMinimumArcLength => write!(f, "minimum arc length must be nonzero"),
145            Self::InvalidRunningStatisticKFactor => {
146                write!(f, "running-statistic k-factor must be positive and finite")
147            }
148            Self::InvalidMaximumGap => write!(f, "maximum gap must be positive and finite"),
149        }
150    }
151}
152
153impl std::error::Error for CycleSlipConfigError {}
154
155/// Error produced while evaluating a cycle-slip detector sample.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum CycleSlipError {
158    /// Detector configuration failed validation.
159    InvalidConfig(CycleSlipConfigError),
160    /// One or more observation scalars were not finite.
161    NonFiniteObservation,
162    /// A supplied epoch time was not finite.
163    NonFiniteEpochTime,
164    /// Supplied epoch times were not ordered.
165    EpochsNotOrdered,
166    /// One or both carrier frequencies were not positive and finite.
167    InvalidFrequency,
168    /// The two carrier frequencies were too close for a wide-lane combination.
169    EqualFrequencies,
170}
171
172impl core::fmt::Display for CycleSlipError {
173    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
174        match self {
175            Self::InvalidConfig(err) => write!(f, "invalid cycle-slip config: {err}"),
176            Self::NonFiniteObservation => write!(f, "cycle-slip observation must be finite"),
177            Self::NonFiniteEpochTime => write!(f, "cycle-slip epoch time must be finite"),
178            Self::EpochsNotOrdered => write!(f, "cycle-slip epochs must be time ordered"),
179            Self::InvalidFrequency => write!(f, "carrier frequency must be positive and finite"),
180            Self::EqualFrequencies => write!(f, "carrier frequencies must be distinct"),
181        }
182    }
183}
184
185impl std::error::Error for CycleSlipError {}
186
187impl From<CycleSlipConfigError> for CycleSlipError {
188    fn from(err: CycleSlipConfigError) -> Self {
189        Self::InvalidConfig(err)
190    }
191}
192
193impl From<CarrierPhaseError> for CycleSlipError {
194    fn from(err: CarrierPhaseError) -> Self {
195        match err {
196            CarrierPhaseError::EqualFrequencies => Self::EqualFrequencies,
197            CarrierPhaseError::InvalidFrequency => Self::InvalidFrequency,
198            CarrierPhaseError::InvalidObservation => Self::NonFiniteObservation,
199            CarrierPhaseError::InvalidThreshold => {
200                Self::InvalidConfig(CycleSlipConfigError::InvalidMelbourneWubbenaThreshold)
201            }
202        }
203    }
204}
205
206/// Running mean and variance for a scalar observable.
207#[derive(Debug, Clone, Copy, PartialEq)]
208pub struct RunningMeanVariance {
209    /// Number of samples accumulated into the statistic.
210    pub sample_count: usize,
211    /// Running sample mean.
212    pub mean: f64,
213    /// Running sample variance.
214    pub variance: f64,
215}
216
217impl RunningMeanVariance {
218    /// Construct an empty running statistic.
219    pub const fn new() -> Self {
220        Self {
221            sample_count: 0,
222            mean: 0.0,
223            variance: 0.0,
224        }
225    }
226
227    /// Add one scalar sample using a numerically stable online update.
228    pub fn push(&mut self, value: f64) {
229        let previous_count = self.sample_count;
230        self.sample_count += 1;
231
232        if previous_count == 0 {
233            self.mean = value;
234            self.variance = 0.0;
235            return;
236        }
237
238        let previous_mean = self.mean;
239        let previous_m2 = self.variance * previous_count.saturating_sub(1) as f64;
240        let delta = value - previous_mean;
241        self.mean = previous_mean + delta / self.sample_count as f64;
242        let delta_after_mean_update = value - self.mean;
243        let m2 = previous_m2 + delta * delta_after_mean_update;
244        self.variance = m2 / (self.sample_count - 1) as f64;
245    }
246
247    /// Return the sample standard deviation when at least two samples exist.
248    pub fn standard_deviation(&self) -> Option<f64> {
249        if self.sample_count >= 2 && self.variance.is_finite() && self.variance >= 0.0 {
250            Some(self.variance.sqrt())
251        } else {
252            None
253        }
254    }
255}
256
257impl Default for RunningMeanVariance {
258    fn default() -> Self {
259        Self::new()
260    }
261}
262
263/// Running cycle-slip detector state for one satellite/ambiguity arc.
264#[derive(Debug, Clone, Copy, PartialEq)]
265pub struct SatelliteCycleSlipState {
266    /// Previous usable epoch time, in seconds, when supplied by the caller.
267    pub previous_epoch_time_s: Option<f64>,
268    /// Previous Melbourne-Wubbena combination, in wide-lane cycles.
269    pub previous_melbourne_wubbena_cycles: Option<f64>,
270    /// Running Melbourne-Wubbena mean and variance, in wide-lane cycles.
271    pub melbourne_wubbena: RunningMeanVariance,
272    /// Previous geometry-free phase combination, in meters.
273    pub previous_geometry_free_m: Option<f64>,
274}
275
276impl SatelliteCycleSlipState {
277    /// Construct an empty per-satellite/ambiguity detector state.
278    pub const fn new() -> Self {
279        Self {
280            previous_epoch_time_s: None,
281            previous_melbourne_wubbena_cycles: None,
282            melbourne_wubbena: RunningMeanVariance::new(),
283            previous_geometry_free_m: None,
284        }
285    }
286
287    /// Clear arc-history fields after a gap or reacquisition.
288    pub fn reset_arc(&mut self) {
289        *self = Self::new();
290    }
291}
292
293impl Default for SatelliteCycleSlipState {
294    fn default() -> Self {
295        Self::new()
296    }
297}
298
299/// Detector-state map key `(satellite_id, ambiguity_id)`.
300pub type CycleSlipStateKey = (String, String);
301
302/// Stateful detector storage keyed by PPP satellite and ambiguity identifiers.
303#[derive(Debug, Clone, PartialEq)]
304pub struct CycleSlipDetectorState {
305    /// Running state for each satellite/ambiguity arc currently tracked by the detector.
306    pub satellites: BTreeMap<CycleSlipStateKey, SatelliteCycleSlipState>,
307}
308
309impl CycleSlipDetectorState {
310    /// Construct an empty detector state.
311    pub fn new() -> Self {
312        Self {
313            satellites: BTreeMap::new(),
314        }
315    }
316}
317
318impl Default for CycleSlipDetectorState {
319    fn default() -> Self {
320        Self::new()
321    }
322}
323
324/// Result from updating one satellite's Melbourne-Wubbena detector state.
325#[derive(Debug, Clone, Copy, PartialEq)]
326pub struct MelbourneWubbenaUpdate {
327    /// Melbourne-Wubbena combination, in wide-lane cycles.
328    pub melbourne_wubbena_cycles: f64,
329    /// True when the sample exceeds the configured Melbourne-Wubbena slip gate.
330    pub slip: bool,
331}
332
333/// Result from updating one satellite's geometry-free detector state.
334#[derive(Debug, Clone, Copy, PartialEq)]
335pub struct GeometryFreeUpdate {
336    /// Geometry-free phase combination, in meters.
337    pub geometry_free_m: f64,
338    /// True when the sample exceeds the configured geometry-free slip gate.
339    pub slip: bool,
340    /// True when a long gap reset the arc before this sample was accepted.
341    pub reset: bool,
342}
343
344/// Cycle-slip flag for one satellite observation in an epoch.
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub struct CycleSlipFlagObservation {
347    /// Satellite identifier copied from the input observation.
348    pub satellite_id: String,
349    /// True when any cycle-slip detector flagged this satellite at the epoch.
350    pub slip: bool,
351    /// Slip reasons in deterministic detector order.
352    pub reasons: Vec<SlipReason>,
353}
354
355/// Cycle-slip flags for one input dual-frequency epoch.
356#[derive(Debug, Clone, PartialEq)]
357pub struct CycleSlipFlagEpoch {
358    /// Comparable epoch coordinate copied from the input epoch.
359    pub gap_time_s: Option<f64>,
360    /// Per-satellite slip flags for observations in this epoch.
361    pub observations: Vec<CycleSlipFlagObservation>,
362}
363
364/// Compute the Melbourne-Wubbena combination in wide-lane cycles.
365pub fn melbourne_wubbena_cycles(
366    observation: &DualFrequencyObservation,
367) -> Result<f64, CycleSlipError> {
368    validate_observation_finite(observation)?;
369    let lambda_wl = wide_lane_wavelength_m(observation.f1_hz, observation.f2_hz)?;
370    let narrow_lane_code_m = narrow_lane_code_m(observation)?;
371    let wide_lane_phase_m = lambda_wl * (observation.phi1_cyc - observation.phi2_cyc);
372    Ok((wide_lane_phase_m - narrow_lane_code_m) / lambda_wl)
373}
374
375/// Update one satellite state with a Melbourne-Wubbena sample and classify it.
376pub fn update_melbourne_wubbena(
377    state: &mut SatelliteCycleSlipState,
378    observation: &DualFrequencyObservation,
379    config: CycleSlipConfig,
380) -> Result<MelbourneWubbenaUpdate, CycleSlipError> {
381    config.validate()?;
382    let mw_cycles = melbourne_wubbena_cycles(observation)?;
383    let slip = melbourne_wubbena_slip(state, mw_cycles, config);
384
385    state.previous_melbourne_wubbena_cycles = Some(mw_cycles);
386    state.melbourne_wubbena.push(mw_cycles);
387
388    Ok(MelbourneWubbenaUpdate {
389        melbourne_wubbena_cycles: mw_cycles,
390        slip,
391    })
392}
393
394/// Compute the geometry-free phase combination `L1 - L2`, in meters.
395pub fn geometry_free_m(observation: &DualFrequencyObservation) -> Result<f64, CycleSlipError> {
396    validate_observation_finite(observation)?;
397    let l1_m = phase_meters(observation.phi1_cyc, observation.f1_hz)?;
398    let l2_m = phase_meters(observation.phi2_cyc, observation.f2_hz)?;
399    Ok(l1_m - l2_m)
400}
401
402/// Update one satellite state with a geometry-free sample and classify it.
403pub fn update_geometry_free(
404    state: &mut SatelliteCycleSlipState,
405    observation: &DualFrequencyObservation,
406    epoch_time_s: Option<f64>,
407    config: CycleSlipConfig,
408) -> Result<GeometryFreeUpdate, CycleSlipError> {
409    config.validate()?;
410    validate_epoch_time(epoch_time_s)?;
411    if epoch_time_goes_back(state.previous_epoch_time_s, epoch_time_s) {
412        return Err(CycleSlipError::EpochsNotOrdered);
413    }
414    let gf_m = geometry_free_m(observation)?;
415    let reset = gap_reset(
416        state.previous_epoch_time_s,
417        epoch_time_s,
418        config.maximum_gap_s,
419    );
420    if reset {
421        state.reset_arc();
422    }
423    let slip = !reset
424        && state
425            .previous_geometry_free_m
426            .is_some_and(|prev| (gf_m - prev).abs() > config.geometry_free_threshold_m);
427
428    remember_epoch_time(state, epoch_time_s);
429    state.previous_geometry_free_m = Some(gf_m);
430
431    Ok(GeometryFreeUpdate {
432        geometry_free_m: gf_m,
433        slip,
434        reset,
435    })
436}
437
438/// Detect cycle slips across an ordered sequence of dual-frequency PPP epochs.
439pub fn detect_cycle_slips(
440    epochs: &[super::prep::DualFrequencyEpoch],
441    config: CycleSlipConfig,
442) -> Result<Vec<CycleSlipFlagEpoch>, CycleSlipError> {
443    config.validate()?;
444    validate_epoch_order(epochs)?;
445    let mut state = CycleSlipDetectorState::new();
446    let mut out = Vec::with_capacity(epochs.len());
447
448    for epoch in epochs {
449        let observations = epoch
450            .observations
451            .iter()
452            .map(|observation| {
453                let ambiguity_state = state
454                    .satellites
455                    .entry(cycle_slip_state_key(observation))
456                    .or_default();
457                classify_dual_frequency_observation(
458                    ambiguity_state,
459                    observation,
460                    epoch.gap_time_s,
461                    config,
462                )
463            })
464            .collect::<Result<Vec<_>, _>>()?;
465        out.push(CycleSlipFlagEpoch {
466            gap_time_s: epoch.gap_time_s,
467            observations,
468        });
469    }
470
471    Ok(out)
472}
473
474fn cycle_slip_state_key(observation: &DualFrequencyObservation) -> CycleSlipStateKey {
475    (
476        observation.satellite_id.clone(),
477        observation.ambiguity_id.clone(),
478    )
479}
480
481fn validate_positive_finite(
482    value: f64,
483    error: CycleSlipConfigError,
484) -> Result<(), CycleSlipConfigError> {
485    if value.is_finite() && value > 0.0 {
486        Ok(())
487    } else {
488        Err(error)
489    }
490}
491
492fn validate_observation_finite(
493    observation: &DualFrequencyObservation,
494) -> Result<(), CycleSlipError> {
495    if observation.p1_m.is_finite()
496        && observation.p2_m.is_finite()
497        && observation.phi1_cyc.is_finite()
498        && observation.phi2_cyc.is_finite()
499        && observation.f1_hz.is_finite()
500        && observation.f2_hz.is_finite()
501    {
502        Ok(())
503    } else {
504        Err(CycleSlipError::NonFiniteObservation)
505    }
506}
507
508fn validate_epoch_time(epoch_time_s: Option<f64>) -> Result<(), CycleSlipError> {
509    if epoch_time_s.is_some_and(|time_s| !time_s.is_finite()) {
510        Err(CycleSlipError::NonFiniteEpochTime)
511    } else {
512        Ok(())
513    }
514}
515
516fn validate_epoch_order(epochs: &[super::prep::DualFrequencyEpoch]) -> Result<(), CycleSlipError> {
517    let mut previous_time_s = None;
518    for epoch in epochs {
519        validate_epoch_time(epoch.gap_time_s)?;
520        if let (Some(previous), Some(current)) = (previous_time_s, epoch.gap_time_s) {
521            if current < previous {
522                return Err(CycleSlipError::EpochsNotOrdered);
523            }
524        }
525        if epoch.gap_time_s.is_some() {
526            previous_time_s = epoch.gap_time_s;
527        }
528    }
529    Ok(())
530}
531
532fn phase_meters(phi_cycles: f64, f_hz: f64) -> Result<f64, CycleSlipError> {
533    validate_frequency(f_hz)?;
534    let wavelength_m = wavelength_for_frequency(f_hz).ok_or(CycleSlipError::InvalidFrequency)?;
535    let phase_m = wavelength_m * phi_cycles;
536    if phase_m.is_finite() {
537        Ok(phase_m)
538    } else {
539        Err(CycleSlipError::NonFiniteObservation)
540    }
541}
542
543fn validate_frequency(f_hz: f64) -> Result<(), CycleSlipError> {
544    if f_hz.is_finite() && f_hz > 0.0 {
545        Ok(())
546    } else {
547        Err(CycleSlipError::InvalidFrequency)
548    }
549}
550
551fn wide_lane_wavelength_m(f1_hz: f64, f2_hz: f64) -> Result<f64, CycleSlipError> {
552    validate_frequency(f1_hz)?;
553    validate_frequency(f2_hz)?;
554    let lambda1 = wavelength_for_frequency(f1_hz).ok_or(CycleSlipError::InvalidFrequency)?;
555    let lambda2 = wavelength_for_frequency(f2_hz).ok_or(CycleSlipError::InvalidFrequency)?;
556    let inverse_wide_lane = 1.0 / lambda1 - 1.0 / lambda2;
557    if inverse_wide_lane.abs() * crate::constants::C_M_S < FREQUENCY_DENOMINATOR_EPS_HZ {
558        Err(CycleSlipError::EqualFrequencies)
559    } else {
560        Ok(1.0 / inverse_wide_lane)
561    }
562}
563
564fn narrow_lane_code_m(observation: &DualFrequencyObservation) -> Result<f64, CycleSlipError> {
565    let f1_hz = observation.f1_hz;
566    let f2_hz = observation.f2_hz;
567    validate_frequency(f1_hz)?;
568    validate_frequency(f2_hz)?;
569    if wavelength_for_frequency(f1_hz).is_none() || wavelength_for_frequency(f2_hz).is_none() {
570        return Err(CycleSlipError::InvalidFrequency);
571    }
572    let denominator = f1_hz + f2_hz;
573    if denominator.abs() < FREQUENCY_DENOMINATOR_EPS_HZ {
574        Err(CycleSlipError::EqualFrequencies)
575    } else {
576        Ok((f1_hz * observation.p1_m + f2_hz * observation.p2_m) / denominator)
577    }
578}
579
580fn melbourne_wubbena_slip(
581    state: &SatelliteCycleSlipState,
582    mw_cycles: f64,
583    config: CycleSlipConfig,
584) -> bool {
585    let step_slip = state
586        .previous_melbourne_wubbena_cycles
587        .is_some_and(|prev| (mw_cycles - prev).abs() > config.melbourne_wubbena_threshold_cycles);
588    let sigma_slip = state.melbourne_wubbena.sample_count >= config.minimum_arc_length
589        && state
590            .melbourne_wubbena
591            .standard_deviation()
592            .is_some_and(|sigma| {
593                sigma > 0.0
594                    && (mw_cycles - state.melbourne_wubbena.mean).abs()
595                        > config.running_statistic_k_factor * sigma
596            });
597    step_slip || sigma_slip
598}
599
600fn epoch_time_goes_back(prev_time_s: Option<f64>, time_s: Option<f64>) -> bool {
601    matches!(
602        (prev_time_s, time_s),
603        (Some(previous), Some(current)) if current < previous
604    )
605}
606
607fn gap_reset(prev_time_s: Option<f64>, time_s: Option<f64>, maximum_gap_s: f64) -> bool {
608    match (prev_time_s, time_s) {
609        (Some(prev), Some(current)) => current - prev > maximum_gap_s,
610        _ => false,
611    }
612}
613
614fn remember_epoch_time(state: &mut SatelliteCycleSlipState, epoch_time_s: Option<f64>) {
615    if let Some(time_s) = epoch_time_s {
616        state.previous_epoch_time_s = Some(time_s);
617    }
618}
619
620fn lli_slip(observation: &DualFrequencyObservation) -> bool {
621    lli_bit0_set(observation.lli1) || lli_bit0_set(observation.lli2)
622}
623
624fn lli_bit0_set(lli: Option<i64>) -> bool {
625    lli.is_some_and(|value| (value & 1) == 1)
626}
627
628fn classify_dual_frequency_observation(
629    state: &mut SatelliteCycleSlipState,
630    observation: &DualFrequencyObservation,
631    epoch_time_s: Option<f64>,
632    config: CycleSlipConfig,
633) -> Result<CycleSlipFlagObservation, CycleSlipError> {
634    let reset = gap_reset(
635        state.previous_epoch_time_s,
636        epoch_time_s,
637        config.maximum_gap_s,
638    );
639    if reset {
640        state.reset_arc();
641    }
642
643    let mw_cycles = melbourne_wubbena_cycles(observation)?;
644    let gf_m = geometry_free_m(observation)?;
645    let mut reasons = Vec::new();
646    let lli = lli_slip(observation);
647    if lli {
648        reasons.push(SlipReason::Lli);
649        state.reset_arc();
650    }
651    if !reset && !lli {
652        if state
653            .previous_geometry_free_m
654            .is_some_and(|prev| (gf_m - prev).abs() > config.geometry_free_threshold_m)
655        {
656            reasons.push(SlipReason::GeometryFree);
657        }
658        if melbourne_wubbena_slip(state, mw_cycles, config) {
659            reasons.push(SlipReason::MelbourneWubbena);
660        }
661    }
662
663    remember_epoch_time(state, epoch_time_s);
664    state.previous_geometry_free_m = Some(gf_m);
665    state.previous_melbourne_wubbena_cycles = Some(mw_cycles);
666    state.melbourne_wubbena.push(mw_cycles);
667
668    Ok(CycleSlipFlagObservation {
669        satellite_id: observation.satellite_id.clone(),
670        slip: !reasons.is_empty(),
671        reasons,
672    })
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678    use crate::constants::{C_M_S, F_L1_HZ, F_L2_HZ};
679
680    #[test]
681    fn cycle_slip_types_construct() {
682        let config = CycleSlipConfig {
683            melbourne_wubbena_threshold_cycles: 3.0,
684            geometry_free_threshold_m: 0.04,
685            minimum_arc_length: 3,
686            running_statistic_k_factor: 5.0,
687            maximum_gap_s: 120.0,
688        };
689        let satellite = SatelliteCycleSlipState {
690            previous_epoch_time_s: Some(30.0),
691            previous_melbourne_wubbena_cycles: Some(12.0),
692            melbourne_wubbena: RunningMeanVariance {
693                sample_count: 4,
694                mean: 12.0,
695                variance: 0.01,
696            },
697            previous_geometry_free_m: Some(0.2),
698        };
699        let mut detector = CycleSlipDetectorState::new();
700        detector
701            .satellites
702            .insert(("G01".to_string(), "G01:L1L2".to_string()), satellite);
703
704        assert_eq!(config.minimum_arc_length, 3);
705        assert_eq!(
706            detector
707                .satellites
708                .get(&("G01".to_string(), "G01:L1L2".to_string())),
709            Some(&SatelliteCycleSlipState {
710                previous_epoch_time_s: Some(30.0),
711                previous_melbourne_wubbena_cycles: Some(12.0),
712                melbourne_wubbena: RunningMeanVariance {
713                    sample_count: 4,
714                    mean: 12.0,
715                    variance: 0.01,
716                },
717                previous_geometry_free_m: Some(0.2),
718            })
719        );
720    }
721
722    #[test]
723    fn cycle_slip_default_config_is_well_formed() {
724        let config = CycleSlipConfig::default();
725
726        assert_eq!(
727            config.melbourne_wubbena_threshold_cycles,
728            DEFAULT_MW_THRESHOLD_CYCLES
729        );
730        assert_eq!(config.geometry_free_threshold_m, DEFAULT_GF_THRESHOLD_M);
731        assert_eq!(config.maximum_gap_s, DEFAULT_MIN_ARC_GAP_S);
732        assert!(config.validate().is_ok());
733    }
734
735    #[test]
736    fn cycle_slip_config_rejects_nonsensical_thresholds() {
737        assert_eq!(
738            CycleSlipConfig {
739                melbourne_wubbena_threshold_cycles: 0.0,
740                ..CycleSlipConfig::default()
741            }
742            .validate(),
743            Err(CycleSlipConfigError::InvalidMelbourneWubbenaThreshold)
744        );
745        assert_eq!(
746            CycleSlipConfig {
747                geometry_free_threshold_m: f64::INFINITY,
748                ..CycleSlipConfig::default()
749            }
750            .validate(),
751            Err(CycleSlipConfigError::InvalidGeometryFreeThreshold)
752        );
753        assert_eq!(
754            CycleSlipConfig {
755                minimum_arc_length: 0,
756                ..CycleSlipConfig::default()
757            }
758            .validate(),
759            Err(CycleSlipConfigError::InvalidMinimumArcLength)
760        );
761        assert_eq!(
762            CycleSlipConfig {
763                running_statistic_k_factor: f64::NAN,
764                ..CycleSlipConfig::default()
765            }
766            .validate(),
767            Err(CycleSlipConfigError::InvalidRunningStatisticKFactor)
768        );
769        assert_eq!(
770            CycleSlipConfig {
771                maximum_gap_s: f64::NAN,
772                ..CycleSlipConfig::default()
773            }
774            .validate(),
775            Err(CycleSlipConfigError::InvalidMaximumGap)
776        );
777    }
778
779    #[test]
780    fn cycle_slip_clean_constant_mw_series_produces_no_slip() {
781        let mut state = SatelliteCycleSlipState::new();
782        let config = CycleSlipConfig {
783            melbourne_wubbena_threshold_cycles: 2.0,
784            minimum_arc_length: 2,
785            running_statistic_k_factor: 3.0,
786            ..CycleSlipConfig::default()
787        };
788        let updates = (0..5)
789            .map(|epoch| {
790                update_melbourne_wubbena(&mut state, &mw_observation("G01", epoch, 8.0), config)
791                    .expect("MW update")
792            })
793            .collect::<Vec<_>>();
794
795        assert!(updates.iter().all(|update| !update.slip));
796        assert_eq!(state.melbourne_wubbena.sample_count, 5);
797    }
798
799    #[test]
800    fn cycle_slip_injected_widelane_integer_jump_is_flagged() {
801        let mut state = SatelliteCycleSlipState::new();
802        let config = CycleSlipConfig {
803            melbourne_wubbena_threshold_cycles: 2.0,
804            minimum_arc_length: 2,
805            running_statistic_k_factor: 3.0,
806            ..CycleSlipConfig::default()
807        };
808        let slips = (0..5)
809            .map(|epoch| {
810                let wide_lane_cycles = if epoch >= 3 { 12.0 } else { 8.0 };
811                update_melbourne_wubbena(
812                    &mut state,
813                    &mw_observation("G01", epoch, wide_lane_cycles),
814                    config,
815                )
816                .expect("MW update")
817                .slip
818            })
819            .collect::<Vec<_>>();
820
821        assert_eq!(slips, vec![false, false, false, true, false]);
822    }
823
824    #[test]
825    fn cycle_slip_smooth_geometry_free_variation_does_not_flag() {
826        let mut state = SatelliteCycleSlipState::new();
827        let config = CycleSlipConfig {
828            geometry_free_threshold_m: 0.05,
829            maximum_gap_s: 120.0,
830            ..CycleSlipConfig::default()
831        };
832        let slips = (0..5)
833            .map(|epoch| {
834                update_geometry_free(
835                    &mut state,
836                    &gf_observation("G01", epoch, epoch as f64 * 0.01),
837                    Some(epoch as f64 * 30.0),
838                    config,
839                )
840                .expect("GF update")
841                .slip
842            })
843            .collect::<Vec<_>>();
844
845        assert_eq!(slips, vec![false; 5]);
846    }
847
848    #[test]
849    fn cycle_slip_geometry_free_phase_jump_on_one_frequency_flags() {
850        let mut state = SatelliteCycleSlipState::new();
851        let config = CycleSlipConfig {
852            geometry_free_threshold_m: 0.05,
853            maximum_gap_s: 120.0,
854            ..CycleSlipConfig::default()
855        };
856        let slips = (0..5)
857            .map(|epoch| {
858                let gf_m = if epoch >= 3 {
859                    0.03 + 0.30
860                } else {
861                    epoch as f64 * 0.01
862                };
863                update_geometry_free(
864                    &mut state,
865                    &gf_observation("G01", epoch, gf_m),
866                    Some(epoch as f64 * 30.0),
867                    config,
868                )
869                .expect("GF update")
870                .slip
871            })
872            .collect::<Vec<_>>();
873
874        assert_eq!(slips, vec![false, false, false, true, false]);
875    }
876
877    #[test]
878    fn cycle_slip_long_data_gap_resets_geometry_free_arc() {
879        let mut state = SatelliteCycleSlipState::new();
880        let config = CycleSlipConfig {
881            geometry_free_threshold_m: 0.05,
882            maximum_gap_s: 120.0,
883            ..CycleSlipConfig::default()
884        };
885        let samples = [
886            (0.0, 0.00),
887            (30.0, 0.01),
888            (60.0, 0.02),
889            (600.0, 0.50),
890            (630.0, 0.51),
891        ];
892        let updates = samples
893            .iter()
894            .enumerate()
895            .map(|(idx, (time_s, gf_m))| {
896                update_geometry_free(
897                    &mut state,
898                    &gf_observation("G01", idx, *gf_m),
899                    Some(*time_s),
900                    config,
901                )
902                .expect("GF update")
903            })
904            .collect::<Vec<_>>();
905
906        assert_eq!(
907            updates.iter().map(|update| update.slip).collect::<Vec<_>>(),
908            vec![false; 5]
909        );
910        assert_eq!(
911            updates
912                .iter()
913                .map(|update| update.reset)
914                .collect::<Vec<_>>(),
915            vec![false, false, false, true, false]
916        );
917    }
918
919    #[test]
920    fn cycle_slip_geometry_free_rejects_backward_timestamp_without_resetting_arc() {
921        let mut state = SatelliteCycleSlipState::new();
922        let config = CycleSlipConfig {
923            geometry_free_threshold_m: 0.05,
924            maximum_gap_s: 120.0,
925            ..CycleSlipConfig::default()
926        };
927
928        update_geometry_free(
929            &mut state,
930            &gf_observation("G01", 0, 0.01),
931            Some(30.0),
932            config,
933        )
934        .expect("initial GF update");
935
936        let err = update_geometry_free(
937            &mut state,
938            &gf_observation("G01", 1, 0.50),
939            Some(0.0),
940            config,
941        )
942        .expect_err("backward timestamp");
943
944        assert_eq!(err, CycleSlipError::EpochsNotOrdered);
945        assert_eq!(state.previous_epoch_time_s, Some(30.0));
946        assert_close(state.previous_geometry_free_m.expect("GF state"), 0.01);
947    }
948
949    #[test]
950    fn cycle_slip_geometry_free_forward_gap_still_resets_arc() {
951        let mut state = SatelliteCycleSlipState::new();
952        let config = CycleSlipConfig {
953            geometry_free_threshold_m: 0.05,
954            maximum_gap_s: 120.0,
955            ..CycleSlipConfig::default()
956        };
957
958        update_geometry_free(
959            &mut state,
960            &gf_observation("G01", 0, 0.01),
961            Some(30.0),
962            config,
963        )
964        .expect("initial GF update");
965        let update = update_geometry_free(
966            &mut state,
967            &gf_observation("G01", 1, 0.50),
968            Some(200.0),
969            config,
970        )
971        .expect("forward gap");
972
973        assert!(update.reset);
974        assert!(!update.slip);
975        assert_eq!(state.previous_epoch_time_s, Some(200.0));
976        assert_close(state.previous_geometry_free_m.expect("GF state"), 0.50);
977    }
978
979    #[test]
980    fn cycle_slip_driver_flags_exact_satellite_and_epoch() {
981        let epochs = (0..5)
982            .map(|epoch| super::super::prep::DualFrequencyEpoch {
983                gap_time_s: Some(epoch as f64 * 30.0),
984                observations: vec![
985                    combined_observation("G01", epoch, 5.0, epoch as f64 * 0.01),
986                    combined_observation(
987                        "G02",
988                        epoch,
989                        if epoch >= 3 { 11.0 } else { 7.0 },
990                        epoch as f64 * 0.01,
991                    ),
992                ],
993            })
994            .collect::<Vec<_>>();
995        let config = CycleSlipConfig {
996            melbourne_wubbena_threshold_cycles: 2.0,
997            geometry_free_threshold_m: 0.05,
998            maximum_gap_s: 120.0,
999            ..CycleSlipConfig::default()
1000        };
1001
1002        let flags = detect_cycle_slips(&epochs, config).expect("cycle-slip driver");
1003        let slipped = slipped_epoch_satellites(&flags);
1004
1005        assert_eq!(
1006            slipped,
1007            vec![(3, "G02".to_string(), vec![SlipReason::MelbourneWubbena])]
1008        );
1009    }
1010
1011    #[test]
1012    fn cycle_slip_driver_keeps_same_satellite_ambiguities_independent() {
1013        let epochs = (0..5)
1014            .map(|epoch| super::super::prep::DualFrequencyEpoch {
1015                gap_time_s: Some(epoch as f64 * 30.0),
1016                observations: vec![
1017                    combined_observation_with_ambiguity(
1018                        "G01",
1019                        "G01:L1L2",
1020                        epoch,
1021                        if epoch >= 3 { 11.0 } else { 7.0 },
1022                        epoch as f64 * 0.01,
1023                    ),
1024                    combined_observation_with_ambiguity(
1025                        "G01",
1026                        "G01:L1L5",
1027                        epoch,
1028                        7.0,
1029                        epoch as f64 * 0.01,
1030                    ),
1031                ],
1032            })
1033            .collect::<Vec<_>>();
1034        let config = CycleSlipConfig {
1035            melbourne_wubbena_threshold_cycles: 2.0,
1036            geometry_free_threshold_m: 0.05,
1037            maximum_gap_s: 120.0,
1038            ..CycleSlipConfig::default()
1039        };
1040
1041        let flags = detect_cycle_slips(&epochs, config).expect("cycle-slip driver");
1042        let slipped = slipped_epoch_observations(&flags);
1043
1044        assert_eq!(
1045            slipped,
1046            vec![(3, 0, "G01".to_string(), vec![SlipReason::MelbourneWubbena])]
1047        );
1048        assert_eq!(flags[3].observations[0].satellite_id, "G01");
1049        assert_eq!(flags[3].observations[1].satellite_id, "G01");
1050    }
1051
1052    #[test]
1053    fn cycle_slip_driver_keeps_shared_ambiguity_different_satellites_independent() {
1054        let epochs = (0..=10)
1055            .map(|epoch| {
1056                let mut observations = Vec::new();
1057                if epoch == 0 {
1058                    observations.push(combined_observation_with_ambiguity(
1059                        "G01",
1060                        "shared-ambiguity",
1061                        epoch,
1062                        5.0,
1063                        0.0,
1064                    ));
1065                } else if epoch == 10 {
1066                    observations.push(combined_observation_with_ambiguity(
1067                        "G01",
1068                        "shared-ambiguity",
1069                        epoch,
1070                        12.0,
1071                        0.80,
1072                    ));
1073                }
1074                observations.push(combined_observation_with_ambiguity(
1075                    "G02",
1076                    "shared-ambiguity",
1077                    epoch,
1078                    5.0,
1079                    epoch as f64 * 0.005,
1080                ));
1081                super::super::prep::DualFrequencyEpoch {
1082                    gap_time_s: Some(epoch as f64 * 30.0),
1083                    observations,
1084                }
1085            })
1086            .collect::<Vec<_>>();
1087        let config = CycleSlipConfig {
1088            melbourne_wubbena_threshold_cycles: 2.0,
1089            geometry_free_threshold_m: 0.05,
1090            maximum_gap_s: 120.0,
1091            ..CycleSlipConfig::default()
1092        };
1093
1094        let flags = detect_cycle_slips(&epochs, config).expect("cycle-slip driver");
1095
1096        assert!(slipped_epoch_observations(&flags).is_empty());
1097        assert_eq!(flags[10].observations[0].satellite_id, "G01");
1098        assert_eq!(flags[10].observations[1].satellite_id, "G02");
1099    }
1100
1101    #[test]
1102    fn cycle_slip_driver_preserves_input_observation_order() {
1103        let epochs = vec![super::super::prep::DualFrequencyEpoch {
1104            gap_time_s: Some(0.0),
1105            observations: vec![
1106                combined_observation("G03", 0, 5.0, 0.0),
1107                combined_observation("G01", 0, 5.0, 0.0),
1108                combined_observation("G02", 0, 5.0, 0.0),
1109            ],
1110        }];
1111
1112        let flags = detect_cycle_slips(&epochs, quiet_cycle_slip_config()).expect("cycle slips");
1113        let satellite_ids = flags[0]
1114            .observations
1115            .iter()
1116            .map(|observation| observation.satellite_id.as_str())
1117            .collect::<Vec<_>>();
1118
1119        assert_eq!(satellite_ids, vec!["G03", "G01", "G02"]);
1120    }
1121
1122    #[test]
1123    fn cycle_slip_driver_flags_lli_bit_without_combination_jump() {
1124        let mut epochs = constant_combination_epochs();
1125        epochs[1].observations[0].lli1 = Some(1);
1126
1127        let flags = detect_cycle_slips(&epochs, quiet_cycle_slip_config()).expect("LLI flags");
1128        let slipped = slipped_epoch_satellites(&flags);
1129
1130        assert_eq!(slipped, vec![(1, "G01".to_string(), vec![SlipReason::Lli])]);
1131    }
1132
1133    #[test]
1134    fn cycle_slip_lli_epoch_restarts_arc_at_marked_epoch() {
1135        let mut state = SatelliteCycleSlipState::new();
1136        let config = quiet_cycle_slip_config();
1137
1138        for epoch in 0..50 {
1139            let flag = classify_dual_frequency_observation(
1140                &mut state,
1141                &combined_observation("G01", epoch, 5.0, 0.0),
1142                Some(epoch as f64 * 30.0),
1143                config,
1144            )
1145            .expect("pre-slip epoch");
1146            assert!(!flag.slip);
1147        }
1148
1149        let mut lli_epoch = combined_observation("G01", 50, 20.0, 0.50);
1150        lli_epoch.lli1 = Some(1);
1151        let lli_flag =
1152            classify_dual_frequency_observation(&mut state, &lli_epoch, Some(50.0 * 30.0), config)
1153                .expect("LLI epoch");
1154
1155        assert_eq!(lli_flag.reasons, vec![SlipReason::Lli]);
1156        assert_eq!(state.previous_epoch_time_s, Some(50.0 * 30.0));
1157        assert_close(state.previous_geometry_free_m.expect("GF state"), 0.50);
1158        assert_close(
1159            state.previous_melbourne_wubbena_cycles.expect("MW state"),
1160            20.0,
1161        );
1162        assert_eq!(state.melbourne_wubbena.sample_count, 1);
1163        assert_close(state.melbourne_wubbena.mean, 20.0);
1164        assert_close(state.melbourne_wubbena.variance, 0.0);
1165
1166        let clean_flag = classify_dual_frequency_observation(
1167            &mut state,
1168            &combined_observation("G01", 51, 20.05, 0.51),
1169            Some(51.0 * 30.0),
1170            config,
1171        )
1172        .expect("post-LLI epoch");
1173
1174        assert!(!clean_flag.slip);
1175        assert_eq!(state.melbourne_wubbena.sample_count, 2);
1176    }
1177
1178    #[test]
1179    fn cycle_slip_unmarked_epoch_keeps_existing_arc_history() {
1180        let mut state = SatelliteCycleSlipState::new();
1181        let config = quiet_cycle_slip_config();
1182
1183        for epoch in 0..3 {
1184            let flag = classify_dual_frequency_observation(
1185                &mut state,
1186                &combined_observation("G01", epoch, 5.0 + epoch as f64 * 0.1, 0.0),
1187                Some(epoch as f64 * 30.0),
1188                config,
1189            )
1190            .expect("clean epoch");
1191            assert!(!flag.slip);
1192        }
1193
1194        assert_eq!(state.previous_epoch_time_s, Some(60.0));
1195        assert_eq!(state.melbourne_wubbena.sample_count, 3);
1196        assert_close(state.melbourne_wubbena.mean, 5.1);
1197    }
1198
1199    #[test]
1200    fn cycle_slip_driver_leaves_clean_lli_unflagged() {
1201        let mut epochs = constant_combination_epochs();
1202        epochs[1].observations[0].lli1 = Some(0);
1203
1204        let flags = detect_cycle_slips(&epochs, quiet_cycle_slip_config()).expect("clean LLI");
1205        let slipped = slipped_epoch_satellites(&flags);
1206
1207        assert!(slipped.is_empty());
1208    }
1209
1210    #[test]
1211    fn cycle_slip_driver_initializes_new_and_reacquired_arcs_without_spurious_slips() {
1212        let epochs = vec![
1213            super::super::prep::DualFrequencyEpoch {
1214                gap_time_s: Some(0.0),
1215                observations: vec![combined_observation("G01", 0, 5.0, 0.00)],
1216            },
1217            super::super::prep::DualFrequencyEpoch {
1218                gap_time_s: Some(30.0),
1219                observations: vec![
1220                    combined_observation("G01", 1, 5.0, 0.01),
1221                    combined_observation("G02", 1, 20.0, 0.50),
1222                ],
1223            },
1224            super::super::prep::DualFrequencyEpoch {
1225                gap_time_s: Some(600.0),
1226                observations: vec![combined_observation("G01", 2, 20.0, 0.80)],
1227            },
1228        ];
1229        let config = CycleSlipConfig {
1230            melbourne_wubbena_threshold_cycles: 2.0,
1231            geometry_free_threshold_m: 0.05,
1232            maximum_gap_s: 120.0,
1233            ..CycleSlipConfig::default()
1234        };
1235
1236        let flags = detect_cycle_slips(&epochs, config).expect("cycle-slip driver");
1237
1238        assert!(flags
1239            .iter()
1240            .flat_map(|epoch| &epoch.observations)
1241            .all(|observation| !observation.slip));
1242    }
1243
1244    #[test]
1245    fn cycle_slip_driver_carries_time_across_untimed_samples() {
1246        let config = CycleSlipConfig {
1247            melbourne_wubbena_threshold_cycles: 2.0,
1248            geometry_free_threshold_m: 0.05,
1249            maximum_gap_s: 120.0,
1250            ..CycleSlipConfig::default()
1251        };
1252        let intermittent = vec![
1253            super::super::prep::DualFrequencyEpoch {
1254                gap_time_s: Some(0.0),
1255                observations: vec![combined_observation("G01", 0, 5.0, 0.00)],
1256            },
1257            super::super::prep::DualFrequencyEpoch {
1258                gap_time_s: None,
1259                observations: vec![combined_observation("G01", 1, 5.2, 0.01)],
1260            },
1261            super::super::prep::DualFrequencyEpoch {
1262                gap_time_s: Some(200.0),
1263                observations: vec![combined_observation("G01", 2, 11.0, 0.50)],
1264            },
1265        ];
1266        let timed = vec![
1267            super::super::prep::DualFrequencyEpoch {
1268                gap_time_s: Some(0.0),
1269                observations: vec![combined_observation("G01", 0, 5.0, 0.00)],
1270            },
1271            super::super::prep::DualFrequencyEpoch {
1272                gap_time_s: Some(30.0),
1273                observations: vec![combined_observation("G01", 1, 5.2, 0.01)],
1274            },
1275            super::super::prep::DualFrequencyEpoch {
1276                gap_time_s: Some(60.0),
1277                observations: vec![combined_observation("G01", 2, 11.0, 0.50)],
1278            },
1279        ];
1280
1281        let intermittent_flags =
1282            detect_cycle_slips(&intermittent, config).expect("intermittent timestamps");
1283        let timed_flags = detect_cycle_slips(&timed, config).expect("timed timestamps");
1284
1285        assert!(slipped_epoch_satellites(&intermittent_flags).is_empty());
1286        assert_eq!(
1287            slipped_epoch_satellites(&timed_flags),
1288            vec![(
1289                2,
1290                "G01".to_string(),
1291                vec![SlipReason::GeometryFree, SlipReason::MelbourneWubbena]
1292            )]
1293        );
1294    }
1295
1296    #[test]
1297    fn cycle_slip_driver_rejects_unordered_epoch_times() {
1298        let epochs = vec![
1299            super::super::prep::DualFrequencyEpoch {
1300                gap_time_s: Some(30.0),
1301                observations: Vec::new(),
1302            },
1303            super::super::prep::DualFrequencyEpoch {
1304                gap_time_s: Some(0.0),
1305                observations: Vec::new(),
1306            },
1307        ];
1308
1309        assert_eq!(
1310            detect_cycle_slips(&epochs, CycleSlipConfig::default()),
1311            Err(CycleSlipError::EpochsNotOrdered)
1312        );
1313    }
1314
1315    #[test]
1316    fn cycle_slip_geometry_free_rejects_nonfinite_phase_meter_output() {
1317        let mut observation = gf_observation("G01", 0, 0.25);
1318        observation.f1_hz = f64::MIN_POSITIVE;
1319
1320        assert_eq!(
1321            geometry_free_m(&observation),
1322            Err(CycleSlipError::NonFiniteObservation)
1323        );
1324    }
1325
1326    fn mw_observation(
1327        satellite_id: &str,
1328        epoch_index: usize,
1329        wide_lane_cycles: f64,
1330    ) -> DualFrequencyObservation {
1331        let geometric_m = 23_000_000.0 + epoch_index as f64 * 100.0;
1332        let n2 = 80_000.0;
1333        let n1 = n2 + wide_lane_cycles;
1334        let lambda1 = C_M_S / F_L1_HZ;
1335        let lambda2 = C_M_S / F_L2_HZ;
1336
1337        DualFrequencyObservation {
1338            satellite_id: satellite_id.to_string(),
1339            ambiguity_id: satellite_id.to_string(),
1340            p1_m: geometric_m,
1341            p2_m: geometric_m,
1342            phi1_cyc: (geometric_m + n1 * lambda1) / lambda1,
1343            phi2_cyc: (geometric_m + n2 * lambda2) / lambda2,
1344            f1_hz: F_L1_HZ,
1345            f2_hz: F_L2_HZ,
1346            lli1: None,
1347            lli2: None,
1348        }
1349    }
1350
1351    fn gf_observation(
1352        satellite_id: &str,
1353        epoch_index: usize,
1354        geometry_free_m: f64,
1355    ) -> DualFrequencyObservation {
1356        let geometric_m = 23_000_000.0 + epoch_index as f64 * 100.0;
1357        let lambda1 = C_M_S / F_L1_HZ;
1358        let lambda2 = C_M_S / F_L2_HZ;
1359
1360        DualFrequencyObservation {
1361            satellite_id: satellite_id.to_string(),
1362            ambiguity_id: satellite_id.to_string(),
1363            p1_m: geometric_m,
1364            p2_m: geometric_m,
1365            phi1_cyc: geometric_m / lambda1,
1366            phi2_cyc: (geometric_m - geometry_free_m) / lambda2,
1367            f1_hz: F_L1_HZ,
1368            f2_hz: F_L2_HZ,
1369            lli1: None,
1370            lli2: None,
1371        }
1372    }
1373
1374    fn combined_observation(
1375        satellite_id: &str,
1376        epoch_index: usize,
1377        melbourne_wubbena_cycles: f64,
1378        geometry_free_m: f64,
1379    ) -> DualFrequencyObservation {
1380        let geometric_m = 23_000_000.0 + epoch_index as f64 * 100.0;
1381        let lambda1 = C_M_S / F_L1_HZ;
1382        let lambda2 = C_M_S / F_L2_HZ;
1383        let lambda_wl = C_M_S / (F_L1_HZ - F_L2_HZ);
1384        let l2_m = geometric_m + lambda_wl * (melbourne_wubbena_cycles - geometry_free_m / lambda1);
1385        let l1_m = l2_m + geometry_free_m;
1386
1387        DualFrequencyObservation {
1388            satellite_id: satellite_id.to_string(),
1389            ambiguity_id: satellite_id.to_string(),
1390            p1_m: geometric_m,
1391            p2_m: geometric_m,
1392            phi1_cyc: l1_m / lambda1,
1393            phi2_cyc: l2_m / lambda2,
1394            f1_hz: F_L1_HZ,
1395            f2_hz: F_L2_HZ,
1396            lli1: None,
1397            lli2: None,
1398        }
1399    }
1400
1401    fn combined_observation_with_ambiguity(
1402        satellite_id: &str,
1403        ambiguity_id: &str,
1404        epoch_index: usize,
1405        melbourne_wubbena_cycles: f64,
1406        geometry_free_m: f64,
1407    ) -> DualFrequencyObservation {
1408        let mut observation = combined_observation(
1409            satellite_id,
1410            epoch_index,
1411            melbourne_wubbena_cycles,
1412            geometry_free_m,
1413        );
1414        observation.ambiguity_id = ambiguity_id.to_string();
1415        observation
1416    }
1417
1418    fn constant_combination_epochs() -> Vec<super::super::prep::DualFrequencyEpoch> {
1419        (0..3)
1420            .map(|epoch| super::super::prep::DualFrequencyEpoch {
1421                gap_time_s: Some(epoch as f64 * 30.0),
1422                observations: vec![combined_observation("G01", epoch, 5.0, 0.0)],
1423            })
1424            .collect()
1425    }
1426
1427    fn quiet_cycle_slip_config() -> CycleSlipConfig {
1428        CycleSlipConfig {
1429            melbourne_wubbena_threshold_cycles: 2.0,
1430            geometry_free_threshold_m: 0.05,
1431            maximum_gap_s: 120.0,
1432            ..CycleSlipConfig::default()
1433        }
1434    }
1435
1436    fn slipped_epoch_satellites(
1437        flags: &[CycleSlipFlagEpoch],
1438    ) -> Vec<(usize, String, Vec<SlipReason>)> {
1439        flags
1440            .iter()
1441            .enumerate()
1442            .flat_map(|(epoch_index, epoch)| {
1443                epoch
1444                    .observations
1445                    .iter()
1446                    .filter(|observation| observation.slip)
1447                    .map(move |observation| {
1448                        (
1449                            epoch_index,
1450                            observation.satellite_id.clone(),
1451                            observation.reasons.clone(),
1452                        )
1453                    })
1454            })
1455            .collect()
1456    }
1457
1458    fn slipped_epoch_observations(
1459        flags: &[CycleSlipFlagEpoch],
1460    ) -> Vec<(usize, usize, String, Vec<SlipReason>)> {
1461        flags
1462            .iter()
1463            .enumerate()
1464            .flat_map(|(epoch_index, epoch)| {
1465                epoch
1466                    .observations
1467                    .iter()
1468                    .enumerate()
1469                    .filter(|(_, observation)| observation.slip)
1470                    .map(move |(observation_index, observation)| {
1471                        (
1472                            epoch_index,
1473                            observation_index,
1474                            observation.satellite_id.clone(),
1475                            observation.reasons.clone(),
1476                        )
1477                    })
1478            })
1479            .collect()
1480    }
1481
1482    fn assert_close(actual: f64, expected: f64) {
1483        assert!(
1484            (actual - expected).abs() < 1.0e-6,
1485            "expected {actual} to be within tolerance of {expected}"
1486        );
1487    }
1488}