Skip to main content

howler_core/
clustering.rs

1use crate::models::Sighting;
2use anyhow::Result;
3use geo::{algorithm::convex_hull::ConvexHull, point, Coord};
4use geojson::{Geometry, Value};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// DBSCAN clustering parameters
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct DbscanParams {
11    /// Maximum distance between points to be considered in the same neighborhood (in kilometers)
12    pub epsilon: f64,
13    /// Minimum number of points to form a cluster
14    pub min_points: usize,
15}
16
17impl Default for DbscanParams {
18    fn default() -> Self {
19        Self {
20            epsilon: 5.0, // 5 km
21            min_points: 5,
22        }
23    }
24}
25
26/// Detected pack territory
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PackTerritory {
29    /// Unique territory ID
30    pub id: usize,
31    /// List of sighting IDs in this territory
32    pub sighting_ids: Vec<i64>,
33    /// Territory boundary as GeoJSON polygon
34    pub boundary: Option<String>,
35    /// Estimated territory size in square kilometers
36    pub area_km2: Option<f64>,
37    /// Center point (latitude, longitude)
38    pub center: (f64, f64),
39    /// Number of sightings in territory
40    pub sighting_count: usize,
41}
42
43/// DBSCAN clustering result
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ClusteringResult {
46    /// Detected territories
47    pub territories: Vec<PackTerritory>,
48    /// Number of noise points (sightings not in any territory)
49    pub noise_count: usize,
50    /// Total number of sightings processed
51    pub total_sightings: usize,
52}
53
54/// DBSCAN clustering algorithm implementation
55pub fn dbscan_cluster(sightings: &[Sighting], params: &DbscanParams) -> Result<ClusteringResult> {
56    if sightings.is_empty() {
57        return Ok(ClusteringResult {
58            territories: vec![],
59            noise_count: 0,
60            total_sightings: 0,
61        });
62    }
63
64    let mut visited = vec![false; sightings.len()];
65    let mut cluster_labels: Vec<Option<usize>> = vec![None; sightings.len()];
66    let mut cluster_id = 0;
67
68    for i in 0..sightings.len() {
69        if visited[i] {
70            continue;
71        }
72        visited[i] = true;
73
74        let neighbors = region_query(sightings, i, params);
75
76        if neighbors.len() < params.min_points {
77            // Mark as noise
78            cluster_labels[i] = None;
79        } else {
80            // Start new cluster
81            cluster_labels[i] = Some(cluster_id);
82            expand_cluster(
83                sightings,
84                &mut visited,
85                &mut cluster_labels,
86                &mut neighbors.clone(),
87                cluster_id,
88                params,
89            );
90            cluster_id += 1;
91        }
92    }
93
94    // Build territories from clusters
95    let mut cluster_map: HashMap<usize, Vec<i64>> = HashMap::new();
96    for (i, label) in cluster_labels.iter().enumerate() {
97        if let Some(cid) = label {
98            if let Some(id) = sightings[i].id {
99                cluster_map.entry(*cid).or_default().push(id);
100            }
101        }
102    }
103
104    let mut territories = Vec::new();
105    for (cid, sighting_ids) in cluster_map {
106        let cluster_sightings: Vec<&Sighting> = sighting_ids
107            .iter()
108            .filter_map(|&id| sightings.iter().find(|s| s.id == Some(id)))
109            .collect();
110
111        let center = calculate_center(&cluster_sightings);
112        let boundary = calculate_convex_hull(&cluster_sightings);
113        let area_km2 = calculate_area(&boundary);
114
115        territories.push(PackTerritory {
116            id: cid,
117            sighting_ids,
118            boundary,
119            area_km2,
120            center,
121            sighting_count: cluster_sightings.len(),
122        });
123    }
124
125    let noise_count = cluster_labels.iter().filter(|l| l.is_none()).count();
126
127    Ok(ClusteringResult {
128        territories,
129        noise_count,
130        total_sightings: sightings.len(),
131    })
132}
133
134/// Find all points within epsilon distance of point at index
135fn region_query(sightings: &[Sighting], index: usize, params: &DbscanParams) -> Vec<usize> {
136    let mut neighbors = Vec::new();
137    let _p1 = point!(x: sightings[index].longitude, y: sightings[index].latitude);
138
139    for (i, sighting) in sightings.iter().enumerate() {
140        let _p2 = point!(x: sighting.longitude, y: sighting.latitude);
141        let distance_km = haversine_distance(
142            sightings[index].latitude,
143            sightings[index].longitude,
144            sighting.latitude,
145            sighting.longitude,
146        );
147
148        if distance_km <= params.epsilon {
149            neighbors.push(i);
150        }
151    }
152
153    neighbors
154}
155
156/// Expand cluster by adding density-reachable points
157fn expand_cluster(
158    sightings: &[Sighting],
159    visited: &mut [bool],
160    cluster_labels: &mut [Option<usize>],
161    neighbors: &mut Vec<usize>,
162    cluster_id: usize,
163    params: &DbscanParams,
164) {
165    let mut i = 0;
166    while i < neighbors.len() {
167        let neighbor_idx = neighbors[i];
168
169        if !visited[neighbor_idx] {
170            visited[neighbor_idx] = true;
171            let new_neighbors = region_query(sightings, neighbor_idx, params);
172
173            if new_neighbors.len() >= params.min_points {
174                for &nn in &new_neighbors {
175                    if !neighbors.contains(&nn) {
176                        neighbors.push(nn);
177                    }
178                }
179            }
180        }
181
182        if cluster_labels[neighbor_idx].is_none() {
183            cluster_labels[neighbor_idx] = Some(cluster_id);
184        }
185
186        i += 1;
187    }
188}
189
190/// Calculate center point of a cluster
191fn calculate_center(sightings: &[&Sighting]) -> (f64, f64) {
192    if sightings.is_empty() {
193        return (0.0, 0.0);
194    }
195
196    let sum_lat: f64 = sightings.iter().map(|s| s.latitude).sum();
197    let sum_lon: f64 = sightings.iter().map(|s| s.longitude).sum();
198    let count = sightings.len() as f64;
199
200    (sum_lat / count, sum_lon / count)
201}
202
203/// Calculate convex hull boundary as GeoJSON polygon
204fn calculate_convex_hull(sightings: &[&Sighting]) -> Option<String> {
205    if sightings.len() < 3 {
206        return None;
207    }
208
209    let points: Vec<Coord<f64>> = sightings
210        .iter()
211        .map(|s| Coord {
212            x: s.longitude,
213            y: s.latitude,
214        })
215        .collect();
216
217    let polygon = geo::Polygon::<f64>::new(points.into(), vec![]);
218    let hull = polygon.convex_hull();
219
220    let exterior = hull.exterior();
221    let coords: Vec<Vec<f64>> = exterior.points().map(|p| vec![p.x(), p.y()]).collect();
222
223    let geometry = Geometry::new(Value::Polygon(vec![coords]));
224    Some(serde_json::to_string(&geometry).unwrap_or_default())
225}
226
227/// Calculate area of polygon in square kilometers
228fn calculate_area(boundary: &Option<String>) -> Option<f64> {
229    let boundary_str = boundary.as_ref()?;
230    let geometry: Geometry = serde_json::from_str(boundary_str).ok()?;
231
232    match geometry.value {
233        Value::Polygon(ref rings) => {
234            if let Some(exterior) = rings.first() {
235                if exterior.len() < 4 {
236                    return None;
237                }
238
239                // Use shoelace formula for area calculation
240                let mut area = 0.0;
241                for i in 0..exterior.len() - 1 {
242                    let (x1, y1) = (exterior[i][0], exterior[i][1]);
243                    let (x2, y2) = (exterior[i + 1][0], exterior[i + 1][1]);
244                    area += (x1 * y2) - (x2 * y1);
245                }
246                area = area.abs() / 2.0;
247
248                // Convert from degrees^2 to approximate km^2
249                // This is a rough approximation; for precise calculations, use proper projection
250                Some(area * 111.32 * 111.32)
251            } else {
252                None
253            }
254        }
255        _ => None,
256    }
257}
258
259/// Calculate Haversine distance between two points in kilometers
260fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
261    const EARTH_RADIUS_KM: f64 = 6371.0;
262
263    let dlat = (lat2 - lat1).to_radians();
264    let dlon = (lon2 - lon1).to_radians();
265
266    let lat1_rad = lat1.to_radians();
267    let lat2_rad = lat2.to_radians();
268
269    let a = (dlat / 2.0).sin() * (dlat / 2.0).sin()
270        + (dlon / 2.0).sin() * (dlon / 2.0).sin() * lat1_rad.cos() * lat2_rad.cos();
271    let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
272
273    EARTH_RADIUS_KM * c
274}
275
276/// Detect territory overlaps
277pub fn detect_overlaps(
278    territories: &[PackTerritory],
279    threshold_km: f64,
280) -> Vec<(usize, usize, f64)> {
281    let mut overlaps = Vec::new();
282
283    for i in 0..territories.len() {
284        for j in (i + 1)..territories.len() {
285            let distance = haversine_distance(
286                territories[i].center.0,
287                territories[i].center.1,
288                territories[j].center.0,
289                territories[j].center.1,
290            );
291
292            if distance < threshold_km {
293                overlaps.push((i, j, distance));
294            }
295        }
296    }
297
298    overlaps
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use chrono::Utc;
305
306    fn create_test_sighting(lat: f64, lon: f64, id: i64) -> Sighting {
307        Sighting {
308            id: Some(id),
309            species: "Canis lupus".to_string(),
310            scientific_name: Some("Canis lupus".to_string()),
311            latitude: lat,
312            longitude: lon,
313            observed_on: Utc::now(),
314            source: crate::models::Source::GBIF,
315            source_id: format!("test_{}", id),
316            details: None,
317        }
318    }
319
320    #[test]
321    fn test_haversine_distance() {
322        let distance = haversine_distance(0.0, 0.0, 0.0, 1.0);
323        assert!(distance > 100.0 && distance < 120.0); // ~111 km per degree
324    }
325
326    #[test]
327    fn test_dbscan_empty() {
328        let sightings = vec![];
329        let params = DbscanParams::default();
330        let result = dbscan_cluster(&sightings, &params).unwrap();
331        assert_eq!(result.total_sightings, 0);
332        assert!(result.territories.is_empty());
333    }
334
335    #[test]
336    fn test_dbscan_single_cluster() {
337        let sightings = vec![
338            create_test_sighting(45.0, -122.0, 1),
339            create_test_sighting(45.01, -122.01, 2),
340            create_test_sighting(45.02, -122.02, 3),
341            create_test_sighting(45.03, -122.03, 4),
342            create_test_sighting(45.04, -122.04, 5),
343        ];
344
345        let params = DbscanParams {
346            epsilon: 10.0,
347            min_points: 3,
348        };
349
350        let result = dbscan_cluster(&sightings, &params).unwrap();
351        assert_eq!(result.territories.len(), 1);
352        assert_eq!(result.territories[0].sighting_count, 5);
353    }
354
355    #[test]
356    fn test_dbscan_noise() {
357        let sightings = vec![
358            create_test_sighting(45.0, -122.0, 1),
359            create_test_sighting(45.01, -122.01, 2),
360            create_test_sighting(50.0, -120.0, 3), // Far away - should be noise
361        ];
362
363        let params = DbscanParams {
364            epsilon: 5.0,
365            min_points: 2,
366        };
367
368        let result = dbscan_cluster(&sightings, &params).unwrap();
369        assert_eq!(result.territories.len(), 1);
370        assert_eq!(result.noise_count, 1);
371    }
372
373    #[test]
374    fn test_calculate_center() {
375        let sightings = [
376            create_test_sighting(45.0, -122.0, 1),
377            create_test_sighting(47.0, -124.0, 2),
378        ];
379
380        let center = calculate_center(&sightings.iter().collect::<Vec<_>>());
381        assert!((center.0 - 46.0).abs() < 0.01);
382        assert!((center.1 - (-123.0)).abs() < 0.01);
383    }
384}