use crate::frequencies::CarrierBand;
use crate::validate::{self, FieldError};
use crate::{GnssSatelliteId, GnssSystem, Wgs84Geodetic};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NmeaTime {
pub hour: u8,
pub minute: u8,
pub second: u8,
pub nanos: u32,
pub decimals: u8,
}
impl NmeaTime {
pub fn parse(token: &str) -> Result<Self, FieldError> {
let token = token.trim();
if token.is_empty() {
return Err(FieldError::Missing { field: "nmea time" });
}
let (whole, frac) = token.split_once('.').unwrap_or((token, ""));
if whole.len() != 6 || !whole.bytes().all(|b| b.is_ascii_digit()) {
return Err(FieldError::IntParse {
field: "nmea time",
value: token.to_string(),
});
}
if frac.len() > 9 || !frac.bytes().all(|b| b.is_ascii_digit()) {
return Err(FieldError::IntParse {
field: "nmea time fraction",
value: token.to_string(),
});
}
let hour = whole[0..2]
.parse::<u8>()
.map_err(|_| FieldError::IntParse {
field: "nmea time hour",
value: token.to_string(),
})?;
let minute = whole[2..4]
.parse::<u8>()
.map_err(|_| FieldError::IntParse {
field: "nmea time minute",
value: token.to_string(),
})?;
let second = whole[4..6]
.parse::<u8>()
.map_err(|_| FieldError::IntParse {
field: "nmea time second",
value: token.to_string(),
})?;
if hour > 23 || minute > 59 || second > 60 {
return Err(FieldError::InvalidCivilTime {
field: "nmea time",
hour: i64::from(hour),
minute: i64::from(minute),
second: f64::from(second),
});
}
let decimals = frac.len() as u8;
let frac_value = if frac.is_empty() {
0
} else {
frac.parse::<u32>().map_err(|_| FieldError::IntParse {
field: "nmea time fraction",
value: token.to_string(),
})?
};
let nanos = frac_value * 10_u32.pow(9 - u32::from(decimals));
Ok(Self {
hour,
minute,
second,
nanos,
decimals,
})
}
pub fn key(self) -> (u8, u8, u8, u32) {
(self.hour, self.minute, self.second, self.nanos)
}
pub fn from_seconds_of_day_floor_centis(seconds: f64) -> Result<Self, crate::nmea::NmeaError> {
if !seconds.is_finite() || !(0.0..86_400.0).contains(&seconds) {
return Err(crate::nmea::NmeaError::InvalidInput {
field: "time",
reason: "must be finite and in [0, 86400)",
});
}
let whole = seconds.floor() as u32;
let fractional = (seconds - f64::from(whole)).clamp(0.0, 1.0);
let centis = (Duration::from_secs_f64(fractional).as_nanos() / 10_000_000).min(99) as u32;
Ok(Self {
hour: (whole / 3600) as u8,
minute: ((whole % 3600) / 60) as u8,
second: (whole % 60) as u8,
nanos: centis * 10_000_000,
decimals: 2,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NmeaCoordinate {
pub degrees: u16,
pub minutes_scaled: u64,
pub decimals: u8,
pub negative: bool,
}
impl NmeaCoordinate {
pub fn parse(value: &str, hemisphere: &str, is_latitude: bool) -> Result<Self, FieldError> {
let value = value.trim();
let hemisphere = hemisphere.trim();
if value.is_empty() || hemisphere.is_empty() {
return Err(FieldError::Missing {
field: if is_latitude { "latitude" } else { "longitude" },
});
}
let (negative, valid_hemisphere) = match hemisphere {
"N" => (false, is_latitude),
"S" => (true, is_latitude),
"E" => (false, !is_latitude),
"W" => (true, !is_latitude),
_ => (false, false),
};
if !valid_hemisphere {
return Err(FieldError::OutOfRange {
field: "hemisphere",
min: 0.0,
max: 0.0,
upper_inclusive: true,
});
}
let degree_digits = if is_latitude { 2 } else { 3 };
if value.len() < degree_digits + 2
|| !value[..degree_digits + 2]
.bytes()
.all(|b| b.is_ascii_digit())
{
return Err(FieldError::FloatParse {
field: if is_latitude { "latitude" } else { "longitude" },
value: value.to_string(),
});
}
let degrees = value[..degree_digits]
.parse::<u16>()
.map_err(|_| FieldError::IntParse {
field: "coordinate degrees",
value: value.to_string(),
})?;
let minute_token = &value[degree_digits..];
let (whole_minutes, minute_frac) =
minute_token.split_once('.').unwrap_or((minute_token, ""));
if whole_minutes.len() != 2
|| !whole_minutes.bytes().all(|b| b.is_ascii_digit())
|| minute_frac.len() > 9
|| !minute_frac.bytes().all(|b| b.is_ascii_digit())
{
return Err(FieldError::FloatParse {
field: "coordinate minutes",
value: value.to_string(),
});
}
let decimals = minute_frac.len() as u8;
let scale = 10_u64.pow(u32::from(decimals));
let minutes_whole = whole_minutes
.parse::<u64>()
.map_err(|_| FieldError::IntParse {
field: "coordinate minutes",
value: value.to_string(),
})?;
let frac_scaled = if minute_frac.is_empty() {
0
} else {
minute_frac
.parse::<u64>()
.map_err(|_| FieldError::IntParse {
field: "coordinate minute fraction",
value: value.to_string(),
})?
};
let minutes_scaled = minutes_whole * scale + frac_scaled;
let degree_max = if is_latitude { 90 } else { 180 };
if degrees > degree_max
|| minutes_whole > 59
|| (degrees == degree_max && minutes_scaled != 0)
{
return Err(FieldError::OutOfRange {
field: if is_latitude { "latitude" } else { "longitude" },
min: 0.0,
max: f64::from(degree_max),
upper_inclusive: true,
});
}
Ok(Self {
degrees,
minutes_scaled,
decimals,
negative,
})
}
pub fn from_degrees(
degrees: f64,
is_latitude: bool,
decimals: u8,
) -> Result<Self, crate::nmea::NmeaError> {
if !degrees.is_finite() || decimals > 9 {
return Err(crate::nmea::NmeaError::InvalidInput {
field: "coordinate",
reason: "must be finite with at most 9 decimals",
});
}
let max = if is_latitude { 90.0 } else { 180.0 };
if degrees.abs() > max {
return Err(crate::nmea::NmeaError::InvalidInput {
field: "coordinate",
reason: "out of range",
});
}
let negative = degrees.is_sign_negative();
let abs = degrees.abs();
let mut whole_degrees = abs.floor() as u16;
let scale = 10_u64.pow(u32::from(decimals));
let minutes = (abs - f64::from(whole_degrees)) * 60.0;
let mut minutes_scaled = round_half_away_from_zero(minutes * scale as f64) as u64;
if minutes_scaled >= 60 * scale {
whole_degrees += 1;
minutes_scaled -= 60 * scale;
}
if f64::from(whole_degrees) > max {
return Err(crate::nmea::NmeaError::InvalidInput {
field: "coordinate",
reason: "rounding exceeded coordinate bound",
});
}
Ok(Self {
degrees: whole_degrees,
minutes_scaled,
decimals,
negative,
})
}
pub fn degrees_f64(&self) -> f64 {
let sign = if self.negative { -1.0 } else { 1.0 };
let scale = 10_f64.powi(i32::from(self.decimals));
sign * (f64::from(self.degrees) + (self.minutes_scaled as f64 / scale) / 60.0)
}
pub fn radians(&self) -> f64 {
self.degrees_f64().to_radians()
}
}
fn round_half_away_from_zero(value: f64) -> i64 {
if value >= 0.0 {
(value + 0.5).floor() as i64
} else {
(value - 0.5).ceil() as i64
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NmeaDate {
pub year: u16,
pub month: u8,
pub day: u8,
}
impl NmeaDate {
pub fn parse_rmc(token: &str) -> Result<Self, FieldError> {
let token = token.trim();
if token.len() != 6 || !token.bytes().all(|b| b.is_ascii_digit()) {
return Err(FieldError::IntParse {
field: "nmea date",
value: token.to_string(),
});
}
let day = parse_u8(&token[0..2], "nmea date day")?;
let month = parse_u8(&token[2..4], "nmea date month")?;
let yy = parse_u8(&token[4..6], "nmea date year")?;
let year = if yy >= 80 {
1900 + u16::from(yy)
} else {
2000 + u16::from(yy)
};
Self::new(year, month, day)
}
pub fn new(year: u16, month: u8, day: u8) -> Result<Self, FieldError> {
let max_day = crate::astro::time::civil::days_in_month(i64::from(year), i64::from(month));
if max_day == 0 || day == 0 || i64::from(day) > max_day {
return Err(FieldError::InvalidCivilDate {
field: "nmea date",
year: i64::from(year),
month: i64::from(month),
day: i64::from(day),
});
}
Ok(Self { year, month, day })
}
pub fn next_day(self) -> Self {
let max_day =
crate::astro::time::civil::days_in_month(i64::from(self.year), i64::from(self.month))
as u8;
if self.day < max_day {
Self {
day: self.day + 1,
..self
}
} else if self.month < 12 {
Self {
month: self.month + 1,
day: 1,
..self
}
} else {
Self {
year: self.year + 1,
month: 1,
day: 1,
}
}
}
}
fn parse_u8(token: &str, field: &'static str) -> Result<u8, FieldError> {
token.parse::<u8>().map_err(|_| FieldError::IntParse {
field,
value: token.to_string(),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NmeaTalker {
System(GnssSystem),
Combined,
Other([u8; 2]),
}
impl NmeaTalker {
pub fn parse(token: &str) -> Self {
match token.as_bytes() {
b"GP" => Self::System(GnssSystem::Gps),
b"GL" => Self::System(GnssSystem::Glonass),
b"GA" => Self::System(GnssSystem::Galileo),
b"GB" | b"BD" => Self::System(GnssSystem::BeiDou),
b"GQ" | b"QZ" => Self::System(GnssSystem::Qzss),
b"GI" => Self::System(GnssSystem::Navic),
b"GN" => Self::Combined,
[a, b] => Self::Other([*a, *b]),
_ => Self::Other([b'?', b'?']),
}
}
pub fn code(self) -> Result<[u8; 2], crate::nmea::NmeaError> {
match self {
Self::System(GnssSystem::Gps) | Self::System(GnssSystem::Sbas) => Ok(*b"GP"),
Self::System(GnssSystem::Glonass) => Ok(*b"GL"),
Self::System(GnssSystem::Galileo) => Ok(*b"GA"),
Self::System(GnssSystem::BeiDou) => Ok(*b"GB"),
Self::System(GnssSystem::Qzss) => Ok(*b"GQ"),
Self::System(GnssSystem::Navic) => Ok(*b"GI"),
Self::Combined => Ok(*b"GN"),
Self::Other(raw) if raw.iter().all(u8::is_ascii) => Ok(raw),
Self::Other(_) => Err(crate::nmea::NmeaError::InvalidInput {
field: "talker",
reason: "must be ASCII",
}),
}
}
pub fn system(self) -> Option<GnssSystem> {
match self {
Self::System(system) => Some(system),
Self::Combined | Self::Other(_) => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GgaQuality {
Invalid,
GpsSps,
Differential,
Pps,
RtkFixed,
RtkFloat,
Estimated,
Manual,
Simulator,
Other(u8),
}
impl GgaQuality {
pub fn parse(token: &str) -> Result<Self, FieldError> {
let value = validate::strict_int::<u8>(token, "gga quality")?;
Ok(match value {
0 => Self::Invalid,
1 => Self::GpsSps,
2 => Self::Differential,
3 => Self::Pps,
4 => Self::RtkFixed,
5 => Self::RtkFloat,
6 => Self::Estimated,
7 => Self::Manual,
8 => Self::Simulator,
other => Self::Other(other),
})
}
pub fn value(self) -> u8 {
match self {
Self::Invalid => 0,
Self::GpsSps => 1,
Self::Differential => 2,
Self::Pps => 3,
Self::RtkFixed => 4,
Self::RtkFloat => 5,
Self::Estimated => 6,
Self::Manual => 7,
Self::Simulator => 8,
Self::Other(value) => value,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Gga {
pub time: Option<NmeaTime>,
pub latitude: Option<NmeaCoordinate>,
pub longitude: Option<NmeaCoordinate>,
pub quality: Option<GgaQuality>,
pub satellites_used: Option<u8>,
pub hdop: Option<f64>,
pub altitude_msl_m: Option<f64>,
pub geoid_separation_m: Option<f64>,
pub differential_age_s: Option<f64>,
pub differential_station_id: Option<u16>,
}
impl Gga {
pub fn vrs_position(
position: Wgs84Geodetic,
time: NmeaTime,
quality: GgaQuality,
satellites_used: u8,
hdop: f64,
coordinate_decimals: u8,
) -> Result<Self, crate::nmea::NmeaError> {
if !hdop.is_finite() || hdop < 0.0 {
return Err(crate::nmea::NmeaError::InvalidInput {
field: "hdop",
reason: "must be finite and non-negative",
});
}
Ok(Self {
time: Some(time),
latitude: Some(NmeaCoordinate::from_degrees(
position.lat_rad.to_degrees(),
true,
coordinate_decimals,
)?),
longitude: Some(NmeaCoordinate::from_degrees(
position.lon_rad.to_degrees(),
false,
coordinate_decimals,
)?),
quality: Some(quality),
satellites_used: Some(satellites_used),
hdop: Some(hdop),
altitude_msl_m: Some(position.height_m),
geoid_separation_m: Some(0.0),
differential_age_s: None,
differential_station_id: None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NmeaSatNumber {
pub raw: u16,
pub resolved: Option<GnssSatelliteId>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NmeaSignalId {
pub system: Option<GnssSystem>,
pub id: u8,
}
impl NmeaSignalId {
pub fn carrier_band(&self) -> Option<CarrierBand> {
let system = self.system?;
match system {
GnssSystem::Gps | GnssSystem::Sbas => match self.id {
1..=3 => Some(CarrierBand::L1),
4..=6 => Some(CarrierBand::L2),
7 | 8 => Some(CarrierBand::L5),
_ => None,
},
GnssSystem::Glonass => match self.id {
1 | 2 => Some(CarrierBand::G1),
3 | 4 => Some(CarrierBand::G2),
_ => None,
},
GnssSystem::Galileo => match self.id {
1 => Some(CarrierBand::E5a),
2 => Some(CarrierBand::E5b),
3 => Some(CarrierBand::E5),
4 | 5 => Some(CarrierBand::E6),
6 | 7 => Some(CarrierBand::E1),
_ => None,
},
GnssSystem::BeiDou => match self.id {
1 | 2 => Some(CarrierBand::B1i),
3 | 4 => Some(CarrierBand::B1c),
5 => Some(CarrierBand::B2a),
6 => Some(CarrierBand::B2b),
7 => Some(CarrierBand::B2),
8 | 9 => Some(CarrierBand::B3i),
_ => None,
},
GnssSystem::Qzss => match self.id {
1..=4 => Some(CarrierBand::L1),
5 | 6 => Some(CarrierBand::L2),
7 | 8 => Some(CarrierBand::L5),
_ => None,
},
GnssSystem::Navic => match self.id {
1 | 3 => Some(CarrierBand::L5),
_ => None,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RmcStatus {
Valid,
Warning,
Other(char),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GsaSelectionMode {
Manual,
Automatic,
Other(char),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GsaFixMode {
None,
TwoD,
ThreeD,
Other(u8),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Rmc {
pub time: Option<NmeaTime>,
pub status: Option<RmcStatus>,
pub latitude: Option<NmeaCoordinate>,
pub longitude: Option<NmeaCoordinate>,
pub speed_over_ground_kn: Option<f64>,
pub course_over_ground_deg: Option<f64>,
pub date: Option<NmeaDate>,
pub magnetic_variation_deg: Option<f64>,
pub faa_mode: Option<char>,
pub navigational_status: Option<char>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Gsa {
pub selection_mode: Option<GsaSelectionMode>,
pub fix_mode: Option<GsaFixMode>,
pub satellites: Vec<NmeaSatNumber>,
pub pdop: Option<f64>,
pub hdop: Option<f64>,
pub vdop: Option<f64>,
pub system_id: Option<u8>,
pub system: Option<GnssSystem>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct GsvSatellite {
pub sat_number: Option<NmeaSatNumber>,
pub elevation_deg: Option<i16>,
pub azimuth_deg: Option<u16>,
pub cn0_db_hz: Option<u8>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Gsv {
pub total_messages: u8,
pub message_number: u8,
pub satellites_in_view: Option<u16>,
pub satellites: Vec<GsvSatellite>,
pub signal: Option<NmeaSignalId>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Gst {
pub time: Option<NmeaTime>,
pub rms_range_residual_m: Option<f64>,
pub semi_major_error_m: Option<f64>,
pub semi_minor_error_m: Option<f64>,
pub orientation_deg: Option<f64>,
pub latitude_sigma_m: Option<f64>,
pub longitude_sigma_m: Option<f64>,
pub altitude_sigma_m: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Vtg {
pub course_true_deg: Option<f64>,
pub course_magnetic_deg: Option<f64>,
pub speed_kn: Option<f64>,
pub speed_kmh: Option<f64>,
pub faa_mode: Option<char>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Gll {
pub latitude: Option<NmeaCoordinate>,
pub longitude: Option<NmeaCoordinate>,
pub time: Option<NmeaTime>,
pub status: Option<RmcStatus>,
pub faa_mode: Option<char>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Zda {
pub time: Option<NmeaTime>,
pub date: Option<NmeaDate>,
pub local_zone_hours: Option<i8>,
pub local_zone_minutes: Option<u8>,
}
pub(crate) fn resolve_sat_number(context: Option<GnssSystem>, raw: u16) -> Option<GnssSatelliteId> {
let candidate = match context {
Some(GnssSystem::Gps) => match raw {
1..=32 => Some((GnssSystem::Gps, raw)),
33..=64 => Some((GnssSystem::Sbas, raw - 13)),
_ => None,
},
Some(GnssSystem::Glonass) => match raw {
65..=99 => Some((GnssSystem::Glonass, raw - 64)),
1..=35 => Some((GnssSystem::Glonass, raw)),
_ => None,
},
Some(GnssSystem::Galileo) => match raw {
1..=36 => Some((GnssSystem::Galileo, raw)),
_ => None,
},
Some(GnssSystem::BeiDou) => match raw {
1..=64 => Some((GnssSystem::BeiDou, raw)),
_ => None,
},
Some(GnssSystem::Qzss) => match raw {
1..=10 => Some((GnssSystem::Qzss, raw)),
193..=202 => Some((GnssSystem::Qzss, raw - 192)),
_ => None,
},
Some(GnssSystem::Navic) => match raw {
1..=15 => Some((GnssSystem::Navic, raw)),
_ => None,
},
Some(GnssSystem::Sbas) => match raw {
33..=64 => Some((GnssSystem::Sbas, raw - 13)),
120..=158 => Some((GnssSystem::Sbas, raw - 100)),
_ => None,
},
None => match raw {
1..=32 => Some((GnssSystem::Gps, raw)),
33..=64 => Some((GnssSystem::Sbas, raw - 13)),
65..=99 => Some((GnssSystem::Glonass, raw - 64)),
193..=202 => Some((GnssSystem::Qzss, raw - 192)),
_ => None,
},
}?;
let prn = u8::try_from(candidate.1).ok()?;
GnssSatelliteId::new(candidate.0, prn).ok()
}