Skip to main content

sidereon_core/
rtk.rs

1//! RTK double-difference primitives.
2//!
3//! This module owns the language-independent carrier/code double-difference
4//! construction used by Sidereon' public RTK API.
5
6use crate::astro::frames::transforms::itrs_to_geodetic_compute;
7use crate::astro::math::vec3::{dot3, norm3, sub3};
8use crate::astro::time::model::{Instant, JulianDateSplit, TimeScale};
9
10use crate::ambiguity::{self, AmbiguityId, NarrowLaneParams};
11use crate::carrier_phase::{
12    detect_cycle_slips, validate_hatch_window_cap, ArcEpoch, CarrierPhaseError, CycleSlipOptions,
13    SlipReason,
14};
15use crate::combinations::{self, IonosphereFreeError};
16use crate::constants::{DEG_TO_RAD, KM_TO_M, RAD_TO_DEG};
17use crate::tropo::{tropo_slant, Met};
18use crate::validate;
19use crate::Wgs84Geodetic;
20use std::cmp::Ordering;
21use std::collections::{BTreeMap, BTreeSet};
22
23/// One single-frequency code/carrier observation at a receiver.
24#[derive(Debug, Clone, PartialEq)]
25pub struct Observation {
26    pub satellite_id: String,
27    pub ambiguity_id: String,
28    pub code_m: f64,
29    pub phase_m: f64,
30}
31
32/// One single-frequency RTK observation for code-smoothing preprocessing.
33#[derive(Debug, Clone, PartialEq)]
34pub struct CodeSmoothingObservation {
35    pub satellite_id: String,
36    pub ambiguity_id: String,
37    pub code_m: f64,
38    pub phase_m: f64,
39    pub lli: Option<i64>,
40}
41
42/// One RTK epoch for base/rover code-smoothing preprocessing.
43#[derive(Debug, Clone, PartialEq)]
44pub struct CodeSmoothingEpoch {
45    pub base_observations: Vec<CodeSmoothingObservation>,
46    pub rover_observations: Vec<CodeSmoothingObservation>,
47}
48
49/// One single-frequency RTK observation for cycle-slip preprocessing.
50pub type CycleSlipObservation = CodeSmoothingObservation;
51
52/// One single-frequency RTK epoch for cycle-slip preprocessing.
53pub type CycleSlipEpoch = CodeSmoothingEpoch;
54
55/// One receiver's dual-frequency code/carrier observation used by the
56/// wide-lane RTK pre-step.
57#[derive(Debug, Clone, PartialEq)]
58pub struct DualObservation {
59    pub ambiguity_id: String,
60    pub p1_m: f64,
61    pub p2_m: f64,
62    pub phi1_cycles: f64,
63    pub phi2_cycles: f64,
64    pub f1_hz: f64,
65    pub f2_hz: f64,
66}
67
68/// Paired base/rover dual-frequency observation for one satellite.
69#[derive(Debug, Clone, PartialEq)]
70pub struct DualSatelliteObservation {
71    pub satellite_id: String,
72    pub base: DualObservation,
73    pub rover: DualObservation,
74}
75
76/// One dual-frequency RTK epoch, already normalized to satellites usable by the
77/// caller's baseline epoch contract.
78#[derive(Debug, Clone, PartialEq)]
79pub struct DualEpoch {
80    pub observations: Vec<DualSatelliteObservation>,
81}
82
83/// One receiver's dual-frequency observation for cycle-slip preprocessing.
84#[derive(Debug, Clone, PartialEq)]
85pub struct DualCycleSlipObservation {
86    pub satellite_id: String,
87    pub ambiguity_id: String,
88    pub p1_m: f64,
89    pub p2_m: f64,
90    pub phi1_cycles: f64,
91    pub phi2_cycles: f64,
92    pub f1_hz: f64,
93    pub f2_hz: f64,
94    pub lli1: Option<i64>,
95    pub lli2: Option<i64>,
96}
97
98/// One dual-frequency RTK epoch for cycle-slip preprocessing.
99#[derive(Debug, Clone, PartialEq)]
100pub struct DualCycleSlipEpoch {
101    /// Caller-provided deterministic epoch ordering key.
102    pub epoch_sort_key: String,
103    /// Comparable epoch coordinate in seconds, when the caller can supply one.
104    pub gap_time_s: Option<f64>,
105    pub base_observations: Vec<DualCycleSlipObservation>,
106    pub rover_observations: Vec<DualCycleSlipObservation>,
107}
108
109/// One receiver's dual-frequency observation plus a precomputed non-dispersive
110/// range correction removed from the ionosphere-free observable.
111#[derive(Debug, Clone, PartialEq)]
112pub struct DualIonosphereFreeObservation {
113    pub ambiguity_id: String,
114    pub p1_m: f64,
115    pub p2_m: f64,
116    pub phi1_cycles: f64,
117    pub phi2_cycles: f64,
118    pub f1_hz: f64,
119    pub f2_hz: f64,
120    pub tropo_m: f64,
121}
122
123/// Paired base/rover dual-frequency observation for IF/narrow-lane conversion.
124#[derive(Debug, Clone, PartialEq)]
125pub struct DualIonosphereFreeSatelliteObservation {
126    pub satellite_id: String,
127    pub base: DualIonosphereFreeObservation,
128    pub rover: DualIonosphereFreeObservation,
129}
130
131/// One dual-frequency RTK epoch for IF/narrow-lane conversion.
132#[derive(Debug, Clone, PartialEq)]
133pub struct DualIonosphereFreeEpoch {
134    pub observations: Vec<DualIonosphereFreeSatelliteObservation>,
135}
136
137/// One normalized dual-frequency RTK epoch before IF/narrow-lane conversion.
138///
139/// The core owns the optional troposphere setup, so this shape carries the
140/// satellite positions and split Julian epoch needed to form per-receiver
141/// slant delays before the IF conversion is applied.
142#[derive(Debug, Clone, PartialEq)]
143pub struct DualIonosphereFreeSetupEpoch {
144    pub jd_whole: f64,
145    pub jd_fraction: f64,
146    pub observations: Vec<DualSatelliteObservation>,
147    pub base_satellite_positions_m: BTreeMap<String, [f64; 3]>,
148    pub rover_satellite_positions_m: BTreeMap<String, [f64; 3]>,
149}
150
151/// One converted single-observable epoch.
152#[derive(Debug, Clone, PartialEq)]
153pub struct IonosphereFreeBaselineEpoch {
154    pub epoch_index: usize,
155    pub satellite_ids: Vec<String>,
156    pub base_observations: Vec<Observation>,
157    pub rover_observations: Vec<Observation>,
158}
159
160/// Converted IF epochs plus per-DD narrow-lane ambiguity parameters.
161#[derive(Debug, Clone, PartialEq)]
162pub struct IonosphereFreeBaselineResult {
163    pub epochs: Vec<IonosphereFreeBaselineEpoch>,
164    pub wavelengths_m: BTreeMap<String, f64>,
165    pub offsets_m: BTreeMap<String, f64>,
166}
167
168/// One normalized RTK baseline epoch for reference-satellite selection.
169#[derive(Debug, Clone, PartialEq)]
170pub struct BaselineReferenceEpoch {
171    /// Satellites available in both receivers and in the position maps.
172    pub available_satellite_ids: Vec<String>,
173    /// Satellite ECEF positions in metres, keyed by satellite id.
174    pub satellite_positions_m: BTreeMap<String, [f64; 3]>,
175}
176
177/// One RTK baseline epoch's satellite positions for elevation masking.
178#[derive(Debug, Clone, PartialEq)]
179pub struct ElevationMaskEpoch {
180    /// Satellite ECEF positions in metres, keyed by satellite id.
181    pub satellite_positions_m: BTreeMap<String, [f64; 3]>,
182}
183
184/// Per-epoch elevation-mask decision.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct ElevationMaskEpochResult {
187    /// Satellites at or above the mask in this epoch, sorted by id.
188    pub kept_satellite_ids: Vec<String>,
189}
190
191/// Elevation-mask result for a baseline arc.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct ElevationMaskResult {
194    pub epochs: Vec<ElevationMaskEpochResult>,
195    /// Satellites below the mask in any epoch, sorted by id.
196    pub masked_satellite_ids: Vec<String>,
197}
198
199/// Wide-lane integer estimation controls.
200#[derive(Debug, Clone, Copy, PartialEq)]
201pub struct WideLaneOptions {
202    pub min_epochs: usize,
203    pub tolerance_cycles: f64,
204    /// When true, short ambiguity fragments are omitted instead of failing. This
205    /// is the `:split_arc` policy used by Sidereon after cycle-slip segmentation.
206    pub skip_short_fragments: bool,
207}
208
209/// Error from dual-frequency wide-lane integer estimation.
210#[derive(Debug, Clone, PartialEq)]
211pub enum WideLaneError {
212    InvalidInput {
213        field: &'static str,
214        reason: &'static str,
215    },
216    ReferenceSatelliteMissing(String),
217    WideLaneFailed {
218        satellite_id: String,
219        reason: CarrierPhaseError,
220    },
221    TooFewWideLaneEpochs {
222        ambiguity_id: String,
223        count: usize,
224        minimum: usize,
225    },
226    WideLaneNotInteger {
227        ambiguity_id: String,
228        mean_cycles: f64,
229        fixed_cycles: i64,
230    },
231}
232
233/// Error from dual-frequency IF/narrow-lane conversion.
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub enum IonosphereFreeBaselineError {
236    InvalidInput {
237        field: &'static str,
238        reason: &'static str,
239    },
240    NoEpochs,
241    InconsistentFrequencies(String),
242    NarrowLaneFailed(IonosphereFreeError),
243    IonosphereFreeFailed {
244        satellite_id: String,
245        reason: IonosphereFreeError,
246    },
247}
248
249/// Error from RTK code-smoothing preprocessing.
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum CodeSmoothingError {
252    InvalidWindowCap,
253}
254
255/// Base/rover receiver side for RTK preprocessing diagnostics.
256#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
257pub enum CycleSlipReceiver {
258    Base,
259    Rover,
260}
261
262pub use crate::ambiguity::CycleSlipPolicy;
263
264/// Public split-arc metadata, with epoch indices for callers to remap.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct CycleSlipSplitArc {
267    pub receiver: CycleSlipReceiver,
268    pub satellite_id: String,
269    pub ambiguity_id: String,
270    pub start_epoch_index: usize,
271    pub end_epoch_index: usize,
272    pub n_epochs: usize,
273}
274
275/// Prepared single-frequency RTK epochs and policy metadata.
276#[derive(Debug, Clone, PartialEq)]
277pub struct CycleSlipPrepResult {
278    pub epochs: Vec<CycleSlipEpoch>,
279    pub dropped_sats: Vec<String>,
280    pub split_arcs: Vec<CycleSlipSplitArc>,
281}
282
283/// Prepared dual-frequency RTK epochs and policy metadata.
284#[derive(Debug, Clone, PartialEq)]
285pub struct DualCycleSlipPrepResult {
286    pub epochs: Vec<DualCycleSlipEpoch>,
287    pub dropped_sats: Vec<String>,
288    pub split_arcs: Vec<CycleSlipSplitArc>,
289}
290
291/// Error from RTK cycle-slip preprocessing.
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub enum CycleSlipPrepError {
294    InvalidInput {
295        field: &'static str,
296        reason: &'static str,
297    },
298    CycleSlipDetected {
299        receiver: CycleSlipReceiver,
300        satellite_id: String,
301        epoch_index: usize,
302        reasons: Vec<SlipReason>,
303    },
304}
305
306/// Reference-satellite option for double-difference construction.
307#[derive(Debug, Clone, Default, PartialEq, Eq)]
308pub enum ReferenceSelection {
309    /// Pick the lexicographically first common satellite per constellation.
310    #[default]
311    Auto,
312    /// Use one fixed reference satellite. Valid only for single-system data.
313    Satellite(String),
314    /// Use one fixed reference satellite per constellation letter.
315    PerSystem(BTreeMap<String, String>),
316}
317
318/// Reference report shape matching the Sidereon public API.
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub enum ReferenceReport {
321    Satellite(String),
322    PerSystem(BTreeMap<String, String>),
323}
324
325/// Baseline-solver reference-satellite option.
326#[derive(Debug, Clone, Default, PartialEq, Eq)]
327pub enum BaselineReferenceSelection {
328    /// Pick the highest-average-elevation satellite per constellation.
329    #[default]
330    Auto,
331    /// Use one fixed reference satellite. Valid only for single-system data.
332    Satellite(String),
333    /// Use one fixed reference satellite per constellation letter.
334    PerSystem(BTreeMap<String, String>),
335}
336
337/// One non-reference satellite's double-difference measurement.
338#[derive(Debug, Clone, PartialEq)]
339pub struct DoubleDifference {
340    pub satellite_id: String,
341    pub reference_satellite_id: String,
342    pub ambiguity_id: String,
343    pub code_m: f64,
344    pub phase_m: f64,
345}
346
347/// Result of double-difference construction.
348#[derive(Debug, Clone, PartialEq)]
349pub struct DoubleDifferenceResult {
350    pub reference_satellite_id: ReferenceReport,
351    pub double_differences: Vec<DoubleDifference>,
352    pub dropped_sats: Vec<String>,
353}
354
355/// Error from double-difference construction.
356#[derive(Debug, Clone, PartialEq, Eq)]
357pub enum DoubleDifferenceError {
358    InvalidInput {
359        field: &'static str,
360        reason: &'static str,
361    },
362    DuplicateObservation(String),
363    TooFewCommonSatellites {
364        count: usize,
365        minimum: usize,
366    },
367    NoCommonReferenceSatellite(String),
368    MissingSatellitePosition(String),
369    ReferenceSatelliteMissing(String),
370    ReferenceSatelliteSingleSystem(String),
371    ReferenceSatelliteMissingSystem(String),
372    InvalidReferenceOption,
373}
374
375#[derive(Debug, Clone)]
376struct SingleDifference {
377    satellite_id: String,
378    ambiguity_id: AmbiguityId,
379    code_m: f64,
380    phase_m: f64,
381}
382
383#[derive(Debug, Clone, Copy)]
384struct CodeSmoothingState {
385    p_smooth_m: f64,
386    phase_m: f64,
387    window: usize,
388}
389
390#[derive(Debug, Clone, PartialEq, Eq)]
391struct CycleSlipEvent {
392    receiver: CycleSlipReceiver,
393    satellite_id: String,
394    epoch_index: usize,
395    reasons: Vec<SlipReason>,
396}
397
398#[derive(Debug, Clone)]
399struct DualSingleDifference {
400    satellite_id: String,
401    ambiguity_id: AmbiguityId,
402    wide_lane_cycles: f64,
403}
404
405#[derive(Debug, Clone)]
406struct WideLaneSample {
407    ambiguity_id: AmbiguityId,
408    cycles: f64,
409}
410
411#[derive(Debug, Clone, Copy)]
412struct DualTropoReceiver {
413    position_m: [f64; 3],
414    geodetic: Wgs84Geodetic,
415}
416
417#[derive(Debug, Clone, Copy)]
418struct DualTropoConfig {
419    base: DualTropoReceiver,
420    rover: DualTropoReceiver,
421}
422
423/// Build code and carrier-phase double differences from base and rover observations.
424pub fn double_differences(
425    base_observations: &[Observation],
426    rover_observations: &[Observation],
427    reference: ReferenceSelection,
428) -> Result<DoubleDifferenceResult, DoubleDifferenceError> {
429    let base = observations_by_satellite(base_observations)?;
430    let rover = observations_by_satellite(rover_observations)?;
431    let (common, dropped_sats) = common_observations(&base, &rover)?;
432    let refs = reference_satellites(&common, &reference)?;
433    ensure_non_reference_satellites(&common, &refs)?;
434    let ref_set = refs.values().cloned().collect::<BTreeSet<_>>();
435
436    let mut ref_data = BTreeMap::new();
437    for (system, reference_sat) in &refs {
438        let ref_base = base.get(reference_sat).expect("reference base observation");
439        let ref_rover = rover
440            .get(reference_sat)
441            .expect("reference rover observation");
442        ref_data.insert(
443            system.clone(),
444            SingleDifference {
445                satellite_id: reference_sat.clone(),
446                ambiguity_id: single_difference_ambiguity_id(reference_sat, ref_base, ref_rover),
447                code_m: finite_double_difference_value(
448                    ref_rover.code_m - ref_base.code_m,
449                    "rtk single difference code_m",
450                )?,
451                phase_m: finite_double_difference_value(
452                    ref_rover.phase_m - ref_base.phase_m,
453                    "rtk single difference phase_m",
454                )?,
455            },
456        );
457    }
458
459    let double_differences = common
460        .iter()
461        .filter(|sat| !ref_set.contains(*sat))
462        .map(|sat| {
463            let system = satellite_system(sat);
464            let reference = ref_data
465                .get(&system)
466                .expect("reference for satellite system");
467            let base_obs = base.get(sat).expect("base observation");
468            let rover_obs = rover.get(sat).expect("rover observation");
469            let sat_sd_id = single_difference_ambiguity_id(sat, base_obs, rover_obs);
470            let code_m = finite_double_difference_value(
471                rover_obs.code_m - base_obs.code_m - reference.code_m,
472                "rtk double difference code_m",
473            )?;
474            let phase_m = finite_double_difference_value(
475                rover_obs.phase_m - base_obs.phase_m - reference.phase_m,
476                "rtk double difference phase_m",
477            )?;
478
479            Ok(DoubleDifference {
480                satellite_id: sat.clone(),
481                reference_satellite_id: reference.satellite_id.clone(),
482                ambiguity_id: double_difference_ambiguity_id(sat, &sat_sd_id, reference)
483                    .into_string(),
484                code_m,
485                phase_m,
486            })
487        })
488        .collect::<Result<Vec<_>, _>>()?;
489
490    Ok(DoubleDifferenceResult {
491        reference_satellite_id: reference_report(refs),
492        double_differences,
493        dropped_sats,
494    })
495}
496
497/// Hatch-smooth code observations independently for base and rover receivers.
498///
499/// State is keyed by ambiguity id, reset when LLI bit 0 is set, and advanced in
500/// satellite-id order within each epoch to match the Sidereon public RTK path.
501pub fn hatch_smooth_baseline_code_epochs(
502    epochs: &[CodeSmoothingEpoch],
503    hatch_window_cap: usize,
504) -> Result<Vec<CodeSmoothingEpoch>, CodeSmoothingError> {
505    let hatch_window_cap = validate_hatch_window_cap(hatch_window_cap)
506        .map_err(|_| CodeSmoothingError::InvalidWindowCap)?;
507
508    let mut smoothed = epochs.to_vec();
509    smooth_receiver_code_epochs(&mut smoothed, Receiver::Base, hatch_window_cap);
510    smooth_receiver_code_epochs(&mut smoothed, Receiver::Rover, hatch_window_cap);
511    Ok(smoothed)
512}
513
514/// Prepare single-frequency RTK epochs according to the configured cycle-slip policy.
515///
516/// The core owns the language-independent LLI event ordering, drop/split policy
517/// behavior, split-arc ambiguity ids, and reacquired-satellite ambiguity ids.
518pub fn prepare_cycle_slip_baseline_epochs(
519    epochs: &[CycleSlipEpoch],
520    policy: CycleSlipPolicy,
521) -> Result<CycleSlipPrepResult, CycleSlipPrepError> {
522    validate_cycle_slip_baseline_epochs(epochs)?;
523    let slips = cycle_slip_events(epochs);
524
525    let (prepared, dropped_sats, split_arcs) = match (policy, slips.as_slice()) {
526        (_, []) => (epochs.to_vec(), Vec::new(), Vec::new()),
527        (CycleSlipPolicy::Error, [slip, ..]) => {
528            return Err(CycleSlipPrepError::CycleSlipDetected {
529                receiver: slip.receiver,
530                satellite_id: slip.satellite_id.clone(),
531                epoch_index: slip.epoch_index,
532                reasons: slip.reasons.clone(),
533            });
534        }
535        (CycleSlipPolicy::DropSatellite, slips) => {
536            let dropped_sats = dropped_cycle_slip_sats(slips);
537            (
538                drop_cycle_slip_satellites(epochs, &dropped_sats),
539                dropped_sats,
540                Vec::new(),
541            )
542        }
543        (CycleSlipPolicy::SplitArc, slips) => {
544            let split_sides = cycle_slip_split_sides(slips);
545            let split_epochs = split_cycle_slip_arcs(epochs, &split_sides, slips);
546            let split_arcs = cycle_slip_split_metadata(&split_epochs, &split_sides);
547            (split_epochs, Vec::new(), split_arcs)
548        }
549    };
550
551    Ok(CycleSlipPrepResult {
552        epochs: segment_reacquired_arcs(prepared),
553        dropped_sats,
554        split_arcs,
555    })
556}
557
558/// Prepare dual-frequency RTK epochs according to the configured cycle-slip policy.
559///
560/// The core owns the LLI, data-gap, geometry-free, and Melbourne-Wubbena
561/// classification used before wide-lane estimation, plus the drop/split policy,
562/// split-arc ambiguity ids, and reacquired-satellite ambiguity ids.
563pub fn prepare_dual_cycle_slip_baseline_epochs(
564    epochs: &[DualCycleSlipEpoch],
565    policy: CycleSlipPolicy,
566    options: CycleSlipOptions,
567) -> Result<DualCycleSlipPrepResult, CycleSlipPrepError> {
568    validate_cycle_slip_options(options)?;
569    validate_dual_cycle_slip_baseline_epochs(epochs)?;
570    let slips = dual_cycle_slip_events(epochs, options)?;
571
572    let (prepared, dropped_sats, split_arcs) = match (policy, slips.as_slice()) {
573        (_, []) => (epochs.to_vec(), Vec::new(), Vec::new()),
574        (CycleSlipPolicy::Error, [slip, ..]) => {
575            return Err(CycleSlipPrepError::CycleSlipDetected {
576                receiver: slip.receiver,
577                satellite_id: slip.satellite_id.clone(),
578                epoch_index: slip.epoch_index,
579                reasons: slip.reasons.clone(),
580            });
581        }
582        (CycleSlipPolicy::DropSatellite, slips) => {
583            let dropped_sats = dropped_cycle_slip_sats(slips);
584            (
585                drop_dual_cycle_slip_satellites(epochs, &dropped_sats),
586                dropped_sats,
587                Vec::new(),
588            )
589        }
590        (CycleSlipPolicy::SplitArc, slips) => {
591            let split_sides = cycle_slip_split_sides(slips);
592            let split_epochs = split_dual_cycle_slip_arcs(epochs, &split_sides, slips);
593            let split_arcs = dual_cycle_slip_split_metadata(&split_epochs, &split_sides);
594            (split_epochs, Vec::new(), split_arcs)
595        }
596    };
597
598    Ok(DualCycleSlipPrepResult {
599        epochs: segment_reacquired_dual_arcs(prepared),
600        dropped_sats,
601        split_arcs,
602    })
603}
604
605/// Select per-system RTK baseline reference satellites.
606///
607/// This is the baseline-solver rule: automatic references are the
608/// highest-average-elevation satellites within each constellation's per-system
609/// common set. It is intentionally separate from [`double_differences`], whose
610/// public helper keeps the older lexicographic default.
611pub fn baseline_reference_satellites(
612    base_m: [f64; 3],
613    epochs: &[BaselineReferenceEpoch],
614    selection: BaselineReferenceSelection,
615) -> Result<BTreeMap<String, String>, DoubleDifferenceError> {
616    validate_rtk_receiver_position(base_m)?;
617    validate_baseline_reference_positions(base_m, epochs)?;
618
619    let all_sats = baseline_all_satellites(epochs);
620    let systems = all_sats
621        .iter()
622        .map(|sat| satellite_system(sat))
623        .collect::<BTreeSet<_>>();
624    let common_by_system = baseline_common_by_system(epochs);
625
626    match selection {
627        BaselineReferenceSelection::Auto => systems
628            .into_iter()
629            .map(|system| {
630                let common = common_by_system.get(&system).cloned().unwrap_or_default();
631                if common.is_empty() {
632                    Err(DoubleDifferenceError::NoCommonReferenceSatellite(system))
633                } else {
634                    let system_epochs = baseline_epochs_for_system(epochs, &system);
635                    let reference = highest_elevation_reference(base_m, &system_epochs, &common)?;
636                    Ok((system, reference))
637                }
638            })
639            .collect(),
640        BaselineReferenceSelection::Satellite(sat) => {
641            let systems = systems.into_iter().collect::<Vec<_>>();
642            match systems.as_slice() {
643                [system] => {
644                    if common_by_system
645                        .get(system)
646                        .is_some_and(|sats| sats.contains(&sat))
647                    {
648                        Ok(BTreeMap::from([(system.clone(), sat)]))
649                    } else {
650                        Err(DoubleDifferenceError::ReferenceSatelliteMissing(sat))
651                    }
652                }
653                _ => Err(DoubleDifferenceError::ReferenceSatelliteSingleSystem(sat)),
654            }
655        }
656        BaselineReferenceSelection::PerSystem(refs) => {
657            let mut out = BTreeMap::new();
658            for system in systems {
659                let Some(sat) = refs.get(&system) else {
660                    return Err(DoubleDifferenceError::ReferenceSatelliteMissingSystem(
661                        system,
662                    ));
663                };
664                if common_by_system
665                    .get(&system)
666                    .is_some_and(|sats| sats.contains(sat))
667                {
668                    out.insert(system, sat.clone());
669                } else {
670                    return Err(DoubleDifferenceError::ReferenceSatelliteMissing(
671                        sat.clone(),
672                    ));
673                }
674            }
675            Ok(out)
676        }
677    }
678}
679
680fn validate_baseline_reference_positions(
681    base_m: [f64; 3],
682    epochs: &[BaselineReferenceEpoch],
683) -> Result<(), DoubleDifferenceError> {
684    for epoch in epochs {
685        for sat in &epoch.available_satellite_ids {
686            let sat_pos = *baseline_satellite_position(epoch, sat)?;
687            validate_rtk_satellite_geometry(base_m, sat_pos)?;
688        }
689    }
690    Ok(())
691}
692
693fn baseline_satellite_position<'a>(
694    epoch: &'a BaselineReferenceEpoch,
695    sat: &str,
696) -> Result<&'a [f64; 3], DoubleDifferenceError> {
697    validate::present(
698        epoch.satellite_positions_m.get(sat),
699        "satellite_positions_m",
700    )
701    .map_err(|_| DoubleDifferenceError::MissingSatellitePosition(sat.to_string()))
702}
703
704fn validate_rtk_receiver_position(base_m: [f64; 3]) -> Result<(), DoubleDifferenceError> {
705    validate::finite_vec3(base_m, "rtk base position_m")
706        .map_err(double_difference_invalid_input)?;
707    let norm = norm3(base_m);
708    if !norm.is_finite() {
709        return Err(invalid_double_difference_input(
710            "rtk base position_m",
711            "out of range",
712        ));
713    }
714    if norm <= 0.0 {
715        return Err(invalid_double_difference_input(
716            "rtk base position_m",
717            "degenerate geometry",
718        ));
719    }
720    Ok(())
721}
722
723fn validate_rtk_satellite_geometry(
724    base_m: [f64; 3],
725    sat_pos_m: [f64; 3],
726) -> Result<(), DoubleDifferenceError> {
727    validate::finite_vec3(sat_pos_m, "rtk satellite position_m")
728        .map_err(double_difference_invalid_input)?;
729    rtk_line_of_sight(base_m, sat_pos_m).map(|_| ())
730}
731
732/// Apply an RTK elevation mask at the base receiver.
733///
734/// A satellite is kept in an epoch when the sine of its geocentric-up elevation
735/// is at least `sin(mask_deg)`. The caller owns receiver observation maps and
736/// uses the returned keep lists to thin each epoch consistently.
737pub fn apply_elevation_mask(
738    base_m: [f64; 3],
739    epochs: &[ElevationMaskEpoch],
740    mask_deg: f64,
741) -> Result<ElevationMaskResult, DoubleDifferenceError> {
742    validate_rtk_receiver_position(base_m)?;
743    let mask_deg = validate::finite_in_range(mask_deg, -90.0, 90.0, "rtk elevation mask_deg")
744        .map_err(double_difference_invalid_input)?;
745    let min_sin = (mask_deg * DEG_TO_RAD).sin();
746    let up = local_up(base_m);
747    let mut masked = BTreeSet::new();
748    let mut results = Vec::with_capacity(epochs.len());
749
750    for epoch in epochs {
751        let mut kept = Vec::new();
752        for (sat, sat_pos) in &epoch.satellite_positions_m {
753            if elevation_score_with_up(base_m, up, *sat_pos)? >= min_sin {
754                kept.push(sat.clone());
755            } else {
756                masked.insert(sat.clone());
757            }
758        }
759        results.push(ElevationMaskEpochResult {
760            kept_satellite_ids: kept,
761        });
762    }
763
764    Ok(ElevationMaskResult {
765        epochs: results,
766        masked_satellite_ids: masked.into_iter().collect(),
767    })
768}
769
770/// Estimate arc-level double-difference wide-lane integers from dual-frequency
771/// base/rover observations.
772pub fn estimate_wide_lane_ambiguities(
773    epochs: &[DualEpoch],
774    reference_satellite_id: &str,
775    options: WideLaneOptions,
776) -> Result<BTreeMap<String, i64>, WideLaneError> {
777    validate_wide_lane_options(options)?;
778    validate_wide_lane_epochs(epochs)?;
779    let mut samples = BTreeMap::<AmbiguityId, Vec<f64>>::new();
780
781    for epoch in epochs {
782        let reference = epoch
783            .observations
784            .iter()
785            .find(|obs| obs.satellite_id == reference_satellite_id)
786            .ok_or_else(|| {
787                WideLaneError::ReferenceSatelliteMissing(reference_satellite_id.to_string())
788            })?;
789        let reference = dual_single_difference(reference)?;
790
791        for observation in epoch
792            .observations
793            .iter()
794            .filter(|obs| obs.satellite_id != reference_satellite_id)
795        {
796            let sample = dual_wide_lane_double_difference(observation, &reference)?;
797            samples
798                .entry(sample.ambiguity_id)
799                .or_default()
800                .push(sample.cycles);
801        }
802    }
803
804    let mut fixed = BTreeMap::new();
805    for (ambiguity_id, cycles) in samples {
806        match estimate_wide_lane_integer(ambiguity_id.as_str(), &cycles, options) {
807            Ok(value) => {
808                fixed.insert(ambiguity_id.into_string(), value);
809            }
810            Err(WideLaneError::TooFewWideLaneEpochs { .. }) if options.skip_short_fragments => {}
811            Err(err) => return Err(err),
812        }
813    }
814    Ok(fixed)
815}
816
817/// Build ionosphere-free single-observable epochs and the corresponding
818/// narrow-lane ambiguity wavelength/offset maps.
819pub fn build_ionosphere_free_baseline_epochs(
820    epochs: &[DualIonosphereFreeEpoch],
821    reference_satellite_id: &str,
822    wide_lane_cycles: &BTreeMap<String, i64>,
823) -> Result<IonosphereFreeBaselineResult, IonosphereFreeBaselineError> {
824    validate_ionosphere_free_epochs(epochs)?;
825    let params = dual_narrow_lane_params(epochs, reference_satellite_id, wide_lane_cycles)?;
826    let mut if_epochs = Vec::new();
827
828    for (epoch_index, epoch) in epochs.iter().enumerate() {
829        let keep_sats =
830            dual_ionosphere_free_keep_sats(epoch, reference_satellite_id, wide_lane_cycles);
831        if keep_sats.len() < 2 {
832            continue;
833        }
834
835        if_epochs.push(IonosphereFreeBaselineEpoch {
836            epoch_index,
837            base_observations: dual_ionosphere_free_observations(
838                epoch,
839                &keep_sats,
840                Receiver::Base,
841            )?,
842            rover_observations: dual_ionosphere_free_observations(
843                epoch,
844                &keep_sats,
845                Receiver::Rover,
846            )?,
847            satellite_ids: keep_sats,
848        });
849    }
850
851    if if_epochs.is_empty() {
852        return Err(IonosphereFreeBaselineError::NoEpochs);
853    }
854
855    Ok(IonosphereFreeBaselineResult {
856        epochs: if_epochs,
857        wavelengths_m: params
858            .iter()
859            .map(|(id, param)| (id.as_str().to_string(), param.wavelength_m))
860            .collect(),
861        offsets_m: params
862            .into_iter()
863            .map(|(id, param)| (id.into_string(), param.offset_m))
864            .collect(),
865    })
866}
867
868/// Build IF/narrow-lane baseline epochs from normalized dual-frequency data.
869///
870/// This composes the language-independent dual-frequency setup that Sidereon used
871/// to perform in Elixir: receiver geodetic conversion from the base plus
872/// initial baseline, RTKLIB-style standard-atmosphere meteorology, per-satellite
873/// geodetic elevation, optional slant troposphere subtraction, then the existing
874/// IF/narrow-lane conversion.
875pub fn prepare_ionosphere_free_baseline_epochs(
876    base_m: [f64; 3],
877    initial_baseline_m: [f64; 3],
878    epochs: &[DualIonosphereFreeSetupEpoch],
879    reference_satellite_id: &str,
880    wide_lane_cycles: &BTreeMap<String, i64>,
881    apply_troposphere: bool,
882) -> Result<IonosphereFreeBaselineResult, IonosphereFreeBaselineError> {
883    validate_ionosphere_free_setup_epochs(base_m, initial_baseline_m, epochs, apply_troposphere)?;
884    let tropo = apply_troposphere
885        .then(|| dual_tropo_config(base_m, initial_baseline_m))
886        .transpose()?;
887    let if_epochs = epochs
888        .iter()
889        .map(|epoch| dual_setup_ionosphere_free_epoch(epoch, tropo.as_ref()))
890        .collect::<Result<Vec<_>, _>>()?;
891
892    build_ionosphere_free_baseline_epochs(&if_epochs, reference_satellite_id, wide_lane_cycles)
893}
894
895fn observations_by_satellite(
896    observations: &[Observation],
897) -> Result<BTreeMap<String, Observation>, DoubleDifferenceError> {
898    let mut by_sat = BTreeMap::new();
899    for observation in observations {
900        validate_double_difference_observation(observation)?;
901        if by_sat
902            .insert(observation.satellite_id.clone(), observation.clone())
903            .is_some()
904        {
905            return Err(DoubleDifferenceError::DuplicateObservation(
906                observation.satellite_id.clone(),
907            ));
908        }
909    }
910    Ok(by_sat)
911}
912
913fn validate_double_difference_observation(
914    observation: &Observation,
915) -> Result<(), DoubleDifferenceError> {
916    validate::finite(observation.code_m, "rtk observation code_m")
917        .map_err(double_difference_invalid_input)?;
918    validate::finite(observation.phase_m, "rtk observation phase_m")
919        .map_err(double_difference_invalid_input)?;
920    Ok(())
921}
922
923fn finite_double_difference_value(
924    value: f64,
925    field: &'static str,
926) -> Result<f64, DoubleDifferenceError> {
927    validate::finite(value, field).map_err(double_difference_invalid_input)
928}
929
930fn double_difference_invalid_input(error: validate::FieldError) -> DoubleDifferenceError {
931    DoubleDifferenceError::InvalidInput {
932        field: error.field(),
933        reason: error.reason(),
934    }
935}
936
937fn invalid_double_difference_input(
938    field: &'static str,
939    reason: &'static str,
940) -> DoubleDifferenceError {
941    DoubleDifferenceError::InvalidInput { field, reason }
942}
943
944fn common_observations(
945    base: &BTreeMap<String, Observation>,
946    rover: &BTreeMap<String, Observation>,
947) -> Result<(Vec<String>, Vec<String>), DoubleDifferenceError> {
948    let base_sats = base.keys().cloned().collect::<BTreeSet<_>>();
949    let rover_sats = rover.keys().cloned().collect::<BTreeSet<_>>();
950    let common = base_sats
951        .intersection(&rover_sats)
952        .cloned()
953        .collect::<Vec<_>>();
954
955    if common.len() < 2 {
956        return Err(DoubleDifferenceError::TooFewCommonSatellites {
957            count: common.len(),
958            minimum: 2,
959        });
960    }
961
962    let common_set = common.iter().cloned().collect::<BTreeSet<_>>();
963    let dropped = base_sats
964        .union(&rover_sats)
965        .filter(|sat| !common_set.contains(*sat))
966        .cloned()
967        .collect();
968    Ok((common, dropped))
969}
970
971fn reference_satellites(
972    common: &[String],
973    selection: &ReferenceSelection,
974) -> Result<BTreeMap<String, String>, DoubleDifferenceError> {
975    let mut common_by_system = BTreeMap::<String, Vec<String>>::new();
976    for sat in common {
977        common_by_system
978            .entry(satellite_system(sat))
979            .or_default()
980            .push(sat.clone());
981    }
982    let systems = common_by_system.keys().cloned().collect::<Vec<_>>();
983
984    match selection {
985        ReferenceSelection::Auto => Ok(common_by_system
986            .into_iter()
987            .map(|(system, sats)| (system, sats[0].clone()))
988            .collect()),
989        ReferenceSelection::Satellite(sat) => match systems.as_slice() {
990            [system] => {
991                if common_by_system
992                    .get(system)
993                    .is_some_and(|sats| sats.contains(sat))
994                {
995                    Ok(BTreeMap::from([(system.clone(), sat.clone())]))
996                } else {
997                    Err(DoubleDifferenceError::ReferenceSatelliteMissing(
998                        sat.clone(),
999                    ))
1000                }
1001            }
1002            _ => Err(DoubleDifferenceError::ReferenceSatelliteSingleSystem(
1003                sat.clone(),
1004            )),
1005        },
1006        ReferenceSelection::PerSystem(refs) => {
1007            let mut out = BTreeMap::new();
1008            for system in systems {
1009                let Some(sat) = refs.get(&system) else {
1010                    return Err(DoubleDifferenceError::ReferenceSatelliteMissingSystem(
1011                        system,
1012                    ));
1013                };
1014                if common_by_system
1015                    .get(&system)
1016                    .is_some_and(|sats| sats.contains(sat))
1017                {
1018                    out.insert(system, sat.clone());
1019                } else {
1020                    return Err(DoubleDifferenceError::ReferenceSatelliteMissing(
1021                        sat.clone(),
1022                    ));
1023                }
1024            }
1025            Ok(out)
1026        }
1027    }
1028}
1029
1030fn ensure_non_reference_satellites(
1031    common: &[String],
1032    refs: &BTreeMap<String, String>,
1033) -> Result<(), DoubleDifferenceError> {
1034    for (system, reference_sat) in refs {
1035        let common_count = common
1036            .iter()
1037            .filter(|sat| satellite_system(sat) == system.as_str())
1038            .count();
1039        let non_reference_count = common
1040            .iter()
1041            .filter(|sat| {
1042                satellite_system(sat) == system.as_str() && sat.as_str() != reference_sat.as_str()
1043            })
1044            .count();
1045        if non_reference_count == 0 {
1046            return Err(DoubleDifferenceError::TooFewCommonSatellites {
1047                count: common_count,
1048                minimum: 2,
1049            });
1050        }
1051    }
1052    Ok(())
1053}
1054
1055fn baseline_all_satellites(epochs: &[BaselineReferenceEpoch]) -> Vec<String> {
1056    epochs
1057        .iter()
1058        .flat_map(|epoch| epoch.available_satellite_ids.iter().cloned())
1059        .collect::<BTreeSet<_>>()
1060        .into_iter()
1061        .collect()
1062}
1063
1064fn baseline_common_by_system(
1065    epochs: &[BaselineReferenceEpoch],
1066) -> BTreeMap<String, BTreeSet<String>> {
1067    let mut common_by_system = BTreeMap::<String, BTreeSet<String>>::new();
1068
1069    for epoch in epochs {
1070        let mut sats_by_system = BTreeMap::<String, BTreeSet<String>>::new();
1071        for sat in &epoch.available_satellite_ids {
1072            sats_by_system
1073                .entry(satellite_system(sat))
1074                .or_default()
1075                .insert(sat.clone());
1076        }
1077
1078        for (system, sats) in sats_by_system {
1079            common_by_system
1080                .entry(system)
1081                .and_modify(|common| {
1082                    *common = common.intersection(&sats).cloned().collect();
1083                })
1084                .or_insert(sats);
1085        }
1086    }
1087
1088    common_by_system
1089}
1090
1091fn baseline_epochs_for_system<'a>(
1092    epochs: &'a [BaselineReferenceEpoch],
1093    system: &str,
1094) -> Vec<&'a BaselineReferenceEpoch> {
1095    epochs
1096        .iter()
1097        .filter(|epoch| {
1098            epoch
1099                .available_satellite_ids
1100                .iter()
1101                .any(|sat| satellite_system(sat) == system)
1102        })
1103        .collect()
1104}
1105
1106fn highest_elevation_reference(
1107    base_m: [f64; 3],
1108    epochs: &[&BaselineReferenceEpoch],
1109    common: &BTreeSet<String>,
1110) -> Result<String, DoubleDifferenceError> {
1111    let mut scores = common
1112        .iter()
1113        .map(|sat| average_elevation_score(base_m, epochs, sat).map(|score| (sat.clone(), score)))
1114        .collect::<Result<Vec<_>, _>>()?;
1115
1116    scores.sort_by(|(sat_a, score_a), (sat_b, score_b)| {
1117        score_b
1118            .partial_cmp(score_a)
1119            .unwrap_or(Ordering::Equal)
1120            .then_with(|| sat_a.cmp(sat_b))
1121    });
1122
1123    Ok(scores[0].0.clone())
1124}
1125
1126fn average_elevation_score(
1127    base_m: [f64; 3],
1128    epochs: &[&BaselineReferenceEpoch],
1129    sat: &str,
1130) -> Result<f64, DoubleDifferenceError> {
1131    let up = local_up(base_m);
1132    let mut sum = 0.0;
1133
1134    for epoch in epochs {
1135        let sat_pos = *baseline_satellite_position(epoch, sat)?;
1136        sum += elevation_score_with_up(base_m, up, sat_pos)?;
1137    }
1138
1139    Ok(sum / epochs.len() as f64)
1140}
1141
1142fn elevation_score_with_up(
1143    base_m: [f64; 3],
1144    up: [f64; 3],
1145    sat_pos_m: [f64; 3],
1146) -> Result<f64, DoubleDifferenceError> {
1147    validate::finite_vec3(up, "rtk local up").map_err(double_difference_invalid_input)?;
1148    let (los, n) = rtk_line_of_sight(base_m, sat_pos_m)?;
1149    let inv = 1.0 / n;
1150    let los = [los[0] * inv, los[1] * inv, los[2] * inv];
1151    let score = dot3(los, up);
1152    validate_elevation_score(score)
1153}
1154
1155fn rtk_line_of_sight(
1156    base_m: [f64; 3],
1157    sat_pos_m: [f64; 3],
1158) -> Result<([f64; 3], f64), DoubleDifferenceError> {
1159    validate::finite_vec3(base_m, "rtk base position_m")
1160        .map_err(double_difference_invalid_input)?;
1161    validate::finite_vec3(sat_pos_m, "rtk satellite position_m")
1162        .map_err(double_difference_invalid_input)?;
1163    let los = sub3(sat_pos_m, base_m);
1164    validate::finite_vec3(los, "rtk line of sight_m").map_err(double_difference_invalid_input)?;
1165    let n = norm3(los);
1166    if !n.is_finite() {
1167        return Err(invalid_double_difference_input(
1168            "rtk line of sight range_m",
1169            "out of range",
1170        ));
1171    }
1172    if n <= 0.0 {
1173        return Err(invalid_double_difference_input(
1174            "rtk line of sight_m",
1175            "degenerate geometry",
1176        ));
1177    }
1178    Ok((los, n))
1179}
1180
1181fn validate_elevation_score(score: f64) -> Result<f64, DoubleDifferenceError> {
1182    validate::finite(score, "rtk elevation score").map_err(double_difference_invalid_input)?;
1183    if !(-1.0 - 1.0e-12..=1.0 + 1.0e-12).contains(&score) {
1184        return Err(invalid_double_difference_input(
1185            "rtk elevation score",
1186            "out of range",
1187        ));
1188    }
1189    Ok(score.clamp(-1.0, 1.0))
1190}
1191
1192fn local_up(base_m: [f64; 3]) -> [f64; 3] {
1193    crate::estimation::substrate::frames::local_up(
1194        crate::estimation::recipe::FrameRecipe::GeocentricUpRtkReference,
1195        base_m,
1196    )
1197}
1198
1199fn validate_wide_lane_options(options: WideLaneOptions) -> Result<(), WideLaneError> {
1200    if options.min_epochs == 0 {
1201        return Err(invalid_wide_lane_input(
1202            "rtk wide lane min_epochs",
1203            "not positive",
1204        ));
1205    }
1206    validate::finite_positive(options.tolerance_cycles, "rtk wide lane tolerance_cycles")
1207        .map_err(wide_lane_invalid_input)?;
1208    Ok(())
1209}
1210
1211fn validate_wide_lane_epochs(epochs: &[DualEpoch]) -> Result<(), WideLaneError> {
1212    for epoch in epochs {
1213        for observation in &epoch.observations {
1214            validate_wide_lane_observation(&observation.base)?;
1215            validate_wide_lane_observation(&observation.rover)?;
1216        }
1217    }
1218    Ok(())
1219}
1220
1221fn validate_wide_lane_observation(observation: &DualObservation) -> Result<(), WideLaneError> {
1222    validate::finite(observation.p1_m, "rtk wide lane p1_m").map_err(wide_lane_invalid_input)?;
1223    validate::finite(observation.p2_m, "rtk wide lane p2_m").map_err(wide_lane_invalid_input)?;
1224    validate::finite(observation.phi1_cycles, "rtk wide lane phi1_cycles")
1225        .map_err(wide_lane_invalid_input)?;
1226    validate::finite(observation.phi2_cycles, "rtk wide lane phi2_cycles")
1227        .map_err(wide_lane_invalid_input)?;
1228    validate::finite_positive(observation.f1_hz, "rtk wide lane f1_hz")
1229        .map_err(wide_lane_invalid_input)?;
1230    validate::finite_positive(observation.f2_hz, "rtk wide lane f2_hz")
1231        .map_err(wide_lane_invalid_input)?;
1232    Ok(())
1233}
1234
1235fn finite_wide_lane_value(value: f64, field: &'static str) -> Result<f64, WideLaneError> {
1236    validate::finite(value, field).map_err(wide_lane_invalid_input)
1237}
1238
1239fn wide_lane_invalid_input(error: validate::FieldError) -> WideLaneError {
1240    WideLaneError::InvalidInput {
1241        field: error.field(),
1242        reason: error.reason(),
1243    }
1244}
1245
1246fn invalid_wide_lane_input(field: &'static str, reason: &'static str) -> WideLaneError {
1247    WideLaneError::InvalidInput { field, reason }
1248}
1249
1250fn dual_single_difference(
1251    observation: &DualSatelliteObservation,
1252) -> Result<DualSingleDifference, WideLaneError> {
1253    let rover_wide_lane = dual_observation_wide_lane_cycles(&observation.rover).map_err(|err| {
1254        WideLaneError::WideLaneFailed {
1255            satellite_id: observation.satellite_id.clone(),
1256            reason: err,
1257        }
1258    })?;
1259    let base_wide_lane = dual_observation_wide_lane_cycles(&observation.base).map_err(|err| {
1260        WideLaneError::WideLaneFailed {
1261            satellite_id: observation.satellite_id.clone(),
1262            reason: err,
1263        }
1264    })?;
1265
1266    let wide_lane_cycles = finite_wide_lane_value(
1267        rover_wide_lane - base_wide_lane,
1268        "rtk wide lane single difference cycles",
1269    )?;
1270
1271    Ok(DualSingleDifference {
1272        satellite_id: observation.satellite_id.clone(),
1273        ambiguity_id: single_difference_dual_ambiguity_id(
1274            &observation.satellite_id,
1275            &observation.base,
1276            &observation.rover,
1277        ),
1278        wide_lane_cycles,
1279    })
1280}
1281
1282fn dual_wide_lane_double_difference(
1283    observation: &DualSatelliteObservation,
1284    reference: &DualSingleDifference,
1285) -> Result<WideLaneSample, WideLaneError> {
1286    let sd = dual_single_difference(observation)?;
1287    let cycles = finite_wide_lane_value(
1288        sd.wide_lane_cycles - reference.wide_lane_cycles,
1289        "rtk wide lane double difference cycles",
1290    )?;
1291    Ok(WideLaneSample {
1292        ambiguity_id: double_difference_dual_ambiguity_id(
1293            &observation.satellite_id,
1294            &sd.ambiguity_id,
1295            reference,
1296        ),
1297        cycles,
1298    })
1299}
1300
1301fn dual_observation_wide_lane_cycles(
1302    observation: &DualObservation,
1303) -> Result<f64, CarrierPhaseError> {
1304    crate::carrier_phase::wide_lane_cycles(
1305        observation.phi1_cycles,
1306        observation.phi2_cycles,
1307        observation.p1_m,
1308        observation.p2_m,
1309        observation.f1_hz,
1310        observation.f2_hz,
1311    )
1312}
1313
1314fn estimate_wide_lane_integer(
1315    ambiguity_id: &str,
1316    cycles: &[f64],
1317    options: WideLaneOptions,
1318) -> Result<i64, WideLaneError> {
1319    ambiguity::estimate_wide_lane_integer(cycles, options.min_epochs, options.tolerance_cycles)
1320        .map_err(|err| match err {
1321            ambiguity::WideLaneEstimateError::TooFewEpochs { count, minimum } => {
1322                WideLaneError::TooFewWideLaneEpochs {
1323                    ambiguity_id: ambiguity_id.to_string(),
1324                    count,
1325                    minimum,
1326                }
1327            }
1328            ambiguity::WideLaneEstimateError::NotInteger {
1329                mean_cycles,
1330                fixed_cycles,
1331            } => WideLaneError::WideLaneNotInteger {
1332                ambiguity_id: ambiguity_id.to_string(),
1333                mean_cycles,
1334                fixed_cycles,
1335            },
1336        })
1337}
1338
1339fn validate_ionosphere_free_epochs(
1340    epochs: &[DualIonosphereFreeEpoch],
1341) -> Result<(), IonosphereFreeBaselineError> {
1342    for epoch in epochs {
1343        for observation in &epoch.observations {
1344            validate_ionosphere_free_observation(&observation.base)?;
1345            validate_ionosphere_free_observation(&observation.rover)?;
1346        }
1347    }
1348    Ok(())
1349}
1350
1351fn validate_ionosphere_free_observation(
1352    observation: &DualIonosphereFreeObservation,
1353) -> Result<(), IonosphereFreeBaselineError> {
1354    validate::finite(observation.p1_m, "rtk if p1_m").map_err(ionosphere_free_invalid_input)?;
1355    validate::finite(observation.p2_m, "rtk if p2_m").map_err(ionosphere_free_invalid_input)?;
1356    validate::finite(observation.phi1_cycles, "rtk if phi1_cycles")
1357        .map_err(ionosphere_free_invalid_input)?;
1358    validate::finite(observation.phi2_cycles, "rtk if phi2_cycles")
1359        .map_err(ionosphere_free_invalid_input)?;
1360    validate::finite_positive(observation.f1_hz, "rtk if f1_hz")
1361        .map_err(ionosphere_free_invalid_input)?;
1362    validate::finite_positive(observation.f2_hz, "rtk if f2_hz")
1363        .map_err(ionosphere_free_invalid_input)?;
1364    validate::finite(observation.tropo_m, "rtk if tropo_m")
1365        .map_err(ionosphere_free_invalid_input)?;
1366    Ok(())
1367}
1368
1369fn validate_ionosphere_free_setup_epochs(
1370    base_m: [f64; 3],
1371    initial_baseline_m: [f64; 3],
1372    epochs: &[DualIonosphereFreeSetupEpoch],
1373    apply_troposphere: bool,
1374) -> Result<(), IonosphereFreeBaselineError> {
1375    validate_tropo_receiver_position(base_m, "rtk tropo base position_m")?;
1376    validate::finite_vec3(initial_baseline_m, "rtk tropo initial_baseline_m")
1377        .map_err(ionosphere_free_invalid_input)?;
1378    let rover_m = [
1379        base_m[0] + initial_baseline_m[0],
1380        base_m[1] + initial_baseline_m[1],
1381        base_m[2] + initial_baseline_m[2],
1382    ];
1383    validate_tropo_receiver_position(rover_m, "rtk tropo rover position_m")?;
1384
1385    for epoch in epochs {
1386        validate::finite(epoch.jd_whole, "rtk if setup jd_whole")
1387            .map_err(ionosphere_free_invalid_input)?;
1388        validate::finite_in_range(epoch.jd_fraction, -1.0, 1.0, "rtk if setup jd_fraction")
1389            .map_err(ionosphere_free_invalid_input)?;
1390        for observation in &epoch.observations {
1391            validate_setup_dual_observation(&observation.base)?;
1392            validate_setup_dual_observation(&observation.rover)?;
1393            if apply_troposphere {
1394                let base_sat = *validate::present(
1395                    epoch
1396                        .base_satellite_positions_m
1397                        .get(&observation.satellite_id),
1398                    "rtk tropo base satellite position_m",
1399                )
1400                .map_err(ionosphere_free_invalid_input)?;
1401                let rover_sat = *validate::present(
1402                    epoch
1403                        .rover_satellite_positions_m
1404                        .get(&observation.satellite_id),
1405                    "rtk tropo rover satellite position_m",
1406                )
1407                .map_err(ionosphere_free_invalid_input)?;
1408                validate_tropo_satellite_geometry(
1409                    base_m,
1410                    base_sat,
1411                    "rtk tropo base satellite position_m",
1412                )?;
1413                validate_tropo_satellite_geometry(
1414                    rover_m,
1415                    rover_sat,
1416                    "rtk tropo rover satellite position_m",
1417                )?;
1418            }
1419        }
1420    }
1421    Ok(())
1422}
1423
1424fn validate_setup_dual_observation(
1425    observation: &DualObservation,
1426) -> Result<(), IonosphereFreeBaselineError> {
1427    validate::finite(observation.p1_m, "rtk if setup p1_m")
1428        .map_err(ionosphere_free_invalid_input)?;
1429    validate::finite(observation.p2_m, "rtk if setup p2_m")
1430        .map_err(ionosphere_free_invalid_input)?;
1431    validate::finite(observation.phi1_cycles, "rtk if setup phi1_cycles")
1432        .map_err(ionosphere_free_invalid_input)?;
1433    validate::finite(observation.phi2_cycles, "rtk if setup phi2_cycles")
1434        .map_err(ionosphere_free_invalid_input)?;
1435    validate::finite_positive(observation.f1_hz, "rtk if setup f1_hz")
1436        .map_err(ionosphere_free_invalid_input)?;
1437    validate::finite_positive(observation.f2_hz, "rtk if setup f2_hz")
1438        .map_err(ionosphere_free_invalid_input)?;
1439    Ok(())
1440}
1441
1442fn validate_tropo_receiver_position(
1443    position_m: [f64; 3],
1444    field: &'static str,
1445) -> Result<(), IonosphereFreeBaselineError> {
1446    validate::finite_vec3(position_m, field).map_err(ionosphere_free_invalid_input)?;
1447    let norm = norm3(position_m);
1448    if !norm.is_finite() {
1449        return Err(invalid_ionosphere_free_input(field, "out of range"));
1450    }
1451    if norm <= 0.0 {
1452        return Err(invalid_ionosphere_free_input(field, "degenerate geometry"));
1453    }
1454    Ok(())
1455}
1456
1457fn validate_tropo_satellite_geometry(
1458    receiver_m: [f64; 3],
1459    sat_pos_m: [f64; 3],
1460    field: &'static str,
1461) -> Result<(), IonosphereFreeBaselineError> {
1462    validate::finite_vec3(sat_pos_m, field).map_err(ionosphere_free_invalid_input)?;
1463    let los = sub3(sat_pos_m, receiver_m);
1464    validate::finite_vec3(los, "rtk tropo line of sight_m")
1465        .map_err(ionosphere_free_invalid_input)?;
1466    let range = norm3(los);
1467    if !range.is_finite() {
1468        return Err(invalid_ionosphere_free_input(
1469            "rtk tropo line of sight range_m",
1470            "out of range",
1471        ));
1472    }
1473    if range <= 0.0 {
1474        return Err(invalid_ionosphere_free_input(
1475            "rtk tropo line of sight_m",
1476            "degenerate geometry",
1477        ));
1478    }
1479    Ok(())
1480}
1481
1482fn finite_ionosphere_free_value(
1483    value: f64,
1484    field: &'static str,
1485) -> Result<f64, IonosphereFreeBaselineError> {
1486    validate::finite(value, field).map_err(ionosphere_free_invalid_input)
1487}
1488
1489fn ionosphere_free_invalid_input(error: validate::FieldError) -> IonosphereFreeBaselineError {
1490    IonosphereFreeBaselineError::InvalidInput {
1491        field: error.field(),
1492        reason: error.reason(),
1493    }
1494}
1495
1496fn invalid_ionosphere_free_input(
1497    field: &'static str,
1498    reason: &'static str,
1499) -> IonosphereFreeBaselineError {
1500    IonosphereFreeBaselineError::InvalidInput { field, reason }
1501}
1502
1503fn dual_narrow_lane_params(
1504    epochs: &[DualIonosphereFreeEpoch],
1505    reference_satellite_id: &str,
1506    wide_lane_cycles: &BTreeMap<String, i64>,
1507) -> Result<BTreeMap<AmbiguityId, NarrowLaneParams>, IonosphereFreeBaselineError> {
1508    let mut params = BTreeMap::new();
1509    for epoch in epochs {
1510        let Some(ref_sd) = dual_if_single_difference_ambiguity(epoch, reference_satellite_id)
1511        else {
1512            continue;
1513        };
1514        for sat in dual_if_epoch_common_sats(epoch)
1515            .into_iter()
1516            .filter(|sat| sat != reference_satellite_id)
1517        {
1518            let Some(ambiguity_id) = dual_if_wide_lane_ambiguity_id(epoch, &sat, &ref_sd) else {
1519                continue;
1520            };
1521            let Some(&wide_lane) = wide_lane_cycles.get(ambiguity_id.as_str()) else {
1522                continue;
1523            };
1524            let param = dual_narrow_lane_param_from_epoch(
1525                epoch,
1526                &sat,
1527                reference_satellite_id,
1528                ambiguity_id.as_str(),
1529                wide_lane as f64,
1530            )?;
1531            ensure_consistent_dual_narrow_lane_params(
1532                ambiguity_id.as_str(),
1533                param,
1534                params.get(&ambiguity_id).copied(),
1535            )?;
1536            params.entry(ambiguity_id).or_insert(param);
1537        }
1538    }
1539    Ok(params)
1540}
1541
1542#[derive(Debug, Clone, Copy)]
1543enum Receiver {
1544    Base,
1545    Rover,
1546}
1547
1548fn dual_narrow_lane_param_from_epoch(
1549    epoch: &DualIonosphereFreeEpoch,
1550    sat: &str,
1551    reference_satellite_id: &str,
1552    ambiguity_id: &str,
1553    wide_lane_cycles: f64,
1554) -> Result<NarrowLaneParams, IonosphereFreeBaselineError> {
1555    let sat_obs = dual_if_satellite(epoch, sat).expect("satellite from epoch common set");
1556    let ref_obs =
1557        dual_if_satellite(epoch, reference_satellite_id).expect("reference from epoch common set");
1558    ensure_same_dual_frequencies(
1559        ambiguity_id,
1560        [&sat_obs.base, &sat_obs.rover, &ref_obs.base, &ref_obs.rover],
1561    )?;
1562    dual_narrow_lane_param(sat_obs.base.f1_hz, sat_obs.base.f2_hz, wide_lane_cycles)
1563}
1564
1565fn ensure_same_dual_frequencies(
1566    ambiguity_id: &str,
1567    observations: [&DualIonosphereFreeObservation; 4],
1568) -> Result<(), IonosphereFreeBaselineError> {
1569    let first = observations[0];
1570    if observations[1..].iter().all(|obs| {
1571        ambiguity::frequencies_match(obs.f1_hz, first.f1_hz)
1572            && ambiguity::frequencies_match(obs.f2_hz, first.f2_hz)
1573    }) {
1574        Ok(())
1575    } else {
1576        Err(IonosphereFreeBaselineError::InconsistentFrequencies(
1577            ambiguity_id.to_string(),
1578        ))
1579    }
1580}
1581
1582fn dual_narrow_lane_param(
1583    f1_hz: f64,
1584    f2_hz: f64,
1585    wide_lane_cycles: f64,
1586) -> Result<NarrowLaneParams, IonosphereFreeBaselineError> {
1587    ambiguity::narrow_lane_params(f1_hz, f2_hz, wide_lane_cycles)
1588        .map_err(IonosphereFreeBaselineError::NarrowLaneFailed)
1589}
1590
1591fn ensure_consistent_dual_narrow_lane_params(
1592    ambiguity_id: &str,
1593    params: NarrowLaneParams,
1594    prev: Option<NarrowLaneParams>,
1595) -> Result<(), IonosphereFreeBaselineError> {
1596    if prev.is_none_or(|prev| {
1597        ambiguity::frequencies_match(params.f1_hz, prev.f1_hz)
1598            && ambiguity::frequencies_match(params.f2_hz, prev.f2_hz)
1599    }) {
1600        Ok(())
1601    } else {
1602        Err(IonosphereFreeBaselineError::InconsistentFrequencies(
1603            ambiguity_id.to_string(),
1604        ))
1605    }
1606}
1607
1608fn dual_ionosphere_free_keep_sats(
1609    epoch: &DualIonosphereFreeEpoch,
1610    reference_satellite_id: &str,
1611    wide_lane_cycles: &BTreeMap<String, i64>,
1612) -> Vec<String> {
1613    let common = dual_if_epoch_common_sats(epoch);
1614    if !common.iter().any(|sat| sat == reference_satellite_id) {
1615        return Vec::new();
1616    }
1617    let Some(ref_sd) = dual_if_single_difference_ambiguity(epoch, reference_satellite_id) else {
1618        return Vec::new();
1619    };
1620    let kept_nonrefs = common
1621        .into_iter()
1622        .filter(|sat| sat != reference_satellite_id)
1623        .filter(|sat| {
1624            dual_if_wide_lane_ambiguity_id(epoch, sat, &ref_sd)
1625                .is_some_and(|id| wide_lane_cycles.contains_key(id.as_str()))
1626        })
1627        .collect::<Vec<_>>();
1628    if kept_nonrefs.is_empty() {
1629        Vec::new()
1630    } else {
1631        let mut out = Vec::with_capacity(kept_nonrefs.len() + 1);
1632        out.push(reference_satellite_id.to_string());
1633        out.extend(kept_nonrefs);
1634        out
1635    }
1636}
1637
1638fn dual_ionosphere_free_observations(
1639    epoch: &DualIonosphereFreeEpoch,
1640    keep_sats: &[String],
1641    receiver: Receiver,
1642) -> Result<Vec<Observation>, IonosphereFreeBaselineError> {
1643    let keep = keep_sats.iter().collect::<BTreeSet<_>>();
1644    let mut out = Vec::new();
1645    for sat_obs in &epoch.observations {
1646        if keep.contains(&sat_obs.satellite_id) {
1647            let obs = match receiver {
1648                Receiver::Base => &sat_obs.base,
1649                Receiver::Rover => &sat_obs.rover,
1650            };
1651            out.push(dual_ionosphere_free_observation(
1652                &sat_obs.satellite_id,
1653                obs,
1654            )?);
1655        }
1656    }
1657    Ok(out)
1658}
1659
1660fn dual_ionosphere_free_observation(
1661    satellite_id: &str,
1662    obs: &DualIonosphereFreeObservation,
1663) -> Result<Observation, IonosphereFreeBaselineError> {
1664    let code_m = combinations::ionosphere_free(obs.p1_m, obs.p2_m, obs.f1_hz, obs.f2_hz).map_err(
1665        |reason| IonosphereFreeBaselineError::IonosphereFreeFailed {
1666            satellite_id: satellite_id.to_string(),
1667            reason,
1668        },
1669    )?;
1670    let phase_m = combinations::ionosphere_free_phase_cycles(
1671        obs.phi1_cycles,
1672        obs.phi2_cycles,
1673        obs.f1_hz,
1674        obs.f2_hz,
1675    )
1676    .map_err(|reason| IonosphereFreeBaselineError::IonosphereFreeFailed {
1677        satellite_id: satellite_id.to_string(),
1678        reason,
1679    })?;
1680    let code_m = finite_ionosphere_free_value(code_m - obs.tropo_m, "rtk if code_m")?;
1681    let phase_m = finite_ionosphere_free_value(phase_m - obs.tropo_m, "rtk if phase_m")?;
1682    Ok(Observation {
1683        satellite_id: satellite_id.to_string(),
1684        ambiguity_id: obs.ambiguity_id.clone(),
1685        code_m,
1686        phase_m,
1687    })
1688}
1689
1690fn dual_setup_ionosphere_free_epoch(
1691    epoch: &DualIonosphereFreeSetupEpoch,
1692    tropo: Option<&DualTropoConfig>,
1693) -> Result<DualIonosphereFreeEpoch, IonosphereFreeBaselineError> {
1694    let observations = epoch
1695        .observations
1696        .iter()
1697        .map(|obs| {
1698            let base_tropo_m = dual_slant_tropo_m(
1699                tropo,
1700                Receiver::Base,
1701                epoch
1702                    .base_satellite_positions_m
1703                    .get(&obs.satellite_id)
1704                    .copied(),
1705                epoch.jd_whole,
1706                epoch.jd_fraction,
1707            )?;
1708            let rover_tropo_m = dual_slant_tropo_m(
1709                tropo,
1710                Receiver::Rover,
1711                epoch
1712                    .rover_satellite_positions_m
1713                    .get(&obs.satellite_id)
1714                    .copied(),
1715                epoch.jd_whole,
1716                epoch.jd_fraction,
1717            )?;
1718
1719            Ok(DualIonosphereFreeSatelliteObservation {
1720                satellite_id: obs.satellite_id.clone(),
1721                base: dual_if_observation_from_dual(&obs.base, base_tropo_m),
1722                rover: dual_if_observation_from_dual(&obs.rover, rover_tropo_m),
1723            })
1724        })
1725        .collect::<Result<Vec<_>, IonosphereFreeBaselineError>>()?;
1726    Ok(DualIonosphereFreeEpoch { observations })
1727}
1728
1729fn dual_if_observation_from_dual(
1730    obs: &DualObservation,
1731    tropo_m: f64,
1732) -> DualIonosphereFreeObservation {
1733    DualIonosphereFreeObservation {
1734        ambiguity_id: obs.ambiguity_id.clone(),
1735        p1_m: obs.p1_m,
1736        p2_m: obs.p2_m,
1737        phi1_cycles: obs.phi1_cycles,
1738        phi2_cycles: obs.phi2_cycles,
1739        f1_hz: obs.f1_hz,
1740        f2_hz: obs.f2_hz,
1741        tropo_m,
1742    }
1743}
1744
1745fn dual_slant_tropo_m(
1746    tropo: Option<&DualTropoConfig>,
1747    receiver: Receiver,
1748    sat_pos_m: Option<[f64; 3]>,
1749    jd_whole: f64,
1750    jd_fraction: f64,
1751) -> Result<f64, IonosphereFreeBaselineError> {
1752    let (Some(tropo), Some(sat_pos_m)) = (tropo, sat_pos_m) else {
1753        return Ok(0.0);
1754    };
1755    let receiver = match receiver {
1756        Receiver::Base => tropo.base,
1757        Receiver::Rover => tropo.rover,
1758    };
1759    let elevation_rad = geodetic_elevation_rad(receiver.geodetic, receiver.position_m, sat_pos_m);
1760    let met = Met::standard(receiver.geodetic.height_m, 0.0)
1761        .map_err(map_ionosphere_free_setup_tropo_error)?;
1762    let split = JulianDateSplit::new(jd_whole, jd_fraction)
1763        .map_err(map_ionosphere_free_setup_julian_split_error)?;
1764    let epoch = Instant::from_julian_date(TimeScale::Gpst, split);
1765    tropo_slant(elevation_rad, receiver.geodetic, met, epoch)
1766        .map_err(map_ionosphere_free_setup_tropo_error)
1767}
1768
1769fn map_ionosphere_free_setup_julian_split_error(
1770    error: crate::astro::time::model::TimeModelError,
1771) -> IonosphereFreeBaselineError {
1772    let crate::astro::time::model::TimeModelError::InvalidInput { field, reason } = error;
1773    let field = match field {
1774        "jd_whole" => "rtk if setup jd_whole",
1775        "fraction" => "rtk if setup jd_fraction",
1776        _ => "rtk if setup JulianDateSplit",
1777    };
1778    invalid_ionosphere_free_input(field, reason)
1779}
1780
1781fn map_ionosphere_free_setup_tropo_error(
1782    error: crate::error::Error,
1783) -> IonosphereFreeBaselineError {
1784    match error {
1785        crate::error::Error::InvalidInput(message) => {
1786            let (field, reason) = map_ionosphere_free_setup_tropo_message(&message);
1787            invalid_ionosphere_free_input(field, reason)
1788        }
1789        _ => invalid_ionosphere_free_input("rtk if setup tropo", "invalid input"),
1790    }
1791}
1792
1793fn map_ionosphere_free_setup_tropo_message(message: &str) -> (&'static str, &'static str) {
1794    match message {
1795        "height_m not finite" => ("rtk tropo receiver height_m", "not finite"),
1796        "relative_humidity not finite" => ("rtk tropo relative_humidity", "not finite"),
1797        "relative_humidity out of range" => ("rtk tropo relative_humidity", "out of range"),
1798        "elevation_rad not finite" => ("rtk tropo elevation_rad", "not finite"),
1799        "elevation_rad out of range" => ("rtk tropo elevation_rad", "out of range"),
1800        "receiver.lat_rad not finite" => ("rtk tropo receiver.lat_rad", "not finite"),
1801        "receiver.lat_rad out of range" => ("rtk tropo receiver.lat_rad", "out of range"),
1802        "receiver.lon_rad not finite" => ("rtk tropo receiver.lon_rad", "not finite"),
1803        "receiver.lon_rad out of range" => ("rtk tropo receiver.lon_rad", "out of range"),
1804        "receiver.height_m not finite" => ("rtk tropo receiver.height_m", "not finite"),
1805        "receiver.height_m out of range" => ("rtk tropo receiver.height_m", "out of range"),
1806        "pressure_hpa not finite" => ("rtk tropo pressure_hpa", "not finite"),
1807        "pressure_hpa not positive" => ("rtk tropo pressure_hpa", "not positive"),
1808        "temperature_k not finite" => ("rtk tropo temperature_k", "not finite"),
1809        "temperature_k not positive" => ("rtk tropo temperature_k", "not positive"),
1810        "epoch.jd_whole not finite" => ("rtk if setup jd_whole", "not finite"),
1811        "epoch.fraction not finite" => ("rtk if setup jd_fraction", "not finite"),
1812        "epoch.fraction out of range" => ("rtk if setup jd_fraction", "out of range"),
1813        _ => ("rtk if setup tropo", "invalid input"),
1814    }
1815}
1816
1817fn dual_tropo_config(
1818    base_m: [f64; 3],
1819    initial_baseline_m: [f64; 3],
1820) -> Result<DualTropoConfig, IonosphereFreeBaselineError> {
1821    let rover_m = [
1822        base_m[0] + initial_baseline_m[0],
1823        base_m[1] + initial_baseline_m[1],
1824        base_m[2] + initial_baseline_m[2],
1825    ];
1826    Ok(DualTropoConfig {
1827        base: DualTropoReceiver {
1828            position_m: base_m,
1829            geodetic: receiver_geodetic_for_rtk_tropo(base_m, "rtk tropo base position_m")?,
1830        },
1831        rover: DualTropoReceiver {
1832            position_m: rover_m,
1833            geodetic: receiver_geodetic_for_rtk_tropo(rover_m, "rtk tropo rover position_m")?,
1834        },
1835    })
1836}
1837
1838fn receiver_geodetic_for_rtk_tropo(
1839    position_m: [f64; 3],
1840    field: &'static str,
1841) -> Result<Wgs84Geodetic, IonosphereFreeBaselineError> {
1842    let (lat_deg, lon_deg, height_km) = itrs_to_geodetic_compute(
1843        position_m[0] / KM_TO_M,
1844        position_m[1] / KM_TO_M,
1845        position_m[2] / KM_TO_M,
1846    )
1847    .map_err(|_| invalid_ionosphere_free_input(field, "invalid geodetic"))?;
1848    Wgs84Geodetic::new(
1849        lat_deg * DEG_TO_RAD,
1850        normalize_geodetic_lon_rad(lon_deg * DEG_TO_RAD),
1851        (height_km * KM_TO_M).max(0.0),
1852    )
1853    .map_err(|_| invalid_ionosphere_free_input(field, "invalid geodetic"))
1854}
1855
1856fn normalize_geodetic_lon_rad(lon_rad: f64) -> f64 {
1857    if lon_rad <= -core::f64::consts::PI {
1858        core::f64::consts::PI
1859    } else {
1860        lon_rad
1861    }
1862}
1863
1864fn geodetic_elevation_rad(
1865    geodetic: Wgs84Geodetic,
1866    receiver_m: [f64; 3],
1867    sat_pos_m: [f64; 3],
1868) -> f64 {
1869    let dx = sat_pos_m[0] - receiver_m[0];
1870    let dy = sat_pos_m[1] - receiver_m[1];
1871    let dz = sat_pos_m[2] - receiver_m[2];
1872    let range = (dx * dx + dy * dy + dz * dz).sqrt();
1873
1874    if range <= 0.0 {
1875        0.0
1876    } else {
1877        let lat = geodetic.lat_rad;
1878        let lon = geodetic.lon_rad;
1879        let u = lat.cos() * lon.cos() * dx + lat.cos() * lon.sin() * dy + lat.sin() * dz;
1880        let elevation_deg = (u / range).clamp(-1.0, 1.0).asin() * RAD_TO_DEG;
1881        elevation_deg * DEG_TO_RAD
1882    }
1883}
1884
1885fn dual_if_satellite<'a>(
1886    epoch: &'a DualIonosphereFreeEpoch,
1887    sat: &str,
1888) -> Option<&'a DualIonosphereFreeSatelliteObservation> {
1889    epoch
1890        .observations
1891        .iter()
1892        .find(|obs| obs.satellite_id == sat)
1893}
1894
1895fn dual_if_epoch_common_sats(epoch: &DualIonosphereFreeEpoch) -> Vec<String> {
1896    epoch
1897        .observations
1898        .iter()
1899        .map(|obs| obs.satellite_id.clone())
1900        .collect::<BTreeSet<_>>()
1901        .into_iter()
1902        .collect()
1903}
1904
1905fn dual_if_single_difference_ambiguity(
1906    epoch: &DualIonosphereFreeEpoch,
1907    sat: &str,
1908) -> Option<DualSingleDifference> {
1909    let obs = dual_if_satellite(epoch, sat)?;
1910    Some(DualSingleDifference {
1911        satellite_id: sat.to_string(),
1912        ambiguity_id: single_difference_if_ambiguity_id(sat, &obs.base, &obs.rover),
1913        wide_lane_cycles: 0.0,
1914    })
1915}
1916
1917fn dual_if_wide_lane_ambiguity_id(
1918    epoch: &DualIonosphereFreeEpoch,
1919    sat: &str,
1920    ref_sd: &DualSingleDifference,
1921) -> Option<AmbiguityId> {
1922    let obs = dual_if_satellite(epoch, sat)?;
1923    let sat_sd_id = single_difference_if_ambiguity_id(sat, &obs.base, &obs.rover);
1924    Some(double_difference_dual_ambiguity_id(sat, &sat_sd_id, ref_sd))
1925}
1926
1927fn single_difference_if_ambiguity_id(
1928    sat: &str,
1929    base_obs: &DualIonosphereFreeObservation,
1930    rover_obs: &DualIonosphereFreeObservation,
1931) -> AmbiguityId {
1932    let token = match (
1933        base_obs.ambiguity_id.as_str(),
1934        rover_obs.ambiguity_id.as_str(),
1935    ) {
1936        (base_id, rover_id) if base_id == sat && rover_id == sat => sat.to_string(),
1937        (base_id, rover_id) if base_id == sat => rover_id.to_string(),
1938        (base_id, rover_id) if rover_id == sat => base_id.to_string(),
1939        (base_id, rover_id) if base_id == rover_id => base_id.to_string(),
1940        (base_id, rover_id) => format!("{sat}:base={base_id},rover={rover_id}"),
1941    };
1942    AmbiguityId::new(token)
1943}
1944
1945fn reference_report(refs: BTreeMap<String, String>) -> ReferenceReport {
1946    if refs.len() == 1 {
1947        ReferenceReport::Satellite(refs.into_values().next().expect("single reference"))
1948    } else {
1949        ReferenceReport::PerSystem(refs)
1950    }
1951}
1952
1953fn smooth_receiver_code_epochs(
1954    epochs: &mut [CodeSmoothingEpoch],
1955    receiver: Receiver,
1956    hatch_window_cap: usize,
1957) {
1958    let mut states = BTreeMap::<String, CodeSmoothingState>::new();
1959
1960    for epoch in epochs {
1961        let observations = match receiver {
1962            Receiver::Base => &mut epoch.base_observations,
1963            Receiver::Rover => &mut epoch.rover_observations,
1964        };
1965        observations.sort_by(|a, b| a.satellite_id.cmp(&b.satellite_id));
1966
1967        for observation in observations {
1968            let state = states.get(&observation.ambiguity_id).copied();
1969            let (code_m, next_state) =
1970                smooth_observation_code(observation, state, hatch_window_cap);
1971            observation.code_m = code_m;
1972            states.insert(observation.ambiguity_id.clone(), next_state);
1973        }
1974    }
1975}
1976
1977fn smooth_observation_code(
1978    observation: &CodeSmoothingObservation,
1979    state: Option<CodeSmoothingState>,
1980    hatch_window_cap: usize,
1981) -> (f64, CodeSmoothingState) {
1982    if state.is_none() || rtk_lli_set(observation.lli) {
1983        return (
1984            observation.code_m,
1985            CodeSmoothingState {
1986                p_smooth_m: observation.code_m,
1987                phase_m: observation.phase_m,
1988                window: 1,
1989            },
1990        );
1991    }
1992
1993    let state = state.expect("checked above");
1994    let window = (state.window + 1).min(hatch_window_cap);
1995    let n = window as f64;
1996    let p_smooth_m = observation.code_m / n
1997        + (n - 1.0) / n * (state.p_smooth_m + (observation.phase_m - state.phase_m));
1998    (
1999        p_smooth_m,
2000        CodeSmoothingState {
2001            p_smooth_m,
2002            phase_m: observation.phase_m,
2003            window,
2004        },
2005    )
2006}
2007
2008fn rtk_lli_set(lli: Option<i64>) -> bool {
2009    lli.is_some_and(|value| (value & 1) == 1)
2010}
2011
2012fn validate_cycle_slip_baseline_epochs(
2013    epochs: &[CycleSlipEpoch],
2014) -> Result<(), CycleSlipPrepError> {
2015    for epoch in epochs {
2016        for observation in &epoch.base_observations {
2017            validate_cycle_slip_observation(observation)?;
2018        }
2019        for observation in &epoch.rover_observations {
2020            validate_cycle_slip_observation(observation)?;
2021        }
2022    }
2023    Ok(())
2024}
2025
2026fn validate_cycle_slip_observation(
2027    observation: &CycleSlipObservation,
2028) -> Result<(), CycleSlipPrepError> {
2029    validate::finite(observation.code_m, "rtk cycle slip code_m")
2030        .map_err(cycle_slip_invalid_input)?;
2031    validate::finite(observation.phase_m, "rtk cycle slip phase_m")
2032        .map_err(cycle_slip_invalid_input)?;
2033    Ok(())
2034}
2035
2036fn validate_cycle_slip_options(options: CycleSlipOptions) -> Result<(), CycleSlipPrepError> {
2037    validate::finite_positive(options.gf_threshold_m, "rtk cycle slip gf_threshold_m")
2038        .map_err(cycle_slip_invalid_input)?;
2039    validate::finite_positive(
2040        options.mw_threshold_cycles,
2041        "rtk cycle slip mw_threshold_cycles",
2042    )
2043    .map_err(cycle_slip_invalid_input)?;
2044    validate::finite_positive(options.min_arc_gap_s, "rtk cycle slip min_arc_gap_s")
2045        .map_err(cycle_slip_invalid_input)?;
2046    Ok(())
2047}
2048
2049fn validate_dual_cycle_slip_baseline_epochs(
2050    epochs: &[DualCycleSlipEpoch],
2051) -> Result<(), CycleSlipPrepError> {
2052    for epoch in epochs {
2053        if let Some(gap_time_s) = epoch.gap_time_s {
2054            validate::finite(gap_time_s, "rtk cycle slip gap_time_s")
2055                .map_err(cycle_slip_invalid_input)?;
2056        }
2057        for observation in &epoch.base_observations {
2058            validate_dual_cycle_slip_observation(observation)?;
2059        }
2060        for observation in &epoch.rover_observations {
2061            validate_dual_cycle_slip_observation(observation)?;
2062        }
2063    }
2064    Ok(())
2065}
2066
2067fn validate_dual_cycle_slip_observation(
2068    observation: &DualCycleSlipObservation,
2069) -> Result<(), CycleSlipPrepError> {
2070    validate::finite(observation.p1_m, "rtk cycle slip p1_m").map_err(cycle_slip_invalid_input)?;
2071    validate::finite(observation.p2_m, "rtk cycle slip p2_m").map_err(cycle_slip_invalid_input)?;
2072    validate::finite(observation.phi1_cycles, "rtk cycle slip phi1_cycles")
2073        .map_err(cycle_slip_invalid_input)?;
2074    validate::finite(observation.phi2_cycles, "rtk cycle slip phi2_cycles")
2075        .map_err(cycle_slip_invalid_input)?;
2076    validate::finite_positive(observation.f1_hz, "rtk cycle slip f1_hz")
2077        .map_err(cycle_slip_invalid_input)?;
2078    validate::finite_positive(observation.f2_hz, "rtk cycle slip f2_hz")
2079        .map_err(cycle_slip_invalid_input)?;
2080    if (observation.f1_hz - observation.f2_hz).abs() < crate::carrier_phase::FREQ_EPSILON_HZ {
2081        return Err(invalid_cycle_slip_input(
2082            "rtk cycle slip frequencies_hz",
2083            "degenerate frequencies",
2084        ));
2085    }
2086    Ok(())
2087}
2088
2089fn cycle_slip_invalid_input(error: validate::FieldError) -> CycleSlipPrepError {
2090    CycleSlipPrepError::InvalidInput {
2091        field: error.field(),
2092        reason: error.reason(),
2093    }
2094}
2095
2096fn invalid_cycle_slip_input(field: &'static str, reason: &'static str) -> CycleSlipPrepError {
2097    CycleSlipPrepError::InvalidInput { field, reason }
2098}
2099
2100fn cycle_slip_events(epochs: &[CycleSlipEpoch]) -> Vec<CycleSlipEvent> {
2101    let mut events = Vec::new();
2102    for (epoch_index, epoch) in epochs.iter().enumerate() {
2103        cycle_slip_events_for_receiver(
2104            CycleSlipReceiver::Base,
2105            epoch_index,
2106            &epoch.base_observations,
2107            &mut events,
2108        );
2109        cycle_slip_events_for_receiver(
2110            CycleSlipReceiver::Rover,
2111            epoch_index,
2112            &epoch.rover_observations,
2113            &mut events,
2114        );
2115    }
2116    events
2117}
2118
2119fn cycle_slip_events_for_receiver(
2120    receiver: CycleSlipReceiver,
2121    epoch_index: usize,
2122    observations: &[CycleSlipObservation],
2123    events: &mut Vec<CycleSlipEvent>,
2124) {
2125    let mut observations = observations.iter().collect::<Vec<_>>();
2126    observations.sort_by(|a, b| a.satellite_id.cmp(&b.satellite_id));
2127
2128    for obs in observations {
2129        if rtk_lli_set(obs.lli) {
2130            events.push(CycleSlipEvent {
2131                receiver,
2132                satellite_id: obs.satellite_id.clone(),
2133                epoch_index,
2134                reasons: vec![SlipReason::Lli],
2135            });
2136        }
2137    }
2138}
2139
2140fn dual_cycle_slip_events(
2141    epochs: &[DualCycleSlipEpoch],
2142    options: CycleSlipOptions,
2143) -> Result<Vec<CycleSlipEvent>, CycleSlipPrepError> {
2144    let mut events = Vec::new();
2145    dual_cycle_slip_events_for_receiver(CycleSlipReceiver::Base, epochs, options, &mut events)?;
2146    dual_cycle_slip_events_for_receiver(CycleSlipReceiver::Rover, epochs, options, &mut events)?;
2147    Ok(events)
2148}
2149
2150#[derive(Clone, Copy)]
2151struct DualCycleSlipSample<'a> {
2152    epoch_index: usize,
2153    epoch_sort_key: &'a str,
2154    gap_time_s: Option<f64>,
2155    observation: &'a DualCycleSlipObservation,
2156}
2157
2158fn dual_cycle_slip_events_for_receiver(
2159    receiver: CycleSlipReceiver,
2160    epochs: &[DualCycleSlipEpoch],
2161    options: CycleSlipOptions,
2162    events: &mut Vec<CycleSlipEvent>,
2163) -> Result<(), CycleSlipPrepError> {
2164    let mut arcs = BTreeMap::<String, Vec<DualCycleSlipSample<'_>>>::new();
2165
2166    for (epoch_index, epoch) in epochs.iter().enumerate() {
2167        let observations = match receiver {
2168            CycleSlipReceiver::Base => &epoch.base_observations,
2169            CycleSlipReceiver::Rover => &epoch.rover_observations,
2170        };
2171
2172        for observation in observations {
2173            arcs.entry(observation.satellite_id.clone())
2174                .or_default()
2175                .push(DualCycleSlipSample {
2176                    epoch_index,
2177                    epoch_sort_key: &epoch.epoch_sort_key,
2178                    gap_time_s: epoch.gap_time_s,
2179                    observation,
2180                });
2181        }
2182    }
2183
2184    for (satellite_id, mut samples) in arcs {
2185        samples.sort_by(|a, b| a.epoch_sort_key.cmp(b.epoch_sort_key));
2186        let arc = samples
2187            .iter()
2188            .map(|sample| dual_arc_epoch(sample.observation, sample.gap_time_s))
2189            .collect::<Vec<_>>();
2190        let results = detect_cycle_slips(&arc, options).map_err(cycle_slip_detector_error)?;
2191
2192        for (sample, result) in samples.iter().zip(results) {
2193            if result.slip {
2194                events.push(CycleSlipEvent {
2195                    receiver,
2196                    satellite_id: satellite_id.clone(),
2197                    epoch_index: sample.epoch_index,
2198                    reasons: result.reasons,
2199                });
2200            }
2201        }
2202    }
2203    Ok(())
2204}
2205
2206fn cycle_slip_detector_error(error: CarrierPhaseError) -> CycleSlipPrepError {
2207    let (field, reason) = match error {
2208        CarrierPhaseError::EqualFrequencies => {
2209            ("rtk cycle slip frequencies_hz", "degenerate frequencies")
2210        }
2211        CarrierPhaseError::InvalidFrequency => ("rtk cycle slip frequency_hz", "not positive"),
2212        CarrierPhaseError::InvalidObservation => ("rtk cycle slip observation", "not finite"),
2213        CarrierPhaseError::InvalidThreshold => ("rtk cycle slip threshold", "invalid"),
2214    };
2215    invalid_cycle_slip_input(field, reason)
2216}
2217
2218fn dual_arc_epoch(observation: &DualCycleSlipObservation, gap_time_s: Option<f64>) -> ArcEpoch {
2219    ArcEpoch {
2220        phi1_cycles: Some(observation.phi1_cycles),
2221        phi2_cycles: Some(observation.phi2_cycles),
2222        p1_m: Some(observation.p1_m),
2223        p2_m: Some(observation.p2_m),
2224        lli1: observation.lli1,
2225        lli2: observation.lli2,
2226        f1_hz: Some(observation.f1_hz),
2227        f2_hz: Some(observation.f2_hz),
2228        gap_time_s,
2229    }
2230}
2231
2232fn dropped_cycle_slip_sats(slips: &[CycleSlipEvent]) -> Vec<String> {
2233    slips
2234        .iter()
2235        .map(|slip| slip.satellite_id.clone())
2236        .collect::<BTreeSet<_>>()
2237        .into_iter()
2238        .collect()
2239}
2240
2241fn drop_cycle_slip_satellites(
2242    epochs: &[CycleSlipEpoch],
2243    dropped_sats: &[String],
2244) -> Vec<CycleSlipEpoch> {
2245    let dropped = dropped_sats.iter().collect::<BTreeSet<_>>();
2246    epochs
2247        .iter()
2248        .map(|epoch| CycleSlipEpoch {
2249            base_observations: epoch
2250                .base_observations
2251                .iter()
2252                .filter(|obs| !dropped.contains(&obs.satellite_id))
2253                .cloned()
2254                .collect(),
2255            rover_observations: epoch
2256                .rover_observations
2257                .iter()
2258                .filter(|obs| !dropped.contains(&obs.satellite_id))
2259                .cloned()
2260                .collect(),
2261        })
2262        .collect()
2263}
2264
2265fn drop_dual_cycle_slip_satellites(
2266    epochs: &[DualCycleSlipEpoch],
2267    dropped_sats: &[String],
2268) -> Vec<DualCycleSlipEpoch> {
2269    let dropped = dropped_sats.iter().collect::<BTreeSet<_>>();
2270    epochs
2271        .iter()
2272        .map(|epoch| DualCycleSlipEpoch {
2273            epoch_sort_key: epoch.epoch_sort_key.clone(),
2274            gap_time_s: epoch.gap_time_s,
2275            base_observations: epoch
2276                .base_observations
2277                .iter()
2278                .filter(|obs| !dropped.contains(&obs.satellite_id))
2279                .cloned()
2280                .collect(),
2281            rover_observations: epoch
2282                .rover_observations
2283                .iter()
2284                .filter(|obs| !dropped.contains(&obs.satellite_id))
2285                .cloned()
2286                .collect(),
2287        })
2288        .collect()
2289}
2290
2291fn cycle_slip_split_sides(slips: &[CycleSlipEvent]) -> BTreeSet<(CycleSlipReceiver, String)> {
2292    slips
2293        .iter()
2294        .map(|slip| (slip.receiver, slip.satellite_id.clone()))
2295        .collect()
2296}
2297
2298fn split_cycle_slip_arcs(
2299    epochs: &[CycleSlipEpoch],
2300    split_sides: &BTreeSet<(CycleSlipReceiver, String)>,
2301    slips: &[CycleSlipEvent],
2302) -> Vec<CycleSlipEpoch> {
2303    let slip_epochs = slips
2304        .iter()
2305        .map(|slip| (slip.receiver, slip.satellite_id.clone(), slip.epoch_index))
2306        .collect::<BTreeSet<_>>();
2307    let mut split_epochs = epochs.to_vec();
2308    let mut segments = BTreeMap::<(CycleSlipReceiver, String), usize>::new();
2309
2310    for (epoch_index, epoch) in split_epochs.iter_mut().enumerate() {
2311        split_receiver_cycle_slip_arcs(
2312            CycleSlipReceiver::Base,
2313            epoch_index,
2314            &mut epoch.base_observations,
2315            split_sides,
2316            &slip_epochs,
2317            &mut segments,
2318        );
2319        split_receiver_cycle_slip_arcs(
2320            CycleSlipReceiver::Rover,
2321            epoch_index,
2322            &mut epoch.rover_observations,
2323            split_sides,
2324            &slip_epochs,
2325            &mut segments,
2326        );
2327    }
2328
2329    split_epochs
2330}
2331
2332fn split_dual_cycle_slip_arcs(
2333    epochs: &[DualCycleSlipEpoch],
2334    split_sides: &BTreeSet<(CycleSlipReceiver, String)>,
2335    slips: &[CycleSlipEvent],
2336) -> Vec<DualCycleSlipEpoch> {
2337    let slip_epochs = slips
2338        .iter()
2339        .map(|slip| (slip.receiver, slip.satellite_id.clone(), slip.epoch_index))
2340        .collect::<BTreeSet<_>>();
2341    let mut split_epochs = epochs.to_vec();
2342    let mut segments = BTreeMap::<(CycleSlipReceiver, String), usize>::new();
2343
2344    for (epoch_index, epoch) in split_epochs.iter_mut().enumerate() {
2345        split_dual_receiver_cycle_slip_arcs(
2346            CycleSlipReceiver::Base,
2347            epoch_index,
2348            &mut epoch.base_observations,
2349            split_sides,
2350            &slip_epochs,
2351            &mut segments,
2352        );
2353        split_dual_receiver_cycle_slip_arcs(
2354            CycleSlipReceiver::Rover,
2355            epoch_index,
2356            &mut epoch.rover_observations,
2357            split_sides,
2358            &slip_epochs,
2359            &mut segments,
2360        );
2361    }
2362
2363    split_epochs
2364}
2365
2366fn split_receiver_cycle_slip_arcs(
2367    receiver: CycleSlipReceiver,
2368    epoch_index: usize,
2369    observations: &mut [CycleSlipObservation],
2370    split_sides: &BTreeSet<(CycleSlipReceiver, String)>,
2371    slip_epochs: &BTreeSet<(CycleSlipReceiver, String, usize)>,
2372    segments: &mut BTreeMap<(CycleSlipReceiver, String), usize>,
2373) {
2374    observations.sort_by(|a, b| a.satellite_id.cmp(&b.satellite_id));
2375
2376    for obs in observations {
2377        let key = (receiver, obs.satellite_id.clone());
2378        if split_sides.contains(&key) {
2379            let current_segment = segments.get(&key).copied().unwrap_or(1);
2380            let segment =
2381                if slip_epochs.contains(&(receiver, obs.satellite_id.clone(), epoch_index)) {
2382                    current_segment + 1
2383                } else {
2384                    current_segment
2385                };
2386            obs.ambiguity_id = split_side_ambiguity_id_core(&obs.satellite_id, receiver, segment);
2387            segments.insert(key, segment);
2388        }
2389    }
2390}
2391
2392fn split_dual_receiver_cycle_slip_arcs(
2393    receiver: CycleSlipReceiver,
2394    epoch_index: usize,
2395    observations: &mut [DualCycleSlipObservation],
2396    split_sides: &BTreeSet<(CycleSlipReceiver, String)>,
2397    slip_epochs: &BTreeSet<(CycleSlipReceiver, String, usize)>,
2398    segments: &mut BTreeMap<(CycleSlipReceiver, String), usize>,
2399) {
2400    observations.sort_by(|a, b| a.satellite_id.cmp(&b.satellite_id));
2401
2402    for obs in observations {
2403        let key = (receiver, obs.satellite_id.clone());
2404        if split_sides.contains(&key) {
2405            let current_segment = segments.get(&key).copied().unwrap_or(1);
2406            let segment =
2407                if slip_epochs.contains(&(receiver, obs.satellite_id.clone(), epoch_index)) {
2408                    current_segment + 1
2409                } else {
2410                    current_segment
2411                };
2412            obs.ambiguity_id = split_side_ambiguity_id_core(&obs.satellite_id, receiver, segment);
2413            segments.insert(key, segment);
2414        }
2415    }
2416}
2417
2418fn cycle_slip_split_metadata(
2419    epochs: &[CycleSlipEpoch],
2420    split_sides: &BTreeSet<(CycleSlipReceiver, String)>,
2421) -> Vec<CycleSlipSplitArc> {
2422    let mut grouped = BTreeMap::<(CycleSlipReceiver, String, String), Vec<usize>>::new();
2423
2424    for (epoch_index, epoch) in epochs.iter().enumerate() {
2425        split_metadata_entries(
2426            CycleSlipReceiver::Base,
2427            epoch_index,
2428            &epoch.base_observations,
2429            split_sides,
2430            &mut grouped,
2431        );
2432        split_metadata_entries(
2433            CycleSlipReceiver::Rover,
2434            epoch_index,
2435            &epoch.rover_observations,
2436            split_sides,
2437            &mut grouped,
2438        );
2439    }
2440
2441    grouped
2442        .into_iter()
2443        .map(|((receiver, satellite_id, ambiguity_id), epoch_indices)| {
2444            let start_epoch_index = *epoch_indices
2445                .first()
2446                .expect("metadata has at least one epoch");
2447            let end_epoch_index = *epoch_indices
2448                .last()
2449                .expect("metadata has at least one epoch");
2450            CycleSlipSplitArc {
2451                receiver,
2452                satellite_id,
2453                ambiguity_id,
2454                start_epoch_index,
2455                end_epoch_index,
2456                n_epochs: epoch_indices.len(),
2457            }
2458        })
2459        .collect()
2460}
2461
2462fn dual_cycle_slip_split_metadata(
2463    epochs: &[DualCycleSlipEpoch],
2464    split_sides: &BTreeSet<(CycleSlipReceiver, String)>,
2465) -> Vec<CycleSlipSplitArc> {
2466    let mut grouped = BTreeMap::<(CycleSlipReceiver, String, String), Vec<usize>>::new();
2467
2468    for (epoch_index, epoch) in epochs.iter().enumerate() {
2469        dual_split_metadata_entries(
2470            CycleSlipReceiver::Base,
2471            epoch_index,
2472            &epoch.base_observations,
2473            split_sides,
2474            &mut grouped,
2475        );
2476        dual_split_metadata_entries(
2477            CycleSlipReceiver::Rover,
2478            epoch_index,
2479            &epoch.rover_observations,
2480            split_sides,
2481            &mut grouped,
2482        );
2483    }
2484
2485    grouped
2486        .into_iter()
2487        .map(|((receiver, satellite_id, ambiguity_id), epoch_indices)| {
2488            let start_epoch_index = *epoch_indices
2489                .first()
2490                .expect("metadata has at least one epoch");
2491            let end_epoch_index = *epoch_indices
2492                .last()
2493                .expect("metadata has at least one epoch");
2494            CycleSlipSplitArc {
2495                receiver,
2496                satellite_id,
2497                ambiguity_id,
2498                start_epoch_index,
2499                end_epoch_index,
2500                n_epochs: epoch_indices.len(),
2501            }
2502        })
2503        .collect()
2504}
2505
2506fn split_metadata_entries(
2507    receiver: CycleSlipReceiver,
2508    epoch_index: usize,
2509    observations: &[CycleSlipObservation],
2510    split_sides: &BTreeSet<(CycleSlipReceiver, String)>,
2511    grouped: &mut BTreeMap<(CycleSlipReceiver, String, String), Vec<usize>>,
2512) {
2513    let mut observations = observations.iter().collect::<Vec<_>>();
2514    observations.sort_by(|a, b| a.satellite_id.cmp(&b.satellite_id));
2515
2516    for obs in observations {
2517        if split_sides.contains(&(receiver, obs.satellite_id.clone())) {
2518            grouped
2519                .entry((receiver, obs.satellite_id.clone(), obs.ambiguity_id.clone()))
2520                .or_default()
2521                .push(epoch_index);
2522        }
2523    }
2524}
2525
2526fn dual_split_metadata_entries(
2527    receiver: CycleSlipReceiver,
2528    epoch_index: usize,
2529    observations: &[DualCycleSlipObservation],
2530    split_sides: &BTreeSet<(CycleSlipReceiver, String)>,
2531    grouped: &mut BTreeMap<(CycleSlipReceiver, String, String), Vec<usize>>,
2532) {
2533    let mut observations = observations.iter().collect::<Vec<_>>();
2534    observations.sort_by(|a, b| a.satellite_id.cmp(&b.satellite_id));
2535
2536    for obs in observations {
2537        if split_sides.contains(&(receiver, obs.satellite_id.clone())) {
2538            grouped
2539                .entry((receiver, obs.satellite_id.clone(), obs.ambiguity_id.clone()))
2540                .or_default()
2541                .push(epoch_index);
2542        }
2543    }
2544}
2545
2546fn segment_reacquired_arcs(mut epochs: Vec<CycleSlipEpoch>) -> Vec<CycleSlipEpoch> {
2547    segment_receiver_reacquisitions(&mut epochs, CycleSlipReceiver::Base);
2548    segment_receiver_reacquisitions(&mut epochs, CycleSlipReceiver::Rover);
2549    epochs
2550}
2551
2552fn segment_receiver_reacquisitions(epochs: &mut [CycleSlipEpoch], receiver: CycleSlipReceiver) {
2553    let mut present_last = BTreeSet::<String>::new();
2554    let mut arcs = BTreeMap::<String, usize>::new();
2555
2556    for epoch in epochs {
2557        let observations = match receiver {
2558            CycleSlipReceiver::Base => &mut epoch.base_observations,
2559            CycleSlipReceiver::Rover => &mut epoch.rover_observations,
2560        };
2561        observations.sort_by(|a, b| a.satellite_id.cmp(&b.satellite_id));
2562        let present = observations
2563            .iter()
2564            .map(|obs| obs.satellite_id.clone())
2565            .collect::<BTreeSet<_>>();
2566
2567        for obs in observations {
2568            let reacquired =
2569                arcs.contains_key(&obs.satellite_id) && !present_last.contains(&obs.satellite_id);
2570            let arc = arcs.get(&obs.satellite_id).copied().unwrap_or(0) + usize::from(reacquired);
2571            arcs.insert(obs.satellite_id.clone(), arc);
2572
2573            if arc > 0 {
2574                obs.ambiguity_id = reacquired_ambiguity_id_core(&obs.ambiguity_id, arc);
2575            }
2576        }
2577
2578        present_last = present;
2579    }
2580}
2581
2582fn segment_reacquired_dual_arcs(mut epochs: Vec<DualCycleSlipEpoch>) -> Vec<DualCycleSlipEpoch> {
2583    segment_dual_receiver_reacquisitions(&mut epochs, CycleSlipReceiver::Base);
2584    segment_dual_receiver_reacquisitions(&mut epochs, CycleSlipReceiver::Rover);
2585    epochs
2586}
2587
2588fn segment_dual_receiver_reacquisitions(
2589    epochs: &mut [DualCycleSlipEpoch],
2590    receiver: CycleSlipReceiver,
2591) {
2592    let mut present_last = BTreeSet::<String>::new();
2593    let mut arcs = BTreeMap::<String, usize>::new();
2594
2595    for epoch in epochs {
2596        let observations = match receiver {
2597            CycleSlipReceiver::Base => &mut epoch.base_observations,
2598            CycleSlipReceiver::Rover => &mut epoch.rover_observations,
2599        };
2600        observations.sort_by(|a, b| a.satellite_id.cmp(&b.satellite_id));
2601        let present = observations
2602            .iter()
2603            .map(|obs| obs.satellite_id.clone())
2604            .collect::<BTreeSet<_>>();
2605
2606        for obs in observations {
2607            let reacquired =
2608                arcs.contains_key(&obs.satellite_id) && !present_last.contains(&obs.satellite_id);
2609            let arc = arcs.get(&obs.satellite_id).copied().unwrap_or(0) + usize::from(reacquired);
2610            arcs.insert(obs.satellite_id.clone(), arc);
2611
2612            if arc > 0 {
2613                obs.ambiguity_id = reacquired_ambiguity_id_core(&obs.ambiguity_id, arc);
2614            }
2615        }
2616
2617        present_last = present;
2618    }
2619}
2620
2621fn split_side_ambiguity_id_core(
2622    satellite_id: &str,
2623    receiver: CycleSlipReceiver,
2624    segment: usize,
2625) -> String {
2626    format!(
2627        "{satellite_id}@{}#{segment}",
2628        cycle_slip_receiver_tag(receiver)
2629    )
2630}
2631
2632fn reacquired_ambiguity_id_core(ambiguity_id: &str, arc: usize) -> String {
2633    let base = strip_reacquired_suffix(ambiguity_id);
2634    format!("{base}~ra{arc}")
2635}
2636
2637fn strip_reacquired_suffix(ambiguity_id: &str) -> &str {
2638    if let Some(index) = ambiguity_id.rfind("~ra") {
2639        let suffix = &ambiguity_id[index + 3..];
2640        if !suffix.is_empty() && suffix.chars().all(|ch| ch.is_ascii_digit()) {
2641            return &ambiguity_id[..index];
2642        }
2643    }
2644    ambiguity_id
2645}
2646
2647fn cycle_slip_receiver_tag(receiver: CycleSlipReceiver) -> &'static str {
2648    match receiver {
2649        CycleSlipReceiver::Base => "base",
2650        CycleSlipReceiver::Rover => "rover",
2651    }
2652}
2653
2654fn satellite_system(satellite_id: &str) -> String {
2655    crate::id::constellation_letter(satellite_id).to_string()
2656}
2657
2658fn single_difference_ambiguity_id(
2659    sat: &str,
2660    base_obs: &Observation,
2661    rover_obs: &Observation,
2662) -> AmbiguityId {
2663    let token = match (
2664        base_obs.ambiguity_id.as_str(),
2665        rover_obs.ambiguity_id.as_str(),
2666    ) {
2667        (base_id, rover_id) if base_id == sat && rover_id == sat => sat.to_string(),
2668        (base_id, rover_id) if base_id == sat => rover_id.to_string(),
2669        (base_id, rover_id) if rover_id == sat => base_id.to_string(),
2670        (base_id, rover_id) if base_id == rover_id => base_id.to_string(),
2671        (base_id, rover_id) => format!("{sat}:base={base_id},rover={rover_id}"),
2672    };
2673    AmbiguityId::new(token)
2674}
2675
2676fn single_difference_dual_ambiguity_id(
2677    sat: &str,
2678    base_obs: &DualObservation,
2679    rover_obs: &DualObservation,
2680) -> AmbiguityId {
2681    let token = match (
2682        base_obs.ambiguity_id.as_str(),
2683        rover_obs.ambiguity_id.as_str(),
2684    ) {
2685        (base_id, rover_id) if base_id == sat && rover_id == sat => sat.to_string(),
2686        (base_id, rover_id) if base_id == sat => rover_id.to_string(),
2687        (base_id, rover_id) if rover_id == sat => base_id.to_string(),
2688        (base_id, rover_id) if base_id == rover_id => base_id.to_string(),
2689        (base_id, rover_id) => format!("{sat}:base={base_id},rover={rover_id}"),
2690    };
2691    AmbiguityId::new(token)
2692}
2693
2694fn double_difference_ambiguity_id(
2695    sat: &str,
2696    sat_sd_id: &AmbiguityId,
2697    ref_sd: &SingleDifference,
2698) -> AmbiguityId {
2699    let token = if sat_sd_id.as_str() == sat && ref_sd.ambiguity_id.as_str() == ref_sd.satellite_id
2700    {
2701        sat.to_string()
2702    } else {
2703        format!("{sat_sd_id}|ref={}", ref_sd.ambiguity_id)
2704    };
2705    AmbiguityId::new(token)
2706}
2707
2708fn double_difference_dual_ambiguity_id(
2709    sat: &str,
2710    sat_sd_id: &AmbiguityId,
2711    ref_sd: &DualSingleDifference,
2712) -> AmbiguityId {
2713    let token = if sat_sd_id.as_str() == sat && ref_sd.ambiguity_id.as_str() == ref_sd.satellite_id
2714    {
2715        sat.to_string()
2716    } else {
2717        format!("{sat_sd_id}|ref={}", ref_sd.ambiguity_id)
2718    };
2719    AmbiguityId::new(token)
2720}
2721
2722#[cfg(test)]
2723mod tests {
2724    use super::*;
2725
2726    fn gps_l1_hz() -> f64 {
2727        crate::frequencies::frequency_hz(
2728            crate::GnssSystem::Gps,
2729            crate::frequencies::CarrierBand::L1,
2730        )
2731        .expect("canonical GPS L1 carrier exists")
2732    }
2733
2734    fn gps_l2_hz() -> f64 {
2735        crate::frequencies::frequency_hz(
2736            crate::GnssSystem::Gps,
2737            crate::frequencies::CarrierBand::L2,
2738        )
2739        .expect("canonical GPS L2 carrier exists")
2740    }
2741
2742    fn obs(sat: &str, code_m: f64, phase_m: f64) -> Observation {
2743        Observation {
2744            satellite_id: sat.to_string(),
2745            ambiguity_id: sat.to_string(),
2746            code_m,
2747            phase_m,
2748        }
2749    }
2750
2751    fn dual_observation(ambiguity_id: &str, wide_lane_phase_cycles: f64) -> DualObservation {
2752        DualObservation {
2753            ambiguity_id: ambiguity_id.to_string(),
2754            p1_m: 0.0,
2755            p2_m: 0.0,
2756            phi1_cycles: wide_lane_phase_cycles,
2757            phi2_cycles: 0.0,
2758            f1_hz: gps_l1_hz(),
2759            f2_hz: gps_l2_hz(),
2760        }
2761    }
2762
2763    fn dual_pair(sat: &str, base_wide_lane: f64, rover_wide_lane: f64) -> DualSatelliteObservation {
2764        DualSatelliteObservation {
2765            satellite_id: sat.to_string(),
2766            base: dual_observation(sat, base_wide_lane),
2767            rover: dual_observation(sat, rover_wide_lane),
2768        }
2769    }
2770
2771    fn split_dual_pair(
2772        sat: &str,
2773        rover_id: &str,
2774        base_wide_lane: f64,
2775        rover_wide_lane: f64,
2776    ) -> DualSatelliteObservation {
2777        DualSatelliteObservation {
2778            satellite_id: sat.to_string(),
2779            base: dual_observation(sat, base_wide_lane),
2780            rover: dual_observation(rover_id, rover_wide_lane),
2781        }
2782    }
2783
2784    fn if_observation(
2785        ambiguity_id: &str,
2786        p1_m: f64,
2787        p2_m: f64,
2788        phi1_cycles: f64,
2789        phi2_cycles: f64,
2790        tropo_m: f64,
2791    ) -> DualIonosphereFreeObservation {
2792        DualIonosphereFreeObservation {
2793            ambiguity_id: ambiguity_id.to_string(),
2794            p1_m,
2795            p2_m,
2796            phi1_cycles,
2797            phi2_cycles,
2798            f1_hz: gps_l1_hz(),
2799            f2_hz: gps_l2_hz(),
2800            tropo_m,
2801        }
2802    }
2803
2804    fn if_pair(
2805        sat: &str,
2806        base_code: f64,
2807        rover_code: f64,
2808        base_phase_cycles: f64,
2809        rover_phase_cycles: f64,
2810    ) -> DualIonosphereFreeSatelliteObservation {
2811        DualIonosphereFreeSatelliteObservation {
2812            satellite_id: sat.to_string(),
2813            base: if_observation(
2814                sat,
2815                base_code,
2816                base_code + 2.0,
2817                base_phase_cycles,
2818                base_phase_cycles - 4.0,
2819                0.25,
2820            ),
2821            rover: if_observation(
2822                sat,
2823                rover_code,
2824                rover_code + 2.5,
2825                rover_phase_cycles,
2826                rover_phase_cycles - 3.0,
2827                0.5,
2828            ),
2829        }
2830    }
2831
2832    fn arc_obs(sat: &str, ambiguity_id: &str, code_m: f64, phase_m: f64) -> Observation {
2833        Observation {
2834            satellite_id: sat.to_string(),
2835            ambiguity_id: ambiguity_id.to_string(),
2836            code_m,
2837            phase_m,
2838        }
2839    }
2840
2841    fn smooth_obs(
2842        sat: &str,
2843        ambiguity_id: &str,
2844        code_m: f64,
2845        phase_m: f64,
2846        lli: Option<i64>,
2847    ) -> CodeSmoothingObservation {
2848        CodeSmoothingObservation {
2849            satellite_id: sat.to_string(),
2850            ambiguity_id: ambiguity_id.to_string(),
2851            code_m,
2852            phase_m,
2853            lli,
2854        }
2855    }
2856
2857    fn dual_slip_obs(
2858        sat: &str,
2859        ambiguity_id: &str,
2860        phi1_cycles: f64,
2861        phi2_cycles: f64,
2862        lli1: Option<i64>,
2863        lli2: Option<i64>,
2864    ) -> DualCycleSlipObservation {
2865        DualCycleSlipObservation {
2866            satellite_id: sat.to_string(),
2867            ambiguity_id: ambiguity_id.to_string(),
2868            p1_m: 20.0,
2869            p2_m: 21.0,
2870            phi1_cycles,
2871            phi2_cycles,
2872            f1_hz: gps_l1_hz(),
2873            f2_hz: gps_l2_hz(),
2874            lli1,
2875            lli2,
2876        }
2877    }
2878
2879    fn dual_slip_epoch(
2880        epoch_sort_key: &str,
2881        gap_time_s: f64,
2882        base_observations: Vec<DualCycleSlipObservation>,
2883        rover_observations: Vec<DualCycleSlipObservation>,
2884    ) -> DualCycleSlipEpoch {
2885        DualCycleSlipEpoch {
2886            epoch_sort_key: epoch_sort_key.to_string(),
2887            gap_time_s: Some(gap_time_s),
2888            base_observations,
2889            rover_observations,
2890        }
2891    }
2892
2893    fn code_bits(observations: &[CodeSmoothingObservation]) -> Vec<(&str, u64)> {
2894        observations
2895            .iter()
2896            .map(|obs| (obs.satellite_id.as_str(), obs.code_m.to_bits()))
2897            .collect()
2898    }
2899
2900    fn ambiguity_id<'a>(
2901        observations: &'a [CycleSlipObservation],
2902        satellite_id: &str,
2903    ) -> Option<&'a str> {
2904        observations
2905            .iter()
2906            .find(|obs| obs.satellite_id == satellite_id)
2907            .map(|obs| obs.ambiguity_id.as_str())
2908    }
2909
2910    fn dual_ambiguity_id<'a>(
2911        observations: &'a [DualCycleSlipObservation],
2912        satellite_id: &str,
2913    ) -> Option<&'a str> {
2914        observations
2915            .iter()
2916            .find(|obs| obs.satellite_id == satellite_id)
2917            .map(|obs| obs.ambiguity_id.as_str())
2918    }
2919
2920    fn baseline_reference_epoch(entries: &[(&str, [f64; 3])]) -> BaselineReferenceEpoch {
2921        BaselineReferenceEpoch {
2922            available_satellite_ids: entries.iter().map(|(sat, _)| sat.to_string()).collect(),
2923            satellite_positions_m: entries
2924                .iter()
2925                .map(|(sat, pos)| (sat.to_string(), *pos))
2926                .collect(),
2927        }
2928    }
2929
2930    #[test]
2931    fn double_differences_cancel_receiver_and_common_terms() {
2932        let sats = ["G01", "G02", "G03", "G04"];
2933        let reference = "G01";
2934        let base_clock_m = 125.0;
2935        let rover_clock_m = -42.0;
2936        let base_ranges = BTreeMap::from([
2937            ("G01", 20_000.0),
2938            ("G02", 21_000.0),
2939            ("G03", 22_500.0),
2940            ("G04", 23_100.0),
2941        ]);
2942        let rover_ranges = BTreeMap::from([
2943            ("G01", 20_010.0),
2944            ("G02", 21_025.0),
2945            ("G03", 22_480.0),
2946            ("G04", 23_150.0),
2947        ]);
2948        let common_errors =
2949            BTreeMap::from([("G01", 3.25), ("G02", -12.0), ("G03", 8.5), ("G04", 1.0)]);
2950        let base_ambiguities =
2951            BTreeMap::from([("G01", 2.0), ("G02", -3.0), ("G03", 7.0), ("G04", 11.0)]);
2952        let rover_ambiguities =
2953            BTreeMap::from([("G01", 5.0), ("G02", 4.0), ("G03", 1.0), ("G04", 19.0)]);
2954
2955        let base = sats
2956            .iter()
2957            .map(|sat| {
2958                obs(
2959                    sat,
2960                    base_ranges[sat] + base_clock_m + common_errors[sat],
2961                    base_ranges[sat] + base_clock_m + common_errors[sat] + base_ambiguities[sat],
2962                )
2963            })
2964            .collect::<Vec<_>>();
2965        let rover = sats
2966            .iter()
2967            .map(|sat| {
2968                obs(
2969                    sat,
2970                    rover_ranges[sat] + rover_clock_m + common_errors[sat],
2971                    rover_ranges[sat] + rover_clock_m + common_errors[sat] + rover_ambiguities[sat],
2972                )
2973            })
2974            .collect::<Vec<_>>();
2975
2976        let result = double_differences(
2977            &base,
2978            &rover,
2979            ReferenceSelection::Satellite(reference.to_string()),
2980        )
2981        .unwrap();
2982
2983        assert_eq!(
2984            result.reference_satellite_id,
2985            ReferenceReport::Satellite(reference.to_string())
2986        );
2987        assert!(result.dropped_sats.is_empty());
2988
2989        let by_sat = result
2990            .double_differences
2991            .iter()
2992            .map(|dd| (dd.satellite_id.as_str(), dd))
2993            .collect::<BTreeMap<_, _>>();
2994
2995        for sat in sats.into_iter().filter(|sat| *sat != reference) {
2996            let expected_code = rover_ranges[sat]
2997                - base_ranges[sat]
2998                - (rover_ranges[reference] - base_ranges[reference]);
2999            let expected_phase = expected_code + (rover_ambiguities[sat] - base_ambiguities[sat])
3000                - (rover_ambiguities[reference] - base_ambiguities[reference]);
3001            let dd = by_sat[sat];
3002            assert_eq!(dd.reference_satellite_id, reference);
3003            assert_eq!(dd.code_m, expected_code);
3004            assert_eq!(dd.phase_m, expected_phase);
3005        }
3006    }
3007
3008    #[test]
3009    fn auto_reference_reports_dropped_satellites() {
3010        let base = vec![
3011            obs("G02", 210.0, 211.0),
3012            obs("G01", 100.0, 101.0),
3013            obs("G09", 900.0, 901.0),
3014        ];
3015        let rover = vec![
3016            obs("G02", 230.0, 233.0),
3017            obs("G01", 105.0, 108.0),
3018            obs("G10", 1000.0, 1001.0),
3019        ];
3020
3021        let result = double_differences(&base, &rover, ReferenceSelection::Auto).unwrap();
3022
3023        assert_eq!(
3024            result.reference_satellite_id,
3025            ReferenceReport::Satellite("G01".to_string())
3026        );
3027        assert_eq!(result.dropped_sats, ["G09".to_string(), "G10".to_string()]);
3028        assert_eq!(
3029            result.double_differences,
3030            vec![DoubleDifference {
3031                satellite_id: "G02".to_string(),
3032                reference_satellite_id: "G01".to_string(),
3033                ambiguity_id: "G02".to_string(),
3034                code_m: 15.0,
3035                phase_m: 15.0,
3036            }]
3037        );
3038    }
3039
3040    #[test]
3041    fn explicit_arc_ids_feed_double_difference_ambiguity_id() {
3042        let base = vec![obs("G01", 100.0, 101.0), obs("G02", 210.0, 211.0)];
3043        let rover = vec![
3044            arc_obs("G01", "G01#2", 105.0, 108.0),
3045            arc_obs("G02", "G02#2", 230.0, 233.0),
3046        ];
3047
3048        let result = double_differences(
3049            &base,
3050            &rover,
3051            ReferenceSelection::Satellite("G01".to_string()),
3052        )
3053        .unwrap();
3054
3055        assert_eq!(result.double_differences[0].ambiguity_id, "G02#2|ref=G01#2");
3056    }
3057
3058    #[test]
3059    fn multi_system_uses_reference_per_system() {
3060        let base = vec![
3061            obs("G01", 10.0, 11.0),
3062            obs("G02", 20.0, 21.0),
3063            obs("E11", 30.0, 31.0),
3064            obs("E19", 40.0, 41.0),
3065        ];
3066        let rover = vec![
3067            obs("G01", 12.0, 14.0),
3068            obs("G02", 24.0, 27.0),
3069            obs("E11", 33.0, 35.0),
3070            obs("E19", 46.0, 49.0),
3071        ];
3072        let refs = BTreeMap::from([
3073            ("E".to_string(), "E11".to_string()),
3074            ("G".to_string(), "G01".to_string()),
3075        ]);
3076
3077        let result =
3078            double_differences(&base, &rover, ReferenceSelection::PerSystem(refs.clone())).unwrap();
3079
3080        assert_eq!(
3081            result.reference_satellite_id,
3082            ReferenceReport::PerSystem(refs)
3083        );
3084        assert_eq!(
3085            result
3086                .double_differences
3087                .iter()
3088                .map(|dd| (&dd.satellite_id, &dd.reference_satellite_id))
3089                .collect::<Vec<_>>(),
3090            vec![
3091                (&"E19".to_string(), &"E11".to_string()),
3092                (&"G02".to_string(), &"G01".to_string()),
3093            ]
3094        );
3095    }
3096
3097    #[test]
3098    fn multi_system_requires_non_reference_satellite_per_system() {
3099        let base = vec![obs("G01", 10.0, 11.0), obs("E01", 30.0, 31.0)];
3100        let rover = vec![obs("G01", 12.0, 14.0), obs("E01", 33.0, 35.0)];
3101        let expected = Err(DoubleDifferenceError::TooFewCommonSatellites {
3102            count: 1,
3103            minimum: 2,
3104        });
3105
3106        assert_eq!(
3107            double_differences(&base, &rover, ReferenceSelection::Auto),
3108            expected
3109        );
3110        assert_eq!(
3111            double_differences(
3112                &base,
3113                &rover,
3114                ReferenceSelection::PerSystem(BTreeMap::from([
3115                    ("E".to_string(), "E01".to_string()),
3116                    ("G".to_string(), "G01".to_string()),
3117                ])),
3118            ),
3119            expected
3120        );
3121    }
3122
3123    #[test]
3124    fn baseline_auto_reference_uses_highest_average_elevation_per_system() {
3125        let base = [10.0, 0.0, 0.0];
3126        let epochs = vec![
3127            baseline_reference_epoch(&[
3128                ("G01", [20.0, 0.0, 0.0]),
3129                ("G02", [20.0, 0.0, 0.0]),
3130                ("G03", [20.0, 0.0, 0.0]),
3131                ("E01", [10.0, 10.0, 0.0]),
3132                ("E02", [20.0, 0.0, 0.0]),
3133            ]),
3134            baseline_reference_epoch(&[
3135                ("G01", [10.0, 10.0, 0.0]),
3136                ("G03", [20.0, 0.0, 0.0]),
3137                ("E01", [10.0, 10.0, 0.0]),
3138                ("E02", [20.0, 0.0, 0.0]),
3139            ]),
3140        ];
3141
3142        let refs =
3143            baseline_reference_satellites(base, &epochs, BaselineReferenceSelection::Auto).unwrap();
3144
3145        assert_eq!(
3146            refs,
3147            BTreeMap::from([
3148                ("E".to_string(), "E02".to_string()),
3149                ("G".to_string(), "G03".to_string()),
3150            ])
3151        );
3152
3153        let g_epochs = baseline_epochs_for_system(&epochs, "G");
3154        assert_eq!(
3155            average_elevation_score(base, &g_epochs, "G01")
3156                .unwrap()
3157                .to_bits(),
3158            0x3fe0_0000_0000_0000
3159        );
3160        assert_eq!(
3161            average_elevation_score(base, &g_epochs, "G03")
3162                .unwrap()
3163                .to_bits(),
3164            0x3ff0_0000_0000_0000
3165        );
3166    }
3167
3168    #[test]
3169    fn baseline_auto_reference_ties_by_satellite_id() {
3170        let base = [10.0, 0.0, 0.0];
3171        let epochs = vec![baseline_reference_epoch(&[
3172            ("G01", [20.0, 0.0, 0.0]),
3173            ("G02", [20.0, 0.0, 0.0]),
3174        ])];
3175
3176        let refs =
3177            baseline_reference_satellites(base, &epochs, BaselineReferenceSelection::Auto).unwrap();
3178
3179        assert_eq!(refs, BTreeMap::from([("G".to_string(), "G01".to_string())]));
3180    }
3181
3182    #[test]
3183    fn baseline_reference_errors_when_available_satellite_position_missing() {
3184        let mut epoch = baseline_reference_epoch(&[("G01", [20.0, 0.0, 0.0])]);
3185        epoch.available_satellite_ids.push("G02".to_string());
3186
3187        assert_eq!(
3188            baseline_reference_satellites(
3189                [10.0, 0.0, 0.0],
3190                &[epoch],
3191                BaselineReferenceSelection::Auto
3192            ),
3193            Err(DoubleDifferenceError::MissingSatellitePosition(
3194                "G02".to_string()
3195            ))
3196        );
3197    }
3198
3199    #[test]
3200    fn elevation_mask_keeps_epoch_satellites_above_threshold() {
3201        let base = [10.0, 0.0, 0.0];
3202        let up = local_up(base);
3203
3204        assert_eq!(
3205            elevation_score_with_up(base, up, [20.0, 0.0, 0.0])
3206                .unwrap()
3207                .to_bits(),
3208            0x3ff0_0000_0000_0000
3209        );
3210        assert_eq!(
3211            elevation_score_with_up(base, up, [10.0, 10.0, 0.0])
3212                .unwrap()
3213                .to_bits(),
3214            0x0000_0000_0000_0000
3215        );
3216        assert_eq!(
3217            elevation_score_with_up(base, up, [0.0, 0.0, 0.0])
3218                .unwrap()
3219                .to_bits(),
3220            0xbff0_0000_0000_0000
3221        );
3222
3223        let epochs = vec![
3224            ElevationMaskEpoch {
3225                satellite_positions_m: BTreeMap::from([
3226                    ("G01".to_string(), [20.0, 0.0, 0.0]),
3227                    ("G02".to_string(), [10.0, 10.0, 0.0]),
3228                    ("G03".to_string(), [0.0, 0.0, 0.0]),
3229                ]),
3230            },
3231            ElevationMaskEpoch {
3232                satellite_positions_m: BTreeMap::from([
3233                    ("G01".to_string(), [20.0, 0.0, 0.0]),
3234                    ("G02".to_string(), [20.0, 0.0, 0.0]),
3235                    ("G04".to_string(), [0.0, 0.0, 0.0]),
3236                ]),
3237            },
3238        ];
3239
3240        let result = apply_elevation_mask(base, &epochs, 30.0).unwrap();
3241
3242        assert_eq!(
3243            result.epochs,
3244            vec![
3245                ElevationMaskEpochResult {
3246                    kept_satellite_ids: vec!["G01".to_string()],
3247                },
3248                ElevationMaskEpochResult {
3249                    kept_satellite_ids: vec!["G01".to_string(), "G02".to_string()],
3250                },
3251            ]
3252        );
3253        assert_eq!(
3254            result.masked_satellite_ids,
3255            vec!["G02".to_string(), "G03".to_string(), "G04".to_string()]
3256        );
3257    }
3258
3259    #[test]
3260    fn baseline_reference_rejects_invalid_geometry() {
3261        let epochs = vec![baseline_reference_epoch(&[
3262            ("G01", [20.0, 0.0, 0.0]),
3263            ("G02", [30.0, 0.0, 0.0]),
3264        ])];
3265
3266        assert_eq!(
3267            baseline_reference_satellites(
3268                [f64::NAN, 0.0, 0.0],
3269                &epochs,
3270                BaselineReferenceSelection::Auto,
3271            ),
3272            Err(DoubleDifferenceError::InvalidInput {
3273                field: "rtk base position_m",
3274                reason: "not finite",
3275            })
3276        );
3277        assert_eq!(
3278            baseline_reference_satellites(
3279                [0.0, 0.0, 0.0],
3280                &epochs,
3281                BaselineReferenceSelection::Auto,
3282            ),
3283            Err(DoubleDifferenceError::InvalidInput {
3284                field: "rtk base position_m",
3285                reason: "degenerate geometry",
3286            })
3287        );
3288
3289        let invalid_sat = vec![baseline_reference_epoch(&[
3290            ("G01", [20.0, 0.0, 0.0]),
3291            ("G02", [f64::INFINITY, 0.0, 0.0]),
3292        ])];
3293        assert_eq!(
3294            baseline_reference_satellites(
3295                [10.0, 0.0, 0.0],
3296                &invalid_sat,
3297                BaselineReferenceSelection::Auto,
3298            ),
3299            Err(DoubleDifferenceError::InvalidInput {
3300                field: "rtk satellite position_m",
3301                reason: "not finite",
3302            })
3303        );
3304
3305        let coincident_sat = vec![baseline_reference_epoch(&[
3306            ("G01", [10.0, 0.0, 0.0]),
3307            ("G02", [20.0, 0.0, 0.0]),
3308        ])];
3309        assert_eq!(
3310            baseline_reference_satellites(
3311                [10.0, 0.0, 0.0],
3312                &coincident_sat,
3313                BaselineReferenceSelection::Auto,
3314            ),
3315            Err(DoubleDifferenceError::InvalidInput {
3316                field: "rtk line of sight_m",
3317                reason: "degenerate geometry",
3318            })
3319        );
3320    }
3321
3322    #[test]
3323    fn elevation_mask_rejects_invalid_geometry_and_mask() {
3324        let epochs = vec![ElevationMaskEpoch {
3325            satellite_positions_m: BTreeMap::from([("G01".to_string(), [20.0, 0.0, 0.0])]),
3326        }];
3327
3328        assert_eq!(
3329            apply_elevation_mask([10.0, 0.0, 0.0], &epochs, f64::NAN),
3330            Err(DoubleDifferenceError::InvalidInput {
3331                field: "rtk elevation mask_deg",
3332                reason: "not finite",
3333            })
3334        );
3335        assert_eq!(
3336            apply_elevation_mask([10.0, 0.0, 0.0], &epochs, 91.0),
3337            Err(DoubleDifferenceError::InvalidInput {
3338                field: "rtk elevation mask_deg",
3339                reason: "out of range",
3340            })
3341        );
3342
3343        let coincident_sat = vec![ElevationMaskEpoch {
3344            satellite_positions_m: BTreeMap::from([("G01".to_string(), [10.0, 0.0, 0.0])]),
3345        }];
3346        assert_eq!(
3347            apply_elevation_mask([10.0, 0.0, 0.0], &coincident_sat, 0.0),
3348            Err(DoubleDifferenceError::InvalidInput {
3349                field: "rtk line of sight_m",
3350                reason: "degenerate geometry",
3351            })
3352        );
3353    }
3354
3355    #[test]
3356    fn code_smoothing_has_frozen_bits_and_receiver_state() {
3357        let epochs = vec![
3358            CodeSmoothingEpoch {
3359                base_observations: vec![
3360                    smooth_obs("G02", "G02", 100.0, 10.0, None),
3361                    smooth_obs("G01", "G01", 200.0, 20.0, None),
3362                ],
3363                rover_observations: vec![
3364                    smooth_obs("G03", "G03", 600.0, 60.0, None),
3365                    smooth_obs("G02", "G02", 500.0, 50.0, None),
3366                ],
3367            },
3368            CodeSmoothingEpoch {
3369                base_observations: vec![
3370                    smooth_obs("G01", "G01", 201.0, 21.0, Some(1)),
3371                    smooth_obs("G02", "G02", 102.0, 11.0, None),
3372                ],
3373                rover_observations: vec![smooth_obs("G02", "G02", 504.0, 51.0, None)],
3374            },
3375            CodeSmoothingEpoch {
3376                base_observations: vec![
3377                    smooth_obs("G02", "G02", 103.0, 13.0, None),
3378                    smooth_obs("G01", "G01", 202.0, 22.0, None),
3379                ],
3380                rover_observations: vec![
3381                    smooth_obs("G03", "G03", 604.0, 62.0, None),
3382                    smooth_obs("G02", "G02", 506.0, 53.0, None),
3383                ],
3384            },
3385        ];
3386
3387        let result = hatch_smooth_baseline_code_epochs(&epochs, 2).unwrap();
3388
3389        assert_eq!(
3390            code_bits(&result[0].base_observations),
3391            vec![
3392                ("G01", 0x4069_0000_0000_0000),
3393                ("G02", 0x4059_0000_0000_0000),
3394            ]
3395        );
3396        assert_eq!(
3397            code_bits(&result[1].base_observations),
3398            vec![
3399                ("G01", 0x4069_2000_0000_0000),
3400                ("G02", 0x4059_6000_0000_0000),
3401            ]
3402        );
3403        assert_eq!(
3404            code_bits(&result[2].base_observations),
3405            vec![
3406                ("G01", 0x4069_4000_0000_0000),
3407                ("G02", 0x4059_d000_0000_0000),
3408            ]
3409        );
3410        assert_eq!(
3411            code_bits(&result[0].rover_observations),
3412            vec![
3413                ("G02", 0x407f_4000_0000_0000),
3414                ("G03", 0x4082_c000_0000_0000),
3415            ]
3416        );
3417        assert_eq!(
3418            code_bits(&result[1].rover_observations),
3419            vec![("G02", 0x407f_6800_0000_0000)]
3420        );
3421        assert_eq!(
3422            code_bits(&result[2].rover_observations),
3423            vec![
3424                ("G02", 0x407f_9400_0000_0000),
3425                ("G03", 0x4082_d800_0000_0000),
3426            ]
3427        );
3428        assert_eq!(result[1].base_observations[0].lli, Some(1));
3429        assert_eq!(
3430            hatch_smooth_baseline_code_epochs(&epochs, 0),
3431            Err(CodeSmoothingError::InvalidWindowCap)
3432        );
3433    }
3434
3435    #[test]
3436    fn cycle_slip_prep_pins_policy_and_reacquisition_behavior() {
3437        let epochs = vec![
3438            CycleSlipEpoch {
3439                base_observations: vec![
3440                    smooth_obs("G02", "G02", 100.0, 10.0, None),
3441                    smooth_obs("G01", "G01", 200.0, 20.0, None),
3442                ],
3443                rover_observations: vec![
3444                    smooth_obs("G02", "G02", 101.0, 11.0, None),
3445                    smooth_obs("G01", "G01", 201.0, 21.0, None),
3446                ],
3447            },
3448            CycleSlipEpoch {
3449                base_observations: vec![
3450                    smooth_obs("G01", "G01", 210.0, 30.0, None),
3451                    smooth_obs("G02", "G02", 110.0, 15.0, None),
3452                ],
3453                rover_observations: vec![
3454                    smooth_obs("G01", "G01", 211.0, 31.0, None),
3455                    smooth_obs("G02", "G02", 111.0, 16.0, Some(1)),
3456                ],
3457            },
3458            CycleSlipEpoch {
3459                base_observations: vec![smooth_obs("G01", "G01", 220.0, 40.0, None)],
3460                rover_observations: vec![smooth_obs("G01", "G01", 221.0, 41.0, None)],
3461            },
3462            CycleSlipEpoch {
3463                base_observations: vec![
3464                    smooth_obs("G02", "G02", 130.0, 18.0, None),
3465                    smooth_obs("G01", "G01", 230.0, 50.0, None),
3466                ],
3467                rover_observations: vec![
3468                    smooth_obs("G02", "G02", 131.0, 19.0, None),
3469                    smooth_obs("G01", "G01", 231.0, 51.0, None),
3470                ],
3471            },
3472        ];
3473
3474        assert_eq!(
3475            prepare_cycle_slip_baseline_epochs(&epochs, CycleSlipPolicy::Error),
3476            Err(CycleSlipPrepError::CycleSlipDetected {
3477                receiver: CycleSlipReceiver::Rover,
3478                satellite_id: "G02".to_string(),
3479                epoch_index: 1,
3480                reasons: vec![SlipReason::Lli],
3481            })
3482        );
3483
3484        let dropped =
3485            prepare_cycle_slip_baseline_epochs(&epochs, CycleSlipPolicy::DropSatellite).unwrap();
3486        assert_eq!(dropped.dropped_sats, vec!["G02".to_string()]);
3487        assert!(dropped.split_arcs.is_empty());
3488        assert!(dropped
3489            .epochs
3490            .iter()
3491            .all(
3492                |epoch| ambiguity_id(&epoch.base_observations, "G02").is_none()
3493                    && ambiguity_id(&epoch.rover_observations, "G02").is_none()
3494            ));
3495
3496        let split = prepare_cycle_slip_baseline_epochs(&epochs, CycleSlipPolicy::SplitArc).unwrap();
3497        assert!(split.dropped_sats.is_empty());
3498        assert_eq!(
3499            split.split_arcs,
3500            vec![
3501                CycleSlipSplitArc {
3502                    receiver: CycleSlipReceiver::Rover,
3503                    satellite_id: "G02".to_string(),
3504                    ambiguity_id: "G02@rover#1".to_string(),
3505                    start_epoch_index: 0,
3506                    end_epoch_index: 0,
3507                    n_epochs: 1,
3508                },
3509                CycleSlipSplitArc {
3510                    receiver: CycleSlipReceiver::Rover,
3511                    satellite_id: "G02".to_string(),
3512                    ambiguity_id: "G02@rover#2".to_string(),
3513                    start_epoch_index: 1,
3514                    end_epoch_index: 3,
3515                    n_epochs: 2,
3516                },
3517            ]
3518        );
3519        assert_eq!(
3520            ambiguity_id(&split.epochs[0].rover_observations, "G02"),
3521            Some("G02@rover#1")
3522        );
3523        assert_eq!(
3524            ambiguity_id(&split.epochs[1].rover_observations, "G02"),
3525            Some("G02@rover#2")
3526        );
3527        assert_eq!(
3528            ambiguity_id(&split.epochs[2].rover_observations, "G02"),
3529            None
3530        );
3531        assert_eq!(
3532            ambiguity_id(&split.epochs[3].rover_observations, "G02"),
3533            Some("G02@rover#2~ra1")
3534        );
3535        assert_eq!(
3536            ambiguity_id(&split.epochs[3].base_observations, "G02"),
3537            Some("G02~ra1")
3538        );
3539    }
3540
3541    #[test]
3542    fn cycle_slip_prep_rejects_non_finite_arc_data() {
3543        assert_eq!(
3544            prepare_cycle_slip_baseline_epochs(
3545                &[CycleSlipEpoch {
3546                    base_observations: vec![smooth_obs("G01", "G01", f64::NAN, 10.0, None)],
3547                    rover_observations: Vec::new(),
3548                }],
3549                CycleSlipPolicy::DropSatellite,
3550            ),
3551            Err(CycleSlipPrepError::InvalidInput {
3552                field: "rtk cycle slip code_m",
3553                reason: "not finite",
3554            })
3555        );
3556        assert_eq!(
3557            prepare_cycle_slip_baseline_epochs(
3558                &[CycleSlipEpoch {
3559                    base_observations: Vec::new(),
3560                    rover_observations: vec![smooth_obs("G01", "G01", 100.0, f64::INFINITY, None,)],
3561                }],
3562                CycleSlipPolicy::DropSatellite,
3563            ),
3564            Err(CycleSlipPrepError::InvalidInput {
3565                field: "rtk cycle slip phase_m",
3566                reason: "not finite",
3567            })
3568        );
3569    }
3570
3571    #[test]
3572    fn dual_cycle_slip_prep_rejects_invalid_arc_data_and_options() {
3573        let epochs = vec![dual_slip_epoch(
3574            "0",
3575            0.0,
3576            vec![dual_slip_obs("G01", "G01", 10.0, 8.0, None, None)],
3577            Vec::new(),
3578        )];
3579
3580        assert_eq!(
3581            prepare_dual_cycle_slip_baseline_epochs(
3582                &epochs,
3583                CycleSlipPolicy::DropSatellite,
3584                CycleSlipOptions {
3585                    gf_threshold_m: f64::NAN,
3586                    ..CycleSlipOptions::default()
3587                },
3588            ),
3589            Err(CycleSlipPrepError::InvalidInput {
3590                field: "rtk cycle slip gf_threshold_m",
3591                reason: "not finite",
3592            })
3593        );
3594        assert_eq!(
3595            prepare_dual_cycle_slip_baseline_epochs(
3596                &epochs,
3597                CycleSlipPolicy::DropSatellite,
3598                CycleSlipOptions {
3599                    min_arc_gap_s: 0.0,
3600                    ..CycleSlipOptions::default()
3601                },
3602            ),
3603            Err(CycleSlipPrepError::InvalidInput {
3604                field: "rtk cycle slip min_arc_gap_s",
3605                reason: "not positive",
3606            })
3607        );
3608
3609        let mut bad_gap = epochs.clone();
3610        bad_gap[0].gap_time_s = Some(f64::INFINITY);
3611        assert_eq!(
3612            prepare_dual_cycle_slip_baseline_epochs(
3613                &bad_gap,
3614                CycleSlipPolicy::DropSatellite,
3615                CycleSlipOptions::default(),
3616            ),
3617            Err(CycleSlipPrepError::InvalidInput {
3618                field: "rtk cycle slip gap_time_s",
3619                reason: "not finite",
3620            })
3621        );
3622
3623        let mut bad_phase = epochs.clone();
3624        bad_phase[0].base_observations[0].phi1_cycles = f64::NAN;
3625        assert_eq!(
3626            prepare_dual_cycle_slip_baseline_epochs(
3627                &bad_phase,
3628                CycleSlipPolicy::DropSatellite,
3629                CycleSlipOptions::default(),
3630            ),
3631            Err(CycleSlipPrepError::InvalidInput {
3632                field: "rtk cycle slip phi1_cycles",
3633                reason: "not finite",
3634            })
3635        );
3636
3637        let mut equal_frequency = epochs;
3638        equal_frequency[0].base_observations[0].f2_hz =
3639            equal_frequency[0].base_observations[0].f1_hz;
3640        assert_eq!(
3641            prepare_dual_cycle_slip_baseline_epochs(
3642                &equal_frequency,
3643                CycleSlipPolicy::DropSatellite,
3644                CycleSlipOptions::default(),
3645            ),
3646            Err(CycleSlipPrepError::InvalidInput {
3647                field: "rtk cycle slip frequencies_hz",
3648                reason: "degenerate frequencies",
3649            })
3650        );
3651
3652        let overflow_epoch = dual_slip_epoch(
3653            "0",
3654            0.0,
3655            vec![dual_slip_obs("G01", "G01", f64::MAX, -f64::MAX, None, None)],
3656            Vec::new(),
3657        );
3658        assert_eq!(
3659            prepare_dual_cycle_slip_baseline_epochs(
3660                &[overflow_epoch],
3661                CycleSlipPolicy::DropSatellite,
3662                CycleSlipOptions::default(),
3663            ),
3664            Err(CycleSlipPrepError::InvalidInput {
3665                field: "rtk cycle slip observation",
3666                reason: "not finite",
3667            })
3668        );
3669    }
3670
3671    #[test]
3672    fn dual_cycle_slip_prep_pins_policy_and_reacquisition_behavior() {
3673        let epochs = vec![
3674            dual_slip_epoch(
3675                "0",
3676                0.0,
3677                vec![
3678                    dual_slip_obs("G02", "G02", 10.0, 8.0, None, None),
3679                    dual_slip_obs("G01", "G01", 20.0, 18.0, None, None),
3680                ],
3681                vec![
3682                    dual_slip_obs("G02", "G02", 11.0, 9.0, None, None),
3683                    dual_slip_obs("G01", "G01", 21.0, 19.0, None, None),
3684                ],
3685            ),
3686            dual_slip_epoch(
3687                "1",
3688                1.0,
3689                vec![
3690                    dual_slip_obs("G01", "G01", 20.0, 18.0, None, None),
3691                    dual_slip_obs("G02", "G02", 10.0, 8.0, None, None),
3692                ],
3693                vec![
3694                    dual_slip_obs("G01", "G01", 21.0, 19.0, None, None),
3695                    dual_slip_obs("G02", "G02", 11.0, 9.0, Some(1), None),
3696                ],
3697            ),
3698            dual_slip_epoch(
3699                "2",
3700                2.0,
3701                vec![dual_slip_obs("G01", "G01", 20.0, 18.0, None, None)],
3702                vec![dual_slip_obs("G01", "G01", 21.0, 19.0, None, None)],
3703            ),
3704            dual_slip_epoch(
3705                "3",
3706                3.0,
3707                vec![
3708                    dual_slip_obs("G02", "G02", 10.0, 8.0, None, None),
3709                    dual_slip_obs("G01", "G01", 20.0, 18.0, None, None),
3710                ],
3711                vec![
3712                    dual_slip_obs("G02", "G02", 11.0, 9.0, None, None),
3713                    dual_slip_obs("G01", "G01", 21.0, 19.0, None, None),
3714                ],
3715            ),
3716        ];
3717        let options = CycleSlipOptions {
3718            gf_threshold_m: 1.0e9,
3719            mw_threshold_cycles: 1.0e9,
3720            min_arc_gap_s: 300.0,
3721        };
3722
3723        assert_eq!(
3724            prepare_dual_cycle_slip_baseline_epochs(&epochs, CycleSlipPolicy::Error, options),
3725            Err(CycleSlipPrepError::CycleSlipDetected {
3726                receiver: CycleSlipReceiver::Rover,
3727                satellite_id: "G02".to_string(),
3728                epoch_index: 1,
3729                reasons: vec![SlipReason::Lli],
3730            })
3731        );
3732
3733        let dropped = prepare_dual_cycle_slip_baseline_epochs(
3734            &epochs,
3735            CycleSlipPolicy::DropSatellite,
3736            options,
3737        )
3738        .unwrap();
3739        assert_eq!(dropped.dropped_sats, vec!["G02".to_string()]);
3740        assert!(dropped.split_arcs.is_empty());
3741        assert!(dropped.epochs.iter().all(|epoch| dual_ambiguity_id(
3742            &epoch.base_observations,
3743            "G02"
3744        )
3745        .is_none()
3746            && dual_ambiguity_id(&epoch.rover_observations, "G02").is_none()));
3747
3748        let split =
3749            prepare_dual_cycle_slip_baseline_epochs(&epochs, CycleSlipPolicy::SplitArc, options)
3750                .unwrap();
3751        assert!(split.dropped_sats.is_empty());
3752        assert_eq!(
3753            split.split_arcs,
3754            vec![
3755                CycleSlipSplitArc {
3756                    receiver: CycleSlipReceiver::Rover,
3757                    satellite_id: "G02".to_string(),
3758                    ambiguity_id: "G02@rover#1".to_string(),
3759                    start_epoch_index: 0,
3760                    end_epoch_index: 0,
3761                    n_epochs: 1,
3762                },
3763                CycleSlipSplitArc {
3764                    receiver: CycleSlipReceiver::Rover,
3765                    satellite_id: "G02".to_string(),
3766                    ambiguity_id: "G02@rover#2".to_string(),
3767                    start_epoch_index: 1,
3768                    end_epoch_index: 3,
3769                    n_epochs: 2,
3770                },
3771            ]
3772        );
3773        assert_eq!(
3774            dual_ambiguity_id(&split.epochs[0].rover_observations, "G02"),
3775            Some("G02@rover#1")
3776        );
3777        assert_eq!(
3778            dual_ambiguity_id(&split.epochs[1].rover_observations, "G02"),
3779            Some("G02@rover#2")
3780        );
3781        assert_eq!(
3782            dual_ambiguity_id(&split.epochs[2].rover_observations, "G02"),
3783            None
3784        );
3785        assert_eq!(
3786            dual_ambiguity_id(&split.epochs[3].rover_observations, "G02"),
3787            Some("G02@rover#2~ra1")
3788        );
3789        assert_eq!(
3790            dual_ambiguity_id(&split.epochs[3].base_observations, "G02"),
3791            Some("G02~ra1")
3792        );
3793    }
3794
3795    #[test]
3796    fn dual_cycle_slip_prep_uses_threshold_and_gap_classification() {
3797        let epochs = vec![
3798            dual_slip_epoch(
3799                "0",
3800                0.0,
3801                vec![dual_slip_obs("G03", "G03", 10.0, 8.0, None, None)],
3802                vec![dual_slip_obs("G03", "G03", 11.0, 9.0, None, None)],
3803            ),
3804            dual_slip_epoch(
3805                "1",
3806                20.0,
3807                vec![dual_slip_obs("G03", "G03", 11.0, 8.0, None, None)],
3808                vec![dual_slip_obs("G03", "G03", 11.0, 9.0, None, None)],
3809            ),
3810        ];
3811        let options = CycleSlipOptions {
3812            gf_threshold_m: 0.05,
3813            mw_threshold_cycles: 0.5,
3814            min_arc_gap_s: 10.0,
3815        };
3816
3817        assert_eq!(
3818            prepare_dual_cycle_slip_baseline_epochs(&epochs, CycleSlipPolicy::Error, options),
3819            Err(CycleSlipPrepError::CycleSlipDetected {
3820                receiver: CycleSlipReceiver::Base,
3821                satellite_id: "G03".to_string(),
3822                epoch_index: 1,
3823                reasons: vec![
3824                    SlipReason::DataGap,
3825                    SlipReason::GeometryFree,
3826                    SlipReason::MelbourneWubbena,
3827                ],
3828            })
3829        );
3830    }
3831
3832    #[test]
3833    fn baseline_reference_errors_match_public_tags() {
3834        let base = [10.0, 0.0, 0.0];
3835        let multi = vec![baseline_reference_epoch(&[
3836            ("G01", [20.0, 0.0, 0.0]),
3837            ("G02", [20.0, 0.0, 0.0]),
3838            ("E01", [20.0, 0.0, 0.0]),
3839            ("E02", [20.0, 0.0, 0.0]),
3840        ])];
3841        assert_eq!(
3842            baseline_reference_satellites(
3843                base,
3844                &multi,
3845                BaselineReferenceSelection::Satellite("G01".to_string()),
3846            ),
3847            Err(DoubleDifferenceError::ReferenceSatelliteSingleSystem(
3848                "G01".to_string()
3849            ))
3850        );
3851        assert_eq!(
3852            baseline_reference_satellites(
3853                base,
3854                &multi,
3855                BaselineReferenceSelection::PerSystem(BTreeMap::from([(
3856                    "G".to_string(),
3857                    "G01".to_string(),
3858                )])),
3859            ),
3860            Err(DoubleDifferenceError::ReferenceSatelliteMissingSystem(
3861                "E".to_string()
3862            ))
3863        );
3864
3865        let split = vec![
3866            baseline_reference_epoch(&[("G01", [20.0, 0.0, 0.0])]),
3867            baseline_reference_epoch(&[("G02", [20.0, 0.0, 0.0])]),
3868        ];
3869        assert_eq!(
3870            baseline_reference_satellites(base, &split, BaselineReferenceSelection::Auto),
3871            Err(DoubleDifferenceError::NoCommonReferenceSatellite(
3872                "G".to_string()
3873            ))
3874        );
3875    }
3876
3877    #[test]
3878    fn errors_are_tagged() {
3879        assert_eq!(
3880            double_differences(
3881                &[obs("G01", 1.0, 2.0)],
3882                &[obs("G01", 1.0, 2.0)],
3883                ReferenceSelection::Auto
3884            ),
3885            Err(DoubleDifferenceError::TooFewCommonSatellites {
3886                count: 1,
3887                minimum: 2,
3888            })
3889        );
3890        assert_eq!(
3891            double_differences(
3892                &[obs("G01", 1.0, 2.0), obs("G01", 3.0, 4.0)],
3893                &[obs("G01", 1.0, 2.0), obs("G02", 3.0, 4.0)],
3894                ReferenceSelection::Auto,
3895            ),
3896            Err(DoubleDifferenceError::DuplicateObservation(
3897                "G01".to_string()
3898            ))
3899        );
3900        assert_eq!(
3901            double_differences(
3902                &[obs("G01", 1.0, 2.0), obs("G02", 3.0, 4.0)],
3903                &[obs("G01", 1.0, 2.0), obs("G02", 3.0, 4.0)],
3904                ReferenceSelection::Satellite("G99".to_string()),
3905            ),
3906            Err(DoubleDifferenceError::ReferenceSatelliteMissing(
3907                "G99".to_string()
3908            ))
3909        );
3910    }
3911
3912    #[test]
3913    fn double_differences_reject_non_finite_observations() {
3914        let rover = vec![obs("G01", 10.0, 11.0), obs("G02", 20.0, 21.0)];
3915
3916        assert_eq!(
3917            double_differences(
3918                &[obs("G01", 1.0, 2.0), obs("G02", f64::NAN, 4.0)],
3919                &rover,
3920                ReferenceSelection::Auto,
3921            ),
3922            Err(DoubleDifferenceError::InvalidInput {
3923                field: "rtk observation code_m",
3924                reason: "not finite",
3925            })
3926        );
3927
3928        assert_eq!(
3929            double_differences(
3930                &[obs("G01", 1.0, 2.0), obs("G02", 3.0, 4.0)],
3931                &[obs("G01", 10.0, f64::INFINITY), obs("G02", 20.0, 21.0)],
3932                ReferenceSelection::Auto,
3933            ),
3934            Err(DoubleDifferenceError::InvalidInput {
3935                field: "rtk observation phase_m",
3936                reason: "not finite",
3937            })
3938        );
3939    }
3940
3941    #[test]
3942    fn estimates_dual_frequency_wide_lane_integers() {
3943        let epochs = vec![
3944            DualEpoch {
3945                observations: vec![
3946                    dual_pair("G01", 0.0, 1.0),
3947                    dual_pair("G02", 0.0, 4.0),
3948                    dual_pair("G03", 0.0, -1.0),
3949                ],
3950            },
3951            DualEpoch {
3952                observations: vec![
3953                    dual_pair("G01", 0.0, 2.0),
3954                    dual_pair("G02", 0.0, 5.0),
3955                    dual_pair("G03", 0.0, 0.0),
3956                ],
3957            },
3958        ];
3959
3960        let fixed = estimate_wide_lane_ambiguities(
3961            &epochs,
3962            "G01",
3963            WideLaneOptions {
3964                min_epochs: 2,
3965                tolerance_cycles: 1.0e-9,
3966                skip_short_fragments: false,
3967            },
3968        )
3969        .unwrap();
3970
3971        assert_eq!(
3972            fixed,
3973            BTreeMap::from([("G02".to_string(), 3), ("G03".to_string(), -2)])
3974        );
3975    }
3976
3977    #[test]
3978    fn split_arc_wide_lane_skips_short_fragments() {
3979        let epochs = vec![
3980            DualEpoch {
3981                observations: vec![
3982                    dual_pair("G01", 0.0, 1.0),
3983                    split_dual_pair("G02", "G02@rover#1", 0.0, 4.0),
3984                    dual_pair("G03", 0.0, -1.0),
3985                ],
3986            },
3987            DualEpoch {
3988                observations: vec![dual_pair("G01", 0.0, 2.0), dual_pair("G03", 0.0, 0.0)],
3989            },
3990        ];
3991
3992        let options = WideLaneOptions {
3993            min_epochs: 2,
3994            tolerance_cycles: 1.0e-9,
3995            skip_short_fragments: true,
3996        };
3997        let fixed = estimate_wide_lane_ambiguities(&epochs, "G01", options).unwrap();
3998        assert_eq!(fixed, BTreeMap::from([("G03".to_string(), -2)]));
3999
4000        let err = estimate_wide_lane_ambiguities(
4001            &epochs,
4002            "G01",
4003            WideLaneOptions {
4004                skip_short_fragments: false,
4005                ..options
4006            },
4007        )
4008        .unwrap_err();
4009        assert_eq!(
4010            err,
4011            WideLaneError::TooFewWideLaneEpochs {
4012                ambiguity_id: "G02@rover#1|ref=G01".to_string(),
4013                count: 1,
4014                minimum: 2,
4015            }
4016        );
4017    }
4018
4019    #[test]
4020    fn wide_lane_rejects_invalid_inputs_and_options() {
4021        let epochs = vec![DualEpoch {
4022            observations: vec![dual_pair("G01", 0.0, 1.0), dual_pair("G02", 0.0, 4.0)],
4023        }];
4024
4025        assert_eq!(
4026            estimate_wide_lane_ambiguities(
4027                &epochs,
4028                "G01",
4029                WideLaneOptions {
4030                    min_epochs: 0,
4031                    tolerance_cycles: 1.0e-9,
4032                    skip_short_fragments: false,
4033                },
4034            ),
4035            Err(WideLaneError::InvalidInput {
4036                field: "rtk wide lane min_epochs",
4037                reason: "not positive",
4038            })
4039        );
4040        assert_eq!(
4041            estimate_wide_lane_ambiguities(
4042                &epochs,
4043                "G01",
4044                WideLaneOptions {
4045                    min_epochs: 1,
4046                    tolerance_cycles: f64::NAN,
4047                    skip_short_fragments: false,
4048                },
4049            ),
4050            Err(WideLaneError::InvalidInput {
4051                field: "rtk wide lane tolerance_cycles",
4052                reason: "not finite",
4053            })
4054        );
4055
4056        let mut bad_epoch = epochs;
4057        bad_epoch[0].observations[1].rover.p1_m = f64::INFINITY;
4058        assert_eq!(
4059            estimate_wide_lane_ambiguities(
4060                &bad_epoch,
4061                "G01",
4062                WideLaneOptions {
4063                    min_epochs: 1,
4064                    tolerance_cycles: 1.0e-9,
4065                    skip_short_fragments: false,
4066                },
4067            ),
4068            Err(WideLaneError::InvalidInput {
4069                field: "rtk wide lane p1_m",
4070                reason: "not finite",
4071            })
4072        );
4073    }
4074
4075    #[test]
4076    fn builds_ionosphere_free_epochs_and_narrow_lane_params() {
4077        let epochs = vec![
4078            DualIonosphereFreeEpoch {
4079                observations: vec![
4080                    if_pair(
4081                        "G01",
4082                        20_000_000.0,
4083                        20_000_020.0,
4084                        105_100_000.0,
4085                        105_100_040.0,
4086                    ),
4087                    if_pair(
4088                        "G02",
4089                        21_000_000.0,
4090                        21_000_035.0,
4091                        110_200_000.0,
4092                        110_200_090.0,
4093                    ),
4094                    if_pair(
4095                        "G03",
4096                        22_000_000.0,
4097                        22_000_055.0,
4098                        115_300_000.0,
4099                        115_300_120.0,
4100                    ),
4101                ],
4102            },
4103            DualIonosphereFreeEpoch {
4104                observations: vec![
4105                    if_pair(
4106                        "G01",
4107                        20_000_100.0,
4108                        20_000_120.0,
4109                        105_100_500.0,
4110                        105_100_540.0,
4111                    ),
4112                    if_pair(
4113                        "G02",
4114                        21_000_100.0,
4115                        21_000_135.0,
4116                        110_200_500.0,
4117                        110_200_590.0,
4118                    ),
4119                    if_pair(
4120                        "G03",
4121                        22_000_100.0,
4122                        22_000_155.0,
4123                        115_300_500.0,
4124                        115_300_620.0,
4125                    ),
4126                ],
4127            },
4128        ];
4129        let wide_lanes = BTreeMap::from([("G02".to_string(), 3), ("G03".to_string(), -5)]);
4130
4131        let result = build_ionosphere_free_baseline_epochs(&epochs, "G01", &wide_lanes).unwrap();
4132
4133        assert_eq!(
4134            result
4135                .wavelengths_m
4136                .iter()
4137                .map(|(id, value)| (id.as_str(), value.to_bits()))
4138                .collect::<Vec<_>>(),
4139            [("G02", 0x3fbb614bed5136b9), ("G03", 0x3fbb614bed5136b9),]
4140        );
4141        assert_eq!(
4142            result
4143                .offsets_m
4144                .iter()
4145                .map(|(id, value)| (id.as_str(), value.to_bits()))
4146                .collect::<Vec<_>>(),
4147            [("G02", 0x3ff21e814dfd4618), ("G03", 0xbffe32d781fb74d4),]
4148        );
4149        assert_eq!(result.epochs.len(), 2);
4150        assert_eq!(
4151            result
4152                .epochs
4153                .iter()
4154                .map(|epoch| (
4155                    epoch.epoch_index,
4156                    epoch.satellite_ids.clone(),
4157                    epoch
4158                        .base_observations
4159                        .iter()
4160                        .map(|obs| (
4161                            obs.satellite_id.as_str(),
4162                            obs.ambiguity_id.as_str(),
4163                            obs.code_m.to_bits(),
4164                            obs.phase_m.to_bits()
4165                        ))
4166                        .collect::<Vec<_>>(),
4167                    epoch
4168                        .rover_observations
4169                        .iter()
4170                        .map(|obs| (
4171                            obs.satellite_id.as_str(),
4172                            obs.ambiguity_id.as_str(),
4173                            obs.code_m.to_bits(),
4174                            obs.phase_m.to_bits()
4175                        ))
4176                        .collect::<Vec<_>>()
4177                ))
4178                .collect::<Vec<_>>(),
4179            vec![
4180                (
4181                    0,
4182                    vec!["G01".to_string(), "G02".to_string(), "G03".to_string()],
4183                    vec![
4184                        ("G01", "G01", 0x417312cfca8965e4, 0x416570ac29af7848),
4185                        ("G02", "G02", 0x417406f3ca8965e4, 0x41667b02f0ff8bd8),
4186                        ("G03", "G03", 0x4174fb17ca8965e4, 0x41678559b84f9f64),
4187                    ],
4188                    vec![
4189                        ("G01", "G01", 0x417312d0fa2bbf5f, 0x416570ac9e819db4),
4190                        ("G02", "G02", 0x417406f5ea2bbf5e, 0x41667b0410f1cbd0),
4191                        ("G03", "G03", 0x4174fb1b2a2bbf5e, 0x4167855b3eeebc18),
4192                    ],
4193                ),
4194                (
4195                    1,
4196                    vec!["G01".to_string(), "G02".to_string(), "G03".to_string()],
4197                    vec![
4198                        ("G01", "G01", 0x417312d60a8965e4, 0x416570b2d8f081bc),
4199                        ("G02", "G02", 0x417406fa0a8965e4, 0x41667b09a0409544),
4200                        ("G03", "G03", 0x4174fb1e0a8965e4, 0x416785606790a8d4),
4201                    ],
4202                    vec![
4203                        ("G01", "G01", 0x417312d73a2bbf5f, 0x416570b34dc2a728),
4204                        ("G02", "G02", 0x417406fc2a2bbf5e, 0x41667b0ac032d540),
4205                        ("G03", "G03", 0x4174fb216a2bbf5e, 0x41678561ee2fc588),
4206                    ],
4207                ),
4208            ]
4209        );
4210    }
4211
4212    #[test]
4213    fn ionosphere_free_builders_reject_invalid_tropo_and_setup_inputs() {
4214        let mut epochs = vec![DualIonosphereFreeEpoch {
4215            observations: vec![
4216                if_pair(
4217                    "G01",
4218                    20_000_000.0,
4219                    20_000_020.0,
4220                    105_100_000.0,
4221                    105_100_040.0,
4222                ),
4223                if_pair(
4224                    "G02",
4225                    21_000_000.0,
4226                    21_000_035.0,
4227                    110_200_000.0,
4228                    110_200_090.0,
4229                ),
4230            ],
4231        }];
4232        epochs[0].observations[1].rover.tropo_m = f64::NAN;
4233        assert_eq!(
4234            build_ionosphere_free_baseline_epochs(
4235                &epochs,
4236                "G01",
4237                &BTreeMap::from([("G02".to_string(), 3)]),
4238            ),
4239            Err(IonosphereFreeBaselineError::InvalidInput {
4240                field: "rtk if tropo_m",
4241                reason: "not finite",
4242            })
4243        );
4244
4245        let setup_epochs = vec![DualIonosphereFreeSetupEpoch {
4246            jd_whole: 2_460_100.5,
4247            jd_fraction: 2.0,
4248            observations: vec![dual_pair("G01", 0.0, 1.0), dual_pair("G02", 0.0, 4.0)],
4249            base_satellite_positions_m: BTreeMap::from([
4250                ("G01".to_string(), [20.0, 0.0, 0.0]),
4251                ("G02".to_string(), [30.0, 0.0, 0.0]),
4252            ]),
4253            rover_satellite_positions_m: BTreeMap::from([
4254                ("G01".to_string(), [20.0, 0.0, 0.0]),
4255                ("G02".to_string(), [30.0, 0.0, 0.0]),
4256            ]),
4257        }];
4258        assert_eq!(
4259            prepare_ionosphere_free_baseline_epochs(
4260                [10.0, 0.0, 0.0],
4261                [0.0, 0.0, 0.0],
4262                &setup_epochs,
4263                "G01",
4264                &BTreeMap::from([("G02".to_string(), 3)]),
4265                false,
4266            ),
4267            Err(IonosphereFreeBaselineError::InvalidInput {
4268                field: "rtk if setup jd_fraction",
4269                reason: "out of range",
4270            })
4271        );
4272
4273        let tropo_epochs = vec![DualIonosphereFreeSetupEpoch {
4274            jd_whole: 2_460_100.5,
4275            jd_fraction: 0.0,
4276            observations: vec![dual_pair("G01", 0.0, 1.0), dual_pair("G02", 0.0, 4.0)],
4277            base_satellite_positions_m: BTreeMap::from([
4278                ("G01".to_string(), [10.0, 0.0, 0.0]),
4279                ("G02".to_string(), [30.0, 0.0, 0.0]),
4280            ]),
4281            rover_satellite_positions_m: BTreeMap::from([
4282                ("G01".to_string(), [20.0, 0.0, 0.0]),
4283                ("G02".to_string(), [30.0, 0.0, 0.0]),
4284            ]),
4285        }];
4286        assert_eq!(
4287            prepare_ionosphere_free_baseline_epochs(
4288                [10.0, 0.0, 0.0],
4289                [0.0, 0.0, 0.0],
4290                &tropo_epochs,
4291                "G01",
4292                &BTreeMap::from([("G02".to_string(), 3)]),
4293                true,
4294            ),
4295            Err(IonosphereFreeBaselineError::InvalidInput {
4296                field: "rtk tropo line of sight_m",
4297                reason: "degenerate geometry",
4298            })
4299        );
4300    }
4301
4302    #[test]
4303    fn ionosphere_free_setup_rejects_invalid_julian_split_without_panic() {
4304        let setup_epochs = vec![DualIonosphereFreeSetupEpoch {
4305            jd_whole: f64::NAN,
4306            jd_fraction: 0.0,
4307            observations: vec![dual_pair("G01", 0.0, 1.0), dual_pair("G02", 0.0, 4.0)],
4308            base_satellite_positions_m: BTreeMap::from([
4309                (
4310                    "G01".to_string(),
4311                    [20_200_000.0, 14_000_000.0, 21_700_000.0],
4312                ),
4313                (
4314                    "G02".to_string(),
4315                    [21_200_000.0, 13_000_000.0, 20_700_000.0],
4316                ),
4317            ]),
4318            rover_satellite_positions_m: BTreeMap::from([
4319                (
4320                    "G01".to_string(),
4321                    [20_200_100.0, 14_000_000.0, 21_700_000.0],
4322                ),
4323                (
4324                    "G02".to_string(),
4325                    [21_200_100.0, 13_000_000.0, 20_700_000.0],
4326                ),
4327            ]),
4328        }];
4329
4330        let result = std::panic::catch_unwind(|| {
4331            prepare_ionosphere_free_baseline_epochs(
4332                [6_378_137.0, 0.0, 0.0],
4333                [1.0, 0.0, 0.0],
4334                &setup_epochs,
4335                "G01",
4336                &BTreeMap::from([("G02".to_string(), 3)]),
4337                true,
4338            )
4339        });
4340
4341        assert!(result.is_ok(), "invalid Julian split must not panic");
4342        assert_eq!(
4343            result.expect("invalid Julian split should not unwind"),
4344            Err(IonosphereFreeBaselineError::InvalidInput {
4345                field: "rtk if setup jd_whole",
4346                reason: "not finite",
4347            })
4348        );
4349    }
4350
4351    #[test]
4352    fn ionosphere_free_setup_rejects_nonfinite_tropo_receiver_without_panic() {
4353        let setup_epochs = vec![DualIonosphereFreeSetupEpoch {
4354            jd_whole: 2_460_100.5,
4355            jd_fraction: 0.0,
4356            observations: vec![dual_pair("G01", 0.0, 1.0), dual_pair("G02", 0.0, 4.0)],
4357            base_satellite_positions_m: BTreeMap::from([
4358                (
4359                    "G01".to_string(),
4360                    [20_200_000.0, 14_000_000.0, 21_700_000.0],
4361                ),
4362                (
4363                    "G02".to_string(),
4364                    [21_200_000.0, 13_000_000.0, 20_700_000.0],
4365                ),
4366            ]),
4367            rover_satellite_positions_m: BTreeMap::from([
4368                (
4369                    "G01".to_string(),
4370                    [20_200_100.0, 14_000_000.0, 21_700_000.0],
4371                ),
4372                (
4373                    "G02".to_string(),
4374                    [21_200_100.0, 13_000_000.0, 20_700_000.0],
4375                ),
4376            ]),
4377        }];
4378
4379        let result = std::panic::catch_unwind(|| {
4380            prepare_ionosphere_free_baseline_epochs(
4381                [f64::NAN, 0.0, 0.0],
4382                [1.0, 0.0, 0.0],
4383                &setup_epochs,
4384                "G01",
4385                &BTreeMap::from([("G02".to_string(), 3)]),
4386                true,
4387            )
4388        });
4389
4390        assert!(result.is_ok(), "non-finite tropo receiver must not panic");
4391        assert_eq!(
4392            result.expect("non-finite tropo receiver should not unwind"),
4393            Err(IonosphereFreeBaselineError::InvalidInput {
4394                field: "rtk tropo base position_m",
4395                reason: "not finite",
4396            })
4397        );
4398    }
4399
4400    #[test]
4401    fn ionosphere_free_setup_handles_antimeridian_tropo_receiver_without_panic() {
4402        let setup_epochs = vec![DualIonosphereFreeSetupEpoch {
4403            jd_whole: 2_460_100.5,
4404            jd_fraction: 0.0,
4405            observations: vec![dual_pair("G01", 0.0, 1.0), dual_pair("G02", 0.0, 4.0)],
4406            base_satellite_positions_m: BTreeMap::from([
4407                (
4408                    "G01".to_string(),
4409                    [-20_200_000.0, -14_000_000.0, 21_700_000.0],
4410                ),
4411                (
4412                    "G02".to_string(),
4413                    [-21_200_000.0, -13_000_000.0, 20_700_000.0],
4414                ),
4415            ]),
4416            rover_satellite_positions_m: BTreeMap::from([
4417                (
4418                    "G01".to_string(),
4419                    [-20_200_100.0, -14_000_000.0, 21_700_000.0],
4420                ),
4421                (
4422                    "G02".to_string(),
4423                    [-21_200_100.0, -13_000_000.0, 20_700_000.0],
4424                ),
4425            ]),
4426        }];
4427
4428        let result = std::panic::catch_unwind(|| {
4429            prepare_ionosphere_free_baseline_epochs(
4430                [-6_378_137.0, -0.0, 0.0],
4431                [1.0, 0.0, 0.0],
4432                &setup_epochs,
4433                "G01",
4434                &BTreeMap::from([("G02".to_string(), 3)]),
4435                true,
4436            )
4437        });
4438
4439        assert!(result.is_ok(), "antimeridian tropo receiver must not panic");
4440        result
4441            .expect("antimeridian tropo receiver should not unwind")
4442            .expect("antimeridian tropo receiver should prepare IF epochs");
4443    }
4444
4445    #[test]
4446    fn ionosphere_free_epoch_builder_skips_missing_wide_lane_fragments() {
4447        let epochs = vec![DualIonosphereFreeEpoch {
4448            observations: vec![
4449                if_pair(
4450                    "G01",
4451                    20_000_000.0,
4452                    20_000_020.0,
4453                    105_100_000.0,
4454                    105_100_040.0,
4455                ),
4456                if_pair(
4457                    "G02",
4458                    21_000_000.0,
4459                    21_000_035.0,
4460                    110_200_000.0,
4461                    110_200_090.0,
4462                ),
4463                if_pair(
4464                    "G03",
4465                    22_000_000.0,
4466                    22_000_055.0,
4467                    115_300_000.0,
4468                    115_300_120.0,
4469                ),
4470            ],
4471        }];
4472        let wide_lanes = BTreeMap::from([("G02".to_string(), 3)]);
4473
4474        let result = build_ionosphere_free_baseline_epochs(&epochs, "G01", &wide_lanes).unwrap();
4475
4476        assert_eq!(result.epochs[0].satellite_ids, ["G01", "G02"]);
4477        assert_eq!(
4478            result.wavelengths_m.keys().collect::<Vec<_>>(),
4479            [&"G02".to_string()]
4480        );
4481    }
4482
4483    #[test]
4484    fn wide_lane_errors_on_equal_frequencies() {
4485        let mut bad = dual_pair("G02", 0.0, 4.0);
4486        bad.base.f2_hz = gps_l1_hz();
4487        let epochs = vec![DualEpoch {
4488            observations: vec![dual_pair("G01", 0.0, 1.0), bad],
4489        }];
4490
4491        let err = estimate_wide_lane_ambiguities(
4492            &epochs,
4493            "G01",
4494            WideLaneOptions {
4495                min_epochs: 1,
4496                tolerance_cycles: 0.5,
4497                skip_short_fragments: false,
4498            },
4499        )
4500        .unwrap_err();
4501
4502        assert_eq!(
4503            err,
4504            WideLaneError::WideLaneFailed {
4505                satellite_id: "G02".to_string(),
4506                reason: CarrierPhaseError::EqualFrequencies,
4507            }
4508        );
4509    }
4510}