Skip to main content

kinavis_kernel/
state.rs

1//! Navigation state: the estimator's belief as one aggregate.
2//!
3//! An estimator fuses fixes, headings and speeds into one belief — position,
4//! heading, speed through the water, current — with its uncertainty.
5//! [`NavigationState`] is a DDD aggregate: private fields, constructed only
6//! through invariant-checking constructors, exposed through the projection
7//! [`NavigationState::project`].
8//!
9//! Internally a six-element state vector and its covariance
10//! ([`StateComponent`]). The dimension is not public: consumers see an
11//! [`ErrorEllipse`] and sigmas for heading and speed. The estimator uses hidden
12//! accessors for the vector and covariance and rebuilds the state through a
13//! hidden, re-validating constructor. Six components because each is directly
14//! observed by a standard bridge sensor — GNSS position, gyro heading, log
15//! speed — with current reconciling them.
16//!
17//! ```rust
18//! use kinavis_kernel::gnss::{Dop, GnssFix};
19//! use kinavis_kernel::state::NavigationState;
20//! use kinavis_kernel::time::{Civil, Instant, Utc};
21//! use kinavis_kernel::{Position, Speed, TrueCourse};
22//!
23//! let fix = GnssFix::builder(
24//!     Instant::<Utc>::from_civil(Civil::date(2026, 9, 11))?,
25//!     "50°45.3'N 001°20.0'W".parse::<Position>()?,
26//! )
27//! .course_over_ground(TrueCourse::new(272.5)?)
28//! .speed_over_ground(Speed::from_knots(11.3)?)
29//! .hdop(Dop::new(1.0)?)
30//! .build();
31//!
32//! let state = NavigationState::initialised_from(&fix, None)?;
33//! assert_eq!(state.heading().degrees(), 272.5);
34//! assert!((state.speed_through_water().knots() - 11.3).abs() < 1e-9);
35//! // Four metres one-sigma from the HDOP, in every direction.
36//! assert!((state.horizontal_error().semi_major().metres() - 4.0).abs() < 1e-9);
37//! # Ok::<(), kinavis_kernel::KernelError>(())
38//! ```
39
40use crate::angle::TrueCourse;
41use crate::error::{ensure_range, KernelError, Result};
42use crate::event::PositionSource;
43use crate::geodesy::{Ellipsoid, GeodeticPoint, Height};
44use crate::gnss::GnssFix;
45use crate::local::{LocalFrame, Ned, Vector3};
46use crate::math;
47use crate::matrix::{Matrix, Vector};
48use crate::observation::{ObservationStatus, Observed, Quality};
49use crate::position::Position;
50use crate::snapshot::{ErrorEllipse, GroundTrack, NavigationSnapshot};
51use crate::time::{Instant, Utc};
52use crate::units::{Angle, Distance, Speed, METRES_PER_NAUTICAL_MILE};
53
54/// Number of state components.
55///
56/// Internal to the crate family: hidden, not covered by the stability
57/// guarantee. See [hidden items](crate#hidden-items).
58#[doc(hidden)]
59pub const STATE_DIM: usize = 6;
60
61/// State vector component, by name.
62///
63/// Observations reference components by name, never by index, so the vector
64/// layout stays internal. `#[non_exhaustive]`; match with a wildcard arm.
65#[non_exhaustive]
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67pub enum StateComponent {
68    /// Northing from the anchor, m.
69    North,
70    /// Easting from the anchor, m.
71    East,
72    /// True heading, rad.
73    Heading,
74    /// Speed through the water along the heading, m/s.
75    SpeedThroughWater,
76    /// Current, north component, m/s.
77    CurrentNorth,
78    /// Current, east component, m/s.
79    CurrentEast,
80}
81
82impl StateComponent {
83    /// All components, in vector order.
84    pub const ALL: [Self; STATE_DIM] = [
85        Self::North,
86        Self::East,
87        Self::Heading,
88        Self::SpeedThroughWater,
89        Self::CurrentNorth,
90        Self::CurrentEast,
91    ];
92
93    /// Vector index of the component.
94    ///
95    /// Internal to the crate family: hidden, not covered by the stability
96    /// guarantee. See [hidden items](crate#hidden-items).
97    #[doc(hidden)]
98    #[must_use]
99    pub const fn index(self) -> usize {
100        match self {
101            Self::North => 0,
102            Self::East => 1,
103            Self::Heading => 2,
104            Self::SpeedThroughWater => 3,
105            Self::CurrentNorth => 4,
106            Self::CurrentEast => 5,
107        }
108    }
109}
110
111/// Initial 1σ priors where the fix provides none.
112///
113/// A fix usually gives horizontal accuracy, rarely course accuracy, never speed
114/// accuracy, and nothing about current. The defaults are deliberately loose: an
115/// estimator recovers from a loose prior within a few observations, never from
116/// a tight wrong one.
117///
118/// Start from [`StatePriors::standard`] and adjust with `with_*`; each rejects
119/// non-positive sigmas, so no component starts as exactly known.
120#[derive(Debug, Clone, Copy, PartialEq)]
121pub struct StatePriors {
122    position: Distance,
123    heading: Angle,
124    heading_unknown: Angle,
125    speed: Speed,
126    speed_unknown: Speed,
127    current: Speed,
128}
129
130impl StatePriors {
131    /// Merchant vessel with standard GNSS, gyro and log: 10 m; heading 3°, or
132    /// 104° (uniform over the circle) if unknown; speed 1 kn, or 10 kn if
133    /// unknown; current 1 kn.
134    #[must_use]
135    pub const fn standard() -> Self {
136        Self {
137            position: Distance::from_nautical_miles_unchecked(10.0 / METRES_PER_NAUTICAL_MILE),
138            heading: Angle::from_degrees_unchecked(3.0),
139            heading_unknown: Angle::from_degrees_unchecked(104.0),
140            speed: Speed::from_knots_unchecked(1.0),
141            speed_unknown: Speed::from_knots_unchecked(10.0),
142            current: Speed::from_knots_unchecked(1.0),
143        }
144    }
145
146    /// Sets the horizontal position sigma used when the fix gives no accuracy.
147    ///
148    /// # Errors
149    ///
150    /// [`KernelError::OutOfRange`] unless positive.
151    pub fn with_position(mut self, sigma: Distance) -> Result<Self> {
152        ensure_positive("position prior", sigma.metres())?;
153        self.position = sigma;
154        Ok(self)
155    }
156
157    /// Sets the heading sigmas: `known` for a heading without sigma or taken
158    /// from COG; `unknown` when neither is available.
159    ///
160    /// # Errors
161    ///
162    /// [`KernelError::OutOfRange`] unless both positive.
163    pub fn with_heading(mut self, known: Angle, unknown: Angle) -> Result<Self> {
164        ensure_positive("heading prior", known.degrees())?;
165        ensure_positive("unknown-heading prior", unknown.degrees())?;
166        self.heading = known;
167        self.heading_unknown = unknown;
168        Ok(self)
169    }
170
171    /// Sets the speed-through-water sigmas: `known` when taken from SOG;
172    /// `unknown` when the fix has no speed.
173    ///
174    /// # Errors
175    ///
176    /// [`KernelError::OutOfRange`] unless both positive.
177    pub fn with_speed(mut self, known: Speed, unknown: Speed) -> Result<Self> {
178        ensure_positive("speed prior", known.knots())?;
179        ensure_positive("unknown-speed prior", unknown.knots())?;
180        self.speed = known;
181        self.speed_unknown = unknown;
182        Ok(self)
183    }
184
185    /// Sets the sigma of each current component (not directly observed).
186    ///
187    /// # Errors
188    ///
189    /// [`KernelError::OutOfRange`] unless positive.
190    pub fn with_current(mut self, sigma: Speed) -> Result<Self> {
191        ensure_positive("current prior", sigma.knots())?;
192        self.current = sigma;
193        Ok(self)
194    }
195
196    /// Horizontal position, when the fix gives no accuracy.
197    #[must_use]
198    pub const fn position(&self) -> Distance {
199        self.position
200    }
201
202    /// Heading, when given without sigma or taken from COG.
203    #[must_use]
204    pub const fn heading(&self) -> Angle {
205        self.heading
206    }
207
208    /// Heading, when neither heading nor course is available.
209    #[must_use]
210    pub const fn heading_unknown(&self) -> Angle {
211        self.heading_unknown
212    }
213
214    /// Speed through the water, taken from SOG.
215    #[must_use]
216    pub const fn speed(&self) -> Speed {
217        self.speed
218    }
219
220    /// Speed through the water, when the fix has no speed.
221    #[must_use]
222    pub const fn speed_unknown(&self) -> Speed {
223        self.speed_unknown
224    }
225
226    /// Each current component.
227    #[must_use]
228    pub const fn current(&self) -> Speed {
229        self.current
230    }
231}
232
233/// A sigma must be finite and positive: zero means exactly known; negative is
234/// invalid.
235fn ensure_positive(parameter: &'static str, value: f64) -> Result<()> {
236    ensure_range(parameter, value, f64::MIN_POSITIVE, f64::MAX)
237}
238
239/// Maximum speed through the water, m/s (100 kn).
240const MAX_SPEED_METRES_PER_SECOND: f64 = 51.4;
241
242/// Maximum current, m/s (20 kn).
243const MAX_CURRENT_METRES_PER_SECOND: f64 = 10.3;
244
245/// Estimator belief about the vessel at one instant.
246#[derive(Debug, Clone, Copy, PartialEq)]
247pub struct NavigationState {
248    valid_at: Instant<Utc>,
249    /// Local frame of northing and easting.
250    frame: LocalFrame,
251    /// `[north, east, heading, speed through water, current north, current east]`.
252    vector: Vector<STATE_DIM>,
253    covariance: Matrix<STATE_DIM, STATE_DIM>,
254}
255
256impl NavigationState {
257    /// State from a first fix and an optional heading.
258    ///
259    /// The fix position anchors the local frame, so northing and easting start
260    /// at zero. Heading comes from the observation, else from COG, else it is
261    /// unknown with the corresponding sigma. Speed through the water starts as
262    /// SOG; current starts at zero with [`StatePriors::current`] uncertainty.
263    ///
264    /// # Errors
265    ///
266    /// As [`LocalFrame::at`] (not reachable from a fix);
267    /// [`KernelError::OutOfRange`] for a speed above 100 kn.
268    pub fn initialised_from(
269        fix: &GnssFix,
270        heading: Option<Observed<TrueCourse, Angle>>,
271    ) -> Result<Self> {
272        Self::initialised_with(fix, heading, &StatePriors::standard())
273    }
274
275    /// As [`NavigationState::initialised_from`], with custom priors.
276    ///
277    /// # Errors
278    ///
279    /// As [`NavigationState::initialised_from`].
280    pub fn initialised_with(
281        fix: &GnssFix,
282        heading: Option<Observed<TrueCourse, Angle>>,
283        priors: &StatePriors,
284    ) -> Result<Self> {
285        let anchor = GeodeticPoint::new(fix.position(), Height::above_ellipsoid(Distance::ZERO));
286        let frame = LocalFrame::at(anchor, &Ellipsoid::WGS84)?;
287
288        let (heading_radians, heading_sigma) = match (heading, fix.course_over_ground()) {
289            (Some(observed), _) => (
290                math::to_radians(observed.value().degrees()),
291                observed
292                    .quality()
293                    .sigma()
294                    .map_or(priors.heading, |sigma| *sigma),
295            ),
296            (None, Some(course)) => (math::to_radians(course.degrees()), priors.heading),
297            (None, None) => (0.0, priors.heading_unknown),
298        };
299        let (speed, speed_sigma) = match fix.speed_over_ground() {
300            Some(speed) => (speed, priors.speed),
301            None => (Speed::ZERO, priors.speed_unknown),
302        };
303        let position_sigma = fix.horizontal_accuracy().unwrap_or(priors.position);
304
305        let vector = Vector::from_column([
306            0.0,
307            0.0,
308            heading_radians,
309            speed.metres_per_second(),
310            0.0,
311            0.0,
312        ]);
313        let square = |value: f64| value * value;
314        let covariance = Matrix::diagonal([
315            square(position_sigma.metres()),
316            square(position_sigma.metres()),
317            square(heading_sigma.radians()),
318            square(speed_sigma.metres_per_second()),
319            square(priors.current.metres_per_second()),
320            square(priors.current.metres_per_second()),
321        ]);
322        Self::from_parts(fix.taken_at(), frame, vector, covariance)
323    }
324
325    /// State from parts, validated; used by the estimator to rebuild a state
326    /// after a step.
327    ///
328    /// # Errors
329    ///
330    /// [`KernelError::NotFinite`] for a non-finite element;
331    /// [`KernelError::NotCovariance`] unless the covariance is symmetric
332    /// positive semi-definite; [`KernelError::OutOfRange`] for speed or current
333    /// beyond physical limits.
334    ///
335    /// Internal to the crate family: hidden, not covered by the stability
336    /// guarantee. See [hidden items](crate#hidden-items).
337    #[doc(hidden)]
338    pub fn from_parts(
339        valid_at: Instant<Utc>,
340        frame: LocalFrame,
341        vector: Vector<STATE_DIM>,
342        covariance: Matrix<STATE_DIM, STATE_DIM>,
343    ) -> Result<Self> {
344        if !vector.is_finite() {
345            return Err(KernelError::NotFinite {
346                parameter: "navigation state",
347                value: f64::NAN,
348            });
349        }
350        if !covariance.is_finite() {
351            return Err(KernelError::NotFinite {
352                parameter: "navigation state covariance",
353                value: f64::NAN,
354            });
355        }
356        if !covariance.is_covariance() {
357            return Err(KernelError::NotCovariance {
358                context: "a navigation state's covariance",
359            });
360        }
361        let element = |component: StateComponent| vector.element(component.index()).unwrap_or(0.0);
362        let speed = element(StateComponent::SpeedThroughWater);
363        if math::abs(speed) > MAX_SPEED_METRES_PER_SECOND {
364            return Err(KernelError::OutOfRange {
365                parameter: "speed through water",
366                value: speed,
367                min: -MAX_SPEED_METRES_PER_SECOND,
368                max: MAX_SPEED_METRES_PER_SECOND,
369            });
370        }
371        let current = math::hypot(
372            element(StateComponent::CurrentNorth),
373            element(StateComponent::CurrentEast),
374        );
375        if current > MAX_CURRENT_METRES_PER_SECOND {
376            return Err(KernelError::OutOfRange {
377                parameter: "current",
378                value: current,
379                min: 0.0,
380                max: MAX_CURRENT_METRES_PER_SECOND,
381            });
382        }
383        // Keep heading in `[0, 2π)` so equal headings compare equal and the
384        // projection needs no wrapping.
385        let heading = element(StateComponent::Heading);
386        let mut vector = vector;
387        vector.set(
388            StateComponent::Heading.index(),
389            0,
390            math::to_radians(crate::angle::wrap360(math::to_degrees(heading))),
391        );
392        Ok(Self {
393            valid_at,
394            frame,
395            vector,
396            covariance: covariance.symmetrised(),
397        })
398    }
399
400    /// Time of the state.
401    #[must_use]
402    pub const fn valid_at(&self) -> Instant<Utc> {
403        self.valid_at
404    }
405
406    /// Local frame of the state.
407    ///
408    /// Internal to the crate family: hidden, not covered by the stability
409    /// guarantee. See [hidden items](crate#hidden-items).
410    #[doc(hidden)]
411    #[must_use]
412    pub const fn frame(&self) -> &LocalFrame {
413        &self.frame
414    }
415
416    /// State vector.
417    ///
418    /// Internal to the crate family: hidden, not covered by the stability
419    /// guarantee. See [hidden items](crate#hidden-items).
420    #[doc(hidden)]
421    #[must_use]
422    pub const fn vector(&self) -> &Vector<STATE_DIM> {
423        &self.vector
424    }
425
426    /// Covariance.
427    ///
428    /// Internal to the crate family: hidden, not covered by the stability
429    /// guarantee. See [hidden items](crate#hidden-items).
430    #[doc(hidden)]
431    #[must_use]
432    pub const fn covariance(&self) -> &Matrix<STATE_DIM, STATE_DIM> {
433        &self.covariance
434    }
435
436    /// Vector component by name.
437    fn component(&self, component: StateComponent) -> f64 {
438        self.vector.element(component.index()).unwrap_or(0.0)
439    }
440
441    /// Variance of a component.
442    fn variance(&self, component: StateComponent) -> f64 {
443        self.covariance
444            .get(component.index(), component.index())
445            .unwrap_or(0.0)
446            .max(0.0)
447    }
448
449    /// Estimated position.
450    #[must_use]
451    pub fn position(&self) -> Position {
452        let (north, east) = (
453            self.component(StateComponent::North),
454            self.component(StateComponent::East),
455        );
456        // Zero displacement returns the anchor exactly, avoiding round-trip
457        // noise.
458        if north == 0.0 && east == 0.0 {
459            return self.frame.origin().position();
460        }
461        let displacement: Vector3<Ned, Distance> = Vector3::new(
462            Distance::from_metres(north).unwrap_or(Distance::ZERO),
463            Distance::from_metres(east).unwrap_or(Distance::ZERO),
464            Distance::ZERO,
465        );
466        // Fails only for planetary-scale displacements, excluded by the speed
467        // and step bounds; the anchor is the fallback.
468        self.frame
469            .point_from_ned(displacement)
470            .map_or(self.frame.origin().position(), |point| point.position())
471    }
472
473    /// Estimated heading.
474    #[must_use]
475    pub fn heading(&self) -> TrueCourse {
476        TrueCourse::wrap(math::to_degrees(self.component(StateComponent::Heading)))
477            .unwrap_or(TrueCourse::NORTH)
478    }
479
480    /// 1σ heading uncertainty.
481    #[must_use]
482    pub fn heading_sigma(&self) -> Angle {
483        Angle::from_radians(math::sqrt(self.variance(StateComponent::Heading)))
484            .unwrap_or(Angle::ZERO)
485    }
486
487    /// Estimated speed through the water along the heading.
488    #[must_use]
489    pub fn speed_through_water(&self) -> Speed {
490        Speed::from_metres_per_second(self.component(StateComponent::SpeedThroughWater))
491            .unwrap_or(Speed::ZERO)
492    }
493
494    /// 1σ speed uncertainty.
495    #[must_use]
496    pub fn speed_sigma(&self) -> Speed {
497        Speed::from_metres_per_second(math::sqrt(self.variance(StateComponent::SpeedThroughWater)))
498            .unwrap_or(Speed::ZERO)
499    }
500
501    /// Estimated current.
502    #[must_use]
503    pub fn current(&self) -> Vector3<Ned, Speed> {
504        Vector3::new(
505            Speed::from_metres_per_second(self.component(StateComponent::CurrentNorth))
506                .unwrap_or(Speed::ZERO),
507            Speed::from_metres_per_second(self.component(StateComponent::CurrentEast))
508                .unwrap_or(Speed::ZERO),
509            Speed::ZERO,
510        )
511    }
512
513    /// Ground velocity: water velocity plus current.
514    #[must_use]
515    pub fn velocity_over_ground(&self) -> Vector3<Ned, Speed> {
516        let heading = self.component(StateComponent::Heading);
517        let speed = self.component(StateComponent::SpeedThroughWater);
518        let through_water: Vector3<Ned, Speed> = Vector3::new(
519            Speed::from_metres_per_second(speed * math::cos(heading)).unwrap_or(Speed::ZERO),
520            Speed::from_metres_per_second(speed * math::sin(heading)).unwrap_or(Speed::ZERO),
521            Speed::ZERO,
522        );
523        through_water + self.current()
524    }
525
526    /// Course and speed made good; `None` if the vessel is not moving.
527    #[must_use]
528    pub fn ground_track(&self) -> Option<GroundTrack> {
529        let velocity = self.velocity_over_ground();
530        Some(GroundTrack {
531            course_over_ground: velocity.horizontal_direction()?,
532            speed_over_ground: velocity.horizontal_magnitude(),
533        })
534    }
535
536    /// 1σ position error ellipse.
537    #[must_use]
538    pub fn horizontal_error(&self) -> ErrorEllipse {
539        let north = StateComponent::North.index();
540        let east = StateComponent::East.index();
541        ErrorEllipse::from_covariance(
542            self.covariance.get(north, north).unwrap_or(0.0),
543            self.covariance.get(north, east).unwrap_or(0.0),
544            self.covariance.get(east, east).unwrap_or(0.0),
545        )
546        .unwrap_or_else(|| ErrorEllipse::circular(Distance::ZERO))
547    }
548
549    /// Read model for displays and alarms.
550    ///
551    /// Position status is `Valid` (the estimator's committed belief), sigma is
552    /// the ellipse's equivalent radius. Age and staleness are left to the
553    /// caller, who knows the current time.
554    #[must_use]
555    pub fn project(&self) -> NavigationSnapshot {
556        let ellipse = self.horizontal_error();
557        let position = Observed::new(
558            self.position(),
559            self.valid_at,
560            Quality::new(ObservationStatus::Valid).with_sigma(ellipse.equivalent_radius()),
561        );
562        let mut snapshot = NavigationSnapshot::EMPTY
563            .with_position(position, PositionSource::Estimated)
564            .with_heading(self.heading(), Some(self.heading_sigma()))
565            .with_horizontal_error(ellipse);
566        if let Some(track) = self.ground_track() {
567            snapshot = snapshot.with_ground_track(track);
568        }
569        snapshot
570    }
571}
572
573#[cfg(test)]
574#[allow(clippy::unwrap_used, clippy::float_cmp)]
575mod tests {
576    use super::*;
577    use crate::gnss::Dop;
578
579    fn fix() -> GnssFix {
580        GnssFix::builder(
581            Instant::from_unix_seconds(1_000),
582            "50°45.3'N 001°20.0'W".parse().unwrap(),
583        )
584        .course_over_ground(TrueCourse::new(90.0).unwrap())
585        .speed_over_ground(Speed::from_metres_per_second(5.0).unwrap())
586        .hdop(Dop::new(2.0).unwrap())
587        .build()
588    }
589
590    #[test]
591    fn a_state_from_a_fix_starts_at_the_fix() {
592        let state = NavigationState::initialised_from(&fix(), None).unwrap();
593        assert_eq!(state.valid_at(), Instant::from_unix_seconds(1_000));
594        assert_eq!(state.position(), fix().position());
595        assert_eq!(state.heading().degrees(), 90.0);
596        assert_eq!(state.speed_through_water().metres_per_second(), 5.0);
597        assert_eq!(state.current().magnitude().metres_per_second(), 0.0);
598        let velocity = state.velocity_over_ground();
599        assert!(velocity.north().metres_per_second().abs() < 1e-12);
600        assert!((velocity.east().metres_per_second() - 5.0).abs() < 1e-12);
601        let track = state.ground_track().unwrap();
602        assert!((track.course_over_ground.degrees() - 90.0).abs() < 1e-9);
603        // HDOP 2.0 × 4 m: 8 m circle.
604        assert!((state.horizontal_error().semi_major().metres() - 8.0).abs() < 1e-9);
605        assert!((state.horizontal_error().semi_minor().metres() - 8.0).abs() < 1e-9);
606        assert!((state.heading_sigma().degrees() - 3.0).abs() < 1e-9);
607    }
608
609    #[test]
610    fn a_supplied_heading_beats_the_course_over_ground() {
611        let heading = Observed::new(
612            TrueCourse::new(80.0).unwrap(),
613            Instant::from_unix_seconds(1_000),
614            Quality::new(ObservationStatus::Valid).with_sigma(Angle::from_degrees(0.5).unwrap()),
615        );
616        let state = NavigationState::initialised_from(&fix(), Some(heading)).unwrap();
617        assert_eq!(state.heading().degrees(), 80.0);
618        assert!((state.heading_sigma().degrees() - 0.5).abs() < 1e-9);
619    }
620
621    #[test]
622    fn a_bare_fix_leaves_heading_and_speed_unknown_and_says_so() {
623        let bare = GnssFix::builder(Instant::from_unix_seconds(0), fix().position()).build();
624        let state = NavigationState::initialised_from(&bare, None).unwrap();
625        assert_eq!(state.speed_through_water().metres_per_second(), 0.0);
626        assert!(state.ground_track().is_none());
627        assert!(state.heading_sigma().degrees() > 100.0);
628        assert!(state.speed_sigma().knots() > 5.0);
629        // No HDOP: the 10 m prior.
630        assert!((state.horizontal_error().semi_major().metres() - 10.0).abs() < 1e-9);
631    }
632
633    #[test]
634    fn priors_of_your_own_are_used_and_a_sigma_of_nothing_is_refused() {
635        let bare = GnssFix::builder(Instant::from_unix_seconds(0), fix().position()).build();
636        let priors = StatePriors::standard()
637            .with_position(Distance::from_metres(25.0).unwrap())
638            .unwrap()
639            .with_heading(
640                Angle::from_degrees(1.0).unwrap(),
641                Angle::from_degrees(90.0).unwrap(),
642            )
643            .unwrap()
644            .with_speed(
645                Speed::from_knots(0.5).unwrap(),
646                Speed::from_knots(4.0).unwrap(),
647            )
648            .unwrap()
649            .with_current(Speed::from_knots(2.0).unwrap())
650            .unwrap();
651        assert_eq!(priors.position().metres(), 25.0);
652        assert_eq!(priors.heading_unknown().degrees(), 90.0);
653        assert_eq!(priors.speed_unknown().knots(), 4.0);
654        assert_eq!(priors.current().knots(), 2.0);
655        let state = NavigationState::initialised_with(&bare, None, &priors).unwrap();
656        assert!((state.horizontal_error().semi_major().metres() - 25.0).abs() < 1e-9);
657        assert!((state.heading_sigma().degrees() - 90.0).abs() < 1e-9);
658        assert!((state.speed_sigma().knots() - 4.0).abs() < 1e-9);
659
660        // Zero sigma means exactly known; negative is invalid.
661        let standard = StatePriors::standard();
662        assert!(standard.with_position(Distance::ZERO).is_err());
663        assert!(standard
664            .with_position(Distance::from_metres(-1.0).unwrap())
665            .is_err());
666        assert!(standard
667            .with_heading(Angle::ZERO, Angle::from_degrees(90.0).unwrap())
668            .is_err());
669        assert!(standard
670            .with_heading(Angle::from_degrees(1.0).unwrap(), Angle::ZERO)
671            .is_err());
672        assert!(standard
673            .with_speed(Speed::ZERO, Speed::from_knots(4.0).unwrap())
674            .is_err());
675        assert!(standard
676            .with_speed(Speed::from_knots(0.5).unwrap(), Speed::ZERO)
677            .is_err());
678        assert!(standard.with_current(Speed::ZERO).is_err());
679        assert!(standard
680            .with_current(Speed::from_knots_unchecked(f64::NAN))
681            .is_err());
682        // Standard figures match the documentation.
683        assert!((standard.position().metres() - 10.0).abs() < 1e-9);
684        assert_eq!(standard.heading().degrees(), 3.0);
685        assert_eq!(standard.heading_unknown().degrees(), 104.0);
686        assert_eq!(standard.speed().knots(), 1.0);
687        assert_eq!(standard.speed_unknown().knots(), 10.0);
688        assert_eq!(standard.current().knots(), 1.0);
689    }
690
691    #[test]
692    fn the_projection_is_a_snapshot_the_display_can_use() {
693        let state = NavigationState::initialised_from(&fix(), None).unwrap();
694        let snapshot = state.project();
695        assert_eq!(snapshot.source(), Some(PositionSource::Estimated));
696        assert_eq!(*snapshot.position().unwrap().value(), fix().position());
697        assert_eq!(snapshot.heading().unwrap().degrees(), 90.0);
698        assert!(snapshot.horizontal_error().is_some());
699        assert!(snapshot.ground_track().is_some());
700        assert_eq!(snapshot.age(), None);
701    }
702
703    #[test]
704    fn the_invariants_are_checked_on_the_way_in() {
705        let good = NavigationState::initialised_from(&fix(), None).unwrap();
706        let frame = *good.frame();
707        let at = good.valid_at();
708        let mut vector = *good.vector();
709        let covariance = *good.covariance();
710
711        // Not a covariance.
712        let mut bad = covariance;
713        bad.set(0, 1, 1e9);
714        bad.set(1, 0, 1e9);
715        assert!(NavigationState::from_parts(at, frame, vector, bad).is_err());
716        // Impossible speed.
717        vector.set(StateComponent::SpeedThroughWater.index(), 0, 200.0);
718        assert!(NavigationState::from_parts(at, frame, vector, covariance).is_err());
719        // Heading 370° normalises to 10°.
720        let mut wrapped = *good.vector();
721        wrapped.set(StateComponent::Heading.index(), 0, math::to_radians(370.0));
722        let state = NavigationState::from_parts(at, frame, wrapped, covariance).unwrap();
723        assert!((state.heading().degrees() - 10.0).abs() < 1e-9);
724        // NaN anywhere.
725        let mut nan = *good.vector();
726        nan.set(0, 0, f64::NAN);
727        assert!(NavigationState::from_parts(at, frame, nan, covariance).is_err());
728    }
729}