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