1use crate::basis::fourier_basis_with_period;
13use crate::fdata::deriv_1d;
14use num_complex::Complex;
15use rayon::prelude::*;
16use rustfft::FftPlanner;
17use std::f64::consts::PI;
18
19#[derive(Debug, Clone)]
21pub struct PeriodEstimate {
22 pub period: f64,
24 pub frequency: f64,
26 pub power: f64,
28 pub confidence: f64,
30}
31
32#[derive(Debug, Clone)]
34pub struct Peak {
35 pub time: f64,
37 pub value: f64,
39 pub prominence: f64,
41}
42
43#[derive(Debug, Clone)]
45pub struct PeakDetectionResult {
46 pub peaks: Vec<Vec<Peak>>,
48 pub inter_peak_distances: Vec<Vec<f64>>,
50 pub mean_period: f64,
52}
53
54#[derive(Debug, Clone)]
56pub struct DetectedPeriod {
57 pub period: f64,
59 pub confidence: f64,
61 pub strength: f64,
63 pub amplitude: f64,
65 pub phase: f64,
67 pub iteration: usize,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum StrengthMethod {
74 Variance,
76 Spectral,
78}
79
80#[derive(Debug, Clone)]
82pub struct ChangePoint {
83 pub time: f64,
85 pub change_type: ChangeType,
87 pub strength_before: f64,
89 pub strength_after: f64,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum ChangeType {
96 Onset,
98 Cessation,
100}
101
102#[derive(Debug, Clone)]
104pub struct ChangeDetectionResult {
105 pub change_points: Vec<ChangePoint>,
107 pub strength_curve: Vec<f64>,
109}
110
111#[derive(Debug, Clone)]
113pub struct InstantaneousPeriod {
114 pub period: Vec<f64>,
116 pub frequency: Vec<f64>,
118 pub amplitude: Vec<f64>,
120}
121
122#[derive(Debug, Clone)]
124pub struct PeakTimingResult {
125 pub peak_times: Vec<f64>,
127 pub peak_values: Vec<f64>,
129 pub normalized_timing: Vec<f64>,
131 pub mean_timing: f64,
133 pub std_timing: f64,
135 pub range_timing: f64,
137 pub variability_score: f64,
139 pub timing_trend: f64,
141 pub cycle_indices: Vec<usize>,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum SeasonalType {
148 StableSeasonal,
150 VariableTiming,
152 IntermittentSeasonal,
154 NonSeasonal,
156}
157
158#[derive(Debug, Clone)]
160pub struct SeasonalityClassification {
161 pub is_seasonal: bool,
163 pub has_stable_timing: bool,
165 pub timing_variability: f64,
167 pub seasonal_strength: f64,
169 pub cycle_strengths: Vec<f64>,
171 pub weak_seasons: Vec<usize>,
173 pub classification: SeasonalType,
175 pub peak_timing: Option<PeakTimingResult>,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq)]
181pub enum ThresholdMethod {
182 Fixed(f64),
184 Percentile(f64),
186 Otsu,
188}
189
190fn periodogram(data: &[f64], argvals: &[f64]) -> (Vec<f64>, Vec<f64>) {
197 let n = data.len();
198 if n < 2 || argvals.len() != n {
199 return (Vec::new(), Vec::new());
200 }
201
202 let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
203 let fs = 1.0 / dt; let mut planner = FftPlanner::<f64>::new();
206 let fft = planner.plan_fft_forward(n);
207
208 let mut buffer: Vec<Complex<f64>> = data.iter().map(|&x| Complex::new(x, 0.0)).collect();
209 fft.process(&mut buffer);
210
211 let n_freq = n / 2 + 1;
213 let mut power = Vec::with_capacity(n_freq);
214 let mut frequencies = Vec::with_capacity(n_freq);
215
216 for k in 0..n_freq {
217 let freq = k as f64 * fs / n as f64;
218 frequencies.push(freq);
219
220 let p = buffer[k].norm_sqr() / (n as f64 * n as f64);
221 let p = if k > 0 && k < n / 2 { 2.0 * p } else { p };
223 power.push(p);
224 }
225
226 (frequencies, power)
227}
228
229fn autocorrelation(data: &[f64], max_lag: usize) -> Vec<f64> {
231 let n = data.len();
232 if n == 0 {
233 return Vec::new();
234 }
235
236 let mean: f64 = data.iter().sum::<f64>() / n as f64;
237 let var: f64 = data.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n as f64;
238
239 if var < 1e-15 {
240 return vec![1.0; max_lag.min(n) + 1];
241 }
242
243 let max_lag = max_lag.min(n - 1);
244 let mut acf = Vec::with_capacity(max_lag + 1);
245
246 for lag in 0..=max_lag {
247 let mut sum = 0.0;
248 for i in 0..(n - lag) {
249 sum += (data[i] - mean) * (data[i + lag] - mean);
250 }
251 acf.push(sum / (n as f64 * var));
252 }
253
254 acf
255}
256
257fn find_peaks_1d(signal: &[f64], min_distance: usize) -> Vec<usize> {
259 let n = signal.len();
260 if n < 3 {
261 return Vec::new();
262 }
263
264 let mut peaks = Vec::new();
265
266 for i in 1..(n - 1) {
267 if signal[i] > signal[i - 1] && signal[i] > signal[i + 1] {
268 if peaks.is_empty() || i - peaks[peaks.len() - 1] >= min_distance {
270 peaks.push(i);
271 } else if signal[i] > signal[peaks[peaks.len() - 1]] {
272 peaks.pop();
274 peaks.push(i);
275 }
276 }
277 }
278
279 peaks
280}
281
282fn compute_prominence(signal: &[f64], peak_idx: usize) -> f64 {
284 let n = signal.len();
285 let peak_val = signal[peak_idx];
286
287 let mut left_min = peak_val;
289 for i in (0..peak_idx).rev() {
290 if signal[i] >= peak_val {
291 break;
292 }
293 left_min = left_min.min(signal[i]);
294 }
295
296 let mut right_min = peak_val;
297 for i in (peak_idx + 1)..n {
298 if signal[i] >= peak_val {
299 break;
300 }
301 right_min = right_min.min(signal[i]);
302 }
303
304 peak_val - left_min.max(right_min)
305}
306
307fn hilbert_transform(signal: &[f64]) -> Vec<Complex<f64>> {
309 let n = signal.len();
310 if n == 0 {
311 return Vec::new();
312 }
313
314 let mut planner = FftPlanner::<f64>::new();
315 let fft_forward = planner.plan_fft_forward(n);
316 let fft_inverse = planner.plan_fft_inverse(n);
317
318 let mut buffer: Vec<Complex<f64>> = signal.iter().map(|&x| Complex::new(x, 0.0)).collect();
320 fft_forward.process(&mut buffer);
321
322 let half = n / 2;
325 for k in 1..half {
326 buffer[k] *= 2.0;
327 }
328 for k in (half + 1)..n {
329 buffer[k] = Complex::new(0.0, 0.0);
330 }
331
332 fft_inverse.process(&mut buffer);
334
335 for c in buffer.iter_mut() {
337 *c /= n as f64;
338 }
339
340 buffer
341}
342
343fn unwrap_phase(phase: &[f64]) -> Vec<f64> {
345 if phase.is_empty() {
346 return Vec::new();
347 }
348
349 let mut unwrapped = vec![phase[0]];
350 let mut cumulative_correction = 0.0;
351
352 for i in 1..phase.len() {
353 let diff = phase[i] - phase[i - 1];
354
355 if diff > PI {
357 cumulative_correction -= 2.0 * PI;
358 } else if diff < -PI {
359 cumulative_correction += 2.0 * PI;
360 }
361
362 unwrapped.push(phase[i] + cumulative_correction);
363 }
364
365 unwrapped
366}
367
368fn otsu_threshold(values: &[f64]) -> f64 {
373 if values.is_empty() {
374 return 0.5;
375 }
376
377 let valid: Vec<f64> = values.iter().copied().filter(|x| x.is_finite()).collect();
379 if valid.is_empty() {
380 return 0.5;
381 }
382
383 let min_val = valid.iter().cloned().fold(f64::INFINITY, f64::min);
384 let max_val = valid.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
385
386 if (max_val - min_val).abs() < 1e-10 {
387 return (min_val + max_val) / 2.0;
388 }
389
390 let n_bins = 256;
392 let bin_width = (max_val - min_val) / n_bins as f64;
393 let mut histogram = vec![0usize; n_bins];
394
395 for &v in &valid {
396 let bin = ((v - min_val) / bin_width).min(n_bins as f64 - 1.0) as usize;
397 histogram[bin] += 1;
398 }
399
400 let total = valid.len() as f64;
401 let mut sum_total = 0.0;
402 for (i, &count) in histogram.iter().enumerate() {
403 sum_total += i as f64 * count as f64;
404 }
405
406 let mut best_threshold = min_val;
407 let mut best_variance = 0.0;
408
409 let mut sum_b = 0.0;
410 let mut weight_b = 0.0;
411
412 for t in 0..n_bins {
413 weight_b += histogram[t] as f64;
414 if weight_b == 0.0 {
415 continue;
416 }
417
418 let weight_f = total - weight_b;
419 if weight_f == 0.0 {
420 break;
421 }
422
423 sum_b += t as f64 * histogram[t] as f64;
424
425 let mean_b = sum_b / weight_b;
426 let mean_f = (sum_total - sum_b) / weight_f;
427
428 let variance = weight_b * weight_f * (mean_b - mean_f).powi(2);
430
431 if variance > best_variance {
432 best_variance = variance;
433 best_threshold = min_val + (t as f64 + 0.5) * bin_width;
434 }
435 }
436
437 best_threshold
438}
439
440fn linear_slope(x: &[f64], y: &[f64]) -> f64 {
442 if x.len() != y.len() || x.len() < 2 {
443 return 0.0;
444 }
445
446 let n = x.len() as f64;
447 let mean_x: f64 = x.iter().sum::<f64>() / n;
448 let mean_y: f64 = y.iter().sum::<f64>() / n;
449
450 let mut num = 0.0;
451 let mut den = 0.0;
452
453 for (&xi, &yi) in x.iter().zip(y.iter()) {
454 num += (xi - mean_x) * (yi - mean_y);
455 den += (xi - mean_x).powi(2);
456 }
457
458 if den.abs() < 1e-15 {
459 0.0
460 } else {
461 num / den
462 }
463}
464
465pub fn estimate_period_fft(data: &[f64], n: usize, m: usize, argvals: &[f64]) -> PeriodEstimate {
483 if n == 0 || m < 4 || argvals.len() != m {
484 return PeriodEstimate {
485 period: f64::NAN,
486 frequency: f64::NAN,
487 power: 0.0,
488 confidence: 0.0,
489 };
490 }
491
492 let mean_curve: Vec<f64> = (0..m)
494 .map(|j| {
495 let mut sum = 0.0;
496 for i in 0..n {
497 sum += data[i + j * n];
498 }
499 sum / n as f64
500 })
501 .collect();
502
503 let (frequencies, power) = periodogram(&mean_curve, argvals);
504
505 if frequencies.len() < 2 {
506 return PeriodEstimate {
507 period: f64::NAN,
508 frequency: f64::NAN,
509 power: 0.0,
510 confidence: 0.0,
511 };
512 }
513
514 let mut max_power = 0.0;
516 let mut max_idx = 1;
517 for (i, &p) in power.iter().enumerate().skip(1) {
518 if p > max_power {
519 max_power = p;
520 max_idx = i;
521 }
522 }
523
524 let dominant_freq = frequencies[max_idx];
525 let period = if dominant_freq > 1e-15 {
526 1.0 / dominant_freq
527 } else {
528 f64::INFINITY
529 };
530
531 let mean_power: f64 = power.iter().skip(1).sum::<f64>() / (power.len() - 1) as f64;
533 let confidence = if mean_power > 1e-15 {
534 max_power / mean_power
535 } else {
536 0.0
537 };
538
539 PeriodEstimate {
540 period,
541 frequency: dominant_freq,
542 power: max_power,
543 confidence,
544 }
545}
546
547pub fn estimate_period_acf(
558 data: &[f64],
559 n: usize,
560 m: usize,
561 argvals: &[f64],
562 max_lag: usize,
563) -> PeriodEstimate {
564 if n == 0 || m < 4 || argvals.len() != m {
565 return PeriodEstimate {
566 period: f64::NAN,
567 frequency: f64::NAN,
568 power: 0.0,
569 confidence: 0.0,
570 };
571 }
572
573 let mean_curve: Vec<f64> = (0..m)
575 .map(|j| {
576 let mut sum = 0.0;
577 for i in 0..n {
578 sum += data[i + j * n];
579 }
580 sum / n as f64
581 })
582 .collect();
583
584 let acf = autocorrelation(&mean_curve, max_lag);
585
586 let min_lag = 2;
588 let peaks = find_peaks_1d(&acf[min_lag..], 1);
589
590 if peaks.is_empty() {
591 return PeriodEstimate {
592 period: f64::NAN,
593 frequency: f64::NAN,
594 power: 0.0,
595 confidence: 0.0,
596 };
597 }
598
599 let peak_lag = peaks[0] + min_lag;
600 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
601 let period = peak_lag as f64 * dt;
602 let frequency = if period > 1e-15 { 1.0 / period } else { 0.0 };
603
604 PeriodEstimate {
605 period,
606 frequency,
607 power: acf[peak_lag],
608 confidence: acf[peak_lag].abs(),
609 }
610}
611
612pub fn estimate_period_regression(
627 data: &[f64],
628 n: usize,
629 m: usize,
630 argvals: &[f64],
631 period_min: f64,
632 period_max: f64,
633 n_candidates: usize,
634 n_harmonics: usize,
635) -> PeriodEstimate {
636 if n == 0 || m < 4 || argvals.len() != m || period_min >= period_max || n_candidates < 2 {
637 return PeriodEstimate {
638 period: f64::NAN,
639 frequency: f64::NAN,
640 power: 0.0,
641 confidence: 0.0,
642 };
643 }
644
645 let mean_curve: Vec<f64> = (0..m)
647 .map(|j| {
648 let mut sum = 0.0;
649 for i in 0..n {
650 sum += data[i + j * n];
651 }
652 sum / n as f64
653 })
654 .collect();
655
656 let nbasis = 1 + 2 * n_harmonics;
657
658 let candidates: Vec<f64> = (0..n_candidates)
660 .map(|i| period_min + (period_max - period_min) * i as f64 / (n_candidates - 1) as f64)
661 .collect();
662
663 let results: Vec<(f64, f64)> = candidates
664 .par_iter()
665 .map(|&period| {
666 let basis = fourier_basis_with_period(argvals, nbasis, period);
667
668 let mut rss = 0.0;
670 for j in 0..m {
671 let mut fitted = 0.0;
672 for k in 0..nbasis {
674 let b_val = basis[j + k * m];
675 let coef: f64 = (0..m)
676 .map(|l| mean_curve[l] * basis[l + k * m])
677 .sum::<f64>()
678 / (0..m)
679 .map(|l| basis[l + k * m].powi(2))
680 .sum::<f64>()
681 .max(1e-15);
682 fitted += coef * b_val;
683 }
684 let resid = mean_curve[j] - fitted;
685 rss += resid * resid;
686 }
687
688 (period, rss)
689 })
690 .collect();
691
692 let (best_period, min_rss) = results
694 .iter()
695 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
696 .cloned()
697 .unwrap_or((f64::NAN, f64::INFINITY));
698
699 let mean_rss: f64 = results.iter().map(|(_, r)| r).sum::<f64>() / results.len() as f64;
701 let confidence = if min_rss > 1e-15 {
702 (mean_rss / min_rss).min(10.0)
703 } else {
704 10.0
705 };
706
707 PeriodEstimate {
708 period: best_period,
709 frequency: if best_period > 1e-15 {
710 1.0 / best_period
711 } else {
712 0.0
713 },
714 power: 1.0 - min_rss / mean_rss,
715 confidence,
716 }
717}
718
719pub fn detect_multiple_periods(
740 data: &[f64],
741 n: usize,
742 m: usize,
743 argvals: &[f64],
744 max_periods: usize,
745 min_confidence: f64,
746 min_strength: f64,
747) -> Vec<DetectedPeriod> {
748 if n == 0 || m < 4 || argvals.len() != m || max_periods == 0 {
749 return Vec::new();
750 }
751
752 let mean_curve: Vec<f64> = (0..m)
754 .map(|j| {
755 let mut sum = 0.0;
756 for i in 0..n {
757 sum += data[i + j * n];
758 }
759 sum / n as f64
760 })
761 .collect();
762
763 let mut residual = mean_curve.clone();
764 let mut detected = Vec::with_capacity(max_periods);
765
766 for iteration in 1..=max_periods {
767 let residual_data: Vec<f64> = residual.clone();
769
770 let est = estimate_period_fft(&residual_data, 1, m, argvals);
772
773 if est.confidence < min_confidence || est.period.is_nan() || est.period.is_infinite() {
774 break;
775 }
776
777 let strength = seasonal_strength_variance(&residual_data, 1, m, argvals, est.period, 3);
779
780 if strength < min_strength || strength.is_nan() {
781 break;
782 }
783
784 let omega = 2.0 * PI / est.period;
786 let mut cos_sum = 0.0;
787 let mut sin_sum = 0.0;
788
789 for (j, &t) in argvals.iter().enumerate() {
790 cos_sum += residual[j] * (omega * t).cos();
791 sin_sum += residual[j] * (omega * t).sin();
792 }
793
794 let a = 2.0 * cos_sum / m as f64; let b = 2.0 * sin_sum / m as f64; let amplitude = (a * a + b * b).sqrt();
797 let phase = b.atan2(a);
798
799 detected.push(DetectedPeriod {
800 period: est.period,
801 confidence: est.confidence,
802 strength,
803 amplitude,
804 phase,
805 iteration,
806 });
807
808 for (j, &t) in argvals.iter().enumerate() {
810 let fitted = a * (omega * t).cos() + b * (omega * t).sin();
811 residual[j] -= fitted;
812 }
813 }
814
815 detected
816}
817
818pub fn detect_peaks(
838 data: &[f64],
839 n: usize,
840 m: usize,
841 argvals: &[f64],
842 min_distance: Option<f64>,
843 min_prominence: Option<f64>,
844 smooth_first: bool,
845 smooth_nbasis: Option<usize>,
846) -> PeakDetectionResult {
847 if n == 0 || m < 3 || argvals.len() != m {
848 return PeakDetectionResult {
849 peaks: Vec::new(),
850 inter_peak_distances: Vec::new(),
851 mean_period: f64::NAN,
852 };
853 }
854
855 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
856 let min_dist_points = min_distance.map(|d| (d / dt).round() as usize).unwrap_or(1);
857
858 let work_data = if smooth_first {
860 let nbasis = smooth_nbasis
862 .unwrap_or_else(|| crate::basis::select_fourier_nbasis_gcv(data, n, m, argvals, 5, 25));
863
864 if let Some(result) = crate::basis::fourier_fit_1d(data, n, m, argvals, nbasis) {
866 result.fitted
867 } else {
868 data.to_vec()
869 }
870 } else {
871 data.to_vec()
872 };
873
874 let deriv1 = deriv_1d(&work_data, n, m, argvals, 1);
876
877 let data_range: f64 = {
879 let mut min_val = f64::INFINITY;
880 let mut max_val = f64::NEG_INFINITY;
881 for &v in work_data.iter() {
882 min_val = min_val.min(v);
883 max_val = max_val.max(v);
884 }
885 (max_val - min_val).max(1e-15)
886 };
887
888 let results: Vec<(Vec<Peak>, Vec<f64>)> = (0..n)
890 .into_par_iter()
891 .map(|i| {
892 let curve: Vec<f64> = (0..m).map(|j| work_data[i + j * n]).collect();
894 let d1: Vec<f64> = (0..m).map(|j| deriv1[i + j * n]).collect();
895
896 let mut peak_indices = Vec::new();
898 for j in 1..m {
899 if d1[j - 1] > 0.0 && d1[j] <= 0.0 {
900 let idx = if (d1[j - 1] - d1[j]).abs() > 1e-15 {
902 j - 1
903 } else {
904 j
905 };
906
907 if peak_indices.is_empty()
909 || idx - peak_indices[peak_indices.len() - 1] >= min_dist_points
910 {
911 peak_indices.push(idx);
912 }
913 }
914 }
915
916 let mut peaks: Vec<Peak> = peak_indices
918 .iter()
919 .map(|&idx| {
920 let prominence = compute_prominence(&curve, idx) / data_range;
921 Peak {
922 time: argvals[idx],
923 value: curve[idx],
924 prominence,
925 }
926 })
927 .collect();
928
929 if let Some(min_prom) = min_prominence {
931 peaks.retain(|p| p.prominence >= min_prom);
932 }
933
934 let distances: Vec<f64> = peaks.windows(2).map(|w| w[1].time - w[0].time).collect();
936
937 (peaks, distances)
938 })
939 .collect();
940
941 let peaks: Vec<Vec<Peak>> = results.iter().map(|(p, _)| p.clone()).collect();
942 let inter_peak_distances: Vec<Vec<f64>> = results.iter().map(|(_, d)| d.clone()).collect();
943
944 let all_distances: Vec<f64> = inter_peak_distances.iter().flatten().cloned().collect();
946 let mean_period = if all_distances.is_empty() {
947 f64::NAN
948 } else {
949 all_distances.iter().sum::<f64>() / all_distances.len() as f64
950 };
951
952 PeakDetectionResult {
953 peaks,
954 inter_peak_distances,
955 mean_period,
956 }
957}
958
959pub fn seasonal_strength_variance(
979 data: &[f64],
980 n: usize,
981 m: usize,
982 argvals: &[f64],
983 period: f64,
984 n_harmonics: usize,
985) -> f64 {
986 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
987 return f64::NAN;
988 }
989
990 let mean_curve: Vec<f64> = (0..m)
992 .map(|j| {
993 let mut sum = 0.0;
994 for i in 0..n {
995 sum += data[i + j * n];
996 }
997 sum / n as f64
998 })
999 .collect();
1000
1001 let global_mean: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1003 let total_var: f64 = mean_curve
1004 .iter()
1005 .map(|&x| (x - global_mean).powi(2))
1006 .sum::<f64>()
1007 / m as f64;
1008
1009 if total_var < 1e-15 {
1010 return 0.0;
1011 }
1012
1013 let nbasis = 1 + 2 * n_harmonics;
1015 let basis = fourier_basis_with_period(argvals, nbasis, period);
1016
1017 let mut seasonal = vec![0.0; m];
1019 for k in 1..nbasis {
1020 let b_sum: f64 = (0..m).map(|j| basis[j + k * m].powi(2)).sum();
1022 if b_sum > 1e-15 {
1023 let coef: f64 = (0..m)
1024 .map(|j| mean_curve[j] * basis[j + k * m])
1025 .sum::<f64>()
1026 / b_sum;
1027 for j in 0..m {
1028 seasonal[j] += coef * basis[j + k * m];
1029 }
1030 }
1031 }
1032
1033 let seasonal_mean: f64 = seasonal.iter().sum::<f64>() / m as f64;
1035 let seasonal_var: f64 = seasonal
1036 .iter()
1037 .map(|&x| (x - seasonal_mean).powi(2))
1038 .sum::<f64>()
1039 / m as f64;
1040
1041 (seasonal_var / total_var).min(1.0)
1042}
1043
1044pub fn seasonal_strength_spectral(
1055 data: &[f64],
1056 n: usize,
1057 m: usize,
1058 argvals: &[f64],
1059 period: f64,
1060) -> f64 {
1061 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1062 return f64::NAN;
1063 }
1064
1065 let mean_curve: Vec<f64> = (0..m)
1067 .map(|j| {
1068 let mut sum = 0.0;
1069 for i in 0..n {
1070 sum += data[i + j * n];
1071 }
1072 sum / n as f64
1073 })
1074 .collect();
1075
1076 let (frequencies, power) = periodogram(&mean_curve, argvals);
1077
1078 if frequencies.len() < 2 {
1079 return f64::NAN;
1080 }
1081
1082 let fundamental_freq = 1.0 / period;
1083
1084 let _freq_tol = fundamental_freq * 0.1; let mut seasonal_power = 0.0;
1087 let mut total_power = 0.0;
1088
1089 for (i, (&freq, &p)) in frequencies.iter().zip(power.iter()).enumerate() {
1090 if i == 0 {
1091 continue;
1092 } total_power += p;
1095
1096 let ratio = freq / fundamental_freq;
1098 let nearest_harmonic = ratio.round();
1099 if (ratio - nearest_harmonic).abs() < 0.1 && nearest_harmonic >= 1.0 {
1100 seasonal_power += p;
1101 }
1102 }
1103
1104 if total_power < 1e-15 {
1105 return 0.0;
1106 }
1107
1108 (seasonal_power / total_power).min(1.0)
1109}
1110
1111pub fn seasonal_strength_windowed(
1125 data: &[f64],
1126 n: usize,
1127 m: usize,
1128 argvals: &[f64],
1129 period: f64,
1130 window_size: f64,
1131 method: StrengthMethod,
1132) -> Vec<f64> {
1133 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 || window_size <= 0.0 {
1134 return Vec::new();
1135 }
1136
1137 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1138 let half_window_points = ((window_size / 2.0) / dt).round() as usize;
1139
1140 let mean_curve: Vec<f64> = (0..m)
1142 .map(|j| {
1143 let mut sum = 0.0;
1144 for i in 0..n {
1145 sum += data[i + j * n];
1146 }
1147 sum / n as f64
1148 })
1149 .collect();
1150
1151 (0..m)
1152 .into_par_iter()
1153 .map(|center| {
1154 let start = center.saturating_sub(half_window_points);
1155 let end = (center + half_window_points + 1).min(m);
1156 let window_m = end - start;
1157
1158 if window_m < 4 {
1159 return f64::NAN;
1160 }
1161
1162 let window_data: Vec<f64> = mean_curve[start..end].to_vec();
1163 let window_argvals: Vec<f64> = argvals[start..end].to_vec();
1164
1165 let single_data = window_data.clone();
1167
1168 match method {
1169 StrengthMethod::Variance => seasonal_strength_variance(
1170 &single_data,
1171 1,
1172 window_m,
1173 &window_argvals,
1174 period,
1175 3,
1176 ),
1177 StrengthMethod::Spectral => {
1178 seasonal_strength_spectral(&single_data, 1, window_m, &window_argvals, period)
1179 }
1180 }
1181 })
1182 .collect()
1183}
1184
1185pub fn detect_seasonality_changes(
1204 data: &[f64],
1205 n: usize,
1206 m: usize,
1207 argvals: &[f64],
1208 period: f64,
1209 threshold: f64,
1210 window_size: f64,
1211 min_duration: f64,
1212) -> ChangeDetectionResult {
1213 if n == 0 || m < 4 || argvals.len() != m {
1214 return ChangeDetectionResult {
1215 change_points: Vec::new(),
1216 strength_curve: Vec::new(),
1217 };
1218 }
1219
1220 let strength_curve = seasonal_strength_windowed(
1222 data,
1223 n,
1224 m,
1225 argvals,
1226 period,
1227 window_size,
1228 StrengthMethod::Variance,
1229 );
1230
1231 if strength_curve.is_empty() {
1232 return ChangeDetectionResult {
1233 change_points: Vec::new(),
1234 strength_curve: Vec::new(),
1235 };
1236 }
1237
1238 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1239 let min_dur_points = (min_duration / dt).round() as usize;
1240
1241 let mut change_points = Vec::new();
1243 let mut in_seasonal = strength_curve[0] > threshold;
1244 let mut last_change_idx: Option<usize> = None;
1245
1246 for (i, &ss) in strength_curve.iter().enumerate().skip(1) {
1247 if ss.is_nan() {
1248 continue;
1249 }
1250
1251 let now_seasonal = ss > threshold;
1252
1253 if now_seasonal != in_seasonal {
1254 if let Some(last_idx) = last_change_idx {
1256 if i - last_idx < min_dur_points {
1257 continue; }
1259 }
1260
1261 let change_type = if now_seasonal {
1262 ChangeType::Onset
1263 } else {
1264 ChangeType::Cessation
1265 };
1266
1267 let strength_before = if i > 0 && !strength_curve[i - 1].is_nan() {
1268 strength_curve[i - 1]
1269 } else {
1270 ss
1271 };
1272
1273 change_points.push(ChangePoint {
1274 time: argvals[i],
1275 change_type,
1276 strength_before,
1277 strength_after: ss,
1278 });
1279
1280 in_seasonal = now_seasonal;
1281 last_change_idx = Some(i);
1282 }
1283 }
1284
1285 ChangeDetectionResult {
1286 change_points,
1287 strength_curve,
1288 }
1289}
1290
1291pub fn instantaneous_period(
1306 data: &[f64],
1307 n: usize,
1308 m: usize,
1309 argvals: &[f64],
1310) -> InstantaneousPeriod {
1311 if n == 0 || m < 4 || argvals.len() != m {
1312 return InstantaneousPeriod {
1313 period: Vec::new(),
1314 frequency: Vec::new(),
1315 amplitude: Vec::new(),
1316 };
1317 }
1318
1319 let mean_curve: Vec<f64> = (0..m)
1321 .map(|j| {
1322 let mut sum = 0.0;
1323 for i in 0..n {
1324 sum += data[i + j * n];
1325 }
1326 sum / n as f64
1327 })
1328 .collect();
1329
1330 let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1332 let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
1333
1334 let analytic = hilbert_transform(&detrended);
1336
1337 let amplitude: Vec<f64> = analytic.iter().map(|c| c.norm()).collect();
1339
1340 let phase: Vec<f64> = analytic.iter().map(|c| c.im.atan2(c.re)).collect();
1341
1342 let unwrapped_phase = unwrap_phase(&phase);
1344
1345 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1347 let mut inst_freq = vec![0.0; m];
1348
1349 if m > 1 {
1351 inst_freq[0] = (unwrapped_phase[1] - unwrapped_phase[0]) / dt / (2.0 * PI);
1352 }
1353 for j in 1..(m - 1) {
1354 inst_freq[j] = (unwrapped_phase[j + 1] - unwrapped_phase[j - 1]) / (2.0 * dt) / (2.0 * PI);
1355 }
1356 if m > 1 {
1357 inst_freq[m - 1] = (unwrapped_phase[m - 1] - unwrapped_phase[m - 2]) / dt / (2.0 * PI);
1358 }
1359
1360 let period: Vec<f64> = inst_freq
1362 .iter()
1363 .map(|&f| {
1364 if f.abs() > 1e-10 {
1365 (1.0 / f).abs()
1366 } else {
1367 f64::INFINITY
1368 }
1369 })
1370 .collect();
1371
1372 InstantaneousPeriod {
1373 period,
1374 frequency: inst_freq,
1375 amplitude,
1376 }
1377}
1378
1379pub fn analyze_peak_timing(
1400 data: &[f64],
1401 n: usize,
1402 m: usize,
1403 argvals: &[f64],
1404 period: f64,
1405 smooth_nbasis: Option<usize>,
1406) -> PeakTimingResult {
1407 if n == 0 || m < 3 || argvals.len() != m || period <= 0.0 {
1408 return PeakTimingResult {
1409 peak_times: Vec::new(),
1410 peak_values: Vec::new(),
1411 normalized_timing: Vec::new(),
1412 mean_timing: f64::NAN,
1413 std_timing: f64::NAN,
1414 range_timing: f64::NAN,
1415 variability_score: f64::NAN,
1416 timing_trend: f64::NAN,
1417 cycle_indices: Vec::new(),
1418 };
1419 }
1420
1421 let min_distance = period * 0.7;
1424 let peaks = detect_peaks(
1425 data,
1426 n,
1427 m,
1428 argvals,
1429 Some(min_distance),
1430 None, true, smooth_nbasis,
1433 );
1434
1435 let sample_peaks = if peaks.peaks.is_empty() {
1438 Vec::new()
1439 } else {
1440 peaks.peaks[0].clone()
1441 };
1442
1443 if sample_peaks.is_empty() {
1444 return PeakTimingResult {
1445 peak_times: Vec::new(),
1446 peak_values: Vec::new(),
1447 normalized_timing: Vec::new(),
1448 mean_timing: f64::NAN,
1449 std_timing: f64::NAN,
1450 range_timing: f64::NAN,
1451 variability_score: f64::NAN,
1452 timing_trend: f64::NAN,
1453 cycle_indices: Vec::new(),
1454 };
1455 }
1456
1457 let peak_times: Vec<f64> = sample_peaks.iter().map(|p| p.time).collect();
1458 let peak_values: Vec<f64> = sample_peaks.iter().map(|p| p.value).collect();
1459
1460 let t_start = argvals[0];
1462 let normalized_timing: Vec<f64> = peak_times
1463 .iter()
1464 .map(|&t| {
1465 let cycle_pos = (t - t_start) % period;
1466 cycle_pos / period
1467 })
1468 .collect();
1469
1470 let cycle_indices: Vec<usize> = peak_times
1472 .iter()
1473 .map(|&t| ((t - t_start) / period).floor() as usize + 1)
1474 .collect();
1475
1476 let n_peaks = normalized_timing.len() as f64;
1478 let mean_timing = normalized_timing.iter().sum::<f64>() / n_peaks;
1479
1480 let variance: f64 = normalized_timing
1481 .iter()
1482 .map(|&x| (x - mean_timing).powi(2))
1483 .sum::<f64>()
1484 / n_peaks;
1485 let std_timing = variance.sqrt();
1486
1487 let min_timing = normalized_timing
1488 .iter()
1489 .cloned()
1490 .fold(f64::INFINITY, f64::min);
1491 let max_timing = normalized_timing
1492 .iter()
1493 .cloned()
1494 .fold(f64::NEG_INFINITY, f64::max);
1495 let range_timing = max_timing - min_timing;
1496
1497 let variability_score = (std_timing / 0.1).min(1.0);
1501
1502 let cycle_idx_f64: Vec<f64> = cycle_indices.iter().map(|&i| i as f64).collect();
1504 let timing_trend = linear_slope(&cycle_idx_f64, &normalized_timing);
1505
1506 PeakTimingResult {
1507 peak_times,
1508 peak_values,
1509 normalized_timing,
1510 mean_timing,
1511 std_timing,
1512 range_timing,
1513 variability_score,
1514 timing_trend,
1515 cycle_indices,
1516 }
1517}
1518
1519pub fn classify_seasonality(
1543 data: &[f64],
1544 n: usize,
1545 m: usize,
1546 argvals: &[f64],
1547 period: f64,
1548 strength_threshold: Option<f64>,
1549 timing_threshold: Option<f64>,
1550) -> SeasonalityClassification {
1551 let strength_thresh = strength_threshold.unwrap_or(0.3);
1552 let timing_thresh = timing_threshold.unwrap_or(0.05);
1553
1554 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1555 return SeasonalityClassification {
1556 is_seasonal: false,
1557 has_stable_timing: false,
1558 timing_variability: f64::NAN,
1559 seasonal_strength: f64::NAN,
1560 cycle_strengths: Vec::new(),
1561 weak_seasons: Vec::new(),
1562 classification: SeasonalType::NonSeasonal,
1563 peak_timing: None,
1564 };
1565 }
1566
1567 let overall_strength = seasonal_strength_variance(data, n, m, argvals, period, 3);
1569
1570 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1572 let _points_per_cycle = (period / dt).round() as usize;
1573 let t_start = argvals[0];
1574 let t_end = argvals[m - 1];
1575 let n_cycles = ((t_end - t_start) / period).floor() as usize;
1576
1577 let mut cycle_strengths = Vec::with_capacity(n_cycles);
1578 let mut weak_seasons = Vec::new();
1579
1580 for cycle in 0..n_cycles {
1581 let cycle_start = t_start + cycle as f64 * period;
1582 let cycle_end = cycle_start + period;
1583
1584 let start_idx = argvals.iter().position(|&t| t >= cycle_start).unwrap_or(0);
1586 let end_idx = argvals.iter().position(|&t| t > cycle_end).unwrap_or(m);
1587
1588 let cycle_m = end_idx - start_idx;
1589 if cycle_m < 4 {
1590 cycle_strengths.push(f64::NAN);
1591 continue;
1592 }
1593
1594 let cycle_data: Vec<f64> = (start_idx..end_idx)
1596 .flat_map(|j| (0..n).map(move |i| data[i + j * n]))
1597 .collect();
1598 let cycle_argvals: Vec<f64> = argvals[start_idx..end_idx].to_vec();
1599
1600 let strength =
1601 seasonal_strength_variance(&cycle_data, n, cycle_m, &cycle_argvals, period, 3);
1602
1603 cycle_strengths.push(strength);
1604
1605 if strength < strength_thresh {
1606 weak_seasons.push(cycle);
1607 }
1608 }
1609
1610 let peak_timing = analyze_peak_timing(data, n, m, argvals, period, None);
1612
1613 let is_seasonal = overall_strength >= strength_thresh;
1615 let has_stable_timing = peak_timing.std_timing <= timing_thresh;
1616 let timing_variability = peak_timing.variability_score;
1617
1618 let n_weak = weak_seasons.len();
1620 let classification = if !is_seasonal {
1621 SeasonalType::NonSeasonal
1622 } else if n_cycles > 0 && n_weak as f64 / n_cycles as f64 > 0.3 {
1623 SeasonalType::IntermittentSeasonal
1625 } else if !has_stable_timing {
1626 SeasonalType::VariableTiming
1627 } else {
1628 SeasonalType::StableSeasonal
1629 };
1630
1631 SeasonalityClassification {
1632 is_seasonal,
1633 has_stable_timing,
1634 timing_variability,
1635 seasonal_strength: overall_strength,
1636 cycle_strengths,
1637 weak_seasons,
1638 classification,
1639 peak_timing: Some(peak_timing),
1640 }
1641}
1642
1643pub fn detect_seasonality_changes_auto(
1657 data: &[f64],
1658 n: usize,
1659 m: usize,
1660 argvals: &[f64],
1661 period: f64,
1662 threshold_method: ThresholdMethod,
1663 window_size: f64,
1664 min_duration: f64,
1665) -> ChangeDetectionResult {
1666 if n == 0 || m < 4 || argvals.len() != m {
1667 return ChangeDetectionResult {
1668 change_points: Vec::new(),
1669 strength_curve: Vec::new(),
1670 };
1671 }
1672
1673 let strength_curve = seasonal_strength_windowed(
1675 data,
1676 n,
1677 m,
1678 argvals,
1679 period,
1680 window_size,
1681 StrengthMethod::Variance,
1682 );
1683
1684 if strength_curve.is_empty() {
1685 return ChangeDetectionResult {
1686 change_points: Vec::new(),
1687 strength_curve: Vec::new(),
1688 };
1689 }
1690
1691 let threshold = match threshold_method {
1693 ThresholdMethod::Fixed(t) => t,
1694 ThresholdMethod::Percentile(p) => {
1695 let mut sorted: Vec<f64> = strength_curve
1696 .iter()
1697 .copied()
1698 .filter(|x| x.is_finite())
1699 .collect();
1700 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1701 if sorted.is_empty() {
1702 0.5
1703 } else {
1704 let idx = ((p / 100.0) * sorted.len() as f64) as usize;
1705 sorted[idx.min(sorted.len() - 1)]
1706 }
1707 }
1708 ThresholdMethod::Otsu => otsu_threshold(&strength_curve),
1709 };
1710
1711 detect_seasonality_changes(
1713 data,
1714 n,
1715 m,
1716 argvals,
1717 period,
1718 threshold,
1719 window_size,
1720 min_duration,
1721 )
1722}
1723
1724#[cfg(test)]
1725mod tests {
1726 use super::*;
1727 use std::f64::consts::PI;
1728
1729 fn generate_sine(n: usize, m: usize, period: f64, argvals: &[f64]) -> Vec<f64> {
1730 let mut data = vec![0.0; n * m];
1731 for i in 0..n {
1732 for j in 0..m {
1733 data[i + j * n] = (2.0 * PI * argvals[j] / period).sin();
1734 }
1735 }
1736 data
1737 }
1738
1739 #[test]
1740 fn test_period_estimation_fft() {
1741 let m = 200;
1742 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
1743 let period = 2.0;
1744 let data = generate_sine(1, m, period, &argvals);
1745
1746 let estimate = estimate_period_fft(&data, 1, m, &argvals);
1747 assert!((estimate.period - period).abs() < 0.2);
1748 assert!(estimate.confidence > 1.0);
1749 }
1750
1751 #[test]
1752 fn test_peak_detection() {
1753 let m = 100;
1754 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
1755 let period = 2.0;
1756 let data = generate_sine(1, m, period, &argvals);
1757
1758 let result = detect_peaks(&data, 1, m, &argvals, Some(1.5), None, false, None);
1759
1760 assert!(!result.peaks[0].is_empty());
1762 assert!((result.mean_period - period).abs() < 0.3);
1763 }
1764
1765 #[test]
1766 fn test_peak_detection_known_sine() {
1767 let m = 200; let period = 2.0;
1771 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
1772 let data: Vec<f64> = argvals
1773 .iter()
1774 .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
1775 .collect();
1776
1777 let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
1778
1779 assert_eq!(
1781 result.peaks[0].len(),
1782 5,
1783 "Expected 5 peaks, got {}. Peak times: {:?}",
1784 result.peaks[0].len(),
1785 result.peaks[0].iter().map(|p| p.time).collect::<Vec<_>>()
1786 );
1787
1788 let expected_times = [0.5, 2.5, 4.5, 6.5, 8.5];
1790 for (peak, expected) in result.peaks[0].iter().zip(expected_times.iter()) {
1791 assert!(
1792 (peak.time - expected).abs() < 0.15,
1793 "Peak at {:.3} not close to expected {:.3}",
1794 peak.time,
1795 expected
1796 );
1797 }
1798
1799 assert!(
1801 (result.mean_period - period).abs() < 0.1,
1802 "Mean period {:.3} not close to expected {:.3}",
1803 result.mean_period,
1804 period
1805 );
1806 }
1807
1808 #[test]
1809 fn test_peak_detection_with_min_distance() {
1810 let m = 200;
1812 let period = 2.0;
1813 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
1814 let data: Vec<f64> = argvals
1815 .iter()
1816 .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
1817 .collect();
1818
1819 let result = detect_peaks(&data, 1, m, &argvals, Some(1.5), None, false, None);
1821 assert_eq!(
1822 result.peaks[0].len(),
1823 5,
1824 "With min_distance=1.5, expected 5 peaks, got {}",
1825 result.peaks[0].len()
1826 );
1827
1828 let result2 = detect_peaks(&data, 1, m, &argvals, Some(2.5), None, false, None);
1830 assert!(
1831 result2.peaks[0].len() < 5,
1832 "With min_distance=2.5, expected fewer than 5 peaks, got {}",
1833 result2.peaks[0].len()
1834 );
1835 }
1836
1837 #[test]
1838 fn test_peak_detection_period_1() {
1839 let m = 400; let period = 1.0;
1843 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
1844 let data: Vec<f64> = argvals
1845 .iter()
1846 .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
1847 .collect();
1848
1849 let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
1850
1851 assert_eq!(
1853 result.peaks[0].len(),
1854 10,
1855 "Expected 10 peaks, got {}",
1856 result.peaks[0].len()
1857 );
1858
1859 assert!(
1861 (result.mean_period - period).abs() < 0.1,
1862 "Mean period {:.3} not close to expected {:.3}",
1863 result.mean_period,
1864 period
1865 );
1866 }
1867
1868 #[test]
1869 fn test_peak_detection_shifted_sine() {
1870 let m = 200;
1873 let period = 2.0;
1874 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
1875 let data: Vec<f64> = argvals
1876 .iter()
1877 .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin() + 1.0)
1878 .collect();
1879
1880 let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
1881
1882 assert_eq!(
1884 result.peaks[0].len(),
1885 5,
1886 "Expected 5 peaks for shifted sine, got {}",
1887 result.peaks[0].len()
1888 );
1889
1890 for peak in &result.peaks[0] {
1892 assert!(
1893 (peak.value - 2.0).abs() < 0.05,
1894 "Peak value {:.3} not close to expected 2.0",
1895 peak.value
1896 );
1897 }
1898 }
1899
1900 #[test]
1901 fn test_peak_detection_prominence() {
1902 let m = 200;
1905 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
1906 let data: Vec<f64> = argvals
1907 .iter()
1908 .map(|&t| {
1909 let base = (2.0 * std::f64::consts::PI * t / 2.0).sin();
1910 let ripple = 0.1 * (2.0 * std::f64::consts::PI * t * 4.0).sin();
1912 base + ripple
1913 })
1914 .collect();
1915
1916 let result_no_filter = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
1918
1919 let result_filtered = detect_peaks(&data, 1, m, &argvals, None, Some(0.5), false, None);
1921
1922 assert!(
1924 result_filtered.peaks[0].len() <= result_no_filter.peaks[0].len(),
1925 "Prominence filter should reduce peak count"
1926 );
1927 }
1928
1929 #[test]
1930 fn test_peak_detection_different_amplitudes() {
1931 let m = 200;
1933 let period = 2.0;
1934 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
1935
1936 for amplitude in [0.5, 1.0, 2.0, 5.0] {
1937 let data: Vec<f64> = argvals
1938 .iter()
1939 .map(|&t| amplitude * (2.0 * std::f64::consts::PI * t / period).sin())
1940 .collect();
1941
1942 let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
1943
1944 assert_eq!(
1945 result.peaks[0].len(),
1946 5,
1947 "Amplitude {} should still find 5 peaks",
1948 amplitude
1949 );
1950
1951 for peak in &result.peaks[0] {
1953 assert!(
1954 (peak.value - amplitude).abs() < 0.1,
1955 "Peak value {:.3} should be close to amplitude {}",
1956 peak.value,
1957 amplitude
1958 );
1959 }
1960 }
1961 }
1962
1963 #[test]
1964 fn test_peak_detection_varying_frequency() {
1965 let m = 400;
1968 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
1969
1970 let data: Vec<f64> = argvals
1973 .iter()
1974 .map(|&t| {
1975 let phase = 2.0 * std::f64::consts::PI * (0.5 * t + 0.05 * t * t);
1976 phase.sin()
1977 })
1978 .collect();
1979
1980 let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
1981
1982 assert!(
1984 result.peaks[0].len() >= 5,
1985 "Should find at least 5 peaks, got {}",
1986 result.peaks[0].len()
1987 );
1988
1989 let distances = &result.inter_peak_distances[0];
1991 if distances.len() >= 3 {
1992 let early_avg = (distances[0] + distances[1]) / 2.0;
1994 let late_avg = (distances[distances.len() - 2] + distances[distances.len() - 1]) / 2.0;
1995 assert!(
1996 late_avg < early_avg,
1997 "Later peaks should be closer: early avg={:.3}, late avg={:.3}",
1998 early_avg,
1999 late_avg
2000 );
2001 }
2002 }
2003
2004 #[test]
2005 fn test_peak_detection_sum_of_sines() {
2006 let m = 300;
2009 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 12.0 / (m - 1) as f64).collect();
2010
2011 let data: Vec<f64> = argvals
2012 .iter()
2013 .map(|&t| {
2014 (2.0 * std::f64::consts::PI * t / 2.0).sin()
2015 + 0.5 * (2.0 * std::f64::consts::PI * t / 3.0).sin()
2016 })
2017 .collect();
2018
2019 let result = detect_peaks(&data, 1, m, &argvals, Some(1.0), None, false, None);
2020
2021 assert!(
2023 result.peaks[0].len() >= 4,
2024 "Should find at least 4 peaks, got {}",
2025 result.peaks[0].len()
2026 );
2027
2028 let distances = &result.inter_peak_distances[0];
2030 if distances.len() >= 2 {
2031 let min_dist = distances.iter().cloned().fold(f64::INFINITY, f64::min);
2032 let max_dist = distances.iter().cloned().fold(0.0, f64::max);
2033 assert!(
2034 max_dist > min_dist * 1.1,
2035 "Distances should vary: min={:.3}, max={:.3}",
2036 min_dist,
2037 max_dist
2038 );
2039 }
2040 }
2041
2042 #[test]
2043 fn test_seasonal_strength() {
2044 let m = 200;
2045 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
2046 let period = 2.0;
2047 let data = generate_sine(1, m, period, &argvals);
2048
2049 let strength = seasonal_strength_variance(&data, 1, m, &argvals, period, 3);
2050 assert!(strength > 0.8);
2052
2053 let strength_spectral = seasonal_strength_spectral(&data, 1, m, &argvals, period);
2054 assert!(strength_spectral > 0.5);
2055 }
2056
2057 #[test]
2058 fn test_instantaneous_period() {
2059 let m = 200;
2060 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
2061 let period = 2.0;
2062 let data = generate_sine(1, m, period, &argvals);
2063
2064 let result = instantaneous_period(&data, 1, m, &argvals);
2065
2066 let mid_period = result.period[m / 2];
2068 assert!(
2069 (mid_period - period).abs() < 0.5,
2070 "Expected period ~{}, got {}",
2071 period,
2072 mid_period
2073 );
2074 }
2075
2076 #[test]
2077 fn test_peak_timing_analysis() {
2078 let m = 500;
2080 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.02).collect();
2081 let period = 2.0;
2082 let data = generate_sine(1, m, period, &argvals);
2083
2084 let result = analyze_peak_timing(&data, 1, m, &argvals, period, Some(11));
2085
2086 assert!(!result.peak_times.is_empty());
2088 assert!(result.mean_timing.is_finite());
2090 assert!(result.std_timing < 0.1 || result.std_timing.is_nan());
2092 }
2093
2094 #[test]
2095 fn test_seasonality_classification() {
2096 let m = 400;
2097 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
2098 let period = 2.0;
2099 let data = generate_sine(1, m, period, &argvals);
2100
2101 let result = classify_seasonality(&data, 1, m, &argvals, period, None, None);
2102
2103 assert!(result.is_seasonal);
2104 assert!(result.seasonal_strength > 0.5);
2105 assert!(matches!(
2106 result.classification,
2107 SeasonalType::StableSeasonal | SeasonalType::VariableTiming
2108 ));
2109 }
2110
2111 #[test]
2112 fn test_otsu_threshold() {
2113 let values = vec![
2115 0.1, 0.12, 0.15, 0.18, 0.11, 0.14, 0.7, 0.75, 0.8, 0.85, 0.9, 0.72,
2116 ];
2117
2118 let threshold = otsu_threshold(&values);
2119
2120 assert!(threshold >= 0.1, "Threshold {} should be >= 0.1", threshold);
2124 assert!(threshold <= 0.9, "Threshold {} should be <= 0.9", threshold);
2125 }
2126
2127 #[test]
2128 fn test_gcv_fourier_nbasis_selection() {
2129 let m = 100;
2130 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
2131
2132 let mut data = vec![0.0; m];
2134 for j in 0..m {
2135 data[j] = (2.0 * PI * argvals[j] / 2.0).sin() + 0.1 * (j as f64 * 0.3).sin();
2136 }
2137
2138 let nbasis = crate::basis::select_fourier_nbasis_gcv(&data, 1, m, &argvals, 5, 25);
2139
2140 assert!(nbasis >= 5);
2142 assert!(nbasis <= 25);
2143 }
2144
2145 #[test]
2146 fn test_detect_multiple_periods() {
2147 let m = 400;
2148 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect(); let period1 = 2.0;
2152 let period2 = 7.0;
2153 let mut data = vec![0.0; m];
2154 for j in 0..m {
2155 data[j] = (2.0 * PI * argvals[j] / period1).sin()
2156 + 0.6 * (2.0 * PI * argvals[j] / period2).sin();
2157 }
2158
2159 let detected = detect_multiple_periods(&data, 1, m, &argvals, 5, 0.4, 0.20);
2161
2162 assert!(
2164 detected.len() >= 2,
2165 "Expected at least 2 periods, found {}",
2166 detected.len()
2167 );
2168
2169 let periods: Vec<f64> = detected.iter().map(|d| d.period).collect();
2171 let has_period1 = periods.iter().any(|&p| (p - period1).abs() < 0.3);
2172 let has_period2 = periods.iter().any(|&p| (p - period2).abs() < 0.5);
2173
2174 assert!(
2175 has_period1,
2176 "Expected to find period ~{}, got {:?}",
2177 period1, periods
2178 );
2179 assert!(
2180 has_period2,
2181 "Expected to find period ~{}, got {:?}",
2182 period2, periods
2183 );
2184
2185 assert!(
2187 detected[0].amplitude > detected[1].amplitude,
2188 "First detected should have higher amplitude"
2189 );
2190
2191 for d in &detected {
2193 assert!(
2194 d.strength > 0.0,
2195 "Detected period should have positive strength"
2196 );
2197 assert!(
2198 d.confidence > 0.0,
2199 "Detected period should have positive confidence"
2200 );
2201 assert!(
2202 d.amplitude > 0.0,
2203 "Detected period should have positive amplitude"
2204 );
2205 }
2206 }
2207}