geospatial_types/
coordinate.rs1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
7pub struct GeographicCoordinate {
8 pub latitude: f64,
10 pub longitude: f64,
12}
13
14impl GeographicCoordinate {
15 const EARTH_RADIUS_METERS: f64 = 6_371_008.8;
17
18 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 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 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}