Skip to main content

wm_simulation/
forecasting.rs

1//! Time series forecasting with confidence intervals.
2//!
3//! Supports multiple methods: moving average, exponential smoothing,
4//! and linear trend projection.
5
6#![forbid(unsafe_code)]
7
8use serde::{Deserialize, Serialize};
9
10// ── Forecast Method ───────────────────────────────────────────────────
11
12/// Forecasting method.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum ForecastMethod {
16    /// Simple moving average.
17    MovingAverage,
18    /// Exponential smoothing (single).
19    ExponentialSmoothing,
20    /// Linear trend projection.
21    LinearTrend,
22}
23
24impl ForecastMethod {
25    /// Human-readable name.
26    #[must_use]
27    pub const fn as_str(self) -> &'static str {
28        match self {
29            Self::MovingAverage => "moving_average",
30            Self::ExponentialSmoothing => "exponential_smoothing",
31            Self::LinearTrend => "linear_trend",
32        }
33    }
34}
35
36// ── Forecast Result ───────────────────────────────────────────────────
37
38/// Result of a forecast.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ForecastResult {
41    /// Forecasted values.
42    pub forecast: Vec<f64>,
43    /// Method used.
44    pub method: ForecastMethod,
45    /// 95% CI lower bounds.
46    pub ci_lower: Vec<f64>,
47    /// 95% CI upper bounds.
48    pub ci_upper: Vec<f64>,
49    /// Mean absolute error of the fit.
50    pub mae: f64,
51    /// Root mean squared error of the fit.
52    pub rmse: f64,
53    /// Number of data points used.
54    pub n_points: usize,
55}
56
57impl ForecastResult {
58    /// Convert to JSON.
59    #[must_use]
60    pub fn to_json(&self) -> serde_json::Value {
61        serde_json::json!({
62            "forecast": self.forecast,
63            "method": self.method.as_str(),
64            "ci_lower": self.ci_lower,
65            "ci_upper": self.ci_upper,
66            "mae": self.mae,
67            "rmse": self.rmse,
68            "n_points": self.n_points,
69        })
70    }
71}
72
73// ── Forecaster ────────────────────────────────────────────────────────
74
75/// Time series forecaster.
76pub struct Forecaster {
77    /// Smoothing parameter for exponential smoothing.
78    alpha: f64,
79    /// Window size for moving average.
80    window: usize,
81}
82
83impl Default for Forecaster {
84    fn default() -> Self {
85        Self {
86            alpha: 0.3,
87            window: 5,
88        }
89    }
90}
91
92impl std::fmt::Debug for Forecaster {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.debug_struct("Forecaster")
95            .field("alpha", &self.alpha)
96            .field("window", &self.window)
97            .finish()
98    }
99}
100
101impl Forecaster {
102    /// Create a new forecaster.
103    #[must_use]
104    pub const fn new(alpha: f64, window: usize) -> Self {
105        Self { alpha, window }
106    }
107
108    /// Forecast using the specified method.
109    #[must_use]
110    pub fn forecast(&self, data: &[f64], horizon: usize, method: ForecastMethod) -> ForecastResult {
111        if data.is_empty() {
112            return ForecastResult {
113                forecast: vec![0.0; horizon],
114                method,
115                ci_lower: vec![0.0; horizon],
116                ci_upper: vec![0.0; horizon],
117                mae: 0.0,
118                rmse: 0.0,
119                n_points: 0,
120            };
121        }
122
123        let (forecast, fit_errors) = match method {
124            ForecastMethod::MovingAverage => self.moving_average_forecast(data, horizon),
125            ForecastMethod::ExponentialSmoothing => self.exp_smoothing_forecast(data, horizon),
126            ForecastMethod::LinearTrend => self.linear_trend_forecast(data, horizon),
127        };
128
129        let mae = if fit_errors.is_empty() {
130            0.0
131        } else {
132            fit_errors.iter().sum::<f64>().abs() / fit_errors.len() as f64
133        };
134        let rmse = if fit_errors.is_empty() {
135            0.0
136        } else {
137            (fit_errors.iter().map(|e| e * e).sum::<f64>() / fit_errors.len() as f64).sqrt()
138        };
139
140        // CI based on RMSE
141        let ci_width = 1.96 * rmse;
142        let ci_lower: Vec<f64> = forecast.iter().map(|f| f - ci_width).collect();
143        let ci_upper: Vec<f64> = forecast.iter().map(|f| f + ci_width).collect();
144
145        ForecastResult {
146            forecast,
147            method,
148            ci_lower,
149            ci_upper,
150            mae,
151            rmse,
152            n_points: data.len(),
153        }
154    }
155
156    /// Moving average forecast.
157    fn moving_average_forecast(&self, data: &[f64], horizon: usize) -> (Vec<f64>, Vec<f64>) {
158        let window = self.window.min(data.len());
159        if window == 0 {
160            return (vec![data[0]; horizon], Vec::new());
161        }
162
163        // Compute moving averages
164        let mut mas = Vec::new();
165        for i in window..=data.len() {
166            let ma = data[i - window..i].iter().sum::<f64>() / window as f64;
167            mas.push(ma);
168        }
169
170        // Forecast = last moving average
171        let last_ma = *mas.last().unwrap_or(&data[data.len() - 1]);
172        let forecast = vec![last_ma; horizon];
173
174        // Fit errors (actual - MA)
175        let errors: Vec<f64> = mas
176            .iter()
177            .zip(data[window..].iter())
178            .map(|(ma, actual)| actual - ma)
179            .collect();
180
181        (forecast, errors)
182    }
183
184    /// Exponential smoothing forecast.
185    fn exp_smoothing_forecast(&self, data: &[f64], horizon: usize) -> (Vec<f64>, Vec<f64>) {
186        let mut level = data[0];
187        let mut fitted = vec![level];
188
189        for &val in &data[1..] {
190            let prev_level = level;
191            level = self.alpha.mul_add(val, (1.0 - self.alpha) * level);
192            fitted.push(prev_level);
193        }
194
195        let forecast = vec![level; horizon];
196        let errors: Vec<f64> = data.iter().zip(fitted.iter()).map(|(a, f)| a - f).collect();
197
198        (forecast, errors)
199    }
200
201    /// Linear trend forecast.
202    fn linear_trend_forecast(&self, data: &[f64], horizon: usize) -> (Vec<f64>, Vec<f64>) {
203        let n = data.len() as f64;
204        let sum_x: f64 = (0..data.len()).map(|i| i as f64).sum();
205        let sum_y: f64 = data.iter().sum();
206        let sum_xy: f64 = (0..data.len()).map(|i| i as f64 * data[i]).sum();
207        let sum_x2: f64 = (0..data.len()).map(|i| (i as f64).powi(2)).sum();
208
209        let slope = n.mul_add(sum_xy, -(sum_x * sum_y)) / n.mul_add(sum_x2, -(sum_x * sum_x));
210        let intercept = slope.mul_add(-sum_x, sum_y) / n;
211
212        // Fitted values
213        let fitted: Vec<f64> = (0..data.len())
214            .map(|i| slope.mul_add(i as f64, intercept))
215            .collect();
216        let errors: Vec<f64> = data.iter().zip(fitted.iter()).map(|(a, f)| a - f).collect();
217
218        // Forecast
219        let forecast: Vec<f64> = (0..horizon)
220            .map(|i| slope.mul_add((data.len() + i) as f64, intercept))
221            .collect();
222
223        (forecast, errors)
224    }
225}
226
227// ── Tests ─────────────────────────────────────────────────────────────
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn forecast_method_as_str() {
235        assert_eq!(ForecastMethod::MovingAverage.as_str(), "moving_average");
236        assert_eq!(
237            ForecastMethod::ExponentialSmoothing.as_str(),
238            "exponential_smoothing"
239        );
240        assert_eq!(ForecastMethod::LinearTrend.as_str(), "linear_trend");
241    }
242
243    #[test]
244    fn moving_average_forecast() {
245        let forecaster = Forecaster::new(0.3, 3);
246        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
247        let result = forecaster.forecast(&data, 3, ForecastMethod::MovingAverage);
248        assert_eq!(result.forecast.len(), 3);
249        // Last MA of [3,4,5] = 4
250        assert!((result.forecast[0] - 4.0).abs() < 0.001);
251    }
252
253    #[test]
254    fn exp_smoothing_forecast() {
255        let forecaster = Forecaster::new(0.5, 3);
256        let data = vec![10.0, 12.0, 14.0, 16.0];
257        let result = forecaster.forecast(&data, 2, ForecastMethod::ExponentialSmoothing);
258        assert_eq!(result.forecast.len(), 2);
259        // Should be between 10 and 16
260        assert!(result.forecast[0] > 10.0 && result.forecast[0] < 16.0);
261    }
262
263    #[test]
264    fn linear_trend_forecast() {
265        let forecaster = Forecaster::default();
266        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
267        let result = forecaster.forecast(&data, 3, ForecastMethod::LinearTrend);
268        assert_eq!(result.forecast.len(), 3);
269        // Linear trend: y = 1 + 1*x, so forecast[0] = 1 + 5 = 6
270        assert!((result.forecast[0] - 6.0).abs() < 0.001);
271        assert!((result.forecast[1] - 7.0).abs() < 0.001);
272        assert!((result.forecast[2] - 8.0).abs() < 0.001);
273    }
274
275    #[test]
276    fn forecast_empty_data() {
277        let forecaster = Forecaster::default();
278        let result = forecaster.forecast(&[], 3, ForecastMethod::MovingAverage);
279        assert_eq!(result.n_points, 0);
280        assert_eq!(result.forecast.len(), 3);
281    }
282
283    #[test]
284    fn forecast_ci_bands() {
285        let forecaster = Forecaster::default();
286        let data = vec![1.0, 3.0, 2.0, 4.0, 3.0, 5.0];
287        let result = forecaster.forecast(&data, 3, ForecastMethod::MovingAverage);
288        assert_eq!(result.ci_lower.len(), 3);
289        assert_eq!(result.ci_upper.len(), 3);
290        // CI lower < forecast < CI upper
291        for i in 0..3 {
292            assert!(result.ci_lower[i] <= result.forecast[i]);
293            assert!(result.ci_upper[i] >= result.forecast[i]);
294        }
295    }
296
297    #[test]
298    fn forecast_rmse_computed() {
299        let forecaster = Forecaster::default();
300        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
301        let result = forecaster.forecast(&data, 2, ForecastMethod::LinearTrend);
302        // Perfect linear fit → RMSE ≈ 0
303        assert!(result.rmse < 0.001);
304    }
305
306    #[test]
307    fn forecast_mae_computed() {
308        let forecaster = Forecaster::default();
309        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
310        let result = forecaster.forecast(&data, 2, ForecastMethod::LinearTrend);
311        // Perfect linear fit → MAE ≈ 0
312        assert!(result.mae < 0.001);
313    }
314
315    #[test]
316    fn forecast_result_to_json() {
317        let result = ForecastResult {
318            forecast: vec![5.0, 6.0],
319            method: ForecastMethod::MovingAverage,
320            ci_lower: vec![3.0, 4.0],
321            ci_upper: vec![7.0, 8.0],
322            mae: 0.5,
323            rmse: 0.7,
324            n_points: 10,
325        };
326        let json = result.to_json();
327        assert_eq!(json["method"], "moving_average");
328        assert_eq!(json["n_points"], 10);
329    }
330
331    #[test]
332    fn linear_trend_negative_slope() {
333        let forecaster = Forecaster::default();
334        let data = vec![5.0, 4.0, 3.0, 2.0, 1.0];
335        let result = forecaster.forecast(&data, 3, ForecastMethod::LinearTrend);
336        // y = 6 - 1*x, forecast[0] = 6 - 5 = 0
337        assert!((result.forecast[0] - 0.0).abs() < 0.001);
338    }
339}