Skip to main content

oxigdal_analytics/timeseries/
trend.rs

1//! Trend Detection for Time Series
2//!
3//! This module provides various trend detection methods including:
4//! - Mann-Kendall test (non-parametric trend test)
5//! - Linear regression trend
6//! - Seasonal decomposition
7
8use crate::error::{AnalyticsError, Result};
9use scirs2_core::ndarray::{Array1, ArrayView1};
10
11/// Trend detection methods
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum TrendMethod {
14    /// Mann-Kendall non-parametric trend test
15    MannKendall,
16    /// Linear regression
17    LinearRegression,
18    /// Seasonal trend decomposition
19    SeasonalDecomposition,
20}
21
22/// Result of trend detection
23#[derive(Debug, Clone)]
24pub struct TrendResult {
25    /// Trend direction: positive (1), negative (-1), or no trend (0)
26    pub direction: i8,
27    /// Statistical significance (p-value)
28    pub p_value: f64,
29    /// Trend magnitude (slope or Kendall's tau)
30    pub magnitude: f64,
31    /// Confidence level (typically 0.05 or 0.01)
32    pub confidence: f64,
33    /// Whether trend is statistically significant
34    pub significant: bool,
35}
36
37/// Trend detector for time series analysis
38pub struct TrendDetector {
39    method: TrendMethod,
40    confidence: f64,
41}
42
43impl TrendDetector {
44    /// Create a new trend detector
45    ///
46    /// # Arguments
47    /// * `method` - Trend detection method
48    /// * `confidence` - Confidence level for significance testing (e.g., 0.05 for 95%)
49    pub fn new(method: TrendMethod, confidence: f64) -> Self {
50        Self { method, confidence }
51    }
52
53    /// Detect trend in time series
54    ///
55    /// # Arguments
56    /// * `values` - Time series values
57    ///
58    /// # Errors
59    /// Returns error if computation fails or insufficient data
60    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    /// Mann-Kendall trend test
69    ///
70    /// Non-parametric test for monotonic trend detection.
71    /// Null hypothesis: no trend
72    /// Alternative: monotonic trend exists
73    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        // Calculate S statistic
82        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                // Note: f64::signum() returns 1.0 for 0.0, so we need to check explicitly
87                if diff.abs() > f64::EPSILON {
88                    s += diff.signum() as i64;
89                }
90                // If diff is ~0, add nothing (no contribution to trend)
91            }
92        }
93
94        // Calculate variance
95        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        // Calculate standardized test statistic Z
99        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        // Calculate p-value (two-tailed test)
108        let p_value = 2.0 * (1.0 - standard_normal_cdf(z.abs()));
109
110        // Calculate Kendall's tau
111        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    /// Linear regression trend
123    ///
124    /// Fits a linear trend line y = ax + b
125    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        // Create time indices
134        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
135
136        // Calculate means
137        let x_mean = x.iter().sum::<f64>() / (n as f64);
138        let y_mean = values.sum() / (n as f64);
139
140        // Calculate slope and intercept
141        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        // Calculate residuals and standard error
160        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        // Calculate R-squared
172        let _r_squared = if ss_tot > f64::EPSILON {
173            1.0 - (ss_res / ss_tot)
174        } else {
175            0.0
176        };
177
178        // Calculate standard error of slope
179        let se = if n > 2 {
180            (ss_res / ((n - 2) as f64) / denominator).sqrt()
181        } else {
182            f64::INFINITY
183        };
184
185        // Calculate t-statistic
186        let t_stat = if se.is_finite() && se > f64::EPSILON {
187            slope / se
188        } else {
189            0.0
190        };
191
192        // Approximate p-value using t-distribution (simplified)
193        // For production use, should use proper t-distribution CDF
194        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    /// Detect trend via seasonal decomposition.
211    ///
212    /// Decomposes the series using a centered moving-average seasonal model, then
213    /// applies linear regression to the extracted trend component to produce a
214    /// `TrendResult`. The seasonality period is heuristically chosen as the
215    /// largest of {4, 7, 12} that still satisfies the minimum data requirement
216    /// (at least `2 * period` observations), falling back to `period = 2` for
217    /// very short series.
218    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        // Choose the largest candidate period supported by the data length.
227        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        // Run linear regression on the extracted trend component.
236        let trend_view = decomposed.trend.view();
237        self.linear_regression(&trend_view)
238    }
239}
240
241/// Standard normal cumulative distribution function
242///
243/// Approximation using the error function
244fn standard_normal_cdf(x: f64) -> f64 {
245    0.5 * (1.0 + erf(x / 2_f64.sqrt()))
246}
247
248/// Error function approximation
249///
250/// Uses Abramowitz and Stegun approximation (maximum error: 1.5e-7)
251fn erf(x: f64) -> f64 {
252    let sign = x.signum();
253    let x = x.abs();
254
255    // Constants
256    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/// Seasonal decomposition result
275#[derive(Debug, Clone)]
276pub struct SeasonalDecomposition {
277    /// Trend component
278    pub trend: Array1<f64>,
279    /// Seasonal component
280    pub seasonal: Array1<f64>,
281    /// Residual component
282    pub residual: Array1<f64>,
283}
284
285/// Perform seasonal decomposition
286///
287/// # Arguments
288/// * `values` - Time series values
289/// * `period` - Period of seasonality
290///
291/// # Errors
292/// Returns error if computation fails
293pub 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    // Calculate trend using centered moving average
307    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    // Fill edges with simple extrapolation
318    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    // Calculate detrended series
326    let detrended = values - &trend;
327
328    // Calculate seasonal component (average for each season)
329    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    // Average seasonal components
340    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    // Normalize seasonal component (sum to zero)
353    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    // Apply seasonal component
357    for (i, value) in seasonal.iter_mut().enumerate() {
358        *value = season_normalized[i % period];
359    }
360
361    // Calculate residuals
362    let residual = values - &trend - &seasonal;
363
364    Ok(SeasonalDecomposition {
365        trend,
366        seasonal,
367        residual,
368    })
369}
370
371// Import slice macro for ndarray
372use 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        // Create synthetic seasonal data
433        let n = 24;
434        let period = 6;
435        let mut values = Array1::zeros(n);
436        for i in 0..n {
437            // Trend + seasonal component
438            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        // Test known values
451        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        // Trend + periodic component: trend should be detected as positive
459        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}