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::{iter_maybe_parallel, slice_maybe_parallel};
15use num_complex::Complex;
16#[cfg(feature = "parallel")]
17use rayon::iter::ParallelIterator;
18use rustfft::FftPlanner;
19use std::f64::consts::PI;
20
21/// Result of period estimation.
22#[derive(Debug, Clone)]
23pub struct PeriodEstimate {
24    /// Estimated period
25    pub period: f64,
26    /// Dominant frequency (1/period)
27    pub frequency: f64,
28    /// Power at the dominant frequency
29    pub power: f64,
30    /// Confidence measure (ratio of peak power to mean power)
31    pub confidence: f64,
32}
33
34/// A detected peak in functional data.
35#[derive(Debug, Clone)]
36pub struct Peak {
37    /// Time at which the peak occurs
38    pub time: f64,
39    /// Value at the peak
40    pub value: f64,
41    /// Prominence of the peak (height relative to surrounding valleys)
42    pub prominence: f64,
43}
44
45/// Result of peak detection.
46#[derive(Debug, Clone)]
47pub struct PeakDetectionResult {
48    /// Peaks for each sample: `peaks[sample_idx]` contains peaks for that sample
49    pub peaks: Vec<Vec<Peak>>,
50    /// Inter-peak distances for each sample
51    pub inter_peak_distances: Vec<Vec<f64>>,
52    /// Mean period estimated from inter-peak distances across all samples
53    pub mean_period: f64,
54}
55
56/// A detected period from multiple period detection.
57#[derive(Debug, Clone)]
58pub struct DetectedPeriod {
59    /// Estimated period
60    pub period: f64,
61    /// FFT confidence (ratio of peak power to mean power)
62    pub confidence: f64,
63    /// Seasonal strength at this period (variance explained)
64    pub strength: f64,
65    /// Amplitude of the sinusoidal component
66    pub amplitude: f64,
67    /// Phase of the sinusoidal component (radians)
68    pub phase: f64,
69    /// Iteration number (1-indexed)
70    pub iteration: usize,
71}
72
73/// Method for computing seasonal strength.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum StrengthMethod {
76    /// Variance decomposition: Var(seasonal) / Var(total)
77    Variance,
78    /// Spectral: power at seasonal frequencies / total power
79    Spectral,
80}
81
82/// A detected change point in seasonality.
83#[derive(Debug, Clone)]
84pub struct ChangePoint {
85    /// Time at which the change occurs
86    pub time: f64,
87    /// Type of change
88    pub change_type: ChangeType,
89    /// Seasonal strength before the change
90    pub strength_before: f64,
91    /// Seasonal strength after the change
92    pub strength_after: f64,
93}
94
95/// Type of seasonality change.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum ChangeType {
98    /// Series becomes seasonal
99    Onset,
100    /// Series stops being seasonal
101    Cessation,
102}
103
104/// Result of seasonality change detection.
105#[derive(Debug, Clone)]
106pub struct ChangeDetectionResult {
107    /// Detected change points
108    pub change_points: Vec<ChangePoint>,
109    /// Time-varying seasonal strength curve used for detection
110    pub strength_curve: Vec<f64>,
111}
112
113/// Result of instantaneous period estimation.
114#[derive(Debug, Clone)]
115pub struct InstantaneousPeriod {
116    /// Instantaneous period at each time point
117    pub period: Vec<f64>,
118    /// Instantaneous frequency at each time point
119    pub frequency: Vec<f64>,
120    /// Instantaneous amplitude (envelope) at each time point
121    pub amplitude: Vec<f64>,
122}
123
124/// Result of peak timing variability analysis.
125#[derive(Debug, Clone)]
126pub struct PeakTimingResult {
127    /// Peak times for each cycle
128    pub peak_times: Vec<f64>,
129    /// Peak values
130    pub peak_values: Vec<f64>,
131    /// Within-period timing (0-1 scale, e.g., day-of-year / 365)
132    pub normalized_timing: Vec<f64>,
133    /// Mean normalized timing
134    pub mean_timing: f64,
135    /// Standard deviation of normalized timing
136    pub std_timing: f64,
137    /// Range of normalized timing (max - min)
138    pub range_timing: f64,
139    /// Variability score (0 = stable, 1 = highly variable)
140    pub variability_score: f64,
141    /// Trend in timing (positive = peaks getting later)
142    pub timing_trend: f64,
143    /// Cycle indices (1-indexed)
144    pub cycle_indices: Vec<usize>,
145}
146
147/// Type of seasonality pattern.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum SeasonalType {
150    /// Regular peaks with consistent timing
151    StableSeasonal,
152    /// Regular peaks but timing shifts between cycles
153    VariableTiming,
154    /// Some cycles seasonal, some not
155    IntermittentSeasonal,
156    /// No clear seasonality
157    NonSeasonal,
158}
159
160/// Result of seasonality classification.
161#[derive(Debug, Clone)]
162pub struct SeasonalityClassification {
163    /// Whether the series is seasonal overall
164    pub is_seasonal: bool,
165    /// Whether peak timing is stable across cycles
166    pub has_stable_timing: bool,
167    /// Timing variability score (0 = stable, 1 = highly variable)
168    pub timing_variability: f64,
169    /// Overall seasonal strength
170    pub seasonal_strength: f64,
171    /// Per-cycle seasonal strength
172    pub cycle_strengths: Vec<f64>,
173    /// Indices of weak/missing seasons (0-indexed)
174    pub weak_seasons: Vec<usize>,
175    /// Classification type
176    pub classification: SeasonalType,
177    /// Peak timing analysis (if peaks were detected)
178    pub peak_timing: Option<PeakTimingResult>,
179}
180
181/// Method for automatic threshold selection.
182#[derive(Debug, Clone, Copy, PartialEq)]
183pub enum ThresholdMethod {
184    /// Fixed user-specified threshold
185    Fixed(f64),
186    /// Percentile of strength distribution
187    Percentile(f64),
188    /// Otsu's method (optimal bimodal separation)
189    Otsu,
190}
191
192/// Type of amplitude modulation pattern.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum ModulationType {
195    /// Constant amplitude (no modulation)
196    Stable,
197    /// Amplitude increases over time (seasonality emerges)
198    Emerging,
199    /// Amplitude decreases over time (seasonality fades)
200    Fading,
201    /// Amplitude varies non-monotonically
202    Oscillating,
203    /// No seasonality detected
204    NonSeasonal,
205}
206
207/// Result of amplitude modulation detection.
208#[derive(Debug, Clone)]
209pub struct AmplitudeModulationResult {
210    /// Whether seasonality is present (using robust spectral method)
211    pub is_seasonal: bool,
212    /// Overall seasonal strength (spectral method)
213    pub seasonal_strength: f64,
214    /// Whether amplitude modulation is detected
215    pub has_modulation: bool,
216    /// Type of amplitude modulation
217    pub modulation_type: ModulationType,
218    /// Coefficient of variation of time-varying strength (0 = stable, higher = more modulation)
219    pub modulation_score: f64,
220    /// Trend in amplitude (-1 to 1: negative = fading, positive = emerging)
221    pub amplitude_trend: f64,
222    /// Time-varying seasonal strength curve
223    pub strength_curve: Vec<f64>,
224    /// Time points corresponding to strength_curve
225    pub time_points: Vec<f64>,
226    /// Minimum strength in the curve
227    pub min_strength: f64,
228    /// Maximum strength in the curve
229    pub max_strength: f64,
230}
231
232/// Result of wavelet-based amplitude modulation detection.
233#[derive(Debug, Clone)]
234pub struct WaveletAmplitudeResult {
235    /// Whether seasonality is present
236    pub is_seasonal: bool,
237    /// Overall seasonal strength
238    pub seasonal_strength: f64,
239    /// Whether amplitude modulation is detected
240    pub has_modulation: bool,
241    /// Type of amplitude modulation
242    pub modulation_type: ModulationType,
243    /// Coefficient of variation of wavelet amplitude
244    pub modulation_score: f64,
245    /// Trend in amplitude (-1 to 1)
246    pub amplitude_trend: f64,
247    /// Wavelet amplitude at the seasonal frequency over time
248    pub wavelet_amplitude: Vec<f64>,
249    /// Time points corresponding to wavelet_amplitude
250    pub time_points: Vec<f64>,
251    /// Scale (period) used for wavelet analysis
252    pub scale: f64,
253}
254
255// ============================================================================
256// Internal helper functions
257// ============================================================================
258
259/// Compute mean curve from column-major data matrix.
260///
261/// # Arguments
262/// * `data` - Column-major matrix (n x m)
263/// * `n` - Number of samples (rows)
264/// * `m` - Number of evaluation points (columns)
265/// * `parallel` - Use parallel iteration (default: true)
266///
267/// # Returns
268/// Mean curve of length m
269#[inline]
270fn compute_mean_curve_impl(data: &[f64], n: usize, m: usize, parallel: bool) -> Vec<f64> {
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 * n];
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 * n];
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: &[f64], n: usize, m: usize) -> Vec<f64> {
299    compute_mean_curve_impl(data, n, m, 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/// Compute periodogram from data using FFT.
325/// Returns (frequencies, power) where frequencies are in cycles per unit time.
326fn periodogram(data: &[f64], argvals: &[f64]) -> (Vec<f64>, Vec<f64>) {
327    let n = data.len();
328    if n < 2 || argvals.len() != n {
329        return (Vec::new(), Vec::new());
330    }
331
332    let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
333    let fs = 1.0 / dt; // Sampling frequency
334
335    let mut planner = FftPlanner::<f64>::new();
336    let fft = planner.plan_fft_forward(n);
337
338    let mut buffer: Vec<Complex<f64>> = data.iter().map(|&x| Complex::new(x, 0.0)).collect();
339    fft.process(&mut buffer);
340
341    // Compute power spectrum (one-sided)
342    let n_freq = n / 2 + 1;
343    let mut power = Vec::with_capacity(n_freq);
344    let mut frequencies = Vec::with_capacity(n_freq);
345
346    for k in 0..n_freq {
347        let freq = k as f64 * fs / n as f64;
348        frequencies.push(freq);
349
350        let p = buffer[k].norm_sqr() / (n as f64 * n as f64);
351        // Double power for non-DC and non-Nyquist frequencies (one-sided spectrum)
352        let p = if k > 0 && k < n / 2 { 2.0 * p } else { p };
353        power.push(p);
354    }
355
356    (frequencies, power)
357}
358
359/// Compute autocorrelation function up to max_lag.
360fn autocorrelation(data: &[f64], max_lag: usize) -> Vec<f64> {
361    let n = data.len();
362    if n == 0 {
363        return Vec::new();
364    }
365
366    let mean: f64 = data.iter().sum::<f64>() / n as f64;
367    let var: f64 = data.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n as f64;
368
369    if var < 1e-15 {
370        return vec![1.0; max_lag.min(n) + 1];
371    }
372
373    let max_lag = max_lag.min(n - 1);
374    let mut acf = Vec::with_capacity(max_lag + 1);
375
376    for lag in 0..=max_lag {
377        let mut sum = 0.0;
378        for i in 0..(n - lag) {
379            sum += (data[i] - mean) * (data[i + lag] - mean);
380        }
381        acf.push(sum / (n as f64 * var));
382    }
383
384    acf
385}
386
387/// Find peaks in a 1D signal, returning indices.
388fn find_peaks_1d(signal: &[f64], min_distance: usize) -> Vec<usize> {
389    let n = signal.len();
390    if n < 3 {
391        return Vec::new();
392    }
393
394    let mut peaks = Vec::new();
395
396    for i in 1..(n - 1) {
397        if signal[i] > signal[i - 1] && signal[i] > signal[i + 1] {
398            // Check minimum distance from previous peak
399            if peaks.is_empty() || i - peaks[peaks.len() - 1] >= min_distance {
400                peaks.push(i);
401            } else if signal[i] > signal[peaks[peaks.len() - 1]] {
402                // Replace previous peak if this one is higher
403                peaks.pop();
404                peaks.push(i);
405            }
406        }
407    }
408
409    peaks
410}
411
412/// Compute prominence for a peak (height above surrounding valleys).
413fn compute_prominence(signal: &[f64], peak_idx: usize) -> f64 {
414    let n = signal.len();
415    let peak_val = signal[peak_idx];
416
417    // Find lowest point between peak and boundaries/higher peaks
418    let mut left_min = peak_val;
419    for i in (0..peak_idx).rev() {
420        if signal[i] >= peak_val {
421            break;
422        }
423        left_min = left_min.min(signal[i]);
424    }
425
426    let mut right_min = peak_val;
427    for i in (peak_idx + 1)..n {
428        if signal[i] >= peak_val {
429            break;
430        }
431        right_min = right_min.min(signal[i]);
432    }
433
434    peak_val - left_min.max(right_min)
435}
436
437/// Hilbert transform using FFT to compute analytic signal.
438///
439/// # Arguments
440/// * `signal` - Input real signal
441///
442/// # Returns
443/// Analytic signal as complex vector (real part = original, imaginary = Hilbert transform)
444pub fn hilbert_transform(signal: &[f64]) -> Vec<Complex<f64>> {
445    let n = signal.len();
446    if n == 0 {
447        return Vec::new();
448    }
449
450    let mut planner = FftPlanner::<f64>::new();
451    let fft_forward = planner.plan_fft_forward(n);
452    let fft_inverse = planner.plan_fft_inverse(n);
453
454    // Forward FFT
455    let mut buffer: Vec<Complex<f64>> = signal.iter().map(|&x| Complex::new(x, 0.0)).collect();
456    fft_forward.process(&mut buffer);
457
458    // Create analytic signal in frequency domain
459    // H[0] = 1, H[1..n/2] = 2, H[n/2] = 1 (if n even), H[n/2+1..] = 0
460    let half = n / 2;
461    for k in 1..half {
462        buffer[k] *= 2.0;
463    }
464    for k in (half + 1)..n {
465        buffer[k] = Complex::new(0.0, 0.0);
466    }
467
468    // Inverse FFT
469    fft_inverse.process(&mut buffer);
470
471    // Normalize
472    for c in buffer.iter_mut() {
473        *c /= n as f64;
474    }
475
476    buffer
477}
478
479/// Unwrap phase to remove 2π discontinuities.
480fn unwrap_phase(phase: &[f64]) -> Vec<f64> {
481    if phase.is_empty() {
482        return Vec::new();
483    }
484
485    let mut unwrapped = vec![phase[0]];
486    let mut cumulative_correction = 0.0;
487
488    for i in 1..phase.len() {
489        let diff = phase[i] - phase[i - 1];
490
491        // Check for wraparound
492        if diff > PI {
493            cumulative_correction -= 2.0 * PI;
494        } else if diff < -PI {
495            cumulative_correction += 2.0 * PI;
496        }
497
498        unwrapped.push(phase[i] + cumulative_correction);
499    }
500
501    unwrapped
502}
503
504/// Morlet wavelet function.
505///
506/// The Morlet wavelet is a complex exponential modulated by a Gaussian:
507/// ψ(t) = exp(i * ω₀ * t) * exp(-t² / 2)
508///
509/// where ω₀ is the central frequency (typically 6 for good time-frequency trade-off).
510fn morlet_wavelet(t: f64, omega0: f64) -> Complex<f64> {
511    let gaussian = (-t * t / 2.0).exp();
512    let oscillation = Complex::new((omega0 * t).cos(), (omega0 * t).sin());
513    oscillation * gaussian
514}
515
516/// Continuous Wavelet Transform at a single scale using Morlet wavelet.
517///
518/// Computes the wavelet coefficients at the specified scale (period) for all time points.
519/// Uses convolution in the time domain.
520///
521/// # Arguments
522/// * `signal` - Input signal
523/// * `argvals` - Time points
524/// * `scale` - Scale parameter (related to period)
525/// * `omega0` - Central frequency of Morlet wavelet (default: 6.0)
526///
527/// # Returns
528/// Complex wavelet coefficients at each time point
529#[allow(dead_code)]
530fn cwt_morlet(signal: &[f64], argvals: &[f64], scale: f64, omega0: f64) -> Vec<Complex<f64>> {
531    let n = signal.len();
532    if n == 0 || scale <= 0.0 {
533        return Vec::new();
534    }
535
536    let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
537
538    // Compute wavelet coefficients via convolution
539    // W(a, b) = (1/sqrt(a)) * Σ x[k] * ψ*((t[k] - b) / a) * dt
540    let norm = 1.0 / scale.sqrt();
541
542    (0..n)
543        .map(|b| {
544            let mut sum = Complex::new(0.0, 0.0);
545            for k in 0..n {
546                let t_normalized = (argvals[k] - argvals[b]) / scale;
547                // Only compute within reasonable range (Gaussian decays quickly)
548                if t_normalized.abs() < 6.0 {
549                    let wavelet = morlet_wavelet(t_normalized, omega0);
550                    sum += signal[k] * wavelet.conj();
551                }
552            }
553            sum * norm * dt
554        })
555        .collect()
556}
557
558/// Continuous Wavelet Transform at a single scale using FFT (faster for large signals).
559///
560/// Uses the convolution theorem: CWT = IFFT(FFT(signal) * FFT(wavelet)*)
561fn cwt_morlet_fft(signal: &[f64], argvals: &[f64], scale: f64, omega0: f64) -> Vec<Complex<f64>> {
562    let n = signal.len();
563    if n == 0 || scale <= 0.0 {
564        return Vec::new();
565    }
566
567    let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
568    let norm = 1.0 / scale.sqrt();
569
570    // Compute wavelet in time domain centered at t=0
571    let wavelet_time: Vec<Complex<f64>> = (0..n)
572        .map(|k| {
573            // Center the wavelet
574            let t = if k <= n / 2 {
575                k as f64 * dt / scale
576            } else {
577                (k as f64 - n as f64) * dt / scale
578            };
579            morlet_wavelet(t, omega0) * norm
580        })
581        .collect();
582
583    let mut planner = FftPlanner::<f64>::new();
584    let fft_forward = planner.plan_fft_forward(n);
585    let fft_inverse = planner.plan_fft_inverse(n);
586
587    // FFT of signal
588    let mut signal_fft: Vec<Complex<f64>> = signal.iter().map(|&x| Complex::new(x, 0.0)).collect();
589    fft_forward.process(&mut signal_fft);
590
591    // FFT of wavelet
592    let mut wavelet_fft = wavelet_time;
593    fft_forward.process(&mut wavelet_fft);
594
595    // Multiply in frequency domain (use conjugate of wavelet FFT for correlation)
596    let mut result: Vec<Complex<f64>> = signal_fft
597        .iter()
598        .zip(wavelet_fft.iter())
599        .map(|(s, w)| *s * w.conj())
600        .collect();
601
602    // Inverse FFT
603    fft_inverse.process(&mut result);
604
605    // Normalize and scale by dt
606    for c in result.iter_mut() {
607        *c *= dt / n as f64;
608    }
609
610    result
611}
612
613/// Compute Otsu's threshold for bimodal separation.
614///
615/// Finds the threshold that minimizes within-class variance (or equivalently
616/// maximizes between-class variance).
617fn otsu_threshold(values: &[f64]) -> f64 {
618    if values.is_empty() {
619        return 0.5;
620    }
621
622    // Filter NaN values
623    let valid: Vec<f64> = values.iter().copied().filter(|x| x.is_finite()).collect();
624    if valid.is_empty() {
625        return 0.5;
626    }
627
628    let min_val = valid.iter().cloned().fold(f64::INFINITY, f64::min);
629    let max_val = valid.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
630
631    if (max_val - min_val).abs() < 1e-10 {
632        return (min_val + max_val) / 2.0;
633    }
634
635    // Create histogram with 256 bins
636    let n_bins = 256;
637    let bin_width = (max_val - min_val) / n_bins as f64;
638    let mut histogram = vec![0usize; n_bins];
639
640    for &v in &valid {
641        let bin = ((v - min_val) / bin_width).min(n_bins as f64 - 1.0) as usize;
642        histogram[bin] += 1;
643    }
644
645    let total = valid.len() as f64;
646    let mut sum_total = 0.0;
647    for (i, &count) in histogram.iter().enumerate() {
648        sum_total += i as f64 * count as f64;
649    }
650
651    let mut best_threshold = min_val;
652    let mut best_variance = 0.0;
653
654    let mut sum_b = 0.0;
655    let mut weight_b = 0.0;
656
657    for t in 0..n_bins {
658        weight_b += histogram[t] as f64;
659        if weight_b == 0.0 {
660            continue;
661        }
662
663        let weight_f = total - weight_b;
664        if weight_f == 0.0 {
665            break;
666        }
667
668        sum_b += t as f64 * histogram[t] as f64;
669
670        let mean_b = sum_b / weight_b;
671        let mean_f = (sum_total - sum_b) / weight_f;
672
673        // Between-class variance
674        let variance = weight_b * weight_f * (mean_b - mean_f).powi(2);
675
676        if variance > best_variance {
677            best_variance = variance;
678            best_threshold = min_val + (t as f64 + 0.5) * bin_width;
679        }
680    }
681
682    best_threshold
683}
684
685/// Compute linear regression slope (simple OLS).
686fn linear_slope(x: &[f64], y: &[f64]) -> f64 {
687    if x.len() != y.len() || x.len() < 2 {
688        return 0.0;
689    }
690
691    let n = x.len() as f64;
692    let mean_x: f64 = x.iter().sum::<f64>() / n;
693    let mean_y: f64 = y.iter().sum::<f64>() / n;
694
695    let mut num = 0.0;
696    let mut den = 0.0;
697
698    for (&xi, &yi) in x.iter().zip(y.iter()) {
699        num += (xi - mean_x) * (yi - mean_y);
700        den += (xi - mean_x).powi(2);
701    }
702
703    if den.abs() < 1e-15 {
704        0.0
705    } else {
706        num / den
707    }
708}
709
710// ============================================================================
711// Period Estimation
712// ============================================================================
713
714/// Estimate period using FFT periodogram.
715///
716/// Finds the dominant frequency in the periodogram (excluding DC) and
717/// returns the corresponding period.
718///
719/// # Arguments
720/// * `data` - Column-major matrix (n x m)
721/// * `n` - Number of samples
722/// * `m` - Number of evaluation points
723/// * `argvals` - Evaluation points (time values)
724///
725/// # Returns
726/// Period estimate with confidence measure
727pub fn estimate_period_fft(data: &[f64], n: usize, m: usize, argvals: &[f64]) -> PeriodEstimate {
728    if n == 0 || m < 4 || argvals.len() != m {
729        return PeriodEstimate {
730            period: f64::NAN,
731            frequency: f64::NAN,
732            power: 0.0,
733            confidence: 0.0,
734        };
735    }
736
737    // Compute mean curve first
738    let mean_curve = compute_mean_curve(data, n, m);
739
740    let (frequencies, power) = periodogram(&mean_curve, argvals);
741
742    if frequencies.len() < 2 {
743        return PeriodEstimate {
744            period: f64::NAN,
745            frequency: f64::NAN,
746            power: 0.0,
747            confidence: 0.0,
748        };
749    }
750
751    // Find peak in power spectrum (skip DC component at index 0)
752    let mut max_power = 0.0;
753    let mut max_idx = 1;
754    for (i, &p) in power.iter().enumerate().skip(1) {
755        if p > max_power {
756            max_power = p;
757            max_idx = i;
758        }
759    }
760
761    let dominant_freq = frequencies[max_idx];
762    let period = if dominant_freq > 1e-15 {
763        1.0 / dominant_freq
764    } else {
765        f64::INFINITY
766    };
767
768    // Confidence: ratio of peak power to mean power (excluding DC)
769    let mean_power: f64 = power.iter().skip(1).sum::<f64>() / (power.len() - 1) as f64;
770    let confidence = if mean_power > 1e-15 {
771        max_power / mean_power
772    } else {
773        0.0
774    };
775
776    PeriodEstimate {
777        period,
778        frequency: dominant_freq,
779        power: max_power,
780        confidence,
781    }
782}
783
784/// Estimate period using autocorrelation function.
785///
786/// Finds the first significant peak in the ACF after lag 0.
787///
788/// # Arguments
789/// * `data` - Column-major matrix (n x m)
790/// * `n` - Number of samples
791/// * `m` - Number of evaluation points
792/// * `argvals` - Evaluation points
793/// * `max_lag` - Maximum lag to consider (in number of points)
794pub fn estimate_period_acf(
795    data: &[f64],
796    n: usize,
797    m: usize,
798    argvals: &[f64],
799    max_lag: usize,
800) -> PeriodEstimate {
801    if n == 0 || m < 4 || argvals.len() != m {
802        return PeriodEstimate {
803            period: f64::NAN,
804            frequency: f64::NAN,
805            power: 0.0,
806            confidence: 0.0,
807        };
808    }
809
810    // Compute mean curve
811    let mean_curve = compute_mean_curve(data, n, m);
812
813    let acf = autocorrelation(&mean_curve, max_lag);
814
815    // Find first peak after lag 0 (skip first few lags to avoid finding lag 0)
816    let min_lag = 2;
817    let peaks = find_peaks_1d(&acf[min_lag..], 1);
818
819    if peaks.is_empty() {
820        return PeriodEstimate {
821            period: f64::NAN,
822            frequency: f64::NAN,
823            power: 0.0,
824            confidence: 0.0,
825        };
826    }
827
828    let peak_lag = peaks[0] + min_lag;
829    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
830    let period = peak_lag as f64 * dt;
831    let frequency = if period > 1e-15 { 1.0 / period } else { 0.0 };
832
833    PeriodEstimate {
834        period,
835        frequency,
836        power: acf[peak_lag],
837        confidence: acf[peak_lag].abs(),
838    }
839}
840
841/// Estimate period via Fourier regression grid search.
842///
843/// Tests multiple candidate periods and selects the one that minimizes
844/// the reconstruction error (similar to GCV).
845///
846/// # Arguments
847/// * `data` - Column-major matrix (n x m)
848/// * `n` - Number of samples
849/// * `m` - Number of evaluation points
850/// * `argvals` - Evaluation points
851/// * `period_min` - Minimum period to test
852/// * `period_max` - Maximum period to test
853/// * `n_candidates` - Number of candidate periods to test
854/// * `n_harmonics` - Number of Fourier harmonics to use
855pub fn estimate_period_regression(
856    data: &[f64],
857    n: usize,
858    m: usize,
859    argvals: &[f64],
860    period_min: f64,
861    period_max: f64,
862    n_candidates: usize,
863    n_harmonics: usize,
864) -> PeriodEstimate {
865    if n == 0 || m < 4 || argvals.len() != m || period_min >= period_max || n_candidates < 2 {
866        return PeriodEstimate {
867            period: f64::NAN,
868            frequency: f64::NAN,
869            power: 0.0,
870            confidence: 0.0,
871        };
872    }
873
874    // Compute mean curve
875    let mean_curve = compute_mean_curve(data, n, m);
876
877    let nbasis = 1 + 2 * n_harmonics;
878
879    // Grid search over candidate periods
880    let candidates: Vec<f64> = (0..n_candidates)
881        .map(|i| period_min + (period_max - period_min) * i as f64 / (n_candidates - 1) as f64)
882        .collect();
883
884    let results: Vec<(f64, f64)> = slice_maybe_parallel!(candidates)
885        .map(|&period| {
886            let basis = fourier_basis_with_period(argvals, nbasis, period);
887
888            // Simple least squares fit
889            let mut rss = 0.0;
890            for j in 0..m {
891                let mut fitted = 0.0;
892                // Simple: use mean of basis function times data as rough fit
893                for k in 0..nbasis {
894                    let b_val = basis[j + k * m];
895                    let coef: f64 = (0..m)
896                        .map(|l| mean_curve[l] * basis[l + k * m])
897                        .sum::<f64>()
898                        / (0..m)
899                            .map(|l| basis[l + k * m].powi(2))
900                            .sum::<f64>()
901                            .max(1e-15);
902                    fitted += coef * b_val;
903                }
904                let resid = mean_curve[j] - fitted;
905                rss += resid * resid;
906            }
907
908            (period, rss)
909        })
910        .collect();
911
912    // Find period with minimum RSS
913    let (best_period, min_rss) = results
914        .iter()
915        .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
916        .cloned()
917        .unwrap_or((f64::NAN, f64::INFINITY));
918
919    // Confidence based on how much better the best is vs average
920    let mean_rss: f64 = results.iter().map(|(_, r)| r).sum::<f64>() / results.len() as f64;
921    let confidence = if min_rss > 1e-15 {
922        (mean_rss / min_rss).min(10.0)
923    } else {
924        10.0
925    };
926
927    PeriodEstimate {
928        period: best_period,
929        frequency: if best_period > 1e-15 {
930            1.0 / best_period
931        } else {
932            0.0
933        },
934        power: 1.0 - min_rss / mean_rss,
935        confidence,
936    }
937}
938
939/// Detect multiple concurrent periodicities using iterative residual subtraction.
940///
941/// This function iteratively:
942/// 1. Estimates the dominant period using FFT
943/// 2. Checks both FFT confidence and seasonal strength as stopping criteria
944/// 3. Computes the amplitude and phase of the sinusoidal component
945/// 4. Subtracts the fitted sinusoid from the signal
946/// 5. Repeats on the residual until stopping criteria are met
947///
948/// # Arguments
949/// * `data` - Column-major matrix (n x m)
950/// * `n` - Number of samples
951/// * `m` - Number of evaluation points
952/// * `argvals` - Evaluation points
953/// * `max_periods` - Maximum number of periods to detect
954/// * `min_confidence` - Minimum FFT confidence to continue (default: 0.4)
955/// * `min_strength` - Minimum seasonal strength to continue (default: 0.15)
956///
957/// # Returns
958/// Vector of detected periods with their properties
959pub fn detect_multiple_periods(
960    data: &[f64],
961    n: usize,
962    m: usize,
963    argvals: &[f64],
964    max_periods: usize,
965    min_confidence: f64,
966    min_strength: f64,
967) -> Vec<DetectedPeriod> {
968    if n == 0 || m < 4 || argvals.len() != m || max_periods == 0 {
969        return Vec::new();
970    }
971
972    // Compute mean curve
973    let mean_curve = compute_mean_curve(data, n, m);
974
975    let mut residual = mean_curve.clone();
976    let mut detected = Vec::with_capacity(max_periods);
977
978    for iteration in 1..=max_periods {
979        // Create single-sample data from residual
980        let residual_data: Vec<f64> = residual.clone();
981
982        // Estimate period
983        let est = estimate_period_fft(&residual_data, 1, m, argvals);
984
985        if est.confidence < min_confidence || est.period.is_nan() || est.period.is_infinite() {
986            break;
987        }
988
989        // Check seasonal strength at detected period
990        let strength = seasonal_strength_variance(&residual_data, 1, m, argvals, est.period, 3);
991
992        if strength < min_strength || strength.is_nan() {
993            break;
994        }
995
996        // Compute amplitude and phase using least squares fit
997        let omega = 2.0 * PI / est.period;
998        let mut cos_sum = 0.0;
999        let mut sin_sum = 0.0;
1000
1001        for (j, &t) in argvals.iter().enumerate() {
1002            cos_sum += residual[j] * (omega * t).cos();
1003            sin_sum += residual[j] * (omega * t).sin();
1004        }
1005
1006        let a = 2.0 * cos_sum / m as f64; // Cosine coefficient
1007        let b = 2.0 * sin_sum / m as f64; // Sine coefficient
1008        let amplitude = (a * a + b * b).sqrt();
1009        let phase = b.atan2(a);
1010
1011        detected.push(DetectedPeriod {
1012            period: est.period,
1013            confidence: est.confidence,
1014            strength,
1015            amplitude,
1016            phase,
1017            iteration,
1018        });
1019
1020        // Subtract fitted sinusoid from residual
1021        for (j, &t) in argvals.iter().enumerate() {
1022            let fitted = a * (omega * t).cos() + b * (omega * t).sin();
1023            residual[j] -= fitted;
1024        }
1025    }
1026
1027    detected
1028}
1029
1030// ============================================================================
1031// Peak Detection
1032// ============================================================================
1033
1034/// Detect peaks in functional data.
1035///
1036/// Uses derivative zero-crossings to find local maxima, with optional
1037/// Fourier basis smoothing and filtering by minimum distance and prominence.
1038///
1039/// # Arguments
1040/// * `data` - Column-major matrix (n x m)
1041/// * `n` - Number of samples
1042/// * `m` - Number of evaluation points
1043/// * `argvals` - Evaluation points
1044/// * `min_distance` - Minimum time between peaks (None = no constraint)
1045/// * `min_prominence` - Minimum prominence (0-1 scale, None = no filter)
1046/// * `smooth_first` - Whether to smooth data before peak detection using Fourier basis
1047/// * `smooth_nbasis` - Number of Fourier basis functions. If None and smooth_first=true,
1048///   uses GCV to automatically select optimal nbasis (range 5-25).
1049pub fn detect_peaks(
1050    data: &[f64],
1051    n: usize,
1052    m: usize,
1053    argvals: &[f64],
1054    min_distance: Option<f64>,
1055    min_prominence: Option<f64>,
1056    smooth_first: bool,
1057    smooth_nbasis: Option<usize>,
1058) -> PeakDetectionResult {
1059    if n == 0 || m < 3 || argvals.len() != m {
1060        return PeakDetectionResult {
1061            peaks: Vec::new(),
1062            inter_peak_distances: Vec::new(),
1063            mean_period: f64::NAN,
1064        };
1065    }
1066
1067    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1068    let min_dist_points = min_distance.map(|d| (d / dt).round() as usize).unwrap_or(1);
1069
1070    // Optionally smooth the data using Fourier basis
1071    let work_data = if smooth_first {
1072        // Determine nbasis: use provided value or select via GCV
1073        let nbasis = smooth_nbasis
1074            .unwrap_or_else(|| crate::basis::select_fourier_nbasis_gcv(data, n, m, argvals, 5, 25));
1075
1076        // Use Fourier basis smoothing
1077        if let Some(result) = crate::basis::fourier_fit_1d(data, n, m, argvals, nbasis) {
1078            result.fitted
1079        } else {
1080            data.to_vec()
1081        }
1082    } else {
1083        data.to_vec()
1084    };
1085
1086    // Compute first derivative
1087    let deriv1 = deriv_1d(&work_data, n, m, argvals, 1);
1088
1089    // Compute data range for prominence normalization
1090    let data_range: f64 = {
1091        let mut min_val = f64::INFINITY;
1092        let mut max_val = f64::NEG_INFINITY;
1093        for &v in work_data.iter() {
1094            min_val = min_val.min(v);
1095            max_val = max_val.max(v);
1096        }
1097        (max_val - min_val).max(1e-15)
1098    };
1099
1100    // Find peaks for each sample
1101    let results: Vec<(Vec<Peak>, Vec<f64>)> = iter_maybe_parallel!(0..n)
1102        .map(|i| {
1103            // Extract curve and derivative
1104            let curve: Vec<f64> = (0..m).map(|j| work_data[i + j * n]).collect();
1105            let d1: Vec<f64> = (0..m).map(|j| deriv1[i + j * n]).collect();
1106
1107            // Find zero crossings where derivative goes from positive to negative
1108            let mut peak_indices = Vec::new();
1109            for j in 1..m {
1110                if d1[j - 1] > 0.0 && d1[j] <= 0.0 {
1111                    // Interpolate to find more precise location
1112                    let idx = if (d1[j - 1] - d1[j]).abs() > 1e-15 {
1113                        j - 1
1114                    } else {
1115                        j
1116                    };
1117
1118                    // Check minimum distance
1119                    if peak_indices.is_empty()
1120                        || idx - peak_indices[peak_indices.len() - 1] >= min_dist_points
1121                    {
1122                        peak_indices.push(idx);
1123                    }
1124                }
1125            }
1126
1127            // Build peaks with prominence
1128            let mut peaks: Vec<Peak> = peak_indices
1129                .iter()
1130                .map(|&idx| {
1131                    let prominence = compute_prominence(&curve, idx) / data_range;
1132                    Peak {
1133                        time: argvals[idx],
1134                        value: curve[idx],
1135                        prominence,
1136                    }
1137                })
1138                .collect();
1139
1140            // Filter by prominence
1141            if let Some(min_prom) = min_prominence {
1142                peaks.retain(|p| p.prominence >= min_prom);
1143            }
1144
1145            // Compute inter-peak distances
1146            let distances: Vec<f64> = peaks.windows(2).map(|w| w[1].time - w[0].time).collect();
1147
1148            (peaks, distances)
1149        })
1150        .collect();
1151
1152    let peaks: Vec<Vec<Peak>> = results.iter().map(|(p, _)| p.clone()).collect();
1153    let inter_peak_distances: Vec<Vec<f64>> = results.iter().map(|(_, d)| d.clone()).collect();
1154
1155    // Compute mean period from all inter-peak distances
1156    let all_distances: Vec<f64> = inter_peak_distances.iter().flatten().cloned().collect();
1157    let mean_period = if all_distances.is_empty() {
1158        f64::NAN
1159    } else {
1160        all_distances.iter().sum::<f64>() / all_distances.len() as f64
1161    };
1162
1163    PeakDetectionResult {
1164        peaks,
1165        inter_peak_distances,
1166        mean_period,
1167    }
1168}
1169
1170// ============================================================================
1171// Seasonal Strength
1172// ============================================================================
1173
1174/// Measure seasonal strength using variance decomposition.
1175///
1176/// Computes SS = Var(seasonal_component) / Var(total) where the seasonal
1177/// component is extracted using Fourier basis.
1178///
1179/// # Arguments
1180/// * `data` - Column-major matrix (n x m)
1181/// * `n` - Number of samples
1182/// * `m` - Number of evaluation points
1183/// * `argvals` - Evaluation points
1184/// * `period` - Seasonal period
1185/// * `n_harmonics` - Number of Fourier harmonics to use
1186///
1187/// # Returns
1188/// Seasonal strength in [0, 1]
1189pub fn seasonal_strength_variance(
1190    data: &[f64],
1191    n: usize,
1192    m: usize,
1193    argvals: &[f64],
1194    period: f64,
1195    n_harmonics: usize,
1196) -> f64 {
1197    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1198        return f64::NAN;
1199    }
1200
1201    // Compute mean curve
1202    let mean_curve = compute_mean_curve(data, n, m);
1203
1204    // Total variance
1205    let global_mean: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1206    let total_var: f64 = mean_curve
1207        .iter()
1208        .map(|&x| (x - global_mean).powi(2))
1209        .sum::<f64>()
1210        / m as f64;
1211
1212    if total_var < 1e-15 {
1213        return 0.0;
1214    }
1215
1216    // Fit Fourier basis to extract seasonal component
1217    let nbasis = 1 + 2 * n_harmonics;
1218    let basis = fourier_basis_with_period(argvals, nbasis, period);
1219
1220    // Project data onto basis (simple least squares for mean curve)
1221    let mut seasonal = vec![0.0; m];
1222    for k in 1..nbasis {
1223        // Skip DC component
1224        let b_sum: f64 = (0..m).map(|j| basis[j + k * m].powi(2)).sum();
1225        if b_sum > 1e-15 {
1226            let coef: f64 = (0..m)
1227                .map(|j| mean_curve[j] * basis[j + k * m])
1228                .sum::<f64>()
1229                / b_sum;
1230            for j in 0..m {
1231                seasonal[j] += coef * basis[j + k * m];
1232            }
1233        }
1234    }
1235
1236    // Seasonal variance
1237    let seasonal_mean: f64 = seasonal.iter().sum::<f64>() / m as f64;
1238    let seasonal_var: f64 = seasonal
1239        .iter()
1240        .map(|&x| (x - seasonal_mean).powi(2))
1241        .sum::<f64>()
1242        / m as f64;
1243
1244    (seasonal_var / total_var).min(1.0)
1245}
1246
1247/// Measure seasonal strength using spectral method.
1248///
1249/// Computes SS = power at seasonal frequencies / total power.
1250///
1251/// # Arguments
1252/// * `data` - Column-major matrix (n x m)
1253/// * `n` - Number of samples
1254/// * `m` - Number of evaluation points
1255/// * `argvals` - Evaluation points
1256/// * `period` - Seasonal period
1257pub fn seasonal_strength_spectral(
1258    data: &[f64],
1259    n: usize,
1260    m: usize,
1261    argvals: &[f64],
1262    period: f64,
1263) -> f64 {
1264    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1265        return f64::NAN;
1266    }
1267
1268    // Compute mean curve
1269    let mean_curve = compute_mean_curve(data, n, m);
1270
1271    let (frequencies, power) = periodogram(&mean_curve, argvals);
1272
1273    if frequencies.len() < 2 {
1274        return f64::NAN;
1275    }
1276
1277    let fundamental_freq = 1.0 / period;
1278
1279    // Sum power at seasonal frequencies (fundamental and harmonics)
1280    let _freq_tol = fundamental_freq * 0.1; // 10% tolerance (for future use)
1281    let mut seasonal_power = 0.0;
1282    let mut total_power = 0.0;
1283
1284    for (i, (&freq, &p)) in frequencies.iter().zip(power.iter()).enumerate() {
1285        if i == 0 {
1286            continue;
1287        } // Skip DC
1288
1289        total_power += p;
1290
1291        // Check if frequency is near a harmonic of fundamental
1292        let ratio = freq / fundamental_freq;
1293        let nearest_harmonic = ratio.round();
1294        if (ratio - nearest_harmonic).abs() < 0.1 && nearest_harmonic >= 1.0 {
1295            seasonal_power += p;
1296        }
1297    }
1298
1299    if total_power < 1e-15 {
1300        return 0.0;
1301    }
1302
1303    (seasonal_power / total_power).min(1.0)
1304}
1305
1306/// Compute seasonal strength using Morlet wavelet power at the target period.
1307///
1308/// This method uses the Continuous Wavelet Transform (CWT) with a Morlet wavelet
1309/// to measure power at the specified seasonal period. Unlike spectral methods,
1310/// wavelets provide time-localized frequency information.
1311///
1312/// # Arguments
1313/// * `data` - Column-major matrix (n x m)
1314/// * `n` - Number of samples
1315/// * `m` - Number of evaluation points
1316/// * `argvals` - Evaluation points
1317/// * `period` - Seasonal period in argvals units
1318///
1319/// # Returns
1320/// Seasonal strength as ratio of wavelet power to total variance (0 to 1)
1321///
1322/// # Notes
1323/// - Uses Morlet wavelet with ω₀ = 6 (standard choice)
1324/// - Scale is computed as: scale = period * ω₀ / (2π)
1325/// - Strength is computed over the interior 80% of the signal to avoid edge effects
1326pub fn seasonal_strength_wavelet(
1327    data: &[f64],
1328    n: usize,
1329    m: usize,
1330    argvals: &[f64],
1331    period: f64,
1332) -> f64 {
1333    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1334        return f64::NAN;
1335    }
1336
1337    // Compute mean curve
1338    let mean_curve = compute_mean_curve(data, n, m);
1339
1340    // Remove DC component
1341    let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1342    let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
1343
1344    // Compute total variance
1345    let total_variance: f64 = detrended.iter().map(|&x| x * x).sum::<f64>() / m as f64;
1346
1347    if total_variance < 1e-15 {
1348        return 0.0;
1349    }
1350
1351    // Compute wavelet transform at the seasonal scale
1352    let omega0 = 6.0;
1353    let scale = period * omega0 / (2.0 * PI);
1354    let wavelet_coeffs = cwt_morlet_fft(&detrended, argvals, scale, omega0);
1355
1356    if wavelet_coeffs.is_empty() {
1357        return f64::NAN;
1358    }
1359
1360    // Compute wavelet power, skipping edges (10% on each side)
1361    let (interior_start, interior_end) = match interior_bounds(m) {
1362        Some(bounds) => bounds,
1363        None => return f64::NAN,
1364    };
1365
1366    let wavelet_power: f64 = wavelet_coeffs[interior_start..interior_end]
1367        .iter()
1368        .map(|c| c.norm_sqr())
1369        .sum::<f64>()
1370        / (interior_end - interior_start) as f64;
1371
1372    // Return ratio of wavelet power to total variance
1373    // Normalize so that a pure sine at the target period gives ~1.0
1374    (wavelet_power / total_variance).sqrt().min(1.0)
1375}
1376
1377/// Compute time-varying seasonal strength using sliding windows.
1378///
1379/// # Arguments
1380/// * `data` - Column-major matrix (n x m)
1381/// * `n` - Number of samples
1382/// * `m` - Number of evaluation points
1383/// * `argvals` - Evaluation points
1384/// * `period` - Seasonal period
1385/// * `window_size` - Window width (recommended: 2 * period)
1386/// * `method` - Method for computing strength (Variance or Spectral)
1387///
1388/// # Returns
1389/// Seasonal strength at each time point
1390pub fn seasonal_strength_windowed(
1391    data: &[f64],
1392    n: usize,
1393    m: usize,
1394    argvals: &[f64],
1395    period: f64,
1396    window_size: f64,
1397    method: StrengthMethod,
1398) -> Vec<f64> {
1399    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 || window_size <= 0.0 {
1400        return Vec::new();
1401    }
1402
1403    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1404    let half_window_points = ((window_size / 2.0) / dt).round() as usize;
1405
1406    // Compute mean curve
1407    let mean_curve = compute_mean_curve(data, n, m);
1408
1409    iter_maybe_parallel!(0..m)
1410        .map(|center| {
1411            let start = center.saturating_sub(half_window_points);
1412            let end = (center + half_window_points + 1).min(m);
1413            let window_m = end - start;
1414
1415            if window_m < 4 {
1416                return f64::NAN;
1417            }
1418
1419            let window_data: Vec<f64> = mean_curve[start..end].to_vec();
1420            let window_argvals: Vec<f64> = argvals[start..end].to_vec();
1421
1422            // Create single-sample data for the strength functions
1423            let single_data = window_data.clone();
1424
1425            match method {
1426                StrengthMethod::Variance => seasonal_strength_variance(
1427                    &single_data,
1428                    1,
1429                    window_m,
1430                    &window_argvals,
1431                    period,
1432                    3,
1433                ),
1434                StrengthMethod::Spectral => {
1435                    seasonal_strength_spectral(&single_data, 1, window_m, &window_argvals, period)
1436                }
1437            }
1438        })
1439        .collect()
1440}
1441
1442// ============================================================================
1443// Seasonality Change Detection
1444// ============================================================================
1445
1446/// Detect changes in seasonality.
1447///
1448/// Monitors time-varying seasonal strength and detects threshold crossings
1449/// that indicate onset or cessation of seasonality.
1450///
1451/// # Arguments
1452/// * `data` - Column-major matrix (n x m)
1453/// * `n` - Number of samples
1454/// * `m` - Number of evaluation points
1455/// * `argvals` - Evaluation points
1456/// * `period` - Seasonal period
1457/// * `threshold` - SS threshold for seasonal/non-seasonal (e.g., 0.3)
1458/// * `window_size` - Window size for local strength estimation
1459/// * `min_duration` - Minimum duration to confirm a change
1460pub fn detect_seasonality_changes(
1461    data: &[f64],
1462    n: usize,
1463    m: usize,
1464    argvals: &[f64],
1465    period: f64,
1466    threshold: f64,
1467    window_size: f64,
1468    min_duration: f64,
1469) -> ChangeDetectionResult {
1470    if n == 0 || m < 4 || argvals.len() != m {
1471        return ChangeDetectionResult {
1472            change_points: Vec::new(),
1473            strength_curve: Vec::new(),
1474        };
1475    }
1476
1477    // Compute time-varying seasonal strength
1478    let strength_curve = seasonal_strength_windowed(
1479        data,
1480        n,
1481        m,
1482        argvals,
1483        period,
1484        window_size,
1485        StrengthMethod::Variance,
1486    );
1487
1488    if strength_curve.is_empty() {
1489        return ChangeDetectionResult {
1490            change_points: Vec::new(),
1491            strength_curve: Vec::new(),
1492        };
1493    }
1494
1495    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1496    let min_dur_points = (min_duration / dt).round() as usize;
1497
1498    // Detect threshold crossings
1499    let mut change_points = Vec::new();
1500    let mut in_seasonal = strength_curve[0] > threshold;
1501    let mut last_change_idx: Option<usize> = None;
1502
1503    for (i, &ss) in strength_curve.iter().enumerate().skip(1) {
1504        if ss.is_nan() {
1505            continue;
1506        }
1507
1508        let now_seasonal = ss > threshold;
1509
1510        if now_seasonal != in_seasonal {
1511            // Potential change point
1512            if let Some(last_idx) = last_change_idx {
1513                if i - last_idx < min_dur_points {
1514                    continue; // Too soon after last change
1515                }
1516            }
1517
1518            let change_type = if now_seasonal {
1519                ChangeType::Onset
1520            } else {
1521                ChangeType::Cessation
1522            };
1523
1524            let strength_before = if i > 0 && !strength_curve[i - 1].is_nan() {
1525                strength_curve[i - 1]
1526            } else {
1527                ss
1528            };
1529
1530            change_points.push(ChangePoint {
1531                time: argvals[i],
1532                change_type,
1533                strength_before,
1534                strength_after: ss,
1535            });
1536
1537            in_seasonal = now_seasonal;
1538            last_change_idx = Some(i);
1539        }
1540    }
1541
1542    ChangeDetectionResult {
1543        change_points,
1544        strength_curve,
1545    }
1546}
1547
1548// ============================================================================
1549// Amplitude Modulation Detection
1550// ============================================================================
1551
1552/// Detect amplitude modulation in seasonal time series.
1553///
1554/// This function first checks if seasonality exists using the spectral method
1555/// (which is robust to amplitude modulation), then uses Hilbert transform to
1556/// extract the amplitude envelope and analyze modulation patterns.
1557///
1558/// # Arguments
1559/// * `data` - Column-major matrix (n x m)
1560/// * `n` - Number of samples
1561/// * `m` - Number of evaluation points
1562/// * `argvals` - Evaluation points
1563/// * `period` - Seasonal period in argvals units
1564/// * `modulation_threshold` - CV threshold for detecting modulation (default: 0.15)
1565/// * `seasonality_threshold` - Strength threshold for seasonality (default: 0.3)
1566///
1567/// # Returns
1568/// `AmplitudeModulationResult` containing detection results and diagnostics
1569///
1570/// # Example
1571/// ```ignore
1572/// let result = detect_amplitude_modulation(
1573///     &data, n, m, &argvals,
1574///     period,
1575///     0.15,          // CV > 0.15 indicates modulation
1576///     0.3,           // strength > 0.3 indicates seasonality
1577/// );
1578/// if result.has_modulation {
1579///     match result.modulation_type {
1580///         ModulationType::Emerging => println!("Seasonality is emerging"),
1581///         ModulationType::Fading => println!("Seasonality is fading"),
1582///         _ => {}
1583///     }
1584/// }
1585/// ```
1586pub fn detect_amplitude_modulation(
1587    data: &[f64],
1588    n: usize,
1589    m: usize,
1590    argvals: &[f64],
1591    period: f64,
1592    modulation_threshold: f64,
1593    seasonality_threshold: f64,
1594) -> AmplitudeModulationResult {
1595    // Default result for invalid input
1596    let empty_result = AmplitudeModulationResult {
1597        is_seasonal: false,
1598        seasonal_strength: 0.0,
1599        has_modulation: false,
1600        modulation_type: ModulationType::NonSeasonal,
1601        modulation_score: 0.0,
1602        amplitude_trend: 0.0,
1603        strength_curve: Vec::new(),
1604        time_points: Vec::new(),
1605        min_strength: 0.0,
1606        max_strength: 0.0,
1607    };
1608
1609    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1610        return empty_result;
1611    }
1612
1613    // Step 1: Check if seasonality exists using spectral method (robust to AM)
1614    let overall_strength = seasonal_strength_spectral(data, n, m, argvals, period);
1615
1616    if overall_strength < seasonality_threshold {
1617        return AmplitudeModulationResult {
1618            is_seasonal: false,
1619            seasonal_strength: overall_strength,
1620            has_modulation: false,
1621            modulation_type: ModulationType::NonSeasonal,
1622            modulation_score: 0.0,
1623            amplitude_trend: 0.0,
1624            strength_curve: Vec::new(),
1625            time_points: argvals.to_vec(),
1626            min_strength: 0.0,
1627            max_strength: 0.0,
1628        };
1629    }
1630
1631    // Step 2: Compute mean curve
1632    let mean_curve = compute_mean_curve(data, n, m);
1633
1634    // Step 3: Use Hilbert transform to get amplitude envelope
1635    let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1636    let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
1637    let analytic = hilbert_transform(&detrended);
1638    let envelope: Vec<f64> = analytic.iter().map(|c| c.norm()).collect();
1639
1640    if envelope.is_empty() {
1641        return AmplitudeModulationResult {
1642            is_seasonal: true,
1643            seasonal_strength: overall_strength,
1644            has_modulation: false,
1645            modulation_type: ModulationType::Stable,
1646            modulation_score: 0.0,
1647            amplitude_trend: 0.0,
1648            strength_curve: Vec::new(),
1649            time_points: argvals.to_vec(),
1650            min_strength: 0.0,
1651            max_strength: 0.0,
1652        };
1653    }
1654
1655    // Step 4: Smooth the envelope to reduce high-frequency noise
1656    // Use simple moving average with window size based on period
1657    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1658    let smooth_window = ((period / dt) as usize).max(3);
1659    let half_window = smooth_window / 2;
1660
1661    let smoothed_envelope: Vec<f64> = (0..m)
1662        .map(|i| {
1663            let start = i.saturating_sub(half_window);
1664            let end = (i + half_window + 1).min(m);
1665            let sum: f64 = envelope[start..end].iter().sum();
1666            sum / (end - start) as f64
1667        })
1668        .collect();
1669
1670    // Step 5: Compute statistics on smoothed envelope
1671    // Skip edge regions (first and last 10% of points)
1672    let (interior_start, interior_end) = match interior_bounds(m) {
1673        Some((s, e)) if e > s + 4 => (s, e),
1674        _ => {
1675            return AmplitudeModulationResult {
1676                is_seasonal: true,
1677                seasonal_strength: overall_strength,
1678                has_modulation: false,
1679                modulation_type: ModulationType::Stable,
1680                modulation_score: 0.0,
1681                amplitude_trend: 0.0,
1682                strength_curve: envelope,
1683                time_points: argvals.to_vec(),
1684                min_strength: 0.0,
1685                max_strength: 0.0,
1686            };
1687        }
1688    };
1689
1690    let interior_envelope = &smoothed_envelope[interior_start..interior_end];
1691    let interior_times = &argvals[interior_start..interior_end];
1692    let n_interior = interior_envelope.len() as f64;
1693
1694    let mean_amp = interior_envelope.iter().sum::<f64>() / n_interior;
1695    let min_amp = interior_envelope
1696        .iter()
1697        .cloned()
1698        .fold(f64::INFINITY, f64::min);
1699    let max_amp = interior_envelope
1700        .iter()
1701        .cloned()
1702        .fold(f64::NEG_INFINITY, f64::max);
1703
1704    // Coefficient of variation (modulation score)
1705    let variance = interior_envelope
1706        .iter()
1707        .map(|&a| (a - mean_amp).powi(2))
1708        .sum::<f64>()
1709        / n_interior;
1710    let std_amp = variance.sqrt();
1711    let modulation_score = if mean_amp > 1e-10 {
1712        std_amp / mean_amp
1713    } else {
1714        0.0
1715    };
1716
1717    // Step 6: Compute trend in amplitude (linear regression slope)
1718    let t_mean = interior_times.iter().sum::<f64>() / n_interior;
1719    let mut cov_ta = 0.0;
1720    let mut var_t = 0.0;
1721    for (&t, &a) in interior_times.iter().zip(interior_envelope.iter()) {
1722        cov_ta += (t - t_mean) * (a - mean_amp);
1723        var_t += (t - t_mean).powi(2);
1724    }
1725    let slope = if var_t > 1e-10 { cov_ta / var_t } else { 0.0 };
1726
1727    // Normalize slope to [-1, 1] based on amplitude range and time span
1728    let time_span = interior_times.last().unwrap_or(&1.0) - interior_times.first().unwrap_or(&0.0);
1729    let amp_range = max_amp - min_amp;
1730    let amplitude_trend = if amp_range > 1e-10 && time_span > 1e-10 && mean_amp > 1e-10 {
1731        // Normalized: what fraction of mean amplitude changes per unit time span
1732        (slope * time_span / mean_amp).clamp(-1.0, 1.0)
1733    } else {
1734        0.0
1735    };
1736
1737    // Step 7: Classify modulation type
1738    let has_modulation = modulation_score > modulation_threshold;
1739    let modulation_type = if !has_modulation {
1740        ModulationType::Stable
1741    } else if amplitude_trend > 0.3 {
1742        ModulationType::Emerging
1743    } else if amplitude_trend < -0.3 {
1744        ModulationType::Fading
1745    } else {
1746        ModulationType::Oscillating
1747    };
1748
1749    AmplitudeModulationResult {
1750        is_seasonal: true,
1751        seasonal_strength: overall_strength,
1752        has_modulation,
1753        modulation_type,
1754        modulation_score,
1755        amplitude_trend,
1756        strength_curve: envelope,
1757        time_points: argvals.to_vec(),
1758        min_strength: min_amp,
1759        max_strength: max_amp,
1760    }
1761}
1762
1763/// Detect amplitude modulation using Morlet wavelet transform.
1764///
1765/// Uses continuous wavelet transform at the seasonal period to extract
1766/// time-varying amplitude. This method is more robust to noise and can
1767/// better handle non-stationary signals compared to Hilbert transform.
1768///
1769/// # Arguments
1770/// * `data` - Column-major matrix (n x m)
1771/// * `n` - Number of samples
1772/// * `m` - Number of evaluation points
1773/// * `argvals` - Evaluation points
1774/// * `period` - Seasonal period in argvals units
1775/// * `modulation_threshold` - CV threshold for detecting modulation (default: 0.15)
1776/// * `seasonality_threshold` - Strength threshold for seasonality (default: 0.3)
1777///
1778/// # Returns
1779/// `WaveletAmplitudeResult` containing detection results and wavelet amplitude curve
1780///
1781/// # Notes
1782/// - Uses Morlet wavelet with ω₀ = 6 (standard choice)
1783/// - The scale parameter is derived from the period: scale = period * ω₀ / (2π)
1784/// - This relates to how wavelets measure period: for Morlet, period ≈ scale * 2π / ω₀
1785pub fn detect_amplitude_modulation_wavelet(
1786    data: &[f64],
1787    n: usize,
1788    m: usize,
1789    argvals: &[f64],
1790    period: f64,
1791    modulation_threshold: f64,
1792    seasonality_threshold: f64,
1793) -> WaveletAmplitudeResult {
1794    let empty_result = WaveletAmplitudeResult {
1795        is_seasonal: false,
1796        seasonal_strength: 0.0,
1797        has_modulation: false,
1798        modulation_type: ModulationType::NonSeasonal,
1799        modulation_score: 0.0,
1800        amplitude_trend: 0.0,
1801        wavelet_amplitude: Vec::new(),
1802        time_points: Vec::new(),
1803        scale: 0.0,
1804    };
1805
1806    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1807        return empty_result;
1808    }
1809
1810    // Step 1: Check if seasonality exists using spectral method
1811    let overall_strength = seasonal_strength_spectral(data, n, m, argvals, period);
1812
1813    if overall_strength < seasonality_threshold {
1814        return WaveletAmplitudeResult {
1815            is_seasonal: false,
1816            seasonal_strength: overall_strength,
1817            has_modulation: false,
1818            modulation_type: ModulationType::NonSeasonal,
1819            modulation_score: 0.0,
1820            amplitude_trend: 0.0,
1821            wavelet_amplitude: Vec::new(),
1822            time_points: argvals.to_vec(),
1823            scale: 0.0,
1824        };
1825    }
1826
1827    // Step 2: Compute mean curve
1828    let mean_curve = compute_mean_curve(data, n, m);
1829
1830    // Remove DC component
1831    let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1832    let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
1833
1834    // Step 3: Compute wavelet transform at the seasonal period
1835    // For Morlet wavelet: period = scale * 2π / ω₀, so scale = period * ω₀ / (2π)
1836    let omega0 = 6.0; // Standard Morlet parameter
1837    let scale = period * omega0 / (2.0 * PI);
1838
1839    // Use FFT-based CWT for efficiency
1840    let wavelet_coeffs = cwt_morlet_fft(&detrended, argvals, scale, omega0);
1841
1842    if wavelet_coeffs.is_empty() {
1843        return WaveletAmplitudeResult {
1844            is_seasonal: true,
1845            seasonal_strength: overall_strength,
1846            has_modulation: false,
1847            modulation_type: ModulationType::Stable,
1848            modulation_score: 0.0,
1849            amplitude_trend: 0.0,
1850            wavelet_amplitude: Vec::new(),
1851            time_points: argvals.to_vec(),
1852            scale,
1853        };
1854    }
1855
1856    // Step 4: Extract amplitude (magnitude of wavelet coefficients)
1857    let wavelet_amplitude: Vec<f64> = wavelet_coeffs.iter().map(|c| c.norm()).collect();
1858
1859    // Step 5: Compute statistics on amplitude (skip edges)
1860    let (interior_start, interior_end) = match interior_bounds(m) {
1861        Some((s, e)) if e > s + 4 => (s, e),
1862        _ => {
1863            return WaveletAmplitudeResult {
1864                is_seasonal: true,
1865                seasonal_strength: overall_strength,
1866                has_modulation: false,
1867                modulation_type: ModulationType::Stable,
1868                modulation_score: 0.0,
1869                amplitude_trend: 0.0,
1870                wavelet_amplitude,
1871                time_points: argvals.to_vec(),
1872                scale,
1873            };
1874        }
1875    };
1876
1877    let interior_amp = &wavelet_amplitude[interior_start..interior_end];
1878    let interior_times = &argvals[interior_start..interior_end];
1879    let n_interior = interior_amp.len() as f64;
1880
1881    let mean_amp = interior_amp.iter().sum::<f64>() / n_interior;
1882
1883    // Coefficient of variation
1884    let variance = interior_amp
1885        .iter()
1886        .map(|&a| (a - mean_amp).powi(2))
1887        .sum::<f64>()
1888        / n_interior;
1889    let std_amp = variance.sqrt();
1890    let modulation_score = if mean_amp > 1e-10 {
1891        std_amp / mean_amp
1892    } else {
1893        0.0
1894    };
1895
1896    // Step 6: Compute trend
1897    let t_mean = interior_times.iter().sum::<f64>() / n_interior;
1898    let mut cov_ta = 0.0;
1899    let mut var_t = 0.0;
1900    for (&t, &a) in interior_times.iter().zip(interior_amp.iter()) {
1901        cov_ta += (t - t_mean) * (a - mean_amp);
1902        var_t += (t - t_mean).powi(2);
1903    }
1904    let slope = if var_t > 1e-10 { cov_ta / var_t } else { 0.0 };
1905
1906    let time_span = interior_times.last().unwrap_or(&1.0) - interior_times.first().unwrap_or(&0.0);
1907    let amplitude_trend = if mean_amp > 1e-10 && time_span > 1e-10 {
1908        (slope * time_span / mean_amp).clamp(-1.0, 1.0)
1909    } else {
1910        0.0
1911    };
1912
1913    // Step 7: Classify modulation type
1914    let has_modulation = modulation_score > modulation_threshold;
1915    let modulation_type = if !has_modulation {
1916        ModulationType::Stable
1917    } else if amplitude_trend > 0.3 {
1918        ModulationType::Emerging
1919    } else if amplitude_trend < -0.3 {
1920        ModulationType::Fading
1921    } else {
1922        ModulationType::Oscillating
1923    };
1924
1925    WaveletAmplitudeResult {
1926        is_seasonal: true,
1927        seasonal_strength: overall_strength,
1928        has_modulation,
1929        modulation_type,
1930        modulation_score,
1931        amplitude_trend,
1932        wavelet_amplitude,
1933        time_points: argvals.to_vec(),
1934        scale,
1935    }
1936}
1937
1938// ============================================================================
1939// Instantaneous Period
1940// ============================================================================
1941
1942/// Estimate instantaneous period using Hilbert transform.
1943///
1944/// For series with drifting/changing period, this computes the period
1945/// at each time point using the analytic signal.
1946///
1947/// # Arguments
1948/// * `data` - Column-major matrix (n x m)
1949/// * `n` - Number of samples
1950/// * `m` - Number of evaluation points
1951/// * `argvals` - Evaluation points
1952pub fn instantaneous_period(
1953    data: &[f64],
1954    n: usize,
1955    m: usize,
1956    argvals: &[f64],
1957) -> InstantaneousPeriod {
1958    if n == 0 || m < 4 || argvals.len() != m {
1959        return InstantaneousPeriod {
1960            period: Vec::new(),
1961            frequency: Vec::new(),
1962            amplitude: Vec::new(),
1963        };
1964    }
1965
1966    // Compute mean curve
1967    let mean_curve = compute_mean_curve(data, n, m);
1968
1969    // Remove DC component (detrend by subtracting mean)
1970    let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1971    let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
1972
1973    // Compute analytic signal via Hilbert transform
1974    let analytic = hilbert_transform(&detrended);
1975
1976    // Extract instantaneous amplitude and phase
1977    let amplitude: Vec<f64> = analytic.iter().map(|c| c.norm()).collect();
1978
1979    let phase: Vec<f64> = analytic.iter().map(|c| c.im.atan2(c.re)).collect();
1980
1981    // Unwrap phase
1982    let unwrapped_phase = unwrap_phase(&phase);
1983
1984    // Compute instantaneous frequency (derivative of phase)
1985    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1986    let mut inst_freq = vec![0.0; m];
1987
1988    // Central differences for interior, forward/backward at boundaries
1989    if m > 1 {
1990        inst_freq[0] = (unwrapped_phase[1] - unwrapped_phase[0]) / dt / (2.0 * PI);
1991    }
1992    for j in 1..(m - 1) {
1993        inst_freq[j] = (unwrapped_phase[j + 1] - unwrapped_phase[j - 1]) / (2.0 * dt) / (2.0 * PI);
1994    }
1995    if m > 1 {
1996        inst_freq[m - 1] = (unwrapped_phase[m - 1] - unwrapped_phase[m - 2]) / dt / (2.0 * PI);
1997    }
1998
1999    // Period = 1/frequency (handle near-zero frequencies)
2000    let period: Vec<f64> = inst_freq
2001        .iter()
2002        .map(|&f| {
2003            if f.abs() > 1e-10 {
2004                (1.0 / f).abs()
2005            } else {
2006                f64::INFINITY
2007            }
2008        })
2009        .collect();
2010
2011    InstantaneousPeriod {
2012        period,
2013        frequency: inst_freq,
2014        amplitude,
2015    }
2016}
2017
2018// ============================================================================
2019// Peak Timing Variability Analysis
2020// ============================================================================
2021
2022/// Analyze peak timing variability across cycles.
2023///
2024/// For short series (e.g., 3-5 years of yearly data), this function detects
2025/// one peak per cycle and analyzes how peak timing varies between cycles.
2026///
2027/// # Arguments
2028/// * `data` - Column-major matrix (n x m)
2029/// * `n` - Number of samples
2030/// * `m` - Number of evaluation points
2031/// * `argvals` - Evaluation points
2032/// * `period` - Known period (e.g., 365 for daily data with yearly seasonality)
2033/// * `smooth_nbasis` - Number of Fourier basis functions for smoothing.
2034///   If None, uses GCV for automatic selection.
2035///
2036/// # Returns
2037/// Peak timing result with variability metrics
2038pub fn analyze_peak_timing(
2039    data: &[f64],
2040    n: usize,
2041    m: usize,
2042    argvals: &[f64],
2043    period: f64,
2044    smooth_nbasis: Option<usize>,
2045) -> PeakTimingResult {
2046    if n == 0 || m < 3 || argvals.len() != m || period <= 0.0 {
2047        return PeakTimingResult {
2048            peak_times: Vec::new(),
2049            peak_values: Vec::new(),
2050            normalized_timing: Vec::new(),
2051            mean_timing: f64::NAN,
2052            std_timing: f64::NAN,
2053            range_timing: f64::NAN,
2054            variability_score: f64::NAN,
2055            timing_trend: f64::NAN,
2056            cycle_indices: Vec::new(),
2057        };
2058    }
2059
2060    // Detect peaks with minimum distance constraint of 0.7 * period
2061    // This ensures we get at most one peak per cycle
2062    let min_distance = period * 0.7;
2063    let peaks = detect_peaks(
2064        data,
2065        n,
2066        m,
2067        argvals,
2068        Some(min_distance),
2069        None, // No prominence filter
2070        true, // Smooth first with Fourier basis
2071        smooth_nbasis,
2072    );
2073
2074    // Use the first sample's peaks (for mean curve analysis)
2075    // If multiple samples, we take the mean curve which is effectively in sample 0
2076    let sample_peaks = if peaks.peaks.is_empty() {
2077        Vec::new()
2078    } else {
2079        peaks.peaks[0].clone()
2080    };
2081
2082    if sample_peaks.is_empty() {
2083        return PeakTimingResult {
2084            peak_times: Vec::new(),
2085            peak_values: Vec::new(),
2086            normalized_timing: Vec::new(),
2087            mean_timing: f64::NAN,
2088            std_timing: f64::NAN,
2089            range_timing: f64::NAN,
2090            variability_score: f64::NAN,
2091            timing_trend: f64::NAN,
2092            cycle_indices: Vec::new(),
2093        };
2094    }
2095
2096    let peak_times: Vec<f64> = sample_peaks.iter().map(|p| p.time).collect();
2097    let peak_values: Vec<f64> = sample_peaks.iter().map(|p| p.value).collect();
2098
2099    // Compute normalized timing (position within cycle, 0-1 scale)
2100    let t_start = argvals[0];
2101    let normalized_timing: Vec<f64> = peak_times
2102        .iter()
2103        .map(|&t| {
2104            let cycle_pos = (t - t_start) % period;
2105            cycle_pos / period
2106        })
2107        .collect();
2108
2109    // Compute cycle indices (1-indexed)
2110    let cycle_indices: Vec<usize> = peak_times
2111        .iter()
2112        .map(|&t| ((t - t_start) / period).floor() as usize + 1)
2113        .collect();
2114
2115    // Compute statistics
2116    let n_peaks = normalized_timing.len() as f64;
2117    let mean_timing = normalized_timing.iter().sum::<f64>() / n_peaks;
2118
2119    let variance: f64 = normalized_timing
2120        .iter()
2121        .map(|&x| (x - mean_timing).powi(2))
2122        .sum::<f64>()
2123        / n_peaks;
2124    let std_timing = variance.sqrt();
2125
2126    let min_timing = normalized_timing
2127        .iter()
2128        .cloned()
2129        .fold(f64::INFINITY, f64::min);
2130    let max_timing = normalized_timing
2131        .iter()
2132        .cloned()
2133        .fold(f64::NEG_INFINITY, f64::max);
2134    let range_timing = max_timing - min_timing;
2135
2136    // Variability score: normalized std deviation
2137    // Max possible std for uniform in [0,1] is ~0.289, so we scale by that
2138    // But since peaks cluster, we use 0.1 as "high" variability threshold
2139    let variability_score = (std_timing / 0.1).min(1.0);
2140
2141    // Timing trend: linear regression of normalized timing on cycle index
2142    let cycle_idx_f64: Vec<f64> = cycle_indices.iter().map(|&i| i as f64).collect();
2143    let timing_trend = linear_slope(&cycle_idx_f64, &normalized_timing);
2144
2145    PeakTimingResult {
2146        peak_times,
2147        peak_values,
2148        normalized_timing,
2149        mean_timing,
2150        std_timing,
2151        range_timing,
2152        variability_score,
2153        timing_trend,
2154        cycle_indices,
2155    }
2156}
2157
2158// ============================================================================
2159// Seasonality Classification
2160// ============================================================================
2161
2162/// Classify the type of seasonality in functional data.
2163///
2164/// This is particularly useful for short series (3-5 years) where you need
2165/// to identify:
2166/// - Whether seasonality is present
2167/// - Whether peak timing is stable or variable
2168/// - Which cycles have weak or missing seasonality
2169///
2170/// # Arguments
2171/// * `data` - Column-major matrix (n x m)
2172/// * `n` - Number of samples
2173/// * `m` - Number of evaluation points
2174/// * `argvals` - Evaluation points
2175/// * `period` - Known seasonal period
2176/// * `strength_threshold` - Threshold for seasonal/non-seasonal (default: 0.3)
2177/// * `timing_threshold` - Max std of normalized timing for "stable" (default: 0.05)
2178///
2179/// # Returns
2180/// Seasonality classification with type and diagnostics
2181pub fn classify_seasonality(
2182    data: &[f64],
2183    n: usize,
2184    m: usize,
2185    argvals: &[f64],
2186    period: f64,
2187    strength_threshold: Option<f64>,
2188    timing_threshold: Option<f64>,
2189) -> SeasonalityClassification {
2190    let strength_thresh = strength_threshold.unwrap_or(0.3);
2191    let timing_thresh = timing_threshold.unwrap_or(0.05);
2192
2193    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
2194        return SeasonalityClassification {
2195            is_seasonal: false,
2196            has_stable_timing: false,
2197            timing_variability: f64::NAN,
2198            seasonal_strength: f64::NAN,
2199            cycle_strengths: Vec::new(),
2200            weak_seasons: Vec::new(),
2201            classification: SeasonalType::NonSeasonal,
2202            peak_timing: None,
2203        };
2204    }
2205
2206    // Compute overall seasonal strength
2207    let overall_strength = seasonal_strength_variance(data, n, m, argvals, period, 3);
2208
2209    // Compute per-cycle strength
2210    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
2211    let _points_per_cycle = (period / dt).round() as usize;
2212    let t_start = argvals[0];
2213    let t_end = argvals[m - 1];
2214    let n_cycles = ((t_end - t_start) / period).floor() as usize;
2215
2216    let mut cycle_strengths = Vec::with_capacity(n_cycles);
2217    let mut weak_seasons = Vec::new();
2218
2219    for cycle in 0..n_cycles {
2220        let cycle_start = t_start + cycle as f64 * period;
2221        let cycle_end = cycle_start + period;
2222
2223        // Find indices for this cycle
2224        let start_idx = argvals.iter().position(|&t| t >= cycle_start).unwrap_or(0);
2225        let end_idx = argvals.iter().position(|&t| t > cycle_end).unwrap_or(m);
2226
2227        let cycle_m = end_idx - start_idx;
2228        if cycle_m < 4 {
2229            cycle_strengths.push(f64::NAN);
2230            continue;
2231        }
2232
2233        // Extract cycle data
2234        let cycle_data: Vec<f64> = (start_idx..end_idx)
2235            .flat_map(|j| (0..n).map(move |i| data[i + j * n]))
2236            .collect();
2237        let cycle_argvals: Vec<f64> = argvals[start_idx..end_idx].to_vec();
2238
2239        let strength =
2240            seasonal_strength_variance(&cycle_data, n, cycle_m, &cycle_argvals, period, 3);
2241
2242        cycle_strengths.push(strength);
2243
2244        if strength < strength_thresh {
2245            weak_seasons.push(cycle);
2246        }
2247    }
2248
2249    // Analyze peak timing
2250    let peak_timing = analyze_peak_timing(data, n, m, argvals, period, None);
2251
2252    // Determine classification
2253    let is_seasonal = overall_strength >= strength_thresh;
2254    let has_stable_timing = peak_timing.std_timing <= timing_thresh;
2255    let timing_variability = peak_timing.variability_score;
2256
2257    // Classify based on patterns
2258    let n_weak = weak_seasons.len();
2259    let classification = if !is_seasonal {
2260        SeasonalType::NonSeasonal
2261    } else if n_cycles > 0 && n_weak as f64 / n_cycles as f64 > 0.3 {
2262        // More than 30% of cycles are weak
2263        SeasonalType::IntermittentSeasonal
2264    } else if !has_stable_timing {
2265        SeasonalType::VariableTiming
2266    } else {
2267        SeasonalType::StableSeasonal
2268    };
2269
2270    SeasonalityClassification {
2271        is_seasonal,
2272        has_stable_timing,
2273        timing_variability,
2274        seasonal_strength: overall_strength,
2275        cycle_strengths,
2276        weak_seasons,
2277        classification,
2278        peak_timing: Some(peak_timing),
2279    }
2280}
2281
2282/// Detect seasonality changes with automatic threshold selection.
2283///
2284/// Uses Otsu's method or percentile-based threshold instead of a fixed value.
2285///
2286/// # Arguments
2287/// * `data` - Column-major matrix (n x m)
2288/// * `n` - Number of samples
2289/// * `m` - Number of evaluation points
2290/// * `argvals` - Evaluation points
2291/// * `period` - Seasonal period
2292/// * `threshold_method` - Method for threshold selection
2293/// * `window_size` - Window size for local strength estimation
2294/// * `min_duration` - Minimum duration to confirm a change
2295pub fn detect_seasonality_changes_auto(
2296    data: &[f64],
2297    n: usize,
2298    m: usize,
2299    argvals: &[f64],
2300    period: f64,
2301    threshold_method: ThresholdMethod,
2302    window_size: f64,
2303    min_duration: f64,
2304) -> ChangeDetectionResult {
2305    if n == 0 || m < 4 || argvals.len() != m {
2306        return ChangeDetectionResult {
2307            change_points: Vec::new(),
2308            strength_curve: Vec::new(),
2309        };
2310    }
2311
2312    // Compute time-varying seasonal strength
2313    let strength_curve = seasonal_strength_windowed(
2314        data,
2315        n,
2316        m,
2317        argvals,
2318        period,
2319        window_size,
2320        StrengthMethod::Variance,
2321    );
2322
2323    if strength_curve.is_empty() {
2324        return ChangeDetectionResult {
2325            change_points: Vec::new(),
2326            strength_curve: Vec::new(),
2327        };
2328    }
2329
2330    // Determine threshold
2331    let threshold = match threshold_method {
2332        ThresholdMethod::Fixed(t) => t,
2333        ThresholdMethod::Percentile(p) => {
2334            let mut sorted: Vec<f64> = strength_curve
2335                .iter()
2336                .copied()
2337                .filter(|x| x.is_finite())
2338                .collect();
2339            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2340            if sorted.is_empty() {
2341                0.5
2342            } else {
2343                let idx = ((p / 100.0) * sorted.len() as f64) as usize;
2344                sorted[idx.min(sorted.len() - 1)]
2345            }
2346        }
2347        ThresholdMethod::Otsu => otsu_threshold(&strength_curve),
2348    };
2349
2350    // Now use the regular detection with computed threshold
2351    detect_seasonality_changes(
2352        data,
2353        n,
2354        m,
2355        argvals,
2356        period,
2357        threshold,
2358        window_size,
2359        min_duration,
2360    )
2361}
2362
2363/// Result of SAZED ensemble period detection.
2364#[derive(Debug, Clone)]
2365pub struct SazedResult {
2366    /// Primary detected period (consensus from ensemble)
2367    pub period: f64,
2368    /// Confidence score (0-1, based on component agreement)
2369    pub confidence: f64,
2370    /// Periods detected by each component (may be NaN if not detected)
2371    pub component_periods: SazedComponents,
2372    /// Number of components that agreed on the final period
2373    pub agreeing_components: usize,
2374}
2375
2376/// Individual period estimates from each SAZED component.
2377#[derive(Debug, Clone)]
2378pub struct SazedComponents {
2379    /// Period from spectral (FFT) detection
2380    pub spectral: f64,
2381    /// Period from ACF peak detection
2382    pub acf_peak: f64,
2383    /// Period from weighted ACF average
2384    pub acf_average: f64,
2385    /// Period from ACF zero-crossing analysis
2386    pub zero_crossing: f64,
2387    /// Period from spectral differencing
2388    pub spectral_diff: f64,
2389}
2390
2391/// SAZED: Spectral-ACF Zero-crossing Ensemble Detection
2392///
2393/// A parameter-free ensemble method for robust period detection.
2394/// Combines 5 detection components:
2395/// 1. Spectral (FFT) - peaks in periodogram
2396/// 2. ACF peak - first significant peak in autocorrelation
2397/// 3. ACF average - weighted mean of ACF peaks
2398/// 4. Zero-crossing - period from ACF zero crossings
2399/// 5. Spectral differencing - FFT on first-differenced signal
2400///
2401/// Each component provides both a period estimate and a confidence score.
2402/// Only components with sufficient confidence participate in voting.
2403/// The final period is chosen by majority voting with tolerance.
2404///
2405/// # Arguments
2406/// * `data` - Input signal (1D time series or mean curve from fdata)
2407/// * `argvals` - Time points corresponding to data
2408/// * `tolerance` - Relative tolerance for considering periods equal (default: 0.05 = 5%)
2409///
2410/// # Returns
2411/// * `SazedResult` containing the consensus period and component details
2412pub fn sazed(data: &[f64], argvals: &[f64], tolerance: Option<f64>) -> SazedResult {
2413    let n = data.len();
2414    let tol = tolerance.unwrap_or(0.05); // Tighter default tolerance
2415
2416    if n < 8 || argvals.len() != n {
2417        return SazedResult {
2418            period: f64::NAN,
2419            confidence: 0.0,
2420            component_periods: SazedComponents {
2421                spectral: f64::NAN,
2422                acf_peak: f64::NAN,
2423                acf_average: f64::NAN,
2424                zero_crossing: f64::NAN,
2425                spectral_diff: f64::NAN,
2426            },
2427            agreeing_components: 0,
2428        };
2429    }
2430
2431    let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
2432    let max_lag = (n / 2).max(4);
2433    let signal_range = argvals[n - 1] - argvals[0];
2434
2435    // Minimum detectable period (at least 3 cycles)
2436    let min_period = signal_range / (n as f64 / 3.0);
2437    // Maximum detectable period (at most 2 complete cycles)
2438    let max_period = signal_range / 2.0;
2439
2440    // Component 1: Spectral (FFT) detection with confidence
2441    let (spectral_period, spectral_conf) = sazed_spectral_with_confidence(data, argvals);
2442
2443    // Component 2: ACF peak detection with confidence
2444    let (acf_peak_period, acf_peak_conf) = sazed_acf_peak_with_confidence(data, dt, max_lag);
2445
2446    // Component 3: ACF weighted average (uses ACF peak confidence)
2447    let acf_average_period = sazed_acf_average(data, dt, max_lag);
2448
2449    // Component 4: Zero-crossing analysis with confidence
2450    let (zero_crossing_period, zero_crossing_conf) =
2451        sazed_zero_crossing_with_confidence(data, dt, max_lag);
2452
2453    // Component 5: Spectral on differenced signal with confidence
2454    let (spectral_diff_period, spectral_diff_conf) =
2455        sazed_spectral_diff_with_confidence(data, argvals);
2456
2457    let components = SazedComponents {
2458        spectral: spectral_period,
2459        acf_peak: acf_peak_period,
2460        acf_average: acf_average_period,
2461        zero_crossing: zero_crossing_period,
2462        spectral_diff: spectral_diff_period,
2463    };
2464
2465    // Confidence thresholds for each component (tuned to minimize FPR on noise)
2466    // For Gaussian noise: spectral peaks rarely exceed 6x median, ACF ~1/sqrt(n)
2467    let spectral_thresh = 8.0; // Power ratio must be > 8x median (noise rarely exceeds 6x)
2468    let acf_thresh = 0.3; // ACF correlation must be > 0.3 (noise ~0.1 for n=100)
2469    let zero_crossing_thresh = 0.9; // Zero-crossing consistency > 90%
2470    let spectral_diff_thresh = 6.0; // Diff spectral power ratio > 6x
2471
2472    // Minimum number of confident components required to report a period
2473    let min_confident_components = 2;
2474
2475    // Collect valid periods (only from components with sufficient confidence)
2476    let mut confident_periods: Vec<f64> = Vec::new();
2477
2478    if spectral_period.is_finite()
2479        && spectral_period > min_period
2480        && spectral_period < max_period
2481        && spectral_conf > spectral_thresh
2482    {
2483        confident_periods.push(spectral_period);
2484    }
2485
2486    if acf_peak_period.is_finite()
2487        && acf_peak_period > min_period
2488        && acf_peak_period < max_period
2489        && acf_peak_conf > acf_thresh
2490    {
2491        confident_periods.push(acf_peak_period);
2492    }
2493
2494    // ACF average only counted if ACF peak is confident
2495    if acf_average_period.is_finite()
2496        && acf_average_period > min_period
2497        && acf_average_period < max_period
2498        && acf_peak_conf > acf_thresh
2499    {
2500        confident_periods.push(acf_average_period);
2501    }
2502
2503    if zero_crossing_period.is_finite()
2504        && zero_crossing_period > min_period
2505        && zero_crossing_period < max_period
2506        && zero_crossing_conf > zero_crossing_thresh
2507    {
2508        confident_periods.push(zero_crossing_period);
2509    }
2510
2511    if spectral_diff_period.is_finite()
2512        && spectral_diff_period > min_period
2513        && spectral_diff_period < max_period
2514        && spectral_diff_conf > spectral_diff_thresh
2515    {
2516        confident_periods.push(spectral_diff_period);
2517    }
2518
2519    // Require minimum number of confident components before reporting a period
2520    if confident_periods.len() < min_confident_components {
2521        return SazedResult {
2522            period: f64::NAN,
2523            confidence: 0.0,
2524            component_periods: components,
2525            agreeing_components: confident_periods.len(),
2526        };
2527    }
2528
2529    // Ensemble voting: find the mode with tolerance
2530    let (consensus_period, agreeing_count) = find_consensus_period(&confident_periods, tol);
2531    let confidence = agreeing_count as f64 / 5.0;
2532
2533    SazedResult {
2534        period: consensus_period,
2535        confidence,
2536        component_periods: components,
2537        agreeing_components: agreeing_count,
2538    }
2539}
2540
2541/// SAZED for functional data (matrix format)
2542///
2543/// Computes mean curve first, then applies SAZED.
2544///
2545/// # Arguments
2546/// * `data` - Column-major matrix (n x m)
2547/// * `n` - Number of samples
2548/// * `m` - Number of evaluation points
2549/// * `argvals` - Evaluation points
2550/// * `tolerance` - Relative tolerance for period matching
2551pub fn sazed_fdata(
2552    data: &[f64],
2553    n: usize,
2554    m: usize,
2555    argvals: &[f64],
2556    tolerance: Option<f64>,
2557) -> SazedResult {
2558    if n == 0 || m < 8 || argvals.len() != m {
2559        return SazedResult {
2560            period: f64::NAN,
2561            confidence: 0.0,
2562            component_periods: SazedComponents {
2563                spectral: f64::NAN,
2564                acf_peak: f64::NAN,
2565                acf_average: f64::NAN,
2566                zero_crossing: f64::NAN,
2567                spectral_diff: f64::NAN,
2568            },
2569            agreeing_components: 0,
2570        };
2571    }
2572
2573    let mean_curve = compute_mean_curve(data, n, m);
2574    sazed(&mean_curve, argvals, tolerance)
2575}
2576
2577/// Spectral component with confidence: returns (period, power_ratio)
2578fn sazed_spectral_with_confidence(data: &[f64], argvals: &[f64]) -> (f64, f64) {
2579    let (frequencies, power) = periodogram(data, argvals);
2580
2581    if frequencies.len() < 3 {
2582        return (f64::NAN, 0.0);
2583    }
2584
2585    // Find peaks in power spectrum (skip DC)
2586    let power_no_dc: Vec<f64> = power.iter().skip(1).copied().collect();
2587
2588    if power_no_dc.is_empty() {
2589        return (f64::NAN, 0.0);
2590    }
2591
2592    // Calculate noise floor as median
2593    let mut sorted_power = power_no_dc.clone();
2594    sorted_power.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2595    let noise_floor = sorted_power[sorted_power.len() / 2].max(1e-15);
2596
2597    // Find global maximum
2598    let mut max_idx = 0;
2599    let mut max_val = 0.0;
2600    for (i, &p) in power_no_dc.iter().enumerate() {
2601        if p > max_val {
2602            max_val = p;
2603            max_idx = i;
2604        }
2605    }
2606
2607    let power_ratio = max_val / noise_floor;
2608    let freq = frequencies[max_idx + 1];
2609
2610    if freq > 1e-15 {
2611        (1.0 / freq, power_ratio)
2612    } else {
2613        (f64::NAN, 0.0)
2614    }
2615}
2616
2617/// ACF peak component with confidence: returns (period, acf_value_at_peak)
2618fn sazed_acf_peak_with_confidence(data: &[f64], dt: f64, max_lag: usize) -> (f64, f64) {
2619    let acf = autocorrelation(data, max_lag);
2620
2621    if acf.len() < 4 {
2622        return (f64::NAN, 0.0);
2623    }
2624
2625    // Find first peak after initial descent
2626    // Skip lag 0 and find where ACF first becomes negative or reaches minimum
2627    let mut min_search_start = 1;
2628    for i in 1..acf.len() {
2629        if acf[i] < 0.0 {
2630            min_search_start = i;
2631            break;
2632        }
2633        if i > 1 && acf[i] > acf[i - 1] {
2634            min_search_start = i - 1;
2635            break;
2636        }
2637    }
2638
2639    // Find first peak after minimum
2640    let peaks = find_peaks_1d(&acf[min_search_start..], 1);
2641
2642    if peaks.is_empty() {
2643        return (f64::NAN, 0.0);
2644    }
2645
2646    let peak_lag = peaks[0] + min_search_start;
2647    let acf_value = acf[peak_lag].max(0.0); // Confidence is the ACF value at the peak
2648
2649    (peak_lag as f64 * dt, acf_value)
2650}
2651
2652/// ACF average component: weighted mean of ACF peak locations
2653fn sazed_acf_average(data: &[f64], dt: f64, max_lag: usize) -> f64 {
2654    let acf = autocorrelation(data, max_lag);
2655
2656    if acf.len() < 4 {
2657        return f64::NAN;
2658    }
2659
2660    // Find all peaks in ACF
2661    let peaks = find_peaks_1d(&acf[1..], 1);
2662
2663    if peaks.is_empty() {
2664        return f64::NAN;
2665    }
2666
2667    // Weight peaks by their ACF value
2668    let mut weighted_sum = 0.0;
2669    let mut weight_sum = 0.0;
2670
2671    for (i, &peak_idx) in peaks.iter().enumerate() {
2672        let lag = peak_idx + 1;
2673        let weight = acf[lag].max(0.0);
2674
2675        if i == 0 {
2676            // First peak is the fundamental period
2677            weighted_sum += lag as f64 * weight;
2678            weight_sum += weight;
2679        } else {
2680            // Later peaks: estimate fundamental by dividing by harmonic number
2681            let expected_fundamental = peaks[0] + 1;
2682            let harmonic = ((lag as f64 / expected_fundamental as f64) + 0.5) as usize;
2683            if harmonic > 0 {
2684                let fundamental_est = lag as f64 / harmonic as f64;
2685                weighted_sum += fundamental_est * weight;
2686                weight_sum += weight;
2687            }
2688        }
2689    }
2690
2691    if weight_sum > 1e-15 {
2692        weighted_sum / weight_sum * dt
2693    } else {
2694        f64::NAN
2695    }
2696}
2697
2698/// Zero-crossing component with confidence: returns (period, consistency)
2699/// Consistency is how regular the zero crossings are (std/mean of half-periods)
2700fn sazed_zero_crossing_with_confidence(data: &[f64], dt: f64, max_lag: usize) -> (f64, f64) {
2701    let acf = autocorrelation(data, max_lag);
2702
2703    if acf.len() < 4 {
2704        return (f64::NAN, 0.0);
2705    }
2706
2707    // Find zero crossings (sign changes)
2708    let mut crossings = Vec::new();
2709    for i in 1..acf.len() {
2710        if acf[i - 1] * acf[i] < 0.0 {
2711            // Linear interpolation for more precise crossing
2712            let frac = acf[i - 1].abs() / (acf[i - 1].abs() + acf[i].abs());
2713            crossings.push((i - 1) as f64 + frac);
2714        }
2715    }
2716
2717    if crossings.len() < 2 {
2718        return (f64::NAN, 0.0);
2719    }
2720
2721    // Period is twice the distance between consecutive zero crossings
2722    // (ACF goes through two zero crossings per period)
2723    let mut half_periods = Vec::new();
2724    for i in 1..crossings.len() {
2725        half_periods.push(crossings[i] - crossings[i - 1]);
2726    }
2727
2728    if half_periods.is_empty() {
2729        return (f64::NAN, 0.0);
2730    }
2731
2732    // Calculate consistency: 1 - (std/mean) of half-periods
2733    // High consistency means regular zero crossings
2734    let mean: f64 = half_periods.iter().sum::<f64>() / half_periods.len() as f64;
2735    let variance: f64 =
2736        half_periods.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / half_periods.len() as f64;
2737    let std = variance.sqrt();
2738    let consistency = (1.0 - std / mean.max(1e-15)).clamp(0.0, 1.0);
2739
2740    // Median half-period
2741    half_periods.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2742    let median_half = half_periods[half_periods.len() / 2];
2743
2744    (2.0 * median_half * dt, consistency)
2745}
2746
2747/// Spectral differencing with confidence: returns (period, power_ratio)
2748fn sazed_spectral_diff_with_confidence(data: &[f64], argvals: &[f64]) -> (f64, f64) {
2749    if data.len() < 4 {
2750        return (f64::NAN, 0.0);
2751    }
2752
2753    // First difference to remove trend
2754    let diff: Vec<f64> = data.windows(2).map(|w| w[1] - w[0]).collect();
2755    let diff_argvals: Vec<f64> = argvals.windows(2).map(|w| (w[0] + w[1]) / 2.0).collect();
2756
2757    sazed_spectral_with_confidence(&diff, &diff_argvals)
2758}
2759
2760/// Find peaks in power spectrum above noise floor
2761fn find_spectral_peaks(power: &[f64]) -> Vec<usize> {
2762    if power.len() < 3 {
2763        return Vec::new();
2764    }
2765
2766    // Estimate noise floor as median power
2767    let mut sorted_power: Vec<f64> = power.to_vec();
2768    sorted_power.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2769    let noise_floor = sorted_power[sorted_power.len() / 2];
2770    let threshold = noise_floor * 2.0; // Peaks must be at least 2x median
2771
2772    // Find all local maxima above threshold
2773    let mut peaks: Vec<(usize, f64)> = Vec::new();
2774    for i in 1..(power.len() - 1) {
2775        if power[i] > power[i - 1] && power[i] > power[i + 1] && power[i] > threshold {
2776            peaks.push((i, power[i]));
2777        }
2778    }
2779
2780    // Sort by power (descending)
2781    peaks.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
2782
2783    peaks.into_iter().map(|(idx, _)| idx).collect()
2784}
2785
2786/// Find consensus period from multiple estimates using tolerance-based voting
2787fn find_consensus_period(periods: &[f64], tolerance: f64) -> (f64, usize) {
2788    if periods.is_empty() {
2789        return (f64::NAN, 0);
2790    }
2791    if periods.len() == 1 {
2792        return (periods[0], 1);
2793    }
2794
2795    // For each period, count how many others are within tolerance
2796    let mut best_period = periods[0];
2797    let mut best_count = 0;
2798    let mut best_sum = 0.0;
2799
2800    for &p1 in periods {
2801        let mut count = 0;
2802        let mut sum = 0.0;
2803
2804        for &p2 in periods {
2805            let rel_diff = (p1 - p2).abs() / p1.max(p2);
2806            if rel_diff <= tolerance {
2807                count += 1;
2808                sum += p2;
2809            }
2810        }
2811
2812        if count > best_count
2813            || (count == best_count && sum / count as f64 > best_sum / best_count.max(1) as f64)
2814        {
2815            best_count = count;
2816            best_period = sum / count as f64; // Average of agreeing periods
2817            best_sum = sum;
2818        }
2819    }
2820
2821    (best_period, best_count)
2822}
2823
2824/// Result of Autoperiod detection.
2825#[derive(Debug, Clone)]
2826pub struct AutoperiodResult {
2827    /// Detected period
2828    pub period: f64,
2829    /// Combined confidence (FFT * ACF validation)
2830    pub confidence: f64,
2831    /// FFT power at the detected period
2832    pub fft_power: f64,
2833    /// ACF validation score (0-1)
2834    pub acf_validation: f64,
2835    /// All candidate periods considered
2836    pub candidates: Vec<AutoperiodCandidate>,
2837}
2838
2839/// A candidate period from Autoperiod detection.
2840#[derive(Debug, Clone)]
2841pub struct AutoperiodCandidate {
2842    /// Candidate period
2843    pub period: f64,
2844    /// FFT power
2845    pub fft_power: f64,
2846    /// ACF validation score
2847    pub acf_score: f64,
2848    /// Combined score (power * validation)
2849    pub combined_score: f64,
2850}
2851
2852/// Autoperiod: Hybrid FFT + ACF Period Detection
2853///
2854/// Implements the Autoperiod algorithm (Vlachos et al. 2005) which:
2855/// 1. Computes the periodogram via FFT to find candidate periods
2856/// 2. Validates each candidate using the autocorrelation function
2857/// 3. Applies gradient ascent to refine the period estimate
2858/// 4. Returns the period with the highest combined confidence
2859///
2860/// This method is more robust than pure FFT because ACF validation
2861/// filters out spurious spectral peaks that don't correspond to
2862/// true periodicity.
2863///
2864/// # Arguments
2865/// * `data` - Input signal (1D time series)
2866/// * `argvals` - Time points corresponding to data
2867/// * `n_candidates` - Maximum number of FFT peaks to consider (default: 5)
2868/// * `gradient_steps` - Number of gradient ascent refinement steps (default: 10)
2869///
2870/// # Returns
2871/// * `AutoperiodResult` containing the best period and validation details
2872pub fn autoperiod(
2873    data: &[f64],
2874    argvals: &[f64],
2875    n_candidates: Option<usize>,
2876    gradient_steps: Option<usize>,
2877) -> AutoperiodResult {
2878    let n = data.len();
2879    let max_candidates = n_candidates.unwrap_or(5);
2880    let steps = gradient_steps.unwrap_or(10);
2881
2882    if n < 8 || argvals.len() != n {
2883        return AutoperiodResult {
2884            period: f64::NAN,
2885            confidence: 0.0,
2886            fft_power: 0.0,
2887            acf_validation: 0.0,
2888            candidates: Vec::new(),
2889        };
2890    }
2891
2892    let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
2893    let max_lag = (n / 2).max(4);
2894
2895    // Step 1: Compute periodogram and find candidate periods
2896    let (frequencies, power) = periodogram(data, argvals);
2897
2898    if frequencies.len() < 3 {
2899        return AutoperiodResult {
2900            period: f64::NAN,
2901            confidence: 0.0,
2902            fft_power: 0.0,
2903            acf_validation: 0.0,
2904            candidates: Vec::new(),
2905        };
2906    }
2907
2908    // Find top spectral peaks
2909    let power_no_dc: Vec<f64> = power.iter().skip(1).copied().collect();
2910    let peak_indices = find_spectral_peaks(&power_no_dc);
2911
2912    if peak_indices.is_empty() {
2913        return AutoperiodResult {
2914            period: f64::NAN,
2915            confidence: 0.0,
2916            fft_power: 0.0,
2917            acf_validation: 0.0,
2918            candidates: Vec::new(),
2919        };
2920    }
2921
2922    // Step 2: Compute ACF for validation
2923    let acf = autocorrelation(data, max_lag);
2924
2925    // Step 3: Validate each candidate and refine with gradient ascent
2926    let mut candidates: Vec<AutoperiodCandidate> = Vec::new();
2927    let total_power: f64 = power_no_dc.iter().sum();
2928
2929    for &peak_idx in peak_indices.iter().take(max_candidates) {
2930        let freq = frequencies[peak_idx + 1];
2931        if freq < 1e-15 {
2932            continue;
2933        }
2934
2935        let initial_period = 1.0 / freq;
2936        let fft_power = power_no_dc[peak_idx];
2937        let normalized_power = fft_power / total_power.max(1e-15);
2938
2939        // Refine period using gradient ascent on ACF
2940        let refined_period = refine_period_gradient(&acf, initial_period, dt, steps);
2941
2942        // Revalidate refined period
2943        let refined_acf_score = validate_period_acf(&acf, refined_period, dt);
2944
2945        let combined_score = normalized_power * refined_acf_score;
2946
2947        candidates.push(AutoperiodCandidate {
2948            period: refined_period,
2949            fft_power,
2950            acf_score: refined_acf_score,
2951            combined_score,
2952        });
2953    }
2954
2955    if candidates.is_empty() {
2956        return AutoperiodResult {
2957            period: f64::NAN,
2958            confidence: 0.0,
2959            fft_power: 0.0,
2960            acf_validation: 0.0,
2961            candidates: Vec::new(),
2962        };
2963    }
2964
2965    // Select best candidate based on combined score
2966    let best = candidates
2967        .iter()
2968        .max_by(|a, b| {
2969            a.combined_score
2970                .partial_cmp(&b.combined_score)
2971                .unwrap_or(std::cmp::Ordering::Equal)
2972        })
2973        .unwrap();
2974
2975    AutoperiodResult {
2976        period: best.period,
2977        confidence: best.combined_score,
2978        fft_power: best.fft_power,
2979        acf_validation: best.acf_score,
2980        candidates,
2981    }
2982}
2983
2984/// Autoperiod for functional data (matrix format)
2985pub fn autoperiod_fdata(
2986    data: &[f64],
2987    n: usize,
2988    m: usize,
2989    argvals: &[f64],
2990    n_candidates: Option<usize>,
2991    gradient_steps: Option<usize>,
2992) -> AutoperiodResult {
2993    if n == 0 || m < 8 || argvals.len() != m {
2994        return AutoperiodResult {
2995            period: f64::NAN,
2996            confidence: 0.0,
2997            fft_power: 0.0,
2998            acf_validation: 0.0,
2999            candidates: Vec::new(),
3000        };
3001    }
3002
3003    let mean_curve = compute_mean_curve(data, n, m);
3004    autoperiod(&mean_curve, argvals, n_candidates, gradient_steps)
3005}
3006
3007/// Validate a candidate period using ACF
3008fn validate_period_acf(acf: &[f64], period: f64, dt: f64) -> f64 {
3009    let lag = (period / dt).round() as usize;
3010
3011    if lag == 0 || lag >= acf.len() {
3012        return 0.0;
3013    }
3014
3015    // Score based on ACF value at the period lag
3016    // Positive ACF values indicate valid periodicity
3017    let acf_at_lag = acf[lag];
3018
3019    // Also check harmonics (period/2, period*2) for consistency
3020    let half_lag = lag / 2;
3021    let double_lag = lag * 2;
3022
3023    let mut score = acf_at_lag.max(0.0);
3024
3025    // For a true period, ACF at half-period should be low/negative
3026    // and ACF at double-period should also be high
3027    if half_lag > 0 && half_lag < acf.len() {
3028        let half_acf = acf[half_lag];
3029        // Penalize if half-period has high ACF (suggests half-period is real)
3030        if half_acf > acf_at_lag * 0.7 {
3031            score *= 0.5;
3032        }
3033    }
3034
3035    if double_lag < acf.len() {
3036        let double_acf = acf[double_lag];
3037        // Bonus if double-period also shows periodicity
3038        if double_acf > 0.3 {
3039            score *= 1.2;
3040        }
3041    }
3042
3043    score.min(1.0)
3044}
3045
3046/// Refine period estimate using gradient ascent on ACF
3047fn refine_period_gradient(acf: &[f64], initial_period: f64, dt: f64, steps: usize) -> f64 {
3048    let mut period = initial_period;
3049    let step_size = dt * 0.5; // Search step size
3050
3051    for _ in 0..steps {
3052        let current_score = validate_period_acf(acf, period, dt);
3053        let left_score = validate_period_acf(acf, period - step_size, dt);
3054        let right_score = validate_period_acf(acf, period + step_size, dt);
3055
3056        if left_score > current_score && left_score > right_score {
3057            period -= step_size;
3058        } else if right_score > current_score {
3059            period += step_size;
3060        }
3061        // If current is best, we've converged
3062    }
3063
3064    period.max(dt) // Ensure period is at least one time step
3065}
3066
3067/// Result of CFDAutoperiod detection.
3068#[derive(Debug, Clone)]
3069pub struct CfdAutoperiodResult {
3070    /// Detected period (primary)
3071    pub period: f64,
3072    /// Confidence score
3073    pub confidence: f64,
3074    /// ACF validation score for the primary period
3075    pub acf_validation: f64,
3076    /// All detected periods (cluster centers)
3077    pub periods: Vec<f64>,
3078    /// Confidence for each detected period
3079    pub confidences: Vec<f64>,
3080}
3081
3082/// CFDAutoperiod: Clustered Filtered Detrended Autoperiod
3083///
3084/// Implements the CFDAutoperiod algorithm (Puech et al. 2020) which:
3085/// 1. Applies first-order differencing to remove trends
3086/// 2. Computes FFT on the detrended signal
3087/// 3. Identifies candidate periods from periodogram peaks
3088/// 4. Clusters nearby candidates using density-based clustering
3089/// 5. Validates cluster centers using ACF on the original signal
3090///
3091/// This method is particularly effective for signals with strong trends
3092/// and handles multiple periodicities by detecting clusters of candidate periods.
3093///
3094/// # Arguments
3095/// * `data` - Input signal (1D time series)
3096/// * `argvals` - Time points corresponding to data
3097/// * `cluster_tolerance` - Relative tolerance for clustering periods (default: 0.1 = 10%)
3098/// * `min_cluster_size` - Minimum number of candidates to form a cluster (default: 1)
3099///
3100/// # Returns
3101/// * `CfdAutoperiodResult` containing detected periods and validation scores
3102pub fn cfd_autoperiod(
3103    data: &[f64],
3104    argvals: &[f64],
3105    cluster_tolerance: Option<f64>,
3106    min_cluster_size: Option<usize>,
3107) -> CfdAutoperiodResult {
3108    let n = data.len();
3109    let tol = cluster_tolerance.unwrap_or(0.1);
3110    let min_size = min_cluster_size.unwrap_or(1);
3111
3112    if n < 8 || argvals.len() != n {
3113        return CfdAutoperiodResult {
3114            period: f64::NAN,
3115            confidence: 0.0,
3116            acf_validation: 0.0,
3117            periods: Vec::new(),
3118            confidences: Vec::new(),
3119        };
3120    }
3121
3122    let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
3123    let max_lag = (n / 2).max(4);
3124
3125    // Step 1: Apply first-order differencing to detrend
3126    let diff: Vec<f64> = data.windows(2).map(|w| w[1] - w[0]).collect();
3127    let diff_argvals: Vec<f64> = argvals.windows(2).map(|w| (w[0] + w[1]) / 2.0).collect();
3128
3129    // Step 2: Compute periodogram on detrended signal
3130    let (frequencies, power) = periodogram(&diff, &diff_argvals);
3131
3132    if frequencies.len() < 3 {
3133        return CfdAutoperiodResult {
3134            period: f64::NAN,
3135            confidence: 0.0,
3136            acf_validation: 0.0,
3137            periods: Vec::new(),
3138            confidences: Vec::new(),
3139        };
3140    }
3141
3142    // Step 3: Find all candidate periods from spectral peaks
3143    let power_no_dc: Vec<f64> = power.iter().skip(1).copied().collect();
3144    let peak_indices = find_spectral_peaks(&power_no_dc);
3145
3146    if peak_indices.is_empty() {
3147        return CfdAutoperiodResult {
3148            period: f64::NAN,
3149            confidence: 0.0,
3150            acf_validation: 0.0,
3151            periods: Vec::new(),
3152            confidences: Vec::new(),
3153        };
3154    }
3155
3156    // Convert peak indices to periods with their powers
3157    let total_power: f64 = power_no_dc.iter().sum();
3158    let mut candidates: Vec<(f64, f64)> = Vec::new(); // (period, normalized_power)
3159
3160    for &peak_idx in &peak_indices {
3161        let freq = frequencies[peak_idx + 1];
3162        if freq > 1e-15 {
3163            let period = 1.0 / freq;
3164            let normalized_power = power_no_dc[peak_idx] / total_power.max(1e-15);
3165            candidates.push((period, normalized_power));
3166        }
3167    }
3168
3169    if candidates.is_empty() {
3170        return CfdAutoperiodResult {
3171            period: f64::NAN,
3172            confidence: 0.0,
3173            acf_validation: 0.0,
3174            periods: Vec::new(),
3175            confidences: Vec::new(),
3176        };
3177    }
3178
3179    // Step 4: Cluster candidates using density-based approach
3180    let clusters = cluster_periods(&candidates, tol, min_size);
3181
3182    if clusters.is_empty() {
3183        return CfdAutoperiodResult {
3184            period: f64::NAN,
3185            confidence: 0.0,
3186            acf_validation: 0.0,
3187            periods: Vec::new(),
3188            confidences: Vec::new(),
3189        };
3190    }
3191
3192    // Step 5: Validate cluster centers using ACF on original signal
3193    let acf = autocorrelation(data, max_lag);
3194
3195    let mut validated: Vec<(f64, f64, f64)> = Vec::new(); // (period, acf_score, power)
3196
3197    for (center, power_sum) in clusters {
3198        let acf_score = validate_period_acf(&acf, center, dt);
3199        if acf_score > 0.1 {
3200            // Only keep periods with reasonable ACF validation
3201            validated.push((center, acf_score, power_sum));
3202        }
3203    }
3204
3205    if validated.is_empty() {
3206        // Fall back to best cluster without ACF validation
3207        let (center, power_sum) = cluster_periods(&candidates, tol, min_size)
3208            .into_iter()
3209            .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
3210            .unwrap();
3211        return CfdAutoperiodResult {
3212            period: center,
3213            confidence: power_sum,
3214            acf_validation: 0.0,
3215            periods: vec![center],
3216            confidences: vec![power_sum],
3217        };
3218    }
3219
3220    // Sort by combined score (ACF * power)
3221    validated.sort_by(|a, b| {
3222        let score_a = a.1 * a.2;
3223        let score_b = b.1 * b.2;
3224        score_b
3225            .partial_cmp(&score_a)
3226            .unwrap_or(std::cmp::Ordering::Equal)
3227    });
3228
3229    let periods: Vec<f64> = validated.iter().map(|v| v.0).collect();
3230    let confidences: Vec<f64> = validated.iter().map(|v| v.1 * v.2).collect();
3231
3232    CfdAutoperiodResult {
3233        period: validated[0].0,
3234        confidence: validated[0].1 * validated[0].2,
3235        acf_validation: validated[0].1,
3236        periods,
3237        confidences,
3238    }
3239}
3240
3241/// CFDAutoperiod for functional data (matrix format)
3242pub fn cfd_autoperiod_fdata(
3243    data: &[f64],
3244    n: usize,
3245    m: usize,
3246    argvals: &[f64],
3247    cluster_tolerance: Option<f64>,
3248    min_cluster_size: Option<usize>,
3249) -> CfdAutoperiodResult {
3250    if n == 0 || m < 8 || argvals.len() != m {
3251        return CfdAutoperiodResult {
3252            period: f64::NAN,
3253            confidence: 0.0,
3254            acf_validation: 0.0,
3255            periods: Vec::new(),
3256            confidences: Vec::new(),
3257        };
3258    }
3259
3260    let mean_curve = compute_mean_curve(data, n, m);
3261    cfd_autoperiod(&mean_curve, argvals, cluster_tolerance, min_cluster_size)
3262}
3263
3264/// Cluster periods using a simple density-based approach
3265fn cluster_periods(candidates: &[(f64, f64)], tolerance: f64, min_size: usize) -> Vec<(f64, f64)> {
3266    if candidates.is_empty() {
3267        return Vec::new();
3268    }
3269
3270    // Sort candidates by period
3271    let mut sorted: Vec<(f64, f64)> = candidates.to_vec();
3272    sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
3273
3274    let mut clusters: Vec<(f64, f64)> = Vec::new(); // (center, total_power)
3275    let mut current_cluster: Vec<(f64, f64)> = vec![sorted[0]];
3276
3277    for &(period, power) in sorted.iter().skip(1) {
3278        let cluster_center =
3279            current_cluster.iter().map(|(p, _)| p).sum::<f64>() / current_cluster.len() as f64;
3280
3281        let rel_diff = (period - cluster_center).abs() / cluster_center.max(period);
3282
3283        if rel_diff <= tolerance {
3284            // Add to current cluster
3285            current_cluster.push((period, power));
3286        } else {
3287            // Finish current cluster and start new one
3288            if current_cluster.len() >= min_size {
3289                let center = current_cluster.iter().map(|(p, pw)| p * pw).sum::<f64>()
3290                    / current_cluster
3291                        .iter()
3292                        .map(|(_, pw)| pw)
3293                        .sum::<f64>()
3294                        .max(1e-15);
3295                let total_power: f64 = current_cluster.iter().map(|(_, pw)| pw).sum();
3296                clusters.push((center, total_power));
3297            }
3298            current_cluster = vec![(period, power)];
3299        }
3300    }
3301
3302    // Don't forget the last cluster
3303    if current_cluster.len() >= min_size {
3304        let center = current_cluster.iter().map(|(p, pw)| p * pw).sum::<f64>()
3305            / current_cluster
3306                .iter()
3307                .map(|(_, pw)| pw)
3308                .sum::<f64>()
3309                .max(1e-15);
3310        let total_power: f64 = current_cluster.iter().map(|(_, pw)| pw).sum();
3311        clusters.push((center, total_power));
3312    }
3313
3314    clusters
3315}
3316
3317#[cfg(test)]
3318mod tests {
3319    use super::*;
3320    use std::f64::consts::PI;
3321
3322    fn generate_sine(n: usize, m: usize, period: f64, argvals: &[f64]) -> Vec<f64> {
3323        let mut data = vec![0.0; n * m];
3324        for i in 0..n {
3325            for j in 0..m {
3326                data[i + j * n] = (2.0 * PI * argvals[j] / period).sin();
3327            }
3328        }
3329        data
3330    }
3331
3332    #[test]
3333    fn test_period_estimation_fft() {
3334        let m = 200;
3335        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
3336        let period = 2.0;
3337        let data = generate_sine(1, m, period, &argvals);
3338
3339        let estimate = estimate_period_fft(&data, 1, m, &argvals);
3340        assert!((estimate.period - period).abs() < 0.2);
3341        assert!(estimate.confidence > 1.0);
3342    }
3343
3344    #[test]
3345    fn test_peak_detection() {
3346        let m = 100;
3347        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
3348        let period = 2.0;
3349        let data = generate_sine(1, m, period, &argvals);
3350
3351        let result = detect_peaks(&data, 1, m, &argvals, Some(1.5), None, false, None);
3352
3353        // Should find approximately 5 peaks (10 / 2)
3354        assert!(!result.peaks[0].is_empty());
3355        assert!((result.mean_period - period).abs() < 0.3);
3356    }
3357
3358    #[test]
3359    fn test_peak_detection_known_sine() {
3360        // Pure sine wave: sin(2*pi*t/2) on [0, 10]
3361        // Peaks occur at t = period/4 + k*period = 0.5, 2.5, 4.5, 6.5, 8.5
3362        let m = 200; // High resolution for accurate detection
3363        let period = 2.0;
3364        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
3365        let data: Vec<f64> = argvals
3366            .iter()
3367            .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
3368            .collect();
3369
3370        let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
3371
3372        // Should find exactly 5 peaks
3373        assert_eq!(
3374            result.peaks[0].len(),
3375            5,
3376            "Expected 5 peaks, got {}. Peak times: {:?}",
3377            result.peaks[0].len(),
3378            result.peaks[0].iter().map(|p| p.time).collect::<Vec<_>>()
3379        );
3380
3381        // Check peak locations are close to expected
3382        let expected_times = [0.5, 2.5, 4.5, 6.5, 8.5];
3383        for (peak, expected) in result.peaks[0].iter().zip(expected_times.iter()) {
3384            assert!(
3385                (peak.time - expected).abs() < 0.15,
3386                "Peak at {:.3} not close to expected {:.3}",
3387                peak.time,
3388                expected
3389            );
3390        }
3391
3392        // Check mean period
3393        assert!(
3394            (result.mean_period - period).abs() < 0.1,
3395            "Mean period {:.3} not close to expected {:.3}",
3396            result.mean_period,
3397            period
3398        );
3399    }
3400
3401    #[test]
3402    fn test_peak_detection_with_min_distance() {
3403        // Same sine wave but with min_distance constraint
3404        let m = 200;
3405        let period = 2.0;
3406        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
3407        let data: Vec<f64> = argvals
3408            .iter()
3409            .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
3410            .collect();
3411
3412        // min_distance = 1.5 should still find all 5 peaks (spacing = 2.0)
3413        let result = detect_peaks(&data, 1, m, &argvals, Some(1.5), None, false, None);
3414        assert_eq!(
3415            result.peaks[0].len(),
3416            5,
3417            "With min_distance=1.5, expected 5 peaks, got {}",
3418            result.peaks[0].len()
3419        );
3420
3421        // min_distance = 2.5 should find fewer peaks
3422        let result2 = detect_peaks(&data, 1, m, &argvals, Some(2.5), None, false, None);
3423        assert!(
3424            result2.peaks[0].len() < 5,
3425            "With min_distance=2.5, expected fewer than 5 peaks, got {}",
3426            result2.peaks[0].len()
3427        );
3428    }
3429
3430    #[test]
3431    fn test_peak_detection_period_1() {
3432        // Higher frequency: sin(2*pi*t/1) on [0, 10]
3433        // Peaks at t = 0.25, 1.25, 2.25, ..., 9.25 (10 peaks)
3434        let m = 400; // Higher resolution for higher frequency
3435        let period = 1.0;
3436        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
3437        let data: Vec<f64> = argvals
3438            .iter()
3439            .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
3440            .collect();
3441
3442        let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
3443
3444        // Should find 10 peaks
3445        assert_eq!(
3446            result.peaks[0].len(),
3447            10,
3448            "Expected 10 peaks, got {}",
3449            result.peaks[0].len()
3450        );
3451
3452        // Check mean period
3453        assert!(
3454            (result.mean_period - period).abs() < 0.1,
3455            "Mean period {:.3} not close to expected {:.3}",
3456            result.mean_period,
3457            period
3458        );
3459    }
3460
3461    #[test]
3462    fn test_peak_detection_shifted_sine() {
3463        // Shifted sine: sin(2*pi*t/2) + 1 on [0, 10]
3464        // Same peak locations, just shifted up
3465        let m = 200;
3466        let period = 2.0;
3467        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
3468        let data: Vec<f64> = argvals
3469            .iter()
3470            .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin() + 1.0)
3471            .collect();
3472
3473        let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
3474
3475        // Should still find 5 peaks
3476        assert_eq!(
3477            result.peaks[0].len(),
3478            5,
3479            "Expected 5 peaks for shifted sine, got {}",
3480            result.peaks[0].len()
3481        );
3482
3483        // Peak values should be around 2.0 (max of sin + 1)
3484        for peak in &result.peaks[0] {
3485            assert!(
3486                (peak.value - 2.0).abs() < 0.05,
3487                "Peak value {:.3} not close to expected 2.0",
3488                peak.value
3489            );
3490        }
3491    }
3492
3493    #[test]
3494    fn test_peak_detection_prominence() {
3495        // Create signal with peaks of different heights
3496        // Large peaks at odd positions, small peaks at even positions
3497        let m = 200;
3498        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
3499        let data: Vec<f64> = argvals
3500            .iter()
3501            .map(|&t| {
3502                let base = (2.0 * std::f64::consts::PI * t / 2.0).sin();
3503                // Add small ripples
3504                let ripple = 0.1 * (2.0 * std::f64::consts::PI * t * 4.0).sin();
3505                base + ripple
3506            })
3507            .collect();
3508
3509        // Without prominence filter, may find extra peaks from ripples
3510        let result_no_filter = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
3511
3512        // With prominence filter, should only find major peaks
3513        let result_filtered = detect_peaks(&data, 1, m, &argvals, None, Some(0.5), false, None);
3514
3515        // Filtered should have fewer or equal peaks
3516        assert!(
3517            result_filtered.peaks[0].len() <= result_no_filter.peaks[0].len(),
3518            "Prominence filter should reduce peak count"
3519        );
3520    }
3521
3522    #[test]
3523    fn test_peak_detection_different_amplitudes() {
3524        // Test with various amplitudes: 0.5, 1.0, 2.0, 5.0
3525        let m = 200;
3526        let period = 2.0;
3527        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
3528
3529        for amplitude in [0.5, 1.0, 2.0, 5.0] {
3530            let data: Vec<f64> = argvals
3531                .iter()
3532                .map(|&t| amplitude * (2.0 * std::f64::consts::PI * t / period).sin())
3533                .collect();
3534
3535            let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
3536
3537            assert_eq!(
3538                result.peaks[0].len(),
3539                5,
3540                "Amplitude {} should still find 5 peaks",
3541                amplitude
3542            );
3543
3544            // Peak values should be close to amplitude
3545            for peak in &result.peaks[0] {
3546                assert!(
3547                    (peak.value - amplitude).abs() < 0.1,
3548                    "Peak value {:.3} should be close to amplitude {}",
3549                    peak.value,
3550                    amplitude
3551                );
3552            }
3553        }
3554    }
3555
3556    #[test]
3557    fn test_peak_detection_varying_frequency() {
3558        // Signal with varying frequency: chirp-like signal
3559        // Peaks get closer together over time
3560        let m = 400;
3561        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
3562
3563        // Frequency increases linearly: f(t) = 0.5 + 0.1*t
3564        // Phase integral: phi(t) = 2*pi * (0.5*t + 0.05*t^2)
3565        let data: Vec<f64> = argvals
3566            .iter()
3567            .map(|&t| {
3568                let phase = 2.0 * std::f64::consts::PI * (0.5 * t + 0.05 * t * t);
3569                phase.sin()
3570            })
3571            .collect();
3572
3573        let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
3574
3575        // Should find multiple peaks with decreasing spacing
3576        assert!(
3577            result.peaks[0].len() >= 5,
3578            "Should find at least 5 peaks, got {}",
3579            result.peaks[0].len()
3580        );
3581
3582        // Verify inter-peak distances decrease over time
3583        let distances = &result.inter_peak_distances[0];
3584        if distances.len() >= 3 {
3585            // Later peaks should be closer than earlier peaks
3586            let early_avg = (distances[0] + distances[1]) / 2.0;
3587            let late_avg = (distances[distances.len() - 2] + distances[distances.len() - 1]) / 2.0;
3588            assert!(
3589                late_avg < early_avg,
3590                "Later peaks should be closer: early avg={:.3}, late avg={:.3}",
3591                early_avg,
3592                late_avg
3593            );
3594        }
3595    }
3596
3597    #[test]
3598    fn test_peak_detection_sum_of_sines() {
3599        // Sum of two sine waves with different periods creates non-uniform peak spacing
3600        // y = sin(2*pi*t/2) + 0.5*sin(2*pi*t/3)
3601        let m = 300;
3602        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 12.0 / (m - 1) as f64).collect();
3603
3604        let data: Vec<f64> = argvals
3605            .iter()
3606            .map(|&t| {
3607                (2.0 * std::f64::consts::PI * t / 2.0).sin()
3608                    + 0.5 * (2.0 * std::f64::consts::PI * t / 3.0).sin()
3609            })
3610            .collect();
3611
3612        let result = detect_peaks(&data, 1, m, &argvals, Some(1.0), None, false, None);
3613
3614        // Should find peaks (exact count depends on interference pattern)
3615        assert!(
3616            result.peaks[0].len() >= 4,
3617            "Should find at least 4 peaks, got {}",
3618            result.peaks[0].len()
3619        );
3620
3621        // Inter-peak distances should vary
3622        let distances = &result.inter_peak_distances[0];
3623        if distances.len() >= 2 {
3624            let min_dist = distances.iter().cloned().fold(f64::INFINITY, f64::min);
3625            let max_dist = distances.iter().cloned().fold(0.0, f64::max);
3626            assert!(
3627                max_dist > min_dist * 1.1,
3628                "Distances should vary: min={:.3}, max={:.3}",
3629                min_dist,
3630                max_dist
3631            );
3632        }
3633    }
3634
3635    #[test]
3636    fn test_seasonal_strength() {
3637        let m = 200;
3638        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
3639        let period = 2.0;
3640        let data = generate_sine(1, m, period, &argvals);
3641
3642        let strength = seasonal_strength_variance(&data, 1, m, &argvals, period, 3);
3643        // Pure sine should have high seasonal strength
3644        assert!(strength > 0.8);
3645
3646        let strength_spectral = seasonal_strength_spectral(&data, 1, m, &argvals, period);
3647        assert!(strength_spectral > 0.5);
3648    }
3649
3650    #[test]
3651    fn test_instantaneous_period() {
3652        let m = 200;
3653        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
3654        let period = 2.0;
3655        let data = generate_sine(1, m, period, &argvals);
3656
3657        let result = instantaneous_period(&data, 1, m, &argvals);
3658
3659        // Check that instantaneous period is close to true period (away from boundaries)
3660        let mid_period = result.period[m / 2];
3661        assert!(
3662            (mid_period - period).abs() < 0.5,
3663            "Expected period ~{}, got {}",
3664            period,
3665            mid_period
3666        );
3667    }
3668
3669    #[test]
3670    fn test_peak_timing_analysis() {
3671        // Generate 5 cycles of sine with period 2
3672        let m = 500;
3673        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.02).collect();
3674        let period = 2.0;
3675        let data = generate_sine(1, m, period, &argvals);
3676
3677        let result = analyze_peak_timing(&data, 1, m, &argvals, period, Some(11));
3678
3679        // Should find approximately 5 peaks
3680        assert!(!result.peak_times.is_empty());
3681        // Normalized timing should be around 0.25 (peak of sin at π/2)
3682        assert!(result.mean_timing.is_finite());
3683        // Pure sine should have low timing variability
3684        assert!(result.std_timing < 0.1 || result.std_timing.is_nan());
3685    }
3686
3687    #[test]
3688    fn test_seasonality_classification() {
3689        let m = 400;
3690        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
3691        let period = 2.0;
3692        let data = generate_sine(1, m, period, &argvals);
3693
3694        let result = classify_seasonality(&data, 1, m, &argvals, period, None, None);
3695
3696        assert!(result.is_seasonal);
3697        assert!(result.seasonal_strength > 0.5);
3698        assert!(matches!(
3699            result.classification,
3700            SeasonalType::StableSeasonal | SeasonalType::VariableTiming
3701        ));
3702    }
3703
3704    #[test]
3705    fn test_otsu_threshold() {
3706        // Bimodal distribution: mix of low (0.1-0.2) and high (0.7-0.9) values
3707        let values = vec![
3708            0.1, 0.12, 0.15, 0.18, 0.11, 0.14, 0.7, 0.75, 0.8, 0.85, 0.9, 0.72,
3709        ];
3710
3711        let threshold = otsu_threshold(&values);
3712
3713        // Threshold should be between the two modes
3714        // Due to small sample size, Otsu's method may not find optimal threshold
3715        // Just verify it returns a reasonable value in the data range
3716        assert!(threshold >= 0.1, "Threshold {} should be >= 0.1", threshold);
3717        assert!(threshold <= 0.9, "Threshold {} should be <= 0.9", threshold);
3718    }
3719
3720    #[test]
3721    fn test_gcv_fourier_nbasis_selection() {
3722        let m = 100;
3723        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
3724
3725        // Noisy sine wave
3726        let mut data = vec![0.0; m];
3727        for j in 0..m {
3728            data[j] = (2.0 * PI * argvals[j] / 2.0).sin() + 0.1 * (j as f64 * 0.3).sin();
3729        }
3730
3731        let nbasis = crate::basis::select_fourier_nbasis_gcv(&data, 1, m, &argvals, 5, 25);
3732
3733        // nbasis should be reasonable (between min and max)
3734        assert!(nbasis >= 5);
3735        assert!(nbasis <= 25);
3736    }
3737
3738    #[test]
3739    fn test_detect_multiple_periods() {
3740        let m = 400;
3741        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect(); // 0 to 20
3742
3743        // Signal with two periods: 2 and 7
3744        let period1 = 2.0;
3745        let period2 = 7.0;
3746        let mut data = vec![0.0; m];
3747        for j in 0..m {
3748            data[j] = (2.0 * PI * argvals[j] / period1).sin()
3749                + 0.6 * (2.0 * PI * argvals[j] / period2).sin();
3750        }
3751
3752        // Use higher min_strength threshold to properly stop after real periods
3753        let detected = detect_multiple_periods(&data, 1, m, &argvals, 5, 0.4, 0.20);
3754
3755        // Should detect exactly 2 periods with these thresholds
3756        assert!(
3757            detected.len() >= 2,
3758            "Expected at least 2 periods, found {}",
3759            detected.len()
3760        );
3761
3762        // Check that both periods were detected (order depends on amplitude)
3763        let periods: Vec<f64> = detected.iter().map(|d| d.period).collect();
3764        let has_period1 = periods.iter().any(|&p| (p - period1).abs() < 0.3);
3765        let has_period2 = periods.iter().any(|&p| (p - period2).abs() < 0.5);
3766
3767        assert!(
3768            has_period1,
3769            "Expected to find period ~{}, got {:?}",
3770            period1, periods
3771        );
3772        assert!(
3773            has_period2,
3774            "Expected to find period ~{}, got {:?}",
3775            period2, periods
3776        );
3777
3778        // Verify first detected has higher amplitude (amplitude 1.0 vs 0.6)
3779        assert!(
3780            detected[0].amplitude > detected[1].amplitude,
3781            "First detected should have higher amplitude"
3782        );
3783
3784        // Each detected period should have strength and confidence info
3785        for d in &detected {
3786            assert!(
3787                d.strength > 0.0,
3788                "Detected period should have positive strength"
3789            );
3790            assert!(
3791                d.confidence > 0.0,
3792                "Detected period should have positive confidence"
3793            );
3794            assert!(
3795                d.amplitude > 0.0,
3796                "Detected period should have positive amplitude"
3797            );
3798        }
3799    }
3800
3801    // ========================================================================
3802    // Amplitude Modulation Detection Tests
3803    // ========================================================================
3804
3805    #[test]
3806    fn test_amplitude_modulation_stable() {
3807        // Constant amplitude seasonal signal - should detect as Stable
3808        let m = 200;
3809        let period = 0.2;
3810        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
3811
3812        // Constant amplitude sine wave
3813        let data: Vec<f64> = argvals
3814            .iter()
3815            .map(|&t| (2.0 * PI * t / period).sin())
3816            .collect();
3817
3818        let result = detect_amplitude_modulation(
3819            &data, 1, m, &argvals, period, 0.15, // modulation threshold
3820            0.3,  // seasonality threshold
3821        );
3822
3823        eprintln!(
3824            "Stable test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
3825            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
3826        );
3827
3828        assert!(result.is_seasonal, "Should detect seasonality");
3829        assert!(
3830            !result.has_modulation,
3831            "Constant amplitude should not have modulation, got score={:.4}",
3832            result.modulation_score
3833        );
3834        assert_eq!(
3835            result.modulation_type,
3836            ModulationType::Stable,
3837            "Should be classified as Stable"
3838        );
3839    }
3840
3841    #[test]
3842    fn test_amplitude_modulation_emerging() {
3843        // Amplitude increases over time (emerging seasonality)
3844        let m = 200;
3845        let period = 0.2;
3846        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
3847
3848        // Amplitude grows from 0.2 to 1.0
3849        let data: Vec<f64> = argvals
3850            .iter()
3851            .map(|&t| {
3852                let amplitude = 0.2 + 0.8 * t; // Linear increase
3853                amplitude * (2.0 * PI * t / period).sin()
3854            })
3855            .collect();
3856
3857        let result = detect_amplitude_modulation(&data, 1, m, &argvals, period, 0.15, 0.2);
3858
3859        eprintln!(
3860            "Emerging test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
3861            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
3862        );
3863
3864        assert!(result.is_seasonal, "Should detect seasonality");
3865        assert!(
3866            result.has_modulation,
3867            "Growing amplitude should have modulation, score={:.4}",
3868            result.modulation_score
3869        );
3870        assert_eq!(
3871            result.modulation_type,
3872            ModulationType::Emerging,
3873            "Should be classified as Emerging, trend={:.4}",
3874            result.amplitude_trend
3875        );
3876        assert!(
3877            result.amplitude_trend > 0.0,
3878            "Trend should be positive for emerging"
3879        );
3880    }
3881
3882    #[test]
3883    fn test_amplitude_modulation_fading() {
3884        // Amplitude decreases over time (fading seasonality)
3885        let m = 200;
3886        let period = 0.2;
3887        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
3888
3889        // Amplitude decreases from 1.0 to 0.2
3890        let data: Vec<f64> = argvals
3891            .iter()
3892            .map(|&t| {
3893                let amplitude = 1.0 - 0.8 * t; // Linear decrease
3894                amplitude * (2.0 * PI * t / period).sin()
3895            })
3896            .collect();
3897
3898        let result = detect_amplitude_modulation(&data, 1, m, &argvals, period, 0.15, 0.2);
3899
3900        eprintln!(
3901            "Fading test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
3902            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
3903        );
3904
3905        assert!(result.is_seasonal, "Should detect seasonality");
3906        assert!(
3907            result.has_modulation,
3908            "Fading amplitude should have modulation"
3909        );
3910        assert_eq!(
3911            result.modulation_type,
3912            ModulationType::Fading,
3913            "Should be classified as Fading, trend={:.4}",
3914            result.amplitude_trend
3915        );
3916        assert!(
3917            result.amplitude_trend < 0.0,
3918            "Trend should be negative for fading"
3919        );
3920    }
3921
3922    #[test]
3923    fn test_amplitude_modulation_oscillating() {
3924        // Amplitude oscillates (neither purely emerging nor fading)
3925        let m = 200;
3926        let period = 0.1;
3927        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
3928
3929        // Amplitude oscillates: high-low-high-low pattern
3930        let data: Vec<f64> = argvals
3931            .iter()
3932            .map(|&t| {
3933                let amplitude = 0.5 + 0.4 * (2.0 * PI * t * 2.0).sin(); // 2 modulation cycles
3934                amplitude * (2.0 * PI * t / period).sin()
3935            })
3936            .collect();
3937
3938        let result = detect_amplitude_modulation(&data, 1, m, &argvals, period, 0.15, 0.2);
3939
3940        eprintln!(
3941            "Oscillating test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
3942            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
3943        );
3944
3945        assert!(result.is_seasonal, "Should detect seasonality");
3946        // Oscillating has high variation but near-zero trend
3947        if result.has_modulation {
3948            // Trend should be near zero for oscillating
3949            assert!(
3950                result.amplitude_trend.abs() < 0.5,
3951                "Trend should be small for oscillating"
3952            );
3953        }
3954    }
3955
3956    #[test]
3957    fn test_amplitude_modulation_non_seasonal() {
3958        // Pure noise - no seasonality
3959        let m = 100;
3960        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
3961
3962        // Random noise (use simple pseudo-random)
3963        let data: Vec<f64> = (0..m)
3964            .map(|i| ((i as f64 * 1.618).sin() * 100.0).fract())
3965            .collect();
3966
3967        let result = detect_amplitude_modulation(
3968            &data, 1, m, &argvals, 0.2, // arbitrary period
3969            0.15, 0.3,
3970        );
3971
3972        assert!(
3973            !result.is_seasonal,
3974            "Noise should not be detected as seasonal"
3975        );
3976        assert_eq!(
3977            result.modulation_type,
3978            ModulationType::NonSeasonal,
3979            "Should be classified as NonSeasonal"
3980        );
3981    }
3982
3983    // ========================================================================
3984    // Wavelet-based Amplitude Modulation Detection Tests
3985    // ========================================================================
3986
3987    #[test]
3988    fn test_wavelet_amplitude_modulation_stable() {
3989        let m = 200;
3990        let period = 0.2;
3991        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
3992
3993        let data: Vec<f64> = argvals
3994            .iter()
3995            .map(|&t| (2.0 * PI * t / period).sin())
3996            .collect();
3997
3998        let result = detect_amplitude_modulation_wavelet(&data, 1, m, &argvals, period, 0.15, 0.3);
3999
4000        eprintln!(
4001            "Wavelet stable: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
4002            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
4003        );
4004
4005        assert!(result.is_seasonal, "Should detect seasonality");
4006        assert!(
4007            !result.has_modulation,
4008            "Constant amplitude should not have modulation, got score={:.4}",
4009            result.modulation_score
4010        );
4011    }
4012
4013    #[test]
4014    fn test_wavelet_amplitude_modulation_emerging() {
4015        let m = 200;
4016        let period = 0.2;
4017        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
4018
4019        // Amplitude grows from 0.2 to 1.0
4020        let data: Vec<f64> = argvals
4021            .iter()
4022            .map(|&t| {
4023                let amplitude = 0.2 + 0.8 * t;
4024                amplitude * (2.0 * PI * t / period).sin()
4025            })
4026            .collect();
4027
4028        let result = detect_amplitude_modulation_wavelet(&data, 1, m, &argvals, period, 0.15, 0.2);
4029
4030        eprintln!(
4031            "Wavelet emerging: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
4032            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
4033        );
4034
4035        assert!(result.is_seasonal, "Should detect seasonality");
4036        assert!(
4037            result.has_modulation,
4038            "Growing amplitude should have modulation"
4039        );
4040        assert!(
4041            result.amplitude_trend > 0.0,
4042            "Trend should be positive for emerging"
4043        );
4044    }
4045
4046    #[test]
4047    fn test_wavelet_amplitude_modulation_fading() {
4048        let m = 200;
4049        let period = 0.2;
4050        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
4051
4052        // Amplitude decreases from 1.0 to 0.2
4053        let data: Vec<f64> = argvals
4054            .iter()
4055            .map(|&t| {
4056                let amplitude = 1.0 - 0.8 * t;
4057                amplitude * (2.0 * PI * t / period).sin()
4058            })
4059            .collect();
4060
4061        let result = detect_amplitude_modulation_wavelet(&data, 1, m, &argvals, period, 0.15, 0.2);
4062
4063        eprintln!(
4064            "Wavelet fading: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
4065            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
4066        );
4067
4068        assert!(result.is_seasonal, "Should detect seasonality");
4069        assert!(
4070            result.has_modulation,
4071            "Fading amplitude should have modulation"
4072        );
4073        assert!(
4074            result.amplitude_trend < 0.0,
4075            "Trend should be negative for fading"
4076        );
4077    }
4078
4079    #[test]
4080    fn test_seasonal_strength_wavelet() {
4081        let m = 200;
4082        let period = 0.2;
4083        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
4084
4085        // Pure sine wave at target period - should have high strength
4086        let seasonal_data: Vec<f64> = argvals
4087            .iter()
4088            .map(|&t| (2.0 * PI * t / period).sin())
4089            .collect();
4090
4091        let strength = seasonal_strength_wavelet(&seasonal_data, 1, m, &argvals, period);
4092        eprintln!("Wavelet strength (pure sine): {:.4}", strength);
4093        assert!(
4094            strength > 0.5,
4095            "Pure sine should have high wavelet strength"
4096        );
4097
4098        // Pure noise - should have low strength
4099        let noise_data: Vec<f64> = (0..m)
4100            .map(|i| ((i * 12345 + 67890) % 1000) as f64 / 1000.0 - 0.5)
4101            .collect();
4102
4103        let noise_strength = seasonal_strength_wavelet(&noise_data, 1, m, &argvals, period);
4104        eprintln!("Wavelet strength (noise): {:.4}", noise_strength);
4105        assert!(
4106            noise_strength < 0.3,
4107            "Noise should have low wavelet strength"
4108        );
4109
4110        // Wrong period - should have lower strength
4111        let wrong_period_strength =
4112            seasonal_strength_wavelet(&seasonal_data, 1, m, &argvals, period * 2.0);
4113        eprintln!(
4114            "Wavelet strength (wrong period): {:.4}",
4115            wrong_period_strength
4116        );
4117        assert!(
4118            wrong_period_strength < strength,
4119            "Wrong period should have lower strength"
4120        );
4121    }
4122
4123    #[test]
4124    fn test_compute_mean_curve() {
4125        // 2 samples, 3 time points
4126        // Sample 1: [1, 2, 3]
4127        // Sample 2: [2, 4, 6]
4128        // Mean: [1.5, 3, 4.5]
4129        let data = vec![1.0, 2.0, 2.0, 4.0, 3.0, 6.0]; // column-major
4130        let mean = compute_mean_curve(&data, 2, 3);
4131        assert_eq!(mean.len(), 3);
4132        assert!((mean[0] - 1.5).abs() < 1e-10);
4133        assert!((mean[1] - 3.0).abs() < 1e-10);
4134        assert!((mean[2] - 4.5).abs() < 1e-10);
4135    }
4136
4137    #[test]
4138    fn test_compute_mean_curve_parallel_consistency() {
4139        // Test that parallel and sequential give same results
4140        let n = 10;
4141        let m = 200;
4142        let data: Vec<f64> = (0..n * m).map(|i| (i as f64 * 0.1).sin()).collect();
4143
4144        let seq_result = compute_mean_curve_impl(&data, n, m, false);
4145        let par_result = compute_mean_curve_impl(&data, n, m, true);
4146
4147        assert_eq!(seq_result.len(), par_result.len());
4148        for (s, p) in seq_result.iter().zip(par_result.iter()) {
4149            assert!(
4150                (s - p).abs() < 1e-10,
4151                "Sequential and parallel results differ"
4152            );
4153        }
4154    }
4155
4156    #[test]
4157    fn test_interior_bounds() {
4158        // m = 100: edge_skip = 10, interior = [10, 90)
4159        let bounds = interior_bounds(100);
4160        assert!(bounds.is_some());
4161        let (start, end) = bounds.unwrap();
4162        assert_eq!(start, 10);
4163        assert_eq!(end, 90);
4164
4165        // m = 10: edge_skip = 1, but min(1, 2) = 1, max(9, 7) = 9
4166        let bounds = interior_bounds(10);
4167        assert!(bounds.is_some());
4168        let (start, end) = bounds.unwrap();
4169        assert!(start < end);
4170
4171        // Very small m might not have valid interior
4172        let bounds = interior_bounds(2);
4173        // Should still return something as long as end > start
4174        assert!(bounds.is_some() || bounds.is_none());
4175    }
4176
4177    #[test]
4178    fn test_hilbert_transform_pure_sine() {
4179        // Hilbert transform of sin(t) should give cos(t) in imaginary part
4180        let m = 200;
4181        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4182        let signal: Vec<f64> = argvals.iter().map(|&t| (2.0 * PI * t).sin()).collect();
4183
4184        let analytic = hilbert_transform(&signal);
4185        assert_eq!(analytic.len(), m);
4186
4187        // Check amplitude is approximately 1
4188        for c in analytic.iter().skip(10).take(m - 20) {
4189            let amp = c.norm();
4190            assert!(
4191                (amp - 1.0).abs() < 0.1,
4192                "Amplitude should be ~1, got {}",
4193                amp
4194            );
4195        }
4196    }
4197
4198    #[test]
4199    fn test_sazed_pure_sine() {
4200        // Pure sine wave with known period
4201        let m = 200;
4202        let period = 2.0;
4203        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4204        let data: Vec<f64> = argvals
4205            .iter()
4206            .map(|&t| (2.0 * PI * t / period).sin())
4207            .collect();
4208
4209        let result = sazed(&data, &argvals, None);
4210
4211        assert!(result.period.is_finite(), "SAZED should detect a period");
4212        assert!(
4213            (result.period - period).abs() < 0.3,
4214            "Expected period ~{}, got {}",
4215            period,
4216            result.period
4217        );
4218        assert!(
4219            result.confidence > 0.4,
4220            "Expected confidence > 0.4, got {}",
4221            result.confidence
4222        );
4223        assert!(
4224            result.agreeing_components >= 2,
4225            "Expected at least 2 agreeing components, got {}",
4226            result.agreeing_components
4227        );
4228    }
4229
4230    #[test]
4231    fn test_sazed_noisy_sine() {
4232        // Sine wave with noise
4233        let m = 300;
4234        let period = 3.0;
4235        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4236
4237        // Deterministic pseudo-noise using sin with different frequency
4238        let data: Vec<f64> = argvals
4239            .iter()
4240            .enumerate()
4241            .map(|(i, &t)| {
4242                let signal = (2.0 * PI * t / period).sin();
4243                let noise = 0.1 * (17.3 * i as f64).sin();
4244                signal + noise
4245            })
4246            .collect();
4247
4248        let result = sazed(&data, &argvals, Some(0.15));
4249
4250        assert!(
4251            result.period.is_finite(),
4252            "SAZED should detect a period even with noise"
4253        );
4254        assert!(
4255            (result.period - period).abs() < 0.5,
4256            "Expected period ~{}, got {}",
4257            period,
4258            result.period
4259        );
4260    }
4261
4262    #[test]
4263    fn test_sazed_fdata() {
4264        // Multiple samples with same period
4265        let n = 5;
4266        let m = 200;
4267        let period = 2.0;
4268        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4269        let data = generate_sine(n, m, period, &argvals);
4270
4271        let result = sazed_fdata(&data, n, m, &argvals, None);
4272
4273        assert!(result.period.is_finite(), "SAZED should detect a period");
4274        assert!(
4275            (result.period - period).abs() < 0.3,
4276            "Expected period ~{}, got {}",
4277            period,
4278            result.period
4279        );
4280    }
4281
4282    #[test]
4283    fn test_sazed_short_series() {
4284        // Very short series - should return NaN gracefully
4285        let argvals: Vec<f64> = (0..5).map(|i| i as f64).collect();
4286        let data: Vec<f64> = argvals.iter().map(|&t| t.sin()).collect();
4287
4288        let result = sazed(&data, &argvals, None);
4289
4290        // Should handle gracefully (return NaN for too-short series)
4291        assert!(
4292            result.period.is_nan() || result.period.is_finite(),
4293            "Should return NaN or valid period"
4294        );
4295    }
4296
4297    #[test]
4298    fn test_autoperiod_pure_sine() {
4299        // Pure sine wave with known period
4300        let m = 200;
4301        let period = 2.0;
4302        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4303        let data: Vec<f64> = argvals
4304            .iter()
4305            .map(|&t| (2.0 * PI * t / period).sin())
4306            .collect();
4307
4308        let result = autoperiod(&data, &argvals, None, None);
4309
4310        assert!(
4311            result.period.is_finite(),
4312            "Autoperiod should detect a period"
4313        );
4314        assert!(
4315            (result.period - period).abs() < 0.3,
4316            "Expected period ~{}, got {}",
4317            period,
4318            result.period
4319        );
4320        assert!(
4321            result.confidence > 0.0,
4322            "Expected positive confidence, got {}",
4323            result.confidence
4324        );
4325    }
4326
4327    #[test]
4328    fn test_autoperiod_with_trend() {
4329        // Sine wave with linear trend
4330        let m = 300;
4331        let period = 3.0;
4332        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4333        let data: Vec<f64> = argvals
4334            .iter()
4335            .map(|&t| 0.2 * t + (2.0 * PI * t / period).sin())
4336            .collect();
4337
4338        let result = autoperiod(&data, &argvals, None, None);
4339
4340        assert!(
4341            result.period.is_finite(),
4342            "Autoperiod should detect a period"
4343        );
4344        // Allow more tolerance with trend
4345        assert!(
4346            (result.period - period).abs() < 0.5,
4347            "Expected period ~{}, got {}",
4348            period,
4349            result.period
4350        );
4351    }
4352
4353    #[test]
4354    fn test_autoperiod_candidates() {
4355        // Verify candidates are generated
4356        let m = 200;
4357        let period = 2.0;
4358        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4359        let data: Vec<f64> = argvals
4360            .iter()
4361            .map(|&t| (2.0 * PI * t / period).sin())
4362            .collect();
4363
4364        let result = autoperiod(&data, &argvals, Some(5), Some(10));
4365
4366        assert!(
4367            !result.candidates.is_empty(),
4368            "Should have at least one candidate"
4369        );
4370
4371        // Best candidate should have highest combined score
4372        let max_score = result
4373            .candidates
4374            .iter()
4375            .map(|c| c.combined_score)
4376            .fold(f64::NEG_INFINITY, f64::max);
4377        assert!(
4378            (result.confidence - max_score).abs() < 1e-10,
4379            "Returned confidence should match best candidate's score"
4380        );
4381    }
4382
4383    #[test]
4384    fn test_autoperiod_fdata() {
4385        // Multiple samples with same period
4386        let n = 5;
4387        let m = 200;
4388        let period = 2.0;
4389        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4390        let data = generate_sine(n, m, period, &argvals);
4391
4392        let result = autoperiod_fdata(&data, n, m, &argvals, None, None);
4393
4394        assert!(
4395            result.period.is_finite(),
4396            "Autoperiod should detect a period"
4397        );
4398        assert!(
4399            (result.period - period).abs() < 0.3,
4400            "Expected period ~{}, got {}",
4401            period,
4402            result.period
4403        );
4404    }
4405
4406    #[test]
4407    fn test_cfd_autoperiod_pure_sine() {
4408        // Pure sine wave with known period
4409        let m = 200;
4410        let period = 2.0;
4411        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4412        let data: Vec<f64> = argvals
4413            .iter()
4414            .map(|&t| (2.0 * PI * t / period).sin())
4415            .collect();
4416
4417        let result = cfd_autoperiod(&data, &argvals, None, None);
4418
4419        assert!(
4420            result.period.is_finite(),
4421            "CFDAutoperiod should detect a period"
4422        );
4423        assert!(
4424            (result.period - period).abs() < 0.3,
4425            "Expected period ~{}, got {}",
4426            period,
4427            result.period
4428        );
4429    }
4430
4431    #[test]
4432    fn test_cfd_autoperiod_with_trend() {
4433        // Sine wave with strong linear trend - CFD excels here
4434        let m = 300;
4435        let period = 3.0;
4436        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4437        let data: Vec<f64> = argvals
4438            .iter()
4439            .map(|&t| 2.0 * t + (2.0 * PI * t / period).sin())
4440            .collect();
4441
4442        let result = cfd_autoperiod(&data, &argvals, None, None);
4443
4444        assert!(
4445            result.period.is_finite(),
4446            "CFDAutoperiod should detect a period despite trend"
4447        );
4448        // Allow more tolerance since trend can affect detection
4449        assert!(
4450            (result.period - period).abs() < 0.6,
4451            "Expected period ~{}, got {}",
4452            period,
4453            result.period
4454        );
4455    }
4456
4457    #[test]
4458    fn test_cfd_autoperiod_multiple_periods() {
4459        // Signal with two periods - should detect multiple
4460        let m = 400;
4461        let period1 = 2.0;
4462        let period2 = 5.0;
4463        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4464        let data: Vec<f64> = argvals
4465            .iter()
4466            .map(|&t| (2.0 * PI * t / period1).sin() + 0.5 * (2.0 * PI * t / period2).sin())
4467            .collect();
4468
4469        let result = cfd_autoperiod(&data, &argvals, None, None);
4470
4471        assert!(
4472            !result.periods.is_empty(),
4473            "Should detect at least one period"
4474        );
4475        // The primary period should be one of the two
4476        let close_to_p1 = (result.period - period1).abs() < 0.5;
4477        let close_to_p2 = (result.period - period2).abs() < 1.0;
4478        assert!(
4479            close_to_p1 || close_to_p2,
4480            "Primary period {} not close to {} or {}",
4481            result.period,
4482            period1,
4483            period2
4484        );
4485    }
4486
4487    #[test]
4488    fn test_cfd_autoperiod_fdata() {
4489        // Multiple samples with same period
4490        let n = 5;
4491        let m = 200;
4492        let period = 2.0;
4493        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
4494        let data = generate_sine(n, m, period, &argvals);
4495
4496        let result = cfd_autoperiod_fdata(&data, n, m, &argvals, None, None);
4497
4498        assert!(
4499            result.period.is_finite(),
4500            "CFDAutoperiod should detect a period"
4501        );
4502        assert!(
4503            (result.period - period).abs() < 0.3,
4504            "Expected period ~{}, got {}",
4505            period,
4506            result.period
4507        );
4508    }
4509}