Skip to main content

kinavis_kernel/
position.rs

1//! Geographic position: latitude, longitude, position.
2//!
3//! Both coordinates enforce their range: a [`Latitude`] is in `[-90°, 90°]`; a
4//! [`Longitude`] is in `[-180°, 180°)` and wraps, so crossing the antimeridian
5//! cannot produce an invalid value.
6//!
7//! # Example
8//!
9//! ```rust
10//! use kinavis_kernel::{Latitude, Longitude, KernelError, NorthSouth, Position};
11//!
12//! // Decimal degrees, or the degrees-and-minutes a chart is marked in.
13//! let position = Position::new(
14//!     Latitude::from_degrees_minutes(50, 45.3, NorthSouth::North)?,
15//!     Longitude::from_degrees(-1.296_667)?,
16//! );
17//!
18//! assert_eq!(format!("{position}"), "50°45.3'N 001°17.8'W");
19//! assert_eq!(position.latitude().degrees(), 50.755);
20//!
21//! // Out of range is rejected; longitude wraps.
22//! assert!(Latitude::from_degrees(91.0).is_err());
23//! assert_eq!(Longitude::from_degrees(180.0)?.degrees(), -180.0);
24//! # Ok::<(), KernelError>(())
25//! ```
26
27use core::fmt;
28use core::str::FromStr;
29
30use crate::angle::wrap180;
31use crate::error::{ensure_finite, ensure_range, KernelError, Result};
32use crate::math;
33use crate::units::{Angle, Distance};
34
35/// WGS 84 flattening.
36pub const WGS84_FLATTENING: f64 = 1.0 / 298.257_223_563;
37/// WGS 84 semi-major axis, m.
38pub const WGS84_SEMI_MAJOR_AXIS_METRES: f64 = 6_378_137.0;
39/// WGS 84 first eccentricity squared, `f·(2 − f)`.
40pub const WGS84_ECCENTRICITY_SQUARED: f64 = WGS84_FLATTENING * (2.0 - WGS84_FLATTENING);
41/// WGS 84 first eccentricity.
42///
43/// A literal because `sqrt` is not `const`; a test checks it equals the square
44/// root.
45const WGS84_ECCENTRICITY: f64 = 0.081_819_190_842_621_49;
46
47/// Inverse hyperbolic tangent via the natural logarithm.
48fn artanh(value: f64) -> f64 {
49    0.5 * math::ln((1.0 + value) / (1.0 - value))
50}
51
52/// Latitude hemisphere.
53///
54/// `#[non_exhaustive]`; match with a wildcard arm.
55#[non_exhaustive]
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
58pub enum NorthSouth {
59    /// North of the equator.
60    North,
61    /// South of the equator.
62    South,
63}
64
65impl NorthSouth {
66    /// `1.0` north, `-1.0` south.
67    #[must_use]
68    pub const fn sign(self) -> f64 {
69        match self {
70            Self::North => 1.0,
71            Self::South => -1.0,
72        }
73    }
74
75    /// Chart letter.
76    #[must_use]
77    pub const fn letter(self) -> char {
78        match self {
79            Self::North => 'N',
80            Self::South => 'S',
81        }
82    }
83}
84
85/// Longitude hemisphere.
86///
87/// `#[non_exhaustive]`; match with a wildcard arm.
88#[non_exhaustive]
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
90#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
91pub enum EastWest {
92    /// East of Greenwich.
93    East,
94    /// West of Greenwich.
95    West,
96}
97
98impl EastWest {
99    /// `1.0` east, `-1.0` west.
100    #[must_use]
101    pub const fn sign(self) -> f64 {
102        match self {
103            Self::East => 1.0,
104            Self::West => -1.0,
105        }
106    }
107
108    /// Chart letter.
109    #[must_use]
110    pub const fn letter(self) -> char {
111        match self {
112            Self::East => 'E',
113            Self::West => 'W',
114        }
115    }
116}
117
118/// Latitude in `[-90°, 90°]`, north positive.
119#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
120#[cfg_attr(
121    feature = "serde",
122    derive(serde::Serialize, serde::Deserialize),
123    serde(try_from = "f64", into = "f64")
124)]
125pub struct Latitude(f64);
126
127impl Latitude {
128    /// The equator.
129    pub const EQUATOR: Self = Self(0.0);
130    /// The north pole.
131    pub const NORTH_POLE: Self = Self(90.0);
132    /// The south pole.
133    pub const SOUTH_POLE: Self = Self(-90.0);
134
135    /// Latitude from decimal degrees, north positive.
136    ///
137    /// # Errors
138    ///
139    /// [`crate::KernelError::NotFinite`] for `NaN` or infinity;
140    /// [`crate::KernelError::OutOfRange`] outside `[-90.0, 90.0]`.
141    pub fn from_degrees(value: f64) -> Result<Self> {
142        ensure_range("latitude", value, -90.0, 90.0)?;
143        Ok(Self(value))
144    }
145
146    /// Latitude from whole degrees and decimal minutes.
147    ///
148    /// # Errors
149    ///
150    /// As [`Latitude::from_degrees`], after combining.
151    pub fn from_degrees_minutes(
152        degrees: u16,
153        minutes: f64,
154        hemisphere: NorthSouth,
155    ) -> Result<Self> {
156        ensure_finite("latitude minutes", minutes)?;
157        let magnitude = f64::from(degrees) + minutes / 60.0;
158        Self::from_degrees(magnitude * hemisphere.sign())
159    }
160
161    /// Latitude from a value known to be finite, clamping rounding overshoot
162    /// past ±90°.
163    ///
164    /// `NaN` survives the clamp and breaks the invariant; use
165    /// [`Latitude::from_degrees`] otherwise.
166    ///
167    /// Internal to the crate family: hidden, not covered by the stability
168    /// guarantee. See [hidden items](crate#hidden-items).
169    #[doc(hidden)]
170    #[must_use]
171    pub fn from_degrees_clamped(value: f64) -> Self {
172        Self(value.clamp(-90.0, 90.0))
173    }
174
175    /// Degrees, north positive.
176    #[must_use]
177    pub const fn degrees(self) -> f64 {
178        self.0
179    }
180
181    /// Radians.
182    #[must_use]
183    pub fn radians(self) -> f64 {
184        math::to_radians(self.0)
185    }
186
187    /// Hemisphere; the equator counts as north.
188    #[must_use]
189    pub fn hemisphere(self) -> NorthSouth {
190        if self.0 < 0.0 {
191            NorthSouth::South
192        } else {
193            NorthSouth::North
194        }
195    }
196
197    /// Whole degrees, decimal minutes and hemisphere.
198    #[must_use]
199    pub fn to_degrees_minutes(self) -> (u16, f64, NorthSouth) {
200        split_degrees_minutes(self.0, 90)
201            .map_or((0, 0.0, self.hemisphere()), |(degrees, minutes)| {
202                (degrees, minutes, self.hemisphere())
203            })
204    }
205
206    /// Whether at a pole, where longitude is undefined.
207    #[must_use]
208    pub fn is_polar(self) -> bool {
209        math::abs(math::abs(self.0) - 90.0) < 1e-9
210    }
211
212    /// Meridional parts: Mercator y-coordinate in minutes of arc, on the WGS 84
213    /// ellipsoid.
214    ///
215    /// At 45° it gives 3013.6 (a sphere gives 3029.9).
216    ///
217    /// `kinavis::sailings::rhumb_line` uses a spherical model and will not
218    /// match a Mercator sailing computed from these values exactly; use
219    /// `kinavis::sailings::geodesic` when the ellipsoid matters.
220    ///
221    /// # Errors
222    ///
223    /// [`crate::KernelError::Indeterminate`] at a pole (infinite).
224    pub fn meridional_parts(self) -> Result<f64> {
225        if self.is_polar() {
226            return Err(crate::KernelError::Indeterminate {
227                quantity: "meridional parts at the pole",
228            });
229        }
230        // Exact ellipsoidal correction, not the truncated series: the classic
231        // 23.268932·sinφ − … coefficients are for Clarke 1866 and are 1.4′ off
232        // at low latitudes on WGS 84.
233        let eccentricity = WGS84_ECCENTRICITY;
234        let sine = math::sin(self.radians());
235        let correction = eccentricity * artanh(eccentricity * sine);
236        Ok(self.isometric_minutes() - math::to_degrees(correction) * 60.0)
237    }
238
239    /// Latitude whose spherical isometric latitude is `minutes`: the
240    /// Gudermannian, inverse of [`Latitude::isometric_minutes`].
241    ///
242    /// `NaN` breaks the invariant; callers pass values derived from valid
243    /// latitudes.
244    ///
245    /// Internal to the crate family: hidden, not covered by the stability
246    /// guarantee. See [hidden items](crate#hidden-items).
247    #[doc(hidden)]
248    #[must_use]
249    pub fn from_isometric_minutes(minutes: f64) -> Self {
250        let radians = math::to_radians(minutes / 60.0);
251        let latitude = 2.0 * math::atan(math::exp(radians)) - core::f64::consts::FRAC_PI_2;
252        Self::from_degrees_clamped(math::to_degrees(latitude))
253    }
254
255    /// Spherical isometric latitude, minutes of arc.
256    ///
257    /// The Mercator y-coordinate on a sphere, used by the rhumb-line sailings
258    /// in `kinavis::sailings`. Infinite at the poles.
259    #[must_use]
260    pub fn isometric_minutes(self) -> f64 {
261        math::to_degrees(math::ln(math::tan(
262            core::f64::consts::FRAC_PI_4 + self.radians() / 2.0,
263        ))) * 60.0
264    }
265}
266
267impl fmt::Display for Latitude {
268    /// Formats as `50°45.3'N`.
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        let precision = f.precision().unwrap_or(1);
271        let (degrees, minutes, hemisphere) = self.to_degrees_minutes();
272        write!(
273            f,
274            "{degrees:02}°{minutes:0>width$.precision$}'{}",
275            hemisphere.letter(),
276            width = if precision == 0 { 2 } else { precision + 3 }
277        )
278    }
279}
280
281impl FromStr for Latitude {
282    type Err = crate::KernelError;
283
284    /// Parses `50°45.3'N`, `N50 45 18`, `-33.9` etc.
285    ///
286    /// Hemisphere letter or sign, not both. See [`crate::position`] for
287    /// accepted forms.
288    ///
289    /// # Errors
290    ///
291    /// [`crate::KernelError::Parse`] for unreadable input or a non-N/S
292    /// hemisphere; [`crate::KernelError::OutOfRange`] beyond the poles.
293    fn from_str(input: &str) -> Result<Self> {
294        let parsed = crate::parse::sexagesimal("latitude", input)?;
295        if let Some(letter) = parsed.hemisphere {
296            if !matches!(letter, 'N' | 'S') {
297                return Err(crate::parse::parse_error("latitude", input));
298            }
299        }
300        Self::from_degrees(parsed.signed("S"))
301    }
302}
303
304/// Longitude in `[-180°, 180°)`, east positive.
305///
306/// Out-of-range values wrap: adding a longitude difference across the
307/// antimeridian is routine.
308#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
309#[cfg_attr(
310    feature = "serde",
311    derive(serde::Serialize, serde::Deserialize),
312    serde(try_from = "f64", into = "f64")
313)]
314pub struct Longitude(f64);
315
316impl Longitude {
317    /// Prime meridian.
318    pub const GREENWICH: Self = Self(0.0);
319
320    /// Longitude from decimal degrees, east positive, wrapped.
321    ///
322    /// # Errors
323    ///
324    /// [`crate::KernelError::NotFinite`] for `NaN` or infinity.
325    pub fn from_degrees(value: f64) -> Result<Self> {
326        ensure_finite("longitude", value)?;
327        Ok(Self(wrap180(value)))
328    }
329
330    /// Longitude from whole degrees and decimal minutes.
331    ///
332    /// # Errors
333    ///
334    /// [`crate::KernelError::NotFinite`] for `NaN` or infinity.
335    pub fn from_degrees_minutes(degrees: u16, minutes: f64, hemisphere: EastWest) -> Result<Self> {
336        ensure_finite("longitude minutes", minutes)?;
337        let magnitude = f64::from(degrees) + minutes / 60.0;
338        Self::from_degrees(magnitude * hemisphere.sign())
339    }
340
341    /// Longitude from a value known to be finite.
342    ///
343    /// A non-finite argument breaks the invariant; use
344    /// [`Longitude::from_degrees`] otherwise.
345    ///
346    /// Internal to the crate family: hidden, not covered by the stability
347    /// guarantee. See [hidden items](crate#hidden-items).
348    #[doc(hidden)]
349    #[must_use]
350    pub fn from_degrees_wrapped(value: f64) -> Self {
351        Self(wrap180(value))
352    }
353
354    /// Degrees, east positive.
355    #[must_use]
356    pub const fn degrees(self) -> f64 {
357        self.0
358    }
359
360    /// Radians.
361    #[must_use]
362    pub fn radians(self) -> f64 {
363        math::to_radians(self.0)
364    }
365
366    /// Hemisphere; Greenwich counts as east.
367    #[must_use]
368    pub fn hemisphere(self) -> EastWest {
369        if self.0 < 0.0 {
370            EastWest::West
371        } else {
372            EastWest::East
373        }
374    }
375
376    /// Whole degrees, decimal minutes and hemisphere.
377    #[must_use]
378    pub fn to_degrees_minutes(self) -> (u16, f64, EastWest) {
379        split_degrees_minutes(self.0, 180)
380            .map_or((0, 0.0, self.hemisphere()), |(degrees, minutes)| {
381                (degrees, minutes, self.hemisphere())
382            })
383    }
384
385    /// Shortest signed longitude difference to `other`, in `[-180°, 180°)`,
386    /// east positive.
387    #[must_use]
388    pub fn difference_to(self, other: Self) -> Angle {
389        Angle::from_degrees_unchecked(wrap180(other.0 - self.0))
390    }
391}
392
393impl fmt::Display for Longitude {
394    /// Formats as `001°17.8'W`.
395    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
396        let precision = f.precision().unwrap_or(1);
397        let (degrees, minutes, hemisphere) = self.to_degrees_minutes();
398        write!(
399            f,
400            "{degrees:03}°{minutes:0>width$.precision$}'{}",
401            hemisphere.letter(),
402            width = if precision == 0 { 2 } else { precision + 3 }
403        )
404    }
405}
406
407impl FromStr for Longitude {
408    type Err = crate::KernelError;
409
410    /// Parses `001°17.8'W`, `W001 17 48`, `151.2` etc.
411    ///
412    /// # Errors
413    ///
414    /// [`crate::KernelError::Parse`] for unreadable input or a non-E/W
415    /// hemisphere.
416    fn from_str(input: &str) -> Result<Self> {
417        let parsed = crate::parse::sexagesimal("longitude", input)?;
418        if let Some(letter) = parsed.hemisphere {
419            if !matches!(letter, 'E' | 'W') {
420                return Err(crate::parse::parse_error("longitude", input));
421            }
422        }
423        Self::from_degrees(parsed.signed("W"))
424    }
425}
426
427/// Position on the Earth's surface (WGS 84).
428#[derive(Debug, Clone, Copy, PartialEq, Default)]
429#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
430pub struct Position {
431    latitude: Latitude,
432    longitude: Longitude,
433}
434
435impl Position {
436    /// 0°N 0°E.
437    pub const ORIGIN: Self = Self {
438        latitude: Latitude::EQUATOR,
439        longitude: Longitude::GREENWICH,
440    };
441
442    /// Position from latitude and longitude.
443    #[must_use]
444    pub const fn new(latitude: Latitude, longitude: Longitude) -> Self {
445        Self {
446            latitude,
447            longitude,
448        }
449    }
450
451    /// Position from decimal degrees.
452    ///
453    /// # Errors
454    ///
455    /// As [`Latitude::from_degrees`] and [`Longitude::from_degrees`].
456    pub fn from_degrees(latitude: f64, longitude: f64) -> Result<Self> {
457        Ok(Self::new(
458            Latitude::from_degrees(latitude)?,
459            Longitude::from_degrees(longitude)?,
460        ))
461    }
462
463    /// Latitude.
464    #[must_use]
465    pub const fn latitude(self) -> Latitude {
466        self.latitude
467    }
468
469    /// Longitude.
470    #[must_use]
471    pub const fn longitude(self) -> Longitude {
472        self.longitude
473    }
474
475    /// Latitude difference to `other`, north positive.
476    #[must_use]
477    pub fn latitude_difference(self, other: Self) -> Angle {
478        Angle::from_degrees_unchecked(other.latitude.0 - self.latitude.0)
479    }
480
481    /// Longitude difference to `other`, east positive, short way.
482    #[must_use]
483    pub fn longitude_difference(self, other: Self) -> Angle {
484        self.longitude.difference_to(other.longitude)
485    }
486
487    /// Departure: east-west distance between the meridians at the mean
488    /// latitude.
489    ///
490    /// Plane-sailing approximation, for short distances.
491    #[must_use]
492    pub fn departure(self, other: Self) -> Distance {
493        let mean_latitude = f64::midpoint(self.latitude.radians(), other.latitude.radians());
494        Distance::from_nautical_miles_unchecked(
495            self.longitude_difference(other).minutes() * math::cos(mean_latitude),
496        )
497    }
498
499    /// Position as a geocentric direction; the form spherical geometry is
500    /// computed in. See [`GeocentricUnit`] for the axes.
501    #[must_use]
502    pub fn to_geocentric_unit(self) -> GeocentricUnit {
503        let (latitude, longitude) = (self.latitude.radians(), self.longitude.radians());
504        let cos_latitude = math::cos(latitude);
505        GeocentricUnit {
506            x: cos_latitude * math::cos(longitude),
507            y: cos_latitude * math::sin(longitude),
508            z: math::sin(latitude),
509        }
510    }
511
512    /// Position of a geocentric direction.
513    ///
514    /// Infallible: the zero vector cannot be a [`GeocentricUnit`].
515    #[must_use]
516    pub fn from_geocentric_unit(unit: GeocentricUnit) -> Self {
517        let horizontal = math::hypot(unit.x, unit.y);
518        Self::new(
519            Latitude::from_degrees_clamped(math::to_degrees(math::atan2(unit.z, horizontal))),
520            Longitude::from_degrees_wrapped(math::to_degrees(math::atan2(unit.y, unit.x))),
521        )
522    }
523}
524
525/// Unit vector from the Earth's centre.
526///
527/// x towards 0°N 0°E, y towards 0°N 90°E, z towards the north pole. Direction
528/// only, no radius.
529///
530/// Components are private, finite and of unit length. As a bare `[f64; 3]` a
531/// caller could pass the zero vector, a vector in other axes, or metres, and
532/// the signature would not prevent it.
533///
534/// # Example
535///
536/// ```rust
537/// use kinavis_kernel::{GeocentricUnit, Latitude, Longitude, Position};
538///
539/// let greenwich = Position::new(Latitude::from_degrees(0.0)?, Longitude::from_degrees(0.0)?);
540/// let unit = greenwich.to_geocentric_unit();
541/// assert!((unit.x() - 1.0).abs() < 1e-12);
542///
543/// // Round trips, and the length is normalised on the way in.
544/// let doubled = GeocentricUnit::new(2.0, 0.0, 0.0)?;
545/// assert_eq!(Position::from_geocentric_unit(doubled), greenwich);
546///
547/// // The zero vector points nowhere and is refused.
548/// assert!(GeocentricUnit::new(0.0, 0.0, 0.0).is_err());
549/// # Ok::<(), kinavis_kernel::KernelError>(())
550/// ```
551#[derive(Debug, Clone, Copy, PartialEq)]
552pub struct GeocentricUnit {
553    x: f64,
554    y: f64,
555    z: f64,
556}
557
558impl GeocentricUnit {
559    /// Normalises an Earth-centred vector; any positive multiple gives the same
560    /// direction.
561    ///
562    /// # Errors
563    ///
564    /// - [`KernelError::NotFinite`] for a `NaN` or infinite component.
565    /// - [`KernelError::Indeterminate`] for the zero vector or one too small to
566    ///   normalise meaningfully.
567    pub fn new(x: f64, y: f64, z: f64) -> Result<Self> {
568        ensure_finite("geocentric x", x)?;
569        ensure_finite("geocentric y", y)?;
570        ensure_finite("geocentric z", z)?;
571        Self::from_finite(x, y, z).ok_or(KernelError::Indeterminate {
572            quantity: "a direction from a vector of zero length",
573        })
574    }
575
576    /// As [`GeocentricUnit::new`], for components known to be finite.
577    ///
578    /// `None` only for the zero vector, so the caller can name the
579    /// indeterminate quantity. A non-finite component breaks the invariant.
580    ///
581    /// Internal to the crate family: hidden, not covered by the stability
582    /// guarantee. See [hidden items](crate#hidden-items).
583    #[doc(hidden)]
584    #[must_use]
585    pub fn from_finite(x: f64, y: f64, z: f64) -> Option<Self> {
586        let magnitude = math::hypot(math::hypot(x, y), z);
587        if magnitude < f64::MIN_POSITIVE {
588            return None;
589        }
590        Some(Self {
591            x: x / magnitude,
592            y: y / magnitude,
593            z: z / magnitude,
594        })
595    }
596
597    /// Component towards 0°N 0°E.
598    #[must_use]
599    pub const fn x(self) -> f64 {
600        self.x
601    }
602
603    /// Component towards 0°N 90°E.
604    #[must_use]
605    pub const fn y(self) -> f64 {
606        self.y
607    }
608
609    /// Component towards the north pole.
610    #[must_use]
611    pub const fn z(self) -> f64 {
612        self.z
613    }
614
615    /// Components `[x, y, z]`.
616    #[must_use]
617    pub const fn components(self) -> [f64; 3] {
618        [self.x, self.y, self.z]
619    }
620
621    /// Cosine of the angle between two directions.
622    #[must_use]
623    pub fn dot(self, other: Self) -> f64 {
624        self.x * other.x + self.y * other.y + self.z * other.z
625    }
626
627    /// Antipodal direction.
628    #[must_use]
629    pub fn antipode(self) -> Self {
630        Self {
631            x: -self.x,
632            y: -self.y,
633            z: -self.z,
634        }
635    }
636}
637
638impl FromStr for Position {
639    type Err = crate::KernelError;
640
641    /// Parses latitude then longitude.
642    ///
643    /// `50°45.3'N 001°17.8'W`, `N50 45.3 W001 17.8`, `50.755, -1.2967`.
644    ///
645    /// # Errors
646    ///
647    /// [`crate::KernelError::Parse`] if the halves cannot be separated or
648    /// either is unreadable.
649    fn from_str(input: &str) -> Result<Self> {
650        let (latitude, longitude) = crate::parse::split_position(input)?;
651        Ok(Self::new(latitude.parse()?, longitude.parse()?))
652    }
653}
654
655impl fmt::Display for Position {
656    /// Formats as `50°45.3'N 001°17.8'W`.
657    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
658        let precision = f.precision().unwrap_or(1);
659        write!(
660            f,
661            "{:.precision$} {:.precision$}",
662            self.latitude, self.longitude
663        )
664    }
665}
666
667/// Splits signed decimal degrees into whole degrees and decimal minutes.
668///
669/// `None` if the magnitude exceeds the bound; unreachable for a valid latitude
670/// or longitude.
671fn split_degrees_minutes(value: f64, maximum: u16) -> Option<(u16, f64)> {
672    let magnitude = math::abs(value);
673    let mut degrees = u16::try_from(math::to_usize(magnitude)).ok()?;
674    let mut minutes = (magnitude - f64::from(degrees)) * 60.0;
675    // Rounding guard: 10.99999' must not print as `10°60.0'`.
676    if minutes >= 59.999_95 {
677        minutes = 0.0;
678        degrees = degrees.checked_add(1)?;
679    }
680    if degrees > maximum {
681        return None;
682    }
683    Some((degrees, minutes))
684}
685
686#[cfg(feature = "serde")]
687impl TryFrom<f64> for Latitude {
688    type Error = KernelError;
689
690    /// Validated on deserialisation.
691    fn try_from(value: f64) -> Result<Self> {
692        Self::from_degrees(value)
693    }
694}
695
696#[cfg(feature = "serde")]
697impl From<Latitude> for f64 {
698    fn from(value: Latitude) -> Self {
699        value.0
700    }
701}
702
703#[cfg(feature = "serde")]
704impl TryFrom<f64> for Longitude {
705    type Error = KernelError;
706
707    /// Validated on deserialisation.
708    fn try_from(value: f64) -> Result<Self> {
709        Self::from_degrees(value)
710    }
711}
712
713#[cfg(feature = "serde")]
714impl From<Longitude> for f64 {
715    fn from(value: Longitude) -> Self {
716        value.0
717    }
718}
719
720#[cfg(test)]
721#[allow(clippy::unwrap_used, clippy::float_cmp, clippy::indexing_slicing)]
722mod tests {
723    use super::*;
724    use alloc::format;
725
726    #[test]
727    fn latitude_validates() {
728        assert!(Latitude::from_degrees(90.0).is_ok());
729        assert!(Latitude::from_degrees(-90.0).is_ok());
730        assert!(Latitude::from_degrees(90.000_001).is_err());
731        assert!(Latitude::from_degrees(f64::NAN).is_err());
732        assert!(Latitude::from_degrees(f64::INFINITY).is_err());
733    }
734
735    #[test]
736    fn longitude_wraps_instead_of_failing() {
737        assert_eq!(Longitude::from_degrees(180.0).unwrap().degrees(), -180.0);
738        assert_eq!(Longitude::from_degrees(-180.0).unwrap().degrees(), -180.0);
739        assert_eq!(Longitude::from_degrees(190.0).unwrap().degrees(), -170.0);
740        assert_eq!(Longitude::from_degrees(-190.0).unwrap().degrees(), 170.0);
741        assert_eq!(Longitude::from_degrees(720.5).unwrap().degrees(), 0.5);
742        assert!(Longitude::from_degrees(f64::NAN).is_err());
743    }
744
745    #[test]
746    fn degrees_and_minutes_round_trip() {
747        let latitude = Latitude::from_degrees_minutes(50, 45.3, NorthSouth::North).unwrap();
748        assert!((latitude.degrees() - 50.755).abs() < 1e-12);
749        let (degrees, minutes, hemisphere) = latitude.to_degrees_minutes();
750        assert_eq!(degrees, 50);
751        assert!((minutes - 45.3).abs() < 1e-9);
752        assert_eq!(hemisphere, NorthSouth::North);
753
754        let longitude = Longitude::from_degrees_minutes(1, 17.8, EastWest::West).unwrap();
755        assert!((longitude.degrees() + 1.296_666_667).abs() < 1e-9);
756    }
757
758    #[test]
759    fn display_matches_chart_convention() {
760        let position = Position::from_degrees(50.755, -1.296_666_667).unwrap();
761        assert_eq!(format!("{position}"), "50°45.3'N 001°17.8'W");
762
763        let southern = Position::from_degrees(-33.9, 151.2).unwrap();
764        assert_eq!(format!("{southern}"), "33°54.0'S 151°12.0'E");
765
766        // Rounding must not produce 60.0 minutes.
767        let boundary = Latitude::from_degrees(10.999_999_9).unwrap();
768        assert_eq!(format!("{boundary}"), "11°00.0'N");
769    }
770
771    #[test]
772    fn isometric_latitude_round_trips() {
773        for degrees in [-85.0, -45.0, -0.5, 0.0, 0.5, 10.0, 45.0, 80.0, 89.0] {
774            let latitude = Latitude::from_degrees(degrees).unwrap();
775            let back = Latitude::from_isometric_minutes(latitude.isometric_minutes());
776            assert!(
777                (back.degrees() - degrees).abs() < 1e-9,
778                "{degrees} came back as {}",
779                back.degrees()
780            );
781        }
782        // Poles are the limits; values clamp rather than overflow.
783        assert!(Latitude::from_isometric_minutes(f64::INFINITY).is_polar());
784        assert!(Latitude::from_isometric_minutes(f64::NEG_INFINITY).is_polar());
785    }
786
787    #[test]
788    fn eccentricity_constant_is_the_square_root_it_claims_to_be() {
789        let expected = WGS84_ECCENTRICITY_SQUARED.sqrt();
790        assert!((WGS84_ECCENTRICITY - expected).abs() < 1e-15);
791    }
792
793    #[test]
794    fn positions_read_back_from_what_they_print() {
795        for (latitude, longitude) in [
796            (50.755, -1.296_666_667),
797            (-33.9, 151.2),
798            (0.0, 0.0),
799            (89.5, -179.5),
800        ] {
801            let position = Position::from_degrees(latitude, longitude).unwrap();
802            let printed = alloc::format!("{position:.4}");
803            let read: Position = printed.parse().unwrap();
804            assert!(
805                (read.latitude().degrees() - latitude).abs() < 1e-6,
806                "{printed}"
807            );
808            assert!(
809                read.longitude()
810                    .difference_to(position.longitude())
811                    .degrees()
812                    .abs()
813                    < 1e-6,
814                "{printed}"
815            );
816        }
817    }
818
819    #[test]
820    fn positions_parse_from_the_usual_forms() {
821        let expected = Position::from_degrees(50.755, -1.296_666_667).unwrap();
822        for input in [
823            "50°45.3'N 001°17.8'W",
824            "50 45.3 N 001 17.8 W",
825            "N50°45.3' W001°17.8'",
826            "50.755, -1.2966667",
827            "50.755 -1.2966667",
828        ] {
829            let parsed: Position = input.parse().unwrap();
830            assert!(
831                (parsed.latitude().degrees() - expected.latitude().degrees()).abs() < 1e-6,
832                "{input}"
833            );
834            assert!(
835                (parsed.longitude().degrees() - expected.longitude().degrees()).abs() < 1e-6,
836                "{input}"
837            );
838        }
839    }
840
841    #[test]
842    fn the_wrong_hemisphere_letter_is_refused() {
843        assert!("50°45.3'E".parse::<Latitude>().is_err());
844        assert!("001°17.8'N".parse::<Longitude>().is_err());
845        assert!("50°45.3'N".parse::<Longitude>().is_err());
846        // A latitude beyond the pole is out of range.
847        assert!("91 00.0 N".parse::<Latitude>().is_err());
848        // A longitude past the antimeridian wraps.
849        assert_eq!("190".parse::<Longitude>().unwrap().degrees(), -170.0);
850    }
851
852    #[test]
853    fn unreadable_input_is_an_error_not_a_panic() {
854        for input in ["", "   ", "north", "50°45.3'N", "a b", "50 45.3 60.0"] {
855            assert!(input.parse::<Position>().is_err(), "{input}");
856        }
857
858        // Two bare numbers are latitude and longitude, not degrees and minutes
859        // of one coordinate: a position needs both halves.
860        let pair: Position = "50 45.3".parse().unwrap();
861        assert_eq!(pair.latitude().degrees(), 50.0);
862        assert_eq!(pair.longitude().degrees(), 45.3);
863        for input in ["", "  ", "fifty", "50 60.0", "50 45 18 12"] {
864            assert!(input.parse::<Latitude>().is_err(), "{input}");
865            assert!(input.parse::<Longitude>().is_err(), "{input}");
866        }
867    }
868
869    #[test]
870    fn hemispheres() {
871        assert_eq!(
872            Latitude::from_degrees(0.0).unwrap().hemisphere(),
873            NorthSouth::North
874        );
875        assert_eq!(
876            Latitude::from_degrees(-0.1).unwrap().hemisphere(),
877            NorthSouth::South
878        );
879        assert_eq!(
880            Longitude::from_degrees(0.0).unwrap().hemisphere(),
881            EastWest::East
882        );
883        assert_eq!(
884            Longitude::from_degrees(-0.1).unwrap().hemisphere(),
885            EastWest::West
886        );
887        assert!(Latitude::NORTH_POLE.is_polar());
888        assert!(!Latitude::from_degrees(89.0).unwrap().is_polar());
889    }
890
891    #[test]
892    fn longitude_difference_takes_the_short_way() {
893        let west = Longitude::from_degrees(-179.0).unwrap();
894        let east = Longitude::from_degrees(179.0).unwrap();
895        assert!((west.difference_to(east).degrees() + 2.0).abs() < 1e-12);
896        assert!((east.difference_to(west).degrees() - 2.0).abs() < 1e-12);
897    }
898
899    #[test]
900    fn meridional_parts_match_the_tables() {
901        // Meridional parts on WGS 84 from an independent evaluation of a·[ln
902        // tan(π/4 + φ/2) − e·artanh(e sin φ)], minutes of arc.
903        for (degrees, expected) in [
904            (10.0, 599.073),
905            (30.0, 1876.862),
906            (45.0, 3013.648),
907            (60.0, 4507.404),
908            (75.0, 6948.063),
909        ] {
910            let latitude = Latitude::from_degrees(degrees).unwrap();
911            let parts = latitude.meridional_parts().unwrap();
912            assert!(
913                (parts - expected).abs() < 0.001,
914                "at {degrees}°: {parts} vs {expected}"
915            );
916        }
917
918        let latitude = Latitude::from_degrees(45.0).unwrap();
919        // The spherical value used by the rhumb sailings differs measurably.
920        assert!((latitude.isometric_minutes() - 3029.9).abs() < 0.1);
921        assert!(Latitude::EQUATOR.meridional_parts().unwrap().abs() < 1e-9);
922        assert!(Latitude::NORTH_POLE.meridional_parts().is_err());
923
924        // Symmetric about the equator.
925        let south = Latitude::from_degrees(-45.0).unwrap();
926        assert!((south.meridional_parts().unwrap() + 3013.648).abs() < 0.001);
927    }
928
929    #[test]
930    fn geocentric_units_round_trip() {
931        for (latitude, longitude) in [
932            (0.0, 0.0),
933            (45.0, 90.0),
934            (-33.9, 151.2),
935            (89.0, -179.0),
936            (0.0, -180.0),
937        ] {
938            let position = Position::from_degrees(latitude, longitude).unwrap();
939            let unit = position.to_geocentric_unit();
940            // Invariant: unit length.
941            assert!((unit.dot(unit) - 1.0).abs() < 1e-12);
942
943            let back = Position::from_geocentric_unit(unit);
944            assert!((back.latitude().degrees() - latitude).abs() < 1e-9);
945            assert!(
946                back.longitude()
947                    .difference_to(position.longitude())
948                    .degrees()
949                    .abs()
950                    < 1e-9
951            );
952
953            // The antipode is 180° away.
954            let opposite = Position::from_geocentric_unit(unit.antipode());
955            assert!((opposite.latitude().degrees() + latitude).abs() < 1e-9);
956            assert!((unit.dot(unit.antipode()) + 1.0).abs() < 1e-12);
957        }
958    }
959
960    #[test]
961    fn a_geocentric_unit_normalises_and_refuses_what_points_nowhere() {
962        // Any positive multiple gives the same direction.
963        let one = GeocentricUnit::new(1.0, 0.0, 0.0).unwrap();
964        let many = GeocentricUnit::new(1_000.0, 0.0, 0.0).unwrap();
965        assert_eq!(one, many);
966        assert!((one.x() - 1.0).abs() < 1e-12);
967        assert_eq!(one.components(), [1.0, 0.0, 0.0]);
968
969        // Length is normalised, not just checked.
970        let diagonal = GeocentricUnit::new(3.0, 4.0, 0.0).unwrap();
971        assert!((diagonal.x() - 0.6).abs() < 1e-12);
972        assert!((diagonal.y() - 0.8).abs() < 1e-12);
973
974        assert!(matches!(
975            GeocentricUnit::new(0.0, 0.0, 0.0),
976            Err(KernelError::Indeterminate { .. })
977        ));
978        assert!(matches!(
979            GeocentricUnit::new(f64::NAN, 0.0, 0.0),
980            Err(KernelError::NotFinite { .. })
981        ));
982        assert!(GeocentricUnit::new(f64::INFINITY, 0.0, 1.0).is_err());
983    }
984
985    #[test]
986    fn departure_matches_plane_sailing() {
987        // 1° of longitude at 60°: 30 NM departure.
988        let from = Position::from_degrees(60.0, 0.0).unwrap();
989        let to = Position::from_degrees(60.0, 1.0).unwrap();
990        assert!((from.departure(to).nautical_miles() - 30.0).abs() < 0.01);
991        assert!((from.latitude_difference(to).degrees()).abs() < 1e-12);
992    }
993}