1#![forbid(unsafe_code)]
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum ForecastMethod {
16 MovingAverage,
18 ExponentialSmoothing,
20 LinearTrend,
22}
23
24impl ForecastMethod {
25 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ForecastResult {
41 pub forecast: Vec<f64>,
43 pub method: ForecastMethod,
45 pub ci_lower: Vec<f64>,
47 pub ci_upper: Vec<f64>,
49 pub mae: f64,
51 pub rmse: f64,
53 pub n_points: usize,
55}
56
57impl ForecastResult {
58 #[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
73pub struct Forecaster {
77 alpha: f64,
79 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 #[must_use]
104 pub const fn new(alpha: f64, window: usize) -> Self {
105 Self { alpha, window }
106 }
107
108 #[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 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 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 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 let last_ma = *mas.last().unwrap_or(&data[data.len() - 1]);
172 let forecast = vec![last_ma; horizon];
173
174 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 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 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 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 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#[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 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 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 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 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 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 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 assert!((result.forecast[0] - 0.0).abs() < 0.001);
338 }
339}