1use crate::models::Sighting;
2use anyhow::Result;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Movement {
8 pub from_id: i64,
10 pub to_id: i64,
12 pub distance_km: f64,
14 pub bearing_degrees: f64,
16 pub duration_seconds: i64,
18 pub speed_kmh: f64,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub enum MovementPattern {
25 Random,
27 Circular,
29 Linear,
31 Stationary,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct MovementAnalysis {
38 pub movements: Vec<Movement>,
40 pub average_speed_kmh: f64,
42 pub max_speed_kmh: f64,
44 pub total_distance_km: f64,
46 pub pattern: MovementPattern,
48 pub average_bearing_degrees: f64,
50}
51
52pub 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
65pub 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
82pub 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 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
163fn 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 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 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
196pub 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 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
222pub 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 let bearing = calculate_bearing(0.0, 0.0, 1.0, 0.0);
285 assert!((bearing - 0.0).abs() < 1.0);
286
287 let bearing = calculate_bearing(0.0, 0.0, 0.0, 1.0);
289 assert!((bearing - 90.0).abs() < 1.0);
290
291 let bearing = calculate_bearing(0.0, 0.0, -1.0, 0.0);
293 assert!((bearing - 180.0).abs() < 1.0);
294
295 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}