Skip to main content

aprs_decode/types/
lonlat.rs

1use crate::error::AprsError;
2use crate::util::parse_bytes;
3use std::ops::{Deref, RangeInclusive};
4
5/// Granularity of a parsed position coordinate, inferred from trailing-space ambiguity.
6#[derive(Debug, Copy, Clone, PartialOrd, PartialEq, Ord, Eq, Default)]
7#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
8pub enum Precision {
9    TenDegree,
10    OneDegree,
11    TenMinute,
12    OneMinute,
13    TenthMinute,
14    #[default]
15    HundredthMinute,
16}
17
18impl Precision {
19    /// Width of the precision cell in degrees.
20    pub fn width(self) -> f64 {
21        match self {
22            Precision::HundredthMinute => 1.0 / 6000.0,
23            Precision::TenthMinute => 1.0 / 600.0,
24            Precision::OneMinute => 1.0 / 60.0,
25            Precision::TenMinute => 1.0 / 6.0,
26            Precision::OneDegree => 1.0,
27            Precision::TenDegree => 10.0,
28        }
29    }
30
31    pub fn range(self, center: f64) -> RangeInclusive<f64> {
32        let w = self.width();
33        (center - w / 2.0)..=(center + w / 2.0)
34    }
35
36    fn num_blank_digits(self) -> u8 {
37        match self {
38            Precision::HundredthMinute => 0,
39            Precision::TenthMinute => 1,
40            Precision::OneMinute => 2,
41            Precision::TenMinute => 3,
42            Precision::OneDegree => 4,
43            Precision::TenDegree => 5,
44        }
45    }
46
47    fn from_blank_digits(blanks: u8) -> Option<Self> {
48        Some(match blanks {
49            0 => Precision::HundredthMinute,
50            1 => Precision::TenthMinute,
51            2 => Precision::OneMinute,
52            3 => Precision::TenMinute,
53            4 => Precision::OneDegree,
54            5 => Precision::TenDegree,
55            _ => return None,
56        })
57    }
58}
59
60
61/// APRS latitude value in decimal degrees (positive = North, negative = South).
62#[derive(Debug, Copy, Clone, PartialOrd, PartialEq, Default)]
63#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
64#[cfg_attr(feature = "serde", serde(transparent))]
65pub struct Latitude(f64);
66
67impl Deref for Latitude {
68    type Target = f64;
69    fn deref(&self) -> &f64 {
70        &self.0
71    }
72}
73
74impl Latitude {
75    pub fn new(value: f64) -> Option<Self> {
76        if value.is_nan() || !(-90.0..=90.0).contains(&value) {
77            None
78        } else {
79            Some(Self(value))
80        }
81    }
82
83    pub fn value(self) -> f64 {
84        self.0
85    }
86
87    /// Decompose into (degrees, whole minutes, hundredths-of-minute, is_north).
88    pub fn dmh(self) -> (u32, u32, u32, bool) {
89        let (is_north, v) = if self.0 >= 0.0 { (true, self.0) } else { (false, -self.0) };
90        let deg = v as u32;
91        let min = ((v - deg as f64) * 60.0) as u32;
92        let mut hdths = ((v - deg as f64 - min as f64 / 60.0) * 6000.0).round() as u32;
93        let mut min = min;
94        let mut deg = deg;
95        if hdths >= 100 { hdths = 0; min += 1; }
96        if min >= 60 { min = 0; deg += 1; }
97        (deg, min, hdths, is_north)
98    }
99
100    /// Parse 8-byte uncompressed latitude: `DDmm.mmN` or `DDmm.mmS`.
101    /// Trailing spaces encode ambiguity (reduced precision).
102    pub(crate) fn parse_uncompressed(b: &[u8]) -> Result<(Self, Precision), AprsError> {
103        if b.len() != 8 || b[4] != b'.' {
104            return Err(AprsError::InvalidLatitude { raw: b.to_vec() });
105        }
106        let is_north = match b[7] {
107            b'N' => true,
108            b'S' => false,
109            _ => return Err(AprsError::InvalidLatitude { raw: b.to_vec() }),
110        };
111        let (deg, b0) = parse_pair_ambiguous(&[b[0], b[1]], false)
112            .ok_or_else(|| AprsError::InvalidLatitude { raw: b.to_vec() })?;
113        let (min, b1) = parse_pair_ambiguous(&[b[2], b[3]], b0 > 0)
114            .ok_or_else(|| AprsError::InvalidLatitude { raw: b.to_vec() })?;
115        let (hdths, b2) = parse_pair_ambiguous(&[b[5], b[6]], b1 > 0)
116            .ok_or_else(|| AprsError::InvalidLatitude { raw: b.to_vec() })?;
117        let blanks = b0 + b1 + b2;
118        let precision = Precision::from_blank_digits(blanks)
119            .ok_or_else(|| AprsError::InvalidLatitude { raw: b.to_vec() })?;
120        let value = deg as f64 + min as f64 / 60.0 + hdths as f64 / 6000.0;
121        let value = if is_north { value } else { -value };
122        let lat = Latitude::new(value)
123            .ok_or_else(|| AprsError::InvalidLatitude { raw: b.to_vec() })?;
124        Ok((lat, precision))
125    }
126
127    /// Parse 4-byte base-91 compressed latitude.
128    pub(crate) fn parse_compressed(b: &[u8]) -> Result<Self, AprsError> {
129        let enc = base91_decode4(b)
130            .ok_or_else(|| AprsError::InvalidLatitude { raw: b.to_vec() })?;
131        let value = 90.0 - enc / 380926.0;
132        Latitude::new(value).ok_or_else(|| AprsError::InvalidLatitude { raw: b.to_vec() })
133    }
134
135    pub(crate) fn encode_uncompressed(&self, out: &mut Vec<u8>, precision: Precision) {
136        let (deg, min, hdths, is_north) = self.dmh();
137        let dir = if is_north { b'N' } else { b'S' };
138        let blanks = precision.num_blank_digits() as usize;
139        // digits: d0 d1 m0 m1 . h0 h1  (6 significant digits, then N/S)
140        let mut digits = [0u8; 6];
141        let _ = write_digits_6(&mut digits, deg, min, hdths);
142        let end = 6usize.saturating_sub(blanks);
143        let mut buf = [b' '; 6];
144        buf[..end].copy_from_slice(&digits[..end]);
145        out.extend_from_slice(&buf[..4]);
146        out.push(b'.');
147        out.extend_from_slice(&buf[4..6]);
148        out.push(dir);
149    }
150
151    pub(crate) fn encode_compressed(&self, out: &mut Vec<u8>) {
152        let value = (90.0 - self.0) * 380926.0;
153        base91_encode4(value.round() as u32, out);
154    }
155}
156
157/// APRS longitude value in decimal degrees (positive = East, negative = West).
158#[derive(Debug, Copy, Clone, PartialOrd, PartialEq, Default)]
159#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
160#[cfg_attr(feature = "serde", serde(transparent))]
161pub struct Longitude(f64);
162
163impl Deref for Longitude {
164    type Target = f64;
165    fn deref(&self) -> &f64 {
166        &self.0
167    }
168}
169
170impl Longitude {
171    pub fn new(value: f64) -> Option<Self> {
172        if value.is_nan() || !(-180.0..=180.0).contains(&value) {
173            None
174        } else {
175            Some(Self(value))
176        }
177    }
178
179    pub fn value(self) -> f64 {
180        self.0
181    }
182
183    /// Decompose into (degrees, whole minutes, hundredths-of-minute, is_east).
184    pub fn dmh(self) -> (u32, u32, u32, bool) {
185        let (is_east, v) = if self.0 >= 0.0 { (true, self.0) } else { (false, -self.0) };
186        let deg = v as u32;
187        let min = ((v - deg as f64) * 60.0) as u32;
188        let mut hdths = ((v - deg as f64 - min as f64 / 60.0) * 6000.0).round() as u32;
189        let mut min = min;
190        let mut deg = deg;
191        if hdths >= 100 { hdths = 0; min += 1; }
192        if min >= 60 { min = 0; deg += 1; }
193        (deg, min, hdths, is_east)
194    }
195
196    /// Parse 9-byte uncompressed longitude: `DDDmm.mmE` or `DDDmm.mmW`.
197    /// Uses the latitude's precision to mask low-order digits.
198    pub(crate) fn parse_uncompressed(b: &[u8], precision: Precision) -> Result<Self, AprsError> {
199        if b.len() != 9 || b[5] != b'.' {
200            return Err(AprsError::InvalidLongitude { raw: b.to_vec() });
201        }
202        let is_east = match b[8] {
203            b'E' => true,
204            b'W' => false,
205            _ => return Err(AprsError::InvalidLongitude { raw: b.to_vec() }),
206        };
207        // Assemble 7 digit positions: deg(3) min(2) frac(2)
208        let mut digits = [0u8; 7];
209        digits[0..5].copy_from_slice(&b[0..5]);
210        digits[5..7].copy_from_slice(&b[6..8]);
211        // Zero out low-order digits according to precision
212        let blanks = precision.num_blank_digits() as usize;
213        for d in digits.iter_mut().skip(7usize.saturating_sub(blanks)) {
214            *d = b'0';
215        }
216        let deg = parse_bytes::<u32>(&digits[0..3])
217            .ok_or_else(|| AprsError::InvalidLongitude { raw: b.to_vec() })?;
218        let min = parse_bytes::<u32>(&digits[3..5])
219            .ok_or_else(|| AprsError::InvalidLongitude { raw: b.to_vec() })?;
220        let hdths = parse_bytes::<u32>(&digits[5..7])
221            .ok_or_else(|| AprsError::InvalidLongitude { raw: b.to_vec() })?;
222        let value = deg as f64 + min as f64 / 60.0 + hdths as f64 / 6000.0;
223        let value = if is_east { value } else { -value };
224        Longitude::new(value).ok_or_else(|| AprsError::InvalidLongitude { raw: b.to_vec() })
225    }
226
227    /// Parse 4-byte base-91 compressed longitude.
228    pub(crate) fn parse_compressed(b: &[u8]) -> Result<Self, AprsError> {
229        let enc = base91_decode4(b)
230            .ok_or_else(|| AprsError::InvalidLongitude { raw: b.to_vec() })?;
231        let value = enc / 190463.0 - 180.0;
232        Longitude::new(value).ok_or_else(|| AprsError::InvalidLongitude { raw: b.to_vec() })
233    }
234
235    pub(crate) fn encode_uncompressed(&self, out: &mut Vec<u8>) {
236        let (deg, min, hdths, is_east) = self.dmh();
237        let dir = if is_east { b'E' } else { b'W' };
238        out.extend_from_slice(format!("{:03}{:02}.{:02}{}", deg, min, hdths, dir as char).as_bytes());
239    }
240
241    pub(crate) fn encode_compressed(&self, out: &mut Vec<u8>) {
242        let value = (180.0 + self.0) * 190463.0;
243        base91_encode4(value.round() as u32, out);
244    }
245}
246
247// --- base-91 helpers (APRS standard: char 33..=123) ---
248
249pub(crate) fn base91_decode4(b: &[u8]) -> Option<f64> {
250    if b.len() < 4 { return None; }
251    let mut val = 0.0f64;
252    for &byte in &b[..4] {
253        let d = byte.checked_sub(33)?;
254        if d > 90 { return None; }
255        val = val * 91.0 + d as f64;
256    }
257    Some(val)
258}
259
260pub(crate) fn base91_encode4(mut val: u32, out: &mut Vec<u8>) {
261    let mut buf = [33u8; 4]; // '!' = 33 = base-91 zero
262    for i in (0..4).rev() {
263        buf[i] = (val % 91) as u8 + 33;
264        val /= 91;
265    }
266    out.extend_from_slice(&buf);
267}
268
269pub(crate) fn base91_decode1(b: u8) -> Option<u8> {
270    b.checked_sub(33)
271}
272
273pub(crate) fn base91_encode1(v: u8) -> u8 {
274    v + 33
275}
276
277// --- internal helpers ---
278
279/// Parse a 2-byte pair allowing trailing spaces for ambiguity.
280/// Returns `(value, num_spaces_found)`.
281fn parse_pair_ambiguous(b: &[u8; 2], must_be_spaces: bool) -> Option<(u32, u8)> {
282    if must_be_spaces {
283        return if b == b"  " { Some((0, 2)) } else { None };
284    }
285    match (b[0], b[1]) {
286        (b' ', b' ') => Some((0, 2)),
287        (d, b' ') if d.is_ascii_digit() => Some(((d - b'0') as u32 * 10, 1)),
288        (d0, d1) if d0.is_ascii_digit() && d1.is_ascii_digit() => {
289            Some(((d0 - b'0') as u32 * 10 + (d1 - b'0') as u32, 0))
290        }
291        _ => None,
292    }
293}
294
295/// Format `(deg, min, hdths)` into a 6-byte ASCII digit array without the decimal point.
296fn write_digits_6(buf: &mut [u8; 6], deg: u32, min: u32, hdths: u32) -> Option<()> {
297    let s = format!("{:02}{:02}{:02}", deg, min, hdths);
298    if s.len() != 6 { return None; }
299    buf.copy_from_slice(s.as_bytes());
300    Some(())
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use approx::assert_relative_eq;
307
308    #[test]
309    fn lat_uncompressed_basic() {
310        let (lat, prec) = Latitude::parse_uncompressed(b"4903.50N").unwrap();
311        assert_relative_eq!(lat.value(), 49.05833333333333, epsilon = 1e-9);
312        assert_eq!(prec, Precision::HundredthMinute);
313    }
314
315    #[test]
316    fn lat_uncompressed_south() {
317        let (lat, _) = Latitude::parse_uncompressed(b"4903.50S").unwrap();
318        assert_relative_eq!(lat.value(), -49.05833333333333, epsilon = 1e-9);
319    }
320
321    #[test]
322    fn lat_ambiguity_one_tenth() {
323        let (lat, prec) = Latitude::parse_uncompressed(b"4903.5 N").unwrap();
324        assert_eq!(prec, Precision::TenthMinute);
325        assert_relative_eq!(lat.value(), 49.05833333333333, epsilon = 1e-4);
326    }
327
328    #[test]
329    fn lat_ambiguity_one_minute() {
330        let (lat, prec) = Latitude::parse_uncompressed(b"4903.  N").unwrap();
331        assert_eq!(prec, Precision::OneMinute);
332        assert_relative_eq!(lat.value(), 49.05, epsilon = 1e-4);
333    }
334
335    #[test]
336    fn lat_invalid_direction() {
337        assert!(Latitude::parse_uncompressed(b"4903.50W").is_err());
338    }
339
340    #[test]
341    fn lat_out_of_range() {
342        assert!(Latitude::new(90.1).is_none());
343        assert!(Latitude::new(-90.1).is_none());
344    }
345
346    #[test]
347    fn lon_uncompressed_east() {
348        let lon = Longitude::parse_uncompressed(b"07201.75E", Precision::default()).unwrap();
349        assert_relative_eq!(lon.value(), 72.02916666666667, epsilon = 1e-9);
350    }
351
352    #[test]
353    fn lon_uncompressed_west() {
354        let lon = Longitude::parse_uncompressed(b"07201.75W", Precision::default()).unwrap();
355        assert_relative_eq!(lon.value(), -72.02916666666667, epsilon = 1e-9);
356    }
357
358    #[test]
359    fn lon_invalid_direction() {
360        assert!(Longitude::parse_uncompressed(b"07201.75N", Precision::default()).is_err());
361    }
362
363    #[test]
364    fn lat_encode_round_trip() {
365        let (lat, prec) = Latitude::parse_uncompressed(b"4903.50N").unwrap();
366        let mut out = Vec::new();
367        lat.encode_uncompressed(&mut out, prec);
368        assert_eq!(out, b"4903.50N");
369    }
370
371    #[test]
372    fn lon_encode_round_trip() {
373        let lon = Longitude::parse_uncompressed(b"07201.75W", Precision::default()).unwrap();
374        let mut out = Vec::new();
375        lon.encode_uncompressed(&mut out);
376        assert_eq!(out, b"07201.75W");
377    }
378
379    #[test]
380    fn compressed_lat_round_trip() {
381        let original = Latitude::new(49.05833).unwrap();
382        let mut enc = Vec::new();
383        original.encode_compressed(&mut enc);
384        assert_eq!(enc.len(), 4);
385        let decoded = Latitude::parse_compressed(&enc).unwrap();
386        assert_relative_eq!(decoded.value(), original.value(), epsilon = 0.001);
387    }
388
389    #[test]
390    fn compressed_lon_round_trip() {
391        let original = Longitude::new(-72.029).unwrap();
392        let mut enc = Vec::new();
393        original.encode_compressed(&mut enc);
394        assert_eq!(enc.len(), 4);
395        let decoded = Longitude::parse_compressed(&enc).unwrap();
396        assert_relative_eq!(decoded.value(), original.value(), epsilon = 0.001);
397    }
398
399    #[test]
400    fn base91_decode4_known() {
401        // From aprs-parser-rs test: "#$%^" => 1532410.0
402        let val = base91_decode4(b"#$%^").unwrap();
403        assert_relative_eq!(val, 1532410.0, epsilon = 0.5);
404    }
405}