oxigdal_analytics/timeseries/
mod.rs1pub 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#[derive(Debug, Clone, Copy)]
17pub struct TimePoint {
18 pub time: f64,
20 pub value: f64,
22}
23
24#[derive(Debug, Clone)]
26pub struct TimeSeries {
27 pub times: Array1<f64>,
29 pub values: Array1<f64>,
31}
32
33impl TimeSeries {
34 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 #[must_use]
61 pub fn len(&self) -> usize {
62 self.values.len()
63 }
64
65 #[must_use]
67 pub fn is_empty(&self) -> bool {
68 self.values.is_empty()
69 }
70
71 #[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 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 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 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 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 let mut last_valid_idx = first_valid;
178 for i in (first_valid + 1)..=last_valid {
179 if mask[i] {
180 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 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
203use 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]; 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}