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,
};
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>,
}
impl StlConfig {
pub fn new(period: usize) -> Self {
Self {
period,
n_iter: 2,
trend_window: None,
seasonal_window: None,
}
}
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
}
fn effective_trend_window(&self) -> usize {
let w = self.trend_window.unwrap_or(self.period + 1);
let w = w.max(3);
if w.is_multiple_of(2) {
w + 1
} else {
w
}
}
fn effective_seasonal_window(&self) -> usize {
let w = self.seasonal_window.unwrap_or(self.period.max(7));
let w = w.max(3);
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 mut seasonal = vec![0.0; n];
let mut trend = vec![0.0; n];
for _ in 0..config.n_iter {
let detrended: Vec<f64> = values
.iter()
.zip(trend.iter())
.map(|(y, t)| y - t)
.collect();
let mut cycle = vec![0.0; n];
for s in 0..period {
let indices: Vec<usize> = (s..n).step_by(period).collect();
let sub: Vec<f64> = indices.iter().map(|&i| detrended[i]).collect();
let smoothed = loess_smooth(&sub, seasonal_w);
for (&i, &v) in indices.iter().zip(smoothed.iter()) {
cycle[i] = v;
}
}
let low_pass = low_pass_filter(&cycle, period);
for i in 0..n {
seasonal[i] = cycle[i] - low_pass[i];
}
let deseasoned: Vec<f64> = values
.iter()
.zip(seasonal.iter())
.map(|(y, s)| y - s)
.collect();
trend = loess_smooth(&deseasoned, trend_w);
}
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: &[f64], period: usize) -> Vec<f64> {
let n = cycle.len();
let mut padded = Vec::with_capacity(n + 2 * period);
padded.extend_from_slice(&cycle[..period]);
padded.extend_from_slice(cycle);
padded.extend_from_slice(&cycle[n - period..]);
let mut filtered = centered_moving_average(&padded, period);
if period.is_multiple_of(2) {
filtered = centered_moving_average(&filtered, 2);
}
let filtered = centered_moving_average(&filtered, 3);
filtered[period..period + n].to_vec()
}
fn centered_moving_average(x: &[f64], w: usize) -> Vec<f64> {
let n = x.len();
if w <= 1 || n == 0 {
return x.to_vec();
}
let before = w / 2;
let after = if w.is_multiple_of(2) {
w / 2 - 1
} else {
w / 2
};
(0..n)
.map(|i| {
let lo = i.saturating_sub(before);
let hi = (i + after).min(n - 1);
let slice = &x[lo..=hi];
slice.iter().sum::<f64>() / slice.len() as f64
})
.collect()
}
fn loess_smooth(values: &[f64], window: usize) -> Vec<f64> {
let n = values.len();
if window == 0 {
return values.to_vec();
}
if window >= n {
let mean = values.iter().sum::<f64>() / n as f64;
return vec![mean; n];
}
let half = window / 2;
let mut out = Vec::with_capacity(n);
for i in 0..n {
let lo = i.saturating_sub(half);
let hi = (i + half).min(n - 1);
let max_dist = (half.max(1)) 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 u = ((j as f64 - i as f64) / max_dist).abs().min(1.0);
let u3 = u * u * u;
let w = (1.0 - u3) * (1.0 - u3) * (1.0 - u3); let x = j as f64;
let y = values[j];
sw += w;
swx += w * x;
swy += w * y;
swxx += w * x * x;
swxy += w * x * y;
}
let det = sw * swxx - swx * swx;
let fitted = if det.abs() < 1e-15 {
if sw > 0.0 {
swy / sw
} else {
values[i]
}
} else {
let a = (swxx * swy - swx * swxy) / det;
let b = (sw * swxy - swx * swy) / det;
a + b * i as f64
};
out.push(fitted);
}
out
}
#[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 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),
);
}
}