oxigdal_analytics/timeseries/
trend.rs1use crate::error::{AnalyticsError, Result};
9use scirs2_core::ndarray::{Array1, ArrayView1};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum TrendMethod {
14 MannKendall,
16 LinearRegression,
18 SeasonalDecomposition,
20}
21
22#[derive(Debug, Clone)]
24pub struct TrendResult {
25 pub direction: i8,
27 pub p_value: f64,
29 pub magnitude: f64,
31 pub confidence: f64,
33 pub significant: bool,
35}
36
37pub struct TrendDetector {
39 method: TrendMethod,
40 confidence: f64,
41}
42
43impl TrendDetector {
44 pub fn new(method: TrendMethod, confidence: f64) -> Self {
50 Self { method, confidence }
51 }
52
53 pub fn detect(&self, values: &ArrayView1<f64>) -> Result<TrendResult> {
61 match self.method {
62 TrendMethod::MannKendall => self.mann_kendall(values),
63 TrendMethod::LinearRegression => self.linear_regression(values),
64 TrendMethod::SeasonalDecomposition => Err(AnalyticsError::time_series_error(
65 "Seasonal decomposition not yet implemented",
66 )),
67 }
68 }
69
70 fn mann_kendall(&self, values: &ArrayView1<f64>) -> Result<TrendResult> {
76 let n = values.len();
77 if n < 3 {
78 return Err(AnalyticsError::insufficient_data(
79 "Mann-Kendall test requires at least 3 data points",
80 ));
81 }
82
83 let mut s = 0i64;
85 for i in 0..n - 1 {
86 for j in (i + 1)..n {
87 let diff = values[j] - values[i];
88 if diff.abs() > f64::EPSILON {
90 s += diff.signum() as i64;
91 }
92 }
94 }
95
96 let n_f64 = n as f64;
98 let var_s = (n_f64 * (n_f64 - 1.0) * (2.0 * n_f64 + 5.0)) / 18.0;
99
100 let z = if s > 0 {
102 ((s - 1) as f64) / var_s.sqrt()
103 } else if s < 0 {
104 ((s + 1) as f64) / var_s.sqrt()
105 } else {
106 0.0
107 };
108
109 let p_value = 2.0 * (1.0 - standard_normal_cdf(z.abs()));
111
112 let tau = (2.0 * s as f64) / (n_f64 * (n_f64 - 1.0));
114
115 Ok(TrendResult {
116 direction: s.signum() as i8,
117 p_value,
118 magnitude: tau,
119 confidence: self.confidence,
120 significant: p_value < self.confidence,
121 })
122 }
123
124 fn linear_regression(&self, values: &ArrayView1<f64>) -> Result<TrendResult> {
128 let n = values.len();
129 if n < 2 {
130 return Err(AnalyticsError::insufficient_data(
131 "Linear regression requires at least 2 data points",
132 ));
133 }
134
135 let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
137
138 let x_mean = x.iter().sum::<f64>() / (n as f64);
140 let y_mean = values.sum() / (n as f64);
141
142 let mut numerator = 0.0;
144 let mut denominator = 0.0;
145
146 for i in 0..n {
147 let x_diff = x[i] - x_mean;
148 let y_diff = values[i] - y_mean;
149 numerator += x_diff * y_diff;
150 denominator += x_diff * x_diff;
151 }
152
153 if denominator.abs() < f64::EPSILON {
154 return Err(AnalyticsError::numerical_instability(
155 "Cannot compute slope: zero denominator",
156 ));
157 }
158
159 let slope = numerator / denominator;
160
161 let intercept = y_mean - slope * x_mean;
163 let mut ss_res = 0.0;
164 let mut ss_tot = 0.0;
165
166 for i in 0..n {
167 let y_pred = slope * x[i] + intercept;
168 let residual = values[i] - y_pred;
169 ss_res += residual * residual;
170 ss_tot += (values[i] - y_mean) * (values[i] - y_mean);
171 }
172
173 let _r_squared = if ss_tot > f64::EPSILON {
175 1.0 - (ss_res / ss_tot)
176 } else {
177 0.0
178 };
179
180 let se = if n > 2 {
182 (ss_res / ((n - 2) as f64) / denominator).sqrt()
183 } else {
184 f64::INFINITY
185 };
186
187 let t_stat = if se.is_finite() && se > f64::EPSILON {
189 slope / se
190 } else {
191 0.0
192 };
193
194 let df = (n - 2) as f64;
197 let p_value = if df > 0.0 {
198 2.0 * (1.0 - standard_normal_cdf(t_stat.abs()))
199 } else {
200 1.0
201 };
202
203 Ok(TrendResult {
204 direction: slope.signum() as i8,
205 p_value,
206 magnitude: slope,
207 confidence: self.confidence,
208 significant: p_value < self.confidence,
209 })
210 }
211}
212
213fn standard_normal_cdf(x: f64) -> f64 {
217 0.5 * (1.0 + erf(x / 2_f64.sqrt()))
218}
219
220fn erf(x: f64) -> f64 {
224 let sign = x.signum();
225 let x = x.abs();
226
227 let a1 = 0.254_829_592;
229 let a2 = -0.284_496_736;
230 let a3 = 1.421_413_741;
231 let a4 = -1.453_152_027;
232 let a5 = 1.061_405_429;
233 let p = 0.327_591_100;
234
235 let t = 1.0 / (1.0 + p * x);
236 let t2 = t * t;
237 let t3 = t2 * t;
238 let t4 = t3 * t;
239 let t5 = t4 * t;
240
241 let result = 1.0 - (a1 * t + a2 * t2 + a3 * t3 + a4 * t4 + a5 * t5) * (-x * x).exp();
242
243 sign * result
244}
245
246#[derive(Debug, Clone)]
248pub struct SeasonalDecomposition {
249 pub trend: Array1<f64>,
251 pub seasonal: Array1<f64>,
253 pub residual: Array1<f64>,
255}
256
257pub fn seasonal_decompose(
266 values: &ArrayView1<f64>,
267 period: usize,
268) -> Result<SeasonalDecomposition> {
269 let n = values.len();
270 if n < 2 * period {
271 return Err(AnalyticsError::insufficient_data(format!(
272 "Need at least {} data points for period {}",
273 2 * period,
274 period
275 )));
276 }
277
278 let mut trend = Array1::zeros(n);
280 let half_window = period / 2;
281
282 for i in half_window..(n - half_window) {
283 let start = i - half_window;
284 let end = i + half_window + 1;
285 let window = values.slice(s![start..end]);
286 trend[i] = window.sum() / (period as f64);
287 }
288
289 for i in 0..half_window {
291 trend[i] = trend[half_window];
292 }
293 for i in (n - half_window)..n {
294 trend[i] = trend[n - half_window - 1];
295 }
296
297 let detrended = values - &trend;
299
300 let mut seasonal = Array1::zeros(n);
302 let mut season_sums = vec![0.0; period];
303 let mut season_counts = vec![0; period];
304
305 for (i, &value) in detrended.iter().enumerate() {
306 let season_idx = i % period;
307 season_sums[season_idx] += value;
308 season_counts[season_idx] += 1;
309 }
310
311 let season_avgs: Vec<f64> = season_sums
313 .iter()
314 .zip(season_counts.iter())
315 .map(|(sum, count)| {
316 if *count > 0 {
317 sum / (*count as f64)
318 } else {
319 0.0
320 }
321 })
322 .collect();
323
324 let season_mean = season_avgs.iter().sum::<f64>() / (period as f64);
326 let season_normalized: Vec<f64> = season_avgs.iter().map(|x| x - season_mean).collect();
327
328 for (i, value) in seasonal.iter_mut().enumerate() {
330 *value = season_normalized[i % period];
331 }
332
333 let residual = values - &trend - &seasonal;
335
336 Ok(SeasonalDecomposition {
337 trend,
338 seasonal,
339 residual,
340 })
341}
342
343use scirs2_core::ndarray::s;
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use approx::assert_abs_diff_eq;
350 use scirs2_core::ndarray::array;
351
352 #[test]
353 fn test_mann_kendall_positive_trend() {
354 let values = array![1.0, 2.0, 3.0, 4.0, 5.0];
355 let detector = TrendDetector::new(TrendMethod::MannKendall, 0.05);
356 let result = detector
357 .detect(&values.view())
358 .expect("Mann-Kendall detection should succeed for valid data");
359
360 assert_eq!(result.direction, 1);
361 assert!(result.p_value < 0.05);
362 assert!(result.significant);
363 }
364
365 #[test]
366 fn test_mann_kendall_negative_trend() {
367 let values = array![5.0, 4.0, 3.0, 2.0, 1.0];
368 let detector = TrendDetector::new(TrendMethod::MannKendall, 0.05);
369 let result = detector
370 .detect(&values.view())
371 .expect("Mann-Kendall detection should succeed for negative trend");
372
373 assert_eq!(result.direction, -1);
374 assert!(result.p_value < 0.05);
375 assert!(result.significant);
376 }
377
378 #[test]
379 fn test_mann_kendall_no_trend() {
380 let values = array![1.0, 1.0, 1.0, 1.0, 1.0];
381 let detector = TrendDetector::new(TrendMethod::MannKendall, 0.05);
382 let result = detector
383 .detect(&values.view())
384 .expect("Mann-Kendall detection should succeed for no trend data");
385
386 assert_eq!(result.direction, 0);
387 assert!(!result.significant);
388 }
389
390 #[test]
391 fn test_linear_regression() {
392 let values = array![1.0, 2.0, 3.0, 4.0, 5.0];
393 let detector = TrendDetector::new(TrendMethod::LinearRegression, 0.05);
394 let result = detector
395 .detect(&values.view())
396 .expect("Linear regression should succeed for valid data");
397
398 assert_eq!(result.direction, 1);
399 assert_abs_diff_eq!(result.magnitude, 1.0, epsilon = 1e-10);
400 }
401
402 #[test]
403 fn test_seasonal_decompose() {
404 let n = 24;
406 let period = 6;
407 let mut values = Array1::zeros(n);
408 for i in 0..n {
409 values[i] = (i as f64) + ((i % period) as f64);
411 }
412
413 let result = seasonal_decompose(&values.view(), period)
414 .expect("Seasonal decomposition should succeed for valid data");
415 assert_eq!(result.trend.len(), n);
416 assert_eq!(result.seasonal.len(), n);
417 assert_eq!(result.residual.len(), n);
418 }
419
420 #[test]
421 fn test_standard_normal_cdf() {
422 assert_abs_diff_eq!(standard_normal_cdf(0.0), 0.5, epsilon = 1e-6);
424 assert!(standard_normal_cdf(1.96) > 0.975);
425 assert!(standard_normal_cdf(-1.96) < 0.025);
426 }
427}