Skip to main content

kinavis_kernel/
angle.rs

1//! Angles tagged with their reference frame.
2//!
3//! With bare `f64`, `calculate_true_bearing(bearing, variation)` and
4//! `calculate_true_bearing(variation, bearing)` both compile and both yield a
5//! plausible course. Here a [`CompassCourse`] cannot be passed where a
6//! [`MagneticCourse`] is expected, nor a [`Variation`] where a course is
7//! expected.
8//!
9//! The types also enforce range: a [`Direction`] is always finite and in `[0°,
10//! 360°)`, so functions consuming one cannot fail on range and return no
11//! `Result`.
12//!
13//! # Example
14//!
15//! ```rust
16//! use kinavis_kernel::{CompassCourse, Deviation, MagneticCourse, Variation};
17//!
18//! let cc = CompassCourse::new(3.0)?;
19//! let variation = Variation::new(-2.7)?;
20//!
21//! // Out-of-range and non-finite inputs are rejected at construction.
22//! assert!(CompassCourse::new(400.0).is_err());
23//! assert!(Variation::new(f64::NAN).is_err());
24//!
25//! // 360° is accepted and normalised to 0°.
26//! assert_eq!(CompassCourse::new(360.0)?.degrees(), 0.0);
27//!
28//! // Arbitrary values are wrapped explicitly.
29//! assert_eq!(MagneticCourse::wrap(-10.0)?.degrees(), 350.0);
30//! # Ok::<(), kinavis_kernel::KernelError>(())
31//! ```
32
33use core::fmt;
34use core::marker::PhantomData;
35
36use crate::error::{ensure_finite, ensure_range, KernelError, Result};
37use crate::math;
38
39/// Maximum magnitude of a magnetic variation, degrees.
40pub const MAX_VARIATION_DEG: f64 = 180.0;
41
42/// Maximum magnitude of a compass deviation, degrees.
43pub const MAX_DEVIATION_DEG: f64 = 180.0;
44
45/// Normalises a finite angle into `[0.0, 360.0)`.
46///
47/// Correct for every finite input, including values below `-360°`, unlike
48/// `(angle + 360.0) % 360.0`.
49#[must_use]
50pub fn wrap360(degrees: f64) -> f64 {
51    let remainder = degrees % 360.0;
52    if remainder < 0.0 {
53        let shifted = remainder + 360.0;
54        // A remainder such as -1e-16 rounds to exactly 360.0 on addition and
55        // would leave the half-open interval; map it to 0.
56        if shifted >= 360.0 {
57            0.0
58        } else {
59            shifted
60        }
61    } else {
62        // Adding zero turns a `-0.0` remainder into `+0.0`.
63        remainder + 0.0
64    }
65}
66
67/// Normalises a finite angle into `[-180.0, 180.0)`.
68#[must_use]
69pub fn wrap180(degrees: f64) -> f64 {
70    let wrapped = wrap360(degrees);
71    if wrapped >= 180.0 {
72        wrapped - 360.0
73    } else {
74        wrapped
75    }
76}
77
78mod sealed {
79    pub trait Sealed {}
80}
81
82/// Reference frame of a [`Direction`].
83///
84/// Sealed: only the frames below exist.
85pub trait Frame: sealed::Sealed + Copy + Clone + fmt::Debug + 'static {
86    /// Frame name for errors and display.
87    const NAME: &'static str;
88    /// Single-letter suffix, as in `045.0°M`.
89    const SUFFIX: char;
90}
91
92/// True (geographic) north.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
94#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
95pub struct True;
96
97/// Magnetic north.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
99#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
100pub struct Magnetic;
101
102/// Compass north of the ship's magnetic compass.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
104#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
105pub struct Compass;
106
107/// Gyrocompass north.
108///
109/// A gyrocompass has no deviation but has a gyro error, hence a separate frame.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
111#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
112pub struct Gyro;
113
114impl sealed::Sealed for True {}
115impl sealed::Sealed for Magnetic {}
116impl sealed::Sealed for Compass {}
117impl sealed::Sealed for Gyro {}
118
119impl Frame for True {
120    const NAME: &'static str = "true";
121    const SUFFIX: char = 'T';
122}
123
124impl Frame for Magnetic {
125    const NAME: &'static str = "magnetic";
126    const SUFFIX: char = 'M';
127}
128
129impl Frame for Compass {
130    const NAME: &'static str = "compass";
131    const SUFFIX: char = 'C';
132}
133
134impl Frame for Gyro {
135    const NAME: &'static str = "gyro";
136    const SUFFIX: char = 'G';
137}
138
139/// Direction in `[0°, 360°)`, tagged with its reference frame.
140///
141/// Courses and bearings share this type: within a frame they are the same
142/// quantity. The type prevents mixing *frames*.
143#[derive(Clone, Copy, PartialEq, PartialOrd, Default)]
144pub struct Direction<F: Frame> {
145    degrees: f64,
146    frame: PhantomData<F>,
147}
148
149/// Course or bearing, true.
150pub type TrueCourse = Direction<True>;
151/// Bearing, true. Alias of [`TrueCourse`].
152pub type TrueBearing = Direction<True>;
153/// Course or bearing, magnetic.
154pub type MagneticCourse = Direction<Magnetic>;
155/// Bearing, magnetic. Alias of [`MagneticCourse`].
156pub type MagneticBearing = Direction<Magnetic>;
157/// Course or bearing, compass.
158pub type CompassCourse = Direction<Compass>;
159/// Bearing, compass. Alias of [`CompassCourse`].
160pub type CompassBearing = Direction<Compass>;
161/// Course, gyro.
162pub type GyroCourse = Direction<Gyro>;
163/// Bearing, gyro. Alias of [`GyroCourse`].
164pub type GyroBearing = Direction<Gyro>;
165
166impl<F: Frame> Direction<F> {
167    /// Due north, `000°`.
168    pub const NORTH: Self = Self::from_wrapped(0.0);
169    /// Due east, `090°`.
170    pub const EAST: Self = Self::from_wrapped(90.0);
171    /// Due south, `180°`.
172    pub const SOUTH: Self = Self::from_wrapped(180.0);
173    /// Due west, `270°`.
174    pub const WEST: Self = Self::from_wrapped(270.0);
175
176    /// Direction from a value already in `[0.0, 360.0)`.
177    const fn from_wrapped(degrees: f64) -> Self {
178        Self {
179            degrees,
180            frame: PhantomData,
181        }
182    }
183
184    /// Wraps a value known to be finite.
185    ///
186    /// For algorithms whose inputs are validated newtypes, so the result cannot
187    /// be `NaN`. A non-finite argument breaks the type invariant; use
188    /// [`Direction::wrap`] otherwise.
189    ///
190    /// Internal to the crate family: hidden, not covered by the stability
191    /// guarantee. See [hidden items](crate#hidden-items).
192    #[doc(hidden)]
193    #[must_use]
194    pub fn from_degrees_wrapped(degrees: f64) -> Self {
195        Self::from_wrapped(wrap360(degrees))
196    }
197
198    /// Direction from a value in `[0.0, 360.0]`; `360.0` maps to `0.0`.
199    ///
200    /// Out-of-range values are rejected, not wrapped: `400°` is more likely a
201    /// typo or unit error than `040°`. Use [`Direction::wrap`] to wrap.
202    ///
203    /// # Errors
204    ///
205    /// [`KernelError::NotFinite`] for `NaN` or infinity;
206    /// [`KernelError::OutOfRange`] outside `[0.0, 360.0]`.
207    pub fn new(degrees: f64) -> Result<Self> {
208        ensure_range("course", degrees, 0.0, 360.0)?;
209        Ok(Self::from_wrapped(wrap360(degrees)))
210    }
211
212    /// Direction from any finite value, normalised into `[0.0, 360.0)`.
213    ///
214    /// # Errors
215    ///
216    /// [`KernelError::NotFinite`] for `NaN` or infinity.
217    pub fn wrap(degrees: f64) -> Result<Self> {
218        ensure_finite("course", degrees)?;
219        Ok(Self::from_wrapped(wrap360(degrees)))
220    }
221
222    /// Degrees, in `[0.0, 360.0)`.
223    #[must_use]
224    pub const fn degrees(self) -> f64 {
225        self.degrees
226    }
227
228    /// Radians, in `[0.0, 2π)`.
229    #[must_use]
230    pub fn radians(self) -> f64 {
231        math::to_radians(self.degrees)
232    }
233
234    /// Reciprocal direction.
235    #[must_use]
236    pub fn reciprocal(self) -> Self {
237        Self::from_wrapped(wrap360(self.degrees + 180.0))
238    }
239
240    /// North and east components of a vector of `magnitude` in this direction,
241    /// in the magnitude's unit.
242    ///
243    /// The single place where velocity triangles (current triangle, relative
244    /// motion, drift) resolve their sides.
245    #[must_use]
246    pub fn components(self, magnitude: f64) -> (f64, f64) {
247        let radians = self.radians();
248        (
249            magnitude * math::cos(radians),
250            magnitude * math::sin(radians),
251        )
252    }
253
254    /// Rotates by `delta` degrees, wrapping.
255    ///
256    /// # Errors
257    ///
258    /// [`KernelError::NotFinite`] if `delta` is not finite.
259    pub fn offset(self, delta: f64) -> Result<Self> {
260        ensure_finite("delta", delta)?;
261        Ok(Self::from_wrapped(wrap360(self.degrees + delta)))
262    }
263
264    /// Signed shortest angle from `self` to `other`, in `[-180.0, 180.0)`;
265    /// positive clockwise.
266    #[must_use]
267    pub fn signed_difference(self, other: Self) -> f64 {
268        wrap180(other.degrees - self.degrees)
269    }
270
271    /// Unsigned shortest angle between two directions, in `[0.0, 180.0]`.
272    #[must_use]
273    pub fn angular_distance(self, other: Self) -> f64 {
274        math::abs(self.signed_difference(other))
275    }
276
277    /// Changes the frame tag without changing the value.
278    ///
279    /// Hidden on purpose: a frame change must go through a conversion in
280    /// `kinavis::navigation_solutions` that applies variation or deviation.
281    /// This is the primitive those conversions use.
282    ///
283    /// Internal to the crate family: hidden, not covered by the stability
284    /// guarantee. See [hidden items](crate#hidden-items).
285    #[doc(hidden)]
286    #[must_use]
287    pub const fn relabel<G: Frame>(self) -> Direction<G> {
288        Direction::from_wrapped(self.degrees)
289    }
290}
291
292impl<F: Frame> fmt::Display for Direction<F> {
293    /// Formats as `045.0°T`.
294    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        let precision = f.precision().unwrap_or(1);
296        write!(
297            f,
298            "{:0>width$.precision$}°{}",
299            self.degrees,
300            F::SUFFIX,
301            width = if precision == 0 { 3 } else { precision + 4 },
302        )
303    }
304}
305
306impl<F: Frame> fmt::Debug for Direction<F> {
307    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308        write!(f, "{}({}°)", F::NAME, self.degrees)
309    }
310}
311
312/// Magnetic variation: angle from true north to magnetic north.
313///
314/// East positive. True = magnetic + variation.
315#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
316#[cfg_attr(
317    feature = "serde",
318    derive(serde::Serialize, serde::Deserialize),
319    serde(try_from = "f64", into = "f64")
320)]
321pub struct Variation(f64);
322
323impl Variation {
324    /// No variation.
325    pub const ZERO: Self = Self(0.0);
326
327    /// Variation from a value in `[-180.0, 180.0]` degrees.
328    ///
329    /// # Errors
330    ///
331    /// [`KernelError::NotFinite`] for `NaN` or infinity;
332    /// [`KernelError::OutOfRange`] outside `[-180.0, 180.0]`.
333    pub fn new(degrees: f64) -> Result<Self> {
334        ensure_range("variation", degrees, -MAX_VARIATION_DEG, MAX_VARIATION_DEG)?;
335        Ok(Self(degrees))
336    }
337
338    /// Degrees, east positive.
339    #[must_use]
340    pub const fn degrees(self) -> f64 {
341        self.0
342    }
343}
344
345impl fmt::Display for Variation {
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        let precision = f.precision().unwrap_or(1);
348        let hemisphere = if self.0 < 0.0 { 'W' } else { 'E' };
349        write!(f, "{:.precision$}°{hemisphere}", math::abs(self.0))
350    }
351}
352
353/// Compass deviation: angle from magnetic north to compass north.
354///
355/// East positive. Magnetic = compass + deviation.
356#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
357#[cfg_attr(
358    feature = "serde",
359    derive(serde::Serialize, serde::Deserialize),
360    serde(try_from = "f64", into = "f64")
361)]
362pub struct Deviation(f64);
363
364impl Deviation {
365    /// No deviation.
366    pub const ZERO: Self = Self(0.0);
367
368    /// Deviation from a value in `[-180.0, 180.0]` degrees.
369    ///
370    /// # Errors
371    ///
372    /// [`KernelError::NotFinite`] for `NaN` or infinity;
373    /// [`KernelError::OutOfRange`] outside `[-180.0, 180.0]`.
374    pub fn new(degrees: f64) -> Result<Self> {
375        ensure_range("deviation", degrees, -MAX_DEVIATION_DEG, MAX_DEVIATION_DEG)?;
376        Ok(Self(degrees))
377    }
378
379    /// Degrees, east positive.
380    #[must_use]
381    pub const fn degrees(self) -> f64 {
382        self.0
383    }
384}
385
386impl fmt::Display for Deviation {
387    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
388        let precision = f.precision().unwrap_or(1);
389        let hemisphere = if self.0 < 0.0 { 'W' } else { 'E' };
390        write!(f, "{:.precision$}°{hemisphere}", math::abs(self.0))
391    }
392}
393
394/// Cardinal or intercardinal compass point.
395///
396/// A type rather than a string, so deviation-table points cannot be misspelled:
397/// a typo is a compile error, or [`KernelError::UnknownCardinalDirection`] when
398/// parsed, never a silent failed lookup.
399///
400/// `#[non_exhaustive]`; match with a wildcard arm.
401///
402/// # Example
403///
404/// ```rust
405/// use kinavis_kernel::{CardinalPoint, CompassCourse};
406///
407/// assert_eq!(CardinalPoint::SW.degrees(), 225.0);
408/// assert_eq!("sw".parse::<CardinalPoint>()?, CardinalPoint::SW);
409/// assert_eq!(CompassCourse::from(CardinalPoint::SW).degrees(), 225.0);
410/// # Ok::<(), kinavis_kernel::KernelError>(())
411/// ```
412#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
413#[non_exhaustive]
414#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
415pub enum CardinalPoint {
416    /// North, `000°`.
417    #[default]
418    N,
419    /// North-east, `045°`.
420    NE,
421    /// East, `090°`.
422    E,
423    /// South-east, `135°`.
424    SE,
425    /// South, `180°`.
426    S,
427    /// South-west, `225°`.
428    SW,
429    /// West, `270°`.
430    W,
431    /// North-west, `315°`.
432    NW,
433}
434
435impl CardinalPoint {
436    /// All eight points, N to NW.
437    pub const ALL: [Self; 8] = [
438        Self::N,
439        Self::NE,
440        Self::E,
441        Self::SE,
442        Self::S,
443        Self::SW,
444        Self::W,
445        Self::NW,
446    ];
447
448    /// Course of the point in whole degrees, `0..360`.
449    ///
450    /// The points fall on multiples of 45°, and deviation tables are indexed by
451    /// whole degrees.
452    #[must_use]
453    pub const fn whole_degrees(self) -> i32 {
454        match self {
455            Self::N => 0,
456            Self::NE => 45,
457            Self::E => 90,
458            Self::SE => 135,
459            Self::S => 180,
460            Self::SW => 225,
461            Self::W => 270,
462            Self::NW => 315,
463        }
464    }
465
466    /// Course of the point in degrees, `[0.0, 360.0)`.
467    #[must_use]
468    pub fn degrees(self) -> f64 {
469        f64::from(self.whole_degrees())
470    }
471
472    /// Abbreviation, `"N"` to `"NW"`.
473    #[must_use]
474    pub const fn abbreviation(self) -> &'static str {
475        match self {
476            Self::N => "N",
477            Self::NE => "NE",
478            Self::E => "E",
479            Self::SE => "SE",
480            Self::S => "S",
481            Self::SW => "SW",
482            Self::W => "W",
483            Self::NW => "NW",
484        }
485    }
486}
487
488impl fmt::Display for CardinalPoint {
489    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
490        f.write_str(self.abbreviation())
491    }
492}
493
494impl core::str::FromStr for CardinalPoint {
495    type Err = KernelError;
496
497    /// Parses an abbreviation, case-insensitive, trimming whitespace.
498    ///
499    /// # Errors
500    ///
501    /// [`KernelError::UnknownCardinalDirection`] for anything else.
502    fn from_str(text: &str) -> Result<Self> {
503        let trimmed = text.trim();
504        Self::ALL
505            .into_iter()
506            .find(|point| point.abbreviation().eq_ignore_ascii_case(trimmed))
507            .ok_or_else(|| KernelError::UnknownCardinalDirection {
508                direction: trimmed.into(),
509            })
510    }
511}
512
513impl<F: Frame> From<CardinalPoint> for Direction<F> {
514    /// The point as a direction in any frame; the point itself has no frame.
515    fn from(point: CardinalPoint) -> Self {
516        Self::from_wrapped(point.degrees())
517    }
518}
519
520/// Side relative to the bow.
521///
522/// `#[non_exhaustive]`; match with a wildcard arm.
523#[non_exhaustive]
524#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
525#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
526pub enum Side {
527    /// Dead ahead, within tolerance.
528    Ahead,
529    /// Starboard: `000°` to `180°` relative.
530    Starboard,
531    /// Dead astern, within tolerance.
532    Astern,
533    /// Port: `180°` to `360°` relative.
534    Port,
535}
536
537/// Bearing clockwise from the ship's head, in `[0°, 360°)`.
538#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
539#[cfg_attr(
540    feature = "serde",
541    derive(serde::Serialize, serde::Deserialize),
542    serde(try_from = "f64", into = "f64")
543)]
544pub struct RelativeBearing(f64);
545
546impl RelativeBearing {
547    /// Dead ahead.
548    pub const AHEAD: Self = Self(0.0);
549    /// Starboard beam.
550    pub const ABEAM_STARBOARD: Self = Self(90.0);
551    /// Dead astern.
552    pub const ASTERN: Self = Self(180.0);
553    /// Port beam.
554    pub const ABEAM_PORT: Self = Self(270.0);
555
556    /// Relative bearing from a value in `[0.0, 360.0]`; `360.0` maps to `0.0`.
557    ///
558    /// # Errors
559    ///
560    /// [`KernelError::NotFinite`] for `NaN` or infinity;
561    /// [`KernelError::OutOfRange`] outside `[0.0, 360.0]`.
562    pub fn new(degrees: f64) -> Result<Self> {
563        ensure_range("relative bearing", degrees, 0.0, 360.0)?;
564        Ok(Self(wrap360(degrees)))
565    }
566
567    /// Relative bearing from any finite value, normalised into `[0.0, 360.0)`.
568    ///
569    /// # Errors
570    ///
571    /// [`KernelError::NotFinite`] for `NaN` or infinity.
572    pub fn wrap(degrees: f64) -> Result<Self> {
573        ensure_finite("relative bearing", degrees)?;
574        Ok(Self(wrap360(degrees)))
575    }
576
577    /// Wraps a value known to be finite.
578    ///
579    /// A non-finite argument breaks the type invariant; use
580    /// [`RelativeBearing::wrap`] otherwise.
581    ///
582    /// Internal to the crate family: hidden, not covered by the stability
583    /// guarantee. See [hidden items](crate#hidden-items).
584    #[doc(hidden)]
585    #[must_use]
586    pub fn from_degrees_wrapped(degrees: f64) -> Self {
587        Self(wrap360(degrees))
588    }
589
590    /// Degrees, in `[0.0, 360.0)`.
591    #[must_use]
592    pub const fn degrees(self) -> f64 {
593        self.0
594    }
595
596    /// Signed angle in `[-180.0, 180.0)`; positive starboard, negative port.
597    #[must_use]
598    pub fn signed_degrees(self) -> f64 {
599        wrap180(self.0)
600    }
601
602    /// Side relative to the bow.
603    #[must_use]
604    pub fn side(self) -> Side {
605        const TOLERANCE: f64 = 1e-9;
606        let signed = self.signed_degrees();
607        if math::abs(signed) < TOLERANCE {
608            Side::Ahead
609        } else if math::abs(math::abs(signed) - 180.0) < TOLERANCE {
610            Side::Astern
611        } else if signed > 0.0 {
612            Side::Starboard
613        } else {
614            Side::Port
615        }
616    }
617}
618
619impl fmt::Display for RelativeBearing {
620    /// Formats as `030.0° green` / `045.0° red`.
621    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
622        let precision = f.precision().unwrap_or(1);
623        let magnitude = math::abs(self.signed_degrees());
624        match self.side() {
625            Side::Ahead => write!(f, "dead ahead"),
626            Side::Astern => write!(f, "dead astern"),
627            Side::Starboard => write!(f, "{magnitude:.precision$}° green"),
628            Side::Port => write!(f, "{magnitude:.precision$}° red"),
629        }
630    }
631}
632
633#[cfg(feature = "serde")]
634impl TryFrom<f64> for Variation {
635    type Error = KernelError;
636
637    /// Validated on deserialisation.
638    fn try_from(value: f64) -> Result<Self> {
639        Self::new(value)
640    }
641}
642
643#[cfg(feature = "serde")]
644impl From<Variation> for f64 {
645    fn from(value: Variation) -> Self {
646        value.0
647    }
648}
649
650#[cfg(feature = "serde")]
651impl TryFrom<f64> for Deviation {
652    type Error = KernelError;
653
654    /// Validated on deserialisation.
655    fn try_from(value: f64) -> Result<Self> {
656        Self::new(value)
657    }
658}
659
660#[cfg(feature = "serde")]
661impl From<Deviation> for f64 {
662    fn from(value: Deviation) -> Self {
663        value.0
664    }
665}
666
667#[cfg(feature = "serde")]
668impl TryFrom<f64> for RelativeBearing {
669    type Error = KernelError;
670
671    /// Validated on deserialisation.
672    fn try_from(value: f64) -> Result<Self> {
673        Self::new(value)
674    }
675}
676
677#[cfg(feature = "serde")]
678impl From<RelativeBearing> for f64 {
679    fn from(value: RelativeBearing) -> Self {
680        value.0
681    }
682}
683
684#[cfg(feature = "serde")]
685impl<F: Frame> serde::Serialize for Direction<F> {
686    /// Serialised as plain degrees; the frame is in the type.
687    fn serialize<S: serde::Serializer>(
688        &self,
689        serializer: S,
690    ) -> core::result::Result<S::Ok, S::Error> {
691        serializer.serialize_f64(self.degrees)
692    }
693}
694
695#[cfg(feature = "serde")]
696impl<'de, F: Frame> serde::Deserialize<'de> for Direction<F> {
697    /// Deserialised through [`Direction::new`]; values outside `[0°, 360°]` are
698    /// rejected.
699    fn deserialize<D: serde::Deserializer<'de>>(
700        deserializer: D,
701    ) -> core::result::Result<Self, D::Error> {
702        let degrees = f64::deserialize(deserializer)?;
703        Self::new(degrees).map_err(serde::de::Error::custom)
704    }
705}
706
707#[cfg(test)]
708#[allow(clippy::unwrap_used, clippy::float_cmp, clippy::indexing_slicing)]
709mod tests {
710    use super::*;
711    use alloc::format;
712
713    #[test]
714    fn wrap360_is_correct_below_minus_360() {
715        // `(x + 360.0) % 360.0` gives -40.0 here.
716        assert_eq!(wrap360(-400.0), 320.0);
717        assert_eq!(wrap360(-720.0), 0.0);
718        assert_eq!(wrap360(-0.0), 0.0);
719        assert_eq!(wrap360(0.0), 0.0);
720        assert_eq!(wrap360(360.0), 0.0);
721        assert_eq!(wrap360(725.0), 5.0);
722        assert!(wrap360(-1e15).is_finite());
723    }
724
725    #[test]
726    fn wrap360_keeps_tiny_negatives_off_the_far_end() {
727        // These round to exactly 360.0 under naive addition.
728        for value in [-1e-16, -1e-18, -f64::MIN_POSITIVE, -1e-14] {
729            let wrapped = wrap360(value);
730            assert!(
731                (0.0..360.0).contains(&wrapped),
732                "{value} wrapped to {wrapped}"
733            );
734        }
735        assert_eq!(wrap360(-1e-16), 0.0);
736        // A representable large negative still lands just below 360.
737        assert!(wrap360(-1e-10) < 360.0);
738        assert!(wrap360(-1e-10) > 359.999);
739    }
740
741    #[test]
742    fn wrap360_never_leaves_the_interval() {
743        let mut value = -2000.0;
744        while value < 2000.0 {
745            let wrapped = wrap360(value);
746            assert!((0.0..360.0).contains(&wrapped), "{value} -> {wrapped}");
747            value += 0.37;
748        }
749    }
750
751    #[test]
752    fn wrap180_is_symmetric() {
753        assert_eq!(wrap180(0.0), 0.0);
754        assert_eq!(wrap180(90.0), 90.0);
755        assert_eq!(wrap180(180.0), -180.0);
756        assert_eq!(wrap180(190.0), -170.0);
757        assert_eq!(wrap180(-190.0), 170.0);
758    }
759
760    #[test]
761    fn direction_rejects_bad_input() {
762        assert!(TrueCourse::new(f64::NAN).is_err());
763        assert!(TrueCourse::new(f64::INFINITY).is_err());
764        assert!(TrueCourse::new(-0.1).is_err());
765        assert!(TrueCourse::new(400.0).is_err());
766        assert!(TrueCourse::wrap(f64::NAN).is_err());
767    }
768
769    #[test]
770    fn direction_normalises() {
771        assert_eq!(TrueCourse::new(360.0).unwrap().degrees(), 0.0);
772        assert_eq!(TrueCourse::wrap(-10.0).unwrap().degrees(), 350.0);
773        assert_eq!(TrueCourse::wrap(730.0).unwrap().degrees(), 10.0);
774    }
775
776    #[test]
777    fn reciprocal_round_trips() {
778        for degrees in [0.0, 45.0, 179.0, 180.0, 359.9] {
779            let direction = TrueCourse::new(degrees).unwrap();
780            assert!((direction.reciprocal().reciprocal().degrees() - degrees).abs() < 1e-12);
781        }
782    }
783
784    #[test]
785    fn signed_difference_takes_the_short_way() {
786        let a = TrueCourse::new(350.0).unwrap();
787        let b = TrueCourse::new(10.0).unwrap();
788        assert!((a.signed_difference(b) - 20.0).abs() < 1e-12);
789        assert!((b.signed_difference(a) + 20.0).abs() < 1e-12);
790        assert!((a.angular_distance(b) - 20.0).abs() < 1e-12);
791    }
792
793    #[test]
794    fn variation_and_deviation_validate() {
795        assert!(Variation::new(-181.0).is_err());
796        assert!(Variation::new(f64::NAN).is_err());
797        assert!(Variation::new(180.0).is_ok());
798        assert!(Deviation::new(f64::INFINITY).is_err());
799        assert_eq!(Deviation::ZERO.degrees(), 0.0);
800    }
801
802    #[test]
803    fn relative_bearing_sides() {
804        assert_eq!(RelativeBearing::new(0.0).unwrap().side(), Side::Ahead);
805        assert_eq!(RelativeBearing::new(90.0).unwrap().side(), Side::Starboard);
806        assert_eq!(RelativeBearing::new(180.0).unwrap().side(), Side::Astern);
807        assert_eq!(RelativeBearing::new(270.0).unwrap().side(), Side::Port);
808        assert!((RelativeBearing::new(270.0).unwrap().signed_degrees() + 90.0).abs() < 1e-12);
809    }
810
811    #[test]
812    fn display_is_chart_style() {
813        assert_eq!(format!("{}", TrueCourse::new(45.0).unwrap()), "045.0°T");
814        assert_eq!(
815            format!("{}", MagneticCourse::new(357.89).unwrap()),
816            "357.9°M"
817        );
818        assert_eq!(format!("{}", Variation::new(-2.7).unwrap()), "2.7°W");
819        assert_eq!(format!("{}", Deviation::new(1.5).unwrap()), "1.5°E");
820        assert_eq!(
821            format!("{}", RelativeBearing::new(300.0).unwrap()),
822            "60.0° red"
823        );
824    }
825
826    #[test]
827    fn cardinal_points_are_multiples_of_forty_five_degrees() {
828        for (index, point) in CardinalPoint::ALL.into_iter().enumerate() {
829            assert_eq!(point.whole_degrees(), i32::try_from(index).unwrap() * 45);
830            assert_eq!(point.degrees(), f64::from(point.whole_degrees()));
831        }
832    }
833
834    #[test]
835    fn cardinal_points_parse_regardless_of_case_and_padding() {
836        for point in CardinalPoint::ALL {
837            let name = point.abbreviation();
838            assert_eq!(name.parse::<CardinalPoint>().unwrap(), point);
839            assert_eq!(name.to_lowercase().parse::<CardinalPoint>().unwrap(), point);
840            assert_eq!(
841                format!("  {name} ").parse::<CardinalPoint>().unwrap(),
842                point
843            );
844            assert_eq!(format!("{point}"), name);
845        }
846    }
847
848    #[test]
849    fn an_unknown_point_names_itself_in_the_error() {
850        assert!(matches!(
851            "NNE".parse::<CardinalPoint>(),
852            Err(KernelError::UnknownCardinalDirection { direction }) if direction == "NNE"
853        ));
854        assert!("north".parse::<CardinalPoint>().is_err());
855        assert!("".parse::<CardinalPoint>().is_err());
856    }
857
858    #[test]
859    fn a_cardinal_point_becomes_a_direction_in_any_frame() {
860        assert_eq!(CompassCourse::from(CardinalPoint::SW).degrees(), 225.0);
861        assert_eq!(TrueCourse::from(CardinalPoint::N).degrees(), 0.0);
862        assert_eq!(
863            MagneticCourse::from(CardinalPoint::NW),
864            Direction::from(CardinalPoint::NW)
865        );
866    }
867}