#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ForecastMethod {
MovingAverage,
ExponentialSmoothing,
LinearTrend,
}
impl ForecastMethod {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::MovingAverage => "moving_average",
Self::ExponentialSmoothing => "exponential_smoothing",
Self::LinearTrend => "linear_trend",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForecastResult {
pub forecast: Vec<f64>,
pub method: ForecastMethod,
pub ci_lower: Vec<f64>,
pub ci_upper: Vec<f64>,
pub mae: f64,
pub rmse: f64,
pub n_points: usize,
}
impl ForecastResult {
#[must_use]
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"forecast": self.forecast,
"method": self.method.as_str(),
"ci_lower": self.ci_lower,
"ci_upper": self.ci_upper,
"mae": self.mae,
"rmse": self.rmse,
"n_points": self.n_points,
})
}
}
pub struct Forecaster {
alpha: f64,
window: usize,
}
impl Default for Forecaster {
fn default() -> Self {
Self {
alpha: 0.3,
window: 5,
}
}
}
impl std::fmt::Debug for Forecaster {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Forecaster")
.field("alpha", &self.alpha)
.field("window", &self.window)
.finish()
}
}
impl Forecaster {
#[must_use]
pub const fn new(alpha: f64, window: usize) -> Self {
Self { alpha, window }
}
#[must_use]
pub fn forecast(&self, data: &[f64], horizon: usize, method: ForecastMethod) -> ForecastResult {
if data.is_empty() {
return ForecastResult {
forecast: vec![0.0; horizon],
method,
ci_lower: vec![0.0; horizon],
ci_upper: vec![0.0; horizon],
mae: 0.0,
rmse: 0.0,
n_points: 0,
};
}
let (forecast, fit_errors) = match method {
ForecastMethod::MovingAverage => self.moving_average_forecast(data, horizon),
ForecastMethod::ExponentialSmoothing => self.exp_smoothing_forecast(data, horizon),
ForecastMethod::LinearTrend => self.linear_trend_forecast(data, horizon),
};
let mae = if fit_errors.is_empty() {
0.0
} else {
fit_errors.iter().sum::<f64>().abs() / fit_errors.len() as f64
};
let rmse = if fit_errors.is_empty() {
0.0
} else {
(fit_errors.iter().map(|e| e * e).sum::<f64>() / fit_errors.len() as f64).sqrt()
};
let ci_width = 1.96 * rmse;
let ci_lower: Vec<f64> = forecast.iter().map(|f| f - ci_width).collect();
let ci_upper: Vec<f64> = forecast.iter().map(|f| f + ci_width).collect();
ForecastResult {
forecast,
method,
ci_lower,
ci_upper,
mae,
rmse,
n_points: data.len(),
}
}
fn moving_average_forecast(&self, data: &[f64], horizon: usize) -> (Vec<f64>, Vec<f64>) {
let window = self.window.min(data.len());
if window == 0 {
return (vec![data[0]; horizon], Vec::new());
}
let mut mas = Vec::new();
for i in window..=data.len() {
let ma = data[i - window..i].iter().sum::<f64>() / window as f64;
mas.push(ma);
}
let last_ma = *mas.last().unwrap_or(&data[data.len() - 1]);
let forecast = vec![last_ma; horizon];
let errors: Vec<f64> = mas
.iter()
.zip(data[window..].iter())
.map(|(ma, actual)| actual - ma)
.collect();
(forecast, errors)
}
fn exp_smoothing_forecast(&self, data: &[f64], horizon: usize) -> (Vec<f64>, Vec<f64>) {
let mut level = data[0];
let mut fitted = vec![level];
for &val in &data[1..] {
let prev_level = level;
level = self.alpha.mul_add(val, (1.0 - self.alpha) * level);
fitted.push(prev_level);
}
let forecast = vec![level; horizon];
let errors: Vec<f64> = data.iter().zip(fitted.iter()).map(|(a, f)| a - f).collect();
(forecast, errors)
}
fn linear_trend_forecast(&self, data: &[f64], horizon: usize) -> (Vec<f64>, Vec<f64>) {
let n = data.len() as f64;
let sum_x: f64 = (0..data.len()).map(|i| i as f64).sum();
let sum_y: f64 = data.iter().sum();
let sum_xy: f64 = (0..data.len()).map(|i| i as f64 * data[i]).sum();
let sum_x2: f64 = (0..data.len()).map(|i| (i as f64).powi(2)).sum();
let slope = n.mul_add(sum_xy, -(sum_x * sum_y)) / n.mul_add(sum_x2, -(sum_x * sum_x));
let intercept = slope.mul_add(-sum_x, sum_y) / n;
let fitted: Vec<f64> = (0..data.len())
.map(|i| slope.mul_add(i as f64, intercept))
.collect();
let errors: Vec<f64> = data.iter().zip(fitted.iter()).map(|(a, f)| a - f).collect();
let forecast: Vec<f64> = (0..horizon)
.map(|i| slope.mul_add((data.len() + i) as f64, intercept))
.collect();
(forecast, errors)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn forecast_method_as_str() {
assert_eq!(ForecastMethod::MovingAverage.as_str(), "moving_average");
assert_eq!(
ForecastMethod::ExponentialSmoothing.as_str(),
"exponential_smoothing"
);
assert_eq!(ForecastMethod::LinearTrend.as_str(), "linear_trend");
}
#[test]
fn moving_average_forecast() {
let forecaster = Forecaster::new(0.3, 3);
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let result = forecaster.forecast(&data, 3, ForecastMethod::MovingAverage);
assert_eq!(result.forecast.len(), 3);
assert!((result.forecast[0] - 4.0).abs() < 0.001);
}
#[test]
fn exp_smoothing_forecast() {
let forecaster = Forecaster::new(0.5, 3);
let data = vec![10.0, 12.0, 14.0, 16.0];
let result = forecaster.forecast(&data, 2, ForecastMethod::ExponentialSmoothing);
assert_eq!(result.forecast.len(), 2);
assert!(result.forecast[0] > 10.0 && result.forecast[0] < 16.0);
}
#[test]
fn linear_trend_forecast() {
let forecaster = Forecaster::default();
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let result = forecaster.forecast(&data, 3, ForecastMethod::LinearTrend);
assert_eq!(result.forecast.len(), 3);
assert!((result.forecast[0] - 6.0).abs() < 0.001);
assert!((result.forecast[1] - 7.0).abs() < 0.001);
assert!((result.forecast[2] - 8.0).abs() < 0.001);
}
#[test]
fn forecast_empty_data() {
let forecaster = Forecaster::default();
let result = forecaster.forecast(&[], 3, ForecastMethod::MovingAverage);
assert_eq!(result.n_points, 0);
assert_eq!(result.forecast.len(), 3);
}
#[test]
fn forecast_ci_bands() {
let forecaster = Forecaster::default();
let data = vec![1.0, 3.0, 2.0, 4.0, 3.0, 5.0];
let result = forecaster.forecast(&data, 3, ForecastMethod::MovingAverage);
assert_eq!(result.ci_lower.len(), 3);
assert_eq!(result.ci_upper.len(), 3);
for i in 0..3 {
assert!(result.ci_lower[i] <= result.forecast[i]);
assert!(result.ci_upper[i] >= result.forecast[i]);
}
}
#[test]
fn forecast_rmse_computed() {
let forecaster = Forecaster::default();
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let result = forecaster.forecast(&data, 2, ForecastMethod::LinearTrend);
assert!(result.rmse < 0.001);
}
#[test]
fn forecast_mae_computed() {
let forecaster = Forecaster::default();
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let result = forecaster.forecast(&data, 2, ForecastMethod::LinearTrend);
assert!(result.mae < 0.001);
}
#[test]
fn forecast_result_to_json() {
let result = ForecastResult {
forecast: vec![5.0, 6.0],
method: ForecastMethod::MovingAverage,
ci_lower: vec![3.0, 4.0],
ci_upper: vec![7.0, 8.0],
mae: 0.5,
rmse: 0.7,
n_points: 10,
};
let json = result.to_json();
assert_eq!(json["method"], "moving_average");
assert_eq!(json["n_points"], 10);
}
#[test]
fn linear_trend_negative_slope() {
let forecaster = Forecaster::default();
let data = vec![5.0, 4.0, 3.0, 2.0, 1.0];
let result = forecaster.forecast(&data, 3, ForecastMethod::LinearTrend);
assert!((result.forecast[0] - 0.0).abs() < 0.001);
}
}