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// ============================================================================
191// Internal helper functions
192// ============================================================================
193
194/// Compute periodogram from data using FFT.
195/// Returns (frequencies, power) where frequencies are in cycles per unit time.
196fn periodogram(data: &[f64], argvals: &[f64]) -> (Vec<f64>, Vec<f64>) {
197    let n = data.len();
198    if n < 2 || argvals.len() != n {
199        return (Vec::new(), Vec::new());
200    }
201
202    let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
203    let fs = 1.0 / dt; // Sampling frequency
204
205    let mut planner = FftPlanner::<f64>::new();
206    let fft = planner.plan_fft_forward(n);
207
208    let mut buffer: Vec<Complex<f64>> = data.iter().map(|&x| Complex::new(x, 0.0)).collect();
209    fft.process(&mut buffer);
210
211    // Compute power spectrum (one-sided)
212    let n_freq = n / 2 + 1;
213    let mut power = Vec::with_capacity(n_freq);
214    let mut frequencies = Vec::with_capacity(n_freq);
215
216    for k in 0..n_freq {
217        let freq = k as f64 * fs / n as f64;
218        frequencies.push(freq);
219
220        let p = buffer[k].norm_sqr() / (n as f64 * n as f64);
221        // Double power for non-DC and non-Nyquist frequencies (one-sided spectrum)
222        let p = if k > 0 && k < n / 2 { 2.0 * p } else { p };
223        power.push(p);
224    }
225
226    (frequencies, power)
227}
228
229/// Compute autocorrelation function up to max_lag.
230fn autocorrelation(data: &[f64], max_lag: usize) -> Vec<f64> {
231    let n = data.len();
232    if n == 0 {
233        return Vec::new();
234    }
235
236    let mean: f64 = data.iter().sum::<f64>() / n as f64;
237    let var: f64 = data.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n as f64;
238
239    if var < 1e-15 {
240        return vec![1.0; max_lag.min(n) + 1];
241    }
242
243    let max_lag = max_lag.min(n - 1);
244    let mut acf = Vec::with_capacity(max_lag + 1);
245
246    for lag in 0..=max_lag {
247        let mut sum = 0.0;
248        for i in 0..(n - lag) {
249            sum += (data[i] - mean) * (data[i + lag] - mean);
250        }
251        acf.push(sum / (n as f64 * var));
252    }
253
254    acf
255}
256
257/// Find peaks in a 1D signal, returning indices.
258fn find_peaks_1d(signal: &[f64], min_distance: usize) -> Vec<usize> {
259    let n = signal.len();
260    if n < 3 {
261        return Vec::new();
262    }
263
264    let mut peaks = Vec::new();
265
266    for i in 1..(n - 1) {
267        if signal[i] > signal[i - 1] && signal[i] > signal[i + 1] {
268            // Check minimum distance from previous peak
269            if peaks.is_empty() || i - peaks[peaks.len() - 1] >= min_distance {
270                peaks.push(i);
271            } else if signal[i] > signal[peaks[peaks.len() - 1]] {
272                // Replace previous peak if this one is higher
273                peaks.pop();
274                peaks.push(i);
275            }
276        }
277    }
278
279    peaks
280}
281
282/// Compute prominence for a peak (height above surrounding valleys).
283fn compute_prominence(signal: &[f64], peak_idx: usize) -> f64 {
284    let n = signal.len();
285    let peak_val = signal[peak_idx];
286
287    // Find lowest point between peak and boundaries/higher peaks
288    let mut left_min = peak_val;
289    for i in (0..peak_idx).rev() {
290        if signal[i] >= peak_val {
291            break;
292        }
293        left_min = left_min.min(signal[i]);
294    }
295
296    let mut right_min = peak_val;
297    for i in (peak_idx + 1)..n {
298        if signal[i] >= peak_val {
299            break;
300        }
301        right_min = right_min.min(signal[i]);
302    }
303
304    peak_val - left_min.max(right_min)
305}
306
307/// Hilbert transform using FFT to compute analytic signal.
308fn hilbert_transform(signal: &[f64]) -> Vec<Complex<f64>> {
309    let n = signal.len();
310    if n == 0 {
311        return Vec::new();
312    }
313
314    let mut planner = FftPlanner::<f64>::new();
315    let fft_forward = planner.plan_fft_forward(n);
316    let fft_inverse = planner.plan_fft_inverse(n);
317
318    // Forward FFT
319    let mut buffer: Vec<Complex<f64>> = signal.iter().map(|&x| Complex::new(x, 0.0)).collect();
320    fft_forward.process(&mut buffer);
321
322    // Create analytic signal in frequency domain
323    // H[0] = 1, H[1..n/2] = 2, H[n/2] = 1 (if n even), H[n/2+1..] = 0
324    let half = n / 2;
325    for k in 1..half {
326        buffer[k] *= 2.0;
327    }
328    for k in (half + 1)..n {
329        buffer[k] = Complex::new(0.0, 0.0);
330    }
331
332    // Inverse FFT
333    fft_inverse.process(&mut buffer);
334
335    // Normalize
336    for c in buffer.iter_mut() {
337        *c /= n as f64;
338    }
339
340    buffer
341}
342
343/// Unwrap phase to remove 2π discontinuities.
344fn unwrap_phase(phase: &[f64]) -> Vec<f64> {
345    if phase.is_empty() {
346        return Vec::new();
347    }
348
349    let mut unwrapped = vec![phase[0]];
350    let mut cumulative_correction = 0.0;
351
352    for i in 1..phase.len() {
353        let diff = phase[i] - phase[i - 1];
354
355        // Check for wraparound
356        if diff > PI {
357            cumulative_correction -= 2.0 * PI;
358        } else if diff < -PI {
359            cumulative_correction += 2.0 * PI;
360        }
361
362        unwrapped.push(phase[i] + cumulative_correction);
363    }
364
365    unwrapped
366}
367
368/// Compute Otsu's threshold for bimodal separation.
369///
370/// Finds the threshold that minimizes within-class variance (or equivalently
371/// maximizes between-class variance).
372fn otsu_threshold(values: &[f64]) -> f64 {
373    if values.is_empty() {
374        return 0.5;
375    }
376
377    // Filter NaN values
378    let valid: Vec<f64> = values.iter().copied().filter(|x| x.is_finite()).collect();
379    if valid.is_empty() {
380        return 0.5;
381    }
382
383    let min_val = valid.iter().cloned().fold(f64::INFINITY, f64::min);
384    let max_val = valid.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
385
386    if (max_val - min_val).abs() < 1e-10 {
387        return (min_val + max_val) / 2.0;
388    }
389
390    // Create histogram with 256 bins
391    let n_bins = 256;
392    let bin_width = (max_val - min_val) / n_bins as f64;
393    let mut histogram = vec![0usize; n_bins];
394
395    for &v in &valid {
396        let bin = ((v - min_val) / bin_width).min(n_bins as f64 - 1.0) as usize;
397        histogram[bin] += 1;
398    }
399
400    let total = valid.len() as f64;
401    let mut sum_total = 0.0;
402    for (i, &count) in histogram.iter().enumerate() {
403        sum_total += i as f64 * count as f64;
404    }
405
406    let mut best_threshold = min_val;
407    let mut best_variance = 0.0;
408
409    let mut sum_b = 0.0;
410    let mut weight_b = 0.0;
411
412    for t in 0..n_bins {
413        weight_b += histogram[t] as f64;
414        if weight_b == 0.0 {
415            continue;
416        }
417
418        let weight_f = total - weight_b;
419        if weight_f == 0.0 {
420            break;
421        }
422
423        sum_b += t as f64 * histogram[t] as f64;
424
425        let mean_b = sum_b / weight_b;
426        let mean_f = (sum_total - sum_b) / weight_f;
427
428        // Between-class variance
429        let variance = weight_b * weight_f * (mean_b - mean_f).powi(2);
430
431        if variance > best_variance {
432            best_variance = variance;
433            best_threshold = min_val + (t as f64 + 0.5) * bin_width;
434        }
435    }
436
437    best_threshold
438}
439
440/// Compute linear regression slope (simple OLS).
441fn linear_slope(x: &[f64], y: &[f64]) -> f64 {
442    if x.len() != y.len() || x.len() < 2 {
443        return 0.0;
444    }
445
446    let n = x.len() as f64;
447    let mean_x: f64 = x.iter().sum::<f64>() / n;
448    let mean_y: f64 = y.iter().sum::<f64>() / n;
449
450    let mut num = 0.0;
451    let mut den = 0.0;
452
453    for (&xi, &yi) in x.iter().zip(y.iter()) {
454        num += (xi - mean_x) * (yi - mean_y);
455        den += (xi - mean_x).powi(2);
456    }
457
458    if den.abs() < 1e-15 {
459        0.0
460    } else {
461        num / den
462    }
463}
464
465// ============================================================================
466// Period Estimation
467// ============================================================================
468
469/// Estimate period using FFT periodogram.
470///
471/// Finds the dominant frequency in the periodogram (excluding DC) and
472/// returns the corresponding period.
473///
474/// # Arguments
475/// * `data` - Column-major matrix (n x m)
476/// * `n` - Number of samples
477/// * `m` - Number of evaluation points
478/// * `argvals` - Evaluation points (time values)
479///
480/// # Returns
481/// Period estimate with confidence measure
482pub fn estimate_period_fft(data: &[f64], n: usize, m: usize, argvals: &[f64]) -> PeriodEstimate {
483    if n == 0 || m < 4 || argvals.len() != m {
484        return PeriodEstimate {
485            period: f64::NAN,
486            frequency: f64::NAN,
487            power: 0.0,
488            confidence: 0.0,
489        };
490    }
491
492    // Compute mean curve first
493    let mean_curve: Vec<f64> = (0..m)
494        .map(|j| {
495            let mut sum = 0.0;
496            for i in 0..n {
497                sum += data[i + j * n];
498            }
499            sum / n as f64
500        })
501        .collect();
502
503    let (frequencies, power) = periodogram(&mean_curve, argvals);
504
505    if frequencies.len() < 2 {
506        return PeriodEstimate {
507            period: f64::NAN,
508            frequency: f64::NAN,
509            power: 0.0,
510            confidence: 0.0,
511        };
512    }
513
514    // Find peak in power spectrum (skip DC component at index 0)
515    let mut max_power = 0.0;
516    let mut max_idx = 1;
517    for (i, &p) in power.iter().enumerate().skip(1) {
518        if p > max_power {
519            max_power = p;
520            max_idx = i;
521        }
522    }
523
524    let dominant_freq = frequencies[max_idx];
525    let period = if dominant_freq > 1e-15 {
526        1.0 / dominant_freq
527    } else {
528        f64::INFINITY
529    };
530
531    // Confidence: ratio of peak power to mean power (excluding DC)
532    let mean_power: f64 = power.iter().skip(1).sum::<f64>() / (power.len() - 1) as f64;
533    let confidence = if mean_power > 1e-15 {
534        max_power / mean_power
535    } else {
536        0.0
537    };
538
539    PeriodEstimate {
540        period,
541        frequency: dominant_freq,
542        power: max_power,
543        confidence,
544    }
545}
546
547/// Estimate period using autocorrelation function.
548///
549/// Finds the first significant peak in the ACF after lag 0.
550///
551/// # Arguments
552/// * `data` - Column-major matrix (n x m)
553/// * `n` - Number of samples
554/// * `m` - Number of evaluation points
555/// * `argvals` - Evaluation points
556/// * `max_lag` - Maximum lag to consider (in number of points)
557pub fn estimate_period_acf(
558    data: &[f64],
559    n: usize,
560    m: usize,
561    argvals: &[f64],
562    max_lag: usize,
563) -> PeriodEstimate {
564    if n == 0 || m < 4 || argvals.len() != m {
565        return PeriodEstimate {
566            period: f64::NAN,
567            frequency: f64::NAN,
568            power: 0.0,
569            confidence: 0.0,
570        };
571    }
572
573    // Compute mean curve
574    let mean_curve: Vec<f64> = (0..m)
575        .map(|j| {
576            let mut sum = 0.0;
577            for i in 0..n {
578                sum += data[i + j * n];
579            }
580            sum / n as f64
581        })
582        .collect();
583
584    let acf = autocorrelation(&mean_curve, max_lag);
585
586    // Find first peak after lag 0 (skip first few lags to avoid finding lag 0)
587    let min_lag = 2;
588    let peaks = find_peaks_1d(&acf[min_lag..], 1);
589
590    if peaks.is_empty() {
591        return PeriodEstimate {
592            period: f64::NAN,
593            frequency: f64::NAN,
594            power: 0.0,
595            confidence: 0.0,
596        };
597    }
598
599    let peak_lag = peaks[0] + min_lag;
600    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
601    let period = peak_lag as f64 * dt;
602    let frequency = if period > 1e-15 { 1.0 / period } else { 0.0 };
603
604    PeriodEstimate {
605        period,
606        frequency,
607        power: acf[peak_lag],
608        confidence: acf[peak_lag].abs(),
609    }
610}
611
612/// Estimate period via Fourier regression grid search.
613///
614/// Tests multiple candidate periods and selects the one that minimizes
615/// the reconstruction error (similar to GCV).
616///
617/// # Arguments
618/// * `data` - Column-major matrix (n x m)
619/// * `n` - Number of samples
620/// * `m` - Number of evaluation points
621/// * `argvals` - Evaluation points
622/// * `period_min` - Minimum period to test
623/// * `period_max` - Maximum period to test
624/// * `n_candidates` - Number of candidate periods to test
625/// * `n_harmonics` - Number of Fourier harmonics to use
626pub fn estimate_period_regression(
627    data: &[f64],
628    n: usize,
629    m: usize,
630    argvals: &[f64],
631    period_min: f64,
632    period_max: f64,
633    n_candidates: usize,
634    n_harmonics: usize,
635) -> PeriodEstimate {
636    if n == 0 || m < 4 || argvals.len() != m || period_min >= period_max || n_candidates < 2 {
637        return PeriodEstimate {
638            period: f64::NAN,
639            frequency: f64::NAN,
640            power: 0.0,
641            confidence: 0.0,
642        };
643    }
644
645    // Compute mean curve
646    let mean_curve: Vec<f64> = (0..m)
647        .map(|j| {
648            let mut sum = 0.0;
649            for i in 0..n {
650                sum += data[i + j * n];
651            }
652            sum / n as f64
653        })
654        .collect();
655
656    let nbasis = 1 + 2 * n_harmonics;
657
658    // Grid search over candidate periods
659    let candidates: Vec<f64> = (0..n_candidates)
660        .map(|i| period_min + (period_max - period_min) * i as f64 / (n_candidates - 1) as f64)
661        .collect();
662
663    let results: Vec<(f64, f64)> = candidates
664        .par_iter()
665        .map(|&period| {
666            let basis = fourier_basis_with_period(argvals, nbasis, period);
667
668            // Simple least squares fit
669            let mut rss = 0.0;
670            for j in 0..m {
671                let mut fitted = 0.0;
672                // Simple: use mean of basis function times data as rough fit
673                for k in 0..nbasis {
674                    let b_val = basis[j + k * m];
675                    let coef: f64 = (0..m)
676                        .map(|l| mean_curve[l] * basis[l + k * m])
677                        .sum::<f64>()
678                        / (0..m)
679                            .map(|l| basis[l + k * m].powi(2))
680                            .sum::<f64>()
681                            .max(1e-15);
682                    fitted += coef * b_val;
683                }
684                let resid = mean_curve[j] - fitted;
685                rss += resid * resid;
686            }
687
688            (period, rss)
689        })
690        .collect();
691
692    // Find period with minimum RSS
693    let (best_period, min_rss) = results
694        .iter()
695        .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
696        .cloned()
697        .unwrap_or((f64::NAN, f64::INFINITY));
698
699    // Confidence based on how much better the best is vs average
700    let mean_rss: f64 = results.iter().map(|(_, r)| r).sum::<f64>() / results.len() as f64;
701    let confidence = if min_rss > 1e-15 {
702        (mean_rss / min_rss).min(10.0)
703    } else {
704        10.0
705    };
706
707    PeriodEstimate {
708        period: best_period,
709        frequency: if best_period > 1e-15 {
710            1.0 / best_period
711        } else {
712            0.0
713        },
714        power: 1.0 - min_rss / mean_rss,
715        confidence,
716    }
717}
718
719/// Detect multiple concurrent periodicities using iterative residual subtraction.
720///
721/// This function iteratively:
722/// 1. Estimates the dominant period using FFT
723/// 2. Checks both FFT confidence and seasonal strength as stopping criteria
724/// 3. Computes the amplitude and phase of the sinusoidal component
725/// 4. Subtracts the fitted sinusoid from the signal
726/// 5. Repeats on the residual until stopping criteria are met
727///
728/// # Arguments
729/// * `data` - Column-major matrix (n x m)
730/// * `n` - Number of samples
731/// * `m` - Number of evaluation points
732/// * `argvals` - Evaluation points
733/// * `max_periods` - Maximum number of periods to detect
734/// * `min_confidence` - Minimum FFT confidence to continue (default: 0.4)
735/// * `min_strength` - Minimum seasonal strength to continue (default: 0.15)
736///
737/// # Returns
738/// Vector of detected periods with their properties
739pub fn detect_multiple_periods(
740    data: &[f64],
741    n: usize,
742    m: usize,
743    argvals: &[f64],
744    max_periods: usize,
745    min_confidence: f64,
746    min_strength: f64,
747) -> Vec<DetectedPeriod> {
748    if n == 0 || m < 4 || argvals.len() != m || max_periods == 0 {
749        return Vec::new();
750    }
751
752    // Compute mean curve
753    let mean_curve: Vec<f64> = (0..m)
754        .map(|j| {
755            let mut sum = 0.0;
756            for i in 0..n {
757                sum += data[i + j * n];
758            }
759            sum / n as f64
760        })
761        .collect();
762
763    let mut residual = mean_curve.clone();
764    let mut detected = Vec::with_capacity(max_periods);
765
766    for iteration in 1..=max_periods {
767        // Create single-sample data from residual
768        let residual_data: Vec<f64> = residual.clone();
769
770        // Estimate period
771        let est = estimate_period_fft(&residual_data, 1, m, argvals);
772
773        if est.confidence < min_confidence || est.period.is_nan() || est.period.is_infinite() {
774            break;
775        }
776
777        // Check seasonal strength at detected period
778        let strength = seasonal_strength_variance(&residual_data, 1, m, argvals, est.period, 3);
779
780        if strength < min_strength || strength.is_nan() {
781            break;
782        }
783
784        // Compute amplitude and phase using least squares fit
785        let omega = 2.0 * PI / est.period;
786        let mut cos_sum = 0.0;
787        let mut sin_sum = 0.0;
788
789        for (j, &t) in argvals.iter().enumerate() {
790            cos_sum += residual[j] * (omega * t).cos();
791            sin_sum += residual[j] * (omega * t).sin();
792        }
793
794        let a = 2.0 * cos_sum / m as f64; // Cosine coefficient
795        let b = 2.0 * sin_sum / m as f64; // Sine coefficient
796        let amplitude = (a * a + b * b).sqrt();
797        let phase = b.atan2(a);
798
799        detected.push(DetectedPeriod {
800            period: est.period,
801            confidence: est.confidence,
802            strength,
803            amplitude,
804            phase,
805            iteration,
806        });
807
808        // Subtract fitted sinusoid from residual
809        for (j, &t) in argvals.iter().enumerate() {
810            let fitted = a * (omega * t).cos() + b * (omega * t).sin();
811            residual[j] -= fitted;
812        }
813    }
814
815    detected
816}
817
818// ============================================================================
819// Peak Detection
820// ============================================================================
821
822/// Detect peaks in functional data.
823///
824/// Uses derivative zero-crossings to find local maxima, with optional
825/// Fourier basis smoothing and filtering by minimum distance and prominence.
826///
827/// # Arguments
828/// * `data` - Column-major matrix (n x m)
829/// * `n` - Number of samples
830/// * `m` - Number of evaluation points
831/// * `argvals` - Evaluation points
832/// * `min_distance` - Minimum time between peaks (None = no constraint)
833/// * `min_prominence` - Minimum prominence (0-1 scale, None = no filter)
834/// * `smooth_first` - Whether to smooth data before peak detection using Fourier basis
835/// * `smooth_nbasis` - Number of Fourier basis functions. If None and smooth_first=true,
836///   uses GCV to automatically select optimal nbasis (range 5-25).
837pub fn detect_peaks(
838    data: &[f64],
839    n: usize,
840    m: usize,
841    argvals: &[f64],
842    min_distance: Option<f64>,
843    min_prominence: Option<f64>,
844    smooth_first: bool,
845    smooth_nbasis: Option<usize>,
846) -> PeakDetectionResult {
847    if n == 0 || m < 3 || argvals.len() != m {
848        return PeakDetectionResult {
849            peaks: Vec::new(),
850            inter_peak_distances: Vec::new(),
851            mean_period: f64::NAN,
852        };
853    }
854
855    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
856    let min_dist_points = min_distance.map(|d| (d / dt).round() as usize).unwrap_or(1);
857
858    // Optionally smooth the data using Fourier basis
859    let work_data = if smooth_first {
860        // Determine nbasis: use provided value or select via GCV
861        let nbasis = smooth_nbasis
862            .unwrap_or_else(|| crate::basis::select_fourier_nbasis_gcv(data, n, m, argvals, 5, 25));
863
864        // Use Fourier basis smoothing
865        if let Some(result) = crate::basis::fourier_fit_1d(data, n, m, argvals, nbasis) {
866            result.fitted
867        } else {
868            data.to_vec()
869        }
870    } else {
871        data.to_vec()
872    };
873
874    // Compute first derivative
875    let deriv1 = deriv_1d(&work_data, n, m, argvals, 1);
876
877    // Compute data range for prominence normalization
878    let data_range: f64 = {
879        let mut min_val = f64::INFINITY;
880        let mut max_val = f64::NEG_INFINITY;
881        for &v in work_data.iter() {
882            min_val = min_val.min(v);
883            max_val = max_val.max(v);
884        }
885        (max_val - min_val).max(1e-15)
886    };
887
888    // Find peaks for each sample
889    let results: Vec<(Vec<Peak>, Vec<f64>)> = (0..n)
890        .into_par_iter()
891        .map(|i| {
892            // Extract curve and derivative
893            let curve: Vec<f64> = (0..m).map(|j| work_data[i + j * n]).collect();
894            let d1: Vec<f64> = (0..m).map(|j| deriv1[i + j * n]).collect();
895
896            // Find zero crossings where derivative goes from positive to negative
897            let mut peak_indices = Vec::new();
898            for j in 1..m {
899                if d1[j - 1] > 0.0 && d1[j] <= 0.0 {
900                    // Interpolate to find more precise location
901                    let idx = if (d1[j - 1] - d1[j]).abs() > 1e-15 {
902                        j - 1
903                    } else {
904                        j
905                    };
906
907                    // Check minimum distance
908                    if peak_indices.is_empty()
909                        || idx - peak_indices[peak_indices.len() - 1] >= min_dist_points
910                    {
911                        peak_indices.push(idx);
912                    }
913                }
914            }
915
916            // Build peaks with prominence
917            let mut peaks: Vec<Peak> = peak_indices
918                .iter()
919                .map(|&idx| {
920                    let prominence = compute_prominence(&curve, idx) / data_range;
921                    Peak {
922                        time: argvals[idx],
923                        value: curve[idx],
924                        prominence,
925                    }
926                })
927                .collect();
928
929            // Filter by prominence
930            if let Some(min_prom) = min_prominence {
931                peaks.retain(|p| p.prominence >= min_prom);
932            }
933
934            // Compute inter-peak distances
935            let distances: Vec<f64> = peaks.windows(2).map(|w| w[1].time - w[0].time).collect();
936
937            (peaks, distances)
938        })
939        .collect();
940
941    let peaks: Vec<Vec<Peak>> = results.iter().map(|(p, _)| p.clone()).collect();
942    let inter_peak_distances: Vec<Vec<f64>> = results.iter().map(|(_, d)| d.clone()).collect();
943
944    // Compute mean period from all inter-peak distances
945    let all_distances: Vec<f64> = inter_peak_distances.iter().flatten().cloned().collect();
946    let mean_period = if all_distances.is_empty() {
947        f64::NAN
948    } else {
949        all_distances.iter().sum::<f64>() / all_distances.len() as f64
950    };
951
952    PeakDetectionResult {
953        peaks,
954        inter_peak_distances,
955        mean_period,
956    }
957}
958
959// ============================================================================
960// Seasonal Strength
961// ============================================================================
962
963/// Measure seasonal strength using variance decomposition.
964///
965/// Computes SS = Var(seasonal_component) / Var(total) where the seasonal
966/// component is extracted using Fourier basis.
967///
968/// # Arguments
969/// * `data` - Column-major matrix (n x m)
970/// * `n` - Number of samples
971/// * `m` - Number of evaluation points
972/// * `argvals` - Evaluation points
973/// * `period` - Seasonal period
974/// * `n_harmonics` - Number of Fourier harmonics to use
975///
976/// # Returns
977/// Seasonal strength in [0, 1]
978pub fn seasonal_strength_variance(
979    data: &[f64],
980    n: usize,
981    m: usize,
982    argvals: &[f64],
983    period: f64,
984    n_harmonics: usize,
985) -> f64 {
986    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
987        return f64::NAN;
988    }
989
990    // Compute mean curve
991    let mean_curve: Vec<f64> = (0..m)
992        .map(|j| {
993            let mut sum = 0.0;
994            for i in 0..n {
995                sum += data[i + j * n];
996            }
997            sum / n as f64
998        })
999        .collect();
1000
1001    // Total variance
1002    let global_mean: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1003    let total_var: f64 = mean_curve
1004        .iter()
1005        .map(|&x| (x - global_mean).powi(2))
1006        .sum::<f64>()
1007        / m as f64;
1008
1009    if total_var < 1e-15 {
1010        return 0.0;
1011    }
1012
1013    // Fit Fourier basis to extract seasonal component
1014    let nbasis = 1 + 2 * n_harmonics;
1015    let basis = fourier_basis_with_period(argvals, nbasis, period);
1016
1017    // Project data onto basis (simple least squares for mean curve)
1018    let mut seasonal = vec![0.0; m];
1019    for k in 1..nbasis {
1020        // Skip DC component
1021        let b_sum: f64 = (0..m).map(|j| basis[j + k * m].powi(2)).sum();
1022        if b_sum > 1e-15 {
1023            let coef: f64 = (0..m)
1024                .map(|j| mean_curve[j] * basis[j + k * m])
1025                .sum::<f64>()
1026                / b_sum;
1027            for j in 0..m {
1028                seasonal[j] += coef * basis[j + k * m];
1029            }
1030        }
1031    }
1032
1033    // Seasonal variance
1034    let seasonal_mean: f64 = seasonal.iter().sum::<f64>() / m as f64;
1035    let seasonal_var: f64 = seasonal
1036        .iter()
1037        .map(|&x| (x - seasonal_mean).powi(2))
1038        .sum::<f64>()
1039        / m as f64;
1040
1041    (seasonal_var / total_var).min(1.0)
1042}
1043
1044/// Measure seasonal strength using spectral method.
1045///
1046/// Computes SS = power at seasonal frequencies / total power.
1047///
1048/// # Arguments
1049/// * `data` - Column-major matrix (n x m)
1050/// * `n` - Number of samples
1051/// * `m` - Number of evaluation points
1052/// * `argvals` - Evaluation points
1053/// * `period` - Seasonal period
1054pub fn seasonal_strength_spectral(
1055    data: &[f64],
1056    n: usize,
1057    m: usize,
1058    argvals: &[f64],
1059    period: f64,
1060) -> f64 {
1061    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1062        return f64::NAN;
1063    }
1064
1065    // Compute mean curve
1066    let mean_curve: Vec<f64> = (0..m)
1067        .map(|j| {
1068            let mut sum = 0.0;
1069            for i in 0..n {
1070                sum += data[i + j * n];
1071            }
1072            sum / n as f64
1073        })
1074        .collect();
1075
1076    let (frequencies, power) = periodogram(&mean_curve, argvals);
1077
1078    if frequencies.len() < 2 {
1079        return f64::NAN;
1080    }
1081
1082    let fundamental_freq = 1.0 / period;
1083
1084    // Sum power at seasonal frequencies (fundamental and harmonics)
1085    let _freq_tol = fundamental_freq * 0.1; // 10% tolerance (for future use)
1086    let mut seasonal_power = 0.0;
1087    let mut total_power = 0.0;
1088
1089    for (i, (&freq, &p)) in frequencies.iter().zip(power.iter()).enumerate() {
1090        if i == 0 {
1091            continue;
1092        } // Skip DC
1093
1094        total_power += p;
1095
1096        // Check if frequency is near a harmonic of fundamental
1097        let ratio = freq / fundamental_freq;
1098        let nearest_harmonic = ratio.round();
1099        if (ratio - nearest_harmonic).abs() < 0.1 && nearest_harmonic >= 1.0 {
1100            seasonal_power += p;
1101        }
1102    }
1103
1104    if total_power < 1e-15 {
1105        return 0.0;
1106    }
1107
1108    (seasonal_power / total_power).min(1.0)
1109}
1110
1111/// Compute time-varying seasonal strength using sliding windows.
1112///
1113/// # Arguments
1114/// * `data` - Column-major matrix (n x m)
1115/// * `n` - Number of samples
1116/// * `m` - Number of evaluation points
1117/// * `argvals` - Evaluation points
1118/// * `period` - Seasonal period
1119/// * `window_size` - Window width (recommended: 2 * period)
1120/// * `method` - Method for computing strength (Variance or Spectral)
1121///
1122/// # Returns
1123/// Seasonal strength at each time point
1124pub fn seasonal_strength_windowed(
1125    data: &[f64],
1126    n: usize,
1127    m: usize,
1128    argvals: &[f64],
1129    period: f64,
1130    window_size: f64,
1131    method: StrengthMethod,
1132) -> Vec<f64> {
1133    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 || window_size <= 0.0 {
1134        return Vec::new();
1135    }
1136
1137    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1138    let half_window_points = ((window_size / 2.0) / dt).round() as usize;
1139
1140    // Compute mean curve
1141    let mean_curve: Vec<f64> = (0..m)
1142        .map(|j| {
1143            let mut sum = 0.0;
1144            for i in 0..n {
1145                sum += data[i + j * n];
1146            }
1147            sum / n as f64
1148        })
1149        .collect();
1150
1151    (0..m)
1152        .into_par_iter()
1153        .map(|center| {
1154            let start = center.saturating_sub(half_window_points);
1155            let end = (center + half_window_points + 1).min(m);
1156            let window_m = end - start;
1157
1158            if window_m < 4 {
1159                return f64::NAN;
1160            }
1161
1162            let window_data: Vec<f64> = mean_curve[start..end].to_vec();
1163            let window_argvals: Vec<f64> = argvals[start..end].to_vec();
1164
1165            // Create single-sample data for the strength functions
1166            let single_data = window_data.clone();
1167
1168            match method {
1169                StrengthMethod::Variance => seasonal_strength_variance(
1170                    &single_data,
1171                    1,
1172                    window_m,
1173                    &window_argvals,
1174                    period,
1175                    3,
1176                ),
1177                StrengthMethod::Spectral => {
1178                    seasonal_strength_spectral(&single_data, 1, window_m, &window_argvals, period)
1179                }
1180            }
1181        })
1182        .collect()
1183}
1184
1185// ============================================================================
1186// Seasonality Change Detection
1187// ============================================================================
1188
1189/// Detect changes in seasonality.
1190///
1191/// Monitors time-varying seasonal strength and detects threshold crossings
1192/// that indicate onset or cessation of seasonality.
1193///
1194/// # Arguments
1195/// * `data` - Column-major matrix (n x m)
1196/// * `n` - Number of samples
1197/// * `m` - Number of evaluation points
1198/// * `argvals` - Evaluation points
1199/// * `period` - Seasonal period
1200/// * `threshold` - SS threshold for seasonal/non-seasonal (e.g., 0.3)
1201/// * `window_size` - Window size for local strength estimation
1202/// * `min_duration` - Minimum duration to confirm a change
1203pub fn detect_seasonality_changes(
1204    data: &[f64],
1205    n: usize,
1206    m: usize,
1207    argvals: &[f64],
1208    period: f64,
1209    threshold: f64,
1210    window_size: f64,
1211    min_duration: f64,
1212) -> ChangeDetectionResult {
1213    if n == 0 || m < 4 || argvals.len() != m {
1214        return ChangeDetectionResult {
1215            change_points: Vec::new(),
1216            strength_curve: Vec::new(),
1217        };
1218    }
1219
1220    // Compute time-varying seasonal strength
1221    let strength_curve = seasonal_strength_windowed(
1222        data,
1223        n,
1224        m,
1225        argvals,
1226        period,
1227        window_size,
1228        StrengthMethod::Variance,
1229    );
1230
1231    if strength_curve.is_empty() {
1232        return ChangeDetectionResult {
1233            change_points: Vec::new(),
1234            strength_curve: Vec::new(),
1235        };
1236    }
1237
1238    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1239    let min_dur_points = (min_duration / dt).round() as usize;
1240
1241    // Detect threshold crossings
1242    let mut change_points = Vec::new();
1243    let mut in_seasonal = strength_curve[0] > threshold;
1244    let mut last_change_idx: Option<usize> = None;
1245
1246    for (i, &ss) in strength_curve.iter().enumerate().skip(1) {
1247        if ss.is_nan() {
1248            continue;
1249        }
1250
1251        let now_seasonal = ss > threshold;
1252
1253        if now_seasonal != in_seasonal {
1254            // Potential change point
1255            if let Some(last_idx) = last_change_idx {
1256                if i - last_idx < min_dur_points {
1257                    continue; // Too soon after last change
1258                }
1259            }
1260
1261            let change_type = if now_seasonal {
1262                ChangeType::Onset
1263            } else {
1264                ChangeType::Cessation
1265            };
1266
1267            let strength_before = if i > 0 && !strength_curve[i - 1].is_nan() {
1268                strength_curve[i - 1]
1269            } else {
1270                ss
1271            };
1272
1273            change_points.push(ChangePoint {
1274                time: argvals[i],
1275                change_type,
1276                strength_before,
1277                strength_after: ss,
1278            });
1279
1280            in_seasonal = now_seasonal;
1281            last_change_idx = Some(i);
1282        }
1283    }
1284
1285    ChangeDetectionResult {
1286        change_points,
1287        strength_curve,
1288    }
1289}
1290
1291// ============================================================================
1292// Instantaneous Period
1293// ============================================================================
1294
1295/// Estimate instantaneous period using Hilbert transform.
1296///
1297/// For series with drifting/changing period, this computes the period
1298/// at each time point using the analytic signal.
1299///
1300/// # Arguments
1301/// * `data` - Column-major matrix (n x m)
1302/// * `n` - Number of samples
1303/// * `m` - Number of evaluation points
1304/// * `argvals` - Evaluation points
1305pub fn instantaneous_period(
1306    data: &[f64],
1307    n: usize,
1308    m: usize,
1309    argvals: &[f64],
1310) -> InstantaneousPeriod {
1311    if n == 0 || m < 4 || argvals.len() != m {
1312        return InstantaneousPeriod {
1313            period: Vec::new(),
1314            frequency: Vec::new(),
1315            amplitude: Vec::new(),
1316        };
1317    }
1318
1319    // Compute mean curve
1320    let mean_curve: Vec<f64> = (0..m)
1321        .map(|j| {
1322            let mut sum = 0.0;
1323            for i in 0..n {
1324                sum += data[i + j * n];
1325            }
1326            sum / n as f64
1327        })
1328        .collect();
1329
1330    // Remove DC component (detrend by subtracting mean)
1331    let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
1332    let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
1333
1334    // Compute analytic signal via Hilbert transform
1335    let analytic = hilbert_transform(&detrended);
1336
1337    // Extract instantaneous amplitude and phase
1338    let amplitude: Vec<f64> = analytic.iter().map(|c| c.norm()).collect();
1339
1340    let phase: Vec<f64> = analytic.iter().map(|c| c.im.atan2(c.re)).collect();
1341
1342    // Unwrap phase
1343    let unwrapped_phase = unwrap_phase(&phase);
1344
1345    // Compute instantaneous frequency (derivative of phase)
1346    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1347    let mut inst_freq = vec![0.0; m];
1348
1349    // Central differences for interior, forward/backward at boundaries
1350    if m > 1 {
1351        inst_freq[0] = (unwrapped_phase[1] - unwrapped_phase[0]) / dt / (2.0 * PI);
1352    }
1353    for j in 1..(m - 1) {
1354        inst_freq[j] = (unwrapped_phase[j + 1] - unwrapped_phase[j - 1]) / (2.0 * dt) / (2.0 * PI);
1355    }
1356    if m > 1 {
1357        inst_freq[m - 1] = (unwrapped_phase[m - 1] - unwrapped_phase[m - 2]) / dt / (2.0 * PI);
1358    }
1359
1360    // Period = 1/frequency (handle near-zero frequencies)
1361    let period: Vec<f64> = inst_freq
1362        .iter()
1363        .map(|&f| {
1364            if f.abs() > 1e-10 {
1365                (1.0 / f).abs()
1366            } else {
1367                f64::INFINITY
1368            }
1369        })
1370        .collect();
1371
1372    InstantaneousPeriod {
1373        period,
1374        frequency: inst_freq,
1375        amplitude,
1376    }
1377}
1378
1379// ============================================================================
1380// Peak Timing Variability Analysis
1381// ============================================================================
1382
1383/// Analyze peak timing variability across cycles.
1384///
1385/// For short series (e.g., 3-5 years of yearly data), this function detects
1386/// one peak per cycle and analyzes how peak timing varies between cycles.
1387///
1388/// # Arguments
1389/// * `data` - Column-major matrix (n x m)
1390/// * `n` - Number of samples
1391/// * `m` - Number of evaluation points
1392/// * `argvals` - Evaluation points
1393/// * `period` - Known period (e.g., 365 for daily data with yearly seasonality)
1394/// * `smooth_nbasis` - Number of Fourier basis functions for smoothing.
1395///   If None, uses GCV for automatic selection.
1396///
1397/// # Returns
1398/// Peak timing result with variability metrics
1399pub fn analyze_peak_timing(
1400    data: &[f64],
1401    n: usize,
1402    m: usize,
1403    argvals: &[f64],
1404    period: f64,
1405    smooth_nbasis: Option<usize>,
1406) -> PeakTimingResult {
1407    if n == 0 || m < 3 || argvals.len() != m || period <= 0.0 {
1408        return PeakTimingResult {
1409            peak_times: Vec::new(),
1410            peak_values: Vec::new(),
1411            normalized_timing: Vec::new(),
1412            mean_timing: f64::NAN,
1413            std_timing: f64::NAN,
1414            range_timing: f64::NAN,
1415            variability_score: f64::NAN,
1416            timing_trend: f64::NAN,
1417            cycle_indices: Vec::new(),
1418        };
1419    }
1420
1421    // Detect peaks with minimum distance constraint of 0.7 * period
1422    // This ensures we get at most one peak per cycle
1423    let min_distance = period * 0.7;
1424    let peaks = detect_peaks(
1425        data,
1426        n,
1427        m,
1428        argvals,
1429        Some(min_distance),
1430        None, // No prominence filter
1431        true, // Smooth first with Fourier basis
1432        smooth_nbasis,
1433    );
1434
1435    // Use the first sample's peaks (for mean curve analysis)
1436    // If multiple samples, we take the mean curve which is effectively in sample 0
1437    let sample_peaks = if peaks.peaks.is_empty() {
1438        Vec::new()
1439    } else {
1440        peaks.peaks[0].clone()
1441    };
1442
1443    if sample_peaks.is_empty() {
1444        return PeakTimingResult {
1445            peak_times: Vec::new(),
1446            peak_values: Vec::new(),
1447            normalized_timing: Vec::new(),
1448            mean_timing: f64::NAN,
1449            std_timing: f64::NAN,
1450            range_timing: f64::NAN,
1451            variability_score: f64::NAN,
1452            timing_trend: f64::NAN,
1453            cycle_indices: Vec::new(),
1454        };
1455    }
1456
1457    let peak_times: Vec<f64> = sample_peaks.iter().map(|p| p.time).collect();
1458    let peak_values: Vec<f64> = sample_peaks.iter().map(|p| p.value).collect();
1459
1460    // Compute normalized timing (position within cycle, 0-1 scale)
1461    let t_start = argvals[0];
1462    let normalized_timing: Vec<f64> = peak_times
1463        .iter()
1464        .map(|&t| {
1465            let cycle_pos = (t - t_start) % period;
1466            cycle_pos / period
1467        })
1468        .collect();
1469
1470    // Compute cycle indices (1-indexed)
1471    let cycle_indices: Vec<usize> = peak_times
1472        .iter()
1473        .map(|&t| ((t - t_start) / period).floor() as usize + 1)
1474        .collect();
1475
1476    // Compute statistics
1477    let n_peaks = normalized_timing.len() as f64;
1478    let mean_timing = normalized_timing.iter().sum::<f64>() / n_peaks;
1479
1480    let variance: f64 = normalized_timing
1481        .iter()
1482        .map(|&x| (x - mean_timing).powi(2))
1483        .sum::<f64>()
1484        / n_peaks;
1485    let std_timing = variance.sqrt();
1486
1487    let min_timing = normalized_timing
1488        .iter()
1489        .cloned()
1490        .fold(f64::INFINITY, f64::min);
1491    let max_timing = normalized_timing
1492        .iter()
1493        .cloned()
1494        .fold(f64::NEG_INFINITY, f64::max);
1495    let range_timing = max_timing - min_timing;
1496
1497    // Variability score: normalized std deviation
1498    // Max possible std for uniform in [0,1] is ~0.289, so we scale by that
1499    // But since peaks cluster, we use 0.1 as "high" variability threshold
1500    let variability_score = (std_timing / 0.1).min(1.0);
1501
1502    // Timing trend: linear regression of normalized timing on cycle index
1503    let cycle_idx_f64: Vec<f64> = cycle_indices.iter().map(|&i| i as f64).collect();
1504    let timing_trend = linear_slope(&cycle_idx_f64, &normalized_timing);
1505
1506    PeakTimingResult {
1507        peak_times,
1508        peak_values,
1509        normalized_timing,
1510        mean_timing,
1511        std_timing,
1512        range_timing,
1513        variability_score,
1514        timing_trend,
1515        cycle_indices,
1516    }
1517}
1518
1519// ============================================================================
1520// Seasonality Classification
1521// ============================================================================
1522
1523/// Classify the type of seasonality in functional data.
1524///
1525/// This is particularly useful for short series (3-5 years) where you need
1526/// to identify:
1527/// - Whether seasonality is present
1528/// - Whether peak timing is stable or variable
1529/// - Which cycles have weak or missing seasonality
1530///
1531/// # Arguments
1532/// * `data` - Column-major matrix (n x m)
1533/// * `n` - Number of samples
1534/// * `m` - Number of evaluation points
1535/// * `argvals` - Evaluation points
1536/// * `period` - Known seasonal period
1537/// * `strength_threshold` - Threshold for seasonal/non-seasonal (default: 0.3)
1538/// * `timing_threshold` - Max std of normalized timing for "stable" (default: 0.05)
1539///
1540/// # Returns
1541/// Seasonality classification with type and diagnostics
1542pub fn classify_seasonality(
1543    data: &[f64],
1544    n: usize,
1545    m: usize,
1546    argvals: &[f64],
1547    period: f64,
1548    strength_threshold: Option<f64>,
1549    timing_threshold: Option<f64>,
1550) -> SeasonalityClassification {
1551    let strength_thresh = strength_threshold.unwrap_or(0.3);
1552    let timing_thresh = timing_threshold.unwrap_or(0.05);
1553
1554    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
1555        return SeasonalityClassification {
1556            is_seasonal: false,
1557            has_stable_timing: false,
1558            timing_variability: f64::NAN,
1559            seasonal_strength: f64::NAN,
1560            cycle_strengths: Vec::new(),
1561            weak_seasons: Vec::new(),
1562            classification: SeasonalType::NonSeasonal,
1563            peak_timing: None,
1564        };
1565    }
1566
1567    // Compute overall seasonal strength
1568    let overall_strength = seasonal_strength_variance(data, n, m, argvals, period, 3);
1569
1570    // Compute per-cycle strength
1571    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
1572    let _points_per_cycle = (period / dt).round() as usize;
1573    let t_start = argvals[0];
1574    let t_end = argvals[m - 1];
1575    let n_cycles = ((t_end - t_start) / period).floor() as usize;
1576
1577    let mut cycle_strengths = Vec::with_capacity(n_cycles);
1578    let mut weak_seasons = Vec::new();
1579
1580    for cycle in 0..n_cycles {
1581        let cycle_start = t_start + cycle as f64 * period;
1582        let cycle_end = cycle_start + period;
1583
1584        // Find indices for this cycle
1585        let start_idx = argvals.iter().position(|&t| t >= cycle_start).unwrap_or(0);
1586        let end_idx = argvals.iter().position(|&t| t > cycle_end).unwrap_or(m);
1587
1588        let cycle_m = end_idx - start_idx;
1589        if cycle_m < 4 {
1590            cycle_strengths.push(f64::NAN);
1591            continue;
1592        }
1593
1594        // Extract cycle data
1595        let cycle_data: Vec<f64> = (start_idx..end_idx)
1596            .flat_map(|j| (0..n).map(move |i| data[i + j * n]))
1597            .collect();
1598        let cycle_argvals: Vec<f64> = argvals[start_idx..end_idx].to_vec();
1599
1600        let strength =
1601            seasonal_strength_variance(&cycle_data, n, cycle_m, &cycle_argvals, period, 3);
1602
1603        cycle_strengths.push(strength);
1604
1605        if strength < strength_thresh {
1606            weak_seasons.push(cycle);
1607        }
1608    }
1609
1610    // Analyze peak timing
1611    let peak_timing = analyze_peak_timing(data, n, m, argvals, period, None);
1612
1613    // Determine classification
1614    let is_seasonal = overall_strength >= strength_thresh;
1615    let has_stable_timing = peak_timing.std_timing <= timing_thresh;
1616    let timing_variability = peak_timing.variability_score;
1617
1618    // Classify based on patterns
1619    let n_weak = weak_seasons.len();
1620    let classification = if !is_seasonal {
1621        SeasonalType::NonSeasonal
1622    } else if n_cycles > 0 && n_weak as f64 / n_cycles as f64 > 0.3 {
1623        // More than 30% of cycles are weak
1624        SeasonalType::IntermittentSeasonal
1625    } else if !has_stable_timing {
1626        SeasonalType::VariableTiming
1627    } else {
1628        SeasonalType::StableSeasonal
1629    };
1630
1631    SeasonalityClassification {
1632        is_seasonal,
1633        has_stable_timing,
1634        timing_variability,
1635        seasonal_strength: overall_strength,
1636        cycle_strengths,
1637        weak_seasons,
1638        classification,
1639        peak_timing: Some(peak_timing),
1640    }
1641}
1642
1643/// Detect seasonality changes with automatic threshold selection.
1644///
1645/// Uses Otsu's method or percentile-based threshold instead of a fixed value.
1646///
1647/// # Arguments
1648/// * `data` - Column-major matrix (n x m)
1649/// * `n` - Number of samples
1650/// * `m` - Number of evaluation points
1651/// * `argvals` - Evaluation points
1652/// * `period` - Seasonal period
1653/// * `threshold_method` - Method for threshold selection
1654/// * `window_size` - Window size for local strength estimation
1655/// * `min_duration` - Minimum duration to confirm a change
1656pub fn detect_seasonality_changes_auto(
1657    data: &[f64],
1658    n: usize,
1659    m: usize,
1660    argvals: &[f64],
1661    period: f64,
1662    threshold_method: ThresholdMethod,
1663    window_size: f64,
1664    min_duration: f64,
1665) -> ChangeDetectionResult {
1666    if n == 0 || m < 4 || argvals.len() != m {
1667        return ChangeDetectionResult {
1668            change_points: Vec::new(),
1669            strength_curve: Vec::new(),
1670        };
1671    }
1672
1673    // Compute time-varying seasonal strength
1674    let strength_curve = seasonal_strength_windowed(
1675        data,
1676        n,
1677        m,
1678        argvals,
1679        period,
1680        window_size,
1681        StrengthMethod::Variance,
1682    );
1683
1684    if strength_curve.is_empty() {
1685        return ChangeDetectionResult {
1686            change_points: Vec::new(),
1687            strength_curve: Vec::new(),
1688        };
1689    }
1690
1691    // Determine threshold
1692    let threshold = match threshold_method {
1693        ThresholdMethod::Fixed(t) => t,
1694        ThresholdMethod::Percentile(p) => {
1695            let mut sorted: Vec<f64> = strength_curve
1696                .iter()
1697                .copied()
1698                .filter(|x| x.is_finite())
1699                .collect();
1700            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1701            if sorted.is_empty() {
1702                0.5
1703            } else {
1704                let idx = ((p / 100.0) * sorted.len() as f64) as usize;
1705                sorted[idx.min(sorted.len() - 1)]
1706            }
1707        }
1708        ThresholdMethod::Otsu => otsu_threshold(&strength_curve),
1709    };
1710
1711    // Now use the regular detection with computed threshold
1712    detect_seasonality_changes(
1713        data,
1714        n,
1715        m,
1716        argvals,
1717        period,
1718        threshold,
1719        window_size,
1720        min_duration,
1721    )
1722}
1723
1724#[cfg(test)]
1725mod tests {
1726    use super::*;
1727    use std::f64::consts::PI;
1728
1729    fn generate_sine(n: usize, m: usize, period: f64, argvals: &[f64]) -> Vec<f64> {
1730        let mut data = vec![0.0; n * m];
1731        for i in 0..n {
1732            for j in 0..m {
1733                data[i + j * n] = (2.0 * PI * argvals[j] / period).sin();
1734            }
1735        }
1736        data
1737    }
1738
1739    #[test]
1740    fn test_period_estimation_fft() {
1741        let m = 200;
1742        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
1743        let period = 2.0;
1744        let data = generate_sine(1, m, period, &argvals);
1745
1746        let estimate = estimate_period_fft(&data, 1, m, &argvals);
1747        assert!((estimate.period - period).abs() < 0.2);
1748        assert!(estimate.confidence > 1.0);
1749    }
1750
1751    #[test]
1752    fn test_peak_detection() {
1753        let m = 100;
1754        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
1755        let period = 2.0;
1756        let data = generate_sine(1, m, period, &argvals);
1757
1758        let result = detect_peaks(&data, 1, m, &argvals, Some(1.5), None, false, None);
1759
1760        // Should find approximately 5 peaks (10 / 2)
1761        assert!(!result.peaks[0].is_empty());
1762        assert!((result.mean_period - period).abs() < 0.3);
1763    }
1764
1765    #[test]
1766    fn test_peak_detection_known_sine() {
1767        // Pure sine wave: sin(2*pi*t/2) on [0, 10]
1768        // Peaks occur at t = period/4 + k*period = 0.5, 2.5, 4.5, 6.5, 8.5
1769        let m = 200; // High resolution for accurate detection
1770        let period = 2.0;
1771        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
1772        let data: Vec<f64> = argvals
1773            .iter()
1774            .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
1775            .collect();
1776
1777        let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
1778
1779        // Should find exactly 5 peaks
1780        assert_eq!(
1781            result.peaks[0].len(),
1782            5,
1783            "Expected 5 peaks, got {}. Peak times: {:?}",
1784            result.peaks[0].len(),
1785            result.peaks[0].iter().map(|p| p.time).collect::<Vec<_>>()
1786        );
1787
1788        // Check peak locations are close to expected
1789        let expected_times = [0.5, 2.5, 4.5, 6.5, 8.5];
1790        for (peak, expected) in result.peaks[0].iter().zip(expected_times.iter()) {
1791            assert!(
1792                (peak.time - expected).abs() < 0.15,
1793                "Peak at {:.3} not close to expected {:.3}",
1794                peak.time,
1795                expected
1796            );
1797        }
1798
1799        // Check mean period
1800        assert!(
1801            (result.mean_period - period).abs() < 0.1,
1802            "Mean period {:.3} not close to expected {:.3}",
1803            result.mean_period,
1804            period
1805        );
1806    }
1807
1808    #[test]
1809    fn test_peak_detection_with_min_distance() {
1810        // Same sine wave but with min_distance constraint
1811        let m = 200;
1812        let period = 2.0;
1813        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
1814        let data: Vec<f64> = argvals
1815            .iter()
1816            .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
1817            .collect();
1818
1819        // min_distance = 1.5 should still find all 5 peaks (spacing = 2.0)
1820        let result = detect_peaks(&data, 1, m, &argvals, Some(1.5), None, false, None);
1821        assert_eq!(
1822            result.peaks[0].len(),
1823            5,
1824            "With min_distance=1.5, expected 5 peaks, got {}",
1825            result.peaks[0].len()
1826        );
1827
1828        // min_distance = 2.5 should find fewer peaks
1829        let result2 = detect_peaks(&data, 1, m, &argvals, Some(2.5), None, false, None);
1830        assert!(
1831            result2.peaks[0].len() < 5,
1832            "With min_distance=2.5, expected fewer than 5 peaks, got {}",
1833            result2.peaks[0].len()
1834        );
1835    }
1836
1837    #[test]
1838    fn test_peak_detection_period_1() {
1839        // Higher frequency: sin(2*pi*t/1) on [0, 10]
1840        // Peaks at t = 0.25, 1.25, 2.25, ..., 9.25 (10 peaks)
1841        let m = 400; // Higher resolution for higher frequency
1842        let period = 1.0;
1843        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
1844        let data: Vec<f64> = argvals
1845            .iter()
1846            .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin())
1847            .collect();
1848
1849        let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
1850
1851        // Should find 10 peaks
1852        assert_eq!(
1853            result.peaks[0].len(),
1854            10,
1855            "Expected 10 peaks, got {}",
1856            result.peaks[0].len()
1857        );
1858
1859        // Check mean period
1860        assert!(
1861            (result.mean_period - period).abs() < 0.1,
1862            "Mean period {:.3} not close to expected {:.3}",
1863            result.mean_period,
1864            period
1865        );
1866    }
1867
1868    #[test]
1869    fn test_peak_detection_shifted_sine() {
1870        // Shifted sine: sin(2*pi*t/2) + 1 on [0, 10]
1871        // Same peak locations, just shifted up
1872        let m = 200;
1873        let period = 2.0;
1874        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
1875        let data: Vec<f64> = argvals
1876            .iter()
1877            .map(|&t| (2.0 * std::f64::consts::PI * t / period).sin() + 1.0)
1878            .collect();
1879
1880        let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
1881
1882        // Should still find 5 peaks
1883        assert_eq!(
1884            result.peaks[0].len(),
1885            5,
1886            "Expected 5 peaks for shifted sine, got {}",
1887            result.peaks[0].len()
1888        );
1889
1890        // Peak values should be around 2.0 (max of sin + 1)
1891        for peak in &result.peaks[0] {
1892            assert!(
1893                (peak.value - 2.0).abs() < 0.05,
1894                "Peak value {:.3} not close to expected 2.0",
1895                peak.value
1896            );
1897        }
1898    }
1899
1900    #[test]
1901    fn test_peak_detection_prominence() {
1902        // Create signal with peaks of different heights
1903        // Large peaks at odd positions, small peaks at even positions
1904        let m = 200;
1905        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
1906        let data: Vec<f64> = argvals
1907            .iter()
1908            .map(|&t| {
1909                let base = (2.0 * std::f64::consts::PI * t / 2.0).sin();
1910                // Add small ripples
1911                let ripple = 0.1 * (2.0 * std::f64::consts::PI * t * 4.0).sin();
1912                base + ripple
1913            })
1914            .collect();
1915
1916        // Without prominence filter, may find extra peaks from ripples
1917        let result_no_filter = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
1918
1919        // With prominence filter, should only find major peaks
1920        let result_filtered = detect_peaks(&data, 1, m, &argvals, None, Some(0.5), false, None);
1921
1922        // Filtered should have fewer or equal peaks
1923        assert!(
1924            result_filtered.peaks[0].len() <= result_no_filter.peaks[0].len(),
1925            "Prominence filter should reduce peak count"
1926        );
1927    }
1928
1929    #[test]
1930    fn test_peak_detection_different_amplitudes() {
1931        // Test with various amplitudes: 0.5, 1.0, 2.0, 5.0
1932        let m = 200;
1933        let period = 2.0;
1934        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
1935
1936        for amplitude in [0.5, 1.0, 2.0, 5.0] {
1937            let data: Vec<f64> = argvals
1938                .iter()
1939                .map(|&t| amplitude * (2.0 * std::f64::consts::PI * t / period).sin())
1940                .collect();
1941
1942            let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
1943
1944            assert_eq!(
1945                result.peaks[0].len(),
1946                5,
1947                "Amplitude {} should still find 5 peaks",
1948                amplitude
1949            );
1950
1951            // Peak values should be close to amplitude
1952            for peak in &result.peaks[0] {
1953                assert!(
1954                    (peak.value - amplitude).abs() < 0.1,
1955                    "Peak value {:.3} should be close to amplitude {}",
1956                    peak.value,
1957                    amplitude
1958                );
1959            }
1960        }
1961    }
1962
1963    #[test]
1964    fn test_peak_detection_varying_frequency() {
1965        // Signal with varying frequency: chirp-like signal
1966        // Peaks get closer together over time
1967        let m = 400;
1968        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 10.0 / (m - 1) as f64).collect();
1969
1970        // Frequency increases linearly: f(t) = 0.5 + 0.1*t
1971        // Phase integral: phi(t) = 2*pi * (0.5*t + 0.05*t^2)
1972        let data: Vec<f64> = argvals
1973            .iter()
1974            .map(|&t| {
1975                let phase = 2.0 * std::f64::consts::PI * (0.5 * t + 0.05 * t * t);
1976                phase.sin()
1977            })
1978            .collect();
1979
1980        let result = detect_peaks(&data, 1, m, &argvals, None, None, false, None);
1981
1982        // Should find multiple peaks with decreasing spacing
1983        assert!(
1984            result.peaks[0].len() >= 5,
1985            "Should find at least 5 peaks, got {}",
1986            result.peaks[0].len()
1987        );
1988
1989        // Verify inter-peak distances decrease over time
1990        let distances = &result.inter_peak_distances[0];
1991        if distances.len() >= 3 {
1992            // Later peaks should be closer than earlier peaks
1993            let early_avg = (distances[0] + distances[1]) / 2.0;
1994            let late_avg = (distances[distances.len() - 2] + distances[distances.len() - 1]) / 2.0;
1995            assert!(
1996                late_avg < early_avg,
1997                "Later peaks should be closer: early avg={:.3}, late avg={:.3}",
1998                early_avg,
1999                late_avg
2000            );
2001        }
2002    }
2003
2004    #[test]
2005    fn test_peak_detection_sum_of_sines() {
2006        // Sum of two sine waves with different periods creates non-uniform peak spacing
2007        // y = sin(2*pi*t/2) + 0.5*sin(2*pi*t/3)
2008        let m = 300;
2009        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 12.0 / (m - 1) as f64).collect();
2010
2011        let data: Vec<f64> = argvals
2012            .iter()
2013            .map(|&t| {
2014                (2.0 * std::f64::consts::PI * t / 2.0).sin()
2015                    + 0.5 * (2.0 * std::f64::consts::PI * t / 3.0).sin()
2016            })
2017            .collect();
2018
2019        let result = detect_peaks(&data, 1, m, &argvals, Some(1.0), None, false, None);
2020
2021        // Should find peaks (exact count depends on interference pattern)
2022        assert!(
2023            result.peaks[0].len() >= 4,
2024            "Should find at least 4 peaks, got {}",
2025            result.peaks[0].len()
2026        );
2027
2028        // Inter-peak distances should vary
2029        let distances = &result.inter_peak_distances[0];
2030        if distances.len() >= 2 {
2031            let min_dist = distances.iter().cloned().fold(f64::INFINITY, f64::min);
2032            let max_dist = distances.iter().cloned().fold(0.0, f64::max);
2033            assert!(
2034                max_dist > min_dist * 1.1,
2035                "Distances should vary: min={:.3}, max={:.3}",
2036                min_dist,
2037                max_dist
2038            );
2039        }
2040    }
2041
2042    #[test]
2043    fn test_seasonal_strength() {
2044        let m = 200;
2045        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
2046        let period = 2.0;
2047        let data = generate_sine(1, m, period, &argvals);
2048
2049        let strength = seasonal_strength_variance(&data, 1, m, &argvals, period, 3);
2050        // Pure sine should have high seasonal strength
2051        assert!(strength > 0.8);
2052
2053        let strength_spectral = seasonal_strength_spectral(&data, 1, m, &argvals, period);
2054        assert!(strength_spectral > 0.5);
2055    }
2056
2057    #[test]
2058    fn test_instantaneous_period() {
2059        let m = 200;
2060        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
2061        let period = 2.0;
2062        let data = generate_sine(1, m, period, &argvals);
2063
2064        let result = instantaneous_period(&data, 1, m, &argvals);
2065
2066        // Check that instantaneous period is close to true period (away from boundaries)
2067        let mid_period = result.period[m / 2];
2068        assert!(
2069            (mid_period - period).abs() < 0.5,
2070            "Expected period ~{}, got {}",
2071            period,
2072            mid_period
2073        );
2074    }
2075
2076    #[test]
2077    fn test_peak_timing_analysis() {
2078        // Generate 5 cycles of sine with period 2
2079        let m = 500;
2080        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.02).collect();
2081        let period = 2.0;
2082        let data = generate_sine(1, m, period, &argvals);
2083
2084        let result = analyze_peak_timing(&data, 1, m, &argvals, period, Some(11));
2085
2086        // Should find approximately 5 peaks
2087        assert!(!result.peak_times.is_empty());
2088        // Normalized timing should be around 0.25 (peak of sin at π/2)
2089        assert!(result.mean_timing.is_finite());
2090        // Pure sine should have low timing variability
2091        assert!(result.std_timing < 0.1 || result.std_timing.is_nan());
2092    }
2093
2094    #[test]
2095    fn test_seasonality_classification() {
2096        let m = 400;
2097        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect();
2098        let period = 2.0;
2099        let data = generate_sine(1, m, period, &argvals);
2100
2101        let result = classify_seasonality(&data, 1, m, &argvals, period, None, None);
2102
2103        assert!(result.is_seasonal);
2104        assert!(result.seasonal_strength > 0.5);
2105        assert!(matches!(
2106            result.classification,
2107            SeasonalType::StableSeasonal | SeasonalType::VariableTiming
2108        ));
2109    }
2110
2111    #[test]
2112    fn test_otsu_threshold() {
2113        // Bimodal distribution: mix of low (0.1-0.2) and high (0.7-0.9) values
2114        let values = vec![
2115            0.1, 0.12, 0.15, 0.18, 0.11, 0.14, 0.7, 0.75, 0.8, 0.85, 0.9, 0.72,
2116        ];
2117
2118        let threshold = otsu_threshold(&values);
2119
2120        // Threshold should be between the two modes
2121        // Due to small sample size, Otsu's method may not find optimal threshold
2122        // Just verify it returns a reasonable value in the data range
2123        assert!(threshold >= 0.1, "Threshold {} should be >= 0.1", threshold);
2124        assert!(threshold <= 0.9, "Threshold {} should be <= 0.9", threshold);
2125    }
2126
2127    #[test]
2128    fn test_gcv_fourier_nbasis_selection() {
2129        let m = 100;
2130        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.1).collect();
2131
2132        // Noisy sine wave
2133        let mut data = vec![0.0; m];
2134        for j in 0..m {
2135            data[j] = (2.0 * PI * argvals[j] / 2.0).sin() + 0.1 * (j as f64 * 0.3).sin();
2136        }
2137
2138        let nbasis = crate::basis::select_fourier_nbasis_gcv(&data, 1, m, &argvals, 5, 25);
2139
2140        // nbasis should be reasonable (between min and max)
2141        assert!(nbasis >= 5);
2142        assert!(nbasis <= 25);
2143    }
2144
2145    #[test]
2146    fn test_detect_multiple_periods() {
2147        let m = 400;
2148        let argvals: Vec<f64> = (0..m).map(|i| i as f64 * 0.05).collect(); // 0 to 20
2149
2150        // Signal with two periods: 2 and 7
2151        let period1 = 2.0;
2152        let period2 = 7.0;
2153        let mut data = vec![0.0; m];
2154        for j in 0..m {
2155            data[j] = (2.0 * PI * argvals[j] / period1).sin()
2156                + 0.6 * (2.0 * PI * argvals[j] / period2).sin();
2157        }
2158
2159        // Use higher min_strength threshold to properly stop after real periods
2160        let detected = detect_multiple_periods(&data, 1, m, &argvals, 5, 0.4, 0.20);
2161
2162        // Should detect exactly 2 periods with these thresholds
2163        assert!(
2164            detected.len() >= 2,
2165            "Expected at least 2 periods, found {}",
2166            detected.len()
2167        );
2168
2169        // Check that both periods were detected (order depends on amplitude)
2170        let periods: Vec<f64> = detected.iter().map(|d| d.period).collect();
2171        let has_period1 = periods.iter().any(|&p| (p - period1).abs() < 0.3);
2172        let has_period2 = periods.iter().any(|&p| (p - period2).abs() < 0.5);
2173
2174        assert!(
2175            has_period1,
2176            "Expected to find period ~{}, got {:?}",
2177            period1, periods
2178        );
2179        assert!(
2180            has_period2,
2181            "Expected to find period ~{}, got {:?}",
2182            period2, periods
2183        );
2184
2185        // Verify first detected has higher amplitude (amplitude 1.0 vs 0.6)
2186        assert!(
2187            detected[0].amplitude > detected[1].amplitude,
2188            "First detected should have higher amplitude"
2189        );
2190
2191        // Each detected period should have strength and confidence info
2192        for d in &detected {
2193            assert!(
2194                d.strength > 0.0,
2195                "Detected period should have positive strength"
2196            );
2197            assert!(
2198                d.confidence > 0.0,
2199                "Detected period should have positive confidence"
2200            );
2201            assert!(
2202                d.amplitude > 0.0,
2203                "Detected period should have positive amplitude"
2204            );
2205        }
2206    }
2207}