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