Skip to main content

geospatial_types/
coordinate.rs

1//! Geographic coordinate record and distance mathematics.
2
3use serde::{Deserialize, Serialize};
4
5/// A position on the WGS 84 ellipsoid expressed in decimal degrees.
6#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
7pub struct GeographicCoordinate {
8    /// Latitude in decimal degrees, valid within `[-90.0, 90.0]`.
9    pub latitude: f64,
10    /// Longitude in decimal degrees, valid within `[-180.0, 180.0]`.
11    pub longitude: f64,
12}
13
14impl GeographicCoordinate {
15    /// Mean Earth radius in meters used by the haversine formula.
16    const EARTH_RADIUS_METERS: f64 = 6_371_008.8;
17
18    /// Builds a coordinate, rejecting latitudes or longitudes outside their
19    /// canonical ranges.
20    pub fn from_degrees(latitude: f64, longitude: f64) -> Result<Self, crate::SDKError> {
21        if !(-90.0..=90.0).contains(&latitude) {
22            return Err(crate::SDKError::InvalidCoordinateInput(format!(
23                "latitude {latitude} outside [-90, 90]"
24            )));
25        }
26        if !(-180.0..=180.0).contains(&longitude) {
27            return Err(crate::SDKError::InvalidCoordinateInput(format!(
28                "longitude {longitude} outside [-180, 180]"
29            )));
30        }
31        Ok(Self {
32            latitude,
33            longitude,
34        })
35    }
36
37    /// Great-circle distance between two coordinates in meters, computed with
38    /// the haversine formula.
39    pub fn haversine_distance_meters(&self, other: &Self) -> f64 {
40        let latitude_radians_self = self.latitude.to_radians();
41        let latitude_radians_other = other.latitude.to_radians();
42        let delta_latitude = latitude_radians_other - latitude_radians_self;
43        let delta_longitude = (other.longitude - self.longitude).to_radians();
44
45        let sine_half_delta_latitude = (delta_latitude / 2.0).sin();
46        let sine_half_delta_longitude = (delta_longitude / 2.0).sin();
47
48        let haversine_ratio = sine_half_delta_latitude * sine_half_delta_latitude
49            + latitude_radians_self.cos()
50                * latitude_radians_other.cos()
51                * sine_half_delta_longitude
52                * sine_half_delta_longitude;
53
54        2.0 * Self::EARTH_RADIUS_METERS * haversine_ratio.clamp(0.0, 1.0).sqrt()
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::GeographicCoordinate;
61    use proptest::prelude::*;
62
63    #[test]
64    fn rejects_coordinates_outside_canonical_ranges() {
65        assert!(GeographicCoordinate::from_degrees(91.0, 0.0).is_err());
66        assert!(GeographicCoordinate::from_degrees(0.0, 180.5).is_err());
67        assert!(GeographicCoordinate::from_degrees(0.0, 0.0).is_ok());
68        assert!(GeographicCoordinate::from_degrees(90.0, 180.0).is_ok());
69    }
70
71    #[test]
72    fn zero_distance_for_identical_coordinates() {
73        let coordinate = GeographicCoordinate::from_degrees(38.5766, -121.4944).unwrap();
74        assert!(coordinate.haversine_distance_meters(&coordinate) < 1e-6);
75    }
76
77    #[test]
78    fn known_distance_between_sacramento_and_san_francisco() {
79        let capitol = GeographicCoordinate::from_degrees(38.5766, -121.4944).unwrap();
80        let san_francisco = GeographicCoordinate::from_degrees(37.7749, -122.4194).unwrap();
81        let distance = capitol.haversine_distance_meters(&san_francisco);
82        // Published great-circle distance is roughly 120 km.
83        assert!((117_000.0..=123_500.0).contains(&distance));
84    }
85
86    proptest! {
87        #![proptest_config(ProptestConfig::with_cases(512))]
88
89        #[test]
90        fn distance_is_symmetric(
91            latitude_a in -90.0f64..=90.0,
92            longitude_a in -180.0f64..=180.0,
93            latitude_b in -90.0f64..=90.0,
94            longitude_b in -180.0f64..=180.0,
95        ) {
96            let coordinate_a = GeographicCoordinate { latitude: latitude_a, longitude: longitude_a };
97            let coordinate_b = GeographicCoordinate { latitude: latitude_b, longitude: longitude_b };
98            let forward = coordinate_a.haversine_distance_meters(&coordinate_b);
99            let backward = coordinate_b.haversine_distance_meters(&coordinate_a);
100            assert!((forward - backward).abs() < 1e-6);
101            assert!(forward <= std::f64::consts::PI * 6_371_008.8 + 1e-6);
102        }
103
104        #[test]
105        fn distance_shrinks_when_points_converge(
106            latitude in -90.0f64..=90.0,
107            longitude in -180.0f64..=180.0,
108            shift in 0.0f64..0.01,
109        ) {
110            let origin = GeographicCoordinate { latitude, longitude };
111            let shifted = GeographicCoordinate {
112                latitude: (latitude + shift).clamp(-90.0, 90.0),
113                longitude: (longitude + shift).clamp(-180.0, 180.0),
114            };
115            let far = GeographicCoordinate {
116                latitude: origin.latitude.sin().abs() * 80.0,
117                longitude: origin.longitude.cos().abs() * 170.0,
118            };
119            let near = origin.haversine_distance_meters(&shifted);
120            let distant = origin.haversine_distance_meters(&far);
121            assert!(near < distant);
122        }
123    }
124}