1use crate::models::Sighting;
2use anyhow::Result;
3use chrono::{Datelike, Timelike, Weekday};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub enum TimePeriod {
10 Hour(u8), DayOfWeek(Weekday), Month(u8), Season, }
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ActivityStats {
19 pub period: String,
21 pub count: usize,
23 pub percentage: f64,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct TemporalAnalysis {
30 pub hourly_activity: Vec<ActivityStats>,
32 pub daily_activity: Vec<ActivityStats>,
34 pub monthly_activity: Vec<ActivityStats>,
36 pub seasonal_activity: Vec<ActivityStats>,
38 pub most_active_period: String,
40 pub least_active_period: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
46pub enum Season {
47 Winter,
48 Spring,
49 Summer,
50 Fall,
51}
52
53pub 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
64pub 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 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 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 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 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 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
179pub 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 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
220pub 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}