oxigdal_temporal/analysis/
trend.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum TrendMethod {
18 Linear,
20 MannKendall,
22 SensSlope,
24 TheilSen,
26}
27
28#[derive(Debug, Clone)]
30pub struct TrendResult {
31 pub slope: Array3<f64>,
33 pub intercept: Array3<f64>,
35 pub pvalue: Option<Array3<f64>>,
37 pub direction: Array3<i8>,
39 pub magnitude: Option<Array3<f64>>,
41}
42
43impl TrendResult {
44 #[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 #[must_use]
58 pub fn with_pvalue(mut self, pvalue: Array3<f64>) -> Self {
59 self.pvalue = Some(pvalue);
60 self
61 }
62
63 #[must_use]
65 pub fn with_magnitude(mut self, magnitude: Array3<f64>) -> Self {
66 self.magnitude = Some(magnitude);
67 self
68 }
69}
70
71pub struct TrendAnalyzer;
73
74impl TrendAnalyzer {
75 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 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 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 #[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 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 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 let var_s = (n * (n - 1) * (2 * n + 5)) as f64 / 18.0;
198
199 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 let p = 2.0 * (1.0 - Self::normal_cdf(z.abs()));
210
211 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 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 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 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 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 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 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 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 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 fn normal_cdf(x: f64) -> f64 {
351 0.5 * (1.0 + Self::erf(x / 2.0_f64.sqrt()))
352 }
353
354 fn erf(x: f64) -> f64 {
356 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 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); 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}