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