Skip to main content

oxigdal_analytics/timeseries/
mod.rs

1//! Time Series Analysis Module
2//!
3//! This module provides comprehensive time series analysis capabilities for geospatial data,
4//! including trend detection, anomaly detection, seasonal decomposition, and gap filling.
5
6pub mod anomaly;
7pub mod trend;
8
9pub use anomaly::{AnomalyDetector, AnomalyMethod, AnomalyResult};
10pub use trend::{TrendDetector, TrendMethod, TrendResult};
11
12use crate::error::{AnalyticsError, Result};
13use scirs2_core::ndarray::Array1;
14
15/// Time series data point
16#[derive(Debug, Clone, Copy)]
17pub struct TimePoint {
18    /// Time index or timestamp
19    pub time: f64,
20    /// Value at this time point
21    pub value: f64,
22}
23
24/// Time series with temporal metadata
25#[derive(Debug, Clone)]
26pub struct TimeSeries {
27    /// Time points
28    pub times: Array1<f64>,
29    /// Values
30    pub values: Array1<f64>,
31}
32
33impl TimeSeries {
34    /// Create a new time series
35    ///
36    /// # Arguments
37    /// * `times` - Time indices or timestamps
38    /// * `values` - Corresponding values
39    ///
40    /// # Errors
41    /// Returns error if dimensions don't match or data is empty
42    pub fn new(times: Array1<f64>, values: Array1<f64>) -> Result<Self> {
43        if times.len() != values.len() {
44            return Err(AnalyticsError::dimension_mismatch(
45                format!("{}", times.len()),
46                format!("{}", values.len()),
47            ));
48        }
49
50        if times.is_empty() {
51            return Err(AnalyticsError::insufficient_data(
52                "Time series must have at least one data point",
53            ));
54        }
55
56        Ok(Self { times, values })
57    }
58
59    /// Get the length of the time series
60    #[must_use]
61    pub fn len(&self) -> usize {
62        self.values.len()
63    }
64
65    /// Check if time series is empty
66    #[must_use]
67    pub fn is_empty(&self) -> bool {
68        self.values.is_empty()
69    }
70
71    /// Get the time range
72    #[must_use]
73    pub fn time_range(&self) -> Option<(f64, f64)> {
74        if self.is_empty() {
75            return None;
76        }
77
78        let min = self
79            .times
80            .iter()
81            .copied()
82            .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))?;
83        let max = self
84            .times
85            .iter()
86            .copied()
87            .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))?;
88
89        Some((min, max))
90    }
91
92    /// Calculate simple moving average
93    ///
94    /// # Arguments
95    /// * `window_size` - Size of the moving window
96    ///
97    /// # Errors
98    /// Returns error if window size is invalid
99    pub fn moving_average(&self, window_size: usize) -> Result<Array1<f64>> {
100        if window_size == 0 {
101            return Err(AnalyticsError::invalid_parameter(
102                "window_size",
103                "must be greater than 0",
104            ));
105        }
106
107        if window_size > self.len() {
108            return Err(AnalyticsError::invalid_parameter(
109                "window_size",
110                "must not exceed series length",
111            ));
112        }
113
114        let mut result = Array1::zeros(self.len() - window_size + 1);
115
116        for i in 0..result.len() {
117            let window = self.values.slice(s![i..i + window_size]);
118            result[i] = window.sum() / (window_size as f64);
119        }
120
121        Ok(result)
122    }
123
124    /// Calculate exponential moving average
125    ///
126    /// # Arguments
127    /// * `alpha` - Smoothing factor (0 < alpha <= 1)
128    ///
129    /// # Errors
130    /// Returns error if alpha is out of range
131    pub fn exponential_moving_average(&self, alpha: f64) -> Result<Array1<f64>> {
132        if !(0.0 < alpha && alpha <= 1.0) {
133            return Err(AnalyticsError::invalid_parameter(
134                "alpha",
135                "must be in range (0, 1]",
136            ));
137        }
138
139        let mut result = Array1::zeros(self.len());
140        result[0] = self.values[0];
141
142        for i in 1..self.len() {
143            result[i] = alpha * self.values[i] + (1.0 - alpha) * result[i - 1];
144        }
145
146        Ok(result)
147    }
148
149    /// Linear interpolation for gap filling
150    ///
151    /// # Arguments
152    /// * `mask` - Boolean mask where true indicates missing values
153    ///
154    /// # Errors
155    /// Returns error if all values are missing or interpolation fails
156    pub fn linear_interpolate(&self, mask: &Array1<bool>) -> Result<Array1<f64>> {
157        if mask.len() != self.len() {
158            return Err(AnalyticsError::dimension_mismatch(
159                format!("{}", self.len()),
160                format!("{}", mask.len()),
161            ));
162        }
163
164        let mut result = self.values.clone();
165
166        // Find first and last valid indices
167        let first_valid = mask
168            .iter()
169            .position(|&x| !x)
170            .ok_or_else(|| AnalyticsError::insufficient_data("All values are missing"))?;
171        let last_valid = mask
172            .iter()
173            .rposition(|&x| !x)
174            .ok_or_else(|| AnalyticsError::insufficient_data("All values are missing"))?;
175
176        // Interpolate gaps
177        let mut last_valid_idx = first_valid;
178        for i in (first_valid + 1)..=last_valid {
179            if mask[i] {
180                // Find next valid index
181                let next_valid_idx =
182                    ((i + 1)..=last_valid).find(|&j| !mask[j]).ok_or_else(|| {
183                        AnalyticsError::insufficient_data("Cannot interpolate at end")
184                    })?;
185
186                // Linear interpolation
187                let x0 = self.times[last_valid_idx];
188                let x1 = self.times[next_valid_idx];
189                let y0 = self.values[last_valid_idx];
190                let y1 = self.values[next_valid_idx];
191                let x = self.times[i];
192
193                result[i] = y0 + (y1 - y0) * (x - x0) / (x1 - x0);
194            } else {
195                last_valid_idx = i;
196            }
197        }
198
199        Ok(result)
200    }
201}
202
203// Import ndarray slice macro
204use scirs2_core::ndarray::s;
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use approx::assert_abs_diff_eq;
210    use scirs2_core::ndarray::array;
211
212    #[test]
213    fn test_time_series_creation() {
214        let times = array![1.0, 2.0, 3.0, 4.0, 5.0];
215        let values = array![10.0, 20.0, 15.0, 25.0, 30.0];
216        let ts = TimeSeries::new(times, values)
217            .expect("TimeSeries creation should succeed with matching dimensions");
218
219        assert_eq!(ts.len(), 5);
220        assert!(!ts.is_empty());
221    }
222
223    #[test]
224    fn test_time_series_dimension_mismatch() {
225        let times = array![1.0, 2.0, 3.0];
226        let values = array![10.0, 20.0];
227        let result = TimeSeries::new(times, values);
228
229        assert!(result.is_err());
230    }
231
232    #[test]
233    fn test_moving_average() {
234        let times = array![1.0, 2.0, 3.0, 4.0, 5.0];
235        let values = array![10.0, 20.0, 30.0, 40.0, 50.0];
236        let ts = TimeSeries::new(times, values)
237            .expect("TimeSeries creation should succeed with matching dimensions");
238
239        let ma = ts
240            .moving_average(3)
241            .expect("Moving average calculation should succeed with valid window size");
242        assert_eq!(ma.len(), 3);
243        assert_abs_diff_eq!(ma[0], 20.0, epsilon = 1e-10);
244        assert_abs_diff_eq!(ma[1], 30.0, epsilon = 1e-10);
245        assert_abs_diff_eq!(ma[2], 40.0, epsilon = 1e-10);
246    }
247
248    #[test]
249    fn test_exponential_moving_average() {
250        let times = array![1.0, 2.0, 3.0, 4.0, 5.0];
251        let values = array![10.0, 20.0, 30.0, 40.0, 50.0];
252        let ts = TimeSeries::new(times, values)
253            .expect("TimeSeries creation should succeed with matching dimensions");
254
255        let ema = ts
256            .exponential_moving_average(0.5)
257            .expect("EMA calculation should succeed with valid alpha");
258        assert_eq!(ema.len(), 5);
259        assert_abs_diff_eq!(ema[0], 10.0, epsilon = 1e-10);
260    }
261
262    #[test]
263    fn test_linear_interpolate() {
264        let times = array![1.0, 2.0, 3.0, 4.0, 5.0];
265        let values = array![10.0, 20.0, 0.0, 40.0, 50.0]; // 0.0 is placeholder for missing
266        let mask = array![false, false, true, false, false];
267        let ts = TimeSeries::new(times, values)
268            .expect("TimeSeries creation should succeed with matching dimensions");
269
270        let interpolated = ts
271            .linear_interpolate(&mask)
272            .expect("Linear interpolation should succeed with valid mask");
273        assert_abs_diff_eq!(interpolated[2], 30.0, epsilon = 1e-10);
274    }
275}