Skip to main content

sidereon_core/rtcm/
ssr.rs

1//! RTCM SSR orbit, clock, URA, and high-rate clock messages.
2//!
3//! This module is the wire-level IR for the RTCM SSR Phase A messages. Values
4//! are stored as the raw transmitted integers. Scaling to meters and seconds is
5//! handled by the crate-level `ssr` correction store.
6
7use crate::error::{Error, Result};
8use crate::id::GnssSystem;
9
10use super::bits::{BitReader, BitWriter};
11
12/// The SSR message group derived from the message number.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
14pub enum SsrKind {
15    /// Orbit corrections.
16    Orbit,
17    /// Clock corrections.
18    Clock,
19    /// Combined orbit and clock corrections.
20    CombinedOrbitClock,
21    /// Code-bias corrections.
22    CodeBias,
23    /// Phase-bias corrections.
24    PhaseBias,
25    /// User range accuracy.
26    Ura,
27    /// High-rate clock correction.
28    HighRateClock,
29    /// VTEC ionosphere correction.
30    Vtec,
31}
32
33/// Common header for RTCM SSR messages.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct SsrHeader {
36    /// SSR epoch time, seconds of week for GPS and Galileo.
37    pub epoch_time_s: u32,
38    /// SSR update interval index.
39    pub update_interval: u8,
40    /// Multiple-message indicator.
41    pub multiple_message: bool,
42    /// IOD SSR.
43    pub iod_ssr: u8,
44    /// SSR provider identifier.
45    pub provider_id: u16,
46    /// SSR solution identifier.
47    pub solution_id: u8,
48    /// Satellite reference datum bit for orbit and combined messages.
49    pub satellite_reference_datum: Option<bool>,
50    /// Phase-bias dispersive-bias consistency flag.
51    pub dispersive_bias_consistency: Option<bool>,
52    /// Phase-bias Melbourne-Wubbena consistency flag.
53    pub mw_consistency: Option<bool>,
54    /// Number of satellite records.
55    pub satellite_count: u8,
56}
57
58/// One satellite orbit-correction record.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct SsrOrbitRecord {
61    /// Constellation-native satellite id.
62    pub satellite_id: u8,
63    /// Referenced broadcast issue, IODE for GPS and IODnav for Galileo.
64    pub iode: u32,
65    /// Radial delta, int22, scale 0.1 mm.
66    pub delta_radial: i32,
67    /// Along-track delta, int20, scale 0.4 mm.
68    pub delta_along: i32,
69    /// Cross-track delta, int20, scale 0.4 mm.
70    pub delta_cross: i32,
71    /// Radial delta rate, int21, scale 0.001 mm/s.
72    pub dot_delta_radial: i32,
73    /// Along-track delta rate, int19, scale 0.004 mm/s.
74    pub dot_delta_along: i32,
75    /// Cross-track delta rate, int19, scale 0.004 mm/s.
76    pub dot_delta_cross: i32,
77}
78
79/// One satellite clock-correction record.
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct SsrClockRecord {
82    /// Constellation-native satellite id.
83    pub satellite_id: u8,
84    /// C0 clock term, int22, scale 0.1 mm.
85    pub c0: i32,
86    /// C1 clock term, int21, scale 0.001 mm/s.
87    pub c1: i32,
88    /// C2 clock term, int27, scale 0.02 micrometer/s^2.
89    pub c2: i32,
90}
91
92/// One satellite code-bias record.
93#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct SsrCodeBiasRecord {
95    /// Constellation-native satellite id.
96    pub satellite_id: u8,
97    /// Raw signal and tracking-mode id plus raw bias.
98    pub biases: Vec<(u8, i16)>,
99}
100
101/// One signal in a phase-bias record.
102#[derive(Clone, Debug, PartialEq, Eq)]
103pub struct SsrPhaseBiasSignal {
104    /// Raw signal and tracking-mode id.
105    pub signal_id: u8,
106    /// Signal integer indicator.
107    pub integer_indicator: u8,
108    /// Wide-lane integer indicator.
109    pub wide_lane_integer_indicator: u8,
110    /// Discontinuity counter.
111    pub discontinuity_counter: u8,
112    /// Raw phase bias.
113    pub bias: i32,
114}
115
116/// One satellite phase-bias record.
117#[derive(Clone, Debug, PartialEq, Eq)]
118pub struct SsrPhaseBiasRecord {
119    /// Constellation-native satellite id.
120    pub satellite_id: u8,
121    /// Raw yaw angle.
122    pub yaw_angle: u16,
123    /// Raw yaw rate.
124    pub yaw_rate: i8,
125    /// Per-signal phase biases.
126    pub biases: Vec<SsrPhaseBiasSignal>,
127}
128
129/// A decoded RTCM SSR message body.
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct SsrMessage {
132    /// RTCM message number.
133    pub message_number: u16,
134    /// Constellation derived from the message number.
135    pub system: GnssSystem,
136    /// SSR message group.
137    pub kind: SsrKind,
138    /// Common SSR header.
139    pub header: SsrHeader,
140    /// Orbit records, present for orbit and combined messages.
141    pub orbit: Vec<SsrOrbitRecord>,
142    /// Clock records, present for clock, combined, and high-rate messages.
143    pub clock: Vec<SsrClockRecord>,
144    /// Code-bias records.
145    pub code_bias: Vec<SsrCodeBiasRecord>,
146    /// Phase-bias records.
147    pub phase_bias: Vec<SsrPhaseBiasRecord>,
148    /// URA records as `(satellite_id, ura_index)`.
149    pub ura: Vec<(u8, u8)>,
150    /// Body bits after the parsed message fields.
151    pub padding_bits: Vec<bool>,
152}
153
154/// Map an RTCM message number to a supported SSR Phase A type.
155pub(crate) fn ssr_kind(message_number: u16) -> Option<(GnssSystem, SsrKind)> {
156    match message_number {
157        1057 => Some((GnssSystem::Gps, SsrKind::Orbit)),
158        1058 => Some((GnssSystem::Gps, SsrKind::Clock)),
159        1060 => Some((GnssSystem::Gps, SsrKind::CombinedOrbitClock)),
160        1061 => Some((GnssSystem::Gps, SsrKind::Ura)),
161        1062 => Some((GnssSystem::Gps, SsrKind::HighRateClock)),
162        1240 => Some((GnssSystem::Galileo, SsrKind::Orbit)),
163        1241 => Some((GnssSystem::Galileo, SsrKind::Clock)),
164        1243 => Some((GnssSystem::Galileo, SsrKind::CombinedOrbitClock)),
165        1244 => Some((GnssSystem::Galileo, SsrKind::Ura)),
166        1245 => Some((GnssSystem::Galileo, SsrKind::HighRateClock)),
167        _ => None,
168    }
169}
170
171/// True when this module decodes `message_number`.
172pub(crate) fn is_supported_ssr(message_number: u16) -> bool {
173    ssr_kind(message_number).is_some()
174}
175
176impl SsrMessage {
177    /// Decode one RTCM SSR body, without the transport frame.
178    pub fn decode(body: &[u8]) -> Result<Self> {
179        let mut r = BitReader::new(body);
180        let message_number = r.u(12)? as u16;
181        let (system, kind) = ssr_kind(message_number).ok_or_else(|| {
182            Error::Parse(format!(
183                "message {message_number} is not a supported RTCM SSR Phase A type"
184            ))
185        })?;
186        let header = read_header(&mut r, kind)?;
187        let count = usize::from(header.satellite_count);
188        let mut orbit = Vec::new();
189        let mut clock = Vec::new();
190        let mut ura = Vec::new();
191
192        match kind {
193            SsrKind::Orbit => {
194                orbit.reserve(count);
195                for _ in 0..count {
196                    orbit.push(read_orbit_record(&mut r, system)?);
197                }
198            }
199            SsrKind::Clock => {
200                clock.reserve(count);
201                for _ in 0..count {
202                    clock.push(read_clock_record(&mut r)?);
203                }
204            }
205            SsrKind::CombinedOrbitClock => {
206                orbit.reserve(count);
207                clock.reserve(count);
208                for _ in 0..count {
209                    let rec = read_orbit_record(&mut r, system)?;
210                    let satellite_id = rec.satellite_id;
211                    orbit.push(rec);
212                    clock.push(SsrClockRecord {
213                        satellite_id,
214                        c0: r.i(22)? as i32,
215                        c1: r.i(21)? as i32,
216                        c2: r.i(27)? as i32,
217                    });
218                }
219            }
220            SsrKind::Ura => {
221                ura.reserve(count);
222                for _ in 0..count {
223                    let satellite_id = r.u(satellite_id_bits(system))? as u8;
224                    let index = r.u(6)? as u8;
225                    ura.push((satellite_id, index));
226                }
227            }
228            SsrKind::HighRateClock => {
229                clock.reserve(count);
230                for _ in 0..count {
231                    clock.push(SsrClockRecord {
232                        satellite_id: r.u(satellite_id_bits(system))? as u8,
233                        c0: r.i(22)? as i32,
234                        c1: 0,
235                        c2: 0,
236                    });
237                }
238            }
239            SsrKind::CodeBias | SsrKind::PhaseBias | SsrKind::Vtec => {
240                return Err(Error::Parse(format!(
241                    "message {message_number} is not enabled in RTCM SSR Phase A"
242                )));
243            }
244        }
245
246        let mut padding_bits = Vec::with_capacity(r.remaining_bits());
247        while r.remaining_bits() > 0 {
248            padding_bits.push(r.flag()?);
249        }
250
251        Ok(Self {
252            message_number,
253            system,
254            kind,
255            header,
256            orbit,
257            clock,
258            code_bias: Vec::new(),
259            phase_bias: Vec::new(),
260            ura,
261            padding_bits,
262        })
263    }
264
265    /// Encode this message back into an RTCM body.
266    pub fn encode(&self) -> Vec<u8> {
267        let mut w = BitWriter::new();
268        w.push_u(u64::from(self.message_number), 12);
269        write_header(&mut w, &self.header, self.kind);
270
271        match self.kind {
272            SsrKind::Orbit => {
273                for rec in &self.orbit {
274                    write_orbit_record(&mut w, self.system, rec);
275                }
276            }
277            SsrKind::Clock => {
278                for rec in &self.clock {
279                    write_clock_record(&mut w, self.system, rec);
280                }
281            }
282            SsrKind::CombinedOrbitClock => {
283                for (orbit, clock) in self.orbit.iter().zip(&self.clock) {
284                    write_orbit_record(&mut w, self.system, orbit);
285                    w.push_i(i64::from(clock.c0), 22);
286                    w.push_i(i64::from(clock.c1), 21);
287                    w.push_i(i64::from(clock.c2), 27);
288                }
289            }
290            SsrKind::Ura => {
291                for &(satellite_id, index) in &self.ura {
292                    w.push_u(u64::from(satellite_id), satellite_id_bits(self.system));
293                    w.push_u(u64::from(index), 6);
294                }
295            }
296            SsrKind::HighRateClock => {
297                for rec in &self.clock {
298                    w.push_u(u64::from(rec.satellite_id), satellite_id_bits(self.system));
299                    w.push_i(i64::from(rec.c0), 22);
300                }
301            }
302            SsrKind::CodeBias | SsrKind::PhaseBias | SsrKind::Vtec => {}
303        }
304
305        for &bit in &self.padding_bits {
306            w.push_flag(bit);
307        }
308        w.into_bytes()
309    }
310}
311
312fn read_header(r: &mut BitReader<'_>, kind: SsrKind) -> Result<SsrHeader> {
313    let epoch_time_s = r.u(20)? as u32;
314    let update_interval = r.u(4)? as u8;
315    let multiple_message = r.flag()?;
316    let satellite_reference_datum = if matches!(kind, SsrKind::Orbit | SsrKind::CombinedOrbitClock)
317    {
318        Some(r.flag()?)
319    } else {
320        None
321    };
322    let iod_ssr = r.u(4)? as u8;
323    let provider_id = r.u(16)? as u16;
324    let solution_id = r.u(4)? as u8;
325    let satellite_count = r.u(6)? as u8;
326    Ok(SsrHeader {
327        epoch_time_s,
328        update_interval,
329        multiple_message,
330        iod_ssr,
331        provider_id,
332        solution_id,
333        satellite_reference_datum,
334        dispersive_bias_consistency: None,
335        mw_consistency: None,
336        satellite_count,
337    })
338}
339
340fn write_header(w: &mut BitWriter, header: &SsrHeader, kind: SsrKind) {
341    w.push_u(u64::from(header.epoch_time_s), 20);
342    w.push_u(u64::from(header.update_interval), 4);
343    w.push_flag(header.multiple_message);
344    if matches!(kind, SsrKind::Orbit | SsrKind::CombinedOrbitClock) {
345        w.push_flag(header.satellite_reference_datum.unwrap_or(false));
346    }
347    w.push_u(u64::from(header.iod_ssr), 4);
348    w.push_u(u64::from(header.provider_id), 16);
349    w.push_u(u64::from(header.solution_id), 4);
350    w.push_u(u64::from(header.satellite_count), 6);
351}
352
353fn read_orbit_record(r: &mut BitReader<'_>, system: GnssSystem) -> Result<SsrOrbitRecord> {
354    Ok(SsrOrbitRecord {
355        satellite_id: r.u(satellite_id_bits(system))? as u8,
356        iode: r.u(iode_bits(system))? as u32,
357        delta_radial: r.i(22)? as i32,
358        delta_along: r.i(20)? as i32,
359        delta_cross: r.i(20)? as i32,
360        dot_delta_radial: r.i(21)? as i32,
361        dot_delta_along: r.i(19)? as i32,
362        dot_delta_cross: r.i(19)? as i32,
363    })
364}
365
366fn write_orbit_record(w: &mut BitWriter, system: GnssSystem, rec: &SsrOrbitRecord) {
367    w.push_u(u64::from(rec.satellite_id), satellite_id_bits(system));
368    w.push_u(u64::from(rec.iode), iode_bits(system));
369    w.push_i(i64::from(rec.delta_radial), 22);
370    w.push_i(i64::from(rec.delta_along), 20);
371    w.push_i(i64::from(rec.delta_cross), 20);
372    w.push_i(i64::from(rec.dot_delta_radial), 21);
373    w.push_i(i64::from(rec.dot_delta_along), 19);
374    w.push_i(i64::from(rec.dot_delta_cross), 19);
375}
376
377fn read_clock_record(r: &mut BitReader<'_>) -> Result<SsrClockRecord> {
378    Ok(SsrClockRecord {
379        satellite_id: r.u(6)? as u8,
380        c0: r.i(22)? as i32,
381        c1: r.i(21)? as i32,
382        c2: r.i(27)? as i32,
383    })
384}
385
386fn write_clock_record(w: &mut BitWriter, system: GnssSystem, rec: &SsrClockRecord) {
387    w.push_u(u64::from(rec.satellite_id), satellite_id_bits(system));
388    w.push_i(i64::from(rec.c0), 22);
389    w.push_i(i64::from(rec.c1), 21);
390    w.push_i(i64::from(rec.c2), 27);
391}
392
393fn satellite_id_bits(system: GnssSystem) -> usize {
394    match system {
395        GnssSystem::Gps | GnssSystem::Galileo => 6,
396        _ => 6,
397    }
398}
399
400fn iode_bits(system: GnssSystem) -> usize {
401    match system {
402        GnssSystem::Galileo => 10,
403        _ => 8,
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use crate::rtcm::{
411        decode_frame, encode_frame, Message, SsrStreamAssembler, UnsupportedMessage,
412    };
413
414    const REAL_SSRA02IGS0_1243_FRAME_HEX: &str = include_str!(concat!(
415        env!("CARGO_MANIFEST_DIR"),
416        "/tests/fixtures/ssr/SSRA02IGS0_2026181234930_1243.hex"
417    ));
418    const REAL_SSRA02IGS0_1060_FRAME_HEX: &str = include_str!(concat!(
419        env!("CARGO_MANIFEST_DIR"),
420        "/tests/fixtures/ssr/SSRA02IGS0_2026181234930_1060.hex"
421    ));
422
423    type RtklibCombinedRecord = (u8, u32, i32, i32, i32, i32, i32, i32, i32, i32, i32);
424
425    const RTKLIB_GALILEO_1243: &[RtklibCombinedRecord] = &[
426        (2, 65, 1010, 274, -80, -46, -28, 7, 1426, 0, 0),
427        (3, 64, -714, 92, -83, 101, -29, 10, 1467, 0, 0),
428        (4, 63, 2270, -273, -570, 62, -10, -10, -1957, 0, 0),
429        (5, 65, 598, -257, -32, 85, -31, 4, -334, 0, 0),
430        (6, 63, 3510, -770, -997, 44, 11, 3, -4312, 0, 0),
431        (7, 61, -523, -420, 424, 8, -30, -14, 2136, 0, 0),
432        (8, 65, -678, -462, 147, 26, -20, 6, 4289, 0, 0),
433        (9, 65, 4049, -350, -709, 53, -25, 32, -2437, 0, 0),
434        (10, 61, 2796, -279, 104, -5, -14, -22, -2916, 0, 0),
435        (11, 63, 5304, -453, 225, -5, -23, -16, 4, 0, 0),
436        (12, 65, -150, 129, -165, -5, -22, 5, 2686, 0, 0),
437        (13, 65, -1364, -594, 186, 34, -39, -7, 1752, 0, 0),
438        (15, 63, 1526, -1182, -594, 48, -15, 23, -129, 0, 0),
439        (16, 63, 1103, 153, -549, -18, -22, 15, -3064, 0, 0),
440        (19, 63, 1957, 1032, 379, -40, 35, 2, -3568, 0, 0),
441        (21, 65, -2238, 369, -208, 12, -38, 3, 3171, 0, 0),
442        (23, 65, 1153, 535, -516, -49, -22, 23, -2598, 0, 0),
443        (25, 65, 98, 733, -726, -25, -15, 0, 581, 0, 0),
444        (26, 64, -822, -146, 190, 23, -9, -20, 2149, 0, 0),
445        (27, 64, 343, -1258, -237, 32, -24, -9, 220, 0, 0),
446        (28, 65, 2459, -256, -275, -53, -16, 8, -1086, 0, 0),
447        (29, 65, 1202, 228, -407, 0, -12, -16, -77, 0, 0),
448        (30, 65, 1485, 157, 415, -53, -12, 0, 566, 0, 0),
449        (31, 65, -563, 616, 1, -30, 4, -10, 151, 0, 0),
450        (33, 65, 630, -60, 258, -87, -5, -6, 1554, 0, 0),
451        (34, 58, -471, -690, -100, 20, -26, -20, 1790, 0, 0),
452        (36, 49, 1519, 292, 670, -54, -15, 16, 694, 0, 0),
453    ];
454    const RTKLIB_GPS_1060: &[RtklibCombinedRecord] = &[
455        (30, 90, 807, 621, -349, 30, -10, -8, 166, 0, 0),
456        (31, 67, -227, -1752, 1423, -43, -7, 3, 4170, 0, 0),
457    ];
458
459    fn header(kind: SsrKind, count: u8) -> SsrHeader {
460        SsrHeader {
461            epoch_time_s: 345_600,
462            update_interval: 2,
463            multiple_message: true,
464            iod_ssr: 9,
465            provider_id: 123,
466            solution_id: 4,
467            satellite_reference_datum: matches!(kind, SsrKind::Orbit | SsrKind::CombinedOrbitClock)
468                .then_some(false),
469            dispersive_bias_consistency: None,
470            mw_consistency: None,
471            satellite_count: count,
472        }
473    }
474
475    fn orbit_record(system: GnssSystem) -> SsrOrbitRecord {
476        SsrOrbitRecord {
477            satellite_id: 3,
478            iode: if system == GnssSystem::Galileo {
479                513
480            } else {
481                42
482            },
483            delta_radial: -12_345,
484            delta_along: 23_456,
485            delta_cross: -34_567,
486            dot_delta_radial: 456,
487            dot_delta_along: -567,
488            dot_delta_cross: 678,
489        }
490    }
491
492    fn clock_record() -> SsrClockRecord {
493        SsrClockRecord {
494            satellite_id: 3,
495            c0: -78_901,
496            c1: 89_012,
497            c2: -9_012_345,
498        }
499    }
500
501    fn message(message_number: u16, system: GnssSystem, kind: SsrKind) -> SsrMessage {
502        let mut orbit = Vec::new();
503        let mut clock = Vec::new();
504        let mut ura = Vec::new();
505        match kind {
506            SsrKind::Orbit => orbit.push(orbit_record(system)),
507            SsrKind::Clock => clock.push(clock_record()),
508            SsrKind::CombinedOrbitClock => {
509                orbit.push(orbit_record(system));
510                clock.push(clock_record());
511            }
512            SsrKind::Ura => ura.push((3, 41)),
513            SsrKind::HighRateClock => clock.push(SsrClockRecord {
514                satellite_id: 3,
515                c0: -22_222,
516                c1: 0,
517                c2: 0,
518            }),
519            SsrKind::CodeBias | SsrKind::PhaseBias | SsrKind::Vtec => {}
520        }
521        SsrMessage {
522            message_number,
523            system,
524            kind,
525            header: header(kind, 1),
526            orbit,
527            clock,
528            code_bias: Vec::new(),
529            phase_bias: Vec::new(),
530            ura,
531            padding_bits: Vec::new(),
532        }
533    }
534
535    fn hex_bytes(hex: &str) -> Vec<u8> {
536        let compact: String = hex.chars().filter(|c| c.is_ascii_hexdigit()).collect();
537        assert_eq!(compact.len() % 2, 0);
538        compact
539            .as_bytes()
540            .chunks_exact(2)
541            .map(|chunk| {
542                let hi = (chunk[0] as char).to_digit(16).unwrap();
543                let lo = (chunk[1] as char).to_digit(16).unwrap();
544                ((hi << 4) | lo) as u8
545            })
546            .collect()
547    }
548
549    #[test]
550    fn phase_a_messages_decode_fields_and_roundtrip() {
551        for (number, system, kind) in [
552            (1057, GnssSystem::Gps, SsrKind::Orbit),
553            (1058, GnssSystem::Gps, SsrKind::Clock),
554            (1060, GnssSystem::Gps, SsrKind::CombinedOrbitClock),
555            (1061, GnssSystem::Gps, SsrKind::Ura),
556            (1062, GnssSystem::Gps, SsrKind::HighRateClock),
557            (1240, GnssSystem::Galileo, SsrKind::Orbit),
558            (1241, GnssSystem::Galileo, SsrKind::Clock),
559            (1243, GnssSystem::Galileo, SsrKind::CombinedOrbitClock),
560            (1244, GnssSystem::Galileo, SsrKind::Ura),
561            (1245, GnssSystem::Galileo, SsrKind::HighRateClock),
562        ] {
563            let expected = message(number, system, kind);
564            let body = expected.encode();
565            let decoded = SsrMessage::decode(&body).unwrap();
566            assert_eq!(
567                decoded.message_number, expected.message_number,
568                "message {number}"
569            );
570            assert_eq!(decoded.system, expected.system, "message {number}");
571            assert_eq!(decoded.kind, expected.kind, "message {number}");
572            assert_eq!(decoded.header, expected.header, "message {number}");
573            assert_eq!(decoded.orbit, expected.orbit, "message {number}");
574            assert_eq!(decoded.clock, expected.clock, "message {number}");
575            assert_eq!(decoded.ura, expected.ura, "message {number}");
576            assert_eq!(decoded.encode(), body, "message {number} round trip");
577            assert!(matches!(Message::decode(&body).unwrap(), Message::Ssr(_)));
578        }
579    }
580
581    #[test]
582    fn real_ssr_apc_frames_match_rtklib_decode_oracle_and_roundtrip() {
583        let gal_frame = hex_bytes(REAL_SSRA02IGS0_1243_FRAME_HEX);
584        let gps_frame = hex_bytes(REAL_SSRA02IGS0_1060_FRAME_HEX);
585        let mut stream = gal_frame.clone();
586        stream.extend_from_slice(&gps_frame);
587        let mut assembler = SsrStreamAssembler::new();
588        let decoded = assembler.push(&stream);
589        assert_eq!(decoded.len(), 2);
590
591        let Message::Ssr(gal) = decoded[0].as_ref().unwrap() else {
592            panic!("expected Galileo SSR");
593        };
594        assert_eq!(gal.message_number, 1243);
595        assert_eq!(gal.system, GnssSystem::Galileo);
596        assert_eq!(gal.kind, SsrKind::CombinedOrbitClock);
597        assert_eq!(gal.header.epoch_time_s, 344_970);
598        assert_eq!(gal.header.update_interval, 3);
599        assert!(!gal.header.multiple_message);
600        assert_eq!(gal.header.iod_ssr, 1);
601        assert_eq!(gal.header.provider_id, 0);
602        assert_eq!(gal.header.solution_id, 2);
603        assert_eq!(gal.header.satellite_count, 27);
604        assert_rtklib_combined_records(gal, RTKLIB_GALILEO_1243);
605        assert_eq!(
606            encode_frame(&Message::Ssr(gal.clone()).encode()).unwrap(),
607            gal_frame
608        );
609
610        let Message::Ssr(gps) = decoded[1].as_ref().unwrap() else {
611            panic!("expected GPS SSR");
612        };
613        assert_eq!(gps.message_number, 1060);
614        assert_eq!(gps.system, GnssSystem::Gps);
615        assert_eq!(gps.kind, SsrKind::CombinedOrbitClock);
616        assert_eq!(gps.header.epoch_time_s, 344_970);
617        assert_eq!(gps.header.update_interval, 3);
618        assert!(!gps.header.multiple_message);
619        assert_eq!(gps.header.iod_ssr, 1);
620        assert_eq!(gps.header.provider_id, 0);
621        assert_eq!(gps.header.solution_id, 2);
622        assert_eq!(gps.header.satellite_count, 2);
623        assert_rtklib_combined_records(gps, RTKLIB_GPS_1060);
624        assert_eq!(
625            encode_frame(&Message::Ssr(gps.clone()).encode()).unwrap(),
626            gps_frame
627        );
628        assert_eq!(decode_frame(&gal_frame).unwrap().body, gal.encode());
629        assert_eq!(decode_frame(&gps_frame).unwrap().body, gps.encode());
630        assert_eq!(assembler.retained_len(), 0);
631    }
632
633    fn assert_rtklib_combined_records(message: &SsrMessage, expected: &[RtklibCombinedRecord]) {
634        assert_eq!(message.orbit.len(), expected.len());
635        assert_eq!(message.clock.len(), expected.len());
636        for ((orbit, clock), expected) in message.orbit.iter().zip(&message.clock).zip(expected) {
637            let (
638                satellite_id,
639                iode,
640                delta_radial,
641                delta_along,
642                delta_cross,
643                dot_delta_radial,
644                dot_delta_along,
645                dot_delta_cross,
646                c0,
647                c1,
648                c2,
649            ) = *expected;
650            assert_eq!(orbit.satellite_id, satellite_id);
651            assert_eq!(orbit.iode, iode, "sat {satellite_id}");
652            assert_eq!(orbit.delta_radial, delta_radial, "sat {satellite_id}");
653            assert_eq!(orbit.delta_along, delta_along, "sat {satellite_id}");
654            assert_eq!(orbit.delta_cross, delta_cross, "sat {satellite_id}");
655            assert_eq!(
656                orbit.dot_delta_radial, dot_delta_radial,
657                "sat {satellite_id}"
658            );
659            assert_eq!(orbit.dot_delta_along, dot_delta_along, "sat {satellite_id}");
660            assert_eq!(orbit.dot_delta_cross, dot_delta_cross, "sat {satellite_id}");
661            assert_eq!(clock.satellite_id, satellite_id);
662            assert_eq!(clock.c0, c0, "sat {satellite_id}");
663            assert_eq!(clock.c1, c1, "sat {satellite_id}");
664            assert_eq!(clock.c2, c2, "sat {satellite_id}");
665        }
666    }
667
668    #[test]
669    fn truncated_supported_ssr_is_parse_error() {
670        let body = message(1057, GnssSystem::Gps, SsrKind::Orbit).encode();
671        let err = SsrMessage::decode(&body[..body.len() - 1]).unwrap_err();
672        assert!(matches!(err, Error::Parse(_)));
673    }
674
675    #[test]
676    fn unsupported_ssr_bias_message_stays_unsupported() {
677        let mut w = BitWriter::new();
678        w.push_u(1059, 12);
679        let body = w.into_bytes();
680        let decoded = Message::decode(&body).unwrap();
681        assert_eq!(
682            decoded,
683            Message::Unsupported(UnsupportedMessage {
684                message_number: 1059,
685                body
686            })
687        );
688    }
689
690    #[test]
691    fn stream_assembler_keeps_trailing_partial_frame() {
692        let a = Message::Ssr(message(1057, GnssSystem::Gps, SsrKind::Orbit))
693            .to_frame()
694            .unwrap();
695        let b = Message::Ssr(message(1058, GnssSystem::Gps, SsrKind::Clock))
696            .to_frame()
697            .unwrap();
698        let mut chunk = Vec::new();
699        chunk.extend_from_slice(&[0, 1, 2]);
700        chunk.extend_from_slice(&a);
701        chunk.extend_from_slice(&b[..b.len() - 2]);
702
703        let mut assembler = SsrStreamAssembler::new();
704        let first = assembler.push(&chunk);
705        assert_eq!(first.len(), 1);
706        assert_eq!(first[0].as_ref().unwrap().message_number(), 1057);
707        assert_eq!(assembler.retained_len(), b.len() - 2);
708
709        let second = assembler.push(&b[b.len() - 2..]);
710        assert_eq!(second.len(), 1);
711        assert_eq!(second[0].as_ref().unwrap().message_number(), 1058);
712        assert_eq!(assembler.retained_len(), 0);
713    }
714
715    #[test]
716    fn framed_ssr_roundtrips_through_message_decode() {
717        let message = Message::Ssr(message(
718            1243,
719            GnssSystem::Galileo,
720            SsrKind::CombinedOrbitClock,
721        ));
722        let frame = message.to_frame().unwrap();
723        let mut assembler = SsrStreamAssembler::new();
724        let decoded = assembler.push(&frame);
725        assert_eq!(decoded.len(), 1);
726        assert_eq!(decoded[0].as_ref().unwrap().encode(), message.encode());
727        assert_eq!(encode_frame(&message.encode()).unwrap(), frame);
728    }
729}