Skip to main content

nmea_kit/ais/messages/
long_range.rs

1//! AIS Type 27 — Long Range Position Report.
2//!
3//! Compact 96-bit position report from satellite AIS transponders (Class D) and
4//! vessels with long-range AIS. Position precision is 1/10° (vs 1/10000' for Type 1),
5//! suited for the reduced bandwidth of satellite uplinks.
6
7use crate::ais::armor::{extract_i32, extract_u32};
8
9use super::common::NavigationStatus;
10
11/// AIS Type 27 — Long Range Position Report.
12///
13/// ITU-R M.1371 bit layout (96 bits):
14/// - bits  0–5:   message type (= 27)
15/// - bits  6–7:   repeat indicator
16/// - bits  8–37:  MMSI (30 bits)
17/// - bit   38:    position accuracy
18/// - bit   39:    RAIM flag
19/// - bits 40–43:  navigational status (4 bits)
20/// - bits 44–61:  longitude in 1/10° (18 bits, signed)
21/// - bits 62–78:  latitude in 1/10° (17 bits, signed)
22/// - bits 79–84:  SOG in knots (6 bits, integer, 63 = not available)
23/// - bits 85–93:  COG in degrees (9 bits, integer, 511 = not available)
24/// - bit   94:    GNSS position status
25#[derive(Debug, Clone, PartialEq)]
26pub struct LongRangePosition {
27    pub mmsi: u32,
28    /// High position accuracy (DGPS / differential fix).
29    pub position_accuracy: bool,
30    /// RAIM (Receiver Autonomous Integrity Monitoring) flag.
31    pub raim: bool,
32    /// Navigational status. `None` if not defined.
33    pub nav_status: Option<NavigationStatus>,
34    /// Longitude in decimal degrees WGS-84 (1/10° precision). `None` if sentinel (181°).
35    pub longitude: Option<f64>,
36    /// Latitude in decimal degrees WGS-84 (1/10° precision). `None` if sentinel (91°).
37    pub latitude: Option<f64>,
38    /// Speed over ground in integer knots. `None` if not available (raw = 63).
39    pub sog: Option<u8>,
40    /// Course over ground in integer degrees (0–359). `None` if not available (raw = 511).
41    pub cog: Option<u16>,
42    /// `true` = current GNSS position; `false` = not GNSS position (4 h old or more).
43    pub gnss_position_status: bool,
44}
45
46impl LongRangePosition {
47    /// Decode a Type 27 Long Range Position Report from AIS bits.
48    pub(crate) fn decode(bits: &[u8]) -> Option<Self> {
49        if bits.len() < 96 {
50            return None;
51        }
52
53        let mmsi = extract_u32(bits, 8, 30)?;
54        let accuracy = extract_u32(bits, 38, 1)? == 1;
55        let raim = extract_u32(bits, 39, 1)? == 1;
56        let nav_raw = extract_u32(bits, 40, 4)? as u8;
57        let lon_raw = extract_i32(bits, 44, 18)?;
58        let lat_raw = extract_i32(bits, 62, 17)?;
59        let sog_raw = extract_u32(bits, 79, 6)? as u8;
60        let cog_raw = extract_u32(bits, 85, 9)? as u16;
61        let gnss = extract_u32(bits, 94, 1)? == 1;
62
63        // Type 27 uses 1/10° precision (not 1/10000 minutes)
64        let longitude = {
65            let deg = f64::from(lon_raw) / 10.0;
66            if !(-180.0..=180.0).contains(&deg) {
67                None
68            } else {
69                Some(deg)
70            }
71        };
72        let latitude = {
73            let deg = f64::from(lat_raw) / 10.0;
74            if !(-90.0..=90.0).contains(&deg) {
75                None
76            } else {
77                Some(deg)
78            }
79        };
80
81        Some(Self {
82            mmsi,
83            position_accuracy: accuracy,
84            raim,
85            nav_status: Some(NavigationStatus::from(nav_raw)),
86            longitude,
87            latitude,
88            sog: if sog_raw == 63 { None } else { Some(sog_raw) },
89            cog: if cog_raw == 511 { None } else { Some(cog_raw) },
90            gnss_position_status: gnss,
91        })
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use crate::ais::{AisMessage, AisParser};
98    use crate::parse_frame;
99
100    #[test]
101    fn long_range_gpsd() {
102        let mut parser = AisParser::new();
103        // Type 27 from gpsd ais.nmea fixture
104        let frame = parse_frame("!AIVDM,1,1,,A,KCQ9r=hrFUnH7P00,0*41").expect("valid");
105        let msg = parser.decode(&frame).expect("decoded");
106        if let AisMessage::LongRangePosition(pos) = msg {
107            assert!(pos.mmsi > 0, "MMSI must be set");
108            if let (Some(lat), Some(lon)) = (pos.latitude, pos.longitude) {
109                assert!((-90.0..=90.0).contains(&lat), "lat out of range: {lat}");
110                assert!((-180.0..=180.0).contains(&lon), "lon out of range: {lon}");
111            }
112        } else {
113            panic!("expected LongRangePosition, got {msg:?}");
114        }
115    }
116
117    #[test]
118    fn long_range_position_sentinel() {
119        let mut parser = AisParser::new();
120        let frame = parse_frame("!AIVDM,1,1,,A,KCQ9r=hrFUnH7P00,0*41").expect("valid");
121        let msg = parser.decode(&frame).expect("decoded");
122        if let AisMessage::LongRangePosition(pos) = msg {
123            if let Some(lat) = pos.latitude {
124                assert!(
125                    (-90.0..=90.0).contains(&lat),
126                    "lat sentinel not filtered: {lat}"
127                );
128            }
129            if let Some(lon) = pos.longitude {
130                assert!(
131                    (-180.0..=180.0).contains(&lon),
132                    "lon sentinel not filtered: {lon}"
133                );
134            }
135        } else {
136            panic!("expected LongRangePosition, got {msg:?}");
137        }
138    }
139}