Skip to main content

fdars_core/
seasonal.rs

1//! Seasonal time series analysis for functional data.
2//!
3//! This module provides functions for analyzing seasonal patterns in functional data:
4//! - Period estimation (FFT, autocorrelation, regression-based)
5//! - Peak detection with prominence calculation
6//! - Seasonal strength measurement (variance and spectral methods)
7//! - Seasonality change detection (onset/cessation)
8//! - Instantaneous period estimation for drifting seasonality
9//! - Peak timing variability analysis for short series
10//! - Seasonality classification
11
12use 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/// Result of period estimation.
23#[derive(Debug, Clone)]
24pub struct PeriodEstimate {
25    /// Estimated period
26    pub period: f64,
27    /// Dominant frequency (1/period)
28    pub frequency: f64,
29    /// Power at the dominant frequency
30    pub power: f64,
31    /// Confidence measure (ratio of peak power to mean power)
32    pub confidence: f64,
33}
34
35/// A detected peak in functional data.
36#[derive(Debug, Clone)]
37pub struct Peak {
38    /// Time at which the peak occurs
39    pub time: f64,
40    /// Value at the peak
41    pub value: f64,
42    /// Prominence of the peak (height relative to surrounding valleys)
43    pub prominence: f64,
44}
45
46/// Result of peak detection.
47#[derive(Debug, Clone)]
48pub struct PeakDetectionResult {
49    /// Peaks for each sample: `peaks[sample_idx]` contains peaks for that sample
50    pub peaks: Vec<Vec<Peak>>,
51    /// Inter-peak distances for each sample
52    pub inter_peak_distances: Vec<Vec<f64>>,
53    /// Mean period estimated from inter-peak distances across all samples
54    pub mean_period: f64,
55}
56
57/// A detected period from multiple period detection.
58#[derive(Debug, Clone)]
59pub struct DetectedPeriod {
60    /// Estimated period
61    pub period: f64,
62    /// FFT confidence (ratio of peak power to mean power)
63    pub confidence: f64,
64    /// Seasonal strength at this period (variance explained)
65    pub strength: f64,
66    /// Amplitude of the sinusoidal component
67    pub amplitude: f64,
68    /// Phase of the sinusoidal component (radians)
69    pub phase: f64,
70    /// Iteration number (1-indexed)
71    pub iteration: usize,
72}
73
74/// Method for computing seasonal strength.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum StrengthMethod {
77    /// Variance decomposition: Var(seasonal) / Var(total)
78    Variance,
79    /// Spectral: power at seasonal frequencies / total power
80    Spectral,
81}
82
83/// A detected change point in seasonality.
84#[derive(Debug, Clone)]
85pub struct ChangePoint {
86    /// Time at which the change occurs
87    pub time: f64,
88    /// Type of change
89    pub change_type: ChangeType,
90    /// Seasonal strength before the change
91    pub strength_before: f64,
92    /// Seasonal strength after the change
93    pub strength_after: f64,
94}
95
96/// Type of seasonality change.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum ChangeType {
99    /// Series becomes seasonal
100    Onset,
101    /// Series stops being seasonal
102    Cessation,
103}
104
105/// Result of seasonality change detection.
106#[derive(Debug, Clone)]
107pub struct ChangeDetectionResult {
108    /// Detected change points
109    pub change_points: Vec<ChangePoint>,
110    /// Time-varying seasonal strength curve used for detection
111    pub strength_curve: Vec<f64>,
112}
113
114/// Result of instantaneous period estimation.
115#[derive(Debug, Clone)]
116pub struct InstantaneousPeriod {
117    /// Instantaneous period at each time point
118    pub period: Vec<f64>,
119    /// Instantaneous frequency at each time point
120    pub frequency: Vec<f64>,
121    /// Instantaneous amplitude (envelope) at each time point
122    pub amplitude: Vec<f64>,
123}
124
125/// Result of peak timing variability analysis.
126#[derive(Debug, Clone)]
127pub struct PeakTimingResult {
128    /// Peak times for each cycle
129    pub peak_times: Vec<f64>,
130    /// Peak values
131    pub peak_values: Vec<f64>,
132    /// Within-period timing (0-1 scale, e.g., day-of-year / 365)
133    pub normalized_timing: Vec<f64>,
134    /// Mean normalized timing
135    pub mean_timing: f64,
136    /// Standard deviation of normalized timing
137    pub std_timing: f64,
138    /// Range of normalized timing (max - min)
139    pub range_timing: f64,
140    /// Variability score (0 = stable, 1 = highly variable)
141    pub variability_score: f64,
142    /// Trend in timing (positive = peaks getting later)
143    pub timing_trend: f64,
144    /// Cycle indices (1-indexed)
145    pub cycle_indices: Vec<usize>,
146}
147
148/// Type of seasonality pattern.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum SeasonalType {
151    /// Regular peaks with consistent timing
152    StableSeasonal,
153    /// Regular peaks but timing shifts between cycles
154    VariableTiming,
155    /// Some cycles seasonal, some not
156    IntermittentSeasonal,
157    /// No clear seasonality
158    NonSeasonal,
159}
160
161/// Result of seasonality classification.
162#[derive(Debug, Clone)]
163pub struct SeasonalityClassification {
164    /// Whether the series is seasonal overall
165    pub is_seasonal: bool,
166    /// Whether peak timing is stable across cycles
167    pub has_stable_timing: bool,
168    /// Timing variability score (0 = stable, 1 = highly variable)
169    pub timing_variability: f64,
170    /// Overall seasonal strength
171    pub seasonal_strength: f64,
172    /// Per-cycle seasonal strength
173    pub cycle_strengths: Vec<f64>,
174    /// Indices of weak/missing seasons (0-indexed)
175    pub weak_seasons: Vec<usize>,
176    /// Classification type
177    pub classification: SeasonalType,
178    /// Peak timing analysis (if peaks were detected)
179    pub peak_timing: Option<PeakTimingResult>,
180}
181
182/// Method for automatic threshold selection.
183#[derive(Debug, Clone, Copy, PartialEq)]
184pub enum ThresholdMethod {
185    /// Fixed user-specified threshold
186    Fixed(f64),
187    /// Percentile of strength distribution
188    Percentile(f64),
189    /// Otsu's method (optimal bimodal separation)
190    Otsu,
191}
192
193/// Type of amplitude modulation pattern.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum ModulationType {
196    /// Constant amplitude (no modulation)
197    Stable,
198    /// Amplitude increases over time (seasonality emerges)
199    Emerging,
200    /// Amplitude decreases over time (seasonality fades)
201    Fading,
202    /// Amplitude varies non-monotonically
203    Oscillating,
204    /// No seasonality detected
205    NonSeasonal,
206}
207
208/// Result of amplitude modulation detection.
209#[derive(Debug, Clone)]
210pub struct AmplitudeModulationResult {
211    /// Whether seasonality is present (using robust spectral method)
212    pub is_seasonal: bool,
213    /// Overall seasonal strength (spectral method)
214    pub seasonal_strength: f64,
215    /// Whether amplitude modulation is detected
216    pub has_modulation: bool,
217    /// Type of amplitude modulation
218    pub modulation_type: ModulationType,
219    /// Coefficient of variation of time-varying strength (0 = stable, higher = more modulation)
220    pub modulation_score: f64,
221    /// Trend in amplitude (-1 to 1: negative = fading, positive = emerging)
222    pub amplitude_trend: f64,
223    /// Time-varying seasonal strength curve
224    pub strength_curve: Vec<f64>,
225    /// Time points corresponding to strength_curve
226    pub time_points: Vec<f64>,
227    /// Minimum strength in the curve
228    pub min_strength: f64,
229    /// Maximum strength in the curve
230    pub max_strength: f64,
231}
232
233/// Result of wavelet-based amplitude modulation detection.
234#[derive(Debug, Clone)]
235pub struct WaveletAmplitudeResult {
236    /// Whether seasonality is present
237    pub is_seasonal: bool,
238    /// Overall seasonal strength
239    pub seasonal_strength: f64,
240    /// Whether amplitude modulation is detected
241    pub has_modulation: bool,
242    /// Type of amplitude modulation
243    pub modulation_type: ModulationType,
244    /// Coefficient of variation of wavelet amplitude
245    pub modulation_score: f64,
246    /// Trend in amplitude (-1 to 1)
247    pub amplitude_trend: f64,
248    /// Wavelet amplitude at the seasonal frequency over time
249    pub wavelet_amplitude: Vec<f64>,
250    /// Time points corresponding to wavelet_amplitude
251    pub time_points: Vec<f64>,
252    /// Scale (period) used for wavelet analysis
253    pub scale: f64,
254}
255
256// ============================================================================
257// Internal helper functions
258// ============================================================================
259
260/// Compute mean curve from functional data matrix.
261///
262/// # Arguments
263/// * `data` - Functional data matrix (n x m)
264/// * `parallel` - Use parallel iteration (default: true)
265///
266/// # Returns
267/// Mean curve of length m
268#[inline]
269fn compute_mean_curve_impl(data: &FdMatrix, parallel: bool) -> Vec<f64> {
270    let (n, m) = data.shape();
271    if parallel && m >= 100 {
272        // Use parallel iteration for larger datasets
273        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        // Sequential for small datasets or when disabled
284        (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/// Compute mean curve (parallel by default for m >= 100).
297#[inline]
298fn compute_mean_curve(data: &FdMatrix) -> Vec<f64> {
299    compute_mean_curve_impl(data, true)
300}
301
302/// Compute interior bounds for edge-skipping (10% on each side).
303///
304/// Used to avoid edge effects in wavelet and other analyses.
305///
306/// # Arguments
307/// * `m` - Total number of points
308///
309/// # Returns
310/// `(interior_start, interior_end)` indices, or `None` if range is invalid
311#[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
324/// Validate interior bounds with minimum span requirement.
325fn valid_interior_bounds(m: usize, min_span: usize) -> Option<(usize, usize)> {
326    interior_bounds(m).filter(|&(s, e)| e > s + min_span)
327}
328
329/// Compute periodogram from data using FFT.
330/// Returns (frequencies, power) where frequencies are in cycles per unit time.
331fn 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; // Sampling frequency
339
340    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    // Compute power spectrum (one-sided)
347    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        // Double power for non-DC and non-Nyquist frequencies (one-sided spectrum)
357        let p = if k > 0 && k < n / 2 { 2.0 * p } else { p };
358        power.push(p);
359    }
360
361    (frequencies, power)
362}
363
364/// Naive O(n·max_lag) autocorrelation for small inputs.
365fn autocorrelation_naive(data: &[f64], max_lag: usize, mean: f64, var: f64) -> Vec<f64> {
366    let n = data.len();
367    let max_lag = max_lag.min(n - 1);
368    let mut acf = Vec::with_capacity(max_lag + 1);
369    for lag in 0..=max_lag {
370        let mut sum = 0.0;
371        for i in 0..(n - lag) {
372            sum += (data[i] - mean) * (data[i + lag] - mean);
373        }
374        acf.push(sum / (n as f64 * var));
375    }
376    acf
377}
378
379/// Compute autocorrelation function up to max_lag.
380///
381/// Uses FFT-based Wiener-Khinchin theorem for n > 64, falling back to
382/// direct computation for small inputs where FFT overhead isn't worthwhile.
383fn autocorrelation(data: &[f64], max_lag: usize) -> Vec<f64> {
384    let n = data.len();
385    if n == 0 {
386        return Vec::new();
387    }
388
389    let mean: f64 = data.iter().sum::<f64>() / n as f64;
390    // ACF convention: divide by n (matches R's acf(); divisor cancels in normalized ratio)
391    let var: f64 = data.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n as f64;
392
393    if var < 1e-15 {
394        return vec![1.0; max_lag.min(n) + 1];
395    }
396
397    let max_lag = max_lag.min(n - 1);
398
399    if n <= 64 {
400        return autocorrelation_naive(data, max_lag, mean, var);
401    }
402
403    // FFT-based ACF via Wiener-Khinchin theorem
404    let fft_len = (2 * n).next_power_of_two();
405
406    let mut planner = FftPlanner::<f64>::new();
407    let fft_forward = planner.plan_fft_forward(fft_len);
408    let fft_inverse = planner.plan_fft_inverse(fft_len);
409
410    // Zero-padded, mean-centered signal
411    let mut buffer: Vec<Complex<f64>> = Vec::with_capacity(fft_len);
412    for &x in data {
413        buffer.push(Complex::new(x - mean, 0.0));
414    }
415    buffer.resize(fft_len, Complex::new(0.0, 0.0));
416
417    // Forward FFT
418    fft_forward.process(&mut buffer);
419
420    // Power spectral density: |F(k)|²
421    for c in buffer.iter_mut() {
422        *c = Complex::new(c.norm_sqr(), 0.0);
423    }
424
425    // Inverse FFT
426    fft_inverse.process(&mut buffer);
427
428    // Normalize and extract lags 0..=max_lag
429    let norm = fft_len as f64 * n as f64 * var;
430    (0..=max_lag).map(|lag| buffer[lag].re / norm).collect()
431}
432
433/// Try to add a peak, respecting minimum distance. Replaces previous peak if closer but higher.
434fn try_add_peak(peaks: &mut Vec<usize>, candidate: usize, signal: &[f64], min_distance: usize) {
435    if let Some(&last) = peaks.last() {
436        if candidate - last >= min_distance {
437            peaks.push(candidate);
438        } else if signal[candidate] > signal[last] {
439            // Safe: peaks is non-empty since last() succeeded
440            *peaks.last_mut().unwrap_or(&mut 0) = candidate;
441        }
442    } else {
443        peaks.push(candidate);
444    }
445}
446
447/// Find peaks in a 1D signal, returning indices.
448pub(crate) fn find_peaks_1d(signal: &[f64], min_distance: usize) -> Vec<usize> {
449    let n = signal.len();
450    if n < 3 {
451        return Vec::new();
452    }
453
454    let mut peaks = Vec::new();
455
456    for i in 1..(n - 1) {
457        if signal[i] > signal[i - 1] && signal[i] > signal[i + 1] {
458            try_add_peak(&mut peaks, i, signal, min_distance);
459        }
460    }
461
462    peaks
463}
464
465/// Compute prominence for a peak (height above surrounding valleys).
466pub(crate) fn compute_prominence(signal: &[f64], peak_idx: usize) -> f64 {
467    let n = signal.len();
468    let peak_val = signal[peak_idx];
469
470    // Find lowest point between peak and boundaries/higher peaks
471    let mut left_min = peak_val;
472    for i in (0..peak_idx).rev() {
473        if signal[i] >= peak_val {
474            break;
475        }
476        left_min = left_min.min(signal[i]);
477    }
478
479    let mut right_min = peak_val;
480    for i in (peak_idx + 1)..n {
481        if signal[i] >= peak_val {
482            break;
483        }
484        right_min = right_min.min(signal[i]);
485    }
486
487    peak_val - left_min.max(right_min)
488}
489
490/// Hilbert transform using FFT to compute analytic signal.
491///
492/// # Arguments
493/// * `signal` - Input real signal
494///
495/// # Returns
496/// Analytic signal as complex vector (real part = original, imaginary = Hilbert transform)
497pub fn hilbert_transform(signal: &[f64]) -> Vec<Complex<f64>> {
498    let n = signal.len();
499    if n == 0 {
500        return Vec::new();
501    }
502
503    let mut planner = FftPlanner::<f64>::new();
504    let fft_forward = planner.plan_fft_forward(n);
505    let fft_inverse = planner.plan_fft_inverse(n);
506
507    // Forward FFT
508    let mut buffer: Vec<Complex<f64>> = signal.iter().map(|&x| Complex::new(x, 0.0)).collect();
509    fft_forward.process(&mut buffer);
510
511    // Create analytic signal in frequency domain
512    // H[0] = 1 (DC), positive freqs = 2, Nyquist = 1 (even only), negative freqs = 0
513    let half = n / 2;
514    if n % 2 == 0 {
515        // Even: bins 1..half-1 get 2, bin half (Nyquist) stays 1, rest 0
516        for k in 1..half {
517            buffer[k] *= 2.0;
518        }
519        for k in (half + 1)..n {
520            buffer[k] = Complex::new(0.0, 0.0);
521        }
522    } else {
523        // Odd: bins 1..=half get 2, rest 0 (no Nyquist bin)
524        for k in 1..=half {
525            buffer[k] *= 2.0;
526        }
527        for k in (half + 1)..n {
528            buffer[k] = Complex::new(0.0, 0.0);
529        }
530    }
531
532    // Inverse FFT
533    fft_inverse.process(&mut buffer);
534
535    // Normalize
536    for c in buffer.iter_mut() {
537        *c /= n as f64;
538    }
539
540    buffer
541}
542
543/// Unwrap phase to remove 2π discontinuities.
544fn unwrap_phase(phase: &[f64]) -> Vec<f64> {
545    if phase.is_empty() {
546        return Vec::new();
547    }
548
549    let mut unwrapped = vec![phase[0]];
550    let mut cumulative_correction = 0.0;
551
552    for i in 1..phase.len() {
553        let diff = phase[i] - phase[i - 1];
554
555        // Check for wraparound
556        if diff > PI {
557            cumulative_correction -= 2.0 * PI;
558        } else if diff < -PI {
559            cumulative_correction += 2.0 * PI;
560        }
561
562        unwrapped.push(phase[i] + cumulative_correction);
563    }
564
565    unwrapped
566}
567
568/// Morlet wavelet function.
569///
570/// The Morlet wavelet is a complex exponential modulated by a Gaussian:
571/// ψ(t) = exp(i * ω₀ * t) * exp(-t² / 2)
572///
573/// where ω₀ is the central frequency (typically 6 for good time-frequency trade-off).
574fn morlet_wavelet(t: f64, omega0: f64) -> Complex<f64> {
575    let gaussian = (-t * t / 2.0).exp();
576    let oscillation = Complex::new((omega0 * t).cos(), (omega0 * t).sin());
577    oscillation * gaussian
578}
579
580/// Continuous Wavelet Transform at a single scale using Morlet wavelet.
581///
582/// Computes the wavelet coefficients at the specified scale (period) for all time points.
583/// Uses convolution in the time domain.
584///
585/// # Arguments
586/// * `signal` - Input signal
587/// * `argvals` - Time points
588/// * `scale` - Scale parameter (related to period)
589/// * `omega0` - Central frequency of Morlet wavelet (default: 6.0)
590///
591/// # Returns
592/// Complex wavelet coefficients at each time point
593#[allow(dead_code)]
594fn cwt_morlet(signal: &[f64], argvals: &[f64], scale: f64, omega0: f64) -> Vec<Complex<f64>> {
595    let n = signal.len();
596    if n == 0 || scale <= 0.0 {
597        return Vec::new();
598    }
599
600    let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
601
602    // Compute wavelet coefficients via convolution
603    // W(a, b) = (1/sqrt(a)) * Σ x[k] * ψ*((t[k] - b) / a) * dt
604    let norm = 1.0 / scale.sqrt();
605
606    (0..n)
607        .map(|b| {
608            let mut sum = Complex::new(0.0, 0.0);
609            for k in 0..n {
610                let t_normalized = (argvals[k] - argvals[b]) / scale;
611                // Only compute within reasonable range (Gaussian decays quickly)
612                if t_normalized.abs() < 6.0 {
613                    let wavelet = morlet_wavelet(t_normalized, omega0);
614                    sum += signal[k] * wavelet.conj();
615                }
616            }
617            sum * norm * dt
618        })
619        .collect()
620}
621
622/// Continuous Wavelet Transform at a single scale using FFT (faster for large signals).
623///
624/// Uses the convolution theorem: CWT = IFFT(FFT(signal) * FFT(wavelet)*)
625fn cwt_morlet_fft(signal: &[f64], argvals: &[f64], scale: f64, omega0: f64) -> Vec<Complex<f64>> {
626    let n = signal.len();
627    if n == 0 || scale <= 0.0 {
628        return Vec::new();
629    }
630
631    let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
632    let norm = 1.0 / scale.sqrt();
633
634    // Compute wavelet in time domain centered at t=0
635    let wavelet_time: Vec<Complex<f64>> = (0..n)
636        .map(|k| {
637            // Center the wavelet
638            let t = if k <= n / 2 {
639                k as f64 * dt / scale
640            } else {
641                (k as f64 - n as f64) * dt / scale
642            };
643            morlet_wavelet(t, omega0) * norm
644        })
645        .collect();
646
647    let mut planner = FftPlanner::<f64>::new();
648    let fft_forward = planner.plan_fft_forward(n);
649    let fft_inverse = planner.plan_fft_inverse(n);
650
651    // FFT of signal
652    let mut signal_fft: Vec<Complex<f64>> = signal.iter().map(|&x| Complex::new(x, 0.0)).collect();
653    fft_forward.process(&mut signal_fft);
654
655    // FFT of wavelet
656    let mut wavelet_fft = wavelet_time;
657    fft_forward.process(&mut wavelet_fft);
658
659    // Multiply in frequency domain (use conjugate of wavelet FFT for correlation)
660    let mut result: Vec<Complex<f64>> = signal_fft
661        .iter()
662        .zip(wavelet_fft.iter())
663        .map(|(s, w)| *s * w.conj())
664        .collect();
665
666    // Inverse FFT
667    fft_inverse.process(&mut result);
668
669    // Normalize and scale by dt
670    for c in result.iter_mut() {
671        *c *= dt / n as f64;
672    }
673
674    result
675}
676
677/// Compute Otsu's threshold for bimodal separation.
678///
679/// Finds the threshold that minimizes within-class variance (or equivalently
680/// maximizes between-class variance).
681fn otsu_threshold(values: &[f64]) -> f64 {
682    if values.is_empty() {
683        return 0.5;
684    }
685
686    // Filter NaN values
687    let valid: Vec<f64> = values.iter().copied().filter(|x| x.is_finite()).collect();
688    if valid.is_empty() {
689        return 0.5;
690    }
691
692    let vmin = valid.iter().cloned().fold(f64::INFINITY, f64::min);
693    let vmax = valid.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
694
695    if (vmax - vmin).abs() < 1e-10 {
696        return (vmin + vmax) / 2.0;
697    }
698
699    let n_bins = 256;
700    let (histogram, hist_min, bin_width) = build_histogram(&valid, n_bins);
701    let (best_bin, _) = find_optimal_threshold_bin(&histogram, valid.len() as f64);
702
703    hist_min + (best_bin as f64 + 0.5) * bin_width
704}
705
706/// Compute linear regression slope (simple OLS).
707fn linear_slope(x: &[f64], y: &[f64]) -> f64 {
708    if x.len() != y.len() || x.len() < 2 {
709        return 0.0;
710    }
711
712    let n = x.len() as f64;
713    let mean_x: f64 = x.iter().sum::<f64>() / n;
714    let mean_y: f64 = y.iter().sum::<f64>() / n;
715
716    let mut num = 0.0;
717    let mut den = 0.0;
718
719    for (&xi, &yi) in x.iter().zip(y.iter()) {
720        num += (xi - mean_x) * (yi - mean_y);
721        den += (xi - mean_x).powi(2);
722    }
723
724    if den.abs() < 1e-15 {
725        0.0
726    } else {
727        num / den
728    }
729}
730
731/// Statistics from amplitude envelope analysis (shared by Hilbert and wavelet methods).
732struct AmplitudeEnvelopeStats {
733    modulation_score: f64,
734    amplitude_trend: f64,
735    has_modulation: bool,
736    modulation_type: ModulationType,
737    _mean_amp: f64,
738    min_amp: f64,
739    max_amp: f64,
740}
741
742/// Analyze an amplitude envelope slice to compute modulation statistics.
743fn analyze_amplitude_envelope(
744    interior_envelope: &[f64],
745    interior_times: &[f64],
746    modulation_threshold: f64,
747) -> AmplitudeEnvelopeStats {
748    let n_interior = interior_envelope.len() as f64;
749
750    let mean_amp = interior_envelope.iter().sum::<f64>() / n_interior;
751    let min_amp = interior_envelope
752        .iter()
753        .cloned()
754        .fold(f64::INFINITY, f64::min);
755    let max_amp = interior_envelope
756        .iter()
757        .cloned()
758        .fold(f64::NEG_INFINITY, f64::max);
759
760    let variance = interior_envelope
761        .iter()
762        .map(|&a| (a - mean_amp).powi(2))
763        .sum::<f64>()
764        / n_interior;
765    let std_amp = variance.sqrt();
766    let modulation_score = if mean_amp > 1e-10 {
767        std_amp / mean_amp
768    } else {
769        0.0
770    };
771
772    let t_mean = interior_times.iter().sum::<f64>() / n_interior;
773    let mut cov_ta = 0.0;
774    let mut var_t = 0.0;
775    for (&t, &a) in interior_times.iter().zip(interior_envelope.iter()) {
776        cov_ta += (t - t_mean) * (a - mean_amp);
777        var_t += (t - t_mean).powi(2);
778    }
779    let slope = if var_t > 1e-10 { cov_ta / var_t } else { 0.0 };
780
781    let time_span = interior_times.last().unwrap_or(&1.0) - interior_times.first().unwrap_or(&0.0);
782    let amplitude_trend = if mean_amp > 1e-10 && time_span > 1e-10 {
783        (slope * time_span / mean_amp).clamp(-1.0, 1.0)
784    } else {
785        0.0
786    };
787
788    let has_modulation = modulation_score > modulation_threshold;
789    let modulation_type = if !has_modulation {
790        ModulationType::Stable
791    } else if amplitude_trend > 0.3 {
792        ModulationType::Emerging
793    } else if amplitude_trend < -0.3 {
794        ModulationType::Fading
795    } else {
796        ModulationType::Oscillating
797    };
798
799    AmplitudeEnvelopeStats {
800        modulation_score,
801        amplitude_trend,
802        has_modulation,
803        modulation_type,
804        _mean_amp: mean_amp,
805        min_amp,
806        max_amp,
807    }
808}
809
810/// Fit a sinusoid at the given period, subtract it from residual, and return (a, b, amplitude, phase).
811fn fit_and_subtract_sinusoid(
812    residual: &mut [f64],
813    argvals: &[f64],
814    period: f64,
815) -> (f64, f64, f64, f64) {
816    let m = residual.len();
817    let omega = 2.0 * PI / period;
818    let mut cos_sum = 0.0;
819    let mut sin_sum = 0.0;
820
821    for (j, &t) in argvals.iter().enumerate() {
822        cos_sum += residual[j] * (omega * t).cos();
823        sin_sum += residual[j] * (omega * t).sin();
824    }
825
826    let a = 2.0 * cos_sum / m as f64;
827    let b = 2.0 * sin_sum / m as f64;
828    let amplitude = (a * a + b * b).sqrt();
829    let phase = b.atan2(a);
830
831    for (j, &t) in argvals.iter().enumerate() {
832        residual[j] -= a * (omega * t).cos() + b * (omega * t).sin();
833    }
834
835    (a, b, amplitude, phase)
836}
837
838/// Validate a single SAZED component: returns Some(period) if it passes range and confidence checks.
839fn validate_sazed_component(
840    period: f64,
841    confidence: f64,
842    min_period: f64,
843    max_period: f64,
844    threshold: f64,
845) -> Option<f64> {
846    if period.is_finite() && period > min_period && period < max_period && confidence > threshold {
847        Some(period)
848    } else {
849        None
850    }
851}
852
853/// Count how many periods agree with a reference within tolerance, returning (count, sum).
854fn count_agreeing_periods(periods: &[f64], reference: f64, tolerance: f64) -> (usize, f64) {
855    let mut count = 0;
856    let mut sum = 0.0;
857    for &p in periods {
858        let rel_diff = (reference - p).abs() / reference.max(p);
859        if rel_diff <= tolerance {
860            count += 1;
861            sum += p;
862        }
863    }
864    (count, sum)
865}
866
867/// Find the end of the initial ACF descent (first negative or first uptick).
868fn find_acf_descent_end(acf: &[f64]) -> usize {
869    for i in 1..acf.len() {
870        if acf[i] < 0.0 {
871            return i;
872        }
873        if i > 1 && acf[i] > acf[i - 1] {
874            return i - 1;
875        }
876    }
877    1
878}
879
880/// Find the first ACF peak after initial descent. Returns Some((lag, acf_value)).
881fn find_first_acf_peak(acf: &[f64]) -> Option<(usize, f64)> {
882    if acf.len() < 4 {
883        return None;
884    }
885
886    let min_search_start = find_acf_descent_end(acf);
887    let peaks = find_peaks_1d(&acf[min_search_start..], 1);
888    if peaks.is_empty() {
889        return None;
890    }
891
892    let peak_lag = peaks[0] + min_search_start;
893    Some((peak_lag, acf[peak_lag].max(0.0)))
894}
895
896/// Compute per-cycle seasonal strengths and identify weak seasons.
897fn compute_cycle_strengths(
898    data: &FdMatrix,
899    argvals: &[f64],
900    period: f64,
901    strength_thresh: f64,
902) -> (Vec<f64>, Vec<usize>) {
903    let (n, m) = data.shape();
904    let t_start = argvals[0];
905    let t_end = argvals[m - 1];
906    let n_cycles = ((t_end - t_start) / period).floor() as usize;
907
908    let mut cycle_strengths = Vec::with_capacity(n_cycles);
909    let mut weak_seasons = Vec::new();
910
911    for cycle in 0..n_cycles {
912        let cycle_start = t_start + cycle as f64 * period;
913        let cycle_end = cycle_start + period;
914
915        let start_idx = argvals.iter().position(|&t| t >= cycle_start).unwrap_or(0);
916        let end_idx = argvals.iter().position(|&t| t > cycle_end).unwrap_or(m);
917
918        let cycle_m = end_idx - start_idx;
919        if cycle_m < 4 {
920            cycle_strengths.push(f64::NAN);
921            continue;
922        }
923
924        let cycle_data: Vec<f64> = (start_idx..end_idx)
925            .flat_map(|j| (0..n).map(move |i| data[(i, j)]))
926            .collect();
927        let cycle_mat = FdMatrix::from_column_major(cycle_data, n, cycle_m).unwrap();
928        let cycle_argvals: Vec<f64> = argvals[start_idx..end_idx].to_vec();
929
930        let strength = seasonal_strength_variance(&cycle_mat, &cycle_argvals, period, 3);
931
932        cycle_strengths.push(strength);
933        if strength < strength_thresh {
934            weak_seasons.push(cycle);
935        }
936    }
937
938    (cycle_strengths, weak_seasons)
939}
940
941/// Build a histogram from valid values. Returns (histogram, min_val, bin_width).
942fn build_histogram(valid: &[f64], n_bins: usize) -> (Vec<usize>, f64, f64) {
943    let min_val = valid.iter().cloned().fold(f64::INFINITY, f64::min);
944    let max_val = valid.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
945    let bin_width = (max_val - min_val) / n_bins as f64;
946    let mut histogram = vec![0usize; n_bins];
947    for &v in valid {
948        let bin = ((v - min_val) / bin_width).min(n_bins as f64 - 1.0) as usize;
949        histogram[bin] += 1;
950    }
951    (histogram, min_val, bin_width)
952}
953
954/// Find the optimal threshold bin using Otsu's between-class variance. Returns (best_bin, best_variance).
955fn find_optimal_threshold_bin(histogram: &[usize], total: f64) -> (usize, f64) {
956    let n_bins = histogram.len();
957    let mut sum_total = 0.0;
958    for (i, &count) in histogram.iter().enumerate() {
959        sum_total += i as f64 * count as f64;
960    }
961
962    let mut best_bin = 0;
963    let mut best_variance = 0.0;
964    let mut sum_b = 0.0;
965    let mut weight_b = 0.0;
966
967    for t in 0..n_bins {
968        weight_b += histogram[t] as f64;
969        if weight_b == 0.0 {
970            continue;
971        }
972        let weight_f = total - weight_b;
973        if weight_f == 0.0 {
974            break;
975        }
976        sum_b += t as f64 * histogram[t] as f64;
977        let mean_b = sum_b / weight_b;
978        let mean_f = (sum_total - sum_b) / weight_f;
979        let variance = weight_b * weight_f * (mean_b - mean_f).powi(2);
980        if variance > best_variance {
981            best_variance = variance;
982            best_bin = t;
983        }
984    }
985
986    (best_bin, best_variance)
987}
988
989/// Sum power at harmonics of a fundamental frequency within tolerance.
990fn sum_harmonic_power(
991    frequencies: &[f64],
992    power: &[f64],
993    fundamental_freq: f64,
994    tolerance: f64,
995) -> (f64, f64) {
996    let mut seasonal_power = 0.0;
997    let mut total_power = 0.0;
998
999    for (i, (&freq, &p)) in frequencies.iter().zip(power.iter()).enumerate() {
1000        if i == 0 {
1001            continue;
1002        }
1003        total_power += p;
1004        let ratio = freq / fundamental_freq;
1005        let nearest_harmonic = ratio.round();
1006        if (ratio - nearest_harmonic).abs() < tolerance && nearest_harmonic >= 1.0 {
1007            seasonal_power += p;
1008        }
1009    }
1010
1011    (seasonal_power, total_power)
1012}
1013
1014/// Return the new seasonal state if `ss` represents a valid threshold crossing,
1015/// or `None` if the index should be skipped (NaN, no change, or too close to the
1016/// previous change point).
1017fn crossing_direction(
1018    ss: f64,
1019    threshold: f64,
1020    in_seasonal: bool,
1021    i: usize,
1022    last_change_idx: Option<usize>,
1023    min_dur_points: usize,
1024) -> Option<bool> {
1025    if ss.is_nan() {
1026        return None;
1027    }
1028    let now_seasonal = ss > threshold;
1029    if now_seasonal == in_seasonal {
1030        return None;
1031    }
1032    if last_change_idx.is_some_and(|last_idx| i - last_idx < min_dur_points) {
1033        return None;
1034    }
1035    Some(now_seasonal)
1036}
1037
1038/// Build a `ChangePoint` for a threshold crossing at index `i`.
1039fn build_change_point(
1040    i: usize,
1041    ss: f64,
1042    now_seasonal: bool,
1043    strength_curve: &[f64],
1044    argvals: &[f64],
1045) -> ChangePoint {
1046    let change_type = if now_seasonal {
1047        ChangeType::Onset
1048    } else {
1049        ChangeType::Cessation
1050    };
1051    let strength_before = if i > 0 && !strength_curve[i - 1].is_nan() {
1052        strength_curve[i - 1]
1053    } else {
1054        ss
1055    };
1056    ChangePoint {
1057        time: argvals[i],
1058        change_type,
1059        strength_before,
1060        strength_after: ss,
1061    }
1062}
1063
1064/// Detect threshold crossings in a strength curve, returning change points.
1065fn detect_threshold_crossings(
1066    strength_curve: &[f64],
1067    argvals: &[f64],
1068    threshold: f64,
1069    min_dur_points: usize,
1070) -> Vec<ChangePoint> {
1071    let mut change_points = Vec::new();
1072    let mut in_seasonal = strength_curve[0] > threshold;
1073    let mut last_change_idx: Option<usize> = None;
1074
1075    for (i, &ss) in strength_curve.iter().enumerate().skip(1) {
1076        let Some(now_seasonal) = crossing_direction(
1077            ss,
1078            threshold,
1079            in_seasonal,
1080            i,
1081            last_change_idx,
1082            min_dur_points,
1083        ) else {
1084            continue;
1085        };
1086
1087        change_points.push(build_change_point(
1088            i,
1089            ss,
1090            now_seasonal,
1091            strength_curve,
1092            argvals,
1093        ));
1094
1095        in_seasonal = now_seasonal;
1096        last_change_idx = Some(i);
1097    }
1098
1099    change_points
1100}
1101
1102// ============================================================================
1103// Period Estimation
1104// ============================================================================
1105
1106/// Estimate period using FFT periodogram.
1107///
1108/// Finds the dominant frequency in the periodogram (excluding DC) and
1109/// returns the corresponding period.
1110///
1111/// # Arguments
1112/// * `data` - Column-major matrix (n x m)
1113/// * `n` - Number of samples
1114/// * `m` - Number of evaluation points
1115/// * `argvals` - Evaluation points (time values)
1116///
1117/// # Returns
1118/// Period estimate with confidence measure
1119pub fn estimate_period_fft(data: &FdMatrix, argvals: &[f64]) -> PeriodEstimate {
1120    let (n, m) = data.shape();
1121    if n == 0 || m < 4 || argvals.len() != m {
1122        return PeriodEstimate {
1123            period: f64::NAN,
1124            frequency: f64::NAN,
1125            power: 0.0,
1126            confidence: 0.0,
1127        };
1128    }
1129
1130    // Compute mean curve first
1131    let mean_curve = compute_mean_curve(data);
1132
1133    let (frequencies, power) = periodogram(&mean_curve, argvals);
1134
1135    if frequencies.len() < 2 {
1136        return PeriodEstimate {
1137            period: f64::NAN,
1138            frequency: f64::NAN,
1139            power: 0.0,
1140            confidence: 0.0,
1141        };
1142    }
1143
1144    // Find peak in power spectrum (skip DC component at index 0)
1145    let mut max_power = 0.0;
1146    let mut max_idx = 1;
1147    for (i, &p) in power.iter().enumerate().skip(1) {
1148        if p > max_power {
1149            max_power = p;
1150            max_idx = i;
1151        }
1152    }
1153
1154    let dominant_freq = frequencies[max_idx];
1155    let period = if dominant_freq > 1e-15 {
1156        1.0 / dominant_freq
1157    } else {
1158        f64::INFINITY
1159    };
1160
1161    // Confidence: ratio of peak power to mean power (excluding DC)
1162    let mean_power: f64 = power.iter().skip(1).sum::<f64>() / (power.len() - 1) as f64;
1163    let confidence = if mean_power > 1e-15 {
1164        max_power / mean_power
1165    } else {
1166        0.0
1167    };
1168
1169    PeriodEstimate {
1170        period,
1171        frequency: dominant_freq,
1172        power: max_power,
1173        confidence,
1174    }
1175}
1176
1177/// Estimate period using autocorrelation function.
1178///
1179/// Finds the first significant peak in the ACF after lag 0.
1180///
1181/// # Arguments
1182/// * `data` - Column-major matrix (n x m)
1183/// * `n` - Number of samples
1184/// * `m` - Number of evaluation points
1185/// * `argvals` - Evaluation points
1186/// * `max_lag` - Maximum lag to consider (in number of points)
1187pub fn estimate_period_acf(
1188    data: &[f64],
1189    n: usize,
1190    m: usize,
1191    argvals: &[f64],
1192    max_lag: usize,
1193) -> PeriodEstimate {
1194    if n == 0 || m < 4 || argvals.len() != m {
1195        return PeriodEstimate {
1196            period: f64::NAN,
1197            frequency: f64::NAN,
1198            power: 0.0,
1199            confidence: 0.0,
1200        };
1201    }
1202
1203    // Compute mean curve
1204    let mat = FdMatrix::from_slice(data, n, m).unwrap();
1205    let mean_curve = compute_mean_curve(&mat);
1206
1207    let acf = autocorrelation(&mean_curve, max_lag);
1208
1209    // Find first peak after lag 0 (skip first few lags to avoid finding lag 0)
1210    let min_lag = 2;
1211    let peaks = find_peaks_1d(&acf[min_lag..], 1);
1212
1213    if peaks.is_empty() {
1214        return PeriodEstimate {
1215            period: f64::NAN,
1216            frequency: f64::NAN,
1217            power: 0.0,
1218            confidence: 0.0,
1219        };
1220    }
1221
1222    let peak_lag = peaks[0] + min_lag;
1223    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1224    let period = peak_lag as f64 * dt;
1225    let frequency = if period > 1e-15 { 1.0 / period } else { 0.0 };
1226
1227    PeriodEstimate {
1228        period,
1229        frequency,
1230        power: acf[peak_lag],
1231        confidence: acf[peak_lag].abs(),
1232    }
1233}
1234
1235/// Estimate period via Fourier regression grid search.
1236///
1237/// Tests multiple candidate periods and selects the one that minimizes
1238/// the reconstruction error (similar to GCV).
1239///
1240/// # Arguments
1241/// * `data` - Column-major matrix (n x m)
1242/// * `n` - Number of samples
1243/// * `m` - Number of evaluation points
1244/// * `argvals` - Evaluation points
1245/// * `period_min` - Minimum period to test
1246/// * `period_max` - Maximum period to test
1247/// * `n_candidates` - Number of candidate periods to test
1248/// * `n_harmonics` - Number of Fourier harmonics to use
1249pub fn estimate_period_regression(
1250    data: &[f64],
1251    n: usize,
1252    m: usize,
1253    argvals: &[f64],
1254    period_min: f64,
1255    period_max: f64,
1256    n_candidates: usize,
1257    n_harmonics: usize,
1258) -> PeriodEstimate {
1259    if n == 0 || m < 4 || argvals.len() != m || period_min >= period_max || n_candidates < 2 {
1260        return PeriodEstimate {
1261            period: f64::NAN,
1262            frequency: f64::NAN,
1263            power: 0.0,
1264            confidence: 0.0,
1265        };
1266    }
1267
1268    // Compute mean curve
1269    let mat = FdMatrix::from_slice(data, n, m).unwrap();
1270    let mean_curve = compute_mean_curve(&mat);
1271
1272    let nbasis = 1 + 2 * n_harmonics;
1273
1274    // Grid search over candidate periods
1275    let candidates: Vec<f64> = (0..n_candidates)
1276        .map(|i| period_min + (period_max - period_min) * i as f64 / (n_candidates - 1) as f64)
1277        .collect();
1278
1279    let results: Vec<(f64, f64)> = slice_maybe_parallel!(candidates)
1280        .map(|&period| {
1281            let basis = fourier_basis_with_period(argvals, nbasis, period);
1282
1283            // Simple least squares fit
1284            let mut rss = 0.0;
1285            for j in 0..m {
1286                let mut fitted = 0.0;
1287                // Simple: use mean of basis function times data as rough fit
1288                for k in 0..nbasis {
1289                    let b_val = basis[j + k * m];
1290                    let coef: f64 = (0..m)
1291                        .map(|l| mean_curve[l] * basis[l + k * m])
1292                        .sum::<f64>()
1293                        / (0..m)
1294                            .map(|l| basis[l + k * m].powi(2))
1295                            .sum::<f64>()
1296                            .max(1e-15);
1297                    fitted += coef * b_val;
1298                }
1299                let resid = mean_curve[j] - fitted;
1300                rss += resid * resid;
1301            }
1302
1303            (period, rss)
1304        })
1305        .collect();
1306
1307    // Find period with minimum RSS
1308    let (best_period, min_rss) = results
1309        .iter()
1310        .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
1311        .cloned()
1312        .unwrap_or((f64::NAN, f64::INFINITY));
1313
1314    // Confidence based on how much better the best is vs average
1315    let mean_rss: f64 = results.iter().map(|(_, r)| r).sum::<f64>() / results.len() as f64;
1316    let confidence = if min_rss > 1e-15 {
1317        (mean_rss / min_rss).min(10.0)
1318    } else {
1319        10.0
1320    };
1321
1322    PeriodEstimate {
1323        period: best_period,
1324        frequency: if best_period > 1e-15 {
1325            1.0 / best_period
1326        } else {
1327            0.0
1328        },
1329        power: 1.0 - min_rss / mean_rss,
1330        confidence,
1331    }
1332}
1333
1334/// Detect multiple concurrent periodicities using iterative residual subtraction.
1335///
1336/// This function iteratively:
1337/// 1. Estimates the dominant period using FFT
1338/// 2. Checks both FFT confidence and seasonal strength as stopping criteria
1339/// 3. Computes the amplitude and phase of the sinusoidal component
1340/// 4. Subtracts the fitted sinusoid from the signal
1341/// 5. Repeats on the residual until stopping criteria are met
1342///
1343/// # Arguments
1344/// * `data` - Column-major matrix (n x m)
1345/// * `n` - Number of samples
1346/// * `m` - Number of evaluation points
1347/// * `argvals` - Evaluation points
1348/// * `max_periods` - Maximum number of periods to detect
1349/// * `min_confidence` - Minimum FFT confidence to continue (default: 0.4)
1350/// * `min_strength` - Minimum seasonal strength to continue (default: 0.15)
1351///
1352/// # Returns
1353/// Vector of detected periods with their properties
1354pub fn detect_multiple_periods(
1355    data: &[f64],
1356    n: usize,
1357    m: usize,
1358    argvals: &[f64],
1359    max_periods: usize,
1360    min_confidence: f64,
1361    min_strength: f64,
1362) -> Vec<DetectedPeriod> {
1363    if n == 0 || m < 4 || argvals.len() != m || max_periods == 0 {
1364        return Vec::new();
1365    }
1366
1367    // Compute mean curve
1368    let mat = FdMatrix::from_slice(data, n, m).unwrap();
1369    let mean_curve = compute_mean_curve(&mat);
1370
1371    let mut residual = mean_curve.clone();
1372    let mut detected = Vec::with_capacity(max_periods);
1373
1374    for iteration in 1..=max_periods {
1375        match evaluate_next_period(
1376            &mut residual,
1377            m,
1378            argvals,
1379            min_confidence,
1380            min_strength,
1381            iteration,
1382        ) {
1383            Some(period) => detected.push(period),
1384            None => break,
1385        }
1386    }
1387
1388    detected
1389}
1390
1391/// Evaluate and extract the next dominant period from the residual signal.
1392///
1393/// Returns `None` if no significant period is found (signals iteration should stop).
1394fn evaluate_next_period(
1395    residual: &mut [f64],
1396    m: usize,
1397    argvals: &[f64],
1398    min_confidence: f64,
1399    min_strength: f64,
1400    iteration: usize,
1401) -> Option<DetectedPeriod> {
1402    let residual_mat = FdMatrix::from_slice(residual, 1, m).unwrap();
1403    let est = estimate_period_fft(&residual_mat, argvals);
1404
1405    if est.confidence < min_confidence || est.period.is_nan() || est.period.is_infinite() {
1406        return None;
1407    }
1408
1409    let strength = seasonal_strength_variance(&residual_mat, argvals, est.period, 3);
1410    if strength < min_strength || strength.is_nan() {
1411        return None;
1412    }
1413
1414    let (_a, _b, amplitude, phase) = fit_and_subtract_sinusoid(residual, argvals, est.period);
1415
1416    Some(DetectedPeriod {
1417        period: est.period,
1418        confidence: est.confidence,
1419        strength,
1420        amplitude,
1421        phase,
1422        iteration,
1423    })
1424}
1425
1426// ============================================================================
1427// Peak Detection
1428// ============================================================================
1429
1430/// Optionally smooth data using Fourier basis before peak detection.
1431fn smooth_for_peaks(
1432    data: &FdMatrix,
1433    argvals: &[f64],
1434    smooth_first: bool,
1435    smooth_nbasis: Option<usize>,
1436) -> Vec<f64> {
1437    if !smooth_first {
1438        return data.as_slice().to_vec();
1439    }
1440    let nbasis = smooth_nbasis
1441        .unwrap_or_else(|| crate::basis::select_fourier_nbasis_gcv(data, argvals, 5, 25));
1442    if let Some(result) = crate::basis::fourier_fit_1d(data, argvals, nbasis) {
1443        result.fitted.into_vec()
1444    } else {
1445        data.as_slice().to_vec()
1446    }
1447}
1448
1449/// Detect peaks in a single curve using derivative zero-crossings.
1450fn detect_peaks_single_curve(
1451    curve: &[f64],
1452    d1: &[f64],
1453    argvals: &[f64],
1454    min_dist_points: usize,
1455    min_prominence: Option<f64>,
1456    data_range: f64,
1457) -> (Vec<Peak>, Vec<f64>) {
1458    let m = curve.len();
1459    let mut peak_indices = Vec::new();
1460    for j in 1..m {
1461        if d1[j - 1] > 0.0 && d1[j] <= 0.0 {
1462            let idx = if (d1[j - 1] - d1[j]).abs() > 1e-15 {
1463                j - 1
1464            } else {
1465                j
1466            };
1467
1468            if peak_indices.is_empty()
1469                || idx - peak_indices[peak_indices.len() - 1] >= min_dist_points
1470            {
1471                peak_indices.push(idx);
1472            }
1473        }
1474    }
1475
1476    let mut peaks: Vec<Peak> = peak_indices
1477        .iter()
1478        .map(|&idx| {
1479            let prominence = compute_prominence(curve, idx) / data_range;
1480            Peak {
1481                time: argvals[idx],
1482                value: curve[idx],
1483                prominence,
1484            }
1485        })
1486        .collect();
1487
1488    if let Some(min_prom) = min_prominence {
1489        peaks.retain(|p| p.prominence >= min_prom);
1490    }
1491
1492    let distances: Vec<f64> = peaks.windows(2).map(|w| w[1].time - w[0].time).collect();
1493
1494    (peaks, distances)
1495}
1496
1497/// Detect peaks in functional data.
1498///
1499/// Uses derivative zero-crossings to find local maxima, with optional
1500/// Fourier basis smoothing and filtering by minimum distance and prominence.
1501///
1502/// # Arguments
1503/// * `data` - Column-major matrix (n x m)
1504/// * `n` - Number of samples
1505/// * `m` - Number of evaluation points
1506/// * `argvals` - Evaluation points
1507/// * `min_distance` - Minimum time between peaks (None = no constraint)
1508/// * `min_prominence` - Minimum prominence (0-1 scale, None = no filter)
1509/// * `smooth_first` - Whether to smooth data before peak detection using Fourier basis
1510/// * `smooth_nbasis` - Number of Fourier basis functions. If None and smooth_first=true,
1511///   uses GCV to automatically select optimal nbasis (range 5-25).
1512pub fn detect_peaks(
1513    data: &FdMatrix,
1514    argvals: &[f64],
1515    min_distance: Option<f64>,
1516    min_prominence: Option<f64>,
1517    smooth_first: bool,
1518    smooth_nbasis: Option<usize>,
1519) -> PeakDetectionResult {
1520    let (n, m) = data.shape();
1521    if n == 0 || m < 3 || argvals.len() != m {
1522        return PeakDetectionResult {
1523            peaks: Vec::new(),
1524            inter_peak_distances: Vec::new(),
1525            mean_period: f64::NAN,
1526        };
1527    }
1528
1529    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1530    let min_dist_points = min_distance.map(|d| (d / dt).round() as usize).unwrap_or(1);
1531
1532    let work_data = smooth_for_peaks(data, argvals, smooth_first, smooth_nbasis);
1533
1534    // Compute first derivative
1535    let work_mat = FdMatrix::from_column_major(work_data.clone(), n, m).unwrap();
1536    let deriv1 = deriv_1d(&work_mat, argvals, 1).into_vec();
1537
1538    // Compute data range for prominence normalization
1539    let data_range: f64 = {
1540        let mut min_val = f64::INFINITY;
1541        let mut max_val = f64::NEG_INFINITY;
1542        for &v in work_data.iter() {
1543            min_val = min_val.min(v);
1544            max_val = max_val.max(v);
1545        }
1546        (max_val - min_val).max(1e-15)
1547    };
1548
1549    // Find peaks for each sample
1550    let results: Vec<(Vec<Peak>, Vec<f64>)> = iter_maybe_parallel!(0..n)
1551        .map(|i| {
1552            let curve: Vec<f64> = (0..m).map(|j| work_data[i + j * n]).collect();
1553            let d1: Vec<f64> = (0..m).map(|j| deriv1[i + j * n]).collect();
1554            detect_peaks_single_curve(
1555                &curve,
1556                &d1,
1557                argvals,
1558                min_dist_points,
1559                min_prominence,
1560                data_range,
1561            )
1562        })
1563        .collect();
1564
1565    let peaks: Vec<Vec<Peak>> = results.iter().map(|(p, _)| p.clone()).collect();
1566    let inter_peak_distances: Vec<Vec<f64>> = results.iter().map(|(_, d)| d.clone()).collect();
1567
1568    // Compute mean period from all inter-peak distances
1569    let all_distances: Vec<f64> = inter_peak_distances.iter().flatten().cloned().collect();
1570    let mean_period = if all_distances.is_empty() {
1571        f64::NAN
1572    } else {
1573        all_distances.iter().sum::<f64>() / all_distances.len() as f64
1574    };
1575
1576    PeakDetectionResult {
1577        peaks,
1578        inter_peak_distances,
1579        mean_period,
1580    }
1581}
1582
1583// ============================================================================
1584// Seasonal Strength
1585// ============================================================================
1586
1587/// Measure seasonal strength using variance decomposition.
1588///
1589/// Computes SS = Var(seasonal_component) / Var(total) where the seasonal
1590/// component is extracted using Fourier basis.
1591///
1592/// # Arguments
1593/// * `data` - Column-major matrix (n x m)
1594/// * `n` - Number of samples
1595/// * `m` - Number of evaluation points
1596/// * `argvals` - Evaluation points
1597/// * `period` - Seasonal period
1598/// * `n_harmonics` - Number of Fourier harmonics to use
1599///
1600/// # Returns
1601/// Seasonal strength in [0, 1]
1602pub fn seasonal_strength_variance(
1603    data: &FdMatrix,
1604    argvals: &[f64],
1605    period: f64,
1606    n_harmonics: usize,
1607) -> f64 {
1608    let (n, m) = data.shape();
1609    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1610        return f64::NAN;
1611    }
1612
1613    // Compute mean curve
1614    let mean_curve = compute_mean_curve(data);
1615
1616    // Total variance
1617    let global_mean: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1618    let total_var: f64 = mean_curve
1619        .iter()
1620        .map(|&x| (x - global_mean).powi(2))
1621        .sum::<f64>()
1622        / m as f64;
1623
1624    if total_var < 1e-15 {
1625        return 0.0;
1626    }
1627
1628    // Fit Fourier basis to extract seasonal component
1629    let nbasis = 1 + 2 * n_harmonics;
1630    let basis = fourier_basis_with_period(argvals, nbasis, period);
1631
1632    // Project data onto basis (simple least squares for mean curve)
1633    let mut seasonal = vec![0.0; m];
1634    for k in 1..nbasis {
1635        // Skip DC component
1636        let b_sum: f64 = (0..m).map(|j| basis[j + k * m].powi(2)).sum();
1637        if b_sum > 1e-15 {
1638            let coef: f64 = (0..m)
1639                .map(|j| mean_curve[j] * basis[j + k * m])
1640                .sum::<f64>()
1641                / b_sum;
1642            for j in 0..m {
1643                seasonal[j] += coef * basis[j + k * m];
1644            }
1645        }
1646    }
1647
1648    // Seasonal variance
1649    let seasonal_mean: f64 = seasonal.iter().sum::<f64>() / m as f64;
1650    let seasonal_var: f64 = seasonal
1651        .iter()
1652        .map(|&x| (x - seasonal_mean).powi(2))
1653        .sum::<f64>()
1654        / m as f64;
1655
1656    (seasonal_var / total_var).min(1.0)
1657}
1658
1659/// Measure seasonal strength using spectral method.
1660///
1661/// Computes SS = power at seasonal frequencies / total power.
1662///
1663/// # Arguments
1664/// * `data` - Column-major matrix (n x m)
1665/// * `n` - Number of samples
1666/// * `m` - Number of evaluation points
1667/// * `argvals` - Evaluation points
1668/// * `period` - Seasonal period
1669pub fn seasonal_strength_spectral(data: &FdMatrix, argvals: &[f64], period: f64) -> f64 {
1670    let (n, m) = data.shape();
1671    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1672        return f64::NAN;
1673    }
1674
1675    // Compute mean curve
1676    let mean_curve = compute_mean_curve(data);
1677
1678    let (frequencies, power) = periodogram(&mean_curve, argvals);
1679
1680    if frequencies.len() < 2 {
1681        return f64::NAN;
1682    }
1683
1684    let fundamental_freq = 1.0 / period;
1685    let (seasonal_power, total_power) =
1686        sum_harmonic_power(&frequencies, &power, fundamental_freq, 0.1);
1687
1688    if total_power < 1e-15 {
1689        return 0.0;
1690    }
1691
1692    (seasonal_power / total_power).min(1.0)
1693}
1694
1695/// Compute seasonal strength using Morlet wavelet power at the target period.
1696///
1697/// This method uses the Continuous Wavelet Transform (CWT) with a Morlet wavelet
1698/// to measure power at the specified seasonal period. Unlike spectral methods,
1699/// wavelets provide time-localized frequency information.
1700///
1701/// # Arguments
1702/// * `data` - Column-major matrix (n x m)
1703/// * `n` - Number of samples
1704/// * `m` - Number of evaluation points
1705/// * `argvals` - Evaluation points
1706/// * `period` - Seasonal period in argvals units
1707///
1708/// # Returns
1709/// Seasonal strength as ratio of wavelet power to total variance (0 to 1)
1710///
1711/// # Notes
1712/// - Uses Morlet wavelet with ω₀ = 6 (standard choice)
1713/// - Scale is computed as: scale = period * ω₀ / (2π)
1714/// - Strength is computed over the interior 80% of the signal to avoid edge effects
1715pub fn seasonal_strength_wavelet(data: &FdMatrix, argvals: &[f64], period: f64) -> f64 {
1716    let (n, m) = data.shape();
1717    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1718        return f64::NAN;
1719    }
1720
1721    // Compute mean curve
1722    let mean_curve = compute_mean_curve(data);
1723
1724    // Remove DC component
1725    let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1726    let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
1727
1728    // Compute total variance
1729    let total_variance: f64 = detrended.iter().map(|&x| x * x).sum::<f64>() / m as f64;
1730
1731    if total_variance < 1e-15 {
1732        return 0.0;
1733    }
1734
1735    // Compute wavelet transform at the seasonal scale
1736    let omega0 = 6.0;
1737    let scale = period * omega0 / (2.0 * PI);
1738    let wavelet_coeffs = cwt_morlet_fft(&detrended, argvals, scale, omega0);
1739
1740    if wavelet_coeffs.is_empty() {
1741        return f64::NAN;
1742    }
1743
1744    // Compute wavelet power, skipping edges (10% on each side)
1745    let (interior_start, interior_end) = match interior_bounds(m) {
1746        Some(bounds) => bounds,
1747        None => return f64::NAN,
1748    };
1749
1750    let wavelet_power: f64 = wavelet_coeffs[interior_start..interior_end]
1751        .iter()
1752        .map(|c| c.norm_sqr())
1753        .sum::<f64>()
1754        / (interior_end - interior_start) as f64;
1755
1756    // Return ratio of wavelet power to total variance
1757    // Normalize so that a pure sine at the target period gives ~1.0
1758    (wavelet_power / total_variance).sqrt().min(1.0)
1759}
1760
1761/// Compute time-varying seasonal strength using sliding windows.
1762///
1763/// # Arguments
1764/// * `data` - Column-major matrix (n x m)
1765/// * `n` - Number of samples
1766/// * `m` - Number of evaluation points
1767/// * `argvals` - Evaluation points
1768/// * `period` - Seasonal period
1769/// * `window_size` - Window width (recommended: 2 * period)
1770/// * `method` - Method for computing strength (Variance or Spectral)
1771///
1772/// # Returns
1773/// Seasonal strength at each time point
1774pub fn seasonal_strength_windowed(
1775    data: &FdMatrix,
1776    argvals: &[f64],
1777    period: f64,
1778    window_size: f64,
1779    method: StrengthMethod,
1780) -> Vec<f64> {
1781    let (n, m) = data.shape();
1782    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 || window_size <= 0.0 {
1783        return Vec::new();
1784    }
1785
1786    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1787    let half_window_points = ((window_size / 2.0) / dt).round() as usize;
1788
1789    // Compute mean curve
1790    let mean_curve = compute_mean_curve(data);
1791
1792    iter_maybe_parallel!(0..m)
1793        .map(|center| {
1794            let start = center.saturating_sub(half_window_points);
1795            let end = (center + half_window_points + 1).min(m);
1796            let window_m = end - start;
1797
1798            if window_m < 4 {
1799                return f64::NAN;
1800            }
1801
1802            let window_data: Vec<f64> = mean_curve[start..end].to_vec();
1803            let window_argvals: Vec<f64> = argvals[start..end].to_vec();
1804
1805            // Create single-sample FdMatrix for the strength functions
1806            let single_mat = FdMatrix::from_column_major(window_data, 1, window_m).unwrap();
1807
1808            match method {
1809                StrengthMethod::Variance => {
1810                    seasonal_strength_variance(&single_mat, &window_argvals, period, 3)
1811                }
1812                StrengthMethod::Spectral => {
1813                    seasonal_strength_spectral(&single_mat, &window_argvals, period)
1814                }
1815            }
1816        })
1817        .collect()
1818}
1819
1820// ============================================================================
1821// Seasonality Change Detection
1822// ============================================================================
1823
1824/// Detect changes in seasonality.
1825///
1826/// Monitors time-varying seasonal strength and detects threshold crossings
1827/// that indicate onset or cessation of seasonality.
1828///
1829/// # Arguments
1830/// * `data` - Column-major matrix (n x m)
1831/// * `n` - Number of samples
1832/// * `m` - Number of evaluation points
1833/// * `argvals` - Evaluation points
1834/// * `period` - Seasonal period
1835/// * `threshold` - SS threshold for seasonal/non-seasonal (e.g., 0.3)
1836/// * `window_size` - Window size for local strength estimation
1837/// * `min_duration` - Minimum duration to confirm a change
1838pub fn detect_seasonality_changes(
1839    data: &FdMatrix,
1840    argvals: &[f64],
1841    period: f64,
1842    threshold: f64,
1843    window_size: f64,
1844    min_duration: f64,
1845) -> ChangeDetectionResult {
1846    let (n, m) = data.shape();
1847    if n == 0 || m < 4 || argvals.len() != m {
1848        return ChangeDetectionResult {
1849            change_points: Vec::new(),
1850            strength_curve: Vec::new(),
1851        };
1852    }
1853
1854    // Compute time-varying seasonal strength
1855    let strength_curve =
1856        seasonal_strength_windowed(data, argvals, period, window_size, StrengthMethod::Variance);
1857
1858    if strength_curve.is_empty() {
1859        return ChangeDetectionResult {
1860            change_points: Vec::new(),
1861            strength_curve: Vec::new(),
1862        };
1863    }
1864
1865    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1866    let min_dur_points = (min_duration / dt).round() as usize;
1867
1868    let change_points =
1869        detect_threshold_crossings(&strength_curve, argvals, threshold, min_dur_points);
1870
1871    ChangeDetectionResult {
1872        change_points,
1873        strength_curve,
1874    }
1875}
1876
1877// ============================================================================
1878// Amplitude Modulation Detection
1879// ============================================================================
1880
1881/// Detect amplitude modulation in seasonal time series.
1882///
1883/// This function first checks if seasonality exists using the spectral method
1884/// (which is robust to amplitude modulation), then uses Hilbert transform to
1885/// extract the amplitude envelope and analyze modulation patterns.
1886///
1887/// # Arguments
1888/// * `data` - Column-major matrix (n x m)
1889/// * `n` - Number of samples
1890/// * `m` - Number of evaluation points
1891/// * `argvals` - Evaluation points
1892/// * `period` - Seasonal period in argvals units
1893/// * `modulation_threshold` - CV threshold for detecting modulation (default: 0.15)
1894/// * `seasonality_threshold` - Strength threshold for seasonality (default: 0.3)
1895///
1896/// # Returns
1897/// `AmplitudeModulationResult` containing detection results and diagnostics
1898///
1899/// # Example
1900/// ```ignore
1901/// let result = detect_amplitude_modulation(
1902///     &data, n, m, &argvals,
1903///     period,
1904///     0.15,          // CV > 0.15 indicates modulation
1905///     0.3,           // strength > 0.3 indicates seasonality
1906/// );
1907/// if result.has_modulation {
1908///     match result.modulation_type {
1909///         ModulationType::Emerging => println!("Seasonality is emerging"),
1910///         ModulationType::Fading => println!("Seasonality is fading"),
1911///         _ => {}
1912///     }
1913/// }
1914/// ```
1915pub fn detect_amplitude_modulation(
1916    data: &FdMatrix,
1917    argvals: &[f64],
1918    period: f64,
1919    modulation_threshold: f64,
1920    seasonality_threshold: f64,
1921) -> AmplitudeModulationResult {
1922    let (n, m) = data.shape();
1923    // Default result for invalid input
1924    let empty_result = AmplitudeModulationResult {
1925        is_seasonal: false,
1926        seasonal_strength: 0.0,
1927        has_modulation: false,
1928        modulation_type: ModulationType::NonSeasonal,
1929        modulation_score: 0.0,
1930        amplitude_trend: 0.0,
1931        strength_curve: Vec::new(),
1932        time_points: Vec::new(),
1933        min_strength: 0.0,
1934        max_strength: 0.0,
1935    };
1936
1937    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1938        return empty_result;
1939    }
1940
1941    // Step 1: Check if seasonality exists using spectral method (robust to AM)
1942    let overall_strength = seasonal_strength_spectral(data, argvals, period);
1943
1944    if overall_strength < seasonality_threshold {
1945        return AmplitudeModulationResult {
1946            is_seasonal: false,
1947            seasonal_strength: overall_strength,
1948            has_modulation: false,
1949            modulation_type: ModulationType::NonSeasonal,
1950            modulation_score: 0.0,
1951            amplitude_trend: 0.0,
1952            strength_curve: Vec::new(),
1953            time_points: argvals.to_vec(),
1954            min_strength: 0.0,
1955            max_strength: 0.0,
1956        };
1957    }
1958
1959    // Step 2: Compute mean curve
1960    let mean_curve = compute_mean_curve(data);
1961
1962    // Step 3: Use Hilbert transform to get amplitude envelope
1963    let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1964    let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
1965    let analytic = hilbert_transform(&detrended);
1966    let envelope: Vec<f64> = analytic.iter().map(|c| c.norm()).collect();
1967
1968    if envelope.is_empty() {
1969        return AmplitudeModulationResult {
1970            is_seasonal: true,
1971            seasonal_strength: overall_strength,
1972            has_modulation: false,
1973            modulation_type: ModulationType::Stable,
1974            modulation_score: 0.0,
1975            amplitude_trend: 0.0,
1976            strength_curve: Vec::new(),
1977            time_points: argvals.to_vec(),
1978            min_strength: 0.0,
1979            max_strength: 0.0,
1980        };
1981    }
1982
1983    // Step 4: Smooth the envelope to reduce high-frequency noise
1984    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1985    let smooth_window = ((period / dt) as usize).max(3);
1986    let half_window = smooth_window / 2;
1987
1988    let smoothed_envelope: Vec<f64> = (0..m)
1989        .map(|i| {
1990            let start = i.saturating_sub(half_window);
1991            let end = (i + half_window + 1).min(m);
1992            let sum: f64 = envelope[start..end].iter().sum();
1993            sum / (end - start) as f64
1994        })
1995        .collect();
1996
1997    // Step 5: Analyze envelope statistics
1998    let Some((interior_start, interior_end)) = valid_interior_bounds(m, 4) else {
1999        return AmplitudeModulationResult {
2000            is_seasonal: true,
2001            seasonal_strength: overall_strength,
2002            has_modulation: false,
2003            modulation_type: ModulationType::Stable,
2004            modulation_score: 0.0,
2005            amplitude_trend: 0.0,
2006            strength_curve: envelope,
2007            time_points: argvals.to_vec(),
2008            min_strength: 0.0,
2009            max_strength: 0.0,
2010        };
2011    };
2012
2013    let stats = analyze_amplitude_envelope(
2014        &smoothed_envelope[interior_start..interior_end],
2015        &argvals[interior_start..interior_end],
2016        modulation_threshold,
2017    );
2018
2019    AmplitudeModulationResult {
2020        is_seasonal: true,
2021        seasonal_strength: overall_strength,
2022        has_modulation: stats.has_modulation,
2023        modulation_type: stats.modulation_type,
2024        modulation_score: stats.modulation_score,
2025        amplitude_trend: stats.amplitude_trend,
2026        strength_curve: envelope,
2027        time_points: argvals.to_vec(),
2028        min_strength: stats.min_amp,
2029        max_strength: stats.max_amp,
2030    }
2031}
2032
2033/// Detect amplitude modulation using Morlet wavelet transform.
2034///
2035/// Uses continuous wavelet transform at the seasonal period to extract
2036/// time-varying amplitude. This method is more robust to noise and can
2037/// better handle non-stationary signals compared to Hilbert transform.
2038///
2039/// # Arguments
2040/// * `data` - Column-major matrix (n x m)
2041/// * `n` - Number of samples
2042/// * `m` - Number of evaluation points
2043/// * `argvals` - Evaluation points
2044/// * `period` - Seasonal period in argvals units
2045/// * `modulation_threshold` - CV threshold for detecting modulation (default: 0.15)
2046/// * `seasonality_threshold` - Strength threshold for seasonality (default: 0.3)
2047///
2048/// # Returns
2049/// `WaveletAmplitudeResult` containing detection results and wavelet amplitude curve
2050///
2051/// # Notes
2052/// - Uses Morlet wavelet with ω₀ = 6 (standard choice)
2053/// - The scale parameter is derived from the period: scale = period * ω₀ / (2π)
2054/// - This relates to how wavelets measure period: for Morlet, period ≈ scale * 2π / ω₀
2055pub fn detect_amplitude_modulation_wavelet(
2056    data: &FdMatrix,
2057    argvals: &[f64],
2058    period: f64,
2059    modulation_threshold: f64,
2060    seasonality_threshold: f64,
2061) -> WaveletAmplitudeResult {
2062    let (n, m) = data.shape();
2063    let empty_result = WaveletAmplitudeResult {
2064        is_seasonal: false,
2065        seasonal_strength: 0.0,
2066        has_modulation: false,
2067        modulation_type: ModulationType::NonSeasonal,
2068        modulation_score: 0.0,
2069        amplitude_trend: 0.0,
2070        wavelet_amplitude: Vec::new(),
2071        time_points: Vec::new(),
2072        scale: 0.0,
2073    };
2074
2075    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
2076        return empty_result;
2077    }
2078
2079    // Step 1: Check if seasonality exists using spectral method
2080    let overall_strength = seasonal_strength_spectral(data, argvals, period);
2081
2082    if overall_strength < seasonality_threshold {
2083        return WaveletAmplitudeResult {
2084            is_seasonal: false,
2085            seasonal_strength: overall_strength,
2086            has_modulation: false,
2087            modulation_type: ModulationType::NonSeasonal,
2088            modulation_score: 0.0,
2089            amplitude_trend: 0.0,
2090            wavelet_amplitude: Vec::new(),
2091            time_points: argvals.to_vec(),
2092            scale: 0.0,
2093        };
2094    }
2095
2096    // Step 2: Compute mean curve
2097    let mean_curve = compute_mean_curve(data);
2098
2099    // Remove DC component
2100    let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
2101    let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
2102
2103    // Step 3: Compute wavelet transform at the seasonal period
2104    // For Morlet wavelet: period = scale * 2π / ω₀, so scale = period * ω₀ / (2π)
2105    let omega0 = 6.0; // Standard Morlet parameter
2106    let scale = period * omega0 / (2.0 * PI);
2107
2108    // Use FFT-based CWT for efficiency
2109    let wavelet_coeffs = cwt_morlet_fft(&detrended, argvals, scale, omega0);
2110
2111    if wavelet_coeffs.is_empty() {
2112        return WaveletAmplitudeResult {
2113            is_seasonal: true,
2114            seasonal_strength: overall_strength,
2115            has_modulation: false,
2116            modulation_type: ModulationType::Stable,
2117            modulation_score: 0.0,
2118            amplitude_trend: 0.0,
2119            wavelet_amplitude: Vec::new(),
2120            time_points: argvals.to_vec(),
2121            scale,
2122        };
2123    }
2124
2125    // Step 4: Extract amplitude (magnitude of wavelet coefficients)
2126    let wavelet_amplitude: Vec<f64> = wavelet_coeffs.iter().map(|c| c.norm()).collect();
2127
2128    // Step 5: Analyze amplitude envelope statistics (skip edges)
2129    let Some((interior_start, interior_end)) = valid_interior_bounds(m, 4) else {
2130        return WaveletAmplitudeResult {
2131            is_seasonal: true,
2132            seasonal_strength: overall_strength,
2133            has_modulation: false,
2134            modulation_type: ModulationType::Stable,
2135            modulation_score: 0.0,
2136            amplitude_trend: 0.0,
2137            wavelet_amplitude,
2138            time_points: argvals.to_vec(),
2139            scale,
2140        };
2141    };
2142
2143    let stats = analyze_amplitude_envelope(
2144        &wavelet_amplitude[interior_start..interior_end],
2145        &argvals[interior_start..interior_end],
2146        modulation_threshold,
2147    );
2148
2149    WaveletAmplitudeResult {
2150        is_seasonal: true,
2151        seasonal_strength: overall_strength,
2152        has_modulation: stats.has_modulation,
2153        modulation_type: stats.modulation_type,
2154        modulation_score: stats.modulation_score,
2155        amplitude_trend: stats.amplitude_trend,
2156        wavelet_amplitude,
2157        time_points: argvals.to_vec(),
2158        scale,
2159    }
2160}
2161
2162// ============================================================================
2163// Instantaneous Period
2164// ============================================================================
2165
2166/// Estimate instantaneous period using Hilbert transform.
2167///
2168/// For series with drifting/changing period, this computes the period
2169/// at each time point using the analytic signal.
2170///
2171/// # Arguments
2172/// * `data` - Column-major matrix (n x m)
2173/// * `n` - Number of samples
2174/// * `m` - Number of evaluation points
2175/// * `argvals` - Evaluation points
2176pub fn instantaneous_period(data: &FdMatrix, argvals: &[f64]) -> InstantaneousPeriod {
2177    let (n, m) = data.shape();
2178    if n == 0 || m < 4 || argvals.len() != m {
2179        return InstantaneousPeriod {
2180            period: Vec::new(),
2181            frequency: Vec::new(),
2182            amplitude: Vec::new(),
2183        };
2184    }
2185
2186    // Compute mean curve
2187    let mean_curve = compute_mean_curve(data);
2188
2189    // Remove DC component (detrend by subtracting mean)
2190    let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
2191    let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
2192
2193    // Compute analytic signal via Hilbert transform
2194    let analytic = hilbert_transform(&detrended);
2195
2196    // Extract instantaneous amplitude and phase
2197    let amplitude: Vec<f64> = analytic.iter().map(|c| c.norm()).collect();
2198
2199    let phase: Vec<f64> = analytic.iter().map(|c| c.im.atan2(c.re)).collect();
2200
2201    // Unwrap phase
2202    let unwrapped_phase = unwrap_phase(&phase);
2203
2204    // Compute instantaneous frequency (derivative of phase)
2205    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
2206    let mut inst_freq = vec![0.0; m];
2207
2208    // Central differences for interior, forward/backward at boundaries
2209    if m > 1 {
2210        inst_freq[0] = (unwrapped_phase[1] - unwrapped_phase[0]) / dt / (2.0 * PI);
2211    }
2212    for j in 1..(m - 1) {
2213        inst_freq[j] = (unwrapped_phase[j + 1] - unwrapped_phase[j - 1]) / (2.0 * dt) / (2.0 * PI);
2214    }
2215    if m > 1 {
2216        inst_freq[m - 1] = (unwrapped_phase[m - 1] - unwrapped_phase[m - 2]) / dt / (2.0 * PI);
2217    }
2218
2219    // Period = 1/frequency (handle near-zero frequencies)
2220    let period: Vec<f64> = inst_freq
2221        .iter()
2222        .map(|&f| {
2223            if f.abs() > 1e-10 {
2224                (1.0 / f).abs()
2225            } else {
2226                f64::INFINITY
2227            }
2228        })
2229        .collect();
2230
2231    InstantaneousPeriod {
2232        period,
2233        frequency: inst_freq,
2234        amplitude,
2235    }
2236}
2237
2238// ============================================================================
2239// Peak Timing Variability Analysis
2240// ============================================================================
2241
2242/// Analyze peak timing variability across cycles.
2243///
2244/// For short series (e.g., 3-5 years of yearly data), this function detects
2245/// one peak per cycle and analyzes how peak timing varies between cycles.
2246///
2247/// # Arguments
2248/// * `data` - Column-major matrix (n x m)
2249/// * `n` - Number of samples
2250/// * `m` - Number of evaluation points
2251/// * `argvals` - Evaluation points
2252/// * `period` - Known period (e.g., 365 for daily data with yearly seasonality)
2253/// * `smooth_nbasis` - Number of Fourier basis functions for smoothing.
2254///   If None, uses GCV for automatic selection.
2255///
2256/// # Returns
2257/// Peak timing result with variability metrics
2258pub fn analyze_peak_timing(
2259    data: &FdMatrix,
2260    argvals: &[f64],
2261    period: f64,
2262    smooth_nbasis: Option<usize>,
2263) -> PeakTimingResult {
2264    let (n, m) = data.shape();
2265    if n == 0 || m < 3 || argvals.len() != m || period <= 0.0 {
2266        return PeakTimingResult {
2267            peak_times: Vec::new(),
2268            peak_values: Vec::new(),
2269            normalized_timing: Vec::new(),
2270            mean_timing: f64::NAN,
2271            std_timing: f64::NAN,
2272            range_timing: f64::NAN,
2273            variability_score: f64::NAN,
2274            timing_trend: f64::NAN,
2275            cycle_indices: Vec::new(),
2276        };
2277    }
2278
2279    // Detect peaks with minimum distance constraint of 0.7 * period
2280    // This ensures we get at most one peak per cycle
2281    let min_distance = period * 0.7;
2282    let peaks = detect_peaks(
2283        data,
2284        argvals,
2285        Some(min_distance),
2286        None, // No prominence filter
2287        true, // Smooth first with Fourier basis
2288        smooth_nbasis,
2289    );
2290
2291    // Use the first sample's peaks (for mean curve analysis)
2292    // If multiple samples, we take the mean curve which is effectively in sample 0
2293    let sample_peaks = if peaks.peaks.is_empty() {
2294        Vec::new()
2295    } else {
2296        peaks.peaks[0].clone()
2297    };
2298
2299    if sample_peaks.is_empty() {
2300        return PeakTimingResult {
2301            peak_times: Vec::new(),
2302            peak_values: Vec::new(),
2303            normalized_timing: Vec::new(),
2304            mean_timing: f64::NAN,
2305            std_timing: f64::NAN,
2306            range_timing: f64::NAN,
2307            variability_score: f64::NAN,
2308            timing_trend: f64::NAN,
2309            cycle_indices: Vec::new(),
2310        };
2311    }
2312
2313    let peak_times: Vec<f64> = sample_peaks.iter().map(|p| p.time).collect();
2314    let peak_values: Vec<f64> = sample_peaks.iter().map(|p| p.value).collect();
2315
2316    // Compute normalized timing (position within cycle, 0-1 scale)
2317    let t_start = argvals[0];
2318    let normalized_timing: Vec<f64> = peak_times
2319        .iter()
2320        .map(|&t| {
2321            let cycle_pos = (t - t_start) % period;
2322            cycle_pos / period
2323        })
2324        .collect();
2325
2326    // Compute cycle indices (1-indexed)
2327    let cycle_indices: Vec<usize> = peak_times
2328        .iter()
2329        .map(|&t| ((t - t_start) / period).floor() as usize + 1)
2330        .collect();
2331
2332    // Compute statistics
2333    let n_peaks = normalized_timing.len() as f64;
2334    let mean_timing = normalized_timing.iter().sum::<f64>() / n_peaks;
2335
2336    let variance: f64 = normalized_timing
2337        .iter()
2338        .map(|&x| (x - mean_timing).powi(2))
2339        .sum::<f64>()
2340        / n_peaks;
2341    let std_timing = variance.sqrt();
2342
2343    let min_timing = normalized_timing
2344        .iter()
2345        .cloned()
2346        .fold(f64::INFINITY, f64::min);
2347    let max_timing = normalized_timing
2348        .iter()
2349        .cloned()
2350        .fold(f64::NEG_INFINITY, f64::max);
2351    let range_timing = max_timing - min_timing;
2352
2353    // Variability score: normalized std deviation
2354    // Max possible std for uniform in [0,1] is ~0.289, so we scale by that
2355    // But since peaks cluster, we use 0.1 as "high" variability threshold
2356    let variability_score = (std_timing / 0.1).min(1.0);
2357
2358    // Timing trend: linear regression of normalized timing on cycle index
2359    let cycle_idx_f64: Vec<f64> = cycle_indices.iter().map(|&i| i as f64).collect();
2360    let timing_trend = linear_slope(&cycle_idx_f64, &normalized_timing);
2361
2362    PeakTimingResult {
2363        peak_times,
2364        peak_values,
2365        normalized_timing,
2366        mean_timing,
2367        std_timing,
2368        range_timing,
2369        variability_score,
2370        timing_trend,
2371        cycle_indices,
2372    }
2373}
2374
2375// ============================================================================
2376// Seasonality Classification
2377// ============================================================================
2378
2379/// Classify the type of seasonality in functional data.
2380///
2381/// This is particularly useful for short series (3-5 years) where you need
2382/// to identify:
2383/// - Whether seasonality is present
2384/// - Whether peak timing is stable or variable
2385/// - Which cycles have weak or missing seasonality
2386///
2387/// # Arguments
2388/// * `data` - Column-major matrix (n x m)
2389/// * `n` - Number of samples
2390/// * `m` - Number of evaluation points
2391/// * `argvals` - Evaluation points
2392/// * `period` - Known seasonal period
2393/// * `strength_threshold` - Threshold for seasonal/non-seasonal (default: 0.3)
2394/// * `timing_threshold` - Max std of normalized timing for "stable" (default: 0.05)
2395///
2396/// # Returns
2397/// Seasonality classification with type and diagnostics
2398pub fn classify_seasonality(
2399    data: &FdMatrix,
2400    argvals: &[f64],
2401    period: f64,
2402    strength_threshold: Option<f64>,
2403    timing_threshold: Option<f64>,
2404) -> SeasonalityClassification {
2405    let strength_thresh = strength_threshold.unwrap_or(0.3);
2406    let timing_thresh = timing_threshold.unwrap_or(0.05);
2407
2408    let (n, m) = data.shape();
2409    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
2410        return SeasonalityClassification {
2411            is_seasonal: false,
2412            has_stable_timing: false,
2413            timing_variability: f64::NAN,
2414            seasonal_strength: f64::NAN,
2415            cycle_strengths: Vec::new(),
2416            weak_seasons: Vec::new(),
2417            classification: SeasonalType::NonSeasonal,
2418            peak_timing: None,
2419        };
2420    }
2421
2422    // Compute overall seasonal strength
2423    let overall_strength = seasonal_strength_variance(data, argvals, period, 3);
2424
2425    let (cycle_strengths, weak_seasons) =
2426        compute_cycle_strengths(data, argvals, period, strength_thresh);
2427    let n_cycles = cycle_strengths.len();
2428
2429    // Analyze peak timing
2430    let peak_timing = analyze_peak_timing(data, argvals, period, None);
2431
2432    // Determine classification
2433    let is_seasonal = overall_strength >= strength_thresh;
2434    let has_stable_timing = peak_timing.std_timing <= timing_thresh;
2435    let timing_variability = peak_timing.variability_score;
2436
2437    // Classify based on patterns
2438    let n_weak = weak_seasons.len();
2439    let classification = if !is_seasonal {
2440        SeasonalType::NonSeasonal
2441    } else if n_cycles > 0 && n_weak as f64 / n_cycles as f64 > 0.3 {
2442        // More than 30% of cycles are weak
2443        SeasonalType::IntermittentSeasonal
2444    } else if !has_stable_timing {
2445        SeasonalType::VariableTiming
2446    } else {
2447        SeasonalType::StableSeasonal
2448    };
2449
2450    SeasonalityClassification {
2451        is_seasonal,
2452        has_stable_timing,
2453        timing_variability,
2454        seasonal_strength: overall_strength,
2455        cycle_strengths,
2456        weak_seasons,
2457        classification,
2458        peak_timing: Some(peak_timing),
2459    }
2460}
2461
2462/// Detect seasonality changes with automatic threshold selection.
2463///
2464/// Uses Otsu's method or percentile-based threshold instead of a fixed value.
2465///
2466/// # Arguments
2467/// * `data` - Column-major matrix (n x m)
2468/// * `n` - Number of samples
2469/// * `m` - Number of evaluation points
2470/// * `argvals` - Evaluation points
2471/// * `period` - Seasonal period
2472/// * `threshold_method` - Method for threshold selection
2473/// * `window_size` - Window size for local strength estimation
2474/// * `min_duration` - Minimum duration to confirm a change
2475pub fn detect_seasonality_changes_auto(
2476    data: &FdMatrix,
2477    argvals: &[f64],
2478    period: f64,
2479    threshold_method: ThresholdMethod,
2480    window_size: f64,
2481    min_duration: f64,
2482) -> ChangeDetectionResult {
2483    let (n, m) = data.shape();
2484    if n == 0 || m < 4 || argvals.len() != m {
2485        return ChangeDetectionResult {
2486            change_points: Vec::new(),
2487            strength_curve: Vec::new(),
2488        };
2489    }
2490
2491    // Compute time-varying seasonal strength
2492    let strength_curve =
2493        seasonal_strength_windowed(data, argvals, period, window_size, StrengthMethod::Variance);
2494
2495    if strength_curve.is_empty() {
2496        return ChangeDetectionResult {
2497            change_points: Vec::new(),
2498            strength_curve: Vec::new(),
2499        };
2500    }
2501
2502    // Determine threshold
2503    let threshold = match threshold_method {
2504        ThresholdMethod::Fixed(t) => t,
2505        ThresholdMethod::Percentile(p) => {
2506            let mut sorted: Vec<f64> = strength_curve
2507                .iter()
2508                .copied()
2509                .filter(|x| x.is_finite())
2510                .collect();
2511            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2512            if sorted.is_empty() {
2513                0.5
2514            } else {
2515                let idx = ((p / 100.0) * sorted.len() as f64) as usize;
2516                sorted[idx.min(sorted.len() - 1)]
2517            }
2518        }
2519        ThresholdMethod::Otsu => otsu_threshold(&strength_curve),
2520    };
2521
2522    // Now use the regular detection with computed threshold
2523    detect_seasonality_changes(data, argvals, period, threshold, window_size, min_duration)
2524}
2525
2526/// Result of SAZED ensemble period detection.
2527#[derive(Debug, Clone)]
2528pub struct SazedResult {
2529    /// Primary detected period (consensus from ensemble)
2530    pub period: f64,
2531    /// Confidence score (0-1, based on component agreement)
2532    pub confidence: f64,
2533    /// Periods detected by each component (may be NaN if not detected)
2534    pub component_periods: SazedComponents,
2535    /// Number of components that agreed on the final period
2536    pub agreeing_components: usize,
2537}
2538
2539/// Individual period estimates from each SAZED component.
2540#[derive(Debug, Clone)]
2541pub struct SazedComponents {
2542    /// Period from spectral (FFT) detection
2543    pub spectral: f64,
2544    /// Period from ACF peak detection
2545    pub acf_peak: f64,
2546    /// Period from weighted ACF average
2547    pub acf_average: f64,
2548    /// Period from ACF zero-crossing analysis
2549    pub zero_crossing: f64,
2550    /// Period from spectral differencing
2551    pub spectral_diff: f64,
2552}
2553
2554/// SAZED: Spectral-ACF Zero-crossing Ensemble Detection
2555///
2556/// A parameter-free ensemble method for robust period detection.
2557/// Combines 5 detection components:
2558/// 1. Spectral (FFT) - peaks in periodogram
2559/// 2. ACF peak - first significant peak in autocorrelation
2560/// 3. ACF average - weighted mean of ACF peaks
2561/// 4. Zero-crossing - period from ACF zero crossings
2562/// 5. Spectral differencing - FFT on first-differenced signal
2563///
2564/// Each component provides both a period estimate and a confidence score.
2565/// Only components with sufficient confidence participate in voting.
2566/// The final period is chosen by majority voting with tolerance.
2567///
2568/// # Arguments
2569/// * `data` - Input signal (1D time series or mean curve from fdata)
2570/// * `argvals` - Time points corresponding to data
2571/// * `tolerance` - Relative tolerance for considering periods equal (default: 0.05 = 5%)
2572///
2573/// # Returns
2574/// * `SazedResult` containing the consensus period and component details
2575pub fn sazed(data: &[f64], argvals: &[f64], tolerance: Option<f64>) -> SazedResult {
2576    let n = data.len();
2577    let tol = tolerance.unwrap_or(0.05); // Tighter default tolerance
2578
2579    if n < 8 || argvals.len() != n {
2580        return SazedResult {
2581            period: f64::NAN,
2582            confidence: 0.0,
2583            component_periods: SazedComponents {
2584                spectral: f64::NAN,
2585                acf_peak: f64::NAN,
2586                acf_average: f64::NAN,
2587                zero_crossing: f64::NAN,
2588                spectral_diff: f64::NAN,
2589            },
2590            agreeing_components: 0,
2591        };
2592    }
2593
2594    let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
2595    let max_lag = (n / 2).max(4);
2596    let signal_range = argvals[n - 1] - argvals[0];
2597
2598    // Minimum detectable period (at least 3 cycles)
2599    let min_period = signal_range / (n as f64 / 3.0);
2600    // Maximum detectable period (at most 2 complete cycles)
2601    let max_period = signal_range / 2.0;
2602
2603    // Component 1: Spectral (FFT) detection with confidence
2604    let (spectral_period, spectral_conf) = sazed_spectral_with_confidence(data, argvals);
2605
2606    // Component 2: ACF peak detection with confidence
2607    let (acf_peak_period, acf_peak_conf) = sazed_acf_peak_with_confidence(data, dt, max_lag);
2608
2609    // Component 3: ACF weighted average (uses ACF peak confidence)
2610    let acf_average_period = sazed_acf_average(data, dt, max_lag);
2611
2612    // Component 4: Zero-crossing analysis with confidence
2613    let (zero_crossing_period, zero_crossing_conf) =
2614        sazed_zero_crossing_with_confidence(data, dt, max_lag);
2615
2616    // Component 5: Spectral on differenced signal with confidence
2617    let (spectral_diff_period, spectral_diff_conf) =
2618        sazed_spectral_diff_with_confidence(data, argvals);
2619
2620    let components = SazedComponents {
2621        spectral: spectral_period,
2622        acf_peak: acf_peak_period,
2623        acf_average: acf_average_period,
2624        zero_crossing: zero_crossing_period,
2625        spectral_diff: spectral_diff_period,
2626    };
2627
2628    // Confidence thresholds for each component (tuned to minimize FPR on noise)
2629    // For Gaussian noise: spectral peaks rarely exceed 6x median, ACF ~1/sqrt(n)
2630    let spectral_thresh = 8.0; // Power ratio must be > 8x median (noise rarely exceeds 6x)
2631    let acf_thresh = 0.3; // ACF correlation must be > 0.3 (noise ~0.1 for n=100)
2632    let zero_crossing_thresh = 0.9; // Zero-crossing consistency > 90%
2633    let spectral_diff_thresh = 6.0; // Diff spectral power ratio > 6x
2634
2635    // Minimum number of confident components required to report a period
2636    let min_confident_components = 2;
2637
2638    // Collect valid periods (only from components with sufficient confidence)
2639    let confident_periods: Vec<f64> = [
2640        validate_sazed_component(
2641            spectral_period,
2642            spectral_conf,
2643            min_period,
2644            max_period,
2645            spectral_thresh,
2646        ),
2647        validate_sazed_component(
2648            acf_peak_period,
2649            acf_peak_conf,
2650            min_period,
2651            max_period,
2652            acf_thresh,
2653        ),
2654        validate_sazed_component(
2655            acf_average_period,
2656            acf_peak_conf,
2657            min_period,
2658            max_period,
2659            acf_thresh,
2660        ),
2661        validate_sazed_component(
2662            zero_crossing_period,
2663            zero_crossing_conf,
2664            min_period,
2665            max_period,
2666            zero_crossing_thresh,
2667        ),
2668        validate_sazed_component(
2669            spectral_diff_period,
2670            spectral_diff_conf,
2671            min_period,
2672            max_period,
2673            spectral_diff_thresh,
2674        ),
2675    ]
2676    .into_iter()
2677    .flatten()
2678    .collect();
2679
2680    // Require minimum number of confident components before reporting a period
2681    if confident_periods.len() < min_confident_components {
2682        return SazedResult {
2683            period: f64::NAN,
2684            confidence: 0.0,
2685            component_periods: components,
2686            agreeing_components: confident_periods.len(),
2687        };
2688    }
2689
2690    // Ensemble voting: find the mode with tolerance
2691    let (consensus_period, agreeing_count) = find_consensus_period(&confident_periods, tol);
2692    let confidence = agreeing_count as f64 / 5.0;
2693
2694    SazedResult {
2695        period: consensus_period,
2696        confidence,
2697        component_periods: components,
2698        agreeing_components: agreeing_count,
2699    }
2700}
2701
2702/// SAZED for functional data (matrix format)
2703///
2704/// Computes mean curve first, then applies SAZED.
2705///
2706/// # Arguments
2707/// * `data` - Column-major matrix (n x m)
2708/// * `n` - Number of samples
2709/// * `m` - Number of evaluation points
2710/// * `argvals` - Evaluation points
2711/// * `tolerance` - Relative tolerance for period matching
2712pub fn sazed_fdata(data: &FdMatrix, argvals: &[f64], tolerance: Option<f64>) -> SazedResult {
2713    let (n, m) = data.shape();
2714    if n == 0 || m < 8 || argvals.len() != m {
2715        return SazedResult {
2716            period: f64::NAN,
2717            confidence: 0.0,
2718            component_periods: SazedComponents {
2719                spectral: f64::NAN,
2720                acf_peak: f64::NAN,
2721                acf_average: f64::NAN,
2722                zero_crossing: f64::NAN,
2723                spectral_diff: f64::NAN,
2724            },
2725            agreeing_components: 0,
2726        };
2727    }
2728
2729    let mean_curve = compute_mean_curve(data);
2730    sazed(&mean_curve, argvals, tolerance)
2731}
2732
2733/// Spectral component with confidence: returns (period, power_ratio)
2734fn sazed_spectral_with_confidence(data: &[f64], argvals: &[f64]) -> (f64, f64) {
2735    let (frequencies, power) = periodogram(data, argvals);
2736
2737    if frequencies.len() < 3 {
2738        return (f64::NAN, 0.0);
2739    }
2740
2741    // Find peaks in power spectrum (skip DC)
2742    let power_no_dc: Vec<f64> = power.iter().skip(1).copied().collect();
2743
2744    if power_no_dc.is_empty() {
2745        return (f64::NAN, 0.0);
2746    }
2747
2748    // Calculate noise floor as median
2749    let mut sorted_power = power_no_dc.clone();
2750    sorted_power.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2751    let noise_floor = sorted_power[sorted_power.len() / 2].max(1e-15);
2752
2753    // Find global maximum
2754    let mut max_idx = 0;
2755    let mut max_val = 0.0;
2756    for (i, &p) in power_no_dc.iter().enumerate() {
2757        if p > max_val {
2758            max_val = p;
2759            max_idx = i;
2760        }
2761    }
2762
2763    let power_ratio = max_val / noise_floor;
2764    let freq = frequencies[max_idx + 1];
2765
2766    if freq > 1e-15 {
2767        (1.0 / freq, power_ratio)
2768    } else {
2769        (f64::NAN, 0.0)
2770    }
2771}
2772
2773/// ACF peak component with confidence: returns (period, acf_value_at_peak)
2774fn sazed_acf_peak_with_confidence(data: &[f64], dt: f64, max_lag: usize) -> (f64, f64) {
2775    let acf = autocorrelation(data, max_lag);
2776
2777    match find_first_acf_peak(&acf) {
2778        Some((peak_lag, acf_value)) => (peak_lag as f64 * dt, acf_value),
2779        None => (f64::NAN, 0.0),
2780    }
2781}
2782
2783/// ACF average component: weighted mean of ACF peak locations
2784fn sazed_acf_average(data: &[f64], dt: f64, max_lag: usize) -> f64 {
2785    let acf = autocorrelation(data, max_lag);
2786
2787    if acf.len() < 4 {
2788        return f64::NAN;
2789    }
2790
2791    // Find all peaks in ACF
2792    let peaks = find_peaks_1d(&acf[1..], 1);
2793
2794    if peaks.is_empty() {
2795        return f64::NAN;
2796    }
2797
2798    // Weight peaks by their ACF value
2799    let mut weighted_sum = 0.0;
2800    let mut weight_sum = 0.0;
2801
2802    for (i, &peak_idx) in peaks.iter().enumerate() {
2803        let lag = peak_idx + 1;
2804        let weight = acf[lag].max(0.0);
2805
2806        if i == 0 {
2807            // First peak is the fundamental period
2808            weighted_sum += lag as f64 * weight;
2809            weight_sum += weight;
2810        } else {
2811            // Later peaks: estimate fundamental by dividing by harmonic number
2812            let expected_fundamental = peaks[0] + 1;
2813            let harmonic = ((lag as f64 / expected_fundamental as f64) + 0.5) as usize;
2814            if harmonic > 0 {
2815                let fundamental_est = lag as f64 / harmonic as f64;
2816                weighted_sum += fundamental_est * weight;
2817                weight_sum += weight;
2818            }
2819        }
2820    }
2821
2822    if weight_sum > 1e-15 {
2823        weighted_sum / weight_sum * dt
2824    } else {
2825        f64::NAN
2826    }
2827}
2828
2829/// Zero-crossing component with confidence: returns (period, consistency)
2830/// Consistency is how regular the zero crossings are (std/mean of half-periods)
2831fn sazed_zero_crossing_with_confidence(data: &[f64], dt: f64, max_lag: usize) -> (f64, f64) {
2832    let acf = autocorrelation(data, max_lag);
2833
2834    if acf.len() < 4 {
2835        return (f64::NAN, 0.0);
2836    }
2837
2838    // Find zero crossings (sign changes)
2839    let mut crossings = Vec::new();
2840    for i in 1..acf.len() {
2841        if acf[i - 1] * acf[i] < 0.0 {
2842            // Linear interpolation for more precise crossing
2843            let frac = acf[i - 1].abs() / (acf[i - 1].abs() + acf[i].abs());
2844            crossings.push((i - 1) as f64 + frac);
2845        }
2846    }
2847
2848    if crossings.len() < 2 {
2849        return (f64::NAN, 0.0);
2850    }
2851
2852    // Period is twice the distance between consecutive zero crossings
2853    // (ACF goes through two zero crossings per period)
2854    let mut half_periods = Vec::new();
2855    for i in 1..crossings.len() {
2856        half_periods.push(crossings[i] - crossings[i - 1]);
2857    }
2858
2859    if half_periods.is_empty() {
2860        return (f64::NAN, 0.0);
2861    }
2862
2863    // Calculate consistency: 1 - (std/mean) of half-periods
2864    // High consistency means regular zero crossings
2865    let mean: f64 = half_periods.iter().sum::<f64>() / half_periods.len() as f64;
2866    let variance: f64 =
2867        half_periods.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / half_periods.len() as f64;
2868    let std = variance.sqrt();
2869    let consistency = (1.0 - std / mean.max(1e-15)).clamp(0.0, 1.0);
2870
2871    // Median half-period
2872    half_periods.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2873    let median_half = half_periods[half_periods.len() / 2];
2874
2875    (2.0 * median_half * dt, consistency)
2876}
2877
2878/// Spectral differencing with confidence: returns (period, power_ratio)
2879fn sazed_spectral_diff_with_confidence(data: &[f64], argvals: &[f64]) -> (f64, f64) {
2880    if data.len() < 4 {
2881        return (f64::NAN, 0.0);
2882    }
2883
2884    // First difference to remove trend
2885    let diff: Vec<f64> = data.windows(2).map(|w| w[1] - w[0]).collect();
2886    let diff_argvals: Vec<f64> = argvals.windows(2).map(|w| (w[0] + w[1]) / 2.0).collect();
2887
2888    sazed_spectral_with_confidence(&diff, &diff_argvals)
2889}
2890
2891/// Find peaks in power spectrum above noise floor
2892fn find_spectral_peaks(power: &[f64]) -> Vec<usize> {
2893    if power.len() < 3 {
2894        return Vec::new();
2895    }
2896
2897    // Estimate noise floor as median power
2898    let mut sorted_power: Vec<f64> = power.to_vec();
2899    sorted_power.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2900    let noise_floor = sorted_power[sorted_power.len() / 2];
2901    let threshold = noise_floor * 2.0; // Peaks must be at least 2x median
2902
2903    // Find all local maxima above threshold
2904    let mut peaks: Vec<(usize, f64)> = Vec::new();
2905    for i in 1..(power.len() - 1) {
2906        if power[i] > power[i - 1] && power[i] > power[i + 1] && power[i] > threshold {
2907            peaks.push((i, power[i]));
2908        }
2909    }
2910
2911    // Sort by power (descending)
2912    peaks.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
2913
2914    peaks.into_iter().map(|(idx, _)| idx).collect()
2915}
2916
2917/// Find consensus period from multiple estimates using tolerance-based voting
2918fn find_consensus_period(periods: &[f64], tolerance: f64) -> (f64, usize) {
2919    if periods.is_empty() {
2920        return (f64::NAN, 0);
2921    }
2922    if periods.len() == 1 {
2923        return (periods[0], 1);
2924    }
2925
2926    let mut best_period = periods[0];
2927    let mut best_count = 0;
2928    let mut best_sum = 0.0;
2929
2930    for &p1 in periods {
2931        let (count, sum) = count_agreeing_periods(periods, p1, tolerance);
2932
2933        if count > best_count
2934            || (count == best_count && sum / count as f64 > best_sum / best_count.max(1) as f64)
2935        {
2936            best_count = count;
2937            best_period = sum / count as f64;
2938            best_sum = sum;
2939        }
2940    }
2941
2942    (best_period, best_count)
2943}
2944
2945/// Result of Autoperiod detection.
2946#[derive(Debug, Clone)]
2947pub struct AutoperiodResult {
2948    /// Detected period
2949    pub period: f64,
2950    /// Combined confidence (FFT * ACF validation)
2951    pub confidence: f64,
2952    /// FFT power at the detected period
2953    pub fft_power: f64,
2954    /// ACF validation score (0-1)
2955    pub acf_validation: f64,
2956    /// All candidate periods considered
2957    pub candidates: Vec<AutoperiodCandidate>,
2958}
2959
2960/// A candidate period from Autoperiod detection.
2961#[derive(Debug, Clone)]
2962pub struct AutoperiodCandidate {
2963    /// Candidate period
2964    pub period: f64,
2965    /// FFT power
2966    pub fft_power: f64,
2967    /// ACF validation score
2968    pub acf_score: f64,
2969    /// Combined score (power * validation)
2970    pub combined_score: f64,
2971}
2972
2973fn empty_autoperiod_result() -> AutoperiodResult {
2974    AutoperiodResult {
2975        period: f64::NAN,
2976        confidence: 0.0,
2977        fft_power: 0.0,
2978        acf_validation: 0.0,
2979        candidates: Vec::new(),
2980    }
2981}
2982
2983/// Build an autoperiod candidate from a spectral peak, refining with gradient ascent on ACF.
2984fn build_autoperiod_candidate(
2985    peak_idx: usize,
2986    frequencies: &[f64],
2987    power_no_dc: &[f64],
2988    acf: &[f64],
2989    dt: f64,
2990    steps: usize,
2991    total_power: f64,
2992) -> Option<AutoperiodCandidate> {
2993    let freq = frequencies[peak_idx + 1];
2994    if freq < 1e-15 {
2995        return None;
2996    }
2997    let fft_power = power_no_dc[peak_idx];
2998    let normalized_power = fft_power / total_power.max(1e-15);
2999    let refined_period = refine_period_gradient(acf, 1.0 / freq, dt, steps);
3000    let refined_acf_score = validate_period_acf(acf, refined_period, dt);
3001    Some(AutoperiodCandidate {
3002        period: refined_period,
3003        fft_power,
3004        acf_score: refined_acf_score,
3005        combined_score: normalized_power * refined_acf_score,
3006    })
3007}
3008
3009/// Autoperiod: Hybrid FFT + ACF Period Detection
3010///
3011/// Implements the Autoperiod algorithm (Vlachos et al. 2005) which:
3012/// 1. Computes the periodogram via FFT to find candidate periods
3013/// 2. Validates each candidate using the autocorrelation function
3014/// 3. Applies gradient ascent to refine the period estimate
3015/// 4. Returns the period with the highest combined confidence
3016///
3017/// This method is more robust than pure FFT because ACF validation
3018/// filters out spurious spectral peaks that don't correspond to
3019/// true periodicity.
3020///
3021/// # Arguments
3022/// * `data` - Input signal (1D time series)
3023/// * `argvals` - Time points corresponding to data
3024/// * `n_candidates` - Maximum number of FFT peaks to consider (default: 5)
3025/// * `gradient_steps` - Number of gradient ascent refinement steps (default: 10)
3026///
3027/// # Returns
3028/// * `AutoperiodResult` containing the best period and validation details
3029pub fn autoperiod(
3030    data: &[f64],
3031    argvals: &[f64],
3032    n_candidates: Option<usize>,
3033    gradient_steps: Option<usize>,
3034) -> AutoperiodResult {
3035    let n = data.len();
3036    let max_candidates = n_candidates.unwrap_or(5);
3037    let steps = gradient_steps.unwrap_or(10);
3038
3039    if n < 8 || argvals.len() != n {
3040        return empty_autoperiod_result();
3041    }
3042
3043    let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
3044    let max_lag = (n / 2).max(4);
3045
3046    // Step 1: Compute periodogram and find candidate periods
3047    let (frequencies, power) = periodogram(data, argvals);
3048
3049    if frequencies.len() < 3 {
3050        return empty_autoperiod_result();
3051    }
3052
3053    // Find top spectral peaks
3054    let power_no_dc: Vec<f64> = power.iter().skip(1).copied().collect();
3055    let peak_indices = find_spectral_peaks(&power_no_dc);
3056
3057    if peak_indices.is_empty() {
3058        return empty_autoperiod_result();
3059    }
3060
3061    // Step 2: Compute ACF for validation
3062    let acf = autocorrelation(data, max_lag);
3063
3064    // Step 3: Validate each candidate and refine with gradient ascent
3065    let total_power: f64 = power_no_dc.iter().sum();
3066    let candidates: Vec<AutoperiodCandidate> = peak_indices
3067        .iter()
3068        .take(max_candidates)
3069        .filter_map(|&peak_idx| {
3070            build_autoperiod_candidate(
3071                peak_idx,
3072                &frequencies,
3073                &power_no_dc,
3074                &acf,
3075                dt,
3076                steps,
3077                total_power,
3078            )
3079        })
3080        .collect();
3081
3082    if candidates.is_empty() {
3083        return empty_autoperiod_result();
3084    }
3085
3086    // Select best candidate based on combined score
3087    let best = candidates
3088        .iter()
3089        .max_by(|a, b| {
3090            a.combined_score
3091                .partial_cmp(&b.combined_score)
3092                .unwrap_or(std::cmp::Ordering::Equal)
3093        })
3094        .unwrap();
3095
3096    AutoperiodResult {
3097        period: best.period,
3098        confidence: best.combined_score,
3099        fft_power: best.fft_power,
3100        acf_validation: best.acf_score,
3101        candidates,
3102    }
3103}
3104
3105/// Autoperiod for functional data (matrix format)
3106pub fn autoperiod_fdata(
3107    data: &FdMatrix,
3108    argvals: &[f64],
3109    n_candidates: Option<usize>,
3110    gradient_steps: Option<usize>,
3111) -> AutoperiodResult {
3112    let (n, m) = data.shape();
3113    if n == 0 || m < 8 || argvals.len() != m {
3114        return AutoperiodResult {
3115            period: f64::NAN,
3116            confidence: 0.0,
3117            fft_power: 0.0,
3118            acf_validation: 0.0,
3119            candidates: Vec::new(),
3120        };
3121    }
3122
3123    let mean_curve = compute_mean_curve(data);
3124    autoperiod(&mean_curve, argvals, n_candidates, gradient_steps)
3125}
3126
3127/// Validate a candidate period using ACF
3128fn validate_period_acf(acf: &[f64], period: f64, dt: f64) -> f64 {
3129    let lag = (period / dt).round() as usize;
3130
3131    if lag == 0 || lag >= acf.len() {
3132        return 0.0;
3133    }
3134
3135    // Score based on ACF value at the period lag
3136    // Positive ACF values indicate valid periodicity
3137    let acf_at_lag = acf[lag];
3138
3139    // Also check harmonics (period/2, period*2) for consistency
3140    let half_lag = lag / 2;
3141    let double_lag = lag * 2;
3142
3143    let mut score = acf_at_lag.max(0.0);
3144
3145    // For a true period, ACF at half-period should be low/negative
3146    // and ACF at double-period should also be high
3147    if half_lag > 0 && half_lag < acf.len() {
3148        let half_acf = acf[half_lag];
3149        // Penalize if half-period has high ACF (suggests half-period is real)
3150        if half_acf > acf_at_lag * 0.7 {
3151            score *= 0.5;
3152        }
3153    }
3154
3155    if double_lag < acf.len() {
3156        let double_acf = acf[double_lag];
3157        // Bonus if double-period also shows periodicity
3158        if double_acf > 0.3 {
3159            score *= 1.2;
3160        }
3161    }
3162
3163    score.min(1.0)
3164}
3165
3166/// Refine period estimate using gradient ascent on ACF
3167fn refine_period_gradient(acf: &[f64], initial_period: f64, dt: f64, steps: usize) -> f64 {
3168    let mut period = initial_period;
3169    let step_size = dt * 0.5; // Search step size
3170
3171    for _ in 0..steps {
3172        let current_score = validate_period_acf(acf, period, dt);
3173        let left_score = validate_period_acf(acf, period - step_size, dt);
3174        let right_score = validate_period_acf(acf, period + step_size, dt);
3175
3176        if left_score > current_score && left_score > right_score {
3177            period -= step_size;
3178        } else if right_score > current_score {
3179            period += step_size;
3180        }
3181        // If current is best, we've converged
3182    }
3183
3184    period.max(dt) // Ensure period is at least one time step
3185}
3186
3187/// Result of CFDAutoperiod detection.
3188#[derive(Debug, Clone)]
3189pub struct CfdAutoperiodResult {
3190    /// Detected period (primary)
3191    pub period: f64,
3192    /// Confidence score
3193    pub confidence: f64,
3194    /// ACF validation score for the primary period
3195    pub acf_validation: f64,
3196    /// All detected periods (cluster centers)
3197    pub periods: Vec<f64>,
3198    /// Confidence for each detected period
3199    pub confidences: Vec<f64>,
3200}
3201
3202/// Convert spectral peak indices to candidate (period, normalized_power) pairs.
3203fn generate_cfd_candidates(
3204    frequencies: &[f64],
3205    power_no_dc: &[f64],
3206    peak_indices: &[usize],
3207) -> Vec<(f64, f64)> {
3208    let total_power: f64 = power_no_dc.iter().sum();
3209    peak_indices
3210        .iter()
3211        .filter_map(|&peak_idx| {
3212            let freq = frequencies[peak_idx + 1];
3213            if freq > 1e-15 {
3214                let period = 1.0 / freq;
3215                let normalized_power = power_no_dc[peak_idx] / total_power.max(1e-15);
3216                Some((period, normalized_power))
3217            } else {
3218                None
3219            }
3220        })
3221        .collect()
3222}
3223
3224/// Validate clustered period candidates using ACF, returning (period, acf_score, power) triples.
3225fn validate_cfd_candidates(clusters: &[(f64, f64)], acf: &[f64], dt: f64) -> Vec<(f64, f64, f64)> {
3226    clusters
3227        .iter()
3228        .filter_map(|&(center, power_sum)| {
3229            let acf_score = validate_period_acf(acf, center, dt);
3230            if acf_score > 0.1 {
3231                Some((center, acf_score, power_sum))
3232            } else {
3233                None
3234            }
3235        })
3236        .collect()
3237}
3238
3239/// Validate cluster candidates with ACF, falling back to best cluster if none pass.
3240fn validate_or_fallback_cfd(
3241    validated: Vec<(f64, f64, f64)>,
3242    candidates: &[(f64, f64)],
3243    tol: f64,
3244    min_size: usize,
3245) -> Vec<(f64, f64, f64)> {
3246    if !validated.is_empty() {
3247        return validated;
3248    }
3249    // Fallback: pick highest-power cluster without ACF validation
3250    cluster_periods(candidates, tol, min_size)
3251        .into_iter()
3252        .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
3253        .map(|(center, power_sum)| vec![(center, 0.0, power_sum)])
3254        .unwrap_or_default()
3255}
3256
3257/// Rank validated results by combined score (acf * power).
3258/// Returns (periods, confidences, top_acf_validation).
3259fn rank_cfd_results(validated: &[(f64, f64, f64)]) -> (Vec<f64>, Vec<f64>, f64) {
3260    let mut sorted: Vec<_> = validated.to_vec();
3261    sorted.sort_by(|a, b| {
3262        (b.1 * b.2)
3263            .partial_cmp(&(a.1 * a.2))
3264            .unwrap_or(std::cmp::Ordering::Equal)
3265    });
3266    let top_acf = sorted[0].1;
3267    let periods = sorted.iter().map(|v| v.0).collect();
3268    let confidences = sorted.iter().map(|v| v.1 * v.2).collect();
3269    (periods, confidences, top_acf)
3270}
3271
3272fn empty_cfd_result() -> CfdAutoperiodResult {
3273    CfdAutoperiodResult {
3274        period: f64::NAN,
3275        confidence: 0.0,
3276        acf_validation: 0.0,
3277        periods: Vec::new(),
3278        confidences: Vec::new(),
3279    }
3280}
3281
3282/// Extract spectral candidates from differenced data: difference, periodogram, peak-find, generate.
3283fn extract_cfd_spectral_candidates(data: &[f64], argvals: &[f64]) -> Option<Vec<(f64, f64)>> {
3284    let n = data.len();
3285    // Linear detrending instead of first-order differencing.
3286    // Differencing is a high-pass filter that attenuates long-period signals
3287    // by (2π/period)², making them undetectable. Linear detrending removes
3288    // the DC/trend component without any frequency-dependent distortion.
3289    let mean_x: f64 = argvals.iter().sum::<f64>() / n as f64;
3290    let mean_y: f64 = data.iter().sum::<f64>() / n as f64;
3291    let mut num = 0.0;
3292    let mut den = 0.0;
3293    for i in 0..n {
3294        let dx = argvals[i] - mean_x;
3295        num += dx * (data[i] - mean_y);
3296        den += dx * dx;
3297    }
3298    let slope = if den.abs() > 1e-15 { num / den } else { 0.0 };
3299    let detrended: Vec<f64> = data
3300        .iter()
3301        .zip(argvals.iter())
3302        .map(|(&y, &x)| y - (mean_y + slope * (x - mean_x)))
3303        .collect();
3304    let (frequencies, power) = periodogram(&detrended, argvals);
3305    if frequencies.len() < 3 {
3306        return None;
3307    }
3308    let power_no_dc: Vec<f64> = power.iter().skip(1).copied().collect();
3309    let peak_indices = find_spectral_peaks(&power_no_dc);
3310    if peak_indices.is_empty() {
3311        return None;
3312    }
3313    let candidates = generate_cfd_candidates(&frequencies, &power_no_dc, &peak_indices);
3314    if candidates.is_empty() {
3315        None
3316    } else {
3317        Some(candidates)
3318    }
3319}
3320
3321/// CFDAutoperiod: Clustered Filtered Detrended Autoperiod
3322///
3323/// Implements the CFDAutoperiod algorithm (Puech et al. 2020) which:
3324/// 1. Applies first-order differencing to remove trends
3325/// 2. Computes FFT on the detrended signal
3326/// 3. Identifies candidate periods from periodogram peaks
3327/// 4. Clusters nearby candidates using density-based clustering
3328/// 5. Validates cluster centers using ACF on the original signal
3329///
3330/// This method is particularly effective for signals with strong trends
3331/// and handles multiple periodicities by detecting clusters of candidate periods.
3332///
3333/// # Arguments
3334/// * `data` - Input signal (1D time series)
3335/// * `argvals` - Time points corresponding to data
3336/// * `cluster_tolerance` - Relative tolerance for clustering periods (default: 0.1 = 10%)
3337/// * `min_cluster_size` - Minimum number of candidates to form a cluster (default: 1)
3338///
3339/// # Returns
3340/// * `CfdAutoperiodResult` containing detected periods and validation scores
3341pub fn cfd_autoperiod(
3342    data: &[f64],
3343    argvals: &[f64],
3344    cluster_tolerance: Option<f64>,
3345    min_cluster_size: Option<usize>,
3346) -> CfdAutoperiodResult {
3347    let n = data.len();
3348    let tol = cluster_tolerance.unwrap_or(0.1);
3349    let min_size = min_cluster_size.unwrap_or(1);
3350
3351    if n < 8 || argvals.len() != n {
3352        return empty_cfd_result();
3353    }
3354
3355    let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
3356    let max_lag = (n / 2).max(4);
3357
3358    let Some(candidates) = extract_cfd_spectral_candidates(data, argvals) else {
3359        return empty_cfd_result();
3360    };
3361
3362    let clusters = cluster_periods(&candidates, tol, min_size);
3363    if clusters.is_empty() {
3364        return empty_cfd_result();
3365    }
3366
3367    let acf = autocorrelation(data, max_lag);
3368    let validated = validate_cfd_candidates(&clusters, &acf, dt);
3369    let validated = validate_or_fallback_cfd(validated, &candidates, tol, min_size);
3370    let (periods, confidences, top_acf) = rank_cfd_results(&validated);
3371
3372    CfdAutoperiodResult {
3373        period: periods[0],
3374        confidence: confidences[0],
3375        acf_validation: top_acf,
3376        periods,
3377        confidences,
3378    }
3379}
3380
3381/// CFDAutoperiod for functional data (matrix format)
3382pub fn cfd_autoperiod_fdata(
3383    data: &FdMatrix,
3384    argvals: &[f64],
3385    cluster_tolerance: Option<f64>,
3386    min_cluster_size: Option<usize>,
3387) -> CfdAutoperiodResult {
3388    let (n, m) = data.shape();
3389    if n == 0 || m < 8 || argvals.len() != m {
3390        return CfdAutoperiodResult {
3391            period: f64::NAN,
3392            confidence: 0.0,
3393            acf_validation: 0.0,
3394            periods: Vec::new(),
3395            confidences: Vec::new(),
3396        };
3397    }
3398
3399    let mean_curve = compute_mean_curve(data);
3400    cfd_autoperiod(&mean_curve, argvals, cluster_tolerance, min_cluster_size)
3401}
3402
3403/// Cluster periods using a simple density-based approach
3404fn cluster_periods(candidates: &[(f64, f64)], tolerance: f64, min_size: usize) -> Vec<(f64, f64)> {
3405    if candidates.is_empty() {
3406        return Vec::new();
3407    }
3408
3409    // Sort candidates by period
3410    let mut sorted: Vec<(f64, f64)> = candidates.to_vec();
3411    sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
3412
3413    let mut clusters: Vec<(f64, f64)> = Vec::new(); // (center, total_power)
3414    let mut current_cluster: Vec<(f64, f64)> = vec![sorted[0]];
3415
3416    for &(period, power) in sorted.iter().skip(1) {
3417        let cluster_center =
3418            current_cluster.iter().map(|(p, _)| p).sum::<f64>() / current_cluster.len() as f64;
3419
3420        let rel_diff = (period - cluster_center).abs() / cluster_center.max(period);
3421
3422        if rel_diff <= tolerance {
3423            // Add to current cluster
3424            current_cluster.push((period, power));
3425        } else {
3426            // Finish current cluster and start new one
3427            if current_cluster.len() >= min_size {
3428                let center = current_cluster.iter().map(|(p, pw)| p * pw).sum::<f64>()
3429                    / current_cluster
3430                        .iter()
3431                        .map(|(_, pw)| pw)
3432                        .sum::<f64>()
3433                        .max(1e-15);
3434                let total_power: f64 = current_cluster.iter().map(|(_, pw)| pw).sum();
3435                clusters.push((center, total_power));
3436            }
3437            current_cluster = vec![(period, power)];
3438        }
3439    }
3440
3441    // Don't forget the last cluster
3442    if current_cluster.len() >= min_size {
3443        let center = current_cluster.iter().map(|(p, pw)| p * pw).sum::<f64>()
3444            / current_cluster
3445                .iter()
3446                .map(|(_, pw)| pw)
3447                .sum::<f64>()
3448                .max(1e-15);
3449        let total_power: f64 = current_cluster.iter().map(|(_, pw)| pw).sum();
3450        clusters.push((center, total_power));
3451    }
3452
3453    clusters
3454}
3455
3456// ============================================================================
3457// Lomb-Scargle Periodogram
3458// ============================================================================
3459
3460/// Result of Lomb-Scargle periodogram analysis.
3461#[derive(Debug, Clone)]
3462pub struct LombScargleResult {
3463    /// Test frequencies
3464    pub frequencies: Vec<f64>,
3465    /// Corresponding periods (1/frequency)
3466    pub periods: Vec<f64>,
3467    /// Normalized Lomb-Scargle power at each frequency
3468    pub power: Vec<f64>,
3469    /// Peak period (highest power)
3470    pub peak_period: f64,
3471    /// Peak frequency
3472    pub peak_frequency: f64,
3473    /// Peak power
3474    pub peak_power: f64,
3475    /// False alarm probability at peak (significance level)
3476    pub false_alarm_probability: f64,
3477    /// Significance level (1 - FAP)
3478    pub significance: f64,
3479}
3480
3481/// Compute Lomb-Scargle periodogram for irregularly sampled data.
3482///
3483/// The Lomb-Scargle periodogram is designed for unevenly-spaced time series
3484/// and reduces to the standard periodogram for evenly-spaced data.
3485///
3486/// # Algorithm
3487/// Following Scargle (1982) and Horne & Baliunas (1986):
3488/// 1. For each test frequency ω, compute the phase shift τ
3489/// 2. Compute the normalized power P(ω)
3490/// 3. Estimate false alarm probability using the exponential distribution
3491///
3492/// # Arguments
3493/// * `times` - Observation times (not necessarily evenly spaced)
3494/// * `values` - Observed values at each time
3495/// * `frequencies` - Optional frequencies to evaluate (cycles per unit time).
3496///   If None, automatically generates a frequency grid.
3497/// * `oversampling` - Oversampling factor for auto-generated frequency grid.
3498///   Default: 4.0. Higher values give finer frequency resolution.
3499/// * `nyquist_factor` - Maximum frequency as multiple of pseudo-Nyquist.
3500///   Default: 1.0.
3501///
3502/// # Returns
3503/// `LombScargleResult` with power spectrum and significance estimates.
3504///
3505/// # Example
3506/// ```rust
3507/// use fdars_core::seasonal::lomb_scargle;
3508/// use std::f64::consts::PI;
3509///
3510/// // Irregularly sampled sine wave
3511/// let times: Vec<f64> = vec![0.0, 0.3, 0.7, 1.2, 1.5, 2.1, 2.8, 3.0, 3.5, 4.0];
3512/// let period = 1.5;
3513/// let values: Vec<f64> = times.iter()
3514///     .map(|&t| (2.0 * PI * t / period).sin())
3515///     .collect();
3516///
3517/// let result = lomb_scargle(&times, &values, None, None, None);
3518/// assert!((result.peak_period - period).abs() < 0.2);
3519/// ```
3520pub fn lomb_scargle(
3521    times: &[f64],
3522    values: &[f64],
3523    frequencies: Option<&[f64]>,
3524    oversampling: Option<f64>,
3525    nyquist_factor: Option<f64>,
3526) -> LombScargleResult {
3527    let n = times.len();
3528    if n != values.len() || n < 3 {
3529        return LombScargleResult {
3530            frequencies: vec![],
3531            periods: vec![],
3532            power: vec![],
3533            peak_period: f64::NAN,
3534            peak_frequency: f64::NAN,
3535            peak_power: f64::NAN,
3536            false_alarm_probability: f64::NAN,
3537            significance: f64::NAN,
3538        };
3539    }
3540
3541    // Compute mean and variance
3542    let mean_y: f64 = values.iter().sum::<f64>() / n as f64;
3543    let var_y: f64 = values.iter().map(|&y| (y - mean_y).powi(2)).sum::<f64>() / (n - 1) as f64;
3544
3545    // Generate frequency grid if not provided
3546    let freq_vec: Vec<f64>;
3547    let freqs = if let Some(f) = frequencies {
3548        f
3549    } else {
3550        freq_vec = generate_ls_frequencies(
3551            times,
3552            oversampling.unwrap_or(4.0),
3553            nyquist_factor.unwrap_or(1.0),
3554        );
3555        &freq_vec
3556    };
3557
3558    // Compute Lomb-Scargle power at each frequency
3559    let mut power = Vec::with_capacity(freqs.len());
3560
3561    for &freq in freqs.iter() {
3562        let omega = 2.0 * PI * freq;
3563        let p = lomb_scargle_single_freq(times, values, mean_y, var_y, omega);
3564        power.push(p);
3565    }
3566
3567    // Find peak
3568    let (peak_idx, &peak_power) = power
3569        .iter()
3570        .enumerate()
3571        .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
3572        .unwrap_or((0, &0.0));
3573
3574    let peak_frequency = freqs.get(peak_idx).copied().unwrap_or(0.0);
3575    let peak_period = if peak_frequency > 0.0 {
3576        1.0 / peak_frequency
3577    } else {
3578        f64::INFINITY
3579    };
3580
3581    // Compute false alarm probability
3582    let n_indep = estimate_independent_frequencies(times, freqs.len());
3583    let fap = lomb_scargle_fap(peak_power, n_indep, n);
3584
3585    // Compute periods from frequencies
3586    let periods: Vec<f64> = freqs
3587        .iter()
3588        .map(|&f| if f > 0.0 { 1.0 / f } else { f64::INFINITY })
3589        .collect();
3590
3591    LombScargleResult {
3592        frequencies: freqs.to_vec(),
3593        periods,
3594        power,
3595        peak_period,
3596        peak_frequency,
3597        peak_power,
3598        false_alarm_probability: fap,
3599        significance: 1.0 - fap,
3600    }
3601}
3602
3603/// Compute Lomb-Scargle power at a single frequency.
3604///
3605/// Uses the Scargle (1982) normalization.
3606fn lomb_scargle_single_freq(
3607    times: &[f64],
3608    values: &[f64],
3609    mean_y: f64,
3610    var_y: f64,
3611    omega: f64,
3612) -> f64 {
3613    if var_y <= 0.0 || omega <= 0.0 {
3614        return 0.0;
3615    }
3616
3617    let n = times.len();
3618
3619    // Compute tau (phase shift) to make sine and cosine terms orthogonal
3620    let mut sum_sin2 = 0.0;
3621    let mut sum_cos2 = 0.0;
3622    for &t in times.iter() {
3623        let arg = 2.0 * omega * t;
3624        sum_sin2 += arg.sin();
3625        sum_cos2 += arg.cos();
3626    }
3627    let tau = (sum_sin2).atan2(sum_cos2) / (2.0 * omega);
3628
3629    // Compute sums for power calculation
3630    let mut ss = 0.0; // Sum of sin terms
3631    let mut sc = 0.0; // Sum of cos terms
3632    let mut css = 0.0; // Sum of cos^2
3633    let mut sss = 0.0; // Sum of sin^2
3634
3635    for i in 0..n {
3636        let y_centered = values[i] - mean_y;
3637        let arg = omega * (times[i] - tau);
3638        let c = arg.cos();
3639        let s = arg.sin();
3640
3641        sc += y_centered * c;
3642        ss += y_centered * s;
3643        css += c * c;
3644        sss += s * s;
3645    }
3646
3647    // Avoid division by zero
3648    let css = css.max(1e-15);
3649    let sss = sss.max(1e-15);
3650
3651    // Lomb-Scargle power (Scargle 1982 normalization)
3652    0.5 * (sc * sc / css + ss * ss / sss) / var_y
3653}
3654
3655/// Generate frequency grid for Lomb-Scargle.
3656///
3657/// The grid spans from 1/T_total to f_nyquist with oversampling.
3658fn generate_ls_frequencies(times: &[f64], oversampling: f64, nyquist_factor: f64) -> Vec<f64> {
3659    let n = times.len();
3660    if n < 2 {
3661        return vec![0.0];
3662    }
3663
3664    // Time span
3665    let t_min = times.iter().cloned().fold(f64::INFINITY, f64::min);
3666    let t_max = times.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
3667    let t_span = (t_max - t_min).max(1e-10);
3668
3669    // Minimum frequency: one cycle over the observation span
3670    let f_min = 1.0 / t_span;
3671
3672    // Pseudo-Nyquist frequency for irregular data
3673    // Use average sampling rate as approximation
3674    let f_nyquist = 0.5 * (n - 1) as f64 / t_span;
3675
3676    // Maximum frequency
3677    let f_max = f_nyquist * nyquist_factor;
3678
3679    // Frequency resolution with oversampling
3680    let df = f_min / oversampling;
3681
3682    // Generate frequency grid
3683    let n_freq = ((f_max - f_min) / df).ceil() as usize + 1;
3684    let n_freq = n_freq.min(10000); // Cap to prevent memory issues
3685
3686    (0..n_freq).map(|i| f_min + i as f64 * df).collect()
3687}
3688
3689/// Estimate number of independent frequencies (for FAP calculation).
3690///
3691/// For irregularly sampled data, this is approximately the number of
3692/// data points (Horne & Baliunas 1986).
3693fn estimate_independent_frequencies(times: &[f64], n_freq: usize) -> usize {
3694    // A conservative estimate is min(n_data, n_frequencies)
3695    let n = times.len();
3696    n.min(n_freq)
3697}
3698
3699/// Compute false alarm probability for Lomb-Scargle peak.
3700///
3701/// Uses the exponential distribution approximation:
3702/// FAP ≈ 1 - (1 - exp(-z))^M
3703/// where z is the power and M is the number of independent frequencies.
3704fn lomb_scargle_fap(power: f64, n_indep: usize, _n_data: usize) -> f64 {
3705    if power <= 0.0 || n_indep == 0 {
3706        return 1.0;
3707    }
3708
3709    // Probability that a single frequency has power < z
3710    let prob_single = 1.0 - (-power).exp();
3711
3712    // Probability that all M frequencies have power < z
3713    // FAP = 1 - (1 - exp(-z))^M
3714    // For numerical stability, use log:
3715    // 1 - FAP = prob_single^M
3716    // FAP = 1 - exp(M * ln(prob_single))
3717
3718    if prob_single >= 1.0 {
3719        return 0.0; // Very significant
3720    }
3721    if prob_single <= 0.0 {
3722        return 1.0; // Not significant
3723    }
3724
3725    let log_prob = prob_single.ln();
3726    let log_cdf = n_indep as f64 * log_prob;
3727
3728    if log_cdf < -700.0 {
3729        0.0 // Numerical underflow, very significant
3730    } else {
3731        1.0 - log_cdf.exp()
3732    }
3733}
3734
3735/// Compute Lomb-Scargle periodogram for functional data (multiple curves).
3736///
3737/// Computes the periodogram for each curve and returns the result for the
3738/// mean curve or ensemble statistics.
3739///
3740/// # Arguments
3741/// * `data` - Column-major matrix (n x m) of functional data
3742/// * `n` - Number of samples (rows)
3743/// * `m` - Number of evaluation points (columns)
3744/// * `argvals` - Time points of length m
3745/// * `oversampling` - Oversampling factor. Default: 4.0
3746/// * `nyquist_factor` - Maximum frequency multiplier. Default: 1.0
3747///
3748/// # Returns
3749/// `LombScargleResult` computed from the mean curve.
3750pub fn lomb_scargle_fdata(
3751    data: &FdMatrix,
3752    argvals: &[f64],
3753    oversampling: Option<f64>,
3754    nyquist_factor: Option<f64>,
3755) -> LombScargleResult {
3756    // Compute mean curve
3757    let mean_curve = compute_mean_curve(data);
3758
3759    // Run Lomb-Scargle on mean curve
3760    lomb_scargle(argvals, &mean_curve, None, oversampling, nyquist_factor)
3761}
3762
3763// ============================================================================
3764// Matrix Profile (STOMP Algorithm)
3765// ============================================================================
3766
3767/// Result of Matrix Profile computation.
3768#[derive(Debug, Clone)]
3769pub struct MatrixProfileResult {
3770    /// The matrix profile (minimum z-normalized distance for each position)
3771    pub profile: Vec<f64>,
3772    /// Index of the nearest neighbor for each position
3773    pub profile_index: Vec<usize>,
3774    /// Subsequence length used
3775    pub subsequence_length: usize,
3776    /// Detected periods from arc analysis
3777    pub detected_periods: Vec<f64>,
3778    /// Arc counts at each index distance (for period detection)
3779    pub arc_counts: Vec<usize>,
3780    /// Most prominent detected period
3781    pub primary_period: f64,
3782    /// Confidence score for primary period (based on arc prominence)
3783    pub confidence: f64,
3784}
3785
3786/// Compute Matrix Profile using STOMP algorithm (Scalable Time series Ordered-search Matrix Profile).
3787///
3788/// The Matrix Profile is a data structure that stores the z-normalized Euclidean distance
3789/// between each subsequence of a time series and its nearest neighbor. It enables efficient
3790/// motif discovery and anomaly detection.
3791///
3792/// # Algorithm (STOMP - Zhu et al. 2016)
3793/// 1. Pre-compute sliding mean and standard deviation using cumulative sums
3794/// 2. Use FFT to compute first row of distance matrix
3795/// 3. Update subsequent rows incrementally using the dot product update rule
3796/// 4. Track minimum distance and index at each position
3797///
3798/// # Arguments
3799/// * `values` - Time series values
3800/// * `subsequence_length` - Length of subsequences to compare (window size)
3801/// * `exclusion_zone` - Fraction of subsequence length to exclude around each position
3802///   to prevent trivial self-matches. Default: 0.5
3803///
3804/// # Returns
3805/// `MatrixProfileResult` with profile, indices, and detected periods.
3806///
3807/// # Example
3808/// ```rust
3809/// use fdars_core::seasonal::matrix_profile;
3810/// use std::f64::consts::PI;
3811///
3812/// // Periodic signal
3813/// let period = 20.0;
3814/// let values: Vec<f64> = (0..100)
3815///     .map(|i| (2.0 * PI * i as f64 / period).sin())
3816///     .collect();
3817///
3818/// let result = matrix_profile(&values, Some(15), None);
3819/// assert!((result.primary_period - period).abs() < 5.0);
3820/// ```
3821pub fn matrix_profile(
3822    values: &[f64],
3823    subsequence_length: Option<usize>,
3824    exclusion_zone: Option<f64>,
3825) -> MatrixProfileResult {
3826    let n = values.len();
3827
3828    // Default subsequence length: ~ 1/4 of series length, capped at reasonable range
3829    let m = subsequence_length.unwrap_or_else(|| {
3830        let default_m = n / 4;
3831        default_m.max(4).min(n / 2)
3832    });
3833
3834    if m < 3 || m > n / 2 {
3835        return MatrixProfileResult {
3836            profile: vec![],
3837            profile_index: vec![],
3838            subsequence_length: m,
3839            detected_periods: vec![],
3840            arc_counts: vec![],
3841            primary_period: f64::NAN,
3842            confidence: 0.0,
3843        };
3844    }
3845
3846    let exclusion_zone = exclusion_zone.unwrap_or(0.5);
3847    let exclusion_radius = (m as f64 * exclusion_zone).ceil() as usize;
3848
3849    // Number of subsequences
3850    let profile_len = n - m + 1;
3851
3852    // Compute sliding statistics
3853    let (means, stds) = compute_sliding_stats(values, m);
3854
3855    // Compute the matrix profile using STOMP
3856    let (profile, profile_index) = stomp_core(values, m, &means, &stds, exclusion_radius);
3857
3858    // Perform arc analysis to detect periods
3859    let (arc_counts, detected_periods, primary_period, confidence) =
3860        analyze_arcs(&profile_index, profile_len, m);
3861
3862    MatrixProfileResult {
3863        profile,
3864        profile_index,
3865        subsequence_length: m,
3866        detected_periods,
3867        arc_counts,
3868        primary_period,
3869        confidence,
3870    }
3871}
3872
3873/// Compute sliding mean and standard deviation using cumulative sums.
3874///
3875/// This is O(n) and avoids numerical issues with naive implementations.
3876fn compute_sliding_stats(values: &[f64], m: usize) -> (Vec<f64>, Vec<f64>) {
3877    let n = values.len();
3878    let profile_len = n - m + 1;
3879
3880    // Compute cumulative sums
3881    let mut cumsum = vec![0.0; n + 1];
3882    let mut cumsum_sq = vec![0.0; n + 1];
3883
3884    for i in 0..n {
3885        cumsum[i + 1] = cumsum[i] + values[i];
3886        cumsum_sq[i + 1] = cumsum_sq[i] + values[i] * values[i];
3887    }
3888
3889    // Compute means and stds
3890    let mut means = Vec::with_capacity(profile_len);
3891    let mut stds = Vec::with_capacity(profile_len);
3892
3893    let m_f64 = m as f64;
3894
3895    for i in 0..profile_len {
3896        let sum = cumsum[i + m] - cumsum[i];
3897        let sum_sq = cumsum_sq[i + m] - cumsum_sq[i];
3898
3899        let mean = sum / m_f64;
3900        let variance = (sum_sq / m_f64) - mean * mean;
3901        let std = variance.max(0.0).sqrt();
3902
3903        means.push(mean);
3904        stds.push(std.max(1e-10)); // Prevent division by zero
3905    }
3906
3907    (means, stds)
3908}
3909
3910/// Core STOMP algorithm implementation.
3911///
3912/// Uses FFT for the first row and incremental updates for subsequent rows.
3913fn stomp_core(
3914    values: &[f64],
3915    m: usize,
3916    means: &[f64],
3917    stds: &[f64],
3918    exclusion_radius: usize,
3919) -> (Vec<f64>, Vec<usize>) {
3920    let n = values.len();
3921    let profile_len = n - m + 1;
3922
3923    // Initialize profile with infinity and index with 0
3924    let mut profile = vec![f64::INFINITY; profile_len];
3925    let mut profile_index = vec![0usize; profile_len];
3926
3927    // Compute first row using direct computation (could use FFT for large n)
3928    // QT[0,j] = sum(T[0:m] * T[j:j+m]) for each j
3929    let mut qt = vec![0.0; profile_len];
3930
3931    // First query subsequence
3932    for j in 0..profile_len {
3933        let mut dot = 0.0;
3934        for k in 0..m {
3935            dot += values[k] * values[j + k];
3936        }
3937        qt[j] = dot;
3938    }
3939
3940    // Process first row
3941    update_profile_row(
3942        0,
3943        &qt,
3944        means,
3945        stds,
3946        m,
3947        exclusion_radius,
3948        &mut profile,
3949        &mut profile_index,
3950    );
3951
3952    // Process subsequent rows using incremental updates
3953    for i in 1..profile_len {
3954        // Update QT using the sliding dot product update
3955        // QT[i,j] = QT[i-1,j-1] - T[i-1]*T[j-1] + T[i+m-1]*T[j+m-1]
3956        let mut qt_new = vec![0.0; profile_len];
3957
3958        // First element needs direct computation
3959        let mut dot = 0.0;
3960        for k in 0..m {
3961            dot += values[i + k] * values[k];
3962        }
3963        qt_new[0] = dot;
3964
3965        // Update rest using incremental formula
3966        for j in 1..profile_len {
3967            qt_new[j] =
3968                qt[j - 1] - values[i - 1] * values[j - 1] + values[i + m - 1] * values[j + m - 1];
3969        }
3970
3971        qt = qt_new;
3972
3973        // Update profile with this row
3974        update_profile_row(
3975            i,
3976            &qt,
3977            means,
3978            stds,
3979            m,
3980            exclusion_radius,
3981            &mut profile,
3982            &mut profile_index,
3983        );
3984    }
3985
3986    (profile, profile_index)
3987}
3988
3989/// Update profile with distances from row i.
3990fn update_profile_row(
3991    i: usize,
3992    qt: &[f64],
3993    means: &[f64],
3994    stds: &[f64],
3995    m: usize,
3996    exclusion_radius: usize,
3997    profile: &mut [f64],
3998    profile_index: &mut [usize],
3999) {
4000    let profile_len = profile.len();
4001    let m_f64 = m as f64;
4002
4003    for j in 0..profile_len {
4004        // Skip exclusion zone
4005        if i.abs_diff(j) <= exclusion_radius {
4006            continue;
4007        }
4008
4009        // Compute z-normalized distance
4010        // d = sqrt(2*m * (1 - (QT - m*mu_i*mu_j) / (m * sigma_i * sigma_j)))
4011        let numerator = qt[j] - m_f64 * means[i] * means[j];
4012        let denominator = m_f64 * stds[i] * stds[j];
4013
4014        let pearson = if denominator > 0.0 {
4015            (numerator / denominator).clamp(-1.0, 1.0)
4016        } else {
4017            0.0
4018        };
4019
4020        let dist_sq = 2.0 * m_f64 * (1.0 - pearson);
4021        let dist = dist_sq.max(0.0).sqrt();
4022
4023        // Update profile for position i
4024        if dist < profile[i] {
4025            profile[i] = dist;
4026            profile_index[i] = j;
4027        }
4028
4029        // Update profile for position j (symmetric)
4030        if dist < profile[j] {
4031            profile[j] = dist;
4032            profile_index[j] = i;
4033        }
4034    }
4035}
4036
4037/// Analyze profile index to detect periods using arc counting.
4038///
4039/// Arcs connect each position to its nearest neighbor. The distance between
4040/// connected positions reveals repeating patterns (periods).
4041fn analyze_arcs(
4042    profile_index: &[usize],
4043    profile_len: usize,
4044    m: usize,
4045) -> (Vec<usize>, Vec<f64>, f64, f64) {
4046    // Count arcs at each index distance
4047    let max_distance = profile_len;
4048    let mut arc_counts = vec![0usize; max_distance];
4049
4050    for (i, &j) in profile_index.iter().enumerate() {
4051        let distance = i.abs_diff(j);
4052        if distance < max_distance {
4053            arc_counts[distance] += 1;
4054        }
4055    }
4056
4057    // Find peaks in arc counts (candidate periods)
4058    let min_period = m / 2; // Minimum meaningful period
4059    let mut peaks: Vec<(usize, usize)> = Vec::new();
4060
4061    // Simple peak detection with minimum spacing
4062    for i in min_period..arc_counts.len().saturating_sub(1) {
4063        if arc_counts[i] > arc_counts[i.saturating_sub(1)]
4064            && arc_counts[i] > arc_counts[(i + 1).min(arc_counts.len() - 1)]
4065            && arc_counts[i] >= 3
4066        // Minimum count threshold
4067        {
4068            peaks.push((i, arc_counts[i]));
4069        }
4070    }
4071
4072    // Sort by count descending
4073    peaks.sort_by(|a, b| b.1.cmp(&a.1));
4074
4075    // Extract top periods
4076    let detected_periods: Vec<f64> = peaks.iter().take(5).map(|(p, _)| *p as f64).collect();
4077
4078    // Primary period and confidence
4079    let (primary_period, confidence) = if let Some(&(period, count)) = peaks.first() {
4080        // Confidence based on relative peak prominence
4081        let total_arcs: usize = arc_counts[min_period..].iter().sum();
4082        let conf = if total_arcs > 0 {
4083            count as f64 / total_arcs as f64
4084        } else {
4085            0.0
4086        };
4087        (period as f64, conf.min(1.0))
4088    } else {
4089        (0.0, 0.0)
4090    };
4091
4092    (arc_counts, detected_periods, primary_period, confidence)
4093}
4094
4095/// Compute Matrix Profile for functional data (multiple curves).
4096///
4097/// Computes the matrix profile for each curve and returns aggregated results.
4098///
4099/// # Arguments
4100/// * `data` - Column-major matrix (n x m) of functional data
4101/// * `n` - Number of samples (rows)
4102/// * `m` - Number of evaluation points (columns)
4103/// * `subsequence_length` - Length of subsequences. If None, automatically determined.
4104/// * `exclusion_zone` - Exclusion zone fraction. Default: 0.5
4105///
4106/// # Returns
4107/// `MatrixProfileResult` computed from the mean curve.
4108pub fn matrix_profile_fdata(
4109    data: &FdMatrix,
4110    subsequence_length: Option<usize>,
4111    exclusion_zone: Option<f64>,
4112) -> MatrixProfileResult {
4113    // Compute mean curve
4114    let mean_curve = compute_mean_curve(data);
4115
4116    // Run matrix profile on mean curve
4117    matrix_profile(&mean_curve, subsequence_length, exclusion_zone)
4118}
4119
4120/// Detect seasonality using Matrix Profile analysis.
4121///
4122/// Returns true if significant periodicity is detected based on matrix profile analysis.
4123///
4124/// # Arguments
4125/// * `values` - Time series values
4126/// * `subsequence_length` - Length of subsequences to compare
4127/// * `confidence_threshold` - Minimum confidence for positive detection. Default: 0.1
4128///
4129/// # Returns
4130/// Tuple of (is_seasonal, detected_period, confidence)
4131pub fn matrix_profile_seasonality(
4132    values: &[f64],
4133    subsequence_length: Option<usize>,
4134    confidence_threshold: Option<f64>,
4135) -> (bool, f64, f64) {
4136    let result = matrix_profile(values, subsequence_length, None);
4137
4138    let threshold = confidence_threshold.unwrap_or(0.1);
4139    let is_seasonal = result.confidence >= threshold && result.primary_period > 0.0;
4140
4141    (is_seasonal, result.primary_period, result.confidence)
4142}
4143
4144// ============================================================================
4145// Singular Spectrum Analysis (SSA)
4146// ============================================================================
4147
4148/// Result of Singular Spectrum Analysis.
4149#[derive(Debug, Clone)]
4150pub struct SsaResult {
4151    /// Reconstructed trend component
4152    pub trend: Vec<f64>,
4153    /// Reconstructed seasonal/periodic component
4154    pub seasonal: Vec<f64>,
4155    /// Noise/residual component
4156    pub noise: Vec<f64>,
4157    /// Singular values from SVD (sorted descending)
4158    pub singular_values: Vec<f64>,
4159    /// Contribution of each component (proportion of variance)
4160    pub contributions: Vec<f64>,
4161    /// Window length used for embedding
4162    pub window_length: usize,
4163    /// Number of components extracted
4164    pub n_components: usize,
4165    /// Detected period (if any significant periodicity found)
4166    pub detected_period: f64,
4167    /// Confidence score for detected period
4168    pub confidence: f64,
4169}
4170
4171/// Singular Spectrum Analysis (SSA) for time series decomposition.
4172///
4173/// SSA is a model-free, non-parametric method for decomposing a time series
4174/// into trend, oscillatory (seasonal), and noise components using singular
4175/// value decomposition of the trajectory matrix.
4176///
4177/// # Algorithm
4178/// 1. **Embedding**: Convert series into trajectory matrix using sliding windows
4179/// 2. **Decomposition**: SVD of trajectory matrix
4180/// 3. **Grouping**: Identify trend vs. periodic vs. noise components
4181/// 4. **Reconstruction**: Diagonal averaging to recover time series
4182///
4183/// # Arguments
4184/// * `values` - Time series values
4185/// * `window_length` - Embedding window length (L). If None, uses L = min(n/2, 50).
4186///   Larger values capture longer-term patterns but need longer series.
4187/// * `n_components` - Number of components to extract. If None, uses 10.
4188/// * `trend_components` - Indices of components for trend (0-based). If None, auto-detect.
4189/// * `seasonal_components` - Indices of components for seasonal. If None, auto-detect.
4190///
4191/// # Returns
4192/// `SsaResult` with decomposed components and diagnostics.
4193///
4194/// # Example
4195/// ```rust
4196/// use fdars_core::seasonal::ssa;
4197/// use std::f64::consts::PI;
4198///
4199/// // Signal with trend + seasonal + noise
4200/// let n = 100;
4201/// let values: Vec<f64> = (0..n)
4202///     .map(|i| {
4203///         let t = i as f64;
4204///         0.01 * t + (2.0 * PI * t / 12.0).sin() + 0.1 * (i as f64 * 0.1).sin()
4205///     })
4206///     .collect();
4207///
4208/// let result = ssa(&values, None, None, None, None);
4209/// assert!(result.detected_period > 0.0);
4210/// ```
4211pub fn ssa(
4212    values: &[f64],
4213    window_length: Option<usize>,
4214    n_components: Option<usize>,
4215    trend_components: Option<&[usize]>,
4216    seasonal_components: Option<&[usize]>,
4217) -> SsaResult {
4218    let n = values.len();
4219
4220    // Default window length: min(n/2, 50)
4221    let l = window_length.unwrap_or_else(|| (n / 2).clamp(2, 50));
4222
4223    if n < 4 || l < 2 || l > n / 2 {
4224        return SsaResult {
4225            trend: values.to_vec(),
4226            seasonal: vec![0.0; n],
4227            noise: vec![0.0; n],
4228            singular_values: vec![],
4229            contributions: vec![],
4230            window_length: l,
4231            n_components: 0,
4232            detected_period: 0.0,
4233            confidence: 0.0,
4234        };
4235    }
4236
4237    // Number of columns in trajectory matrix
4238    let k = n - l + 1;
4239
4240    // Step 1: Embedding - create trajectory matrix (L x K)
4241    let trajectory = embed_trajectory(values, l, k);
4242
4243    // Step 2: SVD decomposition
4244    let (u, sigma, vt) = svd_decompose(&trajectory, l, k);
4245
4246    // Determine number of components to use
4247    let max_components = sigma.len();
4248    let n_comp = n_components.unwrap_or(10).min(max_components);
4249
4250    // Compute contributions (proportion of total variance)
4251    let total_var: f64 = sigma.iter().map(|&s| s * s).sum();
4252    let contributions: Vec<f64> = sigma
4253        .iter()
4254        .take(n_comp)
4255        .map(|&s| s * s / total_var.max(1e-15))
4256        .collect();
4257
4258    // Step 3: Grouping - identify trend and seasonal components
4259    let (trend_idx, seasonal_idx, detected_period, confidence) =
4260        if trend_components.is_some() || seasonal_components.is_some() {
4261            // Use provided groupings
4262            let t_idx: Vec<usize> = trend_components.map(|v| v.to_vec()).unwrap_or_default();
4263            let s_idx: Vec<usize> = seasonal_components.map(|v| v.to_vec()).unwrap_or_default();
4264            (t_idx, s_idx, 0.0, 0.0)
4265        } else {
4266            // Auto-detect groupings
4267            auto_group_ssa_components(&u, &sigma, l, k, n_comp)
4268        };
4269
4270    // Step 4: Reconstruction via diagonal averaging
4271    let trend = reconstruct_grouped(&u, &sigma, &vt, l, k, n, &trend_idx);
4272    let seasonal = reconstruct_grouped(&u, &sigma, &vt, l, k, n, &seasonal_idx);
4273
4274    // Noise is the remainder
4275    let noise: Vec<f64> = values
4276        .iter()
4277        .zip(trend.iter())
4278        .zip(seasonal.iter())
4279        .map(|((&y, &t), &s)| y - t - s)
4280        .collect();
4281
4282    SsaResult {
4283        trend,
4284        seasonal,
4285        noise,
4286        singular_values: sigma.into_iter().take(n_comp).collect(),
4287        contributions,
4288        window_length: l,
4289        n_components: n_comp,
4290        detected_period,
4291        confidence,
4292    }
4293}
4294
4295/// Create trajectory matrix by embedding the time series.
4296fn embed_trajectory(values: &[f64], l: usize, k: usize) -> Vec<f64> {
4297    // Trajectory matrix is L x K, stored column-major
4298    let mut trajectory = vec![0.0; l * k];
4299
4300    for j in 0..k {
4301        for i in 0..l {
4302            trajectory[i + j * l] = values[i + j];
4303        }
4304    }
4305
4306    trajectory
4307}
4308
4309/// SVD decomposition of trajectory matrix using nalgebra.
4310fn svd_decompose(trajectory: &[f64], l: usize, k: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
4311    use nalgebra::{DMatrix, SVD};
4312
4313    // Create nalgebra matrix (column-major)
4314    let mat = DMatrix::from_column_slice(l, k, trajectory);
4315
4316    // Compute SVD
4317    let svd = SVD::new(mat, true, true);
4318
4319    // Extract components (SVD::new with compute_u/v=true always produces both,
4320    // but handle gracefully in case of degenerate input)
4321    let u_mat = match svd.u {
4322        Some(u) => u,
4323        None => return (vec![], vec![], vec![]),
4324    };
4325    let vt_mat = match svd.v_t {
4326        Some(vt) => vt,
4327        None => return (vec![], vec![], vec![]),
4328    };
4329    let sigma = svd.singular_values;
4330
4331    // Convert to flat vectors
4332    let u: Vec<f64> = u_mat.iter().cloned().collect();
4333    let sigma_vec: Vec<f64> = sigma.iter().cloned().collect();
4334    let vt: Vec<f64> = vt_mat.iter().cloned().collect();
4335
4336    (u, sigma_vec, vt)
4337}
4338
4339enum SsaComponentKind {
4340    Trend,
4341    Seasonal(f64),
4342    Noise,
4343}
4344
4345/// Classify an SSA component as trend, seasonal, or noise.
4346fn classify_ssa_component(u_col: &[f64], trend_count: usize) -> SsaComponentKind {
4347    if is_trend_component(u_col) && trend_count < 2 {
4348        SsaComponentKind::Trend
4349    } else {
4350        let (is_periodic, period) = is_periodic_component(u_col);
4351        if is_periodic {
4352            SsaComponentKind::Seasonal(period)
4353        } else {
4354            SsaComponentKind::Noise
4355        }
4356    }
4357}
4358
4359/// Apply default groupings when auto-detection finds nothing.
4360fn apply_ssa_grouping_defaults(
4361    trend_idx: &mut Vec<usize>,
4362    seasonal_idx: &mut Vec<usize>,
4363    n_comp: usize,
4364) {
4365    if trend_idx.is_empty() && n_comp > 0 {
4366        trend_idx.push(0);
4367    }
4368    if seasonal_idx.is_empty() && n_comp >= 3 {
4369        seasonal_idx.push(1);
4370        if n_comp > 2 {
4371            seasonal_idx.push(2);
4372        }
4373    }
4374}
4375
4376/// Auto-detect trend and seasonal component groupings.
4377fn auto_group_ssa_components(
4378    u: &[f64],
4379    sigma: &[f64],
4380    l: usize,
4381    _k: usize,
4382    n_comp: usize,
4383) -> (Vec<usize>, Vec<usize>, f64, f64) {
4384    let mut trend_idx = Vec::new();
4385    let mut seasonal_idx = Vec::new();
4386    let mut detected_period = 0.0;
4387    let mut confidence = 0.0;
4388
4389    for i in 0..n_comp.min(sigma.len()) {
4390        let u_col: Vec<f64> = (0..l).map(|j| u[j + i * l]).collect();
4391        match classify_ssa_component(&u_col, trend_idx.len()) {
4392            SsaComponentKind::Trend => trend_idx.push(i),
4393            SsaComponentKind::Seasonal(period) => {
4394                seasonal_idx.push(i);
4395                if detected_period == 0.0 && period > 0.0 {
4396                    detected_period = period;
4397                    confidence = sigma[i] / sigma[0].max(1e-15);
4398                }
4399            }
4400            SsaComponentKind::Noise => {}
4401        }
4402    }
4403
4404    apply_ssa_grouping_defaults(&mut trend_idx, &mut seasonal_idx, n_comp);
4405    (trend_idx, seasonal_idx, detected_period, confidence)
4406}
4407
4408/// Check if a singular vector represents a trend component.
4409fn is_trend_component(u_col: &[f64]) -> bool {
4410    let n = u_col.len();
4411    if n < 3 {
4412        return false;
4413    }
4414
4415    // Count sign changes in the vector
4416    let mut sign_changes = 0;
4417    for i in 1..n {
4418        if u_col[i] * u_col[i - 1] < 0.0 {
4419            sign_changes += 1;
4420        }
4421    }
4422
4423    // Trend components have very few sign changes
4424    sign_changes <= n / 10
4425}
4426
4427/// Check if a singular vector represents a periodic component.
4428fn is_periodic_component(u_col: &[f64]) -> (bool, f64) {
4429    let n = u_col.len();
4430    if n < 4 {
4431        return (false, 0.0);
4432    }
4433
4434    // Use autocorrelation to detect periodicity
4435    let mean: f64 = u_col.iter().sum::<f64>() / n as f64;
4436    let centered: Vec<f64> = u_col.iter().map(|&x| x - mean).collect();
4437
4438    let var: f64 = centered.iter().map(|&x| x * x).sum();
4439    if var < 1e-15 {
4440        return (false, 0.0);
4441    }
4442
4443    // Find first significant peak in autocorrelation
4444    let mut best_period = 0.0;
4445    let mut best_acf = 0.0;
4446
4447    for lag in 2..n / 2 {
4448        let mut acf = 0.0;
4449        for i in 0..(n - lag) {
4450            acf += centered[i] * centered[i + lag];
4451        }
4452        acf /= var;
4453
4454        if acf > best_acf && acf > 0.3 {
4455            best_acf = acf;
4456            best_period = lag as f64;
4457        }
4458    }
4459
4460    let is_periodic = best_acf > 0.3 && best_period > 0.0;
4461    (is_periodic, best_period)
4462}
4463
4464/// Reconstruct time series from grouped components via diagonal averaging.
4465fn reconstruct_grouped(
4466    u: &[f64],
4467    sigma: &[f64],
4468    vt: &[f64],
4469    l: usize,
4470    k: usize,
4471    n: usize,
4472    group_idx: &[usize],
4473) -> Vec<f64> {
4474    if group_idx.is_empty() {
4475        return vec![0.0; n];
4476    }
4477
4478    // Sum of rank-1 matrices for this group
4479    let mut grouped_matrix = vec![0.0; l * k];
4480
4481    for &idx in group_idx {
4482        if idx >= sigma.len() {
4483            continue;
4484        }
4485
4486        let s = sigma[idx];
4487
4488        // Add s * u_i * v_i^T
4489        for j in 0..k {
4490            for i in 0..l {
4491                let u_val = u[i + idx * l];
4492                let v_val = vt[idx + j * sigma.len().min(l)]; // v_t is stored as K x min(L,K)
4493                grouped_matrix[i + j * l] += s * u_val * v_val;
4494            }
4495        }
4496    }
4497
4498    // Diagonal averaging (Hankelization)
4499    diagonal_average(&grouped_matrix, l, k, n)
4500}
4501
4502/// Diagonal averaging to convert trajectory matrix back to time series.
4503fn diagonal_average(matrix: &[f64], l: usize, k: usize, n: usize) -> Vec<f64> {
4504    let mut result = vec![0.0; n];
4505    let mut counts = vec![0.0; n];
4506
4507    // Average along anti-diagonals
4508    for j in 0..k {
4509        for i in 0..l {
4510            let idx = i + j; // Position in original series
4511            if idx < n {
4512                result[idx] += matrix[i + j * l];
4513                counts[idx] += 1.0;
4514            }
4515        }
4516    }
4517
4518    // Normalize by counts
4519    for i in 0..n {
4520        if counts[i] > 0.0 {
4521            result[i] /= counts[i];
4522        }
4523    }
4524
4525    result
4526}
4527
4528/// Compute SSA for functional data (multiple curves).
4529///
4530/// # Arguments
4531/// * `data` - Column-major matrix (n x m) of functional data
4532/// * `n` - Number of samples (rows)
4533/// * `m` - Number of evaluation points (columns)
4534/// * `window_length` - SSA window length. If None, auto-determined.
4535/// * `n_components` - Number of SSA components. Default: 10.
4536///
4537/// # Returns
4538/// `SsaResult` computed from the mean curve.
4539pub fn ssa_fdata(
4540    data: &FdMatrix,
4541    window_length: Option<usize>,
4542    n_components: Option<usize>,
4543) -> SsaResult {
4544    // Compute mean curve
4545    let mean_curve = compute_mean_curve(data);
4546
4547    // Run SSA on mean curve
4548    ssa(&mean_curve, window_length, n_components, None, None)
4549}
4550
4551/// Detect seasonality using SSA.
4552///
4553/// # Arguments
4554/// * `values` - Time series values
4555/// * `window_length` - SSA window length
4556/// * `confidence_threshold` - Minimum confidence for positive detection
4557///
4558/// # Returns
4559/// Tuple of (is_seasonal, detected_period, confidence)
4560pub fn ssa_seasonality(
4561    values: &[f64],
4562    window_length: Option<usize>,
4563    confidence_threshold: Option<f64>,
4564) -> (bool, f64, f64) {
4565    let result = ssa(values, window_length, None, None, None);
4566
4567    let threshold = confidence_threshold.unwrap_or(0.1);
4568    let is_seasonal = result.confidence >= threshold && result.detected_period > 0.0;
4569
4570    (is_seasonal, result.detected_period, result.confidence)
4571}
4572
4573#[cfg(test)]
4574mod tests {
4575    use super::*;
4576    use std::f64::consts::PI;
4577
4578    fn generate_sine(n: usize, m: usize, period: f64, argvals: &[f64]) -> FdMatrix {
4579        let mut data = vec![0.0; n * m];
4580        for i in 0..n {
4581            for j in 0..m {
4582                data[i + j * n] = (2.0 * PI * argvals[j] / period).sin();
4583            }
4584        }
4585        FdMatrix::from_column_major(data, n, m).unwrap()
4586    }
4587
4588    #[test]
4589    fn test_period_estimation_fft() {
4590        let m = 200;
4591        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4592        let period = 2.0;
4593        let data = generate_sine(1, m, period, &argvals);
4594
4595        let estimate = estimate_period_fft(&data, &argvals);
4596        assert!((estimate.period - period).abs() < 0.2);
4597        assert!(estimate.confidence > 1.0);
4598    }
4599
4600    #[test]
4601    fn test_peak_detection() {
4602        let m = 100;
4603        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4604        let period = 2.0;
4605        let data = generate_sine(1, m, period, &argvals);
4606
4607        let result = detect_peaks(&data, &argvals, Some(1.5), None, false, None);
4608
4609        // Should find approximately 5 peaks (10 / 2)
4610        assert!(!result.peaks[0].is_empty());
4611        assert!((result.mean_period - period).abs() < 0.3);
4612    }
4613
4614    #[test]
4615    fn test_peak_detection_known_sine() {
4616        // Pure sine wave: sin(2*pi*t/2) on [0, 10]
4617        // Peaks occur at t = period/4 + k*period = 0.5, 2.5, 4.5, 6.5, 8.5
4618        let m = 200; // High resolution for accurate detection
4619        let period = 2.0;
4620        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4621        let data: Vec<f64> = argvals
4622            .iter()
4623            .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
4624            .collect();
4625        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4626
4627        let result = detect_peaks(&data, &argvals, None, None, false, None);
4628
4629        // Should find exactly 5 peaks
4630        assert_eq!(
4631            result.peaks[0].len(),
4632            5,
4633            "Expected 5 peaks, got {}. Peak times: {:?}",
4634            result.peaks[0].len(),
4635            result.peaks[0].iter().map(|p| p.time).collect::<Vec<_>>()
4636        );
4637
4638        // Check peak locations are close to expected
4639        let expected_times = [0.5, 2.5, 4.5, 6.5, 8.5];
4640        for (peak, expected) in result.peaks[0].iter().zip(expected_times.iter()) {
4641            assert!(
4642                (peak.time - expected).abs() < 0.15,
4643                "Peak at {:.3} not close to expected {:.3}",
4644                peak.time,
4645                expected
4646            );
4647        }
4648
4649        // Check mean period
4650        assert!(
4651            (result.mean_period - period).abs() < 0.1,
4652            "Mean period {:.3} not close to expected {:.3}",
4653            result.mean_period,
4654            period
4655        );
4656    }
4657
4658    #[test]
4659    fn test_peak_detection_with_min_distance() {
4660        // Same sine wave but with min_distance constraint
4661        let m = 200;
4662        let period = 2.0;
4663        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4664        let data: Vec<f64> = argvals
4665            .iter()
4666            .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
4667            .collect();
4668        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4669
4670        // min_distance = 1.5 should still find all 5 peaks (spacing = 2.0)
4671        let result = detect_peaks(&data, &argvals, Some(1.5), None, false, None);
4672        assert_eq!(
4673            result.peaks[0].len(),
4674            5,
4675            "With min_distance=1.5, expected 5 peaks, got {}",
4676            result.peaks[0].len()
4677        );
4678
4679        // min_distance = 2.5 should find fewer peaks
4680        let result2 = detect_peaks(&data, &argvals, Some(2.5), None, false, None);
4681        assert!(
4682            result2.peaks[0].len() < 5,
4683            "With min_distance=2.5, expected fewer than 5 peaks, got {}",
4684            result2.peaks[0].len()
4685        );
4686    }
4687
4688    #[test]
4689    fn test_peak_detection_period_1() {
4690        // Higher frequency: sin(2*pi*t/1) on [0, 10]
4691        // Peaks at t = 0.25, 1.25, 2.25, ..., 9.25 (10 peaks)
4692        let m = 400; // Higher resolution for higher frequency
4693        let period = 1.0;
4694        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4695        let data: Vec<f64> = argvals
4696            .iter()
4697            .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
4698            .collect();
4699        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4700
4701        let result = detect_peaks(&data, &argvals, None, None, false, None);
4702
4703        // Should find 10 peaks
4704        assert_eq!(
4705            result.peaks[0].len(),
4706            10,
4707            "Expected 10 peaks, got {}",
4708            result.peaks[0].len()
4709        );
4710
4711        // Check mean period
4712        assert!(
4713            (result.mean_period - period).abs() < 0.1,
4714            "Mean period {:.3} not close to expected {:.3}",
4715            result.mean_period,
4716            period
4717        );
4718    }
4719
4720    #[test]
4721    fn test_peak_detection_shifted_sine() {
4722        // Shifted sine: sin(2*pi*t/2) + 1 on [0, 10]
4723        // Same peak locations, just shifted up
4724        let m = 200;
4725        let period = 2.0;
4726        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4727        let data: Vec<f64> = argvals
4728            .iter()
4729            .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin() + 1.0)
4730            .collect();
4731        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4732
4733        let result = detect_peaks(&data, &argvals, None, None, false, None);
4734
4735        // Should still find 5 peaks
4736        assert_eq!(
4737            result.peaks[0].len(),
4738            5,
4739            "Expected 5 peaks for shifted sine, got {}",
4740            result.peaks[0].len()
4741        );
4742
4743        // Peak values should be around 2.0 (max of sin + 1)
4744        for peak in &result.peaks[0] {
4745            assert!(
4746                (peak.value - 2.0).abs() < 0.05,
4747                "Peak value {:.3} not close to expected 2.0",
4748                peak.value
4749            );
4750        }
4751    }
4752
4753    #[test]
4754    fn test_peak_detection_prominence() {
4755        // Create signal with peaks of different heights
4756        // Large peaks at odd positions, small peaks at even positions
4757        let m = 200;
4758        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4759        let data: Vec<f64> = argvals
4760            .iter()
4761            .map(|&t| {
4762                let base = (2.0 * std::f64::consts::PI * t / 2.0).sin();
4763                // Add small ripples
4764                let ripple = 0.1 * (2.0 * std::f64::consts::PI * t * 4.0).sin();
4765                base + ripple
4766            })
4767            .collect();
4768        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4769
4770        // Without prominence filter, may find extra peaks from ripples
4771        let result_no_filter = detect_peaks(&data, &argvals, None, None, false, None);
4772
4773        // With prominence filter, should only find major peaks
4774        let result_filtered = detect_peaks(&data, &argvals, None, Some(0.5), false, None);
4775
4776        // Filtered should have fewer or equal peaks
4777        assert!(
4778            result_filtered.peaks[0].len() <= result_no_filter.peaks[0].len(),
4779            "Prominence filter should reduce peak count"
4780        );
4781    }
4782
4783    #[test]
4784    fn test_peak_detection_different_amplitudes() {
4785        // Test with various amplitudes: 0.5, 1.0, 2.0, 5.0
4786        let m = 200;
4787        let period = 2.0;
4788        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4789
4790        for amplitude in [0.5, 1.0, 2.0, 5.0] {
4791            let data: Vec<f64> = argvals
4792                .iter()
4793                .map(|&t| amplitude * (2.0 * std::f64::consts::PI * t / period).sin())
4794                .collect();
4795            let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4796
4797            let result = detect_peaks(&data, &argvals, None, None, false, None);
4798
4799            assert_eq!(
4800                result.peaks[0].len(),
4801                5,
4802                "Amplitude {} should still find 5 peaks",
4803                amplitude
4804            );
4805
4806            // Peak values should be close to amplitude
4807            for peak in &result.peaks[0] {
4808                assert!(
4809                    (peak.value - amplitude).abs() < 0.1,
4810                    "Peak value {:.3} should be close to amplitude {}",
4811                    peak.value,
4812                    amplitude
4813                );
4814            }
4815        }
4816    }
4817
4818    #[test]
4819    fn test_peak_detection_varying_frequency() {
4820        // Signal with varying frequency: chirp-like signal
4821        // Peaks get closer together over time
4822        let m = 400;
4823        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
4824
4825        // Frequency increases linearly: f(t) = 0.5 + 0.1*t
4826        // Phase integral: phi(t) = 2*pi * (0.5*t + 0.05*t^2)
4827        let data: Vec<f64> = argvals
4828            .iter()
4829            .map(|&t| {
4830                let phase = 2.0 * std::f64::consts::PI * (0.5 * t + 0.05 * t * t);
4831                phase.sin()
4832            })
4833            .collect();
4834        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4835
4836        let result = detect_peaks(&data, &argvals, None, None, false, None);
4837
4838        // Should find multiple peaks with decreasing spacing
4839        assert!(
4840            result.peaks[0].len() >= 5,
4841            "Should find at least 5 peaks, got {}",
4842            result.peaks[0].len()
4843        );
4844
4845        // Verify inter-peak distances decrease over time
4846        let distances = &result.inter_peak_distances[0];
4847        if distances.len() >= 3 {
4848            // Later peaks should be closer than earlier peaks
4849            let early_avg = (distances[0] + distances[1]) / 2.0;
4850            let late_avg = (distances[distances.len() - 2] + distances[distances.len() - 1]) / 2.0;
4851            assert!(
4852                late_avg < early_avg,
4853                "Later peaks should be closer: early avg={:.3}, late avg={:.3}",
4854                early_avg,
4855                late_avg
4856            );
4857        }
4858    }
4859
4860    #[test]
4861    fn test_peak_detection_sum_of_sines() {
4862        // Sum of two sine waves with different periods creates non-uniform peak spacing
4863        // y = sin(2*pi*t/2) + 0.5*sin(2*pi*t/3)
4864        let m = 300;
4865        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 12.0 / (m - 1) as f64).collect();
4866
4867        let data: Vec<f64> = argvals
4868            .iter()
4869            .map(|&t| {
4870                (2.0 * std::f64::consts::PI * t / 2.0).sin()
4871                    + 0.5 * (2.0 * std::f64::consts::PI * t / 3.0).sin()
4872            })
4873            .collect();
4874        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
4875
4876        let result = detect_peaks(&data, &argvals, Some(1.0), None, false, None);
4877
4878        // Should find peaks (exact count depends on interference pattern)
4879        assert!(
4880            result.peaks[0].len() >= 4,
4881            "Should find at least 4 peaks, got {}",
4882            result.peaks[0].len()
4883        );
4884
4885        // Inter-peak distances should vary
4886        let distances = &result.inter_peak_distances[0];
4887        if distances.len() >= 2 {
4888            let min_dist = distances.iter().cloned().fold(f64::INFINITY, f64::min);
4889            let max_dist = distances.iter().cloned().fold(0.0, f64::max);
4890            assert!(
4891                max_dist > min_dist * 1.1,
4892                "Distances should vary: min={:.3}, max={:.3}",
4893                min_dist,
4894                max_dist
4895            );
4896        }
4897    }
4898
4899    #[test]
4900    fn test_seasonal_strength() {
4901        let m = 200;
4902        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4903        let period = 2.0;
4904        let data = generate_sine(1, m, period, &argvals);
4905
4906        let strength = seasonal_strength_variance(&data, &argvals, period, 3);
4907        // Pure sine should have high seasonal strength
4908        assert!(strength > 0.8);
4909
4910        let strength_spectral = seasonal_strength_spectral(&data, &argvals, period);
4911        assert!(strength_spectral > 0.5);
4912    }
4913
4914    #[test]
4915    fn test_instantaneous_period() {
4916        let m = 200;
4917        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4918        let period = 2.0;
4919        let data = generate_sine(1, m, period, &argvals);
4920
4921        let result = instantaneous_period(&data, &argvals);
4922
4923        // Check that instantaneous period is close to true period (away from boundaries)
4924        let mid_period = result.period[m / 2];
4925        assert!(
4926            (mid_period - period).abs() < 0.5,
4927            "Expected period ~{}, got {}",
4928            period,
4929            mid_period
4930        );
4931    }
4932
4933    #[test]
4934    fn test_peak_timing_analysis() {
4935        // Generate 5 cycles of sine with period 2
4936        let m = 500;
4937        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.02).collect();
4938        let period = 2.0;
4939        let data = generate_sine(1, m, period, &argvals);
4940
4941        let result = analyze_peak_timing(&data, &argvals, period, Some(11));
4942
4943        // Should find approximately 5 peaks
4944        assert!(!result.peak_times.is_empty());
4945        // Normalized timing should be around 0.25 (peak of sin at π/2)
4946        assert!(result.mean_timing.is_finite());
4947        // Pure sine should have low timing variability
4948        assert!(result.std_timing < 0.1 || result.std_timing.is_nan());
4949    }
4950
4951    #[test]
4952    fn test_seasonality_classification() {
4953        let m = 400;
4954        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
4955        let period = 2.0;
4956        let data = generate_sine(1, m, period, &argvals);
4957
4958        let result = classify_seasonality(&data, &argvals, period, None, None);
4959
4960        assert!(result.is_seasonal);
4961        assert!(result.seasonal_strength > 0.5);
4962        assert!(matches!(
4963            result.classification,
4964            SeasonalType::StableSeasonal | SeasonalType::VariableTiming
4965        ));
4966    }
4967
4968    #[test]
4969    fn test_otsu_threshold() {
4970        // Bimodal distribution: mix of low (0.1-0.2) and high (0.7-0.9) values
4971        let values = vec![
4972            0.1, 0.12, 0.15, 0.18, 0.11, 0.14, 0.7, 0.75, 0.8, 0.85, 0.9, 0.72,
4973        ];
4974
4975        let threshold = otsu_threshold(&values);
4976
4977        // Threshold should be between the two modes
4978        // Due to small sample size, Otsu's method may not find optimal threshold
4979        // Just verify it returns a reasonable value in the data range
4980        assert!(threshold >= 0.1, "Threshold {} should be >= 0.1", threshold);
4981        assert!(threshold <= 0.9, "Threshold {} should be <= 0.9", threshold);
4982    }
4983
4984    #[test]
4985    fn test_gcv_fourier_nbasis_selection() {
4986        let m = 100;
4987        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4988
4989        // Noisy sine wave
4990        let mut data = vec![0.0; m];
4991        for j in 0..m {
4992            data[j] = (2.0 * PI * argvals[j] / 2.0).sin() + 0.1 * (j as f64 * 0.3).sin();
4993        }
4994
4995        let data_mat = crate::matrix::FdMatrix::from_column_major(data, 1, m).unwrap();
4996        let nbasis = crate::basis::select_fourier_nbasis_gcv(&data_mat, &argvals, 5, 25);
4997
4998        // nbasis should be reasonable (between min and max)
4999        assert!(nbasis >= 5);
5000        assert!(nbasis <= 25);
5001    }
5002
5003    #[test]
5004    fn test_detect_multiple_periods() {
5005        let m = 400;
5006        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect(); // 0 to 20
5007
5008        // Signal with two periods: 2 and 7
5009        let period1 = 2.0;
5010        let period2 = 7.0;
5011        let mut data = vec![0.0; m];
5012        for j in 0..m {
5013            data[j] = (2.0 * PI * argvals[j] / period1).sin()
5014                + 0.6 * (2.0 * PI * argvals[j] / period2).sin();
5015        }
5016
5017        // Use higher min_strength threshold to properly stop after real periods
5018        let detected = detect_multiple_periods(&data, 1, m, &argvals, 5, 0.4, 0.20);
5019
5020        // Should detect exactly 2 periods with these thresholds
5021        assert!(
5022            detected.len() >= 2,
5023            "Expected at least 2 periods, found {}",
5024            detected.len()
5025        );
5026
5027        // Check that both periods were detected (order depends on amplitude)
5028        let periods: Vec<f64> = detected.iter().map(|d| d.period).collect();
5029        let has_period1 = periods.iter().any(|&p| (p - period1).abs() < 0.3);
5030        let has_period2 = periods.iter().any(|&p| (p - period2).abs() < 0.5);
5031
5032        assert!(
5033            has_period1,
5034            "Expected to find period ~{}, got {:?}",
5035            period1, periods
5036        );
5037        assert!(
5038            has_period2,
5039            "Expected to find period ~{}, got {:?}",
5040            period2, periods
5041        );
5042
5043        // Verify first detected has higher amplitude (amplitude 1.0 vs 0.6)
5044        assert!(
5045            detected[0].amplitude > detected[1].amplitude,
5046            "First detected should have higher amplitude"
5047        );
5048
5049        // Each detected period should have strength and confidence info
5050        for d in &detected {
5051            assert!(
5052                d.strength > 0.0,
5053                "Detected period should have positive strength"
5054            );
5055            assert!(
5056                d.confidence > 0.0,
5057                "Detected period should have positive confidence"
5058            );
5059            assert!(
5060                d.amplitude > 0.0,
5061                "Detected period should have positive amplitude"
5062            );
5063        }
5064    }
5065
5066    // ========================================================================
5067    // Amplitude Modulation Detection Tests
5068    // ========================================================================
5069
5070    #[test]
5071    fn test_amplitude_modulation_stable() {
5072        // Constant amplitude seasonal signal - should detect as Stable
5073        let m = 200;
5074        let period = 0.2;
5075        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5076
5077        // Constant amplitude sine wave
5078        let data: Vec<f64> = argvals
5079            .iter()
5080            .map(|&t| (2.0 * PI * t / period).sin())
5081            .collect();
5082        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5083
5084        let result = detect_amplitude_modulation(
5085            &data, &argvals, period, 0.15, // modulation threshold
5086            0.3,  // seasonality threshold
5087        );
5088
5089        eprintln!(
5090            "Stable test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5091            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5092        );
5093
5094        assert!(result.is_seasonal, "Should detect seasonality");
5095        assert!(
5096            !result.has_modulation,
5097            "Constant amplitude should not have modulation, got score={:.4}",
5098            result.modulation_score
5099        );
5100        assert_eq!(
5101            result.modulation_type,
5102            ModulationType::Stable,
5103            "Should be classified as Stable"
5104        );
5105    }
5106
5107    #[test]
5108    fn test_amplitude_modulation_emerging() {
5109        // Amplitude increases over time (emerging seasonality)
5110        let m = 200;
5111        let period = 0.2;
5112        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5113
5114        // Amplitude grows from 0.2 to 1.0
5115        let data: Vec<f64> = argvals
5116            .iter()
5117            .map(|&t| {
5118                let amplitude = 0.2 + 0.8 * t; // Linear increase
5119                amplitude * (2.0 * PI * t / period).sin()
5120            })
5121            .collect();
5122
5123        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5124
5125        let result = detect_amplitude_modulation(&data, &argvals, period, 0.15, 0.2);
5126
5127        eprintln!(
5128            "Emerging test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5129            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5130        );
5131
5132        assert!(result.is_seasonal, "Should detect seasonality");
5133        assert!(
5134            result.has_modulation,
5135            "Growing amplitude should have modulation, score={:.4}",
5136            result.modulation_score
5137        );
5138        assert_eq!(
5139            result.modulation_type,
5140            ModulationType::Emerging,
5141            "Should be classified as Emerging, trend={:.4}",
5142            result.amplitude_trend
5143        );
5144        assert!(
5145            result.amplitude_trend > 0.0,
5146            "Trend should be positive for emerging"
5147        );
5148    }
5149
5150    #[test]
5151    fn test_amplitude_modulation_fading() {
5152        // Amplitude decreases over time (fading seasonality)
5153        let m = 200;
5154        let period = 0.2;
5155        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5156
5157        // Amplitude decreases from 1.0 to 0.2
5158        let data: Vec<f64> = argvals
5159            .iter()
5160            .map(|&t| {
5161                let amplitude = 1.0 - 0.8 * t; // Linear decrease
5162                amplitude * (2.0 * PI * t / period).sin()
5163            })
5164            .collect();
5165
5166        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5167
5168        let result = detect_amplitude_modulation(&data, &argvals, period, 0.15, 0.2);
5169
5170        eprintln!(
5171            "Fading test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5172            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5173        );
5174
5175        assert!(result.is_seasonal, "Should detect seasonality");
5176        assert!(
5177            result.has_modulation,
5178            "Fading amplitude should have modulation"
5179        );
5180        assert_eq!(
5181            result.modulation_type,
5182            ModulationType::Fading,
5183            "Should be classified as Fading, trend={:.4}",
5184            result.amplitude_trend
5185        );
5186        assert!(
5187            result.amplitude_trend < 0.0,
5188            "Trend should be negative for fading"
5189        );
5190    }
5191
5192    #[test]
5193    fn test_amplitude_modulation_oscillating() {
5194        // Amplitude oscillates (neither purely emerging nor fading)
5195        let m = 200;
5196        let period = 0.1;
5197        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5198
5199        // Amplitude oscillates: high-low-high-low pattern
5200        let data: Vec<f64> = argvals
5201            .iter()
5202            .map(|&t| {
5203                let amplitude = 0.5 + 0.4 * (2.0 * PI * t * 2.0).sin(); // 2 modulation cycles
5204                amplitude * (2.0 * PI * t / period).sin()
5205            })
5206            .collect();
5207
5208        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5209
5210        let result = detect_amplitude_modulation(&data, &argvals, period, 0.15, 0.2);
5211
5212        eprintln!(
5213            "Oscillating test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5214            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5215        );
5216
5217        assert!(result.is_seasonal, "Should detect seasonality");
5218        // Oscillating has high variation but near-zero trend
5219        if result.has_modulation {
5220            // Trend should be near zero for oscillating
5221            assert!(
5222                result.amplitude_trend.abs() < 0.5,
5223                "Trend should be small for oscillating"
5224            );
5225        }
5226    }
5227
5228    #[test]
5229    fn test_amplitude_modulation_non_seasonal() {
5230        // Pure noise - no seasonality
5231        let m = 100;
5232        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5233
5234        // Random noise (use simple pseudo-random)
5235        let data: Vec<f64> = (0..m)
5236            .map(|i| ((i as f64 * 1.618).sin() * 100.0).fract())
5237            .collect();
5238
5239        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5240
5241        let result = detect_amplitude_modulation(
5242            &data, &argvals, 0.2, // arbitrary period
5243            0.15, 0.3,
5244        );
5245
5246        assert!(
5247            !result.is_seasonal,
5248            "Noise should not be detected as seasonal"
5249        );
5250        assert_eq!(
5251            result.modulation_type,
5252            ModulationType::NonSeasonal,
5253            "Should be classified as NonSeasonal"
5254        );
5255    }
5256
5257    // ========================================================================
5258    // Wavelet-based Amplitude Modulation Detection Tests
5259    // ========================================================================
5260
5261    #[test]
5262    fn test_wavelet_amplitude_modulation_stable() {
5263        let m = 200;
5264        let period = 0.2;
5265        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5266
5267        let data: Vec<f64> = argvals
5268            .iter()
5269            .map(|&t| (2.0 * PI * t / period).sin())
5270            .collect();
5271
5272        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5273
5274        let result = detect_amplitude_modulation_wavelet(&data, &argvals, period, 0.15, 0.3);
5275
5276        eprintln!(
5277            "Wavelet stable: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5278            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5279        );
5280
5281        assert!(result.is_seasonal, "Should detect seasonality");
5282        assert!(
5283            !result.has_modulation,
5284            "Constant amplitude should not have modulation, got score={:.4}",
5285            result.modulation_score
5286        );
5287    }
5288
5289    #[test]
5290    fn test_wavelet_amplitude_modulation_emerging() {
5291        let m = 200;
5292        let period = 0.2;
5293        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5294
5295        // Amplitude grows from 0.2 to 1.0
5296        let data: Vec<f64> = argvals
5297            .iter()
5298            .map(|&t| {
5299                let amplitude = 0.2 + 0.8 * t;
5300                amplitude * (2.0 * PI * t / period).sin()
5301            })
5302            .collect();
5303
5304        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5305
5306        let result = detect_amplitude_modulation_wavelet(&data, &argvals, period, 0.15, 0.2);
5307
5308        eprintln!(
5309            "Wavelet emerging: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5310            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5311        );
5312
5313        assert!(result.is_seasonal, "Should detect seasonality");
5314        assert!(
5315            result.has_modulation,
5316            "Growing amplitude should have modulation"
5317        );
5318        assert!(
5319            result.amplitude_trend > 0.0,
5320            "Trend should be positive for emerging"
5321        );
5322    }
5323
5324    #[test]
5325    fn test_wavelet_amplitude_modulation_fading() {
5326        let m = 200;
5327        let period = 0.2;
5328        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5329
5330        // Amplitude decreases from 1.0 to 0.2
5331        let data: Vec<f64> = argvals
5332            .iter()
5333            .map(|&t| {
5334                let amplitude = 1.0 - 0.8 * t;
5335                amplitude * (2.0 * PI * t / period).sin()
5336            })
5337            .collect();
5338
5339        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
5340
5341        let result = detect_amplitude_modulation_wavelet(&data, &argvals, period, 0.15, 0.2);
5342
5343        eprintln!(
5344            "Wavelet fading: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
5345            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
5346        );
5347
5348        assert!(result.is_seasonal, "Should detect seasonality");
5349        assert!(
5350            result.has_modulation,
5351            "Fading amplitude should have modulation"
5352        );
5353        assert!(
5354            result.amplitude_trend < 0.0,
5355            "Trend should be negative for fading"
5356        );
5357    }
5358
5359    #[test]
5360    fn test_seasonal_strength_wavelet() {
5361        let m = 200;
5362        let period = 0.2;
5363        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
5364
5365        // Pure sine wave at target period - should have high strength
5366        let seasonal_data: Vec<f64> = argvals
5367            .iter()
5368            .map(|&t| (2.0 * PI * t / period).sin())
5369            .collect();
5370
5371        let seasonal_data = FdMatrix::from_column_major(seasonal_data, 1, m).unwrap();
5372
5373        let strength = seasonal_strength_wavelet(&seasonal_data, &argvals, period);
5374        eprintln!("Wavelet strength (pure sine): {:.4}", strength);
5375        assert!(
5376            strength > 0.5,
5377            "Pure sine should have high wavelet strength"
5378        );
5379
5380        // Pure noise - should have low strength
5381        let noise_data: Vec<f64> = (0..m)
5382            .map(|i| ((i * 12345 + 67890) % 1000) as f64 / 1000.0 - 0.5)
5383            .collect();
5384
5385        let noise_data = FdMatrix::from_column_major(noise_data, 1, m).unwrap();
5386
5387        let noise_strength = seasonal_strength_wavelet(&noise_data, &argvals, period);
5388        eprintln!("Wavelet strength (noise): {:.4}", noise_strength);
5389        assert!(
5390            noise_strength < 0.3,
5391            "Noise should have low wavelet strength"
5392        );
5393
5394        // Wrong period - should have lower strength
5395        let wrong_period_strength =
5396            seasonal_strength_wavelet(&seasonal_data, &argvals, period * 2.0);
5397        eprintln!(
5398            "Wavelet strength (wrong period): {:.4}",
5399            wrong_period_strength
5400        );
5401        assert!(
5402            wrong_period_strength < strength,
5403            "Wrong period should have lower strength"
5404        );
5405    }
5406
5407    #[test]
5408    fn test_compute_mean_curve() {
5409        // 2 samples, 3 time points
5410        // Sample 1: [1, 2, 3]
5411        // Sample 2: [2, 4, 6]
5412        // Mean: [1.5, 3, 4.5]
5413        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 2.0, 4.0, 3.0, 6.0], 2, 3).unwrap();
5414        let mean = compute_mean_curve(&data);
5415        assert_eq!(mean.len(), 3);
5416        assert!((mean[0] - 1.5).abs() < 1e-10);
5417        assert!((mean[1] - 3.0).abs() < 1e-10);
5418        assert!((mean[2] - 4.5).abs() < 1e-10);
5419    }
5420
5421    #[test]
5422    fn test_compute_mean_curve_parallel_consistency() {
5423        // Test that parallel and sequential give same results
5424        let n = 10;
5425        let m = 200;
5426        let data: Vec<f64> = (0..n * m).map(|i| (i as f64 * 0.1).sin()).collect();
5427
5428        let data = FdMatrix::from_column_major(data, n, m).unwrap();
5429
5430        let seq_result = compute_mean_curve_impl(&data, false);
5431        let par_result = compute_mean_curve_impl(&data, true);
5432
5433        assert_eq!(seq_result.len(), par_result.len());
5434        for (s, p) in seq_result.iter().zip(par_result.iter()) {
5435            assert!(
5436                (s - p).abs() < 1e-10,
5437                "Sequential and parallel results differ"
5438            );
5439        }
5440    }
5441
5442    #[test]
5443    fn test_interior_bounds() {
5444        // m = 100: edge_skip = 10, interior = [10, 90)
5445        let bounds = interior_bounds(100);
5446        assert!(bounds.is_some());
5447        let (start, end) = bounds.unwrap();
5448        assert_eq!(start, 10);
5449        assert_eq!(end, 90);
5450
5451        // m = 10: edge_skip = 1, but min(1, 2) = 1, max(9, 7) = 9
5452        let bounds = interior_bounds(10);
5453        assert!(bounds.is_some());
5454        let (start, end) = bounds.unwrap();
5455        assert!(start < end);
5456
5457        // Very small m might not have valid interior
5458        let bounds = interior_bounds(2);
5459        // Should still return something as long as end > start
5460        assert!(bounds.is_some() || bounds.is_none());
5461    }
5462
5463    #[test]
5464    fn test_hilbert_transform_pure_sine() {
5465        // Hilbert transform of sin(t) should give cos(t) in imaginary part
5466        let m = 200;
5467        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5468        let signal: Vec<f64> = argvals.iter().map(|&t| (2.0 * PI * t).sin()).collect();
5469
5470        let analytic = hilbert_transform(&signal);
5471        assert_eq!(analytic.len(), m);
5472
5473        // Check amplitude is approximately 1
5474        for c in analytic.iter().skip(10).take(m - 20) {
5475            let amp = c.norm();
5476            assert!(
5477                (amp - 1.0).abs() < 0.1,
5478                "Amplitude should be ~1, got {}",
5479                amp
5480            );
5481        }
5482    }
5483
5484    #[test]
5485    fn test_sazed_pure_sine() {
5486        // Pure sine wave with known period
5487        let m = 200;
5488        let period = 2.0;
5489        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5490        let data: Vec<f64> = argvals
5491            .iter()
5492            .map(|&t| (2.0 * PI * t / period).sin())
5493            .collect();
5494
5495        let result = sazed(&data, &argvals, None);
5496
5497        assert!(result.period.is_finite(), "SAZED should detect a period");
5498        assert!(
5499            (result.period - period).abs() < 0.3,
5500            "Expected period ~{}, got {}",
5501            period,
5502            result.period
5503        );
5504        assert!(
5505            result.confidence > 0.4,
5506            "Expected confidence > 0.4, got {}",
5507            result.confidence
5508        );
5509        assert!(
5510            result.agreeing_components >= 2,
5511            "Expected at least 2 agreeing components, got {}",
5512            result.agreeing_components
5513        );
5514    }
5515
5516    #[test]
5517    fn test_sazed_noisy_sine() {
5518        // Sine wave with noise
5519        let m = 300;
5520        let period = 3.0;
5521        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5522
5523        // Deterministic pseudo-noise using sin with different frequency
5524        let data: Vec<f64> = argvals
5525            .iter()
5526            .enumerate()
5527            .map(|(i, &t)| {
5528                let signal = (2.0 * PI * t / period).sin();
5529                let noise = 0.1 * (17.3 * i as f64).sin();
5530                signal + noise
5531            })
5532            .collect();
5533
5534        let result = sazed(&data, &argvals, Some(0.15));
5535
5536        assert!(
5537            result.period.is_finite(),
5538            "SAZED should detect a period even with noise"
5539        );
5540        assert!(
5541            (result.period - period).abs() < 0.5,
5542            "Expected period ~{}, got {}",
5543            period,
5544            result.period
5545        );
5546    }
5547
5548    #[test]
5549    fn test_sazed_fdata() {
5550        // Multiple samples with same period
5551        let n = 5;
5552        let m = 200;
5553        let period = 2.0;
5554        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5555        let data = generate_sine(n, m, period, &argvals);
5556
5557        let result = sazed_fdata(&data, &argvals, None);
5558
5559        assert!(result.period.is_finite(), "SAZED should detect a period");
5560        assert!(
5561            (result.period - period).abs() < 0.3,
5562            "Expected period ~{}, got {}",
5563            period,
5564            result.period
5565        );
5566    }
5567
5568    #[test]
5569    fn test_sazed_short_series() {
5570        // Very short series - should return NaN gracefully
5571        let argvals: Vec<f64> = (0..5).map(|i| i as f64).collect();
5572        let data: Vec<f64> = argvals.iter().map(|&t| t.sin()).collect();
5573
5574        let result = sazed(&data, &argvals, None);
5575
5576        // Should handle gracefully (return NaN for too-short series)
5577        assert!(
5578            result.period.is_nan() || result.period.is_finite(),
5579            "Should return NaN or valid period"
5580        );
5581    }
5582
5583    #[test]
5584    fn test_autoperiod_pure_sine() {
5585        // Pure sine wave with known period
5586        let m = 200;
5587        let period = 2.0;
5588        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5589        let data: Vec<f64> = argvals
5590            .iter()
5591            .map(|&t| (2.0 * PI * t / period).sin())
5592            .collect();
5593
5594        let result = autoperiod(&data, &argvals, None, None);
5595
5596        assert!(
5597            result.period.is_finite(),
5598            "Autoperiod should detect a period"
5599        );
5600        assert!(
5601            (result.period - period).abs() < 0.3,
5602            "Expected period ~{}, got {}",
5603            period,
5604            result.period
5605        );
5606        assert!(
5607            result.confidence > 0.0,
5608            "Expected positive confidence, got {}",
5609            result.confidence
5610        );
5611    }
5612
5613    #[test]
5614    fn test_autoperiod_with_trend() {
5615        // Sine wave with linear trend
5616        let m = 300;
5617        let period = 3.0;
5618        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5619        let data: Vec<f64> = argvals
5620            .iter()
5621            .map(|&t| 0.2 * t + (2.0 * PI * t / period).sin())
5622            .collect();
5623
5624        let result = autoperiod(&data, &argvals, None, None);
5625
5626        assert!(
5627            result.period.is_finite(),
5628            "Autoperiod should detect a period"
5629        );
5630        // Allow more tolerance with trend
5631        assert!(
5632            (result.period - period).abs() < 0.5,
5633            "Expected period ~{}, got {}",
5634            period,
5635            result.period
5636        );
5637    }
5638
5639    #[test]
5640    fn test_autoperiod_candidates() {
5641        // Verify candidates are generated
5642        let m = 200;
5643        let period = 2.0;
5644        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5645        let data: Vec<f64> = argvals
5646            .iter()
5647            .map(|&t| (2.0 * PI * t / period).sin())
5648            .collect();
5649
5650        let result = autoperiod(&data, &argvals, Some(5), Some(10));
5651
5652        assert!(
5653            !result.candidates.is_empty(),
5654            "Should have at least one candidate"
5655        );
5656
5657        // Best candidate should have highest combined score
5658        let max_score = result
5659            .candidates
5660            .iter()
5661            .map(|c| c.combined_score)
5662            .fold(f64::NEG_INFINITY, f64::max);
5663        assert!(
5664            (result.confidence - max_score).abs() < 1e-10,
5665            "Returned confidence should match best candidate's score"
5666        );
5667    }
5668
5669    #[test]
5670    fn test_autoperiod_fdata() {
5671        // Multiple samples with same period
5672        let n = 5;
5673        let m = 200;
5674        let period = 2.0;
5675        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5676        let data = generate_sine(n, m, period, &argvals);
5677
5678        let result = autoperiod_fdata(&data, &argvals, None, None);
5679
5680        assert!(
5681            result.period.is_finite(),
5682            "Autoperiod should detect a period"
5683        );
5684        assert!(
5685            (result.period - period).abs() < 0.3,
5686            "Expected period ~{}, got {}",
5687            period,
5688            result.period
5689        );
5690    }
5691
5692    #[test]
5693    fn test_cfd_autoperiod_pure_sine() {
5694        // Pure sine wave with known period
5695        let m = 200;
5696        let period = 2.0;
5697        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5698        let data: Vec<f64> = argvals
5699            .iter()
5700            .map(|&t| (2.0 * PI * t / period).sin())
5701            .collect();
5702
5703        let result = cfd_autoperiod(&data, &argvals, None, None);
5704
5705        assert!(
5706            result.period.is_finite(),
5707            "CFDAutoperiod should detect a period"
5708        );
5709        assert!(
5710            (result.period - period).abs() < 0.3,
5711            "Expected period ~{}, got {}",
5712            period,
5713            result.period
5714        );
5715    }
5716
5717    #[test]
5718    fn test_cfd_autoperiod_with_trend() {
5719        // Sine wave with strong linear trend - CFD excels here
5720        let m = 300;
5721        let period = 3.0;
5722        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5723        let data: Vec<f64> = argvals
5724            .iter()
5725            .map(|&t| 2.0 * t + (2.0 * PI * t / period).sin())
5726            .collect();
5727
5728        let result = cfd_autoperiod(&data, &argvals, None, None);
5729
5730        assert!(
5731            result.period.is_finite(),
5732            "CFDAutoperiod should detect a period despite trend"
5733        );
5734        // Allow more tolerance since trend can affect detection
5735        assert!(
5736            (result.period - period).abs() < 0.6,
5737            "Expected period ~{}, got {}",
5738            period,
5739            result.period
5740        );
5741    }
5742
5743    #[test]
5744    fn test_cfd_autoperiod_multiple_periods() {
5745        // Signal with two periods - should detect multiple
5746        let m = 400;
5747        let period1 = 2.0;
5748        let period2 = 5.0;
5749        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5750        let data: Vec<f64> = argvals
5751            .iter()
5752            .map(|&t| (2.0 * PI * t / period1).sin() + 0.5 * (2.0 * PI * t / period2).sin())
5753            .collect();
5754
5755        let result = cfd_autoperiod(&data, &argvals, None, None);
5756
5757        assert!(
5758            !result.periods.is_empty(),
5759            "Should detect at least one period"
5760        );
5761        // The primary period should be one of the two
5762        let close_to_p1 = (result.period - period1).abs() < 0.5;
5763        let close_to_p2 = (result.period - period2).abs() < 1.0;
5764        assert!(
5765            close_to_p1 || close_to_p2,
5766            "Primary period {} not close to {} or {}",
5767            result.period,
5768            period1,
5769            period2
5770        );
5771    }
5772
5773    #[test]
5774    fn test_cfd_autoperiod_fdata() {
5775        // Multiple samples with same period
5776        let n = 5;
5777        let m = 200;
5778        let period = 2.0;
5779        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
5780        let data = generate_sine(n, m, period, &argvals);
5781
5782        let result = cfd_autoperiod_fdata(&data, &argvals, None, None);
5783
5784        assert!(
5785            result.period.is_finite(),
5786            "CFDAutoperiod should detect a period"
5787        );
5788        assert!(
5789            (result.period - period).abs() < 0.3,
5790            "Expected period ~{}, got {}",
5791            period,
5792            result.period
5793        );
5794    }
5795
5796    // ========================================================================
5797    // SSA Tests
5798    // ========================================================================
5799
5800    #[test]
5801    fn test_ssa_pure_sine() {
5802        let n = 200;
5803        let period = 12.0;
5804        let values: Vec<f64> = (0..n)
5805            .map(|i| {
5806                let t = i as f64;
5807                0.01 * t + (2.0 * PI * t / period).sin() + 0.05 * ((i * 7) as f64 * 0.1).sin()
5808            })
5809            .collect();
5810
5811        let result = ssa(&values, None, None, None, None);
5812
5813        // trend + seasonal + noise ≈ original
5814        for i in 0..n {
5815            let reconstructed = result.trend[i] + result.seasonal[i] + result.noise[i];
5816            assert!(
5817                (reconstructed - values[i]).abs() < 1e-8,
5818                "SSA reconstruction error at {}: {} vs {}",
5819                i,
5820                reconstructed,
5821                values[i]
5822            );
5823        }
5824
5825        // Singular values should be descending
5826        for i in 1..result.singular_values.len() {
5827            assert!(
5828                result.singular_values[i] <= result.singular_values[i - 1] + 1e-10,
5829                "Singular values should be descending: {} > {}",
5830                result.singular_values[i],
5831                result.singular_values[i - 1]
5832            );
5833        }
5834
5835        // Contributions should sum to <= 1
5836        let total_contrib: f64 = result.contributions.iter().sum();
5837        assert!(
5838            total_contrib <= 1.0 + 1e-10,
5839            "Contributions should sum to <= 1, got {}",
5840            total_contrib
5841        );
5842    }
5843
5844    #[test]
5845    fn test_ssa_explicit_groupings() {
5846        let n = 100;
5847        let period = 10.0;
5848        let values: Vec<f64> = (0..n)
5849            .map(|i| 0.01 * i as f64 + (2.0 * PI * i as f64 / period).sin())
5850            .collect();
5851
5852        let trend_components = [0usize];
5853        let seasonal_components = [1usize, 2];
5854
5855        let result = ssa(
5856            &values,
5857            None,
5858            None,
5859            Some(&trend_components),
5860            Some(&seasonal_components),
5861        );
5862
5863        assert_eq!(result.trend.len(), n);
5864        assert_eq!(result.seasonal.len(), n);
5865        assert_eq!(result.noise.len(), n);
5866
5867        // Reconstruction should still hold
5868        for i in 0..n {
5869            let reconstructed = result.trend[i] + result.seasonal[i] + result.noise[i];
5870            assert!(
5871                (reconstructed - values[i]).abs() < 1e-8,
5872                "SSA explicit grouping reconstruction error at {}",
5873                i
5874            );
5875        }
5876    }
5877
5878    #[test]
5879    fn test_ssa_short_series() {
5880        // n < 4 should trigger early return
5881        let values = vec![1.0, 2.0, 3.0];
5882        let result = ssa(&values, None, None, None, None);
5883
5884        assert_eq!(result.trend, values);
5885        assert_eq!(result.seasonal, vec![0.0; 3]);
5886        assert_eq!(result.noise, vec![0.0; 3]);
5887        assert_eq!(result.n_components, 0);
5888    }
5889
5890    #[test]
5891    fn test_ssa_fdata() {
5892        let n = 5;
5893        let m = 100;
5894        let mut data = vec![0.0; n * m];
5895        for i in 0..n {
5896            let amp = (i + 1) as f64;
5897            for j in 0..m {
5898                data[i + j * n] = amp * (2.0 * PI * j as f64 / 12.0).sin() + 0.01 * j as f64;
5899            }
5900        }
5901
5902        let data = FdMatrix::from_column_major(data, n, m).unwrap();
5903
5904        let result = ssa_fdata(&data, None, None);
5905
5906        assert_eq!(result.trend.len(), m);
5907        assert_eq!(result.seasonal.len(), m);
5908        assert_eq!(result.noise.len(), m);
5909        assert!(!result.singular_values.is_empty());
5910    }
5911
5912    #[test]
5913    fn test_ssa_seasonality() {
5914        // Seasonal signal
5915        let n = 200;
5916        let period = 12.0;
5917        let seasonal_values: Vec<f64> = (0..n)
5918            .map(|i| (2.0 * PI * i as f64 / period).sin())
5919            .collect();
5920
5921        let (is_seasonal, _det_period, confidence) =
5922            ssa_seasonality(&seasonal_values, None, Some(0.05));
5923
5924        // A pure sine should be detected as seasonal
5925        // (confidence depends on component grouping)
5926        assert!(confidence >= 0.0, "Confidence should be non-negative");
5927
5928        // Noise-only signal should not be seasonal
5929        let noise_values: Vec<f64> = (0..n)
5930            .map(|i| ((i * 13 + 7) as f64 * 0.1).sin() * 0.01)
5931            .collect();
5932
5933        let (is_noise_seasonal, _, noise_conf) = ssa_seasonality(&noise_values, None, Some(0.5));
5934
5935        // Noise should have low confidence (but it's not guaranteed to be strictly false
5936        // depending on auto-grouping, so we just check confidence)
5937        let _ = (is_seasonal, is_noise_seasonal, noise_conf);
5938    }
5939
5940    // ========================================================================
5941    // Matrix Profile Tests
5942    // ========================================================================
5943
5944    #[test]
5945    fn test_matrix_profile_periodic() {
5946        let period = 20;
5947        let n = period * 10;
5948        let values: Vec<f64> = (0..n)
5949            .map(|i| (2.0 * PI * i as f64 / period as f64).sin())
5950            .collect();
5951
5952        let result = matrix_profile(&values, Some(15), None);
5953
5954        assert_eq!(result.profile.len(), n - 15 + 1);
5955        assert_eq!(result.profile_index.len(), n - 15 + 1);
5956        assert_eq!(result.subsequence_length, 15);
5957
5958        // Profile should be finite
5959        for &p in &result.profile {
5960            assert!(p.is_finite(), "Profile values should be finite");
5961        }
5962
5963        // Primary period should be close to 20
5964        assert!(
5965            (result.primary_period - period as f64).abs() < 5.0,
5966            "Expected primary_period ≈ {}, got {}",
5967            period,
5968            result.primary_period
5969        );
5970    }
5971
5972    #[test]
5973    fn test_matrix_profile_non_periodic() {
5974        // Linear ramp (non-periodic)
5975        let n = 200;
5976        let values: Vec<f64> = (0..n).map(|i| i as f64 * 0.1).collect();
5977
5978        let result = matrix_profile(&values, Some(10), None);
5979
5980        assert_eq!(result.profile.len(), n - 10 + 1);
5981
5982        // Should have lower confidence than periodic
5983        // (not always strictly 0, depends on ramp structure)
5984        assert!(result.confidence <= 1.0, "Confidence should be <= 1.0");
5985    }
5986
5987    #[test]
5988    fn test_matrix_profile_fdata() {
5989        let n = 3;
5990        let m = 200;
5991        let period = 20.0;
5992        let mut data = vec![0.0; n * m];
5993        for i in 0..n {
5994            let amp = (i + 1) as f64;
5995            for j in 0..m {
5996                data[i + j * n] = amp * (2.0 * PI * j as f64 / period).sin();
5997            }
5998        }
5999
6000        let data = FdMatrix::from_column_major(data, n, m).unwrap();
6001
6002        let result = matrix_profile_fdata(&data, Some(15), None);
6003
6004        assert!(!result.profile.is_empty());
6005        assert!(result.profile_index.len() == result.profile.len());
6006    }
6007
6008    #[test]
6009    fn test_matrix_profile_seasonality() {
6010        let period = 20;
6011        let n = period * 10;
6012        // Periodic signal
6013        let values: Vec<f64> = (0..n)
6014            .map(|i| (2.0 * PI * i as f64 / period as f64).sin())
6015            .collect();
6016
6017        let (is_seasonal, det_period, confidence) =
6018            matrix_profile_seasonality(&values, Some(15), Some(0.05));
6019
6020        assert!(
6021            is_seasonal,
6022            "Periodic signal should be detected as seasonal"
6023        );
6024        assert!(det_period > 0.0, "Detected period should be positive");
6025        assert!(confidence >= 0.05, "Confidence should be above threshold");
6026
6027        // Weak / non-periodic signal
6028        let weak_values: Vec<f64> = (0..n).map(|i| i as f64 * 0.001).collect();
6029        let (is_weak_seasonal, _, _) =
6030            matrix_profile_seasonality(&weak_values, Some(15), Some(0.5));
6031        let _ = is_weak_seasonal; // May or may not detect - we just check it doesn't panic
6032    }
6033
6034    // ========================================================================
6035    // Lomb-Scargle Tests
6036    // ========================================================================
6037
6038    #[test]
6039    fn test_lomb_scargle_regular() {
6040        let n = 200;
6041        let true_period = 5.0;
6042        let times: Vec<f64> = (0..n).map(|i| i as f64 * 0.1).collect();
6043        let values: Vec<f64> = times
6044            .iter()
6045            .map(|&t| (2.0 * PI * t / true_period).sin())
6046            .collect();
6047
6048        let result = lomb_scargle(&times, &values, None, None, None);
6049
6050        assert!(
6051            (result.peak_period - true_period).abs() < 0.5,
6052            "Expected peak_period ≈ {}, got {}",
6053            true_period,
6054            result.peak_period
6055        );
6056        assert!(
6057            result.false_alarm_probability < 0.05,
6058            "FAP should be low for strong signal: {}",
6059            result.false_alarm_probability
6060        );
6061        assert!(result.peak_power > 0.0, "Peak power should be positive");
6062        assert!(!result.frequencies.is_empty());
6063        assert_eq!(result.frequencies.len(), result.power.len());
6064        assert_eq!(result.frequencies.len(), result.periods.len());
6065    }
6066
6067    #[test]
6068    fn test_lomb_scargle_irregular() {
6069        let true_period = 5.0;
6070        // Irregularly sampled: take a subset of regular samples
6071        let all_times: Vec<f64> = (0..300).map(|i| i as f64 * 0.1).collect();
6072        // Take every other point with some jitter-like selection
6073        let times: Vec<f64> = all_times
6074            .iter()
6075            .enumerate()
6076            .filter(|(i, _)| i % 2 == 0 || i % 3 == 0)
6077            .map(|(_, &t)| t)
6078            .collect();
6079        let values: Vec<f64> = times
6080            .iter()
6081            .map(|&t| (2.0 * PI * t / true_period).sin())
6082            .collect();
6083
6084        let result = lomb_scargle(&times, &values, None, None, None);
6085
6086        assert!(
6087            (result.peak_period - true_period).abs() < 1.0,
6088            "Irregular LS: expected period ≈ {}, got {}",
6089            true_period,
6090            result.peak_period
6091        );
6092    }
6093
6094    #[test]
6095    fn test_lomb_scargle_custom_frequencies() {
6096        let n = 100;
6097        let true_period = 5.0;
6098        let times: Vec<f64> = (0..n).map(|i| i as f64 * 0.1).collect();
6099        let values: Vec<f64> = times
6100            .iter()
6101            .map(|&t| (2.0 * PI * t / true_period).sin())
6102            .collect();
6103
6104        // Explicit frequency grid
6105        let frequencies: Vec<f64> = (1..50).map(|i| i as f64 * 0.01).collect();
6106        let result = lomb_scargle(&times, &values, Some(&frequencies), None, None);
6107
6108        assert_eq!(result.frequencies.len(), frequencies.len());
6109        assert_eq!(result.power.len(), frequencies.len());
6110        assert!(result.peak_power > 0.0);
6111    }
6112
6113    #[test]
6114    fn test_lomb_scargle_fdata() {
6115        let n = 5;
6116        let m = 200;
6117        let period = 5.0;
6118        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6119        let data = generate_sine(n, m, period, &argvals);
6120
6121        let result = lomb_scargle_fdata(&data, &argvals, None, None);
6122
6123        assert!(
6124            (result.peak_period - period).abs() < 0.5,
6125            "Fdata LS: expected period ≈ {}, got {}",
6126            period,
6127            result.peak_period
6128        );
6129        assert!(!result.frequencies.is_empty());
6130    }
6131
6132    // ========================================================================
6133    // Seasonality change detection tests
6134    // ========================================================================
6135
6136    #[test]
6137    fn test_detect_seasonality_changes_onset() {
6138        let m = 400;
6139        let period = 2.0;
6140        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect(); // 0..20
6141
6142        // First half: noise-like (low seasonality), second half: strong sine
6143        let data: Vec<f64> = argvals
6144            .iter()
6145            .map(|&t| {
6146                if t < 10.0 {
6147                    // Weak signal
6148                    0.05 * ((t * 13.0).sin() + (t * 7.0).cos())
6149                } else {
6150                    // Strong seasonal
6151                    (2.0 * PI * t / period).sin()
6152                }
6153            })
6154            .collect();
6155        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6156
6157        let result = detect_seasonality_changes(&data, &argvals, period, 0.3, 4.0, 2.0);
6158
6159        assert!(
6160            !result.strength_curve.is_empty(),
6161            "Strength curve should not be empty"
6162        );
6163        assert_eq!(result.strength_curve.len(), m);
6164
6165        // Should detect at least one change point (onset around t=10)
6166        if !result.change_points.is_empty() {
6167            let onset_points: Vec<_> = result
6168                .change_points
6169                .iter()
6170                .filter(|cp| cp.change_type == ChangeType::Onset)
6171                .collect();
6172            // At least one onset should exist near the transition
6173            assert!(!onset_points.is_empty(), "Should detect Onset change point");
6174        }
6175    }
6176
6177    #[test]
6178    fn test_detect_seasonality_changes_no_change() {
6179        let m = 400;
6180        let period = 2.0;
6181        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6182
6183        // Consistently strong seasonal signal
6184        let data: Vec<f64> = argvals
6185            .iter()
6186            .map(|&t| (2.0 * PI * t / period).sin())
6187            .collect();
6188        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6189
6190        let result = detect_seasonality_changes(&data, &argvals, period, 0.3, 4.0, 2.0);
6191
6192        assert!(!result.strength_curve.is_empty());
6193        // With consistently seasonal data, there should be no Cessation points
6194        let cessation_points: Vec<_> = result
6195            .change_points
6196            .iter()
6197            .filter(|cp| cp.change_type == ChangeType::Cessation)
6198            .collect();
6199        assert!(
6200            cessation_points.is_empty(),
6201            "Consistently seasonal signal should have no Cessation points, found {}",
6202            cessation_points.len()
6203        );
6204    }
6205
6206    // ========================================================================
6207    // Period estimation tests (ACF and regression)
6208    // ========================================================================
6209
6210    #[test]
6211    fn test_estimate_period_acf() {
6212        let m = 200;
6213        let period = 2.0;
6214        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6215        let data = generate_sine(1, m, period, &argvals);
6216
6217        let estimate = estimate_period_acf(data.as_slice(), 1, m, &argvals, m / 2);
6218
6219        assert!(
6220            estimate.period.is_finite(),
6221            "ACF period estimate should be finite"
6222        );
6223        assert!(
6224            (estimate.period - period).abs() < 0.5,
6225            "ACF expected period ≈ {}, got {}",
6226            period,
6227            estimate.period
6228        );
6229        assert!(
6230            estimate.confidence > 0.0,
6231            "ACF confidence should be positive"
6232        );
6233    }
6234
6235    #[test]
6236    fn test_estimate_period_regression() {
6237        let m = 200;
6238        let period = 2.0;
6239        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6240        let data = generate_sine(1, m, period, &argvals);
6241
6242        let estimate =
6243            estimate_period_regression(data.as_slice(), 1, m, &argvals, 1.5, 3.0, 100, 3);
6244
6245        assert!(
6246            estimate.period.is_finite(),
6247            "Regression period estimate should be finite"
6248        );
6249        assert!(
6250            (estimate.period - period).abs() < 0.5,
6251            "Regression expected period ≈ {}, got {}",
6252            period,
6253            estimate.period
6254        );
6255        assert!(
6256            estimate.confidence > 0.0,
6257            "Regression confidence should be positive"
6258        );
6259    }
6260
6261    // ========================================================================
6262    // Seasonal strength windowed test
6263    // ========================================================================
6264
6265    #[test]
6266    fn test_seasonal_strength_windowed_variance() {
6267        let m = 200;
6268        let period = 2.0;
6269        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6270        let data = generate_sine(1, m, period, &argvals);
6271
6272        let strengths = seasonal_strength_windowed(
6273            &data,
6274            &argvals,
6275            period,
6276            4.0, // window_size
6277            StrengthMethod::Variance,
6278        );
6279
6280        assert_eq!(strengths.len(), m, "Should return m values");
6281
6282        // Interior values (away from edges) should be in [0,1]
6283        let interior_start = m / 4;
6284        let interior_end = 3 * m / 4;
6285        for i in interior_start..interior_end {
6286            let s = strengths[i];
6287            if s.is_finite() {
6288                assert!(
6289                    (-0.01..=1.01).contains(&s),
6290                    "Windowed strength at {} should be near [0,1], got {}",
6291                    i,
6292                    s
6293                );
6294            }
6295        }
6296    }
6297
6298    #[test]
6299    fn test_constant_signal_fft_period() {
6300        // Constant signal has no periodicity
6301        let m = 100;
6302        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 1.0).collect();
6303        let data_vec: Vec<f64> = vec![5.0; m];
6304        let data = FdMatrix::from_column_major(data_vec, 1, m).unwrap();
6305        let result = estimate_period_fft(&data, &argvals);
6306        // Should return something (even if period is meaningless for constant)
6307        assert!(result.period.is_finite() || result.period.is_nan());
6308    }
6309
6310    #[test]
6311    fn test_very_short_series_period() {
6312        // Very short series (4 points)
6313        let argvals = vec![0.0, 1.0, 2.0, 3.0];
6314        let data_vec = vec![1.0, -1.0, 1.0, -1.0];
6315        let data = FdMatrix::from_column_major(data_vec, 1, 4).unwrap();
6316        let result = estimate_period_fft(&data, &argvals);
6317        assert!(result.period.is_finite() || result.period.is_nan());
6318    }
6319
6320    #[test]
6321    fn test_nan_sazed_no_panic() {
6322        let mut data = vec![0.0; 50];
6323        let argvals: Vec<f64> = (0..50).map(|i| i as f64).collect();
6324        for i in 0..50 {
6325            data[i] = (2.0 * std::f64::consts::PI * i as f64 / 10.0).sin();
6326        }
6327        data[10] = f64::NAN;
6328        let result = sazed(&data, &argvals, None);
6329        // Should not panic
6330        assert!(result.period.is_finite() || result.period.is_nan());
6331    }
6332
6333    // ========================================================================
6334    // Additional coverage tests
6335    // ========================================================================
6336
6337    #[test]
6338    fn test_interior_bounds_very_small() {
6339        // m = 0 should return None since end <= start
6340        let bounds = interior_bounds(0);
6341        assert!(bounds.is_none());
6342
6343        // m = 1
6344        let bounds = interior_bounds(1);
6345        // edge_skip = 0, interior_start = min(0, 0) = 0, interior_end = max(1, 0) = 1
6346        // end > start => Some
6347        assert!(bounds.is_some() || bounds.is_none());
6348    }
6349
6350    #[test]
6351    fn test_valid_interior_bounds_min_span() {
6352        // m = 10 should give valid bounds
6353        let bounds = valid_interior_bounds(10, 4);
6354        // Should pass since span > 4
6355        assert!(bounds.is_some());
6356
6357        // Very high min_span should fail
6358        let bounds = valid_interior_bounds(10, 100);
6359        assert!(bounds.is_none());
6360    }
6361
6362    #[test]
6363    fn test_periodogram_edge_cases() {
6364        // Empty data
6365        let (freqs, power) = periodogram(&[], &[]);
6366        assert!(freqs.is_empty());
6367        assert!(power.is_empty());
6368
6369        // Single data point
6370        let (freqs, power) = periodogram(&[1.0], &[0.0]);
6371        assert!(freqs.is_empty());
6372        assert!(power.is_empty());
6373
6374        // Mismatched lengths
6375        let (freqs, power) = periodogram(&[1.0, 2.0], &[0.0]);
6376        assert!(freqs.is_empty());
6377        assert!(power.is_empty());
6378    }
6379
6380    #[test]
6381    fn test_autocorrelation_edge_cases() {
6382        // Empty data
6383        let acf = autocorrelation(&[], 10);
6384        assert!(acf.is_empty());
6385
6386        // Constant data (zero variance)
6387        let acf = autocorrelation(&[5.0, 5.0, 5.0, 5.0], 3);
6388        assert_eq!(acf.len(), 4);
6389        for &v in &acf {
6390            assert!((v - 1.0).abs() < 1e-10, "Constant data ACF should be 1.0");
6391        }
6392    }
6393
6394    #[test]
6395    fn test_detect_seasonality_changes_empty_data() {
6396        // Empty matrix should return empty result
6397        let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6398        let argvals: Vec<f64> = vec![];
6399        let result = detect_seasonality_changes(&data, &argvals, 2.0, 0.3, 4.0, 2.0);
6400        assert!(result.change_points.is_empty());
6401        assert!(result.strength_curve.is_empty());
6402
6403        // Too few points (m < 4)
6404        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6405        let argvals = vec![0.0, 1.0, 2.0];
6406        let result = detect_seasonality_changes(&data, &argvals, 2.0, 0.3, 4.0, 2.0);
6407        assert!(result.change_points.is_empty());
6408        assert!(result.strength_curve.is_empty());
6409    }
6410
6411    #[test]
6412    fn test_detect_amplitude_modulation_non_seasonal_returns_early() {
6413        // Non-seasonal data should return early with NonSeasonal type
6414        let m = 100;
6415        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
6416
6417        // Pure noise
6418        let data: Vec<f64> = (0..m)
6419            .map(|i| ((i as f64 * 1.618).sin() * 100.0).fract())
6420            .collect();
6421        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6422
6423        let result = detect_amplitude_modulation(&data, &argvals, 0.2, 0.15, 0.5);
6424        assert!(!result.is_seasonal);
6425        assert_eq!(result.modulation_type, ModulationType::NonSeasonal);
6426        assert_eq!(result.modulation_score, 0.0);
6427    }
6428
6429    #[test]
6430    fn test_detect_amplitude_modulation_small_data() {
6431        // m < 4 should hit early return
6432        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6433        let argvals = vec![0.0, 1.0, 2.0];
6434
6435        let result = detect_amplitude_modulation(&data, &argvals, 1.0, 0.15, 0.3);
6436        assert!(!result.is_seasonal);
6437    }
6438
6439    #[test]
6440    fn test_detect_amplitude_modulation_wavelet_invalid_inputs() {
6441        // Empty data
6442        let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6443        let result = detect_amplitude_modulation_wavelet(&data, &[], 2.0, 0.15, 0.3);
6444        assert!(!result.is_seasonal);
6445        assert_eq!(result.modulation_type, ModulationType::NonSeasonal);
6446
6447        // m < 4
6448        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6449        let argvals = vec![0.0, 1.0, 2.0];
6450        let result = detect_amplitude_modulation_wavelet(&data, &argvals, 2.0, 0.15, 0.3);
6451        assert!(!result.is_seasonal);
6452
6453        // period <= 0
6454        let m = 100;
6455        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
6456        let data: Vec<f64> = argvals
6457            .iter()
6458            .map(|&t| (2.0 * PI * t / 0.2).sin())
6459            .collect();
6460        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6461        let result = detect_amplitude_modulation_wavelet(&data, &argvals, -1.0, 0.15, 0.3);
6462        assert!(!result.is_seasonal);
6463    }
6464
6465    #[test]
6466    fn test_detect_amplitude_modulation_wavelet_non_seasonal() {
6467        // Non-seasonal data should return early
6468        let m = 100;
6469        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
6470
6471        // Pure noise
6472        let data: Vec<f64> = (0..m)
6473            .map(|i| ((i as f64 * 1.618).sin() * 100.0).fract())
6474            .collect();
6475        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6476
6477        let result = detect_amplitude_modulation_wavelet(&data, &argvals, 0.2, 0.15, 0.5);
6478        assert!(!result.is_seasonal);
6479        assert_eq!(result.modulation_type, ModulationType::NonSeasonal);
6480    }
6481
6482    #[test]
6483    fn test_detect_amplitude_modulation_wavelet_seasonal() {
6484        // Seasonal signal with known modulation
6485        let m = 200;
6486        let period = 0.2;
6487        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
6488
6489        // Amplitude grows from 0.2 to 1.0
6490        let data: Vec<f64> = argvals
6491            .iter()
6492            .map(|&t| {
6493                let amplitude = 0.2 + 0.8 * t;
6494                amplitude * (2.0 * PI * t / period).sin()
6495            })
6496            .collect();
6497        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6498
6499        let result = detect_amplitude_modulation_wavelet(&data, &argvals, period, 0.15, 0.2);
6500        assert!(result.is_seasonal, "Should detect seasonality");
6501        assert!(result.scale > 0.0, "Scale should be positive");
6502        assert!(
6503            !result.wavelet_amplitude.is_empty(),
6504            "Wavelet amplitude should be computed"
6505        );
6506        assert_eq!(result.time_points.len(), m);
6507    }
6508
6509    #[test]
6510    fn test_instantaneous_period_invalid_inputs() {
6511        // Empty data
6512        let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6513        let result = instantaneous_period(&data, &[]);
6514        assert!(result.period.is_empty());
6515        assert!(result.frequency.is_empty());
6516        assert!(result.amplitude.is_empty());
6517
6518        // m < 4
6519        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6520        let result = instantaneous_period(&data, &[0.0, 1.0, 2.0]);
6521        assert!(result.period.is_empty());
6522    }
6523
6524    #[test]
6525    fn test_analyze_peak_timing_invalid_inputs() {
6526        // Empty data
6527        let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6528        let result = analyze_peak_timing(&data, &[], 2.0, None);
6529        assert!(result.peak_times.is_empty());
6530        assert!(result.mean_timing.is_nan());
6531
6532        // m < 3
6533        let data = FdMatrix::from_column_major(vec![1.0, 2.0], 1, 2).unwrap();
6534        let result = analyze_peak_timing(&data, &[0.0, 1.0], 2.0, None);
6535        assert!(result.peak_times.is_empty());
6536        assert!(result.mean_timing.is_nan());
6537
6538        // period <= 0
6539        let m = 100;
6540        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6541        let data = generate_sine(1, m, 2.0, &argvals);
6542        let result = analyze_peak_timing(&data, &argvals, -1.0, None);
6543        assert!(result.peak_times.is_empty());
6544        assert!(result.mean_timing.is_nan());
6545    }
6546
6547    #[test]
6548    fn test_analyze_peak_timing_no_peaks() {
6549        // Very short data (m < 3) should return early with no peaks
6550        let data = FdMatrix::from_column_major(vec![5.0, 5.0], 1, 2).unwrap();
6551        let result = analyze_peak_timing(&data, &[0.0, 1.0], 2.0, Some(11));
6552        assert!(result.peak_times.is_empty());
6553        assert!(result.mean_timing.is_nan());
6554    }
6555
6556    #[test]
6557    fn test_classify_seasonality_invalid_inputs() {
6558        // Empty data
6559        let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6560        let result = classify_seasonality(&data, &[], 2.0, None, None);
6561        assert!(!result.is_seasonal);
6562        assert!(result.seasonal_strength.is_nan());
6563        assert_eq!(result.classification, SeasonalType::NonSeasonal);
6564
6565        // m < 4
6566        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6567        let result = classify_seasonality(&data, &[0.0, 1.0, 2.0], 2.0, None, None);
6568        assert!(!result.is_seasonal);
6569        assert_eq!(result.classification, SeasonalType::NonSeasonal);
6570
6571        // period <= 0
6572        let m = 100;
6573        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6574        let data = generate_sine(1, m, 2.0, &argvals);
6575        let result = classify_seasonality(&data, &argvals, -1.0, None, None);
6576        assert!(!result.is_seasonal);
6577    }
6578
6579    #[test]
6580    fn test_classify_seasonality_non_seasonal() {
6581        // Constant data should be non-seasonal
6582        let m = 100;
6583        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6584        let data = FdMatrix::from_column_major(vec![5.0; m], 1, m).unwrap();
6585
6586        let result = classify_seasonality(&data, &argvals, 2.0, Some(0.3), Some(0.05));
6587        assert!(!result.is_seasonal);
6588        assert_eq!(result.classification, SeasonalType::NonSeasonal);
6589    }
6590
6591    #[test]
6592    fn test_classify_seasonality_strong_seasonal() {
6593        // Strong, stable seasonal signal
6594        let m = 400;
6595        let period = 2.0;
6596        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6597        let data = generate_sine(1, m, period, &argvals);
6598
6599        let result = classify_seasonality(&data, &argvals, period, Some(0.3), Some(0.5));
6600        assert!(result.is_seasonal);
6601        assert!(result.seasonal_strength > 0.5);
6602        assert!(result.peak_timing.is_some());
6603        // Check cycle_strengths is populated
6604        assert!(
6605            !result.cycle_strengths.is_empty(),
6606            "cycle_strengths should be computed"
6607        );
6608    }
6609
6610    #[test]
6611    fn test_classify_seasonality_with_custom_thresholds() {
6612        let m = 400;
6613        let period = 2.0;
6614        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6615        let data = generate_sine(1, m, period, &argvals);
6616
6617        // Very high strength threshold
6618        let result = classify_seasonality(&data, &argvals, period, Some(0.99), None);
6619        // Should still detect as seasonal for pure sine
6620        assert!(result.seasonal_strength > 0.8);
6621    }
6622
6623    #[test]
6624    fn test_detect_seasonality_changes_auto_fixed() {
6625        let m = 400;
6626        let period = 2.0;
6627        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6628
6629        // Signal with onset
6630        let data: Vec<f64> = argvals
6631            .iter()
6632            .map(|&t| {
6633                if t < 10.0 {
6634                    0.05 * ((t * 13.0).sin() + (t * 7.0).cos())
6635                } else {
6636                    (2.0 * PI * t / period).sin()
6637                }
6638            })
6639            .collect();
6640        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6641
6642        // Test with Fixed threshold method
6643        let result = detect_seasonality_changes_auto(
6644            &data,
6645            &argvals,
6646            period,
6647            ThresholdMethod::Fixed(0.3),
6648            4.0,
6649            2.0,
6650        );
6651        assert!(!result.strength_curve.is_empty());
6652    }
6653
6654    #[test]
6655    fn test_detect_seasonality_changes_auto_percentile() {
6656        let m = 400;
6657        let period = 2.0;
6658        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6659
6660        let data: Vec<f64> = argvals
6661            .iter()
6662            .map(|&t| {
6663                if t < 10.0 {
6664                    0.05 * ((t * 13.0).sin() + (t * 7.0).cos())
6665                } else {
6666                    (2.0 * PI * t / period).sin()
6667                }
6668            })
6669            .collect();
6670        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6671
6672        // Test with Percentile threshold method
6673        let result = detect_seasonality_changes_auto(
6674            &data,
6675            &argvals,
6676            period,
6677            ThresholdMethod::Percentile(50.0),
6678            4.0,
6679            2.0,
6680        );
6681        assert!(!result.strength_curve.is_empty());
6682    }
6683
6684    #[test]
6685    fn test_detect_seasonality_changes_auto_otsu() {
6686        let m = 400;
6687        let period = 2.0;
6688        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
6689
6690        let data: Vec<f64> = argvals
6691            .iter()
6692            .map(|&t| {
6693                if t < 10.0 {
6694                    0.05 * ((t * 13.0).sin() + (t * 7.0).cos())
6695                } else {
6696                    (2.0 * PI * t / period).sin()
6697                }
6698            })
6699            .collect();
6700        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
6701
6702        // Test with Otsu threshold method
6703        let result = detect_seasonality_changes_auto(
6704            &data,
6705            &argvals,
6706            period,
6707            ThresholdMethod::Otsu,
6708            4.0,
6709            2.0,
6710        );
6711        assert!(!result.strength_curve.is_empty());
6712    }
6713
6714    #[test]
6715    fn test_detect_seasonality_changes_auto_invalid_inputs() {
6716        // Empty data
6717        let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6718        let result =
6719            detect_seasonality_changes_auto(&data, &[], 2.0, ThresholdMethod::Fixed(0.3), 4.0, 2.0);
6720        assert!(result.change_points.is_empty());
6721        assert!(result.strength_curve.is_empty());
6722
6723        // m < 4
6724        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
6725        let result = detect_seasonality_changes_auto(
6726            &data,
6727            &[0.0, 1.0, 2.0],
6728            2.0,
6729            ThresholdMethod::Otsu,
6730            1.0,
6731            0.5,
6732        );
6733        assert!(result.change_points.is_empty());
6734        assert!(result.strength_curve.is_empty());
6735    }
6736
6737    #[test]
6738    fn test_cfd_autoperiod_fdata_invalid_inputs() {
6739        // Empty data
6740        let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6741        let result = cfd_autoperiod_fdata(&data, &[], None, None);
6742        assert!(result.period.is_nan());
6743        assert_eq!(result.confidence, 0.0);
6744
6745        // m < 8
6746        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 5.0], 1, 5).unwrap();
6747        let result = cfd_autoperiod_fdata(&data, &[0.0, 1.0, 2.0, 3.0, 4.0], None, None);
6748        assert!(result.period.is_nan());
6749    }
6750
6751    #[test]
6752    fn test_cfd_autoperiod_fdata_valid() {
6753        // Valid data with seasonal pattern
6754        let n = 3;
6755        let m = 200;
6756        let period = 2.0;
6757        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6758        let data = generate_sine(n, m, period, &argvals);
6759
6760        let result = cfd_autoperiod_fdata(&data, &argvals, Some(0.1), Some(1));
6761        assert!(result.period.is_finite());
6762    }
6763
6764    #[test]
6765    fn test_lomb_scargle_fap_edge_cases() {
6766        // power <= 0
6767        let fap = lomb_scargle_fap(0.0, 100, 200);
6768        assert_eq!(fap, 1.0);
6769
6770        let fap = lomb_scargle_fap(-1.0, 100, 200);
6771        assert_eq!(fap, 1.0);
6772
6773        // n_indep = 0
6774        let fap = lomb_scargle_fap(10.0, 0, 200);
6775        assert_eq!(fap, 1.0);
6776
6777        // Very high power should give FAP near 0
6778        let fap = lomb_scargle_fap(100.0, 100, 200);
6779        assert!(
6780            fap < 0.01,
6781            "Very high power should give low FAP, got {}",
6782            fap
6783        );
6784
6785        // Moderate power
6786        let fap = lomb_scargle_fap(5.0, 50, 100);
6787        assert!((0.0..=1.0).contains(&fap));
6788    }
6789
6790    #[test]
6791    fn test_lomb_scargle_fdata_valid() {
6792        let n = 3;
6793        let m = 200;
6794        let period = 5.0;
6795        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6796        let data = generate_sine(n, m, period, &argvals);
6797
6798        let result = lomb_scargle_fdata(&data, &argvals, Some(4.0), Some(1.0));
6799        assert!(
6800            (result.peak_period - period).abs() < 1.0,
6801            "Expected period ~{}, got {}",
6802            period,
6803            result.peak_period
6804        );
6805        assert!(!result.frequencies.is_empty());
6806    }
6807
6808    #[test]
6809    fn test_cwt_morlet_edge_cases() {
6810        // Empty signal
6811        let result = cwt_morlet_fft(&[], &[], 1.0, 6.0);
6812        assert!(result.is_empty());
6813
6814        // scale <= 0
6815        let result = cwt_morlet_fft(&[1.0, 2.0], &[0.0, 1.0], 0.0, 6.0);
6816        assert!(result.is_empty());
6817
6818        let result = cwt_morlet_fft(&[1.0, 2.0], &[0.0, 1.0], -1.0, 6.0);
6819        assert!(result.is_empty());
6820    }
6821
6822    #[test]
6823    fn test_hilbert_transform_empty() {
6824        let result = hilbert_transform(&[]);
6825        assert!(result.is_empty());
6826    }
6827
6828    #[test]
6829    fn test_unwrap_phase_empty() {
6830        let result = unwrap_phase(&[]);
6831        assert!(result.is_empty());
6832    }
6833
6834    #[test]
6835    fn test_unwrap_phase_monotonic() {
6836        // Phase that wraps around
6837        let phase = vec![0.0, 1.0, 2.0, 3.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0];
6838        let unwrapped = unwrap_phase(&phase);
6839        assert_eq!(unwrapped.len(), phase.len());
6840        // After unwrapping, phase should be monotonically increasing
6841        for i in 1..unwrapped.len() {
6842            assert!(
6843                unwrapped[i] >= unwrapped[i - 1] - 0.01,
6844                "Unwrapped phase should be monotonic at {}: {} vs {}",
6845                i,
6846                unwrapped[i],
6847                unwrapped[i - 1]
6848            );
6849        }
6850    }
6851
6852    #[test]
6853    fn test_linear_slope_edge_cases() {
6854        // Mismatched lengths
6855        assert_eq!(linear_slope(&[1.0, 2.0], &[1.0]), 0.0);
6856
6857        // Too few points
6858        assert_eq!(linear_slope(&[1.0], &[1.0]), 0.0);
6859
6860        // Perfect linear relationship: y = 2x + 1
6861        let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
6862        let y = vec![1.0, 3.0, 5.0, 7.0, 9.0];
6863        let slope = linear_slope(&x, &y);
6864        assert!(
6865            (slope - 2.0).abs() < 1e-10,
6866            "Slope should be 2.0, got {}",
6867            slope
6868        );
6869
6870        // Constant x (zero variance in x)
6871        let x = vec![5.0, 5.0, 5.0];
6872        let y = vec![1.0, 2.0, 3.0];
6873        let slope = linear_slope(&x, &y);
6874        assert_eq!(slope, 0.0, "Constant x should give slope 0");
6875    }
6876
6877    #[test]
6878    fn test_otsu_threshold_edge_cases() {
6879        // Empty values
6880        let threshold = otsu_threshold(&[]);
6881        assert!((threshold - 0.5).abs() < 1e-10);
6882
6883        // All NaN
6884        let threshold = otsu_threshold(&[f64::NAN, f64::NAN]);
6885        assert!((threshold - 0.5).abs() < 1e-10);
6886
6887        // Constant values
6888        let threshold = otsu_threshold(&[5.0, 5.0, 5.0]);
6889        assert!((threshold - 5.0).abs() < 1e-10);
6890
6891        // Two distinct values
6892        let threshold = otsu_threshold(&[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
6893        assert!(threshold > 0.0 && threshold < 1.0);
6894    }
6895
6896    #[test]
6897    fn test_find_peaks_1d_edge_cases() {
6898        // Too short
6899        assert!(find_peaks_1d(&[], 1).is_empty());
6900        assert!(find_peaks_1d(&[1.0], 1).is_empty());
6901        assert!(find_peaks_1d(&[1.0, 2.0], 1).is_empty());
6902
6903        // Single peak
6904        let peaks = find_peaks_1d(&[0.0, 1.0, 0.0], 1);
6905        assert_eq!(peaks, vec![1]);
6906
6907        // Two peaks with min distance filtering
6908        let signal = vec![0.0, 2.0, 0.0, 1.5, 0.0];
6909        let peaks = find_peaks_1d(&signal, 1);
6910        assert_eq!(peaks, vec![1, 3]);
6911
6912        // Two peaks close together, min_distance replaces shorter
6913        let signal = vec![0.0, 1.0, 0.5, 2.0, 0.0];
6914        let peaks = find_peaks_1d(&signal, 3);
6915        // Peak at 1 (val=1.0) found first, then peak at 3 (val=2.0) is within
6916        // distance 3, but higher, so replaces it
6917        assert_eq!(peaks.len(), 1);
6918        assert_eq!(peaks[0], 3);
6919    }
6920
6921    #[test]
6922    fn test_compute_prominence() {
6923        // Simple peak
6924        let signal = vec![0.0, 0.0, 5.0, 0.0, 0.0];
6925        let prom = compute_prominence(&signal, 2);
6926        assert!((prom - 5.0).abs() < 1e-10);
6927
6928        // Peak with asymmetric valleys
6929        let signal = vec![0.0, 2.0, 5.0, 1.0, 0.0];
6930        let prom = compute_prominence(&signal, 2);
6931        // Left min = 0.0 (going left until higher peak), right min = 0.0
6932        // Prominence = 5.0 - max(0.0, 0.0) = 5.0
6933        assert!(prom > 0.0);
6934    }
6935
6936    #[test]
6937    fn test_seasonal_strength_variance_invalid() {
6938        // Empty data
6939        let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6940        let result = seasonal_strength_variance(&data, &[], 2.0, 3);
6941        assert!(result.is_nan());
6942
6943        // period <= 0
6944        let m = 50;
6945        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6946        let data = generate_sine(1, m, 2.0, &argvals);
6947        let result = seasonal_strength_variance(&data, &argvals, -1.0, 3);
6948        assert!(result.is_nan());
6949    }
6950
6951    #[test]
6952    fn test_seasonal_strength_spectral_invalid() {
6953        // Empty data
6954        let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6955        let result = seasonal_strength_spectral(&data, &[], 2.0);
6956        assert!(result.is_nan());
6957
6958        // period <= 0
6959        let m = 50;
6960        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6961        let data = generate_sine(1, m, 2.0, &argvals);
6962        let result = seasonal_strength_spectral(&data, &argvals, -1.0);
6963        assert!(result.is_nan());
6964    }
6965
6966    #[test]
6967    fn test_seasonal_strength_wavelet_invalid() {
6968        // Empty data
6969        let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
6970        let result = seasonal_strength_wavelet(&data, &[], 2.0);
6971        assert!(result.is_nan());
6972
6973        // period <= 0
6974        let m = 50;
6975        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6976        let data = generate_sine(1, m, 2.0, &argvals);
6977        let result = seasonal_strength_wavelet(&data, &argvals, -1.0);
6978        assert!(result.is_nan());
6979
6980        // Constant data (zero variance)
6981        let data = FdMatrix::from_column_major(vec![5.0; m], 1, m).unwrap();
6982        let result = seasonal_strength_wavelet(&data, &argvals, 2.0);
6983        assert!(
6984            (result - 0.0).abs() < 1e-10,
6985            "Constant data should have 0 strength"
6986        );
6987    }
6988
6989    #[test]
6990    fn test_seasonal_strength_windowed_spectral() {
6991        // Test with Spectral method (existing test only covers Variance)
6992        let m = 200;
6993        let period = 2.0;
6994        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
6995        let data = generate_sine(1, m, period, &argvals);
6996
6997        let strengths =
6998            seasonal_strength_windowed(&data, &argvals, period, 4.0, StrengthMethod::Spectral);
6999
7000        assert_eq!(strengths.len(), m, "Should return m values");
7001    }
7002
7003    #[test]
7004    fn test_seasonal_strength_windowed_invalid() {
7005        // Empty data
7006        let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
7007        let strengths = seasonal_strength_windowed(&data, &[], 2.0, 4.0, StrengthMethod::Variance);
7008        assert!(strengths.is_empty());
7009
7010        // window_size <= 0
7011        let m = 50;
7012        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
7013        let data = generate_sine(1, m, 2.0, &argvals);
7014        let strengths =
7015            seasonal_strength_windowed(&data, &argvals, 2.0, -1.0, StrengthMethod::Variance);
7016        assert!(strengths.is_empty());
7017    }
7018
7019    #[test]
7020    fn test_ssa_custom_window_length() {
7021        // Test with explicit window_length that might trigger edge case
7022        let n = 50;
7023        let values: Vec<f64> = (0..n).map(|i| (2.0 * PI * i as f64 / 10.0).sin()).collect();
7024
7025        // window_length > n/2 should trigger early return
7026        let result = ssa(&values, Some(30), None, None, None);
7027        // Since 30 > 50/2 = 25, this should return the early return path
7028        assert_eq!(result.trend, values);
7029        assert_eq!(result.n_components, 0);
7030    }
7031
7032    #[test]
7033    fn test_ssa_window_length_too_small() {
7034        let n = 50;
7035        let values: Vec<f64> = (0..n).map(|i| i as f64).collect();
7036
7037        // window_length = 1 (< 2)
7038        let result = ssa(&values, Some(1), None, None, None);
7039        assert_eq!(result.trend, values);
7040        assert_eq!(result.n_components, 0);
7041    }
7042
7043    #[test]
7044    fn test_ssa_auto_grouping() {
7045        // Test auto-grouping path (no explicit trend/seasonal components)
7046        let n = 200;
7047        let period = 12.0;
7048        let values: Vec<f64> = (0..n)
7049            .map(|i| {
7050                let t = i as f64;
7051                // Clear trend + seasonal + noise
7052                0.05 * t + 2.0 * (2.0 * PI * t / period).sin() + 0.01 * ((i * 7) as f64).sin()
7053            })
7054            .collect();
7055
7056        let result = ssa(&values, Some(30), Some(6), None, None);
7057
7058        // Should detect period
7059        assert!(
7060            result.detected_period > 0.0,
7061            "Should detect a period, got {}",
7062            result.detected_period
7063        );
7064        assert!(result.confidence > 0.0);
7065
7066        // Reconstruction should hold
7067        for i in 0..n {
7068            let reconstructed = result.trend[i] + result.seasonal[i] + result.noise[i];
7069            assert!(
7070                (reconstructed - values[i]).abs() < 1e-8,
7071                "SSA auto-grouping reconstruction error at {}",
7072                i
7073            );
7074        }
7075    }
7076
7077    #[test]
7078    fn test_ssa_with_many_components() {
7079        // Test with n_components larger than available
7080        let n = 100;
7081        let values: Vec<f64> = (0..n).map(|i| (2.0 * PI * i as f64 / 12.0).sin()).collect();
7082
7083        let result = ssa(&values, None, Some(100), None, None);
7084        assert!(result.n_components <= n);
7085        assert!(!result.singular_values.is_empty());
7086    }
7087
7088    #[test]
7089    fn test_embed_trajectory() {
7090        // Simple test: [1, 2, 3, 4, 5] with L=3, K=3
7091        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
7092        let l = 3;
7093        let k = 3; // n - l + 1 = 5 - 3 + 1 = 3
7094        let traj = embed_trajectory(&values, l, k);
7095
7096        // Column 0: [1, 2, 3]
7097        assert!((traj[0] - 1.0).abs() < 1e-10);
7098        assert!((traj[1] - 2.0).abs() < 1e-10);
7099        assert!((traj[2] - 3.0).abs() < 1e-10);
7100
7101        // Column 1: [2, 3, 4]
7102        assert!((traj[3] - 2.0).abs() < 1e-10);
7103        assert!((traj[4] - 3.0).abs() < 1e-10);
7104        assert!((traj[5] - 4.0).abs() < 1e-10);
7105
7106        // Column 2: [3, 4, 5]
7107        assert!((traj[6] - 3.0).abs() < 1e-10);
7108        assert!((traj[7] - 4.0).abs() < 1e-10);
7109        assert!((traj[8] - 5.0).abs() < 1e-10);
7110    }
7111
7112    #[test]
7113    fn test_diagonal_average() {
7114        // Test with a simple 3x3 trajectory matrix
7115        // If all values are 1.0, diagonal averaging should give 1.0
7116        let l = 3;
7117        let k = 3;
7118        let n = l + k - 1; // = 5
7119        let matrix = vec![1.0; l * k];
7120        let result = diagonal_average(&matrix, l, k, n);
7121        assert_eq!(result.len(), n);
7122        for &v in &result {
7123            assert!((v - 1.0).abs() < 1e-10, "Expected 1.0, got {}", v);
7124        }
7125    }
7126
7127    #[test]
7128    fn test_svd_decompose() {
7129        // Simple 3x2 matrix
7130        let l = 3;
7131        let k = 2;
7132        let trajectory = vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0]; // identity-like
7133        let (u, sigma, vt) = svd_decompose(&trajectory, l, k);
7134
7135        assert!(!u.is_empty(), "U should not be empty");
7136        assert!(!sigma.is_empty(), "Sigma should not be empty");
7137        assert!(!vt.is_empty(), "V^T should not be empty");
7138
7139        // Singular values should be non-negative and descending
7140        for &s in &sigma {
7141            assert!(s >= 0.0, "Singular values must be non-negative");
7142        }
7143        for i in 1..sigma.len() {
7144            assert!(sigma[i] <= sigma[i - 1] + 1e-10);
7145        }
7146    }
7147
7148    #[test]
7149    fn test_is_trend_component() {
7150        // Monotonic vector (trend-like)
7151        let trend_vec: Vec<f64> = (0..20).map(|i| i as f64).collect();
7152        assert!(is_trend_component(&trend_vec));
7153
7154        // Oscillating vector (not a trend)
7155        let osc_vec: Vec<f64> = (0..20).map(|i| (PI * i as f64 / 3.0).sin()).collect();
7156        assert!(!is_trend_component(&osc_vec));
7157
7158        // Too short
7159        assert!(!is_trend_component(&[1.0, 2.0]));
7160    }
7161
7162    #[test]
7163    fn test_is_periodic_component() {
7164        // Periodic signal
7165        let periodic: Vec<f64> = (0..40)
7166            .map(|i| (2.0 * PI * i as f64 / 10.0).sin())
7167            .collect();
7168        let (is_periodic, period) = is_periodic_component(&periodic);
7169        assert!(is_periodic, "Should detect periodicity");
7170        assert!(
7171            (period - 10.0).abs() < 2.0,
7172            "Expected period ~10, got {}",
7173            period
7174        );
7175
7176        // Monotonic signal (no periodicity)
7177        let monotonic: Vec<f64> = (0..40).map(|i| i as f64 * 0.01).collect();
7178        let (is_periodic, _) = is_periodic_component(&monotonic);
7179        // Monotonic ramp has no significant positive ACF peak at lag > 1
7180        // (autocorrelation decays monotonically)
7181        // We just verify it doesn't panic and returns a result
7182        let _ = is_periodic;
7183
7184        // Too short
7185        let (is_periodic, _) = is_periodic_component(&[1.0, 2.0, 3.0]);
7186        assert!(!is_periodic, "Too short to be periodic");
7187    }
7188
7189    #[test]
7190    fn test_classify_ssa_component() {
7191        // Trend-like vector
7192        let trend_vec: Vec<f64> = (0..20).map(|i| i as f64 * 0.1).collect();
7193        let kind = classify_ssa_component(&trend_vec, 0);
7194        assert!(matches!(kind, SsaComponentKind::Trend));
7195
7196        // Second trend should still classify as trend if count < 2
7197        let kind = classify_ssa_component(&trend_vec, 1);
7198        assert!(matches!(kind, SsaComponentKind::Trend));
7199
7200        // With trend_count >= 2, is_trend_component still returns true but the
7201        // condition fails, so it falls through to is_periodic_component.
7202        // A monotonic ramp may or may not be detected as periodic depending on
7203        // ACF behavior, so we just verify it doesn't crash and isn't Trend.
7204        let kind = classify_ssa_component(&trend_vec, 2);
7205        assert!(!matches!(kind, SsaComponentKind::Trend));
7206
7207        // Periodic vector
7208        let periodic: Vec<f64> = (0..40)
7209            .map(|i| (2.0 * PI * i as f64 / 10.0).sin())
7210            .collect();
7211        let kind = classify_ssa_component(&periodic, 0);
7212        assert!(matches!(kind, SsaComponentKind::Seasonal(_)));
7213    }
7214
7215    #[test]
7216    fn test_apply_ssa_grouping_defaults() {
7217        // Empty indices with enough components
7218        let mut trend_idx = Vec::new();
7219        let mut seasonal_idx = Vec::new();
7220        apply_ssa_grouping_defaults(&mut trend_idx, &mut seasonal_idx, 5);
7221        assert_eq!(trend_idx, vec![0]);
7222        assert_eq!(seasonal_idx, vec![1, 2]);
7223
7224        // Already populated indices
7225        let mut trend_idx = vec![0];
7226        let mut seasonal_idx = vec![1];
7227        apply_ssa_grouping_defaults(&mut trend_idx, &mut seasonal_idx, 5);
7228        assert_eq!(trend_idx, vec![0]); // unchanged
7229        assert_eq!(seasonal_idx, vec![1]); // unchanged
7230
7231        // n_comp < 3: no seasonal defaults
7232        let mut trend_idx = Vec::new();
7233        let mut seasonal_idx = Vec::new();
7234        apply_ssa_grouping_defaults(&mut trend_idx, &mut seasonal_idx, 2);
7235        assert_eq!(trend_idx, vec![0]);
7236        assert!(seasonal_idx.is_empty());
7237
7238        // n_comp = 0: no defaults
7239        let mut trend_idx = Vec::new();
7240        let mut seasonal_idx = Vec::new();
7241        apply_ssa_grouping_defaults(&mut trend_idx, &mut seasonal_idx, 0);
7242        assert!(trend_idx.is_empty());
7243        assert!(seasonal_idx.is_empty());
7244    }
7245
7246    #[test]
7247    fn test_reconstruct_grouped_empty() {
7248        // Empty group
7249        let result = reconstruct_grouped(&[], &[], &[], 3, 3, 5, &[]);
7250        assert_eq!(result, vec![0.0; 5]);
7251    }
7252
7253    #[test]
7254    fn test_reconstruct_grouped_idx_out_of_range() {
7255        // group_idx with index beyond sigma length
7256        let u = vec![1.0; 9]; // 3x3
7257        let sigma = vec![1.0, 0.5];
7258        let vt = vec![1.0; 6]; // 2x3 or similar
7259        let result = reconstruct_grouped(&u, &sigma, &vt, 3, 3, 5, &[5]);
7260        // Index 5 is beyond sigma.len()=2, so it should be skipped
7261        assert_eq!(result, vec![0.0; 5]);
7262    }
7263
7264    #[test]
7265    fn test_auto_group_ssa_components() {
7266        // Build a simple set of components
7267        let l = 20;
7268        let n_comp = 4;
7269        // U matrix: first column is trend-like, second and third are periodic
7270        let mut u = vec![0.0; l * n_comp];
7271        for i in 0..l {
7272            u[i] = i as f64 * 0.1; // Trend (monotonic)
7273            u[i + l] = (2.0 * PI * i as f64 / 8.0).sin(); // Periodic
7274            u[i + 2 * l] = (2.0 * PI * i as f64 / 8.0).cos(); // Periodic (same frequency)
7275            u[i + 3 * l] = (i as f64 * 1.618).fract(); // Noise-like
7276        }
7277        let sigma = vec![10.0, 5.0, 4.0, 0.1];
7278
7279        let (trend_idx, seasonal_idx, detected_period, confidence) =
7280            auto_group_ssa_components(&u, &sigma, l, 10, n_comp);
7281
7282        assert!(
7283            !trend_idx.is_empty(),
7284            "Should detect at least one trend component"
7285        );
7286        assert!(
7287            !seasonal_idx.is_empty(),
7288            "Should detect at least one seasonal component"
7289        );
7290        // Detected period should come from the periodic components
7291        if detected_period > 0.0 {
7292            assert!(confidence > 0.0);
7293        }
7294    }
7295
7296    #[test]
7297    fn test_estimate_period_fft_invalid() {
7298        // n == 0
7299        let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
7300        let result = estimate_period_fft(&data, &[]);
7301        assert!(result.period.is_nan());
7302        assert!(result.frequency.is_nan());
7303
7304        // m < 4
7305        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
7306        let result = estimate_period_fft(&data, &[0.0, 1.0, 2.0]);
7307        assert!(result.period.is_nan());
7308    }
7309
7310    #[test]
7311    fn test_detect_peaks_invalid_inputs() {
7312        // Empty data
7313        let data = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
7314        let result = detect_peaks(&data, &[], None, None, false, None);
7315        assert!(result.peaks.is_empty());
7316        assert!(result.mean_period.is_nan());
7317
7318        // m < 3
7319        let data = FdMatrix::from_column_major(vec![1.0, 2.0], 1, 2).unwrap();
7320        let result = detect_peaks(&data, &[0.0, 1.0], None, None, false, None);
7321        assert!(result.peaks.is_empty());
7322    }
7323
7324    #[test]
7325    fn test_detect_peaks_with_smoothing() {
7326        // Test peak detection with smoothing enabled (smooth_first = true)
7327        let m = 200;
7328        let period = 2.0;
7329        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
7330
7331        // Noisy sine wave
7332        let data: Vec<f64> = argvals
7333            .iter()
7334            .enumerate()
7335            .map(|(i, &t)| (2.0 * PI * t / period).sin() + 0.1 * ((i as f64 * 5.7).sin()))
7336            .collect();
7337        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
7338
7339        // With smoothing
7340        let result = detect_peaks(&data, &argvals, Some(1.5), None, true, Some(11));
7341        assert!(!result.peaks[0].is_empty());
7342    }
7343
7344    #[test]
7345    fn test_morlet_wavelet() {
7346        // At t=0, wavelet should be exp(0) * [cos(0), sin(0)] = [1, 0]
7347        let w = morlet_wavelet(0.0, 6.0);
7348        assert!((w.re - 1.0).abs() < 1e-10);
7349        assert!(w.im.abs() < 1e-10);
7350
7351        // At large |t|, wavelet should be near zero (Gaussian decay)
7352        let w = morlet_wavelet(10.0, 6.0);
7353        assert!(w.norm() < 1e-10, "Wavelet should decay for large t");
7354    }
7355
7356    #[test]
7357    fn test_matrix_profile_short_series() {
7358        // Short series: n=10, m=3, so m <= n/2 = 5
7359        let values: Vec<f64> = (0..10).map(|i| (i as f64 * 0.5).sin()).collect();
7360        let result = matrix_profile(&values, Some(3), None);
7361        assert_eq!(result.profile.len(), 8); // n - m + 1 = 10 - 3 + 1 = 8
7362    }
7363
7364    #[test]
7365    fn test_ssa_seasonality_with_threshold() {
7366        // Test ssa_seasonality with explicit confidence_threshold
7367        let n = 200;
7368        let period = 12.0;
7369        let values: Vec<f64> = (0..n)
7370            .map(|i| (2.0 * PI * i as f64 / period).sin())
7371            .collect();
7372
7373        // Low threshold
7374        let (is_seasonal, det_period, confidence) = ssa_seasonality(&values, None, Some(0.01));
7375        assert!(confidence >= 0.0);
7376        let _ = (is_seasonal, det_period);
7377
7378        // Very high threshold
7379        let (is_seasonal, _, _) = ssa_seasonality(&values, None, Some(0.99));
7380        let _ = is_seasonal;
7381    }
7382
7383    #[test]
7384    fn test_cfd_autoperiod_short_data() {
7385        // Data too short for differencing to work well
7386        let argvals: Vec<f64> = (0..8).map(|i| i as f64).collect();
7387        let data: Vec<f64> = argvals
7388            .iter()
7389            .map(|&t| (2.0 * PI * t / 4.0).sin())
7390            .collect();
7391
7392        let result = cfd_autoperiod(&data, &argvals, None, None);
7393        // Should handle gracefully
7394        assert!(result.period.is_finite() || result.period.is_nan());
7395    }
7396
7397    #[test]
7398    fn test_cfd_autoperiod_long_period() {
7399        // 8 years of daily temperature-like data with annual period (365 days).
7400        // Regression test for GH-14: differencing attenuated long-period signals,
7401        // returning period ≈ 2.2 instead of 365. Linear detrending fixes this.
7402        let n = 365 * 8;
7403        let argvals: Vec<f64> = (0..n).map(|i| i as f64).collect();
7404        let data: Vec<f64> = argvals
7405            .iter()
7406            .map(|&t| 15.0 + 10.0 * (2.0 * PI * t / 365.0).sin() + 0.001 * t)
7407            .collect();
7408        let result = cfd_autoperiod(&data, &argvals, None, None);
7409        let err = (result.period - 365.0).abs();
7410        assert!(
7411            err < 2.0,
7412            "long-period detection: expected ~365, got {:.1} (err={:.1})",
7413            result.period,
7414            err
7415        );
7416    }
7417
7418    #[test]
7419    fn test_validate_sazed_component() {
7420        // Valid component
7421        let result = validate_sazed_component(5.0, 0.8, 1.0, 10.0, 0.5);
7422        assert_eq!(result, Some(5.0));
7423
7424        // Period out of range
7425        let result = validate_sazed_component(0.5, 0.8, 1.0, 10.0, 0.5);
7426        assert_eq!(result, None);
7427
7428        let result = validate_sazed_component(15.0, 0.8, 1.0, 10.0, 0.5);
7429        assert_eq!(result, None);
7430
7431        // Low confidence
7432        let result = validate_sazed_component(5.0, 0.3, 1.0, 10.0, 0.5);
7433        assert_eq!(result, None);
7434
7435        // NaN period
7436        let result = validate_sazed_component(f64::NAN, 0.8, 1.0, 10.0, 0.5);
7437        assert_eq!(result, None);
7438    }
7439
7440    #[test]
7441    fn test_count_agreeing_periods() {
7442        let periods = vec![5.0, 5.1, 5.2, 10.0, 15.0];
7443
7444        // All three ~5.x should agree with reference 5.0 within 10% tolerance
7445        let (count, sum) = count_agreeing_periods(&periods, 5.0, 0.1);
7446        assert_eq!(count, 3);
7447        assert!((sum - 15.3).abs() < 1e-10);
7448
7449        // None should agree with 100.0
7450        let (count, _) = count_agreeing_periods(&periods, 100.0, 0.1);
7451        assert_eq!(count, 0);
7452    }
7453
7454    #[test]
7455    fn test_generate_ls_frequencies() {
7456        let times: Vec<f64> = (0..100).map(|i| i as f64).collect();
7457        let freqs = generate_ls_frequencies(&times, 4.0, 1.0);
7458        assert!(!freqs.is_empty());
7459        // First frequency should be approximately 1/T_span = 1/99
7460        assert!(
7461            (freqs[0] - 1.0 / 99.0).abs() < 0.01,
7462            "First freq should be ~1/99, got {}",
7463            freqs[0]
7464        );
7465
7466        // Short series
7467        let freqs = generate_ls_frequencies(&[0.0], 4.0, 1.0);
7468        assert_eq!(freqs, vec![0.0]);
7469    }
7470
7471    #[test]
7472    fn test_estimate_independent_frequencies() {
7473        let times: Vec<f64> = (0..50).map(|i| i as f64).collect();
7474        let n_indep = estimate_independent_frequencies(&times, 100);
7475        assert_eq!(n_indep, 50); // min(50, 100) = 50
7476
7477        let n_indep = estimate_independent_frequencies(&times, 30);
7478        assert_eq!(n_indep, 30); // min(50, 30) = 30
7479    }
7480
7481    #[test]
7482    fn test_cluster_periods() {
7483        // Empty candidates
7484        let result = cluster_periods(&[], 0.1, 1);
7485        assert!(result.is_empty());
7486
7487        // Single candidate
7488        let candidates = vec![(5.0, 1.0)];
7489        let result = cluster_periods(&candidates, 0.1, 1);
7490        assert_eq!(result.len(), 1);
7491
7492        // Two clusters
7493        let candidates = vec![(5.0, 1.0), (5.05, 0.8), (10.0, 0.5), (10.1, 0.4)];
7494        let result = cluster_periods(&candidates, 0.1, 1);
7495        assert_eq!(result.len(), 2, "Should find 2 clusters");
7496
7497        // High min_size filters small clusters
7498        let candidates = vec![(5.0, 1.0), (10.0, 0.5)];
7499        let result = cluster_periods(&candidates, 0.01, 2);
7500        // Each cluster has only 1 member, min_size=2 filters them
7501        assert!(result.is_empty());
7502    }
7503
7504    #[test]
7505    fn test_validate_cfd_candidates() {
7506        // Create a simple ACF with a peak at lag ~10
7507        let n = 50;
7508        let dt = 1.0;
7509        let mut acf = vec![0.0; n + 1];
7510        acf[0] = 1.0;
7511        for i in 1..=n {
7512            acf[i] = (2.0 * PI * i as f64 / 10.0).cos() * 0.5;
7513        }
7514
7515        let clusters = vec![(10.0, 1.0), (20.0, 0.5)];
7516        let validated = validate_cfd_candidates(&clusters, &acf, dt);
7517        // At least the period=10 cluster should validate
7518        assert!(
7519            !validated.is_empty(),
7520            "Should validate at least one candidate"
7521        );
7522    }
7523
7524    #[test]
7525    fn test_validate_or_fallback_cfd() {
7526        // When validated is not empty, return as-is
7527        let validated = vec![(10.0, 0.8, 1.0)];
7528        let candidates = vec![(10.0, 1.0)];
7529        let result = validate_or_fallback_cfd(validated.clone(), &candidates, 0.1, 1);
7530        assert_eq!(result.len(), 1);
7531
7532        // When validated is empty, fallback to best cluster
7533        let candidates = vec![(10.0, 1.0), (10.2, 0.8)];
7534        let result = validate_or_fallback_cfd(vec![], &candidates, 0.1, 1);
7535        assert!(!result.is_empty(), "Fallback should return something");
7536    }
7537
7538    #[test]
7539    fn test_rank_cfd_results() {
7540        let validated = vec![
7541            (10.0, 0.5, 1.0), // score = 0.5
7542            (5.0, 0.8, 2.0),  // score = 1.6
7543        ];
7544        let (periods, confidences, top_acf) = rank_cfd_results(&validated);
7545        // Should be sorted by combined score descending
7546        assert_eq!(periods[0], 5.0); // highest score
7547        assert_eq!(periods[1], 10.0);
7548        assert!((top_acf - 0.8).abs() < 1e-10);
7549        assert_eq!(confidences.len(), 2);
7550    }
7551
7552    #[test]
7553    fn test_empty_cfd_result() {
7554        let result = empty_cfd_result();
7555        assert!(result.period.is_nan());
7556        assert_eq!(result.confidence, 0.0);
7557        assert_eq!(result.acf_validation, 0.0);
7558        assert!(result.periods.is_empty());
7559    }
7560
7561    #[test]
7562    fn test_fit_and_subtract_sinusoid() {
7563        let m = 200;
7564        let period = 10.0;
7565        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
7566        let mut residual: Vec<f64> = argvals
7567            .iter()
7568            .map(|&t| 3.0 * (2.0 * PI * t / period).sin())
7569            .collect();
7570
7571        let (a, b, amplitude, phase) = fit_and_subtract_sinusoid(&mut residual, &argvals, period);
7572
7573        assert!(
7574            amplitude > 2.0,
7575            "Amplitude should be ~3.0, got {}",
7576            amplitude
7577        );
7578        assert!(phase.is_finite());
7579        let _ = (a, b);
7580
7581        // After subtraction, residual should be near zero
7582        let max_residual: f64 = residual.iter().map(|&x| x.abs()).fold(0.0, f64::max);
7583        assert!(
7584            max_residual < 0.5,
7585            "Residual after subtraction should be small, got {}",
7586            max_residual
7587        );
7588    }
7589
7590    #[test]
7591    fn test_detect_seasonality_changes_cessation() {
7592        // Signal that starts seasonal and becomes non-seasonal
7593        let m = 400;
7594        let period = 2.0;
7595        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
7596
7597        let data: Vec<f64> = argvals
7598            .iter()
7599            .map(|&t| {
7600                if t < 10.0 {
7601                    // Strong seasonal
7602                    (2.0 * PI * t / period).sin()
7603                } else {
7604                    // Weak/no seasonality
7605                    0.05 * ((t * 13.0).sin() + (t * 7.0).cos())
7606                }
7607            })
7608            .collect();
7609        let data = FdMatrix::from_column_major(data, 1, m).unwrap();
7610
7611        let result = detect_seasonality_changes(&data, &argvals, period, 0.3, 4.0, 2.0);
7612
7613        assert!(!result.strength_curve.is_empty());
7614        // Should detect cessation change point
7615        if !result.change_points.is_empty() {
7616            let cessation_points: Vec<_> = result
7617                .change_points
7618                .iter()
7619                .filter(|cp| cp.change_type == ChangeType::Cessation)
7620                .collect();
7621            assert!(!cessation_points.is_empty(), "Should detect Cessation");
7622        }
7623    }
7624
7625    #[test]
7626    fn test_matrix_profile_fdata_multiple_samples() {
7627        // Test with multiple samples
7628        let n = 5;
7629        let m = 200;
7630        let period = 20.0;
7631        let mut data = vec![0.0; n * m];
7632        for i in 0..n {
7633            let amp = (i + 1) as f64;
7634            for j in 0..m {
7635                data[i + j * n] = amp * (2.0 * PI * j as f64 / period).sin();
7636            }
7637        }
7638        let data = FdMatrix::from_column_major(data, n, m).unwrap();
7639
7640        let result = matrix_profile_fdata(&data, Some(15), None);
7641        assert!(!result.profile.is_empty());
7642        assert!(result.primary_period > 0.0);
7643    }
7644
7645    #[test]
7646    fn test_ssa_fdata_multiple_samples() {
7647        let n = 3;
7648        let m = 200;
7649        let mut data = vec![0.0; n * m];
7650        for i in 0..n {
7651            for j in 0..m {
7652                data[i + j * n] = (2.0 * PI * j as f64 / 12.0).sin() + 0.01 * j as f64;
7653            }
7654        }
7655        let data = FdMatrix::from_column_major(data, n, m).unwrap();
7656
7657        let result = ssa_fdata(&data, Some(25), Some(5));
7658        assert_eq!(result.trend.len(), m);
7659        assert_eq!(result.seasonal.len(), m);
7660    }
7661
7662    #[test]
7663    fn test_compute_cycle_strengths() {
7664        let m = 400;
7665        let period = 2.0;
7666        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect(); // 0..20
7667
7668        // Strong seasonal for all cycles
7669        let data = generate_sine(1, m, period, &argvals);
7670        let (strengths, weak) = compute_cycle_strengths(&data, &argvals, period, 0.3);
7671
7672        assert!(
7673            !strengths.is_empty(),
7674            "Should compute at least one cycle strength"
7675        );
7676        // For pure sine, no cycles should be weak
7677        assert!(
7678            weak.is_empty(),
7679            "Pure sine should have no weak seasons, got {:?}",
7680            weak
7681        );
7682    }
7683
7684    #[test]
7685    fn test_find_acf_descent_end() {
7686        // ACF that descends then goes negative
7687        let acf = vec![1.0, 0.8, 0.5, 0.2, -0.1, -0.3, 0.0, 0.3, 0.5];
7688        let end = find_acf_descent_end(&acf);
7689        assert_eq!(end, 4, "Should find first negative at index 4");
7690
7691        // ACF that descends but never goes negative, has uptick
7692        // At i=4: acf[4]=0.4 > acf[3]=0.3, so returns i-1=3
7693        let acf = vec![1.0, 0.8, 0.5, 0.3, 0.4, 0.6];
7694        let end = find_acf_descent_end(&acf);
7695        assert_eq!(end, 3, "Should find uptick at index 3 (i-1 where i=4)");
7696    }
7697
7698    #[test]
7699    fn test_autocorrelation_fft_matches_naive() {
7700        // Generate a signal long enough to exercise the FFT path (n > 64)
7701        let n = 200;
7702        let data: Vec<f64> = (0..n)
7703            .map(|i| (2.0 * PI * i as f64 / 20.0).sin() + 0.5 * (i as f64 * 0.1).cos())
7704            .collect();
7705        let max_lag = 50;
7706
7707        let mean: f64 = data.iter().sum::<f64>() / n as f64;
7708        let var: f64 = data.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n as f64;
7709
7710        let naive = autocorrelation_naive(&data, max_lag, mean, var);
7711        let fft = autocorrelation(&data, max_lag); // n=200 > 64, takes FFT path
7712
7713        assert_eq!(naive.len(), fft.len());
7714        for (lag, (n_val, f_val)) in naive.iter().zip(fft.iter()).enumerate() {
7715            assert!(
7716                (n_val - f_val).abs() < 1e-10,
7717                "Mismatch at lag {lag}: naive={n_val}, fft={f_val}"
7718            );
7719        }
7720    }
7721}