Skip to main content

sidereon_core/precise_positioning/
prep.rs

1//! Wide-lane/narrow-lane and cycle-slip preparation for static PPP.
2//!
3//! Owns the dual-frequency ambiguity prep that runs ahead of the float/fixed
4//! solve: wide-lane integer estimation, ionosphere-free narrow-lane epoch
5//! construction, and cycle-slip arc segmentation (both the dual-frequency
6//! wide-lane path and the float ambiguity-id tagging path). These steps depend
7//! only on the raw observations, not on the least-squares solve, so they form a
8//! self-contained leaf that the parent module re-exports.
9
10use std::collections::{BTreeMap, BTreeSet};
11
12use crate::ambiguity::{self, AmbiguityId, CycleSlipPolicy, NarrowLaneParams};
13use crate::carrier_phase::{
14    detect_cycle_slips, ArcEpoch, CarrierPhaseError, CycleSlipOptions, SlipReason,
15};
16use crate::combinations::{self, IonosphereFreeError};
17
18/// Raw dual-frequency PPP observation used by wide-lane/narrow-lane prep.
19#[derive(Debug, Clone, PartialEq)]
20pub struct DualFrequencyObservation {
21    pub satellite_id: String,
22    pub ambiguity_id: String,
23    pub p1_m: f64,
24    pub p2_m: f64,
25    pub phi1_cyc: f64,
26    pub phi2_cyc: f64,
27    pub f1_hz: f64,
28    pub f2_hz: f64,
29    pub lli1: Option<i64>,
30    pub lli2: Option<i64>,
31}
32
33/// One raw dual-frequency PPP epoch.
34#[derive(Debug, Clone, PartialEq)]
35pub struct DualFrequencyEpoch {
36    /// Comparable epoch coordinate in seconds for data-gap cycle-slip checks.
37    pub gap_time_s: Option<f64>,
38    pub observations: Vec<DualFrequencyObservation>,
39}
40
41/// One ionosphere-free PPP observation emitted by dual-frequency prep.
42#[derive(Debug, Clone, PartialEq)]
43pub struct PreparedFloatObservation {
44    pub satellite_id: String,
45    pub ambiguity_id: String,
46    pub code_m: f64,
47    pub phase_m: f64,
48}
49
50/// One prepared ionosphere-free epoch.
51#[derive(Debug, Clone, PartialEq)]
52pub struct PreparedFloatEpoch {
53    pub epoch_index: usize,
54    pub observations: Vec<PreparedFloatObservation>,
55}
56
57/// Wide-lane and narrow-lane prep controls.
58#[derive(Debug, Clone, Copy, PartialEq)]
59pub struct WideLanePrepOptions {
60    pub min_epochs: usize,
61    pub tolerance_cycles: f64,
62}
63
64/// Public split-arc metadata for PPP ambiguity segmentation.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct PppSplitArc {
67    pub satellite_id: String,
68    pub ambiguity_id: String,
69    pub start_epoch_index: usize,
70    pub end_epoch_index: usize,
71    pub n_epochs: usize,
72}
73
74/// Prepared dual-frequency PPP arc for the fixed wide-lane/narrow-lane path.
75#[derive(Debug, Clone, PartialEq)]
76pub struct WideLanePrepResult {
77    pub epochs: Vec<PreparedFloatEpoch>,
78    pub wavelengths_m: BTreeMap<String, f64>,
79    pub offsets_m: BTreeMap<String, f64>,
80    pub wide_lane_cycles: BTreeMap<String, i64>,
81    pub dropped_sats: Vec<String>,
82    pub split_arcs: Vec<PppSplitArc>,
83}
84
85/// Error from PPP wide-lane/narrow-lane prep.
86#[derive(Debug, Clone, PartialEq)]
87pub enum WideLanePrepError {
88    CycleSlipDetected {
89        satellite_id: String,
90        epoch_index: usize,
91        reasons: Vec<SlipReason>,
92    },
93    WideLaneFailed {
94        ambiguity_id: String,
95        reason: CarrierPhaseError,
96    },
97    TooFewWideLaneEpochs {
98        ambiguity_id: String,
99        count: usize,
100        minimum: usize,
101    },
102    WideLaneNotInteger {
103        ambiguity_id: String,
104        mean_cycles: f64,
105        fixed_cycles: i64,
106    },
107    MissingWideLaneAmbiguity(String),
108    InconsistentFrequencies(String),
109    IonosphereFreeFailed {
110        satellite_id: String,
111        reason: IonosphereFreeError,
112    },
113}
114
115/// One float PPP observation with optional raw dual-frequency fields for
116/// cycle-slip ambiguity-tagging.
117#[derive(Debug, Clone, PartialEq)]
118pub struct FloatCycleSlipObservation {
119    pub satellite_id: String,
120    pub ambiguity_id: String,
121    pub raw: Option<DualFrequencyObservation>,
122}
123
124/// One float PPP epoch for cycle-slip ambiguity-tagging.
125#[derive(Debug, Clone, PartialEq)]
126pub struct FloatCycleSlipEpoch {
127    pub gap_time_s: Option<f64>,
128    pub observations: Vec<FloatCycleSlipObservation>,
129}
130
131/// One tagged float PPP observation returned by cycle-slip prep.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct FloatCycleSlipTaggedObservation {
134    pub satellite_id: String,
135    pub ambiguity_id: String,
136}
137
138/// One tagged float PPP epoch returned by cycle-slip prep.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct FloatCycleSlipTaggedEpoch {
141    pub observations: Vec<FloatCycleSlipTaggedObservation>,
142}
143/// Prepare raw dual-frequency PPP observations for the wide-lane then
144/// narrow-lane fixed ambiguity path.
145pub fn prepare_widelane_fixed_epochs(
146    epochs: &[DualFrequencyEpoch],
147    wide_lane: WideLanePrepOptions,
148    cycle_slip_policy: CycleSlipPolicy,
149    cycle_slip_options: CycleSlipOptions,
150) -> Result<WideLanePrepResult, WideLanePrepError> {
151    let (prepared_dual_epochs, wide_lane_cycles, dropped_sats, split_arcs) =
152        wide_lane_ambiguities(epochs, wide_lane, cycle_slip_policy, cycle_slip_options)?;
153    let filtered_dual_epochs =
154        filter_dual_epochs_by_wide_lanes(&prepared_dual_epochs, &wide_lane_cycles);
155    let (if_epochs, wavelengths_m, offsets_m) =
156        ionosphere_free_narrow_lane_epochs(&filtered_dual_epochs, &wide_lane_cycles)?;
157    Ok(WideLanePrepResult {
158        epochs: if_epochs,
159        wavelengths_m,
160        offsets_m,
161        wide_lane_cycles,
162        dropped_sats,
163        split_arcs,
164    })
165}
166/// Rewrite float PPP ambiguity ids at detected dual-frequency cycle slips.
167pub fn split_float_cycle_slip_epochs(
168    epochs: &[FloatCycleSlipEpoch],
169    cycle_slip_options: CycleSlipOptions,
170) -> Vec<FloatCycleSlipTaggedEpoch> {
171    let tags = float_cycle_slip_tags(epochs, cycle_slip_options);
172    epochs
173        .iter()
174        .enumerate()
175        .map(|(epoch_index, epoch)| {
176            let mut observations = epoch
177                .observations
178                .iter()
179                .map(|obs| {
180                    let ambiguity_id = tags
181                        .get(&(epoch_index, obs.satellite_id.clone()))
182                        .map(|id| id.as_str().to_string())
183                        .unwrap_or_else(|| obs.ambiguity_id.clone());
184                    FloatCycleSlipTaggedObservation {
185                        satellite_id: obs.satellite_id.clone(),
186                        ambiguity_id,
187                    }
188                })
189                .collect::<Vec<_>>();
190            observations.sort_by(|a, b| {
191                (a.satellite_id.as_str(), a.ambiguity_id.as_str())
192                    .cmp(&(b.satellite_id.as_str(), b.ambiguity_id.as_str()))
193            });
194            FloatCycleSlipTaggedEpoch { observations }
195        })
196        .collect()
197}
198#[derive(Clone, Copy)]
199struct DualArcSample<'a> {
200    epoch_index: usize,
201    gap_time_s: Option<f64>,
202    observation: &'a DualFrequencyObservation,
203}
204
205#[derive(Clone)]
206struct PreparedDualFrequencyEpoch {
207    epoch_index: usize,
208    observations: Vec<DualFrequencyObservation>,
209}
210
211struct DualSlipEvent {
212    epoch_index: usize,
213    reasons: Vec<SlipReason>,
214}
215
216type WideLanePrepPieces = (
217    Vec<PreparedDualFrequencyEpoch>,
218    BTreeMap<String, i64>,
219    Vec<String>,
220    Vec<PppSplitArc>,
221);
222
223type TaggedWideLaneArc = (
224    Vec<(usize, DualFrequencyObservation)>,
225    BTreeMap<String, i64>,
226    Option<PppSplitArc>,
227);
228
229type WideLaneArcPrepared = (
230    Vec<(usize, DualFrequencyObservation)>,
231    BTreeMap<String, i64>,
232    Vec<String>,
233    Vec<PppSplitArc>,
234);
235
236fn wide_lane_ambiguities(
237    epochs: &[DualFrequencyEpoch],
238    wide_lane: WideLanePrepOptions,
239    cycle_slip_policy: CycleSlipPolicy,
240    cycle_slip_options: CycleSlipOptions,
241) -> Result<WideLanePrepPieces, WideLanePrepError> {
242    let mut arcs = BTreeMap::<String, Vec<DualArcSample<'_>>>::new();
243    for (epoch_index, epoch) in epochs.iter().enumerate() {
244        for observation in &epoch.observations {
245            arcs.entry(observation.satellite_id.clone())
246                .or_default()
247                .push(DualArcSample {
248                    epoch_index,
249                    gap_time_s: epoch.gap_time_s,
250                    observation,
251                });
252        }
253    }
254
255    let mut entries = Vec::new();
256    let mut cycles = BTreeMap::new();
257    let mut dropped = Vec::new();
258    let mut split_arcs = Vec::new();
259    for (satellite_id, mut arc) in arcs {
260        arc.sort_by_key(|sample| sample.epoch_index);
261        let (arc_entries, arc_cycles, arc_dropped, arc_splits) = prepare_wide_lane_arc(
262            &satellite_id,
263            &arc,
264            wide_lane,
265            cycle_slip_policy,
266            cycle_slip_options,
267        )?;
268        entries.extend(arc_entries);
269        cycles.extend(arc_cycles);
270        dropped.extend(arc_dropped);
271        split_arcs.extend(arc_splits);
272    }
273
274    dropped.sort();
275    dropped.dedup();
276    split_arcs.sort_by(|a, b| {
277        (a.satellite_id.as_str(), a.ambiguity_id.as_str())
278            .cmp(&(b.satellite_id.as_str(), b.ambiguity_id.as_str()))
279    });
280
281    Ok((
282        dual_epochs_from_entries(entries),
283        cycles,
284        dropped,
285        split_arcs,
286    ))
287}
288
289fn prepare_wide_lane_arc(
290    satellite_id: &str,
291    arc: &[DualArcSample<'_>],
292    wide_lane: WideLanePrepOptions,
293    cycle_slip_policy: CycleSlipPolicy,
294    cycle_slip_options: CycleSlipOptions,
295) -> Result<WideLaneArcPrepared, WideLanePrepError> {
296    let slips = cycle_slips_for_dual_arc(arc, cycle_slip_options);
297    match cycle_slip_policy {
298        CycleSlipPolicy::SplitArc if !slips.is_empty() => {
299            prepare_split_wide_lane_arc(satellite_id, arc, wide_lane, &slips)
300        }
301        _ if slips.is_empty() => {
302            // An unslipped arc's ambiguity id is the bare satellite token.
303            let arc_id = AmbiguityId::new(satellite_id);
304            estimate_tagged_wide_lane_arc(&arc_id, &arc_id, arc, wide_lane, None).map(
305                |(entries, cycles, split_arc)| {
306                    (entries, cycles, Vec::new(), split_arc.into_iter().collect())
307                },
308            )
309        }
310        CycleSlipPolicy::DropSatellite => Ok((
311            Vec::new(),
312            BTreeMap::new(),
313            vec![satellite_id.to_string()],
314            Vec::new(),
315        )),
316        CycleSlipPolicy::Error | CycleSlipPolicy::SplitArc => {
317            let slip = &slips[0];
318            Err(WideLanePrepError::CycleSlipDetected {
319                satellite_id: satellite_id.to_string(),
320                epoch_index: slip.epoch_index,
321                reasons: slip.reasons.clone(),
322            })
323        }
324    }
325}
326
327fn prepare_split_wide_lane_arc(
328    satellite_id: &str,
329    arc: &[DualArcSample<'_>],
330    wide_lane: WideLanePrepOptions,
331    slips: &[DualSlipEvent],
332) -> Result<WideLaneArcPrepared, WideLanePrepError> {
333    let slip_epochs = slips
334        .iter()
335        .map(|slip| slip.epoch_index)
336        .collect::<BTreeSet<_>>();
337    let segments = split_dual_arc(arc, &slip_epochs);
338    let mut entries = Vec::new();
339    let mut cycles = BTreeMap::new();
340    let dropped = Vec::new();
341    let mut split_arcs = Vec::new();
342
343    for (segment_idx, segment) in segments {
344        if segment.len() < wide_lane.min_epochs {
345            continue;
346        }
347        let ambiguity_id = split_ambiguity_id(satellite_id, segment_idx);
348        let split_arc = split_arc_metadata(satellite_id, &ambiguity_id, &segment);
349        let (arc_entries, arc_cycles, arc_split) = estimate_tagged_wide_lane_arc(
350            &ambiguity_id,
351            &ambiguity_id,
352            &segment,
353            wide_lane,
354            Some(split_arc),
355        )?;
356        entries.extend(arc_entries);
357        cycles.extend(arc_cycles);
358        split_arcs.extend(arc_split);
359    }
360
361    if cycles.is_empty() {
362        Ok((
363            Vec::new(),
364            BTreeMap::new(),
365            vec![satellite_id.to_string()],
366            split_arcs,
367        ))
368    } else {
369        Ok((entries, cycles, dropped, split_arcs))
370    }
371}
372
373fn estimate_tagged_wide_lane_arc(
374    error_id: &AmbiguityId,
375    ambiguity_id: &AmbiguityId,
376    arc: &[DualArcSample<'_>],
377    wide_lane: WideLanePrepOptions,
378    split_arc: Option<PppSplitArc>,
379) -> Result<TaggedWideLaneArc, WideLanePrepError> {
380    let fixed = estimate_wide_lane_integer(error_id, arc, wide_lane)?;
381    let entries = arc
382        .iter()
383        .map(|sample| {
384            let mut observation = sample.observation.clone();
385            observation.ambiguity_id = ambiguity_id.to_string();
386            (sample.epoch_index, observation)
387        })
388        .collect();
389    Ok((
390        entries,
391        BTreeMap::from([(ambiguity_id.to_string(), fixed)]),
392        split_arc,
393    ))
394}
395
396fn estimate_wide_lane_integer(
397    ambiguity_id: &AmbiguityId,
398    arc: &[DualArcSample<'_>],
399    wide_lane: WideLanePrepOptions,
400) -> Result<i64, WideLanePrepError> {
401    let mut cycles = Vec::with_capacity(arc.len());
402    for sample in arc {
403        let value = wide_lane_cycles(sample.observation).map_err(|reason| {
404            WideLanePrepError::WideLaneFailed {
405                ambiguity_id: ambiguity_id.to_string(),
406                reason,
407            }
408        })?;
409        cycles.push(value);
410    }
411
412    ambiguity::estimate_wide_lane_integer(&cycles, wide_lane.min_epochs, wide_lane.tolerance_cycles)
413        .map_err(|err| match err {
414            ambiguity::WideLaneEstimateError::TooFewEpochs { count, minimum } => {
415                WideLanePrepError::TooFewWideLaneEpochs {
416                    ambiguity_id: ambiguity_id.to_string(),
417                    count,
418                    minimum,
419                }
420            }
421            ambiguity::WideLaneEstimateError::NotInteger {
422                mean_cycles,
423                fixed_cycles,
424            } => WideLanePrepError::WideLaneNotInteger {
425                ambiguity_id: ambiguity_id.to_string(),
426                mean_cycles,
427                fixed_cycles,
428            },
429        })
430}
431
432fn wide_lane_cycles(observation: &DualFrequencyObservation) -> Result<f64, CarrierPhaseError> {
433    crate::carrier_phase::wide_lane_cycles(
434        observation.phi1_cyc,
435        observation.phi2_cyc,
436        observation.p1_m,
437        observation.p2_m,
438        observation.f1_hz,
439        observation.f2_hz,
440    )
441}
442
443fn cycle_slips_for_dual_arc<'a>(
444    arc: &'a [DualArcSample<'a>],
445    options: CycleSlipOptions,
446) -> Vec<DualSlipEvent> {
447    let arc_epochs = arc
448        .iter()
449        .map(|sample| dual_arc_epoch(sample.observation, sample.gap_time_s))
450        .collect::<Vec<_>>();
451    let results = detect_cycle_slips(&arc_epochs, options).expect("validated cycle-slip arc");
452    arc.iter()
453        .zip(results)
454        .filter_map(|(sample, result)| {
455            if result.slip {
456                Some(DualSlipEvent {
457                    epoch_index: sample.epoch_index,
458                    reasons: result.reasons,
459                })
460            } else {
461                None
462            }
463        })
464        .collect()
465}
466
467fn dual_arc_epoch(observation: &DualFrequencyObservation, gap_time_s: Option<f64>) -> ArcEpoch {
468    ArcEpoch {
469        phi1_cycles: Some(observation.phi1_cyc),
470        phi2_cycles: Some(observation.phi2_cyc),
471        p1_m: Some(observation.p1_m),
472        p2_m: Some(observation.p2_m),
473        lli1: observation.lli1,
474        lli2: observation.lli2,
475        f1_hz: Some(observation.f1_hz),
476        f2_hz: Some(observation.f2_hz),
477        gap_time_s,
478    }
479}
480
481fn split_dual_arc<'a>(
482    arc: &'a [DualArcSample<'a>],
483    slip_epochs: &BTreeSet<usize>,
484) -> Vec<(usize, Vec<DualArcSample<'a>>)> {
485    let mut segments = Vec::new();
486    let mut current = Vec::new();
487    let mut current_idx = 1;
488    for sample in arc {
489        if slip_epochs.contains(&sample.epoch_index) {
490            if !current.is_empty() {
491                segments.push((current_idx, current));
492            }
493            current = vec![*sample];
494            current_idx += 1;
495        } else {
496            current.push(*sample);
497        }
498    }
499    if !current.is_empty() {
500        segments.push((current_idx, current));
501    }
502    segments
503}
504
505fn split_ambiguity_id(satellite_id: &str, segment_idx: usize) -> AmbiguityId {
506    AmbiguityId::new(format!("{satellite_id}#{segment_idx}"))
507}
508
509fn split_arc_metadata(
510    satellite_id: &str,
511    ambiguity_id: &AmbiguityId,
512    segment: &[DualArcSample<'_>],
513) -> PppSplitArc {
514    PppSplitArc {
515        satellite_id: satellite_id.to_string(),
516        ambiguity_id: ambiguity_id.to_string(),
517        start_epoch_index: segment.first().map(|s| s.epoch_index).unwrap_or(0),
518        end_epoch_index: segment.last().map(|s| s.epoch_index).unwrap_or(0),
519        n_epochs: segment.len(),
520    }
521}
522
523fn dual_epochs_from_entries(
524    entries: Vec<(usize, DualFrequencyObservation)>,
525) -> Vec<PreparedDualFrequencyEpoch> {
526    let mut by_epoch = BTreeMap::<usize, Vec<DualFrequencyObservation>>::new();
527    for (epoch_index, observation) in entries {
528        by_epoch.entry(epoch_index).or_default().push(observation);
529    }
530    by_epoch
531        .into_iter()
532        .map(|(epoch_index, mut observations)| {
533            observations.sort_by(|a, b| {
534                (a.satellite_id.as_str(), a.ambiguity_id.as_str())
535                    .cmp(&(b.satellite_id.as_str(), b.ambiguity_id.as_str()))
536            });
537            PreparedDualFrequencyEpoch {
538                epoch_index,
539                observations,
540            }
541        })
542        .collect()
543}
544
545fn filter_dual_epochs_by_wide_lanes(
546    dual_epochs: &[PreparedDualFrequencyEpoch],
547    wide_lane_cycles: &BTreeMap<String, i64>,
548) -> Vec<PreparedDualFrequencyEpoch> {
549    let keep = wide_lane_cycles.keys().cloned().collect::<BTreeSet<_>>();
550    dual_epochs
551        .iter()
552        .filter_map(|epoch| {
553            let observations = epoch
554                .observations
555                .iter()
556                .filter(|observation| keep.contains(&observation.ambiguity_id))
557                .cloned()
558                .collect::<Vec<_>>();
559            if observations.is_empty() {
560                None
561            } else {
562                Some(PreparedDualFrequencyEpoch {
563                    epoch_index: epoch.epoch_index,
564                    observations,
565                })
566            }
567        })
568        .collect()
569}
570
571type IonosphereFreeNarrowLane = (
572    Vec<PreparedFloatEpoch>,
573    BTreeMap<String, f64>,
574    BTreeMap<String, f64>,
575);
576
577fn ionosphere_free_narrow_lane_epochs(
578    dual_epochs: &[PreparedDualFrequencyEpoch],
579    wide_lane_cycles: &BTreeMap<String, i64>,
580) -> Result<IonosphereFreeNarrowLane, WideLanePrepError> {
581    let params = narrow_lane_params(dual_epochs, wide_lane_cycles)?;
582    let if_epochs = ionosphere_free_epochs(dual_epochs)?;
583    let wavelengths_m = params
584        .iter()
585        .map(|(id, params)| (id.as_str().to_string(), params.wavelength_m))
586        .collect();
587    let offsets_m = params
588        .iter()
589        .map(|(id, params)| (id.as_str().to_string(), params.offset_m))
590        .collect();
591    Ok((if_epochs, wavelengths_m, offsets_m))
592}
593
594fn narrow_lane_params(
595    dual_epochs: &[PreparedDualFrequencyEpoch],
596    wide_lane_cycles: &BTreeMap<String, i64>,
597) -> Result<BTreeMap<AmbiguityId, NarrowLaneParams>, WideLanePrepError> {
598    let mut out = BTreeMap::new();
599    for observation in dual_epochs.iter().flat_map(|epoch| &epoch.observations) {
600        let ambiguity_id = AmbiguityId::new(observation.ambiguity_id.as_str());
601        let wide_lane = wide_lane_cycles
602            .get(ambiguity_id.as_str())
603            .copied()
604            .ok_or_else(|| {
605                WideLanePrepError::MissingWideLaneAmbiguity(ambiguity_id.as_str().to_string())
606            })?;
607        let params = narrow_lane_param(observation.f1_hz, observation.f2_hz, wide_lane as f64)?;
608        if let Some(prev) = out.get(&ambiguity_id) {
609            ensure_consistent_narrow_lane_params(&ambiguity_id, params, *prev)?;
610        } else {
611            out.insert(ambiguity_id, params);
612        }
613    }
614    Ok(out)
615}
616
617fn narrow_lane_param(
618    f1_hz: f64,
619    f2_hz: f64,
620    wide_lane_cycles: f64,
621) -> Result<NarrowLaneParams, WideLanePrepError> {
622    ambiguity::narrow_lane_params(f1_hz, f2_hz, wide_lane_cycles).map_err(|reason| {
623        WideLanePrepError::IonosphereFreeFailed {
624            satellite_id: String::new(),
625            reason,
626        }
627    })
628}
629
630fn ensure_consistent_narrow_lane_params(
631    ambiguity_id: &AmbiguityId,
632    params: NarrowLaneParams,
633    prev: NarrowLaneParams,
634) -> Result<(), WideLanePrepError> {
635    if ambiguity::frequencies_match(params.f1_hz, prev.f1_hz)
636        && ambiguity::frequencies_match(params.f2_hz, prev.f2_hz)
637    {
638        Ok(())
639    } else {
640        Err(WideLanePrepError::InconsistentFrequencies(
641            ambiguity_id.to_string(),
642        ))
643    }
644}
645
646fn ionosphere_free_epochs(
647    dual_epochs: &[PreparedDualFrequencyEpoch],
648) -> Result<Vec<PreparedFloatEpoch>, WideLanePrepError> {
649    dual_epochs
650        .iter()
651        .map(|epoch| {
652            Ok(PreparedFloatEpoch {
653                epoch_index: epoch.epoch_index,
654                observations: ionosphere_free_observations(&epoch.observations)?,
655            })
656        })
657        .collect()
658}
659
660fn ionosphere_free_observations(
661    observations: &[DualFrequencyObservation],
662) -> Result<Vec<PreparedFloatObservation>, WideLanePrepError> {
663    observations
664        .iter()
665        .map(|observation| {
666            let code_m = combinations::ionosphere_free(
667                observation.p1_m,
668                observation.p2_m,
669                observation.f1_hz,
670                observation.f2_hz,
671            )
672            .map_err(|reason| WideLanePrepError::IonosphereFreeFailed {
673                satellite_id: observation.satellite_id.clone(),
674                reason,
675            })?;
676            let phase_m = combinations::ionosphere_free_phase_cycles(
677                observation.phi1_cyc,
678                observation.phi2_cyc,
679                observation.f1_hz,
680                observation.f2_hz,
681            )
682            .map_err(|reason| WideLanePrepError::IonosphereFreeFailed {
683                satellite_id: observation.satellite_id.clone(),
684                reason,
685            })?;
686            Ok(PreparedFloatObservation {
687                satellite_id: observation.satellite_id.clone(),
688                ambiguity_id: observation.ambiguity_id.clone(),
689                code_m,
690                phase_m,
691            })
692        })
693        .collect()
694}
695
696#[derive(Clone, Copy)]
697struct FloatSlipSample<'a> {
698    epoch_index: usize,
699    gap_time_s: Option<f64>,
700    observation: &'a FloatCycleSlipObservation,
701}
702
703fn float_cycle_slip_tags(
704    epochs: &[FloatCycleSlipEpoch],
705    options: CycleSlipOptions,
706) -> BTreeMap<(usize, String), AmbiguityId> {
707    let mut arcs = BTreeMap::<String, Vec<FloatSlipSample<'_>>>::new();
708    for (epoch_index, epoch) in epochs.iter().enumerate() {
709        for observation in &epoch.observations {
710            arcs.entry(observation.satellite_id.clone())
711                .or_default()
712                .push(FloatSlipSample {
713                    epoch_index,
714                    gap_time_s: epoch.gap_time_s,
715                    observation,
716                });
717        }
718    }
719
720    let mut tags = BTreeMap::new();
721    for (satellite_id, mut arc) in arcs {
722        arc.sort_by_key(|sample| sample.epoch_index);
723        tags.extend(float_arc_tags(&satellite_id, &arc, options));
724    }
725    tags
726}
727
728fn float_arc_tags(
729    satellite_id: &str,
730    arc: &[FloatSlipSample<'_>],
731    options: CycleSlipOptions,
732) -> BTreeMap<(usize, String), AmbiguityId> {
733    let carrier_phase_samples = float_carrier_phase_arc(arc);
734    if carrier_phase_samples.is_empty() {
735        return BTreeMap::new();
736    }
737    let arc_epochs = carrier_phase_samples
738        .iter()
739        .map(|(_, epoch)| *epoch)
740        .collect::<Vec<_>>();
741    let slip_epochs = detect_cycle_slips(&arc_epochs, options)
742        .expect("validated cycle-slip arc")
743        .into_iter()
744        .zip(carrier_phase_samples.iter())
745        .filter_map(|(result, (epoch_index, _))| result.slip.then_some(*epoch_index))
746        .collect::<BTreeSet<_>>();
747    if slip_epochs.is_empty() {
748        return BTreeMap::new();
749    }
750
751    let mut out = BTreeMap::new();
752    for (segment_idx, segment) in split_float_arc(arc, &slip_epochs) {
753        let ambiguity_id = split_ambiguity_id(satellite_id, segment_idx);
754        for sample in segment {
755            out.insert(
756                (sample.epoch_index, satellite_id.to_string()),
757                ambiguity_id.clone(),
758            );
759        }
760    }
761    out
762}
763
764fn float_carrier_phase_arc(arc: &[FloatSlipSample<'_>]) -> Vec<(usize, ArcEpoch)> {
765    arc.iter()
766        .filter_map(|sample| {
767            let raw = sample.observation.raw.as_ref()?;
768            Some((sample.epoch_index, dual_arc_epoch(raw, sample.gap_time_s)))
769        })
770        .collect()
771}
772
773fn split_float_arc<'a>(
774    arc: &'a [FloatSlipSample<'a>],
775    slip_epochs: &BTreeSet<usize>,
776) -> Vec<(usize, Vec<FloatSlipSample<'a>>)> {
777    let mut segments = Vec::new();
778    let mut current = Vec::new();
779    let mut current_idx = 1;
780    for sample in arc {
781        if slip_epochs.contains(&sample.epoch_index) {
782            if !current.is_empty() {
783                segments.push((current_idx, current));
784            }
785            current = vec![*sample];
786            current_idx += 1;
787        } else {
788            current.push(*sample);
789        }
790    }
791    if !current.is_empty() {
792        segments.push((current_idx, current));
793    }
794    segments
795}