use crate::error::{Result, TemporalError};
use crate::timeseries::TimeSeriesRaster;
use scirs2_core::ndarray::Array3;
use serde::{Deserialize, Serialize};
use tracing::info;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ForecastMethod {
LastValue,
Mean,
LinearExtrapolation,
ExponentialSmoothing,
MovingAverage,
}
#[derive(Debug, Clone)]
pub struct ForecastResult {
pub forecast: Array3<f64>,
pub lower_bound: Option<Array3<f64>>,
pub upper_bound: Option<Array3<f64>>,
pub horizon: usize,
}
impl ForecastResult {
#[must_use]
pub fn new(forecast: Array3<f64>, horizon: usize) -> Self {
Self {
forecast,
lower_bound: None,
upper_bound: None,
horizon,
}
}
#[must_use]
pub fn with_confidence(mut self, lower_bound: Array3<f64>, upper_bound: Array3<f64>) -> Self {
self.lower_bound = Some(lower_bound);
self.upper_bound = Some(upper_bound);
self
}
}
pub struct Forecaster;
impl Forecaster {
pub fn forecast(
ts: &TimeSeriesRaster,
method: ForecastMethod,
horizon: usize,
params: Option<ForecastParams>,
) -> Result<ForecastResult> {
if horizon == 0 {
return Err(TemporalError::invalid_parameter(
"horizon",
"must be greater than 0",
));
}
match method {
ForecastMethod::LastValue => Self::last_value_forecast(ts, horizon),
ForecastMethod::Mean => Self::mean_forecast(ts, horizon),
ForecastMethod::LinearExtrapolation => Self::linear_forecast(ts, horizon),
ForecastMethod::ExponentialSmoothing => {
let alpha = params.map_or(0.3, |p| p.alpha);
Self::exponential_smoothing_forecast(ts, horizon, alpha)
}
ForecastMethod::MovingAverage => {
let window = params.map_or(3, |p| p.window_size);
Self::moving_average_forecast(ts, horizon, window)
}
}
}
fn last_value_forecast(ts: &TimeSeriesRaster, horizon: usize) -> Result<ForecastResult> {
if ts.is_empty() {
return Err(TemporalError::insufficient_data("Empty time series"));
}
let (_height, _width, _n_bands) = ts
.expected_shape()
.ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
let last_entry = ts.get_by_index(ts.len() - 1)?;
let last_data = last_entry
.data
.as_ref()
.ok_or_else(|| TemporalError::invalid_input("Data not loaded"))?;
let forecast = last_data.clone();
info!("Generated last value forecast for {} steps", horizon);
Ok(ForecastResult::new(forecast, horizon))
}
fn mean_forecast(ts: &TimeSeriesRaster, horizon: usize) -> Result<ForecastResult> {
if ts.is_empty() {
return Err(TemporalError::insufficient_data("Empty time series"));
}
let (height, width, n_bands) = ts
.expected_shape()
.ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
let mut forecast = Array3::zeros((height, width, n_bands));
for i in 0..height {
for j in 0..width {
for k in 0..n_bands {
let values = ts.extract_pixel_timeseries(i, j, k)?;
let mean = values.iter().sum::<f64>() / values.len() as f64;
forecast[[i, j, k]] = mean;
}
}
}
info!("Generated mean forecast for {} steps", horizon);
Ok(ForecastResult::new(forecast, horizon))
}
fn linear_forecast(ts: &TimeSeriesRaster, horizon: usize) -> Result<ForecastResult> {
if ts.len() < 2 {
return Err(TemporalError::insufficient_data(
"Need at least 2 observations",
));
}
let (height, width, n_bands) = ts
.expected_shape()
.ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
let mut forecast = Array3::zeros((height, width, n_bands));
for i in 0..height {
for j in 0..width {
for k in 0..n_bands {
let values = ts.extract_pixel_timeseries(i, j, k)?;
let n = values.len() as f64;
let times: Vec<f64> = (0..values.len()).map(|t| t as f64).collect();
let sum_t: f64 = times.iter().sum();
let sum_y: f64 = values.iter().sum();
let sum_t2: f64 = times.iter().map(|t| t * t).sum();
let sum_ty: f64 = times.iter().zip(values.iter()).map(|(t, y)| t * y).sum();
let slope = (n * sum_ty - sum_t * sum_y) / (n * sum_t2 - sum_t * sum_t);
let intercept = (sum_y - slope * sum_t) / n;
let forecast_time = (values.len() + horizon - 1) as f64;
forecast[[i, j, k]] = slope * forecast_time + intercept;
}
}
}
info!("Generated linear forecast for {} steps", horizon);
Ok(ForecastResult::new(forecast, horizon))
}
fn exponential_smoothing_forecast(
ts: &TimeSeriesRaster,
horizon: usize,
alpha: f64,
) -> Result<ForecastResult> {
if ts.is_empty() {
return Err(TemporalError::insufficient_data("Empty time series"));
}
if !(0.0..=1.0).contains(&alpha) {
return Err(TemporalError::invalid_parameter(
"alpha",
"must be between 0 and 1",
));
}
let (height, width, n_bands) = ts
.expected_shape()
.ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
let mut forecast = Array3::zeros((height, width, n_bands));
for i in 0..height {
for j in 0..width {
for k in 0..n_bands {
let values = ts.extract_pixel_timeseries(i, j, k)?;
let mut smoothed = values[0];
for &value in &values[1..] {
smoothed = alpha * value + (1.0 - alpha) * smoothed;
}
forecast[[i, j, k]] = smoothed;
}
}
}
info!(
"Generated exponential smoothing forecast (alpha={}) for {} steps",
alpha, horizon
);
Ok(ForecastResult::new(forecast, horizon))
}
fn moving_average_forecast(
ts: &TimeSeriesRaster,
horizon: usize,
window: usize,
) -> Result<ForecastResult> {
if ts.len() < window {
return Err(TemporalError::insufficient_data(format!(
"Need at least {} observations for window size {}",
window, window
)));
}
let (height, width, n_bands) = ts
.expected_shape()
.ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
let mut forecast = Array3::zeros((height, width, n_bands));
for i in 0..height {
for j in 0..width {
for k in 0..n_bands {
let values = ts.extract_pixel_timeseries(i, j, k)?;
let start_idx = values.len().saturating_sub(window);
let sum: f64 = values[start_idx..].iter().sum();
let avg = sum / (values.len() - start_idx) as f64;
forecast[[i, j, k]] = avg;
}
}
}
info!(
"Generated moving average forecast (window={}) for {} steps",
window, horizon
);
Ok(ForecastResult::new(forecast, horizon))
}
pub fn calculate_confidence(
ts: &TimeSeriesRaster,
forecast: &Array3<f64>,
confidence_level: f64,
) -> Result<(Array3<f64>, Array3<f64>)> {
if !(0.0..=1.0).contains(&confidence_level) {
return Err(TemporalError::invalid_parameter(
"confidence_level",
"must be between 0 and 1",
));
}
let (height, width, n_bands) = ts
.expected_shape()
.ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
let mut lower = Array3::zeros((height, width, n_bands));
let mut upper = Array3::zeros((height, width, n_bands));
let z = match confidence_level {
x if x >= 0.99 => 2.576,
x if x >= 0.95 => 1.96,
x if x >= 0.90 => 1.645,
_ => 1.0,
};
for i in 0..height {
for j in 0..width {
for k in 0..n_bands {
let values = ts.extract_pixel_timeseries(i, j, k)?;
let mean = values.iter().sum::<f64>() / values.len() as f64;
let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>()
/ values.len() as f64;
let std_error = variance.sqrt() / (values.len() as f64).sqrt();
let margin = z * std_error;
lower[[i, j, k]] = forecast[[i, j, k]] - margin;
upper[[i, j, k]] = forecast[[i, j, k]] + margin;
}
}
}
Ok((lower, upper))
}
}
#[derive(Debug, Clone, Copy)]
pub struct ForecastParams {
pub alpha: f64,
pub window_size: usize,
}
impl Default for ForecastParams {
fn default() -> Self {
Self {
alpha: 0.3,
window_size: 3,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::timeseries::{TemporalMetadata, TimeSeriesRaster};
use chrono::{DateTime, NaiveDate};
#[test]
fn test_linear_forecast() {
let mut ts = TimeSeriesRaster::new();
for i in 0..10 {
let dt = DateTime::from_timestamp(1640995200 + i * 86400, 0).expect("valid");
let date = NaiveDate::from_ymd_opt(2022, 1, 1 + i as u32).expect("valid");
let metadata = TemporalMetadata::new(dt, date);
let data = Array3::from_elem((3, 3, 1), (i * 2) as f64);
ts.add_raster(metadata, data).expect("should add");
}
let result = Forecaster::forecast(&ts, ForecastMethod::LinearExtrapolation, 5, None)
.expect("should forecast");
assert_eq!(result.horizon, 5);
assert!(result.forecast[[0, 0, 0]] > 18.0);
}
#[test]
fn test_exponential_smoothing() {
let mut ts = TimeSeriesRaster::new();
for i in 0..10 {
let dt = DateTime::from_timestamp(1640995200 + i * 86400, 0).expect("valid");
let date = NaiveDate::from_ymd_opt(2022, 1, 1 + i as u32).expect("valid");
let metadata = TemporalMetadata::new(dt, date);
let data = Array3::from_elem((3, 3, 1), 10.0 + (i as f64));
ts.add_raster(metadata, data).expect("should add");
}
let params = ForecastParams {
alpha: 0.5,
window_size: 3,
};
let result =
Forecaster::forecast(&ts, ForecastMethod::ExponentialSmoothing, 3, Some(params))
.expect("should forecast");
assert_eq!(result.horizon, 3);
assert!(result.forecast[[0, 0, 0]] > 10.0);
}
}