use crate::error::{AnalyticsError, Result};
use scirs2_core::ndarray::{Array1, ArrayView1};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrendMethod {
MannKendall,
LinearRegression,
SeasonalDecomposition,
}
#[derive(Debug, Clone)]
pub struct TrendResult {
pub direction: i8,
pub p_value: f64,
pub magnitude: f64,
pub confidence: f64,
pub significant: bool,
}
pub struct TrendDetector {
method: TrendMethod,
confidence: f64,
}
impl TrendDetector {
pub fn new(method: TrendMethod, confidence: f64) -> Self {
Self { method, confidence }
}
pub fn detect(&self, values: &ArrayView1<f64>) -> Result<TrendResult> {
match self.method {
TrendMethod::MannKendall => self.mann_kendall(values),
TrendMethod::LinearRegression => self.linear_regression(values),
TrendMethod::SeasonalDecomposition => Err(AnalyticsError::time_series_error(
"Seasonal decomposition not yet implemented",
)),
}
}
fn mann_kendall(&self, values: &ArrayView1<f64>) -> Result<TrendResult> {
let n = values.len();
if n < 3 {
return Err(AnalyticsError::insufficient_data(
"Mann-Kendall test requires at least 3 data points",
));
}
let mut s = 0i64;
for i in 0..n - 1 {
for j in (i + 1)..n {
let diff = values[j] - values[i];
if diff.abs() > f64::EPSILON {
s += diff.signum() as i64;
}
}
}
let n_f64 = n as f64;
let var_s = (n_f64 * (n_f64 - 1.0) * (2.0 * n_f64 + 5.0)) / 18.0;
let z = if s > 0 {
((s - 1) as f64) / var_s.sqrt()
} else if s < 0 {
((s + 1) as f64) / var_s.sqrt()
} else {
0.0
};
let p_value = 2.0 * (1.0 - standard_normal_cdf(z.abs()));
let tau = (2.0 * s as f64) / (n_f64 * (n_f64 - 1.0));
Ok(TrendResult {
direction: s.signum() as i8,
p_value,
magnitude: tau,
confidence: self.confidence,
significant: p_value < self.confidence,
})
}
fn linear_regression(&self, values: &ArrayView1<f64>) -> Result<TrendResult> {
let n = values.len();
if n < 2 {
return Err(AnalyticsError::insufficient_data(
"Linear regression requires at least 2 data points",
));
}
let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
let x_mean = x.iter().sum::<f64>() / (n as f64);
let y_mean = values.sum() / (n as f64);
let mut numerator = 0.0;
let mut denominator = 0.0;
for i in 0..n {
let x_diff = x[i] - x_mean;
let y_diff = values[i] - y_mean;
numerator += x_diff * y_diff;
denominator += x_diff * x_diff;
}
if denominator.abs() < f64::EPSILON {
return Err(AnalyticsError::numerical_instability(
"Cannot compute slope: zero denominator",
));
}
let slope = numerator / denominator;
let intercept = y_mean - slope * x_mean;
let mut ss_res = 0.0;
let mut ss_tot = 0.0;
for i in 0..n {
let y_pred = slope * x[i] + intercept;
let residual = values[i] - y_pred;
ss_res += residual * residual;
ss_tot += (values[i] - y_mean) * (values[i] - y_mean);
}
let _r_squared = if ss_tot > f64::EPSILON {
1.0 - (ss_res / ss_tot)
} else {
0.0
};
let se = if n > 2 {
(ss_res / ((n - 2) as f64) / denominator).sqrt()
} else {
f64::INFINITY
};
let t_stat = if se.is_finite() && se > f64::EPSILON {
slope / se
} else {
0.0
};
let df = (n - 2) as f64;
let p_value = if df > 0.0 {
2.0 * (1.0 - standard_normal_cdf(t_stat.abs()))
} else {
1.0
};
Ok(TrendResult {
direction: slope.signum() as i8,
p_value,
magnitude: slope,
confidence: self.confidence,
significant: p_value < self.confidence,
})
}
}
fn standard_normal_cdf(x: f64) -> f64 {
0.5 * (1.0 + erf(x / 2_f64.sqrt()))
}
fn erf(x: f64) -> f64 {
let sign = x.signum();
let x = x.abs();
let a1 = 0.254_829_592;
let a2 = -0.284_496_736;
let a3 = 1.421_413_741;
let a4 = -1.453_152_027;
let a5 = 1.061_405_429;
let p = 0.327_591_100;
let t = 1.0 / (1.0 + p * x);
let t2 = t * t;
let t3 = t2 * t;
let t4 = t3 * t;
let t5 = t4 * t;
let result = 1.0 - (a1 * t + a2 * t2 + a3 * t3 + a4 * t4 + a5 * t5) * (-x * x).exp();
sign * result
}
#[derive(Debug, Clone)]
pub struct SeasonalDecomposition {
pub trend: Array1<f64>,
pub seasonal: Array1<f64>,
pub residual: Array1<f64>,
}
pub fn seasonal_decompose(
values: &ArrayView1<f64>,
period: usize,
) -> Result<SeasonalDecomposition> {
let n = values.len();
if n < 2 * period {
return Err(AnalyticsError::insufficient_data(format!(
"Need at least {} data points for period {}",
2 * period,
period
)));
}
let mut trend = Array1::zeros(n);
let half_window = period / 2;
for i in half_window..(n - half_window) {
let start = i - half_window;
let end = i + half_window + 1;
let window = values.slice(s![start..end]);
trend[i] = window.sum() / (period as f64);
}
for i in 0..half_window {
trend[i] = trend[half_window];
}
for i in (n - half_window)..n {
trend[i] = trend[n - half_window - 1];
}
let detrended = values - &trend;
let mut seasonal = Array1::zeros(n);
let mut season_sums = vec![0.0; period];
let mut season_counts = vec![0; period];
for (i, &value) in detrended.iter().enumerate() {
let season_idx = i % period;
season_sums[season_idx] += value;
season_counts[season_idx] += 1;
}
let season_avgs: Vec<f64> = season_sums
.iter()
.zip(season_counts.iter())
.map(|(sum, count)| {
if *count > 0 {
sum / (*count as f64)
} else {
0.0
}
})
.collect();
let season_mean = season_avgs.iter().sum::<f64>() / (period as f64);
let season_normalized: Vec<f64> = season_avgs.iter().map(|x| x - season_mean).collect();
for (i, value) in seasonal.iter_mut().enumerate() {
*value = season_normalized[i % period];
}
let residual = values - &trend - &seasonal;
Ok(SeasonalDecomposition {
trend,
seasonal,
residual,
})
}
use scirs2_core::ndarray::s;
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
use scirs2_core::ndarray::array;
#[test]
fn test_mann_kendall_positive_trend() {
let values = array![1.0, 2.0, 3.0, 4.0, 5.0];
let detector = TrendDetector::new(TrendMethod::MannKendall, 0.05);
let result = detector
.detect(&values.view())
.expect("Mann-Kendall detection should succeed for valid data");
assert_eq!(result.direction, 1);
assert!(result.p_value < 0.05);
assert!(result.significant);
}
#[test]
fn test_mann_kendall_negative_trend() {
let values = array![5.0, 4.0, 3.0, 2.0, 1.0];
let detector = TrendDetector::new(TrendMethod::MannKendall, 0.05);
let result = detector
.detect(&values.view())
.expect("Mann-Kendall detection should succeed for negative trend");
assert_eq!(result.direction, -1);
assert!(result.p_value < 0.05);
assert!(result.significant);
}
#[test]
fn test_mann_kendall_no_trend() {
let values = array![1.0, 1.0, 1.0, 1.0, 1.0];
let detector = TrendDetector::new(TrendMethod::MannKendall, 0.05);
let result = detector
.detect(&values.view())
.expect("Mann-Kendall detection should succeed for no trend data");
assert_eq!(result.direction, 0);
assert!(!result.significant);
}
#[test]
fn test_linear_regression() {
let values = array![1.0, 2.0, 3.0, 4.0, 5.0];
let detector = TrendDetector::new(TrendMethod::LinearRegression, 0.05);
let result = detector
.detect(&values.view())
.expect("Linear regression should succeed for valid data");
assert_eq!(result.direction, 1);
assert_abs_diff_eq!(result.magnitude, 1.0, epsilon = 1e-10);
}
#[test]
fn test_seasonal_decompose() {
let n = 24;
let period = 6;
let mut values = Array1::zeros(n);
for i in 0..n {
values[i] = (i as f64) + ((i % period) as f64);
}
let result = seasonal_decompose(&values.view(), period)
.expect("Seasonal decomposition should succeed for valid data");
assert_eq!(result.trend.len(), n);
assert_eq!(result.seasonal.len(), n);
assert_eq!(result.residual.len(), n);
}
#[test]
fn test_standard_normal_cdf() {
assert_abs_diff_eq!(standard_normal_cdf(0.0), 0.5, epsilon = 1e-6);
assert!(standard_normal_cdf(1.96) > 0.975);
assert!(standard_normal_cdf(-1.96) < 0.025);
}
}