Skip to main content

howler_core/
temporal.rs

1use crate::models::Sighting;
2use anyhow::Result;
3use chrono::{Datelike, Timelike, Weekday};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Time period for temporal analysis
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub enum TimePeriod {
10    Hour(u8),           // 0-23
11    DayOfWeek(Weekday), // Monday-Sunday
12    Month(u8),          // 1-12
13    Season,             // Winter, Spring, Summer, Fall
14}
15
16/// Activity statistics for a time period
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ActivityStats {
19    /// Time period
20    pub period: String,
21    /// Number of sightings in this period
22    pub count: usize,
23    /// Percentage of total sightings
24    pub percentage: f64,
25}
26
27/// Temporal analysis result
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct TemporalAnalysis {
30    /// Activity by hour of day
31    pub hourly_activity: Vec<ActivityStats>,
32    /// Activity by day of week
33    pub daily_activity: Vec<ActivityStats>,
34    /// Activity by month
35    pub monthly_activity: Vec<ActivityStats>,
36    /// Activity by season
37    pub seasonal_activity: Vec<ActivityStats>,
38    /// Most active time period
39    pub most_active_period: String,
40    /// Least active time period
41    pub least_active_period: String,
42}
43
44/// Season classification
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
46pub enum Season {
47    Winter,
48    Spring,
49    Summer,
50    Fall,
51}
52
53/// Get season from month
54pub fn month_to_season(month: u8) -> Season {
55    match month {
56        12 | 1 | 2 => Season::Winter,
57        3..=5 => Season::Spring,
58        6..=8 => Season::Summer,
59        9..=11 => Season::Fall,
60        _ => Season::Winter,
61    }
62}
63
64/// Analyze temporal patterns in sightings
65pub fn analyze_temporal_patterns(sightings: &[Sighting]) -> Result<TemporalAnalysis> {
66    if sightings.is_empty() {
67        return Ok(TemporalAnalysis {
68            hourly_activity: vec![],
69            daily_activity: vec![],
70            monthly_activity: vec![],
71            seasonal_activity: vec![],
72            most_active_period: "No data".to_string(),
73            least_active_period: "No data".to_string(),
74        });
75    }
76
77    let total = sightings.len();
78
79    // Analyze by hour
80    let mut hour_counts: HashMap<u8, usize> = HashMap::new();
81    for sighting in sightings {
82        let hour = sighting.observed_on.hour() as u8;
83        *hour_counts.entry(hour).or_insert(0) += 1;
84    }
85
86    let mut hourly_activity: Vec<ActivityStats> = hour_counts
87        .into_iter()
88        .map(|(hour, count)| ActivityStats {
89            period: format!("{}:00", hour),
90            count,
91            percentage: (count as f64 / total as f64) * 100.0,
92        })
93        .collect();
94    hourly_activity.sort_by(|a, b| a.period.cmp(&b.period));
95
96    // Analyze by day of week
97    let mut day_counts: HashMap<Weekday, usize> = HashMap::new();
98    for sighting in sightings {
99        let weekday = sighting.observed_on.weekday();
100        *day_counts.entry(weekday).or_insert(0) += 1;
101    }
102
103    let mut daily_activity: Vec<ActivityStats> = day_counts
104        .into_iter()
105        .map(|(day, count)| ActivityStats {
106            period: format!("{:?}", day),
107            count,
108            percentage: (count as f64 / total as f64) * 100.0,
109        })
110        .collect();
111    daily_activity.sort_by(|a, b| a.period.cmp(&b.period));
112
113    // Analyze by month
114    let mut month_counts: HashMap<u8, usize> = HashMap::new();
115    for sighting in sightings {
116        let month = sighting.observed_on.month() as u8;
117        *month_counts.entry(month).or_insert(0) += 1;
118    }
119
120    let mut monthly_activity: Vec<ActivityStats> = month_counts
121        .into_iter()
122        .map(|(month, count)| ActivityStats {
123            period: format!("Month {}", month),
124            count,
125            percentage: (count as f64 / total as f64) * 100.0,
126        })
127        .collect();
128    monthly_activity.sort_by(|a, b| a.period.cmp(&b.period));
129
130    // Analyze by season
131    let mut season_counts: HashMap<Season, usize> = HashMap::new();
132    for sighting in sightings {
133        let month = sighting.observed_on.month() as u8;
134        let season = month_to_season(month);
135        *season_counts.entry(season).or_insert(0) += 1;
136    }
137
138    let mut seasonal_activity: Vec<ActivityStats> = season_counts
139        .into_iter()
140        .map(|(season, count)| ActivityStats {
141            period: format!("{:?}", season),
142            count,
143            percentage: (count as f64 / total as f64) * 100.0,
144        })
145        .collect();
146    seasonal_activity.sort_by(|a, b| a.period.cmp(&b.period));
147
148    // Find most and least active periods
149    let all_periods: Vec<&ActivityStats> = hourly_activity
150        .iter()
151        .chain(daily_activity.iter())
152        .chain(monthly_activity.iter())
153        .chain(seasonal_activity.iter())
154        .collect();
155
156    let most_active = all_periods
157        .iter()
158        .max_by(|a, b| a.count.cmp(&b.count))
159        .map(|s| s.period.clone())
160        .unwrap_or_else(|| "Unknown".to_string());
161
162    let least_active = all_periods
163        .iter()
164        .filter(|s| s.count > 0)
165        .min_by(|a, b| a.count.cmp(&b.count))
166        .map(|s| s.period.clone())
167        .unwrap_or_else(|| "Unknown".to_string());
168
169    Ok(TemporalAnalysis {
170        hourly_activity,
171        daily_activity,
172        monthly_activity,
173        seasonal_activity,
174        most_active_period: most_active,
175        least_active_period: least_active,
176    })
177}
178
179/// Calculate trend over time (increasing, decreasing, stable)
180pub fn calculate_trend(sightings: &[Sighting]) -> String {
181    if sightings.len() < 2 {
182        return "Insufficient data".to_string();
183    }
184
185    let mut sorted_sightings = sightings.to_vec();
186    sorted_sightings.sort_by_key(|a| a.observed_on);
187
188    // Group by month
189    let mut monthly_counts: HashMap<String, usize> = HashMap::new();
190    for sighting in &sorted_sightings {
191        let key = sighting.observed_on.format("%Y-%m").to_string();
192        *monthly_counts.entry(key).or_insert(0) += 1;
193    }
194
195    let mut counts: Vec<usize> = monthly_counts.values().cloned().collect();
196    if counts.len() < 2 {
197        return "Insufficient data".to_string();
198    }
199
200    counts.sort();
201
202    let first_half: f64 = counts[..counts.len() / 2].iter().sum::<usize>() as f64;
203    let second_half: f64 = counts[counts.len() / 2..].iter().sum::<usize>() as f64;
204
205    let ratio = if first_half > 0.0 {
206        second_half / first_half
207    } else {
208        1.0
209    };
210
211    if ratio > 1.2 {
212        "Increasing".to_string()
213    } else if ratio < 0.8 {
214        "Decreasing".to_string()
215    } else {
216        "Stable".to_string()
217    }
218}
219
220/// Generate heat map data by time period
221pub fn generate_heatmap_data(sightings: &[Sighting], period: TimePeriod) -> Vec<(String, usize)> {
222    let mut data: HashMap<String, usize> = HashMap::new();
223
224    for sighting in sightings {
225        let key = match period {
226            TimePeriod::Hour(_) => format!("{}:00", sighting.observed_on.hour()),
227            TimePeriod::DayOfWeek(_) => format!("{:?}", sighting.observed_on.weekday()),
228            TimePeriod::Month(_) => format!("Month {}", sighting.observed_on.month()),
229            TimePeriod::Season => {
230                let season = month_to_season(sighting.observed_on.month() as u8);
231                format!("{:?}", season)
232            }
233        };
234
235        *data.entry(key).or_insert(0) += 1;
236    }
237
238    let mut result: Vec<(String, usize)> = data.into_iter().collect();
239    result.sort_by(|a, b| a.0.cmp(&b.0));
240    result
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::models::Source;
247    use chrono::{Duration, Utc};
248
249    fn create_test_sighting(lat: f64, lon: f64, id: i64, hours_ago: i64) -> Sighting {
250        Sighting {
251            id: Some(id),
252            species: "Canis lupus".to_string(),
253            scientific_name: Some("Canis lupus".to_string()),
254            latitude: lat,
255            longitude: lon,
256            observed_on: Utc::now() - Duration::hours(hours_ago),
257            source: Source::GBIF,
258            source_id: format!("test_{}", id),
259            details: None,
260        }
261    }
262
263    #[test]
264    fn test_month_to_season() {
265        assert_eq!(month_to_season(1), Season::Winter);
266        assert_eq!(month_to_season(4), Season::Spring);
267        assert_eq!(month_to_season(7), Season::Summer);
268        assert_eq!(month_to_season(10), Season::Fall);
269    }
270
271    #[test]
272    fn test_analyze_temporal_patterns_empty() {
273        let sightings = vec![];
274        let result = analyze_temporal_patterns(&sightings).unwrap();
275        assert_eq!(result.hourly_activity.len(), 0);
276    }
277
278    #[test]
279    fn test_analyze_temporal_patterns() {
280        let sightings = vec![
281            create_test_sighting(45.0, -122.0, 1, 24),
282            create_test_sighting(45.1, -122.0, 2, 48),
283            create_test_sighting(45.2, -122.0, 3, 72),
284        ];
285
286        let result = analyze_temporal_patterns(&sightings).unwrap();
287        assert!(!result.hourly_activity.is_empty());
288        assert!(!result.daily_activity.is_empty());
289    }
290
291    #[test]
292    fn test_calculate_trend_empty() {
293        let sightings = vec![];
294        let trend = calculate_trend(&sightings);
295        assert_eq!(trend, "Insufficient data");
296    }
297
298    #[test]
299    fn test_calculate_trend_single() {
300        let sightings = vec![create_test_sighting(45.0, -122.0, 1, 0)];
301        let trend = calculate_trend(&sightings);
302        assert_eq!(trend, "Insufficient data");
303    }
304
305    #[test]
306    fn test_calculate_trend_stable() {
307        let sightings = vec![
308            create_test_sighting(45.0, -122.0, 1, 720),
309            create_test_sighting(45.1, -122.0, 2, 360),
310            create_test_sighting(45.2, -122.0, 3, 0),
311        ];
312
313        let trend = calculate_trend(&sightings);
314        assert!(!trend.is_empty());
315    }
316
317    #[test]
318    fn test_generate_heatmap_data() {
319        let sightings = vec![
320            create_test_sighting(45.0, -122.0, 1, 24),
321            create_test_sighting(45.1, -122.0, 2, 48),
322        ];
323
324        let data = generate_heatmap_data(&sightings, TimePeriod::Hour(0));
325        assert!(!data.is_empty());
326    }
327}