1use crate::models::Sighting;
2use anyhow::Result;
3use chrono::{Datelike, Timelike};
4use linfa::prelude::*;
5use linfa_linear::LinearRegression;
6use linfa_trees::DecisionTree;
7use ndarray::{Array1, Array2, Axis};
8use rand::Rng;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct LocationPrediction {
14 pub latitude: f64,
16 pub longitude: f64,
18 pub confidence: f64,
20 pub horizon_hours: u32,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
26pub enum BehaviorType {
27 Stationary,
29 Territorial,
31 Linear,
33 Random,
35 CentralPlace,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct BehaviorPrediction {
42 pub behavior: BehaviorType,
44 pub confidence: f64,
46 pub features: BehaviorFeatures,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct BehaviorFeatures {
53 pub avg_speed_kmh: f64,
55 pub speed_variance: f64,
57 pub turning_angle_variance: f64,
59 pub straightness_index: f64,
61 pub territory_radius_km: f64,
63 pub time_span_hours: f64,
65 pub num_sightings: usize,
67 pub hour_of_day_sin: f64,
69 pub hour_of_day_cos: f64,
70 pub day_of_year_sin: f64,
72 pub day_of_year_cos: f64,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ActivityPrediction {
78 pub hour: u8,
80 pub activity_probability: f64,
82 pub expected_sightings: f64,
84}
85
86pub struct BehaviorModel {
88 classifier: Option<DecisionTree<f64, usize>>,
90 location_model: Option<linfa_linear::FittedLinearRegression<f64>>,
92 scaler_mean: Option<Array1<f64>>,
94 scaler_std: Option<Array1<f64>>,
95}
96
97impl Default for BehaviorModel {
98 fn default() -> Self {
99 Self::new()
100 }
101}
102
103impl BehaviorModel {
104 pub fn new() -> Self {
105 Self {
106 classifier: None,
107 location_model: None,
108 scaler_mean: None,
109 scaler_std: None,
110 }
111 }
112
113 pub fn extract_features(sightings: &[Sighting]) -> Result<BehaviorFeatures> {
115 if sightings.len() < 2 {
116 anyhow::bail!("Need at least 2 sightings for feature extraction");
117 }
118
119 let mut movements = Vec::new();
120 let mut speeds = Vec::new();
121 let mut bearings = Vec::new();
122
123 let mut sorted = sightings.to_vec();
125 sorted.sort_by_key(|s| s.observed_on);
126
127 for window in sorted.windows(2) {
128 let from = &window[0];
129 let to = &window[1];
130
131 let distance = crate::movement::haversine_distance(
132 from.latitude,
133 from.longitude,
134 to.latitude,
135 to.longitude,
136 );
137 let bearing = crate::movement::calculate_bearing(
138 from.latitude,
139 from.longitude,
140 to.latitude,
141 to.longitude,
142 );
143 let duration = to
144 .observed_on
145 .signed_duration_since(from.observed_on)
146 .num_seconds() as f64
147 / 3600.0;
148
149 if duration > 0.0 {
150 let speed = distance / duration;
151 speeds.push(speed);
152 movements.push((distance, bearing));
153 bearings.push(bearing);
154 }
155 }
156
157 if speeds.is_empty() {
158 anyhow::bail!("No valid movements found");
159 }
160
161 let avg_speed = speeds.iter().sum::<f64>() / speeds.len() as f64;
163 let speed_variance =
164 speeds.iter().map(|s| (s - avg_speed).powi(2)).sum::<f64>() / speeds.len() as f64;
165
166 let mut turning_angles = Vec::new();
168 for i in 1..bearings.len() {
169 let diff = (bearings[i] - bearings[i - 1]).abs();
170 let angle = if diff > 180.0 { 360.0 - diff } else { diff };
171 turning_angles.push(angle.to_radians());
172 }
173 let turning_angle_variance = if turning_angles.len() > 1 {
174 let mean = turning_angles.iter().sum::<f64>() / turning_angles.len() as f64;
175 turning_angles
176 .iter()
177 .map(|a| (a - mean).powi(2))
178 .sum::<f64>()
179 / turning_angles.len() as f64
180 } else {
181 0.0
182 };
183
184 let total_distance: f64 = movements.iter().map(|m| m.0).sum();
186 let net_displacement = if sorted.len() >= 2 {
187 let first = &sorted[0];
188 let last = &sorted[sorted.len() - 1];
189 crate::movement::haversine_distance(
190 first.latitude,
191 first.longitude,
192 last.latitude,
193 last.longitude,
194 )
195 } else {
196 0.0
197 };
198 let straightness_index = if total_distance > 0.0 {
199 net_displacement / total_distance
200 } else {
201 0.0
202 };
203
204 let centroid_lat = sorted.iter().map(|s| s.latitude).sum::<f64>() / sorted.len() as f64;
206 let centroid_lon = sorted.iter().map(|s| s.longitude).sum::<f64>() / sorted.len() as f64;
207 let territory_radius_km = sorted
208 .iter()
209 .map(|s| {
210 crate::movement::haversine_distance(
211 s.latitude,
212 s.longitude,
213 centroid_lat,
214 centroid_lon,
215 )
216 })
217 .fold(0.0, f64::max);
218
219 let first_time = sorted.first().unwrap().observed_on;
221 let last_time = sorted.last().unwrap().observed_on;
222 let time_span_hours =
223 last_time.signed_duration_since(first_time).num_seconds() as f64 / 3600.0;
224
225 let last = sorted.last().unwrap();
227 let hour = last.observed_on.hour() as f64;
228 let day_of_year = last.observed_on.ordinal() as f64;
229
230 Ok(BehaviorFeatures {
231 avg_speed_kmh: avg_speed,
232 speed_variance,
233 turning_angle_variance,
234 straightness_index,
235 territory_radius_km,
236 time_span_hours,
237 num_sightings: sorted.len(),
238 hour_of_day_sin: (hour * 2.0 * std::f64::consts::PI / 24.0).sin(),
239 hour_of_day_cos: (hour * 2.0 * std::f64::consts::PI / 24.0).cos(),
240 day_of_year_sin: (day_of_year * 2.0 * std::f64::consts::PI / 365.0).sin(),
241 day_of_year_cos: (day_of_year * 2.0 * std::f64::consts::PI / 365.0).cos(),
242 })
243 }
244
245 fn features_to_array(features: &BehaviorFeatures) -> Array1<f64> {
247 Array1::from(vec![
248 features.avg_speed_kmh,
249 features.speed_variance,
250 features.turning_angle_variance,
251 features.straightness_index,
252 features.territory_radius_km,
253 features.time_span_hours,
254 features.num_sightings as f64,
255 features.hour_of_day_sin,
256 features.hour_of_day_cos,
257 features.day_of_year_sin,
258 features.day_of_year_cos,
259 ])
260 }
261
262 fn standardize(&mut self, data: &mut Array2<f64>) {
264 let n_features = data.ncols();
265 let mut mean = Array1::zeros(n_features);
266 let mut std = Array1::zeros(n_features);
267
268 for j in 0..n_features {
269 let col = data.column(j);
270 mean[j] = col.mean().unwrap_or(0.0);
271 std[j] = col.std(0.0).max(1e-8);
272 }
273
274 for mut row in data.rows_mut() {
275 for j in 0..n_features {
276 row[j] = (row[j] - mean[j]) / std[j];
277 }
278 }
279
280 self.scaler_mean = Some(mean);
281 self.scaler_std = Some(std);
282 }
283
284 fn apply_scaling(&self, features: &mut Array1<f64>) {
286 if let (Some(mean), Some(std)) = (&self.scaler_mean, &self.scaler_std) {
287 for j in 0..features.len() {
288 features[j] = (features[j] - mean[j]) / std[j];
289 }
290 }
291 }
292
293 pub fn train_classifier(
295 &mut self,
296 training_data: &[(BehaviorFeatures, BehaviorType)],
297 ) -> Result<()> {
298 if training_data.is_empty() {
299 anyhow::bail!("No training data provided");
300 }
301
302 let n_samples = training_data.len();
303 let n_features = 11;
304 let mut x = Array2::zeros((n_samples, n_features));
305 let mut y = Array1::zeros(n_samples);
306
307 for (i, (features, label)) in training_data.iter().enumerate() {
308 let arr = Self::features_to_array(features);
309 for j in 0..n_features {
310 x[[i, j]] = arr[j];
311 }
312 y[i] = match label {
313 BehaviorType::Stationary => 0,
314 BehaviorType::Territorial => 1,
315 BehaviorType::Linear => 2,
316 BehaviorType::Random => 3,
317 BehaviorType::CentralPlace => 4,
318 };
319 }
320
321 self.standardize(&mut x);
322
323 let dataset = DatasetBase::new(x, y);
324 let model = DecisionTree::params()
325 .max_depth(Some(10))
326 .min_weight_split(2.0)
327 .min_weight_leaf(1.0)
328 .fit(&dataset)?;
329
330 self.classifier = Some(model);
331 Ok(())
332 }
333
334 pub fn predict_behavior(&self, features: &BehaviorFeatures) -> Result<BehaviorPrediction> {
336 let mut x = Self::features_to_array(features);
337 self.apply_scaling(&mut x);
338
339 let classifier = self
340 .classifier
341 .as_ref()
342 .ok_or_else(|| anyhow::anyhow!("Classifier not trained"))?;
343
344 let pred = classifier.predict(&x.view().insert_axis(Axis(0)));
345 let behavior = match pred[0] {
346 0 => BehaviorType::Stationary,
347 1 => BehaviorType::Territorial,
348 2 => BehaviorType::Linear,
349 3 => BehaviorType::Random,
350 4 => BehaviorType::CentralPlace,
351 _ => BehaviorType::Random,
352 };
353
354 Ok(BehaviorPrediction {
355 behavior,
356 confidence: 0.8,
357 features: features.clone(),
358 })
359 }
360
361 pub fn train_location_predictor(&mut self, sightings: &[Sighting]) -> Result<()> {
363 if sightings.len() < 3 {
364 anyhow::bail!("Need at least 3 sightings for location prediction");
365 }
366
367 let mut sorted = sightings.to_vec();
368 sorted.sort_by_key(|s| s.observed_on);
369
370 let n = sorted.len() - 1;
371 let mut x = Array2::zeros((n, 3));
372 let mut y_lat = Array1::zeros(n);
373 let mut y_lon = Array1::zeros(n);
374
375 for i in 0..n {
376 let dt = sorted[i + 1]
377 .observed_on
378 .signed_duration_since(sorted[i].observed_on)
379 .num_seconds() as f64
380 / 3600.0;
381 x[[i, 0]] = dt;
382 x[[i, 1]] = sorted[i].latitude;
383 x[[i, 2]] = sorted[i].longitude;
384 y_lat[i] = sorted[i + 1].latitude;
385 y_lon[i] = sorted[i + 1].longitude;
386 }
387
388 let dataset_lat = DatasetBase::new(x, y_lat);
390 let model = LinearRegression::default().fit(&dataset_lat)?;
391 self.location_model = Some(model);
392
393 Ok(())
394 }
395
396 pub fn predict_next_location(
398 &self,
399 sightings: &[Sighting],
400 horizon_hours: u32,
401 ) -> Result<LocationPrediction> {
402 if sightings.len() < 2 {
403 anyhow::bail!("Need at least 2 sightings");
404 }
405
406 let mut sorted = sightings.to_vec();
407 sorted.sort_by_key(|s| s.observed_on);
408
409 let last = sorted.last().unwrap();
410 let prev = &sorted[sorted.len() - 2];
411
412 let dt = last
413 .observed_on
414 .signed_duration_since(prev.observed_on)
415 .num_seconds() as f64
416 / 3600.0;
417
418 let bearing = crate::movement::calculate_bearing(
420 prev.latitude,
421 prev.longitude,
422 last.latitude,
423 last.longitude,
424 );
425 let distance = crate::movement::haversine_distance(
426 prev.latitude,
427 prev.longitude,
428 last.latitude,
429 last.longitude,
430 );
431 let speed = if dt > 0.0 { distance / dt } else { 0.0 };
432
433 let pred_distance = speed * horizon_hours as f64;
434
435 let lat1 = last.latitude.to_radians();
437 let lon1 = last.longitude.to_radians();
438 let brng = bearing.to_radians();
439 let d = pred_distance / 6371.0;
440
441 let lat2 = (lat1.sin() * d.cos() + lat1.cos() * d.sin() * brng.cos()).asin();
442 let lon2 =
443 lon1 + (brng.sin() * d.cos() * lat1.cos()).atan2(d.cos() - lat1.sin() * lat2.sin());
444
445 let confidence = (1.0 / (1.0 + horizon_hours as f64 * 0.1)).min(0.95);
446
447 Ok(LocationPrediction {
448 latitude: lat2.to_degrees(),
449 longitude: lon2.to_degrees(),
450 confidence,
451 horizon_hours,
452 })
453 }
454}
455
456pub fn predict_activity_pattern(sightings: &[Sighting]) -> Vec<ActivityPrediction> {
458 let mut hourly_counts = [0usize; 24];
459 let mut total = 0;
460
461 for s in sightings {
462 let hour = s.observed_on.hour() as usize;
463 hourly_counts[hour] += 1;
464 total += 1;
465 }
466
467 if total == 0 {
468 return vec![];
469 }
470
471 let mut predictions = Vec::new();
473 for (hour, &count) in hourly_counts.iter().enumerate() {
474 let count = count as f64;
475 let probability = (count + 0.5) / (total as f64 + 12.0); predictions.push(ActivityPrediction {
477 hour: hour as u8,
478 activity_probability: probability,
479 expected_sightings: probability * (total as f64 / 24.0),
480 });
481 }
482
483 predictions
484}
485
486pub fn generate_synthetic_training_data() -> Vec<(BehaviorFeatures, BehaviorType)> {
488 let mut data = Vec::new();
489 let mut rng = rand::thread_rng();
490
491 for _ in 0..50 {
493 data.push((
494 BehaviorFeatures {
495 avg_speed_kmh: rng.gen_range(0.0..0.5),
496 speed_variance: rng.gen_range(0.0..0.1),
497 turning_angle_variance: rng.gen_range(0.0..1.0),
498 straightness_index: rng.gen_range(0.0..0.3),
499 territory_radius_km: rng.gen_range(0.0..2.0),
500 time_span_hours: rng.gen_range(1.0..100.0),
501 num_sightings: rng.gen_range(5..20),
502 hour_of_day_sin: rng.gen_range(-1.0..1.0),
503 hour_of_day_cos: rng.gen_range(-1.0..1.0),
504 day_of_year_sin: rng.gen_range(-1.0..1.0),
505 day_of_year_cos: rng.gen_range(-1.0..1.0),
506 },
507 BehaviorType::Stationary,
508 ));
509 }
510
511 for _ in 0..50 {
513 data.push((
514 BehaviorFeatures {
515 avg_speed_kmh: rng.gen_range(1.0..5.0),
516 speed_variance: rng.gen_range(0.5..3.0),
517 turning_angle_variance: rng.gen_range(1.0..3.0),
518 straightness_index: rng.gen_range(0.1..0.4),
519 territory_radius_km: rng.gen_range(5.0..20.0),
520 time_span_hours: rng.gen_range(24.0..500.0),
521 num_sightings: rng.gen_range(10..50),
522 hour_of_day_sin: rng.gen_range(-1.0..1.0),
523 hour_of_day_cos: rng.gen_range(-1.0..1.0),
524 day_of_year_sin: rng.gen_range(-1.0..1.0),
525 day_of_year_cos: rng.gen_range(-1.0..1.0),
526 },
527 BehaviorType::Territorial,
528 ));
529 }
530
531 for _ in 0..50 {
533 data.push((
534 BehaviorFeatures {
535 avg_speed_kmh: rng.gen_range(3.0..10.0),
536 speed_variance: rng.gen_range(0.5..2.0),
537 turning_angle_variance: rng.gen_range(0.0..0.5),
538 straightness_index: rng.gen_range(0.7..1.0),
539 territory_radius_km: rng.gen_range(50.0..500.0),
540 time_span_hours: rng.gen_range(10.0..200.0),
541 num_sightings: rng.gen_range(5..30),
542 hour_of_day_sin: rng.gen_range(-1.0..1.0),
543 hour_of_day_cos: rng.gen_range(-1.0..1.0),
544 day_of_year_sin: rng.gen_range(-1.0..1.0),
545 day_of_year_cos: rng.gen_range(-1.0..1.0),
546 },
547 BehaviorType::Linear,
548 ));
549 }
550
551 for _ in 0..50 {
553 data.push((
554 BehaviorFeatures {
555 avg_speed_kmh: rng.gen_range(0.5..3.0),
556 speed_variance: rng.gen_range(1.0..5.0),
557 turning_angle_variance: rng.gen_range(2.0..4.0),
558 straightness_index: rng.gen_range(0.0..0.3),
559 territory_radius_km: rng.gen_range(10.0..100.0),
560 time_span_hours: rng.gen_range(10.0..300.0),
561 num_sightings: rng.gen_range(5..40),
562 hour_of_day_sin: rng.gen_range(-1.0..1.0),
563 hour_of_day_cos: rng.gen_range(-1.0..1.0),
564 day_of_year_sin: rng.gen_range(-1.0..1.0),
565 day_of_year_cos: rng.gen_range(-1.0..1.0),
566 },
567 BehaviorType::Random,
568 ));
569 }
570
571 for _ in 0..50 {
573 data.push((
574 BehaviorFeatures {
575 avg_speed_kmh: rng.gen_range(1.0..4.0),
576 speed_variance: rng.gen_range(0.5..2.0),
577 turning_angle_variance: rng.gen_range(0.5..1.5),
578 straightness_index: rng.gen_range(0.3..0.6),
579 territory_radius_km: rng.gen_range(5.0..30.0),
580 time_span_hours: rng.gen_range(24.0..400.0),
581 num_sightings: rng.gen_range(10..60),
582 hour_of_day_sin: rng.gen_range(-1.0..1.0),
583 hour_of_day_cos: rng.gen_range(-1.0..1.0),
584 day_of_year_sin: rng.gen_range(-1.0..1.0),
585 day_of_year_cos: rng.gen_range(-1.0..1.0),
586 },
587 BehaviorType::CentralPlace,
588 ));
589 }
590
591 data
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597 use crate::models::{Sighting, Source};
598 use chrono::Utc;
599
600 fn create_test_sightings() -> Vec<Sighting> {
601 let base_time = Utc::now();
602 vec![
603 Sighting {
604 id: Some(1),
605 species: "Canis lupus".to_string(),
606 scientific_name: Some("Canis lupus".to_string()),
607 latitude: 45.0,
608 longitude: -122.0,
609 observed_on: base_time - chrono::Duration::hours(6),
610 source: Source::GBIF,
611 source_id: "test_1".to_string(),
612 details: None,
613 },
614 Sighting {
615 id: Some(2),
616 species: "Canis lupus".to_string(),
617 scientific_name: Some("Canis lupus".to_string()),
618 latitude: 45.1,
619 longitude: -122.1,
620 observed_on: base_time - chrono::Duration::hours(3),
621 source: Source::GBIF,
622 source_id: "test_2".to_string(),
623 details: None,
624 },
625 Sighting {
626 id: Some(3),
627 species: "Canis lupus".to_string(),
628 scientific_name: Some("Canis lupus".to_string()),
629 latitude: 45.2,
630 longitude: -122.2,
631 observed_on: base_time,
632 source: Source::GBIF,
633 source_id: "test_3".to_string(),
634 details: None,
635 },
636 ]
637 }
638
639 #[test]
640 fn test_extract_features() {
641 let sightings = create_test_sightings();
642 let features = BehaviorModel::extract_features(&sightings).unwrap();
643
644 assert!(features.avg_speed_kmh >= 0.0);
645 assert_eq!(features.num_sightings, 3);
646 assert!(features.time_span_hours > 0.0);
647 }
648
649 #[test]
650 fn test_predict_activity_pattern() {
651 let sightings = create_test_sightings();
652 let predictions = predict_activity_pattern(&sightings);
653
654 assert_eq!(predictions.len(), 24);
655 let total_prob: f64 = predictions.iter().map(|p| p.activity_probability).sum();
656 assert!((total_prob - 1.0).abs() < 0.1);
657 }
658
659 #[test]
660 fn test_generate_synthetic_training_data() {
661 let data = generate_synthetic_training_data();
662 assert_eq!(data.len(), 250);
663
664 let mut class_counts = std::collections::HashMap::new();
665 for (_, label) in &data {
666 *class_counts.entry(label.clone()).or_insert(0) += 1;
667 }
668 assert_eq!(class_counts.len(), 5);
669 }
670
671 #[test]
672 fn test_behavior_model_train_and_predict() {
673 let training_data = generate_synthetic_training_data();
674 let mut model = BehaviorModel::new();
675 model.train_classifier(&training_data).unwrap();
676
677 let features = BehaviorFeatures {
678 avg_speed_kmh: 0.1,
679 speed_variance: 0.01,
680 turning_angle_variance: 0.5,
681 straightness_index: 0.1,
682 territory_radius_km: 0.5,
683 time_span_hours: 50.0,
684 num_sightings: 10,
685 hour_of_day_sin: 0.0,
686 hour_of_day_cos: 1.0,
687 day_of_year_sin: 0.0,
688 day_of_year_cos: 1.0,
689 };
690
691 let prediction = model.predict_behavior(&features).unwrap();
692 assert!(prediction.confidence > 0.0);
693 }
694
695 #[test]
696 fn test_location_prediction() {
697 let sightings = create_test_sightings();
698 let model = BehaviorModel::new();
699 let prediction = model.predict_next_location(&sightings, 1).unwrap();
700
701 assert!(prediction.latitude >= -90.0 && prediction.latitude <= 90.0);
702 assert!(prediction.longitude >= -180.0 && prediction.longitude <= 180.0);
703 assert!(prediction.confidence > 0.0 && prediction.confidence <= 1.0);
704 assert_eq!(prediction.horizon_hours, 1);
705 }
706}