Skip to main content

fusion_nav/
geodetic.rs

1//! Geodetic positions and the local tangent plane the filter navigates in.
2//! Equations (43)–(44).
3//!
4//! The filter navigates in Cartesian NED about a fixed origin, and GNSS reports latitude,
5//! longitude and height. Something has to convert, and the conversion is only right if it
6//! is taken about the same origin the filter's position is relative to. An application
7//! converting on its own side has nothing to check that against: the filter's origin is
8//! where the vehicle was at initialization, and the application's is whatever point it
9//! chose. So the filter owns the origin — see
10//! [`Eskf::fuse_gnss_geodetic`](crate::Eskf::fuse_gnss_geodetic) — and this module is
11//! the conversion it owns it with.
12
13use nalgebra::{ComplexField, Matrix3, RealField, Vector3};
14
15use crate::frames::Ned;
16use crate::units::{Position, RadiansPerSecond};
17
18/// WGS84 semi-major axis, in meters: a defining parameter of the ellipsoid,
19/// NGA.STND.0036 v1.0.0 Table 3.1.
20const WGS84_A: f64 = 6_378_137.0;
21
22/// WGS84 first eccentricity squared, NGA.STND.0036 v1.0.0 Table 3.5. Written to the digits
23/// the standard prints rather than derived from the flattening: it asks that derived
24/// constants keep them, so that precision stays consistent between parameters.
25const WGS84_E2: f64 = 6.694_379_990_141e-3;
26
27/// WGS84 flattening, NGA.STND.0036 v1.0.0 Table 3.1: a defining parameter, as its reciprocal.
28const WGS84_F: f64 = 1.0 / 298.257_223_563;
29
30/// WGS84 normal gravity at the equator, m s⁻², and Somigliana's constant `k`,
31/// NGA.STND.0036 v1.0.0 Table 3.6.
32const WGS84_GAMMA_E: f64 = 9.780_325_335_9;
33const WGS84_K: f64 = 1.931_852_652_458e-3;
34
35/// WGS84 normal gravity formula constant `m = ω²a²b/GM`, NGA.STND.0036 v1.0.0 Table 3.6.
36const WGS84_M: f64 = 3.449_786_506_841e-3;
37
38/// WGS84 angular velocity of the Earth, `ω`, NGA.STND.0036 v1.0.0 Table 3.1. The propagation of
39/// (9)–(15) leaves it out; [`Measured::gyro_bias`](crate::init::Measured::gyro_bias) says what
40/// that costs the gyroscope bias.
41pub(crate) const EARTH_RATE: RadiansPerSecond = RadiansPerSecond::from_rad_per_s(7.292_115e-5);
42
43/// A position on the WGS84 ellipsoid: latitude, longitude, and height.
44///
45/// `f64` throughout, where the rest of the crate is `f32`: at a latitude of 50° an `f32`
46/// degree has a resolution of 4 × 10⁻⁶ °, which is 0.4 m on the ground. Positions
47/// relative to the origin are small, so everything after the conversion is `f32` again.
48///
49/// Height is above the WGS84 ellipsoid for the conversion to be exact, which is what
50/// receivers compute (u-blox `height`, PX4 `altitude_ellipsoid_m`). Height above mean
51/// sea level works if used consistently, but it misplaces the ellipsoid by the local geoid
52/// undulation — up to 100 m — and so scales horizontal distance by up to 1.6 × 10⁻⁵:
53/// 1.6 cm per kilometer from the origin.
54#[derive(Clone, Copy, Debug, PartialEq)]
55pub struct Geodetic {
56    latitude: f64,
57    longitude: f64,
58    height: f64,
59}
60
61impl Geodetic {
62    /// Construct from latitude and longitude in degrees, and height in meters.
63    ///
64    /// Current PX4 publishes exactly this, as `float64`: `latitude_deg`, `longitude_deg` and
65    /// `altitude_ellipsoid_m` of `SensorGps` (`msg/SensorGps.msg:8-11` at `c4e4ef98`) pass
66    /// straight through, with no scaling.
67    pub fn from_degrees(latitude: f64, longitude: f64, height: f64) -> Self {
68        Self::from_radians(latitude.to_radians(), longitude.to_radians(), height)
69    }
70
71    /// Construct from latitude and longitude in radians, and height in meters.
72    pub const fn from_radians(latitude: f64, longitude: f64, height: f64) -> Self {
73        Self {
74            latitude,
75            longitude,
76            height,
77        }
78    }
79
80    /// Construct from the integer encoding u-blox `NAV-PVT`, MAVLink `GPS_RAW_INT`, and
81    /// PX4's older `vehicle_gps_position` all share: latitude and longitude in 10⁻⁷
82    /// degrees, height in millimeters.
83    ///
84    /// ArduPilot's `Location` shares the latitude and longitude but not the height, which is
85    /// centimeters (`int32_t alt; // in cm`, `libraries/AP_Common/Location.h:17` at `368dc0c4`):
86    /// passed here unconverted it is ten times too low. Multiply it by ten first.
87    pub fn from_degrees_e7(latitude: i32, longitude: i32, height_mm: i32) -> Self {
88        Self::from_degrees(
89            f64::from(latitude) * 1e-7,
90            f64::from(longitude) * 1e-7,
91            f64::from(height_mm) * 1e-3,
92        )
93    }
94
95    /// Latitude in degrees.
96    pub fn latitude_deg(self) -> f64 {
97        self.latitude.to_degrees()
98    }
99
100    /// Longitude in degrees.
101    pub fn longitude_deg(self) -> f64 {
102        self.longitude.to_degrees()
103    }
104
105    /// Latitude in radians.
106    pub const fn latitude_rad(self) -> f64 {
107        self.latitude
108    }
109
110    /// Longitude in radians.
111    pub const fn longitude_rad(self) -> f64 {
112        self.longitude
113    }
114
115    /// Height in meters, on the receiver's datum.
116    pub const fn height(self) -> f64 {
117        self.height
118    }
119
120    /// The magnetic declination here, east-positive, from the crate's World Magnetic Model
121    /// table (one epoch, 10° grid, bilinear): `None` for a coordinate that is not a number.
122    /// Height is ignored.
123    ///
124    /// The value [`Eskf`](crate::Eskf) applies itself where it places its origin, unless
125    /// [`set_magnetic_declination`](crate::Eskf::set_magnetic_declination) has been called.
126    /// A caller that knows its site before initializing hands the filter the site through
127    /// [`set_origin`](crate::Eskf::set_origin) instead, which reads this and leaves the model
128    /// in charge; passing this to `set_magnetic_declination` fixes it for good. Behind the
129    /// `magnetic-model` feature, on by default.
130    #[cfg(feature = "magnetic-model")]
131    pub fn magnetic_declination(self) -> Option<crate::units::Radians> {
132        crate::magnetic::declination_at(self)
133    }
134
135    /// WGS84 normal gravity here, m s⁻²: the value [`Config::gravity`](crate::Config::gravity)
136    /// is configured with for a site.
137    ///
138    /// Somigliana's closed formula on the ellipsoid, NGA.STND.0036 v1.0.0 equation (4-1), carried
139    /// to the height by the second-order series of (4-3), which the standard gives for small
140    /// heights: it is within a few µm s⁻² of the exact (4-4) at the altitudes a vehicle
141    /// navigates, against the 0.03 m s⁻² the latitude moves it. Not a number for a coordinate that
142    /// is not one.
143    pub fn normal_gravity(self) -> f32 {
144        let sin_phi = ComplexField::sin(self.latitude);
145        let sin2 = sin_phi * sin_phi;
146        // (4-1)
147        let surface =
148            WGS84_GAMMA_E * (1.0 + WGS84_K * sin2) / ComplexField::sqrt(1.0 - WGS84_E2 * sin2);
149        // (4-3)
150        let h = self.height;
151        let first = 2.0 / WGS84_A * (1.0 + WGS84_F + WGS84_M - 2.0 * WGS84_F * sin2) * h;
152        let second = 3.0 / (WGS84_A * WGS84_A) * h * h;
153        (surface * (1.0 - first + second)) as f32
154    }
155
156    /// Whether every coordinate is a number, neither NaN nor infinite.
157    pub(crate) fn is_finite(self) -> bool {
158        self.latitude.is_finite() && self.longitude.is_finite() && self.height.is_finite()
159    }
160}
161
162/// The origin of the navigation frame, and the local tangent plane about it.
163/// Equation (43).
164///
165/// Exact, not approximated: a geodetic position goes to Earth-centered Cartesian
166/// coordinates, the origin's are subtracted, and the difference is rotated onto the
167/// origin's north, east and down. Every step is closed-form geometry on the WGS84
168/// ellipsoid, so there is no range at which the conversion starts to lose accuracy; the
169/// only rounding is the final narrowing to `f32`, 1 mm at 10 km. The inverse is exact to
170/// micrometers too — see [`to_geodetic`](Self::to_geodetic).
171///
172/// What *does* change with range is not the conversion but the frame: a plane leaves a
173/// curved Earth. At a horizontal distance `d` the plane sits `d²/2R` above the surface
174/// under it — 8 cm at 1 km, 7.8 m at 10 km — so `-p_D` there is not height above the
175/// origin, and down there is tilted `d/R` from local vertical. See
176/// [`EQUATIONS.md`](https://github.com/wboayue/fusion-nav/blob/main/EQUATIONS.md#geodetic-origin)
177/// for what that means for the barometer.
178///
179/// PX4 projects with an azimuthal equidistant projection on a sphere
180/// (`MapProjection::project` in `src/lib/geo/geo.cpp`): a map rather than a plane, with a
181/// spherical Earth's scale, 0.3% off at mid latitudes.
182#[derive(Clone, Copy, Debug, PartialEq)]
183pub struct LocalOrigin {
184    origin: Geodetic,
185    /// The origin in Earth-centered, Earth-fixed coordinates, in meters.
186    ecef: Vector3<f64>,
187    /// `Cₑⁿ`: rotates an ECEF difference onto the origin's north, east, down.
188    ecef_to_ned: Matrix3<f64>,
189}
190
191impl LocalOrigin {
192    /// The tangent plane about `origin`, or `None` for a point that cannot be one: a
193    /// coordinate that is not a number, or a latitude beyond ±90°.
194    pub fn new(origin: Geodetic) -> Option<Self> {
195        Self::is_usable(origin).then(|| Self::about(origin))
196    }
197
198    /// The tangent plane about a point already known to be usable.
199    fn about(origin: Geodetic) -> Self {
200        let (sin_phi, cos_phi) = ComplexField::sin_cos(origin.latitude);
201        let (sin_lambda, cos_lambda) = ComplexField::sin_cos(origin.longitude);
202        // Rows are the origin's north, east and down, written in ECEF.
203        #[rustfmt::skip]
204        let ecef_to_ned = Matrix3::new(
205            -sin_phi * cos_lambda, -sin_phi * sin_lambda,  cos_phi,
206            -sin_lambda,            cos_lambda,            0.0,
207            -cos_phi * cos_lambda, -cos_phi * sin_lambda, -sin_phi,
208        );
209        Self {
210            origin,
211            ecef: ecef(origin),
212            ecef_to_ned,
213        }
214    }
215
216    /// Where the origin is.
217    pub const fn geodetic(&self) -> Geodetic {
218        self.origin
219    }
220
221    /// A geodetic position as NED meters from the origin. Equation (43).
222    pub fn to_ned(&self, point: Geodetic) -> Position<Ned> {
223        let ned = self.ecef_to_ned * (ecef(point) - self.ecef);
224        // Narrowed only after the subtraction, which is where the f64 was needed.
225        Position::from_vector(ned.map(|meters| meters as f32))
226    }
227
228    /// NED meters from the origin as a geodetic position: equation (43) run backwards.
229    ///
230    /// The rotation inverts exactly (it is orthonormal, so its inverse is its transpose);
231    /// ECEF to geodetic is the one step with no closed form this simple, and is solved by
232    /// fixed-point iteration; see `geodetic` in this module.
233    pub fn to_geodetic(&self, position: Position<Ned>) -> Geodetic {
234        let ned = position.vector().map(f64::from);
235        geodetic(self.ecef + self.ecef_to_ned.transpose() * ned)
236    }
237
238    /// The origin whose tangent plane puts `fix` at `estimate`. Equation (44).
239    ///
240    /// For a filter that has been navigating before it knew where it was: it has a
241    /// position relative to its own start, and the first fix says where that start was.
242    /// The origin is placed so the two agree, rather than at the fix, which would step the
243    /// estimate by however far the vehicle had moved. PX4 does the same when its origin is
244    /// first set with aiding already active (`collect_gps`, `_pos_ref.reproject(-pos)`).
245    ///
246    /// Solved by iteration, because the answer depends on itself: stepping `-estimate`
247    /// from the fix needs the origin's axes, and those depend on where the origin is. Each
248    /// pass takes the axes from the previous guess, starting at the fix. Away from the
249    /// poles each pass shrinks the error by about `|estimate| / R`: measured at Zurich,
250    /// five passes leave 5 nm at 10 km, 20 µm at 100 km, and 13 mm at 300 km.
251    ///
252    /// Near a pole the axes turn with longitude, and each pass shrinks the error only by
253    /// `|estimate|` over the distance to the pole — which grows it once the estimate is the
254    /// longer of the two. There the origin may not exist at all: an origin `r` from the
255    /// pole puts a point `e` due east of it `√(r² + e²)` from the pole, so a fix 11 m from
256    /// the pole is 100 m east of no origin. So the result is checked, not assumed: the fix
257    /// must land on the estimate to within what the `f32` state resolves there — 1 mm, or
258    /// one part in 2²³ of `|estimate|` beyond 8 km — so a placement that passes steps
259    /// nothing the state could represent.
260    ///
261    /// `None` if the fix cannot be an origin — see [`new`](Self::new) — or no origin was
262    /// found that puts it at the estimate.
263    pub fn placing(fix: Geodetic, estimate: Position<Ned>) -> Option<Self> {
264        const PASSES: usize = 5;
265        if !Self::is_usable(fix) {
266            return None;
267        }
268        let offset = estimate.vector().map(f64::from);
269        let fix = ecef(fix);
270        let mut origin = Self::about(geodetic(fix));
271        for _ in 0..PASSES {
272            origin = Self::about(geodetic(fix - origin.ecef_to_ned.transpose() * offset));
273        }
274        // Measured in f64, before the narrowing `to_ned` would add.
275        let landed = origin.ecef_to_ned * (fix - origin.ecef);
276        let tolerance = f64::max(1e-3, f64::from(f32::EPSILON) * offset.norm());
277        ((landed - offset).norm() <= tolerance).then_some(origin)
278    }
279
280    /// Whether `origin` can be one: finite, and a latitude that exists. The poles are
281    /// fine — north there is whichever meridian the longitude names.
282    fn is_usable(origin: Geodetic) -> bool {
283        origin.is_finite() && origin.latitude.abs() <= core::f64::consts::FRAC_PI_2
284    }
285}
286
287/// Earth-centered, Earth-fixed coordinates of a geodetic position, in meters.
288///
289/// `N` is the prime-vertical radius of curvature at that latitude; see Groves,
290/// *Principles of GNSS, Inertial, and Multisensor Integrated Navigation Systems*, (2.112).
291fn ecef(point: Geodetic) -> Vector3<f64> {
292    let (sin_phi, cos_phi) = ComplexField::sin_cos(point.latitude);
293    let (sin_lambda, cos_lambda) = ComplexField::sin_cos(point.longitude);
294    let n = prime_vertical_radius(sin_phi);
295    let h = point.height;
296    Vector3::new(
297        (n + h) * cos_phi * cos_lambda,
298        (n + h) * cos_phi * sin_lambda,
299        (n * (1.0 - WGS84_E2) + h) * sin_phi,
300    )
301}
302
303/// Geodetic position of ECEF coordinates: the inverse of [`ecef`].
304///
305/// Longitude is direct. Latitude is the fixed point of
306/// `φ = atan2(z + e² N(φ) sin φ, p)`, where `p` is the distance from the polar axis;
307/// each pass shrinks the error by about `e²`, 1/150, so five passes from the geocentric
308/// latitude take the worst case to below a micrometer on the ground. A fixed count rather
309/// than a tolerance, so the cost is the same every call.
310///
311/// Height uses the form that stays finite at the poles, rather than `p / cos φ − N`.
312fn geodetic(ecef: Vector3<f64>) -> Geodetic {
313    const PASSES: usize = 5;
314    let p = ComplexField::hypot(ecef.x, ecef.y);
315    let longitude = RealField::atan2(ecef.y, ecef.x);
316    let mut latitude = RealField::atan2(ecef.z, p);
317    for _ in 0..PASSES {
318        let sin_phi = ComplexField::sin(latitude);
319        let n = prime_vertical_radius(sin_phi);
320        latitude = RealField::atan2(ecef.z + WGS84_E2 * n * sin_phi, p);
321    }
322    let (sin_phi, cos_phi) = ComplexField::sin_cos(latitude);
323    let height = p * cos_phi + ecef.z * sin_phi
324        - WGS84_A * ComplexField::sqrt(1.0 - WGS84_E2 * sin_phi * sin_phi);
325    Geodetic {
326        latitude,
327        longitude,
328        height,
329    }
330}
331
332/// `N`, the radius of curvature in the prime vertical, at a latitude given by its sine.
333fn prime_vertical_radius(sin_phi: f64) -> f64 {
334    WGS84_A / ComplexField::sqrt(1.0 - WGS84_E2 * sin_phi * sin_phi)
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn normal_gravity_meets_the_standards_own_figures() {
343        // NGA.STND.0036 Table 3.6: γe at the equator and γp at the pole, on the ellipsoid. The
344        // pole is not an input, so it checks (4-1) and its constants against each other.
345        let at = |latitude, height| Geodetic::from_degrees(latitude, 0.0, height).normal_gravity();
346        assert_eq!(at(0.0, 0.0), 9.780_325_f32);
347        assert_eq!(at(90.0, 0.0), 9.832_185_f32);
348        assert_eq!(at(-90.0, 0.0), at(90.0, 0.0));
349        // 45°: standard gravity, 9.80665, is a sea-level value near this latitude, not of it.
350        assert!((at(45.0, 0.0) - 9.806_2).abs() < 1e-4, "{}", at(45.0, 0.0));
351        // The free-air gradient, 3.086 mGal m⁻¹, over the first kilometer.
352        let lapse = at(45.0, 0.0) - at(45.0, 1000.0);
353        assert!((lapse - 3.086e-3).abs() < 1e-5, "{lapse}");
354        // (4-3)'s second-order term, `3h²/a²`, bends the lapse: over 20 km the fall is short of
355        // twenty times the first kilometer's by γ · 3 (h₂₀² − 20 h₁²)/a², 2.75e-4 m s⁻².
356        let bend = 20.0 * lapse - (at(45.0, 0.0) - at(45.0, 20_000.0));
357        assert!((bend - 2.75e-4).abs() < 0.3e-4, "{bend}");
358        assert!(at(f64::NAN, 0.0).is_nan());
359    }
360
361    /// Zurich, a mid latitude where east and north scales differ visibly.
362    fn zurich() -> Geodetic {
363        Geodetic::from_degrees(47.3977, 8.5456, 488.0)
364    }
365
366    #[test]
367    fn the_origin_is_zero() {
368        let origin = LocalOrigin::new(zurich()).expect("usable");
369        assert!(origin.to_ned(zurich()).vector().norm() < 1e-6);
370    }
371
372    #[test]
373    fn ecef_matches_the_ellipsoid_where_it_is_known_by_heart() {
374        // On the equator at the prime meridian: the semi-major axis along x.
375        let equator = ecef(Geodetic::from_degrees(0.0, 0.0, 0.0));
376        assert!((equator - Vector3::new(WGS84_A, 0.0, 0.0)).norm() < 1e-6);
377        // At the north pole: the semi-minor axis, b = a √(1 − e²), along z.
378        let b = 6_356_752.314_245;
379        let pole = ecef(Geodetic::from_degrees(90.0, 0.0, 0.0));
380        assert!((pole - Vector3::new(0.0, 0.0, b)).norm() < 1e-3, "{pole:?}");
381    }
382
383    #[test]
384    fn ecef_and_back_is_lossless_everywhere() {
385        for (lat, lon, h) in [
386            (47.3977, 8.5456, 488.0),
387            (-33.9, 151.2, -30.0),
388            (89.9999, 45.0, 1000.0),
389            (90.0, 0.0, 0.0),
390            (-90.0, 0.0, 0.0),
391            (0.0, -179.9999, 12_000.0),
392            (64.1, -21.9, 400_000.0),
393        ] {
394            let point = Geodetic::from_degrees(lat, lon, h);
395            let back = geodetic(ecef(point));
396            let error = (ecef(back) - ecef(point)).norm();
397            assert!(error < 1e-6, "{lat}, {lon}, {h}: {error} m");
398        }
399    }
400
401    #[test]
402    fn a_round_trip_returns_what_went_in_at_any_range() {
403        let origin = LocalOrigin::new(zurich()).expect("usable");
404        // Up to 100 km, where the f32 position itself resolves 8 mm.
405        for p in [
406            Position::ned(1234.5, -876.25, -120.0),
407            Position::ned(10_000.0, 10_000.0, 0.0),
408            Position::ned(-100_000.0, 60_000.0, -3000.0),
409        ] {
410            let back = origin.to_ned(origin.to_geodetic(p));
411            let error = (back.vector() - p.vector()).norm();
412            assert!(error < 1e-2, "{p:?}: {error} m");
413        }
414    }
415
416    #[test]
417    fn range_is_the_straight_line_distance() {
418        // A plane measures chords, so the NED distance from the origin is exactly the
419        // distance through ECEF — nothing a projection could say about any map.
420        let origin = LocalOrigin::new(zurich()).expect("usable");
421        let far = Geodetic::from_degrees(47.9, 9.3, 1200.0);
422        let chord = (ecef(far) - ecef(zurich())).norm();
423        let ned = f64::from(origin.to_ned(far).vector().norm());
424        assert!((ned - chord).abs() < 1e-2, "{ned} vs {chord}");
425    }
426
427    #[test]
428    fn it_matches_the_converter() {
429        // tools/ulog2replay.py::geodetic_to_ned about zurich(), to 1 km and to 10 km.
430        let origin = LocalOrigin::new(zurich()).expect("usable");
431        for (point, expected) in [
432            (
433                Geodetic::from_degrees(47.4077, 8.5556, 498.0),
434                [1111.9223, 754.814, -9.8584],
435            ),
436            (
437                Geodetic::from_degrees(47.4877, 8.6756, 488.0),
438                [10015.096, 9797.691, 15.3833],
439            ),
440        ] {
441            let p = origin.to_ned(point).to_array();
442            for (got, want) in p.into_iter().zip(expected) {
443                assert!((got - want).abs() < 2e-3, "{p:?} vs {expected:?}");
444            }
445        }
446    }
447
448    #[test]
449    fn at_range_the_plane_rises_off_the_surface() {
450        // 10 km out at the origin's height, the plane is d²/2R above the ground.
451        let origin = LocalOrigin::new(zurich()).expect("usable");
452        let down = origin
453            .to_ned(Geodetic::from_degrees(47.4877, 8.5456, 488.0))
454            .z();
455        assert!((7.0..9.0).contains(&down), "{down} m below the plane");
456    }
457
458    #[test]
459    fn up_is_negative_down() {
460        let origin = LocalOrigin::new(zurich()).expect("usable");
461        let above = Geodetic::from_degrees(47.3977, 8.5456, 500.0);
462        assert!((origin.to_ned(above).z() + 12.0).abs() < 1e-5);
463    }
464
465    #[test]
466    fn crossing_the_antimeridian_is_a_short_step() {
467        let origin =
468            LocalOrigin::new(Geodetic::from_degrees(-17.0, 179.9999, 0.0)).expect("usable");
469        let east = origin.to_ned(Geodetic::from_degrees(-17.0, -179.9999, 0.0));
470        let e = east.y();
471        assert!(e > 0.0 && e < 25.0, "0.0002° east, got {e} m");
472    }
473
474    #[test]
475    fn the_integer_encoding_is_the_same_position() {
476        let e7 = Geodetic::from_degrees_e7(473_977_000, 85_456_000, 488_000);
477        let p = LocalOrigin::new(zurich())
478            .expect("usable")
479            .to_ned(e7)
480            .vector();
481        assert!(p.norm() < 1e-3, "{p:?}");
482    }
483
484    #[test]
485    fn placing_puts_the_fix_at_the_estimate_even_far_out() {
486        for estimate in [
487            Position::ned(35.0, -12.0, -4.0),
488            Position::ned(8_000.0, -6_000.0, -300.0),
489            Position::ned(210_000.0, 210_000.0, -100.0),
490        ] {
491            let origin = LocalOrigin::placing(zurich(), estimate).expect("usable");
492            let p = origin.to_ned(zurich());
493            assert!(
494                (p.vector() - estimate.vector()).norm() < 0.05,
495                "{p:?} != {estimate:?}"
496            );
497        }
498    }
499
500    #[test]
501    fn a_pole_is_an_origin_and_nonsense_is_not() {
502        let pole = Geodetic::from_degrees(90.0, 0.0, 0.0);
503        // 1 km down the prime meridian from the pole is 1 km south.
504        let origin = LocalOrigin::new(pole).expect("a pole is an origin");
505        let p = origin.to_ned(origin.to_geodetic(Position::ned(-1000.0, 0.0, 0.0)));
506        assert!((p.x() + 1000.0).abs() < 1e-3 && p.y().abs() < 1e-3, "{p:?}");
507
508        assert_eq!(
509            LocalOrigin::new(Geodetic::from_degrees(f64::NAN, 0.0, 0.0)),
510            None
511        );
512        assert_eq!(
513            LocalOrigin::new(Geodetic::from_degrees(91.0, 0.0, 0.0)),
514            None
515        );
516    }
517
518    #[test]
519    fn placing_refuses_an_origin_that_does_not_exist() {
520        // 11 m from the pole, nothing is 100 m due east of any origin. The iteration used
521        // to return its last guess anyway, putting the fix at (99, -11, 0).
522        let near_pole = Geodetic::from_degrees(89.9999, 90.0, 0.0);
523        assert_eq!(
524            LocalOrigin::placing(near_pole, Position::ned(0.0, 100.0, 0.0)),
525            None
526        );
527        let near_pole = Geodetic::from_degrees(89.999, 90.0, 0.0);
528        assert_eq!(
529            LocalOrigin::placing(near_pole, Position::ned(0.0, 300.0, 0.0)),
530            None
531        );
532        // Where one does exist, near the pole is no obstacle.
533        let estimate = Position::ned(-100.0, 0.0, 0.0);
534        let origin = LocalOrigin::placing(near_pole, estimate).expect("100 m south exists");
535        let p = origin.to_ned(near_pole);
536        assert!((p.vector() - estimate.vector()).norm() < 1e-3, "{p:?}");
537    }
538}