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(data: &[f64], max_lag: usize) -> Vec<f64> {
366 let n = data.len();
367 if n == 0 {
368 return Vec::new();
369 }
370
371 let mean: f64 = data.iter().sum::<f64>() / n as f64;
372 let var: f64 = data.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n as f64;
373
374 if var < 1e-15 {
375 return vec![1.0; max_lag.min(n) + 1];
376 }
377
378 let max_lag = max_lag.min(n - 1);
379 let mut acf = Vec::with_capacity(max_lag + 1);
380
381 for lag in 0..=max_lag {
382 let mut sum = 0.0;
383 for i in 0..(n - lag) {
384 sum += (data[i] - mean) * (data[i + lag] - mean);
385 }
386 acf.push(sum / (n as f64 * var));
387 }
388
389 acf
390}
391
392fn try_add_peak(peaks: &mut Vec<usize>, candidate: usize, signal: &[f64], min_distance: usize) {
394 if let Some(&last) = peaks.last() {
395 if candidate - last >= min_distance {
396 peaks.push(candidate);
397 } else if signal[candidate] > signal[last] {
398 *peaks.last_mut().unwrap_or(&mut 0) = candidate;
400 }
401 } else {
402 peaks.push(candidate);
403 }
404}
405
406pub(crate) fn find_peaks_1d(signal: &[f64], min_distance: usize) -> Vec<usize> {
408 let n = signal.len();
409 if n < 3 {
410 return Vec::new();
411 }
412
413 let mut peaks = Vec::new();
414
415 for i in 1..(n - 1) {
416 if signal[i] > signal[i - 1] && signal[i] > signal[i + 1] {
417 try_add_peak(&mut peaks, i, signal, min_distance);
418 }
419 }
420
421 peaks
422}
423
424pub(crate) fn compute_prominence(signal: &[f64], peak_idx: usize) -> f64 {
426 let n = signal.len();
427 let peak_val = signal[peak_idx];
428
429 let mut left_min = peak_val;
431 for i in (0..peak_idx).rev() {
432 if signal[i] >= peak_val {
433 break;
434 }
435 left_min = left_min.min(signal[i]);
436 }
437
438 let mut right_min = peak_val;
439 for i in (peak_idx + 1)..n {
440 if signal[i] >= peak_val {
441 break;
442 }
443 right_min = right_min.min(signal[i]);
444 }
445
446 peak_val - left_min.max(right_min)
447}
448
449pub fn hilbert_transform(signal: &[f64]) -> Vec<Complex<f64>> {
457 let n = signal.len();
458 if n == 0 {
459 return Vec::new();
460 }
461
462 let mut planner = FftPlanner::<f64>::new();
463 let fft_forward = planner.plan_fft_forward(n);
464 let fft_inverse = planner.plan_fft_inverse(n);
465
466 let mut buffer: Vec<Complex<f64>> = signal.iter().map(|&x| Complex::new(x, 0.0)).collect();
468 fft_forward.process(&mut buffer);
469
470 let half = n / 2;
473 for k in 1..half {
474 buffer[k] *= 2.0;
475 }
476 for k in (half + 1)..n {
477 buffer[k] = Complex::new(0.0, 0.0);
478 }
479
480 fft_inverse.process(&mut buffer);
482
483 for c in buffer.iter_mut() {
485 *c /= n as f64;
486 }
487
488 buffer
489}
490
491fn unwrap_phase(phase: &[f64]) -> Vec<f64> {
493 if phase.is_empty() {
494 return Vec::new();
495 }
496
497 let mut unwrapped = vec![phase[0]];
498 let mut cumulative_correction = 0.0;
499
500 for i in 1..phase.len() {
501 let diff = phase[i] - phase[i - 1];
502
503 if diff > PI {
505 cumulative_correction -= 2.0 * PI;
506 } else if diff < -PI {
507 cumulative_correction += 2.0 * PI;
508 }
509
510 unwrapped.push(phase[i] + cumulative_correction);
511 }
512
513 unwrapped
514}
515
516fn morlet_wavelet(t: f64, omega0: f64) -> Complex<f64> {
523 let gaussian = (-t * t / 2.0).exp();
524 let oscillation = Complex::new((omega0 * t).cos(), (omega0 * t).sin());
525 oscillation * gaussian
526}
527
528#[allow(dead_code)]
542fn cwt_morlet(signal: &[f64], argvals: &[f64], scale: f64, omega0: f64) -> Vec<Complex<f64>> {
543 let n = signal.len();
544 if n == 0 || scale <= 0.0 {
545 return Vec::new();
546 }
547
548 let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
549
550 let norm = 1.0 / scale.sqrt();
553
554 (0..n)
555 .map(|b| {
556 let mut sum = Complex::new(0.0, 0.0);
557 for k in 0..n {
558 let t_normalized = (argvals[k] - argvals[b]) / scale;
559 if t_normalized.abs() < 6.0 {
561 let wavelet = morlet_wavelet(t_normalized, omega0);
562 sum += signal[k] * wavelet.conj();
563 }
564 }
565 sum * norm * dt
566 })
567 .collect()
568}
569
570fn cwt_morlet_fft(signal: &[f64], argvals: &[f64], scale: f64, omega0: f64) -> Vec<Complex<f64>> {
574 let n = signal.len();
575 if n == 0 || scale <= 0.0 {
576 return Vec::new();
577 }
578
579 let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
580 let norm = 1.0 / scale.sqrt();
581
582 let wavelet_time: Vec<Complex<f64>> = (0..n)
584 .map(|k| {
585 let t = if k <= n / 2 {
587 k as f64 * dt / scale
588 } else {
589 (k as f64 - n as f64) * dt / scale
590 };
591 morlet_wavelet(t, omega0) * norm
592 })
593 .collect();
594
595 let mut planner = FftPlanner::<f64>::new();
596 let fft_forward = planner.plan_fft_forward(n);
597 let fft_inverse = planner.plan_fft_inverse(n);
598
599 let mut signal_fft: Vec<Complex<f64>> = signal.iter().map(|&x| Complex::new(x, 0.0)).collect();
601 fft_forward.process(&mut signal_fft);
602
603 let mut wavelet_fft = wavelet_time;
605 fft_forward.process(&mut wavelet_fft);
606
607 let mut result: Vec<Complex<f64>> = signal_fft
609 .iter()
610 .zip(wavelet_fft.iter())
611 .map(|(s, w)| *s * w.conj())
612 .collect();
613
614 fft_inverse.process(&mut result);
616
617 for c in result.iter_mut() {
619 *c *= dt / n as f64;
620 }
621
622 result
623}
624
625fn otsu_threshold(values: &[f64]) -> f64 {
630 if values.is_empty() {
631 return 0.5;
632 }
633
634 let valid: Vec<f64> = values.iter().copied().filter(|x| x.is_finite()).collect();
636 if valid.is_empty() {
637 return 0.5;
638 }
639
640 let vmin = valid.iter().cloned().fold(f64::INFINITY, f64::min);
641 let vmax = valid.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
642
643 if (vmax - vmin).abs() < 1e-10 {
644 return (vmin + vmax) / 2.0;
645 }
646
647 let n_bins = 256;
648 let (histogram, hist_min, bin_width) = build_histogram(&valid, n_bins);
649 let (best_bin, _) = find_optimal_threshold_bin(&histogram, valid.len() as f64);
650
651 hist_min + (best_bin as f64 + 0.5) * bin_width
652}
653
654fn linear_slope(x: &[f64], y: &[f64]) -> f64 {
656 if x.len() != y.len() || x.len() < 2 {
657 return 0.0;
658 }
659
660 let n = x.len() as f64;
661 let mean_x: f64 = x.iter().sum::<f64>() / n;
662 let mean_y: f64 = y.iter().sum::<f64>() / n;
663
664 let mut num = 0.0;
665 let mut den = 0.0;
666
667 for (&xi, &yi) in x.iter().zip(y.iter()) {
668 num += (xi - mean_x) * (yi - mean_y);
669 den += (xi - mean_x).powi(2);
670 }
671
672 if den.abs() < 1e-15 {
673 0.0
674 } else {
675 num / den
676 }
677}
678
679struct AmplitudeEnvelopeStats {
681 modulation_score: f64,
682 amplitude_trend: f64,
683 has_modulation: bool,
684 modulation_type: ModulationType,
685 _mean_amp: f64,
686 min_amp: f64,
687 max_amp: f64,
688}
689
690fn analyze_amplitude_envelope(
692 interior_envelope: &[f64],
693 interior_times: &[f64],
694 modulation_threshold: f64,
695) -> AmplitudeEnvelopeStats {
696 let n_interior = interior_envelope.len() as f64;
697
698 let mean_amp = interior_envelope.iter().sum::<f64>() / n_interior;
699 let min_amp = interior_envelope
700 .iter()
701 .cloned()
702 .fold(f64::INFINITY, f64::min);
703 let max_amp = interior_envelope
704 .iter()
705 .cloned()
706 .fold(f64::NEG_INFINITY, f64::max);
707
708 let variance = interior_envelope
709 .iter()
710 .map(|&a| (a - mean_amp).powi(2))
711 .sum::<f64>()
712 / n_interior;
713 let std_amp = variance.sqrt();
714 let modulation_score = if mean_amp > 1e-10 {
715 std_amp / mean_amp
716 } else {
717 0.0
718 };
719
720 let t_mean = interior_times.iter().sum::<f64>() / n_interior;
721 let mut cov_ta = 0.0;
722 let mut var_t = 0.0;
723 for (&t, &a) in interior_times.iter().zip(interior_envelope.iter()) {
724 cov_ta += (t - t_mean) * (a - mean_amp);
725 var_t += (t - t_mean).powi(2);
726 }
727 let slope = if var_t > 1e-10 { cov_ta / var_t } else { 0.0 };
728
729 let time_span = interior_times.last().unwrap_or(&1.0) - interior_times.first().unwrap_or(&0.0);
730 let amplitude_trend = if mean_amp > 1e-10 && time_span > 1e-10 {
731 (slope * time_span / mean_amp).clamp(-1.0, 1.0)
732 } else {
733 0.0
734 };
735
736 let has_modulation = modulation_score > modulation_threshold;
737 let modulation_type = if !has_modulation {
738 ModulationType::Stable
739 } else if amplitude_trend > 0.3 {
740 ModulationType::Emerging
741 } else if amplitude_trend < -0.3 {
742 ModulationType::Fading
743 } else {
744 ModulationType::Oscillating
745 };
746
747 AmplitudeEnvelopeStats {
748 modulation_score,
749 amplitude_trend,
750 has_modulation,
751 modulation_type,
752 _mean_amp: mean_amp,
753 min_amp,
754 max_amp,
755 }
756}
757
758fn fit_and_subtract_sinusoid(
760 residual: &mut [f64],
761 argvals: &[f64],
762 period: f64,
763) -> (f64, f64, f64, f64) {
764 let m = residual.len();
765 let omega = 2.0 * PI / period;
766 let mut cos_sum = 0.0;
767 let mut sin_sum = 0.0;
768
769 for (j, &t) in argvals.iter().enumerate() {
770 cos_sum += residual[j] * (omega * t).cos();
771 sin_sum += residual[j] * (omega * t).sin();
772 }
773
774 let a = 2.0 * cos_sum / m as f64;
775 let b = 2.0 * sin_sum / m as f64;
776 let amplitude = (a * a + b * b).sqrt();
777 let phase = b.atan2(a);
778
779 for (j, &t) in argvals.iter().enumerate() {
780 residual[j] -= a * (omega * t).cos() + b * (omega * t).sin();
781 }
782
783 (a, b, amplitude, phase)
784}
785
786fn validate_sazed_component(
788 period: f64,
789 confidence: f64,
790 min_period: f64,
791 max_period: f64,
792 threshold: f64,
793) -> Option<f64> {
794 if period.is_finite() && period > min_period && period < max_period && confidence > threshold {
795 Some(period)
796 } else {
797 None
798 }
799}
800
801fn count_agreeing_periods(periods: &[f64], reference: f64, tolerance: f64) -> (usize, f64) {
803 let mut count = 0;
804 let mut sum = 0.0;
805 for &p in periods {
806 let rel_diff = (reference - p).abs() / reference.max(p);
807 if rel_diff <= tolerance {
808 count += 1;
809 sum += p;
810 }
811 }
812 (count, sum)
813}
814
815fn find_acf_descent_end(acf: &[f64]) -> usize {
817 for i in 1..acf.len() {
818 if acf[i] < 0.0 {
819 return i;
820 }
821 if i > 1 && acf[i] > acf[i - 1] {
822 return i - 1;
823 }
824 }
825 1
826}
827
828fn find_first_acf_peak(acf: &[f64]) -> Option<(usize, f64)> {
830 if acf.len() < 4 {
831 return None;
832 }
833
834 let min_search_start = find_acf_descent_end(acf);
835 let peaks = find_peaks_1d(&acf[min_search_start..], 1);
836 if peaks.is_empty() {
837 return None;
838 }
839
840 let peak_lag = peaks[0] + min_search_start;
841 Some((peak_lag, acf[peak_lag].max(0.0)))
842}
843
844fn compute_cycle_strengths(
846 data: &FdMatrix,
847 argvals: &[f64],
848 period: f64,
849 strength_thresh: f64,
850) -> (Vec<f64>, Vec<usize>) {
851 let (n, m) = data.shape();
852 let t_start = argvals[0];
853 let t_end = argvals[m - 1];
854 let n_cycles = ((t_end - t_start) / period).floor() as usize;
855
856 let mut cycle_strengths = Vec::with_capacity(n_cycles);
857 let mut weak_seasons = Vec::new();
858
859 for cycle in 0..n_cycles {
860 let cycle_start = t_start + cycle as f64 * period;
861 let cycle_end = cycle_start + period;
862
863 let start_idx = argvals.iter().position(|&t| t >= cycle_start).unwrap_or(0);
864 let end_idx = argvals.iter().position(|&t| t > cycle_end).unwrap_or(m);
865
866 let cycle_m = end_idx - start_idx;
867 if cycle_m < 4 {
868 cycle_strengths.push(f64::NAN);
869 continue;
870 }
871
872 let cycle_data: Vec<f64> = (start_idx..end_idx)
873 .flat_map(|j| (0..n).map(move |i| data[(i, j)]))
874 .collect();
875 let cycle_mat = FdMatrix::from_column_major(cycle_data, n, cycle_m).unwrap();
876 let cycle_argvals: Vec<f64> = argvals[start_idx..end_idx].to_vec();
877
878 let strength = seasonal_strength_variance(&cycle_mat, &cycle_argvals, period, 3);
879
880 cycle_strengths.push(strength);
881 if strength < strength_thresh {
882 weak_seasons.push(cycle);
883 }
884 }
885
886 (cycle_strengths, weak_seasons)
887}
888
889fn build_histogram(valid: &[f64], n_bins: usize) -> (Vec<usize>, f64, f64) {
891 let min_val = valid.iter().cloned().fold(f64::INFINITY, f64::min);
892 let max_val = valid.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
893 let bin_width = (max_val - min_val) / n_bins as f64;
894 let mut histogram = vec![0usize; n_bins];
895 for &v in valid {
896 let bin = ((v - min_val) / bin_width).min(n_bins as f64 - 1.0) as usize;
897 histogram[bin] += 1;
898 }
899 (histogram, min_val, bin_width)
900}
901
902fn find_optimal_threshold_bin(histogram: &[usize], total: f64) -> (usize, f64) {
904 let n_bins = histogram.len();
905 let mut sum_total = 0.0;
906 for (i, &count) in histogram.iter().enumerate() {
907 sum_total += i as f64 * count as f64;
908 }
909
910 let mut best_bin = 0;
911 let mut best_variance = 0.0;
912 let mut sum_b = 0.0;
913 let mut weight_b = 0.0;
914
915 for t in 0..n_bins {
916 weight_b += histogram[t] as f64;
917 if weight_b == 0.0 {
918 continue;
919 }
920 let weight_f = total - weight_b;
921 if weight_f == 0.0 {
922 break;
923 }
924 sum_b += t as f64 * histogram[t] as f64;
925 let mean_b = sum_b / weight_b;
926 let mean_f = (sum_total - sum_b) / weight_f;
927 let variance = weight_b * weight_f * (mean_b - mean_f).powi(2);
928 if variance > best_variance {
929 best_variance = variance;
930 best_bin = t;
931 }
932 }
933
934 (best_bin, best_variance)
935}
936
937fn sum_harmonic_power(
939 frequencies: &[f64],
940 power: &[f64],
941 fundamental_freq: f64,
942 tolerance: f64,
943) -> (f64, f64) {
944 let mut seasonal_power = 0.0;
945 let mut total_power = 0.0;
946
947 for (i, (&freq, &p)) in frequencies.iter().zip(power.iter()).enumerate() {
948 if i == 0 {
949 continue;
950 }
951 total_power += p;
952 let ratio = freq / fundamental_freq;
953 let nearest_harmonic = ratio.round();
954 if (ratio - nearest_harmonic).abs() < tolerance && nearest_harmonic >= 1.0 {
955 seasonal_power += p;
956 }
957 }
958
959 (seasonal_power, total_power)
960}
961
962fn crossing_direction(
966 ss: f64,
967 threshold: f64,
968 in_seasonal: bool,
969 i: usize,
970 last_change_idx: Option<usize>,
971 min_dur_points: usize,
972) -> Option<bool> {
973 if ss.is_nan() {
974 return None;
975 }
976 let now_seasonal = ss > threshold;
977 if now_seasonal == in_seasonal {
978 return None;
979 }
980 if last_change_idx.is_some_and(|last_idx| i - last_idx < min_dur_points) {
981 return None;
982 }
983 Some(now_seasonal)
984}
985
986fn build_change_point(
988 i: usize,
989 ss: f64,
990 now_seasonal: bool,
991 strength_curve: &[f64],
992 argvals: &[f64],
993) -> ChangePoint {
994 let change_type = if now_seasonal {
995 ChangeType::Onset
996 } else {
997 ChangeType::Cessation
998 };
999 let strength_before = if i > 0 && !strength_curve[i - 1].is_nan() {
1000 strength_curve[i - 1]
1001 } else {
1002 ss
1003 };
1004 ChangePoint {
1005 time: argvals[i],
1006 change_type,
1007 strength_before,
1008 strength_after: ss,
1009 }
1010}
1011
1012fn detect_threshold_crossings(
1014 strength_curve: &[f64],
1015 argvals: &[f64],
1016 threshold: f64,
1017 min_dur_points: usize,
1018) -> Vec<ChangePoint> {
1019 let mut change_points = Vec::new();
1020 let mut in_seasonal = strength_curve[0] > threshold;
1021 let mut last_change_idx: Option<usize> = None;
1022
1023 for (i, &ss) in strength_curve.iter().enumerate().skip(1) {
1024 let Some(now_seasonal) = crossing_direction(
1025 ss,
1026 threshold,
1027 in_seasonal,
1028 i,
1029 last_change_idx,
1030 min_dur_points,
1031 ) else {
1032 continue;
1033 };
1034
1035 change_points.push(build_change_point(
1036 i,
1037 ss,
1038 now_seasonal,
1039 strength_curve,
1040 argvals,
1041 ));
1042
1043 in_seasonal = now_seasonal;
1044 last_change_idx = Some(i);
1045 }
1046
1047 change_points
1048}
1049
1050pub fn estimate_period_fft(data: &FdMatrix, argvals: &[f64]) -> PeriodEstimate {
1068 let (n, m) = data.shape();
1069 if n == 0 || m < 4 || argvals.len() != m {
1070 return PeriodEstimate {
1071 period: f64::NAN,
1072 frequency: f64::NAN,
1073 power: 0.0,
1074 confidence: 0.0,
1075 };
1076 }
1077
1078 let mean_curve = compute_mean_curve(data);
1080
1081 let (frequencies, power) = periodogram(&mean_curve, argvals);
1082
1083 if frequencies.len() < 2 {
1084 return PeriodEstimate {
1085 period: f64::NAN,
1086 frequency: f64::NAN,
1087 power: 0.0,
1088 confidence: 0.0,
1089 };
1090 }
1091
1092 let mut max_power = 0.0;
1094 let mut max_idx = 1;
1095 for (i, &p) in power.iter().enumerate().skip(1) {
1096 if p > max_power {
1097 max_power = p;
1098 max_idx = i;
1099 }
1100 }
1101
1102 let dominant_freq = frequencies[max_idx];
1103 let period = if dominant_freq > 1e-15 {
1104 1.0 / dominant_freq
1105 } else {
1106 f64::INFINITY
1107 };
1108
1109 let mean_power: f64 = power.iter().skip(1).sum::<f64>() / (power.len() - 1) as f64;
1111 let confidence = if mean_power > 1e-15 {
1112 max_power / mean_power
1113 } else {
1114 0.0
1115 };
1116
1117 PeriodEstimate {
1118 period,
1119 frequency: dominant_freq,
1120 power: max_power,
1121 confidence,
1122 }
1123}
1124
1125pub fn estimate_period_acf(
1136 data: &[f64],
1137 n: usize,
1138 m: usize,
1139 argvals: &[f64],
1140 max_lag: usize,
1141) -> PeriodEstimate {
1142 if n == 0 || m < 4 || argvals.len() != m {
1143 return PeriodEstimate {
1144 period: f64::NAN,
1145 frequency: f64::NAN,
1146 power: 0.0,
1147 confidence: 0.0,
1148 };
1149 }
1150
1151 let mat = FdMatrix::from_slice(data, n, m).unwrap();
1153 let mean_curve = compute_mean_curve(&mat);
1154
1155 let acf = autocorrelation(&mean_curve, max_lag);
1156
1157 let min_lag = 2;
1159 let peaks = find_peaks_1d(&acf[min_lag..], 1);
1160
1161 if peaks.is_empty() {
1162 return PeriodEstimate {
1163 period: f64::NAN,
1164 frequency: f64::NAN,
1165 power: 0.0,
1166 confidence: 0.0,
1167 };
1168 }
1169
1170 let peak_lag = peaks[0] + min_lag;
1171 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1172 let period = peak_lag as f64 * dt;
1173 let frequency = if period > 1e-15 { 1.0 / period } else { 0.0 };
1174
1175 PeriodEstimate {
1176 period,
1177 frequency,
1178 power: acf[peak_lag],
1179 confidence: acf[peak_lag].abs(),
1180 }
1181}
1182
1183pub fn estimate_period_regression(
1198 data: &[f64],
1199 n: usize,
1200 m: usize,
1201 argvals: &[f64],
1202 period_min: f64,
1203 period_max: f64,
1204 n_candidates: usize,
1205 n_harmonics: usize,
1206) -> PeriodEstimate {
1207 if n == 0 || m < 4 || argvals.len() != m || period_min >= period_max || n_candidates < 2 {
1208 return PeriodEstimate {
1209 period: f64::NAN,
1210 frequency: f64::NAN,
1211 power: 0.0,
1212 confidence: 0.0,
1213 };
1214 }
1215
1216 let mat = FdMatrix::from_slice(data, n, m).unwrap();
1218 let mean_curve = compute_mean_curve(&mat);
1219
1220 let nbasis = 1 + 2 * n_harmonics;
1221
1222 let candidates: Vec<f64> = (0..n_candidates)
1224 .map(|i| period_min + (period_max - period_min) * i as f64 / (n_candidates - 1) as f64)
1225 .collect();
1226
1227 let results: Vec<(f64, f64)> = slice_maybe_parallel!(candidates)
1228 .map(|&period| {
1229 let basis = fourier_basis_with_period(argvals, nbasis, period);
1230
1231 let mut rss = 0.0;
1233 for j in 0..m {
1234 let mut fitted = 0.0;
1235 for k in 0..nbasis {
1237 let b_val = basis[j + k * m];
1238 let coef: f64 = (0..m)
1239 .map(|l| mean_curve[l] * basis[l + k * m])
1240 .sum::<f64>()
1241 / (0..m)
1242 .map(|l| basis[l + k * m].powi(2))
1243 .sum::<f64>()
1244 .max(1e-15);
1245 fitted += coef * b_val;
1246 }
1247 let resid = mean_curve[j] - fitted;
1248 rss += resid * resid;
1249 }
1250
1251 (period, rss)
1252 })
1253 .collect();
1254
1255 let (best_period, min_rss) = results
1257 .iter()
1258 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
1259 .cloned()
1260 .unwrap_or((f64::NAN, f64::INFINITY));
1261
1262 let mean_rss: f64 = results.iter().map(|(_, r)| r).sum::<f64>() / results.len() as f64;
1264 let confidence = if min_rss > 1e-15 {
1265 (mean_rss / min_rss).min(10.0)
1266 } else {
1267 10.0
1268 };
1269
1270 PeriodEstimate {
1271 period: best_period,
1272 frequency: if best_period > 1e-15 {
1273 1.0 / best_period
1274 } else {
1275 0.0
1276 },
1277 power: 1.0 - min_rss / mean_rss,
1278 confidence,
1279 }
1280}
1281
1282pub fn detect_multiple_periods(
1303 data: &[f64],
1304 n: usize,
1305 m: usize,
1306 argvals: &[f64],
1307 max_periods: usize,
1308 min_confidence: f64,
1309 min_strength: f64,
1310) -> Vec<DetectedPeriod> {
1311 if n == 0 || m < 4 || argvals.len() != m || max_periods == 0 {
1312 return Vec::new();
1313 }
1314
1315 let mat = FdMatrix::from_slice(data, n, m).unwrap();
1317 let mean_curve = compute_mean_curve(&mat);
1318
1319 let mut residual = mean_curve.clone();
1320 let mut detected = Vec::with_capacity(max_periods);
1321
1322 for iteration in 1..=max_periods {
1323 match evaluate_next_period(
1324 &mut residual,
1325 m,
1326 argvals,
1327 min_confidence,
1328 min_strength,
1329 iteration,
1330 ) {
1331 Some(period) => detected.push(period),
1332 None => break,
1333 }
1334 }
1335
1336 detected
1337}
1338
1339fn evaluate_next_period(
1343 residual: &mut [f64],
1344 m: usize,
1345 argvals: &[f64],
1346 min_confidence: f64,
1347 min_strength: f64,
1348 iteration: usize,
1349) -> Option<DetectedPeriod> {
1350 let residual_mat = FdMatrix::from_slice(residual, 1, m).unwrap();
1351 let est = estimate_period_fft(&residual_mat, argvals);
1352
1353 if est.confidence < min_confidence || est.period.is_nan() || est.period.is_infinite() {
1354 return None;
1355 }
1356
1357 let strength = seasonal_strength_variance(&residual_mat, argvals, est.period, 3);
1358 if strength < min_strength || strength.is_nan() {
1359 return None;
1360 }
1361
1362 let (_a, _b, amplitude, phase) = fit_and_subtract_sinusoid(residual, argvals, est.period);
1363
1364 Some(DetectedPeriod {
1365 period: est.period,
1366 confidence: est.confidence,
1367 strength,
1368 amplitude,
1369 phase,
1370 iteration,
1371 })
1372}
1373
1374fn smooth_for_peaks(
1380 data: &FdMatrix,
1381 argvals: &[f64],
1382 smooth_first: bool,
1383 smooth_nbasis: Option<usize>,
1384) -> Vec<f64> {
1385 if !smooth_first {
1386 return data.as_slice().to_vec();
1387 }
1388 let nbasis = smooth_nbasis
1389 .unwrap_or_else(|| crate::basis::select_fourier_nbasis_gcv(data, argvals, 5, 25));
1390 if let Some(result) = crate::basis::fourier_fit_1d(data, argvals, nbasis) {
1391 result.fitted.into_vec()
1392 } else {
1393 data.as_slice().to_vec()
1394 }
1395}
1396
1397fn detect_peaks_single_curve(
1399 curve: &[f64],
1400 d1: &[f64],
1401 argvals: &[f64],
1402 min_dist_points: usize,
1403 min_prominence: Option<f64>,
1404 data_range: f64,
1405) -> (Vec<Peak>, Vec<f64>) {
1406 let m = curve.len();
1407 let mut peak_indices = Vec::new();
1408 for j in 1..m {
1409 if d1[j - 1] > 0.0 && d1[j] <= 0.0 {
1410 let idx = if (d1[j - 1] - d1[j]).abs() > 1e-15 {
1411 j - 1
1412 } else {
1413 j
1414 };
1415
1416 if peak_indices.is_empty()
1417 || idx - peak_indices[peak_indices.len() - 1] >= min_dist_points
1418 {
1419 peak_indices.push(idx);
1420 }
1421 }
1422 }
1423
1424 let mut peaks: Vec<Peak> = peak_indices
1425 .iter()
1426 .map(|&idx| {
1427 let prominence = compute_prominence(curve, idx) / data_range;
1428 Peak {
1429 time: argvals[idx],
1430 value: curve[idx],
1431 prominence,
1432 }
1433 })
1434 .collect();
1435
1436 if let Some(min_prom) = min_prominence {
1437 peaks.retain(|p| p.prominence >= min_prom);
1438 }
1439
1440 let distances: Vec<f64> = peaks.windows(2).map(|w| w[1].time - w[0].time).collect();
1441
1442 (peaks, distances)
1443}
1444
1445pub fn detect_peaks(
1461 data: &FdMatrix,
1462 argvals: &[f64],
1463 min_distance: Option<f64>,
1464 min_prominence: Option<f64>,
1465 smooth_first: bool,
1466 smooth_nbasis: Option<usize>,
1467) -> PeakDetectionResult {
1468 let (n, m) = data.shape();
1469 if n == 0 || m < 3 || argvals.len() != m {
1470 return PeakDetectionResult {
1471 peaks: Vec::new(),
1472 inter_peak_distances: Vec::new(),
1473 mean_period: f64::NAN,
1474 };
1475 }
1476
1477 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1478 let min_dist_points = min_distance.map(|d| (d / dt).round() as usize).unwrap_or(1);
1479
1480 let work_data = smooth_for_peaks(data, argvals, smooth_first, smooth_nbasis);
1481
1482 let work_mat = FdMatrix::from_column_major(work_data.clone(), n, m).unwrap();
1484 let deriv1 = deriv_1d(&work_mat, argvals, 1).into_vec();
1485
1486 let data_range: f64 = {
1488 let mut min_val = f64::INFINITY;
1489 let mut max_val = f64::NEG_INFINITY;
1490 for &v in work_data.iter() {
1491 min_val = min_val.min(v);
1492 max_val = max_val.max(v);
1493 }
1494 (max_val - min_val).max(1e-15)
1495 };
1496
1497 let results: Vec<(Vec<Peak>, Vec<f64>)> = iter_maybe_parallel!(0..n)
1499 .map(|i| {
1500 let curve: Vec<f64> = (0..m).map(|j| work_data[i + j * n]).collect();
1501 let d1: Vec<f64> = (0..m).map(|j| deriv1[i + j * n]).collect();
1502 detect_peaks_single_curve(
1503 &curve,
1504 &d1,
1505 argvals,
1506 min_dist_points,
1507 min_prominence,
1508 data_range,
1509 )
1510 })
1511 .collect();
1512
1513 let peaks: Vec<Vec<Peak>> = results.iter().map(|(p, _)| p.clone()).collect();
1514 let inter_peak_distances: Vec<Vec<f64>> = results.iter().map(|(_, d)| d.clone()).collect();
1515
1516 let all_distances: Vec<f64> = inter_peak_distances.iter().flatten().cloned().collect();
1518 let mean_period = if all_distances.is_empty() {
1519 f64::NAN
1520 } else {
1521 all_distances.iter().sum::<f64>() / all_distances.len() as f64
1522 };
1523
1524 PeakDetectionResult {
1525 peaks,
1526 inter_peak_distances,
1527 mean_period,
1528 }
1529}
1530
1531pub fn seasonal_strength_variance(
1551 data: &FdMatrix,
1552 argvals: &[f64],
1553 period: f64,
1554 n_harmonics: usize,
1555) -> f64 {
1556 let (n, m) = data.shape();
1557 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1558 return f64::NAN;
1559 }
1560
1561 let mean_curve = compute_mean_curve(data);
1563
1564 let global_mean: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1566 let total_var: f64 = mean_curve
1567 .iter()
1568 .map(|&x| (x - global_mean).powi(2))
1569 .sum::<f64>()
1570 / m as f64;
1571
1572 if total_var < 1e-15 {
1573 return 0.0;
1574 }
1575
1576 let nbasis = 1 + 2 * n_harmonics;
1578 let basis = fourier_basis_with_period(argvals, nbasis, period);
1579
1580 let mut seasonal = vec![0.0; m];
1582 for k in 1..nbasis {
1583 let b_sum: f64 = (0..m).map(|j| basis[j + k * m].powi(2)).sum();
1585 if b_sum > 1e-15 {
1586 let coef: f64 = (0..m)
1587 .map(|j| mean_curve[j] * basis[j + k * m])
1588 .sum::<f64>()
1589 / b_sum;
1590 for j in 0..m {
1591 seasonal[j] += coef * basis[j + k * m];
1592 }
1593 }
1594 }
1595
1596 let seasonal_mean: f64 = seasonal.iter().sum::<f64>() / m as f64;
1598 let seasonal_var: f64 = seasonal
1599 .iter()
1600 .map(|&x| (x - seasonal_mean).powi(2))
1601 .sum::<f64>()
1602 / m as f64;
1603
1604 (seasonal_var / total_var).min(1.0)
1605}
1606
1607pub fn seasonal_strength_spectral(data: &FdMatrix, argvals: &[f64], period: f64) -> f64 {
1618 let (n, m) = data.shape();
1619 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1620 return f64::NAN;
1621 }
1622
1623 let mean_curve = compute_mean_curve(data);
1625
1626 let (frequencies, power) = periodogram(&mean_curve, argvals);
1627
1628 if frequencies.len() < 2 {
1629 return f64::NAN;
1630 }
1631
1632 let fundamental_freq = 1.0 / period;
1633 let (seasonal_power, total_power) =
1634 sum_harmonic_power(&frequencies, &power, fundamental_freq, 0.1);
1635
1636 if total_power < 1e-15 {
1637 return 0.0;
1638 }
1639
1640 (seasonal_power / total_power).min(1.0)
1641}
1642
1643pub fn seasonal_strength_wavelet(data: &FdMatrix, argvals: &[f64], period: f64) -> f64 {
1664 let (n, m) = data.shape();
1665 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1666 return f64::NAN;
1667 }
1668
1669 let mean_curve = compute_mean_curve(data);
1671
1672 let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1674 let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
1675
1676 let total_variance: f64 = detrended.iter().map(|&x| x * x).sum::<f64>() / m as f64;
1678
1679 if total_variance < 1e-15 {
1680 return 0.0;
1681 }
1682
1683 let omega0 = 6.0;
1685 let scale = period * omega0 / (2.0 * PI);
1686 let wavelet_coeffs = cwt_morlet_fft(&detrended, argvals, scale, omega0);
1687
1688 if wavelet_coeffs.is_empty() {
1689 return f64::NAN;
1690 }
1691
1692 let (interior_start, interior_end) = match interior_bounds(m) {
1694 Some(bounds) => bounds,
1695 None => return f64::NAN,
1696 };
1697
1698 let wavelet_power: f64 = wavelet_coeffs[interior_start..interior_end]
1699 .iter()
1700 .map(|c| c.norm_sqr())
1701 .sum::<f64>()
1702 / (interior_end - interior_start) as f64;
1703
1704 (wavelet_power / total_variance).sqrt().min(1.0)
1707}
1708
1709pub fn seasonal_strength_windowed(
1723 data: &FdMatrix,
1724 argvals: &[f64],
1725 period: f64,
1726 window_size: f64,
1727 method: StrengthMethod,
1728) -> Vec<f64> {
1729 let (n, m) = data.shape();
1730 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 || window_size <= 0.0 {
1731 return Vec::new();
1732 }
1733
1734 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1735 let half_window_points = ((window_size / 2.0) / dt).round() as usize;
1736
1737 let mean_curve = compute_mean_curve(data);
1739
1740 iter_maybe_parallel!(0..m)
1741 .map(|center| {
1742 let start = center.saturating_sub(half_window_points);
1743 let end = (center + half_window_points + 1).min(m);
1744 let window_m = end - start;
1745
1746 if window_m < 4 {
1747 return f64::NAN;
1748 }
1749
1750 let window_data: Vec<f64> = mean_curve[start..end].to_vec();
1751 let window_argvals: Vec<f64> = argvals[start..end].to_vec();
1752
1753 let single_mat = FdMatrix::from_column_major(window_data, 1, window_m).unwrap();
1755
1756 match method {
1757 StrengthMethod::Variance => {
1758 seasonal_strength_variance(&single_mat, &window_argvals, period, 3)
1759 }
1760 StrengthMethod::Spectral => {
1761 seasonal_strength_spectral(&single_mat, &window_argvals, period)
1762 }
1763 }
1764 })
1765 .collect()
1766}
1767
1768pub fn detect_seasonality_changes(
1787 data: &FdMatrix,
1788 argvals: &[f64],
1789 period: f64,
1790 threshold: f64,
1791 window_size: f64,
1792 min_duration: f64,
1793) -> ChangeDetectionResult {
1794 let (n, m) = data.shape();
1795 if n == 0 || m < 4 || argvals.len() != m {
1796 return ChangeDetectionResult {
1797 change_points: Vec::new(),
1798 strength_curve: Vec::new(),
1799 };
1800 }
1801
1802 let strength_curve =
1804 seasonal_strength_windowed(data, argvals, period, window_size, StrengthMethod::Variance);
1805
1806 if strength_curve.is_empty() {
1807 return ChangeDetectionResult {
1808 change_points: Vec::new(),
1809 strength_curve: Vec::new(),
1810 };
1811 }
1812
1813 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1814 let min_dur_points = (min_duration / dt).round() as usize;
1815
1816 let change_points =
1817 detect_threshold_crossings(&strength_curve, argvals, threshold, min_dur_points);
1818
1819 ChangeDetectionResult {
1820 change_points,
1821 strength_curve,
1822 }
1823}
1824
1825pub fn detect_amplitude_modulation(
1864 data: &FdMatrix,
1865 argvals: &[f64],
1866 period: f64,
1867 modulation_threshold: f64,
1868 seasonality_threshold: f64,
1869) -> AmplitudeModulationResult {
1870 let (n, m) = data.shape();
1871 let empty_result = AmplitudeModulationResult {
1873 is_seasonal: false,
1874 seasonal_strength: 0.0,
1875 has_modulation: false,
1876 modulation_type: ModulationType::NonSeasonal,
1877 modulation_score: 0.0,
1878 amplitude_trend: 0.0,
1879 strength_curve: Vec::new(),
1880 time_points: Vec::new(),
1881 min_strength: 0.0,
1882 max_strength: 0.0,
1883 };
1884
1885 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1886 return empty_result;
1887 }
1888
1889 let overall_strength = seasonal_strength_spectral(data, argvals, period);
1891
1892 if overall_strength < seasonality_threshold {
1893 return AmplitudeModulationResult {
1894 is_seasonal: false,
1895 seasonal_strength: overall_strength,
1896 has_modulation: false,
1897 modulation_type: ModulationType::NonSeasonal,
1898 modulation_score: 0.0,
1899 amplitude_trend: 0.0,
1900 strength_curve: Vec::new(),
1901 time_points: argvals.to_vec(),
1902 min_strength: 0.0,
1903 max_strength: 0.0,
1904 };
1905 }
1906
1907 let mean_curve = compute_mean_curve(data);
1909
1910 let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1912 let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
1913 let analytic = hilbert_transform(&detrended);
1914 let envelope: Vec<f64> = analytic.iter().map(|c| c.norm()).collect();
1915
1916 if envelope.is_empty() {
1917 return AmplitudeModulationResult {
1918 is_seasonal: true,
1919 seasonal_strength: overall_strength,
1920 has_modulation: false,
1921 modulation_type: ModulationType::Stable,
1922 modulation_score: 0.0,
1923 amplitude_trend: 0.0,
1924 strength_curve: Vec::new(),
1925 time_points: argvals.to_vec(),
1926 min_strength: 0.0,
1927 max_strength: 0.0,
1928 };
1929 }
1930
1931 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1933 let smooth_window = ((period / dt) as usize).max(3);
1934 let half_window = smooth_window / 2;
1935
1936 let smoothed_envelope: Vec<f64> = (0..m)
1937 .map(|i| {
1938 let start = i.saturating_sub(half_window);
1939 let end = (i + half_window + 1).min(m);
1940 let sum: f64 = envelope[start..end].iter().sum();
1941 sum / (end - start) as f64
1942 })
1943 .collect();
1944
1945 let Some((interior_start, interior_end)) = valid_interior_bounds(m, 4) else {
1947 return AmplitudeModulationResult {
1948 is_seasonal: true,
1949 seasonal_strength: overall_strength,
1950 has_modulation: false,
1951 modulation_type: ModulationType::Stable,
1952 modulation_score: 0.0,
1953 amplitude_trend: 0.0,
1954 strength_curve: envelope,
1955 time_points: argvals.to_vec(),
1956 min_strength: 0.0,
1957 max_strength: 0.0,
1958 };
1959 };
1960
1961 let stats = analyze_amplitude_envelope(
1962 &smoothed_envelope[interior_start..interior_end],
1963 &argvals[interior_start..interior_end],
1964 modulation_threshold,
1965 );
1966
1967 AmplitudeModulationResult {
1968 is_seasonal: true,
1969 seasonal_strength: overall_strength,
1970 has_modulation: stats.has_modulation,
1971 modulation_type: stats.modulation_type,
1972 modulation_score: stats.modulation_score,
1973 amplitude_trend: stats.amplitude_trend,
1974 strength_curve: envelope,
1975 time_points: argvals.to_vec(),
1976 min_strength: stats.min_amp,
1977 max_strength: stats.max_amp,
1978 }
1979}
1980
1981pub fn detect_amplitude_modulation_wavelet(
2004 data: &FdMatrix,
2005 argvals: &[f64],
2006 period: f64,
2007 modulation_threshold: f64,
2008 seasonality_threshold: f64,
2009) -> WaveletAmplitudeResult {
2010 let (n, m) = data.shape();
2011 let empty_result = WaveletAmplitudeResult {
2012 is_seasonal: false,
2013 seasonal_strength: 0.0,
2014 has_modulation: false,
2015 modulation_type: ModulationType::NonSeasonal,
2016 modulation_score: 0.0,
2017 amplitude_trend: 0.0,
2018 wavelet_amplitude: Vec::new(),
2019 time_points: Vec::new(),
2020 scale: 0.0,
2021 };
2022
2023 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
2024 return empty_result;
2025 }
2026
2027 let overall_strength = seasonal_strength_spectral(data, argvals, period);
2029
2030 if overall_strength < seasonality_threshold {
2031 return WaveletAmplitudeResult {
2032 is_seasonal: false,
2033 seasonal_strength: overall_strength,
2034 has_modulation: false,
2035 modulation_type: ModulationType::NonSeasonal,
2036 modulation_score: 0.0,
2037 amplitude_trend: 0.0,
2038 wavelet_amplitude: Vec::new(),
2039 time_points: argvals.to_vec(),
2040 scale: 0.0,
2041 };
2042 }
2043
2044 let mean_curve = compute_mean_curve(data);
2046
2047 let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
2049 let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
2050
2051 let omega0 = 6.0; let scale = period * omega0 / (2.0 * PI);
2055
2056 let wavelet_coeffs = cwt_morlet_fft(&detrended, argvals, scale, omega0);
2058
2059 if wavelet_coeffs.is_empty() {
2060 return WaveletAmplitudeResult {
2061 is_seasonal: true,
2062 seasonal_strength: overall_strength,
2063 has_modulation: false,
2064 modulation_type: ModulationType::Stable,
2065 modulation_score: 0.0,
2066 amplitude_trend: 0.0,
2067 wavelet_amplitude: Vec::new(),
2068 time_points: argvals.to_vec(),
2069 scale,
2070 };
2071 }
2072
2073 let wavelet_amplitude: Vec<f64> = wavelet_coeffs.iter().map(|c| c.norm()).collect();
2075
2076 let Some((interior_start, interior_end)) = valid_interior_bounds(m, 4) else {
2078 return WaveletAmplitudeResult {
2079 is_seasonal: true,
2080 seasonal_strength: overall_strength,
2081 has_modulation: false,
2082 modulation_type: ModulationType::Stable,
2083 modulation_score: 0.0,
2084 amplitude_trend: 0.0,
2085 wavelet_amplitude,
2086 time_points: argvals.to_vec(),
2087 scale,
2088 };
2089 };
2090
2091 let stats = analyze_amplitude_envelope(
2092 &wavelet_amplitude[interior_start..interior_end],
2093 &argvals[interior_start..interior_end],
2094 modulation_threshold,
2095 );
2096
2097 WaveletAmplitudeResult {
2098 is_seasonal: true,
2099 seasonal_strength: overall_strength,
2100 has_modulation: stats.has_modulation,
2101 modulation_type: stats.modulation_type,
2102 modulation_score: stats.modulation_score,
2103 amplitude_trend: stats.amplitude_trend,
2104 wavelet_amplitude,
2105 time_points: argvals.to_vec(),
2106 scale,
2107 }
2108}
2109
2110pub fn instantaneous_period(data: &FdMatrix, argvals: &[f64]) -> InstantaneousPeriod {
2125 let (n, m) = data.shape();
2126 if n == 0 || m < 4 || argvals.len() != m {
2127 return InstantaneousPeriod {
2128 period: Vec::new(),
2129 frequency: Vec::new(),
2130 amplitude: Vec::new(),
2131 };
2132 }
2133
2134 let mean_curve = compute_mean_curve(data);
2136
2137 let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
2139 let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
2140
2141 let analytic = hilbert_transform(&detrended);
2143
2144 let amplitude: Vec<f64> = analytic.iter().map(|c| c.norm()).collect();
2146
2147 let phase: Vec<f64> = analytic.iter().map(|c| c.im.atan2(c.re)).collect();
2148
2149 let unwrapped_phase = unwrap_phase(&phase);
2151
2152 let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
2154 let mut inst_freq = vec![0.0; m];
2155
2156 if m > 1 {
2158 inst_freq[0] = (unwrapped_phase[1] - unwrapped_phase[0]) / dt / (2.0 * PI);
2159 }
2160 for j in 1..(m - 1) {
2161 inst_freq[j] = (unwrapped_phase[j + 1] - unwrapped_phase[j - 1]) / (2.0 * dt) / (2.0 * PI);
2162 }
2163 if m > 1 {
2164 inst_freq[m - 1] = (unwrapped_phase[m - 1] - unwrapped_phase[m - 2]) / dt / (2.0 * PI);
2165 }
2166
2167 let period: Vec<f64> = inst_freq
2169 .iter()
2170 .map(|&f| {
2171 if f.abs() > 1e-10 {
2172 (1.0 / f).abs()
2173 } else {
2174 f64::INFINITY
2175 }
2176 })
2177 .collect();
2178
2179 InstantaneousPeriod {
2180 period,
2181 frequency: inst_freq,
2182 amplitude,
2183 }
2184}
2185
2186pub fn analyze_peak_timing(
2207 data: &FdMatrix,
2208 argvals: &[f64],
2209 period: f64,
2210 smooth_nbasis: Option<usize>,
2211) -> PeakTimingResult {
2212 let (n, m) = data.shape();
2213 if n == 0 || m < 3 || argvals.len() != m || period <= 0.0 {
2214 return PeakTimingResult {
2215 peak_times: Vec::new(),
2216 peak_values: Vec::new(),
2217 normalized_timing: Vec::new(),
2218 mean_timing: f64::NAN,
2219 std_timing: f64::NAN,
2220 range_timing: f64::NAN,
2221 variability_score: f64::NAN,
2222 timing_trend: f64::NAN,
2223 cycle_indices: Vec::new(),
2224 };
2225 }
2226
2227 let min_distance = period * 0.7;
2230 let peaks = detect_peaks(
2231 data,
2232 argvals,
2233 Some(min_distance),
2234 None, true, smooth_nbasis,
2237 );
2238
2239 let sample_peaks = if peaks.peaks.is_empty() {
2242 Vec::new()
2243 } else {
2244 peaks.peaks[0].clone()
2245 };
2246
2247 if sample_peaks.is_empty() {
2248 return PeakTimingResult {
2249 peak_times: Vec::new(),
2250 peak_values: Vec::new(),
2251 normalized_timing: Vec::new(),
2252 mean_timing: f64::NAN,
2253 std_timing: f64::NAN,
2254 range_timing: f64::NAN,
2255 variability_score: f64::NAN,
2256 timing_trend: f64::NAN,
2257 cycle_indices: Vec::new(),
2258 };
2259 }
2260
2261 let peak_times: Vec<f64> = sample_peaks.iter().map(|p| p.time).collect();
2262 let peak_values: Vec<f64> = sample_peaks.iter().map(|p| p.value).collect();
2263
2264 let t_start = argvals[0];
2266 let normalized_timing: Vec<f64> = peak_times
2267 .iter()
2268 .map(|&t| {
2269 let cycle_pos = (t - t_start) % period;
2270 cycle_pos / period
2271 })
2272 .collect();
2273
2274 let cycle_indices: Vec<usize> = peak_times
2276 .iter()
2277 .map(|&t| ((t - t_start) / period).floor() as usize + 1)
2278 .collect();
2279
2280 let n_peaks = normalized_timing.len() as f64;
2282 let mean_timing = normalized_timing.iter().sum::<f64>() / n_peaks;
2283
2284 let variance: f64 = normalized_timing
2285 .iter()
2286 .map(|&x| (x - mean_timing).powi(2))
2287 .sum::<f64>()
2288 / n_peaks;
2289 let std_timing = variance.sqrt();
2290
2291 let min_timing = normalized_timing
2292 .iter()
2293 .cloned()
2294 .fold(f64::INFINITY, f64::min);
2295 let max_timing = normalized_timing
2296 .iter()
2297 .cloned()
2298 .fold(f64::NEG_INFINITY, f64::max);
2299 let range_timing = max_timing - min_timing;
2300
2301 let variability_score = (std_timing / 0.1).min(1.0);
2305
2306 let cycle_idx_f64: Vec<f64> = cycle_indices.iter().map(|&i| i as f64).collect();
2308 let timing_trend = linear_slope(&cycle_idx_f64, &normalized_timing);
2309
2310 PeakTimingResult {
2311 peak_times,
2312 peak_values,
2313 normalized_timing,
2314 mean_timing,
2315 std_timing,
2316 range_timing,
2317 variability_score,
2318 timing_trend,
2319 cycle_indices,
2320 }
2321}
2322
2323pub fn classify_seasonality(
2347 data: &FdMatrix,
2348 argvals: &[f64],
2349 period: f64,
2350 strength_threshold: Option<f64>,
2351 timing_threshold: Option<f64>,
2352) -> SeasonalityClassification {
2353 let strength_thresh = strength_threshold.unwrap_or(0.3);
2354 let timing_thresh = timing_threshold.unwrap_or(0.05);
2355
2356 let (n, m) = data.shape();
2357 if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
2358 return SeasonalityClassification {
2359 is_seasonal: false,
2360 has_stable_timing: false,
2361 timing_variability: f64::NAN,
2362 seasonal_strength: f64::NAN,
2363 cycle_strengths: Vec::new(),
2364 weak_seasons: Vec::new(),
2365 classification: SeasonalType::NonSeasonal,
2366 peak_timing: None,
2367 };
2368 }
2369
2370 let overall_strength = seasonal_strength_variance(data, argvals, period, 3);
2372
2373 let (cycle_strengths, weak_seasons) =
2374 compute_cycle_strengths(data, argvals, period, strength_thresh);
2375 let n_cycles = cycle_strengths.len();
2376
2377 let peak_timing = analyze_peak_timing(data, argvals, period, None);
2379
2380 let is_seasonal = overall_strength >= strength_thresh;
2382 let has_stable_timing = peak_timing.std_timing <= timing_thresh;
2383 let timing_variability = peak_timing.variability_score;
2384
2385 let n_weak = weak_seasons.len();
2387 let classification = if !is_seasonal {
2388 SeasonalType::NonSeasonal
2389 } else if n_cycles > 0 && n_weak as f64 / n_cycles as f64 > 0.3 {
2390 SeasonalType::IntermittentSeasonal
2392 } else if !has_stable_timing {
2393 SeasonalType::VariableTiming
2394 } else {
2395 SeasonalType::StableSeasonal
2396 };
2397
2398 SeasonalityClassification {
2399 is_seasonal,
2400 has_stable_timing,
2401 timing_variability,
2402 seasonal_strength: overall_strength,
2403 cycle_strengths,
2404 weak_seasons,
2405 classification,
2406 peak_timing: Some(peak_timing),
2407 }
2408}
2409
2410pub fn detect_seasonality_changes_auto(
2424 data: &FdMatrix,
2425 argvals: &[f64],
2426 period: f64,
2427 threshold_method: ThresholdMethod,
2428 window_size: f64,
2429 min_duration: f64,
2430) -> ChangeDetectionResult {
2431 let (n, m) = data.shape();
2432 if n == 0 || m < 4 || argvals.len() != m {
2433 return ChangeDetectionResult {
2434 change_points: Vec::new(),
2435 strength_curve: Vec::new(),
2436 };
2437 }
2438
2439 let strength_curve =
2441 seasonal_strength_windowed(data, argvals, period, window_size, StrengthMethod::Variance);
2442
2443 if strength_curve.is_empty() {
2444 return ChangeDetectionResult {
2445 change_points: Vec::new(),
2446 strength_curve: Vec::new(),
2447 };
2448 }
2449
2450 let threshold = match threshold_method {
2452 ThresholdMethod::Fixed(t) => t,
2453 ThresholdMethod::Percentile(p) => {
2454 let mut sorted: Vec<f64> = strength_curve
2455 .iter()
2456 .copied()
2457 .filter(|x| x.is_finite())
2458 .collect();
2459 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2460 if sorted.is_empty() {
2461 0.5
2462 } else {
2463 let idx = ((p / 100.0) * sorted.len() as f64) as usize;
2464 sorted[idx.min(sorted.len() - 1)]
2465 }
2466 }
2467 ThresholdMethod::Otsu => otsu_threshold(&strength_curve),
2468 };
2469
2470 detect_seasonality_changes(data, argvals, period, threshold, window_size, min_duration)
2472}
2473
2474#[derive(Debug, Clone)]
2476pub struct SazedResult {
2477 pub period: f64,
2479 pub confidence: f64,
2481 pub component_periods: SazedComponents,
2483 pub agreeing_components: usize,
2485}
2486
2487#[derive(Debug, Clone)]
2489pub struct SazedComponents {
2490 pub spectral: f64,
2492 pub acf_peak: f64,
2494 pub acf_average: f64,
2496 pub zero_crossing: f64,
2498 pub spectral_diff: f64,
2500}
2501
2502pub fn sazed(data: &[f64], argvals: &[f64], tolerance: Option<f64>) -> SazedResult {
2524 let n = data.len();
2525 let tol = tolerance.unwrap_or(0.05); if n < 8 || argvals.len() != n {
2528 return SazedResult {
2529 period: f64::NAN,
2530 confidence: 0.0,
2531 component_periods: SazedComponents {
2532 spectral: f64::NAN,
2533 acf_peak: f64::NAN,
2534 acf_average: f64::NAN,
2535 zero_crossing: f64::NAN,
2536 spectral_diff: f64::NAN,
2537 },
2538 agreeing_components: 0,
2539 };
2540 }
2541
2542 let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
2543 let max_lag = (n / 2).max(4);
2544 let signal_range = argvals[n - 1] - argvals[0];
2545
2546 let min_period = signal_range / (n as f64 / 3.0);
2548 let max_period = signal_range / 2.0;
2550
2551 let (spectral_period, spectral_conf) = sazed_spectral_with_confidence(data, argvals);
2553
2554 let (acf_peak_period, acf_peak_conf) = sazed_acf_peak_with_confidence(data, dt, max_lag);
2556
2557 let acf_average_period = sazed_acf_average(data, dt, max_lag);
2559
2560 let (zero_crossing_period, zero_crossing_conf) =
2562 sazed_zero_crossing_with_confidence(data, dt, max_lag);
2563
2564 let (spectral_diff_period, spectral_diff_conf) =
2566 sazed_spectral_diff_with_confidence(data, argvals);
2567
2568 let components = SazedComponents {
2569 spectral: spectral_period,
2570 acf_peak: acf_peak_period,
2571 acf_average: acf_average_period,
2572 zero_crossing: zero_crossing_period,
2573 spectral_diff: spectral_diff_period,
2574 };
2575
2576 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;
2585
2586 let confident_periods: Vec<f64> = [
2588 validate_sazed_component(
2589 spectral_period,
2590 spectral_conf,
2591 min_period,
2592 max_period,
2593 spectral_thresh,
2594 ),
2595 validate_sazed_component(
2596 acf_peak_period,
2597 acf_peak_conf,
2598 min_period,
2599 max_period,
2600 acf_thresh,
2601 ),
2602 validate_sazed_component(
2603 acf_average_period,
2604 acf_peak_conf,
2605 min_period,
2606 max_period,
2607 acf_thresh,
2608 ),
2609 validate_sazed_component(
2610 zero_crossing_period,
2611 zero_crossing_conf,
2612 min_period,
2613 max_period,
2614 zero_crossing_thresh,
2615 ),
2616 validate_sazed_component(
2617 spectral_diff_period,
2618 spectral_diff_conf,
2619 min_period,
2620 max_period,
2621 spectral_diff_thresh,
2622 ),
2623 ]
2624 .into_iter()
2625 .flatten()
2626 .collect();
2627
2628 if confident_periods.len() < min_confident_components {
2630 return SazedResult {
2631 period: f64::NAN,
2632 confidence: 0.0,
2633 component_periods: components,
2634 agreeing_components: confident_periods.len(),
2635 };
2636 }
2637
2638 let (consensus_period, agreeing_count) = find_consensus_period(&confident_periods, tol);
2640 let confidence = agreeing_count as f64 / 5.0;
2641
2642 SazedResult {
2643 period: consensus_period,
2644 confidence,
2645 component_periods: components,
2646 agreeing_components: agreeing_count,
2647 }
2648}
2649
2650pub fn sazed_fdata(data: &FdMatrix, argvals: &[f64], tolerance: Option<f64>) -> SazedResult {
2661 let (n, m) = data.shape();
2662 if n == 0 || m < 8 || argvals.len() != m {
2663 return SazedResult {
2664 period: f64::NAN,
2665 confidence: 0.0,
2666 component_periods: SazedComponents {
2667 spectral: f64::NAN,
2668 acf_peak: f64::NAN,
2669 acf_average: f64::NAN,
2670 zero_crossing: f64::NAN,
2671 spectral_diff: f64::NAN,
2672 },
2673 agreeing_components: 0,
2674 };
2675 }
2676
2677 let mean_curve = compute_mean_curve(data);
2678 sazed(&mean_curve, argvals, tolerance)
2679}
2680
2681fn sazed_spectral_with_confidence(data: &[f64], argvals: &[f64]) -> (f64, f64) {
2683 let (frequencies, power) = periodogram(data, argvals);
2684
2685 if frequencies.len() < 3 {
2686 return (f64::NAN, 0.0);
2687 }
2688
2689 let power_no_dc: Vec<f64> = power.iter().skip(1).copied().collect();
2691
2692 if power_no_dc.is_empty() {
2693 return (f64::NAN, 0.0);
2694 }
2695
2696 let mut sorted_power = power_no_dc.clone();
2698 sorted_power.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2699 let noise_floor = sorted_power[sorted_power.len() / 2].max(1e-15);
2700
2701 let mut max_idx = 0;
2703 let mut max_val = 0.0;
2704 for (i, &p) in power_no_dc.iter().enumerate() {
2705 if p > max_val {
2706 max_val = p;
2707 max_idx = i;
2708 }
2709 }
2710
2711 let power_ratio = max_val / noise_floor;
2712 let freq = frequencies[max_idx + 1];
2713
2714 if freq > 1e-15 {
2715 (1.0 / freq, power_ratio)
2716 } else {
2717 (f64::NAN, 0.0)
2718 }
2719}
2720
2721fn sazed_acf_peak_with_confidence(data: &[f64], dt: f64, max_lag: usize) -> (f64, f64) {
2723 let acf = autocorrelation(data, max_lag);
2724
2725 match find_first_acf_peak(&acf) {
2726 Some((peak_lag, acf_value)) => (peak_lag as f64 * dt, acf_value),
2727 None => (f64::NAN, 0.0),
2728 }
2729}
2730
2731fn sazed_acf_average(data: &[f64], dt: f64, max_lag: usize) -> f64 {
2733 let acf = autocorrelation(data, max_lag);
2734
2735 if acf.len() < 4 {
2736 return f64::NAN;
2737 }
2738
2739 let peaks = find_peaks_1d(&acf[1..], 1);
2741
2742 if peaks.is_empty() {
2743 return f64::NAN;
2744 }
2745
2746 let mut weighted_sum = 0.0;
2748 let mut weight_sum = 0.0;
2749
2750 for (i, &peak_idx) in peaks.iter().enumerate() {
2751 let lag = peak_idx + 1;
2752 let weight = acf[lag].max(0.0);
2753
2754 if i == 0 {
2755 weighted_sum += lag as f64 * weight;
2757 weight_sum += weight;
2758 } else {
2759 let expected_fundamental = peaks[0] + 1;
2761 let harmonic = ((lag as f64 / expected_fundamental as f64) + 0.5) as usize;
2762 if harmonic > 0 {
2763 let fundamental_est = lag as f64 / harmonic as f64;
2764 weighted_sum += fundamental_est * weight;
2765 weight_sum += weight;
2766 }
2767 }
2768 }
2769
2770 if weight_sum > 1e-15 {
2771 weighted_sum / weight_sum * dt
2772 } else {
2773 f64::NAN
2774 }
2775}
2776
2777fn sazed_zero_crossing_with_confidence(data: &[f64], dt: f64, max_lag: usize) -> (f64, f64) {
2780 let acf = autocorrelation(data, max_lag);
2781
2782 if acf.len() < 4 {
2783 return (f64::NAN, 0.0);
2784 }
2785
2786 let mut crossings = Vec::new();
2788 for i in 1..acf.len() {
2789 if acf[i - 1] * acf[i] < 0.0 {
2790 let frac = acf[i - 1].abs() / (acf[i - 1].abs() + acf[i].abs());
2792 crossings.push((i - 1) as f64 + frac);
2793 }
2794 }
2795
2796 if crossings.len() < 2 {
2797 return (f64::NAN, 0.0);
2798 }
2799
2800 let mut half_periods = Vec::new();
2803 for i in 1..crossings.len() {
2804 half_periods.push(crossings[i] - crossings[i - 1]);
2805 }
2806
2807 if half_periods.is_empty() {
2808 return (f64::NAN, 0.0);
2809 }
2810
2811 let mean: f64 = half_periods.iter().sum::<f64>() / half_periods.len() as f64;
2814 let variance: f64 =
2815 half_periods.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / half_periods.len() as f64;
2816 let std = variance.sqrt();
2817 let consistency = (1.0 - std / mean.max(1e-15)).clamp(0.0, 1.0);
2818
2819 half_periods.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2821 let median_half = half_periods[half_periods.len() / 2];
2822
2823 (2.0 * median_half * dt, consistency)
2824}
2825
2826fn sazed_spectral_diff_with_confidence(data: &[f64], argvals: &[f64]) -> (f64, f64) {
2828 if data.len() < 4 {
2829 return (f64::NAN, 0.0);
2830 }
2831
2832 let diff: Vec<f64> = data.windows(2).map(|w| w[1] - w[0]).collect();
2834 let diff_argvals: Vec<f64> = argvals.windows(2).map(|w| (w[0] + w[1]) / 2.0).collect();
2835
2836 sazed_spectral_with_confidence(&diff, &diff_argvals)
2837}
2838
2839fn find_spectral_peaks(power: &[f64]) -> Vec<usize> {
2841 if power.len() < 3 {
2842 return Vec::new();
2843 }
2844
2845 let mut sorted_power: Vec<f64> = power.to_vec();
2847 sorted_power.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2848 let noise_floor = sorted_power[sorted_power.len() / 2];
2849 let threshold = noise_floor * 2.0; let mut peaks: Vec<(usize, f64)> = Vec::new();
2853 for i in 1..(power.len() - 1) {
2854 if power[i] > power[i - 1] && power[i] > power[i + 1] && power[i] > threshold {
2855 peaks.push((i, power[i]));
2856 }
2857 }
2858
2859 peaks.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
2861
2862 peaks.into_iter().map(|(idx, _)| idx).collect()
2863}
2864
2865fn find_consensus_period(periods: &[f64], tolerance: f64) -> (f64, usize) {
2867 if periods.is_empty() {
2868 return (f64::NAN, 0);
2869 }
2870 if periods.len() == 1 {
2871 return (periods[0], 1);
2872 }
2873
2874 let mut best_period = periods[0];
2875 let mut best_count = 0;
2876 let mut best_sum = 0.0;
2877
2878 for &p1 in periods {
2879 let (count, sum) = count_agreeing_periods(periods, p1, tolerance);
2880
2881 if count > best_count
2882 || (count == best_count && sum / count as f64 > best_sum / best_count.max(1) as f64)
2883 {
2884 best_count = count;
2885 best_period = sum / count as f64;
2886 best_sum = sum;
2887 }
2888 }
2889
2890 (best_period, best_count)
2891}
2892
2893#[derive(Debug, Clone)]
2895pub struct AutoperiodResult {
2896 pub period: f64,
2898 pub confidence: f64,
2900 pub fft_power: f64,
2902 pub acf_validation: f64,
2904 pub candidates: Vec<AutoperiodCandidate>,
2906}
2907
2908#[derive(Debug, Clone)]
2910pub struct AutoperiodCandidate {
2911 pub period: f64,
2913 pub fft_power: f64,
2915 pub acf_score: f64,
2917 pub combined_score: f64,
2919}
2920
2921fn empty_autoperiod_result() -> AutoperiodResult {
2922 AutoperiodResult {
2923 period: f64::NAN,
2924 confidence: 0.0,
2925 fft_power: 0.0,
2926 acf_validation: 0.0,
2927 candidates: Vec::new(),
2928 }
2929}
2930
2931fn build_autoperiod_candidate(
2933 peak_idx: usize,
2934 frequencies: &[f64],
2935 power_no_dc: &[f64],
2936 acf: &[f64],
2937 dt: f64,
2938 steps: usize,
2939 total_power: f64,
2940) -> Option<AutoperiodCandidate> {
2941 let freq = frequencies[peak_idx + 1];
2942 if freq < 1e-15 {
2943 return None;
2944 }
2945 let fft_power = power_no_dc[peak_idx];
2946 let normalized_power = fft_power / total_power.max(1e-15);
2947 let refined_period = refine_period_gradient(acf, 1.0 / freq, dt, steps);
2948 let refined_acf_score = validate_period_acf(acf, refined_period, dt);
2949 Some(AutoperiodCandidate {
2950 period: refined_period,
2951 fft_power,
2952 acf_score: refined_acf_score,
2953 combined_score: normalized_power * refined_acf_score,
2954 })
2955}
2956
2957pub fn autoperiod(
2978 data: &[f64],
2979 argvals: &[f64],
2980 n_candidates: Option<usize>,
2981 gradient_steps: Option<usize>,
2982) -> AutoperiodResult {
2983 let n = data.len();
2984 let max_candidates = n_candidates.unwrap_or(5);
2985 let steps = gradient_steps.unwrap_or(10);
2986
2987 if n < 8 || argvals.len() != n {
2988 return empty_autoperiod_result();
2989 }
2990
2991 let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
2992 let max_lag = (n / 2).max(4);
2993
2994 let (frequencies, power) = periodogram(data, argvals);
2996
2997 if frequencies.len() < 3 {
2998 return empty_autoperiod_result();
2999 }
3000
3001 let power_no_dc: Vec<f64> = power.iter().skip(1).copied().collect();
3003 let peak_indices = find_spectral_peaks(&power_no_dc);
3004
3005 if peak_indices.is_empty() {
3006 return empty_autoperiod_result();
3007 }
3008
3009 let acf = autocorrelation(data, max_lag);
3011
3012 let total_power: f64 = power_no_dc.iter().sum();
3014 let candidates: Vec<AutoperiodCandidate> = peak_indices
3015 .iter()
3016 .take(max_candidates)
3017 .filter_map(|&peak_idx| {
3018 build_autoperiod_candidate(
3019 peak_idx,
3020 &frequencies,
3021 &power_no_dc,
3022 &acf,
3023 dt,
3024 steps,
3025 total_power,
3026 )
3027 })
3028 .collect();
3029
3030 if candidates.is_empty() {
3031 return empty_autoperiod_result();
3032 }
3033
3034 let best = candidates
3036 .iter()
3037 .max_by(|a, b| {
3038 a.combined_score
3039 .partial_cmp(&b.combined_score)
3040 .unwrap_or(std::cmp::Ordering::Equal)
3041 })
3042 .unwrap();
3043
3044 AutoperiodResult {
3045 period: best.period,
3046 confidence: best.combined_score,
3047 fft_power: best.fft_power,
3048 acf_validation: best.acf_score,
3049 candidates,
3050 }
3051}
3052
3053pub fn autoperiod_fdata(
3055 data: &FdMatrix,
3056 argvals: &[f64],
3057 n_candidates: Option<usize>,
3058 gradient_steps: Option<usize>,
3059) -> AutoperiodResult {
3060 let (n, m) = data.shape();
3061 if n == 0 || m < 8 || argvals.len() != m {
3062 return AutoperiodResult {
3063 period: f64::NAN,
3064 confidence: 0.0,
3065 fft_power: 0.0,
3066 acf_validation: 0.0,
3067 candidates: Vec::new(),
3068 };
3069 }
3070
3071 let mean_curve = compute_mean_curve(data);
3072 autoperiod(&mean_curve, argvals, n_candidates, gradient_steps)
3073}
3074
3075fn validate_period_acf(acf: &[f64], period: f64, dt: f64) -> f64 {
3077 let lag = (period / dt).round() as usize;
3078
3079 if lag == 0 || lag >= acf.len() {
3080 return 0.0;
3081 }
3082
3083 let acf_at_lag = acf[lag];
3086
3087 let half_lag = lag / 2;
3089 let double_lag = lag * 2;
3090
3091 let mut score = acf_at_lag.max(0.0);
3092
3093 if half_lag > 0 && half_lag < acf.len() {
3096 let half_acf = acf[half_lag];
3097 if half_acf > acf_at_lag * 0.7 {
3099 score *= 0.5;
3100 }
3101 }
3102
3103 if double_lag < acf.len() {
3104 let double_acf = acf[double_lag];
3105 if double_acf > 0.3 {
3107 score *= 1.2;
3108 }
3109 }
3110
3111 score.min(1.0)
3112}
3113
3114fn refine_period_gradient(acf: &[f64], initial_period: f64, dt: f64, steps: usize) -> f64 {
3116 let mut period = initial_period;
3117 let step_size = dt * 0.5; for _ in 0..steps {
3120 let current_score = validate_period_acf(acf, period, dt);
3121 let left_score = validate_period_acf(acf, period - step_size, dt);
3122 let right_score = validate_period_acf(acf, period + step_size, dt);
3123
3124 if left_score > current_score && left_score > right_score {
3125 period -= step_size;
3126 } else if right_score > current_score {
3127 period += step_size;
3128 }
3129 }
3131
3132 period.max(dt) }
3134
3135#[derive(Debug, Clone)]
3137pub struct CfdAutoperiodResult {
3138 pub period: f64,
3140 pub confidence: f64,
3142 pub acf_validation: f64,
3144 pub periods: Vec<f64>,
3146 pub confidences: Vec<f64>,
3148}
3149
3150fn generate_cfd_candidates(
3152 frequencies: &[f64],
3153 power_no_dc: &[f64],
3154 peak_indices: &[usize],
3155) -> Vec<(f64, f64)> {
3156 let total_power: f64 = power_no_dc.iter().sum();
3157 peak_indices
3158 .iter()
3159 .filter_map(|&peak_idx| {
3160 let freq = frequencies[peak_idx + 1];
3161 if freq > 1e-15 {
3162 let period = 1.0 / freq;
3163 let normalized_power = power_no_dc[peak_idx] / total_power.max(1e-15);
3164 Some((period, normalized_power))
3165 } else {
3166 None
3167 }
3168 })
3169 .collect()
3170}
3171
3172fn validate_cfd_candidates(clusters: &[(f64, f64)], acf: &[f64], dt: f64) -> Vec<(f64, f64, f64)> {
3174 clusters
3175 .iter()
3176 .filter_map(|&(center, power_sum)| {
3177 let acf_score = validate_period_acf(acf, center, dt);
3178 if acf_score > 0.1 {
3179 Some((center, acf_score, power_sum))
3180 } else {
3181 None
3182 }
3183 })
3184 .collect()
3185}
3186
3187fn validate_or_fallback_cfd(
3189 validated: Vec<(f64, f64, f64)>,
3190 candidates: &[(f64, f64)],
3191 tol: f64,
3192 min_size: usize,
3193) -> Vec<(f64, f64, f64)> {
3194 if !validated.is_empty() {
3195 return validated;
3196 }
3197 cluster_periods(candidates, tol, min_size)
3199 .into_iter()
3200 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
3201 .map(|(center, power_sum)| vec![(center, 0.0, power_sum)])
3202 .unwrap_or_default()
3203}
3204
3205fn rank_cfd_results(validated: &[(f64, f64, f64)]) -> (Vec<f64>, Vec<f64>, f64) {
3208 let mut sorted: Vec<_> = validated.to_vec();
3209 sorted.sort_by(|a, b| {
3210 (b.1 * b.2)
3211 .partial_cmp(&(a.1 * a.2))
3212 .unwrap_or(std::cmp::Ordering::Equal)
3213 });
3214 let top_acf = sorted[0].1;
3215 let periods = sorted.iter().map(|v| v.0).collect();
3216 let confidences = sorted.iter().map(|v| v.1 * v.2).collect();
3217 (periods, confidences, top_acf)
3218}
3219
3220fn empty_cfd_result() -> CfdAutoperiodResult {
3221 CfdAutoperiodResult {
3222 period: f64::NAN,
3223 confidence: 0.0,
3224 acf_validation: 0.0,
3225 periods: Vec::new(),
3226 confidences: Vec::new(),
3227 }
3228}
3229
3230fn extract_cfd_spectral_candidates(data: &[f64], argvals: &[f64]) -> Option<Vec<(f64, f64)>> {
3232 let n = data.len();
3233 let mean_x: f64 = argvals.iter().sum::<f64>() / n as f64;
3238 let mean_y: f64 = data.iter().sum::<f64>() / n as f64;
3239 let mut num = 0.0;
3240 let mut den = 0.0;
3241 for i in 0..n {
3242 let dx = argvals[i] - mean_x;
3243 num += dx * (data[i] - mean_y);
3244 den += dx * dx;
3245 }
3246 let slope = if den.abs() > 1e-15 { num / den } else { 0.0 };
3247 let detrended: Vec<f64> = data
3248 .iter()
3249 .zip(argvals.iter())
3250 .map(|(&y, &x)| y - (mean_y + slope * (x - mean_x)))
3251 .collect();
3252 let (frequencies, power) = periodogram(&detrended, argvals);
3253 if frequencies.len() < 3 {
3254 return None;
3255 }
3256 let power_no_dc: Vec<f64> = power.iter().skip(1).copied().collect();
3257 let peak_indices = find_spectral_peaks(&power_no_dc);
3258 if peak_indices.is_empty() {
3259 return None;
3260 }
3261 let candidates = generate_cfd_candidates(&frequencies, &power_no_dc, &peak_indices);
3262 if candidates.is_empty() {
3263 None
3264 } else {
3265 Some(candidates)
3266 }
3267}
3268
3269pub fn cfd_autoperiod(
3290 data: &[f64],
3291 argvals: &[f64],
3292 cluster_tolerance: Option<f64>,
3293 min_cluster_size: Option<usize>,
3294) -> CfdAutoperiodResult {
3295 let n = data.len();
3296 let tol = cluster_tolerance.unwrap_or(0.1);
3297 let min_size = min_cluster_size.unwrap_or(1);
3298
3299 if n < 8 || argvals.len() != n {
3300 return empty_cfd_result();
3301 }
3302
3303 let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
3304 let max_lag = (n / 2).max(4);
3305
3306 let Some(candidates) = extract_cfd_spectral_candidates(data, argvals) else {
3307 return empty_cfd_result();
3308 };
3309
3310 let clusters = cluster_periods(&candidates, tol, min_size);
3311 if clusters.is_empty() {
3312 return empty_cfd_result();
3313 }
3314
3315 let acf = autocorrelation(data, max_lag);
3316 let validated = validate_cfd_candidates(&clusters, &acf, dt);
3317 let validated = validate_or_fallback_cfd(validated, &candidates, tol, min_size);
3318 let (periods, confidences, top_acf) = rank_cfd_results(&validated);
3319
3320 CfdAutoperiodResult {
3321 period: periods[0],
3322 confidence: confidences[0],
3323 acf_validation: top_acf,
3324 periods,
3325 confidences,
3326 }
3327}
3328
3329pub fn cfd_autoperiod_fdata(
3331 data: &FdMatrix,
3332 argvals: &[f64],
3333 cluster_tolerance: Option<f64>,
3334 min_cluster_size: Option<usize>,
3335) -> CfdAutoperiodResult {
3336 let (n, m) = data.shape();
3337 if n == 0 || m < 8 || argvals.len() != m {
3338 return CfdAutoperiodResult {
3339 period: f64::NAN,
3340 confidence: 0.0,
3341 acf_validation: 0.0,
3342 periods: Vec::new(),
3343 confidences: Vec::new(),
3344 };
3345 }
3346
3347 let mean_curve = compute_mean_curve(data);
3348 cfd_autoperiod(&mean_curve, argvals, cluster_tolerance, min_cluster_size)
3349}
3350
3351fn cluster_periods(candidates: &[(f64, f64)], tolerance: f64, min_size: usize) -> Vec<(f64, f64)> {
3353 if candidates.is_empty() {
3354 return Vec::new();
3355 }
3356
3357 let mut sorted: Vec<(f64, f64)> = candidates.to_vec();
3359 sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
3360
3361 let mut clusters: Vec<(f64, f64)> = Vec::new(); let mut current_cluster: Vec<(f64, f64)> = vec![sorted[0]];
3363
3364 for &(period, power) in sorted.iter().skip(1) {
3365 let cluster_center =
3366 current_cluster.iter().map(|(p, _)| p).sum::<f64>() / current_cluster.len() as f64;
3367
3368 let rel_diff = (period - cluster_center).abs() / cluster_center.max(period);
3369
3370 if rel_diff <= tolerance {
3371 current_cluster.push((period, power));
3373 } else {
3374 if current_cluster.len() >= min_size {
3376 let center = current_cluster.iter().map(|(p, pw)| p * pw).sum::<f64>()
3377 / current_cluster
3378 .iter()
3379 .map(|(_, pw)| pw)
3380 .sum::<f64>()
3381 .max(1e-15);
3382 let total_power: f64 = current_cluster.iter().map(|(_, pw)| pw).sum();
3383 clusters.push((center, total_power));
3384 }
3385 current_cluster = vec![(period, power)];
3386 }
3387 }
3388
3389 if current_cluster.len() >= min_size {
3391 let center = current_cluster.iter().map(|(p, pw)| p * pw).sum::<f64>()
3392 / current_cluster
3393 .iter()
3394 .map(|(_, pw)| pw)
3395 .sum::<f64>()
3396 .max(1e-15);
3397 let total_power: f64 = current_cluster.iter().map(|(_, pw)| pw).sum();
3398 clusters.push((center, total_power));
3399 }
3400
3401 clusters
3402}
3403
3404#[derive(Debug, Clone)]
3410pub struct LombScargleResult {
3411 pub frequencies: Vec<f64>,
3413 pub periods: Vec<f64>,
3415 pub power: Vec<f64>,
3417 pub peak_period: f64,
3419 pub peak_frequency: f64,
3421 pub peak_power: f64,
3423 pub false_alarm_probability: f64,
3425 pub significance: f64,
3427}
3428
3429pub fn lomb_scargle(
3469 times: &[f64],
3470 values: &[f64],
3471 frequencies: Option<&[f64]>,
3472 oversampling: Option<f64>,
3473 nyquist_factor: Option<f64>,
3474) -> LombScargleResult {
3475 let n = times.len();
3476 assert_eq!(n, values.len(), "times and values must have same length");
3477 assert!(n >= 3, "Need at least 3 data points");
3478
3479 let mean_y: f64 = values.iter().sum::<f64>() / n as f64;
3481 let var_y: f64 = values.iter().map(|&y| (y - mean_y).powi(2)).sum::<f64>() / (n - 1) as f64;
3482
3483 let freq_vec: Vec<f64>;
3485 let freqs = if let Some(f) = frequencies {
3486 f
3487 } else {
3488 freq_vec = generate_ls_frequencies(
3489 times,
3490 oversampling.unwrap_or(4.0),
3491 nyquist_factor.unwrap_or(1.0),
3492 );
3493 &freq_vec
3494 };
3495
3496 let mut power = Vec::with_capacity(freqs.len());
3498
3499 for &freq in freqs.iter() {
3500 let omega = 2.0 * PI * freq;
3501 let p = lomb_scargle_single_freq(times, values, mean_y, var_y, omega);
3502 power.push(p);
3503 }
3504
3505 let (peak_idx, &peak_power) = power
3507 .iter()
3508 .enumerate()
3509 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
3510 .unwrap_or((0, &0.0));
3511
3512 let peak_frequency = freqs.get(peak_idx).copied().unwrap_or(0.0);
3513 let peak_period = if peak_frequency > 0.0 {
3514 1.0 / peak_frequency
3515 } else {
3516 f64::INFINITY
3517 };
3518
3519 let n_indep = estimate_independent_frequencies(times, freqs.len());
3521 let fap = lomb_scargle_fap(peak_power, n_indep, n);
3522
3523 let periods: Vec<f64> = freqs
3525 .iter()
3526 .map(|&f| if f > 0.0 { 1.0 / f } else { f64::INFINITY })
3527 .collect();
3528
3529 LombScargleResult {
3530 frequencies: freqs.to_vec(),
3531 periods,
3532 power,
3533 peak_period,
3534 peak_frequency,
3535 peak_power,
3536 false_alarm_probability: fap,
3537 significance: 1.0 - fap,
3538 }
3539}
3540
3541fn lomb_scargle_single_freq(
3545 times: &[f64],
3546 values: &[f64],
3547 mean_y: f64,
3548 var_y: f64,
3549 omega: f64,
3550) -> f64 {
3551 if var_y <= 0.0 || omega <= 0.0 {
3552 return 0.0;
3553 }
3554
3555 let n = times.len();
3556
3557 let mut sum_sin2 = 0.0;
3559 let mut sum_cos2 = 0.0;
3560 for &t in times.iter() {
3561 let arg = 2.0 * omega * t;
3562 sum_sin2 += arg.sin();
3563 sum_cos2 += arg.cos();
3564 }
3565 let tau = (sum_sin2).atan2(sum_cos2) / (2.0 * omega);
3566
3567 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 {
3574 let y_centered = values[i] - mean_y;
3575 let arg = omega * (times[i] - tau);
3576 let c = arg.cos();
3577 let s = arg.sin();
3578
3579 sc += y_centered * c;
3580 ss += y_centered * s;
3581 css += c * c;
3582 sss += s * s;
3583 }
3584
3585 let css = css.max(1e-15);
3587 let sss = sss.max(1e-15);
3588
3589 0.5 * (sc * sc / css + ss * ss / sss) / var_y
3591}
3592
3593fn generate_ls_frequencies(times: &[f64], oversampling: f64, nyquist_factor: f64) -> Vec<f64> {
3597 let n = times.len();
3598 if n < 2 {
3599 return vec![0.0];
3600 }
3601
3602 let t_min = times.iter().cloned().fold(f64::INFINITY, f64::min);
3604 let t_max = times.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
3605 let t_span = (t_max - t_min).max(1e-10);
3606
3607 let f_min = 1.0 / t_span;
3609
3610 let f_nyquist = 0.5 * (n - 1) as f64 / t_span;
3613
3614 let f_max = f_nyquist * nyquist_factor;
3616
3617 let df = f_min / oversampling;
3619
3620 let n_freq = ((f_max - f_min) / df).ceil() as usize + 1;
3622 let n_freq = n_freq.min(10000); (0..n_freq).map(|i| f_min + i as f64 * df).collect()
3625}
3626
3627fn estimate_independent_frequencies(times: &[f64], n_freq: usize) -> usize {
3632 let n = times.len();
3634 n.min(n_freq)
3635}
3636
3637fn lomb_scargle_fap(power: f64, n_indep: usize, _n_data: usize) -> f64 {
3643 if power <= 0.0 || n_indep == 0 {
3644 return 1.0;
3645 }
3646
3647 let prob_single = 1.0 - (-power).exp();
3649
3650 if prob_single >= 1.0 {
3657 return 0.0; }
3659 if prob_single <= 0.0 {
3660 return 1.0; }
3662
3663 let log_prob = prob_single.ln();
3664 let log_cdf = n_indep as f64 * log_prob;
3665
3666 if log_cdf < -700.0 {
3667 0.0 } else {
3669 1.0 - log_cdf.exp()
3670 }
3671}
3672
3673pub fn lomb_scargle_fdata(
3689 data: &FdMatrix,
3690 argvals: &[f64],
3691 oversampling: Option<f64>,
3692 nyquist_factor: Option<f64>,
3693) -> LombScargleResult {
3694 let mean_curve = compute_mean_curve(data);
3696
3697 lomb_scargle(argvals, &mean_curve, None, oversampling, nyquist_factor)
3699}
3700
3701#[derive(Debug, Clone)]
3707pub struct MatrixProfileResult {
3708 pub profile: Vec<f64>,
3710 pub profile_index: Vec<usize>,
3712 pub subsequence_length: usize,
3714 pub detected_periods: Vec<f64>,
3716 pub arc_counts: Vec<usize>,
3718 pub primary_period: f64,
3720 pub confidence: f64,
3722}
3723
3724pub fn matrix_profile(
3760 values: &[f64],
3761 subsequence_length: Option<usize>,
3762 exclusion_zone: Option<f64>,
3763) -> MatrixProfileResult {
3764 let n = values.len();
3765
3766 let m = subsequence_length.unwrap_or_else(|| {
3768 let default_m = n / 4;
3769 default_m.max(4).min(n / 2)
3770 });
3771
3772 assert!(m >= 3, "Subsequence length must be at least 3");
3773 assert!(
3774 m <= n / 2,
3775 "Subsequence length must be at most half the series length"
3776 );
3777
3778 let exclusion_zone = exclusion_zone.unwrap_or(0.5);
3779 let exclusion_radius = (m as f64 * exclusion_zone).ceil() as usize;
3780
3781 let profile_len = n - m + 1;
3783
3784 let (means, stds) = compute_sliding_stats(values, m);
3786
3787 let (profile, profile_index) = stomp_core(values, m, &means, &stds, exclusion_radius);
3789
3790 let (arc_counts, detected_periods, primary_period, confidence) =
3792 analyze_arcs(&profile_index, profile_len, m);
3793
3794 MatrixProfileResult {
3795 profile,
3796 profile_index,
3797 subsequence_length: m,
3798 detected_periods,
3799 arc_counts,
3800 primary_period,
3801 confidence,
3802 }
3803}
3804
3805fn compute_sliding_stats(values: &[f64], m: usize) -> (Vec<f64>, Vec<f64>) {
3809 let n = values.len();
3810 let profile_len = n - m + 1;
3811
3812 let mut cumsum = vec![0.0; n + 1];
3814 let mut cumsum_sq = vec![0.0; n + 1];
3815
3816 for i in 0..n {
3817 cumsum[i + 1] = cumsum[i] + values[i];
3818 cumsum_sq[i + 1] = cumsum_sq[i] + values[i] * values[i];
3819 }
3820
3821 let mut means = Vec::with_capacity(profile_len);
3823 let mut stds = Vec::with_capacity(profile_len);
3824
3825 let m_f64 = m as f64;
3826
3827 for i in 0..profile_len {
3828 let sum = cumsum[i + m] - cumsum[i];
3829 let sum_sq = cumsum_sq[i + m] - cumsum_sq[i];
3830
3831 let mean = sum / m_f64;
3832 let variance = (sum_sq / m_f64) - mean * mean;
3833 let std = variance.max(0.0).sqrt();
3834
3835 means.push(mean);
3836 stds.push(std.max(1e-10)); }
3838
3839 (means, stds)
3840}
3841
3842fn stomp_core(
3846 values: &[f64],
3847 m: usize,
3848 means: &[f64],
3849 stds: &[f64],
3850 exclusion_radius: usize,
3851) -> (Vec<f64>, Vec<usize>) {
3852 let n = values.len();
3853 let profile_len = n - m + 1;
3854
3855 let mut profile = vec![f64::INFINITY; profile_len];
3857 let mut profile_index = vec![0usize; profile_len];
3858
3859 let mut qt = vec![0.0; profile_len];
3862
3863 for j in 0..profile_len {
3865 let mut dot = 0.0;
3866 for k in 0..m {
3867 dot += values[k] * values[j + k];
3868 }
3869 qt[j] = dot;
3870 }
3871
3872 update_profile_row(
3874 0,
3875 &qt,
3876 means,
3877 stds,
3878 m,
3879 exclusion_radius,
3880 &mut profile,
3881 &mut profile_index,
3882 );
3883
3884 for i in 1..profile_len {
3886 let mut qt_new = vec![0.0; profile_len];
3889
3890 let mut dot = 0.0;
3892 for k in 0..m {
3893 dot += values[i + k] * values[k];
3894 }
3895 qt_new[0] = dot;
3896
3897 for j in 1..profile_len {
3899 qt_new[j] =
3900 qt[j - 1] - values[i - 1] * values[j - 1] + values[i + m - 1] * values[j + m - 1];
3901 }
3902
3903 qt = qt_new;
3904
3905 update_profile_row(
3907 i,
3908 &qt,
3909 means,
3910 stds,
3911 m,
3912 exclusion_radius,
3913 &mut profile,
3914 &mut profile_index,
3915 );
3916 }
3917
3918 (profile, profile_index)
3919}
3920
3921fn update_profile_row(
3923 i: usize,
3924 qt: &[f64],
3925 means: &[f64],
3926 stds: &[f64],
3927 m: usize,
3928 exclusion_radius: usize,
3929 profile: &mut [f64],
3930 profile_index: &mut [usize],
3931) {
3932 let profile_len = profile.len();
3933 let m_f64 = m as f64;
3934
3935 for j in 0..profile_len {
3936 if i.abs_diff(j) <= exclusion_radius {
3938 continue;
3939 }
3940
3941 let numerator = qt[j] - m_f64 * means[i] * means[j];
3944 let denominator = m_f64 * stds[i] * stds[j];
3945
3946 let pearson = if denominator > 0.0 {
3947 (numerator / denominator).clamp(-1.0, 1.0)
3948 } else {
3949 0.0
3950 };
3951
3952 let dist_sq = 2.0 * m_f64 * (1.0 - pearson);
3953 let dist = dist_sq.max(0.0).sqrt();
3954
3955 if dist < profile[i] {
3957 profile[i] = dist;
3958 profile_index[i] = j;
3959 }
3960
3961 if dist < profile[j] {
3963 profile[j] = dist;
3964 profile_index[j] = i;
3965 }
3966 }
3967}
3968
3969fn analyze_arcs(
3974 profile_index: &[usize],
3975 profile_len: usize,
3976 m: usize,
3977) -> (Vec<usize>, Vec<f64>, f64, f64) {
3978 let max_distance = profile_len;
3980 let mut arc_counts = vec![0usize; max_distance];
3981
3982 for (i, &j) in profile_index.iter().enumerate() {
3983 let distance = i.abs_diff(j);
3984 if distance < max_distance {
3985 arc_counts[distance] += 1;
3986 }
3987 }
3988
3989 let min_period = m / 2; let mut peaks: Vec<(usize, usize)> = Vec::new();
3992
3993 for i in min_period..arc_counts.len().saturating_sub(1) {
3995 if arc_counts[i] > arc_counts[i.saturating_sub(1)]
3996 && arc_counts[i] > arc_counts[(i + 1).min(arc_counts.len() - 1)]
3997 && arc_counts[i] >= 3
3998 {
4000 peaks.push((i, arc_counts[i]));
4001 }
4002 }
4003
4004 peaks.sort_by(|a, b| b.1.cmp(&a.1));
4006
4007 let detected_periods: Vec<f64> = peaks.iter().take(5).map(|(p, _)| *p as f64).collect();
4009
4010 let (primary_period, confidence) = if let Some(&(period, count)) = peaks.first() {
4012 let total_arcs: usize = arc_counts[min_period..].iter().sum();
4014 let conf = if total_arcs > 0 {
4015 count as f64 / total_arcs as f64
4016 } else {
4017 0.0
4018 };
4019 (period as f64, conf.min(1.0))
4020 } else {
4021 (0.0, 0.0)
4022 };
4023
4024 (arc_counts, detected_periods, primary_period, confidence)
4025}
4026
4027pub fn matrix_profile_fdata(
4041 data: &FdMatrix,
4042 subsequence_length: Option<usize>,
4043 exclusion_zone: Option<f64>,
4044) -> MatrixProfileResult {
4045 let mean_curve = compute_mean_curve(data);
4047
4048 matrix_profile(&mean_curve, subsequence_length, exclusion_zone)
4050}
4051
4052pub fn matrix_profile_seasonality(
4064 values: &[f64],
4065 subsequence_length: Option<usize>,
4066 confidence_threshold: Option<f64>,
4067) -> (bool, f64, f64) {
4068 let result = matrix_profile(values, subsequence_length, None);
4069
4070 let threshold = confidence_threshold.unwrap_or(0.1);
4071 let is_seasonal = result.confidence >= threshold && result.primary_period > 0.0;
4072
4073 (is_seasonal, result.primary_period, result.confidence)
4074}
4075
4076#[derive(Debug, Clone)]
4082pub struct SsaResult {
4083 pub trend: Vec<f64>,
4085 pub seasonal: Vec<f64>,
4087 pub noise: Vec<f64>,
4089 pub singular_values: Vec<f64>,
4091 pub contributions: Vec<f64>,
4093 pub window_length: usize,
4095 pub n_components: usize,
4097 pub detected_period: f64,
4099 pub confidence: f64,
4101}
4102
4103pub fn ssa(
4144 values: &[f64],
4145 window_length: Option<usize>,
4146 n_components: Option<usize>,
4147 trend_components: Option<&[usize]>,
4148 seasonal_components: Option<&[usize]>,
4149) -> SsaResult {
4150 let n = values.len();
4151
4152 let l = window_length.unwrap_or_else(|| (n / 2).clamp(2, 50));
4154
4155 if n < 4 || l < 2 || l > n / 2 {
4156 return SsaResult {
4157 trend: values.to_vec(),
4158 seasonal: vec![0.0; n],
4159 noise: vec![0.0; n],
4160 singular_values: vec![],
4161 contributions: vec![],
4162 window_length: l,
4163 n_components: 0,
4164 detected_period: 0.0,
4165 confidence: 0.0,
4166 };
4167 }
4168
4169 let k = n - l + 1;
4171
4172 let trajectory = embed_trajectory(values, l, k);
4174
4175 let (u, sigma, vt) = svd_decompose(&trajectory, l, k);
4177
4178 let max_components = sigma.len();
4180 let n_comp = n_components.unwrap_or(10).min(max_components);
4181
4182 let total_var: f64 = sigma.iter().map(|&s| s * s).sum();
4184 let contributions: Vec<f64> = sigma
4185 .iter()
4186 .take(n_comp)
4187 .map(|&s| s * s / total_var.max(1e-15))
4188 .collect();
4189
4190 let (trend_idx, seasonal_idx, detected_period, confidence) =
4192 if trend_components.is_some() || seasonal_components.is_some() {
4193 let t_idx: Vec<usize> = trend_components.map(|v| v.to_vec()).unwrap_or_default();
4195 let s_idx: Vec<usize> = seasonal_components.map(|v| v.to_vec()).unwrap_or_default();
4196 (t_idx, s_idx, 0.0, 0.0)
4197 } else {
4198 auto_group_ssa_components(&u, &sigma, l, k, n_comp)
4200 };
4201
4202 let trend = reconstruct_grouped(&u, &sigma, &vt, l, k, n, &trend_idx);
4204 let seasonal = reconstruct_grouped(&u, &sigma, &vt, l, k, n, &seasonal_idx);
4205
4206 let noise: Vec<f64> = values
4208 .iter()
4209 .zip(trend.iter())
4210 .zip(seasonal.iter())
4211 .map(|((&y, &t), &s)| y - t - s)
4212 .collect();
4213
4214 SsaResult {
4215 trend,
4216 seasonal,
4217 noise,
4218 singular_values: sigma.into_iter().take(n_comp).collect(),
4219 contributions,
4220 window_length: l,
4221 n_components: n_comp,
4222 detected_period,
4223 confidence,
4224 }
4225}
4226
4227fn embed_trajectory(values: &[f64], l: usize, k: usize) -> Vec<f64> {
4229 let mut trajectory = vec![0.0; l * k];
4231
4232 for j in 0..k {
4233 for i in 0..l {
4234 trajectory[i + j * l] = values[i + j];
4235 }
4236 }
4237
4238 trajectory
4239}
4240
4241fn svd_decompose(trajectory: &[f64], l: usize, k: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
4243 use nalgebra::{DMatrix, SVD};
4244
4245 let mat = DMatrix::from_column_slice(l, k, trajectory);
4247
4248 let svd = SVD::new(mat, true, true);
4250
4251 let u_mat = match svd.u {
4254 Some(u) => u,
4255 None => return (vec![], vec![], vec![]),
4256 };
4257 let vt_mat = match svd.v_t {
4258 Some(vt) => vt,
4259 None => return (vec![], vec![], vec![]),
4260 };
4261 let sigma = svd.singular_values;
4262
4263 let u: Vec<f64> = u_mat.iter().cloned().collect();
4265 let sigma_vec: Vec<f64> = sigma.iter().cloned().collect();
4266 let vt: Vec<f64> = vt_mat.iter().cloned().collect();
4267
4268 (u, sigma_vec, vt)
4269}
4270
4271enum SsaComponentKind {
4272 Trend,
4273 Seasonal(f64),
4274 Noise,
4275}
4276
4277fn classify_ssa_component(u_col: &[f64], trend_count: usize) -> SsaComponentKind {
4279 if is_trend_component(u_col) && trend_count < 2 {
4280 SsaComponentKind::Trend
4281 } else {
4282 let (is_periodic, period) = is_periodic_component(u_col);
4283 if is_periodic {
4284 SsaComponentKind::Seasonal(period)
4285 } else {
4286 SsaComponentKind::Noise
4287 }
4288 }
4289}
4290
4291fn apply_ssa_grouping_defaults(
4293 trend_idx: &mut Vec<usize>,
4294 seasonal_idx: &mut Vec<usize>,
4295 n_comp: usize,
4296) {
4297 if trend_idx.is_empty() && n_comp > 0 {
4298 trend_idx.push(0);
4299 }
4300 if seasonal_idx.is_empty() && n_comp >= 3 {
4301 seasonal_idx.push(1);
4302 if n_comp > 2 {
4303 seasonal_idx.push(2);
4304 }
4305 }
4306}
4307
4308fn auto_group_ssa_components(
4310 u: &[f64],
4311 sigma: &[f64],
4312 l: usize,
4313 _k: usize,
4314 n_comp: usize,
4315) -> (Vec<usize>, Vec<usize>, f64, f64) {
4316 let mut trend_idx = Vec::new();
4317 let mut seasonal_idx = Vec::new();
4318 let mut detected_period = 0.0;
4319 let mut confidence = 0.0;
4320
4321 for i in 0..n_comp.min(sigma.len()) {
4322 let u_col: Vec<f64> = (0..l).map(|j| u[j + i * l]).collect();
4323 match classify_ssa_component(&u_col, trend_idx.len()) {
4324 SsaComponentKind::Trend => trend_idx.push(i),
4325 SsaComponentKind::Seasonal(period) => {
4326 seasonal_idx.push(i);
4327 if detected_period == 0.0 && period > 0.0 {
4328 detected_period = period;
4329 confidence = sigma[i] / sigma[0].max(1e-15);
4330 }
4331 }
4332 SsaComponentKind::Noise => {}
4333 }
4334 }
4335
4336 apply_ssa_grouping_defaults(&mut trend_idx, &mut seasonal_idx, n_comp);
4337 (trend_idx, seasonal_idx, detected_period, confidence)
4338}
4339
4340fn is_trend_component(u_col: &[f64]) -> bool {
4342 let n = u_col.len();
4343 if n < 3 {
4344 return false;
4345 }
4346
4347 let mut sign_changes = 0;
4349 for i in 1..n {
4350 if u_col[i] * u_col[i - 1] < 0.0 {
4351 sign_changes += 1;
4352 }
4353 }
4354
4355 sign_changes <= n / 10
4357}
4358
4359fn is_periodic_component(u_col: &[f64]) -> (bool, f64) {
4361 let n = u_col.len();
4362 if n < 4 {
4363 return (false, 0.0);
4364 }
4365
4366 let mean: f64 = u_col.iter().sum::<f64>() / n as f64;
4368 let centered: Vec<f64> = u_col.iter().map(|&x| x - mean).collect();
4369
4370 let var: f64 = centered.iter().map(|&x| x * x).sum();
4371 if var < 1e-15 {
4372 return (false, 0.0);
4373 }
4374
4375 let mut best_period = 0.0;
4377 let mut best_acf = 0.0;
4378
4379 for lag in 2..n / 2 {
4380 let mut acf = 0.0;
4381 for i in 0..(n - lag) {
4382 acf += centered[i] * centered[i + lag];
4383 }
4384 acf /= var;
4385
4386 if acf > best_acf && acf > 0.3 {
4387 best_acf = acf;
4388 best_period = lag as f64;
4389 }
4390 }
4391
4392 let is_periodic = best_acf > 0.3 && best_period > 0.0;
4393 (is_periodic, best_period)
4394}
4395
4396fn reconstruct_grouped(
4398 u: &[f64],
4399 sigma: &[f64],
4400 vt: &[f64],
4401 l: usize,
4402 k: usize,
4403 n: usize,
4404 group_idx: &[usize],
4405) -> Vec<f64> {
4406 if group_idx.is_empty() {
4407 return vec![0.0; n];
4408 }
4409
4410 let mut grouped_matrix = vec![0.0; l * k];
4412
4413 for &idx in group_idx {
4414 if idx >= sigma.len() {
4415 continue;
4416 }
4417
4418 let s = sigma[idx];
4419
4420 for j in 0..k {
4422 for i in 0..l {
4423 let u_val = u[i + idx * l];
4424 let v_val = vt[idx + j * sigma.len().min(l)]; grouped_matrix[i + j * l] += s * u_val * v_val;
4426 }
4427 }
4428 }
4429
4430 diagonal_average(&grouped_matrix, l, k, n)
4432}
4433
4434fn diagonal_average(matrix: &[f64], l: usize, k: usize, n: usize) -> Vec<f64> {
4436 let mut result = vec![0.0; n];
4437 let mut counts = vec![0.0; n];
4438
4439 for j in 0..k {
4441 for i in 0..l {
4442 let idx = i + j; if idx < n {
4444 result[idx] += matrix[i + j * l];
4445 counts[idx] += 1.0;
4446 }
4447 }
4448 }
4449
4450 for i in 0..n {
4452 if counts[i] > 0.0 {
4453 result[i] /= counts[i];
4454 }
4455 }
4456
4457 result
4458}
4459
4460pub fn ssa_fdata(
4472 data: &FdMatrix,
4473 window_length: Option<usize>,
4474 n_components: Option<usize>,
4475) -> SsaResult {
4476 let mean_curve = compute_mean_curve(data);
4478
4479 ssa(&mean_curve, window_length, n_components, None, None)
4481}
4482
4483pub fn ssa_seasonality(
4493 values: &[f64],
4494 window_length: Option<usize>,
4495 confidence_threshold: Option<f64>,
4496) -> (bool, f64, f64) {
4497 let result = ssa(values, window_length, None, None, None);
4498
4499 let threshold = confidence_threshold.unwrap_or(0.1);
4500 let is_seasonal = result.confidence >= threshold && result.detected_period > 0.0;
4501
4502 (is_seasonal, result.detected_period, result.confidence)
4503}
4504
4505#[cfg(test)]
4506mod tests {
4507 use super::*;
4508 use std::f64::consts::PI;
4509
4510 fn generate_sine(n: usize, m: usize, period: f64, argvals: &[f64]) -> FdMatrix {
4511 let mut data = vec![0.0; n * m];
4512 for i in 0..n {
4513 for j in 0..m {
4514 data[i + j * n] = (2.0 * PI * argvals[j] / period).sin();
4515 }
4516 }
4517 FdMatrix::from_column_major(data, n, m).unwrap()
4518 }
4519
4520 #[test]
4521 fn test_period_estimation_fft() {
4522 let m = 200;
4523 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4524 let period = 2.0;
4525 let data = generate_sine(1, m, period, &argvals);
4526
4527 let estimate = estimate_period_fft(&data, &argvals);
4528 assert!((estimate.period - period).abs() < 0.2);
4529 assert!(estimate.confidence > 1.0);
4530 }
4531
4532 #[test]
4533 fn test_peak_detection() {
4534 let m = 100;
4535 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4536 let period = 2.0;
4537 let data = generate_sine(1, m, period, &argvals);
4538
4539 let result = detect_peaks(&data, &argvals, Some(1.5), None, false, None);
4540
4541 assert!(!result.peaks[0].is_empty());
4543 assert!((result.mean_period - period).abs() < 0.3);
4544 }
4545
4546 #[test]
4547 fn test_peak_detection_known_sine() {
4548 let m = 200; let period = 2.0;
4552 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4553 let data: Vec<f64> = argvals
4554 .iter()
4555 .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
4556 .collect();
4557 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4558
4559 let result = detect_peaks(&data, &argvals, None, None, false, None);
4560
4561 assert_eq!(
4563 result.peaks[0].len(),
4564 5,
4565 "Expected 5 peaks, got {}. Peak times: {:?}",
4566 result.peaks[0].len(),
4567 result.peaks[0].iter().map(|p| p.time).collect::<Vec<_>>()
4568 );
4569
4570 let expected_times = [0.5, 2.5, 4.5, 6.5, 8.5];
4572 for (peak, expected) in result.peaks[0].iter().zip(expected_times.iter()) {
4573 assert!(
4574 (peak.time - expected).abs() < 0.15,
4575 "Peak at {:.3} not close to expected {:.3}",
4576 peak.time,
4577 expected
4578 );
4579 }
4580
4581 assert!(
4583 (result.mean_period - period).abs() < 0.1,
4584 "Mean period {:.3} not close to expected {:.3}",
4585 result.mean_period,
4586 period
4587 );
4588 }
4589
4590 #[test]
4591 fn test_peak_detection_with_min_distance() {
4592 let m = 200;
4594 let period = 2.0;
4595 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4596 let data: Vec<f64> = argvals
4597 .iter()
4598 .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
4599 .collect();
4600 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4601
4602 let result = detect_peaks(&data, &argvals, Some(1.5), None, false, None);
4604 assert_eq!(
4605 result.peaks[0].len(),
4606 5,
4607 "With min_distance=1.5, expected 5 peaks, got {}",
4608 result.peaks[0].len()
4609 );
4610
4611 let result2 = detect_peaks(&data, &argvals, Some(2.5), None, false, None);
4613 assert!(
4614 result2.peaks[0].len() < 5,
4615 "With min_distance=2.5, expected fewer than 5 peaks, got {}",
4616 result2.peaks[0].len()
4617 );
4618 }
4619
4620 #[test]
4621 fn test_peak_detection_period_1() {
4622 let m = 400; let period = 1.0;
4626 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4627 let data: Vec<f64> = argvals
4628 .iter()
4629 .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
4630 .collect();
4631 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4632
4633 let result = detect_peaks(&data, &argvals, None, None, false, None);
4634
4635 assert_eq!(
4637 result.peaks[0].len(),
4638 10,
4639 "Expected 10 peaks, got {}",
4640 result.peaks[0].len()
4641 );
4642
4643 assert!(
4645 (result.mean_period - period).abs() < 0.1,
4646 "Mean period {:.3} not close to expected {:.3}",
4647 result.mean_period,
4648 period
4649 );
4650 }
4651
4652 #[test]
4653 fn test_peak_detection_shifted_sine() {
4654 let m = 200;
4657 let period = 2.0;
4658 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4659 let data: Vec<f64> = argvals
4660 .iter()
4661 .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin() + 1.0)
4662 .collect();
4663 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4664
4665 let result = detect_peaks(&data, &argvals, None, None, false, None);
4666
4667 assert_eq!(
4669 result.peaks[0].len(),
4670 5,
4671 "Expected 5 peaks for shifted sine, got {}",
4672 result.peaks[0].len()
4673 );
4674
4675 for peak in &result.peaks[0] {
4677 assert!(
4678 (peak.value - 2.0).abs() < 0.05,
4679 "Peak value {:.3} not close to expected 2.0",
4680 peak.value
4681 );
4682 }
4683 }
4684
4685 #[test]
4686 fn test_peak_detection_prominence() {
4687 let m = 200;
4690 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4691 let data: Vec<f64> = argvals
4692 .iter()
4693 .map(|&t| {
4694 let base = (2.0 * std::f64::consts::PI * t / 2.0).sin();
4695 let ripple = 0.1 * (2.0 * std::f64::consts::PI * t * 4.0).sin();
4697 base + ripple
4698 })
4699 .collect();
4700 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4701
4702 let result_no_filter = detect_peaks(&data, &argvals, None, None, false, None);
4704
4705 let result_filtered = detect_peaks(&data, &argvals, None, Some(0.5), false, None);
4707
4708 assert!(
4710 result_filtered.peaks[0].len() <= result_no_filter.peaks[0].len(),
4711 "Prominence filter should reduce peak count"
4712 );
4713 }
4714
4715 #[test]
4716 fn test_peak_detection_different_amplitudes() {
4717 let m = 200;
4719 let period = 2.0;
4720 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4721
4722 for amplitude in [0.5, 1.0, 2.0, 5.0] {
4723 let data: Vec<f64> = argvals
4724 .iter()
4725 .map(|&t| amplitude * (2.0 * std::f64::consts::PI * t / period).sin())
4726 .collect();
4727 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4728
4729 let result = detect_peaks(&data, &argvals, None, None, false, None);
4730
4731 assert_eq!(
4732 result.peaks[0].len(),
4733 5,
4734 "Amplitude {} should still find 5 peaks",
4735 amplitude
4736 );
4737
4738 for peak in &result.peaks[0] {
4740 assert!(
4741 (peak.value - amplitude).abs() < 0.1,
4742 "Peak value {:.3} should be close to amplitude {}",
4743 peak.value,
4744 amplitude
4745 );
4746 }
4747 }
4748 }
4749
4750 #[test]
4751 fn test_peak_detection_varying_frequency() {
4752 let m = 400;
4755 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4756
4757 let data: Vec<f64> = argvals
4760 .iter()
4761 .map(|&t| {
4762 let phase = 2.0 * std::f64::consts::PI * (0.5 * t + 0.05 * t * t);
4763 phase.sin()
4764 })
4765 .collect();
4766 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4767
4768 let result = detect_peaks(&data, &argvals, None, None, false, None);
4769
4770 assert!(
4772 result.peaks[0].len() >= 5,
4773 "Should find at least 5 peaks, got {}",
4774 result.peaks[0].len()
4775 );
4776
4777 let distances = &result.inter_peak_distances[0];
4779 if distances.len() >= 3 {
4780 let early_avg = (distances[0] + distances[1]) / 2.0;
4782 let late_avg = (distances[distances.len() - 2] + distances[distances.len() - 1]) / 2.0;
4783 assert!(
4784 late_avg < early_avg,
4785 "Later peaks should be closer: early avg={:.3}, late avg={:.3}",
4786 early_avg,
4787 late_avg
4788 );
4789 }
4790 }
4791
4792 #[test]
4793 fn test_peak_detection_sum_of_sines() {
4794 let m = 300;
4797 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 12.0 / (m - 1) as f64).collect();
4798
4799 let data: Vec<f64> = argvals
4800 .iter()
4801 .map(|&t| {
4802 (2.0 * std::f64::consts::PI * t / 2.0).sin()
4803 + 0.5 * (2.0 * std::f64::consts::PI * t / 3.0).sin()
4804 })
4805 .collect();
4806 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4807
4808 let result = detect_peaks(&data, &argvals, Some(1.0), None, false, None);
4809
4810 assert!(
4812 result.peaks[0].len() >= 4,
4813 "Should find at least 4 peaks, got {}",
4814 result.peaks[0].len()
4815 );
4816
4817 let distances = &result.inter_peak_distances[0];
4819 if distances.len() >= 2 {
4820 let min_dist = distances.iter().cloned().fold(f64::INFINITY, f64::min);
4821 let max_dist = distances.iter().cloned().fold(0.0, f64::max);
4822 assert!(
4823 max_dist > min_dist * 1.1,
4824 "Distances should vary: min={:.3}, max={:.3}",
4825 min_dist,
4826 max_dist
4827 );
4828 }
4829 }
4830
4831 #[test]
4832 fn test_seasonal_strength() {
4833 let m = 200;
4834 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4835 let period = 2.0;
4836 let data = generate_sine(1, m, period, &argvals);
4837
4838 let strength = seasonal_strength_variance(&data, &argvals, period, 3);
4839 assert!(strength > 0.8);
4841
4842 let strength_spectral = seasonal_strength_spectral(&data, &argvals, period);
4843 assert!(strength_spectral > 0.5);
4844 }
4845
4846 #[test]
4847 fn test_instantaneous_period() {
4848 let m = 200;
4849 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4850 let period = 2.0;
4851 let data = generate_sine(1, m, period, &argvals);
4852
4853 let result = instantaneous_period(&data, &argvals);
4854
4855 let mid_period = result.period[m / 2];
4857 assert!(
4858 (mid_period - period).abs() < 0.5,
4859 "Expected period ~{}, got {}",
4860 period,
4861 mid_period
4862 );
4863 }
4864
4865 #[test]
4866 fn test_peak_timing_analysis() {
4867 let m = 500;
4869 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.02).collect();
4870 let period = 2.0;
4871 let data = generate_sine(1, m, period, &argvals);
4872
4873 let result = analyze_peak_timing(&data, &argvals, period, Some(11));
4874
4875 assert!(!result.peak_times.is_empty());
4877 assert!(result.mean_timing.is_finite());
4879 assert!(result.std_timing < 0.1 || result.std_timing.is_nan());
4881 }
4882
4883 #[test]
4884 fn test_seasonality_classification() {
4885 let m = 400;
4886 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
4887 let period = 2.0;
4888 let data = generate_sine(1, m, period, &argvals);
4889
4890 let result = classify_seasonality(&data, &argvals, period, None, None);
4891
4892 assert!(result.is_seasonal);
4893 assert!(result.seasonal_strength > 0.5);
4894 assert!(matches!(
4895 result.classification,
4896 SeasonalType::StableSeasonal | SeasonalType::VariableTiming
4897 ));
4898 }
4899
4900 #[test]
4901 fn test_otsu_threshold() {
4902 let values = vec![
4904 0.1, 0.12, 0.15, 0.18, 0.11, 0.14, 0.7, 0.75, 0.8, 0.85, 0.9, 0.72,
4905 ];
4906
4907 let threshold = otsu_threshold(&values);
4908
4909 assert!(threshold >= 0.1, "Threshold {} should be >= 0.1", threshold);
4913 assert!(threshold <= 0.9, "Threshold {} should be <= 0.9", threshold);
4914 }
4915
4916 #[test]
4917 fn test_gcv_fourier_nbasis_selection() {
4918 let m = 100;
4919 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4920
4921 let mut data = vec![0.0; m];
4923 for j in 0..m {
4924 data[j] = (2.0 * PI * argvals[j] / 2.0).sin() + 0.1 * (j as f64 * 0.3).sin();
4925 }
4926
4927 let data_mat = crate::matrix::FdMatrix::from_column_major(data, 1, m).unwrap();
4928 let nbasis = crate::basis::select_fourier_nbasis_gcv(&data_mat, &argvals, 5, 25);
4929
4930 assert!(nbasis >= 5);
4932 assert!(nbasis <= 25);
4933 }
4934
4935 #[test]
4936 fn test_detect_multiple_periods() {
4937 let m = 400;
4938 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect(); let period1 = 2.0;
4942 let period2 = 7.0;
4943 let mut data = vec![0.0; m];
4944 for j in 0..m {
4945 data[j] = (2.0 * PI * argvals[j] / period1).sin()
4946 + 0.6 * (2.0 * PI * argvals[j] / period2).sin();
4947 }
4948
4949 let detected = detect_multiple_periods(&data, 1, m, &argvals, 5, 0.4, 0.20);
4951
4952 assert!(
4954 detected.len() >= 2,
4955 "Expected at least 2 periods, found {}",
4956 detected.len()
4957 );
4958
4959 let periods: Vec<f64> = detected.iter().map(|d| d.period).collect();
4961 let has_period1 = periods.iter().any(|&p| (p - period1).abs() < 0.3);
4962 let has_period2 = periods.iter().any(|&p| (p - period2).abs() < 0.5);
4963
4964 assert!(
4965 has_period1,
4966 "Expected to find period ~{}, got {:?}",
4967 period1, periods
4968 );
4969 assert!(
4970 has_period2,
4971 "Expected to find period ~{}, got {:?}",
4972 period2, periods
4973 );
4974
4975 assert!(
4977 detected[0].amplitude > detected[1].amplitude,
4978 "First detected should have higher amplitude"
4979 );
4980
4981 for d in &detected {
4983 assert!(
4984 d.strength > 0.0,
4985 "Detected period should have positive strength"
4986 );
4987 assert!(
4988 d.confidence > 0.0,
4989 "Detected period should have positive confidence"
4990 );
4991 assert!(
4992 d.amplitude > 0.0,
4993 "Detected period should have positive amplitude"
4994 );
4995 }
4996 }
4997
4998 #[test]
5003 fn test_amplitude_modulation_stable() {
5004 let m = 200;
5006 let period = 0.2;
5007 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5008
5009 let data: Vec<f64> = argvals
5011 .iter()
5012 .map(|&t| (2.0 * PI * t / period).sin())
5013 .collect();
5014 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5015
5016 let result = detect_amplitude_modulation(
5017 &data, &argvals, period, 0.15, 0.3, );
5020
5021 eprintln!(
5022 "Stable test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5023 result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5024 );
5025
5026 assert!(result.is_seasonal, "Should detect seasonality");
5027 assert!(
5028 !result.has_modulation,
5029 "Constant amplitude should not have modulation, got score={:.4}",
5030 result.modulation_score
5031 );
5032 assert_eq!(
5033 result.modulation_type,
5034 ModulationType::Stable,
5035 "Should be classified as Stable"
5036 );
5037 }
5038
5039 #[test]
5040 fn test_amplitude_modulation_emerging() {
5041 let m = 200;
5043 let period = 0.2;
5044 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5045
5046 let data: Vec<f64> = argvals
5048 .iter()
5049 .map(|&t| {
5050 let amplitude = 0.2 + 0.8 * t; amplitude * (2.0 * PI * t / period).sin()
5052 })
5053 .collect();
5054
5055 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5056
5057 let result = detect_amplitude_modulation(&data, &argvals, period, 0.15, 0.2);
5058
5059 eprintln!(
5060 "Emerging test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5061 result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5062 );
5063
5064 assert!(result.is_seasonal, "Should detect seasonality");
5065 assert!(
5066 result.has_modulation,
5067 "Growing amplitude should have modulation, score={:.4}",
5068 result.modulation_score
5069 );
5070 assert_eq!(
5071 result.modulation_type,
5072 ModulationType::Emerging,
5073 "Should be classified as Emerging, trend={:.4}",
5074 result.amplitude_trend
5075 );
5076 assert!(
5077 result.amplitude_trend > 0.0,
5078 "Trend should be positive for emerging"
5079 );
5080 }
5081
5082 #[test]
5083 fn test_amplitude_modulation_fading() {
5084 let m = 200;
5086 let period = 0.2;
5087 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5088
5089 let data: Vec<f64> = argvals
5091 .iter()
5092 .map(|&t| {
5093 let amplitude = 1.0 - 0.8 * t; amplitude * (2.0 * PI * t / period).sin()
5095 })
5096 .collect();
5097
5098 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5099
5100 let result = detect_amplitude_modulation(&data, &argvals, period, 0.15, 0.2);
5101
5102 eprintln!(
5103 "Fading test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5104 result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5105 );
5106
5107 assert!(result.is_seasonal, "Should detect seasonality");
5108 assert!(
5109 result.has_modulation,
5110 "Fading amplitude should have modulation"
5111 );
5112 assert_eq!(
5113 result.modulation_type,
5114 ModulationType::Fading,
5115 "Should be classified as Fading, trend={:.4}",
5116 result.amplitude_trend
5117 );
5118 assert!(
5119 result.amplitude_trend < 0.0,
5120 "Trend should be negative for fading"
5121 );
5122 }
5123
5124 #[test]
5125 fn test_amplitude_modulation_oscillating() {
5126 let m = 200;
5128 let period = 0.1;
5129 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5130
5131 let data: Vec<f64> = argvals
5133 .iter()
5134 .map(|&t| {
5135 let amplitude = 0.5 + 0.4 * (2.0 * PI * t * 2.0).sin(); amplitude * (2.0 * PI * t / period).sin()
5137 })
5138 .collect();
5139
5140 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5141
5142 let result = detect_amplitude_modulation(&data, &argvals, period, 0.15, 0.2);
5143
5144 eprintln!(
5145 "Oscillating test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5146 result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5147 );
5148
5149 assert!(result.is_seasonal, "Should detect seasonality");
5150 if result.has_modulation {
5152 assert!(
5154 result.amplitude_trend.abs() < 0.5,
5155 "Trend should be small for oscillating"
5156 );
5157 }
5158 }
5159
5160 #[test]
5161 fn test_amplitude_modulation_non_seasonal() {
5162 let m = 100;
5164 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5165
5166 let data: Vec<f64> = (0..m)
5168 .map(|i| ((i as f64 * 1.618).sin() * 100.0).fract())
5169 .collect();
5170
5171 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5172
5173 let result = detect_amplitude_modulation(
5174 &data, &argvals, 0.2, 0.15, 0.3,
5176 );
5177
5178 assert!(
5179 !result.is_seasonal,
5180 "Noise should not be detected as seasonal"
5181 );
5182 assert_eq!(
5183 result.modulation_type,
5184 ModulationType::NonSeasonal,
5185 "Should be classified as NonSeasonal"
5186 );
5187 }
5188
5189 #[test]
5194 fn test_wavelet_amplitude_modulation_stable() {
5195 let m = 200;
5196 let period = 0.2;
5197 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5198
5199 let data: Vec<f64> = argvals
5200 .iter()
5201 .map(|&t| (2.0 * PI * t / period).sin())
5202 .collect();
5203
5204 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5205
5206 let result = detect_amplitude_modulation_wavelet(&data, &argvals, period, 0.15, 0.3);
5207
5208 eprintln!(
5209 "Wavelet stable: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5210 result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5211 );
5212
5213 assert!(result.is_seasonal, "Should detect seasonality");
5214 assert!(
5215 !result.has_modulation,
5216 "Constant amplitude should not have modulation, got score={:.4}",
5217 result.modulation_score
5218 );
5219 }
5220
5221 #[test]
5222 fn test_wavelet_amplitude_modulation_emerging() {
5223 let m = 200;
5224 let period = 0.2;
5225 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5226
5227 let data: Vec<f64> = argvals
5229 .iter()
5230 .map(|&t| {
5231 let amplitude = 0.2 + 0.8 * t;
5232 amplitude * (2.0 * PI * t / period).sin()
5233 })
5234 .collect();
5235
5236 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5237
5238 let result = detect_amplitude_modulation_wavelet(&data, &argvals, period, 0.15, 0.2);
5239
5240 eprintln!(
5241 "Wavelet emerging: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5242 result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5243 );
5244
5245 assert!(result.is_seasonal, "Should detect seasonality");
5246 assert!(
5247 result.has_modulation,
5248 "Growing amplitude should have modulation"
5249 );
5250 assert!(
5251 result.amplitude_trend > 0.0,
5252 "Trend should be positive for emerging"
5253 );
5254 }
5255
5256 #[test]
5257 fn test_wavelet_amplitude_modulation_fading() {
5258 let m = 200;
5259 let period = 0.2;
5260 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5261
5262 let data: Vec<f64> = argvals
5264 .iter()
5265 .map(|&t| {
5266 let amplitude = 1.0 - 0.8 * t;
5267 amplitude * (2.0 * PI * t / period).sin()
5268 })
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.2);
5274
5275 eprintln!(
5276 "Wavelet fading: 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 "Fading amplitude should have modulation"
5284 );
5285 assert!(
5286 result.amplitude_trend < 0.0,
5287 "Trend should be negative for fading"
5288 );
5289 }
5290
5291 #[test]
5292 fn test_seasonal_strength_wavelet() {
5293 let m = 200;
5294 let period = 0.2;
5295 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5296
5297 let seasonal_data: Vec<f64> = argvals
5299 .iter()
5300 .map(|&t| (2.0 * PI * t / period).sin())
5301 .collect();
5302
5303 let seasonal_data = FdMatrix::from_column_major(seasonal_data, 1, m).unwrap();
5304
5305 let strength = seasonal_strength_wavelet(&seasonal_data, &argvals, period);
5306 eprintln!("Wavelet strength (pure sine): {:.4}", strength);
5307 assert!(
5308 strength > 0.5,
5309 "Pure sine should have high wavelet strength"
5310 );
5311
5312 let noise_data: Vec<f64> = (0..m)
5314 .map(|i| ((i * 12345 + 67890) % 1000) as f64 / 1000.0 - 0.5)
5315 .collect();
5316
5317 let noise_data = FdMatrix::from_column_major(noise_data, 1, m).unwrap();
5318
5319 let noise_strength = seasonal_strength_wavelet(&noise_data, &argvals, period);
5320 eprintln!("Wavelet strength (noise): {:.4}", noise_strength);
5321 assert!(
5322 noise_strength < 0.3,
5323 "Noise should have low wavelet strength"
5324 );
5325
5326 let wrong_period_strength =
5328 seasonal_strength_wavelet(&seasonal_data, &argvals, period * 2.0);
5329 eprintln!(
5330 "Wavelet strength (wrong period): {:.4}",
5331 wrong_period_strength
5332 );
5333 assert!(
5334 wrong_period_strength < strength,
5335 "Wrong period should have lower strength"
5336 );
5337 }
5338
5339 #[test]
5340 fn test_compute_mean_curve() {
5341 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 2.0, 4.0, 3.0, 6.0], 2, 3).unwrap();
5346 let mean = compute_mean_curve(&data);
5347 assert_eq!(mean.len(), 3);
5348 assert!((mean[0] - 1.5).abs() < 1e-10);
5349 assert!((mean[1] - 3.0).abs() < 1e-10);
5350 assert!((mean[2] - 4.5).abs() < 1e-10);
5351 }
5352
5353 #[test]
5354 fn test_compute_mean_curve_parallel_consistency() {
5355 let n = 10;
5357 let m = 200;
5358 let data: Vec<f64> = (0..n * m).map(|i| (i as f64 * 0.1).sin()).collect();
5359
5360 let data = FdMatrix::from_column_major(data, n, m).unwrap();
5361
5362 let seq_result = compute_mean_curve_impl(&data, false);
5363 let par_result = compute_mean_curve_impl(&data, true);
5364
5365 assert_eq!(seq_result.len(), par_result.len());
5366 for (s, p) in seq_result.iter().zip(par_result.iter()) {
5367 assert!(
5368 (s - p).abs() < 1e-10,
5369 "Sequential and parallel results differ"
5370 );
5371 }
5372 }
5373
5374 #[test]
5375 fn test_interior_bounds() {
5376 let bounds = interior_bounds(100);
5378 assert!(bounds.is_some());
5379 let (start, end) = bounds.unwrap();
5380 assert_eq!(start, 10);
5381 assert_eq!(end, 90);
5382
5383 let bounds = interior_bounds(10);
5385 assert!(bounds.is_some());
5386 let (start, end) = bounds.unwrap();
5387 assert!(start < end);
5388
5389 let bounds = interior_bounds(2);
5391 assert!(bounds.is_some() || bounds.is_none());
5393 }
5394
5395 #[test]
5396 fn test_hilbert_transform_pure_sine() {
5397 let m = 200;
5399 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5400 let signal: Vec<f64> = argvals.iter().map(|&t| (2.0 * PI * t).sin()).collect();
5401
5402 let analytic = hilbert_transform(&signal);
5403 assert_eq!(analytic.len(), m);
5404
5405 for c in analytic.iter().skip(10).take(m - 20) {
5407 let amp = c.norm();
5408 assert!(
5409 (amp - 1.0).abs() < 0.1,
5410 "Amplitude should be ~1, got {}",
5411 amp
5412 );
5413 }
5414 }
5415
5416 #[test]
5417 fn test_sazed_pure_sine() {
5418 let m = 200;
5420 let period = 2.0;
5421 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5422 let data: Vec<f64> = argvals
5423 .iter()
5424 .map(|&t| (2.0 * PI * t / period).sin())
5425 .collect();
5426
5427 let result = sazed(&data, &argvals, None);
5428
5429 assert!(result.period.is_finite(), "SAZED should detect a period");
5430 assert!(
5431 (result.period - period).abs() < 0.3,
5432 "Expected period ~{}, got {}",
5433 period,
5434 result.period
5435 );
5436 assert!(
5437 result.confidence > 0.4,
5438 "Expected confidence > 0.4, got {}",
5439 result.confidence
5440 );
5441 assert!(
5442 result.agreeing_components >= 2,
5443 "Expected at least 2 agreeing components, got {}",
5444 result.agreeing_components
5445 );
5446 }
5447
5448 #[test]
5449 fn test_sazed_noisy_sine() {
5450 let m = 300;
5452 let period = 3.0;
5453 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5454
5455 let data: Vec<f64> = argvals
5457 .iter()
5458 .enumerate()
5459 .map(|(i, &t)| {
5460 let signal = (2.0 * PI * t / period).sin();
5461 let noise = 0.1 * (17.3 * i as f64).sin();
5462 signal + noise
5463 })
5464 .collect();
5465
5466 let result = sazed(&data, &argvals, Some(0.15));
5467
5468 assert!(
5469 result.period.is_finite(),
5470 "SAZED should detect a period even with noise"
5471 );
5472 assert!(
5473 (result.period - period).abs() < 0.5,
5474 "Expected period ~{}, got {}",
5475 period,
5476 result.period
5477 );
5478 }
5479
5480 #[test]
5481 fn test_sazed_fdata() {
5482 let n = 5;
5484 let m = 200;
5485 let period = 2.0;
5486 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5487 let data = generate_sine(n, m, period, &argvals);
5488
5489 let result = sazed_fdata(&data, &argvals, None);
5490
5491 assert!(result.period.is_finite(), "SAZED should detect a period");
5492 assert!(
5493 (result.period - period).abs() < 0.3,
5494 "Expected period ~{}, got {}",
5495 period,
5496 result.period
5497 );
5498 }
5499
5500 #[test]
5501 fn test_sazed_short_series() {
5502 let argvals: Vec<f64> = (0..5).map(|i| i as f64).collect();
5504 let data: Vec<f64> = argvals.iter().map(|&t| t.sin()).collect();
5505
5506 let result = sazed(&data, &argvals, None);
5507
5508 assert!(
5510 result.period.is_nan() || result.period.is_finite(),
5511 "Should return NaN or valid period"
5512 );
5513 }
5514
5515 #[test]
5516 fn test_autoperiod_pure_sine() {
5517 let m = 200;
5519 let period = 2.0;
5520 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5521 let data: Vec<f64> = argvals
5522 .iter()
5523 .map(|&t| (2.0 * PI * t / period).sin())
5524 .collect();
5525
5526 let result = autoperiod(&data, &argvals, None, None);
5527
5528 assert!(
5529 result.period.is_finite(),
5530 "Autoperiod should detect a period"
5531 );
5532 assert!(
5533 (result.period - period).abs() < 0.3,
5534 "Expected period ~{}, got {}",
5535 period,
5536 result.period
5537 );
5538 assert!(
5539 result.confidence > 0.0,
5540 "Expected positive confidence, got {}",
5541 result.confidence
5542 );
5543 }
5544
5545 #[test]
5546 fn test_autoperiod_with_trend() {
5547 let m = 300;
5549 let period = 3.0;
5550 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5551 let data: Vec<f64> = argvals
5552 .iter()
5553 .map(|&t| 0.2 * t + (2.0 * PI * t / period).sin())
5554 .collect();
5555
5556 let result = autoperiod(&data, &argvals, None, None);
5557
5558 assert!(
5559 result.period.is_finite(),
5560 "Autoperiod should detect a period"
5561 );
5562 assert!(
5564 (result.period - period).abs() < 0.5,
5565 "Expected period ~{}, got {}",
5566 period,
5567 result.period
5568 );
5569 }
5570
5571 #[test]
5572 fn test_autoperiod_candidates() {
5573 let m = 200;
5575 let period = 2.0;
5576 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5577 let data: Vec<f64> = argvals
5578 .iter()
5579 .map(|&t| (2.0 * PI * t / period).sin())
5580 .collect();
5581
5582 let result = autoperiod(&data, &argvals, Some(5), Some(10));
5583
5584 assert!(
5585 !result.candidates.is_empty(),
5586 "Should have at least one candidate"
5587 );
5588
5589 let max_score = result
5591 .candidates
5592 .iter()
5593 .map(|c| c.combined_score)
5594 .fold(f64::NEG_INFINITY, f64::max);
5595 assert!(
5596 (result.confidence - max_score).abs() < 1e-10,
5597 "Returned confidence should match best candidate's score"
5598 );
5599 }
5600
5601 #[test]
5602 fn test_autoperiod_fdata() {
5603 let n = 5;
5605 let m = 200;
5606 let period = 2.0;
5607 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5608 let data = generate_sine(n, m, period, &argvals);
5609
5610 let result = autoperiod_fdata(&data, &argvals, None, None);
5611
5612 assert!(
5613 result.period.is_finite(),
5614 "Autoperiod should detect a period"
5615 );
5616 assert!(
5617 (result.period - period).abs() < 0.3,
5618 "Expected period ~{}, got {}",
5619 period,
5620 result.period
5621 );
5622 }
5623
5624 #[test]
5625 fn test_cfd_autoperiod_pure_sine() {
5626 let m = 200;
5628 let period = 2.0;
5629 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5630 let data: Vec<f64> = argvals
5631 .iter()
5632 .map(|&t| (2.0 * PI * t / period).sin())
5633 .collect();
5634
5635 let result = cfd_autoperiod(&data, &argvals, None, None);
5636
5637 assert!(
5638 result.period.is_finite(),
5639 "CFDAutoperiod should detect a period"
5640 );
5641 assert!(
5642 (result.period - period).abs() < 0.3,
5643 "Expected period ~{}, got {}",
5644 period,
5645 result.period
5646 );
5647 }
5648
5649 #[test]
5650 fn test_cfd_autoperiod_with_trend() {
5651 let m = 300;
5653 let period = 3.0;
5654 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5655 let data: Vec<f64> = argvals
5656 .iter()
5657 .map(|&t| 2.0 * t + (2.0 * PI * t / period).sin())
5658 .collect();
5659
5660 let result = cfd_autoperiod(&data, &argvals, None, None);
5661
5662 assert!(
5663 result.period.is_finite(),
5664 "CFDAutoperiod should detect a period despite trend"
5665 );
5666 assert!(
5668 (result.period - period).abs() < 0.6,
5669 "Expected period ~{}, got {}",
5670 period,
5671 result.period
5672 );
5673 }
5674
5675 #[test]
5676 fn test_cfd_autoperiod_multiple_periods() {
5677 let m = 400;
5679 let period1 = 2.0;
5680 let period2 = 5.0;
5681 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5682 let data: Vec<f64> = argvals
5683 .iter()
5684 .map(|&t| (2.0 * PI * t / period1).sin() + 0.5 * (2.0 * PI * t / period2).sin())
5685 .collect();
5686
5687 let result = cfd_autoperiod(&data, &argvals, None, None);
5688
5689 assert!(
5690 !result.periods.is_empty(),
5691 "Should detect at least one period"
5692 );
5693 let close_to_p1 = (result.period - period1).abs() < 0.5;
5695 let close_to_p2 = (result.period - period2).abs() < 1.0;
5696 assert!(
5697 close_to_p1 || close_to_p2,
5698 "Primary period {} not close to {} or {}",
5699 result.period,
5700 period1,
5701 period2
5702 );
5703 }
5704
5705 #[test]
5706 fn test_cfd_autoperiod_fdata() {
5707 let n = 5;
5709 let m = 200;
5710 let period = 2.0;
5711 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5712 let data = generate_sine(n, m, period, &argvals);
5713
5714 let result = cfd_autoperiod_fdata(&data, &argvals, None, None);
5715
5716 assert!(
5717 result.period.is_finite(),
5718 "CFDAutoperiod should detect a period"
5719 );
5720 assert!(
5721 (result.period - period).abs() < 0.3,
5722 "Expected period ~{}, got {}",
5723 period,
5724 result.period
5725 );
5726 }
5727
5728 #[test]
5733 fn test_ssa_pure_sine() {
5734 let n = 200;
5735 let period = 12.0;
5736 let values: Vec<f64> = (0..n)
5737 .map(|i| {
5738 let t = i as f64;
5739 0.01 * t + (2.0 * PI * t / period).sin() + 0.05 * ((i * 7) as f64 * 0.1).sin()
5740 })
5741 .collect();
5742
5743 let result = ssa(&values, None, None, None, None);
5744
5745 for i in 0..n {
5747 let reconstructed = result.trend[i] + result.seasonal[i] + result.noise[i];
5748 assert!(
5749 (reconstructed - values[i]).abs() < 1e-8,
5750 "SSA reconstruction error at {}: {} vs {}",
5751 i,
5752 reconstructed,
5753 values[i]
5754 );
5755 }
5756
5757 for i in 1..result.singular_values.len() {
5759 assert!(
5760 result.singular_values[i] <= result.singular_values[i - 1] + 1e-10,
5761 "Singular values should be descending: {} > {}",
5762 result.singular_values[i],
5763 result.singular_values[i - 1]
5764 );
5765 }
5766
5767 let total_contrib: f64 = result.contributions.iter().sum();
5769 assert!(
5770 total_contrib <= 1.0 + 1e-10,
5771 "Contributions should sum to <= 1, got {}",
5772 total_contrib
5773 );
5774 }
5775
5776 #[test]
5777 fn test_ssa_explicit_groupings() {
5778 let n = 100;
5779 let period = 10.0;
5780 let values: Vec<f64> = (0..n)
5781 .map(|i| 0.01 * i as f64 + (2.0 * PI * i as f64 / period).sin())
5782 .collect();
5783
5784 let trend_components = [0usize];
5785 let seasonal_components = [1usize, 2];
5786
5787 let result = ssa(
5788 &values,
5789 None,
5790 None,
5791 Some(&trend_components),
5792 Some(&seasonal_components),
5793 );
5794
5795 assert_eq!(result.trend.len(), n);
5796 assert_eq!(result.seasonal.len(), n);
5797 assert_eq!(result.noise.len(), n);
5798
5799 for i in 0..n {
5801 let reconstructed = result.trend[i] + result.seasonal[i] + result.noise[i];
5802 assert!(
5803 (reconstructed - values[i]).abs() < 1e-8,
5804 "SSA explicit grouping reconstruction error at {}",
5805 i
5806 );
5807 }
5808 }
5809
5810 #[test]
5811 fn test_ssa_short_series() {
5812 let values = vec![1.0, 2.0, 3.0];
5814 let result = ssa(&values, None, None, None, None);
5815
5816 assert_eq!(result.trend, values);
5817 assert_eq!(result.seasonal, vec![0.0; 3]);
5818 assert_eq!(result.noise, vec![0.0; 3]);
5819 assert_eq!(result.n_components, 0);
5820 }
5821
5822 #[test]
5823 fn test_ssa_fdata() {
5824 let n = 5;
5825 let m = 100;
5826 let mut data = vec![0.0; n * m];
5827 for i in 0..n {
5828 let amp = (i + 1) as f64;
5829 for j in 0..m {
5830 data[i + j * n] = amp * (2.0 * PI * j as f64 / 12.0).sin() + 0.01 * j as f64;
5831 }
5832 }
5833
5834 let data = FdMatrix::from_column_major(data, n, m).unwrap();
5835
5836 let result = ssa_fdata(&data, None, None);
5837
5838 assert_eq!(result.trend.len(), m);
5839 assert_eq!(result.seasonal.len(), m);
5840 assert_eq!(result.noise.len(), m);
5841 assert!(!result.singular_values.is_empty());
5842 }
5843
5844 #[test]
5845 fn test_ssa_seasonality() {
5846 let n = 200;
5848 let period = 12.0;
5849 let seasonal_values: Vec<f64> = (0..n)
5850 .map(|i| (2.0 * PI * i as f64 / period).sin())
5851 .collect();
5852
5853 let (is_seasonal, _det_period, confidence) =
5854 ssa_seasonality(&seasonal_values, None, Some(0.05));
5855
5856 assert!(confidence >= 0.0, "Confidence should be non-negative");
5859
5860 let noise_values: Vec<f64> = (0..n)
5862 .map(|i| ((i * 13 + 7) as f64 * 0.1).sin() * 0.01)
5863 .collect();
5864
5865 let (is_noise_seasonal, _, noise_conf) = ssa_seasonality(&noise_values, None, Some(0.5));
5866
5867 let _ = (is_seasonal, is_noise_seasonal, noise_conf);
5870 }
5871
5872 #[test]
5877 fn test_matrix_profile_periodic() {
5878 let period = 20;
5879 let n = period * 10;
5880 let values: Vec<f64> = (0..n)
5881 .map(|i| (2.0 * PI * i as f64 / period as f64).sin())
5882 .collect();
5883
5884 let result = matrix_profile(&values, Some(15), None);
5885
5886 assert_eq!(result.profile.len(), n - 15 + 1);
5887 assert_eq!(result.profile_index.len(), n - 15 + 1);
5888 assert_eq!(result.subsequence_length, 15);
5889
5890 for &p in &result.profile {
5892 assert!(p.is_finite(), "Profile values should be finite");
5893 }
5894
5895 assert!(
5897 (result.primary_period - period as f64).abs() < 5.0,
5898 "Expected primary_period ≈ {}, got {}",
5899 period,
5900 result.primary_period
5901 );
5902 }
5903
5904 #[test]
5905 fn test_matrix_profile_non_periodic() {
5906 let n = 200;
5908 let values: Vec<f64> = (0..n).map(|i| i as f64 * 0.1).collect();
5909
5910 let result = matrix_profile(&values, Some(10), None);
5911
5912 assert_eq!(result.profile.len(), n - 10 + 1);
5913
5914 assert!(result.confidence <= 1.0, "Confidence should be <= 1.0");
5917 }
5918
5919 #[test]
5920 fn test_matrix_profile_fdata() {
5921 let n = 3;
5922 let m = 200;
5923 let period = 20.0;
5924 let mut data = vec![0.0; n * m];
5925 for i in 0..n {
5926 let amp = (i + 1) as f64;
5927 for j in 0..m {
5928 data[i + j * n] = amp * (2.0 * PI * j as f64 / period).sin();
5929 }
5930 }
5931
5932 let data = FdMatrix::from_column_major(data, n, m).unwrap();
5933
5934 let result = matrix_profile_fdata(&data, Some(15), None);
5935
5936 assert!(!result.profile.is_empty());
5937 assert!(result.profile_index.len() == result.profile.len());
5938 }
5939
5940 #[test]
5941 fn test_matrix_profile_seasonality() {
5942 let period = 20;
5943 let n = period * 10;
5944 let values: Vec<f64> = (0..n)
5946 .map(|i| (2.0 * PI * i as f64 / period as f64).sin())
5947 .collect();
5948
5949 let (is_seasonal, det_period, confidence) =
5950 matrix_profile_seasonality(&values, Some(15), Some(0.05));
5951
5952 assert!(
5953 is_seasonal,
5954 "Periodic signal should be detected as seasonal"
5955 );
5956 assert!(det_period > 0.0, "Detected period should be positive");
5957 assert!(confidence >= 0.05, "Confidence should be above threshold");
5958
5959 let weak_values: Vec<f64> = (0..n).map(|i| i as f64 * 0.001).collect();
5961 let (is_weak_seasonal, _, _) =
5962 matrix_profile_seasonality(&weak_values, Some(15), Some(0.5));
5963 let _ = is_weak_seasonal; }
5965
5966 #[test]
5971 fn test_lomb_scargle_regular() {
5972 let n = 200;
5973 let true_period = 5.0;
5974 let times: Vec<f64> = (0..n).map(|i| i as f64 * 0.1).collect();
5975 let values: Vec<f64> = times
5976 .iter()
5977 .map(|&t| (2.0 * PI * t / true_period).sin())
5978 .collect();
5979
5980 let result = lomb_scargle(×, &values, None, None, None);
5981
5982 assert!(
5983 (result.peak_period - true_period).abs() < 0.5,
5984 "Expected peak_period ≈ {}, got {}",
5985 true_period,
5986 result.peak_period
5987 );
5988 assert!(
5989 result.false_alarm_probability < 0.05,
5990 "FAP should be low for strong signal: {}",
5991 result.false_alarm_probability
5992 );
5993 assert!(result.peak_power > 0.0, "Peak power should be positive");
5994 assert!(!result.frequencies.is_empty());
5995 assert_eq!(result.frequencies.len(), result.power.len());
5996 assert_eq!(result.frequencies.len(), result.periods.len());
5997 }
5998
5999 #[test]
6000 fn test_lomb_scargle_irregular() {
6001 let true_period = 5.0;
6002 let all_times: Vec<f64> = (0..300).map(|i| i as f64 * 0.1).collect();
6004 let times: Vec<f64> = all_times
6006 .iter()
6007 .enumerate()
6008 .filter(|(i, _)| i % 2 == 0 || i % 3 == 0)
6009 .map(|(_, &t)| t)
6010 .collect();
6011 let values: Vec<f64> = times
6012 .iter()
6013 .map(|&t| (2.0 * PI * t / true_period).sin())
6014 .collect();
6015
6016 let result = lomb_scargle(×, &values, None, None, None);
6017
6018 assert!(
6019 (result.peak_period - true_period).abs() < 1.0,
6020 "Irregular LS: expected period ≈ {}, got {}",
6021 true_period,
6022 result.peak_period
6023 );
6024 }
6025
6026 #[test]
6027 fn test_lomb_scargle_custom_frequencies() {
6028 let n = 100;
6029 let true_period = 5.0;
6030 let times: Vec<f64> = (0..n).map(|i| i as f64 * 0.1).collect();
6031 let values: Vec<f64> = times
6032 .iter()
6033 .map(|&t| (2.0 * PI * t / true_period).sin())
6034 .collect();
6035
6036 let frequencies: Vec<f64> = (1..50).map(|i| i as f64 * 0.01).collect();
6038 let result = lomb_scargle(×, &values, Some(&frequencies), None, None);
6039
6040 assert_eq!(result.frequencies.len(), frequencies.len());
6041 assert_eq!(result.power.len(), frequencies.len());
6042 assert!(result.peak_power > 0.0);
6043 }
6044
6045 #[test]
6046 fn test_lomb_scargle_fdata() {
6047 let n = 5;
6048 let m = 200;
6049 let period = 5.0;
6050 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6051 let data = generate_sine(n, m, period, &argvals);
6052
6053 let result = lomb_scargle_fdata(&data, &argvals, None, None);
6054
6055 assert!(
6056 (result.peak_period - period).abs() < 0.5,
6057 "Fdata LS: expected period ≈ {}, got {}",
6058 period,
6059 result.peak_period
6060 );
6061 assert!(!result.frequencies.is_empty());
6062 }
6063
6064 #[test]
6069 fn test_detect_seasonality_changes_onset() {
6070 let m = 400;
6071 let period = 2.0;
6072 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect(); let data: Vec<f64> = argvals
6076 .iter()
6077 .map(|&t| {
6078 if t < 10.0 {
6079 0.05 * ((t * 13.0).sin() + (t * 7.0).cos())
6081 } else {
6082 (2.0 * PI * t / period).sin()
6084 }
6085 })
6086 .collect();
6087 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6088
6089 let result = detect_seasonality_changes(&data, &argvals, period, 0.3, 4.0, 2.0);
6090
6091 assert!(
6092 !result.strength_curve.is_empty(),
6093 "Strength curve should not be empty"
6094 );
6095 assert_eq!(result.strength_curve.len(), m);
6096
6097 if !result.change_points.is_empty() {
6099 let onset_points: Vec<_> = result
6100 .change_points
6101 .iter()
6102 .filter(|cp| cp.change_type == ChangeType::Onset)
6103 .collect();
6104 assert!(!onset_points.is_empty(), "Should detect Onset change point");
6106 }
6107 }
6108
6109 #[test]
6110 fn test_detect_seasonality_changes_no_change() {
6111 let m = 400;
6112 let period = 2.0;
6113 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6114
6115 let data: Vec<f64> = argvals
6117 .iter()
6118 .map(|&t| (2.0 * PI * t / period).sin())
6119 .collect();
6120 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6121
6122 let result = detect_seasonality_changes(&data, &argvals, period, 0.3, 4.0, 2.0);
6123
6124 assert!(!result.strength_curve.is_empty());
6125 let cessation_points: Vec<_> = result
6127 .change_points
6128 .iter()
6129 .filter(|cp| cp.change_type == ChangeType::Cessation)
6130 .collect();
6131 assert!(
6132 cessation_points.is_empty(),
6133 "Consistently seasonal signal should have no Cessation points, found {}",
6134 cessation_points.len()
6135 );
6136 }
6137
6138 #[test]
6143 fn test_estimate_period_acf() {
6144 let m = 200;
6145 let period = 2.0;
6146 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6147 let data = generate_sine(1, m, period, &argvals);
6148
6149 let estimate = estimate_period_acf(data.as_slice(), 1, m, &argvals, m / 2);
6150
6151 assert!(
6152 estimate.period.is_finite(),
6153 "ACF period estimate should be finite"
6154 );
6155 assert!(
6156 (estimate.period - period).abs() < 0.5,
6157 "ACF expected period ≈ {}, got {}",
6158 period,
6159 estimate.period
6160 );
6161 assert!(
6162 estimate.confidence > 0.0,
6163 "ACF confidence should be positive"
6164 );
6165 }
6166
6167 #[test]
6168 fn test_estimate_period_regression() {
6169 let m = 200;
6170 let period = 2.0;
6171 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6172 let data = generate_sine(1, m, period, &argvals);
6173
6174 let estimate =
6175 estimate_period_regression(data.as_slice(), 1, m, &argvals, 1.5, 3.0, 100, 3);
6176
6177 assert!(
6178 estimate.period.is_finite(),
6179 "Regression period estimate should be finite"
6180 );
6181 assert!(
6182 (estimate.period - period).abs() < 0.5,
6183 "Regression expected period ≈ {}, got {}",
6184 period,
6185 estimate.period
6186 );
6187 assert!(
6188 estimate.confidence > 0.0,
6189 "Regression confidence should be positive"
6190 );
6191 }
6192
6193 #[test]
6198 fn test_seasonal_strength_windowed_variance() {
6199 let m = 200;
6200 let period = 2.0;
6201 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6202 let data = generate_sine(1, m, period, &argvals);
6203
6204 let strengths = seasonal_strength_windowed(
6205 &data,
6206 &argvals,
6207 period,
6208 4.0, StrengthMethod::Variance,
6210 );
6211
6212 assert_eq!(strengths.len(), m, "Should return m values");
6213
6214 let interior_start = m / 4;
6216 let interior_end = 3 * m / 4;
6217 for i in interior_start..interior_end {
6218 let s = strengths[i];
6219 if s.is_finite() {
6220 assert!(
6221 (-0.01..=1.01).contains(&s),
6222 "Windowed strength at {} should be near [0,1], got {}",
6223 i,
6224 s
6225 );
6226 }
6227 }
6228 }
6229
6230 #[test]
6231 fn test_constant_signal_fft_period() {
6232 let m = 100;
6234 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 1.0).collect();
6235 let data_vec: Vec<f64> = vec![5.0; m];
6236 let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
6237 let result = estimate_period_fft(&data, &argvals);
6238 assert!(result.period.is_finite() || result.period.is_nan());
6240 }
6241
6242 #[test]
6243 fn test_very_short_series_period() {
6244 let argvals = vec![0.0, 1.0, 2.0, 3.0];
6246 let data_vec = vec![1.0, -1.0, 1.0, -1.0];
6247 let data = FdMatrix::from_column_major(data_vec, 1, 4).unwrap();
6248 let result = estimate_period_fft(&data, &argvals);
6249 assert!(result.period.is_finite() || result.period.is_nan());
6250 }
6251
6252 #[test]
6253 fn test_nan_sazed_no_panic() {
6254 let mut data = vec![0.0; 50];
6255 let argvals: Vec<f64> = (0..50).map(|i| i as f64).collect();
6256 for i in 0..50 {
6257 data[i] = (2.0 * std::f64::consts::PI * i as f64 / 10.0).sin();
6258 }
6259 data[10] = f64::NAN;
6260 let result = sazed(&data, &argvals, None);
6261 assert!(result.period.is_finite() || result.period.is_nan());
6263 }
6264
6265 #[test]
6270 fn test_interior_bounds_very_small() {
6271 let bounds = interior_bounds(0);
6273 assert!(bounds.is_none());
6274
6275 let bounds = interior_bounds(1);
6277 assert!(bounds.is_some() || bounds.is_none());
6280 }
6281
6282 #[test]
6283 fn test_valid_interior_bounds_min_span() {
6284 let bounds = valid_interior_bounds(10, 4);
6286 assert!(bounds.is_some());
6288
6289 let bounds = valid_interior_bounds(10, 100);
6291 assert!(bounds.is_none());
6292 }
6293
6294 #[test]
6295 fn test_periodogram_edge_cases() {
6296 let (freqs, power) = periodogram(&[], &[]);
6298 assert!(freqs.is_empty());
6299 assert!(power.is_empty());
6300
6301 let (freqs, power) = periodogram(&[1.0], &[0.0]);
6303 assert!(freqs.is_empty());
6304 assert!(power.is_empty());
6305
6306 let (freqs, power) = periodogram(&[1.0, 2.0], &[0.0]);
6308 assert!(freqs.is_empty());
6309 assert!(power.is_empty());
6310 }
6311
6312 #[test]
6313 fn test_autocorrelation_edge_cases() {
6314 let acf = autocorrelation(&[], 10);
6316 assert!(acf.is_empty());
6317
6318 let acf = autocorrelation(&[5.0, 5.0, 5.0, 5.0], 3);
6320 assert_eq!(acf.len(), 4);
6321 for &v in &acf {
6322 assert!((v - 1.0).abs() < 1e-10, "Constant data ACF should be 1.0");
6323 }
6324 }
6325
6326 #[test]
6327 fn test_detect_seasonality_changes_empty_data() {
6328 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6330 let argvals: Vec<f64> = vec![];
6331 let result = detect_seasonality_changes(&data, &argvals, 2.0, 0.3, 4.0, 2.0);
6332 assert!(result.change_points.is_empty());
6333 assert!(result.strength_curve.is_empty());
6334
6335 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6337 let argvals = vec![0.0, 1.0, 2.0];
6338 let result = detect_seasonality_changes(&data, &argvals, 2.0, 0.3, 4.0, 2.0);
6339 assert!(result.change_points.is_empty());
6340 assert!(result.strength_curve.is_empty());
6341 }
6342
6343 #[test]
6344 fn test_detect_amplitude_modulation_non_seasonal_returns_early() {
6345 let m = 100;
6347 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
6348
6349 let data: Vec<f64> = (0..m)
6351 .map(|i| ((i as f64 * 1.618).sin() * 100.0).fract())
6352 .collect();
6353 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6354
6355 let result = detect_amplitude_modulation(&data, &argvals, 0.2, 0.15, 0.5);
6356 assert!(!result.is_seasonal);
6357 assert_eq!(result.modulation_type, ModulationType::NonSeasonal);
6358 assert_eq!(result.modulation_score, 0.0);
6359 }
6360
6361 #[test]
6362 fn test_detect_amplitude_modulation_small_data() {
6363 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6365 let argvals = vec![0.0, 1.0, 2.0];
6366
6367 let result = detect_amplitude_modulation(&data, &argvals, 1.0, 0.15, 0.3);
6368 assert!(!result.is_seasonal);
6369 }
6370
6371 #[test]
6372 fn test_detect_amplitude_modulation_wavelet_invalid_inputs() {
6373 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6375 let result = detect_amplitude_modulation_wavelet(&data, &[], 2.0, 0.15, 0.3);
6376 assert!(!result.is_seasonal);
6377 assert_eq!(result.modulation_type, ModulationType::NonSeasonal);
6378
6379 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6381 let argvals = vec![0.0, 1.0, 2.0];
6382 let result = detect_amplitude_modulation_wavelet(&data, &argvals, 2.0, 0.15, 0.3);
6383 assert!(!result.is_seasonal);
6384
6385 let m = 100;
6387 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
6388 let data: Vec<f64> = argvals
6389 .iter()
6390 .map(|&t| (2.0 * PI * t / 0.2).sin())
6391 .collect();
6392 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6393 let result = detect_amplitude_modulation_wavelet(&data, &argvals, -1.0, 0.15, 0.3);
6394 assert!(!result.is_seasonal);
6395 }
6396
6397 #[test]
6398 fn test_detect_amplitude_modulation_wavelet_non_seasonal() {
6399 let m = 100;
6401 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
6402
6403 let data: Vec<f64> = (0..m)
6405 .map(|i| ((i as f64 * 1.618).sin() * 100.0).fract())
6406 .collect();
6407 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6408
6409 let result = detect_amplitude_modulation_wavelet(&data, &argvals, 0.2, 0.15, 0.5);
6410 assert!(!result.is_seasonal);
6411 assert_eq!(result.modulation_type, ModulationType::NonSeasonal);
6412 }
6413
6414 #[test]
6415 fn test_detect_amplitude_modulation_wavelet_seasonal() {
6416 let m = 200;
6418 let period = 0.2;
6419 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
6420
6421 let data: Vec<f64> = argvals
6423 .iter()
6424 .map(|&t| {
6425 let amplitude = 0.2 + 0.8 * t;
6426 amplitude * (2.0 * PI * t / period).sin()
6427 })
6428 .collect();
6429 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6430
6431 let result = detect_amplitude_modulation_wavelet(&data, &argvals, period, 0.15, 0.2);
6432 assert!(result.is_seasonal, "Should detect seasonality");
6433 assert!(result.scale > 0.0, "Scale should be positive");
6434 assert!(
6435 !result.wavelet_amplitude.is_empty(),
6436 "Wavelet amplitude should be computed"
6437 );
6438 assert_eq!(result.time_points.len(), m);
6439 }
6440
6441 #[test]
6442 fn test_instantaneous_period_invalid_inputs() {
6443 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6445 let result = instantaneous_period(&data, &[]);
6446 assert!(result.period.is_empty());
6447 assert!(result.frequency.is_empty());
6448 assert!(result.amplitude.is_empty());
6449
6450 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6452 let result = instantaneous_period(&data, &[0.0, 1.0, 2.0]);
6453 assert!(result.period.is_empty());
6454 }
6455
6456 #[test]
6457 fn test_analyze_peak_timing_invalid_inputs() {
6458 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6460 let result = analyze_peak_timing(&data, &[], 2.0, None);
6461 assert!(result.peak_times.is_empty());
6462 assert!(result.mean_timing.is_nan());
6463
6464 let data = FdMatrix::from_column_major(vec![1.0, 2.0], 1, 2).unwrap();
6466 let result = analyze_peak_timing(&data, &[0.0, 1.0], 2.0, None);
6467 assert!(result.peak_times.is_empty());
6468 assert!(result.mean_timing.is_nan());
6469
6470 let m = 100;
6472 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6473 let data = generate_sine(1, m, 2.0, &argvals);
6474 let result = analyze_peak_timing(&data, &argvals, -1.0, None);
6475 assert!(result.peak_times.is_empty());
6476 assert!(result.mean_timing.is_nan());
6477 }
6478
6479 #[test]
6480 fn test_analyze_peak_timing_no_peaks() {
6481 let data = FdMatrix::from_column_major(vec![5.0, 5.0], 1, 2).unwrap();
6483 let result = analyze_peak_timing(&data, &[0.0, 1.0], 2.0, Some(11));
6484 assert!(result.peak_times.is_empty());
6485 assert!(result.mean_timing.is_nan());
6486 }
6487
6488 #[test]
6489 fn test_classify_seasonality_invalid_inputs() {
6490 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6492 let result = classify_seasonality(&data, &[], 2.0, None, None);
6493 assert!(!result.is_seasonal);
6494 assert!(result.seasonal_strength.is_nan());
6495 assert_eq!(result.classification, SeasonalType::NonSeasonal);
6496
6497 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6499 let result = classify_seasonality(&data, &[0.0, 1.0, 2.0], 2.0, None, None);
6500 assert!(!result.is_seasonal);
6501 assert_eq!(result.classification, SeasonalType::NonSeasonal);
6502
6503 let m = 100;
6505 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6506 let data = generate_sine(1, m, 2.0, &argvals);
6507 let result = classify_seasonality(&data, &argvals, -1.0, None, None);
6508 assert!(!result.is_seasonal);
6509 }
6510
6511 #[test]
6512 fn test_classify_seasonality_non_seasonal() {
6513 let m = 100;
6515 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6516 let data = FdMatrix::from_column_major(vec![5.0; m], 1, m).unwrap();
6517
6518 let result = classify_seasonality(&data, &argvals, 2.0, Some(0.3), Some(0.05));
6519 assert!(!result.is_seasonal);
6520 assert_eq!(result.classification, SeasonalType::NonSeasonal);
6521 }
6522
6523 #[test]
6524 fn test_classify_seasonality_strong_seasonal() {
6525 let m = 400;
6527 let period = 2.0;
6528 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6529 let data = generate_sine(1, m, period, &argvals);
6530
6531 let result = classify_seasonality(&data, &argvals, period, Some(0.3), Some(0.5));
6532 assert!(result.is_seasonal);
6533 assert!(result.seasonal_strength > 0.5);
6534 assert!(result.peak_timing.is_some());
6535 assert!(
6537 !result.cycle_strengths.is_empty(),
6538 "cycle_strengths should be computed"
6539 );
6540 }
6541
6542 #[test]
6543 fn test_classify_seasonality_with_custom_thresholds() {
6544 let m = 400;
6545 let period = 2.0;
6546 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6547 let data = generate_sine(1, m, period, &argvals);
6548
6549 let result = classify_seasonality(&data, &argvals, period, Some(0.99), None);
6551 assert!(result.seasonal_strength > 0.8);
6553 }
6554
6555 #[test]
6556 fn test_detect_seasonality_changes_auto_fixed() {
6557 let m = 400;
6558 let period = 2.0;
6559 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6560
6561 let data: Vec<f64> = argvals
6563 .iter()
6564 .map(|&t| {
6565 if t < 10.0 {
6566 0.05 * ((t * 13.0).sin() + (t * 7.0).cos())
6567 } else {
6568 (2.0 * PI * t / period).sin()
6569 }
6570 })
6571 .collect();
6572 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6573
6574 let result = detect_seasonality_changes_auto(
6576 &data,
6577 &argvals,
6578 period,
6579 ThresholdMethod::Fixed(0.3),
6580 4.0,
6581 2.0,
6582 );
6583 assert!(!result.strength_curve.is_empty());
6584 }
6585
6586 #[test]
6587 fn test_detect_seasonality_changes_auto_percentile() {
6588 let m = 400;
6589 let period = 2.0;
6590 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6591
6592 let data: Vec<f64> = argvals
6593 .iter()
6594 .map(|&t| {
6595 if t < 10.0 {
6596 0.05 * ((t * 13.0).sin() + (t * 7.0).cos())
6597 } else {
6598 (2.0 * PI * t / period).sin()
6599 }
6600 })
6601 .collect();
6602 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6603
6604 let result = detect_seasonality_changes_auto(
6606 &data,
6607 &argvals,
6608 period,
6609 ThresholdMethod::Percentile(50.0),
6610 4.0,
6611 2.0,
6612 );
6613 assert!(!result.strength_curve.is_empty());
6614 }
6615
6616 #[test]
6617 fn test_detect_seasonality_changes_auto_otsu() {
6618 let m = 400;
6619 let period = 2.0;
6620 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6621
6622 let data: Vec<f64> = argvals
6623 .iter()
6624 .map(|&t| {
6625 if t < 10.0 {
6626 0.05 * ((t * 13.0).sin() + (t * 7.0).cos())
6627 } else {
6628 (2.0 * PI * t / period).sin()
6629 }
6630 })
6631 .collect();
6632 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6633
6634 let result = detect_seasonality_changes_auto(
6636 &data,
6637 &argvals,
6638 period,
6639 ThresholdMethod::Otsu,
6640 4.0,
6641 2.0,
6642 );
6643 assert!(!result.strength_curve.is_empty());
6644 }
6645
6646 #[test]
6647 fn test_detect_seasonality_changes_auto_invalid_inputs() {
6648 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6650 let result =
6651 detect_seasonality_changes_auto(&data, &[], 2.0, ThresholdMethod::Fixed(0.3), 4.0, 2.0);
6652 assert!(result.change_points.is_empty());
6653 assert!(result.strength_curve.is_empty());
6654
6655 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6657 let result = detect_seasonality_changes_auto(
6658 &data,
6659 &[0.0, 1.0, 2.0],
6660 2.0,
6661 ThresholdMethod::Otsu,
6662 1.0,
6663 0.5,
6664 );
6665 assert!(result.change_points.is_empty());
6666 assert!(result.strength_curve.is_empty());
6667 }
6668
6669 #[test]
6670 fn test_cfd_autoperiod_fdata_invalid_inputs() {
6671 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6673 let result = cfd_autoperiod_fdata(&data, &[], None, None);
6674 assert!(result.period.is_nan());
6675 assert_eq!(result.confidence, 0.0);
6676
6677 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 5.0], 1, 5).unwrap();
6679 let result = cfd_autoperiod_fdata(&data, &[0.0, 1.0, 2.0, 3.0, 4.0], None, None);
6680 assert!(result.period.is_nan());
6681 }
6682
6683 #[test]
6684 fn test_cfd_autoperiod_fdata_valid() {
6685 let n = 3;
6687 let m = 200;
6688 let period = 2.0;
6689 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6690 let data = generate_sine(n, m, period, &argvals);
6691
6692 let result = cfd_autoperiod_fdata(&data, &argvals, Some(0.1), Some(1));
6693 assert!(result.period.is_finite());
6694 }
6695
6696 #[test]
6697 fn test_lomb_scargle_fap_edge_cases() {
6698 let fap = lomb_scargle_fap(0.0, 100, 200);
6700 assert_eq!(fap, 1.0);
6701
6702 let fap = lomb_scargle_fap(-1.0, 100, 200);
6703 assert_eq!(fap, 1.0);
6704
6705 let fap = lomb_scargle_fap(10.0, 0, 200);
6707 assert_eq!(fap, 1.0);
6708
6709 let fap = lomb_scargle_fap(100.0, 100, 200);
6711 assert!(
6712 fap < 0.01,
6713 "Very high power should give low FAP, got {}",
6714 fap
6715 );
6716
6717 let fap = lomb_scargle_fap(5.0, 50, 100);
6719 assert!((0.0..=1.0).contains(&fap));
6720 }
6721
6722 #[test]
6723 fn test_lomb_scargle_fdata_valid() {
6724 let n = 3;
6725 let m = 200;
6726 let period = 5.0;
6727 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6728 let data = generate_sine(n, m, period, &argvals);
6729
6730 let result = lomb_scargle_fdata(&data, &argvals, Some(4.0), Some(1.0));
6731 assert!(
6732 (result.peak_period - period).abs() < 1.0,
6733 "Expected period ~{}, got {}",
6734 period,
6735 result.peak_period
6736 );
6737 assert!(!result.frequencies.is_empty());
6738 }
6739
6740 #[test]
6741 fn test_cwt_morlet_edge_cases() {
6742 let result = cwt_morlet_fft(&[], &[], 1.0, 6.0);
6744 assert!(result.is_empty());
6745
6746 let result = cwt_morlet_fft(&[1.0, 2.0], &[0.0, 1.0], 0.0, 6.0);
6748 assert!(result.is_empty());
6749
6750 let result = cwt_morlet_fft(&[1.0, 2.0], &[0.0, 1.0], -1.0, 6.0);
6751 assert!(result.is_empty());
6752 }
6753
6754 #[test]
6755 fn test_hilbert_transform_empty() {
6756 let result = hilbert_transform(&[]);
6757 assert!(result.is_empty());
6758 }
6759
6760 #[test]
6761 fn test_unwrap_phase_empty() {
6762 let result = unwrap_phase(&[]);
6763 assert!(result.is_empty());
6764 }
6765
6766 #[test]
6767 fn test_unwrap_phase_monotonic() {
6768 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];
6770 let unwrapped = unwrap_phase(&phase);
6771 assert_eq!(unwrapped.len(), phase.len());
6772 for i in 1..unwrapped.len() {
6774 assert!(
6775 unwrapped[i] >= unwrapped[i - 1] - 0.01,
6776 "Unwrapped phase should be monotonic at {}: {} vs {}",
6777 i,
6778 unwrapped[i],
6779 unwrapped[i - 1]
6780 );
6781 }
6782 }
6783
6784 #[test]
6785 fn test_linear_slope_edge_cases() {
6786 assert_eq!(linear_slope(&[1.0, 2.0], &[1.0]), 0.0);
6788
6789 assert_eq!(linear_slope(&[1.0], &[1.0]), 0.0);
6791
6792 let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
6794 let y = vec![1.0, 3.0, 5.0, 7.0, 9.0];
6795 let slope = linear_slope(&x, &y);
6796 assert!(
6797 (slope - 2.0).abs() < 1e-10,
6798 "Slope should be 2.0, got {}",
6799 slope
6800 );
6801
6802 let x = vec![5.0, 5.0, 5.0];
6804 let y = vec![1.0, 2.0, 3.0];
6805 let slope = linear_slope(&x, &y);
6806 assert_eq!(slope, 0.0, "Constant x should give slope 0");
6807 }
6808
6809 #[test]
6810 fn test_otsu_threshold_edge_cases() {
6811 let threshold = otsu_threshold(&[]);
6813 assert!((threshold - 0.5).abs() < 1e-10);
6814
6815 let threshold = otsu_threshold(&[f64::NAN, f64::NAN]);
6817 assert!((threshold - 0.5).abs() < 1e-10);
6818
6819 let threshold = otsu_threshold(&[5.0, 5.0, 5.0]);
6821 assert!((threshold - 5.0).abs() < 1e-10);
6822
6823 let threshold = otsu_threshold(&[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
6825 assert!(threshold > 0.0 && threshold < 1.0);
6826 }
6827
6828 #[test]
6829 fn test_find_peaks_1d_edge_cases() {
6830 assert!(find_peaks_1d(&[], 1).is_empty());
6832 assert!(find_peaks_1d(&[1.0], 1).is_empty());
6833 assert!(find_peaks_1d(&[1.0, 2.0], 1).is_empty());
6834
6835 let peaks = find_peaks_1d(&[0.0, 1.0, 0.0], 1);
6837 assert_eq!(peaks, vec![1]);
6838
6839 let signal = vec![0.0, 2.0, 0.0, 1.5, 0.0];
6841 let peaks = find_peaks_1d(&signal, 1);
6842 assert_eq!(peaks, vec![1, 3]);
6843
6844 let signal = vec![0.0, 1.0, 0.5, 2.0, 0.0];
6846 let peaks = find_peaks_1d(&signal, 3);
6847 assert_eq!(peaks.len(), 1);
6850 assert_eq!(peaks[0], 3);
6851 }
6852
6853 #[test]
6854 fn test_compute_prominence() {
6855 let signal = vec![0.0, 0.0, 5.0, 0.0, 0.0];
6857 let prom = compute_prominence(&signal, 2);
6858 assert!((prom - 5.0).abs() < 1e-10);
6859
6860 let signal = vec![0.0, 2.0, 5.0, 1.0, 0.0];
6862 let prom = compute_prominence(&signal, 2);
6863 assert!(prom > 0.0);
6866 }
6867
6868 #[test]
6869 fn test_seasonal_strength_variance_invalid() {
6870 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6872 let result = seasonal_strength_variance(&data, &[], 2.0, 3);
6873 assert!(result.is_nan());
6874
6875 let m = 50;
6877 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6878 let data = generate_sine(1, m, 2.0, &argvals);
6879 let result = seasonal_strength_variance(&data, &argvals, -1.0, 3);
6880 assert!(result.is_nan());
6881 }
6882
6883 #[test]
6884 fn test_seasonal_strength_spectral_invalid() {
6885 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6887 let result = seasonal_strength_spectral(&data, &[], 2.0);
6888 assert!(result.is_nan());
6889
6890 let m = 50;
6892 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6893 let data = generate_sine(1, m, 2.0, &argvals);
6894 let result = seasonal_strength_spectral(&data, &argvals, -1.0);
6895 assert!(result.is_nan());
6896 }
6897
6898 #[test]
6899 fn test_seasonal_strength_wavelet_invalid() {
6900 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6902 let result = seasonal_strength_wavelet(&data, &[], 2.0);
6903 assert!(result.is_nan());
6904
6905 let m = 50;
6907 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6908 let data = generate_sine(1, m, 2.0, &argvals);
6909 let result = seasonal_strength_wavelet(&data, &argvals, -1.0);
6910 assert!(result.is_nan());
6911
6912 let data = FdMatrix::from_column_major(vec![5.0; m], 1, m).unwrap();
6914 let result = seasonal_strength_wavelet(&data, &argvals, 2.0);
6915 assert!(
6916 (result - 0.0).abs() < 1e-10,
6917 "Constant data should have 0 strength"
6918 );
6919 }
6920
6921 #[test]
6922 fn test_seasonal_strength_windowed_spectral() {
6923 let m = 200;
6925 let period = 2.0;
6926 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6927 let data = generate_sine(1, m, period, &argvals);
6928
6929 let strengths =
6930 seasonal_strength_windowed(&data, &argvals, period, 4.0, StrengthMethod::Spectral);
6931
6932 assert_eq!(strengths.len(), m, "Should return m values");
6933 }
6934
6935 #[test]
6936 fn test_seasonal_strength_windowed_invalid() {
6937 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6939 let strengths = seasonal_strength_windowed(&data, &[], 2.0, 4.0, StrengthMethod::Variance);
6940 assert!(strengths.is_empty());
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 strengths =
6947 seasonal_strength_windowed(&data, &argvals, 2.0, -1.0, StrengthMethod::Variance);
6948 assert!(strengths.is_empty());
6949 }
6950
6951 #[test]
6952 fn test_ssa_custom_window_length() {
6953 let n = 50;
6955 let values: Vec<f64> = (0..n).map(|i| (2.0 * PI * i as f64 / 10.0).sin()).collect();
6956
6957 let result = ssa(&values, Some(30), None, None, None);
6959 assert_eq!(result.trend, values);
6961 assert_eq!(result.n_components, 0);
6962 }
6963
6964 #[test]
6965 fn test_ssa_window_length_too_small() {
6966 let n = 50;
6967 let values: Vec<f64> = (0..n).map(|i| i as f64).collect();
6968
6969 let result = ssa(&values, Some(1), None, None, None);
6971 assert_eq!(result.trend, values);
6972 assert_eq!(result.n_components, 0);
6973 }
6974
6975 #[test]
6976 fn test_ssa_auto_grouping() {
6977 let n = 200;
6979 let period = 12.0;
6980 let values: Vec<f64> = (0..n)
6981 .map(|i| {
6982 let t = i as f64;
6983 0.05 * t + 2.0 * (2.0 * PI * t / period).sin() + 0.01 * ((i * 7) as f64).sin()
6985 })
6986 .collect();
6987
6988 let result = ssa(&values, Some(30), Some(6), None, None);
6989
6990 assert!(
6992 result.detected_period > 0.0,
6993 "Should detect a period, got {}",
6994 result.detected_period
6995 );
6996 assert!(result.confidence > 0.0);
6997
6998 for i in 0..n {
7000 let reconstructed = result.trend[i] + result.seasonal[i] + result.noise[i];
7001 assert!(
7002 (reconstructed - values[i]).abs() < 1e-8,
7003 "SSA auto-grouping reconstruction error at {}",
7004 i
7005 );
7006 }
7007 }
7008
7009 #[test]
7010 fn test_ssa_with_many_components() {
7011 let n = 100;
7013 let values: Vec<f64> = (0..n).map(|i| (2.0 * PI * i as f64 / 12.0).sin()).collect();
7014
7015 let result = ssa(&values, None, Some(100), None, None);
7016 assert!(result.n_components <= n);
7017 assert!(!result.singular_values.is_empty());
7018 }
7019
7020 #[test]
7021 fn test_embed_trajectory() {
7022 let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
7024 let l = 3;
7025 let k = 3; let traj = embed_trajectory(&values, l, k);
7027
7028 assert!((traj[0] - 1.0).abs() < 1e-10);
7030 assert!((traj[1] - 2.0).abs() < 1e-10);
7031 assert!((traj[2] - 3.0).abs() < 1e-10);
7032
7033 assert!((traj[3] - 2.0).abs() < 1e-10);
7035 assert!((traj[4] - 3.0).abs() < 1e-10);
7036 assert!((traj[5] - 4.0).abs() < 1e-10);
7037
7038 assert!((traj[6] - 3.0).abs() < 1e-10);
7040 assert!((traj[7] - 4.0).abs() < 1e-10);
7041 assert!((traj[8] - 5.0).abs() < 1e-10);
7042 }
7043
7044 #[test]
7045 fn test_diagonal_average() {
7046 let l = 3;
7049 let k = 3;
7050 let n = l + k - 1; let matrix = vec![1.0; l * k];
7052 let result = diagonal_average(&matrix, l, k, n);
7053 assert_eq!(result.len(), n);
7054 for &v in &result {
7055 assert!((v - 1.0).abs() < 1e-10, "Expected 1.0, got {}", v);
7056 }
7057 }
7058
7059 #[test]
7060 fn test_svd_decompose() {
7061 let l = 3;
7063 let k = 2;
7064 let trajectory = vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0]; let (u, sigma, vt) = svd_decompose(&trajectory, l, k);
7066
7067 assert!(!u.is_empty(), "U should not be empty");
7068 assert!(!sigma.is_empty(), "Sigma should not be empty");
7069 assert!(!vt.is_empty(), "V^T should not be empty");
7070
7071 for &s in &sigma {
7073 assert!(s >= 0.0, "Singular values must be non-negative");
7074 }
7075 for i in 1..sigma.len() {
7076 assert!(sigma[i] <= sigma[i - 1] + 1e-10);
7077 }
7078 }
7079
7080 #[test]
7081 fn test_is_trend_component() {
7082 let trend_vec: Vec<f64> = (0..20).map(|i| i as f64).collect();
7084 assert!(is_trend_component(&trend_vec));
7085
7086 let osc_vec: Vec<f64> = (0..20).map(|i| (PI * i as f64 / 3.0).sin()).collect();
7088 assert!(!is_trend_component(&osc_vec));
7089
7090 assert!(!is_trend_component(&[1.0, 2.0]));
7092 }
7093
7094 #[test]
7095 fn test_is_periodic_component() {
7096 let periodic: Vec<f64> = (0..40)
7098 .map(|i| (2.0 * PI * i as f64 / 10.0).sin())
7099 .collect();
7100 let (is_periodic, period) = is_periodic_component(&periodic);
7101 assert!(is_periodic, "Should detect periodicity");
7102 assert!(
7103 (period - 10.0).abs() < 2.0,
7104 "Expected period ~10, got {}",
7105 period
7106 );
7107
7108 let monotonic: Vec<f64> = (0..40).map(|i| i as f64 * 0.01).collect();
7110 let (is_periodic, _) = is_periodic_component(&monotonic);
7111 let _ = is_periodic;
7115
7116 let (is_periodic, _) = is_periodic_component(&[1.0, 2.0, 3.0]);
7118 assert!(!is_periodic, "Too short to be periodic");
7119 }
7120
7121 #[test]
7122 fn test_classify_ssa_component() {
7123 let trend_vec: Vec<f64> = (0..20).map(|i| i as f64 * 0.1).collect();
7125 let kind = classify_ssa_component(&trend_vec, 0);
7126 assert!(matches!(kind, SsaComponentKind::Trend));
7127
7128 let kind = classify_ssa_component(&trend_vec, 1);
7130 assert!(matches!(kind, SsaComponentKind::Trend));
7131
7132 let kind = classify_ssa_component(&trend_vec, 2);
7137 assert!(!matches!(kind, SsaComponentKind::Trend));
7138
7139 let periodic: Vec<f64> = (0..40)
7141 .map(|i| (2.0 * PI * i as f64 / 10.0).sin())
7142 .collect();
7143 let kind = classify_ssa_component(&periodic, 0);
7144 assert!(matches!(kind, SsaComponentKind::Seasonal(_)));
7145 }
7146
7147 #[test]
7148 fn test_apply_ssa_grouping_defaults() {
7149 let mut trend_idx = Vec::new();
7151 let mut seasonal_idx = Vec::new();
7152 apply_ssa_grouping_defaults(&mut trend_idx, &mut seasonal_idx, 5);
7153 assert_eq!(trend_idx, vec![0]);
7154 assert_eq!(seasonal_idx, vec![1, 2]);
7155
7156 let mut trend_idx = vec![0];
7158 let mut seasonal_idx = vec![1];
7159 apply_ssa_grouping_defaults(&mut trend_idx, &mut seasonal_idx, 5);
7160 assert_eq!(trend_idx, vec![0]); assert_eq!(seasonal_idx, vec![1]); let mut trend_idx = Vec::new();
7165 let mut seasonal_idx = Vec::new();
7166 apply_ssa_grouping_defaults(&mut trend_idx, &mut seasonal_idx, 2);
7167 assert_eq!(trend_idx, vec![0]);
7168 assert!(seasonal_idx.is_empty());
7169
7170 let mut trend_idx = Vec::new();
7172 let mut seasonal_idx = Vec::new();
7173 apply_ssa_grouping_defaults(&mut trend_idx, &mut seasonal_idx, 0);
7174 assert!(trend_idx.is_empty());
7175 assert!(seasonal_idx.is_empty());
7176 }
7177
7178 #[test]
7179 fn test_reconstruct_grouped_empty() {
7180 let result = reconstruct_grouped(&[], &[], &[], 3, 3, 5, &[]);
7182 assert_eq!(result, vec![0.0; 5]);
7183 }
7184
7185 #[test]
7186 fn test_reconstruct_grouped_idx_out_of_range() {
7187 let u = vec![1.0; 9]; let sigma = vec![1.0, 0.5];
7190 let vt = vec![1.0; 6]; let result = reconstruct_grouped(&u, &sigma, &vt, 3, 3, 5, &[5]);
7192 assert_eq!(result, vec![0.0; 5]);
7194 }
7195
7196 #[test]
7197 fn test_auto_group_ssa_components() {
7198 let l = 20;
7200 let n_comp = 4;
7201 let mut u = vec![0.0; l * n_comp];
7203 for i in 0..l {
7204 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(); }
7209 let sigma = vec![10.0, 5.0, 4.0, 0.1];
7210
7211 let (trend_idx, seasonal_idx, detected_period, confidence) =
7212 auto_group_ssa_components(&u, &sigma, l, 10, n_comp);
7213
7214 assert!(
7215 !trend_idx.is_empty(),
7216 "Should detect at least one trend component"
7217 );
7218 assert!(
7219 !seasonal_idx.is_empty(),
7220 "Should detect at least one seasonal component"
7221 );
7222 if detected_period > 0.0 {
7224 assert!(confidence > 0.0);
7225 }
7226 }
7227
7228 #[test]
7229 fn test_estimate_period_fft_invalid() {
7230 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
7232 let result = estimate_period_fft(&data, &[]);
7233 assert!(result.period.is_nan());
7234 assert!(result.frequency.is_nan());
7235
7236 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
7238 let result = estimate_period_fft(&data, &[0.0, 1.0, 2.0]);
7239 assert!(result.period.is_nan());
7240 }
7241
7242 #[test]
7243 fn test_detect_peaks_invalid_inputs() {
7244 let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
7246 let result = detect_peaks(&data, &[], None, None, false, None);
7247 assert!(result.peaks.is_empty());
7248 assert!(result.mean_period.is_nan());
7249
7250 let data = FdMatrix::from_column_major(vec![1.0, 2.0], 1, 2).unwrap();
7252 let result = detect_peaks(&data, &[0.0, 1.0], None, None, false, None);
7253 assert!(result.peaks.is_empty());
7254 }
7255
7256 #[test]
7257 fn test_detect_peaks_with_smoothing() {
7258 let m = 200;
7260 let period = 2.0;
7261 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
7262
7263 let data: Vec<f64> = argvals
7265 .iter()
7266 .enumerate()
7267 .map(|(i, &t)| (2.0 * PI * t / period).sin() + 0.1 * ((i as f64 * 5.7).sin()))
7268 .collect();
7269 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
7270
7271 let result = detect_peaks(&data, &argvals, Some(1.5), None, true, Some(11));
7273 assert!(!result.peaks[0].is_empty());
7274 }
7275
7276 #[test]
7277 fn test_morlet_wavelet() {
7278 let w = morlet_wavelet(0.0, 6.0);
7280 assert!((w.re - 1.0).abs() < 1e-10);
7281 assert!(w.im.abs() < 1e-10);
7282
7283 let w = morlet_wavelet(10.0, 6.0);
7285 assert!(w.norm() < 1e-10, "Wavelet should decay for large t");
7286 }
7287
7288 #[test]
7289 fn test_matrix_profile_short_series() {
7290 let values: Vec<f64> = (0..10).map(|i| (i as f64 * 0.5).sin()).collect();
7292 let result = matrix_profile(&values, Some(3), None);
7293 assert_eq!(result.profile.len(), 8); }
7295
7296 #[test]
7297 fn test_ssa_seasonality_with_threshold() {
7298 let n = 200;
7300 let period = 12.0;
7301 let values: Vec<f64> = (0..n)
7302 .map(|i| (2.0 * PI * i as f64 / period).sin())
7303 .collect();
7304
7305 let (is_seasonal, det_period, confidence) = ssa_seasonality(&values, None, Some(0.01));
7307 assert!(confidence >= 0.0);
7308 let _ = (is_seasonal, det_period);
7309
7310 let (is_seasonal, _, _) = ssa_seasonality(&values, None, Some(0.99));
7312 let _ = is_seasonal;
7313 }
7314
7315 #[test]
7316 fn test_cfd_autoperiod_short_data() {
7317 let argvals: Vec<f64> = (0..8).map(|i| i as f64).collect();
7319 let data: Vec<f64> = argvals
7320 .iter()
7321 .map(|&t| (2.0 * PI * t / 4.0).sin())
7322 .collect();
7323
7324 let result = cfd_autoperiod(&data, &argvals, None, None);
7325 assert!(result.period.is_finite() || result.period.is_nan());
7327 }
7328
7329 #[test]
7330 fn test_cfd_autoperiod_long_period() {
7331 let n = 365 * 8;
7335 let argvals: Vec<f64> = (0..n).map(|i| i as f64).collect();
7336 let data: Vec<f64> = argvals
7337 .iter()
7338 .map(|&t| 15.0 + 10.0 * (2.0 * PI * t / 365.0).sin() + 0.001 * t)
7339 .collect();
7340 let result = cfd_autoperiod(&data, &argvals, None, None);
7341 let err = (result.period - 365.0).abs();
7342 assert!(
7343 err < 2.0,
7344 "long-period detection: expected ~365, got {:.1} (err={:.1})",
7345 result.period,
7346 err
7347 );
7348 }
7349
7350 #[test]
7351 fn test_validate_sazed_component() {
7352 let result = validate_sazed_component(5.0, 0.8, 1.0, 10.0, 0.5);
7354 assert_eq!(result, Some(5.0));
7355
7356 let result = validate_sazed_component(0.5, 0.8, 1.0, 10.0, 0.5);
7358 assert_eq!(result, None);
7359
7360 let result = validate_sazed_component(15.0, 0.8, 1.0, 10.0, 0.5);
7361 assert_eq!(result, None);
7362
7363 let result = validate_sazed_component(5.0, 0.3, 1.0, 10.0, 0.5);
7365 assert_eq!(result, None);
7366
7367 let result = validate_sazed_component(f64::NAN, 0.8, 1.0, 10.0, 0.5);
7369 assert_eq!(result, None);
7370 }
7371
7372 #[test]
7373 fn test_count_agreeing_periods() {
7374 let periods = vec![5.0, 5.1, 5.2, 10.0, 15.0];
7375
7376 let (count, sum) = count_agreeing_periods(&periods, 5.0, 0.1);
7378 assert_eq!(count, 3);
7379 assert!((sum - 15.3).abs() < 1e-10);
7380
7381 let (count, _) = count_agreeing_periods(&periods, 100.0, 0.1);
7383 assert_eq!(count, 0);
7384 }
7385
7386 #[test]
7387 fn test_generate_ls_frequencies() {
7388 let times: Vec<f64> = (0..100).map(|i| i as f64).collect();
7389 let freqs = generate_ls_frequencies(×, 4.0, 1.0);
7390 assert!(!freqs.is_empty());
7391 assert!(
7393 (freqs[0] - 1.0 / 99.0).abs() < 0.01,
7394 "First freq should be ~1/99, got {}",
7395 freqs[0]
7396 );
7397
7398 let freqs = generate_ls_frequencies(&[0.0], 4.0, 1.0);
7400 assert_eq!(freqs, vec![0.0]);
7401 }
7402
7403 #[test]
7404 fn test_estimate_independent_frequencies() {
7405 let times: Vec<f64> = (0..50).map(|i| i as f64).collect();
7406 let n_indep = estimate_independent_frequencies(×, 100);
7407 assert_eq!(n_indep, 50); let n_indep = estimate_independent_frequencies(×, 30);
7410 assert_eq!(n_indep, 30); }
7412
7413 #[test]
7414 fn test_cluster_periods() {
7415 let result = cluster_periods(&[], 0.1, 1);
7417 assert!(result.is_empty());
7418
7419 let candidates = vec![(5.0, 1.0)];
7421 let result = cluster_periods(&candidates, 0.1, 1);
7422 assert_eq!(result.len(), 1);
7423
7424 let candidates = vec![(5.0, 1.0), (5.05, 0.8), (10.0, 0.5), (10.1, 0.4)];
7426 let result = cluster_periods(&candidates, 0.1, 1);
7427 assert_eq!(result.len(), 2, "Should find 2 clusters");
7428
7429 let candidates = vec![(5.0, 1.0), (10.0, 0.5)];
7431 let result = cluster_periods(&candidates, 0.01, 2);
7432 assert!(result.is_empty());
7434 }
7435
7436 #[test]
7437 fn test_validate_cfd_candidates() {
7438 let n = 50;
7440 let dt = 1.0;
7441 let mut acf = vec![0.0; n + 1];
7442 acf[0] = 1.0;
7443 for i in 1..=n {
7444 acf[i] = (2.0 * PI * i as f64 / 10.0).cos() * 0.5;
7445 }
7446
7447 let clusters = vec![(10.0, 1.0), (20.0, 0.5)];
7448 let validated = validate_cfd_candidates(&clusters, &acf, dt);
7449 assert!(
7451 !validated.is_empty(),
7452 "Should validate at least one candidate"
7453 );
7454 }
7455
7456 #[test]
7457 fn test_validate_or_fallback_cfd() {
7458 let validated = vec![(10.0, 0.8, 1.0)];
7460 let candidates = vec![(10.0, 1.0)];
7461 let result = validate_or_fallback_cfd(validated.clone(), &candidates, 0.1, 1);
7462 assert_eq!(result.len(), 1);
7463
7464 let candidates = vec![(10.0, 1.0), (10.2, 0.8)];
7466 let result = validate_or_fallback_cfd(vec![], &candidates, 0.1, 1);
7467 assert!(!result.is_empty(), "Fallback should return something");
7468 }
7469
7470 #[test]
7471 fn test_rank_cfd_results() {
7472 let validated = vec![
7473 (10.0, 0.5, 1.0), (5.0, 0.8, 2.0), ];
7476 let (periods, confidences, top_acf) = rank_cfd_results(&validated);
7477 assert_eq!(periods[0], 5.0); assert_eq!(periods[1], 10.0);
7480 assert!((top_acf - 0.8).abs() < 1e-10);
7481 assert_eq!(confidences.len(), 2);
7482 }
7483
7484 #[test]
7485 fn test_empty_cfd_result() {
7486 let result = empty_cfd_result();
7487 assert!(result.period.is_nan());
7488 assert_eq!(result.confidence, 0.0);
7489 assert_eq!(result.acf_validation, 0.0);
7490 assert!(result.periods.is_empty());
7491 }
7492
7493 #[test]
7494 fn test_fit_and_subtract_sinusoid() {
7495 let m = 200;
7496 let period = 10.0;
7497 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
7498 let mut residual: Vec<f64> = argvals
7499 .iter()
7500 .map(|&t| 3.0 * (2.0 * PI * t / period).sin())
7501 .collect();
7502
7503 let (a, b, amplitude, phase) = fit_and_subtract_sinusoid(&mut residual, &argvals, period);
7504
7505 assert!(
7506 amplitude > 2.0,
7507 "Amplitude should be ~3.0, got {}",
7508 amplitude
7509 );
7510 assert!(phase.is_finite());
7511 let _ = (a, b);
7512
7513 let max_residual: f64 = residual.iter().map(|&x| x.abs()).fold(0.0, f64::max);
7515 assert!(
7516 max_residual < 0.5,
7517 "Residual after subtraction should be small, got {}",
7518 max_residual
7519 );
7520 }
7521
7522 #[test]
7523 fn test_detect_seasonality_changes_cessation() {
7524 let m = 400;
7526 let period = 2.0;
7527 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
7528
7529 let data: Vec<f64> = argvals
7530 .iter()
7531 .map(|&t| {
7532 if t < 10.0 {
7533 (2.0 * PI * t / period).sin()
7535 } else {
7536 0.05 * ((t * 13.0).sin() + (t * 7.0).cos())
7538 }
7539 })
7540 .collect();
7541 let data = FdMatrix::from_column_major(data, 1, m).unwrap();
7542
7543 let result = detect_seasonality_changes(&data, &argvals, period, 0.3, 4.0, 2.0);
7544
7545 assert!(!result.strength_curve.is_empty());
7546 if !result.change_points.is_empty() {
7548 let cessation_points: Vec<_> = result
7549 .change_points
7550 .iter()
7551 .filter(|cp| cp.change_type == ChangeType::Cessation)
7552 .collect();
7553 assert!(!cessation_points.is_empty(), "Should detect Cessation");
7554 }
7555 }
7556
7557 #[test]
7558 fn test_matrix_profile_fdata_multiple_samples() {
7559 let n = 5;
7561 let m = 200;
7562 let period = 20.0;
7563 let mut data = vec![0.0; n * m];
7564 for i in 0..n {
7565 let amp = (i + 1) as f64;
7566 for j in 0..m {
7567 data[i + j * n] = amp * (2.0 * PI * j as f64 / period).sin();
7568 }
7569 }
7570 let data = FdMatrix::from_column_major(data, n, m).unwrap();
7571
7572 let result = matrix_profile_fdata(&data, Some(15), None);
7573 assert!(!result.profile.is_empty());
7574 assert!(result.primary_period > 0.0);
7575 }
7576
7577 #[test]
7578 fn test_ssa_fdata_multiple_samples() {
7579 let n = 3;
7580 let m = 200;
7581 let mut data = vec![0.0; n * m];
7582 for i in 0..n {
7583 for j in 0..m {
7584 data[i + j * n] = (2.0 * PI * j as f64 / 12.0).sin() + 0.01 * j as f64;
7585 }
7586 }
7587 let data = FdMatrix::from_column_major(data, n, m).unwrap();
7588
7589 let result = ssa_fdata(&data, Some(25), Some(5));
7590 assert_eq!(result.trend.len(), m);
7591 assert_eq!(result.seasonal.len(), m);
7592 }
7593
7594 #[test]
7595 fn test_compute_cycle_strengths() {
7596 let m = 400;
7597 let period = 2.0;
7598 let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect(); let data = generate_sine(1, m, period, &argvals);
7602 let (strengths, weak) = compute_cycle_strengths(&data, &argvals, period, 0.3);
7603
7604 assert!(
7605 !strengths.is_empty(),
7606 "Should compute at least one cycle strength"
7607 );
7608 assert!(
7610 weak.is_empty(),
7611 "Pure sine should have no weak seasons, got {:?}",
7612 weak
7613 );
7614 }
7615
7616 #[test]
7617 fn test_find_acf_descent_end() {
7618 let acf = vec![1.0, 0.8, 0.5, 0.2, -0.1, -0.3, 0.0, 0.3, 0.5];
7620 let end = find_acf_descent_end(&acf);
7621 assert_eq!(end, 4, "Should find first negative at index 4");
7622
7623 let acf = vec![1.0, 0.8, 0.5, 0.3, 0.4, 0.6];
7626 let end = find_acf_descent_end(&acf);
7627 assert_eq!(end, 3, "Should find uptick at index 3 (i-1 where i=4)");
7628 }
7629}