pamoja_kit/geo.rs
1//! Working with positions on the Earth: distance and staying inside an area.
2
3use core::f64::consts::PI;
4
5use libm::{atan2, cos, sin, sqrt};
6
7// IUGG mean Earth radius in meters. Treating the Earth as a sphere is accurate to a
8// few tenths of a percent, which is well inside the error of a low-cost GPS fix.
9const EARTH_RADIUS_M: f64 = 6_371_008.8;
10
11fn to_radians(degrees: f64) -> f64 {
12 degrees * (PI / 180.0)
13}
14
15fn to_degrees(radians: f64) -> f64 {
16 radians * (180.0 / PI)
17}
18
19// `f64::abs` lives in `std`, so this `no_std` crate takes the magnitude by hand.
20fn magnitude(value: f64) -> f64 {
21 if value < 0.0 {
22 -value
23 } else {
24 value
25 }
26}
27
28/// A position on the Earth, in decimal degrees.
29///
30/// Latitude and longitude are kept as `f64` because a GPS fix needs more precision
31/// than `f32` can hold: rounding a coordinate to `f32` can move it tens of meters.
32///
33/// # Examples
34///
35/// Great-circle distance between two cities, in kilometers:
36///
37/// ```
38/// use pamoja_kit::Coordinate;
39///
40/// let nairobi = Coordinate::new(-1.2921, 36.8219);
41/// let mombasa = Coordinate::new(-4.0435, 39.6682);
42/// let km = nairobi.distance_to(mombasa) / 1000.0;
43/// assert!((km - 440.0).abs() < 10.0); // about 440 km apart
44/// ```
45#[derive(Clone, Copy, Debug, PartialEq)]
46pub struct Coordinate {
47 /// Degrees north of the equator, in `[-90.0, 90.0]`.
48 pub latitude: f64,
49 /// Degrees east of the prime meridian, in `[-180.0, 180.0]`.
50 pub longitude: f64,
51}
52
53impl Coordinate {
54 /// Creates a coordinate from a latitude and longitude in decimal degrees.
55 ///
56 /// # Arguments
57 ///
58 /// * `latitude` - degrees north of the equator.
59 /// * `longitude` - degrees east of the prime meridian.
60 ///
61 /// # Returns
62 ///
63 /// The coordinate.
64 pub fn new(latitude: f64, longitude: f64) -> Self {
65 Self {
66 latitude,
67 longitude,
68 }
69 }
70
71 /// Returns the distance to another coordinate in meters.
72 ///
73 /// This is the great-circle distance: the shortest path over the surface of a
74 /// spherical Earth. The technique one layer down is the haversine formula, which
75 /// stays numerically stable for the short distances a field deployment cares
76 /// about, down to points a few meters apart.
77 ///
78 /// # Arguments
79 ///
80 /// * `other` - the coordinate to measure to.
81 ///
82 /// # Returns
83 ///
84 /// The distance in meters, always zero or positive.
85 pub fn distance_to(&self, other: Coordinate) -> f64 {
86 let lat1 = to_radians(self.latitude);
87 let lat2 = to_radians(other.latitude);
88 let half_dlat = to_radians(other.latitude - self.latitude) / 2.0;
89 let half_dlon = to_radians(other.longitude - self.longitude) / 2.0;
90 let sin_lat = sin(half_dlat);
91 let sin_lon = sin(half_dlon);
92 let a = sin_lat * sin_lat + cos(lat1) * cos(lat2) * sin_lon * sin_lon;
93 let c = 2.0 * atan2(sqrt(a), sqrt(1.0 - a));
94 EARTH_RADIUS_M * c
95 }
96
97 /// Returns the initial bearing to another coordinate, in degrees clockwise from north.
98 ///
99 /// This is the forward azimuth of the great-circle path: the compass heading to set off
100 /// on to reach `other` by the shortest route. Because a great circle curves, the bearing
101 /// changes along the way; this is the heading at the start. The result is normalized to
102 /// `[0.0, 360.0)`, with 0 north, 90 east, 180 south, and 270 west.
103 ///
104 /// # Arguments
105 ///
106 /// * `other` - the coordinate to head toward.
107 ///
108 /// # Returns
109 ///
110 /// The initial bearing in degrees, in `[0.0, 360.0)`. When both points are the same the
111 /// result is `0.0`.
112 pub fn bearing_to(&self, other: Coordinate) -> f64 {
113 let lat1 = to_radians(self.latitude);
114 let lat2 = to_radians(other.latitude);
115 let dlon = to_radians(other.longitude - self.longitude);
116 let y = sin(dlon) * cos(lat2);
117 let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dlon);
118 let bearing = to_degrees(atan2(y, x));
119 if bearing < 0.0 {
120 bearing + 360.0
121 } else {
122 bearing
123 }
124 }
125}
126
127/// Where a fix sits relative to a [`Geofence`], including the moment it crosses.
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129pub enum Boundary {
130 /// The fix is inside the fence and was inside before, or is the first fix inside.
131 Inside,
132 /// The fix is outside the fence and was outside before, or is the first fix outside.
133 Outside,
134 /// The fix just crossed from inside to outside: the moment to raise a breach alert.
135 Exited,
136 /// The fix just crossed from outside back inside.
137 Entered,
138}
139
140/// Keeping a tracked point inside an area, and noticing when it leaves.
141///
142/// This is the primitive behind "tell me when it leaves the safe zone": a collared
143/// animal straying from its pasture, an asset moving off-site, or a drone crossing
144/// its allowed boundary. A fence is a center and a radius; feeding it successive
145/// fixes reports whether each is [`Inside`](Boundary::Inside) or
146/// [`Outside`](Boundary::Outside) and, crucially, the single fix that
147/// [`Exited`](Boundary::Exited) or [`Entered`](Boundary::Entered), so an alert fires
148/// once on the crossing rather than on every fix while away.
149///
150/// # Examples
151///
152/// ```
153/// use pamoja_kit::{Boundary, Coordinate, Geofence};
154///
155/// // A 50 m pen around the waterpoint; the collar fix then wanders out.
156/// let mut pen = Geofence::new(Coordinate::new(-1.2921, 36.8219), 50.0);
157/// assert_eq!(pen.update(Coordinate::new(-1.2921, 36.8219)), Boundary::Inside);
158/// assert_eq!(pen.update(Coordinate::new(-1.2930, 36.8219)), Boundary::Exited);
159/// ```
160#[derive(Clone, Copy, Debug, PartialEq)]
161pub struct Geofence {
162 center: Coordinate,
163 radius_m: f64,
164 inside: Option<bool>,
165}
166
167impl Geofence {
168 /// Creates a fence of `radius_m` meters around `center`.
169 ///
170 /// # Arguments
171 ///
172 /// * `center` - the middle of the safe area.
173 /// * `radius_m` - the radius of the safe area in meters; its magnitude is used. One that
174 /// is not a number contains nothing.
175 ///
176 /// # Returns
177 ///
178 /// A fence that has not yet seen a fix.
179 pub fn new(center: Coordinate, radius_m: f64) -> Self {
180 Self {
181 center,
182 radius_m: magnitude(radius_m),
183 inside: None,
184 }
185 }
186
187 /// Returns whether a point lies within the fence.
188 ///
189 /// # Arguments
190 ///
191 /// * `point` - the coordinate to test.
192 ///
193 /// # Returns
194 ///
195 /// `true` if `point` is on or inside the fence boundary.
196 pub fn contains(&self, point: Coordinate) -> bool {
197 self.center.distance_to(point) <= self.radius_m
198 }
199
200 /// Records a fix and reports its position relative to the fence.
201 ///
202 /// A fix whose latitude or longitude is not a finite number, which a receiver without a
203 /// lock can report, is no fix: it changes nothing and reports the state as it stood,
204 /// [`Boundary::Outside`] before any real fix, and never an exit or an entry.
205 ///
206 /// # Arguments
207 ///
208 /// * `point` - the latest fix.
209 ///
210 /// # Returns
211 ///
212 /// [`Boundary::Entered`] or [`Boundary::Exited`] on the fix that crosses the
213 /// boundary, otherwise [`Boundary::Inside`] or [`Boundary::Outside`].
214 pub fn update(&mut self, point: Coordinate) -> Boundary {
215 if !point.latitude.is_finite() || !point.longitude.is_finite() {
216 return if self.inside == Some(true) {
217 Boundary::Inside
218 } else {
219 Boundary::Outside
220 };
221 }
222 let now_inside = self.contains(point);
223 let boundary = match self.inside {
224 Some(true) if !now_inside => Boundary::Exited,
225 Some(false) if now_inside => Boundary::Entered,
226 _ if now_inside => Boundary::Inside,
227 _ => Boundary::Outside,
228 };
229 self.inside = Some(now_inside);
230 boundary
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn distance_to_self_is_zero() {
240 let point = Coordinate::new(12.34, -56.78);
241 assert_eq!(point.distance_to(point), 0.0);
242 }
243
244 #[test]
245 fn one_degree_of_longitude_at_the_equator() {
246 // A degree of longitude at the equator is about 111.2 km.
247 let here = Coordinate::new(0.0, 0.0);
248 let east = Coordinate::new(0.0, 1.0);
249 let meters = here.distance_to(east);
250 assert!((meters - 111_195.0).abs() < 5.0);
251 }
252
253 #[test]
254 fn distance_is_symmetric() {
255 let a = Coordinate::new(40.7128, -74.0060);
256 let b = Coordinate::new(51.5074, -0.1278);
257 assert!((a.distance_to(b) - b.distance_to(a)).abs() < 1.0);
258 }
259
260 #[test]
261 fn an_intercontinental_distance_is_accurate() {
262 // New York to London is about 5570 km along the great circle.
263 let nyc = Coordinate::new(40.7128, -74.0060);
264 let london = Coordinate::new(51.5074, -0.1278);
265 let km = nyc.distance_to(london) / 1000.0;
266 assert!((km - 5570.0).abs() < 30.0);
267 }
268
269 #[test]
270 fn antipodal_points_are_half_the_circumference() {
271 // Opposite points are pi * R apart, about 20015 km.
272 let here = Coordinate::new(0.0, 0.0);
273 let opposite = Coordinate::new(0.0, 180.0);
274 let km = here.distance_to(opposite) / 1000.0;
275 assert!((km - 20_015.0).abs() < 5.0);
276 }
277
278 #[test]
279 fn bearing_to_the_cardinal_directions() {
280 let here = Coordinate::new(0.0, 0.0);
281 assert!((here.bearing_to(Coordinate::new(1.0, 0.0)) - 0.0).abs() < 1e-6); // north
282 assert!((here.bearing_to(Coordinate::new(0.0, 1.0)) - 90.0).abs() < 1e-6); // east
283 let north = Coordinate::new(1.0, 0.0);
284 assert!((north.bearing_to(here) - 180.0).abs() < 1e-6); // south
285 let east = Coordinate::new(0.0, 1.0);
286 assert!((east.bearing_to(here) - 270.0).abs() < 1e-6); // west
287 }
288
289 #[test]
290 fn bearing_matches_a_worked_example() {
291 // Movable Type's worked example: Baghdad (35 N, 45 E) to Osaka (35 N, 135 E)
292 // sets off on an initial bearing of about 60 degrees.
293 let baghdad = Coordinate::new(35.0, 45.0);
294 let osaka = Coordinate::new(35.0, 135.0);
295 assert!((baghdad.bearing_to(osaka) - 60.0).abs() < 1.0);
296 }
297
298 #[test]
299 fn a_fence_reports_crossings_once() {
300 let mut fence = Geofence::new(Coordinate::new(37.0, -122.0), 100.0);
301 let near = Coordinate::new(37.0005, -122.0); // about 56 m north: inside
302 let far = Coordinate::new(37.002, -122.0); // about 222 m north: outside
303
304 assert!(fence.contains(near));
305 assert!(!fence.contains(far));
306
307 assert_eq!(fence.update(near), Boundary::Inside);
308 assert_eq!(fence.update(far), Boundary::Exited); // the crossing
309 assert_eq!(fence.update(far), Boundary::Outside); // still away, no repeat
310 assert_eq!(fence.update(near), Boundary::Entered); // back across
311 assert_eq!(fence.update(near), Boundary::Inside);
312 }
313
314 #[test]
315 fn a_point_on_the_boundary_counts_as_inside() {
316 // Build a fence whose radius is exactly the distance to a known point.
317 let center = Coordinate::new(0.0, 0.0);
318 let edge = Coordinate::new(0.0, 1.0);
319 let fence = Geofence::new(center, center.distance_to(edge));
320 assert!(fence.contains(edge));
321 }
322
323 #[test]
324 fn a_negative_radius_is_treated_as_its_magnitude() {
325 let fence = Geofence::new(Coordinate::new(0.0, 0.0), -100.0);
326 assert!(fence.contains(Coordinate::new(0.0, 0.0)));
327 }
328
329 #[test]
330 fn a_fix_that_is_not_a_number_is_no_fix() {
331 let mut fence = Geofence::new(Coordinate::new(37.0, -122.0), 100.0);
332 let near = Coordinate::new(37.0005, -122.0);
333 let lost = Coordinate::new(f64::NAN, -122.0);
334 assert_eq!(fence.update(lost), Boundary::Outside);
335 assert_eq!(fence.update(near), Boundary::Inside);
336 assert_eq!(fence.update(lost), Boundary::Inside);
337 assert_eq!(
338 fence.update(Coordinate::new(37.0, f64::INFINITY)),
339 Boundary::Inside
340 );
341 assert_eq!(fence.update(near), Boundary::Inside);
342 }
343}