use super::stl::STL;
use crate::utils::ols::{ols_fit, ols_residuals, OLSResult};
use std::collections::HashMap;
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct MSTLResult {
pub trend: Vec<f64>,
pub seasonal_components: Vec<Vec<f64>>,
pub seasonal_periods: Vec<usize>,
pub remainder: Vec<f64>,
pub regressor_coefficients: Option<OLSResult>,
pub regressor_effect: Option<Vec<f64>>,
}
impl MSTLResult {
pub fn total_seasonal(&self) -> Vec<f64> {
if self.seasonal_components.is_empty() {
return vec![0.0; self.trend.len()];
}
let n = self.trend.len();
let mut total = vec![0.0; n];
for component in &self.seasonal_components {
for i in 0..n {
total[i] += component[i];
}
}
total
}
pub fn seasonal_strength(&self, period_idx: usize) -> Option<f64> {
if period_idx >= self.seasonal_components.len() {
return None;
}
let seasonal = &self.seasonal_components[period_idx];
let var_remainder = variance(&self.remainder);
let seasonal_plus_remainder: Vec<f64> = seasonal
.iter()
.zip(self.remainder.iter())
.map(|(s, r)| s + r)
.collect();
let var_sr = variance(&seasonal_plus_remainder);
if var_sr < 1e-10 {
return Some(0.0);
}
Some((1.0 - var_remainder / var_sr).clamp(0.0, 1.0))
}
pub fn trend_strength(&self) -> f64 {
let var_remainder = variance(&self.remainder);
let trend_plus_remainder: Vec<f64> = self
.trend
.iter()
.zip(self.remainder.iter())
.map(|(t, r)| t + r)
.collect();
let var_tr = variance(&trend_plus_remainder);
if var_tr < 1e-10 {
return 0.0;
}
(1.0 - var_remainder / var_tr).clamp(0.0, 1.0)
}
}
#[derive(Debug, Clone)]
pub struct MSTL {
seasonal_periods: Vec<usize>,
iterations: usize,
robust: bool,
}
impl MSTL {
pub fn new(seasonal_periods: Vec<usize>) -> Self {
let mut periods = seasonal_periods;
periods.sort();
periods.dedup();
Self {
seasonal_periods: periods,
iterations: 2,
robust: false,
}
}
pub fn with_iterations(mut self, n: usize) -> Self {
self.iterations = n;
self
}
pub fn robust(mut self) -> Self {
self.robust = true;
self
}
pub fn seasonal_periods(&self) -> &[usize] {
&self.seasonal_periods
}
pub fn decompose(&self, series: &[f64]) -> Option<MSTLResult> {
let n = series.len();
if self.seasonal_periods.is_empty() {
return None;
}
let max_period = *self.seasonal_periods.last()?;
if n < 2 * max_period {
return None;
}
let num_seasonals = self.seasonal_periods.len();
let mut seasonal_components: Vec<Vec<f64>> = vec![vec![0.0; n]; num_seasonals];
let mut trend = vec![0.0; n];
let mut buf = vec![0.0_f64; n];
let stl_trend = if self.robust {
STL::new(max_period).robust()
} else {
STL::new(max_period)
};
let stl_seasonals: Vec<STL> = self
.seasonal_periods
.iter()
.map(|&period| {
if self.robust {
STL::new(period).robust()
} else {
STL::new(period)
}
})
.collect();
for _ in 0..self.iterations {
buf.copy_from_slice(series);
for seasonal in &seasonal_components {
for i in 0..n {
buf[i] -= seasonal[i];
}
}
if let Some(trend_result) = stl_trend.decompose(&buf) {
trend = trend_result.trend;
}
for s_idx in 0..num_seasonals {
buf.copy_from_slice(series);
for i in 0..n {
buf[i] -= trend[i];
}
for (other_idx, other_seasonal) in seasonal_components.iter().enumerate() {
if other_idx != s_idx {
for i in 0..n {
buf[i] -= other_seasonal[i];
}
}
}
if let Some(seasonal_result) = stl_seasonals[s_idx].decompose(&buf) {
seasonal_components[s_idx] = seasonal_result.seasonal;
}
}
}
let mut remainder = vec![0.0_f64; n];
for i in 0..n {
let mut seasonal_sum = 0.0;
for seasonal in &seasonal_components {
seasonal_sum += seasonal[i];
}
remainder[i] = series[i] - trend[i] - seasonal_sum;
}
Some(MSTLResult {
trend,
seasonal_components,
seasonal_periods: self.seasonal_periods.clone(),
remainder,
regressor_coefficients: None,
regressor_effect: None,
})
}
pub fn decompose_with_regressors(
&self,
series: &[f64],
regressors: &HashMap<String, Vec<f64>>,
) -> Option<MSTLResult> {
if regressors.is_empty() {
return self.decompose(series);
}
let ols_result = ols_fit(series, regressors).ok()?;
let y_adjusted = ols_residuals(series, &ols_result, regressors).ok()?;
let mut result = self.decompose(&y_adjusted)?;
let regressor_effect = ols_result.predict(regressors).ok()?;
result.regressor_coefficients = Some(ols_result);
result.regressor_effect = Some(regressor_effect);
Some(result)
}
}
fn variance(values: &[f64]) -> f64 {
let n = values.len();
if n < 2 {
return 0.0;
}
let mean: f64 = values.iter().sum::<f64>() / n as f64;
values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1) as f64
}
#[cfg(test)]
mod tests {
use super::*;
fn generate_multi_seasonal_series(n: usize, periods: &[usize]) -> Vec<f64> {
(0..n)
.map(|i| {
let trend = 0.05 * i as f64;
let mut seasonal = 0.0;
for (idx, &period) in periods.iter().enumerate() {
let amplitude = 5.0 / (idx + 1) as f64; seasonal +=
amplitude * ((2.0 * std::f64::consts::PI * i as f64 / period as f64).sin());
}
trend + seasonal
})
.collect()
}
#[test]
fn mstl_single_period() {
let period = 12;
let series = generate_multi_seasonal_series(120, &[period]);
let mstl = MSTL::new(vec![period]);
let result = mstl.decompose(&series).unwrap();
assert_eq!(result.seasonal_components.len(), 1);
assert_eq!(result.seasonal_periods, vec![period]);
assert_eq!(result.trend.len(), series.len());
}
#[test]
fn mstl_two_periods() {
let periods = vec![7, 24];
let series = generate_multi_seasonal_series(200, &periods);
let mstl = MSTL::new(periods.clone());
let result = mstl.decompose(&series).unwrap();
assert_eq!(result.seasonal_components.len(), 2);
assert_eq!(result.seasonal_periods, vec![7, 24]);
for i in 0..series.len() {
let reconstructed = result.trend[i]
+ result.seasonal_components[0][i]
+ result.seasonal_components[1][i]
+ result.remainder[i];
assert!(
(series[i] - reconstructed).abs() < 1e-6,
"Reconstruction failed at index {}",
i
);
}
}
#[test]
fn mstl_total_seasonal() {
let periods = vec![7, 24];
let series = generate_multi_seasonal_series(200, &periods);
let mstl = MSTL::new(periods);
let result = mstl.decompose(&series).unwrap();
let total = result.total_seasonal();
assert_eq!(total.len(), series.len());
for i in 0..series.len() {
let expected = result.seasonal_components[0][i] + result.seasonal_components[1][i];
assert!(
(total[i] - expected).abs() < 1e-10,
"Total seasonal mismatch at index {}",
i
);
}
}
#[test]
fn mstl_insufficient_data() {
let periods = vec![7, 24];
let series = vec![1.0; 30];
let mstl = MSTL::new(periods);
assert!(mstl.decompose(&series).is_none());
}
#[test]
fn mstl_empty_periods() {
let series = vec![1.0; 100];
let mstl = MSTL::new(vec![]);
assert!(mstl.decompose(&series).is_none());
}
#[test]
fn mstl_robust() {
let periods = vec![12];
let mut series = generate_multi_seasonal_series(120, &periods);
series[30] = 100.0;
series[60] = -100.0;
let mstl = MSTL::new(periods).robust();
let result = mstl.decompose(&series);
assert!(result.is_some());
}
#[test]
fn mstl_duplicate_periods_removed() {
let mstl = MSTL::new(vec![12, 12, 7, 7]);
assert_eq!(mstl.seasonal_periods(), &[7, 12]);
}
#[test]
fn mstl_periods_sorted() {
let mstl = MSTL::new(vec![24, 7, 12]);
assert_eq!(mstl.seasonal_periods(), &[7, 12, 24]);
}
#[test]
fn mstl_seasonal_strength() {
let periods = vec![12];
let series = generate_multi_seasonal_series(120, &periods);
let mstl = MSTL::new(periods);
let result = mstl.decompose(&series).unwrap();
let strength = result.seasonal_strength(0).unwrap();
assert!(
(0.0..=1.0).contains(&strength),
"Seasonal strength should be in [0, 1]: {}",
strength
);
}
#[test]
fn mstl_trend_strength() {
let periods = vec![12];
let series = generate_multi_seasonal_series(120, &periods);
let mstl = MSTL::new(periods);
let result = mstl.decompose(&series).unwrap();
let strength = result.trend_strength();
assert!(
(0.0..=1.0).contains(&strength),
"Trend strength should be in [0, 1]: {}",
strength
);
}
#[test]
fn mstl_with_iterations() {
let periods = vec![12];
let series = generate_multi_seasonal_series(120, &periods);
let mstl = MSTL::new(periods).with_iterations(3);
let result = mstl.decompose(&series);
assert!(result.is_some());
}
#[test]
fn mstl_invalid_period_index() {
let periods = vec![12];
let series = generate_multi_seasonal_series(120, &periods);
let mstl = MSTL::new(periods);
let result = mstl.decompose(&series).unwrap();
assert!(result.seasonal_strength(5).is_none());
}
#[test]
fn mstl_decompose_no_regressors_returns_none_fields() {
let periods = vec![12];
let series = generate_multi_seasonal_series(120, &periods);
let mstl = MSTL::new(periods);
let result = mstl.decompose(&series).unwrap();
assert!(result.regressor_coefficients.is_none());
assert!(result.regressor_effect.is_none());
}
#[test]
fn mstl_decompose_with_empty_regressors_falls_back() {
let periods = vec![12];
let series = generate_multi_seasonal_series(120, &periods);
let mstl = MSTL::new(periods);
let regressors = std::collections::HashMap::new();
let result = mstl
.decompose_with_regressors(&series, ®ressors)
.unwrap();
assert!(result.regressor_coefficients.is_none());
assert!(result.regressor_effect.is_none());
}
#[test]
fn mstl_decompose_with_regressors_stores_ols() {
let n = 120;
let periods = vec![12];
let x: Vec<f64> = (0..n).map(|i| (i as f64 * 0.1).sin()).collect();
let base = generate_multi_seasonal_series(n, &periods);
let series: Vec<f64> = base
.iter()
.zip(x.iter())
.map(|(b, xi)| b + 2.0 * xi)
.collect();
let mstl = MSTL::new(periods);
let mut regressors = std::collections::HashMap::new();
regressors.insert("x".to_string(), x.clone());
let result = mstl
.decompose_with_regressors(&series, ®ressors)
.unwrap();
assert!(result.regressor_coefficients.is_some());
let ols = result.regressor_coefficients.as_ref().unwrap();
assert_eq!(ols.regressor_names, vec!["x".to_string()]);
assert!(result.regressor_effect.is_some());
assert_eq!(result.regressor_effect.as_ref().unwrap().len(), n);
let regressor_effect = result.regressor_effect.as_ref().unwrap();
for i in 0..n {
let reconstructed = result.trend[i]
+ result.seasonal_components[0][i]
+ result.remainder[i]
+ regressor_effect[i];
assert!(
(series[i] - reconstructed).abs() < 1e-4,
"Reconstruction failed at index {}: expected {}, got {}",
i,
series[i],
reconstructed,
);
}
}
#[test]
fn mstl_decompose_with_regressors_coefficient_accuracy() {
let n = 200;
let periods = vec![12];
let x: Vec<f64> = (0..n).map(|i| i as f64 * 0.05).collect();
let base = generate_multi_seasonal_series(n, &periods);
let series: Vec<f64> = base
.iter()
.zip(x.iter())
.map(|(b, xi)| b + 3.0 * xi)
.collect();
let mstl = MSTL::new(periods);
let mut regressors = std::collections::HashMap::new();
regressors.insert("x".to_string(), x);
let result = mstl
.decompose_with_regressors(&series, ®ressors)
.unwrap();
let ols = result.regressor_coefficients.as_ref().unwrap();
assert!(
ols.coefficients[0] > 0.0,
"Coefficient should be positive, got {}",
ols.coefficients[0],
);
}
}