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 => self.seasonal_decomposition_trend(values),
65 }
66 }
67
68 fn mann_kendall(&self, values: &ArrayView1<f64>) -> Result<TrendResult> {
74 let n = values.len();
75 if n < 3 {
76 return Err(AnalyticsError::insufficient_data(
77 "Mann-Kendall test requires at least 3 data points",
78 ));
79 }
80
81 let mut s = 0i64;
83 for i in 0..n - 1 {
84 for j in (i + 1)..n {
85 let diff = values[j] - values[i];
86 if diff.abs() > f64::EPSILON {
88 s += diff.signum() as i64;
89 }
90 }
92 }
93
94 let n_f64 = n as f64;
96 let var_s = (n_f64 * (n_f64 - 1.0) * (2.0 * n_f64 + 5.0)) / 18.0;
97
98 let z = if s > 0 {
100 ((s - 1) as f64) / var_s.sqrt()
101 } else if s < 0 {
102 ((s + 1) as f64) / var_s.sqrt()
103 } else {
104 0.0
105 };
106
107 let p_value = 2.0 * (1.0 - standard_normal_cdf(z.abs()));
109
110 let tau = (2.0 * s as f64) / (n_f64 * (n_f64 - 1.0));
112
113 Ok(TrendResult {
114 direction: s.signum() as i8,
115 p_value,
116 magnitude: tau,
117 confidence: self.confidence,
118 significant: p_value < self.confidence,
119 })
120 }
121
122 fn linear_regression(&self, values: &ArrayView1<f64>) -> Result<TrendResult> {
126 let n = values.len();
127 if n < 2 {
128 return Err(AnalyticsError::insufficient_data(
129 "Linear regression requires at least 2 data points",
130 ));
131 }
132
133 let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
135
136 let x_mean = x.iter().sum::<f64>() / (n as f64);
138 let y_mean = values.sum() / (n as f64);
139
140 let mut numerator = 0.0;
142 let mut denominator = 0.0;
143
144 for i in 0..n {
145 let x_diff = x[i] - x_mean;
146 let y_diff = values[i] - y_mean;
147 numerator += x_diff * y_diff;
148 denominator += x_diff * x_diff;
149 }
150
151 if denominator.abs() < f64::EPSILON {
152 return Err(AnalyticsError::numerical_instability(
153 "Cannot compute slope: zero denominator",
154 ));
155 }
156
157 let slope = numerator / denominator;
158
159 let intercept = y_mean - slope * x_mean;
161 let mut ss_res = 0.0;
162 let mut ss_tot = 0.0;
163
164 for i in 0..n {
165 let y_pred = slope * x[i] + intercept;
166 let residual = values[i] - y_pred;
167 ss_res += residual * residual;
168 ss_tot += (values[i] - y_mean) * (values[i] - y_mean);
169 }
170
171 let _r_squared = if ss_tot > f64::EPSILON {
173 1.0 - (ss_res / ss_tot)
174 } else {
175 0.0
176 };
177
178 let se = if n > 2 {
180 (ss_res / ((n - 2) as f64) / denominator).sqrt()
181 } else {
182 f64::INFINITY
183 };
184
185 let t_stat = if se.is_finite() && se > f64::EPSILON {
187 slope / se
188 } else {
189 0.0
190 };
191
192 let df = (n - 2) as f64;
195 let p_value = if df > 0.0 {
196 2.0 * (1.0 - standard_normal_cdf(t_stat.abs()))
197 } else {
198 1.0
199 };
200
201 Ok(TrendResult {
202 direction: slope.signum() as i8,
203 p_value,
204 magnitude: slope,
205 confidence: self.confidence,
206 significant: p_value < self.confidence,
207 })
208 }
209
210 fn seasonal_decomposition_trend(&self, values: &ArrayView1<f64>) -> Result<TrendResult> {
219 let n = values.len();
220 if n < 4 {
221 return Err(AnalyticsError::insufficient_data(
222 "Seasonal decomposition trend requires at least 4 data points",
223 ));
224 }
225
226 let period = [12_usize, 7, 4, 2]
228 .iter()
229 .copied()
230 .find(|&p| n >= 2 * p)
231 .unwrap_or(2);
232
233 let decomposed = seasonal_decompose(values, period)?;
234
235 let trend_view = decomposed.trend.view();
237 self.linear_regression(&trend_view)
238 }
239}
240
241fn standard_normal_cdf(x: f64) -> f64 {
245 0.5 * (1.0 + erf(x / 2_f64.sqrt()))
246}
247
248fn erf(x: f64) -> f64 {
252 let sign = x.signum();
253 let x = x.abs();
254
255 let a1 = 0.254_829_592;
257 let a2 = -0.284_496_736;
258 let a3 = 1.421_413_741;
259 let a4 = -1.453_152_027;
260 let a5 = 1.061_405_429;
261 let p = 0.327_591_100;
262
263 let t = 1.0 / (1.0 + p * x);
264 let t2 = t * t;
265 let t3 = t2 * t;
266 let t4 = t3 * t;
267 let t5 = t4 * t;
268
269 let result = 1.0 - (a1 * t + a2 * t2 + a3 * t3 + a4 * t4 + a5 * t5) * (-x * x).exp();
270
271 sign * result
272}
273
274#[derive(Debug, Clone)]
276pub struct SeasonalDecomposition {
277 pub trend: Array1<f64>,
279 pub seasonal: Array1<f64>,
281 pub residual: Array1<f64>,
283}
284
285pub fn seasonal_decompose(
294 values: &ArrayView1<f64>,
295 period: usize,
296) -> Result<SeasonalDecomposition> {
297 let n = values.len();
298 if n < 2 * period {
299 return Err(AnalyticsError::insufficient_data(format!(
300 "Need at least {} data points for period {}",
301 2 * period,
302 period
303 )));
304 }
305
306 let mut trend = Array1::zeros(n);
308 let half_window = period / 2;
309
310 for i in half_window..(n - half_window) {
311 let start = i - half_window;
312 let end = i + half_window + 1;
313 let window = values.slice(s![start..end]);
314 trend[i] = window.sum() / (period as f64);
315 }
316
317 for i in 0..half_window {
319 trend[i] = trend[half_window];
320 }
321 for i in (n - half_window)..n {
322 trend[i] = trend[n - half_window - 1];
323 }
324
325 let detrended = values - &trend;
327
328 let mut seasonal = Array1::zeros(n);
330 let mut season_sums = vec![0.0; period];
331 let mut season_counts = vec![0; period];
332
333 for (i, &value) in detrended.iter().enumerate() {
334 let season_idx = i % period;
335 season_sums[season_idx] += value;
336 season_counts[season_idx] += 1;
337 }
338
339 let season_avgs: Vec<f64> = season_sums
341 .iter()
342 .zip(season_counts.iter())
343 .map(|(sum, count)| {
344 if *count > 0 {
345 sum / (*count as f64)
346 } else {
347 0.0
348 }
349 })
350 .collect();
351
352 let season_mean = season_avgs.iter().sum::<f64>() / (period as f64);
354 let season_normalized: Vec<f64> = season_avgs.iter().map(|x| x - season_mean).collect();
355
356 for (i, value) in seasonal.iter_mut().enumerate() {
358 *value = season_normalized[i % period];
359 }
360
361 let residual = values - &trend - &seasonal;
363
364 Ok(SeasonalDecomposition {
365 trend,
366 seasonal,
367 residual,
368 })
369}
370
371use scirs2_core::ndarray::s;
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use approx::assert_abs_diff_eq;
378 use scirs2_core::ndarray::array;
379
380 #[test]
381 fn test_mann_kendall_positive_trend() {
382 let values = array![1.0, 2.0, 3.0, 4.0, 5.0];
383 let detector = TrendDetector::new(TrendMethod::MannKendall, 0.05);
384 let result = detector
385 .detect(&values.view())
386 .expect("Mann-Kendall detection should succeed for valid data");
387
388 assert_eq!(result.direction, 1);
389 assert!(result.p_value < 0.05);
390 assert!(result.significant);
391 }
392
393 #[test]
394 fn test_mann_kendall_negative_trend() {
395 let values = array![5.0, 4.0, 3.0, 2.0, 1.0];
396 let detector = TrendDetector::new(TrendMethod::MannKendall, 0.05);
397 let result = detector
398 .detect(&values.view())
399 .expect("Mann-Kendall detection should succeed for negative trend");
400
401 assert_eq!(result.direction, -1);
402 assert!(result.p_value < 0.05);
403 assert!(result.significant);
404 }
405
406 #[test]
407 fn test_mann_kendall_no_trend() {
408 let values = array![1.0, 1.0, 1.0, 1.0, 1.0];
409 let detector = TrendDetector::new(TrendMethod::MannKendall, 0.05);
410 let result = detector
411 .detect(&values.view())
412 .expect("Mann-Kendall detection should succeed for no trend data");
413
414 assert_eq!(result.direction, 0);
415 assert!(!result.significant);
416 }
417
418 #[test]
419 fn test_linear_regression() {
420 let values = array![1.0, 2.0, 3.0, 4.0, 5.0];
421 let detector = TrendDetector::new(TrendMethod::LinearRegression, 0.05);
422 let result = detector
423 .detect(&values.view())
424 .expect("Linear regression should succeed for valid data");
425
426 assert_eq!(result.direction, 1);
427 assert_abs_diff_eq!(result.magnitude, 1.0, epsilon = 1e-10);
428 }
429
430 #[test]
431 fn test_seasonal_decompose() {
432 let n = 24;
434 let period = 6;
435 let mut values = Array1::zeros(n);
436 for i in 0..n {
437 values[i] = (i as f64) + ((i % period) as f64);
439 }
440
441 let result = seasonal_decompose(&values.view(), period)
442 .expect("Seasonal decomposition should succeed for valid data");
443 assert_eq!(result.trend.len(), n);
444 assert_eq!(result.seasonal.len(), n);
445 assert_eq!(result.residual.len(), n);
446 }
447
448 #[test]
449 fn test_standard_normal_cdf() {
450 assert_abs_diff_eq!(standard_normal_cdf(0.0), 0.5, epsilon = 1e-6);
452 assert!(standard_normal_cdf(1.96) > 0.975);
453 assert!(standard_normal_cdf(-1.96) < 0.025);
454 }
455
456 #[test]
457 fn test_seasonal_decomposition_trend_positive() {
458 let n = 24;
460 let period = 6;
461 let mut values = Array1::zeros(n);
462 for i in 0..n {
463 values[i] = (i as f64) * 2.0 + ((i % period) as f64);
464 }
465 let detector = TrendDetector::new(TrendMethod::SeasonalDecomposition, 0.05);
466 let result = detector
467 .detect(&values.view())
468 .expect("Seasonal decomposition trend should succeed");
469 assert_eq!(result.direction, 1, "should detect upward trend");
470 assert!(result.magnitude > 0.0, "slope should be positive");
471 }
472
473 #[test]
474 fn test_seasonal_decomposition_trend_negative() {
475 let n = 24;
476 let period = 4;
477 let mut values = Array1::zeros(n);
478 for i in 0..n {
479 values[i] = -(i as f64) * 1.5 + ((i % period) as f64) * 0.5;
480 }
481 let detector = TrendDetector::new(TrendMethod::SeasonalDecomposition, 0.05);
482 let result = detector
483 .detect(&values.view())
484 .expect("Seasonal decomposition trend should succeed for negative trend");
485 assert_eq!(result.direction, -1, "should detect downward trend");
486 }
487
488 #[test]
489 fn test_seasonal_decomposition_trend_too_short() {
490 let values = array![1.0, 2.0, 3.0];
491 let detector = TrendDetector::new(TrendMethod::SeasonalDecomposition, 0.05);
492 let result = detector.detect(&values.view());
493 assert!(result.is_err(), "should fail with fewer than 4 data points");
494 }
495}