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