use thiserror::Error;
#[derive(Debug, Clone)]
pub struct Decomposition {
pub trend: Vec<f64>,
pub seasonal: Vec<f64>,
pub residual: Vec<f64>,
}
pub trait SeasonalDecomposer: Send + Sync {
fn decompose(
&self,
timestamps: &[i64],
values: &[f64],
period: usize,
) -> Result<Decomposition, DecompositionError>;
}
pub struct StlDecomposer {
pub n_iter: usize,
pub trend_window: Option<usize>,
}
impl Default for StlDecomposer {
fn default() -> Self {
Self {
n_iter: 2,
trend_window: None,
}
}
}
impl SeasonalDecomposer for StlDecomposer {
#[tracing::instrument(skip_all, level = "debug")]
fn decompose(
&self,
_timestamps: &[i64],
values: &[f64],
period: usize,
) -> Result<Decomposition, DecompositionError> {
let config = StlConfig {
period,
n_iter: self.n_iter,
trend_window: self.trend_window,
seasonal_window: None,
low_pass_window: None,
outer_iter: 0,
};
stl_decompose(values, &config)
}
}
#[derive(Debug, Error)]
pub enum DecompositionError {
#[error("series length ({len}) must be at least 2× the period ({period})")]
TooShort {
len: usize,
period: usize,
},
#[error("period must be ≥ 2, got {0}")]
InvalidPeriod(usize),
}
#[derive(Debug, Clone)]
pub struct StlConfig {
pub period: usize,
pub n_iter: usize,
pub trend_window: Option<usize>,
pub seasonal_window: Option<usize>,
pub low_pass_window: Option<usize>,
pub outer_iter: usize,
}
impl StlConfig {
pub fn new(period: usize) -> Self {
Self {
period,
n_iter: 2,
trend_window: None,
seasonal_window: None,
low_pass_window: None,
outer_iter: 0,
}
}
pub fn with_iterations(mut self, n: usize) -> Self {
self.n_iter = n.max(1);
self
}
pub fn with_trend_window(mut self, w: usize) -> Self {
self.trend_window = Some(w);
self
}
pub fn with_seasonal_window(mut self, w: usize) -> Self {
self.seasonal_window = Some(w);
self
}
pub fn with_low_pass_window(mut self, w: usize) -> Self {
self.low_pass_window = Some(w);
self
}
pub fn robust(mut self) -> Self {
self.outer_iter = 10;
self
}
pub fn with_outer_iterations(mut self, n: usize) -> Self {
self.outer_iter = n;
self
}
fn effective_trend_window(&self) -> usize {
let w = self.trend_window.unwrap_or_else(|| {
let n_s = self.effective_seasonal_window() as f64;
let raw = 1.5 * self.period as f64 / (1.0 - 1.5 / n_s);
raw.ceil() as usize
});
next_odd_at_least(w, 3)
}
fn effective_seasonal_window(&self) -> usize {
next_odd_at_least(self.seasonal_window.unwrap_or(7), 7)
}
fn effective_low_pass_window(&self) -> usize {
next_odd_at_least(self.low_pass_window.unwrap_or(self.period), 3)
}
}
fn next_odd_at_least(w: usize, floor: usize) -> usize {
let w = w.max(floor);
if w.is_multiple_of(2) { w + 1 } else { w }
}
pub fn stl_decompose(
values: &[f64],
config: &StlConfig,
) -> Result<Decomposition, DecompositionError> {
let n = values.len();
let period = config.period;
if period < 2 {
return Err(DecompositionError::InvalidPeriod(period));
}
if n < 2 * period {
return Err(DecompositionError::TooShort { len: n, period });
}
let trend_w = config.effective_trend_window();
let seasonal_w = config.effective_seasonal_window();
let low_pass_w = config.effective_low_pass_window();
let mut seasonal = vec![0.0; n];
let mut trend = vec![0.0; n];
let mut weights: Option<Vec<f64>> = None;
for outer in 0..=config.outer_iter {
for _ in 0..config.n_iter {
let detrended: Vec<f64> = values
.iter()
.zip(trend.iter())
.map(|(y, t)| y - t)
.collect();
let mut cycle_ext = vec![0.0; n + 2 * period];
for s in 0..period {
let idx: Vec<usize> = (s..n).step_by(period).collect();
let sub: Vec<f64> = idx.iter().map(|&i| detrended[i]).collect();
let sub_w = weights
.as_ref()
.map(|w| idx.iter().map(|&i| w[i]).collect::<Vec<f64>>());
let extended = loess_extended(&sub, seasonal_w, sub_w.as_deref());
for (k, &v) in extended.iter().enumerate() {
let at = s + k * period;
if at < cycle_ext.len() {
cycle_ext[at] = v;
}
}
}
let low_pass = low_pass_filter(&cycle_ext, period, low_pass_w);
debug_assert_eq!(low_pass.len(), n);
for i in 0..n {
seasonal[i] = cycle_ext[period + i] - low_pass[i];
}
let deseasoned: Vec<f64> = values
.iter()
.zip(seasonal.iter())
.map(|(y, s)| y - s)
.collect();
trend = loess_smooth_weighted(&deseasoned, trend_w, weights.as_deref());
}
if outer < config.outer_iter {
weights = Some(robustness_weights(values, &trend, &seasonal));
}
}
let residual: Vec<f64> = values
.iter()
.zip(trend.iter())
.zip(seasonal.iter())
.map(|((y, t), s)| y - t - s)
.collect();
Ok(Decomposition {
trend,
seasonal,
residual,
})
}
pub fn detect_period(values: &[f64], max_period: usize) -> Option<usize> {
detect_period_with_threshold(values, max_period, 0.3)
}
pub fn detect_period_with_threshold(
values: &[f64],
max_period: usize,
min_acf_threshold: f64,
) -> Option<usize> {
let n = values.len();
if n < 4 {
return None;
}
let residual = detrend(values);
let mean = residual.iter().sum::<f64>() / n as f64;
let var: f64 = residual.iter().map(|v| (v - mean).powi(2)).sum();
if var < f64::EPSILON {
return None;
}
let max_lag = max_period.min(n / 2);
if max_lag < 3 {
return None;
}
let acf: Vec<f64> = (0..=max_lag)
.map(|lag| {
let s: f64 = (0..(n - lag))
.map(|i| (residual[i] - mean) * (residual[i + lag] - mean))
.sum();
s / var
})
.collect();
let mut best: Option<(usize, f64)> = None;
for lag in 2..max_lag {
let (prev, here, next) = (acf[lag - 1], acf[lag], acf[lag + 1]);
if here > prev && here >= next && here > min_acf_threshold {
if best.is_none_or(|(_, v)| here > v) {
best = Some((lag, here));
}
}
}
best.map(|(lag, _)| lag)
}
fn detrend(values: &[f64]) -> Vec<f64> {
let n = values.len();
if n < 2 {
return values.to_vec();
}
let n_f = n as f64;
let mean_x = (n_f - 1.0) / 2.0;
let mean_y = values.iter().sum::<f64>() / n_f;
let mut sxx = 0.0;
let mut sxy = 0.0;
for (i, &y) in values.iter().enumerate() {
let dx = i as f64 - mean_x;
sxx += dx * dx;
sxy += dx * (y - mean_y);
}
if sxx <= 0.0 {
return values.iter().map(|v| v - mean_y).collect();
}
let slope = sxy / sxx;
values
.iter()
.enumerate()
.map(|(i, &y)| y - (mean_y + slope * (i as f64 - mean_x)))
.collect()
}
fn low_pass_filter(cycle_ext: &[f64], period: usize, low_pass_window: usize) -> Vec<f64> {
let a = moving_average_valid(cycle_ext, period);
let b = moving_average_valid(&a, period);
let c = moving_average_valid(&b, 3);
loess_smooth(&c, low_pass_window)
}
fn moving_average_valid(x: &[f64], w: usize) -> Vec<f64> {
if w <= 1 {
return x.to_vec();
}
if x.len() < w {
return Vec::new();
}
let wf = w as f64;
let mut out = Vec::with_capacity(x.len() - w + 1);
let mut sum: f64 = x[..w].iter().sum();
out.push(sum / wf);
for i in w..x.len() {
sum += x[i] - x[i - w];
out.push(sum / wf);
}
out
}
fn loess_at(values: &[f64], window: usize, x: f64, robust: Option<&[f64]>) -> f64 {
let n = values.len();
if n == 0 {
return f64::NAN;
}
if n == 1 {
return values[0];
}
let q = window.max(2).min(n);
let lo = (x - (q as f64 - 1.0) / 2.0)
.round()
.clamp(0.0, (n - q) as f64) as usize;
let hi = lo + q - 1;
let mut lambda = ((lo as f64 - x).abs()).max((hi as f64 - x).abs());
if window > n {
lambda *= window as f64 / n as f64;
}
let (mut sw, mut swx, mut swy, mut swxx, mut swxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
for j in lo..=hi {
let d = (j as f64 - x).abs();
let mut w = if lambda <= 0.0 {
f64::from(u8::from(d == 0.0))
} else {
let u = (d / lambda).min(1.0);
let t = 1.0 - u * u * u;
t * t * t
};
if let Some(rw) = robust {
w *= rw[j];
}
if w <= 0.0 {
continue;
}
let (xj, yj) = (j as f64, values[j]);
sw += w;
swx += w * xj;
swy += w * yj;
swxx += w * xj * xj;
swxy += w * xj * yj;
}
if sw <= 0.0 {
if robust.is_some() {
return loess_at(values, window, x, None);
}
return values[(x.round().clamp(0.0, (n - 1) as f64)) as usize];
}
let det = sw * swxx - swx * swx;
if det.abs() < 1e-12 * sw * sw.max(1.0) {
return swy / sw;
}
let a = (swxx * swy - swx * swxy) / det;
let b = (sw * swxy - swx * swy) / det;
a + b * x
}
fn loess_smooth(values: &[f64], window: usize) -> Vec<f64> {
loess_smooth_weighted(values, window, None)
}
fn loess_smooth_weighted(values: &[f64], window: usize, robust: Option<&[f64]>) -> Vec<f64> {
if window == 0 {
return values.to_vec();
}
(0..values.len())
.map(|i| loess_at(values, window, i as f64, robust))
.collect()
}
fn robustness_weights(values: &[f64], trend: &[f64], seasonal: &[f64]) -> Vec<f64> {
let resid: Vec<f64> = values
.iter()
.zip(trend)
.zip(seasonal)
.map(|((y, t), s)| (y - t - s).abs())
.collect();
let mut sorted = resid.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mid = sorted.len() / 2;
let median = if sorted.is_empty() {
0.0
} else if sorted.len().is_multiple_of(2) {
f64::midpoint(sorted[mid - 1], sorted[mid])
} else {
sorted[mid]
};
let (lo, hi) = values
.iter()
.fold((f64::MAX, f64::MIN), |(l, h), &v| (l.min(v), h.max(v)));
let scale_floor = if hi > lo { (hi - lo) * 1e-3 } else { 0.0 };
let cutoff = (6.0 * median).max(scale_floor);
if cutoff <= 0.0 {
return vec![1.0; resid.len()];
}
resid
.iter()
.map(|r| {
let u = (r / cutoff).min(1.0);
let t = 1.0 - u * u;
t * t
})
.collect()
}
fn loess_extended(values: &[f64], window: usize, robust: Option<&[f64]>) -> Vec<f64> {
let n = values.len();
(0..n + 2)
.map(|k| loess_at(values, window, k as f64 - 1.0, robust))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f64, b: f64, eps: f64) -> bool {
(a - b).abs() < eps
}
#[test]
fn stl_recovers_the_trend_and_the_seasonal_pattern() {
let n = 120;
let period = 12;
let seasonal_of = |i: usize| 10.0 * (2.0 * std::f64::consts::PI * i as f64 / 12.0).sin();
let values: Vec<f64> = (0..n).map(|i| 0.5 * i as f64 + seasonal_of(i)).collect();
let dec = stl_decompose(&values, &StlConfig::new(period).with_iterations(5)).unwrap();
let amplitude = dec.seasonal.iter().copied().fold(f64::MIN, f64::max)
- dec.seasonal.iter().copied().fold(f64::MAX, f64::min);
assert!(
amplitude > 18.0,
"seasonal amplitude collapsed: {amplitude}"
);
for i in period..n - period {
assert!(
approx(dec.seasonal[i], seasonal_of(i), 0.6),
"seasonal at {i}: {} vs {}",
dec.seasonal[i],
seasonal_of(i)
);
assert!(
approx(dec.trend[i], 0.5 * i as f64, 0.6),
"trend at {i}: {} vs {}",
dec.trend[i],
0.5 * i as f64
);
assert!(
dec.residual[i].abs() < 0.6,
"residual at {i}: {}",
dec.residual[i]
);
}
for i in 0..n {
let recon = dec.trend[i] + dec.seasonal[i] + dec.residual[i];
assert!(approx(recon, values[i], 1e-9));
}
}
#[test]
fn stl_handles_an_even_period_without_bias() {
let n = 96;
let period = 24;
let seasonal_of = |i: usize| 5.0 * (2.0 * std::f64::consts::PI * i as f64 / 24.0).sin();
let values: Vec<f64> = (0..n).map(|i| 100.0 + seasonal_of(i)).collect();
let dec = stl_decompose(&values, &StlConfig::new(period).with_iterations(3)).unwrap();
for i in period..n - period {
assert!(
approx(dec.seasonal[i], seasonal_of(i), 0.4),
"seasonal at {i}"
);
assert!(
approx(dec.trend[i], 100.0, 0.4),
"trend at {i}: {}",
dec.trend[i]
);
}
let max_residual = dec.residual.iter().map(|r| r.abs()).fold(0.0_f64, f64::max);
assert!(max_residual < 0.8, "residual too large: {max_residual}");
}
#[test]
fn a_straight_line_has_no_seasonal_component() {
for period in [4usize, 7, 12, 24] {
for cycles in [2usize, 3, 7, 12, 30] {
let n = period * cycles;
let y: Vec<f64> = (0..n).map(|i| 1000.0 + 37.0 * i as f64).collect();
let d = stl_decompose(&y, &StlConfig::new(period)).unwrap();
let swing = d.seasonal.iter().fold(0.0f64, |a, b| a.max(b.abs()));
assert!(
swing < 1e-6,
"period {period}, {cycles} cycles: seasonal swing {swing} on a straight line"
);
let resid = d.residual.iter().fold(0.0f64, |a, b| a.max(b.abs()));
assert!(
resid < 1e-6,
"period {period}, {cycles} cycles: residual {resid}"
);
}
}
}
#[test]
fn stl_recovers_the_seasonal_amplitude_from_few_cycles() {
use std::f64::consts::TAU;
for &(period, n, expected) in &[
(12usize, 96usize, 12.000f64),
(12, 240, 12.000),
(24, 168, 12.000),
(24, 720, 12.000),
(7, 70, 11.699),
] {
let y: Vec<f64> = (0..n)
.map(|i| 100.0 + 0.7 * i as f64 + 12.0 * (i as f64 * TAU / period as f64).sin())
.collect();
let d = stl_decompose(&y, &StlConfig::new(period)).unwrap();
let mid = &d.seasonal[n / 4..3 * n / 4];
let amp = (mid.iter().copied().fold(f64::MIN, f64::max)
- mid.iter().copied().fold(f64::MAX, f64::min))
/ 2.0;
assert!(
(amp - expected).abs() < 0.01,
"period {period}, n {n}: amplitude {amp:.4}, expected {expected}"
);
}
}
#[test]
fn stl_follows_a_seasonal_pattern_that_changes() {
use std::f64::consts::TAU;
let (period, n) = (12usize, 240usize);
let y: Vec<f64> = (0..n)
.map(|i| {
let amp = 5.0 + 15.0 * i as f64 / n as f64;
100.0 + amp * (i as f64 * TAU / period as f64).sin()
})
.collect();
let d = stl_decompose(&y, &StlConfig::new(period)).unwrap();
let amp_of = |s: &[f64]| {
(s.iter().copied().fold(f64::MIN, f64::max)
- s.iter().copied().fold(f64::MAX, f64::min))
/ 2.0
};
let early = amp_of(&d.seasonal[12..36]);
let late = amp_of(&d.seasonal[n - 36..n - 12]);
assert!(
(early - 6.5).abs() < 0.6,
"early amplitude {early}, expected ~6.5"
);
assert!(
(late - 18.5).abs() < 0.6,
"late amplitude {late}, expected ~18.5"
);
assert!(
late > early * 2.0,
"the growth was not followed: {early} → {late}"
);
}
#[test]
fn a_robust_fit_leaves_a_spike_in_the_residual() {
use std::f64::consts::TAU;
let (period, n) = (12usize, 240usize);
let mut seed = 4u64;
let mut gauss = || {
let mut u = || {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
((seed >> 11) as f64 / (1u64 << 53) as f64).max(1e-12)
};
let (a, b) = (u(), u());
(-2.0 * a.ln()).sqrt() * (TAU * b).cos()
};
for noise in [0.0f64, 0.5, 2.0, 5.0] {
let clean: Vec<f64> = (0..n)
.map(|i| {
100.0
+ 0.5 * i as f64
+ 10.0 * (i as f64 * TAU / period as f64).sin()
+ gauss() * noise
})
.collect();
let base = stl_decompose(&clean, &StlConfig::new(period)).unwrap();
let mut spiked = clean.clone();
spiked[120] += 200.0;
let plain = stl_decompose(&spiked, &StlConfig::new(period)).unwrap();
let robust = stl_decompose(&spiked, &StlConfig::new(period).robust()).unwrap();
let deviation = |d: &Decomposition| {
(0..n)
.filter(|&i| i != 120)
.map(|i| (d.seasonal[i] - base.seasonal[i]).abs())
.fold(0.0f64, f64::max)
};
let (p, r) = (deviation(&plain), deviation(&robust));
assert!(
r < p / 4.0,
"noise {noise}: robust deviation {r:.3} is not much better than plain {p:.3}"
);
assert!(
robust.residual[120] > 150.0,
"noise {noise}: the spike was absorbed — residual {:.3} of 200",
robust.residual[120]
);
}
}
#[test]
fn robustness_changes_nothing_without_outliers() {
use std::f64::consts::TAU;
let (period, n) = (12usize, 240usize);
let y: Vec<f64> = (0..n)
.map(|i| 100.0 + 0.5 * i as f64 + 10.0 * (i as f64 * TAU / period as f64).sin())
.collect();
let plain = stl_decompose(&y, &StlConfig::new(period)).unwrap();
let robust = stl_decompose(&y, &StlConfig::new(period).robust()).unwrap();
let worst = (0..n)
.map(|i| (plain.seasonal[i] - robust.seasonal[i]).abs())
.fold(0.0f64, f64::max);
assert!(
worst < 1e-9,
"robustness moved the seasonal component by {worst:e} with nothing to down-weight"
);
}
#[test]
fn test_stl_sum_equals_original() {
let n = 200;
let period = 10;
let values: Vec<f64> = (0..n)
.map(|i| (i as f64 * 0.1).sin() + 0.01 * i as f64)
.collect();
let dec = stl_decompose(&values, &StlConfig::new(period)).unwrap();
for i in 0..n {
let recon = dec.trend[i] + dec.seasonal[i] + dec.residual[i];
assert!(approx(recon, values[i], 1e-12));
}
}
#[test]
fn test_stl_too_short() {
let values = vec![1.0; 10];
let err = stl_decompose(&values, &StlConfig::new(8)).unwrap_err();
assert!(matches!(err, DecompositionError::TooShort { .. }));
}
#[test]
fn test_stl_invalid_period() {
let values = vec![1.0; 100];
let err = stl_decompose(&values, &StlConfig::new(1)).unwrap_err();
assert!(matches!(err, DecompositionError::InvalidPeriod(1)));
}
#[test]
fn test_detect_period_sinusoidal() {
let n = 240;
let true_period = 24;
let values: Vec<f64> = (0..n)
.map(|i| (2.0 * std::f64::consts::PI * i as f64 / true_period as f64).sin())
.collect();
let detected = detect_period(&values, 48);
assert_eq!(detected, Some(true_period));
}
#[test]
fn test_detect_period_constant_returns_none() {
let values = vec![42.0; 100];
assert_eq!(detect_period(&values, 50), None);
}
#[test]
fn test_loess_smooth_identity() {
let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let smoothed = loess_smooth(&v, 1);
for (a, b) in smoothed.iter().zip(v.iter()) {
assert!(approx(*a, *b, 1e-9));
}
}
#[test]
fn test_loess_smooth_smoothing() {
let v = vec![1.0, 3.0, 5.0, 7.0, 9.0];
let smoothed = loess_smooth(&v, 3);
for (a, b) in smoothed.iter().zip(v.iter()) {
assert!(approx(*a, *b, 1e-6));
}
}
#[test]
fn test_seasonal_decomposer_trait() {
let decomposer = StlDecomposer::default();
let n = 200;
let period = 10;
let timestamps: Vec<i64> = (0..n as i64).collect();
let values: Vec<f64> = (0..n)
.map(|i| (i as f64 * 0.1).sin() + 0.01 * i as f64)
.collect();
let dec = decomposer.decompose(×tamps, &values, period).unwrap();
assert_eq!(dec.trend.len(), n);
assert_eq!(dec.seasonal.len(), n);
assert_eq!(dec.residual.len(), n);
for i in 0..n {
let recon = dec.trend[i] + dec.seasonal[i] + dec.residual[i];
assert!(approx(recon, values[i], 1e-12));
}
}
#[test]
fn test_seasonal_decomposer_trait_too_short() {
let decomposer = StlDecomposer::default();
let err = decomposer.decompose(&[0, 1], &[1.0, 2.0], 5).unwrap_err();
assert!(matches!(err, DecompositionError::TooShort { .. }));
}
#[test]
fn test_detect_period_with_threshold_controls_sensitivity() {
let n = 120;
let true_period = 24;
let values: Vec<f64> = (0..n)
.map(|i: usize| {
let t = i as f64;
let signal = (2.0 * std::f64::consts::PI * t / true_period as f64).sin();
let mut h = i as u64;
h = h.wrapping_mul(0x9E3779B97F4A7C15);
h = (h ^ (h >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
h = (h ^ (h >> 27)).wrapping_mul(0x94D049BB133111EB);
h = h ^ (h >> 31);
let noise = (h % 10000) as f64 / 5000.0 - 1.0;
signal + noise * 3.0
})
.collect();
let detected_low = detect_period_with_threshold(&values, 48, 0.1);
assert!(
detected_low.is_some(),
"low threshold (0.1) should detect the weak periodicity",
);
let detected_high = detect_period_with_threshold(&values, 48, 0.95);
assert_eq!(
detected_high, None,
"high threshold (0.95) should reject the weak periodicity",
);
}
#[test]
fn detect_period_sees_through_a_trend() {
let n = 300;
let period = 24;
let values: Vec<f64> = (0..n)
.map(|i| {
let t = i as f64;
200.0 + t * 0.5 + 40.0 * (2.0 * std::f64::consts::PI * t / period as f64).sin()
})
.collect();
assert_eq!(detect_period(&values, 48), Some(period));
}
#[test]
fn detect_period_rejects_a_pure_trend() {
let values: Vec<f64> = (0..200).map(|i| 10.0 + i as f64 * 0.3).collect();
assert_eq!(detect_period(&values, 48), None);
}
#[test]
fn detect_period_prefers_the_fundamental_over_a_harmonic() {
let n = 480;
let values: Vec<f64> = (0..n)
.map(|i| {
let t = i as f64;
(2.0 * std::f64::consts::PI * t / 12.0).sin()
+ 0.4 * (2.0 * std::f64::consts::PI * t / 6.0).sin()
})
.collect();
assert_eq!(detect_period(&values, 60), Some(12));
}
#[test]
fn test_detect_period_delegates_to_with_threshold() {
let n = 240;
let values: Vec<f64> = (0..n)
.map(|i| (2.0 * std::f64::consts::PI * i as f64 / 24.0).sin())
.collect();
assert_eq!(
detect_period(&values, 48),
detect_period_with_threshold(&values, 48, 0.3),
);
}
}