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)]
17pub enum ThresholdMethod {
18 Fixed(f64),
20 Percentile(f64),
22 Otsu,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ModulationType {
29 Stable,
31 Emerging,
33 Fading,
35 Oscillating,
37 NonSeasonal,
39}
40
41#[derive(Debug, Clone, PartialEq)]
43pub struct AmplitudeModulationResult {
44 pub is_seasonal: bool,
46 pub seasonal_strength: f64,
48 pub has_modulation: bool,
50 pub modulation_type: ModulationType,
52 pub modulation_score: f64,
54 pub amplitude_trend: f64,
56 pub strength_curve: Vec<f64>,
58 pub time_points: Vec<f64>,
60 pub min_strength: f64,
62 pub max_strength: f64,
64}
65
66#[derive(Debug, Clone, PartialEq)]
68pub struct WaveletAmplitudeResult {
69 pub is_seasonal: bool,
71 pub seasonal_strength: f64,
73 pub has_modulation: bool,
75 pub modulation_type: ModulationType,
77 pub modulation_score: f64,
79 pub amplitude_trend: f64,
81 pub wavelet_amplitude: Vec<f64>,
83 pub time_points: Vec<f64>,
85 pub scale: f64,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum SeasonalType {
92 StableSeasonal,
94 VariableTiming,
96 IntermittentSeasonal,
98 NonSeasonal,
100}
101
102#[derive(Debug, Clone, PartialEq)]
104pub struct SeasonalityClassification {
105 pub is_seasonal: bool,
107 pub has_stable_timing: bool,
109 pub timing_variability: f64,
111 pub seasonal_strength: f64,
113 pub cycle_strengths: Vec<f64>,
115 pub weak_seasons: Vec<usize>,
117 pub classification: SeasonalType,
119 pub peak_timing: Option<PeakTimingResult>,
121}
122
123pub fn detect_seasonality_changes(
136 data: &FdMatrix,
137 argvals: &[f64],
138 period: f64,
139 threshold: f64,
140 window_size: f64,
141 min_duration: f64,
142) -> ChangeDetectionResult {
143 let (n, m) = data.shape();
144 if n == 0 || m < 4 || argvals.len() != m {
145 return ChangeDetectionResult {
146 change_points: Vec::new(),
147 strength_curve: Vec::new(),
148 };
149 }
150
151 let strength_curve =
153 seasonal_strength_windowed(data, argvals, period, window_size, StrengthMethod::Variance);
154
155 if strength_curve.is_empty() {
156 return ChangeDetectionResult {
157 change_points: Vec::new(),
158 strength_curve: Vec::new(),
159 };
160 }
161
162 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
163 let min_dur_points = (min_duration / dt).round() as usize;
164
165 let change_points =
166 detect_threshold_crossings(&strength_curve, argvals, threshold, min_dur_points);
167
168 ChangeDetectionResult {
169 change_points,
170 strength_curve,
171 }
172}
173
174pub fn detect_amplitude_modulation(
190 data: &FdMatrix,
191 argvals: &[f64],
192 period: f64,
193 modulation_threshold: f64,
194 seasonality_threshold: f64,
195) -> AmplitudeModulationResult {
196 let (n, m) = data.shape();
197 let empty_result = AmplitudeModulationResult {
199 is_seasonal: false,
200 seasonal_strength: 0.0,
201 has_modulation: false,
202 modulation_type: ModulationType::NonSeasonal,
203 modulation_score: 0.0,
204 amplitude_trend: 0.0,
205 strength_curve: Vec::new(),
206 time_points: Vec::new(),
207 min_strength: 0.0,
208 max_strength: 0.0,
209 };
210
211 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
212 return empty_result;
213 }
214
215 let overall_strength = seasonal_strength_spectral(data, argvals, period);
217
218 if overall_strength < seasonality_threshold {
219 return AmplitudeModulationResult {
220 is_seasonal: false,
221 seasonal_strength: overall_strength,
222 has_modulation: false,
223 modulation_type: ModulationType::NonSeasonal,
224 modulation_score: 0.0,
225 amplitude_trend: 0.0,
226 strength_curve: Vec::new(),
227 time_points: argvals.to_vec(),
228 min_strength: 0.0,
229 max_strength: 0.0,
230 };
231 }
232
233 let mean_curve = compute_mean_curve(data);
235
236 let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
238 let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
239 let analytic = hilbert_transform(&detrended);
240 let envelope: Vec<f64> = analytic.iter().map(|c| c.norm()).collect();
241
242 if envelope.is_empty() {
243 return AmplitudeModulationResult {
244 is_seasonal: true,
245 seasonal_strength: overall_strength,
246 has_modulation: false,
247 modulation_type: ModulationType::Stable,
248 modulation_score: 0.0,
249 amplitude_trend: 0.0,
250 strength_curve: Vec::new(),
251 time_points: argvals.to_vec(),
252 min_strength: 0.0,
253 max_strength: 0.0,
254 };
255 }
256
257 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
259 let smooth_window = ((period / dt) as usize).max(3);
260 let half_window = smooth_window / 2;
261
262 let smoothed_envelope: Vec<f64> = (0..m)
263 .map(|i| {
264 let start = i.saturating_sub(half_window);
265 let end = (i + half_window + 1).min(m);
266 let sum: f64 = envelope[start..end].iter().sum();
267 sum / (end - start) as f64
268 })
269 .collect();
270
271 let Some((interior_start, interior_end)) = valid_interior_bounds(m, 4) else {
273 return AmplitudeModulationResult {
274 is_seasonal: true,
275 seasonal_strength: overall_strength,
276 has_modulation: false,
277 modulation_type: ModulationType::Stable,
278 modulation_score: 0.0,
279 amplitude_trend: 0.0,
280 strength_curve: envelope,
281 time_points: argvals.to_vec(),
282 min_strength: 0.0,
283 max_strength: 0.0,
284 };
285 };
286
287 let stats = analyze_amplitude_envelope(
288 &smoothed_envelope[interior_start..interior_end],
289 &argvals[interior_start..interior_end],
290 modulation_threshold,
291 );
292
293 AmplitudeModulationResult {
294 is_seasonal: true,
295 seasonal_strength: overall_strength,
296 has_modulation: stats.has_modulation,
297 modulation_type: stats.modulation_type,
298 modulation_score: stats.modulation_score,
299 amplitude_trend: stats.amplitude_trend,
300 strength_curve: envelope,
301 time_points: argvals.to_vec(),
302 min_strength: stats.min_amp,
303 max_strength: stats.max_amp,
304 }
305}
306
307pub fn detect_amplitude_modulation_wavelet(
323 data: &FdMatrix,
324 argvals: &[f64],
325 period: f64,
326 modulation_threshold: f64,
327 seasonality_threshold: f64,
328) -> WaveletAmplitudeResult {
329 let (n, m) = data.shape();
330 let empty_result = WaveletAmplitudeResult {
331 is_seasonal: false,
332 seasonal_strength: 0.0,
333 has_modulation: false,
334 modulation_type: ModulationType::NonSeasonal,
335 modulation_score: 0.0,
336 amplitude_trend: 0.0,
337 wavelet_amplitude: Vec::new(),
338 time_points: Vec::new(),
339 scale: 0.0,
340 };
341
342 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
343 return empty_result;
344 }
345
346 let overall_strength = seasonal_strength_spectral(data, argvals, period);
348
349 if overall_strength < seasonality_threshold {
350 return WaveletAmplitudeResult {
351 is_seasonal: false,
352 seasonal_strength: overall_strength,
353 has_modulation: false,
354 modulation_type: ModulationType::NonSeasonal,
355 modulation_score: 0.0,
356 amplitude_trend: 0.0,
357 wavelet_amplitude: Vec::new(),
358 time_points: argvals.to_vec(),
359 scale: 0.0,
360 };
361 }
362
363 let mean_curve = compute_mean_curve(data);
365
366 let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
368 let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
369
370 let omega0 = 6.0; let scale = period * omega0 / (2.0 * PI);
373
374 let wavelet_coeffs = cwt_morlet_fft(&detrended, argvals, scale, omega0);
376
377 if wavelet_coeffs.is_empty() {
378 return WaveletAmplitudeResult {
379 is_seasonal: true,
380 seasonal_strength: overall_strength,
381 has_modulation: false,
382 modulation_type: ModulationType::Stable,
383 modulation_score: 0.0,
384 amplitude_trend: 0.0,
385 wavelet_amplitude: Vec::new(),
386 time_points: argvals.to_vec(),
387 scale,
388 };
389 }
390
391 let wavelet_amplitude: Vec<f64> = wavelet_coeffs.iter().map(|c| c.norm()).collect();
393
394 let Some((interior_start, interior_end)) = valid_interior_bounds(m, 4) else {
396 return WaveletAmplitudeResult {
397 is_seasonal: true,
398 seasonal_strength: overall_strength,
399 has_modulation: false,
400 modulation_type: ModulationType::Stable,
401 modulation_score: 0.0,
402 amplitude_trend: 0.0,
403 wavelet_amplitude,
404 time_points: argvals.to_vec(),
405 scale,
406 };
407 };
408
409 let stats = analyze_amplitude_envelope(
410 &wavelet_amplitude[interior_start..interior_end],
411 &argvals[interior_start..interior_end],
412 modulation_threshold,
413 );
414
415 WaveletAmplitudeResult {
416 is_seasonal: true,
417 seasonal_strength: overall_strength,
418 has_modulation: stats.has_modulation,
419 modulation_type: stats.modulation_type,
420 modulation_score: stats.modulation_score,
421 amplitude_trend: stats.amplitude_trend,
422 wavelet_amplitude,
423 time_points: argvals.to_vec(),
424 scale,
425 }
426}
427
428pub fn analyze_peak_timing(
443 data: &FdMatrix,
444 argvals: &[f64],
445 period: f64,
446 smooth_nbasis: Option<usize>,
447) -> PeakTimingResult {
448 let (n, m) = data.shape();
449 if n == 0 || m < 3 || argvals.len() != m || period <= 0.0 {
450 return PeakTimingResult {
451 peak_times: Vec::new(),
452 peak_values: Vec::new(),
453 normalized_timing: Vec::new(),
454 mean_timing: f64::NAN,
455 std_timing: f64::NAN,
456 range_timing: f64::NAN,
457 variability_score: f64::NAN,
458 timing_trend: f64::NAN,
459 cycle_indices: Vec::new(),
460 };
461 }
462
463 let min_distance = period * 0.7;
466 let peaks = detect_peaks(
467 data,
468 argvals,
469 Some(min_distance),
470 None, true, smooth_nbasis,
473 );
474
475 let sample_peaks = if peaks.peaks.is_empty() {
477 Vec::new()
478 } else {
479 peaks.peaks[0].clone()
480 };
481
482 if sample_peaks.is_empty() {
483 return PeakTimingResult {
484 peak_times: Vec::new(),
485 peak_values: Vec::new(),
486 normalized_timing: Vec::new(),
487 mean_timing: f64::NAN,
488 std_timing: f64::NAN,
489 range_timing: f64::NAN,
490 variability_score: f64::NAN,
491 timing_trend: f64::NAN,
492 cycle_indices: Vec::new(),
493 };
494 }
495
496 let peak_times: Vec<f64> = sample_peaks.iter().map(|p| p.time).collect();
497 let peak_values: Vec<f64> = sample_peaks.iter().map(|p| p.value).collect();
498
499 let t_start = argvals[0];
501 let normalized_timing: Vec<f64> = peak_times
502 .iter()
503 .map(|&t| {
504 let cycle_pos = (t - t_start) % period;
505 cycle_pos / period
506 })
507 .collect();
508
509 let cycle_indices: Vec<usize> = peak_times
511 .iter()
512 .map(|&t| crate::utility::f64_to_usize_clamped(((t - t_start) / period).floor()) + 1)
513 .collect();
514
515 let n_peaks = normalized_timing.len() as f64;
517 let mean_timing = normalized_timing.iter().sum::<f64>() / n_peaks;
518
519 let variance: f64 = normalized_timing
520 .iter()
521 .map(|&x| (x - mean_timing).powi(2))
522 .sum::<f64>()
523 / n_peaks;
524 let std_timing = variance.sqrt();
525
526 let min_timing = normalized_timing
527 .iter()
528 .copied()
529 .fold(f64::INFINITY, f64::min);
530 let max_timing = normalized_timing
531 .iter()
532 .copied()
533 .fold(f64::NEG_INFINITY, f64::max);
534 let range_timing = max_timing - min_timing;
535
536 let variability_score = (std_timing / 0.1).min(1.0);
538
539 let cycle_idx_f64: Vec<f64> = cycle_indices.iter().map(|&i| i as f64).collect();
541 let timing_trend = linear_slope(&cycle_idx_f64, &normalized_timing);
542
543 PeakTimingResult {
544 peak_times,
545 peak_values,
546 normalized_timing,
547 mean_timing,
548 std_timing,
549 range_timing,
550 variability_score,
551 timing_trend,
552 cycle_indices,
553 }
554}
555
556pub fn classify_seasonality(
574 data: &FdMatrix,
575 argvals: &[f64],
576 period: f64,
577 strength_threshold: Option<f64>,
578 timing_threshold: Option<f64>,
579) -> SeasonalityClassification {
580 let strength_thresh = strength_threshold.unwrap_or(0.3);
581 let timing_thresh = timing_threshold.unwrap_or(0.05);
582
583 let (n, m) = data.shape();
584 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
585 return SeasonalityClassification {
586 is_seasonal: false,
587 has_stable_timing: false,
588 timing_variability: f64::NAN,
589 seasonal_strength: f64::NAN,
590 cycle_strengths: Vec::new(),
591 weak_seasons: Vec::new(),
592 classification: SeasonalType::NonSeasonal,
593 peak_timing: None,
594 };
595 }
596
597 let overall_strength = seasonal_strength_variance(data, argvals, period, 3);
599
600 let (cycle_strengths, weak_seasons) =
601 compute_cycle_strengths(data, argvals, period, strength_thresh);
602 let n_cycles = cycle_strengths.len();
603
604 let peak_timing = analyze_peak_timing(data, argvals, period, None);
606
607 let is_seasonal = overall_strength >= strength_thresh;
609 let has_stable_timing = peak_timing.std_timing <= timing_thresh;
610 let timing_variability = peak_timing.variability_score;
611
612 let n_weak = weak_seasons.len();
614 let classification = if !is_seasonal {
615 SeasonalType::NonSeasonal
616 } else if n_cycles > 0 && n_weak as f64 / n_cycles as f64 > 0.3 {
617 SeasonalType::IntermittentSeasonal
619 } else if !has_stable_timing {
620 SeasonalType::VariableTiming
621 } else {
622 SeasonalType::StableSeasonal
623 };
624
625 SeasonalityClassification {
626 is_seasonal,
627 has_stable_timing,
628 timing_variability,
629 seasonal_strength: overall_strength,
630 cycle_strengths,
631 weak_seasons,
632 classification,
633 peak_timing: Some(peak_timing),
634 }
635}
636
637pub fn detect_seasonality_changes_auto(
649 data: &FdMatrix,
650 argvals: &[f64],
651 period: f64,
652 threshold_method: ThresholdMethod,
653 window_size: f64,
654 min_duration: f64,
655) -> ChangeDetectionResult {
656 let (n, m) = data.shape();
657 if n == 0 || m < 4 || argvals.len() != m {
658 return ChangeDetectionResult {
659 change_points: Vec::new(),
660 strength_curve: Vec::new(),
661 };
662 }
663
664 let strength_curve =
666 seasonal_strength_windowed(data, argvals, period, window_size, StrengthMethod::Variance);
667
668 if strength_curve.is_empty() {
669 return ChangeDetectionResult {
670 change_points: Vec::new(),
671 strength_curve: Vec::new(),
672 };
673 }
674
675 let threshold = match threshold_method {
677 ThresholdMethod::Fixed(t) => t,
678 ThresholdMethod::Percentile(p) => {
679 let mut sorted: Vec<f64> = strength_curve
680 .iter()
681 .copied()
682 .filter(|x| x.is_finite())
683 .collect();
684 crate::helpers::sort_nan_safe(&mut sorted);
685 if sorted.is_empty() {
686 0.5
687 } else {
688 let idx = crate::utility::f64_to_usize_clamped((p / 100.0) * sorted.len() as f64);
689 sorted[idx.min(sorted.len() - 1)]
690 }
691 }
692 ThresholdMethod::Otsu => otsu_threshold(&strength_curve),
693 };
694
695 detect_seasonality_changes(data, argvals, period, threshold, window_size, min_duration)
697}