Skip to main content

kinavis_kernel/geodesy/
mod.rs

1//! Earth figure, points with height, Earth-centred Cartesian coordinates.
2//!
3//! A [`Position`] is latitude and longitude only (the chart view used by the
4//! sailings). Adding height raises "above what?", and adding a Cartesian frame
5//! raises "on which ellipsoid?". Both are explicit here: a [`Height`] carries
6//! its [`VerticalDatum`], a [`GeodeticPoint`] is a position with such a height,
7//! and an [`EcefPoint`] is reached only through a named [`Ellipsoid`].
8//!
9//! A [`Position`] is on WGS 84 by definition. A position from an older chart is
10//! on that chart's [`Datum`] and may be hundreds of metres off;
11//! [`Datum::to_wgs84`] and [`Datum::from_wgs84`] make the shift explicit via a
12//! [`Helmert`] transformation.
13//!
14//! ```rust
15//! use kinavis_kernel::geodesy::{EcefPoint, Ellipsoid, GeodeticPoint, Height};
16//! use kinavis_kernel::{Distance, Position};
17//!
18//! let here: Position = "50°45.3'N 001°20.0'W".parse()?;
19//! let point = GeodeticPoint::new(here, Height::above_ellipsoid(Distance::from_metres(48.0)?));
20//!
21//! let ecef = EcefPoint::from_geodetic(point, &Ellipsoid::WGS84)?;
22//! let back = ecef.to_geodetic(&Ellipsoid::WGS84)?;
23//!
24//! assert!((back.position().latitude().degrees() - here.latitude().degrees()).abs() < 1e-9);
25//! assert!((back.height().value().metres() - 48.0).abs() < 1e-3);
26//! # Ok::<(), kinavis_kernel::KernelError>(())
27//! ```
28
29mod datum;
30
31use core::fmt;
32
33use crate::error::{KernelError, Result};
34use crate::math;
35use crate::position::{Latitude, Longitude, Position};
36use crate::units::Distance;
37
38pub use datum::{Datum, Helmert};
39
40/// Reference ellipsoid.
41///
42/// Defined by equatorial radius and flattening; polar radius and eccentricities
43/// are derived. Common chart ellipsoids are provided; others via
44/// [`Ellipsoid::new`].
45#[derive(Debug, Clone, Copy, PartialEq)]
46#[cfg_attr(
47    feature = "serde",
48    derive(serde::Serialize, serde::Deserialize),
49    serde(try_from = "StoredEllipsoid", into = "StoredEllipsoid")
50)]
51pub struct Ellipsoid {
52    semi_major_metres: f64,
53    inverse_flattening: f64,
54}
55
56impl Ellipsoid {
57    /// WGS 84, the GNSS reference ellipsoid.
58    pub const WGS84: Self = Self {
59        semi_major_metres: 6_378_137.0,
60        inverse_flattening: 298.257_223_563,
61    };
62
63    /// GRS 80 (ITRF and most national datums); differs from WGS 84 by 0.1 mm at
64    /// the pole.
65    pub const GRS80: Self = Self {
66        semi_major_metres: 6_378_137.0,
67        inverse_flattening: 298.257_222_101,
68    };
69
70    /// International 1924 (Hayford): ED50 and many older charts.
71    pub const INTERNATIONAL_1924: Self = Self {
72        semi_major_metres: 6_378_388.0,
73        inverse_flattening: 297.0,
74    };
75
76    /// Clarke 1866: NAD27.
77    ///
78    /// Defined by both axes; inverse flattening derived as `a / (a − b)`.
79    pub const CLARKE_1866: Self = Self {
80        semi_major_metres: 6_378_206.4,
81        inverse_flattening: 294.978_698_213_898,
82    };
83
84    /// Airy 1830: OSGB36.
85    pub const AIRY_1830: Self = Self {
86        semi_major_metres: 6_377_563.396,
87        inverse_flattening: 299.324_964_6,
88    };
89
90    /// Krassowsky 1940: Pulkovo 1942, charts of the former USSR.
91    pub const KRASSOWSKY_1940: Self = Self {
92        semi_major_metres: 6_378_245.0,
93        inverse_flattening: 298.3,
94    };
95
96    /// Bessel 1841: Tokyo datum, DHDN.
97    pub const BESSEL_1841: Self = Self {
98        semi_major_metres: 6_377_397.155,
99        inverse_flattening: 299.152_812_8,
100    };
101
102    /// Australian National Spheroid: AGD66; same figure as GRS 1967 Modified
103    /// (SAD69).
104    pub const AUSTRALIAN_NATIONAL: Self = Self {
105        semi_major_metres: 6_378_160.0,
106        inverse_flattening: 298.25,
107    };
108
109    /// Ellipsoid from equatorial radius and inverse flattening.
110    ///
111    /// # Errors
112    ///
113    /// [`KernelError::OutOfRange`] for a non-positive radius or an inverse
114    /// flattening below 1 (`f64::INFINITY`, a sphere, is accepted).
115    pub fn new(semi_major_axis: Distance, inverse_flattening: f64) -> Result<Self> {
116        Self::from_raw(semi_major_axis.metres(), inverse_flattening)
117    }
118
119    /// Single check shared by the typed constructor and deserialisation. `NaN`
120    /// fails every comparison, so it is rejected explicitly.
121    fn from_raw(semi_major_metres: f64, inverse_flattening: f64) -> Result<Self> {
122        if semi_major_metres.is_nan() || semi_major_metres <= 0.0 {
123            return Err(KernelError::OutOfRange {
124                parameter: "semi-major axis",
125                value: semi_major_metres,
126                min: f64::MIN_POSITIVE,
127                max: f64::MAX,
128            });
129        }
130        if inverse_flattening.is_nan() || inverse_flattening < 1.0 {
131            return Err(KernelError::OutOfRange {
132                parameter: "inverse flattening",
133                value: inverse_flattening,
134                min: 1.0,
135                max: f64::INFINITY,
136            });
137        }
138        Ok(Self {
139            semi_major_metres,
140            inverse_flattening,
141        })
142    }
143
144    /// Equatorial radius `a`.
145    #[must_use]
146    pub fn semi_major_axis(&self) -> Distance {
147        // Finite positive metres; cannot fail.
148        Distance::from_metres(self.semi_major_metres).unwrap_or(Distance::ZERO)
149    }
150
151    /// Polar radius `b = a (1 − f)`.
152    #[must_use]
153    pub fn semi_minor_axis(&self) -> Distance {
154        Distance::from_metres(self.semi_minor_metres()).unwrap_or(Distance::ZERO)
155    }
156
157    /// Flattening `f = (a − b) / a`.
158    #[must_use]
159    pub fn flattening(&self) -> f64 {
160        1.0 / self.inverse_flattening
161    }
162
163    /// Inverse flattening `1 / f`.
164    #[must_use]
165    pub const fn inverse_flattening(&self) -> f64 {
166        self.inverse_flattening
167    }
168
169    /// First eccentricity squared `e² = 2f − f²`.
170    #[must_use]
171    pub fn first_eccentricity_squared(&self) -> f64 {
172        let f = self.flattening();
173        f * (2.0 - f)
174    }
175
176    fn semi_minor_metres(&self) -> f64 {
177        self.semi_major_metres * (1.0 - self.flattening())
178    }
179
180    /// Second eccentricity squared `e′² = e² / (1 − e²)`.
181    fn second_eccentricity_squared(&self) -> f64 {
182        let e2 = self.first_eccentricity_squared();
183        e2 / (1.0 - e2)
184    }
185
186    /// Prime vertical radius of curvature `N` at a latitude.
187    fn prime_vertical_radius(&self, sin_latitude: f64) -> f64 {
188        self.semi_major_metres
189            / math::sqrt(1.0 - self.first_eccentricity_squared() * sin_latitude * sin_latitude)
190    }
191}
192
193/// Vertical datum.
194///
195/// `#[non_exhaustive]`; match with a wildcard arm.
196#[non_exhaustive]
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
198#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
199pub enum VerticalDatum {
200    /// Reference ellipsoid surface (GNSS).
201    Ellipsoid,
202    /// Mean sea level / geoid (barometric, topographic).
203    MeanSeaLevel,
204    /// Chart datum, usually LAT: soundings are below it, tide heights above it.
205    ChartDatum,
206}
207
208/// Serialised form; deserialisation applies the [`Ellipsoid::new`] checks,
209/// rejecting a zero axis.
210#[cfg(feature = "serde")]
211#[derive(serde::Serialize, serde::Deserialize)]
212struct StoredEllipsoid {
213    semi_major_metres: f64,
214    inverse_flattening: f64,
215}
216
217#[cfg(feature = "serde")]
218impl TryFrom<StoredEllipsoid> for Ellipsoid {
219    type Error = KernelError;
220
221    fn try_from(stored: StoredEllipsoid) -> Result<Self> {
222        Self::from_raw(stored.semi_major_metres, stored.inverse_flattening)
223    }
224}
225
226#[cfg(feature = "serde")]
227impl From<Ellipsoid> for StoredEllipsoid {
228    fn from(ellipsoid: Ellipsoid) -> Self {
229        Self {
230            semi_major_metres: ellipsoid.semi_major_metres,
231            inverse_flattening: ellipsoid.inverse_flattening,
232        }
233    }
234}
235
236impl VerticalDatum {
237    const fn name(self) -> &'static str {
238        match self {
239            Self::Ellipsoid => "the ellipsoid",
240            Self::MeanSeaLevel => "mean sea level",
241            Self::ChartDatum => "chart datum",
242        }
243    }
244}
245
246/// Height with its vertical datum.
247///
248/// Positive up; a depth is a negative height. Converting between datums needs a
249/// geoid or tide model, hence the datum is part of the value.
250#[derive(Debug, Clone, Copy, PartialEq)]
251#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
252pub struct Height {
253    value: Distance,
254    datum: VerticalDatum,
255}
256
257impl Height {
258    /// Height above the ellipsoid.
259    #[must_use]
260    pub const fn above_ellipsoid(value: Distance) -> Self {
261        Self {
262            value,
263            datum: VerticalDatum::Ellipsoid,
264        }
265    }
266
267    /// Height above MSL.
268    #[must_use]
269    pub const fn above_mean_sea_level(value: Distance) -> Self {
270        Self {
271            value,
272            datum: VerticalDatum::MeanSeaLevel,
273        }
274    }
275
276    /// Height above chart datum.
277    #[must_use]
278    pub const fn above_chart_datum(value: Distance) -> Self {
279        Self {
280            value,
281            datum: VerticalDatum::ChartDatum,
282        }
283    }
284
285    /// Height on any datum.
286    #[must_use]
287    pub const fn new(value: Distance, datum: VerticalDatum) -> Self {
288        Self { value, datum }
289    }
290
291    /// Height, positive up.
292    #[must_use]
293    pub const fn value(&self) -> Distance {
294        self.value
295    }
296
297    /// Vertical datum.
298    #[must_use]
299    pub const fn datum(&self) -> VerticalDatum {
300        self.datum
301    }
302}
303
304impl fmt::Display for Height {
305    /// Formats as `48.0 m above the ellipsoid`.
306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        let precision = f.precision().unwrap_or(1);
308        write!(
309            f,
310            "{:.*} m above {}",
311            precision,
312            self.value.metres(),
313            self.datum.name()
314        )
315    }
316}
317
318/// Position with height.
319#[derive(Debug, Clone, Copy, PartialEq)]
320#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
321pub struct GeodeticPoint {
322    position: Position,
323    height: Height,
324}
325
326impl GeodeticPoint {
327    /// Point from horizontal position and height.
328    #[must_use]
329    pub const fn new(position: Position, height: Height) -> Self {
330        Self { position, height }
331    }
332
333    /// Horizontal position.
334    #[must_use]
335    pub const fn position(&self) -> Position {
336        self.position
337    }
338
339    /// Height with datum.
340    #[must_use]
341    pub const fn height(&self) -> Height {
342        self.height
343    }
344}
345
346impl fmt::Display for GeodeticPoint {
347    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
348        write!(f, "{}, {}", self.position, self.height)
349    }
350}
351
352/// Earth-centred, Earth-fixed Cartesian point, metres.
353///
354/// Origin at the centre of mass; `x` towards 0°N 0°E, `z` towards the north
355/// pole, `y` towards 0°N 90°E. Reached from a [`GeodeticPoint`] via an
356/// [`Ellipsoid`], and only from an ellipsoidal height: MSL heights need a geoid
357/// model, which this crate does not provide.
358#[derive(Debug, Clone, Copy, PartialEq)]
359#[cfg_attr(
360    feature = "serde",
361    derive(serde::Serialize, serde::Deserialize),
362    serde(try_from = "StoredEcefPoint", into = "StoredEcefPoint")
363)]
364pub struct EcefPoint {
365    x: f64,
366    y: f64,
367    z: f64,
368}
369
370impl EcefPoint {
371    /// Point from three coordinates.
372    #[must_use]
373    pub fn new(x: Distance, y: Distance, z: Distance) -> Self {
374        Self {
375            x: x.metres(),
376            y: y.metres(),
377            z: z.metres(),
378        }
379    }
380
381    /// Towards 0°N 0°E.
382    #[must_use]
383    pub fn x(&self) -> Distance {
384        Distance::from_metres(self.x).unwrap_or(Distance::ZERO)
385    }
386
387    /// Towards 0°N 90°E.
388    #[must_use]
389    pub fn y(&self) -> Distance {
390        Distance::from_metres(self.y).unwrap_or(Distance::ZERO)
391    }
392
393    /// Towards the north pole.
394    #[must_use]
395    pub fn z(&self) -> Distance {
396        Distance::from_metres(self.z).unwrap_or(Distance::ZERO)
397    }
398
399    /// ECEF coordinates of a geodetic point on an ellipsoid.
400    ///
401    /// # Errors
402    ///
403    /// [`KernelError::VerticalDatumMismatch`] unless the height is ellipsoidal;
404    /// MSL heights need a geoid model supplied by an adapter.
405    pub fn from_geodetic(point: GeodeticPoint, ellipsoid: &Ellipsoid) -> Result<Self> {
406        if point.height.datum != VerticalDatum::Ellipsoid {
407            return Err(KernelError::VerticalDatumMismatch {
408                required: VerticalDatum::Ellipsoid,
409                found: point.height.datum,
410            });
411        }
412        let (sin_lat, cos_lat) = sin_cos(point.position.latitude().radians());
413        let (sin_lon, cos_lon) = sin_cos(point.position.longitude().radians());
414        let n = ellipsoid.prime_vertical_radius(sin_lat);
415        let h = point.height.value.metres();
416        let e2 = ellipsoid.first_eccentricity_squared();
417        Ok(Self {
418            x: (n + h) * cos_lat * cos_lon,
419            y: (n + h) * cos_lat * sin_lon,
420            z: (n * (1.0 - e2) + h) * sin_lat,
421        })
422    }
423
424    /// Geodetic point and ellipsoidal height on an ellipsoid.
425    ///
426    /// Bowring (1985) closed form: no iteration, sub-millimetre from the
427    /// surface to satellite altitudes. Round trip with
428    /// [`EcefPoint::from_geodetic`] holds to `1e-9°` and `1e-3 m`, poles and
429    /// antimeridian included (property-tested).
430    ///
431    /// # Errors
432    ///
433    /// [`KernelError::Indeterminate`] at the Earth's centre, and for points so
434    /// close to the polar axis inside the Earth that the geodetic height is
435    /// undefined.
436    // Notation follows Bowring's paper.
437    #[allow(clippy::many_single_char_names)]
438    pub fn to_geodetic(self, ellipsoid: &Ellipsoid) -> Result<GeodeticPoint> {
439        let a = ellipsoid.semi_major_metres;
440        let b = ellipsoid.semi_minor_metres();
441        let e2 = ellipsoid.first_eccentricity_squared();
442        let ep2 = ellipsoid.second_eccentricity_squared();
443
444        let p = math::hypot(self.x, self.y);
445        let r = math::hypot(p, self.z);
446        if r < f64::MIN_POSITIVE {
447            return Err(KernelError::Indeterminate {
448                quantity: "the geodetic position of the Earth's centre",
449            });
450        }
451        let longitude = Longitude::from_degrees(math::to_degrees(math::atan2(self.y, self.x)))?;
452
453        // On the polar axis the parametric latitude is 90° and the height is
454        // the distance along the axis from the pole.
455        if p < f64::MIN_POSITIVE * a {
456            let latitude = if self.z < 0.0 {
457                Latitude::SOUTH_POLE
458            } else {
459                Latitude::NORTH_POLE
460            };
461            let height = Distance::from_metres(math::abs(self.z) - b)?;
462            return Ok(GeodeticPoint::new(
463                Position::new(latitude, longitude),
464                Height::above_ellipsoid(height),
465            ));
466        }
467
468        // Bowring (1985): parametric latitude u from the geocentric latitude,
469        // then geodetic latitude from u in closed form.
470        let tan_u = (b * self.z / (a * p)) * (1.0 + ep2 * b / r);
471        let cos_u = 1.0 / math::sqrt(1.0 + tan_u * tan_u);
472        let sin_u = tan_u * cos_u;
473        let latitude_radians = math::atan2(
474            self.z + ep2 * b * sin_u * sin_u * sin_u,
475            p - e2 * a * cos_u * cos_u * cos_u,
476        );
477        let (sin_lat, cos_lat) = sin_cos(latitude_radians);
478        let n = ellipsoid.prime_vertical_radius(sin_lat);
479        let height_metres = p * cos_lat + self.z * sin_lat - a * a / n;
480
481        let latitude = Latitude::from_degrees(math::to_degrees(latitude_radians))?;
482        let height = Distance::from_metres(height_metres)?;
483        Ok(GeodeticPoint::new(
484            Position::new(latitude, longitude),
485            Height::above_ellipsoid(height),
486        ))
487    }
488
489    /// Straight-line (chord) distance.
490    #[must_use]
491    pub fn chord_to(&self, other: Self) -> Distance {
492        let chord = math::hypot(
493            math::hypot(other.x - self.x, other.y - self.y),
494            other.z - self.z,
495        );
496        Distance::from_metres(chord).unwrap_or(Distance::ZERO)
497    }
498}
499
500/// Serialised form: three metres; deserialisation checks each is finite.
501#[cfg(feature = "serde")]
502#[derive(serde::Serialize, serde::Deserialize)]
503struct StoredEcefPoint {
504    x: f64,
505    y: f64,
506    z: f64,
507}
508
509#[cfg(feature = "serde")]
510impl TryFrom<StoredEcefPoint> for EcefPoint {
511    type Error = KernelError;
512
513    fn try_from(stored: StoredEcefPoint) -> Result<Self> {
514        Ok(Self::new(
515            Distance::from_metres(stored.x)?,
516            Distance::from_metres(stored.y)?,
517            Distance::from_metres(stored.z)?,
518        ))
519    }
520}
521
522#[cfg(feature = "serde")]
523impl From<EcefPoint> for StoredEcefPoint {
524    fn from(point: EcefPoint) -> Self {
525        Self {
526            x: point.x,
527            y: point.y,
528            z: point.z,
529        }
530    }
531}
532
533fn sin_cos(radians: f64) -> (f64, f64) {
534    (math::sin(radians), math::cos(radians))
535}
536
537#[cfg(test)]
538#[allow(clippy::unwrap_used, clippy::float_cmp)]
539mod tests {
540    use super::*;
541    use alloc::format;
542
543    fn point(latitude: f64, longitude: f64, height: f64) -> GeodeticPoint {
544        GeodeticPoint::new(
545            Position::new(
546                Latitude::from_degrees(latitude).unwrap(),
547                Longitude::from_degrees(longitude).unwrap(),
548            ),
549            Height::above_ellipsoid(Distance::from_metres(height).unwrap()),
550        )
551    }
552
553    #[test]
554    fn wgs84_derived_constants_match_the_published_ones() {
555        let e = Ellipsoid::WGS84;
556        assert!((e.semi_minor_axis().metres() - 6_356_752.314_245).abs() < 1e-6);
557        assert!((e.first_eccentricity_squared() - 6.694_379_990_14e-3).abs() < 1e-14);
558        assert!((e.second_eccentricity_squared() - 6.739_496_742_28e-3).abs() < 1e-14);
559        assert_eq!(e.inverse_flattening(), 298.257_223_563);
560        assert!((Ellipsoid::GRS80.semi_minor_axis().metres() - 6_356_752.314_140).abs() < 1e-6);
561    }
562
563    #[test]
564    fn an_ellipsoid_must_be_a_plausible_shape() {
565        assert!(Ellipsoid::new(Distance::from_metres(0.0).unwrap(), 300.0).is_err());
566        assert!(Ellipsoid::new(Distance::from_metres(6.4e6).unwrap(), 0.5).is_err());
567        assert!(Ellipsoid::new(Distance::from_metres(6.4e6).unwrap(), f64::NAN).is_err());
568        // A sphere: zero flattening.
569        let sphere =
570            Ellipsoid::new(Distance::from_metres(6_371_000.0).unwrap(), f64::INFINITY).unwrap();
571        assert_eq!(sphere.flattening(), 0.0);
572        assert_eq!(sphere.semi_minor_axis().metres(), 6_371_000.0);
573    }
574
575    #[test]
576    fn ecef_of_reference_points_matches_the_textbook() {
577        // On the equator at Greenwich: (a, 0, 0).
578        let origin = EcefPoint::from_geodetic(point(0.0, 0.0, 0.0), &Ellipsoid::WGS84).unwrap();
579        assert!((origin.x().metres() - 6_378_137.0).abs() < 1e-6);
580        assert!(origin.y().metres().abs() < 1e-9);
581        assert!(origin.z().metres().abs() < 1e-9);
582        // The north pole: (0, 0, b).
583        let pole = EcefPoint::from_geodetic(point(90.0, 0.0, 0.0), &Ellipsoid::WGS84).unwrap();
584        assert!(pole.x().metres().abs() < 1e-6);
585        assert!((pole.z().metres() - 6_356_752.314_245).abs() < 1e-6);
586        // 34°N 117°W, 251 m, computed independently from the defining formulas.
587        let inland =
588            EcefPoint::from_geodetic(point(34.0, -117.0, 251.0), &Ellipsoid::WGS84).unwrap();
589        assert!((inland.x().metres() - -2_403_183.467).abs() < 1e-3);
590        assert!((inland.y().metres() - -4_716_513.119).abs() < 1e-3);
591        assert!((inland.z().metres() - 3_546_586.921).abs() < 1e-3);
592        // 45°N 45°E, 1000 m: x and y equal by symmetry.
593        let diagonal =
594            EcefPoint::from_geodetic(point(45.0, 45.0, 1000.0), &Ellipsoid::WGS84).unwrap();
595        assert!((diagonal.x().metres() - 3_194_919.145).abs() < 1e-3);
596        assert!((diagonal.x().metres() - diagonal.y().metres()).abs() < 1e-6);
597        assert!((diagonal.z().metres() - 4_488_055.516).abs() < 1e-3);
598    }
599
600    #[test]
601    fn the_round_trip_holds_at_the_awkward_places() {
602        let places = [
603            (0.0, 0.0, 0.0),
604            (90.0, 0.0, 0.0),
605            (-90.0, 45.0, 1000.0),
606            (89.999_999, 179.999_999, -50.0),
607            (-45.0, -180.0, 20_200_000.0),
608            (50.755, -1.333, 48.0),
609            (1e-9, 1e-9, 0.0),
610        ];
611        for (latitude, longitude, height) in places {
612            let there = point(latitude, longitude, height);
613            let back = EcefPoint::from_geodetic(there, &Ellipsoid::WGS84)
614                .unwrap()
615                .to_geodetic(&Ellipsoid::WGS84)
616                .unwrap();
617            assert!(
618                (back.position().latitude().degrees() - latitude).abs() < 1e-9,
619                "latitude at {latitude} {longitude} {height}: {}",
620                back.position().latitude().degrees()
621            );
622            assert!(
623                back.position()
624                    .longitude_difference(there.position())
625                    .degrees()
626                    .abs()
627                    < 1e-9
628                    || latitude.abs() == 90.0,
629                "longitude at {latitude} {longitude} {height}"
630            );
631            assert!(
632                (back.height().value().metres() - height).abs() < 1e-3,
633                "height at {latitude} {longitude} {height}: {}",
634                back.height().value().metres()
635            );
636        }
637    }
638
639    #[test]
640    fn a_sea_level_height_does_not_pretend_to_be_ellipsoidal() {
641        let msl = GeodeticPoint::new(
642            point(50.0, 0.0, 0.0).position(),
643            Height::above_mean_sea_level(Distance::from_metres(10.0).unwrap()),
644        );
645        assert_eq!(
646            EcefPoint::from_geodetic(msl, &Ellipsoid::WGS84),
647            Err(KernelError::VerticalDatumMismatch {
648                required: VerticalDatum::Ellipsoid,
649                found: VerticalDatum::MeanSeaLevel,
650            })
651        );
652        assert_eq!(format!("{}", msl.height()), "10.0 m above mean sea level");
653    }
654
655    #[test]
656    fn the_centre_of_the_earth_has_no_position() {
657        let centre = EcefPoint::new(Distance::ZERO, Distance::ZERO, Distance::ZERO);
658        assert!(matches!(
659            centre.to_geodetic(&Ellipsoid::WGS84),
660            Err(KernelError::Indeterminate { .. })
661        ));
662    }
663
664    #[test]
665    fn the_chord_is_the_straight_line_through_the_earth() {
666        let north = EcefPoint::from_geodetic(point(90.0, 0.0, 0.0), &Ellipsoid::WGS84).unwrap();
667        let south = EcefPoint::from_geodetic(point(-90.0, 0.0, 0.0), &Ellipsoid::WGS84).unwrap();
668        assert!((north.chord_to(south).metres() - 2.0 * 6_356_752.314_245).abs() < 1e-6);
669    }
670}