Skip to main content

sidereon_core/nmea/
fields.rs

1use crate::frequencies::CarrierBand;
2use crate::validate::{self, FieldError};
3use crate::{GnssSatelliteId, GnssSystem, Wgs84Geodetic};
4use std::time::Duration;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct NmeaTime {
8    pub hour: u8,
9    pub minute: u8,
10    pub second: u8,
11    pub nanos: u32,
12    pub decimals: u8,
13}
14
15impl NmeaTime {
16    pub fn parse(token: &str) -> Result<Self, FieldError> {
17        let token = token.trim();
18        if token.is_empty() {
19            return Err(FieldError::Missing { field: "nmea time" });
20        }
21        let (whole, frac) = token.split_once('.').unwrap_or((token, ""));
22        if whole.len() != 6 || !whole.bytes().all(|b| b.is_ascii_digit()) {
23            return Err(FieldError::IntParse {
24                field: "nmea time",
25                value: token.to_string(),
26            });
27        }
28        if frac.len() > 9 || !frac.bytes().all(|b| b.is_ascii_digit()) {
29            return Err(FieldError::IntParse {
30                field: "nmea time fraction",
31                value: token.to_string(),
32            });
33        }
34        let hour = whole[0..2]
35            .parse::<u8>()
36            .map_err(|_| FieldError::IntParse {
37                field: "nmea time hour",
38                value: token.to_string(),
39            })?;
40        let minute = whole[2..4]
41            .parse::<u8>()
42            .map_err(|_| FieldError::IntParse {
43                field: "nmea time minute",
44                value: token.to_string(),
45            })?;
46        let second = whole[4..6]
47            .parse::<u8>()
48            .map_err(|_| FieldError::IntParse {
49                field: "nmea time second",
50                value: token.to_string(),
51            })?;
52        if hour > 23 || minute > 59 || second > 60 {
53            return Err(FieldError::InvalidCivilTime {
54                field: "nmea time",
55                hour: i64::from(hour),
56                minute: i64::from(minute),
57                second: f64::from(second),
58            });
59        }
60        let decimals = frac.len() as u8;
61        let frac_value = if frac.is_empty() {
62            0
63        } else {
64            frac.parse::<u32>().map_err(|_| FieldError::IntParse {
65                field: "nmea time fraction",
66                value: token.to_string(),
67            })?
68        };
69        let nanos = frac_value * 10_u32.pow(9 - u32::from(decimals));
70        Ok(Self {
71            hour,
72            minute,
73            second,
74            nanos,
75            decimals,
76        })
77    }
78
79    pub fn key(self) -> (u8, u8, u8, u32) {
80        (self.hour, self.minute, self.second, self.nanos)
81    }
82
83    pub fn from_seconds_of_day_floor_centis(seconds: f64) -> Result<Self, crate::nmea::NmeaError> {
84        if !seconds.is_finite() || !(0.0..86_400.0).contains(&seconds) {
85            return Err(crate::nmea::NmeaError::InvalidInput {
86                field: "time",
87                reason: "must be finite and in [0, 86400)",
88            });
89        }
90        let whole = seconds.floor() as u32;
91        let fractional = (seconds - f64::from(whole)).clamp(0.0, 1.0);
92        let centis = (Duration::from_secs_f64(fractional).as_nanos() / 10_000_000).min(99) as u32;
93        Ok(Self {
94            hour: (whole / 3600) as u8,
95            minute: ((whole % 3600) / 60) as u8,
96            second: (whole % 60) as u8,
97            nanos: centis * 10_000_000,
98            decimals: 2,
99        })
100    }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct NmeaCoordinate {
105    pub degrees: u16,
106    pub minutes_scaled: u64,
107    pub decimals: u8,
108    pub negative: bool,
109}
110
111impl NmeaCoordinate {
112    pub fn parse(value: &str, hemisphere: &str, is_latitude: bool) -> Result<Self, FieldError> {
113        let value = value.trim();
114        let hemisphere = hemisphere.trim();
115        if value.is_empty() || hemisphere.is_empty() {
116            return Err(FieldError::Missing {
117                field: if is_latitude { "latitude" } else { "longitude" },
118            });
119        }
120        let (negative, valid_hemisphere) = match hemisphere {
121            "N" => (false, is_latitude),
122            "S" => (true, is_latitude),
123            "E" => (false, !is_latitude),
124            "W" => (true, !is_latitude),
125            _ => (false, false),
126        };
127        if !valid_hemisphere {
128            return Err(FieldError::OutOfRange {
129                field: "hemisphere",
130                min: 0.0,
131                max: 0.0,
132                upper_inclusive: true,
133            });
134        }
135        let degree_digits = if is_latitude { 2 } else { 3 };
136        if value.len() < degree_digits + 2
137            || !value[..degree_digits + 2]
138                .bytes()
139                .all(|b| b.is_ascii_digit())
140        {
141            return Err(FieldError::FloatParse {
142                field: if is_latitude { "latitude" } else { "longitude" },
143                value: value.to_string(),
144            });
145        }
146        let degrees = value[..degree_digits]
147            .parse::<u16>()
148            .map_err(|_| FieldError::IntParse {
149                field: "coordinate degrees",
150                value: value.to_string(),
151            })?;
152        let minute_token = &value[degree_digits..];
153        let (whole_minutes, minute_frac) =
154            minute_token.split_once('.').unwrap_or((minute_token, ""));
155        if whole_minutes.len() != 2
156            || !whole_minutes.bytes().all(|b| b.is_ascii_digit())
157            || minute_frac.len() > 9
158            || !minute_frac.bytes().all(|b| b.is_ascii_digit())
159        {
160            return Err(FieldError::FloatParse {
161                field: "coordinate minutes",
162                value: value.to_string(),
163            });
164        }
165        let decimals = minute_frac.len() as u8;
166        let scale = 10_u64.pow(u32::from(decimals));
167        let minutes_whole = whole_minutes
168            .parse::<u64>()
169            .map_err(|_| FieldError::IntParse {
170                field: "coordinate minutes",
171                value: value.to_string(),
172            })?;
173        let frac_scaled = if minute_frac.is_empty() {
174            0
175        } else {
176            minute_frac
177                .parse::<u64>()
178                .map_err(|_| FieldError::IntParse {
179                    field: "coordinate minute fraction",
180                    value: value.to_string(),
181                })?
182        };
183        let minutes_scaled = minutes_whole * scale + frac_scaled;
184        let degree_max = if is_latitude { 90 } else { 180 };
185        if degrees > degree_max
186            || minutes_whole > 59
187            || (degrees == degree_max && minutes_scaled != 0)
188        {
189            return Err(FieldError::OutOfRange {
190                field: if is_latitude { "latitude" } else { "longitude" },
191                min: 0.0,
192                max: f64::from(degree_max),
193                upper_inclusive: true,
194            });
195        }
196        Ok(Self {
197            degrees,
198            minutes_scaled,
199            decimals,
200            negative,
201        })
202    }
203
204    pub fn from_degrees(
205        degrees: f64,
206        is_latitude: bool,
207        decimals: u8,
208    ) -> Result<Self, crate::nmea::NmeaError> {
209        if !degrees.is_finite() || decimals > 9 {
210            return Err(crate::nmea::NmeaError::InvalidInput {
211                field: "coordinate",
212                reason: "must be finite with at most 9 decimals",
213            });
214        }
215        let max = if is_latitude { 90.0 } else { 180.0 };
216        if degrees.abs() > max {
217            return Err(crate::nmea::NmeaError::InvalidInput {
218                field: "coordinate",
219                reason: "out of range",
220            });
221        }
222        let negative = degrees.is_sign_negative();
223        let abs = degrees.abs();
224        let mut whole_degrees = abs.floor() as u16;
225        let scale = 10_u64.pow(u32::from(decimals));
226        let minutes = (abs - f64::from(whole_degrees)) * 60.0;
227        let mut minutes_scaled = round_half_away_from_zero(minutes * scale as f64) as u64;
228        if minutes_scaled >= 60 * scale {
229            whole_degrees += 1;
230            minutes_scaled -= 60 * scale;
231        }
232        if f64::from(whole_degrees) > max {
233            return Err(crate::nmea::NmeaError::InvalidInput {
234                field: "coordinate",
235                reason: "rounding exceeded coordinate bound",
236            });
237        }
238        Ok(Self {
239            degrees: whole_degrees,
240            minutes_scaled,
241            decimals,
242            negative,
243        })
244    }
245
246    pub fn degrees_f64(&self) -> f64 {
247        let sign = if self.negative { -1.0 } else { 1.0 };
248        let scale = 10_f64.powi(i32::from(self.decimals));
249        sign * (f64::from(self.degrees) + (self.minutes_scaled as f64 / scale) / 60.0)
250    }
251
252    pub fn radians(&self) -> f64 {
253        self.degrees_f64().to_radians()
254    }
255}
256
257fn round_half_away_from_zero(value: f64) -> i64 {
258    if value >= 0.0 {
259        (value + 0.5).floor() as i64
260    } else {
261        (value - 0.5).ceil() as i64
262    }
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub struct NmeaDate {
267    pub year: u16,
268    pub month: u8,
269    pub day: u8,
270}
271
272impl NmeaDate {
273    pub fn parse_rmc(token: &str) -> Result<Self, FieldError> {
274        let token = token.trim();
275        if token.len() != 6 || !token.bytes().all(|b| b.is_ascii_digit()) {
276            return Err(FieldError::IntParse {
277                field: "nmea date",
278                value: token.to_string(),
279            });
280        }
281        let day = parse_u8(&token[0..2], "nmea date day")?;
282        let month = parse_u8(&token[2..4], "nmea date month")?;
283        let yy = parse_u8(&token[4..6], "nmea date year")?;
284        let year = if yy >= 80 {
285            1900 + u16::from(yy)
286        } else {
287            2000 + u16::from(yy)
288        };
289        Self::new(year, month, day)
290    }
291
292    pub fn new(year: u16, month: u8, day: u8) -> Result<Self, FieldError> {
293        let max_day = crate::astro::time::civil::days_in_month(i64::from(year), i64::from(month));
294        if max_day == 0 || day == 0 || i64::from(day) > max_day {
295            return Err(FieldError::InvalidCivilDate {
296                field: "nmea date",
297                year: i64::from(year),
298                month: i64::from(month),
299                day: i64::from(day),
300            });
301        }
302        Ok(Self { year, month, day })
303    }
304
305    pub fn next_day(self) -> Self {
306        let max_day =
307            crate::astro::time::civil::days_in_month(i64::from(self.year), i64::from(self.month))
308                as u8;
309        if self.day < max_day {
310            Self {
311                day: self.day + 1,
312                ..self
313            }
314        } else if self.month < 12 {
315            Self {
316                month: self.month + 1,
317                day: 1,
318                ..self
319            }
320        } else {
321            Self {
322                year: self.year + 1,
323                month: 1,
324                day: 1,
325            }
326        }
327    }
328}
329
330fn parse_u8(token: &str, field: &'static str) -> Result<u8, FieldError> {
331    token.parse::<u8>().map_err(|_| FieldError::IntParse {
332        field,
333        value: token.to_string(),
334    })
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
338pub enum NmeaTalker {
339    System(GnssSystem),
340    Combined,
341    Other([u8; 2]),
342}
343
344impl NmeaTalker {
345    pub fn parse(token: &str) -> Self {
346        match token.as_bytes() {
347            b"GP" => Self::System(GnssSystem::Gps),
348            b"GL" => Self::System(GnssSystem::Glonass),
349            b"GA" => Self::System(GnssSystem::Galileo),
350            b"GB" | b"BD" => Self::System(GnssSystem::BeiDou),
351            b"GQ" | b"QZ" => Self::System(GnssSystem::Qzss),
352            b"GI" => Self::System(GnssSystem::Navic),
353            b"GN" => Self::Combined,
354            [a, b] => Self::Other([*a, *b]),
355            _ => Self::Other([b'?', b'?']),
356        }
357    }
358
359    pub fn code(self) -> Result<[u8; 2], crate::nmea::NmeaError> {
360        match self {
361            Self::System(GnssSystem::Gps) | Self::System(GnssSystem::Sbas) => Ok(*b"GP"),
362            Self::System(GnssSystem::Glonass) => Ok(*b"GL"),
363            Self::System(GnssSystem::Galileo) => Ok(*b"GA"),
364            Self::System(GnssSystem::BeiDou) => Ok(*b"GB"),
365            Self::System(GnssSystem::Qzss) => Ok(*b"GQ"),
366            Self::System(GnssSystem::Navic) => Ok(*b"GI"),
367            Self::Combined => Ok(*b"GN"),
368            Self::Other(raw) if raw.iter().all(u8::is_ascii) => Ok(raw),
369            Self::Other(_) => Err(crate::nmea::NmeaError::InvalidInput {
370                field: "talker",
371                reason: "must be ASCII",
372            }),
373        }
374    }
375
376    pub fn system(self) -> Option<GnssSystem> {
377        match self {
378            Self::System(system) => Some(system),
379            Self::Combined | Self::Other(_) => None,
380        }
381    }
382}
383
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385pub enum GgaQuality {
386    Invalid,
387    GpsSps,
388    Differential,
389    Pps,
390    RtkFixed,
391    RtkFloat,
392    Estimated,
393    Manual,
394    Simulator,
395    Other(u8),
396}
397
398impl GgaQuality {
399    pub fn parse(token: &str) -> Result<Self, FieldError> {
400        let value = validate::strict_int::<u8>(token, "gga quality")?;
401        Ok(match value {
402            0 => Self::Invalid,
403            1 => Self::GpsSps,
404            2 => Self::Differential,
405            3 => Self::Pps,
406            4 => Self::RtkFixed,
407            5 => Self::RtkFloat,
408            6 => Self::Estimated,
409            7 => Self::Manual,
410            8 => Self::Simulator,
411            other => Self::Other(other),
412        })
413    }
414
415    pub fn value(self) -> u8 {
416        match self {
417            Self::Invalid => 0,
418            Self::GpsSps => 1,
419            Self::Differential => 2,
420            Self::Pps => 3,
421            Self::RtkFixed => 4,
422            Self::RtkFloat => 5,
423            Self::Estimated => 6,
424            Self::Manual => 7,
425            Self::Simulator => 8,
426            Self::Other(value) => value,
427        }
428    }
429}
430
431#[derive(Debug, Clone, PartialEq)]
432pub struct Gga {
433    pub time: Option<NmeaTime>,
434    pub latitude: Option<NmeaCoordinate>,
435    pub longitude: Option<NmeaCoordinate>,
436    pub quality: Option<GgaQuality>,
437    pub satellites_used: Option<u8>,
438    pub hdop: Option<f64>,
439    pub altitude_msl_m: Option<f64>,
440    pub geoid_separation_m: Option<f64>,
441    pub differential_age_s: Option<f64>,
442    pub differential_station_id: Option<u16>,
443}
444
445impl Gga {
446    pub fn vrs_position(
447        position: Wgs84Geodetic,
448        time: NmeaTime,
449        quality: GgaQuality,
450        satellites_used: u8,
451        hdop: f64,
452        coordinate_decimals: u8,
453    ) -> Result<Self, crate::nmea::NmeaError> {
454        if !hdop.is_finite() || hdop < 0.0 {
455            return Err(crate::nmea::NmeaError::InvalidInput {
456                field: "hdop",
457                reason: "must be finite and non-negative",
458            });
459        }
460        Ok(Self {
461            time: Some(time),
462            latitude: Some(NmeaCoordinate::from_degrees(
463                position.lat_rad.to_degrees(),
464                true,
465                coordinate_decimals,
466            )?),
467            longitude: Some(NmeaCoordinate::from_degrees(
468                position.lon_rad.to_degrees(),
469                false,
470                coordinate_decimals,
471            )?),
472            quality: Some(quality),
473            satellites_used: Some(satellites_used),
474            hdop: Some(hdop),
475            altitude_msl_m: Some(position.height_m),
476            geoid_separation_m: Some(0.0),
477            differential_age_s: None,
478            differential_station_id: None,
479        })
480    }
481}
482
483#[derive(Debug, Clone, Copy, PartialEq, Eq)]
484pub struct NmeaSatNumber {
485    pub raw: u16,
486    pub resolved: Option<GnssSatelliteId>,
487}
488
489#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490pub struct NmeaSignalId {
491    pub system: Option<GnssSystem>,
492    pub id: u8,
493}
494
495impl NmeaSignalId {
496    pub fn carrier_band(&self) -> Option<CarrierBand> {
497        let system = self.system?;
498        match system {
499            GnssSystem::Gps | GnssSystem::Sbas => match self.id {
500                1..=3 => Some(CarrierBand::L1),
501                4..=6 => Some(CarrierBand::L2),
502                7 | 8 => Some(CarrierBand::L5),
503                _ => None,
504            },
505            GnssSystem::Glonass => match self.id {
506                1 | 2 => Some(CarrierBand::G1),
507                3 | 4 => Some(CarrierBand::G2),
508                _ => None,
509            },
510            GnssSystem::Galileo => match self.id {
511                1 => Some(CarrierBand::E5a),
512                2 => Some(CarrierBand::E5b),
513                3 => Some(CarrierBand::E5),
514                4 | 5 => Some(CarrierBand::E6),
515                6 | 7 => Some(CarrierBand::E1),
516                _ => None,
517            },
518            GnssSystem::BeiDou => match self.id {
519                1 | 2 => Some(CarrierBand::B1i),
520                3 | 4 => Some(CarrierBand::B1c),
521                5 => Some(CarrierBand::B2a),
522                6 => Some(CarrierBand::B2b),
523                7 => Some(CarrierBand::B2),
524                8 | 9 => Some(CarrierBand::B3i),
525                _ => None,
526            },
527            GnssSystem::Qzss => match self.id {
528                1..=4 => Some(CarrierBand::L1),
529                5 | 6 => Some(CarrierBand::L2),
530                7 | 8 => Some(CarrierBand::L5),
531                _ => None,
532            },
533            GnssSystem::Navic => match self.id {
534                1 | 3 => Some(CarrierBand::L5),
535                _ => None,
536            },
537        }
538    }
539}
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
542pub enum RmcStatus {
543    Valid,
544    Warning,
545    Other(char),
546}
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
549pub enum GsaSelectionMode {
550    Manual,
551    Automatic,
552    Other(char),
553}
554
555#[derive(Debug, Clone, Copy, PartialEq, Eq)]
556pub enum GsaFixMode {
557    None,
558    TwoD,
559    ThreeD,
560    Other(u8),
561}
562
563#[derive(Debug, Clone, PartialEq)]
564pub struct Rmc {
565    pub time: Option<NmeaTime>,
566    pub status: Option<RmcStatus>,
567    pub latitude: Option<NmeaCoordinate>,
568    pub longitude: Option<NmeaCoordinate>,
569    pub speed_over_ground_kn: Option<f64>,
570    pub course_over_ground_deg: Option<f64>,
571    pub date: Option<NmeaDate>,
572    pub magnetic_variation_deg: Option<f64>,
573    pub faa_mode: Option<char>,
574    pub navigational_status: Option<char>,
575}
576
577#[derive(Debug, Clone, PartialEq)]
578pub struct Gsa {
579    pub selection_mode: Option<GsaSelectionMode>,
580    pub fix_mode: Option<GsaFixMode>,
581    pub satellites: Vec<NmeaSatNumber>,
582    pub pdop: Option<f64>,
583    pub hdop: Option<f64>,
584    pub vdop: Option<f64>,
585    pub system_id: Option<u8>,
586    pub system: Option<GnssSystem>,
587}
588
589#[derive(Debug, Clone, PartialEq)]
590pub struct GsvSatellite {
591    pub sat_number: Option<NmeaSatNumber>,
592    pub elevation_deg: Option<i16>,
593    pub azimuth_deg: Option<u16>,
594    pub cn0_db_hz: Option<u8>,
595}
596
597#[derive(Debug, Clone, PartialEq)]
598pub struct Gsv {
599    pub total_messages: u8,
600    pub message_number: u8,
601    pub satellites_in_view: Option<u16>,
602    pub satellites: Vec<GsvSatellite>,
603    pub signal: Option<NmeaSignalId>,
604}
605
606#[derive(Debug, Clone, PartialEq)]
607pub struct Gst {
608    pub time: Option<NmeaTime>,
609    pub rms_range_residual_m: Option<f64>,
610    pub semi_major_error_m: Option<f64>,
611    pub semi_minor_error_m: Option<f64>,
612    pub orientation_deg: Option<f64>,
613    pub latitude_sigma_m: Option<f64>,
614    pub longitude_sigma_m: Option<f64>,
615    pub altitude_sigma_m: Option<f64>,
616}
617
618#[derive(Debug, Clone, PartialEq)]
619pub struct Vtg {
620    pub course_true_deg: Option<f64>,
621    pub course_magnetic_deg: Option<f64>,
622    pub speed_kn: Option<f64>,
623    pub speed_kmh: Option<f64>,
624    pub faa_mode: Option<char>,
625}
626
627#[derive(Debug, Clone, PartialEq)]
628pub struct Gll {
629    pub latitude: Option<NmeaCoordinate>,
630    pub longitude: Option<NmeaCoordinate>,
631    pub time: Option<NmeaTime>,
632    pub status: Option<RmcStatus>,
633    pub faa_mode: Option<char>,
634}
635
636#[derive(Debug, Clone, PartialEq)]
637pub struct Zda {
638    pub time: Option<NmeaTime>,
639    pub date: Option<NmeaDate>,
640    pub local_zone_hours: Option<i8>,
641    pub local_zone_minutes: Option<u8>,
642}
643
644pub(crate) fn resolve_sat_number(context: Option<GnssSystem>, raw: u16) -> Option<GnssSatelliteId> {
645    let candidate = match context {
646        Some(GnssSystem::Gps) => match raw {
647            1..=32 => Some((GnssSystem::Gps, raw)),
648            33..=64 => Some((GnssSystem::Sbas, raw - 13)),
649            _ => None,
650        },
651        Some(GnssSystem::Glonass) => match raw {
652            65..=99 => Some((GnssSystem::Glonass, raw - 64)),
653            1..=35 => Some((GnssSystem::Glonass, raw)),
654            _ => None,
655        },
656        Some(GnssSystem::Galileo) => match raw {
657            1..=36 => Some((GnssSystem::Galileo, raw)),
658            _ => None,
659        },
660        Some(GnssSystem::BeiDou) => match raw {
661            1..=64 => Some((GnssSystem::BeiDou, raw)),
662            _ => None,
663        },
664        Some(GnssSystem::Qzss) => match raw {
665            1..=10 => Some((GnssSystem::Qzss, raw)),
666            193..=202 => Some((GnssSystem::Qzss, raw - 192)),
667            _ => None,
668        },
669        Some(GnssSystem::Navic) => match raw {
670            1..=15 => Some((GnssSystem::Navic, raw)),
671            _ => None,
672        },
673        Some(GnssSystem::Sbas) => match raw {
674            33..=64 => Some((GnssSystem::Sbas, raw - 13)),
675            120..=158 => Some((GnssSystem::Sbas, raw - 100)),
676            _ => None,
677        },
678        None => match raw {
679            1..=32 => Some((GnssSystem::Gps, raw)),
680            33..=64 => Some((GnssSystem::Sbas, raw - 13)),
681            65..=99 => Some((GnssSystem::Glonass, raw - 64)),
682            193..=202 => Some((GnssSystem::Qzss, raw - 192)),
683            _ => None,
684        },
685    }?;
686    let prn = u8::try_from(candidate.1).ok()?;
687    GnssSatelliteId::new(candidate.0, prn).ok()
688}