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