Skip to main content

oxigdal_temporal/analysis/
trend.rs

1//! Trend Analysis Module
2//!
3//! Implements trend detection algorithms including linear trends, Mann-Kendall test,
4//! Sen's slope estimator, and Theil-Sen regression for robust trend analysis.
5
6use crate::error::{Result, TemporalError};
7use crate::timeseries::TimeSeriesRaster;
8use scirs2_core::ndarray::Array3;
9use serde::{Deserialize, Serialize};
10use tracing::info;
11
12#[cfg(feature = "parallel")]
13use rayon::prelude::*;
14
15/// Trend analysis method
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum TrendMethod {
18    /// Linear trend (OLS regression)
19    Linear,
20    /// Mann-Kendall test for monotonic trend
21    MannKendall,
22    /// Sen's slope estimator (robust)
23    SensSlope,
24    /// Theil-Sen estimator
25    TheilSen,
26}
27
28/// Trend analysis result
29#[derive(Debug, Clone)]
30pub struct TrendResult {
31    /// Trend slope (change per time unit)
32    pub slope: Array3<f64>,
33    /// Trend intercept
34    pub intercept: Array3<f64>,
35    /// Statistical significance (p-value)
36    pub pvalue: Option<Array3<f64>>,
37    /// Trend direction (-1: negative, 0: no trend, 1: positive)
38    pub direction: Array3<i8>,
39    /// Trend strength/magnitude
40    pub magnitude: Option<Array3<f64>>,
41}
42
43impl TrendResult {
44    /// Create new trend result
45    #[must_use]
46    pub fn new(slope: Array3<f64>, intercept: Array3<f64>, direction: Array3<i8>) -> Self {
47        Self {
48            slope,
49            intercept,
50            pvalue: None,
51            direction,
52            magnitude: None,
53        }
54    }
55
56    /// Add p-values
57    #[must_use]
58    pub fn with_pvalue(mut self, pvalue: Array3<f64>) -> Self {
59        self.pvalue = Some(pvalue);
60        self
61    }
62
63    /// Add magnitude
64    #[must_use]
65    pub fn with_magnitude(mut self, magnitude: Array3<f64>) -> Self {
66        self.magnitude = Some(magnitude);
67        self
68    }
69}
70
71/// Trend analyzer
72pub struct TrendAnalyzer;
73
74impl TrendAnalyzer {
75    /// Analyze trends in time series
76    ///
77    /// # Errors
78    /// Returns error if analysis fails
79    pub fn analyze(ts: &TimeSeriesRaster, method: TrendMethod) -> Result<TrendResult> {
80        match method {
81            TrendMethod::Linear => Self::linear_trend(ts),
82            TrendMethod::MannKendall => Self::mann_kendall(ts),
83            TrendMethod::SensSlope | TrendMethod::TheilSen => Self::sens_slope(ts),
84        }
85    }
86
87    /// Linear trend analysis using OLS
88    fn linear_trend(ts: &TimeSeriesRaster) -> Result<TrendResult> {
89        if ts.len() < 3 {
90            return Err(TemporalError::insufficient_data(
91                "Need at least 3 observations",
92            ));
93        }
94
95        let (height, width, n_bands) = ts
96            .expected_shape()
97            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
98
99        let mut slope = Array3::zeros((height, width, n_bands));
100        let mut intercept = Array3::zeros((height, width, n_bands));
101
102        // Collect time indices
103        let times: Vec<f64> = (0..ts.len()).map(|i| i as f64).collect();
104        let n = times.len() as f64;
105        let sum_t: f64 = times.iter().sum();
106        let sum_t2: f64 = times.iter().map(|&t| t * t).sum();
107
108        // Compute OLS for each pixel
109        #[cfg(feature = "parallel")]
110        {
111            use std::sync::Mutex;
112            let slope_mutex = Mutex::new(&mut slope);
113            let intercept_mutex = Mutex::new(&mut intercept);
114
115            (0..height).into_par_iter().for_each(|i| {
116                for j in 0..width {
117                    for k in 0..n_bands {
118                        let values = ts.extract_pixel_timeseries(i, j, k).ok();
119                        if let Some(values) = values {
120                            let sum_y: f64 = values.iter().copied().sum();
121                            let sum_ty: f64 =
122                                times.iter().zip(values.iter()).map(|(t, y)| t * y).sum();
123
124                            let slope_val =
125                                (n * sum_ty - sum_t * sum_y) / (n * sum_t2 - sum_t * sum_t);
126                            let intercept_val = (sum_y - slope_val * sum_t) / n;
127
128                            if let Ok(mut s) = slope_mutex.lock() {
129                                s[[i, j, k]] = slope_val;
130                            }
131                            if let Ok(mut int) = intercept_mutex.lock() {
132                                int[[i, j, k]] = intercept_val;
133                            }
134                        }
135                    }
136                }
137            });
138        }
139
140        #[cfg(not(feature = "parallel"))]
141        {
142            for i in 0..height {
143                for j in 0..width {
144                    for k in 0..n_bands {
145                        let values = ts.extract_pixel_timeseries(i, j, k)?;
146                        let sum_y: f64 = values.iter().sum();
147                        let sum_ty: f64 = times.iter().zip(values.iter()).map(|(t, y)| t * y).sum();
148
149                        let slope_val = (n * sum_ty - sum_t * sum_y) / (n * sum_t2 - sum_t * sum_t);
150                        let intercept_val = (sum_y - slope_val * sum_t) / n;
151
152                        slope[[i, j, k]] = slope_val;
153                        intercept[[i, j, k]] = intercept_val;
154                    }
155                }
156            }
157        }
158
159        let direction = Self::compute_direction(&slope);
160
161        info!("Completed linear trend analysis");
162        Ok(TrendResult::new(slope, intercept, direction))
163    }
164
165    /// Mann-Kendall trend test
166    fn mann_kendall(ts: &TimeSeriesRaster) -> Result<TrendResult> {
167        if ts.len() < 4 {
168            return Err(TemporalError::insufficient_data(
169                "Mann-Kendall requires at least 4 observations",
170            ));
171        }
172
173        let (height, width, n_bands) = ts
174            .expected_shape()
175            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
176
177        let mut slope = Array3::zeros((height, width, n_bands));
178        let mut intercept = Array3::zeros((height, width, n_bands));
179        let mut pvalue = Array3::zeros((height, width, n_bands));
180
181        let n = ts.len();
182
183        for i in 0..height {
184            for j in 0..width {
185                for k in 0..n_bands {
186                    let values = ts.extract_pixel_timeseries(i, j, k)?;
187
188                    // Calculate Mann-Kendall S statistic
189                    let mut s = 0i32;
190                    for m in 0..n {
191                        for l in (m + 1)..n {
192                            s += Self::sign(values[l] - values[m]);
193                        }
194                    }
195
196                    // Calculate variance
197                    let var_s = (n * (n - 1) * (2 * n + 5)) as f64 / 18.0;
198
199                    // Calculate Z-score
200                    let z = if s > 0 {
201                        (s as f64 - 1.0) / var_s.sqrt()
202                    } else if s < 0 {
203                        (s as f64 + 1.0) / var_s.sqrt()
204                    } else {
205                        0.0
206                    };
207
208                    // Calculate p-value (two-tailed test)
209                    let p = 2.0 * (1.0 - Self::normal_cdf(z.abs()));
210
211                    // Calculate Sen's slope for magnitude
212                    let mut slopes = Vec::new();
213                    for m in 0..n {
214                        for l in (m + 1)..n {
215                            if l != m {
216                                slopes.push((values[l] - values[m]) / ((l - m) as f64));
217                            }
218                        }
219                    }
220                    slopes.sort_by(|a: &f64, b: &f64| {
221                        a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
222                    });
223                    let median_slope = if slopes.len() % 2 == 0 {
224                        (slopes[slopes.len() / 2 - 1] + slopes[slopes.len() / 2]) / 2.0
225                    } else {
226                        slopes[slopes.len() / 2]
227                    };
228
229                    slope[[i, j, k]] = median_slope;
230                    pvalue[[i, j, k]] = p;
231
232                    // Compute intercept
233                    let median_intercept = Self::compute_intercept(&values, median_slope);
234                    intercept[[i, j, k]] = median_intercept;
235                }
236            }
237        }
238
239        let direction = Self::compute_direction(&slope);
240
241        info!("Completed Mann-Kendall trend analysis");
242        Ok(TrendResult::new(slope, intercept, direction).with_pvalue(pvalue))
243    }
244
245    /// Sen's slope estimator (robust trend)
246    fn sens_slope(ts: &TimeSeriesRaster) -> Result<TrendResult> {
247        if ts.len() < 3 {
248            return Err(TemporalError::insufficient_data(
249                "Need at least 3 observations",
250            ));
251        }
252
253        let (height, width, n_bands) = ts
254            .expected_shape()
255            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
256
257        let mut slope = Array3::zeros((height, width, n_bands));
258        let mut intercept = Array3::zeros((height, width, n_bands));
259
260        for i in 0..height {
261            for j in 0..width {
262                for k in 0..n_bands {
263                    let values = ts.extract_pixel_timeseries(i, j, k)?;
264
265                    // Compute all pairwise slopes
266                    let mut slopes = Vec::new();
267                    for m in 0..values.len() {
268                        for n in (m + 1)..values.len() {
269                            let slope_mn = (values[n] - values[m]) / ((n - m) as f64);
270                            slopes.push(slope_mn);
271                        }
272                    }
273
274                    // Median slope
275                    slopes.sort_by(|a: &f64, b: &f64| {
276                        a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
277                    });
278                    let median_slope = if slopes.len() % 2 == 0 {
279                        (slopes[slopes.len() / 2 - 1] + slopes[slopes.len() / 2]) / 2.0
280                    } else {
281                        slopes[slopes.len() / 2]
282                    };
283
284                    slope[[i, j, k]] = median_slope;
285
286                    // Compute intercept as median of (y - slope * x)
287                    let median_intercept = Self::compute_intercept(&values, median_slope);
288                    intercept[[i, j, k]] = median_intercept;
289                }
290            }
291        }
292
293        let direction = Self::compute_direction(&slope);
294
295        info!("Completed Sen's slope trend analysis");
296        Ok(TrendResult::new(slope, intercept, direction))
297    }
298
299    /// Compute trend direction from slope
300    fn compute_direction(slope: &Array3<f64>) -> Array3<i8> {
301        let shape = slope.shape();
302        let mut direction = Array3::zeros((shape[0], shape[1], shape[2]));
303
304        for i in 0..shape[0] {
305            for j in 0..shape[1] {
306                for k in 0..shape[2] {
307                    let s = slope[[i, j, k]];
308                    direction[[i, j, k]] = if s > 0.0 {
309                        1
310                    } else if s < 0.0 {
311                        -1
312                    } else {
313                        0
314                    };
315                }
316            }
317        }
318
319        direction
320    }
321
322    /// Compute intercept from values and slope
323    fn compute_intercept(values: &[f64], slope: f64) -> f64 {
324        let mut intercepts: Vec<f64> = values
325            .iter()
326            .enumerate()
327            .map(|(idx, &y)| y - slope * idx as f64)
328            .collect();
329
330        intercepts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
331        if intercepts.len() % 2 == 0 {
332            (intercepts[intercepts.len() / 2 - 1] + intercepts[intercepts.len() / 2]) / 2.0
333        } else {
334            intercepts[intercepts.len() / 2]
335        }
336    }
337
338    /// Sign function for Mann-Kendall
339    fn sign(x: f64) -> i32 {
340        if x > 0.0 {
341            1
342        } else if x < 0.0 {
343            -1
344        } else {
345            0
346        }
347    }
348
349    /// Approximate normal CDF
350    fn normal_cdf(x: f64) -> f64 {
351        0.5 * (1.0 + Self::erf(x / 2.0_f64.sqrt()))
352    }
353
354    /// Error function approximation
355    fn erf(x: f64) -> f64 {
356        // Abramowitz and Stegun approximation
357        let a1 = 0.254829592;
358        let a2 = -0.284496736;
359        let a3 = 1.421413741;
360        let a4 = -1.453152027;
361        let a5 = 1.061405429;
362        let p = 0.3275911;
363
364        let sign = if x < 0.0 { -1.0 } else { 1.0 };
365        let x = x.abs();
366
367        let t = 1.0 / (1.0 + p * x);
368        let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
369
370        sign * y
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use crate::timeseries::{TemporalMetadata, TimeSeriesRaster};
378    use chrono::{DateTime, NaiveDate};
379
380    #[test]
381    fn test_linear_trend() {
382        let mut ts = TimeSeriesRaster::new();
383
384        for i in 0..10 {
385            let dt = DateTime::from_timestamp(1640995200 + i * 86400, 0).expect("valid");
386            let date = NaiveDate::from_ymd_opt(2022, 1, 1 + i as u32).expect("valid");
387            let metadata = TemporalMetadata::new(dt, date);
388            let data = Array3::from_elem((5, 5, 1), i as f64);
389            ts.add_raster(metadata, data).expect("should add");
390        }
391
392        let result = TrendAnalyzer::analyze(&ts, TrendMethod::Linear).expect("should analyze");
393
394        // Slope should be positive (increasing trend)
395        assert!(result.slope[[0, 0, 0]] > 0.0);
396        assert_eq!(result.direction[[0, 0, 0]], 1);
397    }
398
399    #[test]
400    fn test_sens_slope() {
401        let mut ts = TimeSeriesRaster::new();
402
403        for i in 0..10 {
404            let dt = DateTime::from_timestamp(1640995200 + i * 86400, 0).expect("valid");
405            let date = NaiveDate::from_ymd_opt(2022, 1, 1 + i as u32).expect("valid");
406            let metadata = TemporalMetadata::new(dt, date);
407            let data = Array3::from_elem((5, 5, 1), (i * 2) as f64);
408            ts.add_raster(metadata, data).expect("should add");
409        }
410
411        let result = TrendAnalyzer::analyze(&ts, TrendMethod::SensSlope).expect("should analyze");
412
413        assert!(result.slope[[0, 0, 0]] > 0.0);
414        assert_eq!(result.direction[[0, 0, 0]], 1);
415    }
416
417    #[test]
418    fn test_mann_kendall() {
419        let mut ts = TimeSeriesRaster::new();
420
421        for i in 0..10 {
422            let dt = DateTime::from_timestamp(1640995200 + i * 86400, 0).expect("valid");
423            let date = NaiveDate::from_ymd_opt(2022, 1, 1 + i as u32).expect("valid");
424            let metadata = TemporalMetadata::new(dt, date);
425            let data = Array3::from_elem((5, 5, 1), (i * i) as f64); // Non-linear trend
426            ts.add_raster(metadata, data).expect("should add");
427        }
428
429        let result = TrendAnalyzer::analyze(&ts, TrendMethod::MannKendall).expect("should analyze");
430
431        assert!(result.slope[[0, 0, 0]] > 0.0);
432        assert_eq!(result.direction[[0, 0, 0]], 1);
433        assert!(result.pvalue.is_some());
434    }
435}