use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
use scirs2_core::random::{seeded_rng, Rng, RngExt, SeedableRng};
use crate::error::{SpatialError, SpatialResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LisaCluster {
HighHigh,
LowLow,
HighLow,
LowHigh,
NotSignificant,
}
#[derive(Debug, Clone)]
pub struct LisaResult {
pub local_i: Array1<f64>,
pub p_values: Array1<f64>,
pub clusters: Vec<LisaCluster>,
}
#[derive(Debug, Clone)]
pub struct LisaClusterMap {
pub local_i: Array1<f64>,
pub p_values: Array1<f64>,
pub clusters: Vec<LisaCluster>,
pub mean: f64,
pub n_permutations: usize,
pub alpha: f64,
}
#[derive(Debug, Clone)]
pub struct GetisOrdResult {
pub z_scores: Array1<f64>,
pub p_values: Array1<f64>,
}
pub fn local_moran_permutation_test(
values: &ArrayView1<f64>,
weights: &ArrayView2<f64>,
n_permutations: usize,
seed: u64,
) -> SpatialResult<LisaResult> {
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".to_string(),
));
}
let nf = n as f64;
let mean: f64 = values.sum() / nf;
let variance: f64 = values.iter().map(|&x| (x - mean) * (x - mean)).sum::<f64>() / nf;
if variance == 0.0 {
return Err(SpatialError::ValueError("Variance is zero".to_string()));
}
let std_dev = variance.sqrt();
let z: Vec<f64> = values.iter().map(|&x| (x - mean) / std_dev).collect();
let mut local_i = Array1::zeros(n);
for i in 0..n {
let mut wz_sum = 0.0;
for j in 0..n {
if i != j {
wz_sum += weights[[i, j]] * z[j];
}
}
local_i[i] = z[i] * wz_sum;
}
let mut p_values = Array1::zeros(n);
let mut rng = seeded_rng(seed);
let mut indices: Vec<usize> = (0..n).collect();
for i in 0..n {
let obs_i = local_i[i].abs();
let mut count_extreme = 0usize;
for _perm in 0..n_permutations {
fisher_yates_shuffle_except(&mut indices, i, &mut rng);
let mut wz_perm = 0.0;
for j in 0..n {
if j != i {
wz_perm += weights[[i, j]] * z[indices[j]];
}
}
let i_perm = z[i] * wz_perm;
if i_perm.abs() >= obs_i {
count_extreme += 1;
}
}
p_values[i] = (count_extreme as f64 + 1.0) / (n_permutations as f64 + 1.0);
}
let alpha = 0.05;
let clusters = classify_clusters(&z, &local_i, &p_values, weights, alpha);
Ok(LisaResult {
local_i,
p_values,
clusters,
})
}
pub fn lisa_cluster_map(
values: &ArrayView1<f64>,
weights: &ArrayView2<f64>,
n_permutations: usize,
alpha: f64,
seed: u64,
) -> SpatialResult<LisaClusterMap> {
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".to_string(),
));
}
let nf = n as f64;
let mean: f64 = values.sum() / nf;
let variance: f64 = values.iter().map(|&x| (x - mean) * (x - mean)).sum::<f64>() / nf;
if variance == 0.0 {
return Err(SpatialError::ValueError("Variance is zero".to_string()));
}
let std_dev = variance.sqrt();
let z: Vec<f64> = values.iter().map(|&x| (x - mean) / std_dev).collect();
let mut local_i = Array1::zeros(n);
for i in 0..n {
let mut wz_sum = 0.0;
for j in 0..n {
if i != j {
wz_sum += weights[[i, j]] * z[j];
}
}
local_i[i] = z[i] * wz_sum;
}
let mut p_values = Array1::zeros(n);
let mut rng = seeded_rng(seed);
let mut indices: Vec<usize> = (0..n).collect();
for i in 0..n {
let obs_i = local_i[i].abs();
let mut count_extreme = 0usize;
for _perm in 0..n_permutations {
fisher_yates_shuffle_except(&mut indices, i, &mut rng);
let mut wz_perm = 0.0;
for j in 0..n {
if j != i {
wz_perm += weights[[i, j]] * z[indices[j]];
}
}
let i_perm = z[i] * wz_perm;
if i_perm.abs() >= obs_i {
count_extreme += 1;
}
}
p_values[i] = (count_extreme as f64 + 1.0) / (n_permutations as f64 + 1.0);
}
let clusters = classify_clusters(&z, &local_i, &p_values, weights, alpha);
Ok(LisaClusterMap {
local_i,
p_values,
clusters,
mean,
n_permutations,
alpha,
})
}
pub fn getis_ord_gi_star(
values: &ArrayView1<f64>,
weights: &ArrayView2<f64>,
) -> SpatialResult<GetisOrdResult> {
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 < 2 {
return Err(SpatialError::ValueError(
"Need at least 2 observations".to_string(),
));
}
let nf = n as f64;
let xbar: f64 = values.sum() / nf;
let s2: f64 = values.iter().map(|&x| (x - xbar) * (x - xbar)).sum::<f64>() / nf;
if s2 == 0.0 {
return Err(SpatialError::ValueError(
"Variance of values is zero".to_string(),
));
}
let s = s2.sqrt();
let mut z_scores = Array1::zeros(n);
let mut p_values = Array1::zeros(n);
for i in 0..n {
let mut wx_sum = 0.0;
let mut w_sum = 0.0;
let mut w_sq_sum = 0.0;
for j in 0..n {
let wij = if i == j {
if weights[[i, j]] == 0.0 {
1.0
} else {
weights[[i, j]]
}
} else {
weights[[i, j]]
};
wx_sum += wij * values[j];
w_sum += wij;
w_sq_sum += wij * wij;
}
let numerator = wx_sum - xbar * w_sum;
let denom_inner = (nf * w_sq_sum - w_sum * w_sum) / (nf - 1.0);
if denom_inner > 0.0 {
let denominator = s * denom_inner.sqrt();
z_scores[i] = numerator / denominator;
}
p_values[i] = two_sided_p(z_scores[i]);
}
Ok(GetisOrdResult { z_scores, p_values })
}
fn two_sided_p(z: f64) -> f64 {
2.0 * (1.0 - normal_cdf(z.abs()))
}
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 fisher_yates_shuffle_except<R: Rng + ?Sized>(indices: &mut [usize], skip: usize, rng: &mut R) {
let n = indices.len();
for (idx, val) in indices.iter_mut().enumerate() {
*val = idx;
}
let positions: Vec<usize> = (0..n).filter(|&p| p != skip).collect();
let m = positions.len();
for k in (1..m).rev() {
let j = rng.random_range(0..=k);
indices.swap(positions[k], positions[j]);
}
}
fn classify_clusters(
z: &[f64],
local_i: &Array1<f64>,
p_values: &Array1<f64>,
weights: &ArrayView2<f64>,
alpha: f64,
) -> Vec<LisaCluster> {
let n = z.len();
let mut clusters = vec![LisaCluster::NotSignificant; n];
for i in 0..n {
if p_values[i] > alpha {
continue;
}
let mut w_sum = 0.0;
let mut wz_sum = 0.0;
for j in 0..n {
if j != i && weights[[i, j]] > 0.0 {
w_sum += weights[[i, j]];
wz_sum += weights[[i, j]] * z[j];
}
}
let lag = if w_sum > 0.0 { wz_sum / w_sum } else { 0.0 };
clusters[i] = if z[i] > 0.0 && lag > 0.0 {
LisaCluster::HighHigh
} else if z[i] < 0.0 && lag < 0.0 {
LisaCluster::LowLow
} else if z[i] > 0.0 && lag < 0.0 {
LisaCluster::HighLow
} else if z[i] < 0.0 && lag > 0.0 {
LisaCluster::LowHigh
} else {
LisaCluster::NotSignificant
};
}
clusters
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::array;
fn build_chain_weights(n: usize) -> Array2<f64> {
let mut w = Array2::zeros((n, n));
for i in 0..(n - 1) {
w[[i, i + 1]] = 1.0;
w[[i + 1, i]] = 1.0;
}
w
}
#[test]
fn test_local_moran_permutation_hot_spot() {
let values = array![10.0, 10.0, 10.0, 1.0, 1.0, 1.0];
let w = build_chain_weights(6);
let result =
local_moran_permutation_test(&values.view(), &w.view(), 199, 42).expect("lisa failed");
assert_eq!(result.local_i.len(), 6);
assert_eq!(result.p_values.len(), 6);
assert_eq!(result.clusters.len(), 6);
assert!(
result.local_i[0] > 0.0,
"Local I at position 0 should be positive for clustered high values"
);
}
#[test]
fn test_local_moran_permutation_spatial_outlier() {
let values = array![1.0, 1.0, 10.0, 1.0, 1.0];
let w = build_chain_weights(5);
let result =
local_moran_permutation_test(&values.view(), &w.view(), 199, 123).expect("lisa");
assert!(
result.local_i[2] < 0.0,
"Local I at outlier position should be negative, got {}",
result.local_i[2]
);
}
#[test]
fn test_lisa_cluster_map_classifications() {
let values = array![10.0, 10.0, 10.0, 1.0, 1.0, 1.0, 1.0, 1.0];
let w = build_chain_weights(8);
let map = lisa_cluster_map(&values.view(), &w.view(), 499, 0.10, 42).expect("cluster_map");
assert_eq!(map.clusters.len(), 8);
assert_eq!(map.n_permutations, 499);
let has_hh = map.clusters.contains(&LisaCluster::HighHigh);
let has_ll = map.clusters.contains(&LisaCluster::LowLow);
let has_ns = map.clusters.contains(&LisaCluster::NotSignificant);
assert!(
has_hh || has_ll || has_ns,
"Should produce at least one classification"
);
}
#[test]
fn test_getis_ord_gi_star_hotspot() {
let values = array![10.0, 10.0, 10.0, 1.0, 1.0, 1.0];
let w = build_chain_weights(6);
let result = getis_ord_gi_star(&values.view(), &w.view()).expect("gi_star");
assert_eq!(result.z_scores.len(), 6);
assert_eq!(result.p_values.len(), 6);
assert!(
result.z_scores[0] > 0.0,
"z-score at position 0 should be positive for high cluster, got {}",
result.z_scores[0]
);
assert!(
result.z_scores[5] < 0.0,
"z-score at position 5 should be negative for low cluster, got {}",
result.z_scores[5]
);
}
#[test]
fn test_getis_ord_gi_star_uniform() {
let values = array![5.0, 5.0, 5.0, 5.0, 5.0];
let w = build_chain_weights(5);
let result = getis_ord_gi_star(&values.view(), &w.view());
assert!(result.is_err());
}
#[test]
fn test_getis_ord_gi_star_p_values() {
let values = array![100.0, 100.0, 100.0, 1.0, 1.0, 1.0, 1.0, 1.0];
let w = build_chain_weights(8);
let result = getis_ord_gi_star(&values.view(), &w.view()).expect("gi_star");
for &p in result.p_values.iter() {
assert!((0.0..=1.0).contains(&p), "p-value {} out of range", p);
}
assert!(
result.p_values[0] < 0.5,
"p-value at hotspot should be < 0.5"
);
}
}