Skip to main content

kinavis_kernel/
snapshot.rs

1//! Read model of the navigation solution.
2//!
3//! Estimator (state + covariance) or intake (last fix): displays, autopilots
4//! and alarms need the same answers — position, speed, direction, uncertainty,
5//! age. [`NavigationSnapshot`] projects them from either producer, with no
6//! internal representation: uncertainty is an [`ErrorEllipse`], not a
7//! covariance matrix.
8
9use core::fmt;
10use core::time::Duration;
11
12use crate::angle::TrueCourse;
13use crate::error::{ensure_range, Result};
14use crate::event::{NavigationIntegrity, PositionSource};
15use crate::math;
16use crate::observation::Observed;
17use crate::position::Position;
18use crate::units::{Angle, Distance, Speed};
19
20/// Course and speed over ground.
21#[derive(Debug, Clone, Copy, PartialEq)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23pub struct GroundTrack {
24    /// Course over ground.
25    pub course_over_ground: TrueCourse,
26    /// Speed over ground.
27    pub speed_over_ground: Speed,
28}
29
30/// 1σ horizontal position error ellipse.
31///
32/// Semi-axes and true direction of the major axis of a 2 × 2 covariance. Scale
33/// axes by 2.45 for 95 %, by 1.18 for an equal-probability circle when the axes
34/// are nearly equal.
35#[derive(Debug, Clone, Copy, PartialEq)]
36#[cfg_attr(
37    feature = "serde",
38    derive(serde::Serialize, serde::Deserialize),
39    serde(try_from = "StoredErrorEllipse", into = "StoredErrorEllipse")
40)]
41pub struct ErrorEllipse {
42    semi_major: Distance,
43    semi_minor: Distance,
44    orientation: TrueCourse,
45}
46
47impl ErrorEllipse {
48    /// Ellipse of a north/east covariance, m².
49    ///
50    /// `None` unless a valid covariance: finite, non-negative variances,
51    /// |correlation| ≤ 1.
52    #[must_use]
53    pub fn from_covariance(north: f64, north_east: f64, east: f64) -> Option<Self> {
54        if !(north.is_finite() && north_east.is_finite() && east.is_finite())
55            || north < 0.0
56            || east < 0.0
57            || north_east * north_east > north * east * (1.0 + 1e-9)
58        {
59            return None;
60        }
61        // Eigenvalues of the symmetric 2 × 2 matrix.
62        let half_trace = f64::midpoint(north, east);
63        let half_difference = 0.5 * (north - east);
64        let radius = math::hypot(half_difference, north_east);
65        let major = (half_trace + radius).max(0.0);
66        let minor = (half_trace - radius).max(0.0);
67        // Major-axis direction, from north towards east.
68        let orientation = if radius < f64::MIN_POSITIVE {
69            0.0
70        } else {
71            0.5 * math::atan2(2.0 * north_east, north - east)
72        };
73        Some(Self {
74            semi_major: Distance::from_metres(math::sqrt(major)).ok()?,
75            semi_minor: Distance::from_metres(math::sqrt(minor)).ok()?,
76            orientation: TrueCourse::wrap(math::to_degrees(orientation)).ok()?,
77        })
78    }
79
80    /// Ellipse from semi-axes and major-axis direction.
81    ///
82    /// # Errors
83    ///
84    /// [`crate::error::KernelError::OutOfRange`] for a negative axis or minor >
85    /// major.
86    pub fn new(
87        semi_major: Distance,
88        semi_minor: Distance,
89        orientation: TrueCourse,
90    ) -> Result<Self> {
91        ensure_range("semi-major axis", semi_major.metres(), 0.0, f64::MAX)?;
92        ensure_range(
93            "semi-minor axis",
94            semi_minor.metres(),
95            0.0,
96            semi_major.metres(),
97        )?;
98        Ok(Self {
99            semi_major,
100            semi_minor,
101            orientation,
102        })
103    }
104
105    /// Circle of the given radius; a negative radius is taken by magnitude.
106    #[must_use]
107    pub fn circular(radius: Distance) -> Self {
108        let radius = Distance::from_metres(math::abs(radius.metres())).unwrap_or(radius);
109        Self {
110            semi_major: radius,
111            semi_minor: radius,
112            orientation: TrueCourse::NORTH,
113        }
114    }
115
116    /// 1σ semi-major axis.
117    #[must_use]
118    pub const fn semi_major(&self) -> Distance {
119        self.semi_major
120    }
121
122    /// 1σ semi-minor axis.
123    #[must_use]
124    pub const fn semi_minor(&self) -> Distance {
125        self.semi_minor
126    }
127
128    /// True direction of the major axis, in `[0°, 180°)` (axes have no sense).
129    #[must_use]
130    pub fn orientation(&self) -> TrueCourse {
131        let degrees = self.orientation.degrees();
132        if degrees >= 180.0 {
133            TrueCourse::wrap(degrees - 180.0).unwrap_or(self.orientation)
134        } else {
135            self.orientation
136        }
137    }
138
139    /// Radius of the circle of equal area, for displays with no room for an
140    /// ellipse.
141    #[must_use]
142    pub fn equivalent_radius(&self) -> Distance {
143        Distance::from_metres(math::sqrt(
144            self.semi_major.metres() * self.semi_minor.metres(),
145        ))
146        .unwrap_or(Distance::ZERO)
147    }
148}
149
150/// Serialised form; deserialisation goes through [`ErrorEllipse::new`].
151#[cfg(feature = "serde")]
152#[derive(serde::Serialize, serde::Deserialize)]
153struct StoredErrorEllipse {
154    semi_major: Distance,
155    semi_minor: Distance,
156    orientation: TrueCourse,
157}
158
159#[cfg(feature = "serde")]
160impl TryFrom<StoredErrorEllipse> for ErrorEllipse {
161    type Error = crate::error::KernelError;
162
163    fn try_from(stored: StoredErrorEllipse) -> Result<Self> {
164        Self::new(stored.semi_major, stored.semi_minor, stored.orientation)
165    }
166}
167
168#[cfg(feature = "serde")]
169impl From<ErrorEllipse> for StoredErrorEllipse {
170    fn from(ellipse: ErrorEllipse) -> Self {
171        Self {
172            semi_major: ellipse.semi_major,
173            semi_minor: ellipse.semi_minor,
174            orientation: ellipse.orientation,
175        }
176    }
177}
178
179impl fmt::Display for ErrorEllipse {
180    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181        write!(
182            f,
183            "{:.1} m × {:.1} m at {:.0}",
184            self.semi_major.metres(),
185            self.semi_minor.metres(),
186            self.orientation()
187        )
188    }
189}
190
191/// Vessel state at an instant, as far as the producer knows.
192///
193/// Every field except the stale flag is optional: before the first fix nothing
194/// is known; an intake provides no heading. Consumers show missing fields as
195/// unknown.
196#[derive(Debug, Clone, Copy, PartialEq)]
197pub struct NavigationSnapshot {
198    position: Option<Observed<Position, Distance>>,
199    source: Option<PositionSource>,
200    ground_track: Option<GroundTrack>,
201    heading: Option<TrueCourse>,
202    heading_sigma: Option<Angle>,
203    horizontal_error: Option<ErrorEllipse>,
204    integrity: Option<NavigationIntegrity>,
205    age: Option<Duration>,
206    stale: bool,
207}
208
209impl NavigationSnapshot {
210    /// Empty snapshot.
211    pub const EMPTY: Self = Self {
212        position: None,
213        source: None,
214        ground_track: None,
215        heading: None,
216        heading_sigma: None,
217        horizontal_error: None,
218        integrity: None,
219        age: None,
220        stale: false,
221    };
222
223    /// Sets position and source.
224    #[must_use]
225    pub const fn with_position(
226        mut self,
227        position: Observed<Position, Distance>,
228        source: PositionSource,
229    ) -> Self {
230        self.position = Some(position);
231        self.source = Some(source);
232        self
233    }
234
235    /// Sets course and speed over ground.
236    #[must_use]
237    pub const fn with_ground_track(mut self, track: GroundTrack) -> Self {
238        self.ground_track = Some(track);
239        self
240    }
241
242    /// Sets heading and its uncertainty.
243    #[must_use]
244    pub const fn with_heading(mut self, heading: TrueCourse, sigma: Option<Angle>) -> Self {
245        self.heading = Some(heading);
246        self.heading_sigma = sigma;
247        self
248    }
249
250    /// Sets the position error ellipse.
251    #[must_use]
252    pub const fn with_horizontal_error(mut self, ellipse: ErrorEllipse) -> Self {
253        self.horizontal_error = Some(ellipse);
254        self
255    }
256
257    /// Sets the producer's integrity verdict.
258    #[must_use]
259    pub const fn with_integrity(mut self, integrity: NavigationIntegrity) -> Self {
260        self.integrity = Some(integrity);
261        self
262    }
263
264    /// Sets position age and staleness.
265    #[must_use]
266    pub const fn with_age(mut self, age: Option<Duration>, stale: bool) -> Self {
267        self.age = age;
268        self.stale = stale;
269        self
270    }
271
272    /// Position with time and quality; `None` if unknown.
273    #[must_use]
274    pub const fn position(&self) -> Option<&Observed<Position, Distance>> {
275        self.position.as_ref()
276    }
277
278    /// Position source, if any.
279    #[must_use]
280    pub const fn source(&self) -> Option<PositionSource> {
281        self.source
282    }
283
284    /// Course and speed over ground, if known.
285    #[must_use]
286    pub const fn ground_track(&self) -> Option<GroundTrack> {
287        self.ground_track
288    }
289
290    /// Heading (bow direction, distinct from COG), if known.
291    #[must_use]
292    pub const fn heading(&self) -> Option<TrueCourse> {
293        self.heading
294    }
295
296    /// 1σ heading uncertainty, if known.
297    #[must_use]
298    pub const fn heading_sigma(&self) -> Option<Angle> {
299        self.heading_sigma
300    }
301
302    /// 1σ position error ellipse, if known.
303    #[must_use]
304    pub const fn horizontal_error(&self) -> Option<ErrorEllipse> {
305        self.horizontal_error
306    }
307
308    /// Integrity, if assessed by the producer (an intake reports a source, an
309    /// estimator reports integrity).
310    #[must_use]
311    pub const fn integrity(&self) -> Option<NavigationIntegrity> {
312        self.integrity
313    }
314
315    /// Position age at snapshot time; `None` without a position or if the
316    /// position is later than the snapshot.
317    #[must_use]
318    pub const fn age(&self) -> Option<Duration> {
319        self.age
320    }
321
322    /// Whether the position is older than the producer's limit.
323    ///
324    /// A stale position is still reported as the best available, but must not
325    /// be presented as current.
326    #[must_use]
327    pub const fn is_stale(&self) -> bool {
328        self.stale
329    }
330}
331
332#[cfg(test)]
333#[allow(clippy::unwrap_used, clippy::float_cmp)]
334mod tests {
335    use super::*;
336    use alloc::format;
337
338    #[test]
339    fn a_diagonal_covariance_gives_axes_along_north_and_east() {
340        let ellipse = ErrorEllipse::from_covariance(16.0, 0.0, 4.0).unwrap();
341        assert_eq!(ellipse.semi_major().metres(), 4.0);
342        assert_eq!(ellipse.semi_minor().metres(), 2.0);
343        assert_eq!(ellipse.orientation().degrees(), 0.0);
344        let across = ErrorEllipse::from_covariance(4.0, 0.0, 16.0).unwrap();
345        assert_eq!(across.orientation().degrees(), 90.0);
346        assert!((across.equivalent_radius().metres() - math::sqrt(8.0)).abs() < 1e-12);
347        assert_eq!(format!("{across}"), "4.0 m × 2.0 m at 090°T");
348    }
349
350    #[test]
351    fn a_correlated_covariance_tilts_the_ellipse() {
352        // Equal variances, full correlation: a line at 45°.
353        let ellipse = ErrorEllipse::from_covariance(1.0, 1.0, 1.0).unwrap();
354        assert!((ellipse.semi_major().metres() - math::sqrt(2.0)).abs() < 1e-12);
355        assert!(ellipse.semi_minor().metres() < 1e-9);
356        assert!((ellipse.orientation().degrees() - 45.0).abs() < 1e-9);
357        // Negative correlation tilts the other way, still in [0°, 180°).
358        let other = ErrorEllipse::from_covariance(1.0, -0.5, 1.0).unwrap();
359        assert!((other.orientation().degrees() - 135.0).abs() < 1e-9);
360    }
361
362    #[test]
363    fn what_is_not_a_covariance_is_refused() {
364        assert!(ErrorEllipse::from_covariance(-1.0, 0.0, 1.0).is_none());
365        assert!(ErrorEllipse::from_covariance(1.0, 2.0, 1.0).is_none());
366        assert!(ErrorEllipse::from_covariance(f64::NAN, 0.0, 1.0).is_none());
367        assert!(ErrorEllipse::from_covariance(0.0, 0.0, 0.0).is_some());
368    }
369
370    #[test]
371    fn an_empty_snapshot_knows_nothing_and_is_not_stale() {
372        let empty = NavigationSnapshot::EMPTY;
373        assert!(empty.position().is_none());
374        assert!(empty.source().is_none());
375        assert!(empty.ground_track().is_none());
376        assert!(empty.heading().is_none());
377        assert!(empty.horizontal_error().is_none());
378        assert!(empty.integrity().is_none());
379        assert_eq!(empty.age(), None);
380        assert!(!empty.is_stale());
381    }
382}