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