use crate::core::{Forecast, TimeSeries};
use crate::error::{ForecastError, Result};
use crate::models::traits::{validate_series_complete, FittedParams, Forecaster};
use crate::utils::ols::OLSResult;
use std::collections::HashMap;
#[cfg(all(feature = "distributional", feature = "postprocess"))]
use crate::models::laplace::LaplaceForecaster;
#[cfg(feature = "postprocess")]
use crate::validation::aid::AidAnalyzer;
#[cfg(feature = "postprocess")]
use anofox_regression::solvers::{DemandDistribution, DemandType};
#[derive(Debug, Clone, PartialEq)]
pub enum SelectedFamily {
IntermittentPoisson,
IntermittentNegBinomial,
IntermittentRectifiedNormal,
IntermittentPositive,
RegularCount,
RegularPositive,
RegularNormal,
Fallback,
}
pub struct SmartForecaster {
inner: Option<Box<dyn Forecaster + Send>>,
selected: Option<SelectedFamily>,
seasonal_period: usize,
}
impl SmartForecaster {
pub fn new() -> Self {
Self {
inner: None,
selected: None,
seasonal_period: 7,
}
}
pub fn with_seasonal_period(mut self, period: usize) -> Self {
self.seasonal_period = period.max(2);
self
}
pub fn selected_family(&self) -> Option<&SelectedFamily> {
self.selected.as_ref()
}
}
impl Default for SmartForecaster {
fn default() -> Self {
Self::new()
}
}
#[cfg(all(feature = "distributional", feature = "postprocess"))]
fn build_from_aid(
demand_type: DemandType,
distribution: DemandDistribution,
seasonal_period: usize,
) -> (Box<dyn Forecaster + Send>, SelectedFamily) {
match (demand_type, distribution) {
(DemandType::Intermittent, DemandDistribution::Poisson)
| (DemandType::Intermittent, DemandDistribution::Geometric) => {
let m = LaplaceForecaster::new()
.with_poisson_defaults()
.with_seasonal_intermittent_defaults(seasonal_period)
.non_negative();
(Box::new(m), SelectedFamily::IntermittentPoisson)
}
(DemandType::Intermittent, DemandDistribution::NegativeBinomial) => {
let m = LaplaceForecaster::new()
.with_negative_binomial_defaults()
.with_seasonal_intermittent_defaults(seasonal_period)
.non_negative();
(Box::new(m), SelectedFamily::IntermittentNegBinomial)
}
(DemandType::Intermittent, DemandDistribution::RectifiedNormal) => {
let m = LaplaceForecaster::new()
.with_rectified_normal_defaults()
.with_seasonal_intermittent_defaults(seasonal_period)
.non_negative();
(Box::new(m), SelectedFamily::IntermittentRectifiedNormal)
}
(DemandType::Intermittent, DemandDistribution::LogNormal) => {
let m = LaplaceForecaster::new()
.with_lognormal_defaults()
.with_seasonal_intermittent_defaults(seasonal_period)
.non_negative();
(Box::new(m), SelectedFamily::IntermittentPositive)
}
(DemandType::Intermittent, DemandDistribution::Gamma) => {
let m = LaplaceForecaster::new()
.with_gamma_defaults()
.with_seasonal_intermittent_defaults(seasonal_period)
.non_negative();
(Box::new(m), SelectedFamily::IntermittentPositive)
}
(DemandType::Intermittent, DemandDistribution::Normal) => {
let m = LaplaceForecaster::new()
.with_intermittent_defaults()
.non_negative()
.auto();
(Box::new(m), SelectedFamily::IntermittentPositive)
}
(DemandType::Regular, DemandDistribution::Poisson)
| (DemandType::Regular, DemandDistribution::Geometric) => {
let m = LaplaceForecaster::new()
.with_poisson_defaults()
.with_seasonal(seasonal_period)
.non_negative();
(Box::new(m), SelectedFamily::RegularCount)
}
(DemandType::Regular, DemandDistribution::NegativeBinomial) => {
let m = LaplaceForecaster::new()
.with_negative_binomial_defaults()
.with_seasonal(seasonal_period)
.non_negative();
(Box::new(m), SelectedFamily::RegularCount)
}
(DemandType::Regular, DemandDistribution::LogNormal) => {
let m = LaplaceForecaster::new()
.with_lognormal_defaults()
.with_seasonal(seasonal_period)
.non_negative();
(Box::new(m), SelectedFamily::RegularPositive)
}
(DemandType::Regular, DemandDistribution::Gamma) => {
let m = LaplaceForecaster::new()
.with_gamma_defaults()
.with_seasonal(seasonal_period)
.non_negative();
(Box::new(m), SelectedFamily::RegularPositive)
}
(DemandType::Regular, DemandDistribution::RectifiedNormal) => {
let m = LaplaceForecaster::new()
.with_rectified_normal_defaults()
.non_negative();
(Box::new(m), SelectedFamily::RegularPositive)
}
(DemandType::Regular, DemandDistribution::Normal) => {
let m = LaplaceForecaster::new().auto();
(Box::new(m), SelectedFamily::RegularNormal)
}
}
}
impl Forecaster for SmartForecaster {
#[allow(unused_variables)] fn fit(&mut self, series: &TimeSeries) -> Result<()> {
validate_series_complete(series)?;
let values = series.primary_values();
if values.is_empty() {
return Err(ForecastError::InvalidParameter(
"SmartForecaster requires at least one observation".into(),
));
}
#[cfg(all(feature = "distributional", feature = "postprocess"))]
let (mut inner, selected) = {
let aid = AidAnalyzer::new().analyze(values);
let summary = aid.summary();
build_from_aid(
summary.demand_type,
summary.distribution,
self.seasonal_period,
)
};
#[cfg(not(all(feature = "distributional", feature = "postprocess")))]
let (mut inner, selected): (Box<dyn Forecaster + Send>, SelectedFamily) = {
(
Box::new(crate::models::theta::AutoTheta::new()),
SelectedFamily::Fallback,
)
};
inner.fit(series)?;
self.inner = Some(inner);
self.selected = Some(selected);
Ok(())
}
fn predict(&self, horizon: usize) -> Result<Forecast> {
match &self.inner {
Some(m) => m.predict(horizon),
None => Err(ForecastError::FitRequired {
model: Some("SmartForecaster".into()),
}),
}
}
fn predict_with_intervals(&self, horizon: usize, level: f64) -> Result<Forecast> {
match &self.inner {
Some(m) => m.predict_with_intervals(horizon, level),
None => Err(ForecastError::FitRequired {
model: Some("SmartForecaster".into()),
}),
}
}
fn fitted_values(&self) -> Option<&[f64]> {
self.inner.as_ref().and_then(|m| m.fitted_values())
}
fn residuals(&self) -> Option<&[f64]> {
self.inner.as_ref().and_then(|m| m.residuals())
}
fn training_values(&self) -> Result<&[f64]> {
match &self.inner {
Some(m) => m.training_values(),
None => Err(ForecastError::FitRequired {
model: Some("SmartForecaster".into()),
}),
}
}
fn fitted_params(&self) -> Option<FittedParams> {
self.inner.as_ref().and_then(|m| m.fitted_params())
}
fn training_regressors(&self) -> Option<&HashMap<String, Vec<f64>>> {
self.inner.as_ref().and_then(|m| m.training_regressors())
}
fn exog_coefficients(&self) -> Option<&OLSResult> {
self.inner.as_ref().and_then(|m| m.exog_coefficients())
}
fn name(&self) -> &str {
"SmartForecaster"
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, TimeZone, Utc};
fn make_ts(vals: Vec<f64>) -> TimeSeries {
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
let stamps: Vec<_> = (0..vals.len())
.map(|i| base + Duration::hours(i as i64))
.collect();
TimeSeries::univariate(stamps, vals).unwrap()
}
#[cfg(all(feature = "distributional", feature = "postprocess"))]
#[test]
fn intermittent_count_series_routes_to_intermittent_family() {
let mut vals = vec![0.0; 200];
for i in (0..200).step_by(3) {
vals[i] = 2.0;
}
let ts = make_ts(vals);
let mut f = SmartForecaster::new();
f.fit(&ts).unwrap();
assert!(matches!(
f.selected_family(),
Some(SelectedFamily::IntermittentPoisson)
| Some(SelectedFamily::IntermittentNegBinomial)
| Some(SelectedFamily::IntermittentRectifiedNormal)
| Some(SelectedFamily::IntermittentPositive)
));
let fc = f.predict(10).unwrap();
for v in fc.primary() {
assert!(*v >= 0.0);
}
}
#[cfg(all(feature = "distributional", feature = "postprocess"))]
#[test]
fn continuous_regular_normal_falls_through_to_auto() {
let vals: Vec<f64> = (0..200)
.map(|i| 50.0 + (i as f64 * 0.05).sin() * 5.0)
.collect();
let ts = make_ts(vals);
let mut f = SmartForecaster::new();
f.fit(&ts).unwrap();
assert!(matches!(
f.selected_family(),
Some(SelectedFamily::RegularNormal)
| Some(SelectedFamily::RegularPositive)
| Some(SelectedFamily::RegularCount)
));
}
}