Skip to main content

kinavis_kernel/
units.rs

1//! Angles, distances, speeds and rates of turn as types.
2//!
3//! A function taking `(f64, f64)` cannot distinguish knots from m/s; these
4//! types can.
5//!
6//! Internally distances are nautical miles, speeds knots, rates of turn °/min;
7//! construct and read in any unit.
8//!
9//! # Example
10//!
11//! ```rust
12//! use kinavis_kernel::{Distance, KernelError, Speed};
13//! use core::time::Duration;
14//!
15//! let leg = Distance::from_nautical_miles(12.0)?;
16//! assert_eq!(format!("{:.0}", leg.metres()), "22224");
17//! assert_eq!(format!("{:.0}", leg.cables()), "120");
18//!
19//! let speed = Speed::from_knots(8.0)?;
20//! let elapsed = speed.time_to_cover(leg)?;
21//! assert_eq!(elapsed.as_secs(), 5400); // an hour and a half
22//!
23//! assert_eq!(speed.distance_covered(elapsed).nautical_miles(), 12.0);
24//! # Ok::<(), KernelError>(())
25//! ```
26
27use core::fmt;
28use core::ops::{Add, Div, Mul, Neg, Sub};
29use core::str::FromStr;
30use core::time::Duration;
31
32use crate::angle::wrap180;
33use crate::error::{ensure_finite, KernelError, Result};
34use crate::math;
35
36/// Metres per international nautical mile.
37pub const METRES_PER_NAUTICAL_MILE: f64 = 1852.0;
38/// Metres per foot.
39pub const METRES_PER_FOOT: f64 = 0.3048;
40/// Metres per fathom (6 ft).
41pub const METRES_PER_FATHOM: f64 = 6.0 * METRES_PER_FOOT;
42/// Cables per nautical mile.
43pub const CABLES_PER_NAUTICAL_MILE: f64 = 10.0;
44/// Seconds per hour.
45const SECONDS_PER_HOUR: f64 = 3600.0;
46/// Seconds per minute.
47const SECONDS_PER_MINUTE: f64 = 60.0;
48/// Minutes per hour.
49const MINUTES_PER_HOUR: f64 = 60.0;
50
51/// Angular magnitude in degrees.
52///
53/// Not a compass direction and not wrapped: a difference, error or subtended
54/// angle whose sign matters (gyro error, sextant angle, leeway).
55#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
56#[cfg_attr(
57    feature = "serde",
58    derive(serde::Serialize, serde::Deserialize),
59    serde(try_from = "f64", into = "f64")
60)]
61pub struct Angle(f64);
62
63impl Angle {
64    /// Zero.
65    pub const ZERO: Self = Self(0.0);
66
67    /// From degrees.
68    ///
69    /// # Errors
70    ///
71    /// [`KernelError::NotFinite`] for `NaN` or infinity.
72    pub fn from_degrees(value: f64) -> Result<Self> {
73        ensure_finite("angle", value)?;
74        Ok(Self(value))
75    }
76
77    /// From minutes of arc.
78    ///
79    /// # Errors
80    ///
81    /// [`KernelError::NotFinite`] for `NaN` or infinity.
82    pub fn from_minutes(value: f64) -> Result<Self> {
83        ensure_finite("angle", value)?;
84        Ok(Self(value / 60.0))
85    }
86
87    /// From degrees, minutes and seconds.
88    ///
89    /// # Errors
90    ///
91    /// [`KernelError::NotFinite`] for `NaN` or infinity.
92    pub fn from_degrees_minutes_seconds(degrees: f64, minutes: f64, seconds: f64) -> Result<Self> {
93        ensure_finite("angle", degrees)?;
94        ensure_finite("angle", minutes)?;
95        ensure_finite("angle", seconds)?;
96        Ok(Self(degrees + minutes / 60.0 + seconds / 3600.0))
97    }
98
99    /// From radians.
100    ///
101    /// # Errors
102    ///
103    /// [`KernelError::NotFinite`] for `NaN` or infinity.
104    pub fn from_radians(value: f64) -> Result<Self> {
105        ensure_finite("angle", value)?;
106        Ok(Self(math::to_degrees(value)))
107    }
108
109    /// From a value known to be finite.
110    ///
111    /// A non-finite argument breaks the invariant; use [`Angle::from_degrees`]
112    /// otherwise.
113    ///
114    /// Internal to the crate family: hidden, not covered by the stability
115    /// guarantee. See [hidden items](crate#hidden-items).
116    #[doc(hidden)]
117    #[must_use]
118    pub const fn from_degrees_unchecked(value: f64) -> Self {
119        Self(value)
120    }
121
122    /// Degrees.
123    #[must_use]
124    pub const fn degrees(self) -> f64 {
125        self.0
126    }
127
128    /// Minutes of arc.
129    #[must_use]
130    pub fn minutes(self) -> f64 {
131        self.0 * 60.0
132    }
133
134    /// Radians.
135    #[must_use]
136    pub fn radians(self) -> f64 {
137        math::to_radians(self.0)
138    }
139
140    /// Absolute value.
141    #[must_use]
142    pub fn abs(self) -> Self {
143        Self(math::abs(self.0))
144    }
145
146    /// Folded into `[-180°, 180°)`.
147    #[must_use]
148    pub fn normalised(self) -> Self {
149        Self(wrap180(self.0))
150    }
151}
152
153impl fmt::Display for Angle {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        let precision = f.precision().unwrap_or(1);
156        write!(f, "{:.precision$}°", self.0)
157    }
158}
159
160impl Neg for Angle {
161    type Output = Self;
162    fn neg(self) -> Self {
163        Self(-self.0)
164    }
165}
166
167impl Add for Angle {
168    type Output = Self;
169    fn add(self, other: Self) -> Self {
170        Self(self.0 + other.0)
171    }
172}
173
174impl Sub for Angle {
175    type Output = Self;
176    fn sub(self, other: Self) -> Self {
177        Self(self.0 - other.0)
178    }
179}
180
181impl FromStr for Angle {
182    type Err = KernelError;
183
184    /// Parses `1°30.5'`, `1 30 30`, `-2.7` etc.
185    ///
186    /// Sign only; hemisphere letters are rejected.
187    ///
188    /// # Errors
189    ///
190    /// [`KernelError::Parse`] for unreadable input, including a hemisphere
191    /// letter.
192    fn from_str(input: &str) -> Result<Self> {
193        let parsed = crate::parse::sexagesimal("angle", input)?;
194        if parsed.hemisphere.is_some() {
195            return Err(crate::parse::parse_error("angle", input));
196        }
197        Self::from_degrees(parsed.signed(""))
198    }
199}
200
201/// Distance, stored in nautical miles.
202///
203/// Signed: e.g. along-track distance is negative before the start of a leg.
204/// Functions requiring a positive distance check it.
205#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
206#[cfg_attr(
207    feature = "serde",
208    derive(serde::Serialize, serde::Deserialize),
209    serde(try_from = "f64", into = "f64")
210)]
211pub struct Distance(f64);
212
213impl Distance {
214    /// Zero.
215    pub const ZERO: Self = Self(0.0);
216
217    /// From nautical miles.
218    ///
219    /// # Errors
220    ///
221    /// [`KernelError::NotFinite`] for `NaN` or infinity.
222    pub fn from_nautical_miles(value: f64) -> Result<Self> {
223        ensure_finite("distance", value)?;
224        Ok(Self(value))
225    }
226
227    /// From cables (10 per NM).
228    ///
229    /// # Errors
230    ///
231    /// [`KernelError::NotFinite`] for `NaN` or infinity.
232    pub fn from_cables(value: f64) -> Result<Self> {
233        ensure_finite("distance", value)?;
234        Ok(Self(value / CABLES_PER_NAUTICAL_MILE))
235    }
236
237    /// From metres.
238    ///
239    /// # Errors
240    ///
241    /// [`KernelError::NotFinite`] for `NaN` or infinity.
242    pub fn from_metres(value: f64) -> Result<Self> {
243        ensure_finite("distance", value)?;
244        Ok(Self(value / METRES_PER_NAUTICAL_MILE))
245    }
246
247    /// From kilometres.
248    ///
249    /// # Errors
250    ///
251    /// [`KernelError::NotFinite`] for `NaN` or infinity.
252    pub fn from_kilometres(value: f64) -> Result<Self> {
253        ensure_finite("distance", value)?;
254        Ok(Self(value * 1000.0 / METRES_PER_NAUTICAL_MILE))
255    }
256
257    /// From feet (e.g. charted heights of lights).
258    ///
259    /// # Errors
260    ///
261    /// [`KernelError::NotFinite`] for `NaN` or infinity.
262    pub fn from_feet(value: f64) -> Result<Self> {
263        ensure_finite("distance", value)?;
264        Ok(Self(value * METRES_PER_FOOT / METRES_PER_NAUTICAL_MILE))
265    }
266
267    /// From fathoms.
268    ///
269    /// # Errors
270    ///
271    /// [`KernelError::NotFinite`] for `NaN` or infinity.
272    pub fn from_fathoms(value: f64) -> Result<Self> {
273        ensure_finite("distance", value)?;
274        Ok(Self(value * METRES_PER_FATHOM / METRES_PER_NAUTICAL_MILE))
275    }
276
277    /// From minutes of arc of a great circle (1′ = 1 NM).
278    ///
279    /// # Errors
280    ///
281    /// [`KernelError::NotFinite`] for `NaN` or infinity.
282    pub fn from_arc_minutes(value: f64) -> Result<Self> {
283        Self::from_nautical_miles(value)
284    }
285
286    /// From a value known to be finite.
287    ///
288    /// A non-finite argument breaks the invariant; use
289    /// [`Distance::from_nautical_miles`] otherwise.
290    ///
291    /// Internal to the crate family: hidden, not covered by the stability
292    /// guarantee. See [hidden items](crate#hidden-items).
293    #[doc(hidden)]
294    #[must_use]
295    pub const fn from_nautical_miles_unchecked(value: f64) -> Self {
296        Self(value)
297    }
298
299    /// Nautical miles.
300    #[must_use]
301    pub const fn nautical_miles(self) -> f64 {
302        self.0
303    }
304
305    /// Cables.
306    #[must_use]
307    pub fn cables(self) -> f64 {
308        self.0 * CABLES_PER_NAUTICAL_MILE
309    }
310
311    /// Metres.
312    #[must_use]
313    pub fn metres(self) -> f64 {
314        self.0 * METRES_PER_NAUTICAL_MILE
315    }
316
317    /// Kilometres.
318    #[must_use]
319    pub fn kilometres(self) -> f64 {
320        self.0 * METRES_PER_NAUTICAL_MILE / 1000.0
321    }
322
323    /// Feet.
324    #[must_use]
325    pub fn feet(self) -> f64 {
326        self.0 * METRES_PER_NAUTICAL_MILE / METRES_PER_FOOT
327    }
328
329    /// Absolute value.
330    #[must_use]
331    pub fn abs(self) -> Self {
332        Self(math::abs(self.0))
333    }
334
335    /// Whether negative.
336    #[must_use]
337    pub fn is_negative(self) -> bool {
338        self.0 < 0.0
339    }
340
341    /// Time to cover this distance at `speed`.
342    ///
343    /// # Errors
344    ///
345    /// [`KernelError::Indeterminate`] if the speed is zero or of opposite sign.
346    pub fn time_at(self, speed: Speed) -> Result<Duration> {
347        speed.time_to_cover(self)
348    }
349}
350
351impl fmt::Display for Distance {
352    /// Formats in nautical miles: `12.0 M`.
353    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
354        let precision = f.precision().unwrap_or(1);
355        write!(f, "{:.precision$} M", self.0)
356    }
357}
358
359impl Neg for Distance {
360    type Output = Self;
361    fn neg(self) -> Self {
362        Self(-self.0)
363    }
364}
365
366impl Add for Distance {
367    type Output = Self;
368    fn add(self, other: Self) -> Self {
369        Self(self.0 + other.0)
370    }
371}
372
373impl Sub for Distance {
374    type Output = Self;
375    fn sub(self, other: Self) -> Self {
376        Self(self.0 - other.0)
377    }
378}
379
380impl Mul<f64> for Distance {
381    type Output = Self;
382    fn mul(self, factor: f64) -> Self {
383        Self(self.0 * factor)
384    }
385}
386
387impl Div<f64> for Distance {
388    type Output = Self;
389    fn div(self, divisor: f64) -> Self {
390        Self(self.0 / divisor)
391    }
392}
393
394/// Speed, stored in knots. Negative is sternway.
395#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
396#[cfg_attr(
397    feature = "serde",
398    derive(serde::Serialize, serde::Deserialize),
399    serde(try_from = "f64", into = "f64")
400)]
401pub struct Speed(f64);
402
403impl Speed {
404    /// Zero.
405    pub const ZERO: Self = Self(0.0);
406
407    /// From knots.
408    ///
409    /// # Errors
410    ///
411    /// [`KernelError::NotFinite`] for `NaN` or infinity.
412    pub fn from_knots(value: f64) -> Result<Self> {
413        ensure_finite("speed", value)?;
414        Ok(Self(value))
415    }
416
417    /// From m/s.
418    ///
419    /// # Errors
420    ///
421    /// [`KernelError::NotFinite`] for `NaN` or infinity.
422    pub fn from_metres_per_second(value: f64) -> Result<Self> {
423        ensure_finite("speed", value)?;
424        Ok(Self(value * SECONDS_PER_HOUR / METRES_PER_NAUTICAL_MILE))
425    }
426
427    /// From km/h.
428    ///
429    /// # Errors
430    ///
431    /// [`KernelError::NotFinite`] for `NaN` or infinity.
432    pub fn from_kilometres_per_hour(value: f64) -> Result<Self> {
433        ensure_finite("speed", value)?;
434        Ok(Self(value * 1000.0 / METRES_PER_NAUTICAL_MILE))
435    }
436
437    /// From a value known to be finite.
438    ///
439    /// A non-finite argument breaks the invariant; use [`Speed::from_knots`]
440    /// otherwise.
441    ///
442    /// Internal to the crate family: hidden, not covered by the stability
443    /// guarantee. See [hidden items](crate#hidden-items).
444    #[doc(hidden)]
445    #[must_use]
446    pub const fn from_knots_unchecked(value: f64) -> Self {
447        Self(value)
448    }
449
450    /// Knots.
451    #[must_use]
452    pub const fn knots(self) -> f64 {
453        self.0
454    }
455
456    /// m/s.
457    #[must_use]
458    pub fn metres_per_second(self) -> f64 {
459        self.0 * METRES_PER_NAUTICAL_MILE / SECONDS_PER_HOUR
460    }
461
462    /// km/h.
463    #[must_use]
464    pub fn kilometres_per_hour(self) -> f64 {
465        self.0 * METRES_PER_NAUTICAL_MILE / 1000.0
466    }
467
468    /// Absolute value.
469    #[must_use]
470    pub fn abs(self) -> Self {
471        Self(math::abs(self.0))
472    }
473
474    /// Whether negative (sternway).
475    #[must_use]
476    pub fn is_negative(self) -> bool {
477        self.0 < 0.0
478    }
479
480    /// Distance covered in `duration`.
481    #[must_use]
482    pub fn distance_covered(self, elapsed: Duration) -> Distance {
483        Distance(self.0 * elapsed.as_secs_f64() / SECONDS_PER_HOUR)
484    }
485
486    /// Time to cover `distance`.
487    ///
488    /// # Errors
489    ///
490    /// [`KernelError::Indeterminate`] if the speed is zero or of opposite sign
491    /// to the distance.
492    pub fn time_to_cover(self, distance: Distance) -> Result<Duration> {
493        let hours = distance.0 / self.0;
494        if !hours.is_finite() || hours < 0.0 {
495            return Err(KernelError::Indeterminate {
496                quantity: "time to cover the distance",
497            });
498        }
499        Duration::try_from_secs_f64(hours * SECONDS_PER_HOUR).map_err(|_| {
500            KernelError::Unrepresentable {
501                what: "the time to cover the distance",
502            }
503        })
504    }
505}
506
507impl fmt::Display for Speed {
508    /// Formats in knots: `8.0 kn`.
509    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
510        let precision = f.precision().unwrap_or(1);
511        write!(f, "{:.precision$} kn", self.0)
512    }
513}
514
515impl Neg for Speed {
516    type Output = Self;
517    fn neg(self) -> Self {
518        Self(-self.0)
519    }
520}
521
522impl Add for Speed {
523    type Output = Self;
524    fn add(self, other: Self) -> Self {
525        Self(self.0 + other.0)
526    }
527}
528
529impl Sub for Speed {
530    type Output = Self;
531    fn sub(self, other: Self) -> Self {
532        Self(self.0 - other.0)
533    }
534}
535
536impl Mul<f64> for Speed {
537    type Output = Self;
538    fn mul(self, factor: f64) -> Self {
539        Self(self.0 * factor)
540    }
541}
542
543/// Rate of turn in °/min, as shown by the ROT indicator.
544///
545/// Positive to starboard. Keeps °/min distinct from rad/s.
546#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
547#[cfg_attr(
548    feature = "serde",
549    derive(serde::Serialize, serde::Deserialize),
550    serde(try_from = "f64", into = "f64")
551)]
552pub struct RateOfTurn(f64);
553
554impl RateOfTurn {
555    /// Zero.
556    pub const ZERO: Self = Self(0.0);
557
558    /// From °/min.
559    ///
560    /// # Errors
561    ///
562    /// [`KernelError::NotFinite`] for `NaN` or infinity.
563    pub fn from_degrees_per_minute(value: f64) -> Result<Self> {
564        ensure_finite("rate of turn", value)?;
565        Ok(Self(value))
566    }
567
568    /// From rad/s.
569    ///
570    /// # Errors
571    ///
572    /// [`KernelError::NotFinite`] for `NaN` or infinity.
573    pub fn from_radians_per_second(value: f64) -> Result<Self> {
574        ensure_finite("rate of turn", value)?;
575        Ok(Self(math::to_degrees(value) * SECONDS_PER_MINUTE))
576    }
577
578    /// °/min.
579    #[must_use]
580    pub const fn degrees_per_minute(self) -> f64 {
581        self.0
582    }
583
584    /// rad/s.
585    #[must_use]
586    pub fn radians_per_second(self) -> f64 {
587        math::to_radians(self.0) / SECONDS_PER_MINUTE
588    }
589
590    /// Absolute value.
591    #[must_use]
592    pub fn abs(self) -> Self {
593        Self(math::abs(self.0))
594    }
595
596    /// Whether turning to port.
597    #[must_use]
598    pub fn is_to_port(self) -> bool {
599        self.0 < 0.0
600    }
601
602    /// Turning radius at `speed`: speed / rate (rad/h).
603    ///
604    /// # Errors
605    ///
606    /// [`KernelError::Indeterminate`] for a zero rate.
607    pub fn radius_at(self, speed: Speed) -> Result<Distance> {
608        let radians_per_hour = math::to_radians(self.0) * MINUTES_PER_HOUR;
609        let radius = speed.0 / radians_per_hour;
610        if !radius.is_finite() {
611            return Err(KernelError::Indeterminate {
612                quantity: "the radius of a turn at no rate",
613            });
614        }
615        Ok(Distance(math::abs(radius)))
616    }
617
618    /// Rate to starboard for a circle of `radius` at `speed`.
619    ///
620    /// # Errors
621    ///
622    /// [`KernelError::Indeterminate`] for a zero radius.
623    pub fn around(radius: Distance, speed: Speed) -> Result<Self> {
624        let radians_per_hour = speed.0 / radius.0;
625        if !radians_per_hour.is_finite() {
626            return Err(KernelError::Indeterminate {
627                quantity: "the rate of a turn of no radius",
628            });
629        }
630        Ok(Self(
631            math::to_degrees(math::abs(radians_per_hour)) / MINUTES_PER_HOUR,
632        ))
633    }
634}
635
636impl fmt::Display for RateOfTurn {
637    /// Formats in °/min: `12.0°/min`.
638    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
639        let precision = f.precision().unwrap_or(1);
640        write!(f, "{:.precision$}°/min", self.0)
641    }
642}
643
644impl Neg for RateOfTurn {
645    type Output = Self;
646    fn neg(self) -> Self {
647        Self(-self.0)
648    }
649}
650
651/// Duration in hours.
652#[must_use]
653pub fn hours(elapsed: Duration) -> f64 {
654    elapsed.as_secs_f64() / SECONDS_PER_HOUR
655}
656
657/// Duration from hours.
658///
659/// # Errors
660///
661/// [`KernelError::Indeterminate`] for negative, non-finite or unrepresentable
662/// values.
663pub fn duration_from_hours(value: f64) -> Result<Duration> {
664    if !value.is_finite() || value < 0.0 {
665        return Err(KernelError::Indeterminate {
666            quantity: "elapsed time",
667        });
668    }
669    Duration::try_from_secs_f64(value * SECONDS_PER_HOUR).map_err(|_| {
670        KernelError::Unrepresentable {
671            what: "the elapsed time",
672        }
673    })
674}
675
676#[cfg(feature = "serde")]
677impl TryFrom<f64> for Angle {
678    type Error = KernelError;
679
680    /// Validated on deserialisation.
681    fn try_from(value: f64) -> Result<Self> {
682        Self::from_degrees(value)
683    }
684}
685
686#[cfg(feature = "serde")]
687impl From<Angle> for f64 {
688    fn from(value: Angle) -> Self {
689        value.0
690    }
691}
692
693#[cfg(feature = "serde")]
694impl TryFrom<f64> for Distance {
695    type Error = KernelError;
696
697    /// Validated on deserialisation.
698    fn try_from(value: f64) -> Result<Self> {
699        Self::from_nautical_miles(value)
700    }
701}
702
703#[cfg(feature = "serde")]
704impl From<Distance> for f64 {
705    fn from(value: Distance) -> Self {
706        value.0
707    }
708}
709
710#[cfg(feature = "serde")]
711impl TryFrom<f64> for Speed {
712    type Error = KernelError;
713
714    /// Validated on deserialisation.
715    fn try_from(value: f64) -> Result<Self> {
716        Self::from_knots(value)
717    }
718}
719
720#[cfg(feature = "serde")]
721impl From<Speed> for f64 {
722    fn from(value: Speed) -> Self {
723        value.0
724    }
725}
726
727#[cfg(feature = "serde")]
728impl TryFrom<f64> for RateOfTurn {
729    type Error = KernelError;
730
731    /// Validated on deserialisation.
732    fn try_from(value: f64) -> Result<Self> {
733        Self::from_degrees_per_minute(value)
734    }
735}
736
737#[cfg(feature = "serde")]
738impl From<RateOfTurn> for f64 {
739    fn from(value: RateOfTurn) -> Self {
740        value.0
741    }
742}
743
744#[cfg(test)]
745#[allow(clippy::unwrap_used, clippy::float_cmp, clippy::indexing_slicing)]
746mod tests {
747    use super::*;
748    use alloc::format;
749
750    #[test]
751    fn a_rate_of_turn_and_a_radius_are_two_views_of_one_circle() {
752        // 10°/min at 10 kn: the circle takes 36 min and is 6 NM round, radius 6
753        // / 2π.
754        let rate = RateOfTurn::from_degrees_per_minute(10.0).unwrap();
755        let speed = Speed::from_knots(10.0).unwrap();
756        let radius = rate.radius_at(speed).unwrap();
757        assert!((radius.nautical_miles() - 6.0 / core::f64::consts::TAU).abs() < 1e-12);
758        let back = RateOfTurn::around(radius, speed).unwrap();
759        assert!((back.degrees_per_minute() - 10.0).abs() < 1e-12);
760
761        // A port turn has the same radius, with its direction.
762        assert_eq!((-rate).radius_at(speed).unwrap(), radius);
763        assert!((-rate).is_to_port());
764        assert!(!rate.is_to_port());
765        assert_eq!((-rate).abs(), rate);
766
767        assert!((rate.radians_per_second() - 10.0_f64.to_radians() / 60.0).abs() < 1e-15);
768        assert!(
769            (RateOfTurn::from_radians_per_second(rate.radians_per_second())
770                .unwrap()
771                .degrees_per_minute()
772                - 10.0)
773                .abs()
774                < 1e-12
775        );
776        assert_eq!(format!("{rate}"), "10.0°/min");
777        assert_eq!(format!("{}", RateOfTurn::ZERO), "0.0°/min");
778    }
779
780    #[test]
781    fn a_turn_at_no_rate_or_of_no_radius_has_no_circle() {
782        let speed = Speed::from_knots(10.0).unwrap();
783        assert!(RateOfTurn::ZERO.radius_at(speed).is_err());
784        assert!(RateOfTurn::around(Distance::ZERO, speed).is_err());
785        assert!(RateOfTurn::from_degrees_per_minute(f64::NAN).is_err());
786        assert!(RateOfTurn::from_radians_per_second(f64::INFINITY).is_err());
787        // Stopped: zero radius, not an error.
788        assert_eq!(
789            RateOfTurn::from_degrees_per_minute(5.0)
790                .unwrap()
791                .radius_at(Speed::ZERO)
792                .unwrap(),
793            Distance::ZERO
794        );
795    }
796
797    #[test]
798    fn distance_units_round_trip() {
799        let distance = Distance::from_nautical_miles(1.0).unwrap();
800        assert_eq!(distance.metres(), 1852.0);
801        assert_eq!(distance.cables(), 10.0);
802        assert!((distance.kilometres() - 1.852).abs() < 1e-12);
803        assert!((distance.feet() - 6076.115).abs() < 1e-3);
804
805        for constructor in [
806            Distance::from_metres(1852.0),
807            Distance::from_cables(10.0),
808            Distance::from_kilometres(1.852),
809            Distance::from_arc_minutes(1.0),
810        ] {
811            assert!((constructor.unwrap().nautical_miles() - 1.0).abs() < 1e-12);
812        }
813
814        assert!(
815            (Distance::from_feet(6.0).unwrap().nautical_miles()
816                - Distance::from_fathoms(1.0).unwrap().nautical_miles())
817            .abs()
818                < 1e-15
819        );
820    }
821
822    #[test]
823    fn speed_units_round_trip() {
824        let speed = Speed::from_knots(1.0).unwrap();
825        assert!((speed.metres_per_second() - 0.514_444_444).abs() < 1e-9);
826        assert!((speed.kilometres_per_hour() - 1.852).abs() < 1e-12);
827        assert!(
828            (Speed::from_metres_per_second(0.514_444_444_444_444_4)
829                .unwrap()
830                .knots()
831                - 1.0)
832                .abs()
833                < 1e-12
834        );
835    }
836
837    #[test]
838    fn distance_and_time_are_consistent() {
839        let speed = Speed::from_knots(8.0).unwrap();
840        let distance = Distance::from_nautical_miles(12.0).unwrap();
841        let elapsed = speed.time_to_cover(distance).unwrap();
842        assert_eq!(elapsed.as_secs(), 5400);
843        assert!((speed.distance_covered(elapsed).nautical_miles() - 12.0).abs() < 1e-12);
844        assert!((distance.time_at(speed).unwrap().as_secs_f64() - 5400.0).abs() < 1e-9);
845    }
846
847    #[test]
848    fn impossible_times_are_errors_not_panics() {
849        let distance = Distance::from_nautical_miles(10.0).unwrap();
850        assert!(Speed::ZERO.time_to_cover(distance).is_err());
851        assert!(Speed::from_knots(-5.0)
852            .unwrap()
853            .time_to_cover(distance)
854            .is_err());
855        assert!(Speed::from_knots(1e-300)
856            .unwrap()
857            .time_to_cover(Distance::from_nautical_miles(1e300).unwrap())
858            .is_err());
859    }
860
861    #[test]
862    fn non_finite_input_is_rejected() {
863        for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
864            assert!(Distance::from_nautical_miles(value).is_err());
865            assert!(Distance::from_metres(value).is_err());
866            assert!(Speed::from_knots(value).is_err());
867            assert!(Angle::from_degrees(value).is_err());
868            assert!(Angle::from_minutes(value).is_err());
869            assert!(Angle::from_radians(value).is_err());
870        }
871    }
872
873    #[test]
874    fn angle_conversions() {
875        let angle = Angle::from_degrees_minutes_seconds(1.0, 30.0, 0.0).unwrap();
876        assert_eq!(angle.degrees(), 1.5);
877        assert_eq!(angle.minutes(), 90.0);
878        assert!((Angle::from_minutes(90.0).unwrap().degrees() - 1.5).abs() < 1e-12);
879        assert!(
880            (Angle::from_radians(core::f64::consts::PI)
881                .unwrap()
882                .degrees()
883                - 180.0)
884                .abs()
885                < 1e-12
886        );
887        assert_eq!((-angle).abs(), angle);
888        assert_eq!(
889            Angle::from_degrees(370.0).unwrap().normalised().degrees(),
890            10.0
891        );
892    }
893
894    #[test]
895    fn angles_parse_from_how_they_are_written() {
896        assert_eq!("1.5".parse::<Angle>().unwrap().degrees(), 1.5);
897        assert!(("1°30'".parse::<Angle>().unwrap().degrees() - 1.5).abs() < 1e-12);
898        assert!(("1 30 00".parse::<Angle>().unwrap().degrees() - 1.5).abs() < 1e-12);
899        assert!(("-2.7".parse::<Angle>().unwrap().degrees() + 2.7).abs() < 1e-12);
900        // Plain angles take no hemisphere.
901        assert!("1°30'N".parse::<Angle>().is_err());
902        assert!("".parse::<Angle>().is_err());
903        assert!("1 60".parse::<Angle>().is_err());
904    }
905
906    #[test]
907    fn arithmetic_behaves() {
908        let a = Distance::from_nautical_miles(3.0).unwrap();
909        let b = Distance::from_nautical_miles(4.0).unwrap();
910        assert_eq!((a + b).nautical_miles(), 7.0);
911        assert_eq!((b - a).nautical_miles(), 1.0);
912        assert_eq!((a * 2.0).nautical_miles(), 6.0);
913        assert_eq!((b / 2.0).nautical_miles(), 2.0);
914        assert!((a - b).is_negative());
915        assert_eq!((a - b).abs().nautical_miles(), 1.0);
916    }
917
918    #[test]
919    fn display_is_readable() {
920        assert_eq!(
921            format!("{}", Distance::from_nautical_miles(12.0).unwrap()),
922            "12.0 M"
923        );
924        assert_eq!(format!("{:.2}", Speed::from_knots(8.5).unwrap()), "8.50 kn");
925        assert_eq!(format!("{}", Angle::from_degrees(-1.25).unwrap()), "-1.2°");
926    }
927}