Skip to main content

spacelab_gnss/
rtcm.rs

1//! RTCM 3 transport and the messages a real-time GNSS client actually needs: station coordinates,
2//! broadcast ephemerides, and the SSR orbit and clock corrections that turn a broadcast orbit into a
3//! precise one.
4//!
5//! Everything else in this crate reads a *file* — RINEX, SP3, an almanac — which is the same
6//! information after somebody has already framed, checked and written it down. A real-time client
7//! gets a byte stream instead, and the framing is where the problems are: a stream resynchronises
8//! after packet loss, carries messages the reader does not implement, and contains bytes that look
9//! like a preamble but are not. So the decoder here is a **scanner**, not a parser: it is handed
10//! bytes, it finds frames, and it refuses everything whose CRC does not check.
11//!
12//! Written from the message and data-field definitions of RTCM Standard 10403 — the `DF` numbers
13//! below are its — and from the IGS SSR format description, not from any decoder's source. The
14//! standard is a paid document whose text belongs to RTCM; the field layouts are facts.
15//!
16//! # The frame
17//!
18//! ```text
19//! 0xD3 | 6 reserved bits | 10-bit payload length | payload | 24-bit CRC
20//! ```
21//!
22//! The CRC is **CRC-24Q**, the polynomial `0x1864CFB` that GPS itself uses for navigation-message
23//! parity, computed over the header and the payload together with a **zero initial value** and no
24//! final inversion. The zero init is the detail to get right: the same polynomial with `OpenPGP`'s
25//! `0xB704CE` init is a different check, and it is the one whose `0x21CF02` check value is easier to
26//! find. RTCM's variant is the reveng catalogue's `CRC-24/LTE-A`, whose published check value over
27//! `123456789` is `0xCDE703`, and that is what the tests assert.
28//!
29//! It is a genuine check rather than a formality: the byte `0xD3` occurs in payload data
30//! about once every 256 bytes, so a scanner that trusted the preamble alone would frame garbage
31//! constantly, and the length field it then read would send it off by up to a kilobyte.
32//! [`RtcmDecoder`] therefore validates before it yields, and on a failure advances by **one byte**
33//! rather than by the length a corrupt header claimed.
34//!
35//! # What is decoded, and what is deliberately not
36//!
37//! * **1005 / 1006** — the stationary reference station's ECEF antenna reference point, which is the
38//!   coordinate every differential correction is relative to. 1006 adds the antenna height.
39//! * **1019** — the GPS broadcast ephemeris, decoded straight into the [`BroadcastEphemeris`] the
40//!   rest of this crate propagates. The scale factors are IS-GPS-200's, which is why a RINEX
41//!   navigation record and a 1019 frame describing the same satellite must yield the same orbit.
42//! * **1057 / 1058 / 1060** — SSR orbit, clock, and combined corrections for GPS, with
43//!   [`OrbitCorrection::at`] and [`ClockCorrection::at`] evaluating the polynomials in time and
44//!   [`apply_orbit`] rotating the radial/along-track/cross-track triple into ECEF.
45//! * **MSM observation messages (1074–1077 and the other constellations') are not decoded.** They
46//!   are the largest part of a real stream and the least of its mathematics: an MSM7 block is a
47//!   packing exercise over pseudorange, phase, Doppler, CNR and lock time, and the observables it
48//!   yields are the ones [`crate::rinex_obs`] already models. Adding them is a day of bit tables,
49//!   not a day of thinking, and it is recorded here as absent rather than hidden.
50//! * **RTCM 2.x is not supported and will not be.** Different transport, different check,
51//!   superseded since 2004.
52//!
53//! # The sign convention, which is the thing to get wrong
54//!
55//! RTCM SSR orbit corrections are **subtracted** from the broadcast orbit:
56//! `X_precise = X_broadcast − δX`. The clock correction is **added** to the broadcast clock
57//! *offset*: `dt_precise = dt_broadcast + δC/c`. The two go opposite ways, which is not a typo in
58//! the standard and is the commonest integration error against an SSR stream. Both directions are
59//! asserted below against a hand-computed case.
60//!
61//! # Time reference
62//!
63//! A correction's polynomial is evaluated about `t₀ = epoch + Δ/2`, where `Δ` is the update interval
64//! named by DF391: the message's epoch stamps the *beginning* of its validity interval and the
65//! polynomial is referenced to the middle of it. Using the epoch itself is wrong by half an
66//! interval, which on the 60 s interval a typical stream uses is a 30 s lever arm on the rate terms.
67
68// Bit-field extraction indexes by one letter and narrows through widths the reader has already
69// bounds-checked, so the conversions below cannot lose information the field carried.
70#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
71
72use crate::gps::{BroadcastEphemeris, GnssSystem};
73
74/// Semicircles to radians: the broadcast angular quantities are all in half-turns.
75const SEMICIRCLE: f64 = core::f64::consts::PI;
76
77/// Speed of light in vacuum, m/s (IS-GPS-200, and the SI definition).
78pub const SPEED_OF_LIGHT: f64 = 299_792_458.0;
79
80/// The RTCM 3 frame preamble.
81pub const PREAMBLE: u8 = 0xD3;
82
83/// SSR update intervals named by DF391, seconds.
84pub const SSR_UPDATE_INTERVALS: [f64; 16] = [
85    1.0, 2.0, 5.0, 10.0, 15.0, 30.0, 60.0, 120.0, 240.0, 300.0, 600.0, 900.0, 1800.0, 3600.0,
86    7200.0, 10800.0,
87];
88
89/// The largest satellite count DF387, which is six bits, can name.
90pub const MAX_SSR_SATELLITES: usize = 63;
91
92/// CRC-24Q (`x²⁴+x²³+x¹⁸+x¹⁷+x¹⁴+x¹¹+x¹⁰+x⁷+x⁶+x⁵+x⁴+x³+x+1`, generator `0x0186_4CFB`).
93///
94/// Zero initial value, most-significant bit first, no final inversion — the parameters GPS and RTCM
95/// use, and therefore the reveng catalogue's CRC-24/LTE-A rather than its CRC-24/OPENPGP, which is
96/// the same polynomial seeded with `0xB704CE`.
97#[must_use]
98pub fn crc24q(bytes: &[u8]) -> u32 {
99    const POLYNOMIAL: u32 = 0x0186_4CFB;
100    let mut crc = 0_u32;
101    for byte in bytes {
102        crc ^= u32::from(*byte) << 16;
103        for _ in 0..8 {
104            crc <<= 1;
105            if crc & 0x0100_0000 != 0 {
106                crc ^= POLYNOMIAL;
107            }
108        }
109    }
110    crc & 0x00FF_FFFF
111}
112
113/// A most-significant-bit-first reader over a payload.
114///
115/// Every read is bounds-checked and returns `None` past the end, so a message shorter than its own
116/// definition — which happens when a producer truncates — ends the decode instead of reading
117/// whatever follows it in the buffer.
118#[derive(Debug, Clone)]
119struct Bits<'a> {
120    bytes: &'a [u8],
121    offset: usize,
122}
123
124impl<'a> Bits<'a> {
125    const fn new(bytes: &'a [u8]) -> Self {
126        Self { bytes, offset: 0 }
127    }
128
129    const fn consumed(&self) -> usize {
130        self.offset
131    }
132
133    /// How many bits the payload holds in total.
134    const fn len(&self) -> usize {
135        self.bytes.len() * 8
136    }
137
138    /// `width` bits as an unsigned integer, `width ≤ 64`.
139    fn u64(&mut self, width: usize) -> Option<u64> {
140        if width > 64 || self.offset + width > self.bytes.len() * 8 {
141            return None;
142        }
143        let mut value = 0_u64;
144        for i in 0..width {
145            let bit = self.offset + i;
146            let byte = self.bytes[bit / 8];
147            value = (value << 1) | u64::from((byte >> (7 - bit % 8)) & 1);
148        }
149        self.offset += width;
150        Some(value)
151    }
152
153    fn u32(&mut self, width: usize) -> Option<u32> {
154        self.u64(width).map(|v| v as u32)
155    }
156
157    fn u8(&mut self, width: usize) -> Option<u8> {
158        self.u64(width).map(|v| v as u8)
159    }
160
161    /// `width` bits as a two's-complement signed integer.
162    fn i64(&mut self, width: usize) -> Option<i64> {
163        let raw = self.u64(width)?;
164        if width == 64 {
165            return Some(raw.cast_signed());
166        }
167        let sign = 1_u64 << (width - 1);
168        Some(if raw & sign == 0 {
169            raw.cast_signed()
170        } else {
171            raw.cast_signed() - (1_i64 << width)
172        })
173    }
174
175    /// A signed field scaled by a power of two, as the standard's tables specify them.
176    ///
177    /// The scale goes through `ldexp` rather than a multiply by a computed `2^p`, so the result is
178    /// the integer's exact value with its exponent shifted: no rounding is introduced by the scale
179    /// itself, only whatever the producer's own quantisation already cost.
180    #[allow(clippy::cast_precision_loss)] // every width here is ≤ 32, so the integer is exact in f64
181    fn scaled(&mut self, width: usize, power: i32) -> Option<f64> {
182        self.i64(width).map(|v| libm::ldexp(v as f64, power))
183    }
184
185    /// An unsigned field scaled by a power of two.
186    #[allow(clippy::cast_precision_loss)]
187    fn scaled_unsigned(&mut self, width: usize, power: i32) -> Option<f64> {
188        self.u64(width).map(|v| libm::ldexp(v as f64, power))
189    }
190}
191
192/// A stationary reference station's antenna reference point (message 1005 or 1006).
193#[derive(Debug, Clone, Copy, PartialEq)]
194pub struct StationReferencePoint {
195    /// Reference station ID (DF003).
196    pub station_id: u16,
197    /// ITRF realisation year (DF021); `0` when the producer does not say.
198    pub itrf_year: u8,
199    /// ECEF antenna reference point, metres.
200    pub ecef: [f64; 3],
201    /// Antenna height above the marker, metres — `None` for 1005, which does not carry one.
202    pub antenna_height: Option<f64>,
203    /// Whether the producer claims GPS, GLONASS and Galileo service (DF022/DF023/DF024).
204    pub services: [bool; 3],
205}
206
207/// An SSR orbit correction for one satellite.
208#[derive(Debug, Clone, Copy, PartialEq)]
209pub struct OrbitCorrection {
210    /// Satellite PRN.
211    pub prn: u8,
212    /// The broadcast ephemeris issue this correction belongs to. Applying it to a different IODE is
213    /// applying it to a different orbit.
214    pub iode: u8,
215    /// Reference epoch of the polynomial, s of GPS week — the message epoch plus half its update
216    /// interval.
217    pub reference: f64,
218    /// Radial, along-track and cross-track offsets at the reference epoch, metres.
219    pub delta: [f64; 3],
220    /// Their rates, m/s.
221    pub delta_rate: [f64; 3],
222}
223
224impl OrbitCorrection {
225    /// The correction at `tow` (s of GPS week), as `δ₀ + δ̇·(t − t₀)`.
226    #[must_use]
227    pub fn at(&self, tow: f64) -> [f64; 3] {
228        let dt = tow - self.reference;
229        core::array::from_fn(|i| self.delta_rate[i].mul_add(dt, self.delta[i]))
230    }
231}
232
233/// An SSR clock correction for one satellite.
234#[derive(Debug, Clone, Copy, PartialEq)]
235pub struct ClockCorrection {
236    /// Satellite PRN.
237    pub prn: u8,
238    /// Reference epoch of the polynomial, s of GPS week.
239    pub reference: f64,
240    /// Polynomial coefficients `[C0, C1, C2]`, in metres, m/s and m/s².
241    pub coefficients: [f64; 3],
242}
243
244impl ClockCorrection {
245    /// The correction at `tow` (s of GPS week), in **metres** of range.
246    #[must_use]
247    pub fn at(&self, tow: f64) -> f64 {
248        let dt = tow - self.reference;
249        self.coefficients[2]
250            .mul_add(dt, self.coefficients[1])
251            .mul_add(dt, self.coefficients[0])
252    }
253
254    /// The correction at `tow` as a clock offset in **seconds**, ready to be added to the broadcast
255    /// offset from [`BroadcastEphemeris::clock_correction`].
256    #[must_use]
257    pub fn seconds_at(&self, tow: f64) -> f64 {
258        self.at(tow) / SPEED_OF_LIGHT
259    }
260}
261
262/// Correct a broadcast satellite position with an SSR orbit correction.
263///
264/// The radial/along-track/cross-track basis is built from the satellite's own broadcast state —
265/// radial along `r̂`, cross-track along `r̂ × v̂`, along-track completing the right-handed set — and
266/// the correction is **subtracted**, which is RTCM's convention and the opposite of what the word
267/// "correction" suggests.
268///
269/// Returns `None` for a degenerate state — zero radius, or velocity parallel to position — where the
270/// basis does not exist and putting the along-track correction on an arbitrary axis would be worse
271/// than declining.
272#[must_use]
273pub fn apply_orbit(
274    position: [f64; 3],
275    velocity: [f64; 3],
276    correction: [f64; 3],
277) -> Option<[f64; 3]> {
278    let radial = normalise(position)?;
279    let cross = normalise(cross_product(position, velocity))?;
280    let along = cross_product(cross, radial);
281    Some(core::array::from_fn(|i| {
282        position[i]
283            - (radial[i] * correction[0] + along[i] * correction[1] + cross[i] * correction[2])
284    }))
285}
286
287fn cross_product(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
288    [
289        a[1] * b[2] - a[2] * b[1],
290        a[2] * b[0] - a[0] * b[2],
291        a[0] * b[1] - a[1] * b[0],
292    ]
293}
294
295fn normalise(v: [f64; 3]) -> Option<[f64; 3]> {
296    let norm = libm::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
297    if norm <= 0.0 || !norm.is_finite() {
298        return None;
299    }
300    Some([v[0] / norm, v[1] / norm, v[2] / norm])
301}
302
303/// The satellites in one SSR orbit message.
304#[derive(Debug, Clone, PartialEq)]
305pub struct SsrOrbitBlock {
306    satellites: [OrbitCorrection; MAX_SSR_SATELLITES],
307    count: usize,
308    /// SSR issue of data (DF413): corrections from different issues must not be mixed.
309    pub iod_ssr: u8,
310}
311
312/// The satellites in one SSR clock message.
313#[derive(Debug, Clone, PartialEq)]
314pub struct SsrClockBlock {
315    satellites: [ClockCorrection; MAX_SSR_SATELLITES],
316    count: usize,
317    /// SSR issue of data (DF413).
318    pub iod_ssr: u8,
319}
320
321impl SsrOrbitBlock {
322    /// The corrections the message carried, in the order it listed them.
323    #[must_use]
324    pub fn satellites(&self) -> &[OrbitCorrection] {
325        &self.satellites[..self.count]
326    }
327}
328
329impl SsrClockBlock {
330    /// The corrections the message carried, in the order it listed them.
331    #[must_use]
332    pub fn satellites(&self) -> &[ClockCorrection] {
333        &self.satellites[..self.count]
334    }
335}
336
337/// A decoded RTCM message.
338///
339/// [`Message::Unsupported`] is not an error: a real stream is mostly messages any given client does
340/// not implement, and a client that treated those as failures would report a broken feed.
341///
342/// The SSR variants are large — each block is a fixed array sized by DF387's own six-bit satellite
343/// count — and boxing them is not available here: this module is `no_std` and the crate does not
344/// take an `alloc` dependency to carry one enum. A caller that moves many of these should match by
345/// reference.
346#[allow(clippy::large_enum_variant)]
347#[derive(Debug, Clone, PartialEq)]
348pub enum Message {
349    /// 1005 or 1006.
350    StationReferencePoint(StationReferencePoint),
351    /// 1019, a GPS broadcast ephemeris.
352    GpsEphemeris(BroadcastEphemeris),
353    /// 1057.
354    SsrOrbit(SsrOrbitBlock),
355    /// 1058.
356    SsrClock(SsrClockBlock),
357    /// 1060, which carries both for the same satellites and the same issue of data.
358    SsrOrbitAndClock(SsrOrbitBlock, SsrClockBlock),
359    /// A well-framed message this decoder does not model, carrying its number.
360    Unsupported(u16),
361}
362
363/// Why a well-framed payload could not be decoded.
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum DecodeError {
366    /// The payload ended before the message's own definition did.
367    Truncated {
368        /// The message number, if the first twelve bits were readable.
369        message: u16,
370    },
371    /// The payload holds more than the message plus its padding to a byte boundary.
372    WrongLength {
373        /// The message number.
374        message: u16,
375        /// Bits the payload actually holds.
376        payload_bits: usize,
377        /// Bits the message occupies.
378        expected: usize,
379    },
380    /// DF387 named more satellites than the message can hold.
381    TooManySatellites {
382        /// The message number.
383        message: u16,
384        /// The count that was named.
385        count: usize,
386    },
387}
388
389/// Decode one verified payload — the bytes between the length field and the CRC.
390///
391/// # Errors
392///
393/// [`DecodeError`] when the payload does not match the definition its own message number claims.
394pub fn decode(payload: &[u8]) -> Result<Message, DecodeError> {
395    let mut bits = Bits::new(payload);
396    let Some(number) = bits.u32(12) else {
397        return Err(DecodeError::Truncated { message: 0 });
398    };
399    let number = number as u16;
400    match number {
401        1005 | 1006 => decode_station(&mut bits, number).map(Message::StationReferencePoint),
402        1019 => decode_gps_ephemeris(&mut bits).map(Message::GpsEphemeris),
403        1057 => decode_ssr_orbit(&mut bits, number).map(Message::SsrOrbit),
404        1058 => decode_ssr_clock(&mut bits, number).map(Message::SsrClock),
405        1060 => decode_ssr_combined(&mut bits, number),
406        _ => Ok(Message::Unsupported(number)),
407    }
408}
409
410fn decode_station(bits: &mut Bits, number: u16) -> Result<StationReferencePoint, DecodeError> {
411    let truncated = || DecodeError::Truncated { message: number };
412    let station_id = bits.u32(12).ok_or_else(truncated)? as u16;
413    let itrf_year = bits.u8(6).ok_or_else(truncated)?;
414    let gps = bits.u8(1).ok_or_else(truncated)? == 1;
415    let glonass = bits.u8(1).ok_or_else(truncated)? == 1;
416    let galileo = bits.u8(1).ok_or_else(truncated)? == 1;
417    let _reference_station = bits.u8(1).ok_or_else(truncated)?;
418    let x = bits.i64(38).ok_or_else(truncated)?;
419    let _oscillator = bits.u8(1).ok_or_else(truncated)?;
420    let _reserved = bits.u8(1).ok_or_else(truncated)?;
421    let y = bits.i64(38).ok_or_else(truncated)?;
422    let _quarter_cycle = bits.u8(2).ok_or_else(truncated)?;
423    let z = bits.i64(38).ok_or_else(truncated)?;
424    let antenna_height = if number == 1006 {
425        Some(f64::from(bits.u32(16).ok_or_else(truncated)?) * 1e-4)
426    } else {
427        None
428    };
429
430    let expected = if number == 1006 { 168 } else { 152 };
431    check_length(number, bits.len(), expected)?;
432
433    #[allow(clippy::cast_precision_loss)] // 38-bit integers are exact in f64
434    Ok(StationReferencePoint {
435        station_id,
436        itrf_year,
437        ecef: [x as f64 * 1e-4, y as f64 * 1e-4, z as f64 * 1e-4],
438        antenna_height,
439        services: [gps, glonass, galileo],
440    })
441}
442
443fn decode_gps_ephemeris(bits: &mut Bits) -> Result<BroadcastEphemeris, DecodeError> {
444    let truncated = || DecodeError::Truncated { message: 1019 };
445    let prn = bits.u8(6).ok_or_else(truncated)?;
446    let week = bits.u32(10).ok_or_else(truncated)? as u16;
447    let _accuracy = bits.u8(4).ok_or_else(truncated)?;
448    let _code_on_l2 = bits.u8(2).ok_or_else(truncated)?;
449    let idot = bits.scaled(14, -43).ok_or_else(truncated)? * SEMICIRCLE;
450    let _iode = bits.u8(8).ok_or_else(truncated)?;
451    let toc = bits.scaled_unsigned(16, 4).ok_or_else(truncated)?;
452    let af2 = bits.scaled(8, -55).ok_or_else(truncated)?;
453    let af1 = bits.scaled(16, -43).ok_or_else(truncated)?;
454    let af0 = bits.scaled(22, -31).ok_or_else(truncated)?;
455    let _iodc = bits.u32(10).ok_or_else(truncated)?;
456    let crs = bits.scaled(16, -5).ok_or_else(truncated)?;
457    let delta_n = bits.scaled(16, -43).ok_or_else(truncated)? * SEMICIRCLE;
458    let m0 = bits.scaled(32, -31).ok_or_else(truncated)? * SEMICIRCLE;
459    let cuc = bits.scaled(16, -29).ok_or_else(truncated)?;
460    let e = bits.scaled_unsigned(32, -33).ok_or_else(truncated)?;
461    let cus = bits.scaled(16, -29).ok_or_else(truncated)?;
462    let sqrt_a = bits.scaled_unsigned(32, -19).ok_or_else(truncated)?;
463    let toe = bits.scaled_unsigned(16, 4).ok_or_else(truncated)?;
464    let cic = bits.scaled(16, -29).ok_or_else(truncated)?;
465    let omega0 = bits.scaled(32, -31).ok_or_else(truncated)? * SEMICIRCLE;
466    let cis = bits.scaled(16, -29).ok_or_else(truncated)?;
467    let i0 = bits.scaled(32, -31).ok_or_else(truncated)? * SEMICIRCLE;
468    let crc_harmonic = bits.scaled(16, -5).ok_or_else(truncated)?;
469    let omega = bits.scaled(32, -31).ok_or_else(truncated)? * SEMICIRCLE;
470    let omega_dot = bits.scaled(24, -43).ok_or_else(truncated)? * SEMICIRCLE;
471    let tgd = bits.scaled(8, -31).ok_or_else(truncated)?;
472    let health = bits.u8(6).ok_or_else(truncated)?;
473    let _l2p_data = bits.u8(1).ok_or_else(truncated)?;
474    let _fit_interval = bits.u8(1).ok_or_else(truncated)?;
475
476    check_length(1019, bits.len(), 488)?;
477
478    Ok(BroadcastEphemeris {
479        system: GnssSystem::Gps,
480        prn,
481        week,
482        toe,
483        toc,
484        sqrt_a,
485        e,
486        i0,
487        omega0,
488        omega,
489        m0,
490        delta_n,
491        idot,
492        omega_dot,
493        cuc,
494        cus,
495        crc: crc_harmonic,
496        crs,
497        cic,
498        cis,
499        af0,
500        af1,
501        af2,
502        tgd,
503        health,
504    })
505}
506
507/// The header the SSR messages share, returning the polynomial reference epoch, the SSR issue of
508/// data, and the satellite count.
509///
510/// `has_datum` is the one-bit satellite-reference-datum field, which the orbit-bearing messages
511/// (1057, 1060) carry and the clock-only message (1058) does not. Getting it wrong shifts every
512/// subsequent field by one bit, which is why each message's satellite payload is checked by round
513/// trip against physical values rather than by inspection.
514fn decode_ssr_header(
515    bits: &mut Bits,
516    number: u16,
517    has_datum: bool,
518) -> Result<(f64, u8, usize), DecodeError> {
519    let truncated = || DecodeError::Truncated { message: number };
520    let epoch = f64::from(bits.u32(20).ok_or_else(truncated)?);
521    let interval = bits.u8(4).ok_or_else(truncated)? as usize;
522    let _multiple_message = bits.u8(1).ok_or_else(truncated)?;
523    if has_datum {
524        let _datum = bits.u8(1).ok_or_else(truncated)?;
525    }
526    let iod_ssr = bits.u8(4).ok_or_else(truncated)?;
527    let _provider = bits.u32(16).ok_or_else(truncated)?;
528    let _solution = bits.u8(4).ok_or_else(truncated)?;
529    let count = bits.u8(6).ok_or_else(truncated)? as usize;
530    if count > MAX_SSR_SATELLITES {
531        return Err(DecodeError::TooManySatellites {
532            message: number,
533            count,
534        });
535    }
536    // The epoch stamps the start of the validity interval; the polynomial is about its midpoint.
537    let reference = epoch + SSR_UPDATE_INTERVALS[interval] / 2.0;
538    Ok((reference, iod_ssr, count))
539}
540
541#[allow(clippy::cast_precision_loss)] // the widest field here is 27 bits
542fn read_orbit(
543    bits: &mut Bits,
544    number: u16,
545    reference: f64,
546) -> Result<OrbitCorrection, DecodeError> {
547    let truncated = || DecodeError::Truncated { message: number };
548    Ok(OrbitCorrection {
549        prn: bits.u8(6).ok_or_else(truncated)?,
550        iode: bits.u8(8).ok_or_else(truncated)?,
551        reference,
552        delta: [
553            bits.i64(22).ok_or_else(truncated)? as f64 * 1e-4,
554            bits.i64(20).ok_or_else(truncated)? as f64 * 4e-4,
555            bits.i64(20).ok_or_else(truncated)? as f64 * 4e-4,
556        ],
557        delta_rate: [
558            bits.i64(21).ok_or_else(truncated)? as f64 * 1e-6,
559            bits.i64(19).ok_or_else(truncated)? as f64 * 4e-6,
560            bits.i64(19).ok_or_else(truncated)? as f64 * 4e-6,
561        ],
562    })
563}
564
565/// In 1060 the satellite is named once and the clock fields follow the orbit fields, so `prn` is
566/// supplied by the caller there rather than read again.
567#[allow(clippy::cast_precision_loss)]
568fn read_clock(
569    bits: &mut Bits,
570    number: u16,
571    reference: f64,
572    prn: Option<u8>,
573) -> Result<ClockCorrection, DecodeError> {
574    let truncated = || DecodeError::Truncated { message: number };
575    let prn = match prn {
576        Some(prn) => prn,
577        None => bits.u8(6).ok_or_else(truncated)?,
578    };
579    Ok(ClockCorrection {
580        prn,
581        reference,
582        coefficients: [
583            bits.i64(22).ok_or_else(truncated)? as f64 * 1e-4,
584            bits.i64(21).ok_or_else(truncated)? as f64 * 1e-6,
585            bits.i64(27).ok_or_else(truncated)? as f64 * 2e-8,
586        ],
587    })
588}
589
590const EMPTY_ORBIT: OrbitCorrection = OrbitCorrection {
591    prn: 0,
592    iode: 0,
593    reference: 0.0,
594    delta: [0.0; 3],
595    delta_rate: [0.0; 3],
596};
597
598const EMPTY_CLOCK: ClockCorrection = ClockCorrection {
599    prn: 0,
600    reference: 0.0,
601    coefficients: [0.0; 3],
602};
603
604fn decode_ssr_orbit(bits: &mut Bits, number: u16) -> Result<SsrOrbitBlock, DecodeError> {
605    let (reference, iod_ssr, count) = decode_ssr_header(bits, number, true)?;
606    let mut satellites = [EMPTY_ORBIT; MAX_SSR_SATELLITES];
607    for satellite in satellites.iter_mut().take(count) {
608        *satellite = read_orbit(bits, number, reference)?;
609    }
610    check_length(number, bits.len(), bits.consumed())?;
611    Ok(SsrOrbitBlock {
612        satellites,
613        count,
614        iod_ssr,
615    })
616}
617
618fn decode_ssr_clock(bits: &mut Bits, number: u16) -> Result<SsrClockBlock, DecodeError> {
619    let (reference, iod_ssr, count) = decode_ssr_header(bits, number, false)?;
620    let mut satellites = [EMPTY_CLOCK; MAX_SSR_SATELLITES];
621    for satellite in satellites.iter_mut().take(count) {
622        *satellite = read_clock(bits, number, reference, None)?;
623    }
624    check_length(number, bits.len(), bits.consumed())?;
625    Ok(SsrClockBlock {
626        satellites,
627        count,
628        iod_ssr,
629    })
630}
631
632fn decode_ssr_combined(bits: &mut Bits, number: u16) -> Result<Message, DecodeError> {
633    let (reference, iod_ssr, count) = decode_ssr_header(bits, number, true)?;
634    let mut orbits = [EMPTY_ORBIT; MAX_SSR_SATELLITES];
635    let mut clocks = [EMPTY_CLOCK; MAX_SSR_SATELLITES];
636    for i in 0..count {
637        let orbit = read_orbit(bits, number, reference)?;
638        clocks[i] = read_clock(bits, number, reference, Some(orbit.prn))?;
639        orbits[i] = orbit;
640    }
641    check_length(number, bits.len(), bits.consumed())?;
642    Ok(Message::SsrOrbitAndClock(
643        SsrOrbitBlock {
644            satellites: orbits,
645            count,
646            iod_ssr,
647        },
648        SsrClockBlock {
649            satellites: clocks,
650            count,
651            iod_ssr,
652        },
653    ))
654}
655
656/// A payload must hold the message and nothing beyond its padding to a byte boundary.
657///
658/// A payload *shorter* than the message never reaches here — the reads run out first and report
659/// [`DecodeError::Truncated`]. What this catches is the opposite: a producer whose message is longer
660/// than the one its own number names, whose surplus bits are a misalignment somewhere earlier that
661/// every field after it inherited.
662///
663/// For the variable-length SSR messages the same call bounds the satellite blocks, and it is worth
664/// saying what that does and does not prove. Under-reading the block by `k` bits leaves `k` more
665/// trailing bits, so it is caught for `k ≥ 2`; over-reading either exhausts the payload or, for
666/// `k ≥ 7`, fails here. A single-bit width error inside a block survives it, which is why the SSR
667/// fields are also checked by round trip against physical values.
668fn check_length(message: u16, payload_bits: usize, expected: usize) -> Result<(), DecodeError> {
669    if payload_bits >= expected && payload_bits - expected < 8 {
670        Ok(())
671    } else {
672        Err(DecodeError::WrongLength {
673            message,
674            payload_bits,
675            expected,
676        })
677    }
678}
679
680/// A frame found in a byte stream: where it started, and its payload.
681#[derive(Debug, Clone, Copy, PartialEq, Eq)]
682pub struct Frame<'a> {
683    /// Offset of the preamble within the buffer the frame was found in.
684    pub offset: usize,
685    /// The payload, between the length field and the CRC.
686    pub payload: &'a [u8],
687}
688
689/// A resynchronising scanner over an RTCM 3 byte stream.
690///
691/// Holds no buffer of its own: the caller owns the bytes and the decoder walks them, which keeps it
692/// `no_std` and lets a client hand it a ring buffer, a file, or a socket read directly. When it
693/// stops, [`RtcmDecoder::remaining`] says how many trailing bytes belong to a frame that has not
694/// fully arrived and must be carried into the next read.
695#[derive(Debug, Clone)]
696pub struct RtcmDecoder<'a> {
697    bytes: &'a [u8],
698    cursor: usize,
699    rejected: usize,
700}
701
702impl<'a> RtcmDecoder<'a> {
703    /// Scan `bytes`.
704    #[must_use]
705    pub const fn new(bytes: &'a [u8]) -> Self {
706        Self {
707            bytes,
708            cursor: 0,
709            rejected: 0,
710        }
711    }
712
713    /// How many candidate preambles were rejected — a false preamble in payload data, or a genuine
714    /// frame corrupted in transit. A client watching this climb is watching its link degrade, which
715    /// is why it is counted rather than discarded.
716    #[must_use]
717    pub const fn rejected(&self) -> usize {
718        self.rejected
719    }
720
721    /// How many bytes remain unscanned.
722    #[must_use]
723    pub const fn remaining(&self) -> usize {
724        self.bytes.len() - self.cursor
725    }
726}
727
728impl<'a> Iterator for RtcmDecoder<'a> {
729    type Item = Frame<'a>;
730
731    fn next(&mut self) -> Option<Frame<'a>> {
732        while self.cursor + 3 <= self.bytes.len() {
733            if self.bytes[self.cursor] != PREAMBLE {
734                self.cursor += 1;
735                continue;
736            }
737            let header = &self.bytes[self.cursor..self.cursor + 3];
738            // The six reserved bits must be zero; a non-zero pattern is a byte that merely looks
739            // like a preamble, and rejecting it here saves a CRC over up to a kilobyte of noise.
740            if header[1] & 0xFC != 0 {
741                self.cursor += 1;
742                self.rejected += 1;
743                continue;
744            }
745            let length = (usize::from(header[1] & 0x03) << 8) | usize::from(header[2]);
746            let total = 3 + length + 3;
747            if self.cursor + total > self.bytes.len() {
748                // Not a rejection: the rest of the frame may simply not have arrived yet.
749                return None;
750            }
751            let frame = &self.bytes[self.cursor..self.cursor + total];
752            let stated = (u32::from(frame[total - 3]) << 16)
753                | (u32::from(frame[total - 2]) << 8)
754                | u32::from(frame[total - 1]);
755            if crc24q(&frame[..total - 3]) == stated {
756                let payload = &self.bytes[self.cursor + 3..self.cursor + 3 + length];
757                let offset = self.cursor;
758                self.cursor += total;
759                return Some(Frame { offset, payload });
760            }
761            // Advance one byte, never by the length a failed header claimed: that length is exactly
762            // the field a corruption is most likely to have moved.
763            self.cursor += 1;
764            self.rejected += 1;
765        }
766        None
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    #![allow(clippy::expect_used, clippy::panic)]
773
774    use super::*;
775
776    /// A most-significant-bit-first writer, so a message can be built from physical values and read
777    /// back. It is the decoder's inverse and shares no code with it; where the two disagree about a
778    /// field width the fixed-length messages fail [`check_length`] and the variable-length ones
779    /// fail the round trip.
780    #[derive(Default)]
781    struct Writer {
782        bytes: Vec<u8>,
783        bits: usize,
784    }
785
786    impl Writer {
787        fn put(&mut self, value: u64, width: usize) {
788            for i in (0..width).rev() {
789                if self.bits.is_multiple_of(8) {
790                    self.bytes.push(0);
791                }
792                if (value >> i) & 1 == 1 {
793                    let last = self.bytes.len() - 1;
794                    self.bytes[last] |= 1 << (7 - self.bits % 8);
795                }
796                self.bits += 1;
797            }
798        }
799
800        fn put_signed(&mut self, value: i64, width: usize) {
801            let mask = if width == 64 {
802                u64::MAX
803            } else {
804                (1 << width) - 1
805            };
806            self.put((value as u64) & mask, width);
807        }
808
809        /// Quantise a physical value by a power-of-two scale, as an IS-GPS-200 producer does.
810        fn put_scaled(&mut self, value: f64, width: usize, power: i32) {
811            self.put_signed(libm::round(libm::ldexp(value, -power)) as i64, width);
812        }
813
814        fn put_scaled_unsigned(&mut self, value: f64, width: usize, power: i32) {
815            self.put(libm::round(libm::ldexp(value, -power)) as u64, width);
816        }
817
818        /// The SSR fields are scaled by decimal metric steps, not powers of two.
819        fn put_metric(&mut self, value: f64, width: usize, step: f64) {
820            self.put_signed(libm::round(value / step) as i64, width);
821        }
822
823        fn frame(mut self) -> Vec<u8> {
824            // Pad to a byte boundary, as a producer must.
825            let pad = (8 - self.bits % 8) % 8;
826            self.put(0, pad);
827            let length = self.bytes.len();
828            let mut out = vec![PREAMBLE, (length >> 8) as u8 & 0x03, length as u8];
829            out.extend_from_slice(&self.bytes);
830            let crc = crc24q(&out);
831            out.push((crc >> 16) as u8);
832            out.push((crc >> 8) as u8);
833            out.push(crc as u8);
834            out
835        }
836    }
837
838    /// A realistic GPS broadcast ephemeris, in physical units: a mid-plane MEO satellite whose `√a`
839    /// puts the semi-major axis at 26 560 km, which is what makes the decoded orbit checkable
840    /// against physics rather than against the encoder.
841    const SQRT_A: f64 = 5153.65;
842    const ECCENTRICITY: f64 = 0.008_6;
843    const INCLINATION: f64 = 0.961_7;
844    const TOE: f64 = 302_400.0;
845
846    fn semicircles(radians: f64) -> f64 {
847        radians / SEMICIRCLE
848    }
849
850    fn gps_ephemeris_frame() -> Vec<u8> {
851        let mut w = Writer::default();
852        w.put(1019, 12);
853        w.put(7, 6); // PRN
854        w.put(252, 10); // week, modulo 1024
855        w.put(0, 4); // URA index
856        w.put(1, 2); // code on L2
857        w.put_scaled(semicircles(1.0e-10), 14, -43); // IDOT
858        w.put(41, 8); // IODE
859        w.put_scaled_unsigned(TOE, 16, 4); // toc
860        w.put_scaled(5.0e-17, 8, -55); // af2
861        w.put_scaled(1.0e-12, 16, -43); // af1
862        w.put_scaled(1.0e-4, 22, -31); // af0
863        w.put(41, 10); // IODC
864        w.put_scaled(20.0, 16, -5); // Crs
865        w.put_scaled(semicircles(4.6e-9), 16, -43); // dn
866        w.put_scaled(semicircles(0.5), 32, -31); // M0
867        w.put_scaled(1.0e-6, 16, -29); // Cuc
868        w.put_scaled_unsigned(ECCENTRICITY, 32, -33);
869        w.put_scaled(5.0e-6, 16, -29); // Cus
870        w.put_scaled_unsigned(SQRT_A, 32, -19);
871        w.put_scaled_unsigned(TOE, 16, 4);
872        w.put_scaled(-1.0e-7, 16, -29); // Cic
873        w.put_scaled(semicircles(-1.0), 32, -31); // Omega0
874        w.put_scaled(1.0e-7, 16, -29); // Cis
875        w.put_scaled(semicircles(INCLINATION), 32, -31); // i0
876        w.put_scaled(250.0, 16, -5); // Crc
877        w.put_scaled(semicircles(0.9), 32, -31); // omega
878        w.put_scaled(semicircles(-8.0e-9), 24, -43); // Omega dot
879        w.put_scaled(-5.0e-9, 8, -31); // TGD
880        w.put(0, 6); // health
881        w.put(0, 1); // L2 P data flag
882        w.put(0, 1); // fit interval
883        w.frame()
884    }
885
886    /// An SSR message for two satellites; 1060 additionally carries the clock terms per satellite.
887    fn ssr_frame(number: u16) -> Vec<u8> {
888        let mut w = Writer::default();
889        w.put(u64::from(number), 12);
890        w.put(302_400, 20); // epoch, s of GPS week
891        w.put(6, 4); // update interval index -> 60 s
892        w.put(0, 1); // multiple message indicator
893        if number != 1058 {
894            w.put(0, 1); // satellite reference datum
895        }
896        w.put(5, 4); // IOD SSR
897        w.put(0, 16); // provider
898        w.put(0, 4); // solution
899        w.put(2, 6); // satellite count
900        for (prn, iode, radial) in [(7_u64, 41_u64, 0.312_5), (9, 17, -1.406_25)] {
901            w.put(prn, 6);
902            if number == 1058 {
903                w.put_metric(3.5, 22, 1e-4);
904                w.put_metric(-0.01, 21, 1e-6);
905                w.put_metric(2.0e-6, 27, 2e-8);
906                continue;
907            }
908            w.put(iode, 8);
909            w.put_metric(radial, 22, 1e-4);
910            w.put_metric(0.2, 20, 4e-4);
911            w.put_metric(-0.4, 20, 4e-4);
912            w.put_metric(1.0e-3, 21, 1e-6);
913            w.put_metric(-2.0e-3, 19, 4e-6);
914            w.put_metric(4.0e-3, 19, 4e-6);
915            if number == 1060 {
916                w.put_metric(3.5, 22, 1e-4);
917                w.put_metric(-0.01, 21, 1e-6);
918                w.put_metric(2.0e-6, 27, 2e-8);
919            }
920        }
921        w.frame()
922    }
923
924    fn decode_first(stream: &[u8]) -> Message {
925        let frame = RtcmDecoder::new(stream).next().expect("one framed message");
926        decode(frame.payload).expect("a decodable payload")
927    }
928
929    #[test]
930    fn the_crc_reproduces_the_published_check_value() {
931        // The reveng catalogue's check value for CRC-24/LTE-A, which is RTCM's CRC-24Q: the same
932        // polynomial as CRC-24/OPENPGP but seeded with zero rather than 0xB704CE. Asserting the
933        // OpenPGP check value here — 0x21CF02, the one a search finds first — would be asserting a
934        // different algorithm.
935        assert_eq!(crc24q(b"123456789"), 0x00CD_E703);
936        // The degenerate cases that separate a zero initial value from a non-zero one: an empty
937        // message and a zero byte both have zero remainder, a one byte does not.
938        assert_eq!(crc24q(b""), 0);
939        assert_eq!(crc24q(&[0x00]), 0);
940        assert_ne!(crc24q(&[0x01]), 0);
941    }
942
943    #[test]
944    fn a_flipped_bit_anywhere_in_a_frame_is_refused() {
945        // The property the CRC is carried for: every single-bit flip in the frame must fail the
946        // check, so none can reach a decoder. The fixture is a real 1057, 49 bytes framed, so this
947        // is 392 corruptions and not a spot check.
948        let good = ssr_frame(1057);
949        assert_eq!(RtcmDecoder::new(&good).count(), 1);
950        // Pinned so the sweep below cannot silently shrink if the fixture changes.
951        assert_eq!(good.len(), 49, "the 1057 fixture is 3 + 43 + 3 bytes");
952        let payload = RtcmDecoder::new(&good)
953            .next()
954            .expect("a frame")
955            .payload
956            .to_vec();
957        for byte in 0..good.len() {
958            for bit in 0..8 {
959                let mut corrupted = good.clone();
960                corrupted[byte] ^= 1 << bit;
961                let found = RtcmDecoder::new(&corrupted).next();
962                assert!(
963                    found.is_none_or(|f| f.payload != payload.as_slice()),
964                    "byte {byte} bit {bit} passed the CRC unchanged"
965                );
966            }
967        }
968    }
969
970    #[test]
971    fn the_scanner_resynchronises_past_noise_and_false_preambles() {
972        let first = gps_ephemeris_frame();
973        let second = ssr_frame(1058);
974        let mut stream = vec![0xD3, 0xFF, 0x01, 0x99]; // reserved bits set: not a frame
975        stream.extend_from_slice(&first);
976        stream.extend_from_slice(&[0x01, 0x02, 0xD3, 0x00, 0x04, 0xAA, 0xBB]); // a truncated liar
977        stream.extend_from_slice(&second);
978
979        let mut decoder = RtcmDecoder::new(&stream);
980        let a = decoder.next().expect("the first frame");
981        let b = decoder.next().expect("the second frame");
982        assert_eq!(a.offset, 4);
983        assert!(matches!(decode(a.payload), Ok(Message::GpsEphemeris(_))));
984        assert!(matches!(decode(b.payload), Ok(Message::SsrClock(_))));
985        assert!(decoder.next().is_none());
986        assert!(decoder.rejected() >= 2, "rejected {}", decoder.rejected());
987    }
988
989    #[test]
990    fn a_frame_that_has_not_fully_arrived_is_not_a_rejection() {
991        // The distinction a socket client depends on: a partial frame at the end of a read must
992        // leave the scanner silent rather than counting a fault, or a healthy link would look
993        // broken at every buffer boundary.
994        let complete = gps_ephemeris_frame();
995        for cut in 1..complete.len() {
996            let mut decoder = RtcmDecoder::new(&complete[..cut]);
997            assert!(decoder.next().is_none(), "cut {cut} yielded a frame");
998            assert_eq!(decoder.rejected(), 0, "cut {cut} counted a rejection");
999            assert_eq!(
1000                decoder.remaining(),
1001                cut,
1002                "cut {cut} consumed bytes it should have kept"
1003            );
1004        }
1005    }
1006
1007    #[test]
1008    fn the_ephemeris_decodes_to_an_orbit_of_the_right_size_and_period() {
1009        // The check that matters: not that the round trip agrees with the encoder, but that the
1010        // decoded numbers are a GPS orbit. A transposed scale factor anywhere in the 488 bits moves
1011        // the semi-major axis or the mean motion off the constellation's, and both are pinned here
1012        // against IS-GPS-200 physics rather than against the fixture.
1013        let Message::GpsEphemeris(eph) = decode_first(&gps_ephemeris_frame()) else {
1014            panic!("1019 must decode as an ephemeris");
1015        };
1016        let a = eph.semi_major_axis();
1017        assert!(
1018            (a - SQRT_A * SQRT_A).abs() < 1.0,
1019            "semi-major axis {a} m, expected {} m",
1020            SQRT_A * SQRT_A
1021        );
1022        // A GPS satellite has a half-sidereal-day period, 43 082 s. The corrected mean motion this
1023        // fixture carries puts it at 43 076.7 s -- the 5.3 s is `delta_n` plus the fixture's own
1024        // round `sqrt_a`, and a scale-factor error anywhere would be orders of magnitude larger.
1025        let period = 2.0 * core::f64::consts::PI / eph.mean_motion();
1026        assert!((period - 43_082.0).abs() < 20.0, "period {period} s");
1027
1028        // And the radius at the reference epoch is on the orbit `a` and `e` describe, which no
1029        // single field could produce on its own.
1030        let p = eph.position_ecef(TOE);
1031        let radius = libm::sqrt(p[0] * p[0] + p[1] * p[1] + p[2] * p[2]);
1032        assert!(
1033            radius > a * (1.0 - ECCENTRICITY) - 500.0 && radius < a * (1.0 + ECCENTRICITY) + 500.0,
1034            "radius {radius} m outside [{}, {}]",
1035            a * (1.0 - ECCENTRICITY),
1036            a * (1.0 + ECCENTRICITY)
1037        );
1038
1039        // Then the fields whose quantisation is fine enough to name exactly.
1040        assert_eq!(eph.prn, 7);
1041        assert_eq!(eph.week, 252);
1042        assert_eq!(eph.system, GnssSystem::Gps);
1043        assert!((eph.toe - TOE).abs() < 1e-9);
1044        assert!((eph.toc - TOE).abs() < 1e-9);
1045        // Each tolerance below is that field's own quantisation step, which is what a round trip can
1046        // pin and no more: 2^-33 on `e`, 2^-31 semicircles (1.5e-9 rad) on the four angles, 2^-5 m
1047        // on the harmonic radii, 2^-31 s on TGD and 2^-43 semicircles on the node rate. A wrong
1048        // scale factor is orders of magnitude coarser than any of them.
1049        assert!((eph.e - ECCENTRICITY).abs() < 1.2e-10);
1050        assert!((eph.i0 - INCLINATION).abs() < 1.5e-9);
1051        assert!((eph.omega0 + 1.0).abs() < 1.5e-9);
1052        assert!((eph.omega - 0.9).abs() < 1.5e-9);
1053        assert!((eph.m0 - 0.5).abs() < 1.5e-9);
1054        assert!((eph.crc - 250.0).abs() < 0.032);
1055        assert!((eph.crs - 20.0).abs() < 0.032);
1056        assert!((eph.tgd + 5.0e-9).abs() < 4.7e-10);
1057        assert!((eph.omega_dot + 8.0e-9).abs() < 3.6e-13);
1058
1059        // The clock polynomial and the harmonic corrections, on the same rule. Neither the orbit's
1060        // size nor its period depends on any of them, so everything above this line would pass with
1061        // a transposed scale factor on all eight. `af2` in particular has to carry a value: a field
1062        // the fixture leaves at zero is a field whose scale factor no round trip can observe.
1063        assert!((eph.af0 - 1.0e-4).abs() < 4.7e-10);
1064        assert!((eph.af1 - 1.0e-12).abs() < 1.2e-13);
1065        assert!((eph.af2 - 5.0e-17).abs() < 2.8e-17);
1066        assert!((eph.cuc - 1.0e-6).abs() < 1.9e-9);
1067        assert!((eph.cus - 5.0e-6).abs() < 1.9e-9);
1068        assert!((eph.cic + 1.0e-7).abs() < 1.9e-9);
1069        assert!((eph.cis - 1.0e-7).abs() < 1.9e-9);
1070        // The two rate fields the standard publishes in semicircles: the factor of pi is part of
1071        // the decode, and dropping or inverting it is a mean-motion error of a factor of ten.
1072        assert!((eph.idot - 1.0e-10).abs() < 3.6e-13);
1073        assert!((eph.delta_n - 4.6e-9).abs() < 3.6e-13);
1074    }
1075
1076    #[test]
1077    fn the_ssr_orbit_message_round_trips_and_is_referenced_to_the_interval_midpoint() {
1078        let Message::SsrOrbit(block) = decode_first(&ssr_frame(1057)) else {
1079            panic!("1057 must decode as an orbit block");
1080        };
1081        assert_eq!(block.iod_ssr, 5);
1082        let satellites = block.satellites();
1083        assert_eq!(satellites.len(), 2);
1084        assert_eq!(satellites[0].prn, 7);
1085        assert_eq!(satellites[0].iode, 41);
1086        assert_eq!(satellites[1].prn, 9);
1087        assert_eq!(satellites[1].iode, 17);
1088        assert!((satellites[0].delta[0] - 0.312_5).abs() < 1e-4);
1089        assert!((satellites[1].delta[0] + 1.406_25).abs() < 1e-4);
1090        assert!((satellites[0].delta[1] - 0.2).abs() < 4e-4);
1091        assert!((satellites[0].delta[2] + 0.4).abs() < 4e-4);
1092        assert!((satellites[0].delta_rate[0] - 1.0e-3).abs() < 1e-6);
1093        assert!((satellites[0].delta_rate[1] + 2.0e-3).abs() < 4e-6);
1094        assert!((satellites[0].delta_rate[2] - 4.0e-3).abs() < 4e-6);
1095
1096        // Epoch 302 400 with a 60 s interval: the polynomial is about 302 430.
1097        assert!((satellites[0].reference - 302_430.0).abs() < 1e-9);
1098        // Which is what makes the correction *at the epoch itself* not the constant term.
1099        let at_epoch = satellites[0].at(302_400.0);
1100        assert!(
1101            (at_epoch[0] - (0.312_5 - 30.0e-3)).abs() < 1e-4,
1102            "{at_epoch:?}"
1103        );
1104    }
1105
1106    #[test]
1107    fn the_clock_only_message_has_no_datum_bit_and_still_aligns() {
1108        // 1058's header is one bit shorter than 1057's. Reading it with the orbit header's width
1109        // would shift every field after it, so the values are checked rather than the length.
1110        let Message::SsrClock(block) = decode_first(&ssr_frame(1058)) else {
1111            panic!("1058 must decode as a clock block");
1112        };
1113        assert_eq!(block.iod_ssr, 5);
1114        assert_eq!(block.satellites().len(), 2);
1115        for (satellite, prn) in block.satellites().iter().zip([7, 9]) {
1116            assert_eq!(satellite.prn, prn);
1117            assert!((satellite.coefficients[0] - 3.5).abs() < 1e-4);
1118            assert!((satellite.coefficients[1] + 0.01).abs() < 1e-6);
1119            assert!((satellite.coefficients[2] - 2.0e-6).abs() < 2e-8);
1120            assert!((satellite.reference - 302_430.0).abs() < 1e-9);
1121        }
1122    }
1123
1124    #[test]
1125    fn the_combined_message_names_each_satellite_once_for_both_corrections() {
1126        // 1060 interleaves the orbit and clock fields per satellite and does not repeat the PRN. A
1127        // decoder that re-read it would be six bits out from the second field onwards.
1128        let Message::SsrOrbitAndClock(orbit, clock) = decode_first(&ssr_frame(1060)) else {
1129            panic!("1060 must decode as both blocks");
1130        };
1131        assert_eq!(orbit.satellites().len(), 2);
1132        assert_eq!(clock.satellites().len(), 2);
1133        assert_eq!(orbit.iod_ssr, 5);
1134        assert_eq!(clock.iod_ssr, 5);
1135        for (o, c) in orbit.satellites().iter().zip(clock.satellites()) {
1136            assert_eq!(o.prn, c.prn);
1137            assert!((o.reference - c.reference).abs() < 1e-9);
1138            assert!((c.coefficients[0] - 3.5).abs() < 1e-4);
1139            assert!((c.coefficients[1] + 0.01).abs() < 1e-6);
1140            assert!((c.coefficients[2] - 2.0e-6).abs() < 2e-8);
1141        }
1142        assert_eq!(orbit.satellites()[0].iode, 41);
1143        assert_eq!(orbit.satellites()[1].iode, 17);
1144    }
1145
1146    #[test]
1147    fn the_station_message_round_trips_and_only_1006_carries_a_height() {
1148        for number in [1005_u16, 1006] {
1149            let mut w = Writer::default();
1150            w.put(u64::from(number), 12);
1151            w.put(1234, 12); // station ID
1152            w.put(20, 6); // ITRF year
1153            w.put(1, 1); // GPS
1154            w.put(0, 1); // GLONASS
1155            w.put(1, 1); // Galileo
1156            w.put(0, 1); // reference-station indicator
1157            w.put_signed(38_378_286_400, 38); // x = 3 837 828.64 m
1158            w.put(0, 1); // oscillator indicator
1159            w.put(0, 1); // reserved
1160            w.put_signed(45_534_215_000, 38); // y = 4 553 421.50 m
1161            w.put(0, 2); // quarter-cycle indicator
1162            w.put_signed(-6_186_112_000, 38); // z = -618 611.20 m
1163            if number == 1006 {
1164                w.put(21_340, 16); // antenna height 2.134 m
1165            }
1166            let Message::StationReferencePoint(station) = decode_first(&w.frame()) else {
1167                panic!("{number} must decode as a station reference point");
1168            };
1169            assert_eq!(station.station_id, 1234);
1170            assert_eq!(station.itrf_year, 20);
1171            assert_eq!(station.services, [true, false, true]);
1172            assert!(
1173                (station.ecef[0] - 3_837_828.64).abs() < 1e-4,
1174                "{:?}",
1175                station.ecef
1176            );
1177            assert!(
1178                (station.ecef[1] - 4_553_421.50).abs() < 1e-4,
1179                "{:?}",
1180                station.ecef
1181            );
1182            assert!(
1183                (station.ecef[2] + 618_611.20).abs() < 1e-4,
1184                "{:?}",
1185                station.ecef
1186            );
1187            match (number, station.antenna_height) {
1188                (1006, Some(h)) => assert!((h - 2.134).abs() < 1e-4, "{h}"),
1189                (1005, None) => {}
1190                other => panic!("{other:?} is the wrong height for {number}"),
1191            }
1192        }
1193    }
1194
1195    #[test]
1196    fn a_message_that_is_not_its_own_declared_length_is_rejected() {
1197        // 1019 is 488 bits. A producer that emits fewer must not silently yield an ephemeris with
1198        // zeros where the tail should be, because those zeros are a valid-looking orbit; one that
1199        // emits more has misaligned somewhere earlier and its fields cannot be trusted either.
1200        let full = gps_ephemeris_frame();
1201        let payload = RtcmDecoder::new(&full)
1202            .next()
1203            .expect("a frame")
1204            .payload
1205            .to_vec();
1206        assert_eq!(
1207            decode(&payload[..40]),
1208            Err(DecodeError::Truncated { message: 1019 })
1209        );
1210        let mut padded = payload;
1211        padded.push(0);
1212        assert_eq!(
1213            decode(&padded),
1214            Err(DecodeError::WrongLength {
1215                message: 1019,
1216                payload_bits: 496,
1217                expected: 488
1218            })
1219        );
1220    }
1221
1222    #[test]
1223    fn an_unmodelled_message_is_reported_rather_than_failed() {
1224        // 1074 is an MSM4 observation block, deliberately not decoded. It must come back named, so
1225        // a client can count what it is dropping.
1226        let mut w = Writer::default();
1227        w.put(1074, 12);
1228        w.put(0, 20);
1229        assert_eq!(decode_first(&w.frame()), Message::Unsupported(1074));
1230        // And a payload too short even to name itself is an error, not an unsupported message.
1231        assert_eq!(decode(&[0x43]), Err(DecodeError::Truncated { message: 0 }));
1232    }
1233
1234    #[test]
1235    fn a_satellite_count_larger_than_the_field_can_hold_is_refused() {
1236        // DF387 is six bits, so 63 is the largest legal count and a message claiming it must not be
1237        // truncated into a shorter list. Here it claims 63 and carries none.
1238        let mut w = Writer::default();
1239        w.put(1057, 12);
1240        w.put(302_400, 20);
1241        w.put(6, 4);
1242        w.put(0, 1);
1243        w.put(0, 1);
1244        w.put(5, 4);
1245        w.put(0, 16);
1246        w.put(0, 4);
1247        w.put(63, 6);
1248        assert_eq!(
1249            decode(
1250                RtcmDecoder::new(&w.frame())
1251                    .next()
1252                    .expect("a frame")
1253                    .payload
1254            ),
1255            Err(DecodeError::Truncated { message: 1057 })
1256        );
1257    }
1258
1259    #[test]
1260    fn the_orbit_correction_is_subtracted_and_the_clock_correction_added() {
1261        // The sign convention, on a case computed by hand. The satellite is on the +x axis moving
1262        // along +y, so radial is +x, cross-track is +z and along-track is +y — and a +1 m radial
1263        // correction must move the position to 25 999 999 m, not 26 000 001.
1264        let corrected = apply_orbit(
1265            [26_000_000.0, 0.0, 0.0],
1266            [0.0, 3_000.0, 0.0],
1267            [1.0, 2.0, 3.0],
1268        )
1269        .expect("a non-degenerate state");
1270        assert!((corrected[0] - 25_999_999.0).abs() < 1e-9, "{corrected:?}");
1271        assert!((corrected[1] + 2.0).abs() < 1e-9, "{corrected:?}");
1272        assert!((corrected[2] + 3.0).abs() < 1e-9, "{corrected:?}");
1273
1274        // A zero correction must leave the position exactly where it was, which is what separates a
1275        // sign error from a basis error.
1276        let unchanged = apply_orbit([1.0e7, 2.0e7, 3.0e6], [-1.0e3, 2.0e3, 5.0e2], [0.0; 3])
1277            .expect("a non-degenerate state");
1278        assert!((unchanged[0] - 1.0e7).abs() < 1e-6, "{unchanged:?}");
1279        assert!((unchanged[1] - 2.0e7).abs() < 1e-6, "{unchanged:?}");
1280        assert!((unchanged[2] - 3.0e6).abs() < 1e-6, "{unchanged:?}");
1281
1282        let clock = ClockCorrection {
1283            prn: 1,
1284            reference: 100.0,
1285            coefficients: [SPEED_OF_LIGHT * 1e-6, 0.0, 0.0],
1286        };
1287        assert!((clock.seconds_at(100.0) - 1e-6).abs() < 1e-18);
1288    }
1289
1290    #[test]
1291    fn a_degenerate_state_has_no_orbital_basis_and_says_so() {
1292        // Velocity parallel to position: the cross-track axis does not exist. A silent fallback
1293        // would put the along-track correction on an arbitrary axis.
1294        assert!(apply_orbit([7e6, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 1.0]).is_none());
1295        assert!(apply_orbit([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 1.0]).is_none());
1296        assert!(apply_orbit([f64::NAN, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 1.0]).is_none());
1297    }
1298
1299    #[test]
1300    fn the_correction_polynomials_are_the_ones_the_standard_defines() {
1301        let orbit = OrbitCorrection {
1302            prn: 5,
1303            iode: 42,
1304            reference: 60.0,
1305            delta: [1.0, 2.0, 3.0],
1306            delta_rate: [0.1, 0.2, 0.3],
1307        };
1308        let at = orbit.at(70.0);
1309        assert!((at[0] - 2.0).abs() < 1e-12, "{at:?}");
1310        assert!((at[1] - 4.0).abs() < 1e-12, "{at:?}");
1311        assert!((at[2] - 6.0).abs() < 1e-12, "{at:?}");
1312
1313        // 1 + 0.5·10 + 0.25·100 — quadratic, not linear, which is the term an implementation drops.
1314        let clock = ClockCorrection {
1315            prn: 5,
1316            reference: 60.0,
1317            coefficients: [1.0, 0.5, 0.25],
1318        };
1319        assert!((clock.at(70.0) - 31.0).abs() < 1e-12, "{}", clock.at(70.0));
1320        // And backwards in time, where a dropped sign on `dt` would look identical at the midpoint.
1321        assert!((clock.at(50.0) - 21.0).abs() < 1e-12, "{}", clock.at(50.0));
1322    }
1323
1324    #[test]
1325    fn the_update_interval_table_is_the_one_df391_names() {
1326        // A transposed entry shifts every SSR reference epoch by seconds, which shows up as a slow
1327        // drift rather than a failure. Both ends and the two values a real stream uses are pinned.
1328        assert!((SSR_UPDATE_INTERVALS[0] - 1.0).abs() < 1e-12);
1329        assert!((SSR_UPDATE_INTERVALS[6] - 60.0).abs() < 1e-12);
1330        assert!((SSR_UPDATE_INTERVALS[7] - 120.0).abs() < 1e-12);
1331        assert!((SSR_UPDATE_INTERVALS[15] - 10_800.0).abs() < 1e-12);
1332        assert!(
1333            SSR_UPDATE_INTERVALS.windows(2).all(|w| w[1] > w[0]),
1334            "the table must be strictly increasing"
1335        );
1336    }
1337
1338    #[test]
1339    fn the_bit_reader_sign_extends_and_refuses_to_read_past_the_end() {
1340        let bytes = [0b1000_0000_u8, 0b0000_0001];
1341        let mut bits = Bits::new(&bytes);
1342        // 0b1000_0000_0000_0001 read as 16 signed bits is -32 767.
1343        assert_eq!(bits.i64(16), Some(-32_767));
1344        assert_eq!(bits.u64(1), None);
1345
1346        let mut bits = Bits::new(&bytes);
1347        assert_eq!(bits.u64(16), Some(0x8001));
1348        assert_eq!(bits.consumed(), 16);
1349
1350        // A one-bit signed field is -1 or 0, which is where an off-by-one in the sign mask shows.
1351        let mut bits = Bits::new(&[0b1000_0000]);
1352        assert_eq!(bits.i64(1), Some(-1));
1353        assert_eq!(bits.i64(1), Some(0));
1354
1355        // A width the reader cannot represent is refused rather than wrapped.
1356        assert_eq!(Bits::new(&bytes).u64(65), None);
1357
1358        // And the power-of-two scaling is exact: 2^-31 of 2^31 is one, with no rounding of its own.
1359        let mut bits = Bits::new(&[0x40, 0x00, 0x00, 0x00]);
1360        assert_eq!(bits.scaled(32, -31), Some(0.5));
1361    }
1362
1363    #[test]
1364    fn the_width_guard_is_separate_from_the_buffer_guard() {
1365        // `u64` refuses for two independent reasons and the test above only reached one of them:
1366        // 65 bits out of two bytes fails on the buffer long before the width is looked at. With a
1367        // buffer that could serve the read, only the width guard is left standing.
1368        let wide = [0xFF_u8; 9];
1369        assert_eq!(Bits::new(&wide).u64(65), None);
1370        // And 64 is the widest field the reader accepts, not the first one it refuses.
1371        assert_eq!(Bits::new(&wide).u64(64), Some(u64::MAX));
1372    }
1373
1374    #[test]
1375    fn the_radial_along_cross_basis_is_generic_and_not_a_permutation_of_the_axes() {
1376        // The hand-computed case above is axis-aligned, which is exactly the geometry on which the
1377        // basis degenerates into a permutation of the axes: a transposed term in the cross product
1378        // or a dropped factor in the normalisation moves nothing. This state is generic, and the
1379        // expected position is Maxima's at 40 digits rather than a second copy of the same algebra.
1380        let corrected = apply_orbit(
1381            [7.0e6, 2.0e6, 3.0e6],
1382            [-1.0e3, 6.0e3, 2.0e3],
1383            [1.0, 2.0, 3.0],
1384        )
1385        .expect("a non-degenerate state");
1386        let expected = [
1387            7_000_000.682_143_165,
1388            1_999_998.975_730_011_2,
1389            2_999_996.466_509_984,
1390        ];
1391        for (got, want) in corrected.iter().zip(expected) {
1392            // A micron, against corrections of 1 to 3 m: every mutation of the basis is metres.
1393            assert!((got - want).abs() < 1e-6, "{corrected:?} vs {expected:?}");
1394        }
1395    }
1396
1397    #[test]
1398    fn the_scanner_reports_how_much_it_has_not_scanned() {
1399        let stream = ssr_frame(1057);
1400        let mut decoder = RtcmDecoder::new(&stream);
1401        assert_eq!(decoder.remaining(), stream.len());
1402        assert!(decoder.next().is_some());
1403        assert_eq!(decoder.remaining(), 0);
1404    }
1405
1406    #[test]
1407    fn a_frame_past_255_bytes_needs_both_halves_of_the_length_field() {
1408        // DF002 is ten bits split across two header bytes, and every MSM7 observation frame -- the
1409        // bulk of a real stream's volume -- is longer than the low byte alone can express. A
1410        // fixture that never crosses 255 bytes never exercises the high two bits at all.
1411        let mut w = Writer::default();
1412        w.put(1077, 12); // MSM7, GPS
1413        for i in 0..300_u64 {
1414            w.put(i & 0xFF, 8);
1415        }
1416        let frame = w.frame();
1417        assert_eq!(frame.len(), 308, "3 header + 302 payload + 3 CRC");
1418        let framed: Vec<Frame> = RtcmDecoder::new(&frame).collect();
1419        assert_eq!(framed.len(), 1);
1420        assert_eq!(framed[0].payload.len(), 302);
1421        assert!(matches!(
1422            decode(framed[0].payload),
1423            Ok(Message::Unsupported(1077))
1424        ));
1425    }
1426}