Skip to main content

nodedb_cluster/distributed_spatial/
geofence.rs

1//! Distributed geofence evaluation.
2//!
3//! Geofence polygons are replicated to all shards (small — typically <10K
4//! polygons). Each shard evaluates point updates against the local replica.
5//! No cross-shard coordination needed for evaluation. Registration changes
6//! propagate via Raft.
7//!
8//! Use case: when a vehicle position update arrives, check if it enters/exits
9//! any registered geofence polygon.
10
11use nodedb_types::geometry::point_in_polygon;
12use serde::{Deserialize, Serialize};
13
14/// A registered geofence polygon.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Geofence {
17    /// Unique geofence identifier.
18    pub id: String,
19    /// Human-readable name.
20    pub name: String,
21    /// The geofence polygon (exterior ring only for v1).
22    pub polygon: Vec<[f64; 2]>,
23}
24
25/// Event emitted when a point enters or exits a geofence.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct GeofenceEvent {
28    /// The geofence that was entered/exited.
29    pub geofence_id: String,
30    /// The entity (vehicle, device, etc.) that triggered the event.
31    pub entity_id: String,
32    /// Whether the entity entered or exited the geofence.
33    pub event_type: GeofenceEventType,
34    /// The point coordinates that triggered the event.
35    pub point: [f64; 2],
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39pub enum GeofenceEventType {
40    Enter,
41    Exit,
42}
43
44/// Geofence registry replicated across all shards.
45///
46/// Each shard maintains a local copy of all registered geofences. When a
47/// point update arrives, the shard evaluates it against all geofences
48/// locally — no cross-shard coordination needed.
49pub struct GeofenceRegistry {
50    geofences: Vec<Geofence>,
51    /// entity_id → set of geofence_ids the entity is currently inside.
52    entity_state: std::collections::HashMap<String, std::collections::HashSet<String>>,
53}
54
55impl GeofenceRegistry {
56    pub fn new() -> Self {
57        Self {
58            geofences: Vec::new(),
59            entity_state: std::collections::HashMap::new(),
60        }
61    }
62
63    /// Register a new geofence polygon.
64    pub fn register(&mut self, geofence: Geofence) {
65        // Remove old geofence with same ID if exists.
66        self.geofences.retain(|g| g.id != geofence.id);
67        self.geofences.push(geofence);
68    }
69
70    /// Unregister a geofence by ID.
71    pub fn unregister(&mut self, geofence_id: &str) {
72        self.geofences.retain(|g| g.id != geofence_id);
73        // Remove from all entity states.
74        for state in self.entity_state.values_mut() {
75            state.remove(geofence_id);
76        }
77    }
78
79    /// Evaluate a point update for an entity. Returns enter/exit events.
80    ///
81    /// Checks the point against all registered geofences. Compares with
82    /// the entity's previous state to detect enter/exit transitions.
83    pub fn evaluate_point(&mut self, entity_id: &str, lng: f64, lat: f64) -> Vec<GeofenceEvent> {
84        let point = [lng, lat];
85        let mut events = Vec::new();
86
87        let current_inside: std::collections::HashSet<String> = self
88            .geofences
89            .iter()
90            .filter(|g| point_in_polygon(lng, lat, &g.polygon))
91            .map(|g| g.id.clone())
92            .collect();
93
94        let previous = self.entity_state.entry(entity_id.to_string()).or_default();
95
96        // Detect enters: in current but not in previous.
97        for gid in &current_inside {
98            if !previous.contains(gid) {
99                events.push(GeofenceEvent {
100                    geofence_id: gid.clone(),
101                    entity_id: entity_id.to_string(),
102                    event_type: GeofenceEventType::Enter,
103                    point,
104                });
105            }
106        }
107
108        // Detect exits: in previous but not in current.
109        for gid in previous.iter() {
110            if !current_inside.contains(gid) {
111                events.push(GeofenceEvent {
112                    geofence_id: gid.clone(),
113                    entity_id: entity_id.to_string(),
114                    event_type: GeofenceEventType::Exit,
115                    point,
116                });
117            }
118        }
119
120        // Update state.
121        *previous = current_inside;
122        events
123    }
124
125    /// Number of registered geofences.
126    pub fn len(&self) -> usize {
127        self.geofences.len()
128    }
129
130    pub fn is_empty(&self) -> bool {
131        self.geofences.is_empty()
132    }
133
134    /// Serialize the registry for replication to other shards.
135    pub fn export(&self) -> Vec<u8> {
136        // Safety: Vec<Geofence> with Serialize always serializes successfully.
137        sonic_rs::to_vec(&self.geofences).expect("Geofence vec is always serializable")
138    }
139
140    /// Import geofences from a serialized registry (from another shard).
141    pub fn import(&mut self, data: &[u8]) {
142        if let Ok(geofences) = sonic_rs::from_slice::<Vec<Geofence>>(data) {
143            self.geofences = geofences;
144        }
145    }
146}
147
148impl Default for GeofenceRegistry {
149    fn default() -> Self {
150        Self::new()
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn square_geofence(id: &str, min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Geofence {
159        Geofence {
160            id: id.to_string(),
161            name: format!("zone_{id}"),
162            polygon: vec![
163                [min_x, min_y],
164                [max_x, min_y],
165                [max_x, max_y],
166                [min_x, max_y],
167                [min_x, min_y],
168            ],
169        }
170    }
171
172    #[test]
173    fn enter_event() {
174        let mut reg = GeofenceRegistry::new();
175        reg.register(square_geofence("zone1", 0.0, 0.0, 10.0, 10.0));
176
177        // First position outside.
178        let events = reg.evaluate_point("vehicle1", -5.0, -5.0);
179        assert!(events.is_empty());
180
181        // Move inside.
182        let events = reg.evaluate_point("vehicle1", 5.0, 5.0);
183        assert_eq!(events.len(), 1);
184        assert_eq!(events[0].event_type, GeofenceEventType::Enter);
185        assert_eq!(events[0].geofence_id, "zone1");
186    }
187
188    #[test]
189    fn exit_event() {
190        let mut reg = GeofenceRegistry::new();
191        reg.register(square_geofence("zone1", 0.0, 0.0, 10.0, 10.0));
192
193        // Start inside.
194        reg.evaluate_point("v1", 5.0, 5.0);
195        // Move outside.
196        let events = reg.evaluate_point("v1", 20.0, 20.0);
197        assert_eq!(events.len(), 1);
198        assert_eq!(events[0].event_type, GeofenceEventType::Exit);
199    }
200
201    #[test]
202    fn no_event_when_staying() {
203        let mut reg = GeofenceRegistry::new();
204        reg.register(square_geofence("zone1", 0.0, 0.0, 10.0, 10.0));
205
206        reg.evaluate_point("v1", 5.0, 5.0); // enter
207        let events = reg.evaluate_point("v1", 6.0, 6.0); // still inside
208        assert!(events.is_empty());
209    }
210
211    #[test]
212    fn multiple_geofences() {
213        let mut reg = GeofenceRegistry::new();
214        reg.register(square_geofence("a", 0.0, 0.0, 10.0, 10.0));
215        reg.register(square_geofence("b", 5.0, 5.0, 15.0, 15.0));
216
217        // Point at (7, 7) is inside both.
218        let events = reg.evaluate_point("v1", 7.0, 7.0);
219        assert_eq!(events.len(), 2);
220    }
221
222    #[test]
223    fn unregister_geofence() {
224        let mut reg = GeofenceRegistry::new();
225        reg.register(square_geofence("zone1", 0.0, 0.0, 10.0, 10.0));
226        reg.unregister("zone1");
227        assert!(reg.is_empty());
228    }
229
230    #[test]
231    fn export_import_roundtrip() {
232        let mut reg = GeofenceRegistry::new();
233        reg.register(square_geofence("a", 0.0, 0.0, 10.0, 10.0));
234        reg.register(square_geofence("b", 20.0, 20.0, 30.0, 30.0));
235
236        let data = reg.export();
237        let mut reg2 = GeofenceRegistry::new();
238        reg2.import(&data);
239        assert_eq!(reg2.len(), 2);
240    }
241}