Skip to main content

kinavis_kernel/
gnss.rs

1//! Satellite fix, independent of the sentence it arrived in.
2//!
3//! Receivers report fixes in many formats (NMEA `RMC`/`GGA`, binary protocols,
4//! proprietary), all carrying the same facts: position, time, method, quality.
5//! [`GnssFix`] holds those facts; parsers translate into it and use cases read
6//! only it (anti-corruption layer).
7//!
8//! No vertical channel: a height without datum, vertical velocity and source
9//! would be incomplete.
10//!
11//! ```rust
12//! use kinavis_kernel::gnss::{Dop, FixType, GnssFix};
13//! use kinavis_kernel::time::{Civil, Instant, Utc};
14//! use kinavis_kernel::{Position, Speed, TrueCourse};
15//!
16//! let taken_at = Instant::<Utc>::from_civil(Civil::date(2026, 9, 11))?;
17//! let fix = GnssFix::builder(taken_at, "50°45.3'N 001°20.0'W".parse::<Position>()?)
18//!     .fix_type(FixType::Differential)
19//!     .course_over_ground(TrueCourse::new(272.5)?)
20//!     .speed_over_ground(Speed::from_knots(11.3)?)
21//!     .satellites(9)
22//!     .hdop(Dop::new(0.9)?)
23//!     .build();
24//!
25//! assert!(fix.quality().fix_type().is_position_fix());
26//! // HDOP times the nominal one-sigma range error for a differential fix.
27//! assert_eq!(format!("{:.2}", fix.horizontal_accuracy().unwrap().metres()), "0.90");
28//! # Ok::<(), kinavis_kernel::KernelError>(())
29//! ```
30
31use crate::angle::TrueCourse;
32use crate::error::{ensure_finite, KernelError, Result};
33use crate::observation::{ObservationStatus, Observed, Quality};
34use crate::position::Position;
35use crate::time::{Instant, Utc};
36use crate::units::{Distance, Speed};
37
38/// Fix method.
39///
40/// Follows the NMEA mode indicators. Downstream, [`FixType::is_position_fix`]
41/// distinguishes satellite-measured positions from carried-forward, manual or
42/// simulated ones.
43///
44/// `#[non_exhaustive]`; match with a wildcard arm.
45#[non_exhaustive]
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub enum FixType {
49    /// No fix; the position is the last known one.
50    None,
51    /// Autonomous single-receiver fix.
52    Autonomous,
53    /// Differential (DGPS, SBAS).
54    Differential,
55    /// Precise Positioning Service (military).
56    Precise,
57    /// RTK, integer ambiguity fixed.
58    RtkFixed,
59    /// RTK float.
60    RtkFloat,
61    /// Receiver dead reckoning bridging a coverage gap.
62    Estimated,
63    /// Manual input.
64    Manual,
65    /// Simulator.
66    Simulated,
67}
68
69impl FixType {
70    /// Whether the position was measured from satellites.
71    ///
72    /// `Estimated`, `Manual` and `Simulated` may be displayed but are not
73    /// fixes.
74    #[must_use]
75    pub const fn is_position_fix(self) -> bool {
76        matches!(
77            self,
78            Self::Autonomous | Self::Differential | Self::Precise | Self::RtkFixed | Self::RtkFloat
79        )
80    }
81
82    /// Nominal 1σ UERE for this fix type, m; `None` where undefined.
83    ///
84    /// Round figures from published performance standards, for converting DOP
85    /// to distance. A receiver's own accuracy estimate takes precedence.
86    const fn nominal_uere_metres(self) -> Option<f64> {
87        match self {
88            Self::Autonomous => Some(4.0),
89            Self::Differential => Some(1.0),
90            Self::Precise => Some(3.0),
91            Self::RtkFixed => Some(0.02),
92            Self::RtkFloat => Some(0.5),
93            Self::None | Self::Estimated | Self::Manual | Self::Simulated => None,
94        }
95    }
96}
97
98/// Dilution of precision: geometry factor from range error to position error.
99///
100/// Dimensionless, positive, finite; ~1 is good geometry, above ~6 poor.
101#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
102#[cfg_attr(
103    feature = "serde",
104    derive(serde::Serialize, serde::Deserialize),
105    serde(try_from = "f64", into = "f64")
106)]
107pub struct Dop(f64);
108
109impl Dop {
110    /// DOP from a value.
111    ///
112    /// # Errors
113    ///
114    /// [`KernelError::NotFinite`] for `NaN` or infinity;
115    /// [`KernelError::OutOfRange`] for zero or negative.
116    pub fn new(value: f64) -> Result<Self> {
117        ensure_finite("dilution of precision", value)?;
118        if value <= 0.0 {
119            return Err(KernelError::OutOfRange {
120                parameter: "dilution of precision",
121                value,
122                min: f64::MIN_POSITIVE,
123                max: f64::MAX,
124            });
125        }
126        Ok(Self(value))
127    }
128
129    /// Factor.
130    #[must_use]
131    pub const fn value(self) -> f64 {
132        self.0
133    }
134}
135
136impl TryFrom<f64> for Dop {
137    type Error = KernelError;
138
139    fn try_from(value: f64) -> Result<Self> {
140        Self::new(value)
141    }
142}
143
144impl From<Dop> for f64 {
145    fn from(dop: Dop) -> Self {
146        dop.0
147    }
148}
149
150/// Receiver-reported fix quality.
151#[derive(Debug, Clone, Copy, PartialEq)]
152#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
153pub struct GnssQuality {
154    fix_type: FixType,
155    satellites: Option<u8>,
156    hdop: Option<Dop>,
157    vdop: Option<Dop>,
158    pdop: Option<Dop>,
159}
160
161impl GnssQuality {
162    /// Fix method.
163    #[must_use]
164    pub const fn fix_type(&self) -> FixType {
165        self.fix_type
166    }
167
168    /// Satellites used, if reported.
169    #[must_use]
170    pub const fn satellites(&self) -> Option<u8> {
171        self.satellites
172    }
173
174    /// HDOP, if reported.
175    #[must_use]
176    pub const fn hdop(&self) -> Option<Dop> {
177        self.hdop
178    }
179
180    /// VDOP, if reported.
181    #[must_use]
182    pub const fn vdop(&self) -> Option<Dop> {
183        self.vdop
184    }
185
186    /// PDOP, if reported.
187    #[must_use]
188    pub const fn pdop(&self) -> Option<Dop> {
189        self.pdop
190    }
191}
192
193/// Satellite fix: position, time, method and other receiver data.
194///
195/// Built via [`GnssFix::builder`]: most fields are optional and filled
196/// differently by different sentences.
197#[derive(Debug, Clone, Copy, PartialEq)]
198#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
199pub struct GnssFix {
200    taken_at: Instant<Utc>,
201    position: Position,
202    course_over_ground: Option<TrueCourse>,
203    speed_over_ground: Option<Speed>,
204    quality: GnssQuality,
205}
206
207impl GnssFix {
208    /// Starts a fix from time and position, the two mandatory fields.
209    ///
210    /// With nothing else set it is an autonomous fix.
211    pub const fn builder(taken_at: Instant<Utc>, position: Position) -> GnssFixBuilder {
212        GnssFixBuilder {
213            fix: Self {
214                taken_at,
215                position,
216                course_over_ground: None,
217                speed_over_ground: None,
218                quality: GnssQuality {
219                    fix_type: FixType::Autonomous,
220                    satellites: None,
221                    hdop: None,
222                    vdop: None,
223                    pdop: None,
224                },
225            },
226        }
227    }
228
229    /// Fix time, receiver clock, UTC.
230    #[must_use]
231    pub const fn taken_at(&self) -> Instant<Utc> {
232        self.taken_at
233    }
234
235    /// Position.
236    #[must_use]
237    pub const fn position(&self) -> Position {
238        self.position
239    }
240
241    /// Course over ground, if reported.
242    ///
243    /// Receivers omit it when stationary, where it is noise.
244    #[must_use]
245    pub const fn course_over_ground(&self) -> Option<TrueCourse> {
246        self.course_over_ground
247    }
248
249    /// Speed over ground, if reported.
250    #[must_use]
251    pub const fn speed_over_ground(&self) -> Option<Speed> {
252        self.speed_over_ground
253    }
254
255    /// Quality.
256    #[must_use]
257    pub const fn quality(&self) -> &GnssQuality {
258        &self.quality
259    }
260
261    /// Estimated 1σ horizontal error: HDOP × nominal UERE for the fix type.
262    ///
263    /// `None` without HDOP or for non-satellite positions. A nominal estimate,
264    /// not a measurement; see [`GnssFix::horizontal_accuracy_with`] to supply a
265    /// UERE.
266    #[must_use]
267    pub fn horizontal_accuracy(&self) -> Option<Distance> {
268        let uere = self.quality.fix_type.nominal_uere_metres()?;
269        // The UERE table holds finite constants; cannot fail.
270        self.horizontal_accuracy_with(Distance::from_metres(uere).ok()?)
271    }
272
273    /// 1σ horizontal error for a given 1σ range error: `HDOP × UERE`.
274    ///
275    /// `None` without HDOP.
276    #[must_use]
277    pub fn horizontal_accuracy_with(&self, uere: Distance) -> Option<Distance> {
278        self.quality.hdop.map(|hdop| uere * hdop.value())
279    }
280
281    /// Position as an [`Observed`] value, for source-agnostic use cases.
282    ///
283    /// Status by fix type: satellite fix `Valid`; receiver estimate, manual or
284    /// simulated `Suspect`; no fix `Invalid`. Uncertainty is
285    /// [`GnssFix::horizontal_accuracy`] where available.
286    #[must_use]
287    pub fn observed_position(&self) -> Observed<Position, Distance> {
288        let status = match self.quality.fix_type {
289            FixType::None => ObservationStatus::Invalid,
290            FixType::Estimated | FixType::Manual | FixType::Simulated => ObservationStatus::Suspect,
291            _ => ObservationStatus::Valid,
292        };
293        let mut quality = Quality::new(status);
294        if let Some(sigma) = self.horizontal_accuracy() {
295            quality = quality.with_sigma(sigma);
296        }
297        Observed::new(self.position, self.taken_at, quality)
298    }
299}
300
301/// Builder for the optional parts of a [`GnssFix`].
302///
303/// Setters take already-validated types, so [`GnssFixBuilder::build`] cannot
304/// fail. The only builder in the kernel: many optional fields, filled per
305/// sentence.
306#[derive(Debug, Clone, Copy)]
307#[must_use = "a builder does nothing until `build` is called"]
308pub struct GnssFixBuilder {
309    fix: GnssFix,
310}
311
312impl GnssFixBuilder {
313    /// Fix method; default `Autonomous`.
314    pub const fn fix_type(mut self, fix_type: FixType) -> Self {
315        self.fix.quality.fix_type = fix_type;
316        self
317    }
318
319    /// Course over ground.
320    pub const fn course_over_ground(mut self, course: TrueCourse) -> Self {
321        self.fix.course_over_ground = Some(course);
322        self
323    }
324
325    /// Speed over ground.
326    pub const fn speed_over_ground(mut self, speed: Speed) -> Self {
327        self.fix.speed_over_ground = Some(speed);
328        self
329    }
330
331    /// Satellites used.
332    pub const fn satellites(mut self, count: u8) -> Self {
333        self.fix.quality.satellites = Some(count);
334        self
335    }
336
337    /// HDOP.
338    pub const fn hdop(mut self, hdop: Dop) -> Self {
339        self.fix.quality.hdop = Some(hdop);
340        self
341    }
342
343    /// VDOP.
344    pub const fn vdop(mut self, vdop: Dop) -> Self {
345        self.fix.quality.vdop = Some(vdop);
346        self
347    }
348
349    /// PDOP.
350    pub const fn pdop(mut self, pdop: Dop) -> Self {
351        self.fix.quality.pdop = Some(pdop);
352        self
353    }
354
355    /// Builds the fix.
356    #[must_use]
357    pub const fn build(self) -> GnssFix {
358        self.fix
359    }
360}
361
362#[cfg(test)]
363#[allow(clippy::unwrap_used, clippy::float_cmp)]
364mod tests {
365    use super::*;
366
367    fn somewhere() -> Position {
368        "50°45.3'N 001°20.0'W".parse().unwrap()
369    }
370
371    fn at(seconds: i64) -> Instant<Utc> {
372        Instant::from_unix_seconds(seconds)
373    }
374
375    #[test]
376    fn a_bare_fix_is_autonomous_with_nothing_else_known() {
377        let fix = GnssFix::builder(at(100), somewhere()).build();
378        assert_eq!(fix.taken_at(), at(100));
379        assert_eq!(fix.position(), somewhere());
380        assert_eq!(fix.quality().fix_type(), FixType::Autonomous);
381        assert_eq!(fix.course_over_ground(), None);
382        assert_eq!(fix.speed_over_ground(), None);
383        assert_eq!(fix.quality().satellites(), None);
384        assert_eq!(fix.horizontal_accuracy(), None);
385    }
386
387    #[test]
388    fn accuracy_is_hdop_times_the_range_error() {
389        let fix = GnssFix::builder(at(0), somewhere())
390            .hdop(Dop::new(2.0).unwrap())
391            .build();
392        // Autonomous: 4 m nominal.
393        assert!((fix.horizontal_accuracy().unwrap().metres() - 8.0).abs() < 1e-9);
394        let own = Distance::from_metres(1.5).unwrap();
395        assert!((fix.horizontal_accuracy_with(own).unwrap().metres() - 3.0).abs() < 1e-9);
396        // Estimated position: no meaningful range error.
397        let estimated = GnssFix::builder(at(0), somewhere())
398            .fix_type(FixType::Estimated)
399            .hdop(Dop::new(2.0).unwrap())
400            .build();
401        assert_eq!(estimated.horizontal_accuracy(), None);
402        assert!(estimated.horizontal_accuracy_with(own).is_some());
403    }
404
405    #[test]
406    fn the_observed_position_follows_the_fix_type() {
407        let cases = [
408            (FixType::RtkFixed, ObservationStatus::Valid),
409            (FixType::Autonomous, ObservationStatus::Valid),
410            (FixType::Estimated, ObservationStatus::Suspect),
411            (FixType::Simulated, ObservationStatus::Suspect),
412            (FixType::None, ObservationStatus::Invalid),
413        ];
414        for (fix_type, status) in cases {
415            let fix = GnssFix::builder(at(7), somewhere())
416                .fix_type(fix_type)
417                .hdop(Dop::new(1.0).unwrap())
418                .build();
419            let observed = fix.observed_position();
420            assert_eq!(observed.quality().status(), status, "{fix_type:?}");
421            assert_eq!(observed.taken_at(), at(7));
422            assert_eq!(*observed.value(), somewhere());
423            assert_eq!(
424                observed.quality().sigma().is_some(),
425                fix_type.is_position_fix(),
426                "{fix_type:?}"
427            );
428        }
429    }
430
431    #[test]
432    fn a_dilution_of_precision_is_positive_and_finite() {
433        assert!(Dop::new(0.0).is_err());
434        assert!(Dop::new(-1.0).is_err());
435        assert!(Dop::new(f64::NAN).is_err());
436        assert!(Dop::new(f64::INFINITY).is_err());
437        assert_eq!(Dop::new(1.5).unwrap().value(), 1.5);
438    }
439
440    #[cfg(feature = "serde")]
441    #[test]
442    fn serde_round_trips_and_validates_the_dop() {
443        let fix = GnssFix::builder(at(0), somewhere())
444            .fix_type(FixType::Differential)
445            .speed_over_ground(Speed::from_knots(3.0).unwrap())
446            .satellites(12)
447            .pdop(Dop::new(1.7).unwrap())
448            .build();
449        let json = serde_json::to_string(&fix).unwrap();
450        assert_eq!(serde_json::from_str::<GnssFix>(&json).unwrap(), fix);
451        assert!(serde_json::from_str::<Dop>("-1.0").is_err());
452    }
453}