Skip to main content

luci/spatial/
geo.rs

1//! Geo point types and distance computation.
2//!
3//! Uses the Haversine formula for accurate great-circle distance.
4
5/// A geographic point (latitude, longitude in degrees).
6#[derive(Clone, Copy, Debug, PartialEq)]
7pub struct GeoPoint {
8    pub lat: f64,
9    pub lon: f64,
10}
11
12impl GeoPoint {
13    pub fn new(lat: f64, lon: f64) -> Self {
14        Self { lat, lon }
15    }
16
17    /// Parse from JSON. Accepts:
18    /// - `{"lat": 40.7, "lon": -73.9}`
19    /// - `[lon, lat]` (GeoJSON order)
20    /// - `"lat,lon"` string
21    pub fn from_json(value: &serde_json::Value) -> Option<Self> {
22        match value {
23            serde_json::Value::Object(obj) => {
24                let lat = obj.get("lat").and_then(|v| v.as_f64())?;
25                let lon = obj.get("lon").and_then(|v| v.as_f64())?;
26                Some(Self { lat, lon })
27            }
28            serde_json::Value::Array(arr) if arr.len() == 2 => {
29                // GeoJSON: [lon, lat]
30                let lon = arr[0].as_f64()?;
31                let lat = arr[1].as_f64()?;
32                Some(Self { lat, lon })
33            }
34            serde_json::Value::String(s) => {
35                let parts: Vec<&str> = s.split(',').collect();
36                if parts.len() == 2 {
37                    let lat = parts[0].trim().parse().ok()?;
38                    let lon = parts[1].trim().parse().ok()?;
39                    Some(Self { lat, lon })
40                } else {
41                    None
42                }
43            }
44            _ => None,
45        }
46    }
47}
48
49/// Earth radius in kilometers.
50const EARTH_RADIUS_KM: f64 = 6371.0;
51
52/// Haversine distance between two geo points in kilometers.
53pub fn haversine_km(a: GeoPoint, b: GeoPoint) -> f64 {
54    let lat1 = a.lat.to_radians();
55    let lat2 = b.lat.to_radians();
56    let dlat = (b.lat - a.lat).to_radians();
57    let dlon = (b.lon - a.lon).to_radians();
58
59    let h = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
60    let c = 2.0 * h.sqrt().asin();
61
62    EARTH_RADIUS_KM * c
63}
64
65/// Check if a point is within a bounding box.
66pub fn in_bounding_box(point: GeoPoint, top_left: GeoPoint, bottom_right: GeoPoint) -> bool {
67    point.lat <= top_left.lat
68        && point.lat >= bottom_right.lat
69        && point.lon >= top_left.lon
70        && point.lon <= bottom_right.lon
71}
72
73/// Per-segment geo point storage.
74///
75/// Stores (lat, lon) pairs as parallel f64 arrays indexed by doc_id.
76/// On deserialization, builds a latitude-sorted index for O(log n) range queries.
77///
78/// See [[optimization-bkd-tree-geo]] for the sorted scan optimization.
79#[derive(Clone)]
80pub struct GeoPointStore {
81    lats: Vec<f64>,
82    lons: Vec<f64>,
83    /// Doc IDs sorted by latitude (built on deserialize for query-time use).
84    lat_order: Vec<u32>,
85}
86
87impl GeoPointStore {
88    pub fn new() -> Self {
89        Self {
90            lats: Vec::new(),
91            lons: Vec::new(),
92            lat_order: Vec::new(),
93        }
94    }
95
96    pub fn add(&mut self, point: GeoPoint) {
97        self.lats.push(point.lat);
98        self.lons.push(point.lon);
99    }
100
101    pub fn add_null(&mut self) {
102        self.lats.push(f64::NAN);
103        self.lons.push(f64::NAN);
104    }
105
106    pub fn len(&self) -> usize {
107        self.lats.len()
108    }
109    pub fn is_empty(&self) -> bool {
110        self.lats.is_empty()
111    }
112
113    pub fn get(&self, doc_id: u32) -> Option<GeoPoint> {
114        let i = doc_id as usize;
115        if i < self.lats.len() && !self.lats[i].is_nan() {
116            Some(GeoPoint::new(self.lats[i], self.lons[i]))
117        } else {
118            None
119        }
120    }
121
122    /// Serialize to bytes.
123    pub fn to_bytes(&self) -> Vec<u8> {
124        let mut buf = Vec::new();
125        buf.extend_from_slice(&(self.lats.len() as u32).to_le_bytes());
126        for &lat in &self.lats {
127            buf.extend_from_slice(&lat.to_le_bytes());
128        }
129        for &lon in &self.lons {
130            buf.extend_from_slice(&lon.to_le_bytes());
131        }
132        buf
133    }
134
135    /// Returns doc IDs with latitude in [min_lat, max_lat], sorted by doc_id.
136    ///
137    /// Uses binary search on the pre-sorted latitude index for O(log n + k)
138    /// where k is the number of matching documents.
139    pub fn docs_in_lat_range(&self, min_lat: f64, max_lat: f64) -> Vec<u32> {
140        if self.lat_order.is_empty() {
141            return Vec::new();
142        }
143        let start = self
144            .lat_order
145            .partition_point(|&id| self.lats[id as usize] < min_lat);
146        let end = self
147            .lat_order
148            .partition_point(|&id| self.lats[id as usize] <= max_lat);
149        let mut result: Vec<u32> = self.lat_order[start..end].to_vec();
150        result.sort_unstable(); // sort by doc_id for scorer ordering
151        result
152    }
153
154    fn build_lat_order(lats: &[f64]) -> Vec<u32> {
155        let mut order: Vec<u32> = (0..lats.len() as u32)
156            .filter(|&i| !lats[i as usize].is_nan())
157            .collect();
158        order.sort_unstable_by(|&a, &b| lats[a as usize].partial_cmp(&lats[b as usize]).unwrap());
159        order
160    }
161
162    /// Deserialize from bytes.
163    pub fn from_bytes(data: &[u8]) -> Self {
164        let count = u32::from_le_bytes(data[0..4].try_into().unwrap()) as usize;
165        let mut pos = 4;
166        let mut lats = Vec::with_capacity(count);
167        for _ in 0..count {
168            lats.push(f64::from_le_bytes(data[pos..pos + 8].try_into().unwrap()));
169            pos += 8;
170        }
171        let mut lons = Vec::with_capacity(count);
172        for _ in 0..count {
173            lons.push(f64::from_le_bytes(data[pos..pos + 8].try_into().unwrap()));
174            pos += 8;
175        }
176        let lat_order = Self::build_lat_order(&lats);
177        Self {
178            lats,
179            lons,
180            lat_order,
181        }
182    }
183}
184
185impl Default for GeoPointStore {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn haversine_known_distance() {
197        // NYC to London ≈ 5570 km
198        let nyc = GeoPoint::new(40.7128, -74.0060);
199        let london = GeoPoint::new(51.5074, -0.1278);
200        let d = haversine_km(nyc, london);
201        assert!((d - 5570.0).abs() < 50.0, "NYC-London ≈ 5570km, got {d}");
202    }
203
204    #[test]
205    fn haversine_same_point() {
206        let p = GeoPoint::new(0.0, 0.0);
207        assert!(haversine_km(p, p) < 0.001);
208    }
209
210    #[test]
211    fn bounding_box_contains() {
212        let tl = GeoPoint::new(41.0, -74.5);
213        let br = GeoPoint::new(40.0, -73.5);
214        let inside = GeoPoint::new(40.5, -74.0);
215        let outside = GeoPoint::new(42.0, -74.0);
216        assert!(in_bounding_box(inside, tl, br));
217        assert!(!in_bounding_box(outside, tl, br));
218    }
219
220    #[test]
221    fn geo_point_from_json_object() {
222        let v = serde_json::json!({"lat": 40.7, "lon": -73.9});
223        let p = GeoPoint::from_json(&v).unwrap();
224        assert!((p.lat - 40.7).abs() < 0.001);
225    }
226
227    #[test]
228    fn geo_point_from_json_array() {
229        let v = serde_json::json!([-73.9, 40.7]); // GeoJSON: [lon, lat]
230        let p = GeoPoint::from_json(&v).unwrap();
231        assert!((p.lat - 40.7).abs() < 0.001);
232        assert!((p.lon - (-73.9)).abs() < 0.001);
233    }
234
235    #[test]
236    fn geo_point_from_json_string() {
237        let v = serde_json::json!("40.7,-73.9");
238        let p = GeoPoint::from_json(&v).unwrap();
239        assert!((p.lat - 40.7).abs() < 0.001);
240    }
241
242    #[test]
243    fn geo_store_round_trip() {
244        let mut store = GeoPointStore::new();
245        store.add(GeoPoint::new(40.7, -73.9));
246        store.add_null();
247        store.add(GeoPoint::new(51.5, -0.1));
248
249        let bytes = store.to_bytes();
250        let restored = GeoPointStore::from_bytes(&bytes);
251
252        assert_eq!(restored.len(), 3);
253        let p0 = restored.get(0).unwrap();
254        assert!((p0.lat - 40.7).abs() < 0.001);
255        assert!(restored.get(1).is_none()); // null
256        let p2 = restored.get(2).unwrap();
257        assert!((p2.lat - 51.5).abs() < 0.001);
258    }
259}