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