Skip to main content

chronix_analytics/preprocess/
decomposition.rs

1//! Seasonal-Trend decomposition using Loess (STL).
2//!
3//! Splits a time series into **trend**, **seasonal**, and **residual**
4//! components:  `y = trend + seasonal + residual`.
5//!
6//! The implementation follows the Cleveland et al. (1990) STL algorithm:
7//!
8//! 1. Start with `seasonal = 0`.
9//! 2. Detrend: `detrended = y - trend`.
10//! 3. Subseries smoothing: extract each season's slice, apply degree-1 Loess
11//!    (locally weighted polynomial regression with tricube kernel).
12//! 4. Trend update: `y - seasonal`, then Loess with `trend_window`.
13//! 5. Repeat for `n_iter` iterations.
14
15use thiserror::Error;
16
17/// Decomposition result: `y[i] = trend[i] + seasonal[i] + residual[i]`.
18#[derive(Debug, Clone)]
19pub struct Decomposition {
20    /// Trend component.
21    pub trend: Vec<f64>,
22    /// Seasonal component.
23    pub seasonal: Vec<f64>,
24    /// Residual (remainder) component.
25    pub residual: Vec<f64>,
26}
27
28/// Trait for seasonal decomposition strategies.
29pub trait SeasonalDecomposer: Send + Sync {
30    /// Decompose a time series into trend, seasonal, and residual components.
31    fn decompose(
32        &self,
33        timestamps: &[i64],
34        values: &[f64],
35        period: usize,
36    ) -> Result<Decomposition, DecompositionError>;
37}
38
39/// STL decomposer implementing [`SeasonalDecomposer`].
40pub struct StlDecomposer {
41    /// Number of outer-loop iterations.
42    pub n_iter: usize,
43    /// Trend smoother window length (None = auto).
44    pub trend_window: Option<usize>,
45}
46
47impl Default for StlDecomposer {
48    fn default() -> Self {
49        Self {
50            n_iter: 2,
51            trend_window: None,
52        }
53    }
54}
55
56impl SeasonalDecomposer for StlDecomposer {
57    #[tracing::instrument(skip_all, level = "debug")]
58    fn decompose(
59        &self,
60        _timestamps: &[i64],
61        values: &[f64],
62        period: usize,
63    ) -> Result<Decomposition, DecompositionError> {
64        let config = StlConfig {
65            period,
66            n_iter: self.n_iter,
67            trend_window: self.trend_window,
68            seasonal_window: None,
69        };
70        stl_decompose(values, &config)
71    }
72}
73
74/// Errors that can occur during decomposition.
75#[derive(Debug, Error)]
76pub enum DecompositionError {
77    /// The series is too short for the requested seasonal period.
78    #[error("series length ({len}) must be at least 2× the period ({period})")]
79    TooShort {
80        /// Actual series length.
81        len: usize,
82        /// Requested seasonal period.
83        period: usize,
84    },
85
86    /// The seasonal period is invalid (must be ≥ 2).
87    #[error("period must be ≥ 2, got {0}")]
88    InvalidPeriod(usize),
89}
90
91/// Configuration for the STL decomposer.
92#[derive(Debug, Clone)]
93pub struct StlConfig {
94    /// Seasonal period (e.g., 24 for hourly data with daily seasonality).
95    pub period: usize,
96    /// Number of outer-loop iterations (default: 2).
97    pub n_iter: usize,
98    /// Trend smoother window length — must be odd, ≥ 3.
99    /// Default: `period + 1` (rounded up to next odd).
100    pub trend_window: Option<usize>,
101    /// Seasonal subseries smoother window length — must be odd, ≥ 3.
102    /// Default: `max(7, period)` (rounded up to next odd).
103    /// R's STL defaults to at least 7; the previous hardcoded value of 3
104    /// was too small and allowed noise into the seasonal component.
105    pub seasonal_window: Option<usize>,
106}
107
108impl StlConfig {
109    /// Creates a new STL configuration with the given seasonal period.
110    pub fn new(period: usize) -> Self {
111        Self {
112            period,
113            n_iter: 2,
114            trend_window: None,
115            seasonal_window: None,
116        }
117    }
118
119    /// Sets the number of STL iterations.
120    pub fn with_iterations(mut self, n: usize) -> Self {
121        self.n_iter = n.max(1);
122        self
123    }
124
125    /// Sets the trend smoother window length.
126    pub fn with_trend_window(mut self, w: usize) -> Self {
127        self.trend_window = Some(w);
128        self
129    }
130
131    /// Sets the seasonal subseries smoother window length.
132    pub fn with_seasonal_window(mut self, w: usize) -> Self {
133        self.seasonal_window = Some(w);
134        self
135    }
136
137    fn effective_trend_window(&self) -> usize {
138        let w = self.trend_window.unwrap_or(self.period + 1);
139        // must be odd and ≥ 3
140        let w = w.max(3);
141        if w.is_multiple_of(2) {
142            w + 1
143        } else {
144            w
145        }
146    }
147
148    fn effective_seasonal_window(&self) -> usize {
149        let w = self.seasonal_window.unwrap_or(self.period.max(7));
150        let w = w.max(3);
151        if w.is_multiple_of(2) {
152            w + 1
153        } else {
154            w
155        }
156    }
157}
158
159/// Perform STL decomposition.
160pub fn stl_decompose(
161    values: &[f64],
162    config: &StlConfig,
163) -> Result<Decomposition, DecompositionError> {
164    let n = values.len();
165    let period = config.period;
166
167    if period < 2 {
168        return Err(DecompositionError::InvalidPeriod(period));
169    }
170    if n < 2 * period {
171        return Err(DecompositionError::TooShort { len: n, period });
172    }
173
174    let trend_w = config.effective_trend_window();
175    let seasonal_w = config.effective_seasonal_window();
176
177    let mut seasonal = vec![0.0; n];
178    let mut trend = vec![0.0; n];
179
180    // The inner loop of Cleveland et al. (1990), steps 1–6, without the
181    // robustness weights. The step that matters is the low-pass filter: the
182    // cycle-subseries smoother produces a nearly periodic series `C`, and
183    // the seasonal component is `C` minus the *low-frequency* part of `C`,
184    // so that any drift the subseries smoother absorbed goes back to the
185    // trend. What stood here before centred each subseries on its own mean
186    // across cycles — which is zero for exactly the series STL exists for,
187    // a stable seasonal pattern — and printed a seasonal component of 1e-16
188    // for a clean 24-period sine.
189    for _ in 0..config.n_iter {
190        // Step 1: detrend.
191        let detrended: Vec<f64> = values
192            .iter()
193            .zip(trend.iter())
194            .map(|(y, t)| y - t)
195            .collect();
196
197        // Step 2: cycle-subseries smoothing. Each season offset is smoothed
198        // across its cycles, and the result is scattered back in place.
199        let mut cycle = vec![0.0; n];
200        for s in 0..period {
201            let indices: Vec<usize> = (s..n).step_by(period).collect();
202            let sub: Vec<f64> = indices.iter().map(|&i| detrended[i]).collect();
203            let smoothed = loess_smooth(&sub, seasonal_w);
204            for (&i, &v) in indices.iter().zip(smoothed.iter()) {
205                cycle[i] = v;
206            }
207        }
208
209        // Step 3: low-pass filter of the cycle-subseries — a centred moving
210        // average of one period (two, for an even period, to stay centred)
211        // and one of three. The full algorithm extends each subseries by a
212        // cycle at either end before filtering; padding with the adjacent
213        // cycle is the same idea for a series that is nearly periodic.
214        let low_pass = low_pass_filter(&cycle, period);
215
216        // Step 4: the seasonal is what the low-pass filter removed.
217        for i in 0..n {
218            seasonal[i] = cycle[i] - low_pass[i];
219        }
220
221        // Steps 5–6: deseasonalise and smooth the trend.
222        let deseasoned: Vec<f64> = values
223            .iter()
224            .zip(seasonal.iter())
225            .map(|(y, s)| y - s)
226            .collect();
227        trend = loess_smooth(&deseasoned, trend_w);
228    }
229
230    // Residual
231    let residual: Vec<f64> = values
232        .iter()
233        .zip(trend.iter())
234        .zip(seasonal.iter())
235        .map(|((y, t), s)| y - t - s)
236        .collect();
237
238    Ok(Decomposition {
239        trend,
240        seasonal,
241        residual,
242    })
243}
244
245/// Detect the dominant period using autocorrelation peak finding.
246///
247/// Returns `None` if no clear period is found or the series is too short.
248///
249/// # ACF Threshold
250///
251/// The series is **detrended** first, then the autocorrelation is searched
252/// for **local maxima** — the two together are what make this a period
253/// detector rather than a smoothness detector.
254///
255/// The minimum ACF peak value is hardcoded to **0.3** here, which suits the
256/// pronounced cycles of typical TSDB workloads. Use
257/// [`detect_period_with_threshold`] for a noisy signal (a lower threshold
258/// trades precision for recall) or a very clean one (a higher threshold
259/// rejects incidental peaks).
260pub fn detect_period(values: &[f64], max_period: usize) -> Option<usize> {
261    detect_period_with_threshold(values, max_period, 0.3)
262}
263
264/// Detect the dominant period using autocorrelation peak finding with
265/// a caller-specified minimum ACF threshold.
266///
267/// `min_acf_threshold` controls how strong the autocorrelation peak
268/// must be to accept a period:
269/// Lower values (e.g. 0.1) improve recall for weak/noisy signals
270///   at the cost of more false positives.
271/// Higher values (e.g. 0.5) require a strong seasonal pattern,
272///   avoiding spurious detections.
273///
274/// Returns `None` if no clear period is found or the series is too short.
275pub fn detect_period_with_threshold(
276    values: &[f64],
277    max_period: usize,
278    min_acf_threshold: f64,
279) -> Option<usize> {
280    let n = values.len();
281    if n < 4 {
282        return None;
283    }
284
285    // Detrend before correlating. A trended series' autocorrelation is
286    // dominated by the trend and decays from lag 1, so the largest value sits
287    // at the smallest lag and the seasonal peak is invisible underneath it.
288    // Removing the least-squares line leaves the periodic component.
289    let residual = detrend(values);
290
291    let mean = residual.iter().sum::<f64>() / n as f64;
292    let var: f64 = residual.iter().map(|v| (v - mean).powi(2)).sum();
293    if var < f64::EPSILON {
294        return None;
295    }
296
297    let max_lag = max_period.min(n / 2);
298    if max_lag < 3 {
299        return None;
300    }
301
302    // Biased ACF estimator (divide by the total variance rather than by
303    // `n - lag`): it tapers naturally at long lags, which is what stops a
304    // harmonic multiple 2P from outscoring the fundamental P.
305    let acf: Vec<f64> = (0..=max_lag)
306        .map(|lag| {
307            let s: f64 = (0..(n - lag))
308                .map(|i| (residual[i] - mean) * (residual[i + lag] - mean))
309                .sum();
310            s / var
311        })
312        .collect();
313
314    // A period is a **local maximum** of the autocorrelation, not its largest
315    // value: a smooth series is most correlated with its immediate neighbour,
316    // which is a statement about smoothness rather than seasonality.
317    let mut best: Option<(usize, f64)> = None;
318    for lag in 2..max_lag {
319        let (prev, here, next) = (acf[lag - 1], acf[lag], acf[lag + 1]);
320        if here > prev && here >= next && here > min_acf_threshold {
321            // Ties go to the shorter lag: the fundamental, not its harmonic.
322            if best.is_none_or(|(_, v)| here > v) {
323                best = Some((lag, here));
324            }
325        }
326    }
327
328    best.map(|(lag, _)| lag)
329}
330
331/// Remove the least-squares linear trend from a series.
332fn detrend(values: &[f64]) -> Vec<f64> {
333    let n = values.len();
334    if n < 2 {
335        return values.to_vec();
336    }
337    let n_f = n as f64;
338    let mean_x = (n_f - 1.0) / 2.0;
339    let mean_y = values.iter().sum::<f64>() / n_f;
340    let mut sxx = 0.0;
341    let mut sxy = 0.0;
342    for (i, &y) in values.iter().enumerate() {
343        let dx = i as f64 - mean_x;
344        sxx += dx * dx;
345        sxy += dx * (y - mean_y);
346    }
347    if sxx <= 0.0 {
348        return values.iter().map(|v| v - mean_y).collect();
349    }
350    let slope = sxy / sxx;
351    values
352        .iter()
353        .enumerate()
354        .map(|(i, &y)| y - (mean_y + slope * (i as f64 - mean_x)))
355        .collect()
356}
357
358/// Locally weighted regression (Loess) smoother with tricube
359/// kernel (Cleveland 1990).
360///
361/// Fits a degree-1 (linear) polynomial at each point using weighted
362/// least squares.  The tricube kernel `w(u) = (1 - |u|³)³` for `|u| < 1`
363/// downweights points far from the center, producing better edge
364/// behaviour and outlier resistance than a uniform moving average.
365/// The low-pass filter of STL step 3: pad by one cycle at each end with the
366/// adjacent cycle, apply a `period`-point centred moving average (a 2×MA for
367/// an even period), then a 3-point one, and cut the padding off again.
368fn low_pass_filter(cycle: &[f64], period: usize) -> Vec<f64> {
369    let n = cycle.len();
370    let mut padded = Vec::with_capacity(n + 2 * period);
371    padded.extend_from_slice(&cycle[..period]);
372    padded.extend_from_slice(cycle);
373    padded.extend_from_slice(&cycle[n - period..]);
374
375    let mut filtered = centered_moving_average(&padded, period);
376    if period.is_multiple_of(2) {
377        filtered = centered_moving_average(&filtered, 2);
378    }
379    let filtered = centered_moving_average(&filtered, 3);
380    filtered[period..period + n].to_vec()
381}
382
383/// A centred moving average of `w` points. For an even `w` the window is
384/// `[i - w/2, i + w/2 - 1]`, so two passes (`w`, then `2`) give the classic
385/// `2×m`-MA; the window shrinks symmetrically at the ends.
386fn centered_moving_average(x: &[f64], w: usize) -> Vec<f64> {
387    let n = x.len();
388    if w <= 1 || n == 0 {
389        return x.to_vec();
390    }
391    let before = w / 2;
392    let after = if w.is_multiple_of(2) {
393        w / 2 - 1
394    } else {
395        w / 2
396    };
397    (0..n)
398        .map(|i| {
399            let lo = i.saturating_sub(before);
400            let hi = (i + after).min(n - 1);
401            let slice = &x[lo..=hi];
402            slice.iter().sum::<f64>() / slice.len() as f64
403        })
404        .collect()
405}
406
407fn loess_smooth(values: &[f64], window: usize) -> Vec<f64> {
408    let n = values.len();
409    if window == 0 {
410        return values.to_vec();
411    }
412    if window >= n {
413        let mean = values.iter().sum::<f64>() / n as f64;
414        return vec![mean; n];
415    }
416    let half = window / 2;
417    let mut out = Vec::with_capacity(n);
418
419    for i in 0..n {
420        let lo = i.saturating_sub(half);
421        let hi = (i + half).min(n - 1);
422        let max_dist = (half.max(1)) as f64;
423
424        let (mut sw, mut swx, mut swy, mut swxx, mut swxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
425        for j in lo..=hi {
426            let u = ((j as f64 - i as f64) / max_dist).abs().min(1.0);
427            let u3 = u * u * u;
428            let w = (1.0 - u3) * (1.0 - u3) * (1.0 - u3); // tricube
429            let x = j as f64;
430            let y = values[j];
431            sw += w;
432            swx += w * x;
433            swy += w * y;
434            swxx += w * x * x;
435            swxy += w * x * y;
436        }
437        let det = sw * swxx - swx * swx;
438        let fitted = if det.abs() < 1e-15 {
439            if sw > 0.0 {
440                swy / sw
441            } else {
442                values[i]
443            }
444        } else {
445            let a = (swxx * swy - swx * swxy) / det;
446            let b = (sw * swxy - swx * swy) / det;
447            a + b * i as f64
448        };
449        out.push(fitted);
450    }
451    out
452}
453
454// -----------------------------------------------------------------------
455// Tests
456// -----------------------------------------------------------------------
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    fn approx(a: f64, b: f64, eps: f64) -> bool {
462        (a - b).abs() < eps
463    }
464
465    /// `y = 0.5·i + 10·sin(2πi/12)`: the seasonal component must *be* the
466    /// sine and the trend must be the line. The previous version of this
467    /// test asserted only that the three components summed to the input,
468    /// which the residual guarantees by definition — and the seasonal
469    /// component it accepted was identically zero.
470    #[test]
471    fn stl_recovers_the_trend_and_the_seasonal_pattern() {
472        let n = 120;
473        let period = 12;
474        let seasonal_of = |i: usize| 10.0 * (2.0 * std::f64::consts::PI * i as f64 / 12.0).sin();
475        let values: Vec<f64> = (0..n).map(|i| 0.5 * i as f64 + seasonal_of(i)).collect();
476
477        let dec = stl_decompose(&values, &StlConfig::new(period).with_iterations(5)).unwrap();
478
479        let amplitude = dec.seasonal.iter().copied().fold(f64::MIN, f64::max)
480            - dec.seasonal.iter().copied().fold(f64::MAX, f64::min);
481        assert!(
482            amplitude > 18.0,
483            "seasonal amplitude collapsed: {amplitude}"
484        );
485
486        // Away from the ends the recovery is tight; the ends see a shorter
487        // smoother window and are allowed a little more.
488        for i in period..n - period {
489            assert!(
490                approx(dec.seasonal[i], seasonal_of(i), 0.6),
491                "seasonal at {i}: {} vs {}",
492                dec.seasonal[i],
493                seasonal_of(i)
494            );
495            assert!(
496                approx(dec.trend[i], 0.5 * i as f64, 0.6),
497                "trend at {i}: {} vs {}",
498                dec.trend[i],
499                0.5 * i as f64
500            );
501            assert!(
502                dec.residual[i].abs() < 0.6,
503                "residual at {i}: {}",
504                dec.residual[i]
505            );
506        }
507        for i in 0..n {
508            let recon = dec.trend[i] + dec.seasonal[i] + dec.residual[i];
509            assert!(approx(recon, values[i], 1e-9));
510        }
511    }
512
513    /// An even period is centred with the classic 2×m moving average.
514    #[test]
515    fn stl_handles_an_even_period_without_bias() {
516        let n = 96;
517        let period = 24;
518        let seasonal_of = |i: usize| 5.0 * (2.0 * std::f64::consts::PI * i as f64 / 24.0).sin();
519        let values: Vec<f64> = (0..n).map(|i| 100.0 + seasonal_of(i)).collect();
520
521        let dec = stl_decompose(&values, &StlConfig::new(period).with_iterations(3)).unwrap();
522        for i in period..n - period {
523            assert!(
524                approx(dec.seasonal[i], seasonal_of(i), 0.4),
525                "seasonal at {i}"
526            );
527            assert!(
528                approx(dec.trend[i], 100.0, 0.4),
529                "trend at {i}: {}",
530                dec.trend[i]
531            );
532        }
533        let max_residual = dec.residual.iter().map(|r| r.abs()).fold(0.0_f64, f64::max);
534        assert!(max_residual < 0.8, "residual too large: {max_residual}");
535    }
536
537    #[test]
538    fn test_stl_sum_equals_original() {
539        let n = 200;
540        let period = 10;
541        let values: Vec<f64> = (0..n)
542            .map(|i| (i as f64 * 0.1).sin() + 0.01 * i as f64)
543            .collect();
544
545        let dec = stl_decompose(&values, &StlConfig::new(period)).unwrap();
546        for i in 0..n {
547            let recon = dec.trend[i] + dec.seasonal[i] + dec.residual[i];
548            assert!(approx(recon, values[i], 1e-12));
549        }
550    }
551
552    #[test]
553    fn test_stl_too_short() {
554        let values = vec![1.0; 10];
555        let err = stl_decompose(&values, &StlConfig::new(8)).unwrap_err();
556        assert!(matches!(err, DecompositionError::TooShort { .. }));
557    }
558
559    #[test]
560    fn test_stl_invalid_period() {
561        let values = vec![1.0; 100];
562        let err = stl_decompose(&values, &StlConfig::new(1)).unwrap_err();
563        assert!(matches!(err, DecompositionError::InvalidPeriod(1)));
564    }
565
566    #[test]
567    fn test_detect_period_sinusoidal() {
568        let n = 240;
569        let true_period = 24;
570        let values: Vec<f64> = (0..n)
571            .map(|i| (2.0 * std::f64::consts::PI * i as f64 / true_period as f64).sin())
572            .collect();
573
574        let detected = detect_period(&values, 48);
575        assert_eq!(detected, Some(true_period));
576    }
577
578    #[test]
579    fn test_detect_period_constant_returns_none() {
580        let values = vec![42.0; 100];
581        assert_eq!(detect_period(&values, 50), None);
582    }
583
584    #[test]
585    fn test_loess_smooth_identity() {
586        // Single-point bandwidth: each point is its own neighborhood → output ≈ input
587        let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
588        let smoothed = loess_smooth(&v, 1);
589        for (a, b) in smoothed.iter().zip(v.iter()) {
590            assert!(approx(*a, *b, 1e-9));
591        }
592    }
593
594    #[test]
595    fn test_loess_smooth_smoothing() {
596        // With a wider bandwidth the output should be smoother
597        let v = vec![1.0, 3.0, 5.0, 7.0, 9.0];
598        let smoothed = loess_smooth(&v, 3);
599        // For a perfectly linear sequence, Loess (degree 1) should recover
600        // the exact values regardless of bandwidth.
601        for (a, b) in smoothed.iter().zip(v.iter()) {
602            assert!(approx(*a, *b, 1e-6));
603        }
604    }
605
606    #[test]
607    fn test_seasonal_decomposer_trait() {
608        let decomposer = StlDecomposer::default();
609        let n = 200;
610        let period = 10;
611        let timestamps: Vec<i64> = (0..n as i64).collect();
612        let values: Vec<f64> = (0..n)
613            .map(|i| (i as f64 * 0.1).sin() + 0.01 * i as f64)
614            .collect();
615
616        let dec = decomposer.decompose(&timestamps, &values, period).unwrap();
617        assert_eq!(dec.trend.len(), n);
618        assert_eq!(dec.seasonal.len(), n);
619        assert_eq!(dec.residual.len(), n);
620        // Components should sum to original
621        for i in 0..n {
622            let recon = dec.trend[i] + dec.seasonal[i] + dec.residual[i];
623            assert!(approx(recon, values[i], 1e-12));
624        }
625    }
626
627    #[test]
628    fn test_seasonal_decomposer_trait_too_short() {
629        let decomposer = StlDecomposer::default();
630        let err = decomposer.decompose(&[0, 1], &[1.0, 2.0], 5).unwrap_err();
631        assert!(matches!(err, DecompositionError::TooShort { .. }));
632    }
633
634    #[test]
635    fn test_detect_period_with_threshold_controls_sensitivity() {
636        // Build a noisy signal with a buried periodic component.
637        // Signal amplitude tuned so ACF peak falls between the two
638        // thresholds (0.1 and 0.95).
639        let n = 120;
640        let true_period = 24;
641        let values: Vec<f64> = (0..n)
642            .map(|i: usize| {
643                let t = i as f64;
644                let signal = (2.0 * std::f64::consts::PI * t / true_period as f64).sin();
645                // splitmix64-style deterministic hash → uniform in [-1, 1]
646                let mut h = i as u64;
647                h = h.wrapping_mul(0x9E3779B97F4A7C15);
648                h = (h ^ (h >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
649                h = (h ^ (h >> 27)).wrapping_mul(0x94D049BB133111EB);
650                h = h ^ (h >> 31);
651                let noise = (h % 10000) as f64 / 5000.0 - 1.0;
652                signal + noise * 3.0
653            })
654            .collect();
655
656        // With a low threshold the weak period should be detected.
657        let detected_low = detect_period_with_threshold(&values, 48, 0.1);
658        assert!(
659            detected_low.is_some(),
660            "low threshold (0.1) should detect the weak periodicity",
661        );
662
663        // With a high threshold the same signal should be rejected.
664        let detected_high = detect_period_with_threshold(&values, 48, 0.95);
665        assert_eq!(
666            detected_high, None,
667            "high threshold (0.95) should reject the weak periodicity",
668        );
669    }
670
671    /// Why the series is detrended first: the autocorrelation of
672    /// `200 + 0.5·t + 40·sin(2πt/24)` is dominated by the trend and decays
673    /// from lag 1, so its largest value is 2 — smoothness, not seasonality.
674    #[test]
675    fn detect_period_sees_through_a_trend() {
676        let n = 300;
677        let period = 24;
678        let values: Vec<f64> = (0..n)
679            .map(|i| {
680                let t = i as f64;
681                200.0 + t * 0.5 + 40.0 * (2.0 * std::f64::consts::PI * t / period as f64).sin()
682            })
683            .collect();
684        assert_eq!(detect_period(&values, 48), Some(period));
685    }
686
687    /// A smooth series with no cycle must report no period rather than the
688    /// shortest lag it can find.
689    #[test]
690    fn detect_period_rejects_a_pure_trend() {
691        let values: Vec<f64> = (0..200).map(|i| 10.0 + i as f64 * 0.3).collect();
692        assert_eq!(detect_period(&values, 48), None);
693    }
694
695    /// The fundamental wins over its harmonics: a period-12 signal must not
696    /// report 24, 36 or 48.
697    #[test]
698    fn detect_period_prefers_the_fundamental_over_a_harmonic() {
699        let n = 480;
700        let values: Vec<f64> = (0..n)
701            .map(|i| {
702                let t = i as f64;
703                (2.0 * std::f64::consts::PI * t / 12.0).sin()
704                    + 0.4 * (2.0 * std::f64::consts::PI * t / 6.0).sin()
705            })
706            .collect();
707        assert_eq!(detect_period(&values, 60), Some(12));
708    }
709
710    #[test]
711    fn test_detect_period_delegates_to_with_threshold() {
712        // Verify that detect_period(v, m) == detect_period_with_threshold(v, m, 0.3).
713        let n = 240;
714        let values: Vec<f64> = (0..n)
715            .map(|i| (2.0 * std::f64::consts::PI * i as f64 / 24.0).sin())
716            .collect();
717
718        assert_eq!(
719            detect_period(&values, 48),
720            detect_period_with_threshold(&values, 48, 0.3),
721        );
722    }
723}