Skip to main content

sidereon_core/rtcm/
lli.rs

1//! RINEX loss-of-lock indicator derivation from decoded RTCM MSM fields.
2//!
3//! RTCM MSM messages carry raw phase lock-time indicators (DF402 for MSM4,
4//! DF407 for MSM7) plus the half-cycle ambiguity flag (DF420). RINEX stores
5//! that state as an LLI digit attached to phase observations: bit 0 means loss
6//! of lock is possible, and bit 1 means half-cycle ambiguity is possible. This
7//! module keeps that mapping as a small sans-I/O layer over decoded MSM IR.
8
9use std::collections::BTreeMap;
10
11use crate::id::GnssSystem;
12
13use super::{MsmKind, MsmMessage};
14
15const WEEK_MS: u64 = 604_800_000;
16const DAY_MS: u64 = 86_400_000;
17const GLONASS_DAY_UNKNOWN: u32 = 7;
18const GLONASS_DAY_SHIFT: u32 = 27;
19const GLONASS_MS_MASK: u32 = (1 << GLONASS_DAY_SHIFT) - 1;
20
21/// RINEX LLI bit 0: loss of lock, so a cycle slip is possible.
22pub const LLI_LOSS_OF_LOCK: u8 = 0b001;
23
24/// RINEX LLI bit 1: half-cycle ambiguity or half-cycle slip is possible.
25pub const LLI_HALF_CYCLE: u8 = 0b010;
26
27/// The tracked previous observation of one RTCM MSM signal cell.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub struct PreviousLock {
30    /// Previous minimum continuous-lock time in milliseconds.
31    ///
32    /// `None` means the previous indicator was reserved or otherwise did not
33    /// have a defined minimum lock time.
34    pub min_lock_time_ms: Option<u32>,
35    /// Elapsed milliseconds between the previous and current observation of the
36    /// same `(system, satellite, signal)` cell.
37    pub elapsed_ms: u64,
38}
39
40/// Derived RINEX LLI for one MSM signal cell.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub struct CellLli {
43    /// Satellite id, as carried by [`super::MsmSignal::satellite_id`].
44    pub satellite_id: u8,
45    /// Signal id, as carried by [`super::MsmSignal::signal_id`].
46    pub signal_id: u8,
47    /// Derived RINEX LLI value. Only bits 0 and 1 are set by this module.
48    pub lli: u8,
49    /// Current normalized minimum lock time in milliseconds.
50    ///
51    /// `None` means the current indicator is reserved or outside the defined
52    /// range.
53    pub min_lock_time_ms: Option<u32>,
54}
55
56#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
57struct CellKey {
58    system: GnssSystem,
59    satellite_id: u8,
60    signal_id: u8,
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64struct CellState {
65    raw_epoch_time: u32,
66    min_lock_time_ms: Option<u32>,
67}
68
69/// Tracks per-signal MSM lock history and derives RINEX LLI values.
70///
71/// State is keyed by `(constellation, satellite id, signal id)`, so MSM4 and
72/// MSM7 observations of the same signal share continuity while different
73/// constellations never collide. Call [`Self::reset`] when a stream reconnects
74/// or the caller intentionally starts a new continuity arc.
75#[derive(Clone, Debug, Default)]
76pub struct LockTimeTracker {
77    cells: BTreeMap<CellKey, CellState>,
78}
79
80impl LockTimeTracker {
81    /// Build an empty tracker.
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    /// Derive LLI for every signal cell in `message` and advance tracker state.
87    ///
88    /// Output order matches `message.signals`. The tracker stores raw MSM epoch
89    /// fields and computes elapsed time pairwise, including GPS-week rollover
90    /// and GLONASS day-of-week handling. If the same cell repeats at the same
91    /// epoch, LLI is derived but the stored state is not advanced, so a
92    /// duplicate message cannot hide a later decrease.
93    pub fn observe(&mut self, message: &MsmMessage) -> Vec<CellLli> {
94        let mut out = Vec::with_capacity(message.signals.len());
95        for signal in &message.signals {
96            let key = CellKey {
97                system: message.system,
98                satellite_id: signal.satellite_id,
99                signal_id: signal.signal_id,
100            };
101            let min_lock_time_ms = signal.minimum_lock_time_ms(message.kind);
102            let previous = self.cells.get(&key).map(|state| PreviousLock {
103                min_lock_time_ms: state.min_lock_time_ms,
104                elapsed_ms: msm_epoch_dt_ms(
105                    message.system,
106                    state.raw_epoch_time,
107                    message.header.epoch_time,
108                ),
109            });
110            let lli = derive_lli(previous, min_lock_time_ms, signal.half_cycle_ambiguity);
111            out.push(CellLli {
112                satellite_id: signal.satellite_id,
113                signal_id: signal.signal_id,
114                lli,
115                min_lock_time_ms,
116            });
117            if previous.is_none_or(|prev| prev.elapsed_ms != 0) {
118                self.cells.insert(
119                    key,
120                    CellState {
121                        raw_epoch_time: message.header.epoch_time,
122                        min_lock_time_ms,
123                    },
124                );
125            }
126        }
127        out
128    }
129
130    /// Drop all per-cell lock history.
131    pub fn reset(&mut self) {
132        self.cells.clear();
133    }
134}
135
136/// Minimum continuous-lock time encoded by an MSM lock-time indicator.
137///
138/// MSM4 uses DF402, a 4-bit coarse bucket. MSM7 uses DF407, a 10-bit extended
139/// bucket. Returns `None` for indicators outside the field's bit range and for
140/// DF407 reserved values 705 through 1023.
141pub fn minimum_lock_time_ms(kind: MsmKind, indicator: u16) -> Option<u32> {
142    match kind {
143        MsmKind::Msm4 => df402_minimum_lock_time_ms(indicator),
144        MsmKind::Msm7 => df407_minimum_lock_time_ms(indicator),
145    }
146}
147
148/// Derive the RINEX LLI digit for one signal cell.
149///
150/// Bit 0 is set when loss of lock is possible under the lock-time continuity
151/// rules. Bit 1 is the current half-cycle ambiguity flag, copied verbatim from
152/// DF420. Bit 2 is never set here because MSM does not carry the RINEX tracking
153/// mode represented by that bit.
154pub fn derive_lli(
155    previous: Option<PreviousLock>,
156    current_min_lock_ms: Option<u32>,
157    half_cycle_ambiguity: bool,
158) -> u8 {
159    let mut lli = if half_cycle_ambiguity {
160        LLI_HALF_CYCLE
161    } else {
162        0
163    };
164
165    let Some(previous) = previous else {
166        return lli;
167    };
168    let Some(current) = current_min_lock_ms else {
169        return lli | LLI_LOSS_OF_LOCK;
170    };
171
172    let decreased = previous
173        .min_lock_time_ms
174        .is_some_and(|previous_min| current < previous_min);
175    let uncovered_gap = (current as u64) < previous.elapsed_ms;
176    if decreased || uncovered_gap {
177        lli |= LLI_LOSS_OF_LOCK;
178    }
179    lli
180}
181
182/// Elapsed milliseconds between two raw MSM epoch-time fields of one system.
183///
184/// Non-GLONASS MSM epoch fields are milliseconds of week. GLONASS carries a
185/// 3-bit day-of-week plus millisecond-of-day field; when either day is the
186/// unknown value `7`, the subtraction falls back to day modulo arithmetic.
187pub fn msm_epoch_dt_ms(system: GnssSystem, previous: u32, current: u32) -> u64 {
188    if system == GnssSystem::Glonass {
189        let prev_day = previous >> GLONASS_DAY_SHIFT;
190        let now_day = current >> GLONASS_DAY_SHIFT;
191        let prev_ms = u64::from(previous & GLONASS_MS_MASK);
192        let now_ms = u64::from(current & GLONASS_MS_MASK);
193        if prev_day == GLONASS_DAY_UNKNOWN || now_day == GLONASS_DAY_UNKNOWN {
194            modulo_elapsed_ms(prev_ms, now_ms, DAY_MS)
195        } else {
196            let prev = u64::from(prev_day) * DAY_MS + prev_ms;
197            let now = u64::from(now_day) * DAY_MS + now_ms;
198            modulo_elapsed_ms(prev, now, WEEK_MS)
199        }
200    } else {
201        modulo_elapsed_ms(u64::from(previous), u64::from(current), WEEK_MS)
202    }
203}
204
205/// RINEX 3 observation-code suffix for an MSM signal-mask id.
206///
207/// The phase observation code is `"L" <> suffix`; for example GPS signal id 2
208/// maps to suffix `"1C"` and phase observable `L1C`. Returns `None` for
209/// reserved signal ids.
210pub fn msm_signal_rinex_code(system: GnssSystem, signal_id: u8) -> Option<&'static str> {
211    signal_table(system)
212        .get(usize::from(signal_id))
213        .copied()
214        .flatten()
215}
216
217fn df402_minimum_lock_time_ms(indicator: u16) -> Option<u32> {
218    match indicator {
219        0 => Some(0),
220        1..=15 => Some(1u32 << (u32::from(indicator) + 4)),
221        _ => None,
222    }
223}
224
225fn df407_minimum_lock_time_ms(indicator: u16) -> Option<u32> {
226    let n = u32::from(indicator);
227    match n {
228        0..=63 => Some(n),
229        64..=703 => {
230            let segment = (n - 64) / 32;
231            let start = 64 + segment * 32;
232            let scale = 1u32 << (segment + 1);
233            let start_value = scale * start - scale * 32 * (segment + 1);
234            Some(start_value + scale * (n - start))
235        }
236        704 => Some(67_108_864),
237        _ => None,
238    }
239}
240
241fn modulo_elapsed_ms(previous: u64, current: u64, modulus: u64) -> u64 {
242    (current + modulus - (previous % modulus)) % modulus
243}
244
245fn signal_table(system: GnssSystem) -> &'static [Option<&'static str>; 33] {
246    match system {
247        GnssSystem::Gps => &GPS_SIGNALS,
248        GnssSystem::Glonass => &GLONASS_SIGNALS,
249        GnssSystem::Galileo => &GALILEO_SIGNALS,
250        GnssSystem::Sbas => &SBAS_SIGNALS,
251        GnssSystem::Qzss => &QZSS_SIGNALS,
252        GnssSystem::BeiDou => &BEIDOU_SIGNALS,
253        GnssSystem::Navic => &NAVIC_SIGNALS,
254    }
255}
256
257const N: Option<&str> = None;
258
259#[rustfmt::skip]
260const GPS_SIGNALS: [Option<&str>; 33] = [
261    N,
262    N, Some("1C"), Some("1P"), Some("1W"), N, N, N,
263    Some("2C"), Some("2P"), Some("2W"), N, N, N, N,
264    Some("2S"), Some("2L"), Some("2X"), N, N, N, N,
265    Some("5I"), Some("5Q"), Some("5X"), N, N, N, N, N,
266    Some("1S"), Some("1L"), Some("1X"),
267];
268
269#[rustfmt::skip]
270const GLONASS_SIGNALS: [Option<&str>; 33] = [
271    N,
272    N, Some("1C"), Some("1P"), N, N, N, N,
273    Some("2C"), Some("2P"), N, N, N, N, Some("3I"),
274    Some("3Q"), Some("3X"), N, Some("4A"), Some("4B"), Some("4X"), N,
275    Some("6A"), Some("6B"), Some("6X"), N, N, N, N, N,
276    N, N, N,
277];
278
279#[rustfmt::skip]
280const GALILEO_SIGNALS: [Option<&str>; 33] = [
281    N,
282    N, Some("1C"), Some("1A"), Some("1B"), Some("1X"), Some("1Z"), N,
283    Some("6C"), Some("6A"), Some("6B"), Some("6X"), Some("6Z"), N,
284    Some("7I"), Some("7Q"), Some("7X"), N, Some("8I"), Some("8Q"), Some("8X"), N,
285    Some("5I"), Some("5Q"), Some("5X"), N, N, N, N, N,
286    N, N, N,
287];
288
289#[rustfmt::skip]
290const SBAS_SIGNALS: [Option<&str>; 33] = [
291    N,
292    N, Some("1C"), N, N, N, N, N,
293    N, N, N, N, N, N, N,
294    N, N, N, N, N, N, N,
295    Some("5I"), Some("5Q"), Some("5X"), N, N, N, N, N,
296    N, N, N,
297];
298
299#[rustfmt::skip]
300const QZSS_SIGNALS: [Option<&str>; 33] = [
301    N,
302    N, Some("1C"), N, N, Some("1E"), Some("1Z"), Some("1B"),
303    N, Some("6S"), Some("6L"), Some("6X"), Some("6E"), Some("6Z"), N,
304    Some("2S"), Some("2L"), Some("2X"), N, N, N, N,
305    Some("5I"), Some("5Q"), Some("5X"), Some("5D"), Some("5P"), Some("5Z"), N, N,
306    Some("1S"), Some("1L"), Some("1X"),
307];
308
309#[rustfmt::skip]
310const BEIDOU_SIGNALS: [Option<&str>; 33] = [
311    N,
312    N, Some("2I"), Some("2Q"), Some("2X"), Some("1S"), Some("1L"), Some("1Z"),
313    Some("6I"), Some("6Q"), Some("6X"), Some("6D"), Some("6P"), Some("6Z"), Some("7I"),
314    Some("7Q"), Some("7X"), N, Some("8D"), Some("8P"), Some("8X"), N,
315    Some("5D"), Some("5P"), Some("5X"), Some("7D"), Some("7P"), Some("7Z"), N, N,
316    Some("1D"), Some("1P"), Some("1X"),
317];
318
319#[rustfmt::skip]
320const NAVIC_SIGNALS: [Option<&str>; 33] = [
321    N,
322    N, Some("1D"), Some("1P"), Some("1X"), N, N, N,
323    Some("9A"), Some("9B"), Some("9C"), Some("9X"), N, N, N,
324    N, N, N, N, N, N, N,
325    Some("5A"), Some("5B"), Some("5C"), Some("5X"), N, N, N, N,
326    N, N, N,
327];
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn df407_boundaries_are_monotone_and_pinned() {
335        let pinned = [
336            (0, Some(0)),
337            (1, Some(1)),
338            (63, Some(63)),
339            (64, Some(64)),
340            (95, Some(126)),
341            (96, Some(128)),
342            (127, Some(252)),
343            (128, Some(256)),
344            (159, Some(504)),
345            (160, Some(512)),
346            (191, Some(1008)),
347            (192, Some(1024)),
348            (223, Some(2016)),
349            (224, Some(2048)),
350            (255, Some(4032)),
351            (256, Some(4096)),
352            (287, Some(8064)),
353            (288, Some(8192)),
354            (319, Some(16128)),
355            (320, Some(16384)),
356            (351, Some(32256)),
357            (352, Some(32768)),
358            (383, Some(64512)),
359            (384, Some(65536)),
360            (415, Some(129024)),
361            (416, Some(131072)),
362            (447, Some(258048)),
363            (448, Some(262144)),
364            (479, Some(516096)),
365            (480, Some(524288)),
366            (511, Some(1032192)),
367            (512, Some(1048576)),
368            (543, Some(2064384)),
369            (544, Some(2097152)),
370            (575, Some(4128768)),
371            (576, Some(4194304)),
372            (607, Some(8257536)),
373            (608, Some(8388608)),
374            (639, Some(16515072)),
375            (640, Some(16777216)),
376            (671, Some(33030144)),
377            (672, Some(33554432)),
378            (703, Some(66060288)),
379            (704, Some(67108864)),
380            (705, None),
381            (800, None),
382            (1023, None),
383        ];
384        for (indicator, expected) in pinned {
385            assert_eq!(
386                minimum_lock_time_ms(MsmKind::Msm7, indicator),
387                expected,
388                "DF407 {indicator}"
389            );
390        }
391        for indicator in 1..=704 {
392            let prev = minimum_lock_time_ms(MsmKind::Msm7, indicator - 1).unwrap();
393            let now = minimum_lock_time_ms(MsmKind::Msm7, indicator).unwrap();
394            assert!(now > prev, "DF407 must increase at {indicator}");
395        }
396
397        let segment_starts = [
398            64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 480, 512, 544, 576, 608,
399            640, 672, 704,
400        ];
401        for start in segment_starts {
402            let previous_step = if start == 64 {
403                1
404            } else {
405                1u32 << ((start - 64) / 32)
406            };
407            let joined = minimum_lock_time_ms(MsmKind::Msm7, start - 1).unwrap() + previous_step;
408            assert_eq!(
409                minimum_lock_time_ms(MsmKind::Msm7, start).unwrap(),
410                joined,
411                "DF407 segment join at {start}"
412            );
413        }
414    }
415}