Skip to main content

kinavis_kernel/
local.rs

1//! Local Cartesian frames and vectors typed by frame and unit.
2//!
3//! Near a point, positions and velocities are naturally expressed in a local
4//! Cartesian frame — NED, ENU, or the vessel's forward-right-down. Mixing
5//! frames is the classic sign error; adding a velocity to a displacement the
6//! classic unit error.
7//!
8//! [`Vector3<F, U>`] carries both frame `F` and unit `U` in its type:
9//! `Vector3<Ned, Distance>` and `Vector3<Enu, Distance>` cannot be added, nor
10//! `Vector3<Ned, Distance>` and `Vector3<Ned, Speed>`. The same approach as
11//! [`Direction<F>`](crate::Direction), in three dimensions:
12//!
13//! ```compile_fail
14//! use kinavis_kernel::local::{Enu, Ned, Vector3};
15//! use kinavis_kernel::Distance;
16//!
17//! let ned: Vector3<Ned, Distance> = Vector3::new(Distance::ZERO, Distance::ZERO, Distance::ZERO);
18//! let enu: Vector3<Enu, Distance> = Vector3::new(Distance::ZERO, Distance::ZERO, Distance::ZERO);
19//! let _ = ned + enu; // mismatched types: `Ned` is not `Enu`
20//! ```
21//!
22//! A [`LocalFrame`] is a NED frame anchored at a point: it converts a
23//! [`GeodeticPoint`] to a NED displacement from its origin and back, via
24//! [`EcefPoint`] on a named [`Ellipsoid`].
25//!
26//! ```rust
27//! use kinavis_kernel::geodesy::{Ellipsoid, GeodeticPoint, Height};
28//! use kinavis_kernel::local::LocalFrame;
29//! use kinavis_kernel::{Distance, Position};
30//!
31//! let origin = GeodeticPoint::new(
32//!     "50°45.3'N 001°20.0'W".parse::<Position>()?,
33//!     Height::above_ellipsoid(Distance::ZERO),
34//! );
35//! let frame = LocalFrame::at(origin, &Ellipsoid::WGS84)?;
36//!
37//! // A point one minute of latitude north of the origin.
38//! let north = GeodeticPoint::new(
39//!     "50°46.3'N 001°20.0'W".parse::<Position>()?,
40//!     Height::above_ellipsoid(Distance::ZERO),
41//! );
42//! let ned = frame.ned_of(north)?;
43//! // A minute of latitude on the ellipsoid at 50°N is 1854 m, not the
44//! // sphere's 1852.
45//! assert!((ned.north().metres() - 1854.1).abs() < 0.5);
46//! assert!(ned.east().metres().abs() < 1e-6);
47//! // The Earth curves away under a straight line: the point is slightly
48//! // below the origin's horizontal plane.
49//! assert!(ned.down().metres() > 0.0 && ned.down().metres() < 0.3);
50//! # Ok::<(), kinavis_kernel::KernelError>(())
51//! ```
52
53use core::fmt;
54use core::marker::PhantomData;
55use core::ops::{Add, Div, Mul, Neg, Sub};
56
57use crate::angle::TrueCourse;
58use crate::error::Result;
59use crate::geodesy::{EcefPoint, Ellipsoid, GeodeticPoint};
60use crate::math;
61use crate::units::{Distance, Speed};
62
63mod sealed {
64    pub trait Sealed {}
65}
66
67/// Frame of a [`Vector3`].
68///
69/// Sealed: only the frames below exist.
70pub trait VectorFrame:
71    sealed::Sealed + Copy + Clone + fmt::Debug + Eq + core::hash::Hash + Default + 'static
72{
73    /// Frame name.
74    const NAME: &'static str;
75    /// Axis names, in order.
76    const AXES: [&'static str; 3];
77}
78
79/// North, east, down: the navigation frame.
80///
81/// Down is positive towards the Earth's centre, so height is a negative third
82/// component (standard inertial/estimation convention).
83#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
84#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
85pub struct Ned;
86
87/// East, north, up: surveying and mapping frame.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
89#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
90pub struct Enu;
91
92/// Forward, right, down: the vessel body frame.
93///
94/// Forward along the keel to the bow, right to starboard, down through the
95/// keel. Conversion to a level frame needs the attitude, which is the
96/// estimator's concern.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
98#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
99pub struct Body;
100
101impl sealed::Sealed for Ned {}
102impl sealed::Sealed for Enu {}
103impl sealed::Sealed for Body {}
104
105impl VectorFrame for Ned {
106    const NAME: &'static str = "NED";
107    const AXES: [&'static str; 3] = ["north", "east", "down"];
108}
109
110impl VectorFrame for Enu {
111    const NAME: &'static str = "ENU";
112    const AXES: [&'static str; 3] = ["east", "north", "up"];
113}
114
115impl VectorFrame for Body {
116    const NAME: &'static str = "body";
117    const AXES: [&'static str; 3] = ["forward", "right", "down"];
118}
119
120/// Quantity carried by each [`Vector3`] component.
121///
122/// Sealed: [`Distance`] and [`Speed`] only. Arithmetic is done in SI (m, m/s).
123pub trait VectorUnit:
124    sealed::Sealed
125    + Copy
126    + fmt::Debug
127    + PartialEq
128    + Add<Output = Self>
129    + Sub<Output = Self>
130    + Neg<Output = Self>
131    + Mul<f64, Output = Self>
132{
133    /// Value in SI.
134    fn si(self) -> f64;
135    /// From an SI value known to be finite.
136    fn from_si(value: f64) -> Self;
137}
138
139impl sealed::Sealed for Distance {}
140impl sealed::Sealed for Speed {}
141
142impl VectorUnit for Distance {
143    fn si(self) -> f64 {
144        self.metres()
145    }
146
147    fn from_si(value: f64) -> Self {
148        Self::from_metres(value).unwrap_or(Self::ZERO)
149    }
150}
151
152impl VectorUnit for Speed {
153    fn si(self) -> f64 {
154        self.metres_per_second()
155    }
156
157    fn from_si(value: f64) -> Self {
158        Self::from_metres_per_second(value).unwrap_or(Self::ZERO)
159    }
160}
161
162/// Three components of one quantity in one frame.
163///
164/// Components are in the frame's axis order ([`VectorFrame::AXES`]); each frame
165/// has accessors named after its axes.
166#[derive(Clone, Copy, PartialEq)]
167pub struct Vector3<F: VectorFrame, U: VectorUnit> {
168    components: [U; 3],
169    frame: PhantomData<F>,
170}
171
172impl<F: VectorFrame, U: VectorUnit> Vector3<F, U> {
173    /// Vector from components in axis order.
174    #[must_use]
175    pub const fn new(first: U, second: U, third: U) -> Self {
176        Self {
177            components: [first, second, third],
178            frame: PhantomData,
179        }
180    }
181
182    /// Components in axis order.
183    #[must_use]
184    pub const fn components(&self) -> [U; 3] {
185        self.components
186    }
187
188    /// Length.
189    #[must_use]
190    pub fn magnitude(&self) -> U {
191        let [a, b, c] = self.si();
192        U::from_si(math::hypot(math::hypot(a, b), c))
193    }
194
195    /// Length of the projection on the first two axes (horizontal, for a level
196    /// frame).
197    #[must_use]
198    pub fn horizontal_magnitude(&self) -> U {
199        let [a, b, _] = self.si();
200        U::from_si(math::hypot(a, b))
201    }
202
203    fn si(&self) -> [f64; 3] {
204        self.components.map(U::si)
205    }
206
207    fn from_si(components: [f64; 3]) -> Self {
208        Self {
209            components: components.map(U::from_si),
210            frame: PhantomData,
211        }
212    }
213}
214
215impl<U: VectorUnit> Vector3<Ned, U> {
216    /// North component.
217    #[must_use]
218    pub const fn north(&self) -> U {
219        self.components[0]
220    }
221
222    /// East component.
223    #[must_use]
224    pub const fn east(&self) -> U {
225        self.components[1]
226    }
227
228    /// Down component.
229    #[must_use]
230    pub const fn down(&self) -> U {
231        self.components[2]
232    }
233
234    /// Same vector in ENU.
235    #[must_use]
236    pub fn to_enu(self) -> Vector3<Enu, U> {
237        Vector3::new(self.east(), self.north(), -self.down())
238    }
239
240    /// True direction of the horizontal component; `None` if negligible.
241    ///
242    /// For a velocity: course over ground.
243    #[must_use]
244    pub fn horizontal_direction(&self) -> Option<TrueCourse> {
245        horizontal_direction(self.north().si(), self.east().si())
246    }
247}
248
249impl<U: VectorUnit> Vector3<Enu, U> {
250    /// East component.
251    #[must_use]
252    pub const fn east(&self) -> U {
253        self.components[0]
254    }
255
256    /// North component.
257    #[must_use]
258    pub const fn north(&self) -> U {
259        self.components[1]
260    }
261
262    /// Up component.
263    #[must_use]
264    pub const fn up(&self) -> U {
265        self.components[2]
266    }
267
268    /// Same vector in NED.
269    #[must_use]
270    pub fn to_ned(self) -> Vector3<Ned, U> {
271        Vector3::new(self.north(), self.east(), -self.up())
272    }
273
274    /// True direction of the horizontal component; `None` if negligible.
275    #[must_use]
276    pub fn horizontal_direction(&self) -> Option<TrueCourse> {
277        horizontal_direction(self.north().si(), self.east().si())
278    }
279}
280
281impl<U: VectorUnit> Vector3<Body, U> {
282    /// Forward component.
283    #[must_use]
284    pub const fn forward(&self) -> U {
285        self.components[0]
286    }
287
288    /// Starboard component.
289    #[must_use]
290    pub const fn right(&self) -> U {
291        self.components[1]
292    }
293
294    /// Down component.
295    #[must_use]
296    pub const fn down(&self) -> U {
297        self.components[2]
298    }
299}
300
301/// Direction of a north/east pair; `None` when both are zero relative to the
302/// larger.
303fn horizontal_direction(north: f64, east: f64) -> Option<TrueCourse> {
304    let scale = math::abs(north).max(math::abs(east));
305    if scale < f64::MIN_POSITIVE {
306        return None;
307    }
308    TrueCourse::wrap(math::to_degrees(math::atan2(east, north))).ok()
309}
310
311impl<F: VectorFrame, U: VectorUnit> Add for Vector3<F, U> {
312    type Output = Self;
313
314    fn add(self, other: Self) -> Self {
315        let [first, second, third] = self.components;
316        let [x, y, z] = other.components;
317        Self::new(first + x, second + y, third + z)
318    }
319}
320
321impl<F: VectorFrame, U: VectorUnit> Sub for Vector3<F, U> {
322    type Output = Self;
323
324    fn sub(self, other: Self) -> Self {
325        let [first, second, third] = self.components;
326        let [x, y, z] = other.components;
327        Self::new(first - x, second - y, third - z)
328    }
329}
330
331impl<F: VectorFrame, U: VectorUnit> Neg for Vector3<F, U> {
332    type Output = Self;
333
334    fn neg(self) -> Self {
335        let [a, b, c] = self.components;
336        Self::new(-a, -b, -c)
337    }
338}
339
340impl<F: VectorFrame, U: VectorUnit> Mul<f64> for Vector3<F, U> {
341    type Output = Self;
342
343    fn mul(self, factor: f64) -> Self {
344        let [a, b, c] = self.components;
345        Self::new(a * factor, b * factor, c * factor)
346    }
347}
348
349impl<F: VectorFrame, U: VectorUnit> Div<f64> for Vector3<F, U> {
350    type Output = Self;
351
352    /// Division by zero yields infinite components, which `from_si` maps to
353    /// zero; callers dividing by elapsed time check it first.
354    fn div(self, divisor: f64) -> Self {
355        Self::from_si(self.si().map(|value| value / divisor))
356    }
357}
358
359impl<F: VectorFrame, U: VectorUnit> fmt::Debug for Vector3<F, U> {
360    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
361        let mut debug = f.debug_struct(F::NAME);
362        for (axis, component) in F::AXES.iter().zip(&self.components) {
363            debug.field(axis, component);
364        }
365        debug.finish()
366    }
367}
368
369impl<F: VectorFrame, U: VectorUnit + fmt::Display> fmt::Display for Vector3<F, U> {
370    /// Formats as `(north 200.0 m, east 50.0 m, down -3.0 m)`.
371    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
372        f.write_str("(")?;
373        for (index, (axis, component)) in F::AXES.iter().zip(&self.components).enumerate() {
374            if index > 0 {
375                f.write_str(", ")?;
376            }
377            write!(f, "{axis} {component}")?;
378        }
379        f.write_str(")")
380    }
381}
382
383#[cfg(feature = "serde")]
384impl<F: VectorFrame, U: VectorUnit + serde::Serialize> serde::Serialize for Vector3<F, U> {
385    /// Serialised as three components; the frame is in the type.
386    fn serialize<S: serde::Serializer>(
387        &self,
388        serializer: S,
389    ) -> core::result::Result<S::Ok, S::Error> {
390        self.components.serialize(serializer)
391    }
392}
393
394#[cfg(feature = "serde")]
395impl<'de, F: VectorFrame, U: VectorUnit + serde::Deserialize<'de>> serde::Deserialize<'de>
396    for Vector3<F, U>
397{
398    fn deserialize<D: serde::Deserializer<'de>>(
399        deserializer: D,
400    ) -> core::result::Result<Self, D::Error> {
401        let [a, b, c] = <[U; 3]>::deserialize(deserializer)?;
402        Ok(Self::new(a, b, c))
403    }
404}
405
406/// NED frame anchored at a point.
407///
408/// Displacements are computed via ECEF, exact at any distance: no flat-Earth
409/// approximation; curvature appears as a growing `down` component for level
410/// points further from the origin.
411#[derive(Debug, Clone, Copy, PartialEq)]
412pub struct LocalFrame {
413    origin: GeodeticPoint,
414    origin_ecef: EcefPoint,
415    ellipsoid: Ellipsoid,
416    /// Rows of the ECEF → NED rotation.
417    rotation: [[f64; 3]; 3],
418}
419
420impl LocalFrame {
421    /// Frame with origin at a point on an ellipsoid.
422    ///
423    /// # Errors
424    ///
425    /// As [`EcefPoint::from_geodetic`]: the origin height must be ellipsoidal.
426    pub fn at(origin: GeodeticPoint, ellipsoid: &Ellipsoid) -> Result<Self> {
427        let origin_ecef = EcefPoint::from_geodetic(origin, ellipsoid)?;
428        let (sin_lat, cos_lat) = sin_cos(origin.position().latitude().radians());
429        let (sin_lon, cos_lon) = sin_cos(origin.position().longitude().radians());
430        Ok(Self {
431            origin,
432            origin_ecef,
433            ellipsoid: *ellipsoid,
434            rotation: [
435                [-sin_lat * cos_lon, -sin_lat * sin_lon, cos_lat],
436                [-sin_lon, cos_lon, 0.0],
437                [-cos_lat * cos_lon, -cos_lat * sin_lon, -sin_lat],
438            ],
439        })
440    }
441
442    /// Origin.
443    #[must_use]
444    pub const fn origin(&self) -> GeodeticPoint {
445        self.origin
446    }
447
448    /// Ellipsoid.
449    #[must_use]
450    pub const fn ellipsoid(&self) -> &Ellipsoid {
451        &self.ellipsoid
452    }
453
454    /// NED displacement of a point from the origin.
455    ///
456    /// # Errors
457    ///
458    /// As [`EcefPoint::from_geodetic`]: the height must be ellipsoidal.
459    pub fn ned_of(&self, point: GeodeticPoint) -> Result<Vector3<Ned, Distance>> {
460        let ecef = EcefPoint::from_geodetic(point, &self.ellipsoid)?;
461        let delta = [
462            ecef.x().metres() - self.origin_ecef.x().metres(),
463            ecef.y().metres() - self.origin_ecef.y().metres(),
464            ecef.z().metres() - self.origin_ecef.z().metres(),
465        ];
466        Ok(Vector3::from_si(self.rotation.map(|row| dot(row, delta))))
467    }
468
469    /// ENU displacement of a point from the origin.
470    ///
471    /// # Errors
472    ///
473    /// As [`LocalFrame::ned_of`].
474    pub fn enu_of(&self, point: GeodeticPoint) -> Result<Vector3<Enu, Distance>> {
475        self.ned_of(point).map(Vector3::to_enu)
476    }
477
478    /// Point at a NED displacement from the origin.
479    ///
480    /// Inverse of [`LocalFrame::ned_of`]; round trip holds to 0.1 mm for
481    /// terrestrial displacements (property-tested).
482    ///
483    /// # Errors
484    ///
485    /// As [`EcefPoint::to_geodetic`]; not reachable for terrestrial
486    /// displacements.
487    pub fn point_from_ned(&self, displacement: Vector3<Ned, Distance>) -> Result<GeodeticPoint> {
488        let local = displacement.si();
489        // Orthonormal rotation: its transpose maps NED back.
490        let column = |index: usize| {
491            self.rotation
492                .iter()
493                .zip(local)
494                .map(|(row, value)| row.get(index).copied().unwrap_or(0.0) * value)
495                .sum::<f64>()
496        };
497        let ecef = EcefPoint::new(
498            Distance::from_si(self.origin_ecef.x().metres() + column(0)),
499            Distance::from_si(self.origin_ecef.y().metres() + column(1)),
500            Distance::from_si(self.origin_ecef.z().metres() + column(2)),
501        );
502        ecef.to_geodetic(&self.ellipsoid)
503    }
504
505    /// Point at an ENU displacement from the origin.
506    ///
507    /// # Errors
508    ///
509    /// As [`LocalFrame::point_from_ned`].
510    pub fn point_from_enu(&self, displacement: Vector3<Enu, Distance>) -> Result<GeodeticPoint> {
511        self.point_from_ned(displacement.to_ned())
512    }
513}
514
515fn sin_cos(radians: f64) -> (f64, f64) {
516    (math::sin(radians), math::cos(radians))
517}
518
519fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
520    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
521}
522
523#[cfg(test)]
524#[allow(clippy::unwrap_used, clippy::float_cmp)]
525mod tests {
526    use super::*;
527    use crate::geodesy::Height;
528    use crate::position::{Latitude, Longitude, Position};
529    use alloc::format;
530
531    fn metres(value: f64) -> Distance {
532        Distance::from_metres(value).unwrap()
533    }
534
535    fn point(latitude: f64, longitude: f64, height: f64) -> GeodeticPoint {
536        GeodeticPoint::new(
537            Position::new(
538                Latitude::from_degrees(latitude).unwrap(),
539                Longitude::from_degrees(longitude).unwrap(),
540            ),
541            Height::above_ellipsoid(metres(height)),
542        )
543    }
544
545    #[test]
546    fn vectors_add_scale_and_measure_within_one_frame_and_unit() {
547        let a: Vector3<Ned, Distance> = Vector3::new(metres(3.0), metres(4.0), metres(12.0));
548        let b = Vector3::new(metres(1.0), metres(1.0), metres(1.0));
549        let close = |vector: Vector3<Ned, Distance>, wanted: [f64; 3]| {
550            vector
551                .components()
552                .iter()
553                .zip(wanted)
554                .all(|(got, wanted)| (got.metres() - wanted).abs() < 1e-9)
555        };
556        assert!(close(a + b, [4.0, 5.0, 13.0]));
557        assert!(close(a - b, [2.0, 3.0, 11.0]));
558        assert!(close(-a, [-3.0, -4.0, -12.0]));
559        assert!(close(a * 2.0, [6.0, 8.0, 24.0]));
560        assert!(close(a / 2.0, [1.5, 2.0, 6.0]));
561        assert!((a.magnitude().metres() - 13.0).abs() < 1e-9);
562        assert!((a.horizontal_magnitude().metres() - 5.0).abs() < 1e-9);
563        let printed = format!("{a:?}");
564        assert!(printed.starts_with("NED { north: "), "{printed}");
565        assert!(format!("{a}").starts_with("(north "), "{a}");
566    }
567
568    #[test]
569    fn ned_and_enu_are_the_same_vector_written_differently() {
570        let ned: Vector3<Ned, Speed> = Vector3::new(
571            Speed::from_metres_per_second(4.0).unwrap(),
572            Speed::from_metres_per_second(1.0).unwrap(),
573            Speed::from_metres_per_second(-0.5).unwrap(),
574        );
575        let enu = ned.to_enu();
576        assert_eq!(enu.east(), ned.east());
577        assert_eq!(enu.north(), ned.north());
578        assert_eq!(enu.up().metres_per_second(), 0.5);
579        assert_eq!(enu.to_ned(), ned);
580        let course = ned.horizontal_direction().unwrap();
581        assert!((course.degrees() - 14.036_243_467_926_479).abs() < 1e-9);
582        assert_eq!(enu.horizontal_direction(), Some(course));
583        let still: Vector3<Ned, Speed> = Vector3::new(Speed::ZERO, Speed::ZERO, Speed::ZERO);
584        assert_eq!(still.horizontal_direction(), None);
585    }
586
587    #[test]
588    fn a_local_frame_measures_displacements_from_its_origin() {
589        let frame = LocalFrame::at(point(50.0, 0.0, 0.0), &Ellipsoid::WGS84).unwrap();
590        // Origin is at zero.
591        let zero = frame.ned_of(point(50.0, 0.0, 0.0)).unwrap();
592        assert!(zero.magnitude().metres() < 1e-6);
593        // A point straight up is straight up.
594        let above = frame.ned_of(point(50.0, 0.0, 100.0)).unwrap();
595        assert!(above.north().metres().abs() < 1e-6);
596        assert!(above.east().metres().abs() < 1e-6);
597        assert!((above.down().metres() + 100.0).abs() < 1e-6);
598        // A point to the east lies east and slightly below the horizon.
599        let east = frame.ned_of(point(50.0, 0.01, 0.0)).unwrap();
600        assert!(east.east().metres() > 700.0 && east.east().metres() < 720.0);
601        assert!(east.north().metres().abs() < 0.1);
602        assert!(east.down().metres() > 0.0);
603        assert_eq!(east.horizontal_direction().unwrap().degrees().round(), 90.0);
604    }
605
606    #[test]
607    fn displacements_round_trip_through_the_frame() {
608        let origins = [
609            point(50.0, 0.0, 0.0),
610            point(89.99, 179.99, 10.0),
611            point(-33.9, 151.2, 50.0),
612            point(0.0, -180.0, -30.0),
613        ];
614        for origin in origins {
615            let frame = LocalFrame::at(origin, &Ellipsoid::WGS84).unwrap();
616            let displacement: Vector3<Ned, Distance> =
617                Vector3::new(metres(12_345.6), metres(-9_876.5), metres(432.1));
618            let there = frame.point_from_ned(displacement).unwrap();
619            let back = frame.ned_of(there).unwrap();
620            let error = (back - displacement).magnitude().metres();
621            assert!(error < 1e-4, "{origin}: off by {error} m, {back:?}");
622            let enu_back = frame.enu_of(there).unwrap();
623            let again = frame.point_from_enu(enu_back).unwrap();
624            assert!(
625                (again.position().latitude().degrees() - there.position().latitude().degrees())
626                    .abs()
627                    < 1e-9
628            );
629        }
630    }
631
632    #[test]
633    fn the_frame_wants_an_ellipsoidal_origin() {
634        let msl = GeodeticPoint::new(
635            point(50.0, 0.0, 0.0).position(),
636            Height::above_mean_sea_level(Distance::ZERO),
637        );
638        assert!(LocalFrame::at(msl, &Ellipsoid::WGS84).is_err());
639    }
640}