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}