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 num_complex::Complex;
15use rayon::prelude::*;
16use rustfft::FftPlanner;
17use std::f64::consts::PI;
18
19/// Result of period estimation.
20#[derive(Debug, Clone)]
21pub struct PeriodEstimate {
22    /// Estimated period
23    pub period: f64,
24    /// Dominant frequency (1/period)
25    pub frequency: f64,
26    /// Power at the dominant frequency
27    pub power: f64,
28    /// Confidence measure (ratio of peak power to mean power)
29    pub confidence: f64,
30}
31
32/// A detected peak in functional data.
33#[derive(Debug, Clone)]
34pub struct Peak {
35    /// Time at which the peak occurs
36    pub time: f64,
37    /// Value at the peak
38    pub value: f64,
39    /// Prominence of the peak (height relative to surrounding valleys)
40    pub prominence: f64,
41}
42
43/// Result of peak detection.
44#[derive(Debug, Clone)]
45pub struct PeakDetectionResult {
46    /// Peaks for each sample: `peaks[sample_idx]` contains peaks for that sample
47    pub peaks: Vec<Vec<Peak>>,
48    /// Inter-peak distances for each sample
49    pub inter_peak_distances: Vec<Vec<f64>>,
50    /// Mean period estimated from inter-peak distances across all samples
51    pub mean_period: f64,
52}
53
54/// A detected period from multiple period detection.
55#[derive(Debug, Clone)]
56pub struct DetectedPeriod {
57    /// Estimated period
58    pub period: f64,
59    /// FFT confidence (ratio of peak power to mean power)
60    pub confidence: f64,
61    /// Seasonal strength at this period (variance explained)
62    pub strength: f64,
63    /// Amplitude of the sinusoidal component
64    pub amplitude: f64,
65    /// Phase of the sinusoidal component (radians)
66    pub phase: f64,
67    /// Iteration number (1-indexed)
68    pub iteration: usize,
69}
70
71/// Method for computing seasonal strength.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum StrengthMethod {
74    /// Variance decomposition: Var(seasonal) / Var(total)
75    Variance,
76    /// Spectral: power at seasonal frequencies / total power
77    Spectral,
78}
79
80/// A detected change point in seasonality.
81#[derive(Debug, Clone)]
82pub struct ChangePoint {
83    /// Time at which the change occurs
84    pub time: f64,
85    /// Type of change
86    pub change_type: ChangeType,
87    /// Seasonal strength before the change
88    pub strength_before: f64,
89    /// Seasonal strength after the change
90    pub strength_after: f64,
91}
92
93/// Type of seasonality change.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum ChangeType {
96    /// Series becomes seasonal
97    Onset,
98    /// Series stops being seasonal
99    Cessation,
100}
101
102/// Result of seasonality change detection.
103#[derive(Debug, Clone)]
104pub struct ChangeDetectionResult {
105    /// Detected change points
106    pub change_points: Vec<ChangePoint>,
107    /// Time-varying seasonal strength curve used for detection
108    pub strength_curve: Vec<f64>,
109}
110
111/// Result of instantaneous period estimation.
112#[derive(Debug, Clone)]
113pub struct InstantaneousPeriod {
114    /// Instantaneous period at each time point
115    pub period: Vec<f64>,
116    /// Instantaneous frequency at each time point
117    pub frequency: Vec<f64>,
118    /// Instantaneous amplitude (envelope) at each time point
119    pub amplitude: Vec<f64>,
120}
121
122/// Result of peak timing variability analysis.
123#[derive(Debug, Clone)]
124pub struct PeakTimingResult {
125    /// Peak times for each cycle
126    pub peak_times: Vec<f64>,
127    /// Peak values
128    pub peak_values: Vec<f64>,
129    /// Within-period timing (0-1 scale, e.g., day-of-year / 365)
130    pub normalized_timing: Vec<f64>,
131    /// Mean normalized timing
132    pub mean_timing: f64,
133    /// Standard deviation of normalized timing
134    pub std_timing: f64,
135    /// Range of normalized timing (max - min)
136    pub range_timing: f64,
137    /// Variability score (0 = stable, 1 = highly variable)
138    pub variability_score: f64,
139    /// Trend in timing (positive = peaks getting later)
140    pub timing_trend: f64,
141    /// Cycle indices (1-indexed)
142    pub cycle_indices: Vec<usize>,
143}
144
145/// Type of seasonality pattern.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum SeasonalType {
148    /// Regular peaks with consistent timing
149    StableSeasonal,
150    /// Regular peaks but timing shifts between cycles
151    VariableTiming,
152    /// Some cycles seasonal, some not
153    IntermittentSeasonal,
154    /// No clear seasonality
155    NonSeasonal,
156}
157
158/// Result of seasonality classification.
159#[derive(Debug, Clone)]
160pub struct SeasonalityClassification {
161    /// Whether the series is seasonal overall
162    pub is_seasonal: bool,
163    /// Whether peak timing is stable across cycles
164    pub has_stable_timing: bool,
165    /// Timing variability score (0 = stable, 1 = highly variable)
166    pub timing_variability: f64,
167    /// Overall seasonal strength
168    pub seasonal_strength: f64,
169    /// Per-cycle seasonal strength
170    pub cycle_strengths: Vec<f64>,
171    /// Indices of weak/missing seasons (0-indexed)
172    pub weak_seasons: Vec<usize>,
173    /// Classification type
174    pub classification: SeasonalType,
175    /// Peak timing analysis (if peaks were detected)
176    pub peak_timing: Option<PeakTimingResult>,
177}
178
179/// Method for automatic threshold selection.
180#[derive(Debug, Clone, Copy, PartialEq)]
181pub enum ThresholdMethod {
182    /// Fixed user-specified threshold
183    Fixed(f64),
184    /// Percentile of strength distribution
185    Percentile(f64),
186    /// Otsu's method (optimal bimodal separation)
187    Otsu,
188}
189
190/// Type of amplitude modulation pattern.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum ModulationType {
193    /// Constant amplitude (no modulation)
194    Stable,
195    /// Amplitude increases over time (seasonality emerges)
196    Emerging,
197    /// Amplitude decreases over time (seasonality fades)
198    Fading,
199    /// Amplitude varies non-monotonically
200    Oscillating,
201    /// No seasonality detected
202    NonSeasonal,
203}
204
205/// Result of amplitude modulation detection.
206#[derive(Debug, Clone)]
207pub struct AmplitudeModulationResult {
208    /// Whether seasonality is present (using robust spectral method)
209    pub is_seasonal: bool,
210    /// Overall seasonal strength (spectral method)
211    pub seasonal_strength: f64,
212    /// Whether amplitude modulation is detected
213    pub has_modulation: bool,
214    /// Type of amplitude modulation
215    pub modulation_type: ModulationType,
216    /// Coefficient of variation of time-varying strength (0 = stable, higher = more modulation)
217    pub modulation_score: f64,
218    /// Trend in amplitude (-1 to 1: negative = fading, positive = emerging)
219    pub amplitude_trend: f64,
220    /// Time-varying seasonal strength curve
221    pub strength_curve: Vec<f64>,
222    /// Time points corresponding to strength_curve
223    pub time_points: Vec<f64>,
224    /// Minimum strength in the curve
225    pub min_strength: f64,
226    /// Maximum strength in the curve
227    pub max_strength: f64,
228}
229
230/// Result of wavelet-based amplitude modulation detection.
231#[derive(Debug, Clone)]
232pub struct WaveletAmplitudeResult {
233    /// Whether seasonality is present
234    pub is_seasonal: bool,
235    /// Overall seasonal strength
236    pub seasonal_strength: f64,
237    /// Whether amplitude modulation is detected
238    pub has_modulation: bool,
239    /// Type of amplitude modulation
240    pub modulation_type: ModulationType,
241    /// Coefficient of variation of wavelet amplitude
242    pub modulation_score: f64,
243    /// Trend in amplitude (-1 to 1)
244    pub amplitude_trend: f64,
245    /// Wavelet amplitude at the seasonal frequency over time
246    pub wavelet_amplitude: Vec<f64>,
247    /// Time points corresponding to wavelet_amplitude
248    pub time_points: Vec<f64>,
249    /// Scale (period) used for wavelet analysis
250    pub scale: f64,
251}
252
253// ============================================================================
254// Internal helper functions
255// ============================================================================
256
257/// Compute mean curve from column-major data matrix.
258///
259/// # Arguments
260/// * `data` - Column-major matrix (n x m)
261/// * `n` - Number of samples (rows)
262/// * `m` - Number of evaluation points (columns)
263/// * `parallel` - Use parallel iteration (default: true)
264///
265/// # Returns
266/// Mean curve of length m
267#[inline]
268fn compute_mean_curve_impl(data: &[f64], n: usize, m: usize, parallel: bool) -> Vec<f64> {
269    if parallel && m >= 100 {
270        // Use parallel iteration for larger datasets
271        (0..m)
272            .into_par_iter()
273            .map(|j| {
274                let mut sum = 0.0;
275                for i in 0..n {
276                    sum += data[i + j * n];
277                }
278                sum / n as f64
279            })
280            .collect()
281    } else {
282        // Sequential for small datasets or when disabled
283        (0..m)
284            .map(|j| {
285                let mut sum = 0.0;
286                for i in 0..n {
287                    sum += data[i + j * n];
288                }
289                sum / n as f64
290            })
291            .collect()
292    }
293}
294
295/// Compute mean curve (parallel by default for m >= 100).
296#[inline]
297fn compute_mean_curve(data: &[f64], n: usize, m: usize) -> Vec<f64> {
298    compute_mean_curve_impl(data, n, m, true)
299}
300
301/// Compute interior bounds for edge-skipping (10% on each side).
302///
303/// Used to avoid edge effects in wavelet and other analyses.
304///
305/// # Arguments
306/// * `m` - Total number of points
307///
308/// # Returns
309/// `(interior_start, interior_end)` indices, or `None` if range is invalid
310#[inline]
311fn interior_bounds(m: usize) -> Option<(usize, usize)> {
312    let edge_skip = (m as f64 * 0.1) as usize;
313    let interior_start = edge_skip.min(m / 4);
314    let interior_end = m.saturating_sub(edge_skip).max(m * 3 / 4);
315
316    if interior_end <= interior_start {
317        None
318    } else {
319        Some((interior_start, interior_end))
320    }
321}
322
323/// Compute periodogram from data using FFT.
324/// Returns (frequencies, power) where frequencies are in cycles per unit time.
325fn periodogram(data: &[f64], argvals: &[f64]) -> (Vec<f64>, Vec<f64>) {
326    let n = data.len();
327    if n < 2 || argvals.len() != n {
328        return (Vec::new(), Vec::new());
329    }
330
331    let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
332    let fs = 1.0 / dt; // Sampling frequency
333
334    let mut planner = FftPlanner::<f64>::new();
335    let fft = planner.plan_fft_forward(n);
336
337    let mut buffer: Vec<Complex<f64>> = data.iter().map(|&x| Complex::new(x, 0.0)).collect();
338    fft.process(&mut buffer);
339
340    // Compute power spectrum (one-sided)
341    let n_freq = n / 2 + 1;
342    let mut power = Vec::with_capacity(n_freq);
343    let mut frequencies = Vec::with_capacity(n_freq);
344
345    for k in 0..n_freq {
346        let freq = k as f64 * fs / n as f64;
347        frequencies.push(freq);
348
349        let p = buffer[k].norm_sqr() / (n as f64 * n as f64);
350        // Double power for non-DC and non-Nyquist frequencies (one-sided spectrum)
351        let p = if k > 0 && k < n / 2 { 2.0 * p } else { p };
352        power.push(p);
353    }
354
355    (frequencies, power)
356}
357
358/// Compute autocorrelation function up to max_lag.
359fn autocorrelation(data: &[f64], max_lag: usize) -> Vec<f64> {
360    let n = data.len();
361    if n == 0 {
362        return Vec::new();
363    }
364
365    let mean: f64 = data.iter().sum::<f64>() / n as f64;
366    let var: f64 = data.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n as f64;
367
368    if var < 1e-15 {
369        return vec![1.0; max_lag.min(n) + 1];
370    }
371
372    let max_lag = max_lag.min(n - 1);
373    let mut acf = Vec::with_capacity(max_lag + 1);
374
375    for lag in 0..=max_lag {
376        let mut sum = 0.0;
377        for i in 0..(n - lag) {
378            sum += (data[i] - mean) * (data[i + lag] - mean);
379        }
380        acf.push(sum / (n as f64 * var));
381    }
382
383    acf
384}
385
386/// Find peaks in a 1D signal, returning indices.
387fn find_peaks_1d(signal: &[f64], min_distance: usize) -> Vec<usize> {
388    let n = signal.len();
389    if n < 3 {
390        return Vec::new();
391    }
392
393    let mut peaks = Vec::new();
394
395    for i in 1..(n - 1) {
396        if signal[i] > signal[i - 1] && signal[i] > signal[i + 1] {
397            // Check minimum distance from previous peak
398            if peaks.is_empty() || i - peaks[peaks.len() - 1] >= min_distance {
399                peaks.push(i);
400            } else if signal[i] > signal[peaks[peaks.len() - 1]] {
401                // Replace previous peak if this one is higher
402                peaks.pop();
403                peaks.push(i);
404            }
405        }
406    }
407
408    peaks
409}
410
411/// Compute prominence for a peak (height above surrounding valleys).
412fn compute_prominence(signal: &[f64], peak_idx: usize) -> f64 {
413    let n = signal.len();
414    let peak_val = signal[peak_idx];
415
416    // Find lowest point between peak and boundaries/higher peaks
417    let mut left_min = peak_val;
418    for i in (0..peak_idx).rev() {
419        if signal[i] >= peak_val {
420            break;
421        }
422        left_min = left_min.min(signal[i]);
423    }
424
425    let mut right_min = peak_val;
426    for i in (peak_idx + 1)..n {
427        if signal[i] >= peak_val {
428            break;
429        }
430        right_min = right_min.min(signal[i]);
431    }
432
433    peak_val - left_min.max(right_min)
434}
435
436/// Hilbert transform using FFT to compute analytic signal.
437///
438/// # Arguments
439/// * `signal` - Input real signal
440///
441/// # Returns
442/// Analytic signal as complex vector (real part = original, imaginary = Hilbert transform)
443pub fn hilbert_transform(signal: &[f64]) -> Vec<Complex<f64>> {
444    let n = signal.len();
445    if n == 0 {
446        return Vec::new();
447    }
448
449    let mut planner = FftPlanner::<f64>::new();
450    let fft_forward = planner.plan_fft_forward(n);
451    let fft_inverse = planner.plan_fft_inverse(n);
452
453    // Forward FFT
454    let mut buffer: Vec<Complex<f64>> = signal.iter().map(|&x| Complex::new(x, 0.0)).collect();
455    fft_forward.process(&mut buffer);
456
457    // Create analytic signal in frequency domain
458    // H[0] = 1, H[1..n/2] = 2, H[n/2] = 1 (if n even), H[n/2+1..] = 0
459    let half = n / 2;
460    for k in 1..half {
461        buffer[k] *= 2.0;
462    }
463    for k in (half + 1)..n {
464        buffer[k] = Complex::new(0.0, 0.0);
465    }
466
467    // Inverse FFT
468    fft_inverse.process(&mut buffer);
469
470    // Normalize
471    for c in buffer.iter_mut() {
472        *c /= n as f64;
473    }
474
475    buffer
476}
477
478/// Unwrap phase to remove 2π discontinuities.
479fn unwrap_phase(phase: &[f64]) -> Vec<f64> {
480    if phase.is_empty() {
481        return Vec::new();
482    }
483
484    let mut unwrapped = vec![phase[0]];
485    let mut cumulative_correction = 0.0;
486
487    for i in 1..phase.len() {
488        let diff = phase[i] - phase[i - 1];
489
490        // Check for wraparound
491        if diff > PI {
492            cumulative_correction -= 2.0 * PI;
493        } else if diff < -PI {
494            cumulative_correction += 2.0 * PI;
495        }
496
497        unwrapped.push(phase[i] + cumulative_correction);
498    }
499
500    unwrapped
501}
502
503/// Morlet wavelet function.
504///
505/// The Morlet wavelet is a complex exponential modulated by a Gaussian:
506/// ψ(t) = exp(i * ω₀ * t) * exp(-t² / 2)
507///
508/// where ω₀ is the central frequency (typically 6 for good time-frequency trade-off).
509fn morlet_wavelet(t: f64, omega0: f64) -> Complex<f64> {
510    let gaussian = (-t * t / 2.0).exp();
511    let oscillation = Complex::new((omega0 * t).cos(), (omega0 * t).sin());
512    oscillation * gaussian
513}
514
515/// Continuous Wavelet Transform at a single scale using Morlet wavelet.
516///
517/// Computes the wavelet coefficients at the specified scale (period) for all time points.
518/// Uses convolution in the time domain.
519///
520/// # Arguments
521/// * `signal` - Input signal
522/// * `argvals` - Time points
523/// * `scale` - Scale parameter (related to period)
524/// * `omega0` - Central frequency of Morlet wavelet (default: 6.0)
525///
526/// # Returns
527/// Complex wavelet coefficients at each time point
528#[allow(dead_code)]
529fn cwt_morlet(signal: &[f64], argvals: &[f64], scale: f64, omega0: f64) -> Vec<Complex<f64>> {
530    let n = signal.len();
531    if n == 0 || scale <= 0.0 {
532        return Vec::new();
533    }
534
535    let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
536
537    // Compute wavelet coefficients via convolution
538    // W(a, b) = (1/sqrt(a)) * Σ x[k] * ψ*((t[k] - b) / a) * dt
539    let norm = 1.0 / scale.sqrt();
540
541    (0..n)
542        .map(|b| {
543            let mut sum = Complex::new(0.0, 0.0);
544            for k in 0..n {
545                let t_normalized = (argvals[k] - argvals[b]) / scale;
546                // Only compute within reasonable range (Gaussian decays quickly)
547                if t_normalized.abs() < 6.0 {
548                    let wavelet = morlet_wavelet(t_normalized, omega0);
549                    sum += signal[k] * wavelet.conj();
550                }
551            }
552            sum * norm * dt
553        })
554        .collect()
555}
556
557/// Continuous Wavelet Transform at a single scale using FFT (faster for large signals).
558///
559/// Uses the convolution theorem: CWT = IFFT(FFT(signal) * FFT(wavelet)*)
560fn cwt_morlet_fft(signal: &[f64], argvals: &[f64], scale: f64, omega0: f64) -> Vec<Complex<f64>> {
561    let n = signal.len();
562    if n == 0 || scale <= 0.0 {
563        return Vec::new();
564    }
565
566    let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
567    let norm = 1.0 / scale.sqrt();
568
569    // Compute wavelet in time domain centered at t=0
570    let wavelet_time: Vec<Complex<f64>> = (0..n)
571        .map(|k| {
572            // Center the wavelet
573            let t = if k <= n / 2 {
574                k as f64 * dt / scale
575            } else {
576                (k as f64 - n as f64) * dt / scale
577            };
578            morlet_wavelet(t, omega0) * norm
579        })
580        .collect();
581
582    let mut planner = FftPlanner::<f64>::new();
583    let fft_forward = planner.plan_fft_forward(n);
584    let fft_inverse = planner.plan_fft_inverse(n);
585
586    // FFT of signal
587    let mut signal_fft: Vec<Complex<f64>> = signal.iter().map(|&x| Complex::new(x, 0.0)).collect();
588    fft_forward.process(&mut signal_fft);
589
590    // FFT of wavelet
591    let mut wavelet_fft = wavelet_time;
592    fft_forward.process(&mut wavelet_fft);
593
594    // Multiply in frequency domain (use conjugate of wavelet FFT for correlation)
595    let mut result: Vec<Complex<f64>> = signal_fft
596        .iter()
597        .zip(wavelet_fft.iter())
598        .map(|(s, w)| *s * w.conj())
599        .collect();
600
601    // Inverse FFT
602    fft_inverse.process(&mut result);
603
604    // Normalize and scale by dt
605    for c in result.iter_mut() {
606        *c *= dt / n as f64;
607    }
608
609    result
610}
611
612/// Compute Otsu's threshold for bimodal separation.
613///
614/// Finds the threshold that minimizes within-class variance (or equivalently
615/// maximizes between-class variance).
616fn otsu_threshold(values: &[f64]) -> f64 {
617    if values.is_empty() {
618        return 0.5;
619    }
620
621    // Filter NaN values
622    let valid: Vec<f64> = values.iter().copied().filter(|x| x.is_finite()).collect();
623    if valid.is_empty() {
624        return 0.5;
625    }
626
627    let min_val = valid.iter().cloned().fold(f64::INFINITY, f64::min);
628    let max_val = valid.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
629
630    if (max_val - min_val).abs() < 1e-10 {
631        return (min_val + max_val) / 2.0;
632    }
633
634    // Create histogram with 256 bins
635    let n_bins = 256;
636    let bin_width = (max_val - min_val) / n_bins as f64;
637    let mut histogram = vec![0usize; n_bins];
638
639    for &v in &valid {
640        let bin = ((v - min_val) / bin_width).min(n_bins as f64 - 1.0) as usize;
641        histogram[bin] += 1;
642    }
643
644    let total = valid.len() as f64;
645    let mut sum_total = 0.0;
646    for (i, &count) in histogram.iter().enumerate() {
647        sum_total += i as f64 * count as f64;
648    }
649
650    let mut best_threshold = min_val;
651    let mut best_variance = 0.0;
652
653    let mut sum_b = 0.0;
654    let mut weight_b = 0.0;
655
656    for t in 0..n_bins {
657        weight_b += histogram[t] as f64;
658        if weight_b == 0.0 {
659            continue;
660        }
661
662        let weight_f = total - weight_b;
663        if weight_f == 0.0 {
664            break;
665        }
666
667        sum_b += t as f64 * histogram[t] as f64;
668
669        let mean_b = sum_b / weight_b;
670        let mean_f = (sum_total - sum_b) / weight_f;
671
672        // Between-class variance
673        let variance = weight_b * weight_f * (mean_b - mean_f).powi(2);
674
675        if variance > best_variance {
676            best_variance = variance;
677            best_threshold = min_val + (t as f64 + 0.5) * bin_width;
678        }
679    }
680
681    best_threshold
682}
683
684/// Compute linear regression slope (simple OLS).
685fn linear_slope(x: &[f64], y: &[f64]) -> f64 {
686    if x.len() != y.len() || x.len() < 2 {
687        return 0.0;
688    }
689
690    let n = x.len() as f64;
691    let mean_x: f64 = x.iter().sum::<f64>() / n;
692    let mean_y: f64 = y.iter().sum::<f64>() / n;
693
694    let mut num = 0.0;
695    let mut den = 0.0;
696
697    for (&xi, &yi) in x.iter().zip(y.iter()) {
698        num += (xi - mean_x) * (yi - mean_y);
699        den += (xi - mean_x).powi(2);
700    }
701
702    if den.abs() < 1e-15 {
703        0.0
704    } else {
705        num / den
706    }
707}
708
709// ============================================================================
710// Period Estimation
711// ============================================================================
712
713/// Estimate period using FFT periodogram.
714///
715/// Finds the dominant frequency in the periodogram (excluding DC) and
716/// returns the corresponding period.
717///
718/// # Arguments
719/// * `data` - Column-major matrix (n x m)
720/// * `n` - Number of samples
721/// * `m` - Number of evaluation points
722/// * `argvals` - Evaluation points (time values)
723///
724/// # Returns
725/// Period estimate with confidence measure
726pub fn estimate_period_fft(data: &[f64], n: usize, m: usize, argvals: &[f64]) -> PeriodEstimate {
727    if n == 0 || m < 4 || argvals.len() != m {
728        return PeriodEstimate {
729            period: f64::NAN,
730            frequency: f64::NAN,
731            power: 0.0,
732            confidence: 0.0,
733        };
734    }
735
736    // Compute mean curve first
737    let mean_curve = compute_mean_curve(data, n, m);
738
739    let (frequencies, power) = periodogram(&mean_curve, argvals);
740
741    if frequencies.len() < 2 {
742        return PeriodEstimate {
743            period: f64::NAN,
744            frequency: f64::NAN,
745            power: 0.0,
746            confidence: 0.0,
747        };
748    }
749
750    // Find peak in power spectrum (skip DC component at index 0)
751    let mut max_power = 0.0;
752    let mut max_idx = 1;
753    for (i, &p) in power.iter().enumerate().skip(1) {
754        if p > max_power {
755            max_power = p;
756            max_idx = i;
757        }
758    }
759
760    let dominant_freq = frequencies[max_idx];
761    let period = if dominant_freq > 1e-15 {
762        1.0 / dominant_freq
763    } else {
764        f64::INFINITY
765    };
766
767    // Confidence: ratio of peak power to mean power (excluding DC)
768    let mean_power: f64 = power.iter().skip(1).sum::<f64>() / (power.len() - 1) as f64;
769    let confidence = if mean_power > 1e-15 {
770        max_power / mean_power
771    } else {
772        0.0
773    };
774
775    PeriodEstimate {
776        period,
777        frequency: dominant_freq,
778        power: max_power,
779        confidence,
780    }
781}
782
783/// Estimate period using autocorrelation function.
784///
785/// Finds the first significant peak in the ACF after lag 0.
786///
787/// # Arguments
788/// * `data` - Column-major matrix (n x m)
789/// * `n` - Number of samples
790/// * `m` - Number of evaluation points
791/// * `argvals` - Evaluation points
792/// * `max_lag` - Maximum lag to consider (in number of points)
793pub fn estimate_period_acf(
794    data: &[f64],
795    n: usize,
796    m: usize,
797    argvals: &[f64],
798    max_lag: usize,
799) -> PeriodEstimate {
800    if n == 0 || m < 4 || argvals.len() != m {
801        return PeriodEstimate {
802            period: f64::NAN,
803            frequency: f64::NAN,
804            power: 0.0,
805            confidence: 0.0,
806        };
807    }
808
809    // Compute mean curve
810    let mean_curve = compute_mean_curve(data, n, m);
811
812    let acf = autocorrelation(&mean_curve, max_lag);
813
814    // Find first peak after lag 0 (skip first few lags to avoid finding lag 0)
815    let min_lag = 2;
816    let peaks = find_peaks_1d(&acf[min_lag..], 1);
817
818    if peaks.is_empty() {
819        return PeriodEstimate {
820            period: f64::NAN,
821            frequency: f64::NAN,
822            power: 0.0,
823            confidence: 0.0,
824        };
825    }
826
827    let peak_lag = peaks[0] + min_lag;
828    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
829    let period = peak_lag as f64 * dt;
830    let frequency = if period > 1e-15 { 1.0 / period } else { 0.0 };
831
832    PeriodEstimate {
833        period,
834        frequency,
835        power: acf[peak_lag],
836        confidence: acf[peak_lag].abs(),
837    }
838}
839
840/// Estimate period via Fourier regression grid search.
841///
842/// Tests multiple candidate periods and selects the one that minimizes
843/// the reconstruction error (similar to GCV).
844///
845/// # Arguments
846/// * `data` - Column-major matrix (n x m)
847/// * `n` - Number of samples
848/// * `m` - Number of evaluation points
849/// * `argvals` - Evaluation points
850/// * `period_min` - Minimum period to test
851/// * `period_max` - Maximum period to test
852/// * `n_candidates` - Number of candidate periods to test
853/// * `n_harmonics` - Number of Fourier harmonics to use
854pub fn estimate_period_regression(
855    data: &[f64],
856    n: usize,
857    m: usize,
858    argvals: &[f64],
859    period_min: f64,
860    period_max: f64,
861    n_candidates: usize,
862    n_harmonics: usize,
863) -> PeriodEstimate {
864    if n == 0 || m < 4 || argvals.len() != m || period_min >= period_max || n_candidates < 2 {
865        return PeriodEstimate {
866            period: f64::NAN,
867            frequency: f64::NAN,
868            power: 0.0,
869            confidence: 0.0,
870        };
871    }
872
873    // Compute mean curve
874    let mean_curve = compute_mean_curve(data, n, m);
875
876    let nbasis = 1 + 2 * n_harmonics;
877
878    // Grid search over candidate periods
879    let candidates: Vec<f64> = (0..n_candidates)
880        .map(|i| period_min + (period_max - period_min) * i as f64 / (n_candidates - 1) as f64)
881        .collect();
882
883    let results: Vec<(f64, f64)> = candidates
884        .par_iter()
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>)> = (0..n)
1102        .into_par_iter()
1103        .map(|i| {
1104            // Extract curve and derivative
1105            let curve: Vec<f64> = (0..m).map(|j| work_data[i + j * n]).collect();
1106            let d1: Vec<f64> = (0..m).map(|j| deriv1[i + j * n]).collect();
1107
1108            // Find zero crossings where derivative goes from positive to negative
1109            let mut peak_indices = Vec::new();
1110            for j in 1..m {
1111                if d1[j - 1] > 0.0 && d1[j] <= 0.0 {
1112                    // Interpolate to find more precise location
1113                    let idx = if (d1[j - 1] - d1[j]).abs() > 1e-15 {
1114                        j - 1
1115                    } else {
1116                        j
1117                    };
1118
1119                    // Check minimum distance
1120                    if peak_indices.is_empty()
1121                        || idx - peak_indices[peak_indices.len() - 1] >= min_dist_points
1122                    {
1123                        peak_indices.push(idx);
1124                    }
1125                }
1126            }
1127
1128            // Build peaks with prominence
1129            let mut peaks: Vec<Peak> = peak_indices
1130                .iter()
1131                .map(|&idx| {
1132                    let prominence = compute_prominence(&curve, idx) / data_range;
1133                    Peak {
1134                        time: argvals[idx],
1135                        value: curve[idx],
1136                        prominence,
1137                    }
1138                })
1139                .collect();
1140
1141            // Filter by prominence
1142            if let Some(min_prom) = min_prominence {
1143                peaks.retain(|p| p.prominence >= min_prom);
1144            }
1145
1146            // Compute inter-peak distances
1147            let distances: Vec<f64> = peaks.windows(2).map(|w| w[1].time - w[0].time).collect();
1148
1149            (peaks, distances)
1150        })
1151        .collect();
1152
1153    let peaks: Vec<Vec<Peak>> = results.iter().map(|(p, _)| p.clone()).collect();
1154    let inter_peak_distances: Vec<Vec<f64>> = results.iter().map(|(_, d)| d.clone()).collect();
1155
1156    // Compute mean period from all inter-peak distances
1157    let all_distances: Vec<f64> = inter_peak_distances.iter().flatten().cloned().collect();
1158    let mean_period = if all_distances.is_empty() {
1159        f64::NAN
1160    } else {
1161        all_distances.iter().sum::<f64>() / all_distances.len() as f64
1162    };
1163
1164    PeakDetectionResult {
1165        peaks,
1166        inter_peak_distances,
1167        mean_period,
1168    }
1169}
1170
1171// ============================================================================
1172// Seasonal Strength
1173// ============================================================================
1174
1175/// Measure seasonal strength using variance decomposition.
1176///
1177/// Computes SS = Var(seasonal_component) / Var(total) where the seasonal
1178/// component is extracted using Fourier basis.
1179///
1180/// # Arguments
1181/// * `data` - Column-major matrix (n x m)
1182/// * `n` - Number of samples
1183/// * `m` - Number of evaluation points
1184/// * `argvals` - Evaluation points
1185/// * `period` - Seasonal period
1186/// * `n_harmonics` - Number of Fourier harmonics to use
1187///
1188/// # Returns
1189/// Seasonal strength in [0, 1]
1190pub fn seasonal_strength_variance(
1191    data: &[f64],
1192    n: usize,
1193    m: usize,
1194    argvals: &[f64],
1195    period: f64,
1196    n_harmonics: usize,
1197) -> f64 {
1198    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1199        return f64::NAN;
1200    }
1201
1202    // Compute mean curve
1203    let mean_curve = compute_mean_curve(data, n, m);
1204
1205    // Total variance
1206    let global_mean: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1207    let total_var: f64 = mean_curve
1208        .iter()
1209        .map(|&x| (x - global_mean).powi(2))
1210        .sum::<f64>()
1211        / m as f64;
1212
1213    if total_var < 1e-15 {
1214        return 0.0;
1215    }
1216
1217    // Fit Fourier basis to extract seasonal component
1218    let nbasis = 1 + 2 * n_harmonics;
1219    let basis = fourier_basis_with_period(argvals, nbasis, period);
1220
1221    // Project data onto basis (simple least squares for mean curve)
1222    let mut seasonal = vec![0.0; m];
1223    for k in 1..nbasis {
1224        // Skip DC component
1225        let b_sum: f64 = (0..m).map(|j| basis[j + k * m].powi(2)).sum();
1226        if b_sum > 1e-15 {
1227            let coef: f64 = (0..m)
1228                .map(|j| mean_curve[j] * basis[j + k * m])
1229                .sum::<f64>()
1230                / b_sum;
1231            for j in 0..m {
1232                seasonal[j] += coef * basis[j + k * m];
1233            }
1234        }
1235    }
1236
1237    // Seasonal variance
1238    let seasonal_mean: f64 = seasonal.iter().sum::<f64>() / m as f64;
1239    let seasonal_var: f64 = seasonal
1240        .iter()
1241        .map(|&x| (x - seasonal_mean).powi(2))
1242        .sum::<f64>()
1243        / m as f64;
1244
1245    (seasonal_var / total_var).min(1.0)
1246}
1247
1248/// Measure seasonal strength using spectral method.
1249///
1250/// Computes SS = power at seasonal frequencies / total power.
1251///
1252/// # Arguments
1253/// * `data` - Column-major matrix (n x m)
1254/// * `n` - Number of samples
1255/// * `m` - Number of evaluation points
1256/// * `argvals` - Evaluation points
1257/// * `period` - Seasonal period
1258pub fn seasonal_strength_spectral(
1259    data: &[f64],
1260    n: usize,
1261    m: usize,
1262    argvals: &[f64],
1263    period: f64,
1264) -> f64 {
1265    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1266        return f64::NAN;
1267    }
1268
1269    // Compute mean curve
1270    let mean_curve = compute_mean_curve(data, n, m);
1271
1272    let (frequencies, power) = periodogram(&mean_curve, argvals);
1273
1274    if frequencies.len() < 2 {
1275        return f64::NAN;
1276    }
1277
1278    let fundamental_freq = 1.0 / period;
1279
1280    // Sum power at seasonal frequencies (fundamental and harmonics)
1281    let _freq_tol = fundamental_freq * 0.1; // 10% tolerance (for future use)
1282    let mut seasonal_power = 0.0;
1283    let mut total_power = 0.0;
1284
1285    for (i, (&freq, &p)) in frequencies.iter().zip(power.iter()).enumerate() {
1286        if i == 0 {
1287            continue;
1288        } // Skip DC
1289
1290        total_power += p;
1291
1292        // Check if frequency is near a harmonic of fundamental
1293        let ratio = freq / fundamental_freq;
1294        let nearest_harmonic = ratio.round();
1295        if (ratio - nearest_harmonic).abs() < 0.1 && nearest_harmonic >= 1.0 {
1296            seasonal_power += p;
1297        }
1298    }
1299
1300    if total_power < 1e-15 {
1301        return 0.0;
1302    }
1303
1304    (seasonal_power / total_power).min(1.0)
1305}
1306
1307/// Compute seasonal strength using Morlet wavelet power at the target period.
1308///
1309/// This method uses the Continuous Wavelet Transform (CWT) with a Morlet wavelet
1310/// to measure power at the specified seasonal period. Unlike spectral methods,
1311/// wavelets provide time-localized frequency information.
1312///
1313/// # Arguments
1314/// * `data` - Column-major matrix (n x m)
1315/// * `n` - Number of samples
1316/// * `m` - Number of evaluation points
1317/// * `argvals` - Evaluation points
1318/// * `period` - Seasonal period in argvals units
1319///
1320/// # Returns
1321/// Seasonal strength as ratio of wavelet power to total variance (0 to 1)
1322///
1323/// # Notes
1324/// - Uses Morlet wavelet with ω₀ = 6 (standard choice)
1325/// - Scale is computed as: scale = period * ω₀ / (2π)
1326/// - Strength is computed over the interior 80% of the signal to avoid edge effects
1327pub fn seasonal_strength_wavelet(
1328    data: &[f64],
1329    n: usize,
1330    m: usize,
1331    argvals: &[f64],
1332    period: f64,
1333) -> f64 {
1334    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1335        return f64::NAN;
1336    }
1337
1338    // Compute mean curve
1339    let mean_curve = compute_mean_curve(data, n, m);
1340
1341    // Remove DC component
1342    let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1343    let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
1344
1345    // Compute total variance
1346    let total_variance: f64 = detrended.iter().map(|&x| x * x).sum::<f64>() / m as f64;
1347
1348    if total_variance < 1e-15 {
1349        return 0.0;
1350    }
1351
1352    // Compute wavelet transform at the seasonal scale
1353    let omega0 = 6.0;
1354    let scale = period * omega0 / (2.0 * PI);
1355    let wavelet_coeffs = cwt_morlet_fft(&detrended, argvals, scale, omega0);
1356
1357    if wavelet_coeffs.is_empty() {
1358        return f64::NAN;
1359    }
1360
1361    // Compute wavelet power, skipping edges (10% on each side)
1362    let (interior_start, interior_end) = match interior_bounds(m) {
1363        Some(bounds) => bounds,
1364        None => return f64::NAN,
1365    };
1366
1367    let wavelet_power: f64 = wavelet_coeffs[interior_start..interior_end]
1368        .iter()
1369        .map(|c| c.norm_sqr())
1370        .sum::<f64>()
1371        / (interior_end - interior_start) as f64;
1372
1373    // Return ratio of wavelet power to total variance
1374    // Normalize so that a pure sine at the target period gives ~1.0
1375    (wavelet_power / total_variance).sqrt().min(1.0)
1376}
1377
1378/// Compute time-varying seasonal strength using sliding windows.
1379///
1380/// # Arguments
1381/// * `data` - Column-major matrix (n x m)
1382/// * `n` - Number of samples
1383/// * `m` - Number of evaluation points
1384/// * `argvals` - Evaluation points
1385/// * `period` - Seasonal period
1386/// * `window_size` - Window width (recommended: 2 * period)
1387/// * `method` - Method for computing strength (Variance or Spectral)
1388///
1389/// # Returns
1390/// Seasonal strength at each time point
1391pub fn seasonal_strength_windowed(
1392    data: &[f64],
1393    n: usize,
1394    m: usize,
1395    argvals: &[f64],
1396    period: f64,
1397    window_size: f64,
1398    method: StrengthMethod,
1399) -> Vec<f64> {
1400    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 || window_size <= 0.0 {
1401        return Vec::new();
1402    }
1403
1404    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1405    let half_window_points = ((window_size / 2.0) / dt).round() as usize;
1406
1407    // Compute mean curve
1408    let mean_curve = compute_mean_curve(data, n, m);
1409
1410    (0..m)
1411        .into_par_iter()
1412        .map(|center| {
1413            let start = center.saturating_sub(half_window_points);
1414            let end = (center + half_window_points + 1).min(m);
1415            let window_m = end - start;
1416
1417            if window_m < 4 {
1418                return f64::NAN;
1419            }
1420
1421            let window_data: Vec<f64> = mean_curve[start..end].to_vec();
1422            let window_argvals: Vec<f64> = argvals[start..end].to_vec();
1423
1424            // Create single-sample data for the strength functions
1425            let single_data = window_data.clone();
1426
1427            match method {
1428                StrengthMethod::Variance => seasonal_strength_variance(
1429                    &single_data,
1430                    1,
1431                    window_m,
1432                    &window_argvals,
1433                    period,
1434                    3,
1435                ),
1436                StrengthMethod::Spectral => {
1437                    seasonal_strength_spectral(&single_data, 1, window_m, &window_argvals, period)
1438                }
1439            }
1440        })
1441        .collect()
1442}
1443
1444// ============================================================================
1445// Seasonality Change Detection
1446// ============================================================================
1447
1448/// Detect changes in seasonality.
1449///
1450/// Monitors time-varying seasonal strength and detects threshold crossings
1451/// that indicate onset or cessation of seasonality.
1452///
1453/// # Arguments
1454/// * `data` - Column-major matrix (n x m)
1455/// * `n` - Number of samples
1456/// * `m` - Number of evaluation points
1457/// * `argvals` - Evaluation points
1458/// * `period` - Seasonal period
1459/// * `threshold` - SS threshold for seasonal/non-seasonal (e.g., 0.3)
1460/// * `window_size` - Window size for local strength estimation
1461/// * `min_duration` - Minimum duration to confirm a change
1462pub fn detect_seasonality_changes(
1463    data: &[f64],
1464    n: usize,
1465    m: usize,
1466    argvals: &[f64],
1467    period: f64,
1468    threshold: f64,
1469    window_size: f64,
1470    min_duration: f64,
1471) -> ChangeDetectionResult {
1472    if n == 0 || m < 4 || argvals.len() != m {
1473        return ChangeDetectionResult {
1474            change_points: Vec::new(),
1475            strength_curve: Vec::new(),
1476        };
1477    }
1478
1479    // Compute time-varying seasonal strength
1480    let strength_curve = seasonal_strength_windowed(
1481        data,
1482        n,
1483        m,
1484        argvals,
1485        period,
1486        window_size,
1487        StrengthMethod::Variance,
1488    );
1489
1490    if strength_curve.is_empty() {
1491        return ChangeDetectionResult {
1492            change_points: Vec::new(),
1493            strength_curve: Vec::new(),
1494        };
1495    }
1496
1497    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1498    let min_dur_points = (min_duration / dt).round() as usize;
1499
1500    // Detect threshold crossings
1501    let mut change_points = Vec::new();
1502    let mut in_seasonal = strength_curve[0] > threshold;
1503    let mut last_change_idx: Option<usize> = None;
1504
1505    for (i, &ss) in strength_curve.iter().enumerate().skip(1) {
1506        if ss.is_nan() {
1507            continue;
1508        }
1509
1510        let now_seasonal = ss > threshold;
1511
1512        if now_seasonal != in_seasonal {
1513            // Potential change point
1514            if let Some(last_idx) = last_change_idx {
1515                if i - last_idx < min_dur_points {
1516                    continue; // Too soon after last change
1517                }
1518            }
1519
1520            let change_type = if now_seasonal {
1521                ChangeType::Onset
1522            } else {
1523                ChangeType::Cessation
1524            };
1525
1526            let strength_before = if i > 0 && !strength_curve[i - 1].is_nan() {
1527                strength_curve[i - 1]
1528            } else {
1529                ss
1530            };
1531
1532            change_points.push(ChangePoint {
1533                time: argvals[i],
1534                change_type,
1535                strength_before,
1536                strength_after: ss,
1537            });
1538
1539            in_seasonal = now_seasonal;
1540            last_change_idx = Some(i);
1541        }
1542    }
1543
1544    ChangeDetectionResult {
1545        change_points,
1546        strength_curve,
1547    }
1548}
1549
1550// ============================================================================
1551// Amplitude Modulation Detection
1552// ============================================================================
1553
1554/// Detect amplitude modulation in seasonal time series.
1555///
1556/// This function first checks if seasonality exists using the spectral method
1557/// (which is robust to amplitude modulation), then uses Hilbert transform to
1558/// extract the amplitude envelope and analyze modulation patterns.
1559///
1560/// # Arguments
1561/// * `data` - Column-major matrix (n x m)
1562/// * `n` - Number of samples
1563/// * `m` - Number of evaluation points
1564/// * `argvals` - Evaluation points
1565/// * `period` - Seasonal period in argvals units
1566/// * `modulation_threshold` - CV threshold for detecting modulation (default: 0.15)
1567/// * `seasonality_threshold` - Strength threshold for seasonality (default: 0.3)
1568///
1569/// # Returns
1570/// `AmplitudeModulationResult` containing detection results and diagnostics
1571///
1572/// # Example
1573/// ```ignore
1574/// let result = detect_amplitude_modulation(
1575///     &data, n, m, &argvals,
1576///     period,
1577///     0.15,          // CV > 0.15 indicates modulation
1578///     0.3,           // strength > 0.3 indicates seasonality
1579/// );
1580/// if result.has_modulation {
1581///     match result.modulation_type {
1582///         ModulationType::Emerging => println!("Seasonality is emerging"),
1583///         ModulationType::Fading => println!("Seasonality is fading"),
1584///         _ => {}
1585///     }
1586/// }
1587/// ```
1588pub fn detect_amplitude_modulation(
1589    data: &[f64],
1590    n: usize,
1591    m: usize,
1592    argvals: &[f64],
1593    period: f64,
1594    modulation_threshold: f64,
1595    seasonality_threshold: f64,
1596) -> AmplitudeModulationResult {
1597    // Default result for invalid input
1598    let empty_result = AmplitudeModulationResult {
1599        is_seasonal: false,
1600        seasonal_strength: 0.0,
1601        has_modulation: false,
1602        modulation_type: ModulationType::NonSeasonal,
1603        modulation_score: 0.0,
1604        amplitude_trend: 0.0,
1605        strength_curve: Vec::new(),
1606        time_points: Vec::new(),
1607        min_strength: 0.0,
1608        max_strength: 0.0,
1609    };
1610
1611    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1612        return empty_result;
1613    }
1614
1615    // Step 1: Check if seasonality exists using spectral method (robust to AM)
1616    let overall_strength = seasonal_strength_spectral(data, n, m, argvals, period);
1617
1618    if overall_strength < seasonality_threshold {
1619        return AmplitudeModulationResult {
1620            is_seasonal: false,
1621            seasonal_strength: overall_strength,
1622            has_modulation: false,
1623            modulation_type: ModulationType::NonSeasonal,
1624            modulation_score: 0.0,
1625            amplitude_trend: 0.0,
1626            strength_curve: Vec::new(),
1627            time_points: argvals.to_vec(),
1628            min_strength: 0.0,
1629            max_strength: 0.0,
1630        };
1631    }
1632
1633    // Step 2: Compute mean curve
1634    let mean_curve = compute_mean_curve(data, n, m);
1635
1636    // Step 3: Use Hilbert transform to get amplitude envelope
1637    let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1638    let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
1639    let analytic = hilbert_transform(&detrended);
1640    let envelope: Vec<f64> = analytic.iter().map(|c| c.norm()).collect();
1641
1642    if envelope.is_empty() {
1643        return AmplitudeModulationResult {
1644            is_seasonal: true,
1645            seasonal_strength: overall_strength,
1646            has_modulation: false,
1647            modulation_type: ModulationType::Stable,
1648            modulation_score: 0.0,
1649            amplitude_trend: 0.0,
1650            strength_curve: Vec::new(),
1651            time_points: argvals.to_vec(),
1652            min_strength: 0.0,
1653            max_strength: 0.0,
1654        };
1655    }
1656
1657    // Step 4: Smooth the envelope to reduce high-frequency noise
1658    // Use simple moving average with window size based on period
1659    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1660    let smooth_window = ((period / dt) as usize).max(3);
1661    let half_window = smooth_window / 2;
1662
1663    let smoothed_envelope: Vec<f64> = (0..m)
1664        .map(|i| {
1665            let start = i.saturating_sub(half_window);
1666            let end = (i + half_window + 1).min(m);
1667            let sum: f64 = envelope[start..end].iter().sum();
1668            sum / (end - start) as f64
1669        })
1670        .collect();
1671
1672    // Step 5: Compute statistics on smoothed envelope
1673    // Skip edge regions (first and last 10% of points)
1674    let (interior_start, interior_end) = match interior_bounds(m) {
1675        Some((s, e)) if e > s + 4 => (s, e),
1676        _ => {
1677            return AmplitudeModulationResult {
1678                is_seasonal: true,
1679                seasonal_strength: overall_strength,
1680                has_modulation: false,
1681                modulation_type: ModulationType::Stable,
1682                modulation_score: 0.0,
1683                amplitude_trend: 0.0,
1684                strength_curve: envelope,
1685                time_points: argvals.to_vec(),
1686                min_strength: 0.0,
1687                max_strength: 0.0,
1688            };
1689        }
1690    };
1691
1692    let interior_envelope = &smoothed_envelope[interior_start..interior_end];
1693    let interior_times = &argvals[interior_start..interior_end];
1694    let n_interior = interior_envelope.len() as f64;
1695
1696    let mean_amp = interior_envelope.iter().sum::<f64>() / n_interior;
1697    let min_amp = interior_envelope
1698        .iter()
1699        .cloned()
1700        .fold(f64::INFINITY, f64::min);
1701    let max_amp = interior_envelope
1702        .iter()
1703        .cloned()
1704        .fold(f64::NEG_INFINITY, f64::max);
1705
1706    // Coefficient of variation (modulation score)
1707    let variance = interior_envelope
1708        .iter()
1709        .map(|&a| (a - mean_amp).powi(2))
1710        .sum::<f64>()
1711        / n_interior;
1712    let std_amp = variance.sqrt();
1713    let modulation_score = if mean_amp > 1e-10 {
1714        std_amp / mean_amp
1715    } else {
1716        0.0
1717    };
1718
1719    // Step 6: Compute trend in amplitude (linear regression slope)
1720    let t_mean = interior_times.iter().sum::<f64>() / n_interior;
1721    let mut cov_ta = 0.0;
1722    let mut var_t = 0.0;
1723    for (&t, &a) in interior_times.iter().zip(interior_envelope.iter()) {
1724        cov_ta += (t - t_mean) * (a - mean_amp);
1725        var_t += (t - t_mean).powi(2);
1726    }
1727    let slope = if var_t > 1e-10 { cov_ta / var_t } else { 0.0 };
1728
1729    // Normalize slope to [-1, 1] based on amplitude range and time span
1730    let time_span = interior_times.last().unwrap_or(&1.0) - interior_times.first().unwrap_or(&0.0);
1731    let amp_range = max_amp - min_amp;
1732    let amplitude_trend = if amp_range > 1e-10 && time_span > 1e-10 && mean_amp > 1e-10 {
1733        // Normalized: what fraction of mean amplitude changes per unit time span
1734        (slope * time_span / mean_amp).clamp(-1.0, 1.0)
1735    } else {
1736        0.0
1737    };
1738
1739    // Step 7: Classify modulation type
1740    let has_modulation = modulation_score > modulation_threshold;
1741    let modulation_type = if !has_modulation {
1742        ModulationType::Stable
1743    } else if amplitude_trend > 0.3 {
1744        ModulationType::Emerging
1745    } else if amplitude_trend < -0.3 {
1746        ModulationType::Fading
1747    } else {
1748        ModulationType::Oscillating
1749    };
1750
1751    AmplitudeModulationResult {
1752        is_seasonal: true,
1753        seasonal_strength: overall_strength,
1754        has_modulation,
1755        modulation_type,
1756        modulation_score,
1757        amplitude_trend,
1758        strength_curve: envelope,
1759        time_points: argvals.to_vec(),
1760        min_strength: min_amp,
1761        max_strength: max_amp,
1762    }
1763}
1764
1765/// Detect amplitude modulation using Morlet wavelet transform.
1766///
1767/// Uses continuous wavelet transform at the seasonal period to extract
1768/// time-varying amplitude. This method is more robust to noise and can
1769/// better handle non-stationary signals compared to Hilbert transform.
1770///
1771/// # Arguments
1772/// * `data` - Column-major matrix (n x m)
1773/// * `n` - Number of samples
1774/// * `m` - Number of evaluation points
1775/// * `argvals` - Evaluation points
1776/// * `period` - Seasonal period in argvals units
1777/// * `modulation_threshold` - CV threshold for detecting modulation (default: 0.15)
1778/// * `seasonality_threshold` - Strength threshold for seasonality (default: 0.3)
1779///
1780/// # Returns
1781/// `WaveletAmplitudeResult` containing detection results and wavelet amplitude curve
1782///
1783/// # Notes
1784/// - Uses Morlet wavelet with ω₀ = 6 (standard choice)
1785/// - The scale parameter is derived from the period: scale = period * ω₀ / (2π)
1786/// - This relates to how wavelets measure period: for Morlet, period ≈ scale * 2π / ω₀
1787pub fn detect_amplitude_modulation_wavelet(
1788    data: &[f64],
1789    n: usize,
1790    m: usize,
1791    argvals: &[f64],
1792    period: f64,
1793    modulation_threshold: f64,
1794    seasonality_threshold: f64,
1795) -> WaveletAmplitudeResult {
1796    let empty_result = WaveletAmplitudeResult {
1797        is_seasonal: false,
1798        seasonal_strength: 0.0,
1799        has_modulation: false,
1800        modulation_type: ModulationType::NonSeasonal,
1801        modulation_score: 0.0,
1802        amplitude_trend: 0.0,
1803        wavelet_amplitude: Vec::new(),
1804        time_points: Vec::new(),
1805        scale: 0.0,
1806    };
1807
1808    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1809        return empty_result;
1810    }
1811
1812    // Step 1: Check if seasonality exists using spectral method
1813    let overall_strength = seasonal_strength_spectral(data, n, m, argvals, period);
1814
1815    if overall_strength < seasonality_threshold {
1816        return WaveletAmplitudeResult {
1817            is_seasonal: false,
1818            seasonal_strength: overall_strength,
1819            has_modulation: false,
1820            modulation_type: ModulationType::NonSeasonal,
1821            modulation_score: 0.0,
1822            amplitude_trend: 0.0,
1823            wavelet_amplitude: Vec::new(),
1824            time_points: argvals.to_vec(),
1825            scale: 0.0,
1826        };
1827    }
1828
1829    // Step 2: Compute mean curve
1830    let mean_curve = compute_mean_curve(data, n, m);
1831
1832    // Remove DC component
1833    let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1834    let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
1835
1836    // Step 3: Compute wavelet transform at the seasonal period
1837    // For Morlet wavelet: period = scale * 2π / ω₀, so scale = period * ω₀ / (2π)
1838    let omega0 = 6.0; // Standard Morlet parameter
1839    let scale = period * omega0 / (2.0 * PI);
1840
1841    // Use FFT-based CWT for efficiency
1842    let wavelet_coeffs = cwt_morlet_fft(&detrended, argvals, scale, omega0);
1843
1844    if wavelet_coeffs.is_empty() {
1845        return WaveletAmplitudeResult {
1846            is_seasonal: true,
1847            seasonal_strength: overall_strength,
1848            has_modulation: false,
1849            modulation_type: ModulationType::Stable,
1850            modulation_score: 0.0,
1851            amplitude_trend: 0.0,
1852            wavelet_amplitude: Vec::new(),
1853            time_points: argvals.to_vec(),
1854            scale,
1855        };
1856    }
1857
1858    // Step 4: Extract amplitude (magnitude of wavelet coefficients)
1859    let wavelet_amplitude: Vec<f64> = wavelet_coeffs.iter().map(|c| c.norm()).collect();
1860
1861    // Step 5: Compute statistics on amplitude (skip edges)
1862    let (interior_start, interior_end) = match interior_bounds(m) {
1863        Some((s, e)) if e > s + 4 => (s, e),
1864        _ => {
1865            return WaveletAmplitudeResult {
1866                is_seasonal: true,
1867                seasonal_strength: overall_strength,
1868                has_modulation: false,
1869                modulation_type: ModulationType::Stable,
1870                modulation_score: 0.0,
1871                amplitude_trend: 0.0,
1872                wavelet_amplitude,
1873                time_points: argvals.to_vec(),
1874                scale,
1875            };
1876        }
1877    };
1878
1879    let interior_amp = &wavelet_amplitude[interior_start..interior_end];
1880    let interior_times = &argvals[interior_start..interior_end];
1881    let n_interior = interior_amp.len() as f64;
1882
1883    let mean_amp = interior_amp.iter().sum::<f64>() / n_interior;
1884
1885    // Coefficient of variation
1886    let variance = interior_amp
1887        .iter()
1888        .map(|&a| (a - mean_amp).powi(2))
1889        .sum::<f64>()
1890        / n_interior;
1891    let std_amp = variance.sqrt();
1892    let modulation_score = if mean_amp > 1e-10 {
1893        std_amp / mean_amp
1894    } else {
1895        0.0
1896    };
1897
1898    // Step 6: Compute trend
1899    let t_mean = interior_times.iter().sum::<f64>() / n_interior;
1900    let mut cov_ta = 0.0;
1901    let mut var_t = 0.0;
1902    for (&t, &a) in interior_times.iter().zip(interior_amp.iter()) {
1903        cov_ta += (t - t_mean) * (a - mean_amp);
1904        var_t += (t - t_mean).powi(2);
1905    }
1906    let slope = if var_t > 1e-10 { cov_ta / var_t } else { 0.0 };
1907
1908    let time_span = interior_times.last().unwrap_or(&1.0) - interior_times.first().unwrap_or(&0.0);
1909    let amplitude_trend = if mean_amp > 1e-10 && time_span > 1e-10 {
1910        (slope * time_span / mean_amp).clamp(-1.0, 1.0)
1911    } else {
1912        0.0
1913    };
1914
1915    // Step 7: Classify modulation type
1916    let has_modulation = modulation_score > modulation_threshold;
1917    let modulation_type = if !has_modulation {
1918        ModulationType::Stable
1919    } else if amplitude_trend > 0.3 {
1920        ModulationType::Emerging
1921    } else if amplitude_trend < -0.3 {
1922        ModulationType::Fading
1923    } else {
1924        ModulationType::Oscillating
1925    };
1926
1927    WaveletAmplitudeResult {
1928        is_seasonal: true,
1929        seasonal_strength: overall_strength,
1930        has_modulation,
1931        modulation_type,
1932        modulation_score,
1933        amplitude_trend,
1934        wavelet_amplitude,
1935        time_points: argvals.to_vec(),
1936        scale,
1937    }
1938}
1939
1940// ============================================================================
1941// Instantaneous Period
1942// ============================================================================
1943
1944/// Estimate instantaneous period using Hilbert transform.
1945///
1946/// For series with drifting/changing period, this computes the period
1947/// at each time point using the analytic signal.
1948///
1949/// # Arguments
1950/// * `data` - Column-major matrix (n x m)
1951/// * `n` - Number of samples
1952/// * `m` - Number of evaluation points
1953/// * `argvals` - Evaluation points
1954pub fn instantaneous_period(
1955    data: &[f64],
1956    n: usize,
1957    m: usize,
1958    argvals: &[f64],
1959) -> InstantaneousPeriod {
1960    if n == 0 || m < 4 || argvals.len() != m {
1961        return InstantaneousPeriod {
1962            period: Vec::new(),
1963            frequency: Vec::new(),
1964            amplitude: Vec::new(),
1965        };
1966    }
1967
1968    // Compute mean curve
1969    let mean_curve = compute_mean_curve(data, n, m);
1970
1971    // Remove DC component (detrend by subtracting mean)
1972    let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1973    let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
1974
1975    // Compute analytic signal via Hilbert transform
1976    let analytic = hilbert_transform(&detrended);
1977
1978    // Extract instantaneous amplitude and phase
1979    let amplitude: Vec<f64> = analytic.iter().map(|c| c.norm()).collect();
1980
1981    let phase: Vec<f64> = analytic.iter().map(|c| c.im.atan2(c.re)).collect();
1982
1983    // Unwrap phase
1984    let unwrapped_phase = unwrap_phase(&phase);
1985
1986    // Compute instantaneous frequency (derivative of phase)
1987    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1988    let mut inst_freq = vec![0.0; m];
1989
1990    // Central differences for interior, forward/backward at boundaries
1991    if m > 1 {
1992        inst_freq[0] = (unwrapped_phase[1] - unwrapped_phase[0]) / dt / (2.0 * PI);
1993    }
1994    for j in 1..(m - 1) {
1995        inst_freq[j] = (unwrapped_phase[j + 1] - unwrapped_phase[j - 1]) / (2.0 * dt) / (2.0 * PI);
1996    }
1997    if m > 1 {
1998        inst_freq[m - 1] = (unwrapped_phase[m - 1] - unwrapped_phase[m - 2]) / dt / (2.0 * PI);
1999    }
2000
2001    // Period = 1/frequency (handle near-zero frequencies)
2002    let period: Vec<f64> = inst_freq
2003        .iter()
2004        .map(|&f| {
2005            if f.abs() > 1e-10 {
2006                (1.0 / f).abs()
2007            } else {
2008                f64::INFINITY
2009            }
2010        })
2011        .collect();
2012
2013    InstantaneousPeriod {
2014        period,
2015        frequency: inst_freq,
2016        amplitude,
2017    }
2018}
2019
2020// ============================================================================
2021// Peak Timing Variability Analysis
2022// ============================================================================
2023
2024/// Analyze peak timing variability across cycles.
2025///
2026/// For short series (e.g., 3-5 years of yearly data), this function detects
2027/// one peak per cycle and analyzes how peak timing varies between cycles.
2028///
2029/// # Arguments
2030/// * `data` - Column-major matrix (n x m)
2031/// * `n` - Number of samples
2032/// * `m` - Number of evaluation points
2033/// * `argvals` - Evaluation points
2034/// * `period` - Known period (e.g., 365 for daily data with yearly seasonality)
2035/// * `smooth_nbasis` - Number of Fourier basis functions for smoothing.
2036///   If None, uses GCV for automatic selection.
2037///
2038/// # Returns
2039/// Peak timing result with variability metrics
2040pub fn analyze_peak_timing(
2041    data: &[f64],
2042    n: usize,
2043    m: usize,
2044    argvals: &[f64],
2045    period: f64,
2046    smooth_nbasis: Option<usize>,
2047) -> PeakTimingResult {
2048    if n == 0 || m < 3 || argvals.len() != m || period <= 0.0 {
2049        return PeakTimingResult {
2050            peak_times: Vec::new(),
2051            peak_values: Vec::new(),
2052            normalized_timing: Vec::new(),
2053            mean_timing: f64::NAN,
2054            std_timing: f64::NAN,
2055            range_timing: f64::NAN,
2056            variability_score: f64::NAN,
2057            timing_trend: f64::NAN,
2058            cycle_indices: Vec::new(),
2059        };
2060    }
2061
2062    // Detect peaks with minimum distance constraint of 0.7 * period
2063    // This ensures we get at most one peak per cycle
2064    let min_distance = period * 0.7;
2065    let peaks = detect_peaks(
2066        data,
2067        n,
2068        m,
2069        argvals,
2070        Some(min_distance),
2071        None, // No prominence filter
2072        true, // Smooth first with Fourier basis
2073        smooth_nbasis,
2074    );
2075
2076    // Use the first sample's peaks (for mean curve analysis)
2077    // If multiple samples, we take the mean curve which is effectively in sample 0
2078    let sample_peaks = if peaks.peaks.is_empty() {
2079        Vec::new()
2080    } else {
2081        peaks.peaks[0].clone()
2082    };
2083
2084    if sample_peaks.is_empty() {
2085        return PeakTimingResult {
2086            peak_times: Vec::new(),
2087            peak_values: Vec::new(),
2088            normalized_timing: Vec::new(),
2089            mean_timing: f64::NAN,
2090            std_timing: f64::NAN,
2091            range_timing: f64::NAN,
2092            variability_score: f64::NAN,
2093            timing_trend: f64::NAN,
2094            cycle_indices: Vec::new(),
2095        };
2096    }
2097
2098    let peak_times: Vec<f64> = sample_peaks.iter().map(|p| p.time).collect();
2099    let peak_values: Vec<f64> = sample_peaks.iter().map(|p| p.value).collect();
2100
2101    // Compute normalized timing (position within cycle, 0-1 scale)
2102    let t_start = argvals[0];
2103    let normalized_timing: Vec<f64> = peak_times
2104        .iter()
2105        .map(|&t| {
2106            let cycle_pos = (t - t_start) % period;
2107            cycle_pos / period
2108        })
2109        .collect();
2110
2111    // Compute cycle indices (1-indexed)
2112    let cycle_indices: Vec<usize> = peak_times
2113        .iter()
2114        .map(|&t| ((t - t_start) / period).floor() as usize + 1)
2115        .collect();
2116
2117    // Compute statistics
2118    let n_peaks = normalized_timing.len() as f64;
2119    let mean_timing = normalized_timing.iter().sum::<f64>() / n_peaks;
2120
2121    let variance: f64 = normalized_timing
2122        .iter()
2123        .map(|&x| (x - mean_timing).powi(2))
2124        .sum::<f64>()
2125        / n_peaks;
2126    let std_timing = variance.sqrt();
2127
2128    let min_timing = normalized_timing
2129        .iter()
2130        .cloned()
2131        .fold(f64::INFINITY, f64::min);
2132    let max_timing = normalized_timing
2133        .iter()
2134        .cloned()
2135        .fold(f64::NEG_INFINITY, f64::max);
2136    let range_timing = max_timing - min_timing;
2137
2138    // Variability score: normalized std deviation
2139    // Max possible std for uniform in [0,1] is ~0.289, so we scale by that
2140    // But since peaks cluster, we use 0.1 as "high" variability threshold
2141    let variability_score = (std_timing / 0.1).min(1.0);
2142
2143    // Timing trend: linear regression of normalized timing on cycle index
2144    let cycle_idx_f64: Vec<f64> = cycle_indices.iter().map(|&i| i as f64).collect();
2145    let timing_trend = linear_slope(&cycle_idx_f64, &normalized_timing);
2146
2147    PeakTimingResult {
2148        peak_times,
2149        peak_values,
2150        normalized_timing,
2151        mean_timing,
2152        std_timing,
2153        range_timing,
2154        variability_score,
2155        timing_trend,
2156        cycle_indices,
2157    }
2158}
2159
2160// ============================================================================
2161// Seasonality Classification
2162// ============================================================================
2163
2164/// Classify the type of seasonality in functional data.
2165///
2166/// This is particularly useful for short series (3-5 years) where you need
2167/// to identify:
2168/// - Whether seasonality is present
2169/// - Whether peak timing is stable or variable
2170/// - Which cycles have weak or missing seasonality
2171///
2172/// # Arguments
2173/// * `data` - Column-major matrix (n x m)
2174/// * `n` - Number of samples
2175/// * `m` - Number of evaluation points
2176/// * `argvals` - Evaluation points
2177/// * `period` - Known seasonal period
2178/// * `strength_threshold` - Threshold for seasonal/non-seasonal (default: 0.3)
2179/// * `timing_threshold` - Max std of normalized timing for "stable" (default: 0.05)
2180///
2181/// # Returns
2182/// Seasonality classification with type and diagnostics
2183pub fn classify_seasonality(
2184    data: &[f64],
2185    n: usize,
2186    m: usize,
2187    argvals: &[f64],
2188    period: f64,
2189    strength_threshold: Option<f64>,
2190    timing_threshold: Option<f64>,
2191) -> SeasonalityClassification {
2192    let strength_thresh = strength_threshold.unwrap_or(0.3);
2193    let timing_thresh = timing_threshold.unwrap_or(0.05);
2194
2195    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
2196        return SeasonalityClassification {
2197            is_seasonal: false,
2198            has_stable_timing: false,
2199            timing_variability: f64::NAN,
2200            seasonal_strength: f64::NAN,
2201            cycle_strengths: Vec::new(),
2202            weak_seasons: Vec::new(),
2203            classification: SeasonalType::NonSeasonal,
2204            peak_timing: None,
2205        };
2206    }
2207
2208    // Compute overall seasonal strength
2209    let overall_strength = seasonal_strength_variance(data, n, m, argvals, period, 3);
2210
2211    // Compute per-cycle strength
2212    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
2213    let _points_per_cycle = (period / dt).round() as usize;
2214    let t_start = argvals[0];
2215    let t_end = argvals[m - 1];
2216    let n_cycles = ((t_end - t_start) / period).floor() as usize;
2217
2218    let mut cycle_strengths = Vec::with_capacity(n_cycles);
2219    let mut weak_seasons = Vec::new();
2220
2221    for cycle in 0..n_cycles {
2222        let cycle_start = t_start + cycle as f64 * period;
2223        let cycle_end = cycle_start + period;
2224
2225        // Find indices for this cycle
2226        let start_idx = argvals.iter().position(|&t| t >= cycle_start).unwrap_or(0);
2227        let end_idx = argvals.iter().position(|&t| t > cycle_end).unwrap_or(m);
2228
2229        let cycle_m = end_idx - start_idx;
2230        if cycle_m < 4 {
2231            cycle_strengths.push(f64::NAN);
2232            continue;
2233        }
2234
2235        // Extract cycle data
2236        let cycle_data: Vec<f64> = (start_idx..end_idx)
2237            .flat_map(|j| (0..n).map(move |i| data[i + j * n]))
2238            .collect();
2239        let cycle_argvals: Vec<f64> = argvals[start_idx..end_idx].to_vec();
2240
2241        let strength =
2242            seasonal_strength_variance(&cycle_data, n, cycle_m, &cycle_argvals, period, 3);
2243
2244        cycle_strengths.push(strength);
2245
2246        if strength < strength_thresh {
2247            weak_seasons.push(cycle);
2248        }
2249    }
2250
2251    // Analyze peak timing
2252    let peak_timing = analyze_peak_timing(data, n, m, argvals, period, None);
2253
2254    // Determine classification
2255    let is_seasonal = overall_strength >= strength_thresh;
2256    let has_stable_timing = peak_timing.std_timing <= timing_thresh;
2257    let timing_variability = peak_timing.variability_score;
2258
2259    // Classify based on patterns
2260    let n_weak = weak_seasons.len();
2261    let classification = if !is_seasonal {
2262        SeasonalType::NonSeasonal
2263    } else if n_cycles > 0 && n_weak as f64 / n_cycles as f64 > 0.3 {
2264        // More than 30% of cycles are weak
2265        SeasonalType::IntermittentSeasonal
2266    } else if !has_stable_timing {
2267        SeasonalType::VariableTiming
2268    } else {
2269        SeasonalType::StableSeasonal
2270    };
2271
2272    SeasonalityClassification {
2273        is_seasonal,
2274        has_stable_timing,
2275        timing_variability,
2276        seasonal_strength: overall_strength,
2277        cycle_strengths,
2278        weak_seasons,
2279        classification,
2280        peak_timing: Some(peak_timing),
2281    }
2282}
2283
2284/// Detect seasonality changes with automatic threshold selection.
2285///
2286/// Uses Otsu's method or percentile-based threshold instead of a fixed value.
2287///
2288/// # Arguments
2289/// * `data` - Column-major matrix (n x m)
2290/// * `n` - Number of samples
2291/// * `m` - Number of evaluation points
2292/// * `argvals` - Evaluation points
2293/// * `period` - Seasonal period
2294/// * `threshold_method` - Method for threshold selection
2295/// * `window_size` - Window size for local strength estimation
2296/// * `min_duration` - Minimum duration to confirm a change
2297pub fn detect_seasonality_changes_auto(
2298    data: &[f64],
2299    n: usize,
2300    m: usize,
2301    argvals: &[f64],
2302    period: f64,
2303    threshold_method: ThresholdMethod,
2304    window_size: f64,
2305    min_duration: f64,
2306) -> ChangeDetectionResult {
2307    if n == 0 || m < 4 || argvals.len() != m {
2308        return ChangeDetectionResult {
2309            change_points: Vec::new(),
2310            strength_curve: Vec::new(),
2311        };
2312    }
2313
2314    // Compute time-varying seasonal strength
2315    let strength_curve = seasonal_strength_windowed(
2316        data,
2317        n,
2318        m,
2319        argvals,
2320        period,
2321        window_size,
2322        StrengthMethod::Variance,
2323    );
2324
2325    if strength_curve.is_empty() {
2326        return ChangeDetectionResult {
2327            change_points: Vec::new(),
2328            strength_curve: Vec::new(),
2329        };
2330    }
2331
2332    // Determine threshold
2333    let threshold = match threshold_method {
2334        ThresholdMethod::Fixed(t) => t,
2335        ThresholdMethod::Percentile(p) => {
2336            let mut sorted: Vec<f64> = strength_curve
2337                .iter()
2338                .copied()
2339                .filter(|x| x.is_finite())
2340                .collect();
2341            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2342            if sorted.is_empty() {
2343                0.5
2344            } else {
2345                let idx = ((p / 100.0) * sorted.len() as f64) as usize;
2346                sorted[idx.min(sorted.len() - 1)]
2347            }
2348        }
2349        ThresholdMethod::Otsu => otsu_threshold(&strength_curve),
2350    };
2351
2352    // Now use the regular detection with computed threshold
2353    detect_seasonality_changes(
2354        data,
2355        n,
2356        m,
2357        argvals,
2358        period,
2359        threshold,
2360        window_size,
2361        min_duration,
2362    )
2363}
2364
2365#[cfg(test)]
2366mod tests {
2367    use super::*;
2368    use std::f64::consts::PI;
2369
2370    fn generate_sine(n: usize, m: usize, period: f64, argvals: &[f64]) -> Vec<f64> {
2371        let mut data = vec![0.0; n * m];
2372        for i in 0..n {
2373            for j in 0..m {
2374                data[i + j * n] = (2.0 * PI * argvals[j] / period).sin();
2375            }
2376        }
2377        data
2378    }
2379
2380    #[test]
2381    fn test_period_estimation_fft() {
2382        let m = 200;
2383        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
2384        let period = 2.0;
2385        let data = generate_sine(1, m, period, &argvals);
2386
2387        let estimate = estimate_period_fft(&data, 1, m, &argvals);
2388        assert!((estimate.period - period).abs() < 0.2);
2389        assert!(estimate.confidence > 1.0);
2390    }
2391
2392    #[test]
2393    fn test_peak_detection() {
2394        let m = 100;
2395        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
2396        let period = 2.0;
2397        let data = generate_sine(1, m, period, &argvals);
2398
2399        let result = detect_peaks(&data, 1, m, &argvals, Some(1.5), None, false, None);
2400
2401        // Should find approximately 5 peaks (10 / 2)
2402        assert!(!result.peaks[0].is_empty());
2403        assert!((result.mean_period - period).abs() < 0.3);
2404    }
2405
2406    #[test]
2407    fn test_peak_detection_known_sine() {
2408        // Pure sine wave: sin(2*pi*t/2) on [0, 10]
2409        // Peaks occur at t = period/4 + k*period = 0.5, 2.5, 4.5, 6.5, 8.5
2410        let m = 200; // High resolution for accurate detection
2411        let period = 2.0;
2412        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
2413        let data: Vec<f64> = argvals
2414            .iter()
2415            .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
2416            .collect();
2417
2418        let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
2419
2420        // Should find exactly 5 peaks
2421        assert_eq!(
2422            result.peaks[0].len(),
2423            5,
2424            "Expected 5 peaks, got {}. Peak times: {:?}",
2425            result.peaks[0].len(),
2426            result.peaks[0].iter().map(|p| p.time).collect::<Vec<_>>()
2427        );
2428
2429        // Check peak locations are close to expected
2430        let expected_times = [0.5, 2.5, 4.5, 6.5, 8.5];
2431        for (peak, expected) in result.peaks[0].iter().zip(expected_times.iter()) {
2432            assert!(
2433                (peak.time - expected).abs() < 0.15,
2434                "Peak at {:.3} not close to expected {:.3}",
2435                peak.time,
2436                expected
2437            );
2438        }
2439
2440        // Check mean period
2441        assert!(
2442            (result.mean_period - period).abs() < 0.1,
2443            "Mean period {:.3} not close to expected {:.3}",
2444            result.mean_period,
2445            period
2446        );
2447    }
2448
2449    #[test]
2450    fn test_peak_detection_with_min_distance() {
2451        // Same sine wave but with min_distance constraint
2452        let m = 200;
2453        let period = 2.0;
2454        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
2455        let data: Vec<f64> = argvals
2456            .iter()
2457            .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
2458            .collect();
2459
2460        // min_distance = 1.5 should still find all 5 peaks (spacing = 2.0)
2461        let result = detect_peaks(&data, 1, m, &argvals, Some(1.5), None, false, None);
2462        assert_eq!(
2463            result.peaks[0].len(),
2464            5,
2465            "With min_distance=1.5, expected 5 peaks, got {}",
2466            result.peaks[0].len()
2467        );
2468
2469        // min_distance = 2.5 should find fewer peaks
2470        let result2 = detect_peaks(&data, 1, m, &argvals, Some(2.5), None, false, None);
2471        assert!(
2472            result2.peaks[0].len() < 5,
2473            "With min_distance=2.5, expected fewer than 5 peaks, got {}",
2474            result2.peaks[0].len()
2475        );
2476    }
2477
2478    #[test]
2479    fn test_peak_detection_period_1() {
2480        // Higher frequency: sin(2*pi*t/1) on [0, 10]
2481        // Peaks at t = 0.25, 1.25, 2.25, ..., 9.25 (10 peaks)
2482        let m = 400; // Higher resolution for higher frequency
2483        let period = 1.0;
2484        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
2485        let data: Vec<f64> = argvals
2486            .iter()
2487            .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
2488            .collect();
2489
2490        let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
2491
2492        // Should find 10 peaks
2493        assert_eq!(
2494            result.peaks[0].len(),
2495            10,
2496            "Expected 10 peaks, got {}",
2497            result.peaks[0].len()
2498        );
2499
2500        // Check mean period
2501        assert!(
2502            (result.mean_period - period).abs() < 0.1,
2503            "Mean period {:.3} not close to expected {:.3}",
2504            result.mean_period,
2505            period
2506        );
2507    }
2508
2509    #[test]
2510    fn test_peak_detection_shifted_sine() {
2511        // Shifted sine: sin(2*pi*t/2) + 1 on [0, 10]
2512        // Same peak locations, just shifted up
2513        let m = 200;
2514        let period = 2.0;
2515        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
2516        let data: Vec<f64> = argvals
2517            .iter()
2518            .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin() + 1.0)
2519            .collect();
2520
2521        let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
2522
2523        // Should still find 5 peaks
2524        assert_eq!(
2525            result.peaks[0].len(),
2526            5,
2527            "Expected 5 peaks for shifted sine, got {}",
2528            result.peaks[0].len()
2529        );
2530
2531        // Peak values should be around 2.0 (max of sin + 1)
2532        for peak in &result.peaks[0] {
2533            assert!(
2534                (peak.value - 2.0).abs() < 0.05,
2535                "Peak value {:.3} not close to expected 2.0",
2536                peak.value
2537            );
2538        }
2539    }
2540
2541    #[test]
2542    fn test_peak_detection_prominence() {
2543        // Create signal with peaks of different heights
2544        // Large peaks at odd positions, small peaks at even positions
2545        let m = 200;
2546        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
2547        let data: Vec<f64> = argvals
2548            .iter()
2549            .map(|&t| {
2550                let base = (2.0 * std::f64::consts::PI * t / 2.0).sin();
2551                // Add small ripples
2552                let ripple = 0.1 * (2.0 * std::f64::consts::PI * t * 4.0).sin();
2553                base + ripple
2554            })
2555            .collect();
2556
2557        // Without prominence filter, may find extra peaks from ripples
2558        let result_no_filter = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
2559
2560        // With prominence filter, should only find major peaks
2561        let result_filtered = detect_peaks(&data, 1, m, &argvals, None, Some(0.5), false, None);
2562
2563        // Filtered should have fewer or equal peaks
2564        assert!(
2565            result_filtered.peaks[0].len() <= result_no_filter.peaks[0].len(),
2566            "Prominence filter should reduce peak count"
2567        );
2568    }
2569
2570    #[test]
2571    fn test_peak_detection_different_amplitudes() {
2572        // Test with various amplitudes: 0.5, 1.0, 2.0, 5.0
2573        let m = 200;
2574        let period = 2.0;
2575        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
2576
2577        for amplitude in [0.5, 1.0, 2.0, 5.0] {
2578            let data: Vec<f64> = argvals
2579                .iter()
2580                .map(|&t| amplitude * (2.0 * std::f64::consts::PI * t / period).sin())
2581                .collect();
2582
2583            let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
2584
2585            assert_eq!(
2586                result.peaks[0].len(),
2587                5,
2588                "Amplitude {} should still find 5 peaks",
2589                amplitude
2590            );
2591
2592            // Peak values should be close to amplitude
2593            for peak in &result.peaks[0] {
2594                assert!(
2595                    (peak.value - amplitude).abs() < 0.1,
2596                    "Peak value {:.3} should be close to amplitude {}",
2597                    peak.value,
2598                    amplitude
2599                );
2600            }
2601        }
2602    }
2603
2604    #[test]
2605    fn test_peak_detection_varying_frequency() {
2606        // Signal with varying frequency: chirp-like signal
2607        // Peaks get closer together over time
2608        let m = 400;
2609        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
2610
2611        // Frequency increases linearly: f(t) = 0.5 + 0.1*t
2612        // Phase integral: phi(t) = 2*pi * (0.5*t + 0.05*t^2)
2613        let data: Vec<f64> = argvals
2614            .iter()
2615            .map(|&t| {
2616                let phase = 2.0 * std::f64::consts::PI * (0.5 * t + 0.05 * t * t);
2617                phase.sin()
2618            })
2619            .collect();
2620
2621        let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
2622
2623        // Should find multiple peaks with decreasing spacing
2624        assert!(
2625            result.peaks[0].len() >= 5,
2626            "Should find at least 5 peaks, got {}",
2627            result.peaks[0].len()
2628        );
2629
2630        // Verify inter-peak distances decrease over time
2631        let distances = &result.inter_peak_distances[0];
2632        if distances.len() >= 3 {
2633            // Later peaks should be closer than earlier peaks
2634            let early_avg = (distances[0] + distances[1]) / 2.0;
2635            let late_avg = (distances[distances.len() - 2] + distances[distances.len() - 1]) / 2.0;
2636            assert!(
2637                late_avg < early_avg,
2638                "Later peaks should be closer: early avg={:.3}, late avg={:.3}",
2639                early_avg,
2640                late_avg
2641            );
2642        }
2643    }
2644
2645    #[test]
2646    fn test_peak_detection_sum_of_sines() {
2647        // Sum of two sine waves with different periods creates non-uniform peak spacing
2648        // y = sin(2*pi*t/2) + 0.5*sin(2*pi*t/3)
2649        let m = 300;
2650        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 12.0 / (m - 1) as f64).collect();
2651
2652        let data: Vec<f64> = argvals
2653            .iter()
2654            .map(|&t| {
2655                (2.0 * std::f64::consts::PI * t / 2.0).sin()
2656                    + 0.5 * (2.0 * std::f64::consts::PI * t / 3.0).sin()
2657            })
2658            .collect();
2659
2660        let result = detect_peaks(&data, 1, m, &argvals, Some(1.0), None, false, None);
2661
2662        // Should find peaks (exact count depends on interference pattern)
2663        assert!(
2664            result.peaks[0].len() >= 4,
2665            "Should find at least 4 peaks, got {}",
2666            result.peaks[0].len()
2667        );
2668
2669        // Inter-peak distances should vary
2670        let distances = &result.inter_peak_distances[0];
2671        if distances.len() >= 2 {
2672            let min_dist = distances.iter().cloned().fold(f64::INFINITY, f64::min);
2673            let max_dist = distances.iter().cloned().fold(0.0, f64::max);
2674            assert!(
2675                max_dist > min_dist * 1.1,
2676                "Distances should vary: min={:.3}, max={:.3}",
2677                min_dist,
2678                max_dist
2679            );
2680        }
2681    }
2682
2683    #[test]
2684    fn test_seasonal_strength() {
2685        let m = 200;
2686        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
2687        let period = 2.0;
2688        let data = generate_sine(1, m, period, &argvals);
2689
2690        let strength = seasonal_strength_variance(&data, 1, m, &argvals, period, 3);
2691        // Pure sine should have high seasonal strength
2692        assert!(strength > 0.8);
2693
2694        let strength_spectral = seasonal_strength_spectral(&data, 1, m, &argvals, period);
2695        assert!(strength_spectral > 0.5);
2696    }
2697
2698    #[test]
2699    fn test_instantaneous_period() {
2700        let m = 200;
2701        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
2702        let period = 2.0;
2703        let data = generate_sine(1, m, period, &argvals);
2704
2705        let result = instantaneous_period(&data, 1, m, &argvals);
2706
2707        // Check that instantaneous period is close to true period (away from boundaries)
2708        let mid_period = result.period[m / 2];
2709        assert!(
2710            (mid_period - period).abs() < 0.5,
2711            "Expected period ~{}, got {}",
2712            period,
2713            mid_period
2714        );
2715    }
2716
2717    #[test]
2718    fn test_peak_timing_analysis() {
2719        // Generate 5 cycles of sine with period 2
2720        let m = 500;
2721        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.02).collect();
2722        let period = 2.0;
2723        let data = generate_sine(1, m, period, &argvals);
2724
2725        let result = analyze_peak_timing(&data, 1, m, &argvals, period, Some(11));
2726
2727        // Should find approximately 5 peaks
2728        assert!(!result.peak_times.is_empty());
2729        // Normalized timing should be around 0.25 (peak of sin at π/2)
2730        assert!(result.mean_timing.is_finite());
2731        // Pure sine should have low timing variability
2732        assert!(result.std_timing < 0.1 || result.std_timing.is_nan());
2733    }
2734
2735    #[test]
2736    fn test_seasonality_classification() {
2737        let m = 400;
2738        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
2739        let period = 2.0;
2740        let data = generate_sine(1, m, period, &argvals);
2741
2742        let result = classify_seasonality(&data, 1, m, &argvals, period, None, None);
2743
2744        assert!(result.is_seasonal);
2745        assert!(result.seasonal_strength > 0.5);
2746        assert!(matches!(
2747            result.classification,
2748            SeasonalType::StableSeasonal | SeasonalType::VariableTiming
2749        ));
2750    }
2751
2752    #[test]
2753    fn test_otsu_threshold() {
2754        // Bimodal distribution: mix of low (0.1-0.2) and high (0.7-0.9) values
2755        let values = vec![
2756            0.1, 0.12, 0.15, 0.18, 0.11, 0.14, 0.7, 0.75, 0.8, 0.85, 0.9, 0.72,
2757        ];
2758
2759        let threshold = otsu_threshold(&values);
2760
2761        // Threshold should be between the two modes
2762        // Due to small sample size, Otsu's method may not find optimal threshold
2763        // Just verify it returns a reasonable value in the data range
2764        assert!(threshold >= 0.1, "Threshold {} should be >= 0.1", threshold);
2765        assert!(threshold <= 0.9, "Threshold {} should be <= 0.9", threshold);
2766    }
2767
2768    #[test]
2769    fn test_gcv_fourier_nbasis_selection() {
2770        let m = 100;
2771        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
2772
2773        // Noisy sine wave
2774        let mut data = vec![0.0; m];
2775        for j in 0..m {
2776            data[j] = (2.0 * PI * argvals[j] / 2.0).sin() + 0.1 * (j as f64 * 0.3).sin();
2777        }
2778
2779        let nbasis = crate::basis::select_fourier_nbasis_gcv(&data, 1, m, &argvals, 5, 25);
2780
2781        // nbasis should be reasonable (between min and max)
2782        assert!(nbasis >= 5);
2783        assert!(nbasis <= 25);
2784    }
2785
2786    #[test]
2787    fn test_detect_multiple_periods() {
2788        let m = 400;
2789        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect(); // 0 to 20
2790
2791        // Signal with two periods: 2 and 7
2792        let period1 = 2.0;
2793        let period2 = 7.0;
2794        let mut data = vec![0.0; m];
2795        for j in 0..m {
2796            data[j] = (2.0 * PI * argvals[j] / period1).sin()
2797                + 0.6 * (2.0 * PI * argvals[j] / period2).sin();
2798        }
2799
2800        // Use higher min_strength threshold to properly stop after real periods
2801        let detected = detect_multiple_periods(&data, 1, m, &argvals, 5, 0.4, 0.20);
2802
2803        // Should detect exactly 2 periods with these thresholds
2804        assert!(
2805            detected.len() >= 2,
2806            "Expected at least 2 periods, found {}",
2807            detected.len()
2808        );
2809
2810        // Check that both periods were detected (order depends on amplitude)
2811        let periods: Vec<f64> = detected.iter().map(|d| d.period).collect();
2812        let has_period1 = periods.iter().any(|&p| (p - period1).abs() < 0.3);
2813        let has_period2 = periods.iter().any(|&p| (p - period2).abs() < 0.5);
2814
2815        assert!(
2816            has_period1,
2817            "Expected to find period ~{}, got {:?}",
2818            period1, periods
2819        );
2820        assert!(
2821            has_period2,
2822            "Expected to find period ~{}, got {:?}",
2823            period2, periods
2824        );
2825
2826        // Verify first detected has higher amplitude (amplitude 1.0 vs 0.6)
2827        assert!(
2828            detected[0].amplitude > detected[1].amplitude,
2829            "First detected should have higher amplitude"
2830        );
2831
2832        // Each detected period should have strength and confidence info
2833        for d in &detected {
2834            assert!(
2835                d.strength > 0.0,
2836                "Detected period should have positive strength"
2837            );
2838            assert!(
2839                d.confidence > 0.0,
2840                "Detected period should have positive confidence"
2841            );
2842            assert!(
2843                d.amplitude > 0.0,
2844                "Detected period should have positive amplitude"
2845            );
2846        }
2847    }
2848
2849    // ========================================================================
2850    // Amplitude Modulation Detection Tests
2851    // ========================================================================
2852
2853    #[test]
2854    fn test_amplitude_modulation_stable() {
2855        // Constant amplitude seasonal signal - should detect as Stable
2856        let m = 200;
2857        let period = 0.2;
2858        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
2859
2860        // Constant amplitude sine wave
2861        let data: Vec<f64> = argvals
2862            .iter()
2863            .map(|&t| (2.0 * PI * t / period).sin())
2864            .collect();
2865
2866        let result = detect_amplitude_modulation(
2867            &data, 1, m, &argvals, period, 0.15, // modulation threshold
2868            0.3,  // seasonality threshold
2869        );
2870
2871        eprintln!(
2872            "Stable test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
2873            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
2874        );
2875
2876        assert!(result.is_seasonal, "Should detect seasonality");
2877        assert!(
2878            !result.has_modulation,
2879            "Constant amplitude should not have modulation, got score={:.4}",
2880            result.modulation_score
2881        );
2882        assert_eq!(
2883            result.modulation_type,
2884            ModulationType::Stable,
2885            "Should be classified as Stable"
2886        );
2887    }
2888
2889    #[test]
2890    fn test_amplitude_modulation_emerging() {
2891        // Amplitude increases over time (emerging seasonality)
2892        let m = 200;
2893        let period = 0.2;
2894        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
2895
2896        // Amplitude grows from 0.2 to 1.0
2897        let data: Vec<f64> = argvals
2898            .iter()
2899            .map(|&t| {
2900                let amplitude = 0.2 + 0.8 * t; // Linear increase
2901                amplitude * (2.0 * PI * t / period).sin()
2902            })
2903            .collect();
2904
2905        let result = detect_amplitude_modulation(&data, 1, m, &argvals, period, 0.15, 0.2);
2906
2907        eprintln!(
2908            "Emerging test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
2909            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
2910        );
2911
2912        assert!(result.is_seasonal, "Should detect seasonality");
2913        assert!(
2914            result.has_modulation,
2915            "Growing amplitude should have modulation, score={:.4}",
2916            result.modulation_score
2917        );
2918        assert_eq!(
2919            result.modulation_type,
2920            ModulationType::Emerging,
2921            "Should be classified as Emerging, trend={:.4}",
2922            result.amplitude_trend
2923        );
2924        assert!(
2925            result.amplitude_trend > 0.0,
2926            "Trend should be positive for emerging"
2927        );
2928    }
2929
2930    #[test]
2931    fn test_amplitude_modulation_fading() {
2932        // Amplitude decreases over time (fading seasonality)
2933        let m = 200;
2934        let period = 0.2;
2935        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
2936
2937        // Amplitude decreases from 1.0 to 0.2
2938        let data: Vec<f64> = argvals
2939            .iter()
2940            .map(|&t| {
2941                let amplitude = 1.0 - 0.8 * t; // Linear decrease
2942                amplitude * (2.0 * PI * t / period).sin()
2943            })
2944            .collect();
2945
2946        let result = detect_amplitude_modulation(&data, 1, m, &argvals, period, 0.15, 0.2);
2947
2948        eprintln!(
2949            "Fading test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
2950            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
2951        );
2952
2953        assert!(result.is_seasonal, "Should detect seasonality");
2954        assert!(
2955            result.has_modulation,
2956            "Fading amplitude should have modulation"
2957        );
2958        assert_eq!(
2959            result.modulation_type,
2960            ModulationType::Fading,
2961            "Should be classified as Fading, trend={:.4}",
2962            result.amplitude_trend
2963        );
2964        assert!(
2965            result.amplitude_trend < 0.0,
2966            "Trend should be negative for fading"
2967        );
2968    }
2969
2970    #[test]
2971    fn test_amplitude_modulation_oscillating() {
2972        // Amplitude oscillates (neither purely emerging nor fading)
2973        let m = 200;
2974        let period = 0.1;
2975        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
2976
2977        // Amplitude oscillates: high-low-high-low pattern
2978        let data: Vec<f64> = argvals
2979            .iter()
2980            .map(|&t| {
2981                let amplitude = 0.5 + 0.4 * (2.0 * PI * t * 2.0).sin(); // 2 modulation cycles
2982                amplitude * (2.0 * PI * t / period).sin()
2983            })
2984            .collect();
2985
2986        let result = detect_amplitude_modulation(&data, 1, m, &argvals, period, 0.15, 0.2);
2987
2988        eprintln!(
2989            "Oscillating test: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
2990            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
2991        );
2992
2993        assert!(result.is_seasonal, "Should detect seasonality");
2994        // Oscillating has high variation but near-zero trend
2995        if result.has_modulation {
2996            // Trend should be near zero for oscillating
2997            assert!(
2998                result.amplitude_trend.abs() < 0.5,
2999                "Trend should be small for oscillating"
3000            );
3001        }
3002    }
3003
3004    #[test]
3005    fn test_amplitude_modulation_non_seasonal() {
3006        // Pure noise - no seasonality
3007        let m = 100;
3008        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
3009
3010        // Random noise (use simple pseudo-random)
3011        let data: Vec<f64> = (0..m)
3012            .map(|i| ((i as f64 * 1.618).sin() * 100.0).fract())
3013            .collect();
3014
3015        let result = detect_amplitude_modulation(
3016            &data, 1, m, &argvals, 0.2, // arbitrary period
3017            0.15, 0.3,
3018        );
3019
3020        assert!(
3021            !result.is_seasonal,
3022            "Noise should not be detected as seasonal"
3023        );
3024        assert_eq!(
3025            result.modulation_type,
3026            ModulationType::NonSeasonal,
3027            "Should be classified as NonSeasonal"
3028        );
3029    }
3030
3031    // ========================================================================
3032    // Wavelet-based Amplitude Modulation Detection Tests
3033    // ========================================================================
3034
3035    #[test]
3036    fn test_wavelet_amplitude_modulation_stable() {
3037        let m = 200;
3038        let period = 0.2;
3039        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
3040
3041        let data: Vec<f64> = argvals
3042            .iter()
3043            .map(|&t| (2.0 * PI * t / period).sin())
3044            .collect();
3045
3046        let result = detect_amplitude_modulation_wavelet(&data, 1, m, &argvals, period, 0.15, 0.3);
3047
3048        eprintln!(
3049            "Wavelet stable: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
3050            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
3051        );
3052
3053        assert!(result.is_seasonal, "Should detect seasonality");
3054        assert!(
3055            !result.has_modulation,
3056            "Constant amplitude should not have modulation, got score={:.4}",
3057            result.modulation_score
3058        );
3059    }
3060
3061    #[test]
3062    fn test_wavelet_amplitude_modulation_emerging() {
3063        let m = 200;
3064        let period = 0.2;
3065        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
3066
3067        // Amplitude grows from 0.2 to 1.0
3068        let data: Vec<f64> = argvals
3069            .iter()
3070            .map(|&t| {
3071                let amplitude = 0.2 + 0.8 * t;
3072                amplitude * (2.0 * PI * t / period).sin()
3073            })
3074            .collect();
3075
3076        let result = detect_amplitude_modulation_wavelet(&data, 1, m, &argvals, period, 0.15, 0.2);
3077
3078        eprintln!(
3079            "Wavelet emerging: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
3080            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
3081        );
3082
3083        assert!(result.is_seasonal, "Should detect seasonality");
3084        assert!(
3085            result.has_modulation,
3086            "Growing amplitude should have modulation"
3087        );
3088        assert!(
3089            result.amplitude_trend > 0.0,
3090            "Trend should be positive for emerging"
3091        );
3092    }
3093
3094    #[test]
3095    fn test_wavelet_amplitude_modulation_fading() {
3096        let m = 200;
3097        let period = 0.2;
3098        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
3099
3100        // Amplitude decreases from 1.0 to 0.2
3101        let data: Vec<f64> = argvals
3102            .iter()
3103            .map(|&t| {
3104                let amplitude = 1.0 - 0.8 * t;
3105                amplitude * (2.0 * PI * t / period).sin()
3106            })
3107            .collect();
3108
3109        let result = detect_amplitude_modulation_wavelet(&data, 1, m, &argvals, period, 0.15, 0.2);
3110
3111        eprintln!(
3112            "Wavelet fading: is_seasonal={}, has_modulation={}, modulation_score={:.4}, amplitude_trend={:.4}, type={:?}",
3113            result.is_seasonal, result.has_modulation, result.modulation_score, result.amplitude_trend, result.modulation_type
3114        );
3115
3116        assert!(result.is_seasonal, "Should detect seasonality");
3117        assert!(
3118            result.has_modulation,
3119            "Fading amplitude should have modulation"
3120        );
3121        assert!(
3122            result.amplitude_trend < 0.0,
3123            "Trend should be negative for fading"
3124        );
3125    }
3126
3127    #[test]
3128    fn test_seasonal_strength_wavelet() {
3129        let m = 200;
3130        let period = 0.2;
3131        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
3132
3133        // Pure sine wave at target period - should have high strength
3134        let seasonal_data: Vec<f64> = argvals
3135            .iter()
3136            .map(|&t| (2.0 * PI * t / period).sin())
3137            .collect();
3138
3139        let strength = seasonal_strength_wavelet(&seasonal_data, 1, m, &argvals, period);
3140        eprintln!("Wavelet strength (pure sine): {:.4}", strength);
3141        assert!(
3142            strength > 0.5,
3143            "Pure sine should have high wavelet strength"
3144        );
3145
3146        // Pure noise - should have low strength
3147        let noise_data: Vec<f64> = (0..m)
3148            .map(|i| ((i * 12345 + 67890) % 1000) as f64 / 1000.0 - 0.5)
3149            .collect();
3150
3151        let noise_strength = seasonal_strength_wavelet(&noise_data, 1, m, &argvals, period);
3152        eprintln!("Wavelet strength (noise): {:.4}", noise_strength);
3153        assert!(
3154            noise_strength < 0.3,
3155            "Noise should have low wavelet strength"
3156        );
3157
3158        // Wrong period - should have lower strength
3159        let wrong_period_strength =
3160            seasonal_strength_wavelet(&seasonal_data, 1, m, &argvals, period * 2.0);
3161        eprintln!(
3162            "Wavelet strength (wrong period): {:.4}",
3163            wrong_period_strength
3164        );
3165        assert!(
3166            wrong_period_strength < strength,
3167            "Wrong period should have lower strength"
3168        );
3169    }
3170
3171    #[test]
3172    fn test_compute_mean_curve() {
3173        // 2 samples, 3 time points
3174        // Sample 1: [1, 2, 3]
3175        // Sample 2: [2, 4, 6]
3176        // Mean: [1.5, 3, 4.5]
3177        let data = vec![1.0, 2.0, 2.0, 4.0, 3.0, 6.0]; // column-major
3178        let mean = compute_mean_curve(&data, 2, 3);
3179        assert_eq!(mean.len(), 3);
3180        assert!((mean[0] - 1.5).abs() < 1e-10);
3181        assert!((mean[1] - 3.0).abs() < 1e-10);
3182        assert!((mean[2] - 4.5).abs() < 1e-10);
3183    }
3184
3185    #[test]
3186    fn test_compute_mean_curve_parallel_consistency() {
3187        // Test that parallel and sequential give same results
3188        let n = 10;
3189        let m = 200;
3190        let data: Vec<f64> = (0..n * m).map(|i| (i as f64 * 0.1).sin()).collect();
3191
3192        let seq_result = compute_mean_curve_impl(&data, n, m, false);
3193        let par_result = compute_mean_curve_impl(&data, n, m, true);
3194
3195        assert_eq!(seq_result.len(), par_result.len());
3196        for (s, p) in seq_result.iter().zip(par_result.iter()) {
3197            assert!(
3198                (s - p).abs() < 1e-10,
3199                "Sequential and parallel results differ"
3200            );
3201        }
3202    }
3203
3204    #[test]
3205    fn test_interior_bounds() {
3206        // m = 100: edge_skip = 10, interior = [10, 90)
3207        let bounds = interior_bounds(100);
3208        assert!(bounds.is_some());
3209        let (start, end) = bounds.unwrap();
3210        assert_eq!(start, 10);
3211        assert_eq!(end, 90);
3212
3213        // m = 10: edge_skip = 1, but min(1, 2) = 1, max(9, 7) = 9
3214        let bounds = interior_bounds(10);
3215        assert!(bounds.is_some());
3216        let (start, end) = bounds.unwrap();
3217        assert!(start < end);
3218
3219        // Very small m might not have valid interior
3220        let bounds = interior_bounds(2);
3221        // Should still return something as long as end > start
3222        assert!(bounds.is_some() || bounds.is_none());
3223    }
3224
3225    #[test]
3226    fn test_hilbert_transform_pure_sine() {
3227        // Hilbert transform of sin(t) should give cos(t) in imaginary part
3228        let m = 200;
3229        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
3230        let signal: Vec<f64> = argvals.iter().map(|&t| (2.0 * PI * t).sin()).collect();
3231
3232        let analytic = hilbert_transform(&signal);
3233        assert_eq!(analytic.len(), m);
3234
3235        // Check amplitude is approximately 1
3236        for c in analytic.iter().skip(10).take(m - 20) {
3237            let amp = c.norm();
3238            assert!(
3239                (amp - 1.0).abs() < 0.1,
3240                "Amplitude should be ~1, got {}",
3241                amp
3242            );
3243        }
3244    }
3245}