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 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}