Skip to main content

fdars_core/seasonal/
change.rs

1use crate::matrix::FdMatrix;
2use std::f64::consts::PI;
3
4use super::hilbert::hilbert_transform;
5use super::peak::detect_peaks;
6use super::strength::{
7    seasonal_strength_spectral, seasonal_strength_variance, seasonal_strength_windowed,
8};
9use super::{
10    analyze_amplitude_envelope, compute_cycle_strengths, compute_mean_curve, cwt_morlet_fft,
11    detect_threshold_crossings, linear_slope, otsu_threshold, valid_interior_bounds,
12    ChangeDetectionResult, PeakTimingResult, StrengthMethod,
13};
14
15/// Method for automatic threshold selection.
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub enum ThresholdMethod {
18    /// Fixed user-specified threshold
19    Fixed(f64),
20    /// Percentile of strength distribution
21    Percentile(f64),
22    /// Otsu's method (optimal bimodal separation)
23    Otsu,
24}
25
26/// Type of amplitude modulation pattern.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ModulationType {
29    /// Constant amplitude (no modulation)
30    Stable,
31    /// Amplitude increases over time (seasonality emerges)
32    Emerging,
33    /// Amplitude decreases over time (seasonality fades)
34    Fading,
35    /// Amplitude varies non-monotonically
36    Oscillating,
37    /// No seasonality detected
38    NonSeasonal,
39}
40
41/// Result of amplitude modulation detection.
42#[derive(Debug, Clone, PartialEq)]
43pub struct AmplitudeModulationResult {
44    /// Whether seasonality is present (using robust spectral method)
45    pub is_seasonal: bool,
46    /// Overall seasonal strength (spectral method)
47    pub seasonal_strength: f64,
48    /// Whether amplitude modulation is detected
49    pub has_modulation: bool,
50    /// Type of amplitude modulation
51    pub modulation_type: ModulationType,
52    /// Coefficient of variation of time-varying strength (0 = stable, higher = more modulation)
53    pub modulation_score: f64,
54    /// Trend in amplitude (-1 to 1: negative = fading, positive = emerging)
55    pub amplitude_trend: f64,
56    /// Time-varying seasonal strength curve
57    pub strength_curve: Vec<f64>,
58    /// Time points corresponding to strength_curve
59    pub time_points: Vec<f64>,
60    /// Minimum strength in the curve
61    pub min_strength: f64,
62    /// Maximum strength in the curve
63    pub max_strength: f64,
64}
65
66/// Result of wavelet-based amplitude modulation detection.
67#[derive(Debug, Clone, PartialEq)]
68pub struct WaveletAmplitudeResult {
69    /// Whether seasonality is present
70    pub is_seasonal: bool,
71    /// Overall seasonal strength
72    pub seasonal_strength: f64,
73    /// Whether amplitude modulation is detected
74    pub has_modulation: bool,
75    /// Type of amplitude modulation
76    pub modulation_type: ModulationType,
77    /// Coefficient of variation of wavelet amplitude
78    pub modulation_score: f64,
79    /// Trend in amplitude (-1 to 1)
80    pub amplitude_trend: f64,
81    /// Wavelet amplitude at the seasonal frequency over time
82    pub wavelet_amplitude: Vec<f64>,
83    /// Time points corresponding to wavelet_amplitude
84    pub time_points: Vec<f64>,
85    /// Scale (period) used for wavelet analysis
86    pub scale: f64,
87}
88
89/// Type of seasonality pattern.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum SeasonalType {
92    /// Regular peaks with consistent timing
93    StableSeasonal,
94    /// Regular peaks but timing shifts between cycles
95    VariableTiming,
96    /// Some cycles seasonal, some not
97    IntermittentSeasonal,
98    /// No clear seasonality
99    NonSeasonal,
100}
101
102/// Result of seasonality classification.
103#[derive(Debug, Clone, PartialEq)]
104pub struct SeasonalityClassification {
105    /// Whether the series is seasonal overall
106    pub is_seasonal: bool,
107    /// Whether peak timing is stable across cycles
108    pub has_stable_timing: bool,
109    /// Timing variability score (0 = stable, 1 = highly variable)
110    pub timing_variability: f64,
111    /// Overall seasonal strength
112    pub seasonal_strength: f64,
113    /// Per-cycle seasonal strength
114    pub cycle_strengths: Vec<f64>,
115    /// Indices of weak/missing seasons (0-indexed)
116    pub weak_seasons: Vec<usize>,
117    /// Classification type
118    pub classification: SeasonalType,
119    /// Peak timing analysis (if peaks were detected)
120    pub peak_timing: Option<PeakTimingResult>,
121}
122
123/// Detect changes in seasonality.
124///
125/// Monitors time-varying seasonal strength and detects threshold crossings
126/// that indicate onset or cessation of seasonality.
127///
128/// # Arguments
129/// * `data` - Column-major matrix (n x m)
130/// * `argvals` - Evaluation points
131/// * `period` - Seasonal period
132/// * `threshold` - SS threshold for seasonal/non-seasonal (e.g., 0.3)
133/// * `window_size` - Window size for local strength estimation
134/// * `min_duration` - Minimum duration to confirm a change
135pub fn detect_seasonality_changes(
136    data: &FdMatrix,
137    argvals: &[f64],
138    period: f64,
139    threshold: f64,
140    window_size: f64,
141    min_duration: f64,
142) -> ChangeDetectionResult {
143    let (n, m) = data.shape();
144    if n == 0 || m < 4 || argvals.len() != m {
145        return ChangeDetectionResult {
146            change_points: Vec::new(),
147            strength_curve: Vec::new(),
148        };
149    }
150
151    // Compute time-varying seasonal strength
152    let strength_curve =
153        seasonal_strength_windowed(data, argvals, period, window_size, StrengthMethod::Variance);
154
155    if strength_curve.is_empty() {
156        return ChangeDetectionResult {
157            change_points: Vec::new(),
158            strength_curve: Vec::new(),
159        };
160    }
161
162    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
163    let min_dur_points = (min_duration / dt).round() as usize;
164
165    let change_points =
166        detect_threshold_crossings(&strength_curve, argvals, threshold, min_dur_points);
167
168    ChangeDetectionResult {
169        change_points,
170        strength_curve,
171    }
172}
173
174/// Detect amplitude modulation in seasonal time series.
175///
176/// This function first checks if seasonality exists using the spectral method
177/// (which is robust to amplitude modulation), then uses Hilbert transform to
178/// extract the amplitude envelope and analyze modulation patterns.
179///
180/// # Arguments
181/// * `data` - Column-major matrix (n x m)
182/// * `argvals` - Evaluation points
183/// * `period` - Seasonal period in argvals units
184/// * `modulation_threshold` - CV threshold for detecting modulation (default: 0.15)
185/// * `seasonality_threshold` - Strength threshold for seasonality (default: 0.3)
186///
187/// # Returns
188/// `AmplitudeModulationResult` containing detection results and diagnostics
189pub fn detect_amplitude_modulation(
190    data: &FdMatrix,
191    argvals: &[f64],
192    period: f64,
193    modulation_threshold: f64,
194    seasonality_threshold: f64,
195) -> AmplitudeModulationResult {
196    let (n, m) = data.shape();
197    // Default result for invalid input
198    let empty_result = AmplitudeModulationResult {
199        is_seasonal: false,
200        seasonal_strength: 0.0,
201        has_modulation: false,
202        modulation_type: ModulationType::NonSeasonal,
203        modulation_score: 0.0,
204        amplitude_trend: 0.0,
205        strength_curve: Vec::new(),
206        time_points: Vec::new(),
207        min_strength: 0.0,
208        max_strength: 0.0,
209    };
210
211    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
212        return empty_result;
213    }
214
215    // Step 1: Check if seasonality exists using spectral method (robust to AM)
216    let overall_strength = seasonal_strength_spectral(data, argvals, period);
217
218    if overall_strength < seasonality_threshold {
219        return AmplitudeModulationResult {
220            is_seasonal: false,
221            seasonal_strength: overall_strength,
222            has_modulation: false,
223            modulation_type: ModulationType::NonSeasonal,
224            modulation_score: 0.0,
225            amplitude_trend: 0.0,
226            strength_curve: Vec::new(),
227            time_points: argvals.to_vec(),
228            min_strength: 0.0,
229            max_strength: 0.0,
230        };
231    }
232
233    // Step 2: Compute mean curve
234    let mean_curve = compute_mean_curve(data);
235
236    // Step 3: Use Hilbert transform to get amplitude envelope
237    let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
238    let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
239    let analytic = hilbert_transform(&detrended);
240    let envelope: Vec<f64> = analytic.iter().map(|c| c.norm()).collect();
241
242    if envelope.is_empty() {
243        return AmplitudeModulationResult {
244            is_seasonal: true,
245            seasonal_strength: overall_strength,
246            has_modulation: false,
247            modulation_type: ModulationType::Stable,
248            modulation_score: 0.0,
249            amplitude_trend: 0.0,
250            strength_curve: Vec::new(),
251            time_points: argvals.to_vec(),
252            min_strength: 0.0,
253            max_strength: 0.0,
254        };
255    }
256
257    // Step 4: Smooth the envelope to reduce high-frequency noise
258    let dt = (argvals[m - 1] - argvals[0]) / (m - 1) as f64;
259    let smooth_window = ((period / dt) as usize).max(3);
260    let half_window = smooth_window / 2;
261
262    let smoothed_envelope: Vec<f64> = (0..m)
263        .map(|i| {
264            let start = i.saturating_sub(half_window);
265            let end = (i + half_window + 1).min(m);
266            let sum: f64 = envelope[start..end].iter().sum();
267            sum / (end - start) as f64
268        })
269        .collect();
270
271    // Step 5: Analyze envelope statistics
272    let Some((interior_start, interior_end)) = valid_interior_bounds(m, 4) else {
273        return AmplitudeModulationResult {
274            is_seasonal: true,
275            seasonal_strength: overall_strength,
276            has_modulation: false,
277            modulation_type: ModulationType::Stable,
278            modulation_score: 0.0,
279            amplitude_trend: 0.0,
280            strength_curve: envelope,
281            time_points: argvals.to_vec(),
282            min_strength: 0.0,
283            max_strength: 0.0,
284        };
285    };
286
287    let stats = analyze_amplitude_envelope(
288        &smoothed_envelope[interior_start..interior_end],
289        &argvals[interior_start..interior_end],
290        modulation_threshold,
291    );
292
293    AmplitudeModulationResult {
294        is_seasonal: true,
295        seasonal_strength: overall_strength,
296        has_modulation: stats.has_modulation,
297        modulation_type: stats.modulation_type,
298        modulation_score: stats.modulation_score,
299        amplitude_trend: stats.amplitude_trend,
300        strength_curve: envelope,
301        time_points: argvals.to_vec(),
302        min_strength: stats.min_amp,
303        max_strength: stats.max_amp,
304    }
305}
306
307/// Detect amplitude modulation using Morlet wavelet transform.
308///
309/// Uses continuous wavelet transform at the seasonal period to extract
310/// time-varying amplitude. This method is more robust to noise and can
311/// better handle non-stationary signals compared to Hilbert transform.
312///
313/// # Arguments
314/// * `data` - Column-major matrix (n x m)
315/// * `argvals` - Evaluation points
316/// * `period` - Seasonal period in argvals units
317/// * `modulation_threshold` - CV threshold for detecting modulation (default: 0.15)
318/// * `seasonality_threshold` - Strength threshold for seasonality (default: 0.3)
319///
320/// # Returns
321/// `WaveletAmplitudeResult` containing detection results and wavelet amplitude curve
322pub fn detect_amplitude_modulation_wavelet(
323    data: &FdMatrix,
324    argvals: &[f64],
325    period: f64,
326    modulation_threshold: f64,
327    seasonality_threshold: f64,
328) -> WaveletAmplitudeResult {
329    let (n, m) = data.shape();
330    let empty_result = WaveletAmplitudeResult {
331        is_seasonal: false,
332        seasonal_strength: 0.0,
333        has_modulation: false,
334        modulation_type: ModulationType::NonSeasonal,
335        modulation_score: 0.0,
336        amplitude_trend: 0.0,
337        wavelet_amplitude: Vec::new(),
338        time_points: Vec::new(),
339        scale: 0.0,
340    };
341
342    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
343        return empty_result;
344    }
345
346    // Step 1: Check if seasonality exists using spectral method
347    let overall_strength = seasonal_strength_spectral(data, argvals, period);
348
349    if overall_strength < seasonality_threshold {
350        return WaveletAmplitudeResult {
351            is_seasonal: false,
352            seasonal_strength: overall_strength,
353            has_modulation: false,
354            modulation_type: ModulationType::NonSeasonal,
355            modulation_score: 0.0,
356            amplitude_trend: 0.0,
357            wavelet_amplitude: Vec::new(),
358            time_points: argvals.to_vec(),
359            scale: 0.0,
360        };
361    }
362
363    // Step 2: Compute mean curve
364    let mean_curve = compute_mean_curve(data);
365
366    // Remove DC component
367    let dc: f64 = mean_curve.iter().sum::<f64>() / m as f64;
368    let detrended: Vec<f64> = mean_curve.iter().map(|&x| x - dc).collect();
369
370    // Step 3: Compute wavelet transform at the seasonal period
371    let omega0 = 6.0; // Standard Morlet parameter
372    let scale = period * omega0 / (2.0 * PI);
373
374    // Use FFT-based CWT for efficiency
375    let wavelet_coeffs = cwt_morlet_fft(&detrended, argvals, scale, omega0);
376
377    if wavelet_coeffs.is_empty() {
378        return WaveletAmplitudeResult {
379            is_seasonal: true,
380            seasonal_strength: overall_strength,
381            has_modulation: false,
382            modulation_type: ModulationType::Stable,
383            modulation_score: 0.0,
384            amplitude_trend: 0.0,
385            wavelet_amplitude: Vec::new(),
386            time_points: argvals.to_vec(),
387            scale,
388        };
389    }
390
391    // Step 4: Extract amplitude (magnitude of wavelet coefficients)
392    let wavelet_amplitude: Vec<f64> = wavelet_coeffs.iter().map(|c| c.norm()).collect();
393
394    // Step 5: Analyze amplitude envelope statistics (skip edges)
395    let Some((interior_start, interior_end)) = valid_interior_bounds(m, 4) else {
396        return WaveletAmplitudeResult {
397            is_seasonal: true,
398            seasonal_strength: overall_strength,
399            has_modulation: false,
400            modulation_type: ModulationType::Stable,
401            modulation_score: 0.0,
402            amplitude_trend: 0.0,
403            wavelet_amplitude,
404            time_points: argvals.to_vec(),
405            scale,
406        };
407    };
408
409    let stats = analyze_amplitude_envelope(
410        &wavelet_amplitude[interior_start..interior_end],
411        &argvals[interior_start..interior_end],
412        modulation_threshold,
413    );
414
415    WaveletAmplitudeResult {
416        is_seasonal: true,
417        seasonal_strength: overall_strength,
418        has_modulation: stats.has_modulation,
419        modulation_type: stats.modulation_type,
420        modulation_score: stats.modulation_score,
421        amplitude_trend: stats.amplitude_trend,
422        wavelet_amplitude,
423        time_points: argvals.to_vec(),
424        scale,
425    }
426}
427
428/// Analyze peak timing variability across cycles.
429///
430/// For short series (e.g., 3-5 years of yearly data), this function detects
431/// one peak per cycle and analyzes how peak timing varies between cycles.
432///
433/// # Arguments
434/// * `data` - Column-major matrix (n x m)
435/// * `argvals` - Evaluation points
436/// * `period` - Known period (e.g., 365 for daily data with yearly seasonality)
437/// * `smooth_nbasis` - Number of Fourier basis functions for smoothing.
438///   If None, uses GCV for automatic selection.
439///
440/// # Returns
441/// Peak timing result with variability metrics
442pub fn analyze_peak_timing(
443    data: &FdMatrix,
444    argvals: &[f64],
445    period: f64,
446    smooth_nbasis: Option<usize>,
447) -> PeakTimingResult {
448    let (n, m) = data.shape();
449    if n == 0 || m < 3 || argvals.len() != m || period <= 0.0 {
450        return PeakTimingResult {
451            peak_times: Vec::new(),
452            peak_values: Vec::new(),
453            normalized_timing: Vec::new(),
454            mean_timing: f64::NAN,
455            std_timing: f64::NAN,
456            range_timing: f64::NAN,
457            variability_score: f64::NAN,
458            timing_trend: f64::NAN,
459            cycle_indices: Vec::new(),
460        };
461    }
462
463    // Detect peaks with minimum distance constraint of 0.7 * period
464    // This ensures we get at most one peak per cycle
465    let min_distance = period * 0.7;
466    let peaks = detect_peaks(
467        data,
468        argvals,
469        Some(min_distance),
470        None, // No prominence filter
471        true, // Smooth first with Fourier basis
472        smooth_nbasis,
473    );
474
475    // Use the first sample's peaks (for mean curve analysis)
476    let sample_peaks = if peaks.peaks.is_empty() {
477        Vec::new()
478    } else {
479        peaks.peaks[0].clone()
480    };
481
482    if sample_peaks.is_empty() {
483        return PeakTimingResult {
484            peak_times: Vec::new(),
485            peak_values: Vec::new(),
486            normalized_timing: Vec::new(),
487            mean_timing: f64::NAN,
488            std_timing: f64::NAN,
489            range_timing: f64::NAN,
490            variability_score: f64::NAN,
491            timing_trend: f64::NAN,
492            cycle_indices: Vec::new(),
493        };
494    }
495
496    let peak_times: Vec<f64> = sample_peaks.iter().map(|p| p.time).collect();
497    let peak_values: Vec<f64> = sample_peaks.iter().map(|p| p.value).collect();
498
499    // Compute normalized timing (position within cycle, 0-1 scale)
500    let t_start = argvals[0];
501    let normalized_timing: Vec<f64> = peak_times
502        .iter()
503        .map(|&t| {
504            let cycle_pos = (t - t_start) % period;
505            cycle_pos / period
506        })
507        .collect();
508
509    // Compute cycle indices (1-indexed)
510    let cycle_indices: Vec<usize> = peak_times
511        .iter()
512        .map(|&t| crate::utility::f64_to_usize_clamped(((t - t_start) / period).floor()) + 1)
513        .collect();
514
515    // Compute statistics
516    let n_peaks = normalized_timing.len() as f64;
517    let mean_timing = normalized_timing.iter().sum::<f64>() / n_peaks;
518
519    let variance: f64 = normalized_timing
520        .iter()
521        .map(|&x| (x - mean_timing).powi(2))
522        .sum::<f64>()
523        / n_peaks;
524    let std_timing = variance.sqrt();
525
526    let min_timing = normalized_timing
527        .iter()
528        .copied()
529        .fold(f64::INFINITY, f64::min);
530    let max_timing = normalized_timing
531        .iter()
532        .copied()
533        .fold(f64::NEG_INFINITY, f64::max);
534    let range_timing = max_timing - min_timing;
535
536    // Variability score: normalized std deviation
537    let variability_score = (std_timing / 0.1).min(1.0);
538
539    // Timing trend: linear regression of normalized timing on cycle index
540    let cycle_idx_f64: Vec<f64> = cycle_indices.iter().map(|&i| i as f64).collect();
541    let timing_trend = linear_slope(&cycle_idx_f64, &normalized_timing);
542
543    PeakTimingResult {
544        peak_times,
545        peak_values,
546        normalized_timing,
547        mean_timing,
548        std_timing,
549        range_timing,
550        variability_score,
551        timing_trend,
552        cycle_indices,
553    }
554}
555
556/// Classify the type of seasonality in functional data.
557///
558/// This is particularly useful for short series (3-5 years) where you need
559/// to identify:
560/// - Whether seasonality is present
561/// - Whether peak timing is stable or variable
562/// - Which cycles have weak or missing seasonality
563///
564/// # Arguments
565/// * `data` - Column-major matrix (n x m)
566/// * `argvals` - Evaluation points
567/// * `period` - Known seasonal period
568/// * `strength_threshold` - Threshold for seasonal/non-seasonal (default: 0.3)
569/// * `timing_threshold` - Max std of normalized timing for "stable" (default: 0.05)
570///
571/// # Returns
572/// Seasonality classification with type and diagnostics
573pub fn classify_seasonality(
574    data: &FdMatrix,
575    argvals: &[f64],
576    period: f64,
577    strength_threshold: Option<f64>,
578    timing_threshold: Option<f64>,
579) -> SeasonalityClassification {
580    let strength_thresh = strength_threshold.unwrap_or(0.3);
581    let timing_thresh = timing_threshold.unwrap_or(0.05);
582
583    let (n, m) = data.shape();
584    if n == 0 || m < 4 || argvals.len() != m || period <= 0.0 {
585        return SeasonalityClassification {
586            is_seasonal: false,
587            has_stable_timing: false,
588            timing_variability: f64::NAN,
589            seasonal_strength: f64::NAN,
590            cycle_strengths: Vec::new(),
591            weak_seasons: Vec::new(),
592            classification: SeasonalType::NonSeasonal,
593            peak_timing: None,
594        };
595    }
596
597    // Compute overall seasonal strength
598    let overall_strength = seasonal_strength_variance(data, argvals, period, 3);
599
600    let (cycle_strengths, weak_seasons) =
601        compute_cycle_strengths(data, argvals, period, strength_thresh);
602    let n_cycles = cycle_strengths.len();
603
604    // Analyze peak timing
605    let peak_timing = analyze_peak_timing(data, argvals, period, None);
606
607    // Determine classification
608    let is_seasonal = overall_strength >= strength_thresh;
609    let has_stable_timing = peak_timing.std_timing <= timing_thresh;
610    let timing_variability = peak_timing.variability_score;
611
612    // Classify based on patterns
613    let n_weak = weak_seasons.len();
614    let classification = if !is_seasonal {
615        SeasonalType::NonSeasonal
616    } else if n_cycles > 0 && n_weak as f64 / n_cycles as f64 > 0.3 {
617        // More than 30% of cycles are weak
618        SeasonalType::IntermittentSeasonal
619    } else if !has_stable_timing {
620        SeasonalType::VariableTiming
621    } else {
622        SeasonalType::StableSeasonal
623    };
624
625    SeasonalityClassification {
626        is_seasonal,
627        has_stable_timing,
628        timing_variability,
629        seasonal_strength: overall_strength,
630        cycle_strengths,
631        weak_seasons,
632        classification,
633        peak_timing: Some(peak_timing),
634    }
635}
636
637/// Detect seasonality changes with automatic threshold selection.
638///
639/// Uses Otsu's method or percentile-based threshold instead of a fixed value.
640///
641/// # Arguments
642/// * `data` - Column-major matrix (n x m)
643/// * `argvals` - Evaluation points
644/// * `period` - Seasonal period
645/// * `threshold_method` - Method for threshold selection
646/// * `window_size` - Window size for local strength estimation
647/// * `min_duration` - Minimum duration to confirm a change
648pub fn detect_seasonality_changes_auto(
649    data: &FdMatrix,
650    argvals: &[f64],
651    period: f64,
652    threshold_method: ThresholdMethod,
653    window_size: f64,
654    min_duration: f64,
655) -> ChangeDetectionResult {
656    let (n, m) = data.shape();
657    if n == 0 || m < 4 || argvals.len() != m {
658        return ChangeDetectionResult {
659            change_points: Vec::new(),
660            strength_curve: Vec::new(),
661        };
662    }
663
664    // Compute time-varying seasonal strength
665    let strength_curve =
666        seasonal_strength_windowed(data, argvals, period, window_size, StrengthMethod::Variance);
667
668    if strength_curve.is_empty() {
669        return ChangeDetectionResult {
670            change_points: Vec::new(),
671            strength_curve: Vec::new(),
672        };
673    }
674
675    // Determine threshold
676    let threshold = match threshold_method {
677        ThresholdMethod::Fixed(t) => t,
678        ThresholdMethod::Percentile(p) => {
679            let mut sorted: Vec<f64> = strength_curve
680                .iter()
681                .copied()
682                .filter(|x| x.is_finite())
683                .collect();
684            crate::helpers::sort_nan_safe(&mut sorted);
685            if sorted.is_empty() {
686                0.5
687            } else {
688                let idx = crate::utility::f64_to_usize_clamped((p / 100.0) * sorted.len() as f64);
689                sorted[idx.min(sorted.len() - 1)]
690            }
691        }
692        ThresholdMethod::Otsu => otsu_threshold(&strength_curve),
693    };
694
695    // Now use the regular detection with computed threshold
696    detect_seasonality_changes(data, argvals, period, threshold, window_size, min_duration)
697}