1use thiserror::Error;
16
17#[derive(Debug, Clone)]
19pub struct Decomposition {
20 pub trend: Vec<f64>,
22 pub seasonal: Vec<f64>,
24 pub residual: Vec<f64>,
26}
27
28pub trait SeasonalDecomposer: Send + Sync {
30 fn decompose(
32 &self,
33 timestamps: &[i64],
34 values: &[f64],
35 period: usize,
36 ) -> Result<Decomposition, DecompositionError>;
37}
38
39pub struct StlDecomposer {
41 pub n_iter: usize,
43 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 };
70 stl_decompose(values, &config)
71 }
72}
73
74#[derive(Debug, Error)]
76pub enum DecompositionError {
77 #[error("series length ({len}) must be at least 2× the period ({period})")]
79 TooShort {
80 len: usize,
82 period: usize,
84 },
85
86 #[error("period must be ≥ 2, got {0}")]
88 InvalidPeriod(usize),
89}
90
91#[derive(Debug, Clone)]
93pub struct StlConfig {
94 pub period: usize,
96 pub n_iter: usize,
98 pub trend_window: Option<usize>,
101 pub seasonal_window: Option<usize>,
106}
107
108impl StlConfig {
109 pub fn new(period: usize) -> Self {
111 Self {
112 period,
113 n_iter: 2,
114 trend_window: None,
115 seasonal_window: None,
116 }
117 }
118
119 pub fn with_iterations(mut self, n: usize) -> Self {
121 self.n_iter = n.max(1);
122 self
123 }
124
125 pub fn with_trend_window(mut self, w: usize) -> Self {
127 self.trend_window = Some(w);
128 self
129 }
130
131 pub fn with_seasonal_window(mut self, w: usize) -> Self {
133 self.seasonal_window = Some(w);
134 self
135 }
136
137 fn effective_trend_window(&self) -> usize {
138 let w = self.trend_window.unwrap_or(self.period + 1);
139 let w = w.max(3);
141 if w.is_multiple_of(2) {
142 w + 1
143 } else {
144 w
145 }
146 }
147
148 fn effective_seasonal_window(&self) -> usize {
149 let w = self.seasonal_window.unwrap_or(self.period.max(7));
150 let w = w.max(3);
151 if w.is_multiple_of(2) {
152 w + 1
153 } else {
154 w
155 }
156 }
157}
158
159pub fn stl_decompose(
161 values: &[f64],
162 config: &StlConfig,
163) -> Result<Decomposition, DecompositionError> {
164 let n = values.len();
165 let period = config.period;
166
167 if period < 2 {
168 return Err(DecompositionError::InvalidPeriod(period));
169 }
170 if n < 2 * period {
171 return Err(DecompositionError::TooShort { len: n, period });
172 }
173
174 let trend_w = config.effective_trend_window();
175 let seasonal_w = config.effective_seasonal_window();
176
177 let mut seasonal = vec![0.0; n];
178 let mut trend = vec![0.0; n];
179
180 for _ in 0..config.n_iter {
190 let detrended: Vec<f64> = values
192 .iter()
193 .zip(trend.iter())
194 .map(|(y, t)| y - t)
195 .collect();
196
197 let mut cycle = vec![0.0; n];
200 for s in 0..period {
201 let indices: Vec<usize> = (s..n).step_by(period).collect();
202 let sub: Vec<f64> = indices.iter().map(|&i| detrended[i]).collect();
203 let smoothed = loess_smooth(&sub, seasonal_w);
204 for (&i, &v) in indices.iter().zip(smoothed.iter()) {
205 cycle[i] = v;
206 }
207 }
208
209 let low_pass = low_pass_filter(&cycle, period);
215
216 for i in 0..n {
218 seasonal[i] = cycle[i] - low_pass[i];
219 }
220
221 let deseasoned: Vec<f64> = values
223 .iter()
224 .zip(seasonal.iter())
225 .map(|(y, s)| y - s)
226 .collect();
227 trend = loess_smooth(&deseasoned, trend_w);
228 }
229
230 let residual: Vec<f64> = values
232 .iter()
233 .zip(trend.iter())
234 .zip(seasonal.iter())
235 .map(|((y, t), s)| y - t - s)
236 .collect();
237
238 Ok(Decomposition {
239 trend,
240 seasonal,
241 residual,
242 })
243}
244
245pub fn detect_period(values: &[f64], max_period: usize) -> Option<usize> {
261 detect_period_with_threshold(values, max_period, 0.3)
262}
263
264pub fn detect_period_with_threshold(
276 values: &[f64],
277 max_period: usize,
278 min_acf_threshold: f64,
279) -> Option<usize> {
280 let n = values.len();
281 if n < 4 {
282 return None;
283 }
284
285 let residual = detrend(values);
290
291 let mean = residual.iter().sum::<f64>() / n as f64;
292 let var: f64 = residual.iter().map(|v| (v - mean).powi(2)).sum();
293 if var < f64::EPSILON {
294 return None;
295 }
296
297 let max_lag = max_period.min(n / 2);
298 if max_lag < 3 {
299 return None;
300 }
301
302 let acf: Vec<f64> = (0..=max_lag)
306 .map(|lag| {
307 let s: f64 = (0..(n - lag))
308 .map(|i| (residual[i] - mean) * (residual[i + lag] - mean))
309 .sum();
310 s / var
311 })
312 .collect();
313
314 let mut best: Option<(usize, f64)> = None;
318 for lag in 2..max_lag {
319 let (prev, here, next) = (acf[lag - 1], acf[lag], acf[lag + 1]);
320 if here > prev && here >= next && here > min_acf_threshold {
321 if best.is_none_or(|(_, v)| here > v) {
323 best = Some((lag, here));
324 }
325 }
326 }
327
328 best.map(|(lag, _)| lag)
329}
330
331fn detrend(values: &[f64]) -> Vec<f64> {
333 let n = values.len();
334 if n < 2 {
335 return values.to_vec();
336 }
337 let n_f = n as f64;
338 let mean_x = (n_f - 1.0) / 2.0;
339 let mean_y = values.iter().sum::<f64>() / n_f;
340 let mut sxx = 0.0;
341 let mut sxy = 0.0;
342 for (i, &y) in values.iter().enumerate() {
343 let dx = i as f64 - mean_x;
344 sxx += dx * dx;
345 sxy += dx * (y - mean_y);
346 }
347 if sxx <= 0.0 {
348 return values.iter().map(|v| v - mean_y).collect();
349 }
350 let slope = sxy / sxx;
351 values
352 .iter()
353 .enumerate()
354 .map(|(i, &y)| y - (mean_y + slope * (i as f64 - mean_x)))
355 .collect()
356}
357
358fn low_pass_filter(cycle: &[f64], period: usize) -> Vec<f64> {
369 let n = cycle.len();
370 let mut padded = Vec::with_capacity(n + 2 * period);
371 padded.extend_from_slice(&cycle[..period]);
372 padded.extend_from_slice(cycle);
373 padded.extend_from_slice(&cycle[n - period..]);
374
375 let mut filtered = centered_moving_average(&padded, period);
376 if period.is_multiple_of(2) {
377 filtered = centered_moving_average(&filtered, 2);
378 }
379 let filtered = centered_moving_average(&filtered, 3);
380 filtered[period..period + n].to_vec()
381}
382
383fn centered_moving_average(x: &[f64], w: usize) -> Vec<f64> {
387 let n = x.len();
388 if w <= 1 || n == 0 {
389 return x.to_vec();
390 }
391 let before = w / 2;
392 let after = if w.is_multiple_of(2) {
393 w / 2 - 1
394 } else {
395 w / 2
396 };
397 (0..n)
398 .map(|i| {
399 let lo = i.saturating_sub(before);
400 let hi = (i + after).min(n - 1);
401 let slice = &x[lo..=hi];
402 slice.iter().sum::<f64>() / slice.len() as f64
403 })
404 .collect()
405}
406
407fn loess_smooth(values: &[f64], window: usize) -> Vec<f64> {
408 let n = values.len();
409 if window == 0 {
410 return values.to_vec();
411 }
412 if window >= n {
413 let mean = values.iter().sum::<f64>() / n as f64;
414 return vec![mean; n];
415 }
416 let half = window / 2;
417 let mut out = Vec::with_capacity(n);
418
419 for i in 0..n {
420 let lo = i.saturating_sub(half);
421 let hi = (i + half).min(n - 1);
422 let max_dist = (half.max(1)) as f64;
423
424 let (mut sw, mut swx, mut swy, mut swxx, mut swxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
425 for j in lo..=hi {
426 let u = ((j as f64 - i as f64) / max_dist).abs().min(1.0);
427 let u3 = u * u * u;
428 let w = (1.0 - u3) * (1.0 - u3) * (1.0 - u3); let x = j as f64;
430 let y = values[j];
431 sw += w;
432 swx += w * x;
433 swy += w * y;
434 swxx += w * x * x;
435 swxy += w * x * y;
436 }
437 let det = sw * swxx - swx * swx;
438 let fitted = if det.abs() < 1e-15 {
439 if sw > 0.0 {
440 swy / sw
441 } else {
442 values[i]
443 }
444 } else {
445 let a = (swxx * swy - swx * swxy) / det;
446 let b = (sw * swxy - swx * swy) / det;
447 a + b * i as f64
448 };
449 out.push(fitted);
450 }
451 out
452}
453
454#[cfg(test)]
458mod tests {
459 use super::*;
460
461 fn approx(a: f64, b: f64, eps: f64) -> bool {
462 (a - b).abs() < eps
463 }
464
465 #[test]
471 fn stl_recovers_the_trend_and_the_seasonal_pattern() {
472 let n = 120;
473 let period = 12;
474 let seasonal_of = |i: usize| 10.0 * (2.0 * std::f64::consts::PI * i as f64 / 12.0).sin();
475 let values: Vec<f64> = (0..n).map(|i| 0.5 * i as f64 + seasonal_of(i)).collect();
476
477 let dec = stl_decompose(&values, &StlConfig::new(period).with_iterations(5)).unwrap();
478
479 let amplitude = dec.seasonal.iter().copied().fold(f64::MIN, f64::max)
480 - dec.seasonal.iter().copied().fold(f64::MAX, f64::min);
481 assert!(
482 amplitude > 18.0,
483 "seasonal amplitude collapsed: {amplitude}"
484 );
485
486 for i in period..n - period {
489 assert!(
490 approx(dec.seasonal[i], seasonal_of(i), 0.6),
491 "seasonal at {i}: {} vs {}",
492 dec.seasonal[i],
493 seasonal_of(i)
494 );
495 assert!(
496 approx(dec.trend[i], 0.5 * i as f64, 0.6),
497 "trend at {i}: {} vs {}",
498 dec.trend[i],
499 0.5 * i as f64
500 );
501 assert!(
502 dec.residual[i].abs() < 0.6,
503 "residual at {i}: {}",
504 dec.residual[i]
505 );
506 }
507 for i in 0..n {
508 let recon = dec.trend[i] + dec.seasonal[i] + dec.residual[i];
509 assert!(approx(recon, values[i], 1e-9));
510 }
511 }
512
513 #[test]
515 fn stl_handles_an_even_period_without_bias() {
516 let n = 96;
517 let period = 24;
518 let seasonal_of = |i: usize| 5.0 * (2.0 * std::f64::consts::PI * i as f64 / 24.0).sin();
519 let values: Vec<f64> = (0..n).map(|i| 100.0 + seasonal_of(i)).collect();
520
521 let dec = stl_decompose(&values, &StlConfig::new(period).with_iterations(3)).unwrap();
522 for i in period..n - period {
523 assert!(
524 approx(dec.seasonal[i], seasonal_of(i), 0.4),
525 "seasonal at {i}"
526 );
527 assert!(
528 approx(dec.trend[i], 100.0, 0.4),
529 "trend at {i}: {}",
530 dec.trend[i]
531 );
532 }
533 let max_residual = dec.residual.iter().map(|r| r.abs()).fold(0.0_f64, f64::max);
534 assert!(max_residual < 0.8, "residual too large: {max_residual}");
535 }
536
537 #[test]
538 fn test_stl_sum_equals_original() {
539 let n = 200;
540 let period = 10;
541 let values: Vec<f64> = (0..n)
542 .map(|i| (i as f64 * 0.1).sin() + 0.01 * i as f64)
543 .collect();
544
545 let dec = stl_decompose(&values, &StlConfig::new(period)).unwrap();
546 for i in 0..n {
547 let recon = dec.trend[i] + dec.seasonal[i] + dec.residual[i];
548 assert!(approx(recon, values[i], 1e-12));
549 }
550 }
551
552 #[test]
553 fn test_stl_too_short() {
554 let values = vec![1.0; 10];
555 let err = stl_decompose(&values, &StlConfig::new(8)).unwrap_err();
556 assert!(matches!(err, DecompositionError::TooShort { .. }));
557 }
558
559 #[test]
560 fn test_stl_invalid_period() {
561 let values = vec![1.0; 100];
562 let err = stl_decompose(&values, &StlConfig::new(1)).unwrap_err();
563 assert!(matches!(err, DecompositionError::InvalidPeriod(1)));
564 }
565
566 #[test]
567 fn test_detect_period_sinusoidal() {
568 let n = 240;
569 let true_period = 24;
570 let values: Vec<f64> = (0..n)
571 .map(|i| (2.0 * std::f64::consts::PI * i as f64 / true_period as f64).sin())
572 .collect();
573
574 let detected = detect_period(&values, 48);
575 assert_eq!(detected, Some(true_period));
576 }
577
578 #[test]
579 fn test_detect_period_constant_returns_none() {
580 let values = vec![42.0; 100];
581 assert_eq!(detect_period(&values, 50), None);
582 }
583
584 #[test]
585 fn test_loess_smooth_identity() {
586 let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
588 let smoothed = loess_smooth(&v, 1);
589 for (a, b) in smoothed.iter().zip(v.iter()) {
590 assert!(approx(*a, *b, 1e-9));
591 }
592 }
593
594 #[test]
595 fn test_loess_smooth_smoothing() {
596 let v = vec![1.0, 3.0, 5.0, 7.0, 9.0];
598 let smoothed = loess_smooth(&v, 3);
599 for (a, b) in smoothed.iter().zip(v.iter()) {
602 assert!(approx(*a, *b, 1e-6));
603 }
604 }
605
606 #[test]
607 fn test_seasonal_decomposer_trait() {
608 let decomposer = StlDecomposer::default();
609 let n = 200;
610 let period = 10;
611 let timestamps: Vec<i64> = (0..n as i64).collect();
612 let values: Vec<f64> = (0..n)
613 .map(|i| (i as f64 * 0.1).sin() + 0.01 * i as f64)
614 .collect();
615
616 let dec = decomposer.decompose(×tamps, &values, period).unwrap();
617 assert_eq!(dec.trend.len(), n);
618 assert_eq!(dec.seasonal.len(), n);
619 assert_eq!(dec.residual.len(), n);
620 for i in 0..n {
622 let recon = dec.trend[i] + dec.seasonal[i] + dec.residual[i];
623 assert!(approx(recon, values[i], 1e-12));
624 }
625 }
626
627 #[test]
628 fn test_seasonal_decomposer_trait_too_short() {
629 let decomposer = StlDecomposer::default();
630 let err = decomposer.decompose(&[0, 1], &[1.0, 2.0], 5).unwrap_err();
631 assert!(matches!(err, DecompositionError::TooShort { .. }));
632 }
633
634 #[test]
635 fn test_detect_period_with_threshold_controls_sensitivity() {
636 let n = 120;
640 let true_period = 24;
641 let values: Vec<f64> = (0..n)
642 .map(|i: usize| {
643 let t = i as f64;
644 let signal = (2.0 * std::f64::consts::PI * t / true_period as f64).sin();
645 let mut h = i as u64;
647 h = h.wrapping_mul(0x9E3779B97F4A7C15);
648 h = (h ^ (h >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
649 h = (h ^ (h >> 27)).wrapping_mul(0x94D049BB133111EB);
650 h = h ^ (h >> 31);
651 let noise = (h % 10000) as f64 / 5000.0 - 1.0;
652 signal + noise * 3.0
653 })
654 .collect();
655
656 let detected_low = detect_period_with_threshold(&values, 48, 0.1);
658 assert!(
659 detected_low.is_some(),
660 "low threshold (0.1) should detect the weak periodicity",
661 );
662
663 let detected_high = detect_period_with_threshold(&values, 48, 0.95);
665 assert_eq!(
666 detected_high, None,
667 "high threshold (0.95) should reject the weak periodicity",
668 );
669 }
670
671 #[test]
675 fn detect_period_sees_through_a_trend() {
676 let n = 300;
677 let period = 24;
678 let values: Vec<f64> = (0..n)
679 .map(|i| {
680 let t = i as f64;
681 200.0 + t * 0.5 + 40.0 * (2.0 * std::f64::consts::PI * t / period as f64).sin()
682 })
683 .collect();
684 assert_eq!(detect_period(&values, 48), Some(period));
685 }
686
687 #[test]
690 fn detect_period_rejects_a_pure_trend() {
691 let values: Vec<f64> = (0..200).map(|i| 10.0 + i as f64 * 0.3).collect();
692 assert_eq!(detect_period(&values, 48), None);
693 }
694
695 #[test]
698 fn detect_period_prefers_the_fundamental_over_a_harmonic() {
699 let n = 480;
700 let values: Vec<f64> = (0..n)
701 .map(|i| {
702 let t = i as f64;
703 (2.0 * std::f64::consts::PI * t / 12.0).sin()
704 + 0.4 * (2.0 * std::f64::consts::PI * t / 6.0).sin()
705 })
706 .collect();
707 assert_eq!(detect_period(&values, 60), Some(12));
708 }
709
710 #[test]
711 fn test_detect_period_delegates_to_with_threshold() {
712 let n = 240;
714 let values: Vec<f64> = (0..n)
715 .map(|i| (2.0 * std::f64::consts::PI * i as f64 / 24.0).sin())
716 .collect();
717
718 assert_eq!(
719 detect_period(&values, 48),
720 detect_period_with_threshold(&values, 48, 0.3),
721 );
722 }
723}