Skip to main content

nodedb_cluster/distributed_spatial/
geofence.rs

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