1use crate::basis::fourier_basis_with_period;
13use crate::fdata::deriv_1d;
14use crate::matrix::FdMatrix;
15use crate::{iter_maybe_parallel, slice_maybe_parallel};
16use num_complex::Complex;
17#[cfg(feature = "parallel")]
18use rayon::iter::ParallelIterator;
19use rustfft::FftPlanner;
20use std::f64::consts::PI;
21
22#[derive(Debug, Clone)]
24pub struct PeriodEstimate {
25 pub period: f64,
27 pub frequency: f64,
29 pub power: f64,
31 pub confidence: f64,
33}
34
35#[derive(Debug, Clone)]
37pub struct Peak {
38 pub time: f64,
40 pub value: f64,
42 pub prominence: f64,
44}
45
46#[derive(Debug, Clone)]
48pub struct PeakDetectionResult {
49 pub peaks: Vec<Vec<Peak>>,
51 pub inter_peak_distances: Vec<Vec<f64>>,
53 pub mean_period: f64,
55}
56
57#[derive(Debug, Clone)]
59pub struct DetectedPeriod {
60 pub period: f64,
62 pub confidence: f64,
64 pub strength: f64,
66 pub amplitude: f64,
68 pub phase: f64,
70 pub iteration: usize,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum StrengthMethod {
77 Variance,
79 Spectral,
81}
82
83#[derive(Debug, Clone)]
85pub struct ChangePoint {
86 pub time: f64,
88 pub change_type: ChangeType,
90 pub strength_before: f64,
92 pub strength_after: f64,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum ChangeType {
99 Onset,
101 Cessation,
103}
104
105#[derive(Debug, Clone)]
107pub struct ChangeDetectionResult {
108 pub change_points: Vec<ChangePoint>,
110 pub strength_curve: Vec<f64>,
112}
113
114#[derive(Debug, Clone)]
116pub struct InstantaneousPeriod {
117 pub period: Vec<f64>,
119 pub frequency: Vec<f64>,
121 pub amplitude: Vec<f64>,
123}
124
125#[derive(Debug, Clone)]
127pub struct PeakTimingResult {
128 pub peak_times: Vec<f64>,
130 pub peak_values: Vec<f64>,
132 pub normalized_timing: Vec<f64>,
134 pub mean_timing: f64,
136 pub std_timing: f64,
138 pub range_timing: f64,
140 pub variability_score: f64,
142 pub timing_trend: f64,
144 pub cycle_indices: Vec<usize>,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum SeasonalType {
151 StableSeasonal,
153 VariableTiming,
155 IntermittentSeasonal,
157 NonSeasonal,
159}
160
161#[derive(Debug, Clone)]
163pub struct SeasonalityClassification {
164 pub is_seasonal: bool,
166 pub has_stable_timing: bool,
168 pub timing_variability: f64,
170 pub seasonal_strength: f64,
172 pub cycle_strengths: Vec<f64>,
174 pub weak_seasons: Vec<usize>,
176 pub classification: SeasonalType,
178 pub peak_timing: Option<PeakTimingResult>,
180}
181
182#[derive(Debug, Clone, Copy, PartialEq)]
184pub enum ThresholdMethod {
185 Fixed(f64),
187 Percentile(f64),
189 Otsu,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum ModulationType {
196 Stable,
198 Emerging,
200 Fading,
202 Oscillating,
204 NonSeasonal,
206}
207
208#[derive(Debug, Clone)]
210pub struct AmplitudeModulationResult {
211 pub is_seasonal: bool,
213 pub seasonal_strength: f64,
215 pub has_modulation: bool,
217 pub modulation_type: ModulationType,
219 pub modulation_score: f64,
221 pub amplitude_trend: f64,
223 pub strength_curve: Vec<f64>,
225 pub time_points: Vec<f64>,
227 pub min_strength: f64,
229 pub max_strength: f64,
231}
232
233#[derive(Debug, Clone)]
235pub struct WaveletAmplitudeResult {
236 pub is_seasonal: bool,
238 pub seasonal_strength: f64,
240 pub has_modulation: bool,
242 pub modulation_type: ModulationType,
244 pub modulation_score: f64,
246 pub amplitude_trend: f64,
248 pub wavelet_amplitude: Vec<f64>,
250 pub time_points: Vec<f64>,
252 pub scale: f64,
254}
255
256#[inline]
269fn compute_mean_curve_impl(data: &FdMatrix, parallel: bool) -> Vec<f64> {
270 let (n, m) = data.shape();
271 if parallel && m >= 100 {
272 iter_maybe_parallel!(0..m)
274 .map(|j| {
275 let mut sum = 0.0;
276 for i in 0..n {
277 sum += data[(i, j)];
278 }
279 sum / n as f64
280 })
281 .collect()
282 } else {
283 (0..m)
285 .map(|j| {
286 let mut sum = 0.0;
287 for i in 0..n {
288 sum += data[(i, j)];
289 }
290 sum / n as f64
291 })
292 .collect()
293 }
294}
295
296#[inline]
298fn compute_mean_curve(data: &FdMatrix) -> Vec<f64> {
299 compute_mean_curve_impl(data, true)
300}
301
302#[inline]
312fn interior_bounds(m: usize) -> Option<(usize, usize)> {
313 let edge_skip = (m as f64 * 0.1) as usize;
314 let interior_start = edge_skip.min(m / 4);
315 let interior_end = m.saturating_sub(edge_skip).max(m * 3 / 4);
316
317 if interior_end <= interior_start {
318 None
319 } else {
320 Some((interior_start, interior_end))
321 }
322}
323
324fn valid_interior_bounds(m: usize, min_span: usize) -> Option<(usize, usize)> {
326 interior_bounds(m).filter(|&(s, e)| e > s + min_span)
327}
328
329fn periodogram(data: &[f64], argvals: &[f64]) -> (Vec<f64>, Vec<f64>) {
332 let n = data.len();
333 if n < 2 || argvals.len() != n {
334 return (Vec::new(), Vec::new());
335 }
336
337 let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
338 let fs = 1.0 / dt; let mut planner = FftPlanner::<f64>::new();
341 let fft = planner.plan_fft_forward(n);
342
343 let mut buffer: Vec<Complex<f64>> = data.iter().map(|&x| Complex::new(x, 0.0)).collect();
344 fft.process(&mut buffer);
345
346 let n_freq = n / 2 + 1;
348 let mut power = Vec::with_capacity(n_freq);
349 let mut frequencies = Vec::with_capacity(n_freq);
350
351 for k in 0..n_freq {
352 let freq = k as f64 * fs / n as f64;
353 frequencies.push(freq);
354
355 let p = buffer[k].norm_sqr() / (n as f64 * n as f64);
356 let p = if k > 0 && k < n / 2 { 2.0 * p } else { p };
358 power.push(p);
359 }
360
361 (frequencies, power)
362}
363
364fn autocorrelation_naive(data: &[f64], max_lag: usize, mean: f64, var: f64) -> Vec<f64> {
366 let n = data.len();
367 let max_lag = max_lag.min(n - 1);
368 let mut acf = Vec::with_capacity(max_lag + 1);
369 for lag in 0..=max_lag {
370 let mut sum = 0.0;
371 for i in 0..(n - lag) {
372 sum += (data[i] - mean) * (data[i + lag] - mean);
373 }
374 acf.push(sum / (n as f64 * var));
375 }
376 acf
377}
378
379fn autocorrelation(data: &[f64], max_lag: usize) -> Vec<f64> {
384 let n = data.len();
385 if n == 0 {
386 return Vec::new();
387 }
388
389 let mean: f64 = data.iter().sum::<f64>() / n as f64;
390 let var: f64 = data.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n as f64;
392
393 if var < 1e-15 {
394 return vec![1.0; max_lag.min(n) + 1];
395 }
396
397 let max_lag = max_lag.min(n - 1);
398
399 if n <= 64 {
400 return autocorrelation_naive(data, max_lag, mean, var);
401 }
402
403 let fft_len = (2 * n).next_power_of_two();
405
406 let mut planner = FftPlanner::<f64>::new();
407 let fft_forward = planner.plan_fft_forward(fft_len);
408 let fft_inverse = planner.plan_fft_inverse(fft_len);
409
410 let mut buffer: Vec<Complex<f64>> = Vec::with_capacity(fft_len);
412 for &x in data {
413 buffer.push(Complex::new(x - mean, 0.0));
414 }
415 buffer.resize(fft_len, Complex::new(0.0, 0.0));
416
417 fft_forward.process(&mut buffer);
419
420 for c in buffer.iter_mut() {
422 *c = Complex::new(c.norm_sqr(), 0.0);
423 }
424
425 fft_inverse.process(&mut buffer);
427
428 let norm = fft_len as f64 * n as f64 * var;
430 (0..=max_lag).map(|lag| buffer[lag].re / norm).collect()
431}
432
433fn try_add_peak(peaks: &mut Vec<usize>, candidate: usize, signal: &[f64], min_distance: usize) {
435 if let Some(&last) = peaks.last() {
436 if candidate - last >= min_distance {
437 peaks.push(candidate);
438 } else if signal[candidate] > signal[last] {
439 *peaks.last_mut().unwrap_or(&mut 0) = candidate;
441 }
442 } else {
443 peaks.push(candidate);
444 }
445}
446
447pub(crate) fn find_peaks_1d(signal: &[f64], min_distance: usize) -> Vec<usize> {
449 let n = signal.len();
450 if n < 3 {
451 return Vec::new();
452 }
453
454 let mut peaks = Vec::new();
455
456 for i in 1..(n - 1) {
457 if signal[i] > signal[i - 1] && signal[i] > signal[i + 1] {
458 try_add_peak(&mut peaks, i, signal, min_distance);
459 }
460 }
461
462 peaks
463}
464
465pub(crate) fn compute_prominence(signal: &[f64], peak_idx: usize) -> f64 {
467 let n = signal.len();
468 let peak_val = signal[peak_idx];
469
470 let mut left_min = peak_val;
472 for i in (0..peak_idx).rev() {
473 if signal[i] >= peak_val {
474 break;
475 }
476 left_min = left_min.min(signal[i]);
477 }
478
479 let mut right_min = peak_val;
480 for i in (peak_idx + 1)..n {
481 if signal[i] >= peak_val {
482 break;
483 }
484 right_min = right_min.min(signal[i]);
485 }
486
487 peak_val - left_min.max(right_min)
488}
489
490pub fn hilbert_transform(signal: &[f64]) -> Vec<Complex<f64>> {
498 let n = signal.len();
499 if n == 0 {
500 return Vec::new();
501 }
502
503 let mut planner = FftPlanner::<f64>::new();
504 let fft_forward = planner.plan_fft_forward(n);
505 let fft_inverse = planner.plan_fft_inverse(n);
506
507 let mut buffer: Vec<Complex<f64>> = signal.iter().map(|&x| Complex::new(x, 0.0)).collect();
509 fft_forward.process(&mut buffer);
510
511 let half = n / 2;
514 if n % 2 == 0 {
515 for k in 1..half {
517 buffer[k] *= 2.0;
518 }
519 for k in (half + 1)..n {
520 buffer[k] = Complex::new(0.0, 0.0);
521 }
522 } else {
523 for k in 1..=half {
525 buffer[k] *= 2.0;
526 }
527 for k in (half + 1)..n {
528 buffer[k] = Complex::new(0.0, 0.0);
529 }
530 }
531
532 fft_inverse.process(&mut buffer);
534
535 for c in buffer.iter_mut() {
537 *c /= n as f64;
538 }
539
540 buffer
541}
542
543fn unwrap_phase(phase: &[f64]) -> Vec<f64> {
545 if phase.is_empty() {
546 return Vec::new();
547 }
548
549 let mut unwrapped = vec![phase[0]];
550 let mut cumulative_correction = 0.0;
551
552 for i in 1..phase.len() {
553 let diff = phase[i] - phase[i - 1];
554
555 if diff > PI {
557 cumulative_correction -= 2.0 * PI;
558 } else if diff < -PI {
559 cumulative_correction += 2.0 * PI;
560 }
561
562 unwrapped.push(phase[i] + cumulative_correction);
563 }
564
565 unwrapped
566}
567
568fn morlet_wavelet(t: f64, omega0: f64) -> Complex<f64> {
575 let gaussian = (-t * t / 2.0).exp();
576 let oscillation = Complex::new((omega0 * t).cos(), (omega0 * t).sin());
577 oscillation * gaussian
578}
579
580#[allow(dead_code)]
594fn cwt_morlet(signal: &[f64], argvals: &[f64], scale: f64, omega0: f64) -> Vec<Complex<f64>> {
595 let n = signal.len();
596 if n == 0 || scale <= 0.0 {
597 return Vec::new();
598 }
599
600 let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
601
602 let norm = 1.0 / scale.sqrt();
605
606 (0..n)
607 .map(|b| {
608 let mut sum = Complex::new(0.0, 0.0);
609 for k in 0..n {
610 let t_normalized = (argvals[k] - argvals[b]) / scale;
611 if t_normalized.abs() < 6.0 {
613 let wavelet = morlet_wavelet(t_normalized, omega0);
614 sum += signal[k] * wavelet.conj();
615 }
616 }
617 sum * norm * dt
618 })
619 .collect()
620}
621
622fn cwt_morlet_fft(signal: &[f64], argvals: &[f64], scale: f64, omega0: f64) -> Vec<Complex<f64>> {
626 let n = signal.len();
627 if n == 0 || scale <= 0.0 {
628 return Vec::new();
629 }
630
631 let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
632 let norm = 1.0 / scale.sqrt();
633
634 let wavelet_time: Vec<Complex<f64>> = (0..n)
636 .map(|k| {
637 let t = if k <= n / 2 {
639 k as f64 * dt / scale
640 } else {
641 (k as f64 - n as f64) * dt / scale
642 };
643 morlet_wavelet(t, omega0) * norm
644 })
645 .collect();
646
647 let mut planner = FftPlanner::<f64>::new();
648 let fft_forward = planner.plan_fft_forward(n);
649 let fft_inverse = planner.plan_fft_inverse(n);
650
651 let mut signal_fft: Vec<Complex<f64>> = signal.iter().map(|&x| Complex::new(x, 0.0)).collect();
653 fft_forward.process(&mut signal_fft);
654
655 let mut wavelet_fft = wavelet_time;
657 fft_forward.process(&mut wavelet_fft);
658
659 let mut result: Vec<Complex<f64>> = signal_fft
661 .iter()
662 .zip(wavelet_fft.iter())
663 .map(|(s, w)| *s * w.conj())
664 .collect();
665
666 fft_inverse.process(&mut result);
668
669 for c in result.iter_mut() {
671 *c *= dt / n as f64;
672 }
673
674 result
675}
676
677fn otsu_threshold(values: &[f64]) -> f64 {
682 if values.is_empty() {
683 return 0.5;
684 }
685
686 let valid: Vec<f64> = values.iter().copied().filter(|x| x.is_finite()).collect();
688 if valid.is_empty() {
689 return 0.5;
690 }
691
692 let vmin = valid.iter().cloned().fold(f64::INFINITY, f64::min);
693 let vmax = valid.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
694
695 if (vmax - vmin).abs() < 1e-10 {
696 return (vmin + vmax) / 2.0;
697 }
698
699 let n_bins = 256;
700 let (histogram, hist_min, bin_width) = build_histogram(&valid, n_bins);
701 let (best_bin, _) = find_optimal_threshold_bin(&histogram, valid.len() as f64);
702
703 hist_min + (best_bin as f64 + 0.5) * bin_width
704}
705
706fn linear_slope(x: &[f64], y: &[f64]) -> f64 {
708 if x.len() != y.len() || x.len() < 2 {
709 return 0.0;
710 }
711
712 let n = x.len() as f64;
713 let mean_x: f64 = x.iter().sum::<f64>() / n;
714 let mean_y: f64 = y.iter().sum::<f64>() / n;
715
716 let mut num = 0.0;
717 let mut den = 0.0;
718
719 for (&xi, &yi) in x.iter().zip(y.iter()) {
720 num += (xi - mean_x) * (yi - mean_y);
721 den += (xi - mean_x).powi(2);
722 }
723
724 if den.abs() < 1e-15 {
725 0.0
726 } else {
727 num / den
728 }
729}
730
731struct AmplitudeEnvelopeStats {
733 modulation_score: f64,
734 amplitude_trend: f64,
735 has_modulation: bool,
736 modulation_type: ModulationType,
737 _mean_amp: f64,
738 min_amp: f64,
739 max_amp: f64,
740}
741
742fn analyze_amplitude_envelope(
744 interior_envelope: &[f64],
745 interior_times: &[f64],
746 modulation_threshold: f64,
747) -> AmplitudeEnvelopeStats {
748 let n_interior = interior_envelope.len() as f64;
749
750 let mean_amp = interior_envelope.iter().sum::<f64>() / n_interior;
751 let min_amp = interior_envelope
752 .iter()
753 .cloned()
754 .fold(f64::INFINITY, f64::min);
755 let max_amp = interior_envelope
756 .iter()
757 .cloned()
758 .fold(f64::NEG_INFINITY, f64::max);
759
760 let variance = interior_envelope
761 .iter()
762 .map(|&a| (a - mean_amp).powi(2))
763 .sum::<f64>()
764 / n_interior;
765 let std_amp = variance.sqrt();
766 let modulation_score = if mean_amp > 1e-10 {
767 std_amp / mean_amp
768 } else {
769 0.0
770 };
771
772 let t_mean = interior_times.iter().sum::<f64>() / n_interior;
773 let mut cov_ta = 0.0;
774 let mut var_t = 0.0;
775 for (&t, &a) in interior_times.iter().zip(interior_envelope.iter()) {
776 cov_ta += (t - t_mean) * (a - mean_amp);
777 var_t += (t - t_mean).powi(2);
778 }
779 let slope = if var_t > 1e-10 { cov_ta / var_t } else { 0.0 };
780
781 let time_span = interior_times.last().unwrap_or(&1.0) - interior_times.first().unwrap_or(&0.0);
782 let amplitude_trend = if mean_amp > 1e-10 && time_span > 1e-10 {
783 (slope * time_span / mean_amp).clamp(-1.0, 1.0)
784 } else {
785 0.0
786 };
787
788 let has_modulation = modulation_score > modulation_threshold;
789 let modulation_type = if !has_modulation {
790 ModulationType::Stable
791 } else if amplitude_trend > 0.3 {
792 ModulationType::Emerging
793 } else if amplitude_trend < -0.3 {
794 ModulationType::Fading
795 } else {
796 ModulationType::Oscillating
797 };
798
799 AmplitudeEnvelopeStats {
800 modulation_score,
801 amplitude_trend,
802 has_modulation,
803 modulation_type,
804 _mean_amp: mean_amp,
805 min_amp,
806 max_amp,
807 }
808}
809
810fn fit_and_subtract_sinusoid(
812 residual: &mut [f64],
813 argvals: &[f64],
814 period: f64,
815) -> (f64, f64, f64, f64) {
816 let m = residual.len();
817 let omega = 2.0 * PI / period;
818 let mut cos_sum = 0.0;
819 let mut sin_sum = 0.0;
820
821 for (j, &t) in argvals.iter().enumerate() {
822 cos_sum += residual[j] * (omega * t).cos();
823 sin_sum += residual[j] * (omega * t).sin();
824 }
825
826 let a = 2.0 * cos_sum / m as f64;
827 let b = 2.0 * sin_sum / m as f64;
828 let amplitude = (a * a + b * b).sqrt();
829 let phase = b.atan2(a);
830
831 for (j, &t) in argvals.iter().enumerate() {
832 residual[j] -= a * (omega * t).cos() + b * (omega * t).sin();
833 }
834
835 (a, b, amplitude, phase)
836}
837
838fn validate_sazed_component(
840 period: f64,
841 confidence: f64,
842 min_period: f64,
843 max_period: f64,
844 threshold: f64,
845) -> Option<f64> {
846 if period.is_finite() && period > min_period && period < max_period && confidence > threshold {
847 Some(period)
848 } else {
849 None
850 }
851}
852
853fn count_agreeing_periods(periods: &[f64], reference: f64, tolerance: f64) -> (usize, f64) {
855 let mut count = 0;
856 let mut sum = 0.0;
857 for &p in periods {
858 let rel_diff = (reference - p).abs() / reference.max(p);
859 if rel_diff <= tolerance {
860 count += 1;
861 sum += p;
862 }
863 }
864 (count, sum)
865}
866
867fn find_acf_descent_end(acf: &[f64]) -> usize {
869 for i in 1..acf.len() {
870 if acf[i] < 0.0 {
871 return i;
872 }
873 if i > 1 && acf[i] > acf[i - 1] {
874 return i - 1;
875 }
876 }
877 1
878}
879
880fn find_first_acf_peak(acf: &[f64]) -> Option<(usize, f64)> {
882 if acf.len() < 4 {
883 return None;
884 }
885
886 let min_search_start = find_acf_descent_end(acf);
887 let peaks = find_peaks_1d(&acf[min_search_start..], 1);
888 if peaks.is_empty() {
889 return None;
890 }
891
892 let peak_lag = peaks[0] + min_search_start;
893 Some((peak_lag, acf[peak_lag].max(0.0)))
894}
895
896fn compute_cycle_strengths(
898 data: &FdMatrix,
899 argvals: &[f64],
900 period: f64,
901 strength_thresh: f64,
902) -> (Vec<f64>, Vec<usize>) {
903 let (n, m) = data.shape();
904 let t_start = argvals[0];
905 let t_end = argvals[m - 1];
906 let n_cycles = ((t_end - t_start) / period).floor() as usize;
907
908 let mut cycle_strengths = Vec::with_capacity(n_cycles);
909 let mut weak_seasons = Vec::new();
910
911 for cycle in 0..n_cycles {
912 let cycle_start = t_start + cycle as f64 * period;
913 let cycle_end = cycle_start + period;
914
915 let start_idx = argvals.iter().position(|&t| t >= cycle_start).unwrap_or(0);
916 let end_idx = argvals.iter().position(|&t| t > cycle_end).unwrap_or(m);
917
918 let cycle_m = end_idx - start_idx;
919 if cycle_m < 4 {
920 cycle_strengths.push(f64::NAN);
921 continue;
922 }
923
924 let cycle_data: Vec<f64> = (start_idx..end_idx)
925 .flat_map(|j| (0..n).map(move |i| data[(i, j)]))
926 .collect();
927 let cycle_mat = FdMatrix::from_column_major(cycle_data, n, cycle_m).unwrap();
928 let cycle_argvals: Vec<f64> = argvals[start_idx..end_idx].to_vec();
929
930 let strength = seasonal_strength_variance(&cycle_mat, &cycle_argvals, period, 3);
931
932 cycle_strengths.push(strength);
933 if strength < strength_thresh {
934 weak_seasons.push(cycle);
935 }
936 }
937
938 (cycle_strengths, weak_seasons)
939}
940
941fn build_histogram(valid: &[f64], n_bins: usize) -> (Vec<usize>, f64, f64) {
943 let min_val = valid.iter().cloned().fold(f64::INFINITY, f64::min);
944 let max_val = valid.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
945 let bin_width = (max_val - min_val) / n_bins as f64;
946 let mut histogram = vec![0usize; n_bins];
947 for &v in valid {
948 let bin = ((v - min_val) / bin_width).min(n_bins as f64 - 1.0) as usize;
949 histogram[bin] += 1;
950 }
951 (histogram, min_val, bin_width)
952}
953
954fn find_optimal_threshold_bin(histogram: &[usize], total: f64) -> (usize, f64) {
956 let n_bins = histogram.len();
957 let mut sum_total = 0.0;
958 for (i, &count) in histogram.iter().enumerate() {
959 sum_total += i as f64 * count as f64;
960 }
961
962 let mut best_bin = 0;
963 let mut best_variance = 0.0;
964 let mut sum_b = 0.0;
965 let mut weight_b = 0.0;
966
967 for t in 0..n_bins {
968 weight_b += histogram[t] as f64;
969 if weight_b == 0.0 {
970 continue;
971 }
972 let weight_f = total - weight_b;
973 if weight_f == 0.0 {
974 break;
975 }
976 sum_b += t as f64 * histogram[t] as f64;
977 let mean_b = sum_b / weight_b;
978 let mean_f = (sum_total - sum_b) / weight_f;
979 let variance = weight_b * weight_f * (mean_b - mean_f).powi(2);
980 if variance > best_variance {
981 best_variance = variance;
982 best_bin = t;
983 }
984 }
985
986 (best_bin, best_variance)
987}
988
989fn sum_harmonic_power(
991 frequencies: &[f64],
992 power: &[f64],
993 fundamental_freq: f64,
994 tolerance: f64,
995) -> (f64, f64) {
996 let mut seasonal_power = 0.0;
997 let mut total_power = 0.0;
998
999 for (i, (&freq, &p)) in frequencies.iter().zip(power.iter()).enumerate() {
1000 if i == 0 {
1001 continue;
1002 }
1003 total_power += p;
1004 let ratio = freq / fundamental_freq;
1005 let nearest_harmonic = ratio.round();
1006 if (ratio - nearest_harmonic).abs() < tolerance && nearest_harmonic >= 1.0 {
1007 seasonal_power += p;
1008 }
1009 }
1010
1011 (seasonal_power, total_power)
1012}
1013
1014fn crossing_direction(
1018 ss: f64,
1019 threshold: f64,
1020 in_seasonal: bool,
1021 i: usize,
1022 last_change_idx: Option<usize>,
1023 min_dur_points: usize,
1024) -> Option<bool> {
1025 if ss.is_nan() {
1026 return None;
1027 }
1028 let now_seasonal = ss > threshold;
1029 if now_seasonal == in_seasonal {
1030 return None;
1031 }
1032 if last_change_idx.is_some_and(|last_idx| i - last_idx < min_dur_points) {
1033 return None;
1034 }
1035 Some(now_seasonal)
1036}
1037
1038fn build_change_point(
1040 i: usize,
1041 ss: f64,
1042 now_seasonal: bool,
1043 strength_curve: &[f64],
1044 argvals: &[f64],
1045) -> ChangePoint {
1046 let change_type = if now_seasonal {
1047 ChangeType::Onset
1048 } else {
1049 ChangeType::Cessation
1050 };
1051 let strength_before = if i > 0 && !strength_curve[i - 1].is_nan() {
1052 strength_curve[i - 1]
1053 } else {
1054 ss
1055 };
1056 ChangePoint {
1057 time: argvals[i],
1058 change_type,
1059 strength_before,
1060 strength_after: ss,
1061 }
1062}
1063
1064fn detect_threshold_crossings(
1066 strength_curve: &[f64],
1067 argvals: &[f64],
1068 threshold: f64,
1069 min_dur_points: usize,
1070) -> Vec<ChangePoint> {
1071 let mut change_points = Vec::new();
1072 let mut in_seasonal = strength_curve[0] > threshold;
1073 let mut last_change_idx: Option<usize> = None;
1074
1075 for (i, &ss) in strength_curve.iter().enumerate().skip(1) {
1076 let Some(now_seasonal) = crossing_direction(
1077 ss,
1078 threshold,
1079 in_seasonal,
1080 i,
1081 last_change_idx,
1082 min_dur_points,
1083 ) else {
1084 continue;
1085 };
1086
1087 change_points.push(build_change_point(
1088 i,
1089 ss,
1090 now_seasonal,
1091 strength_curve,
1092 argvals,
1093 ));
1094
1095 in_seasonal = now_seasonal;
1096 last_change_idx = Some(i);
1097 }
1098
1099 change_points
1100}
1101
1102pub fn estimate_period_fft(data: &FdMatrix, argvals: &[f64]) -> PeriodEstimate {
1120 let (n, m) = data.shape();
1121 if n == 0 || m < 4 || argvals.len() != m {
1122 return PeriodEstimate {
1123 period: f64::NAN,
1124 frequency: f64::NAN,
1125 power: 0.0,
1126 confidence: 0.0,
1127 };
1128 }
1129
1130 let mean_curve = compute_mean_curve(data);
1132
1133 let (frequencies, power) = periodogram(&mean_curve, argvals);
1134
1135 if frequencies.len() < 2 {
1136 return PeriodEstimate {
1137 period: f64::NAN,
1138 frequency: f64::NAN,
1139 power: 0.0,
1140 confidence: 0.0,
1141 };
1142 }
1143
1144 let mut max_power = 0.0;
1146 let mut max_idx = 1;
1147 for (i, &p) in power.iter().enumerate().skip(1) {
1148 if p > max_power {
1149 max_power = p;
1150 max_idx = i;
1151 }
1152 }
1153
1154 let dominant_freq = frequencies[max_idx];
1155 let period = if dominant_freq > 1e-15 {
1156 1.0 / dominant_freq
1157 } else {
1158 f64::INFINITY
1159 };
1160
1161 let mean_power: f64 = power.iter().skip(1).sum::<f64>() / (power.len() - 1) as f64;
1163 let confidence = if mean_power > 1e-15 {
1164 max_power / mean_power
1165 } else {
1166 0.0
1167 };
1168
1169 PeriodEstimate {
1170 period,
1171 frequency: dominant_freq,
1172 power: max_power,
1173 confidence,
1174 }
1175}
1176
1177pub fn estimate_period_acf(
1188 data: &[f64],
1189 n: usize,
1190 m: usize,
1191 argvals: &[f64],
1192 max_lag: usize,
1193) -> PeriodEstimate {
1194 if n == 0 || m < 4 || argvals.len() != m {
1195 return PeriodEstimate {
1196 period: f64::NAN,
1197 frequency: f64::NAN,
1198 power: 0.0,
1199 confidence: 0.0,
1200 };
1201 }
1202
1203 let mat = FdMatrix::from_slice(data, n, m).unwrap();
1205 let mean_curve = compute_mean_curve(&mat);
1206
1207 let acf = autocorrelation(&mean_curve, max_lag);
1208
1209 let min_lag = 2;
1211 let peaks = find_peaks_1d(&acf[min_lag..], 1);
1212
1213 if peaks.is_empty() {
1214 return PeriodEstimate {
1215 period: f64::NAN,
1216 frequency: f64::NAN,
1217 power: 0.0,
1218 confidence: 0.0,
1219 };
1220 }
1221
1222 let peak_lag = peaks[0] + min_lag;
1223 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1224 let period = peak_lag as f64 * dt;
1225 let frequency = if period > 1e-15 { 1.0 / period } else { 0.0 };
1226
1227 PeriodEstimate {
1228 period,
1229 frequency,
1230 power: acf[peak_lag],
1231 confidence: acf[peak_lag].abs(),
1232 }
1233}
1234
1235pub fn estimate_period_regression(
1250 data: &[f64],
1251 n: usize,
1252 m: usize,
1253 argvals: &[f64],
1254 period_min: f64,
1255 period_max: f64,
1256 n_candidates: usize,
1257 n_harmonics: usize,
1258) -> PeriodEstimate {
1259 if n == 0 || m < 4 || argvals.len() != m || period_min >= period_max || n_candidates < 2 {
1260 return PeriodEstimate {
1261 period: f64::NAN,
1262 frequency: f64::NAN,
1263 power: 0.0,
1264 confidence: 0.0,
1265 };
1266 }
1267
1268 let mat = FdMatrix::from_slice(data, n, m).unwrap();
1270 let mean_curve = compute_mean_curve(&mat);
1271
1272 let nbasis = 1 + 2 * n_harmonics;
1273
1274 let candidates: Vec<f64> = (0..n_candidates)
1276 .map(|i| period_min + (period_max - period_min) * i as f64 / (n_candidates - 1) as f64)
1277 .collect();
1278
1279 let results: Vec<(f64, f64)> = slice_maybe_parallel!(candidates)
1280 .map(|&period| {
1281 let basis = fourier_basis_with_period(argvals, nbasis, period);
1282
1283 let mut rss = 0.0;
1285 for j in 0..m {
1286 let mut fitted = 0.0;
1287 for k in 0..nbasis {
1289 let b_val = basis[j + k * m];
1290 let coef: f64 = (0..m)
1291 .map(|l| mean_curve[l] * basis[l + k * m])
1292 .sum::<f64>()
1293 / (0..m)
1294 .map(|l| basis[l + k * m].powi(2))
1295 .sum::<f64>()
1296 .max(1e-15);
1297 fitted += coef * b_val;
1298 }
1299 let resid = mean_curve[j] - fitted;
1300 rss += resid * resid;
1301 }
1302
1303 (period, rss)
1304 })
1305 .collect();
1306
1307 let (best_period, min_rss) = results
1309 .iter()
1310 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
1311 .cloned()
1312 .unwrap_or((f64::NAN, f64::INFINITY));
1313
1314 let mean_rss: f64 = results.iter().map(|(_, r)| r).sum::<f64>() / results.len() as f64;
1316 let confidence = if min_rss > 1e-15 {
1317 (mean_rss / min_rss).min(10.0)
1318 } else {
1319 10.0
1320 };
1321
1322 PeriodEstimate {
1323 period: best_period,
1324 frequency: if best_period > 1e-15 {
1325 1.0 / best_period
1326 } else {
1327 0.0
1328 },
1329 power: 1.0 - min_rss / mean_rss,
1330 confidence,
1331 }
1332}
1333
1334pub fn detect_multiple_periods(
1355 data: &[f64],
1356 n: usize,
1357 m: usize,
1358 argvals: &[f64],
1359 max_periods: usize,
1360 min_confidence: f64,
1361 min_strength: f64,
1362) -> Vec<DetectedPeriod> {
1363 if n == 0 || m < 4 || argvals.len() != m || max_periods == 0 {
1364 return Vec::new();
1365 }
1366
1367 let mat = FdMatrix::from_slice(data, n, m).unwrap();
1369 let mean_curve = compute_mean_curve(&mat);
1370
1371 let mut residual = mean_curve.clone();
1372 let mut detected = Vec::with_capacity(max_periods);
1373
1374 for iteration in 1..=max_periods {
1375 match evaluate_next_period(
1376 &mut residual,
1377 m,
1378 argvals,
1379 min_confidence,
1380 min_strength,
1381 iteration,
1382 ) {
1383 Some(period) => detected.push(period),
1384 None => break,
1385 }
1386 }
1387
1388 detected
1389}
1390
1391fn evaluate_next_period(
1395 residual: &mut [f64],
1396 m: usize,
1397 argvals: &[f64],
1398 min_confidence: f64,
1399 min_strength: f64,
1400 iteration: usize,
1401) -> Option<DetectedPeriod> {
1402 let residual_mat = FdMatrix::from_slice(residual, 1, m).unwrap();
1403 let est = estimate_period_fft(&residual_mat, argvals);
1404
1405 if est.confidence < min_confidence || est.period.is_nan() || est.period.is_infinite() {
1406 return None;
1407 }
1408
1409 let strength = seasonal_strength_variance(&residual_mat, argvals, est.period, 3);
1410 if strength < min_strength || strength.is_nan() {
1411 return None;
1412 }
1413
1414 let (_a, _b, amplitude, phase) = fit_and_subtract_sinusoid(residual, argvals, est.period);
1415
1416 Some(DetectedPeriod {
1417 period: est.period,
1418 confidence: est.confidence,
1419 strength,
1420 amplitude,
1421 phase,
1422 iteration,
1423 })
1424}
1425
1426fn smooth_for_peaks(
1432 data: &FdMatrix,
1433 argvals: &[f64],
1434 smooth_first: bool,
1435 smooth_nbasis: Option<usize>,
1436) -> Vec<f64> {
1437 if !smooth_first {
1438 return data.as_slice().to_vec();
1439 }
1440 let nbasis = smooth_nbasis
1441 .unwrap_or_else(|| crate::basis::select_fourier_nbasis_gcv(data, argvals, 5, 25));
1442 if let Some(result) = crate::basis::fourier_fit_1d(data, argvals, nbasis) {
1443 result.fitted.into_vec()
1444 } else {
1445 data.as_slice().to_vec()
1446 }
1447}
1448
1449fn detect_peaks_single_curve(
1451 curve: &[f64],
1452 d1: &[f64],
1453 argvals: &[f64],
1454 min_dist_points: usize,
1455 min_prominence: Option<f64>,
1456 data_range: f64,
1457) -> (Vec<Peak>, Vec<f64>) {
1458 let m = curve.len();
1459 let mut peak_indices = Vec::new();
1460 for j in 1..m {
1461 if d1[j - 1] > 0.0 && d1[j] <= 0.0 {
1462 let idx = if (d1[j - 1] - d1[j]).abs() > 1e-15 {
1463 j - 1
1464 } else {
1465 j
1466 };
1467
1468 if peak_indices.is_empty()
1469 || idx - peak_indices[peak_indices.len() - 1] >= min_dist_points
1470 {
1471 peak_indices.push(idx);
1472 }
1473 }
1474 }
1475
1476 let mut peaks: Vec<Peak> = peak_indices
1477 .iter()
1478 .map(|&idx| {
1479 let prominence = compute_prominence(curve, idx) / data_range;
1480 Peak {
1481 time: argvals[idx],
1482 value: curve[idx],
1483 prominence,
1484 }
1485 })
1486 .collect();
1487
1488 if let Some(min_prom) = min_prominence {
1489 peaks.retain(|p| p.prominence >= min_prom);
1490 }
1491
1492 let distances: Vec<f64> = peaks.windows(2).map(|w| w[1].time - w[0].time).collect();
1493
1494 (peaks, distances)
1495}
1496
1497pub fn detect_peaks(
1513 data: &FdMatrix,
1514 argvals: &[f64],
1515 min_distance: Option<f64>,
1516 min_prominence: Option<f64>,
1517 smooth_first: bool,
1518 smooth_nbasis: Option<usize>,
1519) -> PeakDetectionResult {
1520 let (n, m) = data.shape();
1521 if n == 0 || m < 3 || argvals.len() != m {
1522 return PeakDetectionResult {
1523 peaks: Vec::new(),
1524 inter_peak_distances: Vec::new(),
1525 mean_period: f64::NAN,
1526 };
1527 }
1528
1529 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1530 let min_dist_points = min_distance.map(|d| (d / dt).round() as usize).unwrap_or(1);
1531
1532 let work_data = smooth_for_peaks(data, argvals, smooth_first, smooth_nbasis);
1533
1534 let work_mat = FdMatrix::from_column_major(work_data.clone(), n, m).unwrap();
1536 let deriv1 = deriv_1d(&work_mat, argvals, 1).into_vec();
1537
1538 let data_range: f64 = {
1540 let mut min_val = f64::INFINITY;
1541 let mut max_val = f64::NEG_INFINITY;
1542 for &v in work_data.iter() {
1543 min_val = min_val.min(v);
1544 max_val = max_val.max(v);
1545 }
1546 (max_val - min_val).max(1e-15)
1547 };
1548
1549 let results: Vec<(Vec<Peak>, Vec<f64>)> = iter_maybe_parallel!(0..n)
1551 .map(|i| {
1552 let curve: Vec<f64> = (0..m).map(|j| work_data[i + j * n]).collect();
1553 let d1: Vec<f64> = (0..m).map(|j| deriv1[i + j * n]).collect();
1554 detect_peaks_single_curve(
1555 &curve,
1556 &d1,
1557 argvals,
1558 min_dist_points,
1559 min_prominence,
1560 data_range,
1561 )
1562 })
1563 .collect();
1564
1565 let peaks: Vec<Vec<Peak>> = results.iter().map(|(p, _)| p.clone()).collect();
1566 let inter_peak_distances: Vec<Vec<f64>> = results.iter().map(|(_, d)| d.clone()).collect();
1567
1568 let all_distances: Vec<f64> = inter_peak_distances.iter().flatten().cloned().collect();
1570 let mean_period = if all_distances.is_empty() {
1571 f64::NAN
1572 } else {
1573 all_distances.iter().sum::<f64>() / all_distances.len() as f64
1574 };
1575
1576 PeakDetectionResult {
1577 peaks,
1578 inter_peak_distances,
1579 mean_period,
1580 }
1581}
1582
1583pub fn seasonal_strength_variance(
1603 data: &FdMatrix,
1604 argvals: &[f64],
1605 period: f64,
1606 n_harmonics: usize,
1607) -> f64 {
1608 let (n, m) = data.shape();
1609 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1610 return f64::NAN;
1611 }
1612
1613 let mean_curve = compute_mean_curve(data);
1615
1616 let global_mean: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1618 let total_var: f64 = mean_curve
1619 .iter()
1620 .map(|&x| (x - global_mean).powi(2))
1621 .sum::<f64>()
1622 / m as f64;
1623
1624 if total_var < 1e-15 {
1625 return 0.0;
1626 }
1627
1628 let nbasis = 1 + 2 * n_harmonics;
1630 let basis = fourier_basis_with_period(argvals, nbasis, period);
1631
1632 let mut seasonal = vec![0.0; m];
1634 for k in 1..nbasis {
1635 let b_sum: f64 = (0..m).map(|j| basis[j + k * m].powi(2)).sum();
1637 if b_sum > 1e-15 {
1638 let coef: f64 = (0..m)
1639 .map(|j| mean_curve[j] * basis[j + k * m])
1640 .sum::<f64>()
1641 / b_sum;
1642 for j in 0..m {
1643 seasonal[j] += coef * basis[j + k * m];
1644 }
1645 }
1646 }
1647
1648 let seasonal_mean: f64 = seasonal.iter().sum::<f64>() / m as f64;
1650 let seasonal_var: f64 = seasonal
1651 .iter()
1652 .map(|&x| (x - seasonal_mean).powi(2))
1653 .sum::<f64>()
1654 / m as f64;
1655
1656 (seasonal_var / total_var).min(1.0)
1657}
1658
1659pub fn seasonal_strength_spectral(data: &FdMatrix, argvals: &[f64], period: f64) -> f64 {
1670 let (n, m) = data.shape();
1671 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1672 return f64::NAN;
1673 }
1674
1675 let mean_curve = compute_mean_curve(data);
1677
1678 let (frequencies, power) = periodogram(&mean_curve, argvals);
1679
1680 if frequencies.len() < 2 {
1681 return f64::NAN;
1682 }
1683
1684 let fundamental_freq = 1.0 / period;
1685 let (seasonal_power, total_power) =
1686 sum_harmonic_power(&frequencies, &power, fundamental_freq, 0.1);
1687
1688 if total_power < 1e-15 {
1689 return 0.0;
1690 }
1691
1692 (seasonal_power / total_power).min(1.0)
1693}
1694
1695pub fn seasonal_strength_wavelet(data: &FdMatrix, argvals: &[f64], period: f64) -> f64 {
1716 let (n, m) = data.shape();
1717 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1718 return f64::NAN;
1719 }
1720
1721 let mean_curve = compute_mean_curve(data);
1723
1724 let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1726 let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
1727
1728 let total_variance: f64 = detrended.iter().map(|&x| x * x).sum::<f64>() / m as f64;
1730
1731 if total_variance < 1e-15 {
1732 return 0.0;
1733 }
1734
1735 let omega0 = 6.0;
1737 let scale = period * omega0 / (2.0 * PI);
1738 let wavelet_coeffs = cwt_morlet_fft(&detrended, argvals, scale, omega0);
1739
1740 if wavelet_coeffs.is_empty() {
1741 return f64::NAN;
1742 }
1743
1744 let (interior_start, interior_end) = match interior_bounds(m) {
1746 Some(bounds) => bounds,
1747 None => return f64::NAN,
1748 };
1749
1750 let wavelet_power: f64 = wavelet_coeffs[interior_start..interior_end]
1751 .iter()
1752 .map(|c| c.norm_sqr())
1753 .sum::<f64>()
1754 / (interior_end - interior_start) as f64;
1755
1756 (wavelet_power / total_variance).sqrt().min(1.0)
1759}
1760
1761pub fn seasonal_strength_windowed(
1775 data: &FdMatrix,
1776 argvals: &[f64],
1777 period: f64,
1778 window_size: f64,
1779 method: StrengthMethod,
1780) -> Vec<f64> {
1781 let (n, m) = data.shape();
1782 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 || window_size <= 0.0 {
1783 return Vec::new();
1784 }
1785
1786 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1787 let half_window_points = ((window_size / 2.0) / dt).round() as usize;
1788
1789 let mean_curve = compute_mean_curve(data);
1791
1792 iter_maybe_parallel!(0..m)
1793 .map(|center| {
1794 let start = center.saturating_sub(half_window_points);
1795 let end = (center + half_window_points + 1).min(m);
1796 let window_m = end - start;
1797
1798 if window_m < 4 {
1799 return f64::NAN;
1800 }
1801
1802 let window_data: Vec<f64> = mean_curve[start..end].to_vec();
1803 let window_argvals: Vec<f64> = argvals[start..end].to_vec();
1804
1805 let single_mat = FdMatrix::from_column_major(window_data, 1, window_m).unwrap();
1807
1808 match method {
1809 StrengthMethod::Variance => {
1810 seasonal_strength_variance(&single_mat, &window_argvals, period, 3)
1811 }
1812 StrengthMethod::Spectral => {
1813 seasonal_strength_spectral(&single_mat, &window_argvals, period)
1814 }
1815 }
1816 })
1817 .collect()
1818}
1819
1820pub fn detect_seasonality_changes(
1839 data: &FdMatrix,
1840 argvals: &[f64],
1841 period: f64,
1842 threshold: f64,
1843 window_size: f64,
1844 min_duration: f64,
1845) -> ChangeDetectionResult {
1846 let (n, m) = data.shape();
1847 if n == 0 || m < 4 || argvals.len() != m {
1848 return ChangeDetectionResult {
1849 change_points: Vec::new(),
1850 strength_curve: Vec::new(),
1851 };
1852 }
1853
1854 let strength_curve =
1856 seasonal_strength_windowed(data, argvals, period, window_size, StrengthMethod::Variance);
1857
1858 if strength_curve.is_empty() {
1859 return ChangeDetectionResult {
1860 change_points: Vec::new(),
1861 strength_curve: Vec::new(),
1862 };
1863 }
1864
1865 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1866 let min_dur_points = (min_duration / dt).round() as usize;
1867
1868 let change_points =
1869 detect_threshold_crossings(&strength_curve, argvals, threshold, min_dur_points);
1870
1871 ChangeDetectionResult {
1872 change_points,
1873 strength_curve,
1874 }
1875}
1876
1877pub fn detect_amplitude_modulation(
1916 data: &FdMatrix,
1917 argvals: &[f64],
1918 period: f64,
1919 modulation_threshold: f64,
1920 seasonality_threshold: f64,
1921) -> AmplitudeModulationResult {
1922 let (n, m) = data.shape();
1923 let empty_result = AmplitudeModulationResult {
1925 is_seasonal: false,
1926 seasonal_strength: 0.0,
1927 has_modulation: false,
1928 modulation_type: ModulationType::NonSeasonal,
1929 modulation_score: 0.0,
1930 amplitude_trend: 0.0,
1931 strength_curve: Vec::new(),
1932 time_points: Vec::new(),
1933 min_strength: 0.0,
1934 max_strength: 0.0,
1935 };
1936
1937 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1938 return empty_result;
1939 }
1940
1941 let overall_strength = seasonal_strength_spectral(data, argvals, period);
1943
1944 if overall_strength < seasonality_threshold {
1945 return AmplitudeModulationResult {
1946 is_seasonal: false,
1947 seasonal_strength: overall_strength,
1948 has_modulation: false,
1949 modulation_type: ModulationType::NonSeasonal,
1950 modulation_score: 0.0,
1951 amplitude_trend: 0.0,
1952 strength_curve: Vec::new(),
1953 time_points: argvals.to_vec(),
1954 min_strength: 0.0,
1955 max_strength: 0.0,
1956 };
1957 }
1958
1959 let mean_curve = compute_mean_curve(data);
1961
1962 let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1964 let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
1965 let analytic = hilbert_transform(&detrended);
1966 let envelope: Vec<f64> = analytic.iter().map(|c| c.norm()).collect();
1967
1968 if envelope.is_empty() {
1969 return AmplitudeModulationResult {
1970 is_seasonal: true,
1971 seasonal_strength: overall_strength,
1972 has_modulation: false,
1973 modulation_type: ModulationType::Stable,
1974 modulation_score: 0.0,
1975 amplitude_trend: 0.0,
1976 strength_curve: Vec::new(),
1977 time_points: argvals.to_vec(),
1978 min_strength: 0.0,
1979 max_strength: 0.0,
1980 };
1981 }
1982
1983 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1985 let smooth_window = ((period / dt) as usize).max(3);
1986 let half_window = smooth_window / 2;
1987
1988 let smoothed_envelope: Vec<f64> = (0..m)
1989 .map(|i| {
1990 let start = i.saturating_sub(half_window);
1991 let end = (i + half_window + 1).min(m);
1992 let sum: f64 = envelope[start..end].iter().sum();
1993 sum / (end - start) as f64
1994 })
1995 .collect();
1996
1997 let Some((interior_start, interior_end)) = valid_interior_bounds(m, 4) else {
1999 return AmplitudeModulationResult {
2000 is_seasonal: true,
2001 seasonal_strength: overall_strength,
2002 has_modulation: false,
2003 modulation_type: ModulationType::Stable,
2004 modulation_score: 0.0,
2005 amplitude_trend: 0.0,
2006 strength_curve: envelope,
2007 time_points: argvals.to_vec(),
2008 min_strength: 0.0,
2009 max_strength: 0.0,
2010 };
2011 };
2012
2013 let stats = analyze_amplitude_envelope(
2014 &smoothed_envelope[interior_start..interior_end],
2015 &argvals[interior_start..interior_end],
2016 modulation_threshold,
2017 );
2018
2019 AmplitudeModulationResult {
2020 is_seasonal: true,
2021 seasonal_strength: overall_strength,
2022 has_modulation: stats.has_modulation,
2023 modulation_type: stats.modulation_type,
2024 modulation_score: stats.modulation_score,
2025 amplitude_trend: stats.amplitude_trend,
2026 strength_curve: envelope,
2027 time_points: argvals.to_vec(),
2028 min_strength: stats.min_amp,
2029 max_strength: stats.max_amp,
2030 }
2031}
2032
2033pub fn detect_amplitude_modulation_wavelet(
2056 data: &FdMatrix,
2057 argvals: &[f64],
2058 period: f64,
2059 modulation_threshold: f64,
2060 seasonality_threshold: f64,
2061) -> WaveletAmplitudeResult {
2062 let (n, m) = data.shape();
2063 let empty_result = WaveletAmplitudeResult {
2064 is_seasonal: false,
2065 seasonal_strength: 0.0,
2066 has_modulation: false,
2067 modulation_type: ModulationType::NonSeasonal,
2068 modulation_score: 0.0,
2069 amplitude_trend: 0.0,
2070 wavelet_amplitude: Vec::new(),
2071 time_points: Vec::new(),
2072 scale: 0.0,
2073 };
2074
2075 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
2076 return empty_result;
2077 }
2078
2079 let overall_strength = seasonal_strength_spectral(data, argvals, period);
2081
2082 if overall_strength < seasonality_threshold {
2083 return WaveletAmplitudeResult {
2084 is_seasonal: false,
2085 seasonal_strength: overall_strength,
2086 has_modulation: false,
2087 modulation_type: ModulationType::NonSeasonal,
2088 modulation_score: 0.0,
2089 amplitude_trend: 0.0,
2090 wavelet_amplitude: Vec::new(),
2091 time_points: argvals.to_vec(),
2092 scale: 0.0,
2093 };
2094 }
2095
2096 let mean_curve = compute_mean_curve(data);
2098
2099 let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
2101 let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
2102
2103 let omega0 = 6.0; let scale = period * omega0 / (2.0 * PI);
2107
2108 let wavelet_coeffs = cwt_morlet_fft(&detrended, argvals, scale, omega0);
2110
2111 if wavelet_coeffs.is_empty() {
2112 return WaveletAmplitudeResult {
2113 is_seasonal: true,
2114 seasonal_strength: overall_strength,
2115 has_modulation: false,
2116 modulation_type: ModulationType::Stable,
2117 modulation_score: 0.0,
2118 amplitude_trend: 0.0,
2119 wavelet_amplitude: Vec::new(),
2120 time_points: argvals.to_vec(),
2121 scale,
2122 };
2123 }
2124
2125 let wavelet_amplitude: Vec<f64> = wavelet_coeffs.iter().map(|c| c.norm()).collect();
2127
2128 let Some((interior_start, interior_end)) = valid_interior_bounds(m, 4) else {
2130 return WaveletAmplitudeResult {
2131 is_seasonal: true,
2132 seasonal_strength: overall_strength,
2133 has_modulation: false,
2134 modulation_type: ModulationType::Stable,
2135 modulation_score: 0.0,
2136 amplitude_trend: 0.0,
2137 wavelet_amplitude,
2138 time_points: argvals.to_vec(),
2139 scale,
2140 };
2141 };
2142
2143 let stats = analyze_amplitude_envelope(
2144 &wavelet_amplitude[interior_start..interior_end],
2145 &argvals[interior_start..interior_end],
2146 modulation_threshold,
2147 );
2148
2149 WaveletAmplitudeResult {
2150 is_seasonal: true,
2151 seasonal_strength: overall_strength,
2152 has_modulation: stats.has_modulation,
2153 modulation_type: stats.modulation_type,
2154 modulation_score: stats.modulation_score,
2155 amplitude_trend: stats.amplitude_trend,
2156 wavelet_amplitude,
2157 time_points: argvals.to_vec(),
2158 scale,
2159 }
2160}
2161
2162pub fn instantaneous_period(data: &FdMatrix, argvals: &[f64]) -> InstantaneousPeriod {
2177 let (n, m) = data.shape();
2178 if n == 0 || m < 4 || argvals.len() != m {
2179 return InstantaneousPeriod {
2180 period: Vec::new(),
2181 frequency: Vec::new(),
2182 amplitude: Vec::new(),
2183 };
2184 }
2185
2186 let mean_curve = compute_mean_curve(data);
2188
2189 let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
2191 let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
2192
2193 let analytic = hilbert_transform(&detrended);
2195
2196 let amplitude: Vec<f64> = analytic.iter().map(|c| c.norm()).collect();
2198
2199 let phase: Vec<f64> = analytic.iter().map(|c| c.im.atan2(c.re)).collect();
2200
2201 let unwrapped_phase = unwrap_phase(&phase);
2203
2204 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
2206 let mut inst_freq = vec![0.0; m];
2207
2208 if m > 1 {
2210 inst_freq[0] = (unwrapped_phase[1] - unwrapped_phase[0]) / dt / (2.0 * PI);
2211 }
2212 for j in 1..(m - 1) {
2213 inst_freq[j] = (unwrapped_phase[j + 1] - unwrapped_phase[j - 1]) / (2.0 * dt) / (2.0 * PI);
2214 }
2215 if m > 1 {
2216 inst_freq[m - 1] = (unwrapped_phase[m - 1] - unwrapped_phase[m - 2]) / dt / (2.0 * PI);
2217 }
2218
2219 let period: Vec<f64> = inst_freq
2221 .iter()
2222 .map(|&f| {
2223 if f.abs() > 1e-10 {
2224 (1.0 / f).abs()
2225 } else {
2226 f64::INFINITY
2227 }
2228 })
2229 .collect();
2230
2231 InstantaneousPeriod {
2232 period,
2233 frequency: inst_freq,
2234 amplitude,
2235 }
2236}
2237
2238pub fn analyze_peak_timing(
2259 data: &FdMatrix,
2260 argvals: &[f64],
2261 period: f64,
2262 smooth_nbasis: Option<usize>,
2263) -> PeakTimingResult {
2264 let (n, m) = data.shape();
2265 if n == 0 || m < 3 || argvals.len() != m || period <= 0.0 {
2266 return PeakTimingResult {
2267 peak_times: Vec::new(),
2268 peak_values: Vec::new(),
2269 normalized_timing: Vec::new(),
2270 mean_timing: f64::NAN,
2271 std_timing: f64::NAN,
2272 range_timing: f64::NAN,
2273 variability_score: f64::NAN,
2274 timing_trend: f64::NAN,
2275 cycle_indices: Vec::new(),
2276 };
2277 }
2278
2279 let min_distance = period * 0.7;
2282 let peaks = detect_peaks(
2283 data,
2284 argvals,
2285 Some(min_distance),
2286 None, true, smooth_nbasis,
2289 );
2290
2291 let sample_peaks = if peaks.peaks.is_empty() {
2294 Vec::new()
2295 } else {
2296 peaks.peaks[0].clone()
2297 };
2298
2299 if sample_peaks.is_empty() {
2300 return PeakTimingResult {
2301 peak_times: Vec::new(),
2302 peak_values: Vec::new(),
2303 normalized_timing: Vec::new(),
2304 mean_timing: f64::NAN,
2305 std_timing: f64::NAN,
2306 range_timing: f64::NAN,
2307 variability_score: f64::NAN,
2308 timing_trend: f64::NAN,
2309 cycle_indices: Vec::new(),
2310 };
2311 }
2312
2313 let peak_times: Vec<f64> = sample_peaks.iter().map(|p| p.time).collect();
2314 let peak_values: Vec<f64> = sample_peaks.iter().map(|p| p.value).collect();
2315
2316 let t_start = argvals[0];
2318 let normalized_timing: Vec<f64> = peak_times
2319 .iter()
2320 .map(|&t| {
2321 let cycle_pos = (t - t_start) % period;
2322 cycle_pos / period
2323 })
2324 .collect();
2325
2326 let cycle_indices: Vec<usize> = peak_times
2328 .iter()
2329 .map(|&t| ((t - t_start) / period).floor() as usize + 1)
2330 .collect();
2331
2332 let n_peaks = normalized_timing.len() as f64;
2334 let mean_timing = normalized_timing.iter().sum::<f64>() / n_peaks;
2335
2336 let variance: f64 = normalized_timing
2337 .iter()
2338 .map(|&x| (x - mean_timing).powi(2))
2339 .sum::<f64>()
2340 / n_peaks;
2341 let std_timing = variance.sqrt();
2342
2343 let min_timing = normalized_timing
2344 .iter()
2345 .cloned()
2346 .fold(f64::INFINITY, f64::min);
2347 let max_timing = normalized_timing
2348 .iter()
2349 .cloned()
2350 .fold(f64::NEG_INFINITY, f64::max);
2351 let range_timing = max_timing - min_timing;
2352
2353 let variability_score = (std_timing / 0.1).min(1.0);
2357
2358 let cycle_idx_f64: Vec<f64> = cycle_indices.iter().map(|&i| i as f64).collect();
2360 let timing_trend = linear_slope(&cycle_idx_f64, &normalized_timing);
2361
2362 PeakTimingResult {
2363 peak_times,
2364 peak_values,
2365 normalized_timing,
2366 mean_timing,
2367 std_timing,
2368 range_timing,
2369 variability_score,
2370 timing_trend,
2371 cycle_indices,
2372 }
2373}
2374
2375pub fn classify_seasonality(
2399 data: &FdMatrix,
2400 argvals: &[f64],
2401 period: f64,
2402 strength_threshold: Option<f64>,
2403 timing_threshold: Option<f64>,
2404) -> SeasonalityClassification {
2405 let strength_thresh = strength_threshold.unwrap_or(0.3);
2406 let timing_thresh = timing_threshold.unwrap_or(0.05);
2407
2408 let (n, m) = data.shape();
2409 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
2410 return SeasonalityClassification {
2411 is_seasonal: false,
2412 has_stable_timing: false,
2413 timing_variability: f64::NAN,
2414 seasonal_strength: f64::NAN,
2415 cycle_strengths: Vec::new(),
2416 weak_seasons: Vec::new(),
2417 classification: SeasonalType::NonSeasonal,
2418 peak_timing: None,
2419 };
2420 }
2421
2422 let overall_strength = seasonal_strength_variance(data, argvals, period, 3);
2424
2425 let (cycle_strengths, weak_seasons) =
2426 compute_cycle_strengths(data, argvals, period, strength_thresh);
2427 let n_cycles = cycle_strengths.len();
2428
2429 let peak_timing = analyze_peak_timing(data, argvals, period, None);
2431
2432 let is_seasonal = overall_strength >= strength_thresh;
2434 let has_stable_timing = peak_timing.std_timing <= timing_thresh;
2435 let timing_variability = peak_timing.variability_score;
2436
2437 let n_weak = weak_seasons.len();
2439 let classification = if !is_seasonal {
2440 SeasonalType::NonSeasonal
2441 } else if n_cycles > 0 && n_weak as f64 / n_cycles as f64 > 0.3 {
2442 SeasonalType::IntermittentSeasonal
2444 } else if !has_stable_timing {
2445 SeasonalType::VariableTiming
2446 } else {
2447 SeasonalType::StableSeasonal
2448 };
2449
2450 SeasonalityClassification {
2451 is_seasonal,
2452 has_stable_timing,
2453 timing_variability,
2454 seasonal_strength: overall_strength,
2455 cycle_strengths,
2456 weak_seasons,
2457 classification,
2458 peak_timing: Some(peak_timing),
2459 }
2460}
2461
2462pub fn detect_seasonality_changes_auto(
2476 data: &FdMatrix,
2477 argvals: &[f64],
2478 period: f64,
2479 threshold_method: ThresholdMethod,
2480 window_size: f64,
2481 min_duration: f64,
2482) -> ChangeDetectionResult {
2483 let (n, m) = data.shape();
2484 if n == 0 || m < 4 || argvals.len() != m {
2485 return ChangeDetectionResult {
2486 change_points: Vec::new(),
2487 strength_curve: Vec::new(),
2488 };
2489 }
2490
2491 let strength_curve =
2493 seasonal_strength_windowed(data, argvals, period, window_size, StrengthMethod::Variance);
2494
2495 if strength_curve.is_empty() {
2496 return ChangeDetectionResult {
2497 change_points: Vec::new(),
2498 strength_curve: Vec::new(),
2499 };
2500 }
2501
2502 let threshold = match threshold_method {
2504 ThresholdMethod::Fixed(t) => t,
2505 ThresholdMethod::Percentile(p) => {
2506 let mut sorted: Vec<f64> = strength_curve
2507 .iter()
2508 .copied()
2509 .filter(|x| x.is_finite())
2510 .collect();
2511 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2512 if sorted.is_empty() {
2513 0.5
2514 } else {
2515 let idx = ((p / 100.0) * sorted.len() as f64) as usize;
2516 sorted[idx.min(sorted.len() - 1)]
2517 }
2518 }
2519 ThresholdMethod::Otsu => otsu_threshold(&strength_curve),
2520 };
2521
2522 detect_seasonality_changes(data, argvals, period, threshold, window_size, min_duration)
2524}
2525
2526#[derive(Debug, Clone)]
2528pub struct SazedResult {
2529 pub period: f64,
2531 pub confidence: f64,
2533 pub component_periods: SazedComponents,
2535 pub agreeing_components: usize,
2537}
2538
2539#[derive(Debug, Clone)]
2541pub struct SazedComponents {
2542 pub spectral: f64,
2544 pub acf_peak: f64,
2546 pub acf_average: f64,
2548 pub zero_crossing: f64,
2550 pub spectral_diff: f64,
2552}
2553
2554pub fn sazed(data: &[f64], argvals: &[f64], tolerance: Option<f64>) -> SazedResult {
2576 let n = data.len();
2577 let tol = tolerance.unwrap_or(0.05); if n < 8 || argvals.len() != n {
2580 return SazedResult {
2581 period: f64::NAN,
2582 confidence: 0.0,
2583 component_periods: SazedComponents {
2584 spectral: f64::NAN,
2585 acf_peak: f64::NAN,
2586 acf_average: f64::NAN,
2587 zero_crossing: f64::NAN,
2588 spectral_diff: f64::NAN,
2589 },
2590 agreeing_components: 0,
2591 };
2592 }
2593
2594 let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
2595 let max_lag = (n / 2).max(4);
2596 let signal_range = argvals[n - 1] - argvals[0];
2597
2598 let min_period = signal_range / (n as f64 / 3.0);
2600 let max_period = signal_range / 2.0;
2602
2603 let (spectral_period, spectral_conf) = sazed_spectral_with_confidence(data, argvals);
2605
2606 let (acf_peak_period, acf_peak_conf) = sazed_acf_peak_with_confidence(data, dt, max_lag);
2608
2609 let acf_average_period = sazed_acf_average(data, dt, max_lag);
2611
2612 let (zero_crossing_period, zero_crossing_conf) =
2614 sazed_zero_crossing_with_confidence(data, dt, max_lag);
2615
2616 let (spectral_diff_period, spectral_diff_conf) =
2618 sazed_spectral_diff_with_confidence(data, argvals);
2619
2620 let components = SazedComponents {
2621 spectral: spectral_period,
2622 acf_peak: acf_peak_period,
2623 acf_average: acf_average_period,
2624 zero_crossing: zero_crossing_period,
2625 spectral_diff: spectral_diff_period,
2626 };
2627
2628 let spectral_thresh = 8.0; let acf_thresh = 0.3; let zero_crossing_thresh = 0.9; let spectral_diff_thresh = 6.0; let min_confident_components = 2;
2637
2638 let confident_periods: Vec<f64> = [
2640 validate_sazed_component(
2641 spectral_period,
2642 spectral_conf,
2643 min_period,
2644 max_period,
2645 spectral_thresh,
2646 ),
2647 validate_sazed_component(
2648 acf_peak_period,
2649 acf_peak_conf,
2650 min_period,
2651 max_period,
2652 acf_thresh,
2653 ),
2654 validate_sazed_component(
2655 acf_average_period,
2656 acf_peak_conf,
2657 min_period,
2658 max_period,
2659 acf_thresh,
2660 ),
2661 validate_sazed_component(
2662 zero_crossing_period,
2663 zero_crossing_conf,
2664 min_period,
2665 max_period,
2666 zero_crossing_thresh,
2667 ),
2668 validate_sazed_component(
2669 spectral_diff_period,
2670 spectral_diff_conf,
2671 min_period,
2672 max_period,
2673 spectral_diff_thresh,
2674 ),
2675 ]
2676 .into_iter()
2677 .flatten()
2678 .collect();
2679
2680 if confident_periods.len() < min_confident_components {
2682 return SazedResult {
2683 period: f64::NAN,
2684 confidence: 0.0,
2685 component_periods: components,
2686 agreeing_components: confident_periods.len(),
2687 };
2688 }
2689
2690 let (consensus_period, agreeing_count) = find_consensus_period(&confident_periods, tol);
2692 let confidence = agreeing_count as f64 / 5.0;
2693
2694 SazedResult {
2695 period: consensus_period,
2696 confidence,
2697 component_periods: components,
2698 agreeing_components: agreeing_count,
2699 }
2700}
2701
2702pub fn sazed_fdata(data: &FdMatrix, argvals: &[f64], tolerance: Option<f64>) -> SazedResult {
2713 let (n, m) = data.shape();
2714 if n == 0 || m < 8 || argvals.len() != m {
2715 return SazedResult {
2716 period: f64::NAN,
2717 confidence: 0.0,
2718 component_periods: SazedComponents {
2719 spectral: f64::NAN,
2720 acf_peak: f64::NAN,
2721 acf_average: f64::NAN,
2722 zero_crossing: f64::NAN,
2723 spectral_diff: f64::NAN,
2724 },
2725 agreeing_components: 0,
2726 };
2727 }
2728
2729 let mean_curve = compute_mean_curve(data);
2730 sazed(&mean_curve, argvals, tolerance)
2731}
2732
2733fn sazed_spectral_with_confidence(data: &[f64], argvals: &[f64]) -> (f64, f64) {
2735 let (frequencies, power) = periodogram(data, argvals);
2736
2737 if frequencies.len() < 3 {
2738 return (f64::NAN, 0.0);
2739 }
2740
2741 let power_no_dc: Vec<f64> = power.iter().skip(1).copied().collect();
2743
2744 if power_no_dc.is_empty() {
2745 return (f64::NAN, 0.0);
2746 }
2747
2748 let mut sorted_power = power_no_dc.clone();
2750 sorted_power.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2751 let noise_floor = sorted_power[sorted_power.len() / 2].max(1e-15);
2752
2753 let mut max_idx = 0;
2755 let mut max_val = 0.0;
2756 for (i, &p) in power_no_dc.iter().enumerate() {
2757 if p > max_val {
2758 max_val = p;
2759 max_idx = i;
2760 }
2761 }
2762
2763 let power_ratio = max_val / noise_floor;
2764 let freq = frequencies[max_idx + 1];
2765
2766 if freq > 1e-15 {
2767 (1.0 / freq, power_ratio)
2768 } else {
2769 (f64::NAN, 0.0)
2770 }
2771}
2772
2773fn sazed_acf_peak_with_confidence(data: &[f64], dt: f64, max_lag: usize) -> (f64, f64) {
2775 let acf = autocorrelation(data, max_lag);
2776
2777 match find_first_acf_peak(&acf) {
2778 Some((peak_lag, acf_value)) => (peak_lag as f64 * dt, acf_value),
2779 None => (f64::NAN, 0.0),
2780 }
2781}
2782
2783fn sazed_acf_average(data: &[f64], dt: f64, max_lag: usize) -> f64 {
2785 let acf = autocorrelation(data, max_lag);
2786
2787 if acf.len() < 4 {
2788 return f64::NAN;
2789 }
2790
2791 let peaks = find_peaks_1d(&acf[1..], 1);
2793
2794 if peaks.is_empty() {
2795 return f64::NAN;
2796 }
2797
2798 let mut weighted_sum = 0.0;
2800 let mut weight_sum = 0.0;
2801
2802 for (i, &peak_idx) in peaks.iter().enumerate() {
2803 let lag = peak_idx + 1;
2804 let weight = acf[lag].max(0.0);
2805
2806 if i == 0 {
2807 weighted_sum += lag as f64 * weight;
2809 weight_sum += weight;
2810 } else {
2811 let expected_fundamental = peaks[0] + 1;
2813 let harmonic = ((lag as f64 / expected_fundamental as f64) + 0.5) as usize;
2814 if harmonic > 0 {
2815 let fundamental_est = lag as f64 / harmonic as f64;
2816 weighted_sum += fundamental_est * weight;
2817 weight_sum += weight;
2818 }
2819 }
2820 }
2821
2822 if weight_sum > 1e-15 {
2823 weighted_sum / weight_sum * dt
2824 } else {
2825 f64::NAN
2826 }
2827}
2828
2829fn sazed_zero_crossing_with_confidence(data: &[f64], dt: f64, max_lag: usize) -> (f64, f64) {
2832 let acf = autocorrelation(data, max_lag);
2833
2834 if acf.len() < 4 {
2835 return (f64::NAN, 0.0);
2836 }
2837
2838 let mut crossings = Vec::new();
2840 for i in 1..acf.len() {
2841 if acf[i - 1] * acf[i] < 0.0 {
2842 let frac = acf[i - 1].abs() / (acf[i - 1].abs() + acf[i].abs());
2844 crossings.push((i - 1) as f64 + frac);
2845 }
2846 }
2847
2848 if crossings.len() < 2 {
2849 return (f64::NAN, 0.0);
2850 }
2851
2852 let mut half_periods = Vec::new();
2855 for i in 1..crossings.len() {
2856 half_periods.push(crossings[i] - crossings[i - 1]);
2857 }
2858
2859 if half_periods.is_empty() {
2860 return (f64::NAN, 0.0);
2861 }
2862
2863 let mean: f64 = half_periods.iter().sum::<f64>() / half_periods.len() as f64;
2866 let variance: f64 =
2867 half_periods.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / half_periods.len() as f64;
2868 let std = variance.sqrt();
2869 let consistency = (1.0 - std / mean.max(1e-15)).clamp(0.0, 1.0);
2870
2871 half_periods.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2873 let median_half = half_periods[half_periods.len() / 2];
2874
2875 (2.0 * median_half * dt, consistency)
2876}
2877
2878fn sazed_spectral_diff_with_confidence(data: &[f64], argvals: &[f64]) -> (f64, f64) {
2880 if data.len() < 4 {
2881 return (f64::NAN, 0.0);
2882 }
2883
2884 let diff: Vec<f64> = data.windows(2).map(|w| w[1] - w[0]).collect();
2886 let diff_argvals: Vec<f64> = argvals.windows(2).map(|w| (w[0] + w[1]) / 2.0).collect();
2887
2888 sazed_spectral_with_confidence(&diff, &diff_argvals)
2889}
2890
2891fn find_spectral_peaks(power: &[f64]) -> Vec<usize> {
2893 if power.len() < 3 {
2894 return Vec::new();
2895 }
2896
2897 let mut sorted_power: Vec<f64> = power.to_vec();
2899 sorted_power.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2900 let noise_floor = sorted_power[sorted_power.len() / 2];
2901 let threshold = noise_floor * 2.0; let mut peaks: Vec<(usize, f64)> = Vec::new();
2905 for i in 1..(power.len() - 1) {
2906 if power[i] > power[i - 1] && power[i] > power[i + 1] && power[i] > threshold {
2907 peaks.push((i, power[i]));
2908 }
2909 }
2910
2911 peaks.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
2913
2914 peaks.into_iter().map(|(idx, _)| idx).collect()
2915}
2916
2917fn find_consensus_period(periods: &[f64], tolerance: f64) -> (f64, usize) {
2919 if periods.is_empty() {
2920 return (f64::NAN, 0);
2921 }
2922 if periods.len() == 1 {
2923 return (periods[0], 1);
2924 }
2925
2926 let mut best_period = periods[0];
2927 let mut best_count = 0;
2928 let mut best_sum = 0.0;
2929
2930 for &p1 in periods {
2931 let (count, sum) = count_agreeing_periods(periods, p1, tolerance);
2932
2933 if count > best_count
2934 || (count == best_count && sum / count as f64 > best_sum / best_count.max(1) as f64)
2935 {
2936 best_count = count;
2937 best_period = sum / count as f64;
2938 best_sum = sum;
2939 }
2940 }
2941
2942 (best_period, best_count)
2943}
2944
2945#[derive(Debug, Clone)]
2947pub struct AutoperiodResult {
2948 pub period: f64,
2950 pub confidence: f64,
2952 pub fft_power: f64,
2954 pub acf_validation: f64,
2956 pub candidates: Vec<AutoperiodCandidate>,
2958}
2959
2960#[derive(Debug, Clone)]
2962pub struct AutoperiodCandidate {
2963 pub period: f64,
2965 pub fft_power: f64,
2967 pub acf_score: f64,
2969 pub combined_score: f64,
2971}
2972
2973fn empty_autoperiod_result() -> AutoperiodResult {
2974 AutoperiodResult {
2975 period: f64::NAN,
2976 confidence: 0.0,
2977 fft_power: 0.0,
2978 acf_validation: 0.0,
2979 candidates: Vec::new(),
2980 }
2981}
2982
2983fn build_autoperiod_candidate(
2985 peak_idx: usize,
2986 frequencies: &[f64],
2987 power_no_dc: &[f64],
2988 acf: &[f64],
2989 dt: f64,
2990 steps: usize,
2991 total_power: f64,
2992) -> Option<AutoperiodCandidate> {
2993 let freq = frequencies[peak_idx + 1];
2994 if freq < 1e-15 {
2995 return None;
2996 }
2997 let fft_power = power_no_dc[peak_idx];
2998 let normalized_power = fft_power / total_power.max(1e-15);
2999 let refined_period = refine_period_gradient(acf, 1.0 / freq, dt, steps);
3000 let refined_acf_score = validate_period_acf(acf, refined_period, dt);
3001 Some(AutoperiodCandidate {
3002 period: refined_period,
3003 fft_power,
3004 acf_score: refined_acf_score,
3005 combined_score: normalized_power * refined_acf_score,
3006 })
3007}
3008
3009pub fn autoperiod(
3030 data: &[f64],
3031 argvals: &[f64],
3032 n_candidates: Option<usize>,
3033 gradient_steps: Option<usize>,
3034) -> AutoperiodResult {
3035 let n = data.len();
3036 let max_candidates = n_candidates.unwrap_or(5);
3037 let steps = gradient_steps.unwrap_or(10);
3038
3039 if n < 8 || argvals.len() != n {
3040 return empty_autoperiod_result();
3041 }
3042
3043 let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
3044 let max_lag = (n / 2).max(4);
3045
3046 let (frequencies, power) = periodogram(data, argvals);
3048
3049 if frequencies.len() < 3 {
3050 return empty_autoperiod_result();
3051 }
3052
3053 let power_no_dc: Vec<f64> = power.iter().skip(1).copied().collect();
3055 let peak_indices = find_spectral_peaks(&power_no_dc);
3056
3057 if peak_indices.is_empty() {
3058 return empty_autoperiod_result();
3059 }
3060
3061 let acf = autocorrelation(data, max_lag);
3063
3064 let total_power: f64 = power_no_dc.iter().sum();
3066 let candidates: Vec<AutoperiodCandidate> = peak_indices
3067 .iter()
3068 .take(max_candidates)
3069 .filter_map(|&peak_idx| {
3070 build_autoperiod_candidate(
3071 peak_idx,
3072 &frequencies,
3073 &power_no_dc,
3074 &acf,
3075 dt,
3076 steps,
3077 total_power,
3078 )
3079 })
3080 .collect();
3081
3082 if candidates.is_empty() {
3083 return empty_autoperiod_result();
3084 }
3085
3086 let best = candidates
3088 .iter()
3089 .max_by(|a, b| {
3090 a.combined_score
3091 .partial_cmp(&b.combined_score)
3092 .unwrap_or(std::cmp::Ordering::Equal)
3093 })
3094 .unwrap();
3095
3096 AutoperiodResult {
3097 period: best.period,
3098 confidence: best.combined_score,
3099 fft_power: best.fft_power,
3100 acf_validation: best.acf_score,
3101 candidates,
3102 }
3103}
3104
3105pub fn autoperiod_fdata(
3107 data: &FdMatrix,
3108 argvals: &[f64],
3109 n_candidates: Option<usize>,
3110 gradient_steps: Option<usize>,
3111) -> AutoperiodResult {
3112 let (n, m) = data.shape();
3113 if n == 0 || m < 8 || argvals.len() != m {
3114 return AutoperiodResult {
3115 period: f64::NAN,
3116 confidence: 0.0,
3117 fft_power: 0.0,
3118 acf_validation: 0.0,
3119 candidates: Vec::new(),
3120 };
3121 }
3122
3123 let mean_curve = compute_mean_curve(data);
3124 autoperiod(&mean_curve, argvals, n_candidates, gradient_steps)
3125}
3126
3127fn validate_period_acf(acf: &[f64], period: f64, dt: f64) -> f64 {
3129 let lag = (period / dt).round() as usize;
3130
3131 if lag == 0 || lag >= acf.len() {
3132 return 0.0;
3133 }
3134
3135 let acf_at_lag = acf[lag];
3138
3139 let half_lag = lag / 2;
3141 let double_lag = lag * 2;
3142
3143 let mut score = acf_at_lag.max(0.0);
3144
3145 if half_lag > 0 && half_lag < acf.len() {
3148 let half_acf = acf[half_lag];
3149 if half_acf > acf_at_lag * 0.7 {
3151 score *= 0.5;
3152 }
3153 }
3154
3155 if double_lag < acf.len() {
3156 let double_acf = acf[double_lag];
3157 if double_acf > 0.3 {
3159 score *= 1.2;
3160 }
3161 }
3162
3163 score.min(1.0)
3164}
3165
3166fn refine_period_gradient(acf: &[f64], initial_period: f64, dt: f64, steps: usize) -> f64 {
3168 let mut period = initial_period;
3169 let step_size = dt * 0.5; for _ in 0..steps {
3172 let current_score = validate_period_acf(acf, period, dt);
3173 let left_score = validate_period_acf(acf, period - step_size, dt);
3174 let right_score = validate_period_acf(acf, period + step_size, dt);
3175
3176 if left_score > current_score && left_score > right_score {
3177 period -= step_size;
3178 } else if right_score > current_score {
3179 period += step_size;
3180 }
3181 }
3183
3184 period.max(dt) }
3186
3187#[derive(Debug, Clone)]
3189pub struct CfdAutoperiodResult {
3190 pub period: f64,
3192 pub confidence: f64,
3194 pub acf_validation: f64,
3196 pub periods: Vec<f64>,
3198 pub confidences: Vec<f64>,
3200}
3201
3202fn generate_cfd_candidates(
3204 frequencies: &[f64],
3205 power_no_dc: &[f64],
3206 peak_indices: &[usize],
3207) -> Vec<(f64, f64)> {
3208 let total_power: f64 = power_no_dc.iter().sum();
3209 peak_indices
3210 .iter()
3211 .filter_map(|&peak_idx| {
3212 let freq = frequencies[peak_idx + 1];
3213 if freq > 1e-15 {
3214 let period = 1.0 / freq;
3215 let normalized_power = power_no_dc[peak_idx] / total_power.max(1e-15);
3216 Some((period, normalized_power))
3217 } else {
3218 None
3219 }
3220 })
3221 .collect()
3222}
3223
3224fn validate_cfd_candidates(clusters: &[(f64, f64)], acf: &[f64], dt: f64) -> Vec<(f64, f64, f64)> {
3226 clusters
3227 .iter()
3228 .filter_map(|&(center, power_sum)| {
3229 let acf_score = validate_period_acf(acf, center, dt);
3230 if acf_score > 0.1 {
3231 Some((center, acf_score, power_sum))
3232 } else {
3233 None
3234 }
3235 })
3236 .collect()
3237}
3238
3239fn validate_or_fallback_cfd(
3241 validated: Vec<(f64, f64, f64)>,
3242 candidates: &[(f64, f64)],
3243 tol: f64,
3244 min_size: usize,
3245) -> Vec<(f64, f64, f64)> {
3246 if !validated.is_empty() {
3247 return validated;
3248 }
3249 cluster_periods(candidates, tol, min_size)
3251 .into_iter()
3252 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
3253 .map(|(center, power_sum)| vec![(center, 0.0, power_sum)])
3254 .unwrap_or_default()
3255}
3256
3257fn rank_cfd_results(validated: &[(f64, f64, f64)]) -> (Vec<f64>, Vec<f64>, f64) {
3260 let mut sorted: Vec<_> = validated.to_vec();
3261 sorted.sort_by(|a, b| {
3262 (b.1 * b.2)
3263 .partial_cmp(&(a.1 * a.2))
3264 .unwrap_or(std::cmp::Ordering::Equal)
3265 });
3266 let top_acf = sorted[0].1;
3267 let periods = sorted.iter().map(|v| v.0).collect();
3268 let confidences = sorted.iter().map(|v| v.1 * v.2).collect();
3269 (periods, confidences, top_acf)
3270}
3271
3272fn empty_cfd_result() -> CfdAutoperiodResult {
3273 CfdAutoperiodResult {
3274 period: f64::NAN,
3275 confidence: 0.0,
3276 acf_validation: 0.0,
3277 periods: Vec::new(),
3278 confidences: Vec::new(),
3279 }
3280}
3281
3282fn extract_cfd_spectral_candidates(data: &[f64], argvals: &[f64]) -> Option<Vec<(f64, f64)>> {
3284 let n = data.len();
3285 let mean_x: f64 = argvals.iter().sum::<f64>() / n as f64;
3290 let mean_y: f64 = data.iter().sum::<f64>() / n as f64;
3291 let mut num = 0.0;
3292 let mut den = 0.0;
3293 for i in 0..n {
3294 let dx = argvals[i] - mean_x;
3295 num += dx * (data[i] - mean_y);
3296 den += dx * dx;
3297 }
3298 let slope = if den.abs() > 1e-15 { num / den } else { 0.0 };
3299 let detrended: Vec<f64> = data
3300 .iter()
3301 .zip(argvals.iter())
3302 .map(|(&y, &x)| y - (mean_y + slope * (x - mean_x)))
3303 .collect();
3304 let (frequencies, power) = periodogram(&detrended, argvals);
3305 if frequencies.len() < 3 {
3306 return None;
3307 }
3308 let power_no_dc: Vec<f64> = power.iter().skip(1).copied().collect();
3309 let peak_indices = find_spectral_peaks(&power_no_dc);
3310 if peak_indices.is_empty() {
3311 return None;
3312 }
3313 let candidates = generate_cfd_candidates(&frequencies, &power_no_dc, &peak_indices);
3314 if candidates.is_empty() {
3315 None
3316 } else {
3317 Some(candidates)
3318 }
3319}
3320
3321pub fn cfd_autoperiod(
3342 data: &[f64],
3343 argvals: &[f64],
3344 cluster_tolerance: Option<f64>,
3345 min_cluster_size: Option<usize>,
3346) -> CfdAutoperiodResult {
3347 let n = data.len();
3348 let tol = cluster_tolerance.unwrap_or(0.1);
3349 let min_size = min_cluster_size.unwrap_or(1);
3350
3351 if n < 8 || argvals.len() != n {
3352 return empty_cfd_result();
3353 }
3354
3355 let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
3356 let max_lag = (n / 2).max(4);
3357
3358 let Some(candidates) = extract_cfd_spectral_candidates(data, argvals) else {
3359 return empty_cfd_result();
3360 };
3361
3362 let clusters = cluster_periods(&candidates, tol, min_size);
3363 if clusters.is_empty() {
3364 return empty_cfd_result();
3365 }
3366
3367 let acf = autocorrelation(data, max_lag);
3368 let validated = validate_cfd_candidates(&clusters, &acf, dt);
3369 let validated = validate_or_fallback_cfd(validated, &candidates, tol, min_size);
3370 let (periods, confidences, top_acf) = rank_cfd_results(&validated);
3371
3372 CfdAutoperiodResult {
3373 period: periods[0],
3374 confidence: confidences[0],
3375 acf_validation: top_acf,
3376 periods,
3377 confidences,
3378 }
3379}
3380
3381pub fn cfd_autoperiod_fdata(
3383 data: &FdMatrix,
3384 argvals: &[f64],
3385 cluster_tolerance: Option<f64>,
3386 min_cluster_size: Option<usize>,
3387) -> CfdAutoperiodResult {
3388 let (n, m) = data.shape();
3389 if n == 0 || m < 8 || argvals.len() != m {
3390 return CfdAutoperiodResult {
3391 period: f64::NAN,
3392 confidence: 0.0,
3393 acf_validation: 0.0,
3394 periods: Vec::new(),
3395 confidences: Vec::new(),
3396 };
3397 }
3398
3399 let mean_curve = compute_mean_curve(data);
3400 cfd_autoperiod(&mean_curve, argvals, cluster_tolerance, min_cluster_size)
3401}
3402
3403fn cluster_periods(candidates: &[(f64, f64)], tolerance: f64, min_size: usize) -> Vec<(f64, f64)> {
3405 if candidates.is_empty() {
3406 return Vec::new();
3407 }
3408
3409 let mut sorted: Vec<(f64, f64)> = candidates.to_vec();
3411 sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
3412
3413 let mut clusters: Vec<(f64, f64)> = Vec::new(); let mut current_cluster: Vec<(f64, f64)> = vec![sorted[0]];
3415
3416 for &(period, power) in sorted.iter().skip(1) {
3417 let cluster_center =
3418 current_cluster.iter().map(|(p, _)| p).sum::<f64>() / current_cluster.len() as f64;
3419
3420 let rel_diff = (period - cluster_center).abs() / cluster_center.max(period);
3421
3422 if rel_diff <= tolerance {
3423 current_cluster.push((period, power));
3425 } else {
3426 if current_cluster.len() >= min_size {
3428 let center = current_cluster.iter().map(|(p, pw)| p * pw).sum::<f64>()
3429 / current_cluster
3430 .iter()
3431 .map(|(_, pw)| pw)
3432 .sum::<f64>()
3433 .max(1e-15);
3434 let total_power: f64 = current_cluster.iter().map(|(_, pw)| pw).sum();
3435 clusters.push((center, total_power));
3436 }
3437 current_cluster = vec![(period, power)];
3438 }
3439 }
3440
3441 if current_cluster.len() >= min_size {
3443 let center = current_cluster.iter().map(|(p, pw)| p * pw).sum::<f64>()
3444 / current_cluster
3445 .iter()
3446 .map(|(_, pw)| pw)
3447 .sum::<f64>()
3448 .max(1e-15);
3449 let total_power: f64 = current_cluster.iter().map(|(_, pw)| pw).sum();
3450 clusters.push((center, total_power));
3451 }
3452
3453 clusters
3454}
3455
3456#[derive(Debug, Clone)]
3462pub struct LombScargleResult {
3463 pub frequencies: Vec<f64>,
3465 pub periods: Vec<f64>,
3467 pub power: Vec<f64>,
3469 pub peak_period: f64,
3471 pub peak_frequency: f64,
3473 pub peak_power: f64,
3475 pub false_alarm_probability: f64,
3477 pub significance: f64,
3479}
3480
3481pub fn lomb_scargle(
3521 times: &[f64],
3522 values: &[f64],
3523 frequencies: Option<&[f64]>,
3524 oversampling: Option<f64>,
3525 nyquist_factor: Option<f64>,
3526) -> LombScargleResult {
3527 let n = times.len();
3528 if n != values.len() || n < 3 {
3529 return LombScargleResult {
3530 frequencies: vec![],
3531 periods: vec![],
3532 power: vec![],
3533 peak_period: f64::NAN,
3534 peak_frequency: f64::NAN,
3535 peak_power: f64::NAN,
3536 false_alarm_probability: f64::NAN,
3537 significance: f64::NAN,
3538 };
3539 }
3540
3541 let mean_y: f64 = values.iter().sum::<f64>() / n as f64;
3543 let var_y: f64 = values.iter().map(|&y| (y - mean_y).powi(2)).sum::<f64>() / (n - 1) as f64;
3544
3545 let freq_vec: Vec<f64>;
3547 let freqs = if let Some(f) = frequencies {
3548 f
3549 } else {
3550 freq_vec = generate_ls_frequencies(
3551 times,
3552 oversampling.unwrap_or(4.0),
3553 nyquist_factor.unwrap_or(1.0),
3554 );
3555 &freq_vec
3556 };
3557
3558 let mut power = Vec::with_capacity(freqs.len());
3560
3561 for &freq in freqs.iter() {
3562 let omega = 2.0 * PI * freq;
3563 let p = lomb_scargle_single_freq(times, values, mean_y, var_y, omega);
3564 power.push(p);
3565 }
3566
3567 let (peak_idx, &peak_power) = power
3569 .iter()
3570 .enumerate()
3571 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
3572 .unwrap_or((0, &0.0));
3573
3574 let peak_frequency = freqs.get(peak_idx).copied().unwrap_or(0.0);
3575 let peak_period = if peak_frequency > 0.0 {
3576 1.0 / peak_frequency
3577 } else {
3578 f64::INFINITY
3579 };
3580
3581 let n_indep = estimate_independent_frequencies(times, freqs.len());
3583 let fap = lomb_scargle_fap(peak_power, n_indep, n);
3584
3585 let periods: Vec<f64> = freqs
3587 .iter()
3588 .map(|&f| if f > 0.0 { 1.0 / f } else { f64::INFINITY })
3589 .collect();
3590
3591 LombScargleResult {
3592 frequencies: freqs.to_vec(),
3593 periods,
3594 power,
3595 peak_period,
3596 peak_frequency,
3597 peak_power,
3598 false_alarm_probability: fap,
3599 significance: 1.0 - fap,
3600 }
3601}
3602
3603fn lomb_scargle_single_freq(
3607 times: &[f64],
3608 values: &[f64],
3609 mean_y: f64,
3610 var_y: f64,
3611 omega: f64,
3612) -> f64 {
3613 if var_y <= 0.0 || omega <= 0.0 {
3614 return 0.0;
3615 }
3616
3617 let n = times.len();
3618
3619 let mut sum_sin2 = 0.0;
3621 let mut sum_cos2 = 0.0;
3622 for &t in times.iter() {
3623 let arg = 2.0 * omega * t;
3624 sum_sin2 += arg.sin();
3625 sum_cos2 += arg.cos();
3626 }
3627 let tau = (sum_sin2).atan2(sum_cos2) / (2.0 * omega);
3628
3629 let mut ss = 0.0; let mut sc = 0.0; let mut css = 0.0; let mut sss = 0.0; for i in 0..n {
3636 let y_centered = values[i] - mean_y;
3637 let arg = omega * (times[i] - tau);
3638 let c = arg.cos();
3639 let s = arg.sin();
3640
3641 sc += y_centered * c;
3642 ss += y_centered * s;
3643 css += c * c;
3644 sss += s * s;
3645 }
3646
3647 let css = css.max(1e-15);
3649 let sss = sss.max(1e-15);
3650
3651 0.5 * (sc * sc / css + ss * ss / sss) / var_y
3653}
3654
3655fn generate_ls_frequencies(times: &[f64], oversampling: f64, nyquist_factor: f64) -> Vec<f64> {
3659 let n = times.len();
3660 if n < 2 {
3661 return vec![0.0];
3662 }
3663
3664 let t_min = times.iter().cloned().fold(f64::INFINITY, f64::min);
3666 let t_max = times.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
3667 let t_span = (t_max - t_min).max(1e-10);
3668
3669 let f_min = 1.0 / t_span;
3671
3672 let f_nyquist = 0.5 * (n - 1) as f64 / t_span;
3675
3676 let f_max = f_nyquist * nyquist_factor;
3678
3679 let df = f_min / oversampling;
3681
3682 let n_freq = ((f_max - f_min) / df).ceil() as usize + 1;
3684 let n_freq = n_freq.min(10000); (0..n_freq).map(|i| f_min + i as f64 * df).collect()
3687}
3688
3689fn estimate_independent_frequencies(times: &[f64], n_freq: usize) -> usize {
3694 let n = times.len();
3696 n.min(n_freq)
3697}
3698
3699fn lomb_scargle_fap(power: f64, n_indep: usize, _n_data: usize) -> f64 {
3705 if power <= 0.0 || n_indep == 0 {
3706 return 1.0;
3707 }
3708
3709 let prob_single = 1.0 - (-power).exp();
3711
3712 if prob_single >= 1.0 {
3719 return 0.0; }
3721 if prob_single <= 0.0 {
3722 return 1.0; }
3724
3725 let log_prob = prob_single.ln();
3726 let log_cdf = n_indep as f64 * log_prob;
3727
3728 if log_cdf < -700.0 {
3729 0.0 } else {
3731 1.0 - log_cdf.exp()
3732 }
3733}
3734
3735pub fn lomb_scargle_fdata(
3751 data: &FdMatrix,
3752 argvals: &[f64],
3753 oversampling: Option<f64>,
3754 nyquist_factor: Option<f64>,
3755) -> LombScargleResult {
3756 let mean_curve = compute_mean_curve(data);
3758
3759 lomb_scargle(argvals, &mean_curve, None, oversampling, nyquist_factor)
3761}
3762
3763#[derive(Debug, Clone)]
3769pub struct MatrixProfileResult {
3770 pub profile: Vec<f64>,
3772 pub profile_index: Vec<usize>,
3774 pub subsequence_length: usize,
3776 pub detected_periods: Vec<f64>,
3778 pub arc_counts: Vec<usize>,
3780 pub primary_period: f64,
3782 pub confidence: f64,
3784}
3785
3786pub fn matrix_profile(
3822 values: &[f64],
3823 subsequence_length: Option<usize>,
3824 exclusion_zone: Option<f64>,
3825) -> MatrixProfileResult {
3826 let n = values.len();
3827
3828 let m = subsequence_length.unwrap_or_else(|| {
3830 let default_m = n / 4;
3831 default_m.max(4).min(n / 2)
3832 });
3833
3834 if m < 3 || m > n / 2 {
3835 return MatrixProfileResult {
3836 profile: vec![],
3837 profile_index: vec![],
3838 subsequence_length: m,
3839 detected_periods: vec![],
3840 arc_counts: vec![],
3841 primary_period: f64::NAN,
3842 confidence: 0.0,
3843 };
3844 }
3845
3846 let exclusion_zone = exclusion_zone.unwrap_or(0.5);
3847 let exclusion_radius = (m as f64 * exclusion_zone).ceil() as usize;
3848
3849 let profile_len = n - m + 1;
3851
3852 let (means, stds) = compute_sliding_stats(values, m);
3854
3855 let (profile, profile_index) = stomp_core(values, m, &means, &stds, exclusion_radius);
3857
3858 let (arc_counts, detected_periods, primary_period, confidence) =
3860 analyze_arcs(&profile_index, profile_len, m);
3861
3862 MatrixProfileResult {
3863 profile,
3864 profile_index,
3865 subsequence_length: m,
3866 detected_periods,
3867 arc_counts,
3868 primary_period,
3869 confidence,
3870 }
3871}
3872
3873fn compute_sliding_stats(values: &[f64], m: usize) -> (Vec<f64>, Vec<f64>) {
3877 let n = values.len();
3878 let profile_len = n - m + 1;
3879
3880 let mut cumsum = vec![0.0; n + 1];
3882 let mut cumsum_sq = vec![0.0; n + 1];
3883
3884 for i in 0..n {
3885 cumsum[i + 1] = cumsum[i] + values[i];
3886 cumsum_sq[i + 1] = cumsum_sq[i] + values[i] * values[i];
3887 }
3888
3889 let mut means = Vec::with_capacity(profile_len);
3891 let mut stds = Vec::with_capacity(profile_len);
3892
3893 let m_f64 = m as f64;
3894
3895 for i in 0..profile_len {
3896 let sum = cumsum[i + m] - cumsum[i];
3897 let sum_sq = cumsum_sq[i + m] - cumsum_sq[i];
3898
3899 let mean = sum / m_f64;
3900 let variance = (sum_sq / m_f64) - mean * mean;
3901 let std = variance.max(0.0).sqrt();
3902
3903 means.push(mean);
3904 stds.push(std.max(1e-10)); }
3906
3907 (means, stds)
3908}
3909
3910fn stomp_core(
3914 values: &[f64],
3915 m: usize,
3916 means: &[f64],
3917 stds: &[f64],
3918 exclusion_radius: usize,
3919) -> (Vec<f64>, Vec<usize>) {
3920 let n = values.len();
3921 let profile_len = n - m + 1;
3922
3923 let mut profile = vec![f64::INFINITY; profile_len];
3925 let mut profile_index = vec![0usize; profile_len];
3926
3927 let mut qt = vec![0.0; profile_len];
3930
3931 for j in 0..profile_len {
3933 let mut dot = 0.0;
3934 for k in 0..m {
3935 dot += values[k] * values[j + k];
3936 }
3937 qt[j] = dot;
3938 }
3939
3940 update_profile_row(
3942 0,
3943 &qt,
3944 means,
3945 stds,
3946 m,
3947 exclusion_radius,
3948 &mut profile,
3949 &mut profile_index,
3950 );
3951
3952 for i in 1..profile_len {
3954 let mut qt_new = vec![0.0; profile_len];
3957
3958 let mut dot = 0.0;
3960 for k in 0..m {
3961 dot += values[i + k] * values[k];
3962 }
3963 qt_new[0] = dot;
3964
3965 for j in 1..profile_len {
3967 qt_new[j] =
3968 qt[j - 1] - values[i - 1] * values[j - 1] + values[i + m - 1] * values[j + m - 1];
3969 }
3970
3971 qt = qt_new;
3972
3973 update_profile_row(
3975 i,
3976 &qt,
3977 means,
3978 stds,
3979 m,
3980 exclusion_radius,
3981 &mut profile,
3982 &mut profile_index,
3983 );
3984 }
3985
3986 (profile, profile_index)
3987}
3988
3989fn update_profile_row(
3991 i: usize,
3992 qt: &[f64],
3993 means: &[f64],
3994 stds: &[f64],
3995 m: usize,
3996 exclusion_radius: usize,
3997 profile: &mut [f64],
3998 profile_index: &mut [usize],
3999) {
4000 let profile_len = profile.len();
4001 let m_f64 = m as f64;
4002
4003 for j in 0..profile_len {
4004 if i.abs_diff(j) <= exclusion_radius {
4006 continue;
4007 }
4008
4009 let numerator = qt[j] - m_f64 * means[i] * means[j];
4012 let denominator = m_f64 * stds[i] * stds[j];
4013
4014 let pearson = if denominator > 0.0 {
4015 (numerator / denominator).clamp(-1.0, 1.0)
4016 } else {
4017 0.0
4018 };
4019
4020 let dist_sq = 2.0 * m_f64 * (1.0 - pearson);
4021 let dist = dist_sq.max(0.0).sqrt();
4022
4023 if dist < profile[i] {
4025 profile[i] = dist;
4026 profile_index[i] = j;
4027 }
4028
4029 if dist < profile[j] {
4031 profile[j] = dist;
4032 profile_index[j] = i;
4033 }
4034 }
4035}
4036
4037fn analyze_arcs(
4042 profile_index: &[usize],
4043 profile_len: usize,
4044 m: usize,
4045) -> (Vec<usize>, Vec<f64>, f64, f64) {
4046 let max_distance = profile_len;
4048 let mut arc_counts = vec![0usize; max_distance];
4049
4050 for (i, &j) in profile_index.iter().enumerate() {
4051 let distance = i.abs_diff(j);
4052 if distance < max_distance {
4053 arc_counts[distance] += 1;
4054 }
4055 }
4056
4057 let min_period = m / 2; let mut peaks: Vec<(usize, usize)> = Vec::new();
4060
4061 for i in min_period..arc_counts.len().saturating_sub(1) {
4063 if arc_counts[i] > arc_counts[i.saturating_sub(1)]
4064 && arc_counts[i] > arc_counts[(i + 1).min(arc_counts.len() - 1)]
4065 && arc_counts[i] >= 3
4066 {
4068 peaks.push((i, arc_counts[i]));
4069 }
4070 }
4071
4072 peaks.sort_by(|a, b| b.1.cmp(&a.1));
4074
4075 let detected_periods: Vec<f64> = peaks.iter().take(5).map(|(p, _)| *p as f64).collect();
4077
4078 let (primary_period, confidence) = if let Some(&(period, count)) = peaks.first() {
4080 let total_arcs: usize = arc_counts[min_period..].iter().sum();
4082 let conf = if total_arcs > 0 {
4083 count as f64 / total_arcs as f64
4084 } else {
4085 0.0
4086 };
4087 (period as f64, conf.min(1.0))
4088 } else {
4089 (0.0, 0.0)
4090 };
4091
4092 (arc_counts, detected_periods, primary_period, confidence)
4093}
4094
4095pub fn matrix_profile_fdata(
4109 data: &FdMatrix,
4110 subsequence_length: Option<usize>,
4111 exclusion_zone: Option<f64>,
4112) -> MatrixProfileResult {
4113 let mean_curve = compute_mean_curve(data);
4115
4116 matrix_profile(&mean_curve, subsequence_length, exclusion_zone)
4118}
4119
4120pub fn matrix_profile_seasonality(
4132 values: &[f64],
4133 subsequence_length: Option<usize>,
4134 confidence_threshold: Option<f64>,
4135) -> (bool, f64, f64) {
4136 let result = matrix_profile(values, subsequence_length, None);
4137
4138 let threshold = confidence_threshold.unwrap_or(0.1);
4139 let is_seasonal = result.confidence >= threshold && result.primary_period > 0.0;
4140
4141 (is_seasonal, result.primary_period, result.confidence)
4142}
4143
4144#[derive(Debug, Clone)]
4150pub struct SsaResult {
4151 pub trend: Vec<f64>,
4153 pub seasonal: Vec<f64>,
4155 pub noise: Vec<f64>,
4157 pub singular_values: Vec<f64>,
4159 pub contributions: Vec<f64>,
4161 pub window_length: usize,
4163 pub n_components: usize,
4165 pub detected_period: f64,
4167 pub confidence: f64,
4169}
4170
4171pub fn ssa(
4212 values: &[f64],
4213 window_length: Option<usize>,
4214 n_components: Option<usize>,
4215 trend_components: Option<&[usize]>,
4216 seasonal_components: Option<&[usize]>,
4217) -> SsaResult {
4218 let n = values.len();
4219
4220 let l = window_length.unwrap_or_else(|| (n / 2).clamp(2, 50));
4222
4223 if n < 4 || l < 2 || l > n / 2 {
4224 return SsaResult {
4225 trend: values.to_vec(),
4226 seasonal: vec![0.0; n],
4227 noise: vec![0.0; n],
4228 singular_values: vec![],
4229 contributions: vec![],
4230 window_length: l,
4231 n_components: 0,
4232 detected_period: 0.0,
4233 confidence: 0.0,
4234 };
4235 }
4236
4237 let k = n - l + 1;
4239
4240 let trajectory = embed_trajectory(values, l, k);
4242
4243 let (u, sigma, vt) = svd_decompose(&trajectory, l, k);
4245
4246 let max_components = sigma.len();
4248 let n_comp = n_components.unwrap_or(10).min(max_components);
4249
4250 let total_var: f64 = sigma.iter().map(|&s| s * s).sum();
4252 let contributions: Vec<f64> = sigma
4253 .iter()
4254 .take(n_comp)
4255 .map(|&s| s * s / total_var.max(1e-15))
4256 .collect();
4257
4258 let (trend_idx, seasonal_idx, detected_period, confidence) =
4260 if trend_components.is_some() || seasonal_components.is_some() {
4261 let t_idx: Vec<usize> = trend_components.map(|v| v.to_vec()).unwrap_or_default();
4263 let s_idx: Vec<usize> = seasonal_components.map(|v| v.to_vec()).unwrap_or_default();
4264 (t_idx, s_idx, 0.0, 0.0)
4265 } else {
4266 auto_group_ssa_components(&u, &sigma, l, k, n_comp)
4268 };
4269
4270 let trend = reconstruct_grouped(&u, &sigma, &vt, l, k, n, &trend_idx);
4272 let seasonal = reconstruct_grouped(&u, &sigma, &vt, l, k, n, &seasonal_idx);
4273
4274 let noise: Vec<f64> = values
4276 .iter()
4277 .zip(trend.iter())
4278 .zip(seasonal.iter())
4279 .map(|((&y, &t), &s)| y - t - s)
4280 .collect();
4281
4282 SsaResult {
4283 trend,
4284 seasonal,
4285 noise,
4286 singular_values: sigma.into_iter().take(n_comp).collect(),
4287 contributions,
4288 window_length: l,
4289 n_components: n_comp,
4290 detected_period,
4291 confidence,
4292 }
4293}
4294
4295fn embed_trajectory(values: &[f64], l: usize, k: usize) -> Vec<f64> {
4297 let mut trajectory = vec![0.0; l * k];
4299
4300 for j in 0..k {
4301 for i in 0..l {
4302 trajectory[i + j * l] = values[i + j];
4303 }
4304 }
4305
4306 trajectory
4307}
4308
4309fn svd_decompose(trajectory: &[f64], l: usize, k: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
4311 use nalgebra::{DMatrix, SVD};
4312
4313 let mat = DMatrix::from_column_slice(l, k, trajectory);
4315
4316 let svd = SVD::new(mat, true, true);
4318
4319 let u_mat = match svd.u {
4322 Some(u) => u,
4323 None => return (vec![], vec![], vec![]),
4324 };
4325 let vt_mat = match svd.v_t {
4326 Some(vt) => vt,
4327 None => return (vec![], vec![], vec![]),
4328 };
4329 let sigma = svd.singular_values;
4330
4331 let u: Vec<f64> = u_mat.iter().cloned().collect();
4333 let sigma_vec: Vec<f64> = sigma.iter().cloned().collect();
4334 let vt: Vec<f64> = vt_mat.iter().cloned().collect();
4335
4336 (u, sigma_vec, vt)
4337}
4338
4339enum SsaComponentKind {
4340 Trend,
4341 Seasonal(f64),
4342 Noise,
4343}
4344
4345fn classify_ssa_component(u_col: &[f64], trend_count: usize) -> SsaComponentKind {
4347 if is_trend_component(u_col) && trend_count < 2 {
4348 SsaComponentKind::Trend
4349 } else {
4350 let (is_periodic, period) = is_periodic_component(u_col);
4351 if is_periodic {
4352 SsaComponentKind::Seasonal(period)
4353 } else {
4354 SsaComponentKind::Noise
4355 }
4356 }
4357}
4358
4359fn apply_ssa_grouping_defaults(
4361 trend_idx: &mut Vec<usize>,
4362 seasonal_idx: &mut Vec<usize>,
4363 n_comp: usize,
4364) {
4365 if trend_idx.is_empty() && n_comp > 0 {
4366 trend_idx.push(0);
4367 }
4368 if seasonal_idx.is_empty() && n_comp >= 3 {
4369 seasonal_idx.push(1);
4370 if n_comp > 2 {
4371 seasonal_idx.push(2);
4372 }
4373 }
4374}
4375
4376fn auto_group_ssa_components(
4378 u: &[f64],
4379 sigma: &[f64],
4380 l: usize,
4381 _k: usize,
4382 n_comp: usize,
4383) -> (Vec<usize>, Vec<usize>, f64, f64) {
4384 let mut trend_idx = Vec::new();
4385 let mut seasonal_idx = Vec::new();
4386 let mut detected_period = 0.0;
4387 let mut confidence = 0.0;
4388
4389 for i in 0..n_comp.min(sigma.len()) {
4390 let u_col: Vec<f64> = (0..l).map(|j| u[j + i * l]).collect();
4391 match classify_ssa_component(&u_col, trend_idx.len()) {
4392 SsaComponentKind::Trend => trend_idx.push(i),
4393 SsaComponentKind::Seasonal(period) => {
4394 seasonal_idx.push(i);
4395 if detected_period == 0.0 && period > 0.0 {
4396 detected_period = period;
4397 confidence = sigma[i] / sigma[0].max(1e-15);
4398 }
4399 }
4400 SsaComponentKind::Noise => {}
4401 }
4402 }
4403
4404 apply_ssa_grouping_defaults(&mut trend_idx, &mut seasonal_idx, n_comp);
4405 (trend_idx, seasonal_idx, detected_period, confidence)
4406}
4407
4408fn is_trend_component(u_col: &[f64]) -> bool {
4410 let n = u_col.len();
4411 if n < 3 {
4412 return false;
4413 }
4414
4415 let mut sign_changes = 0;
4417 for i in 1..n {
4418 if u_col[i] * u_col[i - 1] < 0.0 {
4419 sign_changes += 1;
4420 }
4421 }
4422
4423 sign_changes <= n / 10
4425}
4426
4427fn is_periodic_component(u_col: &[f64]) -> (bool, f64) {
4429 let n = u_col.len();
4430 if n < 4 {
4431 return (false, 0.0);
4432 }
4433
4434 let mean: f64 = u_col.iter().sum::<f64>() / n as f64;
4436 let centered: Vec<f64> = u_col.iter().map(|&x| x - mean).collect();
4437
4438 let var: f64 = centered.iter().map(|&x| x * x).sum();
4439 if var < 1e-15 {
4440 return (false, 0.0);
4441 }
4442
4443 let mut best_period = 0.0;
4445 let mut best_acf = 0.0;
4446
4447 for lag in 2..n / 2 {
4448 let mut acf = 0.0;
4449 for i in 0..(n - lag) {
4450 acf += centered[i] * centered[i + lag];
4451 }
4452 acf /= var;
4453
4454 if acf > best_acf && acf > 0.3 {
4455 best_acf = acf;
4456 best_period = lag as f64;
4457 }
4458 }
4459
4460 let is_periodic = best_acf > 0.3 && best_period > 0.0;
4461 (is_periodic, best_period)
4462}
4463
4464fn reconstruct_grouped(
4466 u: &[f64],
4467 sigma: &[f64],
4468 vt: &[f64],
4469 l: usize,
4470 k: usize,
4471 n: usize,
4472 group_idx: &[usize],
4473) -> Vec<f64> {
4474 if group_idx.is_empty() {
4475 return vec![0.0; n];
4476 }
4477
4478 let mut grouped_matrix = vec![0.0; l * k];
4480
4481 for &idx in group_idx {
4482 if idx >= sigma.len() {
4483 continue;
4484 }
4485
4486 let s = sigma[idx];
4487
4488 for j in 0..k {
4490 for i in 0..l {
4491 let u_val = u[i + idx * l];
4492 let v_val = vt[idx + j * sigma.len().min(l)]; grouped_matrix[i + j * l] += s * u_val * v_val;
4494 }
4495 }
4496 }
4497
4498 diagonal_average(&grouped_matrix, l, k, n)
4500}
4501
4502fn diagonal_average(matrix: &[f64], l: usize, k: usize, n: usize) -> Vec<f64> {
4504 let mut result = vec![0.0; n];
4505 let mut counts = vec![0.0; n];
4506
4507 for j in 0..k {
4509 for i in 0..l {
4510 let idx = i + j; if idx < n {
4512 result[idx] += matrix[i + j * l];
4513 counts[idx] += 1.0;
4514 }
4515 }
4516 }
4517
4518 for i in 0..n {
4520 if counts[i] > 0.0 {
4521 result[i] /= counts[i];
4522 }
4523 }
4524
4525 result
4526}
4527
4528pub fn ssa_fdata(
4540 data: &FdMatrix,
4541 window_length: Option<usize>,
4542 n_components: Option<usize>,
4543) -> SsaResult {
4544 let mean_curve = compute_mean_curve(data);
4546
4547 ssa(&mean_curve, window_length, n_components, None, None)
4549}
4550
4551pub fn ssa_seasonality(
4561 values: &[f64],
4562 window_length: Option<usize>,
4563 confidence_threshold: Option<f64>,
4564) -> (bool, f64, f64) {
4565 let result = ssa(values, window_length, None, None, None);
4566
4567 let threshold = confidence_threshold.unwrap_or(0.1);
4568 let is_seasonal = result.confidence >= threshold && result.detected_period > 0.0;
4569
4570 (is_seasonal, result.detected_period, result.confidence)
4571}
4572
4573#[cfg(test)]
4574mod tests {
4575 use super::*;
4576 use std::f64::consts::PI;
4577
4578 fn generate_sine(n: usize, m: usize, period: f64, argvals: &[f64]) -> FdMatrix {
4579 let mut data = vec![0.0; n * m];
4580 for i in 0..n {
4581 for j in 0..m {
4582 data[i + j * n] = (2.0 * PI * argvals[j] / period).sin();
4583 }
4584 }
4585 FdMatrix::from_column_major(data, n, m).unwrap()
4586 }
4587
4588 #[test]
4589 fn test_period_estimation_fft() {
4590 let m = 200;
4591 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4592 let period = 2.0;
4593 let data = generate_sine(1, m, period, &argvals);
4594
4595 let estimate = estimate_period_fft(&data, &argvals);
4596 assert!((estimate.period - period).abs() < 0.2);
4597 assert!(estimate.confidence > 1.0);
4598 }
4599
4600 #[test]
4601 fn test_peak_detection() {
4602 let m = 100;
4603 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4604 let period = 2.0;
4605 let data = generate_sine(1, m, period, &argvals);
4606
4607 let result = detect_peaks(&data, &argvals, Some(1.5), None, false, None);
4608
4609 assert!(!result.peaks[0].is_empty());
4611 assert!((result.mean_period - period).abs() < 0.3);
4612 }
4613
4614 #[test]
4615 fn test_peak_detection_known_sine() {
4616 let m = 200; let period = 2.0;
4620 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4621 let data: Vec<f64> = argvals
4622 .iter()
4623 .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
4624 .collect();
4625 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4626
4627 let result = detect_peaks(&data, &argvals, None, None, false, None);
4628
4629 assert_eq!(
4631 result.peaks[0].len(),
4632 5,
4633 "Expected 5 peaks, got {}. Peak times: {:?}",
4634 result.peaks[0].len(),
4635 result.peaks[0].iter().map(|p| p.time).collect::<Vec<_>>()
4636 );
4637
4638 let expected_times = [0.5, 2.5, 4.5, 6.5, 8.5];
4640 for (peak, expected) in result.peaks[0].iter().zip(expected_times.iter()) {
4641 assert!(
4642 (peak.time - expected).abs() < 0.15,
4643 "Peak at {:.3} not close to expected {:.3}",
4644 peak.time,
4645 expected
4646 );
4647 }
4648
4649 assert!(
4651 (result.mean_period - period).abs() < 0.1,
4652 "Mean period {:.3} not close to expected {:.3}",
4653 result.mean_period,
4654 period
4655 );
4656 }
4657
4658 #[test]
4659 fn test_peak_detection_with_min_distance() {
4660 let m = 200;
4662 let period = 2.0;
4663 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4664 let data: Vec<f64> = argvals
4665 .iter()
4666 .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
4667 .collect();
4668 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4669
4670 let result = detect_peaks(&data, &argvals, Some(1.5), None, false, None);
4672 assert_eq!(
4673 result.peaks[0].len(),
4674 5,
4675 "With min_distance=1.5, expected 5 peaks, got {}",
4676 result.peaks[0].len()
4677 );
4678
4679 let result2 = detect_peaks(&data, &argvals, Some(2.5), None, false, None);
4681 assert!(
4682 result2.peaks[0].len() < 5,
4683 "With min_distance=2.5, expected fewer than 5 peaks, got {}",
4684 result2.peaks[0].len()
4685 );
4686 }
4687
4688 #[test]
4689 fn test_peak_detection_period_1() {
4690 let m = 400; let period = 1.0;
4694 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4695 let data: Vec<f64> = argvals
4696 .iter()
4697 .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
4698 .collect();
4699 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4700
4701 let result = detect_peaks(&data, &argvals, None, None, false, None);
4702
4703 assert_eq!(
4705 result.peaks[0].len(),
4706 10,
4707 "Expected 10 peaks, got {}",
4708 result.peaks[0].len()
4709 );
4710
4711 assert!(
4713 (result.mean_period - period).abs() < 0.1,
4714 "Mean period {:.3} not close to expected {:.3}",
4715 result.mean_period,
4716 period
4717 );
4718 }
4719
4720 #[test]
4721 fn test_peak_detection_shifted_sine() {
4722 let m = 200;
4725 let period = 2.0;
4726 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4727 let data: Vec<f64> = argvals
4728 .iter()
4729 .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin() + 1.0)
4730 .collect();
4731 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4732
4733 let result = detect_peaks(&data, &argvals, None, None, false, None);
4734
4735 assert_eq!(
4737 result.peaks[0].len(),
4738 5,
4739 "Expected 5 peaks for shifted sine, got {}",
4740 result.peaks[0].len()
4741 );
4742
4743 for peak in &result.peaks[0] {
4745 assert!(
4746 (peak.value - 2.0).abs() < 0.05,
4747 "Peak value {:.3} not close to expected 2.0",
4748 peak.value
4749 );
4750 }
4751 }
4752
4753 #[test]
4754 fn test_peak_detection_prominence() {
4755 let m = 200;
4758 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4759 let data: Vec<f64> = argvals
4760 .iter()
4761 .map(|&t| {
4762 let base = (2.0 * std::f64::consts::PI * t / 2.0).sin();
4763 let ripple = 0.1 * (2.0 * std::f64::consts::PI * t * 4.0).sin();
4765 base + ripple
4766 })
4767 .collect();
4768 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4769
4770 let result_no_filter = detect_peaks(&data, &argvals, None, None, false, None);
4772
4773 let result_filtered = detect_peaks(&data, &argvals, None, Some(0.5), false, None);
4775
4776 assert!(
4778 result_filtered.peaks[0].len() <= result_no_filter.peaks[0].len(),
4779 "Prominence filter should reduce peak count"
4780 );
4781 }
4782
4783 #[test]
4784 fn test_peak_detection_different_amplitudes() {
4785 let m = 200;
4787 let period = 2.0;
4788 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4789
4790 for amplitude in [0.5, 1.0, 2.0, 5.0] {
4791 let data: Vec<f64> = argvals
4792 .iter()
4793 .map(|&t| amplitude * (2.0 * std::f64::consts::PI * t / period).sin())
4794 .collect();
4795 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4796
4797 let result = detect_peaks(&data, &argvals, None, None, false, None);
4798
4799 assert_eq!(
4800 result.peaks[0].len(),
4801 5,
4802 "Amplitude {} should still find 5 peaks",
4803 amplitude
4804 );
4805
4806 for peak in &result.peaks[0] {
4808 assert!(
4809 (peak.value - amplitude).abs() < 0.1,
4810 "Peak value {:.3} should be close to amplitude {}",
4811 peak.value,
4812 amplitude
4813 );
4814 }
4815 }
4816 }
4817
4818 #[test]
4819 fn test_peak_detection_varying_frequency() {
4820 let m = 400;
4823 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4824
4825 let data: Vec<f64> = argvals
4828 .iter()
4829 .map(|&t| {
4830 let phase = 2.0 * std::f64::consts::PI * (0.5 * t + 0.05 * t * t);
4831 phase.sin()
4832 })
4833 .collect();
4834 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4835
4836 let result = detect_peaks(&data, &argvals, None, None, false, None);
4837
4838 assert!(
4840 result.peaks[0].len() >= 5,
4841 "Should find at least 5 peaks, got {}",
4842 result.peaks[0].len()
4843 );
4844
4845 let distances = &result.inter_peak_distances[0];
4847 if distances.len() >= 3 {
4848 let early_avg = (distances[0] + distances[1]) / 2.0;
4850 let late_avg = (distances[distances.len() - 2] + distances[distances.len() - 1]) / 2.0;
4851 assert!(
4852 late_avg < early_avg,
4853 "Later peaks should be closer: early avg={:.3}, late avg={:.3}",
4854 early_avg,
4855 late_avg
4856 );
4857 }
4858 }
4859
4860 #[test]
4861 fn test_peak_detection_sum_of_sines() {
4862 let m = 300;
4865 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 12.0 / (m - 1) as f64).collect();
4866
4867 let data: Vec<f64> = argvals
4868 .iter()
4869 .map(|&t| {
4870 (2.0 * std::f64::consts::PI * t / 2.0).sin()
4871 + 0.5 * (2.0 * std::f64::consts::PI * t / 3.0).sin()
4872 })
4873 .collect();
4874 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4875
4876 let result = detect_peaks(&data, &argvals, Some(1.0), None, false, None);
4877
4878 assert!(
4880 result.peaks[0].len() >= 4,
4881 "Should find at least 4 peaks, got {}",
4882 result.peaks[0].len()
4883 );
4884
4885 let distances = &result.inter_peak_distances[0];
4887 if distances.len() >= 2 {
4888 let min_dist = distances.iter().cloned().fold(f64::INFINITY, f64::min);
4889 let max_dist = distances.iter().cloned().fold(0.0, f64::max);
4890 assert!(
4891 max_dist > min_dist * 1.1,
4892 "Distances should vary: min={:.3}, max={:.3}",
4893 min_dist,
4894 max_dist
4895 );
4896 }
4897 }
4898
4899 #[test]
4900 fn test_seasonal_strength() {
4901 let m = 200;
4902 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4903 let period = 2.0;
4904 let data = generate_sine(1, m, period, &argvals);
4905
4906 let strength = seasonal_strength_variance(&data, &argvals, period, 3);
4907 assert!(strength > 0.8);
4909
4910 let strength_spectral = seasonal_strength_spectral(&data, &argvals, period);
4911 assert!(strength_spectral > 0.5);
4912 }
4913
4914 #[test]
4915 fn test_instantaneous_period() {
4916 let m = 200;
4917 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4918 let period = 2.0;
4919 let data = generate_sine(1, m, period, &argvals);
4920
4921 let result = instantaneous_period(&data, &argvals);
4922
4923 let mid_period = result.period[m / 2];
4925 assert!(
4926 (mid_period - period).abs() < 0.5,
4927 "Expected period ~{}, got {}",
4928 period,
4929 mid_period
4930 );
4931 }
4932
4933 #[test]
4934 fn test_peak_timing_analysis() {
4935 let m = 500;
4937 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.02).collect();
4938 let period = 2.0;
4939 let data = generate_sine(1, m, period, &argvals);
4940
4941 let result = analyze_peak_timing(&data, &argvals, period, Some(11));
4942
4943 assert!(!result.peak_times.is_empty());
4945 assert!(result.mean_timing.is_finite());
4947 assert!(result.std_timing < 0.1 || result.std_timing.is_nan());
4949 }
4950
4951 #[test]
4952 fn test_seasonality_classification() {
4953 let m = 400;
4954 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
4955 let period = 2.0;
4956 let data = generate_sine(1, m, period, &argvals);
4957
4958 let result = classify_seasonality(&data, &argvals, period, None, None);
4959
4960 assert!(result.is_seasonal);
4961 assert!(result.seasonal_strength > 0.5);
4962 assert!(matches!(
4963 result.classification,
4964 SeasonalType::StableSeasonal | SeasonalType::VariableTiming
4965 ));
4966 }
4967
4968 #[test]
4969 fn test_otsu_threshold() {
4970 let values = vec![
4972 0.1, 0.12, 0.15, 0.18, 0.11, 0.14, 0.7, 0.75, 0.8, 0.85, 0.9, 0.72,
4973 ];
4974
4975 let threshold = otsu_threshold(&values);
4976
4977 assert!(threshold >= 0.1, "Threshold {} should be >= 0.1", threshold);
4981 assert!(threshold <= 0.9, "Threshold {} should be <= 0.9", threshold);
4982 }
4983
4984 #[test]
4985 fn test_gcv_fourier_nbasis_selection() {
4986 let m = 100;
4987 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4988
4989 let mut data = vec![0.0; m];
4991 for j in 0..m {
4992 data[j] = (2.0 * PI * argvals[j] / 2.0).sin() + 0.1 * (j as f64 * 0.3).sin();
4993 }
4994
4995 let data_mat = crate::matrix::FdMatrix::from_column_major(data, 1, m).unwrap();
4996 let nbasis = crate::basis::select_fourier_nbasis_gcv(&data_mat, &argvals, 5, 25);
4997
4998 assert!(nbasis >= 5);
5000 assert!(nbasis <= 25);
5001 }
5002
5003 #[test]
5004 fn test_detect_multiple_periods() {
5005 let m = 400;
5006 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect(); let period1 = 2.0;
5010 let period2 = 7.0;
5011 let mut data = vec![0.0; m];
5012 for j in 0..m {
5013 data[j] = (2.0 * PI * argvals[j] / period1).sin()
5014 + 0.6 * (2.0 * PI * argvals[j] / period2).sin();
5015 }
5016
5017 let detected = detect_multiple_periods(&data, 1, m, &argvals, 5, 0.4, 0.20);
5019
5020 assert!(
5022 detected.len() >= 2,
5023 "Expected at least 2 periods, found {}",
5024 detected.len()
5025 );
5026
5027 let periods: Vec<f64> = detected.iter().map(|d| d.period).collect();
5029 let has_period1 = periods.iter().any(|&p| (p - period1).abs() < 0.3);
5030 let has_period2 = periods.iter().any(|&p| (p - period2).abs() < 0.5);
5031
5032 assert!(
5033 has_period1,
5034 "Expected to find period ~{}, got {:?}",
5035 period1, periods
5036 );
5037 assert!(
5038 has_period2,
5039 "Expected to find period ~{}, got {:?}",
5040 period2, periods
5041 );
5042
5043 assert!(
5045 detected[0].amplitude > detected[1].amplitude,
5046 "First detected should have higher amplitude"
5047 );
5048
5049 for d in &detected {
5051 assert!(
5052 d.strength > 0.0,
5053 "Detected period should have positive strength"
5054 );
5055 assert!(
5056 d.confidence > 0.0,
5057 "Detected period should have positive confidence"
5058 );
5059 assert!(
5060 d.amplitude > 0.0,
5061 "Detected period should have positive amplitude"
5062 );
5063 }
5064 }
5065
5066 #[test]
5071 fn test_amplitude_modulation_stable() {
5072 let m = 200;
5074 let period = 0.2;
5075 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5076
5077 let data: Vec<f64> = argvals
5079 .iter()
5080 .map(|&t| (2.0 * PI * t / period).sin())
5081 .collect();
5082 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5083
5084 let result = detect_amplitude_modulation(
5085 &data, &argvals, period, 0.15, 0.3, );
5088
5089 eprintln!(
5090 "Stable test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5091 result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5092 );
5093
5094 assert!(result.is_seasonal, "Should detect seasonality");
5095 assert!(
5096 !result.has_modulation,
5097 "Constant amplitude should not have modulation, got score={:.4}",
5098 result.modulation_score
5099 );
5100 assert_eq!(
5101 result.modulation_type,
5102 ModulationType::Stable,
5103 "Should be classified as Stable"
5104 );
5105 }
5106
5107 #[test]
5108 fn test_amplitude_modulation_emerging() {
5109 let m = 200;
5111 let period = 0.2;
5112 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5113
5114 let data: Vec<f64> = argvals
5116 .iter()
5117 .map(|&t| {
5118 let amplitude = 0.2 + 0.8 * t; amplitude * (2.0 * PI * t / period).sin()
5120 })
5121 .collect();
5122
5123 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5124
5125 let result = detect_amplitude_modulation(&data, &argvals, period, 0.15, 0.2);
5126
5127 eprintln!(
5128 "Emerging test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5129 result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5130 );
5131
5132 assert!(result.is_seasonal, "Should detect seasonality");
5133 assert!(
5134 result.has_modulation,
5135 "Growing amplitude should have modulation, score={:.4}",
5136 result.modulation_score
5137 );
5138 assert_eq!(
5139 result.modulation_type,
5140 ModulationType::Emerging,
5141 "Should be classified as Emerging, trend={:.4}",
5142 result.amplitude_trend
5143 );
5144 assert!(
5145 result.amplitude_trend > 0.0,
5146 "Trend should be positive for emerging"
5147 );
5148 }
5149
5150 #[test]
5151 fn test_amplitude_modulation_fading() {
5152 let m = 200;
5154 let period = 0.2;
5155 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5156
5157 let data: Vec<f64> = argvals
5159 .iter()
5160 .map(|&t| {
5161 let amplitude = 1.0 - 0.8 * t; amplitude * (2.0 * PI * t / period).sin()
5163 })
5164 .collect();
5165
5166 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5167
5168 let result = detect_amplitude_modulation(&data, &argvals, period, 0.15, 0.2);
5169
5170 eprintln!(
5171 "Fading test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5172 result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5173 );
5174
5175 assert!(result.is_seasonal, "Should detect seasonality");
5176 assert!(
5177 result.has_modulation,
5178 "Fading amplitude should have modulation"
5179 );
5180 assert_eq!(
5181 result.modulation_type,
5182 ModulationType::Fading,
5183 "Should be classified as Fading, trend={:.4}",
5184 result.amplitude_trend
5185 );
5186 assert!(
5187 result.amplitude_trend < 0.0,
5188 "Trend should be negative for fading"
5189 );
5190 }
5191
5192 #[test]
5193 fn test_amplitude_modulation_oscillating() {
5194 let m = 200;
5196 let period = 0.1;
5197 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5198
5199 let data: Vec<f64> = argvals
5201 .iter()
5202 .map(|&t| {
5203 let amplitude = 0.5 + 0.4 * (2.0 * PI * t * 2.0).sin(); amplitude * (2.0 * PI * t / period).sin()
5205 })
5206 .collect();
5207
5208 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5209
5210 let result = detect_amplitude_modulation(&data, &argvals, period, 0.15, 0.2);
5211
5212 eprintln!(
5213 "Oscillating test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5214 result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5215 );
5216
5217 assert!(result.is_seasonal, "Should detect seasonality");
5218 if result.has_modulation {
5220 assert!(
5222 result.amplitude_trend.abs() < 0.5,
5223 "Trend should be small for oscillating"
5224 );
5225 }
5226 }
5227
5228 #[test]
5229 fn test_amplitude_modulation_non_seasonal() {
5230 let m = 100;
5232 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5233
5234 let data: Vec<f64> = (0..m)
5236 .map(|i| ((i as f64 * 1.618).sin() * 100.0).fract())
5237 .collect();
5238
5239 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5240
5241 let result = detect_amplitude_modulation(
5242 &data, &argvals, 0.2, 0.15, 0.3,
5244 );
5245
5246 assert!(
5247 !result.is_seasonal,
5248 "Noise should not be detected as seasonal"
5249 );
5250 assert_eq!(
5251 result.modulation_type,
5252 ModulationType::NonSeasonal,
5253 "Should be classified as NonSeasonal"
5254 );
5255 }
5256
5257 #[test]
5262 fn test_wavelet_amplitude_modulation_stable() {
5263 let m = 200;
5264 let period = 0.2;
5265 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5266
5267 let data: Vec<f64> = argvals
5268 .iter()
5269 .map(|&t| (2.0 * PI * t / period).sin())
5270 .collect();
5271
5272 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5273
5274 let result = detect_amplitude_modulation_wavelet(&data, &argvals, period, 0.15, 0.3);
5275
5276 eprintln!(
5277 "Wavelet stable: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5278 result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5279 );
5280
5281 assert!(result.is_seasonal, "Should detect seasonality");
5282 assert!(
5283 !result.has_modulation,
5284 "Constant amplitude should not have modulation, got score={:.4}",
5285 result.modulation_score
5286 );
5287 }
5288
5289 #[test]
5290 fn test_wavelet_amplitude_modulation_emerging() {
5291 let m = 200;
5292 let period = 0.2;
5293 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5294
5295 let data: Vec<f64> = argvals
5297 .iter()
5298 .map(|&t| {
5299 let amplitude = 0.2 + 0.8 * t;
5300 amplitude * (2.0 * PI * t / period).sin()
5301 })
5302 .collect();
5303
5304 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5305
5306 let result = detect_amplitude_modulation_wavelet(&data, &argvals, period, 0.15, 0.2);
5307
5308 eprintln!(
5309 "Wavelet emerging: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5310 result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5311 );
5312
5313 assert!(result.is_seasonal, "Should detect seasonality");
5314 assert!(
5315 result.has_modulation,
5316 "Growing amplitude should have modulation"
5317 );
5318 assert!(
5319 result.amplitude_trend > 0.0,
5320 "Trend should be positive for emerging"
5321 );
5322 }
5323
5324 #[test]
5325 fn test_wavelet_amplitude_modulation_fading() {
5326 let m = 200;
5327 let period = 0.2;
5328 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5329
5330 let data: Vec<f64> = argvals
5332 .iter()
5333 .map(|&t| {
5334 let amplitude = 1.0 - 0.8 * t;
5335 amplitude * (2.0 * PI * t / period).sin()
5336 })
5337 .collect();
5338
5339 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5340
5341 let result = detect_amplitude_modulation_wavelet(&data, &argvals, period, 0.15, 0.2);
5342
5343 eprintln!(
5344 "Wavelet fading: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5345 result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5346 );
5347
5348 assert!(result.is_seasonal, "Should detect seasonality");
5349 assert!(
5350 result.has_modulation,
5351 "Fading amplitude should have modulation"
5352 );
5353 assert!(
5354 result.amplitude_trend < 0.0,
5355 "Trend should be negative for fading"
5356 );
5357 }
5358
5359 #[test]
5360 fn test_seasonal_strength_wavelet() {
5361 let m = 200;
5362 let period = 0.2;
5363 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5364
5365 let seasonal_data: Vec<f64> = argvals
5367 .iter()
5368 .map(|&t| (2.0 * PI * t / period).sin())
5369 .collect();
5370
5371 let seasonal_data = FdMatrix::from_column_major(seasonal_data, 1, m).unwrap();
5372
5373 let strength = seasonal_strength_wavelet(&seasonal_data, &argvals, period);
5374 eprintln!("Wavelet strength (pure sine): {:.4}", strength);
5375 assert!(
5376 strength > 0.5,
5377 "Pure sine should have high wavelet strength"
5378 );
5379
5380 let noise_data: Vec<f64> = (0..m)
5382 .map(|i| ((i * 12345 + 67890) % 1000) as f64 / 1000.0 - 0.5)
5383 .collect();
5384
5385 let noise_data = FdMatrix::from_column_major(noise_data, 1, m).unwrap();
5386
5387 let noise_strength = seasonal_strength_wavelet(&noise_data, &argvals, period);
5388 eprintln!("Wavelet strength (noise): {:.4}", noise_strength);
5389 assert!(
5390 noise_strength < 0.3,
5391 "Noise should have low wavelet strength"
5392 );
5393
5394 let wrong_period_strength =
5396 seasonal_strength_wavelet(&seasonal_data, &argvals, period * 2.0);
5397 eprintln!(
5398 "Wavelet strength (wrong period): {:.4}",
5399 wrong_period_strength
5400 );
5401 assert!(
5402 wrong_period_strength < strength,
5403 "Wrong period should have lower strength"
5404 );
5405 }
5406
5407 #[test]
5408 fn test_compute_mean_curve() {
5409 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 2.0, 4.0, 3.0, 6.0], 2, 3).unwrap();
5414 let mean = compute_mean_curve(&data);
5415 assert_eq!(mean.len(), 3);
5416 assert!((mean[0] - 1.5).abs() < 1e-10);
5417 assert!((mean[1] - 3.0).abs() < 1e-10);
5418 assert!((mean[2] - 4.5).abs() < 1e-10);
5419 }
5420
5421 #[test]
5422 fn test_compute_mean_curve_parallel_consistency() {
5423 let n = 10;
5425 let m = 200;
5426 let data: Vec<f64> = (0..n * m).map(|i| (i as f64 * 0.1).sin()).collect();
5427
5428 let data = FdMatrix::from_column_major(data, n, m).unwrap();
5429
5430 let seq_result = compute_mean_curve_impl(&data, false);
5431 let par_result = compute_mean_curve_impl(&data, true);
5432
5433 assert_eq!(seq_result.len(), par_result.len());
5434 for (s, p) in seq_result.iter().zip(par_result.iter()) {
5435 assert!(
5436 (s - p).abs() < 1e-10,
5437 "Sequential and parallel results differ"
5438 );
5439 }
5440 }
5441
5442 #[test]
5443 fn test_interior_bounds() {
5444 let bounds = interior_bounds(100);
5446 assert!(bounds.is_some());
5447 let (start, end) = bounds.unwrap();
5448 assert_eq!(start, 10);
5449 assert_eq!(end, 90);
5450
5451 let bounds = interior_bounds(10);
5453 assert!(bounds.is_some());
5454 let (start, end) = bounds.unwrap();
5455 assert!(start < end);
5456
5457 let bounds = interior_bounds(2);
5459 assert!(bounds.is_some() || bounds.is_none());
5461 }
5462
5463 #[test]
5464 fn test_hilbert_transform_pure_sine() {
5465 let m = 200;
5467 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5468 let signal: Vec<f64> = argvals.iter().map(|&t| (2.0 * PI * t).sin()).collect();
5469
5470 let analytic = hilbert_transform(&signal);
5471 assert_eq!(analytic.len(), m);
5472
5473 for c in analytic.iter().skip(10).take(m - 20) {
5475 let amp = c.norm();
5476 assert!(
5477 (amp - 1.0).abs() < 0.1,
5478 "Amplitude should be ~1, got {}",
5479 amp
5480 );
5481 }
5482 }
5483
5484 #[test]
5485 fn test_sazed_pure_sine() {
5486 let m = 200;
5488 let period = 2.0;
5489 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5490 let data: Vec<f64> = argvals
5491 .iter()
5492 .map(|&t| (2.0 * PI * t / period).sin())
5493 .collect();
5494
5495 let result = sazed(&data, &argvals, None);
5496
5497 assert!(result.period.is_finite(), "SAZED should detect a period");
5498 assert!(
5499 (result.period - period).abs() < 0.3,
5500 "Expected period ~{}, got {}",
5501 period,
5502 result.period
5503 );
5504 assert!(
5505 result.confidence > 0.4,
5506 "Expected confidence > 0.4, got {}",
5507 result.confidence
5508 );
5509 assert!(
5510 result.agreeing_components >= 2,
5511 "Expected at least 2 agreeing components, got {}",
5512 result.agreeing_components
5513 );
5514 }
5515
5516 #[test]
5517 fn test_sazed_noisy_sine() {
5518 let m = 300;
5520 let period = 3.0;
5521 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5522
5523 let data: Vec<f64> = argvals
5525 .iter()
5526 .enumerate()
5527 .map(|(i, &t)| {
5528 let signal = (2.0 * PI * t / period).sin();
5529 let noise = 0.1 * (17.3 * i as f64).sin();
5530 signal + noise
5531 })
5532 .collect();
5533
5534 let result = sazed(&data, &argvals, Some(0.15));
5535
5536 assert!(
5537 result.period.is_finite(),
5538 "SAZED should detect a period even with noise"
5539 );
5540 assert!(
5541 (result.period - period).abs() < 0.5,
5542 "Expected period ~{}, got {}",
5543 period,
5544 result.period
5545 );
5546 }
5547
5548 #[test]
5549 fn test_sazed_fdata() {
5550 let n = 5;
5552 let m = 200;
5553 let period = 2.0;
5554 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5555 let data = generate_sine(n, m, period, &argvals);
5556
5557 let result = sazed_fdata(&data, &argvals, None);
5558
5559 assert!(result.period.is_finite(), "SAZED should detect a period");
5560 assert!(
5561 (result.period - period).abs() < 0.3,
5562 "Expected period ~{}, got {}",
5563 period,
5564 result.period
5565 );
5566 }
5567
5568 #[test]
5569 fn test_sazed_short_series() {
5570 let argvals: Vec<f64> = (0..5).map(|i| i as f64).collect();
5572 let data: Vec<f64> = argvals.iter().map(|&t| t.sin()).collect();
5573
5574 let result = sazed(&data, &argvals, None);
5575
5576 assert!(
5578 result.period.is_nan() || result.period.is_finite(),
5579 "Should return NaN or valid period"
5580 );
5581 }
5582
5583 #[test]
5584 fn test_autoperiod_pure_sine() {
5585 let m = 200;
5587 let period = 2.0;
5588 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5589 let data: Vec<f64> = argvals
5590 .iter()
5591 .map(|&t| (2.0 * PI * t / period).sin())
5592 .collect();
5593
5594 let result = autoperiod(&data, &argvals, None, None);
5595
5596 assert!(
5597 result.period.is_finite(),
5598 "Autoperiod should detect a period"
5599 );
5600 assert!(
5601 (result.period - period).abs() < 0.3,
5602 "Expected period ~{}, got {}",
5603 period,
5604 result.period
5605 );
5606 assert!(
5607 result.confidence > 0.0,
5608 "Expected positive confidence, got {}",
5609 result.confidence
5610 );
5611 }
5612
5613 #[test]
5614 fn test_autoperiod_with_trend() {
5615 let m = 300;
5617 let period = 3.0;
5618 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5619 let data: Vec<f64> = argvals
5620 .iter()
5621 .map(|&t| 0.2 * t + (2.0 * PI * t / period).sin())
5622 .collect();
5623
5624 let result = autoperiod(&data, &argvals, None, None);
5625
5626 assert!(
5627 result.period.is_finite(),
5628 "Autoperiod should detect a period"
5629 );
5630 assert!(
5632 (result.period - period).abs() < 0.5,
5633 "Expected period ~{}, got {}",
5634 period,
5635 result.period
5636 );
5637 }
5638
5639 #[test]
5640 fn test_autoperiod_candidates() {
5641 let m = 200;
5643 let period = 2.0;
5644 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5645 let data: Vec<f64> = argvals
5646 .iter()
5647 .map(|&t| (2.0 * PI * t / period).sin())
5648 .collect();
5649
5650 let result = autoperiod(&data, &argvals, Some(5), Some(10));
5651
5652 assert!(
5653 !result.candidates.is_empty(),
5654 "Should have at least one candidate"
5655 );
5656
5657 let max_score = result
5659 .candidates
5660 .iter()
5661 .map(|c| c.combined_score)
5662 .fold(f64::NEG_INFINITY, f64::max);
5663 assert!(
5664 (result.confidence - max_score).abs() < 1e-10,
5665 "Returned confidence should match best candidate's score"
5666 );
5667 }
5668
5669 #[test]
5670 fn test_autoperiod_fdata() {
5671 let n = 5;
5673 let m = 200;
5674 let period = 2.0;
5675 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5676 let data = generate_sine(n, m, period, &argvals);
5677
5678 let result = autoperiod_fdata(&data, &argvals, None, None);
5679
5680 assert!(
5681 result.period.is_finite(),
5682 "Autoperiod should detect a period"
5683 );
5684 assert!(
5685 (result.period - period).abs() < 0.3,
5686 "Expected period ~{}, got {}",
5687 period,
5688 result.period
5689 );
5690 }
5691
5692 #[test]
5693 fn test_cfd_autoperiod_pure_sine() {
5694 let m = 200;
5696 let period = 2.0;
5697 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5698 let data: Vec<f64> = argvals
5699 .iter()
5700 .map(|&t| (2.0 * PI * t / period).sin())
5701 .collect();
5702
5703 let result = cfd_autoperiod(&data, &argvals, None, None);
5704
5705 assert!(
5706 result.period.is_finite(),
5707 "CFDAutoperiod should detect a period"
5708 );
5709 assert!(
5710 (result.period - period).abs() < 0.3,
5711 "Expected period ~{}, got {}",
5712 period,
5713 result.period
5714 );
5715 }
5716
5717 #[test]
5718 fn test_cfd_autoperiod_with_trend() {
5719 let m = 300;
5721 let period = 3.0;
5722 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5723 let data: Vec<f64> = argvals
5724 .iter()
5725 .map(|&t| 2.0 * t + (2.0 * PI * t / period).sin())
5726 .collect();
5727
5728 let result = cfd_autoperiod(&data, &argvals, None, None);
5729
5730 assert!(
5731 result.period.is_finite(),
5732 "CFDAutoperiod should detect a period despite trend"
5733 );
5734 assert!(
5736 (result.period - period).abs() < 0.6,
5737 "Expected period ~{}, got {}",
5738 period,
5739 result.period
5740 );
5741 }
5742
5743 #[test]
5744 fn test_cfd_autoperiod_multiple_periods() {
5745 let m = 400;
5747 let period1 = 2.0;
5748 let period2 = 5.0;
5749 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5750 let data: Vec<f64> = argvals
5751 .iter()
5752 .map(|&t| (2.0 * PI * t / period1).sin() + 0.5 * (2.0 * PI * t / period2).sin())
5753 .collect();
5754
5755 let result = cfd_autoperiod(&data, &argvals, None, None);
5756
5757 assert!(
5758 !result.periods.is_empty(),
5759 "Should detect at least one period"
5760 );
5761 let close_to_p1 = (result.period - period1).abs() < 0.5;
5763 let close_to_p2 = (result.period - period2).abs() < 1.0;
5764 assert!(
5765 close_to_p1 || close_to_p2,
5766 "Primary period {} not close to {} or {}",
5767 result.period,
5768 period1,
5769 period2
5770 );
5771 }
5772
5773 #[test]
5774 fn test_cfd_autoperiod_fdata() {
5775 let n = 5;
5777 let m = 200;
5778 let period = 2.0;
5779 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5780 let data = generate_sine(n, m, period, &argvals);
5781
5782 let result = cfd_autoperiod_fdata(&data, &argvals, None, None);
5783
5784 assert!(
5785 result.period.is_finite(),
5786 "CFDAutoperiod should detect a period"
5787 );
5788 assert!(
5789 (result.period - period).abs() < 0.3,
5790 "Expected period ~{}, got {}",
5791 period,
5792 result.period
5793 );
5794 }
5795
5796 #[test]
5801 fn test_ssa_pure_sine() {
5802 let n = 200;
5803 let period = 12.0;
5804 let values: Vec<f64> = (0..n)
5805 .map(|i| {
5806 let t = i as f64;
5807 0.01 * t + (2.0 * PI * t / period).sin() + 0.05 * ((i * 7) as f64 * 0.1).sin()
5808 })
5809 .collect();
5810
5811 let result = ssa(&values, None, None, None, None);
5812
5813 for i in 0..n {
5815 let reconstructed = result.trend[i] + result.seasonal[i] + result.noise[i];
5816 assert!(
5817 (reconstructed - values[i]).abs() < 1e-8,
5818 "SSA reconstruction error at {}: {} vs {}",
5819 i,
5820 reconstructed,
5821 values[i]
5822 );
5823 }
5824
5825 for i in 1..result.singular_values.len() {
5827 assert!(
5828 result.singular_values[i] <= result.singular_values[i - 1] + 1e-10,
5829 "Singular values should be descending: {} > {}",
5830 result.singular_values[i],
5831 result.singular_values[i - 1]
5832 );
5833 }
5834
5835 let total_contrib: f64 = result.contributions.iter().sum();
5837 assert!(
5838 total_contrib <= 1.0 + 1e-10,
5839 "Contributions should sum to <= 1, got {}",
5840 total_contrib
5841 );
5842 }
5843
5844 #[test]
5845 fn test_ssa_explicit_groupings() {
5846 let n = 100;
5847 let period = 10.0;
5848 let values: Vec<f64> = (0..n)
5849 .map(|i| 0.01 * i as f64 + (2.0 * PI * i as f64 / period).sin())
5850 .collect();
5851
5852 let trend_components = [0usize];
5853 let seasonal_components = [1usize, 2];
5854
5855 let result = ssa(
5856 &values,
5857 None,
5858 None,
5859 Some(&trend_components),
5860 Some(&seasonal_components),
5861 );
5862
5863 assert_eq!(result.trend.len(), n);
5864 assert_eq!(result.seasonal.len(), n);
5865 assert_eq!(result.noise.len(), n);
5866
5867 for i in 0..n {
5869 let reconstructed = result.trend[i] + result.seasonal[i] + result.noise[i];
5870 assert!(
5871 (reconstructed - values[i]).abs() < 1e-8,
5872 "SSA explicit grouping reconstruction error at {}",
5873 i
5874 );
5875 }
5876 }
5877
5878 #[test]
5879 fn test_ssa_short_series() {
5880 let values = vec![1.0, 2.0, 3.0];
5882 let result = ssa(&values, None, None, None, None);
5883
5884 assert_eq!(result.trend, values);
5885 assert_eq!(result.seasonal, vec![0.0; 3]);
5886 assert_eq!(result.noise, vec![0.0; 3]);
5887 assert_eq!(result.n_components, 0);
5888 }
5889
5890 #[test]
5891 fn test_ssa_fdata() {
5892 let n = 5;
5893 let m = 100;
5894 let mut data = vec![0.0; n * m];
5895 for i in 0..n {
5896 let amp = (i + 1) as f64;
5897 for j in 0..m {
5898 data[i + j * n] = amp * (2.0 * PI * j as f64 / 12.0).sin() + 0.01 * j as f64;
5899 }
5900 }
5901
5902 let data = FdMatrix::from_column_major(data, n, m).unwrap();
5903
5904 let result = ssa_fdata(&data, None, None);
5905
5906 assert_eq!(result.trend.len(), m);
5907 assert_eq!(result.seasonal.len(), m);
5908 assert_eq!(result.noise.len(), m);
5909 assert!(!result.singular_values.is_empty());
5910 }
5911
5912 #[test]
5913 fn test_ssa_seasonality() {
5914 let n = 200;
5916 let period = 12.0;
5917 let seasonal_values: Vec<f64> = (0..n)
5918 .map(|i| (2.0 * PI * i as f64 / period).sin())
5919 .collect();
5920
5921 let (is_seasonal, _det_period, confidence) =
5922 ssa_seasonality(&seasonal_values, None, Some(0.05));
5923
5924 assert!(confidence >= 0.0, "Confidence should be non-negative");
5927
5928 let noise_values: Vec<f64> = (0..n)
5930 .map(|i| ((i * 13 + 7) as f64 * 0.1).sin() * 0.01)
5931 .collect();
5932
5933 let (is_noise_seasonal, _, noise_conf) = ssa_seasonality(&noise_values, None, Some(0.5));
5934
5935 let _ = (is_seasonal, is_noise_seasonal, noise_conf);
5938 }
5939
5940 #[test]
5945 fn test_matrix_profile_periodic() {
5946 let period = 20;
5947 let n = period * 10;
5948 let values: Vec<f64> = (0..n)
5949 .map(|i| (2.0 * PI * i as f64 / period as f64).sin())
5950 .collect();
5951
5952 let result = matrix_profile(&values, Some(15), None);
5953
5954 assert_eq!(result.profile.len(), n - 15 + 1);
5955 assert_eq!(result.profile_index.len(), n - 15 + 1);
5956 assert_eq!(result.subsequence_length, 15);
5957
5958 for &p in &result.profile {
5960 assert!(p.is_finite(), "Profile values should be finite");
5961 }
5962
5963 assert!(
5965 (result.primary_period - period as f64).abs() < 5.0,
5966 "Expected primary_period ≈ {}, got {}",
5967 period,
5968 result.primary_period
5969 );
5970 }
5971
5972 #[test]
5973 fn test_matrix_profile_non_periodic() {
5974 let n = 200;
5976 let values: Vec<f64> = (0..n).map(|i| i as f64 * 0.1).collect();
5977
5978 let result = matrix_profile(&values, Some(10), None);
5979
5980 assert_eq!(result.profile.len(), n - 10 + 1);
5981
5982 assert!(result.confidence <= 1.0, "Confidence should be <= 1.0");
5985 }
5986
5987 #[test]
5988 fn test_matrix_profile_fdata() {
5989 let n = 3;
5990 let m = 200;
5991 let period = 20.0;
5992 let mut data = vec![0.0; n * m];
5993 for i in 0..n {
5994 let amp = (i + 1) as f64;
5995 for j in 0..m {
5996 data[i + j * n] = amp * (2.0 * PI * j as f64 / period).sin();
5997 }
5998 }
5999
6000 let data = FdMatrix::from_column_major(data, n, m).unwrap();
6001
6002 let result = matrix_profile_fdata(&data, Some(15), None);
6003
6004 assert!(!result.profile.is_empty());
6005 assert!(result.profile_index.len() == result.profile.len());
6006 }
6007
6008 #[test]
6009 fn test_matrix_profile_seasonality() {
6010 let period = 20;
6011 let n = period * 10;
6012 let values: Vec<f64> = (0..n)
6014 .map(|i| (2.0 * PI * i as f64 / period as f64).sin())
6015 .collect();
6016
6017 let (is_seasonal, det_period, confidence) =
6018 matrix_profile_seasonality(&values, Some(15), Some(0.05));
6019
6020 assert!(
6021 is_seasonal,
6022 "Periodic signal should be detected as seasonal"
6023 );
6024 assert!(det_period > 0.0, "Detected period should be positive");
6025 assert!(confidence >= 0.05, "Confidence should be above threshold");
6026
6027 let weak_values: Vec<f64> = (0..n).map(|i| i as f64 * 0.001).collect();
6029 let (is_weak_seasonal, _, _) =
6030 matrix_profile_seasonality(&weak_values, Some(15), Some(0.5));
6031 let _ = is_weak_seasonal; }
6033
6034 #[test]
6039 fn test_lomb_scargle_regular() {
6040 let n = 200;
6041 let true_period = 5.0;
6042 let times: Vec<f64> = (0..n).map(|i| i as f64 * 0.1).collect();
6043 let values: Vec<f64> = times
6044 .iter()
6045 .map(|&t| (2.0 * PI * t / true_period).sin())
6046 .collect();
6047
6048 let result = lomb_scargle(×, &values, None, None, None);
6049
6050 assert!(
6051 (result.peak_period - true_period).abs() < 0.5,
6052 "Expected peak_period ≈ {}, got {}",
6053 true_period,
6054 result.peak_period
6055 );
6056 assert!(
6057 result.false_alarm_probability < 0.05,
6058 "FAP should be low for strong signal: {}",
6059 result.false_alarm_probability
6060 );
6061 assert!(result.peak_power > 0.0, "Peak power should be positive");
6062 assert!(!result.frequencies.is_empty());
6063 assert_eq!(result.frequencies.len(), result.power.len());
6064 assert_eq!(result.frequencies.len(), result.periods.len());
6065 }
6066
6067 #[test]
6068 fn test_lomb_scargle_irregular() {
6069 let true_period = 5.0;
6070 let all_times: Vec<f64> = (0..300).map(|i| i as f64 * 0.1).collect();
6072 let times: Vec<f64> = all_times
6074 .iter()
6075 .enumerate()
6076 .filter(|(i, _)| i % 2 == 0 || i % 3 == 0)
6077 .map(|(_, &t)| t)
6078 .collect();
6079 let values: Vec<f64> = times
6080 .iter()
6081 .map(|&t| (2.0 * PI * t / true_period).sin())
6082 .collect();
6083
6084 let result = lomb_scargle(×, &values, None, None, None);
6085
6086 assert!(
6087 (result.peak_period - true_period).abs() < 1.0,
6088 "Irregular LS: expected period ≈ {}, got {}",
6089 true_period,
6090 result.peak_period
6091 );
6092 }
6093
6094 #[test]
6095 fn test_lomb_scargle_custom_frequencies() {
6096 let n = 100;
6097 let true_period = 5.0;
6098 let times: Vec<f64> = (0..n).map(|i| i as f64 * 0.1).collect();
6099 let values: Vec<f64> = times
6100 .iter()
6101 .map(|&t| (2.0 * PI * t / true_period).sin())
6102 .collect();
6103
6104 let frequencies: Vec<f64> = (1..50).map(|i| i as f64 * 0.01).collect();
6106 let result = lomb_scargle(×, &values, Some(&frequencies), None, None);
6107
6108 assert_eq!(result.frequencies.len(), frequencies.len());
6109 assert_eq!(result.power.len(), frequencies.len());
6110 assert!(result.peak_power > 0.0);
6111 }
6112
6113 #[test]
6114 fn test_lomb_scargle_fdata() {
6115 let n = 5;
6116 let m = 200;
6117 let period = 5.0;
6118 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6119 let data = generate_sine(n, m, period, &argvals);
6120
6121 let result = lomb_scargle_fdata(&data, &argvals, None, None);
6122
6123 assert!(
6124 (result.peak_period - period).abs() < 0.5,
6125 "Fdata LS: expected period ≈ {}, got {}",
6126 period,
6127 result.peak_period
6128 );
6129 assert!(!result.frequencies.is_empty());
6130 }
6131
6132 #[test]
6137 fn test_detect_seasonality_changes_onset() {
6138 let m = 400;
6139 let period = 2.0;
6140 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect(); let data: Vec<f64> = argvals
6144 .iter()
6145 .map(|&t| {
6146 if t < 10.0 {
6147 0.05 * ((t * 13.0).sin() + (t * 7.0).cos())
6149 } else {
6150 (2.0 * PI * t / period).sin()
6152 }
6153 })
6154 .collect();
6155 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6156
6157 let result = detect_seasonality_changes(&data, &argvals, period, 0.3, 4.0, 2.0);
6158
6159 assert!(
6160 !result.strength_curve.is_empty(),
6161 "Strength curve should not be empty"
6162 );
6163 assert_eq!(result.strength_curve.len(), m);
6164
6165 if !result.change_points.is_empty() {
6167 let onset_points: Vec<_> = result
6168 .change_points
6169 .iter()
6170 .filter(|cp| cp.change_type == ChangeType::Onset)
6171 .collect();
6172 assert!(!onset_points.is_empty(), "Should detect Onset change point");
6174 }
6175 }
6176
6177 #[test]
6178 fn test_detect_seasonality_changes_no_change() {
6179 let m = 400;
6180 let period = 2.0;
6181 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6182
6183 let data: Vec<f64> = argvals
6185 .iter()
6186 .map(|&t| (2.0 * PI * t / period).sin())
6187 .collect();
6188 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6189
6190 let result = detect_seasonality_changes(&data, &argvals, period, 0.3, 4.0, 2.0);
6191
6192 assert!(!result.strength_curve.is_empty());
6193 let cessation_points: Vec<_> = result
6195 .change_points
6196 .iter()
6197 .filter(|cp| cp.change_type == ChangeType::Cessation)
6198 .collect();
6199 assert!(
6200 cessation_points.is_empty(),
6201 "Consistently seasonal signal should have no Cessation points, found {}",
6202 cessation_points.len()
6203 );
6204 }
6205
6206 #[test]
6211 fn test_estimate_period_acf() {
6212 let m = 200;
6213 let period = 2.0;
6214 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6215 let data = generate_sine(1, m, period, &argvals);
6216
6217 let estimate = estimate_period_acf(data.as_slice(), 1, m, &argvals, m / 2);
6218
6219 assert!(
6220 estimate.period.is_finite(),
6221 "ACF period estimate should be finite"
6222 );
6223 assert!(
6224 (estimate.period - period).abs() < 0.5,
6225 "ACF expected period ≈ {}, got {}",
6226 period,
6227 estimate.period
6228 );
6229 assert!(
6230 estimate.confidence > 0.0,
6231 "ACF confidence should be positive"
6232 );
6233 }
6234
6235 #[test]
6236 fn test_estimate_period_regression() {
6237 let m = 200;
6238 let period = 2.0;
6239 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6240 let data = generate_sine(1, m, period, &argvals);
6241
6242 let estimate =
6243 estimate_period_regression(data.as_slice(), 1, m, &argvals, 1.5, 3.0, 100, 3);
6244
6245 assert!(
6246 estimate.period.is_finite(),
6247 "Regression period estimate should be finite"
6248 );
6249 assert!(
6250 (estimate.period - period).abs() < 0.5,
6251 "Regression expected period ≈ {}, got {}",
6252 period,
6253 estimate.period
6254 );
6255 assert!(
6256 estimate.confidence > 0.0,
6257 "Regression confidence should be positive"
6258 );
6259 }
6260
6261 #[test]
6266 fn test_seasonal_strength_windowed_variance() {
6267 let m = 200;
6268 let period = 2.0;
6269 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6270 let data = generate_sine(1, m, period, &argvals);
6271
6272 let strengths = seasonal_strength_windowed(
6273 &data,
6274 &argvals,
6275 period,
6276 4.0, StrengthMethod::Variance,
6278 );
6279
6280 assert_eq!(strengths.len(), m, "Should return m values");
6281
6282 let interior_start = m / 4;
6284 let interior_end = 3 * m / 4;
6285 for i in interior_start..interior_end {
6286 let s = strengths[i];
6287 if s.is_finite() {
6288 assert!(
6289 (-0.01..=1.01).contains(&s),
6290 "Windowed strength at {} should be near [0,1], got {}",
6291 i,
6292 s
6293 );
6294 }
6295 }
6296 }
6297
6298 #[test]
6299 fn test_constant_signal_fft_period() {
6300 let m = 100;
6302 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 1.0).collect();
6303 let data_vec: Vec<f64> = vec![5.0; m];
6304 let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
6305 let result = estimate_period_fft(&data, &argvals);
6306 assert!(result.period.is_finite() || result.period.is_nan());
6308 }
6309
6310 #[test]
6311 fn test_very_short_series_period() {
6312 let argvals = vec![0.0, 1.0, 2.0, 3.0];
6314 let data_vec = vec![1.0, -1.0, 1.0, -1.0];
6315 let data = FdMatrix::from_column_major(data_vec, 1, 4).unwrap();
6316 let result = estimate_period_fft(&data, &argvals);
6317 assert!(result.period.is_finite() || result.period.is_nan());
6318 }
6319
6320 #[test]
6321 fn test_nan_sazed_no_panic() {
6322 let mut data = vec![0.0; 50];
6323 let argvals: Vec<f64> = (0..50).map(|i| i as f64).collect();
6324 for i in 0..50 {
6325 data[i] = (2.0 * std::f64::consts::PI * i as f64 / 10.0).sin();
6326 }
6327 data[10] = f64::NAN;
6328 let result = sazed(&data, &argvals, None);
6329 assert!(result.period.is_finite() || result.period.is_nan());
6331 }
6332
6333 #[test]
6338 fn test_interior_bounds_very_small() {
6339 let bounds = interior_bounds(0);
6341 assert!(bounds.is_none());
6342
6343 let bounds = interior_bounds(1);
6345 assert!(bounds.is_some() || bounds.is_none());
6348 }
6349
6350 #[test]
6351 fn test_valid_interior_bounds_min_span() {
6352 let bounds = valid_interior_bounds(10, 4);
6354 assert!(bounds.is_some());
6356
6357 let bounds = valid_interior_bounds(10, 100);
6359 assert!(bounds.is_none());
6360 }
6361
6362 #[test]
6363 fn test_periodogram_edge_cases() {
6364 let (freqs, power) = periodogram(&[], &[]);
6366 assert!(freqs.is_empty());
6367 assert!(power.is_empty());
6368
6369 let (freqs, power) = periodogram(&[1.0], &[0.0]);
6371 assert!(freqs.is_empty());
6372 assert!(power.is_empty());
6373
6374 let (freqs, power) = periodogram(&[1.0, 2.0], &[0.0]);
6376 assert!(freqs.is_empty());
6377 assert!(power.is_empty());
6378 }
6379
6380 #[test]
6381 fn test_autocorrelation_edge_cases() {
6382 let acf = autocorrelation(&[], 10);
6384 assert!(acf.is_empty());
6385
6386 let acf = autocorrelation(&[5.0, 5.0, 5.0, 5.0], 3);
6388 assert_eq!(acf.len(), 4);
6389 for &v in &acf {
6390 assert!((v - 1.0).abs() < 1e-10, "Constant data ACF should be 1.0");
6391 }
6392 }
6393
6394 #[test]
6395 fn test_detect_seasonality_changes_empty_data() {
6396 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6398 let argvals: Vec<f64> = vec![];
6399 let result = detect_seasonality_changes(&data, &argvals, 2.0, 0.3, 4.0, 2.0);
6400 assert!(result.change_points.is_empty());
6401 assert!(result.strength_curve.is_empty());
6402
6403 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6405 let argvals = vec![0.0, 1.0, 2.0];
6406 let result = detect_seasonality_changes(&data, &argvals, 2.0, 0.3, 4.0, 2.0);
6407 assert!(result.change_points.is_empty());
6408 assert!(result.strength_curve.is_empty());
6409 }
6410
6411 #[test]
6412 fn test_detect_amplitude_modulation_non_seasonal_returns_early() {
6413 let m = 100;
6415 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
6416
6417 let data: Vec<f64> = (0..m)
6419 .map(|i| ((i as f64 * 1.618).sin() * 100.0).fract())
6420 .collect();
6421 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6422
6423 let result = detect_amplitude_modulation(&data, &argvals, 0.2, 0.15, 0.5);
6424 assert!(!result.is_seasonal);
6425 assert_eq!(result.modulation_type, ModulationType::NonSeasonal);
6426 assert_eq!(result.modulation_score, 0.0);
6427 }
6428
6429 #[test]
6430 fn test_detect_amplitude_modulation_small_data() {
6431 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6433 let argvals = vec![0.0, 1.0, 2.0];
6434
6435 let result = detect_amplitude_modulation(&data, &argvals, 1.0, 0.15, 0.3);
6436 assert!(!result.is_seasonal);
6437 }
6438
6439 #[test]
6440 fn test_detect_amplitude_modulation_wavelet_invalid_inputs() {
6441 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6443 let result = detect_amplitude_modulation_wavelet(&data, &[], 2.0, 0.15, 0.3);
6444 assert!(!result.is_seasonal);
6445 assert_eq!(result.modulation_type, ModulationType::NonSeasonal);
6446
6447 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6449 let argvals = vec![0.0, 1.0, 2.0];
6450 let result = detect_amplitude_modulation_wavelet(&data, &argvals, 2.0, 0.15, 0.3);
6451 assert!(!result.is_seasonal);
6452
6453 let m = 100;
6455 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
6456 let data: Vec<f64> = argvals
6457 .iter()
6458 .map(|&t| (2.0 * PI * t / 0.2).sin())
6459 .collect();
6460 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6461 let result = detect_amplitude_modulation_wavelet(&data, &argvals, -1.0, 0.15, 0.3);
6462 assert!(!result.is_seasonal);
6463 }
6464
6465 #[test]
6466 fn test_detect_amplitude_modulation_wavelet_non_seasonal() {
6467 let m = 100;
6469 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
6470
6471 let data: Vec<f64> = (0..m)
6473 .map(|i| ((i as f64 * 1.618).sin() * 100.0).fract())
6474 .collect();
6475 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6476
6477 let result = detect_amplitude_modulation_wavelet(&data, &argvals, 0.2, 0.15, 0.5);
6478 assert!(!result.is_seasonal);
6479 assert_eq!(result.modulation_type, ModulationType::NonSeasonal);
6480 }
6481
6482 #[test]
6483 fn test_detect_amplitude_modulation_wavelet_seasonal() {
6484 let m = 200;
6486 let period = 0.2;
6487 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
6488
6489 let data: Vec<f64> = argvals
6491 .iter()
6492 .map(|&t| {
6493 let amplitude = 0.2 + 0.8 * t;
6494 amplitude * (2.0 * PI * t / period).sin()
6495 })
6496 .collect();
6497 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6498
6499 let result = detect_amplitude_modulation_wavelet(&data, &argvals, period, 0.15, 0.2);
6500 assert!(result.is_seasonal, "Should detect seasonality");
6501 assert!(result.scale > 0.0, "Scale should be positive");
6502 assert!(
6503 !result.wavelet_amplitude.is_empty(),
6504 "Wavelet amplitude should be computed"
6505 );
6506 assert_eq!(result.time_points.len(), m);
6507 }
6508
6509 #[test]
6510 fn test_instantaneous_period_invalid_inputs() {
6511 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6513 let result = instantaneous_period(&data, &[]);
6514 assert!(result.period.is_empty());
6515 assert!(result.frequency.is_empty());
6516 assert!(result.amplitude.is_empty());
6517
6518 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6520 let result = instantaneous_period(&data, &[0.0, 1.0, 2.0]);
6521 assert!(result.period.is_empty());
6522 }
6523
6524 #[test]
6525 fn test_analyze_peak_timing_invalid_inputs() {
6526 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6528 let result = analyze_peak_timing(&data, &[], 2.0, None);
6529 assert!(result.peak_times.is_empty());
6530 assert!(result.mean_timing.is_nan());
6531
6532 let data = FdMatrix::from_column_major(vec![1.0, 2.0], 1, 2).unwrap();
6534 let result = analyze_peak_timing(&data, &[0.0, 1.0], 2.0, None);
6535 assert!(result.peak_times.is_empty());
6536 assert!(result.mean_timing.is_nan());
6537
6538 let m = 100;
6540 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6541 let data = generate_sine(1, m, 2.0, &argvals);
6542 let result = analyze_peak_timing(&data, &argvals, -1.0, None);
6543 assert!(result.peak_times.is_empty());
6544 assert!(result.mean_timing.is_nan());
6545 }
6546
6547 #[test]
6548 fn test_analyze_peak_timing_no_peaks() {
6549 let data = FdMatrix::from_column_major(vec![5.0, 5.0], 1, 2).unwrap();
6551 let result = analyze_peak_timing(&data, &[0.0, 1.0], 2.0, Some(11));
6552 assert!(result.peak_times.is_empty());
6553 assert!(result.mean_timing.is_nan());
6554 }
6555
6556 #[test]
6557 fn test_classify_seasonality_invalid_inputs() {
6558 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6560 let result = classify_seasonality(&data, &[], 2.0, None, None);
6561 assert!(!result.is_seasonal);
6562 assert!(result.seasonal_strength.is_nan());
6563 assert_eq!(result.classification, SeasonalType::NonSeasonal);
6564
6565 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6567 let result = classify_seasonality(&data, &[0.0, 1.0, 2.0], 2.0, None, None);
6568 assert!(!result.is_seasonal);
6569 assert_eq!(result.classification, SeasonalType::NonSeasonal);
6570
6571 let m = 100;
6573 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6574 let data = generate_sine(1, m, 2.0, &argvals);
6575 let result = classify_seasonality(&data, &argvals, -1.0, None, None);
6576 assert!(!result.is_seasonal);
6577 }
6578
6579 #[test]
6580 fn test_classify_seasonality_non_seasonal() {
6581 let m = 100;
6583 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6584 let data = FdMatrix::from_column_major(vec![5.0; m], 1, m).unwrap();
6585
6586 let result = classify_seasonality(&data, &argvals, 2.0, Some(0.3), Some(0.05));
6587 assert!(!result.is_seasonal);
6588 assert_eq!(result.classification, SeasonalType::NonSeasonal);
6589 }
6590
6591 #[test]
6592 fn test_classify_seasonality_strong_seasonal() {
6593 let m = 400;
6595 let period = 2.0;
6596 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6597 let data = generate_sine(1, m, period, &argvals);
6598
6599 let result = classify_seasonality(&data, &argvals, period, Some(0.3), Some(0.5));
6600 assert!(result.is_seasonal);
6601 assert!(result.seasonal_strength > 0.5);
6602 assert!(result.peak_timing.is_some());
6603 assert!(
6605 !result.cycle_strengths.is_empty(),
6606 "cycle_strengths should be computed"
6607 );
6608 }
6609
6610 #[test]
6611 fn test_classify_seasonality_with_custom_thresholds() {
6612 let m = 400;
6613 let period = 2.0;
6614 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6615 let data = generate_sine(1, m, period, &argvals);
6616
6617 let result = classify_seasonality(&data, &argvals, period, Some(0.99), None);
6619 assert!(result.seasonal_strength > 0.8);
6621 }
6622
6623 #[test]
6624 fn test_detect_seasonality_changes_auto_fixed() {
6625 let m = 400;
6626 let period = 2.0;
6627 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6628
6629 let data: Vec<f64> = argvals
6631 .iter()
6632 .map(|&t| {
6633 if t < 10.0 {
6634 0.05 * ((t * 13.0).sin() + (t * 7.0).cos())
6635 } else {
6636 (2.0 * PI * t / period).sin()
6637 }
6638 })
6639 .collect();
6640 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6641
6642 let result = detect_seasonality_changes_auto(
6644 &data,
6645 &argvals,
6646 period,
6647 ThresholdMethod::Fixed(0.3),
6648 4.0,
6649 2.0,
6650 );
6651 assert!(!result.strength_curve.is_empty());
6652 }
6653
6654 #[test]
6655 fn test_detect_seasonality_changes_auto_percentile() {
6656 let m = 400;
6657 let period = 2.0;
6658 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6659
6660 let data: Vec<f64> = argvals
6661 .iter()
6662 .map(|&t| {
6663 if t < 10.0 {
6664 0.05 * ((t * 13.0).sin() + (t * 7.0).cos())
6665 } else {
6666 (2.0 * PI * t / period).sin()
6667 }
6668 })
6669 .collect();
6670 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6671
6672 let result = detect_seasonality_changes_auto(
6674 &data,
6675 &argvals,
6676 period,
6677 ThresholdMethod::Percentile(50.0),
6678 4.0,
6679 2.0,
6680 );
6681 assert!(!result.strength_curve.is_empty());
6682 }
6683
6684 #[test]
6685 fn test_detect_seasonality_changes_auto_otsu() {
6686 let m = 400;
6687 let period = 2.0;
6688 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6689
6690 let data: Vec<f64> = argvals
6691 .iter()
6692 .map(|&t| {
6693 if t < 10.0 {
6694 0.05 * ((t * 13.0).sin() + (t * 7.0).cos())
6695 } else {
6696 (2.0 * PI * t / period).sin()
6697 }
6698 })
6699 .collect();
6700 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6701
6702 let result = detect_seasonality_changes_auto(
6704 &data,
6705 &argvals,
6706 period,
6707 ThresholdMethod::Otsu,
6708 4.0,
6709 2.0,
6710 );
6711 assert!(!result.strength_curve.is_empty());
6712 }
6713
6714 #[test]
6715 fn test_detect_seasonality_changes_auto_invalid_inputs() {
6716 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6718 let result =
6719 detect_seasonality_changes_auto(&data, &[], 2.0, ThresholdMethod::Fixed(0.3), 4.0, 2.0);
6720 assert!(result.change_points.is_empty());
6721 assert!(result.strength_curve.is_empty());
6722
6723 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6725 let result = detect_seasonality_changes_auto(
6726 &data,
6727 &[0.0, 1.0, 2.0],
6728 2.0,
6729 ThresholdMethod::Otsu,
6730 1.0,
6731 0.5,
6732 );
6733 assert!(result.change_points.is_empty());
6734 assert!(result.strength_curve.is_empty());
6735 }
6736
6737 #[test]
6738 fn test_cfd_autoperiod_fdata_invalid_inputs() {
6739 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6741 let result = cfd_autoperiod_fdata(&data, &[], None, None);
6742 assert!(result.period.is_nan());
6743 assert_eq!(result.confidence, 0.0);
6744
6745 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 5.0], 1, 5).unwrap();
6747 let result = cfd_autoperiod_fdata(&data, &[0.0, 1.0, 2.0, 3.0, 4.0], None, None);
6748 assert!(result.period.is_nan());
6749 }
6750
6751 #[test]
6752 fn test_cfd_autoperiod_fdata_valid() {
6753 let n = 3;
6755 let m = 200;
6756 let period = 2.0;
6757 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6758 let data = generate_sine(n, m, period, &argvals);
6759
6760 let result = cfd_autoperiod_fdata(&data, &argvals, Some(0.1), Some(1));
6761 assert!(result.period.is_finite());
6762 }
6763
6764 #[test]
6765 fn test_lomb_scargle_fap_edge_cases() {
6766 let fap = lomb_scargle_fap(0.0, 100, 200);
6768 assert_eq!(fap, 1.0);
6769
6770 let fap = lomb_scargle_fap(-1.0, 100, 200);
6771 assert_eq!(fap, 1.0);
6772
6773 let fap = lomb_scargle_fap(10.0, 0, 200);
6775 assert_eq!(fap, 1.0);
6776
6777 let fap = lomb_scargle_fap(100.0, 100, 200);
6779 assert!(
6780 fap < 0.01,
6781 "Very high power should give low FAP, got {}",
6782 fap
6783 );
6784
6785 let fap = lomb_scargle_fap(5.0, 50, 100);
6787 assert!((0.0..=1.0).contains(&fap));
6788 }
6789
6790 #[test]
6791 fn test_lomb_scargle_fdata_valid() {
6792 let n = 3;
6793 let m = 200;
6794 let period = 5.0;
6795 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6796 let data = generate_sine(n, m, period, &argvals);
6797
6798 let result = lomb_scargle_fdata(&data, &argvals, Some(4.0), Some(1.0));
6799 assert!(
6800 (result.peak_period - period).abs() < 1.0,
6801 "Expected period ~{}, got {}",
6802 period,
6803 result.peak_period
6804 );
6805 assert!(!result.frequencies.is_empty());
6806 }
6807
6808 #[test]
6809 fn test_cwt_morlet_edge_cases() {
6810 let result = cwt_morlet_fft(&[], &[], 1.0, 6.0);
6812 assert!(result.is_empty());
6813
6814 let result = cwt_morlet_fft(&[1.0, 2.0], &[0.0, 1.0], 0.0, 6.0);
6816 assert!(result.is_empty());
6817
6818 let result = cwt_morlet_fft(&[1.0, 2.0], &[0.0, 1.0], -1.0, 6.0);
6819 assert!(result.is_empty());
6820 }
6821
6822 #[test]
6823 fn test_hilbert_transform_empty() {
6824 let result = hilbert_transform(&[]);
6825 assert!(result.is_empty());
6826 }
6827
6828 #[test]
6829 fn test_unwrap_phase_empty() {
6830 let result = unwrap_phase(&[]);
6831 assert!(result.is_empty());
6832 }
6833
6834 #[test]
6835 fn test_unwrap_phase_monotonic() {
6836 let phase = vec![0.0, 1.0, 2.0, 3.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0];
6838 let unwrapped = unwrap_phase(&phase);
6839 assert_eq!(unwrapped.len(), phase.len());
6840 for i in 1..unwrapped.len() {
6842 assert!(
6843 unwrapped[i] >= unwrapped[i - 1] - 0.01,
6844 "Unwrapped phase should be monotonic at {}: {} vs {}",
6845 i,
6846 unwrapped[i],
6847 unwrapped[i - 1]
6848 );
6849 }
6850 }
6851
6852 #[test]
6853 fn test_linear_slope_edge_cases() {
6854 assert_eq!(linear_slope(&[1.0, 2.0], &[1.0]), 0.0);
6856
6857 assert_eq!(linear_slope(&[1.0], &[1.0]), 0.0);
6859
6860 let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
6862 let y = vec![1.0, 3.0, 5.0, 7.0, 9.0];
6863 let slope = linear_slope(&x, &y);
6864 assert!(
6865 (slope - 2.0).abs() < 1e-10,
6866 "Slope should be 2.0, got {}",
6867 slope
6868 );
6869
6870 let x = vec![5.0, 5.0, 5.0];
6872 let y = vec![1.0, 2.0, 3.0];
6873 let slope = linear_slope(&x, &y);
6874 assert_eq!(slope, 0.0, "Constant x should give slope 0");
6875 }
6876
6877 #[test]
6878 fn test_otsu_threshold_edge_cases() {
6879 let threshold = otsu_threshold(&[]);
6881 assert!((threshold - 0.5).abs() < 1e-10);
6882
6883 let threshold = otsu_threshold(&[f64::NAN, f64::NAN]);
6885 assert!((threshold - 0.5).abs() < 1e-10);
6886
6887 let threshold = otsu_threshold(&[5.0, 5.0, 5.0]);
6889 assert!((threshold - 5.0).abs() < 1e-10);
6890
6891 let threshold = otsu_threshold(&[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
6893 assert!(threshold > 0.0 && threshold < 1.0);
6894 }
6895
6896 #[test]
6897 fn test_find_peaks_1d_edge_cases() {
6898 assert!(find_peaks_1d(&[], 1).is_empty());
6900 assert!(find_peaks_1d(&[1.0], 1).is_empty());
6901 assert!(find_peaks_1d(&[1.0, 2.0], 1).is_empty());
6902
6903 let peaks = find_peaks_1d(&[0.0, 1.0, 0.0], 1);
6905 assert_eq!(peaks, vec![1]);
6906
6907 let signal = vec![0.0, 2.0, 0.0, 1.5, 0.0];
6909 let peaks = find_peaks_1d(&signal, 1);
6910 assert_eq!(peaks, vec![1, 3]);
6911
6912 let signal = vec![0.0, 1.0, 0.5, 2.0, 0.0];
6914 let peaks = find_peaks_1d(&signal, 3);
6915 assert_eq!(peaks.len(), 1);
6918 assert_eq!(peaks[0], 3);
6919 }
6920
6921 #[test]
6922 fn test_compute_prominence() {
6923 let signal = vec![0.0, 0.0, 5.0, 0.0, 0.0];
6925 let prom = compute_prominence(&signal, 2);
6926 assert!((prom - 5.0).abs() < 1e-10);
6927
6928 let signal = vec![0.0, 2.0, 5.0, 1.0, 0.0];
6930 let prom = compute_prominence(&signal, 2);
6931 assert!(prom > 0.0);
6934 }
6935
6936 #[test]
6937 fn test_seasonal_strength_variance_invalid() {
6938 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6940 let result = seasonal_strength_variance(&data, &[], 2.0, 3);
6941 assert!(result.is_nan());
6942
6943 let m = 50;
6945 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6946 let data = generate_sine(1, m, 2.0, &argvals);
6947 let result = seasonal_strength_variance(&data, &argvals, -1.0, 3);
6948 assert!(result.is_nan());
6949 }
6950
6951 #[test]
6952 fn test_seasonal_strength_spectral_invalid() {
6953 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6955 let result = seasonal_strength_spectral(&data, &[], 2.0);
6956 assert!(result.is_nan());
6957
6958 let m = 50;
6960 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6961 let data = generate_sine(1, m, 2.0, &argvals);
6962 let result = seasonal_strength_spectral(&data, &argvals, -1.0);
6963 assert!(result.is_nan());
6964 }
6965
6966 #[test]
6967 fn test_seasonal_strength_wavelet_invalid() {
6968 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6970 let result = seasonal_strength_wavelet(&data, &[], 2.0);
6971 assert!(result.is_nan());
6972
6973 let m = 50;
6975 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6976 let data = generate_sine(1, m, 2.0, &argvals);
6977 let result = seasonal_strength_wavelet(&data, &argvals, -1.0);
6978 assert!(result.is_nan());
6979
6980 let data = FdMatrix::from_column_major(vec![5.0; m], 1, m).unwrap();
6982 let result = seasonal_strength_wavelet(&data, &argvals, 2.0);
6983 assert!(
6984 (result - 0.0).abs() < 1e-10,
6985 "Constant data should have 0 strength"
6986 );
6987 }
6988
6989 #[test]
6990 fn test_seasonal_strength_windowed_spectral() {
6991 let m = 200;
6993 let period = 2.0;
6994 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6995 let data = generate_sine(1, m, period, &argvals);
6996
6997 let strengths =
6998 seasonal_strength_windowed(&data, &argvals, period, 4.0, StrengthMethod::Spectral);
6999
7000 assert_eq!(strengths.len(), m, "Should return m values");
7001 }
7002
7003 #[test]
7004 fn test_seasonal_strength_windowed_invalid() {
7005 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
7007 let strengths = seasonal_strength_windowed(&data, &[], 2.0, 4.0, StrengthMethod::Variance);
7008 assert!(strengths.is_empty());
7009
7010 let m = 50;
7012 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
7013 let data = generate_sine(1, m, 2.0, &argvals);
7014 let strengths =
7015 seasonal_strength_windowed(&data, &argvals, 2.0, -1.0, StrengthMethod::Variance);
7016 assert!(strengths.is_empty());
7017 }
7018
7019 #[test]
7020 fn test_ssa_custom_window_length() {
7021 let n = 50;
7023 let values: Vec<f64> = (0..n).map(|i| (2.0 * PI * i as f64 / 10.0).sin()).collect();
7024
7025 let result = ssa(&values, Some(30), None, None, None);
7027 assert_eq!(result.trend, values);
7029 assert_eq!(result.n_components, 0);
7030 }
7031
7032 #[test]
7033 fn test_ssa_window_length_too_small() {
7034 let n = 50;
7035 let values: Vec<f64> = (0..n).map(|i| i as f64).collect();
7036
7037 let result = ssa(&values, Some(1), None, None, None);
7039 assert_eq!(result.trend, values);
7040 assert_eq!(result.n_components, 0);
7041 }
7042
7043 #[test]
7044 fn test_ssa_auto_grouping() {
7045 let n = 200;
7047 let period = 12.0;
7048 let values: Vec<f64> = (0..n)
7049 .map(|i| {
7050 let t = i as f64;
7051 0.05 * t + 2.0 * (2.0 * PI * t / period).sin() + 0.01 * ((i * 7) as f64).sin()
7053 })
7054 .collect();
7055
7056 let result = ssa(&values, Some(30), Some(6), None, None);
7057
7058 assert!(
7060 result.detected_period > 0.0,
7061 "Should detect a period, got {}",
7062 result.detected_period
7063 );
7064 assert!(result.confidence > 0.0);
7065
7066 for i in 0..n {
7068 let reconstructed = result.trend[i] + result.seasonal[i] + result.noise[i];
7069 assert!(
7070 (reconstructed - values[i]).abs() < 1e-8,
7071 "SSA auto-grouping reconstruction error at {}",
7072 i
7073 );
7074 }
7075 }
7076
7077 #[test]
7078 fn test_ssa_with_many_components() {
7079 let n = 100;
7081 let values: Vec<f64> = (0..n).map(|i| (2.0 * PI * i as f64 / 12.0).sin()).collect();
7082
7083 let result = ssa(&values, None, Some(100), None, None);
7084 assert!(result.n_components <= n);
7085 assert!(!result.singular_values.is_empty());
7086 }
7087
7088 #[test]
7089 fn test_embed_trajectory() {
7090 let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
7092 let l = 3;
7093 let k = 3; let traj = embed_trajectory(&values, l, k);
7095
7096 assert!((traj[0] - 1.0).abs() < 1e-10);
7098 assert!((traj[1] - 2.0).abs() < 1e-10);
7099 assert!((traj[2] - 3.0).abs() < 1e-10);
7100
7101 assert!((traj[3] - 2.0).abs() < 1e-10);
7103 assert!((traj[4] - 3.0).abs() < 1e-10);
7104 assert!((traj[5] - 4.0).abs() < 1e-10);
7105
7106 assert!((traj[6] - 3.0).abs() < 1e-10);
7108 assert!((traj[7] - 4.0).abs() < 1e-10);
7109 assert!((traj[8] - 5.0).abs() < 1e-10);
7110 }
7111
7112 #[test]
7113 fn test_diagonal_average() {
7114 let l = 3;
7117 let k = 3;
7118 let n = l + k - 1; let matrix = vec![1.0; l * k];
7120 let result = diagonal_average(&matrix, l, k, n);
7121 assert_eq!(result.len(), n);
7122 for &v in &result {
7123 assert!((v - 1.0).abs() < 1e-10, "Expected 1.0, got {}", v);
7124 }
7125 }
7126
7127 #[test]
7128 fn test_svd_decompose() {
7129 let l = 3;
7131 let k = 2;
7132 let trajectory = vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0]; let (u, sigma, vt) = svd_decompose(&trajectory, l, k);
7134
7135 assert!(!u.is_empty(), "U should not be empty");
7136 assert!(!sigma.is_empty(), "Sigma should not be empty");
7137 assert!(!vt.is_empty(), "V^T should not be empty");
7138
7139 for &s in &sigma {
7141 assert!(s >= 0.0, "Singular values must be non-negative");
7142 }
7143 for i in 1..sigma.len() {
7144 assert!(sigma[i] <= sigma[i - 1] + 1e-10);
7145 }
7146 }
7147
7148 #[test]
7149 fn test_is_trend_component() {
7150 let trend_vec: Vec<f64> = (0..20).map(|i| i as f64).collect();
7152 assert!(is_trend_component(&trend_vec));
7153
7154 let osc_vec: Vec<f64> = (0..20).map(|i| (PI * i as f64 / 3.0).sin()).collect();
7156 assert!(!is_trend_component(&osc_vec));
7157
7158 assert!(!is_trend_component(&[1.0, 2.0]));
7160 }
7161
7162 #[test]
7163 fn test_is_periodic_component() {
7164 let periodic: Vec<f64> = (0..40)
7166 .map(|i| (2.0 * PI * i as f64 / 10.0).sin())
7167 .collect();
7168 let (is_periodic, period) = is_periodic_component(&periodic);
7169 assert!(is_periodic, "Should detect periodicity");
7170 assert!(
7171 (period - 10.0).abs() < 2.0,
7172 "Expected period ~10, got {}",
7173 period
7174 );
7175
7176 let monotonic: Vec<f64> = (0..40).map(|i| i as f64 * 0.01).collect();
7178 let (is_periodic, _) = is_periodic_component(&monotonic);
7179 let _ = is_periodic;
7183
7184 let (is_periodic, _) = is_periodic_component(&[1.0, 2.0, 3.0]);
7186 assert!(!is_periodic, "Too short to be periodic");
7187 }
7188
7189 #[test]
7190 fn test_classify_ssa_component() {
7191 let trend_vec: Vec<f64> = (0..20).map(|i| i as f64 * 0.1).collect();
7193 let kind = classify_ssa_component(&trend_vec, 0);
7194 assert!(matches!(kind, SsaComponentKind::Trend));
7195
7196 let kind = classify_ssa_component(&trend_vec, 1);
7198 assert!(matches!(kind, SsaComponentKind::Trend));
7199
7200 let kind = classify_ssa_component(&trend_vec, 2);
7205 assert!(!matches!(kind, SsaComponentKind::Trend));
7206
7207 let periodic: Vec<f64> = (0..40)
7209 .map(|i| (2.0 * PI * i as f64 / 10.0).sin())
7210 .collect();
7211 let kind = classify_ssa_component(&periodic, 0);
7212 assert!(matches!(kind, SsaComponentKind::Seasonal(_)));
7213 }
7214
7215 #[test]
7216 fn test_apply_ssa_grouping_defaults() {
7217 let mut trend_idx = Vec::new();
7219 let mut seasonal_idx = Vec::new();
7220 apply_ssa_grouping_defaults(&mut trend_idx, &mut seasonal_idx, 5);
7221 assert_eq!(trend_idx, vec![0]);
7222 assert_eq!(seasonal_idx, vec![1, 2]);
7223
7224 let mut trend_idx = vec![0];
7226 let mut seasonal_idx = vec![1];
7227 apply_ssa_grouping_defaults(&mut trend_idx, &mut seasonal_idx, 5);
7228 assert_eq!(trend_idx, vec![0]); assert_eq!(seasonal_idx, vec![1]); let mut trend_idx = Vec::new();
7233 let mut seasonal_idx = Vec::new();
7234 apply_ssa_grouping_defaults(&mut trend_idx, &mut seasonal_idx, 2);
7235 assert_eq!(trend_idx, vec![0]);
7236 assert!(seasonal_idx.is_empty());
7237
7238 let mut trend_idx = Vec::new();
7240 let mut seasonal_idx = Vec::new();
7241 apply_ssa_grouping_defaults(&mut trend_idx, &mut seasonal_idx, 0);
7242 assert!(trend_idx.is_empty());
7243 assert!(seasonal_idx.is_empty());
7244 }
7245
7246 #[test]
7247 fn test_reconstruct_grouped_empty() {
7248 let result = reconstruct_grouped(&[], &[], &[], 3, 3, 5, &[]);
7250 assert_eq!(result, vec![0.0; 5]);
7251 }
7252
7253 #[test]
7254 fn test_reconstruct_grouped_idx_out_of_range() {
7255 let u = vec![1.0; 9]; let sigma = vec![1.0, 0.5];
7258 let vt = vec![1.0; 6]; let result = reconstruct_grouped(&u, &sigma, &vt, 3, 3, 5, &[5]);
7260 assert_eq!(result, vec![0.0; 5]);
7262 }
7263
7264 #[test]
7265 fn test_auto_group_ssa_components() {
7266 let l = 20;
7268 let n_comp = 4;
7269 let mut u = vec![0.0; l * n_comp];
7271 for i in 0..l {
7272 u[i] = i as f64 * 0.1; u[i + l] = (2.0 * PI * i as f64 / 8.0).sin(); u[i + 2 * l] = (2.0 * PI * i as f64 / 8.0).cos(); u[i + 3 * l] = (i as f64 * 1.618).fract(); }
7277 let sigma = vec![10.0, 5.0, 4.0, 0.1];
7278
7279 let (trend_idx, seasonal_idx, detected_period, confidence) =
7280 auto_group_ssa_components(&u, &sigma, l, 10, n_comp);
7281
7282 assert!(
7283 !trend_idx.is_empty(),
7284 "Should detect at least one trend component"
7285 );
7286 assert!(
7287 !seasonal_idx.is_empty(),
7288 "Should detect at least one seasonal component"
7289 );
7290 if detected_period > 0.0 {
7292 assert!(confidence > 0.0);
7293 }
7294 }
7295
7296 #[test]
7297 fn test_estimate_period_fft_invalid() {
7298 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
7300 let result = estimate_period_fft(&data, &[]);
7301 assert!(result.period.is_nan());
7302 assert!(result.frequency.is_nan());
7303
7304 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
7306 let result = estimate_period_fft(&data, &[0.0, 1.0, 2.0]);
7307 assert!(result.period.is_nan());
7308 }
7309
7310 #[test]
7311 fn test_detect_peaks_invalid_inputs() {
7312 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
7314 let result = detect_peaks(&data, &[], None, None, false, None);
7315 assert!(result.peaks.is_empty());
7316 assert!(result.mean_period.is_nan());
7317
7318 let data = FdMatrix::from_column_major(vec![1.0, 2.0], 1, 2).unwrap();
7320 let result = detect_peaks(&data, &[0.0, 1.0], None, None, false, None);
7321 assert!(result.peaks.is_empty());
7322 }
7323
7324 #[test]
7325 fn test_detect_peaks_with_smoothing() {
7326 let m = 200;
7328 let period = 2.0;
7329 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
7330
7331 let data: Vec<f64> = argvals
7333 .iter()
7334 .enumerate()
7335 .map(|(i, &t)| (2.0 * PI * t / period).sin() + 0.1 * ((i as f64 * 5.7).sin()))
7336 .collect();
7337 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
7338
7339 let result = detect_peaks(&data, &argvals, Some(1.5), None, true, Some(11));
7341 assert!(!result.peaks[0].is_empty());
7342 }
7343
7344 #[test]
7345 fn test_morlet_wavelet() {
7346 let w = morlet_wavelet(0.0, 6.0);
7348 assert!((w.re - 1.0).abs() < 1e-10);
7349 assert!(w.im.abs() < 1e-10);
7350
7351 let w = morlet_wavelet(10.0, 6.0);
7353 assert!(w.norm() < 1e-10, "Wavelet should decay for large t");
7354 }
7355
7356 #[test]
7357 fn test_matrix_profile_short_series() {
7358 let values: Vec<f64> = (0..10).map(|i| (i as f64 * 0.5).sin()).collect();
7360 let result = matrix_profile(&values, Some(3), None);
7361 assert_eq!(result.profile.len(), 8); }
7363
7364 #[test]
7365 fn test_ssa_seasonality_with_threshold() {
7366 let n = 200;
7368 let period = 12.0;
7369 let values: Vec<f64> = (0..n)
7370 .map(|i| (2.0 * PI * i as f64 / period).sin())
7371 .collect();
7372
7373 let (is_seasonal, det_period, confidence) = ssa_seasonality(&values, None, Some(0.01));
7375 assert!(confidence >= 0.0);
7376 let _ = (is_seasonal, det_period);
7377
7378 let (is_seasonal, _, _) = ssa_seasonality(&values, None, Some(0.99));
7380 let _ = is_seasonal;
7381 }
7382
7383 #[test]
7384 fn test_cfd_autoperiod_short_data() {
7385 let argvals: Vec<f64> = (0..8).map(|i| i as f64).collect();
7387 let data: Vec<f64> = argvals
7388 .iter()
7389 .map(|&t| (2.0 * PI * t / 4.0).sin())
7390 .collect();
7391
7392 let result = cfd_autoperiod(&data, &argvals, None, None);
7393 assert!(result.period.is_finite() || result.period.is_nan());
7395 }
7396
7397 #[test]
7398 fn test_cfd_autoperiod_long_period() {
7399 let n = 365 * 8;
7403 let argvals: Vec<f64> = (0..n).map(|i| i as f64).collect();
7404 let data: Vec<f64> = argvals
7405 .iter()
7406 .map(|&t| 15.0 + 10.0 * (2.0 * PI * t / 365.0).sin() + 0.001 * t)
7407 .collect();
7408 let result = cfd_autoperiod(&data, &argvals, None, None);
7409 let err = (result.period - 365.0).abs();
7410 assert!(
7411 err < 2.0,
7412 "long-period detection: expected ~365, got {:.1} (err={:.1})",
7413 result.period,
7414 err
7415 );
7416 }
7417
7418 #[test]
7419 fn test_validate_sazed_component() {
7420 let result = validate_sazed_component(5.0, 0.8, 1.0, 10.0, 0.5);
7422 assert_eq!(result, Some(5.0));
7423
7424 let result = validate_sazed_component(0.5, 0.8, 1.0, 10.0, 0.5);
7426 assert_eq!(result, None);
7427
7428 let result = validate_sazed_component(15.0, 0.8, 1.0, 10.0, 0.5);
7429 assert_eq!(result, None);
7430
7431 let result = validate_sazed_component(5.0, 0.3, 1.0, 10.0, 0.5);
7433 assert_eq!(result, None);
7434
7435 let result = validate_sazed_component(f64::NAN, 0.8, 1.0, 10.0, 0.5);
7437 assert_eq!(result, None);
7438 }
7439
7440 #[test]
7441 fn test_count_agreeing_periods() {
7442 let periods = vec![5.0, 5.1, 5.2, 10.0, 15.0];
7443
7444 let (count, sum) = count_agreeing_periods(&periods, 5.0, 0.1);
7446 assert_eq!(count, 3);
7447 assert!((sum - 15.3).abs() < 1e-10);
7448
7449 let (count, _) = count_agreeing_periods(&periods, 100.0, 0.1);
7451 assert_eq!(count, 0);
7452 }
7453
7454 #[test]
7455 fn test_generate_ls_frequencies() {
7456 let times: Vec<f64> = (0..100).map(|i| i as f64).collect();
7457 let freqs = generate_ls_frequencies(×, 4.0, 1.0);
7458 assert!(!freqs.is_empty());
7459 assert!(
7461 (freqs[0] - 1.0 / 99.0).abs() < 0.01,
7462 "First freq should be ~1/99, got {}",
7463 freqs[0]
7464 );
7465
7466 let freqs = generate_ls_frequencies(&[0.0], 4.0, 1.0);
7468 assert_eq!(freqs, vec![0.0]);
7469 }
7470
7471 #[test]
7472 fn test_estimate_independent_frequencies() {
7473 let times: Vec<f64> = (0..50).map(|i| i as f64).collect();
7474 let n_indep = estimate_independent_frequencies(×, 100);
7475 assert_eq!(n_indep, 50); let n_indep = estimate_independent_frequencies(×, 30);
7478 assert_eq!(n_indep, 30); }
7480
7481 #[test]
7482 fn test_cluster_periods() {
7483 let result = cluster_periods(&[], 0.1, 1);
7485 assert!(result.is_empty());
7486
7487 let candidates = vec![(5.0, 1.0)];
7489 let result = cluster_periods(&candidates, 0.1, 1);
7490 assert_eq!(result.len(), 1);
7491
7492 let candidates = vec![(5.0, 1.0), (5.05, 0.8), (10.0, 0.5), (10.1, 0.4)];
7494 let result = cluster_periods(&candidates, 0.1, 1);
7495 assert_eq!(result.len(), 2, "Should find 2 clusters");
7496
7497 let candidates = vec![(5.0, 1.0), (10.0, 0.5)];
7499 let result = cluster_periods(&candidates, 0.01, 2);
7500 assert!(result.is_empty());
7502 }
7503
7504 #[test]
7505 fn test_validate_cfd_candidates() {
7506 let n = 50;
7508 let dt = 1.0;
7509 let mut acf = vec![0.0; n + 1];
7510 acf[0] = 1.0;
7511 for i in 1..=n {
7512 acf[i] = (2.0 * PI * i as f64 / 10.0).cos() * 0.5;
7513 }
7514
7515 let clusters = vec![(10.0, 1.0), (20.0, 0.5)];
7516 let validated = validate_cfd_candidates(&clusters, &acf, dt);
7517 assert!(
7519 !validated.is_empty(),
7520 "Should validate at least one candidate"
7521 );
7522 }
7523
7524 #[test]
7525 fn test_validate_or_fallback_cfd() {
7526 let validated = vec![(10.0, 0.8, 1.0)];
7528 let candidates = vec![(10.0, 1.0)];
7529 let result = validate_or_fallback_cfd(validated.clone(), &candidates, 0.1, 1);
7530 assert_eq!(result.len(), 1);
7531
7532 let candidates = vec![(10.0, 1.0), (10.2, 0.8)];
7534 let result = validate_or_fallback_cfd(vec![], &candidates, 0.1, 1);
7535 assert!(!result.is_empty(), "Fallback should return something");
7536 }
7537
7538 #[test]
7539 fn test_rank_cfd_results() {
7540 let validated = vec![
7541 (10.0, 0.5, 1.0), (5.0, 0.8, 2.0), ];
7544 let (periods, confidences, top_acf) = rank_cfd_results(&validated);
7545 assert_eq!(periods[0], 5.0); assert_eq!(periods[1], 10.0);
7548 assert!((top_acf - 0.8).abs() < 1e-10);
7549 assert_eq!(confidences.len(), 2);
7550 }
7551
7552 #[test]
7553 fn test_empty_cfd_result() {
7554 let result = empty_cfd_result();
7555 assert!(result.period.is_nan());
7556 assert_eq!(result.confidence, 0.0);
7557 assert_eq!(result.acf_validation, 0.0);
7558 assert!(result.periods.is_empty());
7559 }
7560
7561 #[test]
7562 fn test_fit_and_subtract_sinusoid() {
7563 let m = 200;
7564 let period = 10.0;
7565 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
7566 let mut residual: Vec<f64> = argvals
7567 .iter()
7568 .map(|&t| 3.0 * (2.0 * PI * t / period).sin())
7569 .collect();
7570
7571 let (a, b, amplitude, phase) = fit_and_subtract_sinusoid(&mut residual, &argvals, period);
7572
7573 assert!(
7574 amplitude > 2.0,
7575 "Amplitude should be ~3.0, got {}",
7576 amplitude
7577 );
7578 assert!(phase.is_finite());
7579 let _ = (a, b);
7580
7581 let max_residual: f64 = residual.iter().map(|&x| x.abs()).fold(0.0, f64::max);
7583 assert!(
7584 max_residual < 0.5,
7585 "Residual after subtraction should be small, got {}",
7586 max_residual
7587 );
7588 }
7589
7590 #[test]
7591 fn test_detect_seasonality_changes_cessation() {
7592 let m = 400;
7594 let period = 2.0;
7595 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
7596
7597 let data: Vec<f64> = argvals
7598 .iter()
7599 .map(|&t| {
7600 if t < 10.0 {
7601 (2.0 * PI * t / period).sin()
7603 } else {
7604 0.05 * ((t * 13.0).sin() + (t * 7.0).cos())
7606 }
7607 })
7608 .collect();
7609 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
7610
7611 let result = detect_seasonality_changes(&data, &argvals, period, 0.3, 4.0, 2.0);
7612
7613 assert!(!result.strength_curve.is_empty());
7614 if !result.change_points.is_empty() {
7616 let cessation_points: Vec<_> = result
7617 .change_points
7618 .iter()
7619 .filter(|cp| cp.change_type == ChangeType::Cessation)
7620 .collect();
7621 assert!(!cessation_points.is_empty(), "Should detect Cessation");
7622 }
7623 }
7624
7625 #[test]
7626 fn test_matrix_profile_fdata_multiple_samples() {
7627 let n = 5;
7629 let m = 200;
7630 let period = 20.0;
7631 let mut data = vec![0.0; n * m];
7632 for i in 0..n {
7633 let amp = (i + 1) as f64;
7634 for j in 0..m {
7635 data[i + j * n] = amp * (2.0 * PI * j as f64 / period).sin();
7636 }
7637 }
7638 let data = FdMatrix::from_column_major(data, n, m).unwrap();
7639
7640 let result = matrix_profile_fdata(&data, Some(15), None);
7641 assert!(!result.profile.is_empty());
7642 assert!(result.primary_period > 0.0);
7643 }
7644
7645 #[test]
7646 fn test_ssa_fdata_multiple_samples() {
7647 let n = 3;
7648 let m = 200;
7649 let mut data = vec![0.0; n * m];
7650 for i in 0..n {
7651 for j in 0..m {
7652 data[i + j * n] = (2.0 * PI * j as f64 / 12.0).sin() + 0.01 * j as f64;
7653 }
7654 }
7655 let data = FdMatrix::from_column_major(data, n, m).unwrap();
7656
7657 let result = ssa_fdata(&data, Some(25), Some(5));
7658 assert_eq!(result.trend.len(), m);
7659 assert_eq!(result.seasonal.len(), m);
7660 }
7661
7662 #[test]
7663 fn test_compute_cycle_strengths() {
7664 let m = 400;
7665 let period = 2.0;
7666 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect(); let data = generate_sine(1, m, period, &argvals);
7670 let (strengths, weak) = compute_cycle_strengths(&data, &argvals, period, 0.3);
7671
7672 assert!(
7673 !strengths.is_empty(),
7674 "Should compute at least one cycle strength"
7675 );
7676 assert!(
7678 weak.is_empty(),
7679 "Pure sine should have no weak seasons, got {:?}",
7680 weak
7681 );
7682 }
7683
7684 #[test]
7685 fn test_find_acf_descent_end() {
7686 let acf = vec![1.0, 0.8, 0.5, 0.2, -0.1, -0.3, 0.0, 0.3, 0.5];
7688 let end = find_acf_descent_end(&acf);
7689 assert_eq!(end, 4, "Should find first negative at index 4");
7690
7691 let acf = vec![1.0, 0.8, 0.5, 0.3, 0.4, 0.6];
7694 let end = find_acf_descent_end(&acf);
7695 assert_eq!(end, 3, "Should find uptick at index 3 (i-1 where i=4)");
7696 }
7697
7698 #[test]
7699 fn test_autocorrelation_fft_matches_naive() {
7700 let n = 200;
7702 let data: Vec<f64> = (0..n)
7703 .map(|i| (2.0 * PI * i as f64 / 20.0).sin() + 0.5 * (i as f64 * 0.1).cos())
7704 .collect();
7705 let max_lag = 50;
7706
7707 let mean: f64 = data.iter().sum::<f64>() / n as f64;
7708 let var: f64 = data.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n as f64;
7709
7710 let naive = autocorrelation_naive(&data, max_lag, mean, var);
7711 let fft = autocorrelation(&data, max_lag); assert_eq!(naive.len(), fft.len());
7714 for (lag, (n_val, f_val)) in naive.iter().zip(fft.iter()).enumerate() {
7715 assert!(
7716 (n_val - f_val).abs() < 1e-10,
7717 "Mismatch at lag {lag}: naive={n_val}, fft={f_val}"
7718 );
7719 }
7720 }
7721}