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            low_pass_window: None,
70            outer_iter: 0,
71        };
72        stl_decompose(values, &config)
73    }
74}
75
76/// Errors that can occur during decomposition.
77#[derive(Debug, Error)]
78pub enum DecompositionError {
79    /// The series is too short for the requested seasonal period.
80    #[error("series length ({len}) must be at least 2× the period ({period})")]
81    TooShort {
82        /// Actual series length.
83        len: usize,
84        /// Requested seasonal period.
85        period: usize,
86    },
87
88    /// The seasonal period is invalid (must be ≥ 2).
89    #[error("period must be ≥ 2, got {0}")]
90    InvalidPeriod(usize),
91}
92
93/// Configuration for the STL decomposer.
94#[derive(Debug, Clone)]
95pub struct StlConfig {
96    /// Seasonal period (e.g., 24 for hourly data with daily seasonality).
97    pub period: usize,
98    /// Number of outer-loop iterations (default: 2).
99    pub n_iter: usize,
100    /// Trend smoother window length — must be odd, ≥ 3.
101    /// Default: `period + 1` (rounded up to next odd).
102    pub trend_window: Option<usize>,
103    /// Seasonal subseries smoother window length, in **cycles** — must be
104    /// odd, ≥ 7. Default: 7, as in Cleveland et al. (1990), R's `stl` and
105    /// statsmodels' `STL`.
106    pub seasonal_window: Option<usize>,
107    /// Low-pass smoother window length — must be odd, ≥ 3.
108    /// Default: the smallest odd integer ≥ `period`.
109    pub low_pass_window: Option<usize>,
110    /// Number of **outer** iterations, which is what makes the fit robust to
111    /// outliers. `0` (the default) is the plain inner loop.
112    ///
113    /// Each outer pass reweights every point by Cleveland's bisquare of its
114    /// residual against six times the median absolute residual, so a point
115    /// far from the fit stops pulling on the next one. In a metrics database
116    /// the case for it is ordinary rather than exotic: a restart, a scrape
117    /// that timed out and was backfilled as a spike, a sensor returning its
118    /// error sentinel. Without it one such point is spread across the whole
119    /// seasonal component by the cycle-subseries smoother — it lands in the
120    /// same season of every cycle.
121    ///
122    /// Cleveland et al. (1990) §4.3 suggests 5–10 when robustness is wanted
123    /// and 0 when it is not; statsmodels' `STL(robust=True)` uses 15 with an
124    /// early exit, which is the same bargain. Default 0, so the cost is paid
125    /// only where it is asked for.
126    pub outer_iter: usize,
127}
128
129impl StlConfig {
130    /// Creates a new STL configuration with the given seasonal period.
131    pub fn new(period: usize) -> Self {
132        Self {
133            period,
134            n_iter: 2,
135            trend_window: None,
136            seasonal_window: None,
137            low_pass_window: None,
138            outer_iter: 0,
139        }
140    }
141
142    /// Sets the number of STL iterations.
143    pub fn with_iterations(mut self, n: usize) -> Self {
144        self.n_iter = n.max(1);
145        self
146    }
147
148    /// Sets the trend smoother window length.
149    pub fn with_trend_window(mut self, w: usize) -> Self {
150        self.trend_window = Some(w);
151        self
152    }
153
154    /// Sets the seasonal subseries smoother window length, in **cycles**.
155    pub fn with_seasonal_window(mut self, w: usize) -> Self {
156        self.seasonal_window = Some(w);
157        self
158    }
159
160    /// Sets the low-pass smoother window length.
161    pub fn with_low_pass_window(mut self, w: usize) -> Self {
162        self.low_pass_window = Some(w);
163        self
164    }
165
166    /// Makes the fit robust to outliers, with Cleveland's suggested ten
167    /// outer iterations.
168    pub fn robust(mut self) -> Self {
169        self.outer_iter = 10;
170        self
171    }
172
173    /// Sets the number of outer (robustness) iterations explicitly.
174    pub fn with_outer_iterations(mut self, n: usize) -> Self {
175        self.outer_iter = n;
176        self
177    }
178
179    /// Cleveland et al. (1990) §3.4: `n_t` is the smallest odd integer at
180    /// least `1.5 * period / (1 - 1.5 / n_s)`. The ratio to `n_s` is the
181    /// point — a trend smoother chosen independently of the seasonal one
182    /// lets the two compete for the same variation, and the paper's rule is
183    /// what keeps the trend from absorbing the seasonal cycle.
184    fn effective_trend_window(&self) -> usize {
185        let w = self.trend_window.unwrap_or_else(|| {
186            let n_s = self.effective_seasonal_window() as f64;
187            let raw = 1.5 * self.period as f64 / (1.0 - 1.5 / n_s);
188            raw.ceil() as usize
189        });
190        next_odd_at_least(w, 3)
191    }
192
193    /// Cleveland et al. (1990) §3.3: `n_s` is in units of **cycles**, not of
194    /// samples — the seasonal smoother runs along a cycle-subseries, which
195    /// holds one point per cycle, so a series of `n` points offers it only
196    /// `n / period` of them. The paper's fixed default of 7 is what R's
197    /// `stl(s.window = "periodic")` and statsmodels' `STL(seasonal = 7)`
198    /// both use.
199    ///
200    /// This used to default to `max(7, period)`, which reads the window in
201    /// the *series'* units: for hourly data with a daily cycle it asked for
202    /// a 25-point smoother over a subseries holding one point per day, so
203    /// every deployment with fewer than `period` cycles of history got a
204    /// smoother wider than its own data.
205    fn effective_seasonal_window(&self) -> usize {
206        next_odd_at_least(self.seasonal_window.unwrap_or(7), 7)
207    }
208
209    /// Cleveland et al. (1990) §3.5: `n_l` is the smallest odd integer at
210    /// least `period`.
211    fn effective_low_pass_window(&self) -> usize {
212        next_odd_at_least(self.low_pass_window.unwrap_or(self.period), 3)
213    }
214}
215
216/// The smallest odd integer that is at least `w` and at least `floor`.
217fn next_odd_at_least(w: usize, floor: usize) -> usize {
218    let w = w.max(floor);
219    if w.is_multiple_of(2) { w + 1 } else { w }
220}
221
222/// Perform STL decomposition.
223pub fn stl_decompose(
224    values: &[f64],
225    config: &StlConfig,
226) -> Result<Decomposition, DecompositionError> {
227    let n = values.len();
228    let period = config.period;
229
230    if period < 2 {
231        return Err(DecompositionError::InvalidPeriod(period));
232    }
233    if n < 2 * period {
234        return Err(DecompositionError::TooShort { len: n, period });
235    }
236
237    let trend_w = config.effective_trend_window();
238    let seasonal_w = config.effective_seasonal_window();
239    let low_pass_w = config.effective_low_pass_window();
240
241    let mut seasonal = vec![0.0; n];
242    let mut trend = vec![0.0; n];
243    // Robustness weights, all 1.0 until an outer pass computes them. `None`
244    // is the plain inner loop and costs nothing.
245    let mut weights: Option<Vec<f64>> = None;
246
247    // The inner loop of Cleveland et al. (1990), steps 1–6, wrapped in the
248    // outer loop of §4.3 when `outer_iter > 0`.
249    //
250    // Step 2 is the one that carries the algorithm's shape: each
251    // cycle-subseries is smoothed *and extended one cycle beyond either
252    // end*, so the low-pass filter in step 3 can run in valid mode and
253    // still produce a value for every input point. The extension is the
254    // loess fit evaluated at −1 and at `len`, which continues the local
255    // trend. Padding by repeating the first and last cycle instead — which
256    // is what stood here — is correct only for a series that is already
257    // periodic: on a ramp the copy meets the original at a discontinuity
258    // the moving averages then smear across the first and last period. A
259    // straight line, whose seasonal component is exactly zero, came out of
260    // it with a seasonal swing of 278 on values running 1_000 to 11_619,
261    // and `seasonal_strength` read that as a seasonal series.
262    for outer in 0..=config.outer_iter {
263        for _ in 0..config.n_iter {
264            // Step 1: detrend.
265            let detrended: Vec<f64> = values
266                .iter()
267                .zip(trend.iter())
268                .map(|(y, t)| y - t)
269                .collect();
270
271            // Step 2: cycle-subseries smoothing, extended by one cycle at
272            // each end. `cycle_ext[period + i]` is the smoothed value for
273            // `i`. A robustness weight travels with its point into the
274            // subseries, which is the whole mechanism: an outlier lands in
275            // the same season of one cycle, so down-weighting it there is
276            // what stops it being spread across every cycle.
277            let mut cycle_ext = vec![0.0; n + 2 * period];
278            for s in 0..period {
279                let idx: Vec<usize> = (s..n).step_by(period).collect();
280                let sub: Vec<f64> = idx.iter().map(|&i| detrended[i]).collect();
281                let sub_w = weights
282                    .as_ref()
283                    .map(|w| idx.iter().map(|&i| w[i]).collect::<Vec<f64>>());
284                // `extended[0]` is the fit at −1, `extended[k + 1]` at `k`.
285                let extended = loess_extended(&sub, seasonal_w, sub_w.as_deref());
286                for (k, &v) in extended.iter().enumerate() {
287                    // Subseries position `k - 1` sits at series index
288                    // `s + (k - 1) * period`, i.e. `s + k * period` once the
289                    // frame is shifted by one cycle.
290                    let at = s + k * period;
291                    if at < cycle_ext.len() {
292                        cycle_ext[at] = v;
293                    }
294                }
295            }
296
297            // Step 3: low-pass filter — MA(period), MA(period), MA(3), then
298            // a loess of width `n_l`. Each moving average runs in valid
299            // mode, so the three together consume exactly the `2 * period`
300            // points the extension added and the result is `n` long by
301            // construction.
302            let low_pass = low_pass_filter(&cycle_ext, period, low_pass_w);
303            debug_assert_eq!(low_pass.len(), n);
304
305            // Step 4: the seasonal is what the low-pass filter removed.
306            for i in 0..n {
307                seasonal[i] = cycle_ext[period + i] - low_pass[i];
308            }
309
310            // Steps 5–6: deseasonalise and smooth the trend.
311            let deseasoned: Vec<f64> = values
312                .iter()
313                .zip(seasonal.iter())
314                .map(|(y, s)| y - s)
315                .collect();
316            trend = loess_smooth_weighted(&deseasoned, trend_w, weights.as_deref());
317        }
318
319        // The outer loop of §4.3: reweight by how far each point fell from
320        // the fit, then run the inner loop again. Skipped after the last
321        // pass, whose weights nothing would use.
322        if outer < config.outer_iter {
323            weights = Some(robustness_weights(values, &trend, &seasonal));
324        }
325    }
326
327    // Residual
328    let residual: Vec<f64> = values
329        .iter()
330        .zip(trend.iter())
331        .zip(seasonal.iter())
332        .map(|((y, t), s)| y - t - s)
333        .collect();
334
335    Ok(Decomposition {
336        trend,
337        seasonal,
338        residual,
339    })
340}
341
342/// Detect the dominant period using autocorrelation peak finding.
343///
344/// Returns `None` if no clear period is found or the series is too short.
345///
346/// # ACF Threshold
347///
348/// The series is **detrended** first, then the autocorrelation is searched
349/// for **local maxima** — the two together are what make this a period
350/// detector rather than a smoothness detector.
351///
352/// The minimum ACF peak value is hardcoded to **0.3** here, which suits the
353/// pronounced cycles of typical TSDB workloads. Use
354/// [`detect_period_with_threshold`] for a noisy signal (a lower threshold
355/// trades precision for recall) or a very clean one (a higher threshold
356/// rejects incidental peaks).
357pub fn detect_period(values: &[f64], max_period: usize) -> Option<usize> {
358    detect_period_with_threshold(values, max_period, 0.3)
359}
360
361/// Detect the dominant period using autocorrelation peak finding with
362/// a caller-specified minimum ACF threshold.
363///
364/// `min_acf_threshold` controls how strong the autocorrelation peak
365/// must be to accept a period:
366/// Lower values (e.g. 0.1) improve recall for weak/noisy signals
367///   at the cost of more false positives.
368/// Higher values (e.g. 0.5) require a strong seasonal pattern,
369///   avoiding spurious detections.
370///
371/// Returns `None` if no clear period is found or the series is too short.
372pub fn detect_period_with_threshold(
373    values: &[f64],
374    max_period: usize,
375    min_acf_threshold: f64,
376) -> Option<usize> {
377    let n = values.len();
378    if n < 4 {
379        return None;
380    }
381
382    // Detrend before correlating. A trended series' autocorrelation is
383    // dominated by the trend and decays from lag 1, so the largest value sits
384    // at the smallest lag and the seasonal peak is invisible underneath it.
385    // Removing the least-squares line leaves the periodic component.
386    let residual = detrend(values);
387
388    let mean = residual.iter().sum::<f64>() / n as f64;
389    let var: f64 = residual.iter().map(|v| (v - mean).powi(2)).sum();
390    if var < f64::EPSILON {
391        return None;
392    }
393
394    let max_lag = max_period.min(n / 2);
395    if max_lag < 3 {
396        return None;
397    }
398
399    // Biased ACF estimator (divide by the total variance rather than by
400    // `n - lag`): it tapers naturally at long lags, which is what stops a
401    // harmonic multiple 2P from outscoring the fundamental P.
402    let acf: Vec<f64> = (0..=max_lag)
403        .map(|lag| {
404            let s: f64 = (0..(n - lag))
405                .map(|i| (residual[i] - mean) * (residual[i + lag] - mean))
406                .sum();
407            s / var
408        })
409        .collect();
410
411    // A period is a **local maximum** of the autocorrelation, not its largest
412    // value: a smooth series is most correlated with its immediate neighbour,
413    // which is a statement about smoothness rather than seasonality.
414    let mut best: Option<(usize, f64)> = None;
415    for lag in 2..max_lag {
416        let (prev, here, next) = (acf[lag - 1], acf[lag], acf[lag + 1]);
417        if here > prev && here >= next && here > min_acf_threshold {
418            // Ties go to the shorter lag: the fundamental, not its harmonic.
419            if best.is_none_or(|(_, v)| here > v) {
420                best = Some((lag, here));
421            }
422        }
423    }
424
425    best.map(|(lag, _)| lag)
426}
427
428/// Remove the least-squares linear trend from a series.
429fn detrend(values: &[f64]) -> Vec<f64> {
430    let n = values.len();
431    if n < 2 {
432        return values.to_vec();
433    }
434    let n_f = n as f64;
435    let mean_x = (n_f - 1.0) / 2.0;
436    let mean_y = values.iter().sum::<f64>() / n_f;
437    let mut sxx = 0.0;
438    let mut sxy = 0.0;
439    for (i, &y) in values.iter().enumerate() {
440        let dx = i as f64 - mean_x;
441        sxx += dx * dx;
442        sxy += dx * (y - mean_y);
443    }
444    if sxx <= 0.0 {
445        return values.iter().map(|v| v - mean_y).collect();
446    }
447    let slope = sxy / sxx;
448    values
449        .iter()
450        .enumerate()
451        .map(|(i, &y)| y - (mean_y + slope * (i as f64 - mean_x)))
452        .collect()
453}
454
455/// Locally weighted regression (Loess) smoother with tricube
456/// kernel (Cleveland 1990).
457///
458/// Fits a degree-1 (linear) polynomial at each point using weighted
459/// least squares.  The tricube kernel `w(u) = (1 - |u|³)³` for `|u| < 1`
460/// downweights points far from the center, producing better edge
461/// behaviour and outlier resistance than a uniform moving average.
462/// The low-pass filter of STL step 3: pad by one cycle at each end with the
463/// adjacent cycle, apply a `period`-point centred moving average (a 2×MA for
464/// The low-pass filter of STL step 3: `MA(period)`, `MA(period)`, `MA(3)`,
465/// then a degree-1 loess of width `n_l`.
466///
467/// Every moving average runs in **valid** mode — an output point only where
468/// the whole window lies inside the input — so the three of them consume
469/// exactly `(period - 1) + (period - 1) + 2 = 2 * period` points. That is
470/// precisely what the cycle-subseries extension added, which is why the
471/// result is `n` long without any edge special-casing. A partial window at
472/// the ends, which is what `centered_moving_average` used to supply, is an
473/// average of fewer points presented as if it were an average of the whole
474/// window.
475fn low_pass_filter(cycle_ext: &[f64], period: usize, low_pass_window: usize) -> Vec<f64> {
476    let a = moving_average_valid(cycle_ext, period);
477    let b = moving_average_valid(&a, period);
478    let c = moving_average_valid(&b, 3);
479    loess_smooth(&c, low_pass_window)
480}
481
482/// A moving average of `w` points, valid mode: the output is
483/// `x.len() - w + 1` long and each point averages a full window.
484fn moving_average_valid(x: &[f64], w: usize) -> Vec<f64> {
485    if w <= 1 {
486        return x.to_vec();
487    }
488    if x.len() < w {
489        return Vec::new();
490    }
491    let wf = w as f64;
492    let mut out = Vec::with_capacity(x.len() - w + 1);
493    let mut sum: f64 = x[..w].iter().sum();
494    out.push(sum / wf);
495    for i in w..x.len() {
496        sum += x[i] - x[i - w];
497        out.push(sum / wf);
498    }
499    out
500}
501
502/// Degree-1 loess evaluated at one point `x`, which may lie outside
503/// `0..values.len()`.
504///
505/// The neighbourhood is the `q = min(window, n)` nearest points and the
506/// bandwidth is the distance to the farthest of them, scaled by `window / n`
507/// when the window is wider than the data — Cleveland's rule for `q > n`,
508/// which is what lets a smoother wider than its series still express a
509/// slope instead of collapsing to the mean.
510fn loess_at(values: &[f64], window: usize, x: f64, robust: Option<&[f64]>) -> f64 {
511    let n = values.len();
512    if n == 0 {
513        return f64::NAN;
514    }
515    if n == 1 {
516        return values[0];
517    }
518    let q = window.max(2).min(n);
519    // On a regular grid the `q` nearest points are contiguous.
520    let lo = (x - (q as f64 - 1.0) / 2.0)
521        .round()
522        .clamp(0.0, (n - q) as f64) as usize;
523    let hi = lo + q - 1;
524
525    let mut lambda = ((lo as f64 - x).abs()).max((hi as f64 - x).abs());
526    if window > n {
527        lambda *= window as f64 / n as f64;
528    }
529
530    let (mut sw, mut swx, mut swy, mut swxx, mut swxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
531    for j in lo..=hi {
532        let d = (j as f64 - x).abs();
533        let mut w = if lambda <= 0.0 {
534            f64::from(u8::from(d == 0.0))
535        } else {
536            let u = (d / lambda).min(1.0);
537            let t = 1.0 - u * u * u;
538            t * t * t
539        };
540        // The robustness weight multiplies the neighbourhood weight, which
541        // is exactly how Cleveland et al. (1990) §4.3 fold the outer loop
542        // into the same smoother rather than adding a second one.
543        if let Some(rw) = robust {
544            w *= rw[j];
545        }
546        if w <= 0.0 {
547            continue;
548        }
549        let (xj, yj) = (j as f64, values[j]);
550        sw += w;
551        swx += w * xj;
552        swy += w * yj;
553        swxx += w * xj * xj;
554        swxy += w * xj * yj;
555    }
556    if sw <= 0.0 {
557        // Every weight in the neighbourhood vanished. With robustness
558        // weights that is a real situation — a run of points next to a spike
559        // can all be down-weighted at once — and the answer must not be the
560        // raw observation: at the outlier itself that returns the outlier,
561        // which is the least robust value available and made the fit
562        // oscillate between absorbing the spike and rejecting it. The
563        // unweighted fit is the best estimate that remains.
564        if robust.is_some() {
565            return loess_at(values, window, x, None);
566        }
567        return values[(x.round().clamp(0.0, (n - 1) as f64)) as usize];
568    }
569    let det = sw * swxx - swx * swx;
570    if det.abs() < 1e-12 * sw * sw.max(1.0) {
571        // A single distinct abscissa carries no slope; the weighted mean is
572        // the degree-0 fit, which is the right answer only here.
573        return swy / sw;
574    }
575    let a = (swxx * swy - swx * swxy) / det;
576    let b = (sw * swxy - swx * swy) / det;
577    a + b * x
578}
579
580/// Degree-1 loess at every point of `values`.
581fn loess_smooth(values: &[f64], window: usize) -> Vec<f64> {
582    loess_smooth_weighted(values, window, None)
583}
584
585/// [`loess_smooth`] with an additional per-point robustness weight.
586fn loess_smooth_weighted(values: &[f64], window: usize, robust: Option<&[f64]>) -> Vec<f64> {
587    if window == 0 {
588        return values.to_vec();
589    }
590    (0..values.len())
591        .map(|i| loess_at(values, window, i as f64, robust))
592        .collect()
593}
594
595/// Cleveland's bisquare weights: `(1 - (r / 6m)^2)^2` clamped to `[0, 1]`,
596/// where `r` is a point's absolute residual and `m` their median.
597///
598/// Six times the median absolute residual is the paper's cut-off, and it is
599/// the reason a single spike cannot survive an outer pass: its weight goes to
600/// zero while an ordinary point's stays near one.
601///
602/// The cut-off is **floored at a thousandth of the series' range**, which the
603/// paper does not need to say and an implementation does. `6m` describes the
604/// spread of the residuals and means nothing when they have none: a series
605/// the inner loop fits almost exactly leaves rounding error, so `m` is
606/// rounding error too and everything above it counts as an outlier — on a
607/// clean sine with one spike, a median of 0.0047 against a 99th percentile of
608/// 47 gave 101 of 240 points weight zero.
609///
610/// The floor asks what the median cannot: is this residual large *relative to
611/// the series*? It sits far below any real noise level, so `6m` stays in
612/// charge wherever the residuals carry noise — measured at noise 0.5, 2 and 5
613/// on a series of range 260, where it never binds.
614fn robustness_weights(values: &[f64], trend: &[f64], seasonal: &[f64]) -> Vec<f64> {
615    let resid: Vec<f64> = values
616        .iter()
617        .zip(trend)
618        .zip(seasonal)
619        .map(|((y, t), s)| (y - t - s).abs())
620        .collect();
621
622    let mut sorted = resid.clone();
623    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
624    let mid = sorted.len() / 2;
625    let median = if sorted.is_empty() {
626        0.0
627    } else if sorted.len().is_multiple_of(2) {
628        f64::midpoint(sorted[mid - 1], sorted[mid])
629    } else {
630        sorted[mid]
631    };
632
633    let (lo, hi) = values
634        .iter()
635        .fold((f64::MAX, f64::MIN), |(l, h), &v| (l.min(v), h.max(v)));
636    let scale_floor = if hi > lo { (hi - lo) * 1e-3 } else { 0.0 };
637    let cutoff = (6.0 * median).max(scale_floor);
638    if cutoff <= 0.0 {
639        return vec![1.0; resid.len()];
640    }
641    resid
642        .iter()
643        .map(|r| {
644            let u = (r / cutoff).min(1.0);
645            let t = 1.0 - u * u;
646            t * t
647        })
648        .collect()
649}
650
651/// Degree-1 loess at `-1 ..= values.len()`, so the result is `len + 2` long.
652///
653/// STL step 2 needs the smoother to say what the cycle before the first and
654/// the cycle after the last would have been; a loess can answer that because
655/// it is a local *fit*, and evaluating it one step outside its data is the
656/// extension Cleveland et al. (1990) specify.
657fn loess_extended(values: &[f64], window: usize, robust: Option<&[f64]>) -> Vec<f64> {
658    let n = values.len();
659    (0..n + 2)
660        .map(|k| loess_at(values, window, k as f64 - 1.0, robust))
661        .collect()
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    fn approx(a: f64, b: f64, eps: f64) -> bool {
669        (a - b).abs() < eps
670    }
671
672    /// `y = 0.5·i + 10·sin(2πi/12)`: the seasonal component must *be* the
673    /// sine and the trend must be the line. The previous version of this
674    /// test asserted only that the three components summed to the input,
675    /// which the residual guarantees by definition — and the seasonal
676    /// component it accepted was identically zero.
677    #[test]
678    fn stl_recovers_the_trend_and_the_seasonal_pattern() {
679        let n = 120;
680        let period = 12;
681        let seasonal_of = |i: usize| 10.0 * (2.0 * std::f64::consts::PI * i as f64 / 12.0).sin();
682        let values: Vec<f64> = (0..n).map(|i| 0.5 * i as f64 + seasonal_of(i)).collect();
683
684        let dec = stl_decompose(&values, &StlConfig::new(period).with_iterations(5)).unwrap();
685
686        let amplitude = dec.seasonal.iter().copied().fold(f64::MIN, f64::max)
687            - dec.seasonal.iter().copied().fold(f64::MAX, f64::min);
688        assert!(
689            amplitude > 18.0,
690            "seasonal amplitude collapsed: {amplitude}"
691        );
692
693        // Away from the ends the recovery is tight; the ends see a shorter
694        // smoother window and are allowed a little more.
695        for i in period..n - period {
696            assert!(
697                approx(dec.seasonal[i], seasonal_of(i), 0.6),
698                "seasonal at {i}: {} vs {}",
699                dec.seasonal[i],
700                seasonal_of(i)
701            );
702            assert!(
703                approx(dec.trend[i], 0.5 * i as f64, 0.6),
704                "trend at {i}: {} vs {}",
705                dec.trend[i],
706                0.5 * i as f64
707            );
708            assert!(
709                dec.residual[i].abs() < 0.6,
710                "residual at {i}: {}",
711                dec.residual[i]
712            );
713        }
714        for i in 0..n {
715            let recon = dec.trend[i] + dec.seasonal[i] + dec.residual[i];
716            assert!(approx(recon, values[i], 1e-9));
717        }
718    }
719
720    /// An even period is centred with the classic 2×m moving average.
721    #[test]
722    fn stl_handles_an_even_period_without_bias() {
723        let n = 96;
724        let period = 24;
725        let seasonal_of = |i: usize| 5.0 * (2.0 * std::f64::consts::PI * i as f64 / 24.0).sin();
726        let values: Vec<f64> = (0..n).map(|i| 100.0 + seasonal_of(i)).collect();
727
728        let dec = stl_decompose(&values, &StlConfig::new(period).with_iterations(3)).unwrap();
729        for i in period..n - period {
730            assert!(
731                approx(dec.seasonal[i], seasonal_of(i), 0.4),
732                "seasonal at {i}"
733            );
734            assert!(
735                approx(dec.trend[i], 100.0, 0.4),
736                "trend at {i}: {}",
737                dec.trend[i]
738            );
739        }
740        let max_residual = dec.residual.iter().map(|r| r.abs()).fold(0.0_f64, f64::max);
741        assert!(max_residual < 0.8, "residual too large: {max_residual}");
742    }
743
744    /// A straight line has no seasonal component, and STL has to say so.
745    ///
746    /// This is the cheapest test in the file and the one that would have
747    /// caught the padding defect: the old low-pass filter padded by copying
748    /// the first and last cycle, which on a ramp butts the copy against the
749    /// original at a discontinuity, and the moving averages smeared it into
750    /// the first and last period. The seasonal component of `1000 + 37 i`
751    /// swung by 278.
752    ///
753    /// A monotonic counter is the commonest series in a metrics database, so
754    /// this is not a corner: `seasonal_strength` read that swing as 0.81
755    /// against a 0.64 threshold and auto-ARIMA seasonally differenced every
756    /// counter it was shown.
757    #[test]
758    fn a_straight_line_has_no_seasonal_component() {
759        for period in [4usize, 7, 12, 24] {
760            for cycles in [2usize, 3, 7, 12, 30] {
761                let n = period * cycles;
762                let y: Vec<f64> = (0..n).map(|i| 1000.0 + 37.0 * i as f64).collect();
763                let d = stl_decompose(&y, &StlConfig::new(period)).unwrap();
764                let swing = d.seasonal.iter().fold(0.0f64, |a, b| a.max(b.abs()));
765                assert!(
766                    swing < 1e-6,
767                    "period {period}, {cycles} cycles: seasonal swing {swing} on a straight line"
768                );
769                let resid = d.residual.iter().fold(0.0f64, |a, b| a.max(b.abs()));
770                assert!(
771                    resid < 1e-6,
772                    "period {period}, {cycles} cycles: residual {resid}"
773                );
774            }
775        }
776    }
777
778    /// The seasonal amplitude that comes out is the one that went in.
779    ///
780    /// Checked across the number of *cycles*, which is the axis that broke:
781    /// the seasonal smoother runs along a cycle-subseries holding one point
782    /// per cycle, and its window used to default to `max(7, period)` — read
783    /// in the series' units rather than in cycles. Seven days of hourly data
784    /// with a daily cycle therefore asked for a 25-point smoother over a
785    /// 7-point subseries, whereupon `loess_smooth` returned the subseries
786    /// mean and 22 % of the amplitude went into the trend.
787    ///
788    /// The values match statsmodels' `STL` to three decimals on every row of
789    /// this table; where a genuine edge effect remains — `period = 7` over
790    /// 10 cycles — statsmodels reports the same 11.699.
791    #[test]
792    fn stl_recovers_the_seasonal_amplitude_from_few_cycles() {
793        use std::f64::consts::TAU;
794        for &(period, n, expected) in &[
795            (12usize, 96usize, 12.000f64),
796            (12, 240, 12.000),
797            (24, 168, 12.000),
798            (24, 720, 12.000),
799            (7, 70, 11.699),
800        ] {
801            let y: Vec<f64> = (0..n)
802                .map(|i| 100.0 + 0.7 * i as f64 + 12.0 * (i as f64 * TAU / period as f64).sin())
803                .collect();
804            let d = stl_decompose(&y, &StlConfig::new(period)).unwrap();
805            let mid = &d.seasonal[n / 4..3 * n / 4];
806            let amp = (mid.iter().copied().fold(f64::MIN, f64::max)
807                - mid.iter().copied().fold(f64::MAX, f64::min))
808                / 2.0;
809            assert!(
810                (amp - expected).abs() < 0.01,
811                "period {period}, n {n}: amplitude {amp:.4}, expected {expected}"
812            );
813        }
814    }
815
816    /// A seasonal pattern whose amplitude grows is the reason to run STL
817    /// rather than subtract per-season means, so it is asserted rather than
818    /// assumed.
819    #[test]
820    fn stl_follows_a_seasonal_pattern_that_changes() {
821        use std::f64::consts::TAU;
822        let (period, n) = (12usize, 240usize);
823        let y: Vec<f64> = (0..n)
824            .map(|i| {
825                let amp = 5.0 + 15.0 * i as f64 / n as f64;
826                100.0 + amp * (i as f64 * TAU / period as f64).sin()
827            })
828            .collect();
829        let d = stl_decompose(&y, &StlConfig::new(period)).unwrap();
830        let amp_of = |s: &[f64]| {
831            (s.iter().copied().fold(f64::MIN, f64::max)
832                - s.iter().copied().fold(f64::MAX, f64::min))
833                / 2.0
834        };
835        let early = amp_of(&d.seasonal[12..36]);
836        let late = amp_of(&d.seasonal[n - 36..n - 12]);
837        assert!(
838            (early - 6.5).abs() < 0.6,
839            "early amplitude {early}, expected ~6.5"
840        );
841        assert!(
842            (late - 18.5).abs() < 0.6,
843            "late amplitude {late}, expected ~18.5"
844        );
845        assert!(
846            late > early * 2.0,
847            "the growth was not followed: {early} → {late}"
848        );
849    }
850
851    /// An outlier is left in the residual instead of being spread across the
852    /// seasonal component.
853    ///
854    /// This is the case for having the outer loop at all, and it is ordinary
855    /// rather than exotic in a metrics database: a restart, a scrape that
856    /// timed out and was backfilled as one enormous sample, a sensor
857    /// returning its error sentinel. Without robustness weights the
858    /// cycle-subseries smoother spreads that one point across **every** cycle
859    /// — it lands in the same season of each — and the seasonal amplitude of
860    /// a 10-unit sine came back as 33.7.
861    ///
862    /// Checked at three noise levels and at none, because the two regimes
863    /// exercise different halves of the weighting: with noise the median
864    /// absolute residual is a real scale and Cleveland's `6m` governs; with
865    /// none it is rounding error and the floor in `robustness_weights` does.
866    #[test]
867    fn a_robust_fit_leaves_a_spike_in_the_residual() {
868        use std::f64::consts::TAU;
869        let (period, n) = (12usize, 240usize);
870        let mut seed = 4u64;
871        let mut gauss = || {
872            let mut u = || {
873                seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
874                ((seed >> 11) as f64 / (1u64 << 53) as f64).max(1e-12)
875            };
876            let (a, b) = (u(), u());
877            (-2.0 * a.ln()).sqrt() * (TAU * b).cos()
878        };
879
880        for noise in [0.0f64, 0.5, 2.0, 5.0] {
881            let clean: Vec<f64> = (0..n)
882                .map(|i| {
883                    100.0
884                        + 0.5 * i as f64
885                        + 10.0 * (i as f64 * TAU / period as f64).sin()
886                        + gauss() * noise
887                })
888                .collect();
889            let base = stl_decompose(&clean, &StlConfig::new(period)).unwrap();
890
891            let mut spiked = clean.clone();
892            spiked[120] += 200.0;
893            let plain = stl_decompose(&spiked, &StlConfig::new(period)).unwrap();
894            let robust = stl_decompose(&spiked, &StlConfig::new(period).robust()).unwrap();
895
896            let deviation = |d: &Decomposition| {
897                (0..n)
898                    .filter(|&i| i != 120)
899                    .map(|i| (d.seasonal[i] - base.seasonal[i]).abs())
900                    .fold(0.0f64, f64::max)
901            };
902            let (p, r) = (deviation(&plain), deviation(&robust));
903            assert!(
904                r < p / 4.0,
905                "noise {noise}: robust deviation {r:.3} is not much better than plain {p:.3}"
906            );
907            // The spike belongs to the residual, which is where an
908            // unexplained observation goes.
909            assert!(
910                robust.residual[120] > 150.0,
911                "noise {noise}: the spike was absorbed — residual {:.3} of 200",
912                robust.residual[120]
913            );
914        }
915    }
916
917    /// With no outliers to down-weight, the robust fit is the plain fit.
918    ///
919    /// Not a formality: the first version of the weighting zeroed 101 of 240
920    /// points on a series it fitted exactly, because the median absolute
921    /// residual was rounding error and everything above it counted as an
922    /// outlier, and the seasonal component moved by 3.7 on an amplitude of
923    /// 10.
924    ///
925    /// The tolerance is not zero because the outer loop runs the inner loop
926    /// eleven times rather than once, so an all-ones weighting still
927    /// converges a little further; the difference is at 1e-13 on values near
928    /// 200, which is the arithmetic and not the weights.
929    #[test]
930    fn robustness_changes_nothing_without_outliers() {
931        use std::f64::consts::TAU;
932        let (period, n) = (12usize, 240usize);
933        let y: Vec<f64> = (0..n)
934            .map(|i| 100.0 + 0.5 * i as f64 + 10.0 * (i as f64 * TAU / period as f64).sin())
935            .collect();
936        let plain = stl_decompose(&y, &StlConfig::new(period)).unwrap();
937        let robust = stl_decompose(&y, &StlConfig::new(period).robust()).unwrap();
938        let worst = (0..n)
939            .map(|i| (plain.seasonal[i] - robust.seasonal[i]).abs())
940            .fold(0.0f64, f64::max);
941        assert!(
942            worst < 1e-9,
943            "robustness moved the seasonal component by {worst:e} with nothing to down-weight"
944        );
945    }
946
947    #[test]
948    fn test_stl_sum_equals_original() {
949        let n = 200;
950        let period = 10;
951        let values: Vec<f64> = (0..n)
952            .map(|i| (i as f64 * 0.1).sin() + 0.01 * i as f64)
953            .collect();
954
955        let dec = stl_decompose(&values, &StlConfig::new(period)).unwrap();
956        for i in 0..n {
957            let recon = dec.trend[i] + dec.seasonal[i] + dec.residual[i];
958            assert!(approx(recon, values[i], 1e-12));
959        }
960    }
961
962    #[test]
963    fn test_stl_too_short() {
964        let values = vec![1.0; 10];
965        let err = stl_decompose(&values, &StlConfig::new(8)).unwrap_err();
966        assert!(matches!(err, DecompositionError::TooShort { .. }));
967    }
968
969    #[test]
970    fn test_stl_invalid_period() {
971        let values = vec![1.0; 100];
972        let err = stl_decompose(&values, &StlConfig::new(1)).unwrap_err();
973        assert!(matches!(err, DecompositionError::InvalidPeriod(1)));
974    }
975
976    #[test]
977    fn test_detect_period_sinusoidal() {
978        let n = 240;
979        let true_period = 24;
980        let values: Vec<f64> = (0..n)
981            .map(|i| (2.0 * std::f64::consts::PI * i as f64 / true_period as f64).sin())
982            .collect();
983
984        let detected = detect_period(&values, 48);
985        assert_eq!(detected, Some(true_period));
986    }
987
988    #[test]
989    fn test_detect_period_constant_returns_none() {
990        let values = vec![42.0; 100];
991        assert_eq!(detect_period(&values, 50), None);
992    }
993
994    #[test]
995    fn test_loess_smooth_identity() {
996        // Single-point bandwidth: each point is its own neighborhood → output ≈ input
997        let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
998        let smoothed = loess_smooth(&v, 1);
999        for (a, b) in smoothed.iter().zip(v.iter()) {
1000            assert!(approx(*a, *b, 1e-9));
1001        }
1002    }
1003
1004    #[test]
1005    fn test_loess_smooth_smoothing() {
1006        // With a wider bandwidth the output should be smoother
1007        let v = vec![1.0, 3.0, 5.0, 7.0, 9.0];
1008        let smoothed = loess_smooth(&v, 3);
1009        // For a perfectly linear sequence, Loess (degree 1) should recover
1010        // the exact values regardless of bandwidth.
1011        for (a, b) in smoothed.iter().zip(v.iter()) {
1012            assert!(approx(*a, *b, 1e-6));
1013        }
1014    }
1015
1016    #[test]
1017    fn test_seasonal_decomposer_trait() {
1018        let decomposer = StlDecomposer::default();
1019        let n = 200;
1020        let period = 10;
1021        let timestamps: Vec<i64> = (0..n as i64).collect();
1022        let values: Vec<f64> = (0..n)
1023            .map(|i| (i as f64 * 0.1).sin() + 0.01 * i as f64)
1024            .collect();
1025
1026        let dec = decomposer.decompose(&timestamps, &values, period).unwrap();
1027        assert_eq!(dec.trend.len(), n);
1028        assert_eq!(dec.seasonal.len(), n);
1029        assert_eq!(dec.residual.len(), n);
1030        // Components should sum to original
1031        for i in 0..n {
1032            let recon = dec.trend[i] + dec.seasonal[i] + dec.residual[i];
1033            assert!(approx(recon, values[i], 1e-12));
1034        }
1035    }
1036
1037    #[test]
1038    fn test_seasonal_decomposer_trait_too_short() {
1039        let decomposer = StlDecomposer::default();
1040        let err = decomposer.decompose(&[0, 1], &[1.0, 2.0], 5).unwrap_err();
1041        assert!(matches!(err, DecompositionError::TooShort { .. }));
1042    }
1043
1044    #[test]
1045    fn test_detect_period_with_threshold_controls_sensitivity() {
1046        // Build a noisy signal with a buried periodic component.
1047        // Signal amplitude tuned so ACF peak falls between the two
1048        // thresholds (0.1 and 0.95).
1049        let n = 120;
1050        let true_period = 24;
1051        let values: Vec<f64> = (0..n)
1052            .map(|i: usize| {
1053                let t = i as f64;
1054                let signal = (2.0 * std::f64::consts::PI * t / true_period as f64).sin();
1055                // splitmix64-style deterministic hash → uniform in [-1, 1]
1056                let mut h = i as u64;
1057                h = h.wrapping_mul(0x9E3779B97F4A7C15);
1058                h = (h ^ (h >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
1059                h = (h ^ (h >> 27)).wrapping_mul(0x94D049BB133111EB);
1060                h = h ^ (h >> 31);
1061                let noise = (h % 10000) as f64 / 5000.0 - 1.0;
1062                signal + noise * 3.0
1063            })
1064            .collect();
1065
1066        // With a low threshold the weak period should be detected.
1067        let detected_low = detect_period_with_threshold(&values, 48, 0.1);
1068        assert!(
1069            detected_low.is_some(),
1070            "low threshold (0.1) should detect the weak periodicity",
1071        );
1072
1073        // With a high threshold the same signal should be rejected.
1074        let detected_high = detect_period_with_threshold(&values, 48, 0.95);
1075        assert_eq!(
1076            detected_high, None,
1077            "high threshold (0.95) should reject the weak periodicity",
1078        );
1079    }
1080
1081    /// Why the series is detrended first: the autocorrelation of
1082    /// `200 + 0.5·t + 40·sin(2πt/24)` is dominated by the trend and decays
1083    /// from lag 1, so its largest value is 2 — smoothness, not seasonality.
1084    #[test]
1085    fn detect_period_sees_through_a_trend() {
1086        let n = 300;
1087        let period = 24;
1088        let values: Vec<f64> = (0..n)
1089            .map(|i| {
1090                let t = i as f64;
1091                200.0 + t * 0.5 + 40.0 * (2.0 * std::f64::consts::PI * t / period as f64).sin()
1092            })
1093            .collect();
1094        assert_eq!(detect_period(&values, 48), Some(period));
1095    }
1096
1097    /// A smooth series with no cycle must report no period rather than the
1098    /// shortest lag it can find.
1099    #[test]
1100    fn detect_period_rejects_a_pure_trend() {
1101        let values: Vec<f64> = (0..200).map(|i| 10.0 + i as f64 * 0.3).collect();
1102        assert_eq!(detect_period(&values, 48), None);
1103    }
1104
1105    /// The fundamental wins over its harmonics: a period-12 signal must not
1106    /// report 24, 36 or 48.
1107    #[test]
1108    fn detect_period_prefers_the_fundamental_over_a_harmonic() {
1109        let n = 480;
1110        let values: Vec<f64> = (0..n)
1111            .map(|i| {
1112                let t = i as f64;
1113                (2.0 * std::f64::consts::PI * t / 12.0).sin()
1114                    + 0.4 * (2.0 * std::f64::consts::PI * t / 6.0).sin()
1115            })
1116            .collect();
1117        assert_eq!(detect_period(&values, 60), Some(12));
1118    }
1119
1120    #[test]
1121    fn test_detect_period_delegates_to_with_threshold() {
1122        // Verify that detect_period(v, m) == detect_period_with_threshold(v, m, 0.3).
1123        let n = 240;
1124        let values: Vec<f64> = (0..n)
1125            .map(|i| (2.0 * std::f64::consts::PI * i as f64 / 24.0).sin())
1126            .collect();
1127
1128        assert_eq!(
1129            detect_period(&values, 48),
1130            detect_period_with_threshold(&values, 48, 0.3),
1131        );
1132    }
1133}