use anyhow::{anyhow, Result};
use scirs2_core::ndarray_ext::{s, Array1, Array2, ArrayView1};
use scirs2_core::random::Random;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
use tracing::info;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ForecastAlgorithm {
Arima,
ExponentialSmoothing,
SeasonalDecomposition,
Lstm,
MovingAverage,
WeightedMovingAverage,
ExponentialMovingAverage,
HoltWinters,
AutoRegressive,
MovingAverageModel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TrendDirection {
Increasing,
Decreasing,
Stable,
Oscillating,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SeasonalityType {
Additive,
Multiplicative,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForecastingConfig {
pub algorithm: ForecastAlgorithm,
pub horizon: usize,
pub confidence_level: f64,
pub window_size: usize,
pub min_data_points: usize,
pub auto_retrain: bool,
pub retrain_threshold: f64,
pub seasonal_period: Option<usize>,
pub enable_trend_detection: bool,
pub enable_seasonality_detection: bool,
pub max_ar_terms: usize,
pub max_ma_terms: usize,
pub differencing_order: usize,
}
impl Default for ForecastingConfig {
fn default() -> Self {
Self {
algorithm: ForecastAlgorithm::AutoRegressive,
horizon: 10,
confidence_level: 0.95,
window_size: 100,
min_data_points: 20,
auto_retrain: true,
retrain_threshold: 0.15, seasonal_period: None,
enable_trend_detection: true,
enable_seasonality_detection: true,
max_ar_terms: 5,
max_ma_terms: 5,
differencing_order: 1,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForecastResult {
pub predictions: Vec<f64>,
pub lower_bound: Vec<f64>,
pub upper_bound: Vec<f64>,
pub steps_ahead: Vec<usize>,
pub confidence_level: f64,
pub trend: TrendDirection,
pub seasonality: SeasonalityType,
pub accuracy_metrics: AccuracyMetrics,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccuracyMetrics {
pub mae: f64,
pub mse: f64,
pub rmse: f64,
pub mape: f64,
pub r_squared: f64,
pub bias: f64,
}
impl Default for AccuracyMetrics {
fn default() -> Self {
Self {
mae: 0.0,
mse: 0.0,
rmse: 0.0,
mape: 0.0,
r_squared: 0.0,
bias: 0.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PredictiveStats {
pub total_forecasts: u64,
pub retraining_count: u64,
pub avg_accuracy: f64,
pub model_params_count: usize,
pub training_data_size: usize,
pub last_forecast_time: Option<chrono::DateTime<chrono::Utc>>,
}
impl Default for PredictiveStats {
fn default() -> Self {
Self {
total_forecasts: 0,
retraining_count: 0,
avg_accuracy: 0.0,
model_params_count: 0,
training_data_size: 0,
last_forecast_time: None,
}
}
}
pub struct PredictiveAnalytics {
config: ForecastingConfig,
data: Arc<RwLock<VecDeque<f64>>>,
model_params: Arc<RwLock<ModelParameters>>,
stats: Arc<RwLock<PredictiveStats>>,
#[allow(clippy::arc_with_non_send_sync)]
rng: Arc<Mutex<Random>>,
}
#[derive(Debug, Clone)]
struct ModelParameters {
ar_coeffs: Vec<f64>,
ma_coeffs: Vec<f64>,
trend_coeffs: Vec<f64>,
seasonal_components: Vec<f64>,
intercept: f64,
residual_variance: f64,
fitted_values: Vec<f64>,
}
impl Default for ModelParameters {
fn default() -> Self {
Self {
ar_coeffs: Vec::new(),
ma_coeffs: Vec::new(),
trend_coeffs: Vec::new(),
seasonal_components: Vec::new(),
intercept: 0.0,
residual_variance: 1.0,
fitted_values: Vec::new(),
}
}
}
impl PredictiveAnalytics {
#[allow(clippy::arc_with_non_send_sync)]
pub fn new(config: ForecastingConfig) -> Result<Self> {
Ok(Self {
config,
data: Arc::new(RwLock::new(VecDeque::with_capacity(1000))),
model_params: Arc::new(RwLock::new(ModelParameters::default())),
stats: Arc::new(RwLock::new(PredictiveStats::default())),
rng: Arc::new(Mutex::new(Random::default())),
})
}
pub async fn add_data_point(&mut self, value: f64) -> Result<()> {
let mut data = self.data.write().await;
data.push_back(value);
if data.len() > self.config.window_size {
data.pop_front();
}
if self.config.auto_retrain && data.len() >= self.config.min_data_points {
drop(data); self.train_internal().await?;
}
Ok(())
}
pub async fn train(&mut self, data: &[f64]) -> Result<()> {
let mut data_buffer = self.data.write().await;
data_buffer.clear();
data_buffer.extend(data.iter().copied());
while data_buffer.len() > self.config.window_size {
data_buffer.pop_front();
}
drop(data_buffer);
self.train_internal().await
}
async fn train_internal(&mut self) -> Result<()> {
let data = self.data.read().await;
if data.len() < self.config.min_data_points {
return Err(anyhow!(
"Insufficient data points: {} < {}",
data.len(),
self.config.min_data_points
));
}
let data_vec: Vec<f64> = data.iter().copied().collect();
let data_array = Array1::from_vec(data_vec);
drop(data);
match self.config.algorithm {
ForecastAlgorithm::AutoRegressive => {
self.train_ar_model(&data_array).await?;
}
ForecastAlgorithm::Arima => {
self.train_arima_model(&data_array).await?;
}
ForecastAlgorithm::ExponentialSmoothing => {
self.train_exponential_smoothing(&data_array).await?;
}
ForecastAlgorithm::HoltWinters => {
self.train_holt_winters(&data_array).await?;
}
ForecastAlgorithm::MovingAverage => {
self.train_moving_average(&data_array).await?;
}
_ => {
self.train_ar_model(&data_array).await?;
}
}
let mut stats = self.stats.write().await;
stats.retraining_count += 1;
stats.training_data_size = data_array.len();
info!(
"Model trained successfully with {} data points using {:?}",
data_array.len(),
self.config.algorithm
);
Ok(())
}
async fn train_ar_model(&mut self, data: &Array1<f64>) -> Result<()> {
let p = self.config.max_ar_terms.min(data.len() / 3);
if p == 0 {
return Err(anyhow!("Insufficient data for AR model"));
}
let ar_coeffs = self.estimate_ar_coefficients(data, p)?;
let (fitted, residual_var) = self.compute_fitted_values(data, &ar_coeffs)?;
let mut params = self.model_params.write().await;
params.ar_coeffs = ar_coeffs;
params.residual_variance = residual_var;
params.fitted_values = fitted;
params.intercept = data.mean();
Ok(())
}
fn estimate_ar_coefficients(&self, data: &Array1<f64>, p: usize) -> Result<Vec<f64>> {
let n = data.len();
let mean_val = data.mean();
let centered: Vec<f64> = data.iter().map(|&x| x - mean_val).collect();
let mut r = vec![0.0; p + 1];
for k in 0..=p {
let mut sum = 0.0;
for i in 0..(n - k) {
sum += centered[i] * centered[i + k];
}
r[k] = sum / n as f64;
}
let mut matrix = Array2::<f64>::zeros((p, p));
let mut rhs = Array1::<f64>::zeros(p);
for i in 0..p {
for j in 0..p {
matrix[[i, j]] = r[i.abs_diff(j)];
}
rhs[i] = r[i + 1];
}
let coeffs = self.solve_linear_system(&matrix, &rhs)?;
Ok(coeffs)
}
fn solve_linear_system(&self, a: &Array2<f64>, b: &Array1<f64>) -> Result<Vec<f64>> {
let at = a.t();
let ata = at.dot(a);
let atb = at.dot(b);
let n = ata.shape()[0];
let mut aug = Array2::<f64>::zeros((n, n + 1));
for i in 0..n {
for j in 0..n {
aug[[i, j]] = ata[[i, j]];
}
aug[[i, n]] = atb[i];
}
for i in 0..n {
let pivot = aug[[i, i]];
if pivot.abs() < 1e-10 {
continue;
}
for j in (i + 1)..n {
let factor = aug[[j, i]] / pivot;
for k in i..=n {
aug[[j, k]] -= factor * aug[[i, k]];
}
}
}
let mut x = vec![0.0; n];
for i in (0..n).rev() {
let mut sum = aug[[i, n]];
for j in (i + 1)..n {
sum -= aug[[i, j]] * x[j];
}
x[i] = sum / aug[[i, i]].max(1e-10);
}
Ok(x)
}
fn compute_fitted_values(&self, data: &Array1<f64>, coeffs: &[f64]) -> Result<(Vec<f64>, f64)> {
let n = data.len();
let p = coeffs.len();
let mean_val: f64 = data.mean();
let mut fitted: Vec<f64> = vec![mean_val; n];
let mut residuals: Vec<f64> = Vec::with_capacity(n);
for t in p..n {
let mut pred: f64 = mean_val;
for (j, &coeff) in coeffs.iter().enumerate() {
let val: f64 = data[t - j - 1];
pred += coeff * (val - mean_val);
}
fitted[t] = pred;
let actual: f64 = data[t];
residuals.push(actual - pred);
}
let residual_var = if residuals.is_empty() {
1.0
} else {
residuals.iter().map(|&r| r * r).sum::<f64>() / residuals.len() as f64
};
Ok((fitted, residual_var))
}
async fn train_arima_model(&mut self, data: &Array1<f64>) -> Result<()> {
let differenced = if self.config.differencing_order > 0 {
self.difference_series(data, self.config.differencing_order)?
} else {
data.to_vec()
};
let diff_array = Array1::from_vec(differenced);
self.train_ar_model(&diff_array).await?;
Ok(())
}
async fn train_exponential_smoothing(&mut self, data: &Array1<f64>) -> Result<()> {
let alpha = 0.3; let mut smoothed = Vec::with_capacity(data.len());
smoothed.push(data[0]);
for i in 1..data.len() {
let s = alpha * data[i] + (1.0 - alpha) * smoothed[i - 1];
smoothed.push(s);
}
let residuals: Vec<f64> = data.iter().zip(&smoothed).map(|(x, s)| x - s).collect();
let residual_var = residuals.iter().map(|&r| r * r).sum::<f64>() / residuals.len() as f64;
let mut params = self.model_params.write().await;
params.ar_coeffs = vec![alpha];
params.fitted_values = smoothed;
params.residual_variance = residual_var;
params.intercept = data[0];
Ok(())
}
async fn train_holt_winters(&mut self, data: &Array1<f64>) -> Result<()> {
let alpha = 0.3; let beta = 0.1; let gamma = 0.2;
let seasonal_period = self.config.seasonal_period.unwrap_or(12);
if data.len() < 2 * seasonal_period {
return Err(anyhow!("Insufficient data for Holt-Winters"));
}
let mut level = data[0];
let mut trend = (data[seasonal_period] - data[0]) / seasonal_period as f64;
let mut seasonal = vec![1.0; seasonal_period];
for i in 0..seasonal_period {
seasonal[i] =
data[i] / (data.iter().take(seasonal_period).sum::<f64>() / seasonal_period as f64);
}
let mut fitted = Vec::with_capacity(data.len());
for (t, &value) in data.iter().enumerate() {
let season_idx = t % seasonal_period;
let forecast = (level + trend) * seasonal[season_idx];
fitted.push(forecast);
let old_level = level;
level = alpha * (value / seasonal[season_idx]) + (1.0 - alpha) * (level + trend);
trend = beta * (level - old_level) + (1.0 - beta) * trend;
seasonal[season_idx] = gamma * (value / level) + (1.0 - gamma) * seasonal[season_idx];
}
let residuals: Vec<f64> = data.iter().zip(&fitted).map(|(x, f)| x - f).collect();
let residual_var = residuals.iter().map(|&r| r * r).sum::<f64>() / residuals.len() as f64;
let mut params = self.model_params.write().await;
params.trend_coeffs = vec![level, trend];
params.seasonal_components = seasonal;
params.fitted_values = fitted;
params.residual_variance = residual_var;
Ok(())
}
async fn train_moving_average(&mut self, data: &Array1<f64>) -> Result<()> {
let window = self.config.max_ma_terms.min(data.len() / 2);
let mut fitted = Vec::with_capacity(data.len());
for i in 0..data.len() {
let start = i.saturating_sub(window);
let avg = data.slice(s![start..=i]).mean();
fitted.push(avg);
}
let residuals: Vec<f64> = data.iter().zip(&fitted).map(|(x, f)| x - f).collect();
let residual_var = residuals.iter().map(|&r| r * r).sum::<f64>() / residuals.len() as f64;
let mut params = self.model_params.write().await;
params.ma_coeffs = vec![1.0 / window as f64; window];
params.fitted_values = fitted;
params.residual_variance = residual_var;
Ok(())
}
fn difference_series(&self, data: &Array1<f64>, order: usize) -> Result<Vec<f64>> {
let mut result = data.to_vec();
for _ in 0..order {
result = result.windows(2).map(|w| w[1] - w[0]).collect();
}
Ok(result)
}
pub async fn forecast(&mut self, steps: usize) -> Result<ForecastResult> {
let params = self.model_params.read().await;
let data = self.data.read().await;
if data.len() < self.config.min_data_points {
return Err(anyhow!("Insufficient data for forecasting"));
}
let data_vec: Vec<f64> = data.iter().copied().collect();
drop(data);
let predictions = match self.config.algorithm {
ForecastAlgorithm::AutoRegressive => self.forecast_ar(&data_vec, ¶ms, steps)?,
ForecastAlgorithm::ExponentialSmoothing => {
self.forecast_exponential_smoothing(&data_vec, ¶ms, steps)?
}
ForecastAlgorithm::HoltWinters => self.forecast_holt_winters(¶ms, steps)?,
_ => self.forecast_ar(&data_vec, ¶ms, steps)?,
};
let std_dev = params.residual_variance.sqrt();
let z_score = 1.96;
let lower_bound: Vec<f64> = predictions
.iter()
.enumerate()
.map(|(i, &p)| p - z_score * std_dev * ((i + 1) as f64).sqrt())
.collect();
let upper_bound: Vec<f64> = predictions
.iter()
.enumerate()
.map(|(i, &p)| p + z_score * std_dev * ((i + 1) as f64).sqrt())
.collect();
let trend = self.detect_trend(&predictions);
let seasonality = if self.config.enable_seasonality_detection {
self.detect_seasonality(&data_vec)?
} else {
SeasonalityType::None
};
let accuracy_metrics = self.compute_accuracy_metrics(&data_vec, ¶ms.fitted_values)?;
let mut stats = self.stats.write().await;
stats.total_forecasts += 1;
stats.last_forecast_time = Some(chrono::Utc::now());
stats.avg_accuracy = (stats.avg_accuracy * (stats.total_forecasts - 1) as f64
+ accuracy_metrics.r_squared)
/ stats.total_forecasts as f64;
stats.model_params_count = params.ar_coeffs.len() + params.ma_coeffs.len();
Ok(ForecastResult {
predictions,
lower_bound,
upper_bound,
steps_ahead: (1..=steps).collect(),
confidence_level: self.config.confidence_level,
trend,
seasonality,
accuracy_metrics,
})
}
fn forecast_ar(
&self,
data: &[f64],
params: &ModelParameters,
steps: usize,
) -> Result<Vec<f64>> {
let mut predictions = Vec::with_capacity(steps);
let mut history = data.to_vec();
let mean = params.intercept;
for _ in 0..steps {
let mut pred = mean;
for (j, &coeff) in params.ar_coeffs.iter().enumerate() {
if j < history.len() {
pred += coeff * (history[history.len() - j - 1] - mean);
}
}
predictions.push(pred);
history.push(pred);
}
Ok(predictions)
}
fn forecast_exponential_smoothing(
&self,
data: &[f64],
params: &ModelParameters,
steps: usize,
) -> Result<Vec<f64>> {
let last_smooth = params
.fitted_values
.last()
.copied()
.unwrap_or(data.last().copied().unwrap_or(0.0));
Ok(vec![last_smooth; steps])
}
fn forecast_holt_winters(&self, params: &ModelParameters, steps: usize) -> Result<Vec<f64>> {
if params.trend_coeffs.len() < 2 {
return Err(anyhow!("Holt-Winters model not trained"));
}
let level = params.trend_coeffs[0];
let trend = params.trend_coeffs[1];
let seasonal_period = params.seasonal_components.len();
let predictions: Vec<f64> = (0..steps)
.map(|h| {
let season_idx = h % seasonal_period;
(level + (h + 1) as f64 * trend) * params.seasonal_components[season_idx]
})
.collect();
Ok(predictions)
}
fn detect_trend(&self, values: &[f64]) -> TrendDirection {
if values.len() < 2 {
return TrendDirection::Stable;
}
let increases = values.windows(2).filter(|w| w[1] > w[0]).count();
let decreases = values.windows(2).filter(|w| w[1] < w[0]).count();
if increases == 0 && decreases == 0 {
return TrendDirection::Stable;
}
let ratio = increases as f64 / (increases + decreases) as f64;
if ratio > 0.7 {
TrendDirection::Increasing
} else if ratio < 0.3 {
TrendDirection::Decreasing
} else if (ratio - 0.5).abs() < 0.1 {
TrendDirection::Oscillating
} else {
TrendDirection::Stable
}
}
fn detect_seasonality(&self, data: &[f64]) -> Result<SeasonalityType> {
if let Some(period) = self.config.seasonal_period {
if data.len() < 2 * period {
return Ok(SeasonalityType::None);
}
let seasonal_diff: Vec<f64> = data
.iter()
.skip(period)
.zip(data.iter())
.map(|(x2, x1)| x2 - x1)
.collect();
let regular_diff: Vec<f64> = data.windows(2).map(|w| w[1] - w[0]).collect();
let seasonal_var =
seasonal_diff.iter().map(|&x| x * x).sum::<f64>() / seasonal_diff.len() as f64;
let regular_var =
regular_diff.iter().map(|&x| x * x).sum::<f64>() / regular_diff.len() as f64;
if seasonal_var < 0.5 * regular_var {
Ok(SeasonalityType::Additive)
} else {
Ok(SeasonalityType::None)
}
} else {
Ok(SeasonalityType::None)
}
}
fn compute_accuracy_metrics(&self, actual: &[f64], fitted: &[f64]) -> Result<AccuracyMetrics> {
let n = actual.len().min(fitted.len());
if n == 0 {
return Ok(AccuracyMetrics::default());
}
let errors: Vec<f64> = actual
.iter()
.zip(fitted.iter())
.map(|(a, f)| a - f)
.collect();
let abs_errors: Vec<f64> = errors.iter().map(|e| e.abs()).collect();
let squared_errors: Vec<f64> = errors.iter().map(|e| e * e).collect();
let mae = abs_errors.iter().sum::<f64>() / n as f64;
let mse = squared_errors.iter().sum::<f64>() / n as f64;
let rmse = mse.sqrt();
let mape = abs_errors
.iter()
.zip(actual.iter())
.map(|(ae, a)| if a.abs() > 1e-10 { ae / a.abs() } else { 0.0 })
.sum::<f64>()
/ n as f64
* 100.0;
let actual_mean = actual.iter().sum::<f64>() / n as f64;
let ss_tot: f64 = actual.iter().map(|a| (a - actual_mean).powi(2)).sum();
let ss_res: f64 = squared_errors.iter().sum();
let r_squared = if ss_tot > 1e-10 {
1.0 - (ss_res / ss_tot)
} else {
0.0
};
let bias = errors.iter().sum::<f64>() / n as f64;
Ok(AccuracyMetrics {
mae,
mse,
rmse,
mape,
r_squared,
bias,
})
}
pub async fn get_stats(&self) -> PredictiveStats {
self.stats.read().await.clone()
}
pub async fn reset(&mut self) -> Result<()> {
self.data.write().await.clear();
*self.model_params.write().await = ModelParameters::default();
*self.stats.write().await = PredictiveStats::default();
Ok(())
}
}
trait ArrayMean {
fn mean(&self) -> f64;
}
impl ArrayMean for Array1<f64> {
fn mean(&self) -> f64 {
if self.is_empty() {
0.0
} else {
self.sum() / self.len() as f64
}
}
}
impl ArrayMean for ArrayView1<'_, f64> {
fn mean(&self) -> f64 {
if self.is_empty() {
0.0
} else {
self.sum() / self.len() as f64
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_predictive_analytics_creation() {
let config = ForecastingConfig::default();
let analytics = PredictiveAnalytics::new(config);
assert!(analytics.is_ok());
}
#[tokio::test]
async fn test_ar_model_training() {
let config = ForecastingConfig {
algorithm: ForecastAlgorithm::AutoRegressive,
min_data_points: 10,
max_ar_terms: 3,
..Default::default()
};
let mut analytics = PredictiveAnalytics::new(config).unwrap();
let data: Vec<f64> = (0..50).map(|i| i as f64 + (i as f64 * 0.1).sin()).collect();
let result = analytics.train(&data).await;
assert!(result.is_ok());
let stats = analytics.get_stats().await;
assert_eq!(stats.retraining_count, 1);
assert_eq!(stats.training_data_size, 50);
}
#[tokio::test]
async fn test_forecasting() {
let config = ForecastingConfig {
algorithm: ForecastAlgorithm::AutoRegressive,
horizon: 10,
min_data_points: 20,
..Default::default()
};
let mut analytics = PredictiveAnalytics::new(config).unwrap();
let data: Vec<f64> = (0..30).map(|i| i as f64 * 2.0).collect();
analytics.train(&data).await.unwrap();
let forecast = analytics.forecast(10).await;
assert!(forecast.is_ok());
let result = forecast.unwrap();
assert_eq!(result.predictions.len(), 10);
assert_eq!(result.lower_bound.len(), 10);
assert_eq!(result.upper_bound.len(), 10);
let last_data = data.last().copied().unwrap_or(0.0);
let first_pred = result.predictions[0];
assert!(
(first_pred - last_data).abs() < 10.0,
"Prediction {} is too far from last data point {}",
first_pred,
last_data
);
}
#[tokio::test]
async fn test_exponential_smoothing() {
let config = ForecastingConfig {
algorithm: ForecastAlgorithm::ExponentialSmoothing,
min_data_points: 10,
..Default::default()
};
let mut analytics = PredictiveAnalytics::new(config).unwrap();
let data: Vec<f64> = vec![10.0, 12.0, 11.0, 13.0, 14.0, 13.5, 15.0, 16.0, 15.5, 17.0];
analytics.train(&data).await.unwrap();
let forecast = analytics.forecast(5).await;
assert!(forecast.is_ok());
}
#[tokio::test]
async fn test_trend_detection() {
let config = ForecastingConfig::default();
let analytics = PredictiveAnalytics::new(config).unwrap();
let increasing = vec![1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(
analytics.detect_trend(&increasing),
TrendDirection::Increasing
);
let decreasing = vec![5.0, 4.0, 3.0, 2.0, 1.0];
assert_eq!(
analytics.detect_trend(&decreasing),
TrendDirection::Decreasing
);
let stable = vec![5.0, 5.0, 5.0, 5.0, 5.0];
assert_eq!(analytics.detect_trend(&stable), TrendDirection::Stable);
}
#[tokio::test]
async fn test_add_data_point() {
let config = ForecastingConfig {
min_data_points: 5,
auto_retrain: false,
..Default::default()
};
let mut analytics = PredictiveAnalytics::new(config).unwrap();
for i in 0..10 {
let result = analytics.add_data_point(i as f64).await;
assert!(result.is_ok());
}
let data = analytics.data.read().await;
assert_eq!(data.len(), 10);
}
#[tokio::test]
async fn test_holt_winters() {
let config = ForecastingConfig {
algorithm: ForecastAlgorithm::HoltWinters,
seasonal_period: Some(4),
min_data_points: 20,
..Default::default()
};
let mut analytics = PredictiveAnalytics::new(config).unwrap();
let mut data = Vec::new();
for i in 0..24 {
let base = (i / 4) as f64;
let seasonal = (i % 4) as f64 * 2.0;
data.push(base + seasonal);
}
analytics.train(&data).await.unwrap();
let forecast = analytics.forecast(8).await;
assert!(forecast.is_ok());
}
#[tokio::test]
async fn test_accuracy_metrics() {
let config = ForecastingConfig::default();
let analytics = PredictiveAnalytics::new(config).unwrap();
let actual = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let fitted = vec![1.1, 2.1, 2.9, 3.9, 5.1];
let metrics = analytics.compute_accuracy_metrics(&actual, &fitted);
assert!(metrics.is_ok());
let m = metrics.unwrap();
assert!(m.mae > 0.0);
assert!(m.rmse > 0.0);
assert!(m.r_squared > 0.9); }
#[tokio::test]
async fn test_confidence_intervals() {
let config = ForecastingConfig {
confidence_level: 0.95,
..Default::default()
};
let mut analytics = PredictiveAnalytics::new(config).unwrap();
let data: Vec<f64> = (0..30).map(|i| i as f64 + fastrand::f64() * 2.0).collect();
analytics.train(&data).await.unwrap();
let forecast = analytics.forecast(5).await.unwrap();
for i in 0..forecast.predictions.len() - 1 {
let width_i = forecast.upper_bound[i] - forecast.lower_bound[i];
let width_next = forecast.upper_bound[i + 1] - forecast.lower_bound[i + 1];
assert!(width_next >= width_i - 1e-6); }
}
#[tokio::test]
async fn test_reset() {
let config = ForecastingConfig::default();
let mut analytics = PredictiveAnalytics::new(config).unwrap();
let data: Vec<f64> = (0..20).map(|i| i as f64).collect();
analytics.train(&data).await.unwrap();
analytics.reset().await.unwrap();
let stats = analytics.get_stats().await;
assert_eq!(stats.training_data_size, 0);
assert_eq!(stats.total_forecasts, 0);
}
}