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