Skip to main content

howler_core/
movement.rs

1use crate::models::Sighting;
2use anyhow::Result;
3use serde::{Deserialize, Serialize};
4
5/// Movement between two sightings
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Movement {
8    /// From sighting ID
9    pub from_id: i64,
10    /// To sighting ID
11    pub to_id: i64,
12    /// Distance traveled in kilometers
13    pub distance_km: f64,
14    /// Bearing in degrees (0-360, where 0 is North)
15    pub bearing_degrees: f64,
16    /// Duration in seconds
17    pub duration_seconds: i64,
18    /// Speed in km/h
19    pub speed_kmh: f64,
20}
21
22/// Movement pattern type
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub enum MovementPattern {
25    /// Random movement
26    Random,
27    /// Circular/territorial movement
28    Circular,
29    /// Linear movement (migration or dispersal)
30    Linear,
31    /// Stationary (little movement)
32    Stationary,
33}
34
35/// Movement analysis result
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct MovementAnalysis {
38    /// All calculated movements
39    pub movements: Vec<Movement>,
40    /// Average speed in km/h
41    pub average_speed_kmh: f64,
42    /// Maximum speed in km/h
43    pub max_speed_kmh: f64,
44    /// Total distance traveled in kilometers
45    pub total_distance_km: f64,
46    /// Detected movement pattern
47    pub pattern: MovementPattern,
48    /// Average bearing in degrees
49    pub average_bearing_degrees: f64,
50}
51
52/// Calculate bearing between two points
53pub fn calculate_bearing(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
54    let lat1_rad = lat1.to_radians();
55    let lat2_rad = lat2.to_radians();
56    let dlon = (lon2 - lon1).to_radians();
57
58    let y = dlon.sin() * lat2_rad.cos();
59    let x = lat1_rad.cos() * lat2_rad.sin() - lat1_rad.sin() * lat2_rad.cos() * dlon.cos();
60
61    let bearing = y.atan2(x).to_degrees();
62    (bearing + 360.0) % 360.0
63}
64
65/// Calculate Haversine distance between two points in kilometers
66pub fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
67    const EARTH_RADIUS_KM: f64 = 6371.0;
68
69    let dlat = (lat2 - lat1).to_radians();
70    let dlon = (lon2 - lon1).to_radians();
71
72    let lat1_rad = lat1.to_radians();
73    let lat2_rad = lat2.to_radians();
74
75    let a = (dlat / 2.0).sin() * (dlat / 2.0).sin()
76        + (dlon / 2.0).sin() * (dlon / 2.0).sin() * lat1_rad.cos() * lat2_rad.cos();
77    let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
78
79    EARTH_RADIUS_KM * c
80}
81
82/// Analyze movements between sightings
83pub fn analyze_movements(sightings: &[Sighting]) -> Result<MovementAnalysis> {
84    if sightings.len() < 2 {
85        return Ok(MovementAnalysis {
86            movements: vec![],
87            average_speed_kmh: 0.0,
88            max_speed_kmh: 0.0,
89            total_distance_km: 0.0,
90            pattern: MovementPattern::Stationary,
91            average_bearing_degrees: 0.0,
92        });
93    }
94
95    // Sort sightings by time
96    let mut sorted_sightings = sightings.to_vec();
97    sorted_sightings.sort_by_key(|a| a.observed_on);
98
99    let mut movements = Vec::new();
100    let mut total_distance = 0.0;
101    let mut total_speed = 0.0;
102    let mut max_speed: f64 = 0.0;
103    let mut total_bearing = 0.0;
104
105    for window in sorted_sightings.windows(2) {
106        let from = &window[0];
107        let to = &window[1];
108
109        if let (Some(from_id), Some(to_id)) = (from.id, to.id) {
110            let distance =
111                haversine_distance(from.latitude, from.longitude, to.latitude, to.longitude);
112            let bearing =
113                calculate_bearing(from.latitude, from.longitude, to.latitude, to.longitude);
114            let duration = to.observed_on.signed_duration_since(from.observed_on);
115            let duration_seconds = duration.num_seconds();
116
117            let speed_kmh = if duration_seconds > 0 {
118                (distance / duration_seconds as f64) * 3600.0
119            } else {
120                0.0
121            };
122
123            total_distance += distance;
124            total_speed += speed_kmh;
125            max_speed = max_speed.max(speed_kmh);
126            total_bearing += bearing;
127
128            movements.push(Movement {
129                from_id,
130                to_id,
131                distance_km: distance,
132                bearing_degrees: bearing,
133                duration_seconds,
134                speed_kmh,
135            });
136        }
137    }
138
139    let average_speed = if !movements.is_empty() {
140        total_speed / movements.len() as f64
141    } else {
142        0.0
143    };
144
145    let average_bearing = if !movements.is_empty() {
146        total_bearing / movements.len() as f64
147    } else {
148        0.0
149    };
150
151    let pattern = detect_movement_pattern(&movements);
152
153    Ok(MovementAnalysis {
154        movements,
155        average_speed_kmh: average_speed,
156        max_speed_kmh: max_speed,
157        total_distance_km: total_distance,
158        pattern,
159        average_bearing_degrees: average_bearing,
160    })
161}
162
163/// Detect movement pattern from movements
164fn detect_movement_pattern(movements: &[Movement]) -> MovementPattern {
165    if movements.is_empty() {
166        return MovementPattern::Stationary;
167    }
168
169    let total_distance: f64 = movements.iter().map(|m| m.distance_km).sum();
170    let avg_distance = total_distance / movements.len() as f64;
171
172    // Calculate bearing variance
173    let bearings: Vec<f64> = movements.iter().map(|m| m.bearing_degrees).collect();
174    let avg_bearing = bearings.iter().sum::<f64>() / bearings.len() as f64;
175    let bearing_variance: f64 = bearings
176        .iter()
177        .map(|&b| {
178            let diff = (b - avg_bearing).to_radians();
179            diff.sin().powi(2) + diff.cos().powi(2)
180        })
181        .sum::<f64>()
182        / bearings.len() as f64;
183
184    // Determine pattern based on statistics
185    if avg_distance < 0.5 {
186        MovementPattern::Stationary
187    } else if bearing_variance < 0.5 {
188        MovementPattern::Linear
189    } else if bearing_variance > 1.5 {
190        MovementPattern::Circular
191    } else {
192        MovementPattern::Random
193    }
194}
195
196/// Calculate home range using minimum convex polygon
197pub fn calculate_home_range(sightings: &[Sighting]) -> Option<Vec<(f64, f64)>> {
198    if sightings.len() < 3 {
199        return None;
200    }
201
202    let points: Vec<(f64, f64)> = sightings
203        .iter()
204        .map(|s| (s.longitude, s.latitude))
205        .collect();
206
207    // Use convex hull to calculate home range
208    use geo::{algorithm::convex_hull::ConvexHull, Point};
209    let geo_points: Vec<Point<f64>> = points
210        .iter()
211        .map(|&(lon, lat)| Point::new(lon, lat))
212        .collect();
213
214    let line_string = geo::LineString::from(geo_points);
215    let polygon = geo::Polygon::new(line_string, vec![]);
216    let hull = polygon.convex_hull();
217
218    let exterior = hull.exterior();
219    Some(exterior.points().map(|p| (p.y(), p.x())).collect())
220}
221
222/// Calculate daily movement statistics
223pub fn calculate_daily_statistics(sightings: &[Sighting]) -> Vec<(String, f64, f64)> {
224    let mut daily_stats: std::collections::HashMap<String, (f64, usize)> =
225        std::collections::HashMap::new();
226
227    for window in sightings.windows(2) {
228        let from = &window[0];
229        let to = &window[1];
230
231        let date = from.observed_on.format("%Y-%m-%d").to_string();
232        let distance = haversine_distance(from.latitude, from.longitude, to.latitude, to.longitude);
233
234        let entry = daily_stats.entry(date).or_insert((0.0, 0));
235        entry.0 += distance;
236        entry.1 += 1;
237    }
238
239    let mut result: Vec<(String, f64, f64)> = daily_stats
240        .into_iter()
241        .map(|(date, (total_distance, count))| {
242            let avg_distance = if count > 0 {
243                total_distance / count as f64
244            } else {
245                0.0
246            };
247            (date, total_distance, avg_distance)
248        })
249        .collect();
250
251    result.sort_by(|a, b| a.0.cmp(&b.0));
252    result
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::models::Source;
259    use chrono::{Duration, Utc};
260
261    fn create_test_sighting(lat: f64, lon: f64, id: i64, hours_ago: i64) -> Sighting {
262        Sighting {
263            id: Some(id),
264            species: "Canis lupus".to_string(),
265            scientific_name: Some("Canis lupus".to_string()),
266            latitude: lat,
267            longitude: lon,
268            observed_on: Utc::now() - Duration::hours(hours_ago),
269            source: Source::GBIF,
270            source_id: format!("test_{}", id),
271            details: None,
272        }
273    }
274
275    #[test]
276    fn test_haversine_distance() {
277        let distance = haversine_distance(0.0, 0.0, 0.0, 1.0);
278        assert!(distance > 100.0 && distance < 120.0);
279    }
280
281    #[test]
282    fn test_calculate_bearing() {
283        // North
284        let bearing = calculate_bearing(0.0, 0.0, 1.0, 0.0);
285        assert!((bearing - 0.0).abs() < 1.0);
286
287        // East
288        let bearing = calculate_bearing(0.0, 0.0, 0.0, 1.0);
289        assert!((bearing - 90.0).abs() < 1.0);
290
291        // South
292        let bearing = calculate_bearing(0.0, 0.0, -1.0, 0.0);
293        assert!((bearing - 180.0).abs() < 1.0);
294
295        // West
296        let bearing = calculate_bearing(0.0, 0.0, 0.0, -1.0);
297        assert!((bearing - 270.0).abs() < 1.0);
298    }
299
300    #[test]
301    fn test_analyze_movements_empty() {
302        let sightings = vec![];
303        let result = analyze_movements(&sightings).unwrap();
304        assert_eq!(result.movements.len(), 0);
305        assert_eq!(result.pattern, MovementPattern::Stationary);
306    }
307
308    #[test]
309    fn test_analyze_movements_single() {
310        let sightings = vec![create_test_sighting(45.0, -122.0, 1, 0)];
311        let result = analyze_movements(&sightings).unwrap();
312        assert_eq!(result.movements.len(), 0);
313    }
314
315    #[test]
316    fn test_analyze_movements_linear() {
317        let sightings = vec![
318            create_test_sighting(45.0, -122.0, 1, 4),
319            create_test_sighting(45.1, -122.0, 2, 3),
320            create_test_sighting(45.2, -122.0, 3, 2),
321            create_test_sighting(45.3, -122.0, 4, 1),
322            create_test_sighting(45.4, -122.0, 5, 0),
323        ];
324
325        let result = analyze_movements(&sightings).unwrap();
326        assert_eq!(result.movements.len(), 4);
327        assert!(result.total_distance_km > 0.0);
328    }
329
330    #[test]
331    fn test_calculate_daily_statistics() {
332        let sightings = vec![
333            create_test_sighting(45.0, -122.0, 1, 25),
334            create_test_sighting(45.1, -122.0, 2, 24),
335            create_test_sighting(45.2, -122.0, 3, 1),
336            create_test_sighting(45.3, -122.0, 4, 0),
337        ];
338
339        let stats = calculate_daily_statistics(&sightings);
340        assert!(!stats.is_empty());
341    }
342}