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