Skip to main content

sidereon_core/
id.rs

1//! GNSS satellite identification.
2//!
3//! Foundational identifier types only - no domain numerics live here.
4
5use core::fmt;
6
7/// A GNSS constellation (satellite system).
8///
9/// Variants follow the RINEX / IGS single-letter system identifiers, which are
10/// the canonical keys used throughout SP3, RINEX, and IONEX products:
11///
12/// | Letter | Variant                  | System                          |
13/// |--------|--------------------------|---------------------------------|
14/// | `G`    | [`GnssSystem::Gps`]      | GPS (US)                        |
15/// | `R`    | [`GnssSystem::Glonass`]  | GLONASS (RU)                    |
16/// | `E`    | [`GnssSystem::Galileo`]  | Galileo (EU)                    |
17/// | `C`    | [`GnssSystem::BeiDou`]   | BeiDou (CN)                     |
18/// | `J`    | [`GnssSystem::Qzss`]     | QZSS (JP)                       |
19/// | `I`    | [`GnssSystem::Navic`]    | NavIC / IRNSS (IN)              |
20/// | `S`    | [`GnssSystem::Sbas`]     | SBAS (geostationary augmentation) |
21///
22/// Note that timekeeping is constellation-tagged separately (`TimeScale`):
23/// GPS/Galileo/BeiDou each run their own system time, and GNSS week numbers are
24/// **not** cross-comparable between systems.
25#[derive(
26    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
27)]
28pub enum GnssSystem {
29    /// GPS (United States), RINEX letter `G`.
30    Gps,
31    /// GLONASS (Russia), RINEX letter `R`.
32    Glonass,
33    /// Galileo (European Union), RINEX letter `E`.
34    Galileo,
35    /// BeiDou (China), RINEX letter `C`.
36    BeiDou,
37    /// QZSS (Japan), RINEX letter `J`.
38    Qzss,
39    /// NavIC / IRNSS (India), RINEX letter `I`.
40    Navic,
41    /// SBAS geostationary augmentation, RINEX letter `S`.
42    Sbas,
43}
44
45impl GnssSystem {
46    /// The canonical display label (constellation acronyms uppercase, proper
47    /// names as styled): GPS, GLONASS, Galileo, BeiDou, QZSS, NavIC, SBAS.
48    pub const fn as_str(&self) -> &'static str {
49        match *self {
50            GnssSystem::Gps => "GPS",
51            GnssSystem::Glonass => "GLONASS",
52            GnssSystem::Galileo => "Galileo",
53            GnssSystem::BeiDou => "BeiDou",
54            GnssSystem::Qzss => "QZSS",
55            GnssSystem::Navic => "NavIC",
56            GnssSystem::Sbas => "SBAS",
57        }
58    }
59
60    /// The canonical RINEX / IGS single-letter system identifier.
61    pub const fn letter(self) -> char {
62        match self {
63            GnssSystem::Gps => 'G',
64            GnssSystem::Glonass => 'R',
65            GnssSystem::Galileo => 'E',
66            GnssSystem::BeiDou => 'C',
67            GnssSystem::Qzss => 'J',
68            GnssSystem::Navic => 'I',
69            GnssSystem::Sbas => 'S',
70        }
71    }
72
73    /// Parse a RINEX / IGS single-letter system identifier.
74    ///
75    /// Returns `None` for an unrecognized letter. Accepts uppercase letters
76    /// only, as emitted by SP3/RINEX/IONEX products.
77    pub const fn from_letter(letter: char) -> Option<Self> {
78        match letter {
79            'G' => Some(GnssSystem::Gps),
80            'R' => Some(GnssSystem::Glonass),
81            'E' => Some(GnssSystem::Galileo),
82            'C' => Some(GnssSystem::BeiDou),
83            'J' => Some(GnssSystem::Qzss),
84            'I' => Some(GnssSystem::Navic),
85            'S' => Some(GnssSystem::Sbas),
86            _ => None,
87        }
88    }
89}
90
91impl fmt::Display for GnssSystem {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        f.write_str(self.as_str())
94    }
95}
96
97/// A satellite identifier: a constellation plus its within-system PRN/slot.
98///
99/// This is the `GnssSatelliteId { system, prn }` foundational type from the
100/// spec (line 112). The `prn` is the within-constellation satellite number as
101/// it appears in the product (e.g. the `01` in the SP3/RINEX token `G01`); it
102/// is only meaningful in combination with [`GnssSatelliteId::system`].
103#[derive(
104    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
105)]
106pub struct GnssSatelliteId {
107    /// The constellation this satellite belongs to.
108    pub system: GnssSystem,
109    /// The within-constellation PRN / slot number (e.g. `1` for `G01`).
110    pub prn: u8,
111}
112
113/// Error returned when constructing a GNSS satellite identifier from invalid input.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
115pub enum SatelliteIdError {
116    /// The PRN is outside the documented range for its constellation.
117    #[error("invalid GNSS satellite {field}: {reason}")]
118    InvalidInput {
119        field: &'static str,
120        reason: &'static str,
121    },
122}
123
124const fn invalid_input(field: &'static str, reason: &'static str) -> SatelliteIdError {
125    SatelliteIdError::InvalidInput { field, reason }
126}
127
128impl GnssSatelliteId {
129    /// Construct an identifier from a constellation and PRN.
130    pub const fn new(system: GnssSystem, prn: u8) -> Result<Self, SatelliteIdError> {
131        if !is_valid_prn(system, prn) {
132            return Err(invalid_input("prn", "out of range for constellation"));
133        }
134        Ok(Self { system, prn })
135    }
136}
137
138impl fmt::Display for GnssSatelliteId {
139    /// Renders the canonical SP3/RINEX token, e.g. `G01`, `E12`, `C30`.
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        write!(f, "{}{:02}", self.system.letter(), self.prn)
142    }
143}
144
145/// Error returned when a string cannot be parsed as a [`GnssSatelliteId`].
146///
147/// Produced by the [`FromStr`](core::str::FromStr) implementation when the token
148/// is empty, has no recognized constellation letter, or lacks a numeric
149/// within-system PRN.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub struct ParseSatelliteIdError;
152
153impl fmt::Display for ParseSatelliteIdError {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        f.write_str("invalid GNSS satellite token")
156    }
157}
158
159impl std::error::Error for ParseSatelliteIdError {}
160
161impl core::str::FromStr for GnssSatelliteId {
162    type Err = ParseSatelliteIdError;
163
164    /// Parse a canonical SP3/RINEX satellite token (`G01`, `E12`, `C30`): a
165    /// constellation letter followed by the within-system PRN. Whitespace around
166    /// the token and around the PRN is ignored, matching the SP3/RINEX field
167    /// readers. This is the single canonical satellite-token parser; the
168    /// SP3/RINEX/DGNSS readers delegate to it.
169    fn from_str(token: &str) -> Result<Self, Self::Err> {
170        let token = token.trim();
171        let first = token.chars().next().ok_or(ParseSatelliteIdError)?;
172        let system = GnssSystem::from_letter(first).ok_or(ParseSatelliteIdError)?;
173        let prn_token = token[first.len_utf8()..].trim();
174        if prn_token.len() != 2 || !prn_token.bytes().all(|b| b.is_ascii_digit()) {
175            return Err(ParseSatelliteIdError);
176        }
177        let prn = prn_token.parse::<u8>().map_err(|_| ParseSatelliteIdError)?;
178        if !is_valid_prn(system, prn) {
179            return Err(ParseSatelliteIdError);
180        }
181        Self::new(system, prn).map_err(|_| ParseSatelliteIdError)
182    }
183}
184
185pub(crate) const fn is_valid_prn(system: GnssSystem, prn: u8) -> bool {
186    match system {
187        GnssSystem::Gps => prn >= 1 && prn <= 32,
188        GnssSystem::Glonass => prn >= 1 && prn <= 27,
189        GnssSystem::Galileo => prn >= 1 && prn <= 36,
190        GnssSystem::BeiDou => prn >= 1 && prn <= 63,
191        GnssSystem::Qzss => prn >= 1 && prn <= 9,
192        GnssSystem::Navic => prn >= 1 && prn <= 14,
193        GnssSystem::Sbas => prn >= 20 && prn <= 58,
194    }
195}
196
197/// The leading constellation letter of a satellite or single/double-difference
198/// ambiguity id token, as a borrowed slice (`"G01"` -> `"G"`, `"G01~ra1"` ->
199/// `"G"`, `""` -> `""`).
200///
201/// This is the single canonical first-letter extractor used for per-system
202/// grouping of stringly-keyed ids. Satellite tokens are ASCII (the RINEX/IGS
203/// system letters `G/R/E/C/J/I/S`), so the leading byte is the constellation
204/// letter. Modules that need owned keys call `.to_string()` on the result; this
205/// replaces the per-module first-character parsing the `satellite_system`
206/// helpers used to duplicate.
207pub(crate) fn constellation_letter(id: &str) -> &str {
208    id.get(..1).unwrap_or("")
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn letter_round_trips() {
217        for sys in [
218            GnssSystem::Gps,
219            GnssSystem::Glonass,
220            GnssSystem::Galileo,
221            GnssSystem::BeiDou,
222            GnssSystem::Qzss,
223            GnssSystem::Navic,
224            GnssSystem::Sbas,
225        ] {
226            assert_eq!(GnssSystem::from_letter(sys.letter()), Some(sys));
227        }
228        assert_eq!(GnssSystem::from_letter('X'), None);
229    }
230
231    #[test]
232    fn system_labels_are_canonical() {
233        let cases = [
234            (GnssSystem::Gps, "GPS"),
235            (GnssSystem::Glonass, "GLONASS"),
236            (GnssSystem::Galileo, "Galileo"),
237            (GnssSystem::BeiDou, "BeiDou"),
238            (GnssSystem::Qzss, "QZSS"),
239            (GnssSystem::Navic, "NavIC"),
240            (GnssSystem::Sbas, "SBAS"),
241        ];
242        for (system, label) in cases {
243            assert_eq!(system.as_str(), label);
244            assert_eq!(system.to_string(), label);
245        }
246    }
247
248    #[test]
249    fn satellite_token_formats_padded() {
250        let id = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
251        assert_eq!(id.to_string(), "G01");
252        assert_eq!(
253            GnssSatelliteId::new(GnssSystem::BeiDou, 30)
254                .expect("valid satellite id")
255                .to_string(),
256            "C30"
257        );
258    }
259
260    #[test]
261    fn satellite_constructor_validates_prn_range() {
262        let id = GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id");
263        assert_eq!(id.system, GnssSystem::Gps);
264        assert_eq!(id.prn, 1);
265
266        assert_eq!(
267            GnssSatelliteId::new(GnssSystem::Gps, 0),
268            Err(SatelliteIdError::InvalidInput {
269                field: "prn",
270                reason: "out of range for constellation"
271            })
272        );
273        assert_eq!(
274            GnssSatelliteId::new(GnssSystem::Sbas, 19),
275            Err(SatelliteIdError::InvalidInput {
276                field: "prn",
277                reason: "out of range for constellation"
278            })
279        );
280    }
281
282    #[test]
283    fn satellite_token_parses_via_from_str() {
284        assert_eq!(
285            "G01".parse(),
286            Ok(GnssSatelliteId::new(GnssSystem::Gps, 1).expect("valid satellite id"))
287        );
288        assert_eq!(
289            "G32".parse(),
290            Ok(GnssSatelliteId::new(GnssSystem::Gps, 32).expect("valid satellite id"))
291        );
292        assert_eq!(
293            "R27".parse(),
294            Ok(GnssSatelliteId::new(GnssSystem::Glonass, 27).expect("valid satellite id"))
295        );
296        assert_eq!(
297            "E36".parse(),
298            Ok(GnssSatelliteId::new(GnssSystem::Galileo, 36).expect("valid satellite id"))
299        );
300        assert_eq!(
301            "C30".parse(),
302            Ok(GnssSatelliteId::new(GnssSystem::BeiDou, 30).expect("valid satellite id"))
303        );
304        assert_eq!(
305            "C63".parse(),
306            Ok(GnssSatelliteId::new(GnssSystem::BeiDou, 63).expect("valid satellite id"))
307        );
308        assert_eq!(
309            "J09".parse(),
310            Ok(GnssSatelliteId::new(GnssSystem::Qzss, 9).expect("valid satellite id"))
311        );
312        assert_eq!(
313            "I14".parse(),
314            Ok(GnssSatelliteId::new(GnssSystem::Navic, 14).expect("valid satellite id"))
315        );
316        assert_eq!(
317            "S20".parse(),
318            Ok(GnssSatelliteId::new(GnssSystem::Sbas, 20).expect("valid satellite id"))
319        );
320        assert_eq!(
321            "S58".parse(),
322            Ok(GnssSatelliteId::new(GnssSystem::Sbas, 58).expect("valid satellite id"))
323        );
324        // Surrounding whitespace and a padded PRN both parse, matching the
325        // SP3/RINEX field readers.
326        assert_eq!(
327            " E12 ".parse(),
328            Ok(GnssSatelliteId::new(GnssSystem::Galileo, 12).expect("valid satellite id"))
329        );
330        // The Display round-trips through FromStr.
331        let id = GnssSatelliteId::new(GnssSystem::Qzss, 7).expect("valid satellite id");
332        assert_eq!(id.to_string().parse(), Ok(id));
333        // Rejections: empty, unknown letter, missing PRN, non-numeric PRN.
334        assert_eq!("".parse::<GnssSatelliteId>(), Err(ParseSatelliteIdError));
335        assert_eq!("X01".parse::<GnssSatelliteId>(), Err(ParseSatelliteIdError));
336        assert_eq!("G".parse::<GnssSatelliteId>(), Err(ParseSatelliteIdError));
337        assert_eq!("GAB".parse::<GnssSatelliteId>(), Err(ParseSatelliteIdError));
338    }
339
340    #[test]
341    fn satellite_token_rejects_bad_prn_width_and_range() {
342        for token in [
343            "G0", "G1", "G001", "G00", "G33", "G255", "R28", "E37", "C64", "J10", "I15", "S01",
344            "S19", "S59",
345        ] {
346            assert_eq!(
347                token.parse::<GnssSatelliteId>(),
348                Err(ParseSatelliteIdError),
349                "{token}"
350            );
351        }
352    }
353
354    #[test]
355    fn constellation_letter_extracts_leading_token_byte() {
356        assert_eq!(constellation_letter("G01"), "G");
357        assert_eq!(constellation_letter("C30"), "C");
358        assert_eq!(constellation_letter("E12~ra1"), "E");
359        assert_eq!(constellation_letter("R07:base=R07,rover=R07"), "R");
360        assert_eq!(constellation_letter(""), "");
361    }
362}