use anofox_forecast::batch;
use anofox_forecast::core::TimeSeries;
use anofox_forecast::models::exponential::{
AutoETS, AutoETSConfig, ETSSeasonalType, ETSSpec, ErrorType, GlobalETS, ModelPool, TrendType,
ETS,
};
use anofox_forecast::models::mfles::MFLES;
use anofox_forecast::models::Forecaster;
use chrono::{Duration, TimeZone, Utc};
use std::time::Instant;
fn make_timestamps(n: usize) -> Vec<chrono::DateTime<Utc>> {
(0..n)
.map(|i| Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap() + Duration::days(i as i64))
.collect()
}
fn make_series(n_series: usize, n: usize, period: usize) -> Vec<Vec<f64>> {
(0..n_series)
.map(|s| {
(0..n)
.map(|i| {
let base = 50.0 + (s as f64) * 5.0;
let trend = 0.1 * (s as f64 + 1.0) * i as f64;
let seasonal =
8.0 * (2.0 * std::f64::consts::PI * i as f64 / period as f64).sin();
let noise = ((i * 7 + s * 13 + 3) % 11) as f64 * 0.5 - 2.75;
base + trend + seasonal + noise
})
.collect()
})
.collect()
}
#[test]
fn batch_mfles_matches_individual() {
let period = 12;
let horizon = 12;
let n = 120;
let values = make_series(10, n, period);
let timestamps = make_timestamps(n);
let individual: Vec<Vec<f64>> = values
.iter()
.map(|v| {
let ts = TimeSeries::univariate(timestamps.clone(), v.clone()).unwrap();
let mut model = MFLES::new(vec![period]);
model.fit(&ts).unwrap();
model.predict(horizon).unwrap().primary().to_vec()
})
.collect();
let batch_results = batch::mfles(&values, period, Some(horizon));
for (i, (ind, batch_res)) in individual.iter().zip(batch_results.iter()).enumerate() {
let batch_fc = batch_res.as_ref().expect("batch should succeed");
let batch_vals = batch_fc.primary();
for h in 0..horizon {
assert!(
(ind[h] - batch_vals[h]).abs() < 1e-10,
"Series {} h={}: individual={:.6} batch={:.6} diff={:.2e}",
i,
h,
ind[h],
batch_vals[h],
(ind[h] - batch_vals[h]).abs()
);
}
}
}
#[test]
fn batch_auto_ets_matches_individual() {
let period = 7;
let horizon = 7;
let n = 56;
let values = make_series(5, n, period);
let timestamps = make_timestamps(n);
let individual: Vec<(Vec<f64>, String)> = values
.iter()
.map(|v| {
let ts = TimeSeries::univariate(timestamps.clone(), v.clone()).unwrap();
let config = AutoETSConfig::with_period(period).with_model_pool(ModelPool::Reduced);
let mut model = AutoETS::with_config(config);
model.fit(&ts).unwrap();
let fc = model.predict(horizon).unwrap();
let spec = format!("{:?}", model.selected_spec());
(fc.primary().to_vec(), spec)
})
.collect();
let batch_results = batch::auto_ets(&values, period, Some(horizon), Some(ModelPool::Reduced));
for (i, (ind, batch_res)) in individual.iter().zip(batch_results.iter()).enumerate() {
let (batch_fc, batch_spec) = batch_res.as_ref().expect("batch should succeed");
let batch_vals = batch_fc.primary();
let batch_spec_str = format!("{:?}", Some(*batch_spec));
assert_eq!(
ind.1, batch_spec_str,
"Series {}: individual selected {} but batch selected {}",
i, ind.1, batch_spec_str
);
for h in 0..horizon {
assert!(
(ind.0[h] - batch_vals[h]).abs() < 1e-10,
"Series {} h={}: individual={:.6} batch={:.6}",
i,
h,
ind.0[h],
batch_vals[h]
);
}
}
}
#[test]
fn batch_ets_matches_individual() {
let period = 7;
let horizon = 7;
let n = 56;
let spec = ETSSpec::ann();
let values = make_series(5, n, period);
let timestamps = make_timestamps(n);
let individual: Vec<Vec<f64>> = values
.iter()
.map(|v| {
let ts = TimeSeries::univariate(timestamps.clone(), v.clone()).unwrap();
let mut model = ETS::new(spec, period);
model.fit(&ts).unwrap();
model.predict(horizon).unwrap().primary().to_vec()
})
.collect();
let batch_results = batch::ets(&values, spec, period, Some(horizon));
for (i, (ind, batch_res)) in individual.iter().zip(batch_results.iter()).enumerate() {
let batch_fc = batch_res.as_ref().expect("batch should succeed");
let batch_vals = batch_fc.primary();
for h in 0..horizon {
assert!(
(ind[h] - batch_vals[h]).abs() < 1e-10,
"Series {} h={}: individual={:.6} batch={:.6}",
i,
h,
ind[h],
batch_vals[h]
);
}
}
}
#[test]
fn benchmark_mfles_batch_vs_individual() {
let period = 12;
let horizon = 12;
let n = 200;
let n_series = 100;
let values = make_series(n_series, n, period);
let timestamps = make_timestamps(n);
{
let ts = TimeSeries::univariate(timestamps.clone(), values[0].clone()).unwrap();
let mut m = MFLES::new(vec![period]);
m.fit(&ts).unwrap();
}
let start = Instant::now();
let mut individual_forecasts = Vec::with_capacity(n_series);
for v in &values {
let ts = TimeSeries::univariate(timestamps.clone(), v.clone()).unwrap();
let mut model = MFLES::new(vec![period]);
model.fit(&ts).unwrap();
individual_forecasts.push(model.predict(horizon).unwrap());
}
let individual_ms = start.elapsed().as_secs_f64() * 1000.0;
let start = Instant::now();
let batch_forecasts = batch::mfles(&values, period, Some(horizon));
let batch_ms = start.elapsed().as_secs_f64() * 1000.0;
let n_ok = batch_forecasts.iter().filter(|r| r.is_ok()).count();
println!(
"\n=== MFLES: {} series, n={}, period={} ===",
n_series, n, period
);
println!(
"Individual (sequential): {:.1} ms ({:.2} ms/series)",
individual_ms,
individual_ms / n_series as f64
);
println!(
"Batch (sequential): {:.1} ms ({:.2} ms/series)",
batch_ms,
batch_ms / n_series as f64
);
println!("Speedup: {:.2}x", individual_ms / batch_ms);
println!("Success: {}/{}", n_ok, n_series);
for (i, (ind, batch_res)) in individual_forecasts
.iter()
.zip(batch_forecasts.iter())
.enumerate()
{
if let Ok(batch_fc) = batch_res {
let max_diff: f64 = ind
.primary()
.iter()
.zip(batch_fc.primary().iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0, f64::max);
assert!(max_diff < 1e-10, "Series {} max diff: {:.2e}", i, max_diff);
}
}
}
#[test]
fn benchmark_auto_ets_batch_vs_individual() {
let period = 7;
let horizon = 7;
let n = 100;
let n_series = 20;
let values = make_series(n_series, n, period);
let timestamps = make_timestamps(n);
let pool = ModelPool::Reduced;
{
let ts = TimeSeries::univariate(timestamps.clone(), values[0].clone()).unwrap();
let mut m = AutoETS::with_config(AutoETSConfig::with_period(period).with_model_pool(pool));
m.fit(&ts).unwrap();
}
let start = Instant::now();
for v in &values {
let ts = TimeSeries::univariate(timestamps.clone(), v.clone()).unwrap();
let mut m = AutoETS::with_config(AutoETSConfig::with_period(period).with_model_pool(pool));
m.fit(&ts).unwrap();
m.predict(horizon).unwrap();
}
let individual_ms = start.elapsed().as_secs_f64() * 1000.0;
let start = Instant::now();
let batch_results = batch::auto_ets(&values, period, Some(horizon), Some(pool));
let batch_ms = start.elapsed().as_secs_f64() * 1000.0;
let n_ok = batch_results.iter().filter(|r| r.is_ok()).count();
println!(
"\n=== AutoETS Reduced: {} series, n={}, period={} ===",
n_series, n, period
);
println!(
"Individual (sequential): {:.1} ms ({:.2} ms/series)",
individual_ms,
individual_ms / n_series as f64
);
println!(
"Batch (sequential): {:.1} ms ({:.2} ms/series)",
batch_ms,
batch_ms / n_series as f64
);
println!("Speedup: {:.2}x", individual_ms / batch_ms);
println!("Success: {}/{}", n_ok, n_series);
}
#[test]
fn benchmark_global_ets_ann_vs_individual() {
let n = 200;
let horizon = 12;
let period = 1;
for &n_series in &[50, 200, 1000] {
let values = make_series(n_series, n, 12); let timestamps = make_timestamps(n);
let start = Instant::now();
let mut ind_fc: Vec<Vec<f64>> = Vec::with_capacity(n_series);
for v in &values {
let ts = TimeSeries::univariate(timestamps.clone(), v.clone()).unwrap();
let mut model = ETS::new(ETSSpec::ann(), period);
model.fit(&ts).unwrap();
ind_fc.push(model.predict(horizon).unwrap().primary().to_vec());
}
let individual_ms = start.elapsed().as_secs_f64() * 1000.0;
let start = Instant::now();
let mut global = GlobalETS::new(ETSSpec::ann(), period);
global.fit(&values).unwrap();
let global_fc = global.predict(horizon);
let global_ms = start.elapsed().as_secs_f64() * 1000.0;
let (alpha, _, _, _) = global.params();
println!("\n=== GlobalETS ANN: {} series, n={} ===", n_series, n);
println!(
"Individual: {:.1} ms ({:.3} ms/series)",
individual_ms,
individual_ms / n_series as f64
);
println!(
"GlobalETS: {:.1} ms ({:.3} ms/series)",
global_ms,
global_ms / n_series as f64
);
println!("Speedup: {:.1}x", individual_ms / global_ms);
println!("Global α: {:.4}", alpha);
}
}
#[test]
fn benchmark_global_ets_aana_vs_individual() {
let period = 12;
let n = 200;
let horizon = 12;
for &n_series in &[50, 200, 1000] {
let values = make_series(n_series, n, period);
let timestamps = make_timestamps(n);
let spec = ETSSpec::new(
ErrorType::Additive,
TrendType::None,
ETSSeasonalType::Additive,
);
let start = Instant::now();
for v in &values {
let ts = TimeSeries::univariate(timestamps.clone(), v.clone()).unwrap();
let mut model = ETS::new(spec, period);
model.fit(&ts).unwrap();
model.predict(horizon).unwrap();
}
let individual_ms = start.elapsed().as_secs_f64() * 1000.0;
let start = Instant::now();
let mut global = GlobalETS::new(spec, period);
global.fit(&values).unwrap();
let global_fc = global.predict(horizon);
let global_ms = start.elapsed().as_secs_f64() * 1000.0;
let (alpha, _, gamma, _) = global.params();
println!(
"\n=== GlobalETS ANA: {} series, n={}, period={} ===",
n_series, n, period
);
println!(
"Individual: {:.1} ms ({:.3} ms/series)",
individual_ms,
individual_ms / n_series as f64
);
println!(
"GlobalETS: {:.1} ms ({:.3} ms/series)",
global_ms,
global_ms / n_series as f64
);
println!("Speedup: {:.1}x", individual_ms / global_ms);
println!("Global α: {:.4}, γ: {:.4}", alpha, gamma.unwrap_or(0.0));
assert_eq!(global_fc.len(), n_series);
}
}