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 metres. 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 metres.
32///
33/// # Examples
34///
35/// Great-circle distance between two cities, in kilometres:
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 metres.
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 metres apart.
77 ///
78 /// # Arguments
79 ///
80 /// * `other` - the coordinate to measure to.
81 ///
82 /// # Returns
83 ///
84 /// The distance in metres, 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 normalised 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 centre 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` metres around `center`.
169 ///
170 /// # Arguments
171 ///
172 /// * `center` - the middle of the safe area.
173 /// * `radius_m` - the radius of the safe area in metres; its magnitude is used.
174 ///
175 /// # Returns
176 ///
177 /// A fence that has not yet seen a fix.
178 pub fn new(center: Coordinate, radius_m: f64) -> Self {
179 Self {
180 center,
181 radius_m: magnitude(radius_m),
182 inside: None,
183 }
184 }
185
186 /// Returns whether a point lies within the fence.
187 ///
188 /// # Arguments
189 ///
190 /// * `point` - the coordinate to test.
191 ///
192 /// # Returns
193 ///
194 /// `true` if `point` is on or inside the fence boundary.
195 pub fn contains(&self, point: Coordinate) -> bool {
196 self.center.distance_to(point) <= self.radius_m
197 }
198
199 /// Records a fix and reports its position relative to the fence.
200 ///
201 /// # Arguments
202 ///
203 /// * `point` - the latest fix.
204 ///
205 /// # Returns
206 ///
207 /// [`Boundary::Entered`] or [`Boundary::Exited`] on the fix that crosses the
208 /// boundary, otherwise [`Boundary::Inside`] or [`Boundary::Outside`].
209 pub fn update(&mut self, point: Coordinate) -> Boundary {
210 let now_inside = self.contains(point);
211 let boundary = match self.inside {
212 Some(true) if !now_inside => Boundary::Exited,
213 Some(false) if now_inside => Boundary::Entered,
214 _ if now_inside => Boundary::Inside,
215 _ => Boundary::Outside,
216 };
217 self.inside = Some(now_inside);
218 boundary
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn distance_to_self_is_zero() {
228 let point = Coordinate::new(12.34, -56.78);
229 assert_eq!(point.distance_to(point), 0.0);
230 }
231
232 #[test]
233 fn one_degree_of_longitude_at_the_equator() {
234 // A degree of longitude at the equator is about 111.2 km.
235 let here = Coordinate::new(0.0, 0.0);
236 let east = Coordinate::new(0.0, 1.0);
237 let metres = here.distance_to(east);
238 assert!((metres - 111_195.0).abs() < 5.0);
239 }
240
241 #[test]
242 fn distance_is_symmetric() {
243 let a = Coordinate::new(40.7128, -74.0060);
244 let b = Coordinate::new(51.5074, -0.1278);
245 assert!((a.distance_to(b) - b.distance_to(a)).abs() < 1.0);
246 }
247
248 #[test]
249 fn an_intercontinental_distance_is_accurate() {
250 // New York to London is about 5570 km along the great circle.
251 let nyc = Coordinate::new(40.7128, -74.0060);
252 let london = Coordinate::new(51.5074, -0.1278);
253 let km = nyc.distance_to(london) / 1000.0;
254 assert!((km - 5570.0).abs() < 30.0);
255 }
256
257 #[test]
258 fn antipodal_points_are_half_the_circumference() {
259 // Opposite points are pi * R apart, about 20015 km.
260 let here = Coordinate::new(0.0, 0.0);
261 let opposite = Coordinate::new(0.0, 180.0);
262 let km = here.distance_to(opposite) / 1000.0;
263 assert!((km - 20_015.0).abs() < 5.0);
264 }
265
266 #[test]
267 fn bearing_to_the_cardinal_directions() {
268 let here = Coordinate::new(0.0, 0.0);
269 assert!((here.bearing_to(Coordinate::new(1.0, 0.0)) - 0.0).abs() < 1e-6); // north
270 assert!((here.bearing_to(Coordinate::new(0.0, 1.0)) - 90.0).abs() < 1e-6); // east
271 let north = Coordinate::new(1.0, 0.0);
272 assert!((north.bearing_to(here) - 180.0).abs() < 1e-6); // south
273 let east = Coordinate::new(0.0, 1.0);
274 assert!((east.bearing_to(here) - 270.0).abs() < 1e-6); // west
275 }
276
277 #[test]
278 fn bearing_matches_a_worked_example() {
279 // Movable Type's worked example: Baghdad (35 N, 45 E) to Osaka (35 N, 135 E)
280 // sets off on an initial bearing of about 60 degrees.
281 let baghdad = Coordinate::new(35.0, 45.0);
282 let osaka = Coordinate::new(35.0, 135.0);
283 assert!((baghdad.bearing_to(osaka) - 60.0).abs() < 1.0);
284 }
285
286 #[test]
287 fn a_fence_reports_crossings_once() {
288 let mut fence = Geofence::new(Coordinate::new(37.0, -122.0), 100.0);
289 let near = Coordinate::new(37.0005, -122.0); // about 56 m north: inside
290 let far = Coordinate::new(37.002, -122.0); // about 222 m north: outside
291
292 assert!(fence.contains(near));
293 assert!(!fence.contains(far));
294
295 assert_eq!(fence.update(near), Boundary::Inside);
296 assert_eq!(fence.update(far), Boundary::Exited); // the crossing
297 assert_eq!(fence.update(far), Boundary::Outside); // still away, no repeat
298 assert_eq!(fence.update(near), Boundary::Entered); // back across
299 assert_eq!(fence.update(near), Boundary::Inside);
300 }
301
302 #[test]
303 fn a_point_on_the_boundary_counts_as_inside() {
304 // Build a fence whose radius is exactly the distance to a known point.
305 let center = Coordinate::new(0.0, 0.0);
306 let edge = Coordinate::new(0.0, 1.0);
307 let fence = Geofence::new(center, center.distance_to(edge));
308 assert!(fence.contains(edge));
309 }
310
311 #[test]
312 fn a_negative_radius_is_treated_as_its_magnitude() {
313 let fence = Geofence::new(Coordinate::new(0.0, 0.0), -100.0);
314 assert!(fence.contains(Coordinate::new(0.0, 0.0)));
315 }
316}