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        1059 => Some((GnssSystem::Gps, SsrKind::CodeBias)),
161        1060 => Some((GnssSystem::Gps, SsrKind::CombinedOrbitClock)),
162        1061 => Some((GnssSystem::Gps, SsrKind::Ura)),
163        1062 => Some((GnssSystem::Gps, SsrKind::HighRateClock)),
164        1265 => Some((GnssSystem::Gps, SsrKind::PhaseBias)),
165        1240 => Some((GnssSystem::Galileo, SsrKind::Orbit)),
166        1241 => Some((GnssSystem::Galileo, SsrKind::Clock)),
167        1242 => Some((GnssSystem::Galileo, SsrKind::CodeBias)),
168        1243 => Some((GnssSystem::Galileo, SsrKind::CombinedOrbitClock)),
169        1244 => Some((GnssSystem::Galileo, SsrKind::Ura)),
170        1245 => Some((GnssSystem::Galileo, SsrKind::HighRateClock)),
171        1267 => Some((GnssSystem::Galileo, SsrKind::PhaseBias)),
172        _ => None,
173    }
174}
175
176/// True when this module decodes `message_number`.
177pub(crate) fn is_supported_ssr(message_number: u16) -> bool {
178    ssr_kind(message_number).is_some()
179}
180
181impl SsrMessage {
182    /// Decode one RTCM SSR body, without the transport frame.
183    pub fn decode(body: &[u8]) -> Result<Self> {
184        Self::decode_inner(body).map_err(Into::into)
185    }
186
187    pub(crate) fn decode_inner(body: &[u8]) -> DecodeResult<Self> {
188        let mut r = BitReader::new(body);
189        let message_number = r.u(12)? as u16;
190        let (system, kind) = ssr_kind(message_number).ok_or_else(|| {
191            Error::Parse(format!(
192                "message {message_number} is not a supported RTCM SSR Phase A type"
193            ))
194        })?;
195        let header = read_header(&mut r, kind)?;
196        let count = usize::from(header.satellite_count);
197        let mut orbit = Vec::new();
198        let mut clock = Vec::new();
199        let mut code_bias = Vec::new();
200        let mut phase_bias = Vec::new();
201        let mut ura = Vec::new();
202
203        match kind {
204            SsrKind::Orbit => {
205                orbit.reserve(count);
206                for _ in 0..count {
207                    orbit.push(read_orbit_record(&mut r, system)?);
208                }
209            }
210            SsrKind::Clock => {
211                clock.reserve(count);
212                for _ in 0..count {
213                    clock.push(read_clock_record(&mut r)?);
214                }
215            }
216            SsrKind::CombinedOrbitClock => {
217                orbit.reserve(count);
218                clock.reserve(count);
219                for _ in 0..count {
220                    let rec = read_orbit_record(&mut r, system)?;
221                    let satellite_id = rec.satellite_id;
222                    orbit.push(rec);
223                    clock.push(SsrClockRecord {
224                        satellite_id,
225                        c0: r.i(22)? as i32,
226                        c1: r.i(21)? as i32,
227                        c2: r.i(27)? as i32,
228                    });
229                }
230            }
231            SsrKind::Ura => {
232                ura.reserve(count);
233                for _ in 0..count {
234                    let satellite_id = r.u(satellite_id_bits(system))? as u8;
235                    let index = r.u(6)? as u8;
236                    ura.push((satellite_id, index));
237                }
238            }
239            SsrKind::HighRateClock => {
240                clock.reserve(count);
241                for _ in 0..count {
242                    clock.push(SsrClockRecord {
243                        satellite_id: r.u(satellite_id_bits(system))? as u8,
244                        c0: r.i(22)? as i32,
245                        c1: 0,
246                        c2: 0,
247                    });
248                }
249            }
250            SsrKind::CodeBias => {
251                code_bias.reserve(count);
252                for _ in 0..count {
253                    code_bias.push(read_code_bias_record(&mut r, system)?);
254                }
255            }
256            SsrKind::PhaseBias => {
257                phase_bias.reserve(count);
258                for _ in 0..count {
259                    phase_bias.push(read_phase_bias_record(&mut r, system)?);
260                }
261            }
262            SsrKind::Vtec => {
263                return Err(Error::Parse(format!(
264                    "message {message_number} is not enabled in RTCM SSR Phase A"
265                ))
266                .into());
267            }
268        }
269
270        let mut padding_bits = Vec::with_capacity(r.remaining_bits());
271        while r.remaining_bits() > 0 {
272            padding_bits.push(r.flag()?);
273        }
274
275        Ok(Self {
276            message_number,
277            system,
278            kind,
279            header,
280            orbit,
281            clock,
282            code_bias,
283            phase_bias,
284            ura,
285            padding_bits,
286        })
287    }
288
289    /// Encode this message back into an RTCM body.
290    pub fn encode(&self) -> Vec<u8> {
291        let mut w = BitWriter::new();
292        w.push_u(u64::from(self.message_number), 12);
293        write_header(&mut w, &self.header, self.kind);
294
295        match self.kind {
296            SsrKind::Orbit => {
297                for rec in &self.orbit {
298                    write_orbit_record(&mut w, self.system, rec);
299                }
300            }
301            SsrKind::Clock => {
302                for rec in &self.clock {
303                    write_clock_record(&mut w, self.system, rec);
304                }
305            }
306            SsrKind::CombinedOrbitClock => {
307                for (orbit, clock) in self.orbit.iter().zip(&self.clock) {
308                    write_orbit_record(&mut w, self.system, orbit);
309                    w.push_i(i64::from(clock.c0), 22);
310                    w.push_i(i64::from(clock.c1), 21);
311                    w.push_i(i64::from(clock.c2), 27);
312                }
313            }
314            SsrKind::Ura => {
315                for &(satellite_id, index) in &self.ura {
316                    w.push_u(u64::from(satellite_id), satellite_id_bits(self.system));
317                    w.push_u(u64::from(index), 6);
318                }
319            }
320            SsrKind::HighRateClock => {
321                for rec in &self.clock {
322                    w.push_u(u64::from(rec.satellite_id), satellite_id_bits(self.system));
323                    w.push_i(i64::from(rec.c0), 22);
324                }
325            }
326            SsrKind::CodeBias => {
327                for rec in &self.code_bias {
328                    write_code_bias_record(&mut w, self.system, rec);
329                }
330            }
331            SsrKind::PhaseBias => {
332                for rec in &self.phase_bias {
333                    write_phase_bias_record(&mut w, self.system, rec);
334                }
335            }
336            SsrKind::Vtec => {}
337        }
338
339        for &bit in &self.padding_bits {
340            w.push_flag(bit);
341        }
342        w.into_bytes()
343    }
344}
345
346fn read_header(r: &mut BitReader<'_>, kind: SsrKind) -> DecodeResult<SsrHeader> {
347    let epoch_time_s = r.u(20)? as u32;
348    let update_interval = r.u(4)? as u8;
349    let multiple_message = r.flag()?;
350    let satellite_reference_datum = if matches!(kind, SsrKind::Orbit | SsrKind::CombinedOrbitClock)
351    {
352        Some(r.flag()?)
353    } else {
354        None
355    };
356    let iod_ssr = r.u(4)? as u8;
357    let provider_id = r.u(16)? as u16;
358    let solution_id = r.u(4)? as u8;
359    let dispersive_bias_consistency = if kind == SsrKind::PhaseBias {
360        Some(r.flag()?)
361    } else {
362        None
363    };
364    let mw_consistency = if kind == SsrKind::PhaseBias {
365        Some(r.flag()?)
366    } else {
367        None
368    };
369    let satellite_count = r.u(6)? as u8;
370    Ok(SsrHeader {
371        epoch_time_s,
372        update_interval,
373        multiple_message,
374        iod_ssr,
375        provider_id,
376        solution_id,
377        satellite_reference_datum,
378        dispersive_bias_consistency,
379        mw_consistency,
380        satellite_count,
381    })
382}
383
384fn write_header(w: &mut BitWriter, header: &SsrHeader, kind: SsrKind) {
385    w.push_u(u64::from(header.epoch_time_s), 20);
386    w.push_u(u64::from(header.update_interval), 4);
387    w.push_flag(header.multiple_message);
388    if matches!(kind, SsrKind::Orbit | SsrKind::CombinedOrbitClock) {
389        w.push_flag(header.satellite_reference_datum.unwrap_or(false));
390    }
391    w.push_u(u64::from(header.iod_ssr), 4);
392    w.push_u(u64::from(header.provider_id), 16);
393    w.push_u(u64::from(header.solution_id), 4);
394    if kind == SsrKind::PhaseBias {
395        w.push_flag(header.dispersive_bias_consistency.unwrap_or(false));
396        w.push_flag(header.mw_consistency.unwrap_or(false));
397    }
398    w.push_u(u64::from(header.satellite_count), 6);
399}
400
401fn read_orbit_record(r: &mut BitReader<'_>, system: GnssSystem) -> DecodeResult<SsrOrbitRecord> {
402    Ok(SsrOrbitRecord {
403        satellite_id: r.u(satellite_id_bits(system))? as u8,
404        iode: r.u(iode_bits(system))? as u32,
405        delta_radial: r.i(22)? as i32,
406        delta_along: r.i(20)? as i32,
407        delta_cross: r.i(20)? as i32,
408        dot_delta_radial: r.i(21)? as i32,
409        dot_delta_along: r.i(19)? as i32,
410        dot_delta_cross: r.i(19)? as i32,
411    })
412}
413
414fn write_orbit_record(w: &mut BitWriter, system: GnssSystem, rec: &SsrOrbitRecord) {
415    w.push_u(u64::from(rec.satellite_id), satellite_id_bits(system));
416    w.push_u(u64::from(rec.iode), iode_bits(system));
417    w.push_i(i64::from(rec.delta_radial), 22);
418    w.push_i(i64::from(rec.delta_along), 20);
419    w.push_i(i64::from(rec.delta_cross), 20);
420    w.push_i(i64::from(rec.dot_delta_radial), 21);
421    w.push_i(i64::from(rec.dot_delta_along), 19);
422    w.push_i(i64::from(rec.dot_delta_cross), 19);
423}
424
425fn read_clock_record(r: &mut BitReader<'_>) -> DecodeResult<SsrClockRecord> {
426    Ok(SsrClockRecord {
427        satellite_id: r.u(6)? as u8,
428        c0: r.i(22)? as i32,
429        c1: r.i(21)? as i32,
430        c2: r.i(27)? as i32,
431    })
432}
433
434fn write_clock_record(w: &mut BitWriter, system: GnssSystem, rec: &SsrClockRecord) {
435    w.push_u(u64::from(rec.satellite_id), satellite_id_bits(system));
436    w.push_i(i64::from(rec.c0), 22);
437    w.push_i(i64::from(rec.c1), 21);
438    w.push_i(i64::from(rec.c2), 27);
439}
440
441fn read_code_bias_record(
442    r: &mut BitReader<'_>,
443    system: GnssSystem,
444) -> DecodeResult<SsrCodeBiasRecord> {
445    let satellite_id = r.u(satellite_id_bits(system))? as u8;
446    let count = r.u(5)? as usize;
447    let mut biases = Vec::with_capacity(count);
448    for _ in 0..count {
449        let signal_id = r.u(5)? as u8;
450        let bias = r.i(14)? as i16;
451        biases.push((signal_id, bias));
452    }
453    Ok(SsrCodeBiasRecord {
454        satellite_id,
455        biases,
456    })
457}
458
459fn write_code_bias_record(w: &mut BitWriter, system: GnssSystem, rec: &SsrCodeBiasRecord) {
460    w.push_u(u64::from(rec.satellite_id), satellite_id_bits(system));
461    w.push_u(rec.biases.len() as u64, 5);
462    for &(signal_id, bias) in &rec.biases {
463        w.push_u(u64::from(signal_id), 5);
464        w.push_i(i64::from(bias), 14);
465    }
466}
467
468fn read_phase_bias_record(
469    r: &mut BitReader<'_>,
470    system: GnssSystem,
471) -> DecodeResult<SsrPhaseBiasRecord> {
472    let satellite_id = r.u(satellite_id_bits(system))? as u8;
473    let count = r.u(5)? as usize;
474    let yaw_angle = r.u(9)? as u16;
475    let yaw_rate = r.i(8)? as i8;
476    let mut biases = Vec::with_capacity(count);
477    for _ in 0..count {
478        biases.push(SsrPhaseBiasSignal {
479            signal_id: r.u(5)? as u8,
480            integer_indicator: r.u(1)? as u8,
481            wide_lane_integer_indicator: r.u(2)? as u8,
482            discontinuity_counter: r.u(4)? as u8,
483            bias: r.i(20)? as i32,
484        });
485    }
486    Ok(SsrPhaseBiasRecord {
487        satellite_id,
488        yaw_angle,
489        yaw_rate,
490        biases,
491    })
492}
493
494fn write_phase_bias_record(w: &mut BitWriter, system: GnssSystem, rec: &SsrPhaseBiasRecord) {
495    w.push_u(u64::from(rec.satellite_id), satellite_id_bits(system));
496    w.push_u(rec.biases.len() as u64, 5);
497    w.push_u(u64::from(rec.yaw_angle), 9);
498    w.push_i(i64::from(rec.yaw_rate), 8);
499    for bias in &rec.biases {
500        w.push_u(u64::from(bias.signal_id), 5);
501        w.push_u(u64::from(bias.integer_indicator), 1);
502        w.push_u(u64::from(bias.wide_lane_integer_indicator), 2);
503        w.push_u(u64::from(bias.discontinuity_counter), 4);
504        w.push_i(i64::from(bias.bias), 20);
505    }
506}
507
508fn satellite_id_bits(system: GnssSystem) -> usize {
509    match system {
510        GnssSystem::Gps | GnssSystem::Galileo => 6,
511        _ => 6,
512    }
513}
514
515fn iode_bits(system: GnssSystem) -> usize {
516    match system {
517        GnssSystem::Galileo => 10,
518        _ => 8,
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use crate::rtcm::{
526        decode_frame, encode_frame, Message, SsrStreamAssembler, UnsupportedMessage,
527    };
528
529    const REAL_SSRA02IGS0_1243_FRAME_HEX: &str = include_str!(concat!(
530        env!("CARGO_MANIFEST_DIR"),
531        "/tests/fixtures/ssr/SSRA02IGS0_2026181234930_1243.hex"
532    ));
533    const REAL_SSRA02IGS0_1060_FRAME_HEX: &str = include_str!(concat!(
534        env!("CARGO_MANIFEST_DIR"),
535        "/tests/fixtures/ssr/SSRA02IGS0_2026181234930_1060.hex"
536    ));
537
538    type RtklibCombinedRecord = (u8, u32, i32, i32, i32, i32, i32, i32, i32, i32, i32);
539
540    const RTKLIB_GALILEO_1243: &[RtklibCombinedRecord] = &[
541        (2, 65, 1010, 274, -80, -46, -28, 7, 1426, 0, 0),
542        (3, 64, -714, 92, -83, 101, -29, 10, 1467, 0, 0),
543        (4, 63, 2270, -273, -570, 62, -10, -10, -1957, 0, 0),
544        (5, 65, 598, -257, -32, 85, -31, 4, -334, 0, 0),
545        (6, 63, 3510, -770, -997, 44, 11, 3, -4312, 0, 0),
546        (7, 61, -523, -420, 424, 8, -30, -14, 2136, 0, 0),
547        (8, 65, -678, -462, 147, 26, -20, 6, 4289, 0, 0),
548        (9, 65, 4049, -350, -709, 53, -25, 32, -2437, 0, 0),
549        (10, 61, 2796, -279, 104, -5, -14, -22, -2916, 0, 0),
550        (11, 63, 5304, -453, 225, -5, -23, -16, 4, 0, 0),
551        (12, 65, -150, 129, -165, -5, -22, 5, 2686, 0, 0),
552        (13, 65, -1364, -594, 186, 34, -39, -7, 1752, 0, 0),
553        (15, 63, 1526, -1182, -594, 48, -15, 23, -129, 0, 0),
554        (16, 63, 1103, 153, -549, -18, -22, 15, -3064, 0, 0),
555        (19, 63, 1957, 1032, 379, -40, 35, 2, -3568, 0, 0),
556        (21, 65, -2238, 369, -208, 12, -38, 3, 3171, 0, 0),
557        (23, 65, 1153, 535, -516, -49, -22, 23, -2598, 0, 0),
558        (25, 65, 98, 733, -726, -25, -15, 0, 581, 0, 0),
559        (26, 64, -822, -146, 190, 23, -9, -20, 2149, 0, 0),
560        (27, 64, 343, -1258, -237, 32, -24, -9, 220, 0, 0),
561        (28, 65, 2459, -256, -275, -53, -16, 8, -1086, 0, 0),
562        (29, 65, 1202, 228, -407, 0, -12, -16, -77, 0, 0),
563        (30, 65, 1485, 157, 415, -53, -12, 0, 566, 0, 0),
564        (31, 65, -563, 616, 1, -30, 4, -10, 151, 0, 0),
565        (33, 65, 630, -60, 258, -87, -5, -6, 1554, 0, 0),
566        (34, 58, -471, -690, -100, 20, -26, -20, 1790, 0, 0),
567        (36, 49, 1519, 292, 670, -54, -15, 16, 694, 0, 0),
568    ];
569    const RTKLIB_GPS_1060: &[RtklibCombinedRecord] = &[
570        (30, 90, 807, 621, -349, 30, -10, -8, 166, 0, 0),
571        (31, 67, -227, -1752, 1423, -43, -7, 3, 4170, 0, 0),
572    ];
573
574    fn header(kind: SsrKind, count: u8) -> SsrHeader {
575        SsrHeader {
576            epoch_time_s: 345_600,
577            update_interval: 2,
578            multiple_message: true,
579            iod_ssr: 9,
580            provider_id: 123,
581            solution_id: 4,
582            satellite_reference_datum: matches!(kind, SsrKind::Orbit | SsrKind::CombinedOrbitClock)
583                .then_some(false),
584            dispersive_bias_consistency: (kind == SsrKind::PhaseBias).then_some(true),
585            mw_consistency: (kind == SsrKind::PhaseBias).then_some(false),
586            satellite_count: count,
587        }
588    }
589
590    fn orbit_record(system: GnssSystem) -> SsrOrbitRecord {
591        SsrOrbitRecord {
592            satellite_id: 3,
593            iode: if system == GnssSystem::Galileo {
594                513
595            } else {
596                42
597            },
598            delta_radial: -12_345,
599            delta_along: 23_456,
600            delta_cross: -34_567,
601            dot_delta_radial: 456,
602            dot_delta_along: -567,
603            dot_delta_cross: 678,
604        }
605    }
606
607    fn clock_record() -> SsrClockRecord {
608        SsrClockRecord {
609            satellite_id: 3,
610            c0: -78_901,
611            c1: 89_012,
612            c2: -9_012_345,
613        }
614    }
615
616    fn message(message_number: u16, system: GnssSystem, kind: SsrKind) -> SsrMessage {
617        let mut orbit = Vec::new();
618        let mut clock = Vec::new();
619        let mut ura = Vec::new();
620        let mut phase_bias = Vec::new();
621        match kind {
622            SsrKind::Orbit => orbit.push(orbit_record(system)),
623            SsrKind::Clock => clock.push(clock_record()),
624            SsrKind::CombinedOrbitClock => {
625                orbit.push(orbit_record(system));
626                clock.push(clock_record());
627            }
628            SsrKind::Ura => ura.push((3, 41)),
629            SsrKind::HighRateClock => clock.push(SsrClockRecord {
630                satellite_id: 3,
631                c0: -22_222,
632                c1: 0,
633                c2: 0,
634            }),
635            SsrKind::CodeBias => {
636                // RTCM SSR code bias: 5-bit signal id, int14 bias at 0.01 m.
637            }
638            SsrKind::PhaseBias => phase_bias.push(SsrPhaseBiasRecord {
639                satellite_id: 3,
640                yaw_angle: 127,
641                yaw_rate: -12,
642                biases: vec![
643                    SsrPhaseBiasSignal {
644                        signal_id: 1,
645                        integer_indicator: 1,
646                        wide_lane_integer_indicator: 2,
647                        discontinuity_counter: 3,
648                        bias: -123_456,
649                    },
650                    SsrPhaseBiasSignal {
651                        signal_id: 9,
652                        integer_indicator: 0,
653                        wide_lane_integer_indicator: 1,
654                        discontinuity_counter: 4,
655                        bias: 234_567,
656                    },
657                ],
658            }),
659            SsrKind::Vtec => {}
660        }
661        let code_bias = if kind == SsrKind::CodeBias {
662            vec![SsrCodeBiasRecord {
663                satellite_id: 3,
664                biases: vec![(1, -1234), (9, 2345)],
665            }]
666        } else {
667            Vec::new()
668        };
669        SsrMessage {
670            message_number,
671            system,
672            kind,
673            header: header(kind, 1),
674            orbit,
675            clock,
676            code_bias,
677            phase_bias,
678            ura,
679            padding_bits: Vec::new(),
680        }
681    }
682
683    fn hex_bytes(hex: &str) -> Vec<u8> {
684        let compact: String = hex.chars().filter(|c| c.is_ascii_hexdigit()).collect();
685        assert_eq!(compact.len() % 2, 0);
686        compact
687            .as_bytes()
688            .chunks_exact(2)
689            .map(|chunk| {
690                let hi = (chunk[0] as char).to_digit(16).unwrap();
691                let lo = (chunk[1] as char).to_digit(16).unwrap();
692                ((hi << 4) | lo) as u8
693            })
694            .collect()
695    }
696
697    #[test]
698    fn phase_a_messages_decode_fields_and_roundtrip() {
699        for (number, system, kind) in [
700            (1057, GnssSystem::Gps, SsrKind::Orbit),
701            (1058, GnssSystem::Gps, SsrKind::Clock),
702            (1059, GnssSystem::Gps, SsrKind::CodeBias),
703            (1060, GnssSystem::Gps, SsrKind::CombinedOrbitClock),
704            (1061, GnssSystem::Gps, SsrKind::Ura),
705            (1062, GnssSystem::Gps, SsrKind::HighRateClock),
706            (1265, GnssSystem::Gps, SsrKind::PhaseBias),
707            (1240, GnssSystem::Galileo, SsrKind::Orbit),
708            (1241, GnssSystem::Galileo, SsrKind::Clock),
709            (1242, GnssSystem::Galileo, SsrKind::CodeBias),
710            (1243, GnssSystem::Galileo, SsrKind::CombinedOrbitClock),
711            (1244, GnssSystem::Galileo, SsrKind::Ura),
712            (1245, GnssSystem::Galileo, SsrKind::HighRateClock),
713            (1267, GnssSystem::Galileo, SsrKind::PhaseBias),
714        ] {
715            let expected = message(number, system, kind);
716            let body = expected.encode();
717            let decoded = SsrMessage::decode(&body).unwrap();
718            assert_eq!(
719                decoded.message_number, expected.message_number,
720                "message {number}"
721            );
722            assert_eq!(decoded.system, expected.system, "message {number}");
723            assert_eq!(decoded.kind, expected.kind, "message {number}");
724            assert_eq!(decoded.header, expected.header, "message {number}");
725            assert_eq!(decoded.orbit, expected.orbit, "message {number}");
726            assert_eq!(decoded.clock, expected.clock, "message {number}");
727            assert_eq!(decoded.code_bias, expected.code_bias, "message {number}");
728            assert_eq!(decoded.phase_bias, expected.phase_bias, "message {number}");
729            assert_eq!(decoded.ura, expected.ura, "message {number}");
730            assert_eq!(decoded.encode(), body, "message {number} round trip");
731            assert!(matches!(Message::decode(&body).unwrap(), Message::Ssr(_)));
732        }
733    }
734
735    #[test]
736    fn real_ssr_apc_frames_match_rtklib_decode_oracle_and_roundtrip() {
737        let gal_frame = hex_bytes(REAL_SSRA02IGS0_1243_FRAME_HEX);
738        let gps_frame = hex_bytes(REAL_SSRA02IGS0_1060_FRAME_HEX);
739        let mut stream = gal_frame.clone();
740        stream.extend_from_slice(&gps_frame);
741        let mut assembler = SsrStreamAssembler::new();
742        let decoded = assembler.push(&stream);
743        assert_eq!(decoded.len(), 2);
744
745        let Message::Ssr(gal) = decoded[0].as_ref().unwrap() else {
746            panic!("expected Galileo SSR");
747        };
748        assert_eq!(gal.message_number, 1243);
749        assert_eq!(gal.system, GnssSystem::Galileo);
750        assert_eq!(gal.kind, SsrKind::CombinedOrbitClock);
751        assert_eq!(gal.header.epoch_time_s, 344_970);
752        assert_eq!(gal.header.update_interval, 3);
753        assert!(!gal.header.multiple_message);
754        assert_eq!(gal.header.iod_ssr, 1);
755        assert_eq!(gal.header.provider_id, 0);
756        assert_eq!(gal.header.solution_id, 2);
757        assert_eq!(gal.header.satellite_count, 27);
758        assert_rtklib_combined_records(gal, RTKLIB_GALILEO_1243);
759        assert_eq!(
760            encode_frame(&Message::Ssr(gal.clone()).encode()).unwrap(),
761            gal_frame
762        );
763
764        let Message::Ssr(gps) = decoded[1].as_ref().unwrap() else {
765            panic!("expected GPS SSR");
766        };
767        assert_eq!(gps.message_number, 1060);
768        assert_eq!(gps.system, GnssSystem::Gps);
769        assert_eq!(gps.kind, SsrKind::CombinedOrbitClock);
770        assert_eq!(gps.header.epoch_time_s, 344_970);
771        assert_eq!(gps.header.update_interval, 3);
772        assert!(!gps.header.multiple_message);
773        assert_eq!(gps.header.iod_ssr, 1);
774        assert_eq!(gps.header.provider_id, 0);
775        assert_eq!(gps.header.solution_id, 2);
776        assert_eq!(gps.header.satellite_count, 2);
777        assert_rtklib_combined_records(gps, RTKLIB_GPS_1060);
778        assert_eq!(
779            encode_frame(&Message::Ssr(gps.clone()).encode()).unwrap(),
780            gps_frame
781        );
782        assert_eq!(decode_frame(&gal_frame).unwrap().body, gal.encode());
783        assert_eq!(decode_frame(&gps_frame).unwrap().body, gps.encode());
784        assert_eq!(assembler.retained_len(), 0);
785    }
786
787    fn assert_rtklib_combined_records(message: &SsrMessage, expected: &[RtklibCombinedRecord]) {
788        assert_eq!(message.orbit.len(), expected.len());
789        assert_eq!(message.clock.len(), expected.len());
790        for ((orbit, clock), expected) in message.orbit.iter().zip(&message.clock).zip(expected) {
791            let (
792                satellite_id,
793                iode,
794                delta_radial,
795                delta_along,
796                delta_cross,
797                dot_delta_radial,
798                dot_delta_along,
799                dot_delta_cross,
800                c0,
801                c1,
802                c2,
803            ) = *expected;
804            assert_eq!(orbit.satellite_id, satellite_id);
805            assert_eq!(orbit.iode, iode, "sat {satellite_id}");
806            assert_eq!(orbit.delta_radial, delta_radial, "sat {satellite_id}");
807            assert_eq!(orbit.delta_along, delta_along, "sat {satellite_id}");
808            assert_eq!(orbit.delta_cross, delta_cross, "sat {satellite_id}");
809            assert_eq!(
810                orbit.dot_delta_radial, dot_delta_radial,
811                "sat {satellite_id}"
812            );
813            assert_eq!(orbit.dot_delta_along, dot_delta_along, "sat {satellite_id}");
814            assert_eq!(orbit.dot_delta_cross, dot_delta_cross, "sat {satellite_id}");
815            assert_eq!(clock.satellite_id, satellite_id);
816            assert_eq!(clock.c0, c0, "sat {satellite_id}");
817            assert_eq!(clock.c1, c1, "sat {satellite_id}");
818            assert_eq!(clock.c2, c2, "sat {satellite_id}");
819        }
820    }
821
822    #[test]
823    fn truncated_supported_ssr_is_parse_error() {
824        let body = message(1057, GnssSystem::Gps, SsrKind::Orbit).encode();
825        let err = SsrMessage::decode(&body[..body.len() - 1]).unwrap_err();
826        assert!(matches!(err, Error::Parse(_)));
827    }
828
829    #[test]
830    fn unsupported_ssr_bias_message_stays_unsupported() {
831        let mut w = BitWriter::new();
832        w.push_u(1063, 12);
833        let body = w.into_bytes();
834        let decoded = Message::decode(&body).unwrap();
835        assert_eq!(
836            decoded,
837            Message::Unsupported(UnsupportedMessage {
838                message_number: 1063,
839                body
840            })
841        );
842    }
843
844    #[test]
845    fn stream_assembler_keeps_trailing_partial_frame() {
846        let a = Message::Ssr(message(1057, GnssSystem::Gps, SsrKind::Orbit))
847            .to_frame()
848            .unwrap();
849        let b = Message::Ssr(message(1058, GnssSystem::Gps, SsrKind::Clock))
850            .to_frame()
851            .unwrap();
852        let mut chunk = Vec::new();
853        chunk.extend_from_slice(&[0, 1, 2]);
854        chunk.extend_from_slice(&a);
855        chunk.extend_from_slice(&b[..b.len() - 2]);
856
857        let mut assembler = SsrStreamAssembler::new();
858        let first = assembler.push(&chunk);
859        assert_eq!(first.len(), 1);
860        assert_eq!(first[0].as_ref().unwrap().message_number(), 1057);
861        assert_eq!(assembler.retained_len(), b.len() - 2);
862
863        let second = assembler.push(&b[b.len() - 2..]);
864        assert_eq!(second.len(), 1);
865        assert_eq!(second[0].as_ref().unwrap().message_number(), 1058);
866        assert_eq!(assembler.retained_len(), 0);
867    }
868
869    #[test]
870    fn framed_ssr_roundtrips_through_message_decode() {
871        let message = Message::Ssr(message(
872            1243,
873            GnssSystem::Galileo,
874            SsrKind::CombinedOrbitClock,
875        ));
876        let frame = message.to_frame().unwrap();
877        let mut assembler = SsrStreamAssembler::new();
878        let decoded = assembler.push(&frame);
879        assert_eq!(decoded.len(), 1);
880        assert_eq!(decoded[0].as_ref().unwrap().encode(), message.encode());
881        assert_eq!(encode_frame(&message.encode()).unwrap(), frame);
882    }
883}