Skip to main content

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}