use scirs2_core::ndarray::{Array2, ArrayView1, ArrayView2};
use crate::error::{SpatialError, SpatialResult};
#[derive(Debug, Clone)]
pub struct SpatialWeights {
pub weights: Array2<f64>,
pub row_standardized: bool,
}
impl SpatialWeights {
pub fn from_matrix(weights: Array2<f64>) -> Self {
Self {
weights,
row_standardized: false,
}
}
pub fn n(&self) -> usize {
self.weights.nrows()
}
pub fn total_weight(&self) -> f64 {
self.weights.sum()
}
pub fn row_standardize(&mut self) {
let n = self.n();
for i in 0..n {
let row_sum: f64 = (0..n).map(|j| self.weights[[i, j]]).sum();
if row_sum > 0.0 {
for j in 0..n {
self.weights[[i, j]] /= row_sum;
}
}
}
self.row_standardized = true;
}
pub fn view(&self) -> ArrayView2<f64> {
self.weights.view()
}
}
#[derive(Debug, Clone)]
pub struct GlobalAutocorrelationResult {
pub statistic: f64,
pub expected: f64,
pub variance: f64,
pub z_score: f64,
pub p_value: f64,
}
pub fn row_standardize_weights(weights: &ArrayView2<f64>) -> SpatialResult<SpatialWeights> {
if weights.nrows() != weights.ncols() {
return Err(SpatialError::DimensionError(
"Weights matrix must be square".to_string(),
));
}
let mut sw = SpatialWeights::from_matrix(weights.to_owned());
sw.row_standardize();
Ok(sw)
}
pub fn distance_band_weights(
coordinates: &ArrayView2<f64>,
threshold: f64,
) -> SpatialResult<SpatialWeights> {
let n = coordinates.nrows();
let ndim = coordinates.ncols();
if n < 2 {
return Err(SpatialError::ValueError(
"Need at least 2 points".to_string(),
));
}
if threshold <= 0.0 {
return Err(SpatialError::ValueError(
"Distance threshold must be positive".to_string(),
));
}
let mut w = Array2::zeros((n, n));
for i in 0..n {
for j in (i + 1)..n {
let mut dist_sq = 0.0;
for d in 0..ndim {
let diff = coordinates[[i, d]] - coordinates[[j, d]];
dist_sq += diff * diff;
}
if dist_sq.sqrt() <= threshold {
w[[i, j]] = 1.0;
w[[j, i]] = 1.0;
}
}
}
Ok(SpatialWeights::from_matrix(w))
}
pub fn inverse_distance_weights(
coordinates: &ArrayView2<f64>,
power: f64,
max_distance: f64,
) -> SpatialResult<SpatialWeights> {
let n = coordinates.nrows();
let ndim = coordinates.ncols();
if n < 2 {
return Err(SpatialError::ValueError(
"Need at least 2 points".to_string(),
));
}
if max_distance <= 0.0 {
return Err(SpatialError::ValueError(
"max_distance must be positive".to_string(),
));
}
if power <= 0.0 {
return Err(SpatialError::ValueError(
"power must be positive".to_string(),
));
}
let mut w = Array2::zeros((n, n));
for i in 0..n {
for j in (i + 1)..n {
let mut dist_sq = 0.0;
for d in 0..ndim {
let diff = coordinates[[i, d]] - coordinates[[j, d]];
dist_sq += diff * diff;
}
let dist = dist_sq.sqrt();
if dist > 0.0 && dist <= max_distance {
let wt = 1.0 / dist.powf(power);
w[[i, j]] = wt;
w[[j, i]] = wt;
}
}
}
Ok(SpatialWeights::from_matrix(w))
}
fn normal_cdf(x: f64) -> f64 {
let a1 = 0.254829592;
let a2 = -0.284496736;
let a3 = 1.421413741;
let a4 = -1.453152027;
let a5 = 1.061405429;
let p = 0.3275911;
let sign = if x < 0.0 { -1.0 } else { 1.0 };
let x_abs = x.abs() / std::f64::consts::SQRT_2;
let t = 1.0 / (1.0 + p * x_abs);
let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x_abs * x_abs).exp();
0.5 * (1.0 + sign * y)
}
fn two_sided_p(z: f64) -> f64 {
2.0 * (1.0 - normal_cdf(z.abs()))
}
pub fn moran_test(
values: &ArrayView1<f64>,
weights: &ArrayView2<f64>,
) -> SpatialResult<GlobalAutocorrelationResult> {
let n = values.len();
if weights.nrows() != n || weights.ncols() != n {
return Err(SpatialError::DimensionError(
"Weights matrix dimensions must match number of values".to_string(),
));
}
if n < 3 {
return Err(SpatialError::ValueError(
"Need at least 3 observations for Moran's I test".to_string(),
));
}
let nf = n as f64;
let mean = values.sum() / nf;
let dev: Vec<f64> = values.iter().map(|&x| x - mean).collect();
let sum_sq: f64 = dev.iter().map(|d| d * d).sum();
if sum_sq == 0.0 {
return Err(SpatialError::ValueError(
"Variance of values is zero".to_string(),
));
}
let w_total: f64 = weights.sum();
if w_total == 0.0 {
return Err(SpatialError::ValueError("Total weight is zero".to_string()));
}
let mut numer = 0.0;
for i in 0..n {
for j in 0..n {
numer += weights[[i, j]] * dev[i] * dev[j];
}
}
let i_stat = (nf / w_total) * (numer / sum_sq);
let e_i = -1.0 / (nf - 1.0);
let mut s1 = 0.0;
for i in 0..n {
for j in 0..n {
let s = weights[[i, j]] + weights[[j, i]];
s1 += s * s;
}
}
s1 *= 0.5;
let mut s2 = 0.0;
for i in 0..n {
let row_sum: f64 = (0..n).map(|j| weights[[i, j]]).sum();
let col_sum: f64 = (0..n).map(|j| weights[[j, i]]).sum();
let s = row_sum + col_sum;
s2 += s * s;
}
let w2 = w_total * w_total;
let n2 = nf * nf;
let var_i = (n2 * s1 - nf * s2 + 3.0 * w2) / (w2 * (n2 - 1.0)) - e_i * e_i;
let var_i = var_i.max(0.0); let z = if var_i > 0.0 {
(i_stat - e_i) / var_i.sqrt()
} else {
0.0
};
let p = two_sided_p(z);
Ok(GlobalAutocorrelationResult {
statistic: i_stat,
expected: e_i,
variance: var_i,
z_score: z,
p_value: p,
})
}
pub fn geary_test(
values: &ArrayView1<f64>,
weights: &ArrayView2<f64>,
) -> SpatialResult<GlobalAutocorrelationResult> {
let n = values.len();
if weights.nrows() != n || weights.ncols() != n {
return Err(SpatialError::DimensionError(
"Weights matrix dimensions must match number of values".to_string(),
));
}
if n < 3 {
return Err(SpatialError::ValueError(
"Need at least 3 observations for Geary's C test".to_string(),
));
}
let nf = n as f64;
let mean = values.sum() / nf;
let sum_sq: f64 = values.iter().map(|&x| (x - mean) * (x - mean)).sum();
if sum_sq == 0.0 {
return Err(SpatialError::ValueError(
"Variance of values is zero".to_string(),
));
}
let w_total: f64 = weights.sum();
if w_total == 0.0 {
return Err(SpatialError::ValueError("Total weight is zero".to_string()));
}
let mut numer = 0.0;
for i in 0..n {
for j in 0..n {
let diff = values[i] - values[j];
numer += weights[[i, j]] * diff * diff;
}
}
let c_stat = ((nf - 1.0) / (2.0 * w_total)) * (numer / sum_sq);
let e_c = 1.0;
let mut s1 = 0.0;
for i in 0..n {
for j in 0..n {
let s = weights[[i, j]] + weights[[j, i]];
s1 += s * s;
}
}
s1 *= 0.5;
let mut s2 = 0.0;
for i in 0..n {
let row_sum: f64 = (0..n).map(|j| weights[[i, j]]).sum();
let col_sum: f64 = (0..n).map(|j| weights[[j, i]]).sum();
let s = row_sum + col_sum;
s2 += s * s;
}
let n2m1 = nf * nf - 1.0;
let var_c = ((2.0 * s1 + s2) * (nf - 1.0) - 4.0 * w_total * w_total)
/ (2.0 * (nf + 1.0) * w_total * w_total);
let var_c = var_c.max(0.0);
let z = if var_c > 0.0 {
(c_stat - e_c) / var_c.sqrt()
} else {
0.0
};
let p = two_sided_p(z);
Ok(GlobalAutocorrelationResult {
statistic: c_stat,
expected: e_c,
variance: var_c,
z_score: z,
p_value: p,
})
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
use scirs2_core::ndarray::array;
#[test]
fn test_moran_test_clustered() {
let values = array![1.0, 1.0, 1.0, 5.0, 5.0, 5.0];
let mut w = Array2::zeros((6, 6));
for i in 0..5 {
w[[i, i + 1]] = 1.0;
w[[i + 1, i]] = 1.0;
}
let result = moran_test(&values.view(), &w.view()).expect("moran_test failed");
assert!(
result.statistic > result.expected,
"Moran's I = {} should exceed E[I] = {} for clustered data",
result.statistic,
result.expected
);
assert_relative_eq!(result.expected, -1.0 / 5.0, epsilon = 1e-10);
}
#[test]
fn test_moran_test_checkerboard() {
let values = array![10.0, 0.0, 10.0, 0.0];
let w = array![
[0.0, 1.0, 0.0, 1.0],
[1.0, 0.0, 1.0, 0.0],
[0.0, 1.0, 0.0, 1.0],
[1.0, 0.0, 1.0, 0.0],
];
let result = moran_test(&values.view(), &w.view()).expect("moran_test failed");
assert!(
result.statistic < 0.0,
"Moran's I should be negative for checkerboard"
);
}
#[test]
fn test_geary_test_clustered() {
let values = array![1.0, 1.0, 1.0, 5.0, 5.0, 5.0];
let mut w = Array2::zeros((6, 6));
for i in 0..5 {
w[[i, i + 1]] = 1.0;
w[[i + 1, i]] = 1.0;
}
let result = geary_test(&values.view(), &w.view()).expect("geary_test failed");
assert!(
result.statistic < 1.0,
"Geary's C = {} should be < 1 for clustered data",
result.statistic
);
assert_relative_eq!(result.expected, 1.0, epsilon = 1e-10);
}
#[test]
fn test_geary_test_checkerboard() {
let values = array![10.0, 0.0, 10.0, 0.0];
let w = array![
[0.0, 1.0, 0.0, 1.0],
[1.0, 0.0, 1.0, 0.0],
[0.0, 1.0, 0.0, 1.0],
[1.0, 0.0, 1.0, 0.0],
];
let result = geary_test(&values.view(), &w.view()).expect("geary_test failed");
assert!(
result.statistic > 1.0,
"Geary's C = {} should be > 1 for checkerboard",
result.statistic
);
}
#[test]
fn test_distance_band_weights() {
let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [5.0, 5.0]];
let sw = distance_band_weights(&coords.view(), 1.5).expect("distance_band_weights failed");
assert_eq!(sw.weights[[0, 1]], 1.0);
assert_eq!(sw.weights[[0, 2]], 1.0);
assert_eq!(sw.weights[[0, 3]], 0.0);
assert_eq!(sw.weights[[3, 0]], 0.0);
}
#[test]
fn test_inverse_distance_weights() {
let coords = array![[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]];
let sw =
inverse_distance_weights(&coords.view(), 1.0, 5.0).expect("inverse_distance_weights");
assert_relative_eq!(sw.weights[[0, 1]], 1.0, epsilon = 1e-10);
assert_relative_eq!(sw.weights[[0, 2]], 0.5, epsilon = 1e-10);
}
#[test]
fn test_row_standardize() {
let w = array![[0.0, 1.0, 1.0], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0],];
let sw = row_standardize_weights(&w.view()).expect("row_standardize");
assert!(sw.row_standardized);
assert_relative_eq!(sw.weights[[0, 1]], 0.5, epsilon = 1e-10);
assert_relative_eq!(sw.weights[[0, 2]], 0.5, epsilon = 1e-10);
assert_relative_eq!(sw.weights[[1, 0]], 1.0, epsilon = 1e-10);
}
#[test]
fn test_moran_significance() {
let values = array![1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0];
let mut w = Array2::zeros((8, 8));
for i in 0..7 {
w[[i, i + 1]] = 1.0;
w[[i + 1, i]] = 1.0;
}
let result = moran_test(&values.view(), &w.view()).expect("moran_test");
assert!(
result.z_score.abs() > 1.0,
"z_score = {} should be > 1 for strongly clustered data",
result.z_score
);
assert!(result.p_value < 0.5, "p_value should be moderate-to-small");
}
}