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 = (mask_deg * DEG_TO_RAD).sin();
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 = lat.cos() * lon.cos() * dx + lat.cos() * lon.sin() * dy + lat.sin() * dz;
1873        let elevation_deg = (u / range).clamp(-1.0, 1.0).asin() * RAD_TO_DEG;
1874        elevation_deg * DEG_TO_RAD
1875    }
1876}
1877
1878fn dual_if_satellite<'a>(
1879    epoch: &'a DualIonosphereFreeEpoch,
1880    sat: &str,
1881) -> Option<&'a DualIonosphereFreeSatelliteObservation> {
1882    epoch
1883        .observations
1884        .iter()
1885        .find(|obs| obs.satellite_id == sat)
1886}
1887
1888fn dual_if_epoch_common_sats(epoch: &DualIonosphereFreeEpoch) -> Vec<String> {
1889    epoch
1890        .observations
1891        .iter()
1892        .map(|obs| obs.satellite_id.clone())
1893        .collect::<BTreeSet<_>>()
1894        .into_iter()
1895        .collect()
1896}
1897
1898fn dual_if_single_difference_ambiguity(
1899    epoch: &DualIonosphereFreeEpoch,
1900    sat: &str,
1901) -> Option<DualSingleDifference> {
1902    let obs = dual_if_satellite(epoch, sat)?;
1903    Some(DualSingleDifference {
1904        satellite_id: sat.to_string(),
1905        ambiguity_id: single_difference_if_ambiguity_id(sat, &obs.base, &obs.rover),
1906        wide_lane_cycles: 0.0,
1907    })
1908}
1909
1910fn dual_if_wide_lane_ambiguity_id(
1911    epoch: &DualIonosphereFreeEpoch,
1912    sat: &str,
1913    ref_sd: &DualSingleDifference,
1914) -> Option<AmbiguityId> {
1915    let obs = dual_if_satellite(epoch, sat)?;
1916    let sat_sd_id = single_difference_if_ambiguity_id(sat, &obs.base, &obs.rover);
1917    Some(double_difference_dual_ambiguity_id(sat, &sat_sd_id, ref_sd))
1918}
1919
1920fn single_difference_if_ambiguity_id(
1921    sat: &str,
1922    base_obs: &DualIonosphereFreeObservation,
1923    rover_obs: &DualIonosphereFreeObservation,
1924) -> AmbiguityId {
1925    let token = match (
1926        base_obs.ambiguity_id.as_str(),
1927        rover_obs.ambiguity_id.as_str(),
1928    ) {
1929        (base_id, rover_id) if base_id == sat && rover_id == sat => sat.to_string(),
1930        (base_id, rover_id) if base_id == sat => rover_id.to_string(),
1931        (base_id, rover_id) if rover_id == sat => base_id.to_string(),
1932        (base_id, rover_id) if base_id == rover_id => base_id.to_string(),
1933        (base_id, rover_id) => format!("{sat}:base={base_id},rover={rover_id}"),
1934    };
1935    AmbiguityId::new(token)
1936}
1937
1938fn reference_report(refs: BTreeMap<String, String>) -> ReferenceReport {
1939    if refs.len() == 1 {
1940        ReferenceReport::Satellite(refs.into_values().next().expect("single reference"))
1941    } else {
1942        ReferenceReport::PerSystem(refs)
1943    }
1944}
1945
1946fn smooth_receiver_code_epochs(
1947    epochs: &mut [CodeSmoothingEpoch],
1948    receiver: Receiver,
1949    hatch_window_cap: usize,
1950) {
1951    let mut states = BTreeMap::<String, CodeSmoothingState>::new();
1952
1953    for epoch in epochs {
1954        let observations = match receiver {
1955            Receiver::Base => &mut epoch.base_observations,
1956            Receiver::Rover => &mut epoch.rover_observations,
1957        };
1958        observations.sort_by(|a, b| a.satellite_id.cmp(&b.satellite_id));
1959
1960        for observation in observations {
1961            let state = states.get(&observation.ambiguity_id).copied();
1962            let (code_m, next_state) =
1963                smooth_observation_code(observation, state, hatch_window_cap);
1964            observation.code_m = code_m;
1965            states.insert(observation.ambiguity_id.clone(), next_state);
1966        }
1967    }
1968}
1969
1970fn smooth_observation_code(
1971    observation: &CodeSmoothingObservation,
1972    state: Option<CodeSmoothingState>,
1973    hatch_window_cap: usize,
1974) -> (f64, CodeSmoothingState) {
1975    if state.is_none() || rtk_lli_set(observation.lli) {
1976        return (
1977            observation.code_m,
1978            CodeSmoothingState {
1979                p_smooth_m: observation.code_m,
1980                phase_m: observation.phase_m,
1981                window: 1,
1982            },
1983        );
1984    }
1985
1986    let state = state.expect("checked above");
1987    let window = (state.window + 1).min(hatch_window_cap);
1988    let n = window as f64;
1989    let p_smooth_m = observation.code_m / n
1990        + (n - 1.0) / n * (state.p_smooth_m + (observation.phase_m - state.phase_m));
1991    (
1992        p_smooth_m,
1993        CodeSmoothingState {
1994            p_smooth_m,
1995            phase_m: observation.phase_m,
1996            window,
1997        },
1998    )
1999}
2000
2001fn rtk_lli_set(lli: Option<i64>) -> bool {
2002    lli.is_some_and(|value| (value & 1) == 1)
2003}
2004
2005fn validate_cycle_slip_baseline_epochs(
2006    epochs: &[CycleSlipEpoch],
2007) -> Result<(), CycleSlipPrepError> {
2008    for epoch in epochs {
2009        for observation in &epoch.base_observations {
2010            validate_cycle_slip_observation(observation)?;
2011        }
2012        for observation in &epoch.rover_observations {
2013            validate_cycle_slip_observation(observation)?;
2014        }
2015    }
2016    Ok(())
2017}
2018
2019fn validate_cycle_slip_observation(
2020    observation: &CycleSlipObservation,
2021) -> Result<(), CycleSlipPrepError> {
2022    validate::finite(observation.code_m, "rtk cycle slip code_m")
2023        .map_err(cycle_slip_invalid_input)?;
2024    validate::finite(observation.phase_m, "rtk cycle slip phase_m")
2025        .map_err(cycle_slip_invalid_input)?;
2026    Ok(())
2027}
2028
2029fn validate_cycle_slip_options(options: CycleSlipOptions) -> Result<(), CycleSlipPrepError> {
2030    validate::finite_positive(options.gf_threshold_m, "rtk cycle slip gf_threshold_m")
2031        .map_err(cycle_slip_invalid_input)?;
2032    validate::finite_positive(
2033        options.mw_threshold_cycles,
2034        "rtk cycle slip mw_threshold_cycles",
2035    )
2036    .map_err(cycle_slip_invalid_input)?;
2037    validate::finite_positive(options.min_arc_gap_s, "rtk cycle slip min_arc_gap_s")
2038        .map_err(cycle_slip_invalid_input)?;
2039    Ok(())
2040}
2041
2042fn validate_dual_cycle_slip_baseline_epochs(
2043    epochs: &[DualCycleSlipEpoch],
2044) -> Result<(), CycleSlipPrepError> {
2045    for epoch in epochs {
2046        if let Some(gap_time_s) = epoch.gap_time_s {
2047            validate::finite(gap_time_s, "rtk cycle slip gap_time_s")
2048                .map_err(cycle_slip_invalid_input)?;
2049        }
2050        for observation in &epoch.base_observations {
2051            validate_dual_cycle_slip_observation(observation)?;
2052        }
2053        for observation in &epoch.rover_observations {
2054            validate_dual_cycle_slip_observation(observation)?;
2055        }
2056    }
2057    Ok(())
2058}
2059
2060fn validate_dual_cycle_slip_observation(
2061    observation: &DualCycleSlipObservation,
2062) -> Result<(), CycleSlipPrepError> {
2063    validate::finite(observation.p1_m, "rtk cycle slip p1_m").map_err(cycle_slip_invalid_input)?;
2064    validate::finite(observation.p2_m, "rtk cycle slip p2_m").map_err(cycle_slip_invalid_input)?;
2065    validate::finite(observation.phi1_cycles, "rtk cycle slip phi1_cycles")
2066        .map_err(cycle_slip_invalid_input)?;
2067    validate::finite(observation.phi2_cycles, "rtk cycle slip phi2_cycles")
2068        .map_err(cycle_slip_invalid_input)?;
2069    validate::finite_positive(observation.f1_hz, "rtk cycle slip f1_hz")
2070        .map_err(cycle_slip_invalid_input)?;
2071    validate::finite_positive(observation.f2_hz, "rtk cycle slip f2_hz")
2072        .map_err(cycle_slip_invalid_input)?;
2073    if (observation.f1_hz - observation.f2_hz).abs() < crate::carrier_phase::FREQ_EPSILON_HZ {
2074        return Err(invalid_cycle_slip_input(
2075            "rtk cycle slip frequencies_hz",
2076            "degenerate frequencies",
2077        ));
2078    }
2079    Ok(())
2080}
2081
2082fn cycle_slip_invalid_input(error: validate::FieldError) -> CycleSlipPrepError {
2083    CycleSlipPrepError::InvalidInput {
2084        field: error.field(),
2085        reason: error.reason(),
2086    }
2087}
2088
2089fn invalid_cycle_slip_input(field: &'static str, reason: &'static str) -> CycleSlipPrepError {
2090    CycleSlipPrepError::InvalidInput { field, reason }
2091}
2092
2093fn cycle_slip_events(epochs: &[CycleSlipEpoch]) -> Vec<CycleSlipEvent> {
2094    let mut events = Vec::new();
2095    for (epoch_index, epoch) in epochs.iter().enumerate() {
2096        cycle_slip_events_for_receiver(
2097            CycleSlipReceiver::Base,
2098            epoch_index,
2099            &epoch.base_observations,
2100            &mut events,
2101        );
2102        cycle_slip_events_for_receiver(
2103            CycleSlipReceiver::Rover,
2104            epoch_index,
2105            &epoch.rover_observations,
2106            &mut events,
2107        );
2108    }
2109    events
2110}
2111
2112fn cycle_slip_events_for_receiver(
2113    receiver: CycleSlipReceiver,
2114    epoch_index: usize,
2115    observations: &[CycleSlipObservation],
2116    events: &mut Vec<CycleSlipEvent>,
2117) {
2118    let mut observations = observations.iter().collect::<Vec<_>>();
2119    observations.sort_by(|a, b| a.satellite_id.cmp(&b.satellite_id));
2120
2121    for obs in observations {
2122        if rtk_lli_set(obs.lli) {
2123            events.push(CycleSlipEvent {
2124                receiver,
2125                satellite_id: obs.satellite_id.clone(),
2126                epoch_index,
2127                reasons: vec![SlipReason::Lli],
2128            });
2129        }
2130    }
2131}
2132
2133fn dual_cycle_slip_events(
2134    epochs: &[DualCycleSlipEpoch],
2135    options: CycleSlipOptions,
2136) -> Result<Vec<CycleSlipEvent>, CycleSlipPrepError> {
2137    let mut events = Vec::new();
2138    dual_cycle_slip_events_for_receiver(CycleSlipReceiver::Base, epochs, options, &mut events)?;
2139    dual_cycle_slip_events_for_receiver(CycleSlipReceiver::Rover, epochs, options, &mut events)?;
2140    Ok(events)
2141}
2142
2143#[derive(Clone, Copy)]
2144struct DualCycleSlipSample<'a> {
2145    epoch_index: usize,
2146    epoch_sort_key: &'a str,
2147    gap_time_s: Option<f64>,
2148    observation: &'a DualCycleSlipObservation,
2149}
2150
2151fn dual_cycle_slip_events_for_receiver(
2152    receiver: CycleSlipReceiver,
2153    epochs: &[DualCycleSlipEpoch],
2154    options: CycleSlipOptions,
2155    events: &mut Vec<CycleSlipEvent>,
2156) -> Result<(), CycleSlipPrepError> {
2157    let mut arcs = BTreeMap::<String, Vec<DualCycleSlipSample<'_>>>::new();
2158
2159    for (epoch_index, epoch) in epochs.iter().enumerate() {
2160        let observations = match receiver {
2161            CycleSlipReceiver::Base => &epoch.base_observations,
2162            CycleSlipReceiver::Rover => &epoch.rover_observations,
2163        };
2164
2165        for observation in observations {
2166            arcs.entry(observation.satellite_id.clone())
2167                .or_default()
2168                .push(DualCycleSlipSample {
2169                    epoch_index,
2170                    epoch_sort_key: &epoch.epoch_sort_key,
2171                    gap_time_s: epoch.gap_time_s,
2172                    observation,
2173                });
2174        }
2175    }
2176
2177    for (satellite_id, mut samples) in arcs {
2178        samples.sort_by(|a, b| a.epoch_sort_key.cmp(b.epoch_sort_key));
2179        let arc = samples
2180            .iter()
2181            .map(|sample| dual_arc_epoch(sample.observation, sample.gap_time_s))
2182            .collect::<Vec<_>>();
2183        let results = detect_cycle_slips(&arc, options).map_err(cycle_slip_detector_error)?;
2184
2185        for (sample, result) in samples.iter().zip(results) {
2186            if result.slip {
2187                events.push(CycleSlipEvent {
2188                    receiver,
2189                    satellite_id: satellite_id.clone(),
2190                    epoch_index: sample.epoch_index,
2191                    reasons: result.reasons,
2192                });
2193            }
2194        }
2195    }
2196    Ok(())
2197}
2198
2199fn cycle_slip_detector_error(error: CarrierPhaseError) -> CycleSlipPrepError {
2200    let (field, reason) = match error {
2201        CarrierPhaseError::EqualFrequencies => {
2202            ("rtk cycle slip frequencies_hz", "degenerate frequencies")
2203        }
2204        CarrierPhaseError::InvalidFrequency => ("rtk cycle slip frequency_hz", "not positive"),
2205        CarrierPhaseError::InvalidObservation => ("rtk cycle slip observation", "not finite"),
2206        CarrierPhaseError::InvalidThreshold => ("rtk cycle slip threshold", "invalid"),
2207    };
2208    invalid_cycle_slip_input(field, reason)
2209}
2210
2211fn dual_arc_epoch(observation: &DualCycleSlipObservation, gap_time_s: Option<f64>) -> ArcEpoch {
2212    ArcEpoch {
2213        phi1_cycles: Some(observation.phi1_cycles),
2214        phi2_cycles: Some(observation.phi2_cycles),
2215        p1_m: Some(observation.p1_m),
2216        p2_m: Some(observation.p2_m),
2217        lli1: observation.lli1,
2218        lli2: observation.lli2,
2219        f1_hz: Some(observation.f1_hz),
2220        f2_hz: Some(observation.f2_hz),
2221        gap_time_s,
2222    }
2223}
2224
2225fn dropped_cycle_slip_sats(slips: &[CycleSlipEvent]) -> Vec<String> {
2226    slips
2227        .iter()
2228        .map(|slip| slip.satellite_id.clone())
2229        .collect::<BTreeSet<_>>()
2230        .into_iter()
2231        .collect()
2232}
2233
2234fn drop_cycle_slip_satellites(
2235    epochs: &[CycleSlipEpoch],
2236    dropped_sats: &[String],
2237) -> Vec<CycleSlipEpoch> {
2238    let dropped = dropped_sats.iter().collect::<BTreeSet<_>>();
2239    epochs
2240        .iter()
2241        .map(|epoch| CycleSlipEpoch {
2242            base_observations: epoch
2243                .base_observations
2244                .iter()
2245                .filter(|obs| !dropped.contains(&obs.satellite_id))
2246                .cloned()
2247                .collect(),
2248            rover_observations: epoch
2249                .rover_observations
2250                .iter()
2251                .filter(|obs| !dropped.contains(&obs.satellite_id))
2252                .cloned()
2253                .collect(),
2254        })
2255        .collect()
2256}
2257
2258fn drop_dual_cycle_slip_satellites(
2259    epochs: &[DualCycleSlipEpoch],
2260    dropped_sats: &[String],
2261) -> Vec<DualCycleSlipEpoch> {
2262    let dropped = dropped_sats.iter().collect::<BTreeSet<_>>();
2263    epochs
2264        .iter()
2265        .map(|epoch| DualCycleSlipEpoch {
2266            epoch_sort_key: epoch.epoch_sort_key.clone(),
2267            gap_time_s: epoch.gap_time_s,
2268            base_observations: epoch
2269                .base_observations
2270                .iter()
2271                .filter(|obs| !dropped.contains(&obs.satellite_id))
2272                .cloned()
2273                .collect(),
2274            rover_observations: epoch
2275                .rover_observations
2276                .iter()
2277                .filter(|obs| !dropped.contains(&obs.satellite_id))
2278                .cloned()
2279                .collect(),
2280        })
2281        .collect()
2282}
2283
2284fn cycle_slip_split_sides(slips: &[CycleSlipEvent]) -> BTreeSet<(CycleSlipReceiver, String)> {
2285    slips
2286        .iter()
2287        .map(|slip| (slip.receiver, slip.satellite_id.clone()))
2288        .collect()
2289}
2290
2291fn split_cycle_slip_arcs(
2292    epochs: &[CycleSlipEpoch],
2293    split_sides: &BTreeSet<(CycleSlipReceiver, String)>,
2294    slips: &[CycleSlipEvent],
2295) -> Vec<CycleSlipEpoch> {
2296    let slip_epochs = slips
2297        .iter()
2298        .map(|slip| (slip.receiver, slip.satellite_id.clone(), slip.epoch_index))
2299        .collect::<BTreeSet<_>>();
2300    let mut split_epochs = epochs.to_vec();
2301    let mut segments = BTreeMap::<(CycleSlipReceiver, String), usize>::new();
2302
2303    for (epoch_index, epoch) in split_epochs.iter_mut().enumerate() {
2304        split_receiver_cycle_slip_arcs(
2305            CycleSlipReceiver::Base,
2306            epoch_index,
2307            &mut epoch.base_observations,
2308            split_sides,
2309            &slip_epochs,
2310            &mut segments,
2311        );
2312        split_receiver_cycle_slip_arcs(
2313            CycleSlipReceiver::Rover,
2314            epoch_index,
2315            &mut epoch.rover_observations,
2316            split_sides,
2317            &slip_epochs,
2318            &mut segments,
2319        );
2320    }
2321
2322    split_epochs
2323}
2324
2325fn split_dual_cycle_slip_arcs(
2326    epochs: &[DualCycleSlipEpoch],
2327    split_sides: &BTreeSet<(CycleSlipReceiver, String)>,
2328    slips: &[CycleSlipEvent],
2329) -> Vec<DualCycleSlipEpoch> {
2330    let slip_epochs = slips
2331        .iter()
2332        .map(|slip| (slip.receiver, slip.satellite_id.clone(), slip.epoch_index))
2333        .collect::<BTreeSet<_>>();
2334    let mut split_epochs = epochs.to_vec();
2335    let mut segments = BTreeMap::<(CycleSlipReceiver, String), usize>::new();
2336
2337    for (epoch_index, epoch) in split_epochs.iter_mut().enumerate() {
2338        split_dual_receiver_cycle_slip_arcs(
2339            CycleSlipReceiver::Base,
2340            epoch_index,
2341            &mut epoch.base_observations,
2342            split_sides,
2343            &slip_epochs,
2344            &mut segments,
2345        );
2346        split_dual_receiver_cycle_slip_arcs(
2347            CycleSlipReceiver::Rover,
2348            epoch_index,
2349            &mut epoch.rover_observations,
2350            split_sides,
2351            &slip_epochs,
2352            &mut segments,
2353        );
2354    }
2355
2356    split_epochs
2357}
2358
2359fn split_receiver_cycle_slip_arcs(
2360    receiver: CycleSlipReceiver,
2361    epoch_index: usize,
2362    observations: &mut [CycleSlipObservation],
2363    split_sides: &BTreeSet<(CycleSlipReceiver, String)>,
2364    slip_epochs: &BTreeSet<(CycleSlipReceiver, String, usize)>,
2365    segments: &mut BTreeMap<(CycleSlipReceiver, String), usize>,
2366) {
2367    observations.sort_by(|a, b| a.satellite_id.cmp(&b.satellite_id));
2368
2369    for obs in observations {
2370        let key = (receiver, obs.satellite_id.clone());
2371        if split_sides.contains(&key) {
2372            let current_segment = segments.get(&key).copied().unwrap_or(1);
2373            let segment =
2374                if slip_epochs.contains(&(receiver, obs.satellite_id.clone(), epoch_index)) {
2375                    current_segment + 1
2376                } else {
2377                    current_segment
2378                };
2379            obs.ambiguity_id = split_side_ambiguity_id_core(&obs.satellite_id, receiver, segment);
2380            segments.insert(key, segment);
2381        }
2382    }
2383}
2384
2385fn split_dual_receiver_cycle_slip_arcs(
2386    receiver: CycleSlipReceiver,
2387    epoch_index: usize,
2388    observations: &mut [DualCycleSlipObservation],
2389    split_sides: &BTreeSet<(CycleSlipReceiver, String)>,
2390    slip_epochs: &BTreeSet<(CycleSlipReceiver, String, usize)>,
2391    segments: &mut BTreeMap<(CycleSlipReceiver, String), usize>,
2392) {
2393    observations.sort_by(|a, b| a.satellite_id.cmp(&b.satellite_id));
2394
2395    for obs in observations {
2396        let key = (receiver, obs.satellite_id.clone());
2397        if split_sides.contains(&key) {
2398            let current_segment = segments.get(&key).copied().unwrap_or(1);
2399            let segment =
2400                if slip_epochs.contains(&(receiver, obs.satellite_id.clone(), epoch_index)) {
2401                    current_segment + 1
2402                } else {
2403                    current_segment
2404                };
2405            obs.ambiguity_id = split_side_ambiguity_id_core(&obs.satellite_id, receiver, segment);
2406            segments.insert(key, segment);
2407        }
2408    }
2409}
2410
2411fn cycle_slip_split_metadata(
2412    epochs: &[CycleSlipEpoch],
2413    split_sides: &BTreeSet<(CycleSlipReceiver, String)>,
2414) -> Vec<CycleSlipSplitArc> {
2415    let mut grouped = BTreeMap::<(CycleSlipReceiver, String, String), Vec<usize>>::new();
2416
2417    for (epoch_index, epoch) in epochs.iter().enumerate() {
2418        split_metadata_entries(
2419            CycleSlipReceiver::Base,
2420            epoch_index,
2421            &epoch.base_observations,
2422            split_sides,
2423            &mut grouped,
2424        );
2425        split_metadata_entries(
2426            CycleSlipReceiver::Rover,
2427            epoch_index,
2428            &epoch.rover_observations,
2429            split_sides,
2430            &mut grouped,
2431        );
2432    }
2433
2434    grouped
2435        .into_iter()
2436        .map(|((receiver, satellite_id, ambiguity_id), epoch_indices)| {
2437            let start_epoch_index = *epoch_indices
2438                .first()
2439                .expect("metadata has at least one epoch");
2440            let end_epoch_index = *epoch_indices
2441                .last()
2442                .expect("metadata has at least one epoch");
2443            CycleSlipSplitArc {
2444                receiver,
2445                satellite_id,
2446                ambiguity_id,
2447                start_epoch_index,
2448                end_epoch_index,
2449                n_epochs: epoch_indices.len(),
2450            }
2451        })
2452        .collect()
2453}
2454
2455fn dual_cycle_slip_split_metadata(
2456    epochs: &[DualCycleSlipEpoch],
2457    split_sides: &BTreeSet<(CycleSlipReceiver, String)>,
2458) -> Vec<CycleSlipSplitArc> {
2459    let mut grouped = BTreeMap::<(CycleSlipReceiver, String, String), Vec<usize>>::new();
2460
2461    for (epoch_index, epoch) in epochs.iter().enumerate() {
2462        dual_split_metadata_entries(
2463            CycleSlipReceiver::Base,
2464            epoch_index,
2465            &epoch.base_observations,
2466            split_sides,
2467            &mut grouped,
2468        );
2469        dual_split_metadata_entries(
2470            CycleSlipReceiver::Rover,
2471            epoch_index,
2472            &epoch.rover_observations,
2473            split_sides,
2474            &mut grouped,
2475        );
2476    }
2477
2478    grouped
2479        .into_iter()
2480        .map(|((receiver, satellite_id, ambiguity_id), epoch_indices)| {
2481            let start_epoch_index = *epoch_indices
2482                .first()
2483                .expect("metadata has at least one epoch");
2484            let end_epoch_index = *epoch_indices
2485                .last()
2486                .expect("metadata has at least one epoch");
2487            CycleSlipSplitArc {
2488                receiver,
2489                satellite_id,
2490                ambiguity_id,
2491                start_epoch_index,
2492                end_epoch_index,
2493                n_epochs: epoch_indices.len(),
2494            }
2495        })
2496        .collect()
2497}
2498
2499fn split_metadata_entries(
2500    receiver: CycleSlipReceiver,
2501    epoch_index: usize,
2502    observations: &[CycleSlipObservation],
2503    split_sides: &BTreeSet<(CycleSlipReceiver, String)>,
2504    grouped: &mut BTreeMap<(CycleSlipReceiver, String, String), Vec<usize>>,
2505) {
2506    let mut observations = observations.iter().collect::<Vec<_>>();
2507    observations.sort_by(|a, b| a.satellite_id.cmp(&b.satellite_id));
2508
2509    for obs in observations {
2510        if split_sides.contains(&(receiver, obs.satellite_id.clone())) {
2511            grouped
2512                .entry((receiver, obs.satellite_id.clone(), obs.ambiguity_id.clone()))
2513                .or_default()
2514                .push(epoch_index);
2515        }
2516    }
2517}
2518
2519fn dual_split_metadata_entries(
2520    receiver: CycleSlipReceiver,
2521    epoch_index: usize,
2522    observations: &[DualCycleSlipObservation],
2523    split_sides: &BTreeSet<(CycleSlipReceiver, String)>,
2524    grouped: &mut BTreeMap<(CycleSlipReceiver, String, String), Vec<usize>>,
2525) {
2526    let mut observations = observations.iter().collect::<Vec<_>>();
2527    observations.sort_by(|a, b| a.satellite_id.cmp(&b.satellite_id));
2528
2529    for obs in observations {
2530        if split_sides.contains(&(receiver, obs.satellite_id.clone())) {
2531            grouped
2532                .entry((receiver, obs.satellite_id.clone(), obs.ambiguity_id.clone()))
2533                .or_default()
2534                .push(epoch_index);
2535        }
2536    }
2537}
2538
2539fn segment_reacquired_arcs(mut epochs: Vec<CycleSlipEpoch>) -> Vec<CycleSlipEpoch> {
2540    segment_receiver_reacquisitions(&mut epochs, CycleSlipReceiver::Base);
2541    segment_receiver_reacquisitions(&mut epochs, CycleSlipReceiver::Rover);
2542    epochs
2543}
2544
2545fn segment_receiver_reacquisitions(epochs: &mut [CycleSlipEpoch], receiver: CycleSlipReceiver) {
2546    let mut present_last = BTreeSet::<String>::new();
2547    let mut arcs = BTreeMap::<String, usize>::new();
2548
2549    for epoch in epochs {
2550        let observations = match receiver {
2551            CycleSlipReceiver::Base => &mut epoch.base_observations,
2552            CycleSlipReceiver::Rover => &mut epoch.rover_observations,
2553        };
2554        observations.sort_by(|a, b| a.satellite_id.cmp(&b.satellite_id));
2555        let present = observations
2556            .iter()
2557            .map(|obs| obs.satellite_id.clone())
2558            .collect::<BTreeSet<_>>();
2559
2560        for obs in observations {
2561            let reacquired =
2562                arcs.contains_key(&obs.satellite_id) && !present_last.contains(&obs.satellite_id);
2563            let arc = arcs.get(&obs.satellite_id).copied().unwrap_or(0) + usize::from(reacquired);
2564            arcs.insert(obs.satellite_id.clone(), arc);
2565
2566            if arc > 0 {
2567                obs.ambiguity_id = reacquired_ambiguity_id_core(&obs.ambiguity_id, arc);
2568            }
2569        }
2570
2571        present_last = present;
2572    }
2573}
2574
2575fn segment_reacquired_dual_arcs(mut epochs: Vec<DualCycleSlipEpoch>) -> Vec<DualCycleSlipEpoch> {
2576    segment_dual_receiver_reacquisitions(&mut epochs, CycleSlipReceiver::Base);
2577    segment_dual_receiver_reacquisitions(&mut epochs, CycleSlipReceiver::Rover);
2578    epochs
2579}
2580
2581fn segment_dual_receiver_reacquisitions(
2582    epochs: &mut [DualCycleSlipEpoch],
2583    receiver: CycleSlipReceiver,
2584) {
2585    let mut present_last = BTreeSet::<String>::new();
2586    let mut arcs = BTreeMap::<String, usize>::new();
2587
2588    for epoch in epochs {
2589        let observations = match receiver {
2590            CycleSlipReceiver::Base => &mut epoch.base_observations,
2591            CycleSlipReceiver::Rover => &mut epoch.rover_observations,
2592        };
2593        observations.sort_by(|a, b| a.satellite_id.cmp(&b.satellite_id));
2594        let present = observations
2595            .iter()
2596            .map(|obs| obs.satellite_id.clone())
2597            .collect::<BTreeSet<_>>();
2598
2599        for obs in observations {
2600            let reacquired =
2601                arcs.contains_key(&obs.satellite_id) && !present_last.contains(&obs.satellite_id);
2602            let arc = arcs.get(&obs.satellite_id).copied().unwrap_or(0) + usize::from(reacquired);
2603            arcs.insert(obs.satellite_id.clone(), arc);
2604
2605            if arc > 0 {
2606                obs.ambiguity_id = reacquired_ambiguity_id_core(&obs.ambiguity_id, arc);
2607            }
2608        }
2609
2610        present_last = present;
2611    }
2612}
2613
2614fn split_side_ambiguity_id_core(
2615    satellite_id: &str,
2616    receiver: CycleSlipReceiver,
2617    segment: usize,
2618) -> String {
2619    format!(
2620        "{satellite_id}@{}#{segment}",
2621        cycle_slip_receiver_tag(receiver)
2622    )
2623}
2624
2625fn reacquired_ambiguity_id_core(ambiguity_id: &str, arc: usize) -> String {
2626    let base = strip_reacquired_suffix(ambiguity_id);
2627    format!("{base}~ra{arc}")
2628}
2629
2630fn strip_reacquired_suffix(ambiguity_id: &str) -> &str {
2631    if let Some(index) = ambiguity_id.rfind("~ra") {
2632        let suffix = &ambiguity_id[index + 3..];
2633        if !suffix.is_empty() && suffix.chars().all(|ch| ch.is_ascii_digit()) {
2634            return &ambiguity_id[..index];
2635        }
2636    }
2637    ambiguity_id
2638}
2639
2640fn cycle_slip_receiver_tag(receiver: CycleSlipReceiver) -> &'static str {
2641    match receiver {
2642        CycleSlipReceiver::Base => "base",
2643        CycleSlipReceiver::Rover => "rover",
2644    }
2645}
2646
2647fn satellite_system(satellite_id: &str) -> String {
2648    crate::id::constellation_letter(satellite_id).to_string()
2649}
2650
2651/// Single-difference ambiguity-id token from the per-receiver ambiguity ids.
2652///
2653/// Clean arcs (both receivers carry the bare satellite id) yield the satellite
2654/// id; a split on one side carries that side's id, a shared split id carries it,
2655/// and a divergent split records both. Shared by the single- and dual-frequency
2656/// single-difference builders and by the sequential RTK arc driver so the SD
2657/// column naming is defined in exactly one place.
2658pub(crate) fn sd_ambiguity_token(sat: &str, base_id: &str, rover_id: &str) -> String {
2659    match (base_id, rover_id) {
2660        (base_id, rover_id) if base_id == sat && rover_id == sat => sat.to_string(),
2661        (base_id, rover_id) if base_id == sat => rover_id.to_string(),
2662        (base_id, rover_id) if rover_id == sat => base_id.to_string(),
2663        (base_id, rover_id) if base_id == rover_id => base_id.to_string(),
2664        (base_id, rover_id) => format!("{sat}:base={base_id},rover={rover_id}"),
2665    }
2666}
2667
2668/// Double-difference ambiguity-id token from the satellite SD id and its own
2669/// system's reference SD id/satellite. Clean arcs (the satellite carries its
2670/// bare id and the reference SD is the reference satellite's bare id) yield the
2671/// satellite id; otherwise the reference is recorded explicitly. Shared by the
2672/// single- and dual-frequency double-difference builders and the arc driver.
2673pub(crate) fn dd_ambiguity_token(
2674    sat: &str,
2675    sat_sd_id: &str,
2676    ref_sd_id: &str,
2677    ref_sat: &str,
2678) -> String {
2679    if sat_sd_id == sat && ref_sd_id == ref_sat {
2680        sat.to_string()
2681    } else {
2682        format!("{sat_sd_id}|ref={ref_sd_id}")
2683    }
2684}
2685
2686fn single_difference_ambiguity_id(
2687    sat: &str,
2688    base_obs: &Observation,
2689    rover_obs: &Observation,
2690) -> AmbiguityId {
2691    AmbiguityId::new(sd_ambiguity_token(
2692        sat,
2693        base_obs.ambiguity_id.as_str(),
2694        rover_obs.ambiguity_id.as_str(),
2695    ))
2696}
2697
2698fn single_difference_dual_ambiguity_id(
2699    sat: &str,
2700    base_obs: &DualObservation,
2701    rover_obs: &DualObservation,
2702) -> AmbiguityId {
2703    AmbiguityId::new(sd_ambiguity_token(
2704        sat,
2705        base_obs.ambiguity_id.as_str(),
2706        rover_obs.ambiguity_id.as_str(),
2707    ))
2708}
2709
2710fn double_difference_ambiguity_id(
2711    sat: &str,
2712    sat_sd_id: &AmbiguityId,
2713    ref_sd: &SingleDifference,
2714) -> AmbiguityId {
2715    AmbiguityId::new(dd_ambiguity_token(
2716        sat,
2717        sat_sd_id.as_str(),
2718        ref_sd.ambiguity_id.as_str(),
2719        &ref_sd.satellite_id,
2720    ))
2721}
2722
2723fn double_difference_dual_ambiguity_id(
2724    sat: &str,
2725    sat_sd_id: &AmbiguityId,
2726    ref_sd: &DualSingleDifference,
2727) -> AmbiguityId {
2728    AmbiguityId::new(dd_ambiguity_token(
2729        sat,
2730        sat_sd_id.as_str(),
2731        ref_sd.ambiguity_id.as_str(),
2732        &ref_sd.satellite_id,
2733    ))
2734}
2735
2736#[cfg(test)]
2737mod tests {
2738    use super::*;
2739
2740    fn gps_l1_hz() -> f64 {
2741        crate::frequencies::frequency_hz(
2742            crate::GnssSystem::Gps,
2743            crate::frequencies::CarrierBand::L1,
2744        )
2745        .expect("canonical GPS L1 carrier exists")
2746    }
2747
2748    fn gps_l2_hz() -> f64 {
2749        crate::frequencies::frequency_hz(
2750            crate::GnssSystem::Gps,
2751            crate::frequencies::CarrierBand::L2,
2752        )
2753        .expect("canonical GPS L2 carrier exists")
2754    }
2755
2756    fn obs(sat: &str, code_m: f64, phase_m: f64) -> Observation {
2757        Observation {
2758            satellite_id: sat.to_string(),
2759            ambiguity_id: sat.to_string(),
2760            code_m,
2761            phase_m,
2762        }
2763    }
2764
2765    fn dual_observation(ambiguity_id: &str, wide_lane_phase_cycles: f64) -> DualObservation {
2766        DualObservation {
2767            ambiguity_id: ambiguity_id.to_string(),
2768            p1_m: 0.0,
2769            p2_m: 0.0,
2770            phi1_cycles: wide_lane_phase_cycles,
2771            phi2_cycles: 0.0,
2772            f1_hz: gps_l1_hz(),
2773            f2_hz: gps_l2_hz(),
2774        }
2775    }
2776
2777    fn dual_pair(sat: &str, base_wide_lane: f64, rover_wide_lane: f64) -> DualSatelliteObservation {
2778        DualSatelliteObservation {
2779            satellite_id: sat.to_string(),
2780            base: dual_observation(sat, base_wide_lane),
2781            rover: dual_observation(sat, rover_wide_lane),
2782        }
2783    }
2784
2785    fn split_dual_pair(
2786        sat: &str,
2787        rover_id: &str,
2788        base_wide_lane: f64,
2789        rover_wide_lane: f64,
2790    ) -> DualSatelliteObservation {
2791        DualSatelliteObservation {
2792            satellite_id: sat.to_string(),
2793            base: dual_observation(sat, base_wide_lane),
2794            rover: dual_observation(rover_id, rover_wide_lane),
2795        }
2796    }
2797
2798    fn if_observation(
2799        ambiguity_id: &str,
2800        p1_m: f64,
2801        p2_m: f64,
2802        phi1_cycles: f64,
2803        phi2_cycles: f64,
2804        tropo_m: f64,
2805    ) -> DualIonosphereFreeObservation {
2806        DualIonosphereFreeObservation {
2807            ambiguity_id: ambiguity_id.to_string(),
2808            p1_m,
2809            p2_m,
2810            phi1_cycles,
2811            phi2_cycles,
2812            f1_hz: gps_l1_hz(),
2813            f2_hz: gps_l2_hz(),
2814            tropo_m,
2815        }
2816    }
2817
2818    fn if_pair(
2819        sat: &str,
2820        base_code: f64,
2821        rover_code: f64,
2822        base_phase_cycles: f64,
2823        rover_phase_cycles: f64,
2824    ) -> DualIonosphereFreeSatelliteObservation {
2825        DualIonosphereFreeSatelliteObservation {
2826            satellite_id: sat.to_string(),
2827            base: if_observation(
2828                sat,
2829                base_code,
2830                base_code + 2.0,
2831                base_phase_cycles,
2832                base_phase_cycles - 4.0,
2833                0.25,
2834            ),
2835            rover: if_observation(
2836                sat,
2837                rover_code,
2838                rover_code + 2.5,
2839                rover_phase_cycles,
2840                rover_phase_cycles - 3.0,
2841                0.5,
2842            ),
2843        }
2844    }
2845
2846    fn arc_obs(sat: &str, ambiguity_id: &str, code_m: f64, phase_m: f64) -> Observation {
2847        Observation {
2848            satellite_id: sat.to_string(),
2849            ambiguity_id: ambiguity_id.to_string(),
2850            code_m,
2851            phase_m,
2852        }
2853    }
2854
2855    fn smooth_obs(
2856        sat: &str,
2857        ambiguity_id: &str,
2858        code_m: f64,
2859        phase_m: f64,
2860        lli: Option<i64>,
2861    ) -> CodeSmoothingObservation {
2862        CodeSmoothingObservation {
2863            satellite_id: sat.to_string(),
2864            ambiguity_id: ambiguity_id.to_string(),
2865            code_m,
2866            phase_m,
2867            lli,
2868        }
2869    }
2870
2871    fn dual_slip_obs(
2872        sat: &str,
2873        ambiguity_id: &str,
2874        phi1_cycles: f64,
2875        phi2_cycles: f64,
2876        lli1: Option<i64>,
2877        lli2: Option<i64>,
2878    ) -> DualCycleSlipObservation {
2879        DualCycleSlipObservation {
2880            satellite_id: sat.to_string(),
2881            ambiguity_id: ambiguity_id.to_string(),
2882            p1_m: 20.0,
2883            p2_m: 21.0,
2884            phi1_cycles,
2885            phi2_cycles,
2886            f1_hz: gps_l1_hz(),
2887            f2_hz: gps_l2_hz(),
2888            lli1,
2889            lli2,
2890        }
2891    }
2892
2893    fn dual_slip_epoch(
2894        epoch_sort_key: &str,
2895        gap_time_s: f64,
2896        base_observations: Vec<DualCycleSlipObservation>,
2897        rover_observations: Vec<DualCycleSlipObservation>,
2898    ) -> DualCycleSlipEpoch {
2899        DualCycleSlipEpoch {
2900            epoch_sort_key: epoch_sort_key.to_string(),
2901            gap_time_s: Some(gap_time_s),
2902            base_observations,
2903            rover_observations,
2904        }
2905    }
2906
2907    fn code_bits(observations: &[CodeSmoothingObservation]) -> Vec<(&str, u64)> {
2908        observations
2909            .iter()
2910            .map(|obs| (obs.satellite_id.as_str(), obs.code_m.to_bits()))
2911            .collect()
2912    }
2913
2914    fn ambiguity_id<'a>(
2915        observations: &'a [CycleSlipObservation],
2916        satellite_id: &str,
2917    ) -> Option<&'a str> {
2918        observations
2919            .iter()
2920            .find(|obs| obs.satellite_id == satellite_id)
2921            .map(|obs| obs.ambiguity_id.as_str())
2922    }
2923
2924    fn dual_ambiguity_id<'a>(
2925        observations: &'a [DualCycleSlipObservation],
2926        satellite_id: &str,
2927    ) -> Option<&'a str> {
2928        observations
2929            .iter()
2930            .find(|obs| obs.satellite_id == satellite_id)
2931            .map(|obs| obs.ambiguity_id.as_str())
2932    }
2933
2934    fn baseline_reference_epoch(entries: &[(&str, [f64; 3])]) -> BaselineReferenceEpoch {
2935        BaselineReferenceEpoch {
2936            available_satellite_ids: entries.iter().map(|(sat, _)| sat.to_string()).collect(),
2937            satellite_positions_m: entries
2938                .iter()
2939                .map(|(sat, pos)| (sat.to_string(), *pos))
2940                .collect(),
2941        }
2942    }
2943
2944    #[test]
2945    fn double_differences_cancel_receiver_and_common_terms() {
2946        let sats = ["G01", "G02", "G03", "G04"];
2947        let reference = "G01";
2948        let base_clock_m = 125.0;
2949        let rover_clock_m = -42.0;
2950        let base_ranges = BTreeMap::from([
2951            ("G01", 20_000.0),
2952            ("G02", 21_000.0),
2953            ("G03", 22_500.0),
2954            ("G04", 23_100.0),
2955        ]);
2956        let rover_ranges = BTreeMap::from([
2957            ("G01", 20_010.0),
2958            ("G02", 21_025.0),
2959            ("G03", 22_480.0),
2960            ("G04", 23_150.0),
2961        ]);
2962        let common_errors =
2963            BTreeMap::from([("G01", 3.25), ("G02", -12.0), ("G03", 8.5), ("G04", 1.0)]);
2964        let base_ambiguities =
2965            BTreeMap::from([("G01", 2.0), ("G02", -3.0), ("G03", 7.0), ("G04", 11.0)]);
2966        let rover_ambiguities =
2967            BTreeMap::from([("G01", 5.0), ("G02", 4.0), ("G03", 1.0), ("G04", 19.0)]);
2968
2969        let base = sats
2970            .iter()
2971            .map(|sat| {
2972                obs(
2973                    sat,
2974                    base_ranges[sat] + base_clock_m + common_errors[sat],
2975                    base_ranges[sat] + base_clock_m + common_errors[sat] + base_ambiguities[sat],
2976                )
2977            })
2978            .collect::<Vec<_>>();
2979        let rover = sats
2980            .iter()
2981            .map(|sat| {
2982                obs(
2983                    sat,
2984                    rover_ranges[sat] + rover_clock_m + common_errors[sat],
2985                    rover_ranges[sat] + rover_clock_m + common_errors[sat] + rover_ambiguities[sat],
2986                )
2987            })
2988            .collect::<Vec<_>>();
2989
2990        let result = double_differences(
2991            &base,
2992            &rover,
2993            ReferenceSelection::Satellite(reference.to_string()),
2994        )
2995        .unwrap();
2996
2997        assert_eq!(
2998            result.reference_satellite_id,
2999            ReferenceReport::Satellite(reference.to_string())
3000        );
3001        assert!(result.dropped_sats.is_empty());
3002
3003        let by_sat = result
3004            .double_differences
3005            .iter()
3006            .map(|dd| (dd.satellite_id.as_str(), dd))
3007            .collect::<BTreeMap<_, _>>();
3008
3009        for sat in sats.into_iter().filter(|sat| *sat != reference) {
3010            let expected_code = rover_ranges[sat]
3011                - base_ranges[sat]
3012                - (rover_ranges[reference] - base_ranges[reference]);
3013            let expected_phase = expected_code + (rover_ambiguities[sat] - base_ambiguities[sat])
3014                - (rover_ambiguities[reference] - base_ambiguities[reference]);
3015            let dd = by_sat[sat];
3016            assert_eq!(dd.reference_satellite_id, reference);
3017            assert_eq!(dd.code_m, expected_code);
3018            assert_eq!(dd.phase_m, expected_phase);
3019        }
3020    }
3021
3022    #[test]
3023    fn auto_reference_reports_dropped_satellites() {
3024        let base = vec![
3025            obs("G02", 210.0, 211.0),
3026            obs("G01", 100.0, 101.0),
3027            obs("G09", 900.0, 901.0),
3028        ];
3029        let rover = vec![
3030            obs("G02", 230.0, 233.0),
3031            obs("G01", 105.0, 108.0),
3032            obs("G10", 1000.0, 1001.0),
3033        ];
3034
3035        let result = double_differences(&base, &rover, ReferenceSelection::Auto).unwrap();
3036
3037        assert_eq!(
3038            result.reference_satellite_id,
3039            ReferenceReport::Satellite("G01".to_string())
3040        );
3041        assert_eq!(result.dropped_sats, ["G09".to_string(), "G10".to_string()]);
3042        assert_eq!(
3043            result.double_differences,
3044            vec![DoubleDifference {
3045                satellite_id: "G02".to_string(),
3046                reference_satellite_id: "G01".to_string(),
3047                ambiguity_id: "G02".to_string(),
3048                code_m: 15.0,
3049                phase_m: 15.0,
3050            }]
3051        );
3052    }
3053
3054    #[test]
3055    fn explicit_arc_ids_feed_double_difference_ambiguity_id() {
3056        let base = vec![obs("G01", 100.0, 101.0), obs("G02", 210.0, 211.0)];
3057        let rover = vec![
3058            arc_obs("G01", "G01#2", 105.0, 108.0),
3059            arc_obs("G02", "G02#2", 230.0, 233.0),
3060        ];
3061
3062        let result = double_differences(
3063            &base,
3064            &rover,
3065            ReferenceSelection::Satellite("G01".to_string()),
3066        )
3067        .unwrap();
3068
3069        assert_eq!(result.double_differences[0].ambiguity_id, "G02#2|ref=G01#2");
3070    }
3071
3072    #[test]
3073    fn multi_system_uses_reference_per_system() {
3074        let base = vec![
3075            obs("G01", 10.0, 11.0),
3076            obs("G02", 20.0, 21.0),
3077            obs("E11", 30.0, 31.0),
3078            obs("E19", 40.0, 41.0),
3079        ];
3080        let rover = vec![
3081            obs("G01", 12.0, 14.0),
3082            obs("G02", 24.0, 27.0),
3083            obs("E11", 33.0, 35.0),
3084            obs("E19", 46.0, 49.0),
3085        ];
3086        let refs = BTreeMap::from([
3087            ("E".to_string(), "E11".to_string()),
3088            ("G".to_string(), "G01".to_string()),
3089        ]);
3090
3091        let result =
3092            double_differences(&base, &rover, ReferenceSelection::PerSystem(refs.clone())).unwrap();
3093
3094        assert_eq!(
3095            result.reference_satellite_id,
3096            ReferenceReport::PerSystem(refs)
3097        );
3098        assert_eq!(
3099            result
3100                .double_differences
3101                .iter()
3102                .map(|dd| (&dd.satellite_id, &dd.reference_satellite_id))
3103                .collect::<Vec<_>>(),
3104            vec![
3105                (&"E19".to_string(), &"E11".to_string()),
3106                (&"G02".to_string(), &"G01".to_string()),
3107            ]
3108        );
3109    }
3110
3111    #[test]
3112    fn multi_system_requires_non_reference_satellite_per_system() {
3113        let base = vec![obs("G01", 10.0, 11.0), obs("E01", 30.0, 31.0)];
3114        let rover = vec![obs("G01", 12.0, 14.0), obs("E01", 33.0, 35.0)];
3115        let expected = Err(DoubleDifferenceError::TooFewCommonSatellites {
3116            count: 1,
3117            minimum: 2,
3118        });
3119
3120        assert_eq!(
3121            double_differences(&base, &rover, ReferenceSelection::Auto),
3122            expected
3123        );
3124        assert_eq!(
3125            double_differences(
3126                &base,
3127                &rover,
3128                ReferenceSelection::PerSystem(BTreeMap::from([
3129                    ("E".to_string(), "E01".to_string()),
3130                    ("G".to_string(), "G01".to_string()),
3131                ])),
3132            ),
3133            expected
3134        );
3135    }
3136
3137    #[test]
3138    fn baseline_auto_reference_uses_highest_average_elevation_per_system() {
3139        let base = [10.0, 0.0, 0.0];
3140        let epochs = vec![
3141            baseline_reference_epoch(&[
3142                ("G01", [20.0, 0.0, 0.0]),
3143                ("G02", [20.0, 0.0, 0.0]),
3144                ("G03", [20.0, 0.0, 0.0]),
3145                ("E01", [10.0, 10.0, 0.0]),
3146                ("E02", [20.0, 0.0, 0.0]),
3147            ]),
3148            baseline_reference_epoch(&[
3149                ("G01", [10.0, 10.0, 0.0]),
3150                ("G03", [20.0, 0.0, 0.0]),
3151                ("E01", [10.0, 10.0, 0.0]),
3152                ("E02", [20.0, 0.0, 0.0]),
3153            ]),
3154        ];
3155
3156        let refs =
3157            baseline_reference_satellites(base, &epochs, BaselineReferenceSelection::Auto).unwrap();
3158
3159        assert_eq!(
3160            refs,
3161            BTreeMap::from([
3162                ("E".to_string(), "E02".to_string()),
3163                ("G".to_string(), "G03".to_string()),
3164            ])
3165        );
3166
3167        let g_epochs = baseline_epochs_for_system(&epochs, "G");
3168        assert_eq!(
3169            average_elevation_score(base, &g_epochs, "G01")
3170                .unwrap()
3171                .to_bits(),
3172            0x3fe0_0000_0000_0000
3173        );
3174        assert_eq!(
3175            average_elevation_score(base, &g_epochs, "G03")
3176                .unwrap()
3177                .to_bits(),
3178            0x3ff0_0000_0000_0000
3179        );
3180    }
3181
3182    #[test]
3183    fn baseline_auto_reference_ties_by_satellite_id() {
3184        let base = [10.0, 0.0, 0.0];
3185        let epochs = vec![baseline_reference_epoch(&[
3186            ("G01", [20.0, 0.0, 0.0]),
3187            ("G02", [20.0, 0.0, 0.0]),
3188        ])];
3189
3190        let refs =
3191            baseline_reference_satellites(base, &epochs, BaselineReferenceSelection::Auto).unwrap();
3192
3193        assert_eq!(refs, BTreeMap::from([("G".to_string(), "G01".to_string())]));
3194    }
3195
3196    #[test]
3197    fn baseline_reference_errors_when_available_satellite_position_missing() {
3198        let mut epoch = baseline_reference_epoch(&[("G01", [20.0, 0.0, 0.0])]);
3199        epoch.available_satellite_ids.push("G02".to_string());
3200
3201        assert_eq!(
3202            baseline_reference_satellites(
3203                [10.0, 0.0, 0.0],
3204                &[epoch],
3205                BaselineReferenceSelection::Auto
3206            ),
3207            Err(DoubleDifferenceError::MissingSatellitePosition(
3208                "G02".to_string()
3209            ))
3210        );
3211    }
3212
3213    #[test]
3214    fn elevation_mask_keeps_epoch_satellites_above_threshold() {
3215        let base = [10.0, 0.0, 0.0];
3216        let up = local_up(base);
3217
3218        assert_eq!(
3219            elevation_score_with_up(base, up, [20.0, 0.0, 0.0])
3220                .unwrap()
3221                .to_bits(),
3222            0x3ff0_0000_0000_0000
3223        );
3224        assert_eq!(
3225            elevation_score_with_up(base, up, [10.0, 10.0, 0.0])
3226                .unwrap()
3227                .to_bits(),
3228            0x0000_0000_0000_0000
3229        );
3230        assert_eq!(
3231            elevation_score_with_up(base, up, [0.0, 0.0, 0.0])
3232                .unwrap()
3233                .to_bits(),
3234            0xbff0_0000_0000_0000
3235        );
3236
3237        let epochs = vec![
3238            ElevationMaskEpoch {
3239                satellite_positions_m: BTreeMap::from([
3240                    ("G01".to_string(), [20.0, 0.0, 0.0]),
3241                    ("G02".to_string(), [10.0, 10.0, 0.0]),
3242                    ("G03".to_string(), [0.0, 0.0, 0.0]),
3243                ]),
3244            },
3245            ElevationMaskEpoch {
3246                satellite_positions_m: BTreeMap::from([
3247                    ("G01".to_string(), [20.0, 0.0, 0.0]),
3248                    ("G02".to_string(), [20.0, 0.0, 0.0]),
3249                    ("G04".to_string(), [0.0, 0.0, 0.0]),
3250                ]),
3251            },
3252        ];
3253
3254        let result = apply_elevation_mask(base, &epochs, 30.0).unwrap();
3255
3256        assert_eq!(
3257            result.epochs,
3258            vec![
3259                ElevationMaskEpochResult {
3260                    kept_satellite_ids: vec!["G01".to_string()],
3261                },
3262                ElevationMaskEpochResult {
3263                    kept_satellite_ids: vec!["G01".to_string(), "G02".to_string()],
3264                },
3265            ]
3266        );
3267        assert_eq!(
3268            result.masked_satellite_ids,
3269            vec!["G02".to_string(), "G03".to_string(), "G04".to_string()]
3270        );
3271    }
3272
3273    #[test]
3274    fn baseline_reference_rejects_invalid_geometry() {
3275        let epochs = vec![baseline_reference_epoch(&[
3276            ("G01", [20.0, 0.0, 0.0]),
3277            ("G02", [30.0, 0.0, 0.0]),
3278        ])];
3279
3280        assert_eq!(
3281            baseline_reference_satellites(
3282                [f64::NAN, 0.0, 0.0],
3283                &epochs,
3284                BaselineReferenceSelection::Auto,
3285            ),
3286            Err(DoubleDifferenceError::InvalidInput {
3287                field: "rtk base position_m",
3288                reason: "not finite",
3289            })
3290        );
3291        assert_eq!(
3292            baseline_reference_satellites(
3293                [0.0, 0.0, 0.0],
3294                &epochs,
3295                BaselineReferenceSelection::Auto,
3296            ),
3297            Err(DoubleDifferenceError::InvalidInput {
3298                field: "rtk base position_m",
3299                reason: "degenerate geometry",
3300            })
3301        );
3302
3303        let invalid_sat = vec![baseline_reference_epoch(&[
3304            ("G01", [20.0, 0.0, 0.0]),
3305            ("G02", [f64::INFINITY, 0.0, 0.0]),
3306        ])];
3307        assert_eq!(
3308            baseline_reference_satellites(
3309                [10.0, 0.0, 0.0],
3310                &invalid_sat,
3311                BaselineReferenceSelection::Auto,
3312            ),
3313            Err(DoubleDifferenceError::InvalidInput {
3314                field: "rtk satellite position_m",
3315                reason: "not finite",
3316            })
3317        );
3318
3319        let coincident_sat = vec![baseline_reference_epoch(&[
3320            ("G01", [10.0, 0.0, 0.0]),
3321            ("G02", [20.0, 0.0, 0.0]),
3322        ])];
3323        assert_eq!(
3324            baseline_reference_satellites(
3325                [10.0, 0.0, 0.0],
3326                &coincident_sat,
3327                BaselineReferenceSelection::Auto,
3328            ),
3329            Err(DoubleDifferenceError::InvalidInput {
3330                field: "rtk line of sight_m",
3331                reason: "degenerate geometry",
3332            })
3333        );
3334    }
3335
3336    #[test]
3337    fn elevation_mask_rejects_invalid_geometry_and_mask() {
3338        let epochs = vec![ElevationMaskEpoch {
3339            satellite_positions_m: BTreeMap::from([("G01".to_string(), [20.0, 0.0, 0.0])]),
3340        }];
3341
3342        assert_eq!(
3343            apply_elevation_mask([10.0, 0.0, 0.0], &epochs, f64::NAN),
3344            Err(DoubleDifferenceError::InvalidInput {
3345                field: "rtk elevation mask_deg",
3346                reason: "not finite",
3347            })
3348        );
3349        assert_eq!(
3350            apply_elevation_mask([10.0, 0.0, 0.0], &epochs, 91.0),
3351            Err(DoubleDifferenceError::InvalidInput {
3352                field: "rtk elevation mask_deg",
3353                reason: "out of range",
3354            })
3355        );
3356
3357        let coincident_sat = vec![ElevationMaskEpoch {
3358            satellite_positions_m: BTreeMap::from([("G01".to_string(), [10.0, 0.0, 0.0])]),
3359        }];
3360        assert_eq!(
3361            apply_elevation_mask([10.0, 0.0, 0.0], &coincident_sat, 0.0),
3362            Err(DoubleDifferenceError::InvalidInput {
3363                field: "rtk line of sight_m",
3364                reason: "degenerate geometry",
3365            })
3366        );
3367    }
3368
3369    #[test]
3370    fn code_smoothing_has_frozen_bits_and_receiver_state() {
3371        let epochs = vec![
3372            CodeSmoothingEpoch {
3373                base_observations: vec![
3374                    smooth_obs("G02", "G02", 100.0, 10.0, None),
3375                    smooth_obs("G01", "G01", 200.0, 20.0, None),
3376                ],
3377                rover_observations: vec![
3378                    smooth_obs("G03", "G03", 600.0, 60.0, None),
3379                    smooth_obs("G02", "G02", 500.0, 50.0, None),
3380                ],
3381            },
3382            CodeSmoothingEpoch {
3383                base_observations: vec![
3384                    smooth_obs("G01", "G01", 201.0, 21.0, Some(1)),
3385                    smooth_obs("G02", "G02", 102.0, 11.0, None),
3386                ],
3387                rover_observations: vec![smooth_obs("G02", "G02", 504.0, 51.0, None)],
3388            },
3389            CodeSmoothingEpoch {
3390                base_observations: vec![
3391                    smooth_obs("G02", "G02", 103.0, 13.0, None),
3392                    smooth_obs("G01", "G01", 202.0, 22.0, None),
3393                ],
3394                rover_observations: vec![
3395                    smooth_obs("G03", "G03", 604.0, 62.0, None),
3396                    smooth_obs("G02", "G02", 506.0, 53.0, None),
3397                ],
3398            },
3399        ];
3400
3401        let result = hatch_smooth_baseline_code_epochs(&epochs, 2).unwrap();
3402
3403        assert_eq!(
3404            code_bits(&result[0].base_observations),
3405            vec![
3406                ("G01", 0x4069_0000_0000_0000),
3407                ("G02", 0x4059_0000_0000_0000),
3408            ]
3409        );
3410        assert_eq!(
3411            code_bits(&result[1].base_observations),
3412            vec![
3413                ("G01", 0x4069_2000_0000_0000),
3414                ("G02", 0x4059_6000_0000_0000),
3415            ]
3416        );
3417        assert_eq!(
3418            code_bits(&result[2].base_observations),
3419            vec![
3420                ("G01", 0x4069_4000_0000_0000),
3421                ("G02", 0x4059_d000_0000_0000),
3422            ]
3423        );
3424        assert_eq!(
3425            code_bits(&result[0].rover_observations),
3426            vec![
3427                ("G02", 0x407f_4000_0000_0000),
3428                ("G03", 0x4082_c000_0000_0000),
3429            ]
3430        );
3431        assert_eq!(
3432            code_bits(&result[1].rover_observations),
3433            vec![("G02", 0x407f_6800_0000_0000)]
3434        );
3435        assert_eq!(
3436            code_bits(&result[2].rover_observations),
3437            vec![
3438                ("G02", 0x407f_9400_0000_0000),
3439                ("G03", 0x4082_d800_0000_0000),
3440            ]
3441        );
3442        assert_eq!(result[1].base_observations[0].lli, Some(1));
3443        assert_eq!(
3444            hatch_smooth_baseline_code_epochs(&epochs, 0),
3445            Err(CodeSmoothingError::InvalidWindowCap)
3446        );
3447    }
3448
3449    #[test]
3450    fn cycle_slip_prep_pins_policy_and_reacquisition_behavior() {
3451        let epochs = vec![
3452            CycleSlipEpoch {
3453                base_observations: vec![
3454                    smooth_obs("G02", "G02", 100.0, 10.0, None),
3455                    smooth_obs("G01", "G01", 200.0, 20.0, None),
3456                ],
3457                rover_observations: vec![
3458                    smooth_obs("G02", "G02", 101.0, 11.0, None),
3459                    smooth_obs("G01", "G01", 201.0, 21.0, None),
3460                ],
3461            },
3462            CycleSlipEpoch {
3463                base_observations: vec![
3464                    smooth_obs("G01", "G01", 210.0, 30.0, None),
3465                    smooth_obs("G02", "G02", 110.0, 15.0, None),
3466                ],
3467                rover_observations: vec![
3468                    smooth_obs("G01", "G01", 211.0, 31.0, None),
3469                    smooth_obs("G02", "G02", 111.0, 16.0, Some(1)),
3470                ],
3471            },
3472            CycleSlipEpoch {
3473                base_observations: vec![smooth_obs("G01", "G01", 220.0, 40.0, None)],
3474                rover_observations: vec![smooth_obs("G01", "G01", 221.0, 41.0, None)],
3475            },
3476            CycleSlipEpoch {
3477                base_observations: vec![
3478                    smooth_obs("G02", "G02", 130.0, 18.0, None),
3479                    smooth_obs("G01", "G01", 230.0, 50.0, None),
3480                ],
3481                rover_observations: vec![
3482                    smooth_obs("G02", "G02", 131.0, 19.0, None),
3483                    smooth_obs("G01", "G01", 231.0, 51.0, None),
3484                ],
3485            },
3486        ];
3487
3488        assert_eq!(
3489            prepare_cycle_slip_baseline_epochs(&epochs, CycleSlipPolicy::Error),
3490            Err(CycleSlipPrepError::CycleSlipDetected {
3491                receiver: CycleSlipReceiver::Rover,
3492                satellite_id: "G02".to_string(),
3493                epoch_index: 1,
3494                reasons: vec![SlipReason::Lli],
3495            })
3496        );
3497
3498        let dropped =
3499            prepare_cycle_slip_baseline_epochs(&epochs, CycleSlipPolicy::DropSatellite).unwrap();
3500        assert_eq!(dropped.dropped_sats, vec!["G02".to_string()]);
3501        assert!(dropped.split_arcs.is_empty());
3502        assert!(dropped
3503            .epochs
3504            .iter()
3505            .all(
3506                |epoch| ambiguity_id(&epoch.base_observations, "G02").is_none()
3507                    && ambiguity_id(&epoch.rover_observations, "G02").is_none()
3508            ));
3509
3510        let split = prepare_cycle_slip_baseline_epochs(&epochs, CycleSlipPolicy::SplitArc).unwrap();
3511        assert!(split.dropped_sats.is_empty());
3512        assert_eq!(
3513            split.split_arcs,
3514            vec![
3515                CycleSlipSplitArc {
3516                    receiver: CycleSlipReceiver::Rover,
3517                    satellite_id: "G02".to_string(),
3518                    ambiguity_id: "G02@rover#1".to_string(),
3519                    start_epoch_index: 0,
3520                    end_epoch_index: 0,
3521                    n_epochs: 1,
3522                },
3523                CycleSlipSplitArc {
3524                    receiver: CycleSlipReceiver::Rover,
3525                    satellite_id: "G02".to_string(),
3526                    ambiguity_id: "G02@rover#2".to_string(),
3527                    start_epoch_index: 1,
3528                    end_epoch_index: 3,
3529                    n_epochs: 2,
3530                },
3531            ]
3532        );
3533        assert_eq!(
3534            ambiguity_id(&split.epochs[0].rover_observations, "G02"),
3535            Some("G02@rover#1")
3536        );
3537        assert_eq!(
3538            ambiguity_id(&split.epochs[1].rover_observations, "G02"),
3539            Some("G02@rover#2")
3540        );
3541        assert_eq!(
3542            ambiguity_id(&split.epochs[2].rover_observations, "G02"),
3543            None
3544        );
3545        assert_eq!(
3546            ambiguity_id(&split.epochs[3].rover_observations, "G02"),
3547            Some("G02@rover#2~ra1")
3548        );
3549        assert_eq!(
3550            ambiguity_id(&split.epochs[3].base_observations, "G02"),
3551            Some("G02~ra1")
3552        );
3553    }
3554
3555    #[test]
3556    fn cycle_slip_prep_rejects_non_finite_arc_data() {
3557        assert_eq!(
3558            prepare_cycle_slip_baseline_epochs(
3559                &[CycleSlipEpoch {
3560                    base_observations: vec![smooth_obs("G01", "G01", f64::NAN, 10.0, None)],
3561                    rover_observations: Vec::new(),
3562                }],
3563                CycleSlipPolicy::DropSatellite,
3564            ),
3565            Err(CycleSlipPrepError::InvalidInput {
3566                field: "rtk cycle slip code_m",
3567                reason: "not finite",
3568            })
3569        );
3570        assert_eq!(
3571            prepare_cycle_slip_baseline_epochs(
3572                &[CycleSlipEpoch {
3573                    base_observations: Vec::new(),
3574                    rover_observations: vec![smooth_obs("G01", "G01", 100.0, f64::INFINITY, None,)],
3575                }],
3576                CycleSlipPolicy::DropSatellite,
3577            ),
3578            Err(CycleSlipPrepError::InvalidInput {
3579                field: "rtk cycle slip phase_m",
3580                reason: "not finite",
3581            })
3582        );
3583    }
3584
3585    #[test]
3586    fn dual_cycle_slip_prep_rejects_invalid_arc_data_and_options() {
3587        let epochs = vec![dual_slip_epoch(
3588            "0",
3589            0.0,
3590            vec![dual_slip_obs("G01", "G01", 10.0, 8.0, None, None)],
3591            Vec::new(),
3592        )];
3593
3594        assert_eq!(
3595            prepare_dual_cycle_slip_baseline_epochs(
3596                &epochs,
3597                CycleSlipPolicy::DropSatellite,
3598                CycleSlipOptions {
3599                    gf_threshold_m: f64::NAN,
3600                    ..CycleSlipOptions::default()
3601                },
3602            ),
3603            Err(CycleSlipPrepError::InvalidInput {
3604                field: "rtk cycle slip gf_threshold_m",
3605                reason: "not finite",
3606            })
3607        );
3608        assert_eq!(
3609            prepare_dual_cycle_slip_baseline_epochs(
3610                &epochs,
3611                CycleSlipPolicy::DropSatellite,
3612                CycleSlipOptions {
3613                    min_arc_gap_s: 0.0,
3614                    ..CycleSlipOptions::default()
3615                },
3616            ),
3617            Err(CycleSlipPrepError::InvalidInput {
3618                field: "rtk cycle slip min_arc_gap_s",
3619                reason: "not positive",
3620            })
3621        );
3622
3623        let mut bad_gap = epochs.clone();
3624        bad_gap[0].gap_time_s = Some(f64::INFINITY);
3625        assert_eq!(
3626            prepare_dual_cycle_slip_baseline_epochs(
3627                &bad_gap,
3628                CycleSlipPolicy::DropSatellite,
3629                CycleSlipOptions::default(),
3630            ),
3631            Err(CycleSlipPrepError::InvalidInput {
3632                field: "rtk cycle slip gap_time_s",
3633                reason: "not finite",
3634            })
3635        );
3636
3637        let mut bad_phase = epochs.clone();
3638        bad_phase[0].base_observations[0].phi1_cycles = f64::NAN;
3639        assert_eq!(
3640            prepare_dual_cycle_slip_baseline_epochs(
3641                &bad_phase,
3642                CycleSlipPolicy::DropSatellite,
3643                CycleSlipOptions::default(),
3644            ),
3645            Err(CycleSlipPrepError::InvalidInput {
3646                field: "rtk cycle slip phi1_cycles",
3647                reason: "not finite",
3648            })
3649        );
3650
3651        let mut equal_frequency = epochs;
3652        equal_frequency[0].base_observations[0].f2_hz =
3653            equal_frequency[0].base_observations[0].f1_hz;
3654        assert_eq!(
3655            prepare_dual_cycle_slip_baseline_epochs(
3656                &equal_frequency,
3657                CycleSlipPolicy::DropSatellite,
3658                CycleSlipOptions::default(),
3659            ),
3660            Err(CycleSlipPrepError::InvalidInput {
3661                field: "rtk cycle slip frequencies_hz",
3662                reason: "degenerate frequencies",
3663            })
3664        );
3665
3666        let overflow_epoch = dual_slip_epoch(
3667            "0",
3668            0.0,
3669            vec![dual_slip_obs("G01", "G01", f64::MAX, -f64::MAX, None, None)],
3670            Vec::new(),
3671        );
3672        assert_eq!(
3673            prepare_dual_cycle_slip_baseline_epochs(
3674                &[overflow_epoch],
3675                CycleSlipPolicy::DropSatellite,
3676                CycleSlipOptions::default(),
3677            ),
3678            Err(CycleSlipPrepError::InvalidInput {
3679                field: "rtk cycle slip observation",
3680                reason: "not finite",
3681            })
3682        );
3683    }
3684
3685    #[test]
3686    fn dual_cycle_slip_prep_pins_policy_and_reacquisition_behavior() {
3687        let epochs = vec![
3688            dual_slip_epoch(
3689                "0",
3690                0.0,
3691                vec![
3692                    dual_slip_obs("G02", "G02", 10.0, 8.0, None, None),
3693                    dual_slip_obs("G01", "G01", 20.0, 18.0, None, None),
3694                ],
3695                vec![
3696                    dual_slip_obs("G02", "G02", 11.0, 9.0, None, None),
3697                    dual_slip_obs("G01", "G01", 21.0, 19.0, None, None),
3698                ],
3699            ),
3700            dual_slip_epoch(
3701                "1",
3702                1.0,
3703                vec![
3704                    dual_slip_obs("G01", "G01", 20.0, 18.0, None, None),
3705                    dual_slip_obs("G02", "G02", 10.0, 8.0, None, None),
3706                ],
3707                vec![
3708                    dual_slip_obs("G01", "G01", 21.0, 19.0, None, None),
3709                    dual_slip_obs("G02", "G02", 11.0, 9.0, Some(1), None),
3710                ],
3711            ),
3712            dual_slip_epoch(
3713                "2",
3714                2.0,
3715                vec![dual_slip_obs("G01", "G01", 20.0, 18.0, None, None)],
3716                vec![dual_slip_obs("G01", "G01", 21.0, 19.0, None, None)],
3717            ),
3718            dual_slip_epoch(
3719                "3",
3720                3.0,
3721                vec![
3722                    dual_slip_obs("G02", "G02", 10.0, 8.0, None, None),
3723                    dual_slip_obs("G01", "G01", 20.0, 18.0, None, None),
3724                ],
3725                vec![
3726                    dual_slip_obs("G02", "G02", 11.0, 9.0, None, None),
3727                    dual_slip_obs("G01", "G01", 21.0, 19.0, None, None),
3728                ],
3729            ),
3730        ];
3731        let options = CycleSlipOptions {
3732            gf_threshold_m: 1.0e9,
3733            mw_threshold_cycles: 1.0e9,
3734            min_arc_gap_s: 300.0,
3735        };
3736
3737        assert_eq!(
3738            prepare_dual_cycle_slip_baseline_epochs(&epochs, CycleSlipPolicy::Error, options),
3739            Err(CycleSlipPrepError::CycleSlipDetected {
3740                receiver: CycleSlipReceiver::Rover,
3741                satellite_id: "G02".to_string(),
3742                epoch_index: 1,
3743                reasons: vec![SlipReason::Lli],
3744            })
3745        );
3746
3747        let dropped = prepare_dual_cycle_slip_baseline_epochs(
3748            &epochs,
3749            CycleSlipPolicy::DropSatellite,
3750            options,
3751        )
3752        .unwrap();
3753        assert_eq!(dropped.dropped_sats, vec!["G02".to_string()]);
3754        assert!(dropped.split_arcs.is_empty());
3755        assert!(dropped.epochs.iter().all(|epoch| dual_ambiguity_id(
3756            &epoch.base_observations,
3757            "G02"
3758        )
3759        .is_none()
3760            && dual_ambiguity_id(&epoch.rover_observations, "G02").is_none()));
3761
3762        let split =
3763            prepare_dual_cycle_slip_baseline_epochs(&epochs, CycleSlipPolicy::SplitArc, options)
3764                .unwrap();
3765        assert!(split.dropped_sats.is_empty());
3766        assert_eq!(
3767            split.split_arcs,
3768            vec![
3769                CycleSlipSplitArc {
3770                    receiver: CycleSlipReceiver::Rover,
3771                    satellite_id: "G02".to_string(),
3772                    ambiguity_id: "G02@rover#1".to_string(),
3773                    start_epoch_index: 0,
3774                    end_epoch_index: 0,
3775                    n_epochs: 1,
3776                },
3777                CycleSlipSplitArc {
3778                    receiver: CycleSlipReceiver::Rover,
3779                    satellite_id: "G02".to_string(),
3780                    ambiguity_id: "G02@rover#2".to_string(),
3781                    start_epoch_index: 1,
3782                    end_epoch_index: 3,
3783                    n_epochs: 2,
3784                },
3785            ]
3786        );
3787        assert_eq!(
3788            dual_ambiguity_id(&split.epochs[0].rover_observations, "G02"),
3789            Some("G02@rover#1")
3790        );
3791        assert_eq!(
3792            dual_ambiguity_id(&split.epochs[1].rover_observations, "G02"),
3793            Some("G02@rover#2")
3794        );
3795        assert_eq!(
3796            dual_ambiguity_id(&split.epochs[2].rover_observations, "G02"),
3797            None
3798        );
3799        assert_eq!(
3800            dual_ambiguity_id(&split.epochs[3].rover_observations, "G02"),
3801            Some("G02@rover#2~ra1")
3802        );
3803        assert_eq!(
3804            dual_ambiguity_id(&split.epochs[3].base_observations, "G02"),
3805            Some("G02~ra1")
3806        );
3807    }
3808
3809    #[test]
3810    fn dual_cycle_slip_prep_uses_threshold_and_gap_classification() {
3811        let epochs = vec![
3812            dual_slip_epoch(
3813                "0",
3814                0.0,
3815                vec![dual_slip_obs("G03", "G03", 10.0, 8.0, None, None)],
3816                vec![dual_slip_obs("G03", "G03", 11.0, 9.0, None, None)],
3817            ),
3818            dual_slip_epoch(
3819                "1",
3820                20.0,
3821                vec![dual_slip_obs("G03", "G03", 11.0, 8.0, None, None)],
3822                vec![dual_slip_obs("G03", "G03", 11.0, 9.0, None, None)],
3823            ),
3824        ];
3825        let options = CycleSlipOptions {
3826            gf_threshold_m: 0.05,
3827            mw_threshold_cycles: 0.5,
3828            min_arc_gap_s: 10.0,
3829        };
3830
3831        assert_eq!(
3832            prepare_dual_cycle_slip_baseline_epochs(&epochs, CycleSlipPolicy::Error, options),
3833            Err(CycleSlipPrepError::CycleSlipDetected {
3834                receiver: CycleSlipReceiver::Base,
3835                satellite_id: "G03".to_string(),
3836                epoch_index: 1,
3837                reasons: vec![
3838                    SlipReason::DataGap,
3839                    SlipReason::GeometryFree,
3840                    SlipReason::MelbourneWubbena,
3841                ],
3842            })
3843        );
3844    }
3845
3846    #[test]
3847    fn baseline_reference_errors_match_public_tags() {
3848        let base = [10.0, 0.0, 0.0];
3849        let multi = vec![baseline_reference_epoch(&[
3850            ("G01", [20.0, 0.0, 0.0]),
3851            ("G02", [20.0, 0.0, 0.0]),
3852            ("E01", [20.0, 0.0, 0.0]),
3853            ("E02", [20.0, 0.0, 0.0]),
3854        ])];
3855        assert_eq!(
3856            baseline_reference_satellites(
3857                base,
3858                &multi,
3859                BaselineReferenceSelection::Satellite("G01".to_string()),
3860            ),
3861            Err(DoubleDifferenceError::ReferenceSatelliteSingleSystem(
3862                "G01".to_string()
3863            ))
3864        );
3865        assert_eq!(
3866            baseline_reference_satellites(
3867                base,
3868                &multi,
3869                BaselineReferenceSelection::PerSystem(BTreeMap::from([(
3870                    "G".to_string(),
3871                    "G01".to_string(),
3872                )])),
3873            ),
3874            Err(DoubleDifferenceError::ReferenceSatelliteMissingSystem(
3875                "E".to_string()
3876            ))
3877        );
3878
3879        let split = vec![
3880            baseline_reference_epoch(&[("G01", [20.0, 0.0, 0.0])]),
3881            baseline_reference_epoch(&[("G02", [20.0, 0.0, 0.0])]),
3882        ];
3883        assert_eq!(
3884            baseline_reference_satellites(base, &split, BaselineReferenceSelection::Auto),
3885            Err(DoubleDifferenceError::NoCommonReferenceSatellite(
3886                "G".to_string()
3887            ))
3888        );
3889    }
3890
3891    #[test]
3892    fn errors_are_tagged() {
3893        assert_eq!(
3894            double_differences(
3895                &[obs("G01", 1.0, 2.0)],
3896                &[obs("G01", 1.0, 2.0)],
3897                ReferenceSelection::Auto
3898            ),
3899            Err(DoubleDifferenceError::TooFewCommonSatellites {
3900                count: 1,
3901                minimum: 2,
3902            })
3903        );
3904        assert_eq!(
3905            double_differences(
3906                &[obs("G01", 1.0, 2.0), obs("G01", 3.0, 4.0)],
3907                &[obs("G01", 1.0, 2.0), obs("G02", 3.0, 4.0)],
3908                ReferenceSelection::Auto,
3909            ),
3910            Err(DoubleDifferenceError::DuplicateObservation(
3911                "G01".to_string()
3912            ))
3913        );
3914        assert_eq!(
3915            double_differences(
3916                &[obs("G01", 1.0, 2.0), obs("G02", 3.0, 4.0)],
3917                &[obs("G01", 1.0, 2.0), obs("G02", 3.0, 4.0)],
3918                ReferenceSelection::Satellite("G99".to_string()),
3919            ),
3920            Err(DoubleDifferenceError::ReferenceSatelliteMissing(
3921                "G99".to_string()
3922            ))
3923        );
3924    }
3925
3926    #[test]
3927    fn double_differences_reject_non_finite_observations() {
3928        let rover = vec![obs("G01", 10.0, 11.0), obs("G02", 20.0, 21.0)];
3929
3930        assert_eq!(
3931            double_differences(
3932                &[obs("G01", 1.0, 2.0), obs("G02", f64::NAN, 4.0)],
3933                &rover,
3934                ReferenceSelection::Auto,
3935            ),
3936            Err(DoubleDifferenceError::InvalidInput {
3937                field: "rtk observation code_m",
3938                reason: "not finite",
3939            })
3940        );
3941
3942        assert_eq!(
3943            double_differences(
3944                &[obs("G01", 1.0, 2.0), obs("G02", 3.0, 4.0)],
3945                &[obs("G01", 10.0, f64::INFINITY), obs("G02", 20.0, 21.0)],
3946                ReferenceSelection::Auto,
3947            ),
3948            Err(DoubleDifferenceError::InvalidInput {
3949                field: "rtk observation phase_m",
3950                reason: "not finite",
3951            })
3952        );
3953    }
3954
3955    #[test]
3956    fn estimates_dual_frequency_wide_lane_integers() {
3957        let epochs = vec![
3958            DualEpoch {
3959                observations: vec![
3960                    dual_pair("G01", 0.0, 1.0),
3961                    dual_pair("G02", 0.0, 4.0),
3962                    dual_pair("G03", 0.0, -1.0),
3963                ],
3964            },
3965            DualEpoch {
3966                observations: vec![
3967                    dual_pair("G01", 0.0, 2.0),
3968                    dual_pair("G02", 0.0, 5.0),
3969                    dual_pair("G03", 0.0, 0.0),
3970                ],
3971            },
3972        ];
3973
3974        let fixed = estimate_wide_lane_ambiguities(
3975            &epochs,
3976            "G01",
3977            WideLaneOptions {
3978                min_epochs: 2,
3979                tolerance_cycles: 1.0e-9,
3980                skip_short_fragments: false,
3981            },
3982        )
3983        .unwrap();
3984
3985        assert_eq!(
3986            fixed,
3987            BTreeMap::from([("G02".to_string(), 3), ("G03".to_string(), -2)])
3988        );
3989    }
3990
3991    #[test]
3992    fn split_arc_wide_lane_skips_short_fragments() {
3993        let epochs = vec![
3994            DualEpoch {
3995                observations: vec![
3996                    dual_pair("G01", 0.0, 1.0),
3997                    split_dual_pair("G02", "G02@rover#1", 0.0, 4.0),
3998                    dual_pair("G03", 0.0, -1.0),
3999                ],
4000            },
4001            DualEpoch {
4002                observations: vec![dual_pair("G01", 0.0, 2.0), dual_pair("G03", 0.0, 0.0)],
4003            },
4004        ];
4005
4006        let options = WideLaneOptions {
4007            min_epochs: 2,
4008            tolerance_cycles: 1.0e-9,
4009            skip_short_fragments: true,
4010        };
4011        let fixed = estimate_wide_lane_ambiguities(&epochs, "G01", options).unwrap();
4012        assert_eq!(fixed, BTreeMap::from([("G03".to_string(), -2)]));
4013
4014        let err = estimate_wide_lane_ambiguities(
4015            &epochs,
4016            "G01",
4017            WideLaneOptions {
4018                skip_short_fragments: false,
4019                ..options
4020            },
4021        )
4022        .unwrap_err();
4023        assert_eq!(
4024            err,
4025            WideLaneError::TooFewWideLaneEpochs {
4026                ambiguity_id: "G02@rover#1|ref=G01".to_string(),
4027                count: 1,
4028                minimum: 2,
4029            }
4030        );
4031    }
4032
4033    #[test]
4034    fn wide_lane_rejects_invalid_inputs_and_options() {
4035        let epochs = vec![DualEpoch {
4036            observations: vec![dual_pair("G01", 0.0, 1.0), dual_pair("G02", 0.0, 4.0)],
4037        }];
4038
4039        assert_eq!(
4040            estimate_wide_lane_ambiguities(
4041                &epochs,
4042                "G01",
4043                WideLaneOptions {
4044                    min_epochs: 0,
4045                    tolerance_cycles: 1.0e-9,
4046                    skip_short_fragments: false,
4047                },
4048            ),
4049            Err(WideLaneError::InvalidInput {
4050                field: "rtk wide lane min_epochs",
4051                reason: "not positive",
4052            })
4053        );
4054        assert_eq!(
4055            estimate_wide_lane_ambiguities(
4056                &epochs,
4057                "G01",
4058                WideLaneOptions {
4059                    min_epochs: 1,
4060                    tolerance_cycles: f64::NAN,
4061                    skip_short_fragments: false,
4062                },
4063            ),
4064            Err(WideLaneError::InvalidInput {
4065                field: "rtk wide lane tolerance_cycles",
4066                reason: "not finite",
4067            })
4068        );
4069
4070        let mut bad_epoch = epochs;
4071        bad_epoch[0].observations[1].rover.p1_m = f64::INFINITY;
4072        assert_eq!(
4073            estimate_wide_lane_ambiguities(
4074                &bad_epoch,
4075                "G01",
4076                WideLaneOptions {
4077                    min_epochs: 1,
4078                    tolerance_cycles: 1.0e-9,
4079                    skip_short_fragments: false,
4080                },
4081            ),
4082            Err(WideLaneError::InvalidInput {
4083                field: "rtk wide lane p1_m",
4084                reason: "not finite",
4085            })
4086        );
4087    }
4088
4089    #[test]
4090    fn builds_ionosphere_free_epochs_and_narrow_lane_params() {
4091        let epochs = vec![
4092            DualIonosphereFreeEpoch {
4093                observations: vec![
4094                    if_pair(
4095                        "G01",
4096                        20_000_000.0,
4097                        20_000_020.0,
4098                        105_100_000.0,
4099                        105_100_040.0,
4100                    ),
4101                    if_pair(
4102                        "G02",
4103                        21_000_000.0,
4104                        21_000_035.0,
4105                        110_200_000.0,
4106                        110_200_090.0,
4107                    ),
4108                    if_pair(
4109                        "G03",
4110                        22_000_000.0,
4111                        22_000_055.0,
4112                        115_300_000.0,
4113                        115_300_120.0,
4114                    ),
4115                ],
4116            },
4117            DualIonosphereFreeEpoch {
4118                observations: vec![
4119                    if_pair(
4120                        "G01",
4121                        20_000_100.0,
4122                        20_000_120.0,
4123                        105_100_500.0,
4124                        105_100_540.0,
4125                    ),
4126                    if_pair(
4127                        "G02",
4128                        21_000_100.0,
4129                        21_000_135.0,
4130                        110_200_500.0,
4131                        110_200_590.0,
4132                    ),
4133                    if_pair(
4134                        "G03",
4135                        22_000_100.0,
4136                        22_000_155.0,
4137                        115_300_500.0,
4138                        115_300_620.0,
4139                    ),
4140                ],
4141            },
4142        ];
4143        let wide_lanes = BTreeMap::from([("G02".to_string(), 3), ("G03".to_string(), -5)]);
4144
4145        let result = build_ionosphere_free_baseline_epochs(&epochs, "G01", &wide_lanes).unwrap();
4146
4147        assert_eq!(
4148            result
4149                .wavelengths_m
4150                .iter()
4151                .map(|(id, value)| (id.as_str(), value.to_bits()))
4152                .collect::<Vec<_>>(),
4153            [("G02", 0x3fbb614bed5136b9), ("G03", 0x3fbb614bed5136b9),]
4154        );
4155        assert_eq!(
4156            result
4157                .offsets_m
4158                .iter()
4159                .map(|(id, value)| (id.as_str(), value.to_bits()))
4160                .collect::<Vec<_>>(),
4161            [("G02", 0x3ff21e814dfd4618), ("G03", 0xbffe32d781fb74d4),]
4162        );
4163        assert_eq!(result.epochs.len(), 2);
4164        assert_eq!(
4165            result
4166                .epochs
4167                .iter()
4168                .map(|epoch| (
4169                    epoch.epoch_index,
4170                    epoch.satellite_ids.clone(),
4171                    epoch
4172                        .base_observations
4173                        .iter()
4174                        .map(|obs| (
4175                            obs.satellite_id.as_str(),
4176                            obs.ambiguity_id.as_str(),
4177                            obs.code_m.to_bits(),
4178                            obs.phase_m.to_bits()
4179                        ))
4180                        .collect::<Vec<_>>(),
4181                    epoch
4182                        .rover_observations
4183                        .iter()
4184                        .map(|obs| (
4185                            obs.satellite_id.as_str(),
4186                            obs.ambiguity_id.as_str(),
4187                            obs.code_m.to_bits(),
4188                            obs.phase_m.to_bits()
4189                        ))
4190                        .collect::<Vec<_>>()
4191                ))
4192                .collect::<Vec<_>>(),
4193            vec![
4194                (
4195                    0,
4196                    vec!["G01".to_string(), "G02".to_string(), "G03".to_string()],
4197                    vec![
4198                        ("G01", "G01", 0x417312cfca8965e4, 0x416570ac29af7848),
4199                        ("G02", "G02", 0x417406f3ca8965e4, 0x41667b02f0ff8bd8),
4200                        ("G03", "G03", 0x4174fb17ca8965e4, 0x41678559b84f9f64),
4201                    ],
4202                    vec![
4203                        ("G01", "G01", 0x417312d0fa2bbf5f, 0x416570ac9e819db4),
4204                        ("G02", "G02", 0x417406f5ea2bbf5e, 0x41667b0410f1cbd0),
4205                        ("G03", "G03", 0x4174fb1b2a2bbf5e, 0x4167855b3eeebc18),
4206                    ],
4207                ),
4208                (
4209                    1,
4210                    vec!["G01".to_string(), "G02".to_string(), "G03".to_string()],
4211                    vec![
4212                        ("G01", "G01", 0x417312d60a8965e4, 0x416570b2d8f081bc),
4213                        ("G02", "G02", 0x417406fa0a8965e4, 0x41667b09a0409544),
4214                        ("G03", "G03", 0x4174fb1e0a8965e4, 0x416785606790a8d4),
4215                    ],
4216                    vec![
4217                        ("G01", "G01", 0x417312d73a2bbf5f, 0x416570b34dc2a728),
4218                        ("G02", "G02", 0x417406fc2a2bbf5e, 0x41667b0ac032d540),
4219                        ("G03", "G03", 0x4174fb216a2bbf5e, 0x41678561ee2fc588),
4220                    ],
4221                ),
4222            ]
4223        );
4224    }
4225
4226    #[test]
4227    fn ionosphere_free_builders_reject_invalid_tropo_and_setup_inputs() {
4228        let mut epochs = vec![DualIonosphereFreeEpoch {
4229            observations: vec![
4230                if_pair(
4231                    "G01",
4232                    20_000_000.0,
4233                    20_000_020.0,
4234                    105_100_000.0,
4235                    105_100_040.0,
4236                ),
4237                if_pair(
4238                    "G02",
4239                    21_000_000.0,
4240                    21_000_035.0,
4241                    110_200_000.0,
4242                    110_200_090.0,
4243                ),
4244            ],
4245        }];
4246        epochs[0].observations[1].rover.tropo_m = f64::NAN;
4247        assert_eq!(
4248            build_ionosphere_free_baseline_epochs(
4249                &epochs,
4250                "G01",
4251                &BTreeMap::from([("G02".to_string(), 3)]),
4252            ),
4253            Err(IonosphereFreeBaselineError::InvalidInput {
4254                field: "rtk if tropo_m",
4255                reason: "not finite",
4256            })
4257        );
4258
4259        let setup_epochs = vec![DualIonosphereFreeSetupEpoch {
4260            jd_whole: 2_460_100.5,
4261            jd_fraction: 2.0,
4262            observations: vec![dual_pair("G01", 0.0, 1.0), dual_pair("G02", 0.0, 4.0)],
4263            base_satellite_positions_m: BTreeMap::from([
4264                ("G01".to_string(), [20.0, 0.0, 0.0]),
4265                ("G02".to_string(), [30.0, 0.0, 0.0]),
4266            ]),
4267            rover_satellite_positions_m: BTreeMap::from([
4268                ("G01".to_string(), [20.0, 0.0, 0.0]),
4269                ("G02".to_string(), [30.0, 0.0, 0.0]),
4270            ]),
4271        }];
4272        assert_eq!(
4273            prepare_ionosphere_free_baseline_epochs(
4274                [10.0, 0.0, 0.0],
4275                [0.0, 0.0, 0.0],
4276                &setup_epochs,
4277                "G01",
4278                &BTreeMap::from([("G02".to_string(), 3)]),
4279                false,
4280            ),
4281            Err(IonosphereFreeBaselineError::InvalidInput {
4282                field: "rtk if setup jd_fraction",
4283                reason: "out of range",
4284            })
4285        );
4286
4287        let tropo_epochs = vec![DualIonosphereFreeSetupEpoch {
4288            jd_whole: 2_460_100.5,
4289            jd_fraction: 0.0,
4290            observations: vec![dual_pair("G01", 0.0, 1.0), dual_pair("G02", 0.0, 4.0)],
4291            base_satellite_positions_m: BTreeMap::from([
4292                ("G01".to_string(), [10.0, 0.0, 0.0]),
4293                ("G02".to_string(), [30.0, 0.0, 0.0]),
4294            ]),
4295            rover_satellite_positions_m: BTreeMap::from([
4296                ("G01".to_string(), [20.0, 0.0, 0.0]),
4297                ("G02".to_string(), [30.0, 0.0, 0.0]),
4298            ]),
4299        }];
4300        assert_eq!(
4301            prepare_ionosphere_free_baseline_epochs(
4302                [10.0, 0.0, 0.0],
4303                [0.0, 0.0, 0.0],
4304                &tropo_epochs,
4305                "G01",
4306                &BTreeMap::from([("G02".to_string(), 3)]),
4307                true,
4308            ),
4309            Err(IonosphereFreeBaselineError::InvalidInput {
4310                field: "rtk tropo line of sight_m",
4311                reason: "degenerate geometry",
4312            })
4313        );
4314    }
4315
4316    #[test]
4317    fn ionosphere_free_setup_rejects_invalid_julian_split_without_panic() {
4318        let setup_epochs = vec![DualIonosphereFreeSetupEpoch {
4319            jd_whole: f64::NAN,
4320            jd_fraction: 0.0,
4321            observations: vec![dual_pair("G01", 0.0, 1.0), dual_pair("G02", 0.0, 4.0)],
4322            base_satellite_positions_m: BTreeMap::from([
4323                (
4324                    "G01".to_string(),
4325                    [20_200_000.0, 14_000_000.0, 21_700_000.0],
4326                ),
4327                (
4328                    "G02".to_string(),
4329                    [21_200_000.0, 13_000_000.0, 20_700_000.0],
4330                ),
4331            ]),
4332            rover_satellite_positions_m: BTreeMap::from([
4333                (
4334                    "G01".to_string(),
4335                    [20_200_100.0, 14_000_000.0, 21_700_000.0],
4336                ),
4337                (
4338                    "G02".to_string(),
4339                    [21_200_100.0, 13_000_000.0, 20_700_000.0],
4340                ),
4341            ]),
4342        }];
4343
4344        let result = std::panic::catch_unwind(|| {
4345            prepare_ionosphere_free_baseline_epochs(
4346                [6_378_137.0, 0.0, 0.0],
4347                [1.0, 0.0, 0.0],
4348                &setup_epochs,
4349                "G01",
4350                &BTreeMap::from([("G02".to_string(), 3)]),
4351                true,
4352            )
4353        });
4354
4355        assert!(result.is_ok(), "invalid Julian split must not panic");
4356        assert_eq!(
4357            result.expect("invalid Julian split should not unwind"),
4358            Err(IonosphereFreeBaselineError::InvalidInput {
4359                field: "rtk if setup jd_whole",
4360                reason: "not finite",
4361            })
4362        );
4363    }
4364
4365    #[test]
4366    fn ionosphere_free_setup_rejects_nonfinite_tropo_receiver_without_panic() {
4367        let setup_epochs = vec![DualIonosphereFreeSetupEpoch {
4368            jd_whole: 2_460_100.5,
4369            jd_fraction: 0.0,
4370            observations: vec![dual_pair("G01", 0.0, 1.0), dual_pair("G02", 0.0, 4.0)],
4371            base_satellite_positions_m: BTreeMap::from([
4372                (
4373                    "G01".to_string(),
4374                    [20_200_000.0, 14_000_000.0, 21_700_000.0],
4375                ),
4376                (
4377                    "G02".to_string(),
4378                    [21_200_000.0, 13_000_000.0, 20_700_000.0],
4379                ),
4380            ]),
4381            rover_satellite_positions_m: BTreeMap::from([
4382                (
4383                    "G01".to_string(),
4384                    [20_200_100.0, 14_000_000.0, 21_700_000.0],
4385                ),
4386                (
4387                    "G02".to_string(),
4388                    [21_200_100.0, 13_000_000.0, 20_700_000.0],
4389                ),
4390            ]),
4391        }];
4392
4393        let result = std::panic::catch_unwind(|| {
4394            prepare_ionosphere_free_baseline_epochs(
4395                [f64::NAN, 0.0, 0.0],
4396                [1.0, 0.0, 0.0],
4397                &setup_epochs,
4398                "G01",
4399                &BTreeMap::from([("G02".to_string(), 3)]),
4400                true,
4401            )
4402        });
4403
4404        assert!(result.is_ok(), "non-finite tropo receiver must not panic");
4405        assert_eq!(
4406            result.expect("non-finite tropo receiver should not unwind"),
4407            Err(IonosphereFreeBaselineError::InvalidInput {
4408                field: "rtk tropo base position_m",
4409                reason: "not finite",
4410            })
4411        );
4412    }
4413
4414    #[test]
4415    fn ionosphere_free_setup_handles_antimeridian_tropo_receiver_without_panic() {
4416        let setup_epochs = vec![DualIonosphereFreeSetupEpoch {
4417            jd_whole: 2_460_100.5,
4418            jd_fraction: 0.0,
4419            observations: vec![dual_pair("G01", 0.0, 1.0), dual_pair("G02", 0.0, 4.0)],
4420            base_satellite_positions_m: BTreeMap::from([
4421                (
4422                    "G01".to_string(),
4423                    [-20_200_000.0, -14_000_000.0, 21_700_000.0],
4424                ),
4425                (
4426                    "G02".to_string(),
4427                    [-21_200_000.0, -13_000_000.0, 20_700_000.0],
4428                ),
4429            ]),
4430            rover_satellite_positions_m: BTreeMap::from([
4431                (
4432                    "G01".to_string(),
4433                    [-20_200_100.0, -14_000_000.0, 21_700_000.0],
4434                ),
4435                (
4436                    "G02".to_string(),
4437                    [-21_200_100.0, -13_000_000.0, 20_700_000.0],
4438                ),
4439            ]),
4440        }];
4441
4442        let result = std::panic::catch_unwind(|| {
4443            prepare_ionosphere_free_baseline_epochs(
4444                [-6_378_137.0, -0.0, 0.0],
4445                [1.0, 0.0, 0.0],
4446                &setup_epochs,
4447                "G01",
4448                &BTreeMap::from([("G02".to_string(), 3)]),
4449                true,
4450            )
4451        });
4452
4453        assert!(result.is_ok(), "antimeridian tropo receiver must not panic");
4454        result
4455            .expect("antimeridian tropo receiver should not unwind")
4456            .expect("antimeridian tropo receiver should prepare IF epochs");
4457    }
4458
4459    #[test]
4460    fn ionosphere_free_epoch_builder_skips_missing_wide_lane_fragments() {
4461        let epochs = vec![DualIonosphereFreeEpoch {
4462            observations: vec![
4463                if_pair(
4464                    "G01",
4465                    20_000_000.0,
4466                    20_000_020.0,
4467                    105_100_000.0,
4468                    105_100_040.0,
4469                ),
4470                if_pair(
4471                    "G02",
4472                    21_000_000.0,
4473                    21_000_035.0,
4474                    110_200_000.0,
4475                    110_200_090.0,
4476                ),
4477                if_pair(
4478                    "G03",
4479                    22_000_000.0,
4480                    22_000_055.0,
4481                    115_300_000.0,
4482                    115_300_120.0,
4483                ),
4484            ],
4485        }];
4486        let wide_lanes = BTreeMap::from([("G02".to_string(), 3)]);
4487
4488        let result = build_ionosphere_free_baseline_epochs(&epochs, "G01", &wide_lanes).unwrap();
4489
4490        assert_eq!(result.epochs[0].satellite_ids, ["G01", "G02"]);
4491        assert_eq!(
4492            result.wavelengths_m.keys().collect::<Vec<_>>(),
4493            [&"G02".to_string()]
4494        );
4495    }
4496
4497    #[test]
4498    fn wide_lane_errors_on_equal_frequencies() {
4499        let mut bad = dual_pair("G02", 0.0, 4.0);
4500        bad.base.f2_hz = gps_l1_hz();
4501        let epochs = vec![DualEpoch {
4502            observations: vec![dual_pair("G01", 0.0, 1.0), bad],
4503        }];
4504
4505        let err = estimate_wide_lane_ambiguities(
4506            &epochs,
4507            "G01",
4508            WideLaneOptions {
4509                min_epochs: 1,
4510                tolerance_cycles: 0.5,
4511                skip_short_fragments: false,
4512            },
4513        )
4514        .unwrap_err();
4515
4516        assert_eq!(
4517            err,
4518            WideLaneError::WideLaneFailed {
4519                satellite_id: "G02".to_string(),
4520                reason: CarrierPhaseError::EqualFrequencies,
4521            }
4522        );
4523    }
4524}