uqa_storage/
spatial_index.rs1use std::collections::BTreeMap;
13use std::sync::Arc;
14
15use uqa_core::{DocId, Payload, PostingEntry, PostingList};
16
17const EARTH_RADIUS_M: f64 = 6_371_000.0;
18
19pub fn haversine_distance(lon1: f64, lat1: f64, lon2: f64, lat2: f64) -> f64 {
22 let phi1 = lat1.to_radians();
23 let phi2 = lat2.to_radians();
24 let d_phi = (lat2 - lat1).to_radians();
25 let d_lambda = (lon2 - lon1).to_radians();
26 let a = (d_phi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (d_lambda / 2.0).sin().powi(2);
27 let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
28 EARTH_RADIUS_M * c
29}
30
31pub trait SpatialIndex: Send + Sync {
32 fn add(&mut self, doc_id: DocId, lon: f64, lat: f64);
33 fn remove(&mut self, doc_id: DocId);
34 fn clear(&mut self);
35 fn search_within(&self, center_lon: f64, center_lat: f64, radius_m: f64) -> PostingList;
36 fn count(&self) -> usize;
37 fn snapshot(&self) -> Arc<dyn SpatialIndex>;
38}
39
40#[derive(Debug, Default, Clone)]
41pub struct MemorySpatialIndex {
42 field: String,
43 points: BTreeMap<DocId, (f64, f64)>,
44}
45
46impl MemorySpatialIndex {
47 pub fn new(field: impl Into<String>) -> Self {
48 Self {
49 field: field.into(),
50 points: BTreeMap::new(),
51 }
52 }
53
54 pub fn field(&self) -> &str {
55 &self.field
56 }
57}
58
59impl SpatialIndex for MemorySpatialIndex {
60 fn add(&mut self, doc_id: DocId, lon: f64, lat: f64) {
61 self.points.insert(doc_id, (lon, lat));
62 }
63
64 fn remove(&mut self, doc_id: DocId) {
65 self.points.remove(&doc_id);
66 }
67
68 fn clear(&mut self) {
69 self.points.clear();
70 }
71
72 fn search_within(&self, center_lon: f64, center_lat: f64, radius_m: f64) -> PostingList {
73 let mut entries: Vec<PostingEntry> = self
74 .points
75 .iter()
76 .filter_map(|(&doc_id, &(lon, lat))| {
77 let d = haversine_distance(center_lon, center_lat, lon, lat);
78 if d <= radius_m {
79 let score = if radius_m > 0.0 {
80 1.0 - (d / radius_m)
81 } else {
82 1.0
83 };
84 Some(PostingEntry::new(doc_id, Payload::with_score(score)))
85 } else {
86 None
87 }
88 })
89 .collect();
90 entries.sort_by_key(|e| e.doc_id);
91 PostingList::from_sorted_unchecked(entries)
92 }
93
94 fn count(&self) -> usize {
95 self.points.len()
96 }
97
98 fn snapshot(&self) -> Arc<dyn SpatialIndex> {
99 Arc::new(self.clone())
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 fn approx(a: f64, b: f64, eps: f64) {
108 assert!((a - b).abs() < eps, "expected {a} ~ {b} within {eps}");
109 }
110
111 #[test]
112 fn haversine_zero_for_identical_point() {
113 approx(haversine_distance(0.0, 0.0, 0.0, 0.0), 0.0, 1e-6);
114 }
115
116 #[test]
117 fn haversine_known_distance() {
118 let d = haversine_distance(-0.1276, 51.5074, 2.3522, 48.8566);
120 approx(d, 343_000.0, 5_000.0);
121 }
122
123 #[test]
124 fn search_within_filters_by_distance() {
125 let mut idx = MemorySpatialIndex::new("location");
126 idx.add(1, 0.0, 0.0); idx.add(2, 0.001, 0.0); idx.add(3, 1.0, 1.0); let pl = idx.search_within(0.0, 0.0, 500.0);
130 let docs: Vec<DocId> = pl.iter().map(|e| e.doc_id).collect();
131 assert_eq!(docs, vec![1, 2]);
132 let s1 = pl.get_entry(1).unwrap().payload.score;
135 let s2 = pl.get_entry(2).unwrap().payload.score;
136 assert!(s1 > s2);
137 }
138}