Skip to main content

kinavis_kernel/geodesy/
datum.rs

1//! Horizontal datums and datum transformation.
2//!
3//! Pre-satellite charts are referred to national datums: a regional ellipsoid
4//! fixed at a fundamental point. The same coordinates on such a datum and on
5//! WGS 84 are different places — tens of metres in North America, hundreds in
6//! Japan — so a chart position must be shifted before plotting a GNSS fix
7//! against it, as an explicit step.
8//!
9//! ```rust
10//! use kinavis_kernel::geodesy::Datum;
11//! use kinavis_kernel::Position;
12//!
13//! // A light, as an old chart of Tokyo Bay gives it.
14//! let charted: Position = "35°39.0'N 139°45.0'E".parse()?;
15//! let wgs84 = Datum::TOKYO.to_wgs84(charted)?;
16//!
17//! // Some 460 m away: a quarter of a mile, on a chart that says "Tokyo".
18//! assert_eq!(format!("{wgs84:.3}"), "35°39.196'N 139°44.807'E");
19//! assert_eq!(Datum::TOKYO.accuracy().metres().round(), 29.0);
20//!
21//! // And a GNSS fix, as it is to be plotted on that chart.
22//! let plotted = Datum::TOKYO.from_wgs84(wgs84)?;
23//! assert_eq!(format!("{plotted:.3}"), "35°39.000'N 139°45.000'E");
24//! # Ok::<(), kinavis_kernel::KernelError>(())
25//! ```
26
27use core::fmt;
28
29use super::{EcefPoint, Ellipsoid, GeodeticPoint, Height};
30use crate::error::{ensure_finite, KernelError, Result};
31use crate::math;
32use crate::position::Position;
33use crate::units::Distance;
34
35/// Radians per arc-second.
36const RADIANS_PER_ARC_SECOND: f64 = core::f64::consts::PI / (180.0 * 3600.0);
37
38/// Seven-parameter Helmert transformation between geocentric frames:
39/// translation, small rotation, scale.
40///
41/// Parameters come from EPSG, a national survey or a chart note, in one of two
42/// rotation sign conventions: *position vector* (rotates the point) and
43/// *coordinate frame* (rotates the axes; opposite sign). Each has its own
44/// constructor so parameters are entered as published.
45///
46/// The rotation matrix is the small-angle `I + [r]×` used by the definitions,
47/// not an exact rotation: the published parameters were fitted with it.
48#[derive(Debug, Clone, Copy, PartialEq)]
49#[cfg_attr(
50    feature = "serde",
51    derive(serde::Serialize, serde::Deserialize),
52    serde(try_from = "StoredHelmert", into = "StoredHelmert")
53)]
54pub struct Helmert {
55    /// Metres.
56    translation: [f64; 3],
57    /// Arc-seconds, position vector convention.
58    rotation: [f64; 3],
59    /// Parts per million.
60    scale_ppm: f64,
61}
62
63impl Helmert {
64    /// Identity.
65    pub const IDENTITY: Self = Self {
66        translation: [0.0; 3],
67        rotation: [0.0; 3],
68        scale_ppm: 0.0,
69    };
70
71    /// Translation only, metres: the three-parameter form most shifts are
72    /// published in.
73    #[must_use]
74    pub const fn translation(dx: f64, dy: f64, dz: f64) -> Self {
75        Self {
76            translation: [dx, dy, dz],
77            rotation: [0.0; 3],
78            scale_ppm: 0.0,
79        }
80    }
81
82    /// Seven parameters, position vector convention (EPSG 9606, Bursa-Wolf as
83    /// used in Europe): translations in m, rotations in arc-seconds, scale in
84    /// ppm.
85    ///
86    /// # Errors
87    ///
88    /// [`KernelError::NotFinite`] for a non-finite parameter;
89    /// [`KernelError::OutOfRange`] for a scale ≤ −1 000 000 ppm (collapses the
90    /// frame).
91    pub fn position_vector(
92        translation: [f64; 3],
93        rotation: [f64; 3],
94        scale_ppm: f64,
95    ) -> Result<Self> {
96        for value in translation {
97            ensure_finite("Helmert translation", value)?;
98        }
99        for value in rotation {
100            ensure_finite("Helmert rotation", value)?;
101        }
102        ensure_finite("Helmert scale difference", scale_ppm)?;
103        if scale_ppm <= -1e6 {
104            return Err(KernelError::OutOfRange {
105                parameter: "Helmert scale difference",
106                value: scale_ppm,
107                min: -1e6,
108                max: f64::INFINITY,
109            });
110        }
111        Ok(Self {
112            translation,
113            rotation,
114            scale_ppm,
115        })
116    }
117
118    /// Seven parameters, coordinate frame convention (EPSG 9607, as used in the
119    /// US and Australia): as [`Helmert::position_vector`] with rotation signs
120    /// reversed.
121    ///
122    /// # Errors
123    ///
124    /// As [`Helmert::position_vector`].
125    pub fn coordinate_frame(
126        translation: [f64; 3],
127        rotation: [f64; 3],
128        scale_ppm: f64,
129    ) -> Result<Self> {
130        Self::position_vector(
131            translation,
132            [-rotation[0], -rotation[1], -rotation[2]],
133            scale_ppm,
134        )
135    }
136
137    /// Translation, m.
138    #[must_use]
139    pub const fn translation_metres(&self) -> [f64; 3] {
140        self.translation
141    }
142
143    /// Rotation, arc-seconds, position vector convention.
144    #[must_use]
145    pub const fn rotation_arc_seconds(&self) -> [f64; 3] {
146        self.rotation
147    }
148
149    /// Scale difference, ppm.
150    #[must_use]
151    pub const fn scale_ppm(&self) -> f64 {
152        self.scale_ppm
153    }
154
155    /// Whether every parameter is zero.
156    #[must_use]
157    pub fn is_identity(&self) -> bool {
158        *self == Self::IDENTITY
159    }
160
161    fn rotation_radians(&self) -> [f64; 3] {
162        [
163            self.rotation[0] * RADIANS_PER_ARC_SECOND,
164            self.rotation[1] * RADIANS_PER_ARC_SECOND,
165            self.rotation[2] * RADIANS_PER_ARC_SECOND,
166        ]
167    }
168
169    fn scale(&self) -> f64 {
170        1.0 + self.scale_ppm * 1e-6
171    }
172
173    /// Forward transform: `X′ = T + (1 + s) (I + [r]×) X`.
174    #[must_use]
175    pub fn apply(&self, point: EcefPoint) -> EcefPoint {
176        let [rx, ry, rz] = self.rotation_radians();
177        let [tx, ty, tz] = self.translation;
178        let scale = self.scale();
179        let (x, y, z) = (point.x, point.y, point.z);
180        EcefPoint {
181            x: tx + scale * (x - rz * y + ry * z),
182            y: ty + scale * (rz * x + y - rx * z),
183            z: tz + scale * (-ry * x + rx * y + z),
184        }
185    }
186
187    /// Inverse transform, exact (not the usual sign-reversal approximation), so
188    /// a round trip closes to rounding precision.
189    ///
190    /// For `M = I + [r]×`, `M⁻¹ = (I − [r]× + r rᵀ) / (1 + |r|²)`.
191    #[must_use]
192    pub fn apply_inverse(&self, point: EcefPoint) -> EcefPoint {
193        let [rx, ry, rz] = self.rotation_radians();
194        let [tx, ty, tz] = self.translation;
195        let scale = self.scale();
196        let (x, y, z) = (
197            (point.x - tx) / scale,
198            (point.y - ty) / scale,
199            (point.z - tz) / scale,
200        );
201        let along = rx * x + ry * y + rz * z;
202        let norm = 1.0 + rx * rx + ry * ry + rz * rz;
203        EcefPoint {
204            x: (x + rz * y - ry * z + rx * along) / norm,
205            y: (-rz * x + y + rx * z + ry * along) / norm,
206            z: (ry * x - rx * y + z + rz * along) / norm,
207        }
208    }
209}
210
211/// Horizontal geodetic datum: ellipsoid and its transformation to WGS 84.
212///
213/// Each provided datum uses the EPSG transformation for its home area, with the
214/// accuracy EPSG states: a few metres for a modern seven-parameter fit, tens of
215/// metres for a continental mean translation. This is the accuracy of the
216/// shift, not of the chart; a chart's own datum note, when present, takes
217/// precedence over this table.
218#[derive(Debug, Clone, Copy, PartialEq)]
219pub struct Datum {
220    name: &'static str,
221    ellipsoid: Ellipsoid,
222    to_wgs84: Helmert,
223    accuracy_metres: f64,
224}
225
226/// Serialised form, position vector convention. Deserialisation goes through
227/// [`Helmert::position_vector`], rejecting `NaN` and frame-inverting scales.
228#[cfg(feature = "serde")]
229#[derive(serde::Serialize, serde::Deserialize)]
230struct StoredHelmert {
231    translation: [f64; 3],
232    rotation: [f64; 3],
233    scale_ppm: f64,
234}
235
236#[cfg(feature = "serde")]
237impl TryFrom<StoredHelmert> for Helmert {
238    type Error = KernelError;
239
240    fn try_from(stored: StoredHelmert) -> Result<Self> {
241        Self::position_vector(stored.translation, stored.rotation, stored.scale_ppm)
242    }
243}
244
245#[cfg(feature = "serde")]
246impl From<Helmert> for StoredHelmert {
247    fn from(helmert: Helmert) -> Self {
248        Self {
249            translation: helmert.translation,
250            rotation: helmert.rotation,
251            scale_ppm: helmert.scale_ppm,
252        }
253    }
254}
255
256impl Datum {
257    /// WGS 84: the GNSS datum, and the datum of every [`Position`].
258    pub const WGS84: Self = Self {
259        name: "WGS 84",
260        ellipsoid: Ellipsoid::WGS84,
261        to_wgs84: Helmert::IDENTITY,
262        accuracy_metres: 0.0,
263    };
264
265    /// NAD83, on GRS 80. Equivalent to WGS 84 at chart accuracy: EPSG 1188
266    /// (identity), 4 m.
267    pub const NAD83: Self = Self {
268        name: "NAD83",
269        ellipsoid: Ellipsoid::GRS80,
270        to_wgs84: Helmert::IDENTITY,
271        accuracy_metres: 4.0,
272    };
273
274    /// ED50, on International 1924: western European charts before ETRS 89.
275    /// EPSG 1133 (Europe mean), 10 m.
276    pub const ED50: Self = Self {
277        name: "ED50",
278        ellipsoid: Ellipsoid::INTERNATIONAL_1924,
279        to_wgs84: Helmert::translation(-87.0, -98.0, -121.0),
280        accuracy_metres: 10.0,
281    };
282
283    /// NAD27, on Clarke 1866: older US charts. EPSG 1173 (CONUS mean), 10 m.
284    pub const NAD27: Self = Self {
285        name: "NAD27",
286        ellipsoid: Ellipsoid::CLARKE_1866,
287        to_wgs84: Helmert::translation(-8.0, 160.0, 176.0),
288        accuracy_metres: 10.0,
289    };
290
291    /// OSGB36, on Airy 1830. EPSG 1314 (seven-parameter, Great Britain), 2 m.
292    pub const OSGB36: Self = Self {
293        name: "OSGB36",
294        ellipsoid: Ellipsoid::AIRY_1830,
295        to_wgs84: Helmert {
296            translation: [446.448, -125.157, 542.06],
297            rotation: [0.15, 0.247, 0.842],
298            scale_ppm: -20.489,
299        },
300        accuracy_metres: 2.0,
301    };
302
303    /// Pulkovo 1942, on Krassowsky 1940: Russian and former-USSR charts. EPSG
304    /// 5044 (GOST R 51794-2001, Russia), 3 m. Published in the coordinate frame
305    /// convention; stored here in the position vector convention.
306    pub const PULKOVO_1942: Self = Self {
307        name: "Pulkovo 1942",
308        ellipsoid: Ellipsoid::KRASSOWSKY_1940,
309        to_wgs84: Helmert {
310            translation: [23.57, -140.95, -79.8],
311            rotation: [0.0, 0.35, 0.79],
312            scale_ppm: -0.22,
313        },
314        accuracy_metres: 3.0,
315    };
316
317    /// Tokyo, on Bessel 1841: Japanese and Korean charts before JGD 2000. EPSG
318    /// 1230 (Japan and South Korea mean), 29 m; the shift itself exceeds 400 m.
319    pub const TOKYO: Self = Self {
320        name: "Tokyo",
321        ellipsoid: Ellipsoid::BESSEL_1841,
322        to_wgs84: Helmert::translation(-148.0, 507.0, 685.0),
323        accuracy_metres: 29.0,
324    };
325
326    /// DHDN (Potsdam), on Bessel 1841: German charts before ETRS 89. EPSG 1777
327    /// (Germany), 3 m.
328    pub const DHDN: Self = Self {
329        name: "DHDN",
330        ellipsoid: Ellipsoid::BESSEL_1841,
331        to_wgs84: Helmert {
332            translation: [598.1, 73.7, 418.2],
333            rotation: [0.202, 0.045, -2.455],
334            scale_ppm: 6.7,
335        },
336        accuracy_metres: 3.0,
337    };
338
339    /// AGD66, on the Australian National Spheroid. EPSG 15788 (Australia mean),
340    /// 5 m.
341    pub const AGD66: Self = Self {
342        name: "AGD66",
343        ellipsoid: Ellipsoid::AUSTRALIAN_NATIONAL,
344        to_wgs84: Helmert::translation(-127.8, -52.3, 152.9),
345        accuracy_metres: 5.0,
346    };
347
348    /// SAD69, on GRS 1967 Modified. EPSG 1864 (continental mean), 19 m.
349    pub const SAD69: Self = Self {
350        name: "SAD69",
351        ellipsoid: Ellipsoid::AUSTRALIAN_NATIONAL,
352        to_wgs84: Helmert::translation(-57.0, 1.0, -41.0),
353        accuracy_metres: 19.0,
354    };
355
356    /// Datum from its ellipsoid, transformation to WGS 84 and stated accuracy:
357    /// for datums not in the table, or with parameters from a chart note.
358    #[must_use]
359    pub fn new(
360        name: &'static str,
361        ellipsoid: Ellipsoid,
362        to_wgs84: Helmert,
363        accuracy: Distance,
364    ) -> Self {
365        Self {
366            name,
367            ellipsoid,
368            to_wgs84,
369            accuracy_metres: math::abs(accuracy.metres()),
370        }
371    }
372
373    /// Datum name, as in a chart note.
374    #[must_use]
375    pub const fn name(&self) -> &'static str {
376        self.name
377    }
378
379    /// Reference ellipsoid.
380    #[must_use]
381    pub const fn ellipsoid(&self) -> &Ellipsoid {
382        &self.ellipsoid
383    }
384
385    /// Transformation from this datum's geocentric frame to WGS 84.
386    #[must_use]
387    pub const fn to_wgs84_helmert(&self) -> &Helmert {
388        &self.to_wgs84
389    }
390
391    /// Stated accuracy of the shift; zero for WGS 84.
392    #[must_use]
393    pub fn accuracy(&self) -> Distance {
394        Distance::from_metres(self.accuracy_metres).unwrap_or(Distance::ZERO)
395    }
396
397    /// Chart position on this datum → WGS 84.
398    ///
399    /// Placed on the datum ellipsoid at zero height, transformed via ECEF, read
400    /// back on WGS 84. A ship is within ~100 m of the ellipsoid and the shift
401    /// tilts the normal by ≤ 0.1 mrad, so dropping height costs about 1 cm.
402    ///
403    /// # Errors
404    ///
405    /// [`KernelError::Indeterminate`] if the transformed point has no geodetic
406    /// position; impossible with published parameters, possible with arbitrary
407    /// [`Helmert`] values.
408    pub fn to_wgs84(&self, position: Position) -> Result<Position> {
409        if self.to_wgs84.is_identity() && self.ellipsoid == Ellipsoid::WGS84 {
410            return Ok(position);
411        }
412        let point = GeodeticPoint::new(position, Height::above_ellipsoid(Distance::ZERO));
413        let geocentric = EcefPoint::from_geodetic(point, &self.ellipsoid)?;
414        let shifted = self.to_wgs84.apply(geocentric);
415        Ok(shifted.to_geodetic(&Ellipsoid::WGS84)?.position())
416    }
417
418    /// WGS 84 position (GNSS fix) → position on this datum, for plotting on its
419    /// chart.
420    ///
421    /// Exact inverse of [`Datum::to_wgs84`] apart from the height dropped at
422    /// each end: round trip closes to ~1 cm in the datum's home area, a
423    /// decimetre or two on the far side of the Earth where its ellipsoid is
424    /// kilometres from WGS 84.
425    ///
426    /// # Errors
427    ///
428    /// As [`Datum::to_wgs84`].
429    pub fn from_wgs84(&self, position: Position) -> Result<Position> {
430        if self.to_wgs84.is_identity() && self.ellipsoid == Ellipsoid::WGS84 {
431            return Ok(position);
432        }
433        let point = GeodeticPoint::new(position, Height::above_ellipsoid(Distance::ZERO));
434        let geocentric = EcefPoint::from_geodetic(point, &Ellipsoid::WGS84)?;
435        let shifted = self.to_wgs84.apply_inverse(geocentric);
436        Ok(shifted.to_geodetic(&self.ellipsoid)?.position())
437    }
438}
439
440impl fmt::Display for Datum {
441    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
442        f.write_str(self.name)
443    }
444}
445
446#[cfg(test)]
447#[allow(clippy::unwrap_used, clippy::float_cmp)]
448mod tests {
449    use super::*;
450    use crate::position::{Latitude, Longitude};
451
452    fn position(latitude: f64, longitude: f64) -> Position {
453        Position::new(
454            Latitude::from_degrees(latitude).unwrap(),
455            Longitude::from_degrees(longitude).unwrap(),
456        )
457    }
458
459    /// Metres between two nearby positions, on a sphere.
460    fn metres_apart(a: Position, b: Position) -> f64 {
461        let dlat = math::to_radians(b.latitude().degrees() - a.latitude().degrees());
462        let dlon = b.longitude_difference(a).radians();
463        let cos = math::cos(math::to_radians(a.latitude().degrees()));
464        6_371_000.0 * math::hypot(dlat, dlon * cos)
465    }
466
467    #[test]
468    fn the_identity_leaves_a_point_alone_and_a_translation_moves_it() {
469        let point = EcefPoint::new(
470            Distance::from_metres(1.0).unwrap(),
471            Distance::from_metres(2.0).unwrap(),
472            Distance::from_metres(3.0).unwrap(),
473        );
474        assert_eq!(Helmert::IDENTITY.apply(point), point);
475        assert!(Helmert::IDENTITY.is_identity());
476        let moved = Helmert::translation(10.0, -20.0, 30.0).apply(point);
477        assert_eq!(moved.x().metres(), 11.0);
478        assert_eq!(moved.y().metres(), -18.0);
479        assert_eq!(moved.z().metres(), 33.0);
480    }
481
482    #[test]
483    fn the_two_conventions_differ_by_the_sign_of_the_rotation() {
484        let pv = Helmert::position_vector([1.0, 2.0, 3.0], [0.1, -0.2, 0.3], 1.5).unwrap();
485        let cf = Helmert::coordinate_frame([1.0, 2.0, 3.0], [-0.1, 0.2, -0.3], 1.5).unwrap();
486        assert_eq!(pv, cf);
487        assert_eq!(pv.rotation_arc_seconds(), [0.1, -0.2, 0.3]);
488        assert_eq!(pv.translation_metres(), [1.0, 2.0, 3.0]);
489        assert_eq!(pv.scale_ppm(), 1.5);
490    }
491
492    #[test]
493    fn wild_parameters_are_refused() {
494        assert!(Helmert::position_vector([f64::NAN, 0.0, 0.0], [0.0; 3], 0.0).is_err());
495        assert!(Helmert::position_vector([0.0; 3], [0.0, f64::INFINITY, 0.0], 0.0).is_err());
496        assert!(Helmert::position_vector([0.0; 3], [0.0; 3], f64::NAN).is_err());
497        assert!(matches!(
498            Helmert::coordinate_frame([0.0; 3], [0.0; 3], -1e6),
499            Err(KernelError::OutOfRange { .. })
500        ));
501    }
502
503    #[test]
504    fn the_inverse_is_exact_not_the_reversed_parameters() {
505        let helmert = Datum::OSGB36.to_wgs84;
506        let point = EcefPoint::new(
507            Distance::from_metres(3_874_938.849).unwrap(),
508            Distance::from_metres(116_218.624).unwrap(),
509            Distance::from_metres(5_047_168.208).unwrap(),
510        );
511        let back = helmert.apply_inverse(helmert.apply(point));
512        assert!(back.chord_to(point).metres() < 1e-9, "{back:?}");
513
514        // The sign-reversal approximation used in textbooks is off by a
515        // fraction of a millimetre here: second-order in a 20 ppm scale and 1″
516        // rotation.
517        let reversed = Helmert {
518            translation: [-446.448, 125.157, -542.06],
519            rotation: [-0.15, -0.247, -0.842],
520            scale_ppm: 20.489,
521        };
522        let approximate = reversed.apply(helmert.apply(point));
523        assert!(approximate.chord_to(point).metres() > 1e-6);
524    }
525
526    #[test]
527    fn wgs84_and_nad83_shift_nothing() {
528        let here = position(38.9, -77.0);
529        assert_eq!(Datum::WGS84.to_wgs84(here).unwrap(), here);
530        assert_eq!(Datum::WGS84.from_wgs84(here).unwrap(), here);
531        assert_eq!(Datum::WGS84.accuracy(), Distance::ZERO);
532        // NAD83 is on GRS 80; its 0.1 mm polar difference is below position
533        // resolution.
534        let shifted = Datum::NAD83.to_wgs84(here).unwrap();
535        assert!(metres_apart(here, shifted) < 1e-3);
536    }
537
538    #[test]
539    fn the_datums_are_named_and_carry_their_accuracy() {
540        assert_eq!(Datum::OSGB36.name(), "OSGB36");
541        assert_eq!(alloc::format!("{}", Datum::PULKOVO_1942), "Pulkovo 1942");
542        assert!((Datum::TOKYO.accuracy().metres() - 29.0).abs() < 1e-9);
543        assert_eq!(*Datum::ED50.ellipsoid(), Ellipsoid::INTERNATIONAL_1924);
544        assert_eq!(
545            Datum::NAD27.to_wgs84_helmert().translation_metres(),
546            [-8.0, 160.0, 176.0]
547        );
548        let own = Datum::new(
549            "chart note",
550            Ellipsoid::INTERNATIONAL_1924,
551            Helmert::translation(-84.0, -97.0, -117.0),
552            Distance::from_metres(-5.0).unwrap(),
553        );
554        assert_eq!(own.name(), "chart note");
555        assert!((own.accuracy().metres() - 5.0).abs() < 1e-9);
556    }
557}