Skip to main content

fdars_core/seasonal/
sazed.rs

1use crate::matrix::FdMatrix;
2
3use super::{
4    autocorrelation, compute_mean_curve, find_consensus_period, find_first_acf_peak, find_peaks_1d,
5    periodogram, validate_sazed_component,
6};
7
8/// Result of SAZED ensemble period detection.
9#[derive(Debug, Clone, PartialEq)]
10pub struct SazedResult {
11    /// Primary detected period (consensus from ensemble)
12    pub period: f64,
13    /// Confidence score (0-1, based on component agreement)
14    pub confidence: f64,
15    /// Periods detected by each component (may be NaN if not detected)
16    pub component_periods: SazedComponents,
17    /// Number of components that agreed on the final period
18    pub agreeing_components: usize,
19}
20
21/// Individual period estimates from each SAZED component.
22#[derive(Debug, Clone, PartialEq)]
23pub struct SazedComponents {
24    /// Period from spectral (FFT) detection
25    pub spectral: f64,
26    /// Period from ACF peak detection
27    pub acf_peak: f64,
28    /// Period from weighted ACF average
29    pub acf_average: f64,
30    /// Period from ACF zero-crossing analysis
31    pub zero_crossing: f64,
32    /// Period from spectral differencing
33    pub spectral_diff: f64,
34}
35
36/// SAZED: Spectral-ACF Zero-crossing Ensemble Detection
37///
38/// A parameter-free ensemble method for robust period detection.
39/// Combines 5 detection components:
40/// 1. Spectral (FFT) - peaks in periodogram
41/// 2. ACF peak - first significant peak in autocorrelation
42/// 3. ACF average - weighted mean of ACF peaks
43/// 4. Zero-crossing - period from ACF zero crossings
44/// 5. Spectral differencing - FFT on first-differenced signal
45///
46/// Each component provides both a period estimate and a confidence score.
47/// Only components with sufficient confidence participate in voting.
48/// The final period is chosen by majority voting with tolerance.
49///
50/// # Arguments
51/// * `data` - Input signal (1D time series or mean curve from fdata)
52/// * `argvals` - Time points corresponding to data
53/// * `tolerance` - Relative tolerance for considering periods equal (default: 0.05 = 5%)
54///
55/// # Returns
56/// * `SazedResult` containing the consensus period and component details
57pub fn sazed(data: &[f64], argvals: &[f64], tolerance: Option<f64>) -> SazedResult {
58    let n = data.len();
59    let tol = tolerance.unwrap_or(0.05); // Tighter default tolerance
60
61    if n < 8 || argvals.len() != n {
62        return SazedResult {
63            period: f64::NAN,
64            confidence: 0.0,
65            component_periods: SazedComponents {
66                spectral: f64::NAN,
67                acf_peak: f64::NAN,
68                acf_average: f64::NAN,
69                zero_crossing: f64::NAN,
70                spectral_diff: f64::NAN,
71            },
72            agreeing_components: 0,
73        };
74    }
75
76    let dt = (argvals[n - 1] - argvals[0]) / (n - 1) as f64;
77    let max_lag = (n / 2).max(4);
78    let signal_range = argvals[n - 1] - argvals[0];
79
80    // Minimum detectable period (at least 3 cycles)
81    let min_period = signal_range / (n as f64 / 3.0);
82    // Maximum detectable period (at most 2 complete cycles)
83    let max_period = signal_range / 2.0;
84
85    // Component 1: Spectral (FFT) detection with confidence
86    let (spectral_period, spectral_conf) = sazed_spectral_with_confidence(data, argvals);
87
88    // Component 2: ACF peak detection with confidence
89    let (acf_peak_period, acf_peak_conf) = sazed_acf_peak_with_confidence(data, dt, max_lag);
90
91    // Component 3: ACF weighted average (uses ACF peak confidence)
92    let acf_average_period = sazed_acf_average(data, dt, max_lag);
93
94    // Component 4: Zero-crossing analysis with confidence
95    let (zero_crossing_period, zero_crossing_conf) =
96        sazed_zero_crossing_with_confidence(data, dt, max_lag);
97
98    // Component 5: Spectral on differenced signal with confidence
99    let (spectral_diff_period, spectral_diff_conf) =
100        sazed_spectral_diff_with_confidence(data, argvals);
101
102    let components = SazedComponents {
103        spectral: spectral_period,
104        acf_peak: acf_peak_period,
105        acf_average: acf_average_period,
106        zero_crossing: zero_crossing_period,
107        spectral_diff: spectral_diff_period,
108    };
109
110    // Confidence thresholds for each component (tuned to minimize FPR on noise)
111    // For Gaussian noise: spectral peaks rarely exceed 6x median, ACF ~1/sqrt(n)
112    let spectral_thresh = 8.0; // Power ratio must be > 8x median (noise rarely exceeds 6x)
113    let acf_thresh = 0.3; // ACF correlation must be > 0.3 (noise ~0.1 for n=100)
114    let zero_crossing_thresh = 0.9; // Zero-crossing consistency > 90%
115    let spectral_diff_thresh = 6.0; // Diff spectral power ratio > 6x
116
117    // Minimum number of confident components required to report a period
118    let min_confident_components = 2;
119
120    // Collect valid periods (only from components with sufficient confidence)
121    let confident_periods: Vec<f64> = [
122        validate_sazed_component(
123            spectral_period,
124            spectral_conf,
125            min_period,
126            max_period,
127            spectral_thresh,
128        ),
129        validate_sazed_component(
130            acf_peak_period,
131            acf_peak_conf,
132            min_period,
133            max_period,
134            acf_thresh,
135        ),
136        validate_sazed_component(
137            acf_average_period,
138            acf_peak_conf,
139            min_period,
140            max_period,
141            acf_thresh,
142        ),
143        validate_sazed_component(
144            zero_crossing_period,
145            zero_crossing_conf,
146            min_period,
147            max_period,
148            zero_crossing_thresh,
149        ),
150        validate_sazed_component(
151            spectral_diff_period,
152            spectral_diff_conf,
153            min_period,
154            max_period,
155            spectral_diff_thresh,
156        ),
157    ]
158    .into_iter()
159    .flatten()
160    .collect();
161
162    // Require minimum number of confident components before reporting a period
163    if confident_periods.len() < min_confident_components {
164        return SazedResult {
165            period: f64::NAN,
166            confidence: 0.0,
167            component_periods: components,
168            agreeing_components: confident_periods.len(),
169        };
170    }
171
172    // Ensemble voting: find the mode with tolerance
173    let (consensus_period, agreeing_count) = find_consensus_period(&confident_periods, tol);
174    let confidence = agreeing_count as f64 / 5.0;
175
176    SazedResult {
177        period: consensus_period,
178        confidence,
179        component_periods: components,
180        agreeing_components: agreeing_count,
181    }
182}
183
184/// SAZED for functional data (matrix format)
185///
186/// Computes mean curve first, then applies SAZED.
187///
188/// # Arguments
189/// * `data` - Column-major matrix (n x m)
190/// * `argvals` - Evaluation points
191/// * `tolerance` - Relative tolerance for period matching
192pub fn sazed_fdata(data: &FdMatrix, argvals: &[f64], tolerance: Option<f64>) -> SazedResult {
193    let (n, m) = data.shape();
194    if n == 0 || m < 8 || argvals.len() != m {
195        return SazedResult {
196            period: f64::NAN,
197            confidence: 0.0,
198            component_periods: SazedComponents {
199                spectral: f64::NAN,
200                acf_peak: f64::NAN,
201                acf_average: f64::NAN,
202                zero_crossing: f64::NAN,
203                spectral_diff: f64::NAN,
204            },
205            agreeing_components: 0,
206        };
207    }
208
209    let mean_curve = compute_mean_curve(data);
210    sazed(&mean_curve, argvals, tolerance)
211}
212
213/// Spectral component with confidence: returns (period, power_ratio)
214fn sazed_spectral_with_confidence(data: &[f64], argvals: &[f64]) -> (f64, f64) {
215    let (frequencies, power) = periodogram(data, argvals);
216
217    if frequencies.len() < 3 {
218        return (f64::NAN, 0.0);
219    }
220
221    // Find peaks in power spectrum (skip DC)
222    let power_no_dc: Vec<f64> = power.iter().skip(1).copied().collect();
223
224    if power_no_dc.is_empty() {
225        return (f64::NAN, 0.0);
226    }
227
228    // Calculate noise floor as median
229    let mut sorted_power = power_no_dc.clone();
230    crate::helpers::sort_nan_safe(&mut sorted_power);
231    let noise_floor = sorted_power[sorted_power.len() / 2].max(1e-15);
232
233    // Find global maximum
234    let mut max_idx = 0;
235    let mut max_val = 0.0;
236    for (i, &p) in power_no_dc.iter().enumerate() {
237        if p > max_val {
238            max_val = p;
239            max_idx = i;
240        }
241    }
242
243    let power_ratio = max_val / noise_floor;
244    let freq = frequencies[max_idx + 1];
245
246    if freq > 1e-15 {
247        (1.0 / freq, power_ratio)
248    } else {
249        (f64::NAN, 0.0)
250    }
251}
252
253/// ACF peak component with confidence: returns (period, acf_value_at_peak)
254fn sazed_acf_peak_with_confidence(data: &[f64], dt: f64, max_lag: usize) -> (f64, f64) {
255    let acf = autocorrelation(data, max_lag);
256
257    match find_first_acf_peak(&acf) {
258        Some((peak_lag, acf_value)) => (peak_lag as f64 * dt, acf_value),
259        None => (f64::NAN, 0.0),
260    }
261}
262
263/// ACF average component: weighted mean of ACF peak locations
264fn sazed_acf_average(data: &[f64], dt: f64, max_lag: usize) -> f64 {
265    let acf = autocorrelation(data, max_lag);
266
267    if acf.len() < 4 {
268        return f64::NAN;
269    }
270
271    // Find all peaks in ACF
272    let peaks = find_peaks_1d(&acf[1..], 1);
273
274    if peaks.is_empty() {
275        return f64::NAN;
276    }
277
278    // Weight peaks by their ACF value
279    let mut weighted_sum = 0.0;
280    let mut weight_sum = 0.0;
281
282    for (i, &peak_idx) in peaks.iter().enumerate() {
283        let lag = peak_idx + 1;
284        let weight = acf[lag].max(0.0);
285
286        if i == 0 {
287            // First peak is the fundamental period
288            weighted_sum += lag as f64 * weight;
289            weight_sum += weight;
290        } else {
291            // Later peaks: estimate fundamental by dividing by harmonic number
292            let expected_fundamental = peaks[0] + 1;
293            let harmonic = ((lag as f64 / expected_fundamental as f64) + 0.5) as usize;
294            if harmonic > 0 {
295                let fundamental_est = lag as f64 / harmonic as f64;
296                weighted_sum += fundamental_est * weight;
297                weight_sum += weight;
298            }
299        }
300    }
301
302    if weight_sum > 1e-15 {
303        weighted_sum / weight_sum * dt
304    } else {
305        f64::NAN
306    }
307}
308
309/// Zero-crossing component with confidence: returns (period, consistency)
310/// Consistency is how regular the zero crossings are (std/mean of half-periods)
311fn sazed_zero_crossing_with_confidence(data: &[f64], dt: f64, max_lag: usize) -> (f64, f64) {
312    let acf = autocorrelation(data, max_lag);
313
314    if acf.len() < 4 {
315        return (f64::NAN, 0.0);
316    }
317
318    // Find zero crossings (sign changes)
319    let mut crossings = Vec::new();
320    for i in 1..acf.len() {
321        if acf[i - 1] * acf[i] < 0.0 {
322            // Linear interpolation for more precise crossing
323            let frac = acf[i - 1].abs() / (acf[i - 1].abs() + acf[i].abs());
324            crossings.push((i - 1) as f64 + frac);
325        }
326    }
327
328    if crossings.len() < 2 {
329        return (f64::NAN, 0.0);
330    }
331
332    // Period is twice the distance between consecutive zero crossings
333    // (ACF goes through two zero crossings per period)
334    let mut half_periods = Vec::new();
335    for i in 1..crossings.len() {
336        half_periods.push(crossings[i] - crossings[i - 1]);
337    }
338
339    if half_periods.is_empty() {
340        return (f64::NAN, 0.0);
341    }
342
343    // Calculate consistency: 1 - (std/mean) of half-periods
344    // High consistency means regular zero crossings
345    let mean: f64 = half_periods.iter().sum::<f64>() / half_periods.len() as f64;
346    let variance: f64 =
347        half_periods.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / half_periods.len() as f64;
348    let std = variance.sqrt();
349    let consistency = (1.0 - std / mean.max(1e-15)).clamp(0.0, 1.0);
350
351    // Median half-period
352    crate::helpers::sort_nan_safe(&mut half_periods);
353    let median_half = half_periods[half_periods.len() / 2];
354
355    (2.0 * median_half * dt, consistency)
356}
357
358/// Spectral differencing with confidence: returns (period, power_ratio)
359fn sazed_spectral_diff_with_confidence(data: &[f64], argvals: &[f64]) -> (f64, f64) {
360    if data.len() < 4 {
361        return (f64::NAN, 0.0);
362    }
363
364    // First difference to remove trend
365    let diff: Vec<f64> = data.windows(2).map(|w| w[1] - w[0]).collect();
366    let diff_argvals: Vec<f64> = argvals.windows(2).map(|w| (w[0] + w[1]) / 2.0).collect();
367
368    sazed_spectral_with_confidence(&diff, &diff_argvals)
369}