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