Skip to main content

oxigdal_temporal/analysis/
forecast.rs

1//! Time Series Forecasting Module
2//!
3//! Implements forecasting algorithms for predicting future values
4//! in temporal data including simple methods and more advanced techniques.
5
6use crate::error::{Result, TemporalError};
7use crate::timeseries::TimeSeriesRaster;
8use scirs2_core::ndarray::Array3;
9use serde::{Deserialize, Serialize};
10use tracing::info;
11
12/// Forecasting method
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub enum ForecastMethod {
15    /// Last value propagation
16    LastValue,
17    /// Mean of historical values
18    Mean,
19    /// Linear extrapolation
20    LinearExtrapolation,
21    /// Exponential smoothing
22    ExponentialSmoothing,
23    /// Moving average
24    MovingAverage,
25}
26
27/// Forecast result
28#[derive(Debug, Clone)]
29pub struct ForecastResult {
30    /// Forecasted values
31    pub forecast: Array3<f64>,
32    /// Confidence intervals (lower bound)
33    pub lower_bound: Option<Array3<f64>>,
34    /// Confidence intervals (upper bound)
35    pub upper_bound: Option<Array3<f64>>,
36    /// Forecast horizon (number of steps ahead)
37    pub horizon: usize,
38}
39
40impl ForecastResult {
41    /// Create new forecast result
42    #[must_use]
43    pub fn new(forecast: Array3<f64>, horizon: usize) -> Self {
44        Self {
45            forecast,
46            lower_bound: None,
47            upper_bound: None,
48            horizon,
49        }
50    }
51
52    /// Add confidence intervals
53    #[must_use]
54    pub fn with_confidence(mut self, lower_bound: Array3<f64>, upper_bound: Array3<f64>) -> Self {
55        self.lower_bound = Some(lower_bound);
56        self.upper_bound = Some(upper_bound);
57        self
58    }
59}
60
61/// Time series forecaster
62pub struct Forecaster;
63
64impl Forecaster {
65    /// Generate forecast for time series
66    ///
67    /// # Errors
68    /// Returns error if forecasting fails
69    pub fn forecast(
70        ts: &TimeSeriesRaster,
71        method: ForecastMethod,
72        horizon: usize,
73        params: Option<ForecastParams>,
74    ) -> Result<ForecastResult> {
75        if horizon == 0 {
76            return Err(TemporalError::invalid_parameter(
77                "horizon",
78                "must be greater than 0",
79            ));
80        }
81
82        match method {
83            ForecastMethod::LastValue => Self::last_value_forecast(ts, horizon),
84            ForecastMethod::Mean => Self::mean_forecast(ts, horizon),
85            ForecastMethod::LinearExtrapolation => Self::linear_forecast(ts, horizon),
86            ForecastMethod::ExponentialSmoothing => {
87                let alpha = params.map_or(0.3, |p| p.alpha);
88                Self::exponential_smoothing_forecast(ts, horizon, alpha)
89            }
90            ForecastMethod::MovingAverage => {
91                let window = params.map_or(3, |p| p.window_size);
92                Self::moving_average_forecast(ts, horizon, window)
93            }
94        }
95    }
96
97    /// Last value propagation forecast
98    fn last_value_forecast(ts: &TimeSeriesRaster, horizon: usize) -> Result<ForecastResult> {
99        if ts.is_empty() {
100            return Err(TemporalError::insufficient_data("Empty time series"));
101        }
102
103        // Validate shape exists
104        let (_height, _width, _n_bands) = ts
105            .expected_shape()
106            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
107
108        let last_entry = ts.get_by_index(ts.len() - 1)?;
109        let last_data = last_entry
110            .data
111            .as_ref()
112            .ok_or_else(|| TemporalError::invalid_input("Data not loaded"))?;
113
114        let forecast = last_data.clone();
115
116        info!("Generated last value forecast for {} steps", horizon);
117        Ok(ForecastResult::new(forecast, horizon))
118    }
119
120    /// Mean forecast
121    fn mean_forecast(ts: &TimeSeriesRaster, horizon: usize) -> Result<ForecastResult> {
122        if ts.is_empty() {
123            return Err(TemporalError::insufficient_data("Empty time series"));
124        }
125
126        let (height, width, n_bands) = ts
127            .expected_shape()
128            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
129
130        let mut forecast = Array3::zeros((height, width, n_bands));
131
132        for i in 0..height {
133            for j in 0..width {
134                for k in 0..n_bands {
135                    let values = ts.extract_pixel_timeseries(i, j, k)?;
136                    let mean = values.iter().sum::<f64>() / values.len() as f64;
137                    forecast[[i, j, k]] = mean;
138                }
139            }
140        }
141
142        info!("Generated mean forecast for {} steps", horizon);
143        Ok(ForecastResult::new(forecast, horizon))
144    }
145
146    /// Linear extrapolation forecast
147    fn linear_forecast(ts: &TimeSeriesRaster, horizon: usize) -> Result<ForecastResult> {
148        if ts.len() < 2 {
149            return Err(TemporalError::insufficient_data(
150                "Need at least 2 observations",
151            ));
152        }
153
154        let (height, width, n_bands) = ts
155            .expected_shape()
156            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
157
158        let mut forecast = Array3::zeros((height, width, n_bands));
159
160        for i in 0..height {
161            for j in 0..width {
162                for k in 0..n_bands {
163                    let values = ts.extract_pixel_timeseries(i, j, k)?;
164
165                    // Fit linear trend
166                    let n = values.len() as f64;
167                    let times: Vec<f64> = (0..values.len()).map(|t| t as f64).collect();
168
169                    let sum_t: f64 = times.iter().sum();
170                    let sum_y: f64 = values.iter().sum();
171                    let sum_t2: f64 = times.iter().map(|t| t * t).sum();
172                    let sum_ty: f64 = times.iter().zip(values.iter()).map(|(t, y)| t * y).sum();
173
174                    let slope = (n * sum_ty - sum_t * sum_y) / (n * sum_t2 - sum_t * sum_t);
175                    let intercept = (sum_y - slope * sum_t) / n;
176
177                    // Extrapolate
178                    let forecast_time = (values.len() + horizon - 1) as f64;
179                    forecast[[i, j, k]] = slope * forecast_time + intercept;
180                }
181            }
182        }
183
184        info!("Generated linear forecast for {} steps", horizon);
185        Ok(ForecastResult::new(forecast, horizon))
186    }
187
188    /// Exponential smoothing forecast
189    fn exponential_smoothing_forecast(
190        ts: &TimeSeriesRaster,
191        horizon: usize,
192        alpha: f64,
193    ) -> Result<ForecastResult> {
194        if ts.is_empty() {
195            return Err(TemporalError::insufficient_data("Empty time series"));
196        }
197
198        if !(0.0..=1.0).contains(&alpha) {
199            return Err(TemporalError::invalid_parameter(
200                "alpha",
201                "must be between 0 and 1",
202            ));
203        }
204
205        let (height, width, n_bands) = ts
206            .expected_shape()
207            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
208
209        let mut forecast = Array3::zeros((height, width, n_bands));
210
211        for i in 0..height {
212            for j in 0..width {
213                for k in 0..n_bands {
214                    let values = ts.extract_pixel_timeseries(i, j, k)?;
215
216                    // Initialize with first value
217                    let mut smoothed = values[0];
218
219                    // Apply exponential smoothing
220                    for &value in &values[1..] {
221                        smoothed = alpha * value + (1.0 - alpha) * smoothed;
222                    }
223
224                    forecast[[i, j, k]] = smoothed;
225                }
226            }
227        }
228
229        info!(
230            "Generated exponential smoothing forecast (alpha={}) for {} steps",
231            alpha, horizon
232        );
233        Ok(ForecastResult::new(forecast, horizon))
234    }
235
236    /// Moving average forecast
237    fn moving_average_forecast(
238        ts: &TimeSeriesRaster,
239        horizon: usize,
240        window: usize,
241    ) -> Result<ForecastResult> {
242        if ts.len() < window {
243            return Err(TemporalError::insufficient_data(format!(
244                "Need at least {} observations for window size {}",
245                window, window
246            )));
247        }
248
249        let (height, width, n_bands) = ts
250            .expected_shape()
251            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
252
253        let mut forecast = Array3::zeros((height, width, n_bands));
254
255        for i in 0..height {
256            for j in 0..width {
257                for k in 0..n_bands {
258                    let values = ts.extract_pixel_timeseries(i, j, k)?;
259
260                    // Calculate moving average from last window
261                    let start_idx = values.len().saturating_sub(window);
262                    let sum: f64 = values[start_idx..].iter().sum();
263                    let avg = sum / (values.len() - start_idx) as f64;
264
265                    forecast[[i, j, k]] = avg;
266                }
267            }
268        }
269
270        info!(
271            "Generated moving average forecast (window={}) for {} steps",
272            window, horizon
273        );
274        Ok(ForecastResult::new(forecast, horizon))
275    }
276
277    /// Calculate forecast confidence intervals
278    ///
279    /// # Errors
280    /// Returns error if calculation fails
281    pub fn calculate_confidence(
282        ts: &TimeSeriesRaster,
283        forecast: &Array3<f64>,
284        confidence_level: f64,
285    ) -> Result<(Array3<f64>, Array3<f64>)> {
286        if !(0.0..=1.0).contains(&confidence_level) {
287            return Err(TemporalError::invalid_parameter(
288                "confidence_level",
289                "must be between 0 and 1",
290            ));
291        }
292
293        let (height, width, n_bands) = ts
294            .expected_shape()
295            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
296
297        let mut lower = Array3::zeros((height, width, n_bands));
298        let mut upper = Array3::zeros((height, width, n_bands));
299
300        // Z-score for confidence level (approximation)
301        let z = match confidence_level {
302            x if x >= 0.99 => 2.576,
303            x if x >= 0.95 => 1.96,
304            x if x >= 0.90 => 1.645,
305            _ => 1.0,
306        };
307
308        for i in 0..height {
309            for j in 0..width {
310                for k in 0..n_bands {
311                    let values = ts.extract_pixel_timeseries(i, j, k)?;
312
313                    // Calculate standard error
314                    let mean = values.iter().sum::<f64>() / values.len() as f64;
315                    let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>()
316                        / values.len() as f64;
317                    let std_error = variance.sqrt() / (values.len() as f64).sqrt();
318
319                    let margin = z * std_error;
320                    lower[[i, j, k]] = forecast[[i, j, k]] - margin;
321                    upper[[i, j, k]] = forecast[[i, j, k]] + margin;
322                }
323            }
324        }
325
326        Ok((lower, upper))
327    }
328}
329
330/// Forecast parameters
331#[derive(Debug, Clone, Copy)]
332pub struct ForecastParams {
333    /// Alpha parameter for exponential smoothing
334    pub alpha: f64,
335    /// Window size for moving average
336    pub window_size: usize,
337}
338
339impl Default for ForecastParams {
340    fn default() -> Self {
341        Self {
342            alpha: 0.3,
343            window_size: 3,
344        }
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::timeseries::{TemporalMetadata, TimeSeriesRaster};
352    use chrono::{DateTime, NaiveDate};
353
354    #[test]
355    fn test_linear_forecast() {
356        let mut ts = TimeSeriesRaster::new();
357
358        for i in 0..10 {
359            let dt = DateTime::from_timestamp(1640995200 + i * 86400, 0).expect("valid");
360            let date = NaiveDate::from_ymd_opt(2022, 1, 1 + i as u32).expect("valid");
361            let metadata = TemporalMetadata::new(dt, date);
362            let data = Array3::from_elem((3, 3, 1), (i * 2) as f64);
363            ts.add_raster(metadata, data).expect("should add");
364        }
365
366        let result = Forecaster::forecast(&ts, ForecastMethod::LinearExtrapolation, 5, None)
367            .expect("should forecast");
368
369        assert_eq!(result.horizon, 5);
370        // Should extrapolate the linear trend
371        assert!(result.forecast[[0, 0, 0]] > 18.0);
372    }
373
374    #[test]
375    fn test_exponential_smoothing() {
376        let mut ts = TimeSeriesRaster::new();
377
378        for i in 0..10 {
379            let dt = DateTime::from_timestamp(1640995200 + i * 86400, 0).expect("valid");
380            let date = NaiveDate::from_ymd_opt(2022, 1, 1 + i as u32).expect("valid");
381            let metadata = TemporalMetadata::new(dt, date);
382            let data = Array3::from_elem((3, 3, 1), 10.0 + (i as f64));
383            ts.add_raster(metadata, data).expect("should add");
384        }
385
386        let params = ForecastParams {
387            alpha: 0.5,
388            window_size: 3,
389        };
390
391        let result =
392            Forecaster::forecast(&ts, ForecastMethod::ExponentialSmoothing, 3, Some(params))
393                .expect("should forecast");
394
395        assert_eq!(result.horizon, 3);
396        assert!(result.forecast[[0, 0, 0]] > 10.0);
397    }
398}