Skip to main content

sidereon_core/
ambiguity.rs

1//! Shared ambiguity-resolution preparation primitives.
2//!
3//! RTK (`crate::rtk`) and PPP (`crate::precise_positioning`) both prepare
4//! dual-frequency carrier-phase arcs for integer ambiguity resolution: they
5//! apply the same cycle-slip policy, round the same Melbourne-Wubbena wide-lane
6//! sample mean to an integer, derive the same narrow-lane wavelength/offset
7//! algebra, and compare carrier frequencies with the same tolerance. Those
8//! shared pieces live here so the two solvers cannot drift apart.
9//!
10//! The pieces that legitimately differ between the two stay in their own
11//! modules: RTK is receiver-aware (base/rover), tags reacquired arcs with a
12//! `~raN` suffix, and builds single-/double-difference ambiguity ids, whereas
13//! PPP is satellite-only with `sat#N` segment ids. Those id schemes encode
14//! different semantics and are not unified here.
15
16use core::fmt;
17
18use crate::combinations::{self, IonosphereFreeError};
19use crate::constants::C_M_S;
20use crate::tolerances::FREQUENCY_MATCH_EPS_HZ;
21
22/// A carrier-phase ambiguity identifier.
23///
24/// Ambiguity ids label a continuous integer-ambiguity arc. Unlike a
25/// [`GnssSatelliteId`](crate::id::GnssSatelliteId) they are not a fixed grammar:
26/// RTK composes single-/double-difference ids that fold the base/rover arc ids
27/// and reference satellite (`G01:base=...,rover=...`, `...|ref=...`), while PPP
28/// uses `sat#N` segment ids. The type is therefore an opaque ordered string
29/// wrapper; it exists so the solvers cannot confuse an ambiguity id with a raw
30/// satellite token, and so the per-arc maps are keyed by an intent-revealing
31/// type. The wrapped string is preserved byte-for-byte, so ordering, equality,
32/// and the [`Display`](fmt::Display) rendering match the underlying token
33/// exactly (the value the NIF boundary marshals).
34#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
35pub(crate) struct AmbiguityId(String);
36
37impl AmbiguityId {
38    /// Wrap an already-formatted ambiguity-id token.
39    pub(crate) fn new(token: impl Into<String>) -> Self {
40        Self(token.into())
41    }
42
43    /// Borrow the underlying token, e.g. for comparison against a satellite id.
44    pub(crate) fn as_str(&self) -> &str {
45        &self.0
46    }
47
48    /// Consume the wrapper, yielding the token for the NIF I/O boundary.
49    pub(crate) fn into_string(self) -> String {
50        self.0
51    }
52
53    /// Replace the token in place, reusing the existing heap buffer. The hot
54    /// double-difference row builders reuse a pooled [`AmbiguityId`] per scratch
55    /// row, so the per-solve allocation budget depends on this not reallocating.
56    pub(crate) fn assign(&mut self, token: &str) {
57        self.0.clear();
58        self.0.push_str(token);
59    }
60
61    /// Empty the token in place, reusing the heap buffer, before composing a
62    /// multi-part id with [`push_str`](Self::push_str).
63    pub(crate) fn clear(&mut self) {
64        self.0.clear();
65    }
66
67    /// Append a fragment to the token in place (after [`clear`](Self::clear)),
68    /// so a composed double-difference id is built without a fresh allocation.
69    pub(crate) fn push_str(&mut self, fragment: &str) {
70        self.0.push_str(fragment);
71    }
72}
73
74impl fmt::Display for AmbiguityId {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.write_str(&self.0)
77    }
78}
79
80/// Policy applied when a cycle slip is detected while preparing an ambiguity arc.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum CycleSlipPolicy {
83    /// Fail the whole solve when any slip is detected.
84    Error,
85    /// Drop the affected satellite from the solve.
86    DropSatellite,
87    /// Split the arc into a fresh ambiguity at the slip.
88    SplitArc,
89}
90
91/// Narrow-lane wavelength/offset parameters for a fixed wide-lane integer.
92#[derive(Debug, Clone, Copy, PartialEq)]
93pub(crate) struct NarrowLaneParams {
94    pub(crate) wavelength_m: f64,
95    pub(crate) offset_m: f64,
96    pub(crate) f1_hz: f64,
97    pub(crate) f2_hz: f64,
98}
99
100/// Narrow-lane wavelength/offset for a fixed wide-lane integer, in the exact
101/// operation order both solvers' frozen-bits goldens were captured with.
102pub(crate) fn narrow_lane_params(
103    f1_hz: f64,
104    f2_hz: f64,
105    wide_lane_cycles: f64,
106) -> Result<NarrowLaneParams, IonosphereFreeError> {
107    let gamma = combinations::gamma(f1_hz, f2_hz)?;
108    let beta = gamma - 1.0;
109    let lambda2 = C_M_S / f2_hz;
110    Ok(NarrowLaneParams {
111        wavelength_m: C_M_S / (f1_hz + f2_hz),
112        offset_m: beta * lambda2 * wide_lane_cycles,
113        f1_hz,
114        f2_hz,
115    })
116}
117
118/// Why a wide-lane cycle-sample mean could not be fixed to an integer.
119#[derive(Debug, Clone, Copy, PartialEq)]
120pub(crate) enum WideLaneEstimateError {
121    /// Fewer usable cycle samples than the configured minimum.
122    TooFewEpochs { count: usize, minimum: usize },
123    /// The sample mean did not round within tolerance of an integer.
124    NotInteger { mean_cycles: f64, fixed_cycles: i64 },
125}
126
127/// Mean-to-integer wide-lane ambiguity estimation shared by RTK and PPP. The
128/// running sum is a left fold from `0.0` to match both callers' captured bits.
129pub(crate) fn estimate_wide_lane_integer(
130    cycles: &[f64],
131    min_epochs: usize,
132    tolerance_cycles: f64,
133) -> Result<i64, WideLaneEstimateError> {
134    if cycles.len() < min_epochs {
135        return Err(WideLaneEstimateError::TooFewEpochs {
136            count: cycles.len(),
137            minimum: min_epochs,
138        });
139    }
140
141    let mut sum = 0.0;
142    for &cycle in cycles {
143        sum += cycle;
144    }
145    let mean = sum / cycles.len() as f64;
146    let fixed = mean.round() as i64;
147
148    if (mean - fixed as f64).abs() <= tolerance_cycles {
149        Ok(fixed)
150    } else {
151        Err(WideLaneEstimateError::NotInteger {
152            mean_cycles: mean,
153            fixed_cycles: fixed,
154        })
155    }
156}
157
158/// Whether two carrier frequencies agree within the GNSS frequency-match tolerance.
159pub(crate) fn frequencies_match(a: f64, b: f64) -> bool {
160    (a - b).abs() <= FREQUENCY_MATCH_EPS_HZ
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn narrow_lane_params_match_reference_bits() {
169        let f1 = f64::from_bits(0x41d779c018000000);
170        let f2 = f64::from_bits(0x41d24aec20000000);
171        let params = narrow_lane_params(f1, f2, 3.0).unwrap();
172        assert_eq!(params.wavelength_m.to_bits(), 0x3fbb614bed5136b9);
173        assert_eq!(params.offset_m.to_bits(), 0x3ff21e814dfd4618);
174    }
175
176    #[test]
177    fn wide_lane_estimate_rounds_and_gates() {
178        assert_eq!(
179            estimate_wide_lane_integer(&[2.95, 3.02, 3.0], 2, 0.25),
180            Ok(3)
181        );
182        assert_eq!(
183            estimate_wide_lane_integer(&[3.0], 2, 0.25),
184            Err(WideLaneEstimateError::TooFewEpochs {
185                count: 1,
186                minimum: 2,
187            })
188        );
189        assert!(matches!(
190            estimate_wide_lane_integer(&[3.4, 3.5, 3.6], 2, 0.05),
191            Err(WideLaneEstimateError::NotInteger { .. })
192        ));
193    }
194
195    #[test]
196    fn ambiguity_id_preserves_token_bytes() {
197        let id = AmbiguityId::new("G01:base=G01~ra1,rover=G01");
198        // Display and as_str render the wrapped token verbatim.
199        assert_eq!(id.as_str(), "G01:base=G01~ra1,rover=G01");
200        assert_eq!(id.to_string(), "G01:base=G01~ra1,rover=G01");
201        // into_string yields the exact bytes the NIF boundary marshals.
202        assert_eq!(id.clone().into_string(), "G01:base=G01~ra1,rover=G01");
203        // Ordering and equality follow the underlying string byte order.
204        assert!(AmbiguityId::new("G01") < AmbiguityId::new("G02"));
205        assert_eq!(AmbiguityId::new("E12"), AmbiguityId::new("E12"));
206    }
207
208    #[test]
209    fn ambiguity_id_in_place_assign_matches_owned_construction() {
210        // The hot row builders reuse one id per scratch row: assign and the
211        // clear/push_str compose must produce the exact same token bytes as a
212        // freshly-allocated id.
213        let mut reused = AmbiguityId::default();
214        reused.assign("G07");
215        assert_eq!(reused, AmbiguityId::new("G07"));
216
217        // Reassigning a longer token then a shorter one tracks the content
218        // exactly (the retained buffer capacity does not leak into the value).
219        reused.assign("G07:base=G07~ra1,rover=G07");
220        assert_eq!(reused.as_str(), "G07:base=G07~ra1,rover=G07");
221        reused.assign("E05");
222        assert_eq!(reused.as_str(), "E05");
223
224        let mut composed = AmbiguityId::default();
225        composed.clear();
226        composed.push_str("G07");
227        composed.push_str("|ref=");
228        composed.push_str("G04");
229        assert_eq!(composed, AmbiguityId::new("G07|ref=G04"));
230    }
231
232    #[test]
233    fn frequency_match_uses_named_tolerance() {
234        assert!(frequencies_match(1.0e9, 1.0e9 + 5.0e-7));
235        assert!(!frequencies_match(1.0e9, 1.0e9 + 1.0e-3));
236    }
237}