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 => Err(AnalyticsError::time_series_error(
65                "Seasonal decomposition not yet implemented",
66            )),
67        }
68    }
69
70    /// Mann-Kendall trend test
71    ///
72    /// Non-parametric test for monotonic trend detection.
73    /// Null hypothesis: no trend
74    /// Alternative: monotonic trend exists
75    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        // Calculate S statistic
84        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                // Note: f64::signum() returns 1.0 for 0.0, so we need to check explicitly
89                if diff.abs() > f64::EPSILON {
90                    s += diff.signum() as i64;
91                }
92                // If diff is ~0, add nothing (no contribution to trend)
93            }
94        }
95
96        // Calculate variance
97        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        // Calculate standardized test statistic Z
101        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        // Calculate p-value (two-tailed test)
110        let p_value = 2.0 * (1.0 - standard_normal_cdf(z.abs()));
111
112        // Calculate Kendall's tau
113        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    /// Linear regression trend
125    ///
126    /// Fits a linear trend line y = ax + b
127    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        // Create time indices
136        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
137
138        // Calculate means
139        let x_mean = x.iter().sum::<f64>() / (n as f64);
140        let y_mean = values.sum() / (n as f64);
141
142        // Calculate slope and intercept
143        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        // Calculate residuals and standard error
162        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        // Calculate R-squared
174        let _r_squared = if ss_tot > f64::EPSILON {
175            1.0 - (ss_res / ss_tot)
176        } else {
177            0.0
178        };
179
180        // Calculate standard error of slope
181        let se = if n > 2 {
182            (ss_res / ((n - 2) as f64) / denominator).sqrt()
183        } else {
184            f64::INFINITY
185        };
186
187        // Calculate t-statistic
188        let t_stat = if se.is_finite() && se > f64::EPSILON {
189            slope / se
190        } else {
191            0.0
192        };
193
194        // Approximate p-value using t-distribution (simplified)
195        // For production use, should use proper t-distribution CDF
196        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
213/// Standard normal cumulative distribution function
214///
215/// Approximation using the error function
216fn standard_normal_cdf(x: f64) -> f64 {
217    0.5 * (1.0 + erf(x / 2_f64.sqrt()))
218}
219
220/// Error function approximation
221///
222/// Uses Abramowitz and Stegun approximation (maximum error: 1.5e-7)
223fn erf(x: f64) -> f64 {
224    let sign = x.signum();
225    let x = x.abs();
226
227    // Constants
228    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/// Seasonal decomposition result
247#[derive(Debug, Clone)]
248pub struct SeasonalDecomposition {
249    /// Trend component
250    pub trend: Array1<f64>,
251    /// Seasonal component
252    pub seasonal: Array1<f64>,
253    /// Residual component
254    pub residual: Array1<f64>,
255}
256
257/// Perform seasonal decomposition
258///
259/// # Arguments
260/// * `values` - Time series values
261/// * `period` - Period of seasonality
262///
263/// # Errors
264/// Returns error if computation fails
265pub 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    // Calculate trend using centered moving average
279    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    // Fill edges with simple extrapolation
290    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    // Calculate detrended series
298    let detrended = values - &trend;
299
300    // Calculate seasonal component (average for each season)
301    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    // Average seasonal components
312    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    // Normalize seasonal component (sum to zero)
325    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    // Apply seasonal component
329    for (i, value) in seasonal.iter_mut().enumerate() {
330        *value = season_normalized[i % period];
331    }
332
333    // Calculate residuals
334    let residual = values - &trend - &seasonal;
335
336    Ok(SeasonalDecomposition {
337        trend,
338        seasonal,
339        residual,
340    })
341}
342
343// Import slice macro for ndarray
344use 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        // Create synthetic seasonal data
405        let n = 24;
406        let period = 6;
407        let mut values = Array1::zeros(n);
408        for i in 0..n {
409            // Trend + seasonal component
410            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        // Test known values
423        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}