1use crate::matrix::FdMatrix;
2use std::f64::consts::PI;
3
4use super::hilbert::hilbert_transform;
5use super::peak::detect_peaks;
6use super::strength::{
7 seasonal_strength_spectral, seasonal_strength_variance, seasonal_strength_windowed,
8};
9use super::{
10 analyze_amplitude_envelope, compute_cycle_strengths, compute_mean_curve, cwt_morlet_fft,
11 detect_threshold_crossings, linear_slope, otsu_threshold, valid_interior_bounds,
12 ChangeDetectionResult, PeakTimingResult, StrengthMethod,
13};
14
15#[derive(Debug, Clone, Copy, PartialEq)]
17#[non_exhaustive]
18pub enum ThresholdMethod {
19 Fixed(f64),
21 Percentile(f64),
23 Otsu,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum ModulationType {
31 Stable,
33 Emerging,
35 Fading,
37 Oscillating,
39 NonSeasonal,
41}
42
43#[derive(Debug, Clone, PartialEq)]
45#[non_exhaustive]
46pub struct AmplitudeModulationResult {
47 pub is_seasonal: bool,
49 pub seasonal_strength: f64,
51 pub has_modulation: bool,
53 pub modulation_type: ModulationType,
55 pub modulation_score: f64,
57 pub amplitude_trend: f64,
59 pub strength_curve: Vec<f64>,
61 pub time_points: Vec<f64>,
63 pub min_strength: f64,
65 pub max_strength: f64,
67}
68
69#[derive(Debug, Clone, PartialEq)]
71#[non_exhaustive]
72pub struct WaveletAmplitudeResult {
73 pub is_seasonal: bool,
75 pub seasonal_strength: f64,
77 pub has_modulation: bool,
79 pub modulation_type: ModulationType,
81 pub modulation_score: f64,
83 pub amplitude_trend: f64,
85 pub wavelet_amplitude: Vec<f64>,
87 pub time_points: Vec<f64>,
89 pub scale: f64,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95#[non_exhaustive]
96pub enum SeasonalType {
97 StableSeasonal,
99 VariableTiming,
101 IntermittentSeasonal,
103 NonSeasonal,
105}
106
107#[derive(Debug, Clone, PartialEq)]
109#[non_exhaustive]
110pub struct SeasonalityClassification {
111 pub is_seasonal: bool,
113 pub has_stable_timing: bool,
115 pub timing_variability: f64,
117 pub seasonal_strength: f64,
119 pub cycle_strengths: Vec<f64>,
121 pub weak_seasons: Vec<usize>,
123 pub classification: SeasonalType,
125 pub peak_timing: Option<PeakTimingResult>,
127}
128
129pub fn detect_seasonality_changes(
142 data: &FdMatrix,
143 argvals: &[f64],
144 period: f64,
145 threshold: f64,
146 window_size: f64,
147 min_duration: f64,
148) -> ChangeDetectionResult {
149 let (n, m) = data.shape();
150 if n == 0 || m < 4 || argvals.len() != m {
151 return ChangeDetectionResult {
152 change_points: Vec::new(),
153 strength_curve: Vec::new(),
154 };
155 }
156
157 let strength_curve =
159 seasonal_strength_windowed(data, argvals, period, window_size, StrengthMethod::Variance);
160
161 if strength_curve.is_empty() {
162 return ChangeDetectionResult {
163 change_points: Vec::new(),
164 strength_curve: Vec::new(),
165 };
166 }
167
168 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
169 let min_dur_points = (min_duration / dt).round() as usize;
170
171 let change_points =
172 detect_threshold_crossings(&strength_curve, argvals, threshold, min_dur_points);
173
174 ChangeDetectionResult {
175 change_points,
176 strength_curve,
177 }
178}
179
180pub fn detect_amplitude_modulation(
196 data: &FdMatrix,
197 argvals: &[f64],
198 period: f64,
199 modulation_threshold: f64,
200 seasonality_threshold: f64,
201) -> AmplitudeModulationResult {
202 let (n, m) = data.shape();
203 let empty_result = AmplitudeModulationResult {
205 is_seasonal: false,
206 seasonal_strength: 0.0,
207 has_modulation: false,
208 modulation_type: ModulationType::NonSeasonal,
209 modulation_score: 0.0,
210 amplitude_trend: 0.0,
211 strength_curve: Vec::new(),
212 time_points: Vec::new(),
213 min_strength: 0.0,
214 max_strength: 0.0,
215 };
216
217 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
218 return empty_result;
219 }
220
221 let overall_strength = seasonal_strength_spectral(data, argvals, period);
223
224 if overall_strength < seasonality_threshold {
225 return AmplitudeModulationResult {
226 is_seasonal: false,
227 seasonal_strength: overall_strength,
228 has_modulation: false,
229 modulation_type: ModulationType::NonSeasonal,
230 modulation_score: 0.0,
231 amplitude_trend: 0.0,
232 strength_curve: Vec::new(),
233 time_points: argvals.to_vec(),
234 min_strength: 0.0,
235 max_strength: 0.0,
236 };
237 }
238
239 let mean_curve = compute_mean_curve(data);
241
242 let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
244 let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
245 let analytic = hilbert_transform(&detrended);
246 let envelope: Vec<f64> = analytic.iter().map(|c| c.norm()).collect();
247
248 if envelope.is_empty() {
249 return AmplitudeModulationResult {
250 is_seasonal: true,
251 seasonal_strength: overall_strength,
252 has_modulation: false,
253 modulation_type: ModulationType::Stable,
254 modulation_score: 0.0,
255 amplitude_trend: 0.0,
256 strength_curve: Vec::new(),
257 time_points: argvals.to_vec(),
258 min_strength: 0.0,
259 max_strength: 0.0,
260 };
261 }
262
263 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
265 let smooth_window = ((period / dt) as usize).max(3);
266 let half_window = smooth_window / 2;
267
268 let smoothed_envelope: Vec<f64> = (0..m)
269 .map(|i| {
270 let start = i.saturating_sub(half_window);
271 let end = (i + half_window + 1).min(m);
272 let sum: f64 = envelope[start..end].iter().sum();
273 sum / (end - start) as f64
274 })
275 .collect();
276
277 let Some((interior_start, interior_end)) = valid_interior_bounds(m, 4) else {
279 return AmplitudeModulationResult {
280 is_seasonal: true,
281 seasonal_strength: overall_strength,
282 has_modulation: false,
283 modulation_type: ModulationType::Stable,
284 modulation_score: 0.0,
285 amplitude_trend: 0.0,
286 strength_curve: envelope,
287 time_points: argvals.to_vec(),
288 min_strength: 0.0,
289 max_strength: 0.0,
290 };
291 };
292
293 let stats = analyze_amplitude_envelope(
294 &smoothed_envelope[interior_start..interior_end],
295 &argvals[interior_start..interior_end],
296 modulation_threshold,
297 );
298
299 AmplitudeModulationResult {
300 is_seasonal: true,
301 seasonal_strength: overall_strength,
302 has_modulation: stats.has_modulation,
303 modulation_type: stats.modulation_type,
304 modulation_score: stats.modulation_score,
305 amplitude_trend: stats.amplitude_trend,
306 strength_curve: envelope,
307 time_points: argvals.to_vec(),
308 min_strength: stats.min_amp,
309 max_strength: stats.max_amp,
310 }
311}
312
313pub fn detect_amplitude_modulation_wavelet(
329 data: &FdMatrix,
330 argvals: &[f64],
331 period: f64,
332 modulation_threshold: f64,
333 seasonality_threshold: f64,
334) -> WaveletAmplitudeResult {
335 let (n, m) = data.shape();
336 let empty_result = WaveletAmplitudeResult {
337 is_seasonal: false,
338 seasonal_strength: 0.0,
339 has_modulation: false,
340 modulation_type: ModulationType::NonSeasonal,
341 modulation_score: 0.0,
342 amplitude_trend: 0.0,
343 wavelet_amplitude: Vec::new(),
344 time_points: Vec::new(),
345 scale: 0.0,
346 };
347
348 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
349 return empty_result;
350 }
351
352 let overall_strength = seasonal_strength_spectral(data, argvals, period);
354
355 if overall_strength < seasonality_threshold {
356 return WaveletAmplitudeResult {
357 is_seasonal: false,
358 seasonal_strength: overall_strength,
359 has_modulation: false,
360 modulation_type: ModulationType::NonSeasonal,
361 modulation_score: 0.0,
362 amplitude_trend: 0.0,
363 wavelet_amplitude: Vec::new(),
364 time_points: argvals.to_vec(),
365 scale: 0.0,
366 };
367 }
368
369 let mean_curve = compute_mean_curve(data);
371
372 let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
374 let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
375
376 let omega0 = 6.0; let scale = period * omega0 / (2.0 * PI);
379
380 let wavelet_coeffs = cwt_morlet_fft(&detrended, argvals, scale, omega0);
382
383 if wavelet_coeffs.is_empty() {
384 return WaveletAmplitudeResult {
385 is_seasonal: true,
386 seasonal_strength: overall_strength,
387 has_modulation: false,
388 modulation_type: ModulationType::Stable,
389 modulation_score: 0.0,
390 amplitude_trend: 0.0,
391 wavelet_amplitude: Vec::new(),
392 time_points: argvals.to_vec(),
393 scale,
394 };
395 }
396
397 let wavelet_amplitude: Vec<f64> = wavelet_coeffs.iter().map(|c| c.norm()).collect();
399
400 let Some((interior_start, interior_end)) = valid_interior_bounds(m, 4) else {
402 return WaveletAmplitudeResult {
403 is_seasonal: true,
404 seasonal_strength: overall_strength,
405 has_modulation: false,
406 modulation_type: ModulationType::Stable,
407 modulation_score: 0.0,
408 amplitude_trend: 0.0,
409 wavelet_amplitude,
410 time_points: argvals.to_vec(),
411 scale,
412 };
413 };
414
415 let stats = analyze_amplitude_envelope(
416 &wavelet_amplitude[interior_start..interior_end],
417 &argvals[interior_start..interior_end],
418 modulation_threshold,
419 );
420
421 WaveletAmplitudeResult {
422 is_seasonal: true,
423 seasonal_strength: overall_strength,
424 has_modulation: stats.has_modulation,
425 modulation_type: stats.modulation_type,
426 modulation_score: stats.modulation_score,
427 amplitude_trend: stats.amplitude_trend,
428 wavelet_amplitude,
429 time_points: argvals.to_vec(),
430 scale,
431 }
432}
433
434pub fn analyze_peak_timing(
449 data: &FdMatrix,
450 argvals: &[f64],
451 period: f64,
452 smooth_nbasis: Option<usize>,
453) -> PeakTimingResult {
454 let (n, m) = data.shape();
455 if n == 0 || m < 3 || argvals.len() != m || period <= 0.0 {
456 return PeakTimingResult {
457 peak_times: Vec::new(),
458 peak_values: Vec::new(),
459 normalized_timing: Vec::new(),
460 mean_timing: f64::NAN,
461 std_timing: f64::NAN,
462 range_timing: f64::NAN,
463 variability_score: f64::NAN,
464 timing_trend: f64::NAN,
465 cycle_indices: Vec::new(),
466 };
467 }
468
469 let min_distance = period * 0.7;
472 let peaks = detect_peaks(
473 data,
474 argvals,
475 Some(min_distance),
476 None, true, smooth_nbasis,
479 );
480
481 let sample_peaks = if peaks.peaks.is_empty() {
483 Vec::new()
484 } else {
485 peaks.peaks[0].clone()
486 };
487
488 if sample_peaks.is_empty() {
489 return PeakTimingResult {
490 peak_times: Vec::new(),
491 peak_values: Vec::new(),
492 normalized_timing: Vec::new(),
493 mean_timing: f64::NAN,
494 std_timing: f64::NAN,
495 range_timing: f64::NAN,
496 variability_score: f64::NAN,
497 timing_trend: f64::NAN,
498 cycle_indices: Vec::new(),
499 };
500 }
501
502 let peak_times: Vec<f64> = sample_peaks.iter().map(|p| p.time).collect();
503 let peak_values: Vec<f64> = sample_peaks.iter().map(|p| p.value).collect();
504
505 let t_start = argvals[0];
507 let normalized_timing: Vec<f64> = peak_times
508 .iter()
509 .map(|&t| {
510 let cycle_pos = (t - t_start) % period;
511 cycle_pos / period
512 })
513 .collect();
514
515 let cycle_indices: Vec<usize> = peak_times
517 .iter()
518 .map(|&t| crate::utility::f64_to_usize_clamped(((t - t_start) / period).floor()) + 1)
519 .collect();
520
521 let n_peaks = normalized_timing.len() as f64;
523 let mean_timing = normalized_timing.iter().sum::<f64>() / n_peaks;
524
525 let variance: f64 = normalized_timing
526 .iter()
527 .map(|&x| (x - mean_timing).powi(2))
528 .sum::<f64>()
529 / n_peaks;
530 let std_timing = variance.sqrt();
531
532 let min_timing = normalized_timing
533 .iter()
534 .copied()
535 .fold(f64::INFINITY, f64::min);
536 let max_timing = normalized_timing
537 .iter()
538 .copied()
539 .fold(f64::NEG_INFINITY, f64::max);
540 let range_timing = max_timing - min_timing;
541
542 let variability_score = (std_timing / 0.1).min(1.0);
544
545 let cycle_idx_f64: Vec<f64> = cycle_indices.iter().map(|&i| i as f64).collect();
547 let timing_trend = linear_slope(&cycle_idx_f64, &normalized_timing);
548
549 PeakTimingResult {
550 peak_times,
551 peak_values,
552 normalized_timing,
553 mean_timing,
554 std_timing,
555 range_timing,
556 variability_score,
557 timing_trend,
558 cycle_indices,
559 }
560}
561
562pub fn classify_seasonality(
580 data: &FdMatrix,
581 argvals: &[f64],
582 period: f64,
583 strength_threshold: Option<f64>,
584 timing_threshold: Option<f64>,
585) -> SeasonalityClassification {
586 let strength_thresh = strength_threshold.unwrap_or(0.3);
587 let timing_thresh = timing_threshold.unwrap_or(0.05);
588
589 let (n, m) = data.shape();
590 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
591 return SeasonalityClassification {
592 is_seasonal: false,
593 has_stable_timing: false,
594 timing_variability: f64::NAN,
595 seasonal_strength: f64::NAN,
596 cycle_strengths: Vec::new(),
597 weak_seasons: Vec::new(),
598 classification: SeasonalType::NonSeasonal,
599 peak_timing: None,
600 };
601 }
602
603 let overall_strength = seasonal_strength_variance(data, argvals, period, 3);
605
606 let (cycle_strengths, weak_seasons) =
607 compute_cycle_strengths(data, argvals, period, strength_thresh);
608 let n_cycles = cycle_strengths.len();
609
610 let peak_timing = analyze_peak_timing(data, argvals, period, None);
612
613 let is_seasonal = overall_strength >= strength_thresh;
615 let has_stable_timing = peak_timing.std_timing <= timing_thresh;
616 let timing_variability = peak_timing.variability_score;
617
618 let n_weak = weak_seasons.len();
620 let classification = if !is_seasonal {
621 SeasonalType::NonSeasonal
622 } else if n_cycles > 0 && n_weak as f64 / n_cycles as f64 > 0.3 {
623 SeasonalType::IntermittentSeasonal
625 } else if !has_stable_timing {
626 SeasonalType::VariableTiming
627 } else {
628 SeasonalType::StableSeasonal
629 };
630
631 SeasonalityClassification {
632 is_seasonal,
633 has_stable_timing,
634 timing_variability,
635 seasonal_strength: overall_strength,
636 cycle_strengths,
637 weak_seasons,
638 classification,
639 peak_timing: Some(peak_timing),
640 }
641}
642
643pub fn detect_seasonality_changes_auto(
655 data: &FdMatrix,
656 argvals: &[f64],
657 period: f64,
658 threshold_method: ThresholdMethod,
659 window_size: f64,
660 min_duration: f64,
661) -> ChangeDetectionResult {
662 let (n, m) = data.shape();
663 if n == 0 || m < 4 || argvals.len() != m {
664 return ChangeDetectionResult {
665 change_points: Vec::new(),
666 strength_curve: Vec::new(),
667 };
668 }
669
670 let strength_curve =
672 seasonal_strength_windowed(data, argvals, period, window_size, StrengthMethod::Variance);
673
674 if strength_curve.is_empty() {
675 return ChangeDetectionResult {
676 change_points: Vec::new(),
677 strength_curve: Vec::new(),
678 };
679 }
680
681 let threshold = match threshold_method {
683 ThresholdMethod::Fixed(t) => t,
684 ThresholdMethod::Percentile(p) => {
685 let mut sorted: Vec<f64> = strength_curve
686 .iter()
687 .copied()
688 .filter(|x| x.is_finite())
689 .collect();
690 crate::helpers::sort_nan_safe(&mut sorted);
691 if sorted.is_empty() {
692 0.5
693 } else {
694 let idx = crate::utility::f64_to_usize_clamped((p / 100.0) * sorted.len() as f64);
695 sorted[idx.min(sorted.len() - 1)]
696 }
697 }
698 ThresholdMethod::Otsu => otsu_threshold(&strength_curve),
699 };
700
701 detect_seasonality_changes(data, argvals, period, threshold, window_size, min_duration)
703}