1use crate::forecast::error::ForecastError;
39use crate::forecast::result::{ForecastResult, ModelParams, ModelType};
40use crate::forecast::traits::ForecastModel;
41use crate::forecast::util::{median_interval, validate_input};
42
43pub(crate) fn difference(values: &[f64], d: usize) -> Vec<f64> {
51 let mut current = values.to_vec();
52 for _ in 0..d {
53 if current.len() < 2 {
54 return Vec::new();
55 }
56 current = current.windows(2).map(|w| w[1] - w[0]).collect();
57 }
58 current
59}
60
61pub(crate) fn seasonal_difference(values: &[f64], m: usize, d: usize) -> Vec<f64> {
63 if m == 0 {
64 return values.to_vec();
65 }
66 let mut current = values.to_vec();
67 for _ in 0..d {
68 if current.len() <= m {
69 return Vec::new();
70 }
71 current = (m..current.len())
72 .map(|i| current[i] - current[i - m])
73 .collect();
74 }
75 current
76}
77
78fn undifference_lag(predictions: &[f64], anchor: &[f64], m: usize) -> Vec<f64> {
89 let mut buf: Vec<f64> = anchor.to_vec();
90 let mut comp = vec![0.0_f64; m.max(1)];
91 for (h, &v) in predictions.iter().enumerate() {
92 let base = buf[buf.len() - m];
93 let slot = h % m.max(1);
94 let y = v - comp[slot];
95 let t = base + y;
96 comp[slot] = (t - base) - y;
97 buf.push(t);
98 }
99 buf[anchor.len()..].to_vec()
100}
101
102pub(crate) fn estimate_ar_burg(
116 values: &[f64],
117 p: usize,
118) -> Result<(Vec<f64>, usize), ForecastError> {
119 if values.len() < p + 1 {
120 return Err(ForecastError::InsufficientData {
121 min: p + 1,
122 got: values.len(),
123 });
124 }
125 if p == 0 {
126 return Ok((Vec::new(), 0));
127 }
128
129 let n = values.len();
130 let mut ef: Vec<f64> = values.to_vec();
131 let mut eb: Vec<f64> = values.to_vec();
132 let mut ar = vec![0.0; p];
133 let mut effective_order = p;
134
135 for m in 0..p {
136 let mut num = 0.0;
137 let mut den = 0.0;
138 for t in (m + 1)..n {
139 num += ef[t] * eb[t - 1];
140 den += ef[t] * ef[t] + eb[t - 1] * eb[t - 1];
141 }
142 if den.abs() < 1e-15 {
143 effective_order = m;
146 tracing::warn!(
147 requested_order = p,
148 effective_order = m,
149 "Burg AR estimation terminated early due to numerical instability"
150 );
151 break;
152 }
153 let k = 2.0 * num / den;
154
155 let mut ar_new = vec![0.0; p];
156 for j in 0..m {
157 ar_new[j] = ar[j] - k * ar[m - 1 - j];
158 }
159 ar_new[m] = k;
160 ar = ar_new;
161
162 let ef_prev = ef.clone();
163 let eb_prev = eb.clone();
164 for t in (m + 1)..n {
165 ef[t] = ef_prev[t] - k * eb_prev[t - 1];
166 eb[t] = eb_prev[t - 1] - k * ef_prev[t];
167 }
168 }
169
170 Ok((ar, effective_order))
171}
172
173fn poly_mul(a: &[f64], b: &[f64]) -> Vec<f64> {
175 let mut out = vec![0.0; a.len() + b.len() - 1];
176 for (i, &ai) in a.iter().enumerate() {
177 if ai == 0.0 {
178 continue;
179 }
180 for (j, &bj) in b.iter().enumerate() {
181 out[i + j] += ai * bj;
182 }
183 }
184 out
185}
186
187pub(crate) fn expand_ar(phi: &[f64], seasonal_phi: &[f64], m: usize) -> Vec<f64> {
193 if seasonal_phi.is_empty() || m == 0 {
194 return phi.to_vec();
195 }
196 let mut a = vec![0.0; phi.len() + 1];
197 a[0] = 1.0;
198 for (i, &c) in phi.iter().enumerate() {
199 a[i + 1] = -c;
200 }
201 let mut s = vec![0.0; seasonal_phi.len() * m + 1];
202 s[0] = 1.0;
203 for (k, &c) in seasonal_phi.iter().enumerate() {
204 s[(k + 1) * m] = -c;
205 }
206 poly_mul(&a, &s)[1..].iter().map(|c| -c).collect()
207}
208
209pub(crate) fn expand_ma(theta: &[f64], seasonal_theta: &[f64], m: usize) -> Vec<f64> {
216 if seasonal_theta.is_empty() || m == 0 {
217 return theta.to_vec();
218 }
219 let mut b = vec![0.0; theta.len() + 1];
220 b[0] = 1.0;
221 b[1..].copy_from_slice(theta);
222 let mut t = vec![0.0; seasonal_theta.len() * m + 1];
223 t[0] = 1.0;
224 for (k, &c) in seasonal_theta.iter().enumerate() {
225 t[(k + 1) * m] = c;
226 }
227 poly_mul(&b, &t)[1..].to_vec()
228}
229
230pub(crate) fn innovations(values: &[f64], ar: &[f64], ma: &[f64]) -> Vec<f64> {
237 let mut eps = vec![0.0; values.len()];
238 for t in 0..values.len() {
239 let mut pred = 0.0;
240 for (j, &c) in ar.iter().enumerate() {
241 if t > j {
242 pred += c * values[t - j - 1];
243 }
244 }
245 for (j, &c) in ma.iter().enumerate() {
246 if t > j {
247 pred += c * eps[t - j - 1];
248 }
249 }
250 eps[t] = values[t] - pred;
251 }
252 eps
253}
254
255#[inline]
269pub(crate) fn includes_constant(d: usize, seasonal_d: usize) -> bool {
270 d + seasonal_d <= 1
271}
272
273#[inline]
275pub(crate) fn conditioning_warmup(ar_len: usize, ma_len: usize) -> usize {
276 ar_len.max(ma_len)
277}
278
279fn css(values: &[f64], ar: &[f64], ma: &[f64]) -> f64 {
281 let warmup = conditioning_warmup(ar.len(), ma.len());
282 if values.len() <= warmup {
283 return f64::INFINITY;
284 }
285 let eps = innovations(values, ar, ma);
286 eps[warmup..].iter().map(|e| e * e).sum()
287}
288
289pub(crate) fn psi_weights(ar: &[f64], ma: &[f64], horizon: usize) -> Vec<f64> {
294 let mut psi = Vec::with_capacity(horizon.max(1));
295 psi.push(1.0);
296 for j in 1..horizon {
297 let mut val = 0.0;
298 for (i, &c) in ar.iter().enumerate() {
299 if j > i {
300 val += c * psi[j - i - 1];
301 }
302 }
303 if j <= ma.len() {
304 val += ma[j - 1];
305 }
306 psi.push(val);
307 }
308 psi
309}
310
311fn arma_forecast(
315 values: &[f64],
316 residuals: &[f64],
317 ar: &[f64],
318 ma: &[f64],
319 horizon: usize,
320) -> Vec<f64> {
321 let n = values.len();
322 let mut extended = values.to_vec();
323 let mut eps = residuals.to_vec();
324 if eps.len() < n {
330 let mut padded = vec![0.0; n - eps.len()];
331 padded.extend_from_slice(&eps);
332 eps = padded;
333 } else if eps.len() > n {
334 eps.drain(..eps.len() - n);
335 }
336
337 for _ in 0..horizon {
338 let idx = extended.len();
339 let mut pred = 0.0;
340 for (j, &c) in ar.iter().enumerate() {
341 if idx > j {
342 pred += c * extended[idx - j - 1];
343 }
344 }
345 for (j, &c) in ma.iter().enumerate() {
346 if idx > j {
347 pred += c * eps[idx - j - 1];
348 }
349 }
350 extended.push(pred);
351 eps.push(0.0);
352 }
353 extended[n..].to_vec()
354}
355
356fn residual_std(residuals: &[f64], warmup: usize, n_params: usize) -> f64 {
360 let usable = residuals.get(warmup..).unwrap_or(&[]);
361 if usable.len() < 2 {
362 return 0.0;
363 }
364 let mean: f64 = usable.iter().sum::<f64>() / usable.len() as f64;
365 let dof = usable.len().saturating_sub(n_params).max(1);
366 let var: f64 = usable.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / dof as f64;
367 var.sqrt()
368}
369
370pub(crate) fn integrated_ar(ar: &[f64], d: usize, seasonal_d: usize, m: usize) -> Vec<f64> {
383 let mut poly = Vec::with_capacity(ar.len() + 1);
385 poly.push(1.0);
386 poly.extend(ar.iter().map(|c| -c));
387 for _ in 0..d {
388 poly = poly_mul(&poly, &[1.0, -1.0]);
389 }
390 if m > 0 && seasonal_d > 0 {
391 let mut sd = vec![0.0; m + 1];
392 sd[0] = 1.0;
393 sd[m] = -1.0;
394 for _ in 0..seasonal_d {
395 poly = poly_mul(&poly, &sd);
396 }
397 }
398 poly[1..].iter().map(|c| -c).collect()
399}
400
401fn prediction_intervals(
404 values: &[f64],
405 ar: &[f64],
406 ma: &[f64],
407 sigma: f64,
408 horizon: usize,
409 d: usize,
410 seasonal_d: usize,
411 m: usize,
412) -> (Vec<f64>, Vec<f64>) {
413 const Z95: f64 = 1.959_963_984_540_054;
414 let phi_star = integrated_ar(ar, d, seasonal_d, m);
415 let psi = psi_weights(&phi_star, ma, horizon);
416 let mut lower = Vec::with_capacity(horizon);
417 let mut upper = Vec::with_capacity(horizon);
418 for h in 1..=horizon {
419 let scale = psi[..h].iter().map(|w| w * w).sum::<f64>().sqrt();
420 let width = Z95 * sigma * scale;
421 lower.push(values[h - 1] - width);
422 upper.push(values[h - 1] + width);
423 }
424 (lower, upper)
425}
426
427fn forecast_timestamps(last_ts: i64, interval_ns: i64, horizon: usize) -> Vec<i64> {
430 (1..=horizon as i64)
431 .map(|h| last_ts.saturating_add(h.saturating_mul(interval_ns)))
432 .collect()
433}
434
435#[derive(serde::Serialize, serde::Deserialize)]
440pub struct ArimaModel {
441 p: usize,
442 d: usize,
443 q: usize,
444 params: ModelParams,
445 history: Vec<f64>,
446 residuals: Vec<f64>,
447 constant: f64,
450 warmup: usize,
453 last_ts: i64,
454 interval_ns: i64,
455 fitted: bool,
456}
457
458impl ArimaModel {
459 #[must_use]
461 pub fn new(p: usize, d: usize, q: usize) -> Self {
462 Self {
463 p,
464 d,
465 q,
466 params: ModelParams::Arima {
467 p,
468 d,
469 q,
470 ar_coeffs: Vec::new(),
471 ma_coeffs: Vec::new(),
472 constant: 0.0,
473 residual_std: 0.0,
474 effective_ar_order: None,
475 },
476 history: Vec::new(),
477 residuals: Vec::new(),
478 constant: 0.0,
479 warmup: 0,
480 last_ts: 0,
481 interval_ns: 0,
482 fitted: false,
483 }
484 }
485
486 fn estimate_ma(values: &[f64], ar: &[f64], q: usize) -> Vec<f64> {
494 if q == 0 {
495 return Vec::new();
496 }
497 let objective = |theta: &[f64]| css(values, ar, theta);
498 if q == 1 {
499 let opt = crate::forecast::optimizer::minimize_brent(
500 |x| objective(&[x]),
501 -0.99,
502 0.99,
503 1e-8,
504 200,
505 );
506 vec![opt.params[0]]
507 } else {
508 let bounds: Vec<crate::forecast::optimizer::Bound> = (0..q)
509 .map(|_| crate::forecast::optimizer::Bound::new(-0.99, 0.99))
510 .collect();
511 let x0 = vec![0.0; q];
512 crate::forecast::optimizer::minimize_nelder_mead(objective, &x0, &bounds, 500, 1e-8)
513 .params
514 }
515 }
516
517 fn n_params(&self) -> usize {
522 self.p + self.q + usize::from(includes_constant(self.d, 0)) + 1
523 }
524
525 fn usable_rss(&self) -> Option<(f64, usize)> {
528 let warmup = self.warmup.min(self.residuals.len());
529 let usable = &self.residuals[warmup..];
530 if usable.is_empty() {
531 return None;
532 }
533 Some((usable.iter().map(|r| r * r).sum(), usable.len()))
534 }
535
536 #[must_use]
545 pub fn aic(&self) -> Option<f64> {
546 if !self.fitted {
547 return None;
548 }
549 let (rss, n) = self.usable_rss()?;
550 crate::forecast::diagnostics::aic(rss, n, self.n_params())
551 }
552
553 #[must_use]
555 pub fn bic(&self) -> Option<f64> {
556 if !self.fitted {
557 return None;
558 }
559 let (rss, n) = self.usable_rss()?;
560 crate::forecast::diagnostics::bic(rss, n, self.n_params())
561 }
562
563 #[must_use]
565 pub fn aicc(&self) -> Option<f64> {
566 if !self.fitted {
567 return None;
568 }
569 let (rss, n) = self.usable_rss()?;
570 crate::forecast::diagnostics::aicc(rss, n, self.n_params())
571 }
572
573 #[must_use]
578 pub fn diagnostics(
579 &self,
580 actual: &[f64],
581 predicted: &[f64],
582 ) -> Option<crate::forecast::diagnostics::ModelDiagnostics> {
583 if !self.fitted {
584 return None;
585 }
586 crate::forecast::diagnostics::compute_diagnostics(actual, predicted, self.n_params(), None)
587 }
588}
589
590impl ForecastModel for ArimaModel {
591 #[tracing::instrument(skip_all, level = "debug")]
592 fn fit(&mut self, timestamps: &[i64], values: &[f64]) -> Result<(), ForecastError> {
593 let _start = std::time::Instant::now();
594 validate_input(timestamps, values)?;
595 let min_required = self.p.max(self.q) + 2 * self.d + 10;
596 if values.len() < min_required {
597 return Err(ForecastError::InsufficientData {
598 min: min_required,
599 got: values.len(),
600 });
601 }
602
603 self.history = values.to_vec();
604 let diffed = difference(values, self.d);
605 if diffed.is_empty() {
606 return Err(ForecastError::InsufficientData {
607 min: min_required,
608 got: values.len(),
609 });
610 }
611 self.constant = if includes_constant(self.d, 0) {
612 diffed.iter().sum::<f64>() / diffed.len() as f64
613 } else {
614 0.0
615 };
616 let centered: Vec<f64> = diffed.iter().map(|v| v - self.constant).collect();
617
618 let (ar_coeffs, effective_ar_order) = estimate_ar_burg(¢ered, self.p)?;
619 let ma_coeffs = Self::estimate_ma(¢ered, &ar_coeffs, self.q);
620 let residuals = innovations(¢ered, &ar_coeffs, &ma_coeffs);
621
622 metrics::gauge!("chronix_forecast_arima_effective_order",
624 "requested" => format!("{}", self.p))
625 .set(effective_ar_order as f64);
626
627 self.warmup = conditioning_warmup(ar_coeffs.len(), ma_coeffs.len());
628 let sigma = residual_std(&residuals, self.warmup, self.n_params());
629
630 self.residuals = residuals;
631 self.interval_ns = median_interval(timestamps);
632 self.last_ts = timestamps[timestamps.len() - 1];
633
634 self.params = ModelParams::Arima {
635 p: self.p,
636 d: self.d,
637 q: self.q,
638 ar_coeffs,
639 ma_coeffs,
640 constant: self.constant,
641 residual_std: sigma,
642 effective_ar_order: (effective_ar_order < self.p).then_some(effective_ar_order),
643 };
644 self.fitted = true;
645 metrics::histogram!("chronix_forecast_fit_duration_seconds", "model_type" => "arima")
646 .record(_start.elapsed().as_secs_f64());
647 Ok(())
648 }
649
650 #[tracing::instrument(skip_all, level = "debug")]
651 fn predict(&self, horizon: usize) -> Result<ForecastResult, ForecastError> {
652 let _start = std::time::Instant::now();
653 if !self.fitted {
654 return Err(ForecastError::NotFitted);
655 }
656 let ModelParams::Arima {
657 ar_coeffs,
658 ma_coeffs,
659 constant,
660 residual_std: sigma,
661 ..
662 } = &self.params
663 else {
664 return Err(ForecastError::InvalidInput(
665 "unexpected model params variant".into(),
666 ));
667 };
668
669 let diffed = difference(&self.history, self.d);
670 let centered: Vec<f64> = diffed.iter().map(|v| v - constant).collect();
671 let diff_preds: Vec<f64> =
672 arma_forecast(¢ered, &self.residuals, ar_coeffs, ma_coeffs, horizon)
673 .into_iter()
674 .map(|v| v + constant)
675 .collect();
676
677 let mut anchors = Vec::with_capacity(self.d);
680 let mut level = self.history.clone();
681 for _ in 0..self.d {
682 anchors.push(
683 *level
684 .last()
685 .ok_or(ForecastError::InsufficientData { min: 1, got: 0 })?,
686 );
687 level = difference(&level, 1);
688 }
689 let mut values = diff_preds;
690 for anchor in anchors.into_iter().rev() {
691 values = undifference_lag(&values, &[anchor], 1);
692 }
693
694 let (lower, upper) =
695 prediction_intervals(&values, ar_coeffs, ma_coeffs, *sigma, horizon, self.d, 0, 0);
696
697 metrics::histogram!("chronix_forecast_predict_duration_seconds", "model_type" => "arima")
698 .record(_start.elapsed().as_secs_f64());
699 Ok(ForecastResult {
700 values,
701 timestamps: forecast_timestamps(self.last_ts, self.interval_ns, horizon),
702 confidence_lower: lower,
703 confidence_upper: upper,
704 confidence_level: 0.95,
705 })
706 }
707
708 fn update(&mut self, timestamp: i64, value: f64) -> Result<(), ForecastError> {
709 if !self.fitted {
710 return Err(ForecastError::NotFitted);
711 }
712 self.history.push(value);
713 self.last_ts = timestamp;
714
715 let ModelParams::Arima {
716 ar_coeffs,
717 ma_coeffs,
718 constant,
719 ..
720 } = &self.params
721 else {
722 return Err(ForecastError::InvalidInput(
723 "unexpected model params variant".into(),
724 ));
725 };
726 let diffed: Vec<f64> = difference(&self.history, self.d)
727 .into_iter()
728 .map(|v| v - constant)
729 .collect();
730 let Some(idx) = diffed.len().checked_sub(1) else {
731 return Ok(());
732 };
733 let mut pred = 0.0;
734 for (j, &c) in ar_coeffs.iter().enumerate() {
735 if idx > j {
736 pred += c * diffed[idx - j - 1];
737 }
738 }
739 for (j, &c) in ma_coeffs.iter().enumerate() {
740 if let Some(e) = idx.checked_sub(j + 1).and_then(|k| self.residuals.get(k)) {
741 pred += c * e;
742 }
743 }
744 self.residuals.push(diffed[idx] - pred);
745
746 let max_history = (self.p + self.d + self.q + 1).max(64) * 4;
755 if self.history.len() > max_history {
756 self.history.drain(..self.history.len() - max_history);
757 }
758 let want_residuals = self.history.len().saturating_sub(self.d);
759 if self.residuals.len() > want_residuals {
760 self.residuals
761 .drain(..self.residuals.len() - want_residuals);
762 }
763 Ok(())
764 }
765
766 fn model_type(&self) -> ModelType {
767 ModelType::Arima
768 }
769
770 fn params(&self) -> &ModelParams {
771 &self.params
772 }
773}
774
775#[derive(Debug, Clone, Copy, PartialEq, Eq)]
783pub struct SarimaConfig {
784 pub p: usize,
786 pub d: usize,
788 pub q: usize,
790 pub sp: usize,
792 pub sd: usize,
794 pub sq: usize,
796 pub m: usize,
798}
799
800#[derive(serde::Serialize, serde::Deserialize)]
810pub struct SarimaModel {
811 cfg_p: usize,
812 cfg_d: usize,
813 cfg_q: usize,
814 sp: usize,
815 sd: usize,
816 sq: usize,
817 m: usize,
818 params: ModelParams,
819 history: Vec<f64>,
821 residuals: Vec<f64>,
823 ar_expanded: Vec<f64>,
825 ma_expanded: Vec<f64>,
827 constant: f64,
829 warmup: usize,
830 last_ts: i64,
831 interval_ns: i64,
832 fitted: bool,
833}
834
835impl SarimaModel {
836 #[must_use]
838 pub fn from_config(cfg: SarimaConfig) -> Self {
839 Self::new(cfg.p, cfg.d, cfg.q, cfg.sp, cfg.sd, cfg.sq, cfg.m)
840 }
841
842 #[must_use]
848 pub fn new(p: usize, d: usize, q: usize, sp: usize, sd: usize, sq: usize, m: usize) -> Self {
849 let seasonal = m > 1;
850 let (sp, sd, sq) = if seasonal { (sp, sd, sq) } else { (0, 0, 0) };
851 Self {
852 cfg_p: p,
853 cfg_d: d,
854 cfg_q: q,
855 sp,
856 sd,
857 sq,
858 m,
859 params: ModelParams::Sarima {
860 p,
861 d,
862 q,
863 sp,
864 sd,
865 sq,
866 m,
867 ar_coeffs: Vec::new(),
868 ma_coeffs: Vec::new(),
869 sar_coeffs: Vec::new(),
870 sma_coeffs: Vec::new(),
871 constant: 0.0,
872 residual_std: 0.0,
873 },
874 history: Vec::new(),
875 residuals: Vec::new(),
876 ar_expanded: Vec::new(),
877 ma_expanded: Vec::new(),
878 constant: 0.0,
879 warmup: 0,
880 last_ts: 0,
881 interval_ns: 0,
882 fitted: false,
883 }
884 }
885
886 fn transform(&self, values: &[f64]) -> Vec<f64> {
889 difference(&seasonal_difference(values, self.m, self.sd), self.cfg_d)
890 }
891
892 fn centered(&self, values: &[f64]) -> Vec<f64> {
895 self.transform(values)
896 .into_iter()
897 .map(|v| v - self.constant)
898 .collect()
899 }
900
901 fn n_free(&self) -> usize {
903 self.cfg_p
904 + self.sp
905 + self.cfg_q
906 + self.sq
907 + usize::from(includes_constant(self.cfg_d, self.sd))
908 + 1
909 }
910
911 fn expand(&self, x: &[f64]) -> (Vec<f64>, Vec<f64>) {
914 let (p, sp, q) = (self.cfg_p, self.sp, self.cfg_q);
915 let ar = expand_ar(&x[..p], &x[p..p + sp], self.m);
916 let ma = expand_ma(&x[p + sp..p + sp + q], &x[p + sp + q..], self.m);
917 (ar, ma)
918 }
919}
920
921impl ForecastModel for SarimaModel {
922 #[tracing::instrument(skip_all, level = "debug")]
923 fn fit(&mut self, timestamps: &[i64], values: &[f64]) -> Result<(), ForecastError> {
924 let _start = std::time::Instant::now();
925 validate_input(timestamps, values)?;
926
927 let reach = (self.cfg_p + self.sp * self.m).max(self.cfg_q + self.sq * self.m);
931 let min_required = reach + 2 * self.cfg_d + 2 * self.sd * self.m + 10;
932 if values.len() < min_required {
933 return Err(ForecastError::InsufficientData {
934 min: min_required,
935 got: values.len(),
936 });
937 }
938
939 self.history = values.to_vec();
940 let differenced = self.transform(values);
941 if differenced.is_empty() {
942 return Err(ForecastError::InsufficientData {
943 min: min_required,
944 got: values.len(),
945 });
946 }
947 self.constant = if includes_constant(self.cfg_d, self.sd) {
948 differenced.iter().sum::<f64>() / differenced.len() as f64
949 } else {
950 0.0
951 };
952 let w: Vec<f64> = differenced.iter().map(|v| v - self.constant).collect();
953
954 let n_params = self.cfg_p + self.sp + self.cfg_q + self.sq;
955 let mut x0 = vec![0.0; n_params];
956 if self.cfg_p > 0 {
957 let (phi, _) = estimate_ar_burg(&w, self.cfg_p)?;
958 x0[..self.cfg_p].copy_from_slice(&phi);
959 }
960
961 let best = if n_params == 0 {
962 x0
963 } else {
964 let objective = |x: &[f64]| {
965 let (ar, ma) = self.expand(x);
966 css(&w, &ar, &ma)
967 };
968 if n_params == 1 {
969 let opt = crate::forecast::optimizer::minimize_brent(
970 |v| objective(&[v]),
971 -0.99,
972 0.99,
973 1e-8,
974 200,
975 );
976 vec![opt.params[0]]
977 } else {
978 let bounds: Vec<crate::forecast::optimizer::Bound> = (0..n_params)
979 .map(|_| crate::forecast::optimizer::Bound::new(-0.99, 0.99))
980 .collect();
981 crate::forecast::optimizer::minimize_nelder_mead(
982 objective, &x0, &bounds, 1000, 1e-9,
983 )
984 .params
985 }
986 };
987
988 let (ar_expanded, ma_expanded) = self.expand(&best);
989 let residuals = innovations(&w, &ar_expanded, &ma_expanded);
990 self.warmup = conditioning_warmup(ar_expanded.len(), ma_expanded.len());
991 let sigma = residual_std(&residuals, self.warmup, self.n_free());
992
993 let (p, sp, q) = (self.cfg_p, self.sp, self.cfg_q);
994 self.params = ModelParams::Sarima {
995 p,
996 d: self.cfg_d,
997 q,
998 sp,
999 sd: self.sd,
1000 sq: self.sq,
1001 m: self.m,
1002 ar_coeffs: best[..p].to_vec(),
1003 ma_coeffs: best[p + sp..p + sp + q].to_vec(),
1004 sar_coeffs: best[p..p + sp].to_vec(),
1005 sma_coeffs: best[p + sp + q..].to_vec(),
1006 constant: self.constant,
1007 residual_std: sigma,
1008 };
1009 self.ar_expanded = ar_expanded;
1010 self.ma_expanded = ma_expanded;
1011 self.residuals = residuals;
1012 self.interval_ns = median_interval(timestamps);
1013 self.last_ts = timestamps[timestamps.len() - 1];
1014 self.fitted = true;
1015
1016 metrics::histogram!("chronix_forecast_fit_duration_seconds", "model_type" => "sarima")
1017 .record(_start.elapsed().as_secs_f64());
1018 Ok(())
1019 }
1020
1021 #[tracing::instrument(skip_all, level = "debug")]
1022 fn predict(&self, horizon: usize) -> Result<ForecastResult, ForecastError> {
1023 let _start = std::time::Instant::now();
1024 if !self.fitted {
1025 return Err(ForecastError::NotFitted);
1026 }
1027 let ModelParams::Sarima {
1028 residual_std: sigma,
1029 ..
1030 } = &self.params
1031 else {
1032 return Err(ForecastError::InvalidInput(
1033 "unexpected model params variant".into(),
1034 ));
1035 };
1036
1037 let w = self.centered(&self.history);
1038 let mut values: Vec<f64> = arma_forecast(
1039 &w,
1040 &self.residuals,
1041 &self.ar_expanded,
1042 &self.ma_expanded,
1043 horizon,
1044 )
1045 .into_iter()
1046 .map(|v| v + self.constant)
1047 .collect();
1048
1049 let seasonal_base = seasonal_difference(&self.history, self.m, self.sd);
1053 let mut regular_levels = Vec::with_capacity(self.cfg_d);
1054 let mut level = seasonal_base.clone();
1055 for _ in 0..self.cfg_d {
1056 regular_levels.push(level.clone());
1057 level = difference(&level, 1);
1058 }
1059 for hist in regular_levels.into_iter().rev() {
1060 let anchor = *hist
1061 .last()
1062 .ok_or(ForecastError::InsufficientData { min: 1, got: 0 })?;
1063 values = undifference_lag(&values, &[anchor], 1);
1064 }
1065
1066 let mut seasonal_levels = Vec::with_capacity(self.sd);
1067 let mut level = self.history.clone();
1068 for _ in 0..self.sd {
1069 seasonal_levels.push(level.clone());
1070 level = seasonal_difference(&level, self.m, 1);
1071 }
1072 for hist in seasonal_levels.into_iter().rev() {
1073 if hist.len() < self.m {
1074 return Err(ForecastError::InsufficientData {
1075 min: self.m,
1076 got: hist.len(),
1077 });
1078 }
1079 let anchor = &hist[hist.len() - self.m..];
1080 values = undifference_lag(&values, anchor, self.m);
1081 }
1082
1083 let (lower, upper) = prediction_intervals(
1089 &values,
1090 &self.ar_expanded,
1091 &self.ma_expanded,
1092 *sigma,
1093 horizon,
1094 self.cfg_d,
1095 self.sd,
1096 self.m,
1097 );
1098
1099 metrics::histogram!("chronix_forecast_predict_duration_seconds", "model_type" => "sarima")
1100 .record(_start.elapsed().as_secs_f64());
1101 Ok(ForecastResult {
1102 values,
1103 timestamps: forecast_timestamps(self.last_ts, self.interval_ns, horizon),
1104 confidence_lower: lower,
1105 confidence_upper: upper,
1106 confidence_level: 0.95,
1107 })
1108 }
1109
1110 fn update(&mut self, timestamp: i64, value: f64) -> Result<(), ForecastError> {
1111 if !self.fitted {
1112 return Err(ForecastError::NotFitted);
1113 }
1114 self.history.push(value);
1115 self.last_ts = timestamp;
1116
1117 let w = self.centered(&self.history);
1118 let Some(idx) = w.len().checked_sub(1) else {
1119 return Ok(());
1120 };
1121 let mut pred = 0.0;
1122 for (j, &c) in self.ar_expanded.iter().enumerate() {
1123 if idx > j {
1124 pred += c * w[idx - j - 1];
1125 }
1126 }
1127 for (j, &c) in self.ma_expanded.iter().enumerate() {
1128 if let Some(e) = idx.checked_sub(j + 1).and_then(|k| self.residuals.get(k)) {
1129 pred += c * e;
1130 }
1131 }
1132 self.residuals.push(w[idx] - pred);
1133
1134 let reach = self.ar_expanded.len().max(self.ma_expanded.len());
1139 let max_history = (reach + self.cfg_d + self.sd * self.m + 1).max(64) * 4;
1140 if self.history.len() > max_history {
1141 self.history.drain(..self.history.len() - max_history);
1142 }
1143 let want_residuals = self.centered(&self.history).len();
1144 if self.residuals.len() > want_residuals {
1145 self.residuals
1146 .drain(..self.residuals.len() - want_residuals);
1147 }
1148 Ok(())
1149 }
1150
1151 fn model_type(&self) -> ModelType {
1152 ModelType::Sarima
1153 }
1154
1155 fn params(&self) -> &ModelParams {
1156 &self.params
1157 }
1158}
1159
1160pub const KPSS_CRITICAL_5PCT: f64 = 0.463;
1167
1168#[must_use]
1182pub fn kpss_statistic(values: &[f64]) -> f64 {
1183 let n = values.len();
1184 if n < 3 {
1185 return 0.0;
1186 }
1187 let n_f = n as f64;
1188 let mean = values.iter().sum::<f64>() / n_f;
1189
1190 let mut partial = 0.0;
1192 let mut sum_sq_partial = 0.0;
1193 let mut gamma0 = 0.0;
1194 let resid: Vec<f64> = values.iter().map(|v| v - mean).collect();
1195 for &e in &resid {
1196 partial += e;
1197 sum_sq_partial += partial * partial;
1198 gamma0 += e * e;
1199 }
1200 gamma0 /= n_f;
1201 if gamma0 <= 0.0 {
1202 return 0.0;
1203 }
1204
1205 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1207 let lag = (4.0 * (n_f / 100.0).powf(0.25)).floor() as usize;
1208 let mut long_run = gamma0;
1209 for j in 1..=lag.min(n - 1) {
1210 let gamma_j: f64 = resid[j..]
1211 .iter()
1212 .zip(&resid[..n - j])
1213 .map(|(a, b)| a * b)
1214 .sum::<f64>()
1215 / n_f;
1216 long_run += 2.0 * (1.0 - j as f64 / (lag + 1) as f64) * gamma_j;
1217 }
1218 if long_run <= 0.0 {
1219 return 0.0;
1220 }
1221
1222 sum_sq_partial / (n_f * n_f * long_run)
1223}
1224
1225#[must_use]
1237pub fn select_differencing_order(values: &[f64], max_d: usize) -> usize {
1238 let mut current = values.to_vec();
1239 let mut d = 0;
1240 while d < max_d {
1241 if current.len() < 4 || kpss_statistic(¤t) <= KPSS_CRITICAL_5PCT {
1242 break;
1243 }
1244 current = difference(¤t, 1);
1245 d += 1;
1246 }
1247 d
1248}
1249
1250pub const SEASONAL_STRENGTH_THRESHOLD: f64 = 0.64;
1259
1260#[must_use]
1284pub fn select_seasonal_differencing_order(values: &[f64], period: usize, max_d: usize) -> usize {
1285 if period < 2 || max_d == 0 {
1286 return 0;
1287 }
1288 if values.len() < 2 * period {
1291 return 0;
1292 }
1293 let first = values.first().copied().unwrap_or(0.0);
1296 if values.iter().all(|v| (v - first).abs() < f64::EPSILON) {
1297 return 0;
1298 }
1299
1300 let mut current = values.to_vec();
1301 let mut d = 0usize;
1302 while d < max_d {
1303 let Some(strength) = seasonal_strength(¤t, period) else {
1304 break;
1305 };
1306 if strength <= SEASONAL_STRENGTH_THRESHOLD {
1307 break;
1308 }
1309 current = seasonal_difference(¤t, period, 1);
1310 d += 1;
1311 if current.len() < 2 * period {
1312 break;
1313 }
1314 }
1315 d
1316}
1317
1318fn seasonal_strength(values: &[f64], period: usize) -> Option<f64> {
1321 let decomposition = crate::preprocess::decomposition::stl_decompose(
1322 values,
1323 &crate::preprocess::decomposition::StlConfig::new(period),
1324 )
1325 .ok()?;
1326 let remainder_var = variance(&decomposition.residual)?;
1327 let combined: Vec<f64> = decomposition
1328 .residual
1329 .iter()
1330 .zip(&decomposition.seasonal)
1331 .map(|(r, s)| r + s)
1332 .collect();
1333 let combined_var = variance(&combined)?;
1334 if combined_var <= 0.0 {
1335 return Some(0.0);
1336 }
1337 Some((1.0 - remainder_var / combined_var).clamp(0.0, 1.0))
1338}
1339
1340fn variance(values: &[f64]) -> Option<f64> {
1342 if values.len() < 2 {
1343 return None;
1344 }
1345 let n = values.len() as f64;
1346 let mean = values.iter().sum::<f64>() / n;
1347 Some(values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0))
1348}
1349
1350#[derive(Debug, Clone)]
1354pub struct AutoArimaResult {
1355 pub order: (usize, usize, usize),
1357 pub aic: f64,
1359 pub candidates: Vec<(usize, usize, usize, f64)>,
1361 pub evals: usize,
1363 pub kpss_order: usize,
1366}
1367
1368#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1370pub enum SearchStrategy {
1371 #[default]
1373 Exhaustive,
1374 Stepwise,
1377}
1378
1379#[derive(Debug, Clone, Copy, Default)]
1381pub struct AutoArimaOptions {
1382 pub max_evals: Option<usize>,
1384 pub strategy: SearchStrategy,
1386}
1387
1388pub fn auto_arima(
1418 timestamps: &[i64],
1419 values: &[f64],
1420 max_p: usize,
1421 max_d: usize,
1422 max_q: usize,
1423 options: Option<&AutoArimaOptions>,
1424) -> Result<AutoArimaResult, ForecastError> {
1425 validate_input(timestamps, values)?;
1426 if values.len() < 30 {
1427 return Err(ForecastError::InsufficientData {
1428 min: 30,
1429 got: values.len(),
1430 });
1431 }
1432
1433 let kpss_order = select_differencing_order(values, max_d.max(2));
1434 let d = kpss_order.min(max_d);
1435 let opts = options.copied().unwrap_or_default();
1436 let max_evals = opts.max_evals.unwrap_or(usize::MAX);
1437
1438 let score = |p: usize, q: usize| -> Option<f64> {
1439 let min_required = p.max(q) + 2 * d + 10;
1440 if values.len() < min_required {
1441 return None;
1442 }
1443 let mut model = ArimaModel::new(p, d, q);
1444 model.fit(timestamps, values).ok()?;
1445 model.aicc().filter(|a| a.is_finite())
1446 };
1447
1448 let (order, best, mut candidates, evals) = match opts.strategy {
1449 SearchStrategy::Exhaustive => {
1450 use rayon::prelude::*;
1451 let grid: Vec<(usize, usize)> = (0..=max_p)
1452 .flat_map(|p| (0..=max_q).map(move |q| (p, q)))
1453 .filter(|&(p, q)| p + q > 0)
1454 .take(max_evals)
1455 .collect();
1456 let found: Vec<(usize, usize, usize, f64)> = grid
1457 .par_iter()
1458 .filter_map(|&(p, q)| score(p, q).map(|a| (p, d, q, a)))
1459 .collect();
1460 let evals = found.len();
1461 let best = found
1462 .iter()
1463 .copied()
1464 .min_by(|a, b| a.3.total_cmp(&b.3))
1465 .ok_or_else(|| {
1466 ForecastError::NumericalInstability(
1467 "no ARIMA model in the grid could be fitted".into(),
1468 )
1469 })?;
1470 ((best.0, best.1, best.2), best.3, found, evals)
1471 }
1472 SearchStrategy::Stepwise => stepwise(max_p, max_q, d, max_evals, &score)?,
1473 };
1474
1475 candidates.sort_by(|a, b| a.3.total_cmp(&b.3));
1476 Ok(AutoArimaResult {
1477 order,
1478 aic: best,
1479 candidates,
1480 evals,
1481 kpss_order,
1482 })
1483}
1484
1485#[allow(clippy::type_complexity)]
1487fn stepwise(
1488 max_p: usize,
1489 max_q: usize,
1490 d: usize,
1491 max_evals: usize,
1492 score: &dyn Fn(usize, usize) -> Option<f64>,
1493) -> Result<
1494 (
1495 (usize, usize, usize),
1496 f64,
1497 Vec<(usize, usize, usize, f64)>,
1498 usize,
1499 ),
1500 ForecastError,
1501> {
1502 let mut candidates: Vec<(usize, usize, usize, f64)> = Vec::new();
1503 let mut visited: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
1504 let mut evals = 0usize;
1505
1506 let mut best_score = f64::INFINITY;
1507 let mut best = (0usize, 0usize);
1508
1509 let mut try_model = |p: usize,
1510 q: usize,
1511 candidates: &mut Vec<(usize, usize, usize, f64)>,
1512 evals: &mut usize|
1513 -> Option<f64> {
1514 if p > max_p || q > max_q || (p == 0 && q == 0) {
1515 return None;
1516 }
1517 if !visited.insert((p, q)) {
1518 return candidates
1519 .iter()
1520 .find(|c| c.0 == p && c.2 == q)
1521 .map(|c| c.3);
1522 }
1523 if *evals >= max_evals {
1524 return None;
1525 }
1526 let s = score(p, q)?;
1527 *evals += 1;
1528 candidates.push((p, d, q, s));
1529 Some(s)
1530 };
1531
1532 for &(p, q) in &[(2, 2), (0, 0), (1, 0), (0, 1)] {
1533 let (p, q) = (p.min(max_p), q.min(max_q));
1534 if let Some(s) = try_model(p, q, &mut candidates, &mut evals) {
1535 if s < best_score {
1536 best_score = s;
1537 best = (p, q);
1538 }
1539 }
1540 }
1541 if best_score.is_infinite() {
1542 return Err(ForecastError::NumericalInstability(
1543 "no ARIMA model in the stepwise seed set could be fitted".into(),
1544 ));
1545 }
1546
1547 const DELTAS: [(i8, i8); 8] = [
1548 (1, 0),
1549 (-1, 0),
1550 (0, 1),
1551 (0, -1),
1552 (1, -1),
1553 (-1, 1),
1554 (1, 1),
1555 (-1, -1),
1556 ];
1557 loop {
1558 let (cp, cq) = best;
1559 let mut improved = false;
1560 for (dp, dq) in DELTAS {
1561 let (Ok(np), Ok(nq)) = (
1562 usize::try_from(cp as i64 + i64::from(dp)),
1563 usize::try_from(cq as i64 + i64::from(dq)),
1564 ) else {
1565 continue;
1566 };
1567 if let Some(s) = try_model(np, nq, &mut candidates, &mut evals) {
1568 if s < best_score {
1569 best_score = s;
1570 best = (np, nq);
1571 improved = true;
1572 }
1573 }
1574 }
1575 if !improved || evals >= max_evals {
1576 break;
1577 }
1578 }
1579
1580 Ok(((best.0, d, best.1), best_score, candidates, evals))
1581}
1582
1583impl crate::forecast::storage::ModelStore for ArimaModel {}
1584impl crate::forecast::storage::ModelStore for SarimaModel {}
1585
1586#[cfg(test)]
1587#[allow(clippy::unwrap_used, clippy::expect_used)]
1588mod tests {
1589 use super::*;
1590
1591 fn noise(seed: u64, n: usize, amp: f64) -> Vec<f64> {
1594 let mut s = seed;
1595 (0..n)
1596 .map(|_| {
1597 s = s
1598 .wrapping_mul(6_364_136_223_846_793_005)
1599 .wrapping_add(1_442_695_040_888_963_407);
1600 ((s >> 33) as f64 / (1u64 << 31) as f64 - 0.5) * 2.0 * amp
1601 })
1602 .collect()
1603 }
1604
1605 fn ts(n: usize) -> Vec<i64> {
1606 (0..n as i64).map(|i| i * 1_000_000_000).collect()
1607 }
1608
1609 #[test]
1612 fn difference_and_undifference_round_trip() {
1613 let series: Vec<f64> = (0..40).map(|i| (i as f64).powi(2) + 3.0).collect();
1614 for d in 1..=3 {
1615 let diffed = difference(&series, d);
1616 let split = 30;
1618 let mut preds = diffed[diffed.len() - (40 - split)..].to_vec();
1619 let mut anchors = Vec::new();
1620 let mut level = series[..split].to_vec();
1621 for _ in 0..d {
1622 anchors.push(*level.last().unwrap());
1623 level = difference(&level, 1);
1624 }
1625 for a in anchors.into_iter().rev() {
1626 preds = undifference_lag(&preds, &[a], 1);
1627 }
1628 for (i, v) in preds.iter().enumerate() {
1629 assert!(
1630 (v - series[split + i]).abs() < 1e-6,
1631 "d={d} i={i}: {v} vs {}",
1632 series[split + i]
1633 );
1634 }
1635 }
1636 }
1637
1638 #[test]
1639 fn seasonal_difference_and_undifference_round_trip() {
1640 let m = 4;
1641 let series: Vec<f64> = (0..40)
1642 .map(|i| (i % m) as f64 * 2.0 + i as f64 * 0.1)
1643 .collect();
1644 let diffed = seasonal_difference(&series, m, 1);
1645 let split = 30;
1646 let preds = diffed[diffed.len() - (40 - split)..].to_vec();
1647 let anchor = &series[split - m..split];
1648 let restored = undifference_lag(&preds, anchor, m);
1649 for (i, v) in restored.iter().enumerate() {
1650 assert!((v - series[split + i]).abs() < 1e-9, "i={i}: {v}");
1651 }
1652 }
1653
1654 #[test]
1655 fn expanded_polynomials_match_the_multiplicative_form() {
1656 let ar = expand_ar(&[0.5], &[0.3], 4);
1659 let expected = [0.5, 0.0, 0.0, 0.3, -0.15];
1660 assert_eq!(ar.len(), expected.len());
1661 for (a, b) in ar.iter().zip(expected) {
1662 assert!((a - b).abs() < 1e-12, "{ar:?}");
1663 }
1664 let ma = expand_ma(&[0.4], &[0.2], 3);
1666 let expected = [0.4, 0.0, 0.2, 0.08];
1667 for (a, b) in ma.iter().zip(expected) {
1668 assert!((a - b).abs() < 1e-12, "{ma:?}");
1669 }
1670 }
1671
1672 #[test]
1673 fn expanded_polynomials_degenerate_without_seasonal_terms() {
1674 assert_eq!(expand_ar(&[0.5, -0.2], &[], 12), vec![0.5, -0.2]);
1675 assert_eq!(expand_ma(&[0.4], &[], 12), vec![0.4]);
1676 }
1677
1678 #[test]
1679 fn psi_weights_degenerate_to_sqrt_h() {
1680 let psi = psi_weights(&[], &[], 5);
1681 assert_eq!(psi.len(), 5);
1682 assert!((psi[0] - 1.0).abs() < 1e-12);
1683 for (j, w) in psi.iter().enumerate().skip(1) {
1684 assert!(w.abs() < 1e-12, "ψ_{j} should be 0 for pure noise");
1685 }
1686
1687 let psi_ar = psi_weights(&[0.5], &[], 4);
1688 for (j, expected) in [1.0, 0.5, 0.25, 0.125].into_iter().enumerate() {
1689 assert!((psi_ar[j] - expected).abs() < 1e-12);
1690 }
1691
1692 let psi_ma = psi_weights(&[], &[0.8], 4);
1693 assert!((psi_ma[0] - 1.0).abs() < 1e-12);
1694 assert!((psi_ma[1] - 0.8).abs() < 1e-12);
1695 assert!(psi_ma[2].abs() < 1e-12);
1696 assert!(psi_ma[3].abs() < 1e-12);
1697 }
1698
1699 #[test]
1700 fn burg_recovers_a_known_ar2_and_stays_stable() {
1701 let n = 1000;
1702 let eps = noise(12345, n, 0.25);
1703 let mut vals = vec![0.0; n];
1704 for t in 2..n {
1705 vals[t] = 0.75 * vals[t - 1] - 0.5 * vals[t - 2] + eps[t];
1706 }
1707 let (coeffs, _) = estimate_ar_burg(&vals[200..], 2).unwrap();
1708 assert!((coeffs[0] - 0.75).abs() < 0.15, "{coeffs:?}");
1709 assert!((coeffs[1] + 0.5).abs() < 0.15, "{coeffs:?}");
1710 let (b, c) = (-coeffs[0], -coeffs[1]);
1712 let disc = b * b - 4.0 * c;
1713 if disc >= 0.0 {
1714 let r1 = (-b + disc.sqrt()) / 2.0;
1715 let r2 = (-b - disc.sqrt()) / 2.0;
1716 assert!(
1717 r1.abs() < 1.0 && r2.abs() < 1.0,
1718 "unstable roots {r1}, {r2}"
1719 );
1720 }
1721 }
1722
1723 #[test]
1729 fn random_walk_interval_variance_grows_exactly_linearly() {
1730 let e = noise(7, 200, 1.0);
1731 let mut vals = vec![100.0];
1732 for v in e.iter().take(199) {
1733 vals.push(vals.last().unwrap() + v);
1734 }
1735 let mut model = ArimaModel::new(0, 1, 0);
1736 model.fit(&ts(200), &vals).unwrap();
1737 let r = model.predict(12).unwrap();
1738 let half = |h: usize| r.confidence_upper[h] - r.values[h];
1739 assert!(half(0) > 0.0);
1740 for h in 1..=12 {
1741 let ratio = (half(h - 1) / half(0)).powi(2);
1742 assert!(
1743 (ratio - h as f64).abs() < 1e-9,
1744 "var({h})/var(1) = {ratio}, want {h}"
1745 );
1746 }
1747 }
1748
1749 #[test]
1752 fn arima_110_interval_matches_the_psi_closed_form() {
1753 let e = noise(11, 240, 1.0);
1754 let mut vals = vec![50.0];
1755 let mut prev_d = 0.0;
1756 for v in e.iter().take(239) {
1757 let d = 0.6 * prev_d + v;
1758 vals.push(vals.last().unwrap() + d);
1759 prev_d = d;
1760 }
1761 let mut model = ArimaModel::new(1, 1, 0);
1762 model.fit(&ts(240), &vals).unwrap();
1763 let ModelParams::Arima {
1764 ar_coeffs,
1765 residual_std,
1766 ..
1767 } = model.params()
1768 else {
1769 panic!()
1770 };
1771 let phi = ar_coeffs[0];
1772 assert!(phi.abs() > 0.1, "need a non-trivial φ, got {phi}");
1773 let r = model.predict(10).unwrap();
1774 for h in 1..=10 {
1775 let factor: f64 = (0..h)
1776 .map(|j| {
1777 let psi = (1.0 - phi.powi(j as i32 + 1)) / (1.0 - phi);
1778 psi * psi
1779 })
1780 .sum::<f64>();
1781 let expected = 1.959_963_984_540_054 * residual_std * factor.sqrt();
1782 let half = r.confidence_upper[h - 1] - r.values[h - 1];
1783 assert!(
1784 (half - expected).abs() < 1e-9,
1785 "h={h}: half-width {half} != {expected}"
1786 );
1787 }
1788 }
1789
1790 #[test]
1792 fn integrated_ar_expands_the_differencing_operator() {
1793 assert_eq!(integrated_ar(&[], 1, 0, 0), vec![1.0]);
1795 let d2 = integrated_ar(&[], 2, 0, 0);
1797 assert!((d2[0] - 2.0).abs() < 1e-12 && (d2[1] + 1.0).abs() < 1e-12);
1798 let m = integrated_ar(&[0.5], 1, 0, 0);
1800 assert!((m[0] - 1.5).abs() < 1e-12 && (m[1] + 0.5).abs() < 1e-12);
1801 let sd = integrated_ar(&[], 0, 1, 4);
1803 assert_eq!(sd.len(), 4);
1804 assert!((sd[3] - 1.0).abs() < 1e-12);
1805 assert!(sd[..3].iter().all(|c| c.abs() < 1e-12));
1806 assert_eq!(integrated_ar(&[0.3, -0.2], 0, 0, 12), vec![0.3, -0.2]);
1808 }
1809
1810 #[test]
1815 fn arma_forecast_reads_the_newest_innovations() {
1816 let values = [0.0, 0.0, 0.0];
1817 let residuals = [1.0, 2.0, 3.0, 4.0, 5.0];
1818 let out = arma_forecast(&values, &residuals, &[], &[0.5], 1);
1819 assert!((out[0] - 2.5).abs() < 1e-12, "got {}", out[0]);
1820 }
1821
1822 #[test]
1826 fn update_keeps_history_and_residuals_aligned() {
1827 let vals: Vec<f64> = (0..100)
1828 .map(|i| 10.0 + i as f64 * 0.3 + ((i * 17) % 7) as f64 * 0.4)
1829 .collect();
1830 let mut model = ArimaModel::new(1, 1, 1);
1831 model.fit(&ts(100), &vals).unwrap();
1832 for i in 0..300 {
1833 let v = 40.0 + ((i * 13) % 11) as f64 * 0.5;
1834 model.update((100 + i) as i64 * 1_000_000_000, v).unwrap();
1835 }
1836 assert!(model.history.len() < 300, "history was never trimmed");
1837 assert_eq!(
1838 model.residuals.len(),
1839 model.history.len() - model.d,
1840 "residuals must stay aligned with difference(history, d)"
1841 );
1842
1843 let ModelParams::Arima {
1847 ar_coeffs,
1848 ma_coeffs,
1849 constant,
1850 ..
1851 } = model.params().clone()
1852 else {
1853 panic!()
1854 };
1855 let diffed: Vec<f64> = difference(&model.history, 1)
1856 .into_iter()
1857 .map(|v| v - constant)
1858 .collect();
1859 let n = diffed.len();
1860 let expected_diff = constant
1861 + ar_coeffs[0] * diffed[n - 1]
1862 + ma_coeffs[0] * model.residuals[model.residuals.len() - 1];
1863 let expected = model.history[model.history.len() - 1] + expected_diff;
1864 let got = model.predict(1).unwrap().values[0];
1865 assert!((got - expected).abs() < 1e-9, "{got} != {expected}");
1866 }
1867
1868 #[test]
1870 fn sarima_update_keeps_history_and_residuals_aligned() {
1871 let vals = {
1872 (0..120)
1873 .map(|i| 50.0 + 5.0 * (std::f64::consts::TAU * (i % 12) as f64 / 12.0).sin())
1874 .collect::<Vec<f64>>()
1875 };
1876 let mut model = SarimaModel::new(1, 1, 1, 0, 1, 0, 12);
1877 model.fit(&ts(120), &vals).unwrap();
1878 for i in 0..400 {
1879 let v = 50.0 + 5.0 * (std::f64::consts::TAU * (i % 12) as f64 / 12.0).sin();
1880 model.update((120 + i) as i64 * 1_000_000_000, v).unwrap();
1881 }
1882 assert!(model.history.len() < 400, "history was never trimmed");
1883 assert_eq!(model.residuals.len(), model.centered(&model.history).len());
1884 }
1885
1886 #[test]
1887 fn arima_random_walk() {
1888 let e = noise(42, 200, 1.0);
1889 let mut vals = vec![100.0];
1890 for v in e.iter().take(199) {
1891 vals.push(vals.last().unwrap() + v);
1892 }
1893 let mut model = ArimaModel::new(1, 1, 0);
1894 model.fit(&ts(200), &vals).unwrap();
1895 let result = model.predict(10).unwrap();
1896 assert_eq!(result.values.len(), 10);
1897 assert!((result.values[0] - vals[199]).abs() < 10.0);
1898 }
1899
1900 #[test]
1901 fn arima_linear_trend() {
1902 let vals: Vec<f64> = (0..100).map(|i| f64::from(i) * 2.0 + 10.0).collect();
1903 let mut model = ArimaModel::new(0, 1, 0);
1904 model.fit(&ts(100), &vals).unwrap();
1905 let result = model.predict(5).unwrap();
1906 for (i, v) in result.values.iter().enumerate() {
1909 let expected = 208.0 + (i + 1) as f64 * 2.0;
1910 assert!((v - expected).abs() < 1e-6, "h={i}: {v} vs {expected}");
1911 }
1912 }
1913
1914 #[test]
1919 fn arima_d2_extrapolates_a_line_exactly() {
1920 let n = 60usize;
1921 let vals: Vec<f64> = (0..n).map(|i| 3.0 * i as f64 - 7.0).collect();
1922 let mut model = ArimaModel::new(0, 2, 0);
1923 model.fit(&ts(n), &vals).unwrap();
1924 let result = model.predict(5).unwrap();
1925 for (h, v) in result.values.iter().enumerate() {
1926 let expected = 3.0 * (n + h) as f64 - 7.0;
1927 assert!((v - expected).abs() < 1e-6, "h={h}: {v} vs {expected}");
1928 }
1929 }
1930
1931 #[test]
1932 fn arima_d2_undifference_correctness() {
1933 let n = 100usize;
1934 let vals: Vec<f64> = (0..n).map(|i| (i as f64).powi(2)).collect();
1935 let mut model = ArimaModel::new(0, 2, 0);
1936 model.fit(&ts(n), &vals).unwrap();
1937 let result = model.predict(5).unwrap();
1938 let slope = vals[n - 1] - vals[n - 2];
1943 for (h, v) in result.values.iter().enumerate() {
1944 let expected = vals[n - 1] + (h + 1) as f64 * slope;
1945 assert!((v - expected).abs() < 1e-6, "h={h}: {v} vs {expected}");
1946 }
1947 }
1948
1949 #[test]
1950 fn arima_confidence_intervals_bracket_the_forecast() {
1951 let vals: Vec<f64> = (0..100)
1952 .map(|i| (f64::from(i)).sin() * 5.0 + 50.0)
1953 .collect();
1954 let mut model = ArimaModel::new(2, 0, 1);
1955 model.fit(&ts(100), &vals).unwrap();
1956 let result = model.predict(10).unwrap();
1957 for i in 0..10 {
1958 assert!(result.confidence_lower[i] <= result.values[i]);
1959 assert!(result.confidence_upper[i] >= result.values[i]);
1960 }
1961 }
1962
1963 #[test]
1968 fn arima_intervals_track_the_noise_not_the_level() {
1969 let n = 200;
1970 let e = noise(7, n, 0.1);
1971 let mut vals = Vec::with_capacity(n);
1972 let mut x = 100.0_f64;
1973 for v in &e {
1974 x = 100.0 + 0.5 * (x - 100.0) + v;
1975 vals.push(x);
1976 }
1977 let mut model = ArimaModel::new(1, 0, 0);
1978 model.fit(&ts(n), &vals).unwrap();
1979 let r = model.predict(3).unwrap();
1980 let half_width = r.confidence_upper[0] - r.values[0];
1981 assert!(
1982 half_width < 0.5,
1983 "one-step 95% half-width {half_width} exceeds the noise scale (0.1)"
1984 );
1985 assert!(
1986 half_width > 0.05,
1987 "interval collapsed to nothing: {half_width}"
1988 );
1989 }
1990
1991 #[test]
1992 fn arima_insufficient_data() {
1993 let mut model = ArimaModel::new(2, 1, 1);
1994 assert!(model.fit(&[0, 1, 2], &[1.0, 2.0, 3.0]).is_err());
1995 }
1996
1997 #[test]
1998 fn arima_update() {
1999 let vals: Vec<f64> = (0..50).map(f64::from).collect();
2000 let mut model = ArimaModel::new(1, 1, 0);
2001 model.fit(&ts(50), &vals).unwrap();
2002 assert!(model.update(50_000_000_000, 50.0).is_ok());
2003 assert!(model.predict(3).unwrap().values.len() == 3);
2004 }
2005
2006 #[test]
2007 fn css_recovers_a_known_arma11() {
2008 let n = 1000;
2009 let eps = noise(99999, n, 0.2);
2010 let mut vals = vec![0.0; n];
2011 for t in 1..n {
2012 vals[t] = 0.6 * vals[t - 1] + eps[t] + 0.4 * eps[t - 1];
2013 }
2014 let vals = &vals[200..];
2015 let mut model = ArimaModel::new(1, 0, 1);
2016 model.fit(&ts(vals.len()), vals).unwrap();
2017 let ModelParams::Arima {
2018 ma_coeffs,
2019 ar_coeffs,
2020 ..
2021 } = model.params()
2022 else {
2023 panic!("wrong variant");
2024 };
2025 assert!((ma_coeffs[0] - 0.4).abs() < 0.3, "θ = {}", ma_coeffs[0]);
2026 assert!((ar_coeffs[0] - 0.6).abs() < 0.2, "φ = {}", ar_coeffs[0]);
2027 }
2028
2029 #[test]
2032 fn kpss_accepts_stationary_and_rejects_a_random_walk() {
2033 let white = noise(3, 300, 1.0);
2034 assert!(
2035 kpss_statistic(&white) < KPSS_CRITICAL_5PCT,
2036 "white noise rejected: {}",
2037 kpss_statistic(&white)
2038 );
2039
2040 let mut walk = vec![0.0];
2041 for v in white.iter().take(299) {
2042 walk.push(walk.last().unwrap() + v);
2043 }
2044 assert!(
2045 kpss_statistic(&walk) > KPSS_CRITICAL_5PCT,
2046 "random walk accepted: {}",
2047 kpss_statistic(&walk)
2048 );
2049 assert_eq!(select_differencing_order(&white, 2), 0);
2050 assert_eq!(select_differencing_order(&walk, 2), 1);
2051 }
2052
2053 #[test]
2054 fn kpss_is_scale_invariant() {
2055 let v = noise(11, 200, 1.0);
2056 let scaled: Vec<f64> = v.iter().map(|x| x * 1000.0 + 5.0).collect();
2057 let a = kpss_statistic(&v);
2058 let b = kpss_statistic(&scaled);
2059 assert!((a - b).abs() < 1e-9, "{a} vs {b}");
2060 }
2061
2062 #[test]
2063 fn kpss_handles_degenerate_input() {
2064 assert_eq!(kpss_statistic(&[]), 0.0);
2065 assert_eq!(kpss_statistic(&[1.0, 1.0]), 0.0);
2066 assert_eq!(kpss_statistic(&[7.0; 50]), 0.0);
2067 assert_eq!(select_differencing_order(&[7.0; 50], 2), 0);
2068 }
2069
2070 #[test]
2079 fn strong_seasonality_takes_a_seasonal_difference() {
2080 let m = 24usize;
2081 let values: Vec<f64> = (0..(m * 14))
2082 .map(|i| {
2083 let phase = (i % m) as f64 / m as f64 * std::f64::consts::TAU;
2084 100.0 + 20.0 * phase.sin() + ((i * 37) % 11) as f64 * 0.05
2085 })
2086 .collect();
2087 assert_eq!(select_seasonal_differencing_order(&values, m, 1), 1);
2088 }
2089
2090 #[test]
2098 fn weak_seasonality_takes_no_seasonal_difference() {
2099 let m = 24usize;
2100 let values: Vec<f64> = (0..(m * 14))
2101 .map(|i| {
2102 let phase = (i % m) as f64 / m as f64 * std::f64::consts::TAU;
2103 let noise = (((i * 2_654_435_761usize) % 1000) as f64 / 1000.0 - 0.5) * 40.0;
2104 100.0 + 0.5 * phase.sin() + noise
2105 })
2106 .collect();
2107 assert_eq!(select_seasonal_differencing_order(&values, m, 1), 0);
2108 }
2109
2110 #[test]
2111 fn pure_noise_takes_no_seasonal_difference() {
2112 let m = 24usize;
2113 let values: Vec<f64> = (0..(m * 14))
2114 .map(|i| 100.0 + (((i * 2_654_435_761usize) % 1000) as f64 / 1000.0 - 0.5) * 40.0)
2115 .collect();
2116 assert_eq!(select_seasonal_differencing_order(&values, m, 0), 0);
2117 assert_eq!(select_seasonal_differencing_order(&values, m, 1), 0);
2118 }
2119
2120 fn min_ma_root_modulus(theta: &[f64]) -> f64 {
2135 let q = theta.len();
2136 if q == 0 {
2137 return f64::INFINITY;
2138 }
2139 let mut c: Vec<(f64, f64)> = std::iter::once(1.0)
2140 .chain(theta.iter().copied())
2141 .map(|v| (v, 0.0))
2142 .collect();
2143 let lead = c[q].0;
2144 if lead.abs() < 1e-12 {
2145 return min_ma_root_modulus(&theta[..q - 1]);
2146 }
2147 for v in &mut c {
2148 v.0 /= lead;
2149 }
2150 let mul = |a: (f64, f64), b: (f64, f64)| (a.0 * b.0 - a.1 * b.1, a.0 * b.1 + a.1 * b.0);
2151 let sub = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0, a.1 - b.1);
2152 let div = |a: (f64, f64), b: (f64, f64)| {
2153 let d = b.0 * b.0 + b.1 * b.1;
2154 ((a.0 * b.0 + a.1 * b.1) / d, (a.1 * b.0 - a.0 * b.1) / d)
2155 };
2156 let eval = |z: (f64, f64)| {
2157 let mut acc = (0.0, 0.0);
2158 for k in (0..=q).rev() {
2159 acc = mul(acc, z);
2160 acc = (acc.0 + c[k].0, acc.1 + c[k].1);
2161 }
2162 acc
2163 };
2164 let mut roots: Vec<(f64, f64)> = (0..q)
2165 .map(|k| {
2166 let a = 0.4 + 0.9 * k as f64;
2167 (a.cos() * 0.9, a.sin() * 0.9)
2168 })
2169 .collect();
2170 for _ in 0..500 {
2171 let mut moved = 0.0f64;
2172 for i in 0..q {
2173 let mut denom = (1.0, 0.0);
2174 for j in 0..q {
2175 if i != j {
2176 denom = mul(denom, sub(roots[i], roots[j]));
2177 }
2178 }
2179 if denom.0.abs() + denom.1.abs() < 1e-300 {
2180 continue;
2181 }
2182 let delta = div(eval(roots[i]), denom);
2183 roots[i] = sub(roots[i], delta);
2184 moved = moved.max(delta.0.abs() + delta.1.abs());
2185 }
2186 if moved < 1e-14 {
2187 break;
2188 }
2189 }
2190 roots
2191 .iter()
2192 .map(|r| (r.0 * r.0 + r.1 * r.1).sqrt())
2193 .fold(f64::INFINITY, f64::min)
2194 }
2195
2196 #[test]
2197 fn the_root_finder_agrees_with_the_quadratic_formula() {
2198 let m = min_ma_root_modulus(&[0.99, -0.99]);
2202 assert!((m - 0.6225).abs() < 1e-3, "min |root| was {m}");
2203 assert!(min_ma_root_modulus(&[0.5]) > 1.0);
2205 }
2206
2207 #[test]
2208 fn a_fitted_ma_polynomial_is_invertible() {
2209 let mut state = 7u64;
2218 let mut next = move || {
2219 state = state
2220 .wrapping_mul(6_364_136_223_846_793_005)
2221 .wrapping_add(1);
2222 ((state >> 33) as f64 / f64::from(u32::MAX >> 1)) - 1.0
2223 };
2224
2225 let mut checked = 0usize;
2226 let mut worst = f64::INFINITY;
2227 for q in 2..=3usize {
2228 for trial in 0..40 {
2229 let n = 160usize;
2230 let mut v = Vec::with_capacity(n);
2231 let (mut prev0, mut prev1, mut walk) = (0.0f64, 0.0f64, 0.0f64);
2232 for i in 0..n {
2233 let e = next();
2234 v.push(match trial % 4 {
2235 0 => e,
2236 1 => e + 0.95 * prev0 + 0.9 * prev1,
2237 2 => {
2238 walk += e;
2239 walk
2240 }
2241 _ => i as f64 * 0.05 + e,
2242 });
2243 prev1 = prev0;
2244 prev0 = e;
2245 }
2246 let ts: Vec<i64> = (0..n as i64).collect();
2247 let mut model = ArimaModel::new(1, 0, q);
2248 if model.fit(&ts, &v).is_err() {
2249 continue;
2250 }
2251 let ModelParams::Arima { ma_coeffs, .. } = model.params() else {
2252 continue;
2253 };
2254 let r = min_ma_root_modulus(ma_coeffs);
2255 worst = worst.min(r);
2256 assert!(
2257 r > 1.0,
2258 "q={q} trial={trial}: fitted θ={ma_coeffs:?} is non-invertible \
2259 (min |root| = {r})"
2260 );
2261 checked += 1;
2262 }
2263 }
2264 assert!(checked >= 60, "only {checked} fits were checked");
2265 let raw: Vec<f64> = (0..121).map(|_| next()).collect();
2269 let over: Vec<f64> = raw.windows(2).map(|w| w[1] - w[0]).collect();
2270 let ts: Vec<i64> = (0..over.len() as i64).collect();
2271 let mut model = ArimaModel::new(1, 0, 2);
2272 if model.fit(&ts, &over).is_ok() {
2273 if let ModelParams::Arima { ma_coeffs, .. } = model.params() {
2274 let r = min_ma_root_modulus(ma_coeffs);
2275 assert!(r > 1.0, "over-differenced fit is non-invertible: {r}");
2276 }
2277 }
2278 }
2279
2280 #[test]
2281 fn the_strength_measure_is_blind_to_a_stochastic_seasonal_level() {
2282 let m = 24usize;
2283 let mut values = vec![0.0f64; 12 * m];
2284 let mut season_level = vec![0.0f64; m];
2285 let mut rng_state = 42u64;
2286 let mut next = || {
2287 rng_state = rng_state
2288 .wrapping_mul(6_364_136_223_846_793_005)
2289 .wrapping_add(1);
2290 ((rng_state >> 33) as f64 / f64::from(u32::MAX >> 1)) - 1.0
2291 };
2292 for cycle in 0..12 {
2293 for season in 0..m {
2294 season_level[season] += next() * 3.0;
2295 values[cycle * m + season] = season_level[season] + next() * 0.1;
2296 }
2297 }
2298 assert_eq!(
2299 select_seasonal_differencing_order(&values, m, 1),
2300 0,
2301 "if this starts returning 1 the measure has changed — check which \
2302 test is being used before assuming it is an improvement",
2303 );
2304 }
2305
2306 #[test]
2307 fn a_constant_series_is_never_seasonally_differenced() {
2308 let values = vec![7.0f64; 200];
2312 assert_eq!(select_seasonal_differencing_order(&values, 24, 1), 0);
2313 }
2314
2315 #[test]
2316 fn a_series_shorter_than_two_periods_is_never_seasonally_differenced() {
2317 let values: Vec<f64> = (0..30).map(|i| f64::from(i % 24)).collect();
2318 assert_eq!(
2319 select_seasonal_differencing_order(&values, 24, 1),
2320 0,
2321 "STL needs two full periods; there is nothing to measure",
2322 );
2323 }
2324
2325 #[test]
2326 fn a_period_below_two_is_not_seasonal() {
2327 let values: Vec<f64> = (0..100).map(f64::from).collect();
2328 assert_eq!(select_seasonal_differencing_order(&values, 1, 1), 0);
2329 assert_eq!(select_seasonal_differencing_order(&values, 0, 1), 0);
2330 }
2331
2332 #[test]
2333 fn select_differencing_order_respects_the_cap() {
2334 let mut quad: Vec<f64> = (0..200).map(|i| (f64::from(i)).powi(2)).collect();
2335 quad.iter_mut()
2336 .zip(noise(5, 200, 1.0))
2337 .for_each(|(v, e)| *v += e);
2338 assert_eq!(select_differencing_order(&quad, 1), 1);
2339 assert!(select_differencing_order(&quad, 3) >= 2);
2340 }
2341
2342 #[test]
2345 fn auto_arima_differences_a_trend() {
2346 let vals: Vec<f64> = (0..100).map(|i| f64::from(i) * 2.0 + 10.0).collect();
2347 let result = auto_arima(&ts(100), &vals, 2, 2, 2, None).unwrap();
2348 assert!(result.order.1 >= 1, "d should be >= 1 for trending data");
2349 assert!(!result.candidates.is_empty());
2350 assert!(result.aic.is_finite());
2351 assert!(result.evals > 0);
2352 }
2353
2354 #[test]
2358 fn auto_arima_does_not_overdifference_stationary_noise() {
2359 let vals: Vec<f64> = noise(2024, 200, 0.5).iter().map(|v| v + 10.0).collect();
2360 let result = auto_arima(&ts(200), &vals, 2, 2, 2, None).unwrap();
2361 assert_eq!(result.order.1, 0, "stationary series was differenced");
2362 assert_eq!(result.kpss_order, 0);
2363 }
2364
2365 #[test]
2366 fn auto_arima_reports_when_max_d_bound_the_answer() {
2367 let vals: Vec<f64> = (0..200)
2368 .map(|i| (f64::from(i)).powi(2))
2369 .zip(noise(6, 200, 1.0))
2370 .map(|(a, b)| a + b)
2371 .collect();
2372 let result = auto_arima(&ts(200), &vals, 1, 1, 1, None).unwrap();
2373 assert_eq!(result.order.1, 1, "capped at max_d");
2374 assert!(
2375 result.kpss_order >= 2,
2376 "kpss wanted more: {}",
2377 result.kpss_order
2378 );
2379 }
2380
2381 #[test]
2382 fn auto_arima_returns_sorted_candidates_at_one_differencing_order() {
2383 let vals: Vec<f64> = (0..80)
2384 .map(|i| (f64::from(i) * 0.1).sin() * 5.0 + 50.0)
2385 .collect();
2386 let result = auto_arima(&ts(80), &vals, 2, 1, 2, None).unwrap();
2387 for w in result.candidates.windows(2) {
2388 assert!(w[0].3 <= w[1].3, "candidates not sorted");
2389 }
2390 assert!(
2391 result.candidates.iter().all(|c| c.1 == result.order.1),
2392 "candidates must share one differencing order to be comparable"
2393 );
2394 }
2395
2396 #[test]
2397 fn auto_arima_insufficient_data() {
2398 let vals: Vec<f64> = (0..10).map(f64::from).collect();
2399 assert!(auto_arima(&ts(10), &vals, 3, 2, 3, None).is_err());
2400 }
2401
2402 #[test]
2403 fn auto_arima_max_evals_budget() {
2404 let vals: Vec<f64> = (0..100).map(|i| f64::from(i) * 2.0 + 10.0).collect();
2405 let opts = AutoArimaOptions {
2406 max_evals: Some(3),
2407 ..Default::default()
2408 };
2409 let result = auto_arima(&ts(100), &vals, 3, 2, 3, Some(&opts)).unwrap();
2410 assert!(result.evals <= 3, "got {} evals", result.evals);
2411 assert!(result.aic.is_finite());
2412 }
2413
2414 #[test]
2415 fn auto_arima_stepwise_agrees_with_exhaustive_on_a_small_grid() {
2416 let vals: Vec<f64> = (0..100).map(|i| f64::from(i) * 2.0 + 10.0).collect();
2417 let exhaustive = auto_arima(&ts(100), &vals, 2, 1, 2, None).unwrap();
2418 let opts = AutoArimaOptions {
2419 strategy: SearchStrategy::Stepwise,
2420 ..Default::default()
2421 };
2422 let stepwise = auto_arima(&ts(100), &vals, 2, 1, 2, Some(&opts)).unwrap();
2423 let denom = exhaustive.aic.abs().max(1e-6);
2424 assert!(
2425 (stepwise.aic - exhaustive.aic).abs() / denom < 0.10,
2426 "stepwise {} vs exhaustive {}",
2427 stepwise.aic,
2428 exhaustive.aic
2429 );
2430 }
2431
2432 #[test]
2433 fn auto_arima_stepwise_uses_fewer_fits() {
2434 let vals: Vec<f64> = (0..150)
2437 .map(|i| f64::from(i) * 2.0 + 10.0)
2438 .zip(noise(31, 150, 1.0))
2439 .map(|(a, b)| a + b)
2440 .collect();
2441 let exhaustive = auto_arima(&ts(150), &vals, 5, 2, 5, None).unwrap();
2442 assert!(
2443 exhaustive.evals > 10,
2444 "grid collapsed: {}",
2445 exhaustive.evals
2446 );
2447 let opts = AutoArimaOptions {
2448 strategy: SearchStrategy::Stepwise,
2449 ..Default::default()
2450 };
2451 let stepwise = auto_arima(&ts(150), &vals, 5, 2, 5, Some(&opts)).unwrap();
2452 assert!(
2453 stepwise.evals < exhaustive.evals,
2454 "stepwise {} vs exhaustive {}",
2455 stepwise.evals,
2456 exhaustive.evals
2457 );
2458 }
2459
2460 fn seasonal(n: usize, m: usize, amp: f64, level: f64) -> Vec<f64> {
2463 (0..n)
2464 .map(|i| level + amp * (2.0 * std::f64::consts::PI * (i % m) as f64 / m as f64).sin())
2465 .collect()
2466 }
2467
2468 #[test]
2469 fn sarima_seasonal_orders_change_the_forecast() {
2470 let vals = seasonal(240, 12, 5.0, 50.0);
2471 let mut plain = SarimaModel::new(1, 0, 0, 0, 0, 0, 12);
2472 plain.fit(&ts(240), &vals).unwrap();
2473 let mut seas = SarimaModel::new(1, 0, 0, 1, 0, 1, 12);
2474 seas.fit(&ts(240), &vals).unwrap();
2475 let a = plain.predict(12).unwrap();
2476 let b = seas.predict(12).unwrap();
2477 assert_ne!(
2478 a.values, b.values,
2479 "seasonal orders must reach the forecast recursion"
2480 );
2481 }
2482
2483 #[test]
2487 fn sarima_forecasts_the_season() {
2488 let m = 12;
2489 let n = 240;
2490 let vals = seasonal(n, m, 5.0, 50.0);
2491 let truth = seasonal(n + m, m, 5.0, 50.0);
2492 let expected = &truth[n..];
2493
2494 let mut seas = SarimaModel::new(0, 0, 0, 1, 0, 1, m);
2495 seas.fit(&ts(n), &vals).unwrap();
2496 let got = seas.predict(m).unwrap();
2497
2498 let mut plain = ArimaModel::new(1, 0, 0);
2499 plain.fit(&ts(n), &vals).unwrap();
2500 let baseline = plain.predict(m).unwrap();
2501
2502 let err = |p: &[f64]| -> f64 {
2503 p.iter()
2504 .zip(expected)
2505 .map(|(a, b)| (a - b).abs())
2506 .sum::<f64>()
2507 / m as f64
2508 };
2509 let seasonal_err = err(&got.values);
2510 let baseline_err = err(&baseline.values);
2511 assert!(
2512 seasonal_err < baseline_err * 0.5,
2513 "SARIMA MAE {seasonal_err:.4} should beat ARIMA MAE {baseline_err:.4}"
2514 );
2515 }
2516
2517 #[test]
2518 fn sarima_seasonal_differencing_round_trips_the_level() {
2519 let m = 12;
2522 let n = 240;
2523 let vals: Vec<f64> = (0..n)
2524 .map(|i| 50.0 + 5.0 * ((i % m) as f64) + 0.25 * i as f64)
2525 .collect();
2526 let mut model = SarimaModel::new(0, 0, 0, 0, 1, 0, m);
2527 model.fit(&ts(n), &vals).unwrap();
2528 let r = model.predict(m).unwrap();
2529 for (h, v) in r.values.iter().enumerate() {
2530 let expected = 50.0 + 5.0 * ((n + h) % m) as f64 + 0.25 * (n + h) as f64;
2531 assert!((v - expected).abs() < 1.0, "h={h}: {v} vs {expected}");
2532 }
2533 }
2534
2535 #[test]
2536 fn sarima_ignores_seasonal_orders_when_the_period_is_degenerate() {
2537 let vals: Vec<f64> = noise(8, 120, 1.0).iter().map(|v| v + 5.0).collect();
2538 let mut model = SarimaModel::new(1, 0, 0, 2, 1, 2, 1);
2539 model.fit(&ts(120), &vals).unwrap();
2540 let ModelParams::Sarima { sp, sd, sq, .. } = model.params() else {
2541 panic!("wrong variant");
2542 };
2543 assert_eq!((*sp, *sd, *sq), (0, 0, 0));
2544 }
2545
2546 #[test]
2547 fn sarima_insufficient_data() {
2548 let vals = seasonal(20, 12, 5.0, 50.0);
2549 let mut model = SarimaModel::new(1, 0, 0, 1, 1, 0, 12);
2550 assert!(model.fit(&ts(20), &vals).is_err());
2551 }
2552
2553 #[test]
2554 fn sarima_update_advances_the_forecast() {
2555 let vals = seasonal(240, 12, 5.0, 50.0);
2556 let mut model = SarimaModel::new(1, 0, 0, 1, 0, 0, 12);
2557 model.fit(&ts(240), &vals).unwrap();
2558 let before = model.predict(1).unwrap();
2559 model.update(240_000_000_000, 60.0).unwrap();
2560 let after = model.predict(1).unwrap();
2561 assert_ne!(before.values[0], after.values[0]);
2562 assert!(after.timestamps[0] > before.timestamps[0]);
2563 }
2564
2565 #[test]
2566 fn sarima_intervals_widen_with_horizon() {
2567 let vals = seasonal(240, 12, 5.0, 50.0);
2568 let mut model = SarimaModel::new(1, 0, 1, 1, 0, 1, 12);
2569 model.fit(&ts(240), &vals).unwrap();
2570 let r = model.predict(24).unwrap();
2571 let width = |h: usize| r.confidence_upper[h] - r.confidence_lower[h];
2572 for h in 1..24 {
2575 assert!(
2576 width(h) > width(h - 1),
2577 "width must grow strictly: h={h} {} vs {}",
2578 width(h),
2579 width(h - 1)
2580 );
2581 }
2582 let ModelParams::Sarima { residual_std, .. } = model.params() else {
2583 panic!()
2584 };
2585 let phi_star = integrated_ar(&model.ar_expanded, 0, 0, 12);
2586 let psi = psi_weights(&phi_star, &model.ma_expanded, 24);
2587 for h in 1..=24 {
2588 let scale: f64 = psi[..h].iter().map(|w| w * w).sum::<f64>().sqrt();
2589 let expected = 2.0 * 1.959_963_984_540_054 * residual_std * scale;
2590 assert!(
2591 (width(h - 1) - expected).abs() < 1e-9,
2592 "h={h}: width {} != {expected}",
2593 width(h - 1)
2594 );
2595 }
2596 for h in 0..24 {
2597 assert!(r.confidence_lower[h] <= r.values[h]);
2598 assert!(r.confidence_upper[h] >= r.values[h]);
2599 }
2600 }
2601}