1use crate::error::{StatsError, StatsResult};
17use scirs2_core::ndarray::{Array1, Array2};
18
19fn erf_approx(x: f64) -> f64 {
25 let sign = if x >= 0.0 { 1.0 } else { -1.0 };
26 let x = x.abs();
27 let t = 1.0 / (1.0 + 0.327_591_1 * x);
28 let poly = t
29 * (0.254_829_592
30 + t * (-0.284_496_736
31 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
32 sign * (1.0 - poly * (-x * x).exp())
33}
34
35fn norm_cdf(z: f64) -> f64 {
36 0.5 * (1.0 + erf_approx(z / std::f64::consts::SQRT_2))
37}
38
39fn two_tailed_p(z: f64) -> f64 {
41 (2.0 * norm_cdf(-z.abs())).clamp(0.0, 1.0)
42}
43
44#[derive(Debug, Clone)]
50pub struct MoransResult {
51 pub statistic: f64,
53 pub expected: f64,
55 pub variance: f64,
57 pub z_score: f64,
59 pub p_value: f64,
61}
62
63#[derive(Debug, Clone)]
65pub struct LisaResult {
66 pub local_i: Vec<f64>,
68 pub z_scores: Vec<f64>,
70 pub p_values: Vec<f64>,
72}
73
74pub struct MoransI;
76
77impl MoransI {
78 pub fn compute(values: &[f64], weights: &Array2<f64>) -> StatsResult<MoransResult> {
87 let n = values.len();
88 if n < 3 {
89 return Err(StatsError::InvalidArgument(
90 "Moran's I requires at least 3 observations".to_string(),
91 ));
92 }
93 if weights.nrows() != n || weights.ncols() != n {
94 return Err(StatsError::InvalidArgument(format!(
95 "weights must be {n}×{n}, got {}×{}",
96 weights.nrows(),
97 weights.ncols()
98 )));
99 }
100
101 let mean = values.iter().sum::<f64>() / n as f64;
102 let z: Vec<f64> = values.iter().map(|&v| v - mean).collect();
103
104 let w_sum: f64 = weights.iter().sum();
106 if w_sum == 0.0 {
107 return Err(StatsError::InvalidArgument(
108 "Sum of spatial weights is zero".to_string(),
109 ));
110 }
111
112 let mut numerator = 0.0;
114 for i in 0..n {
115 for j in 0..n {
116 numerator += weights[[i, j]] * z[i] * z[j];
117 }
118 }
119
120 let s2: f64 = z.iter().map(|&zi| zi * zi).sum();
122 if s2 == 0.0 {
123 return Err(StatsError::InvalidArgument(
124 "All values are identical; Moran's I is undefined".to_string(),
125 ));
126 }
127
128 let statistic = (n as f64 / w_sum) * (numerator / s2);
129
130 let expected = -1.0 / (n as f64 - 1.0);
132
133 let mut s1 = 0.0;
136 for i in 0..n {
137 for j in 0..n {
138 let v = weights[[i, j]] + weights[[j, i]];
139 s1 += v * v;
140 }
141 }
142 s1 *= 0.5;
143
144 let mut s2_stat = 0.0;
146 for i in 0..n {
147 let row_sum: f64 = (0..n).map(|j| weights[[i, j]]).sum();
148 let col_sum: f64 = (0..n).map(|j| weights[[j, i]]).sum();
149 s2_stat += (row_sum + col_sum).powi(2);
150 }
151
152 let n_f = n as f64;
153 let k4 = {
154 let m2 = z.iter().map(|&zi| zi.powi(2)).sum::<f64>() / n_f;
155 let m4 = z.iter().map(|&zi| zi.powi(4)).sum::<f64>() / n_f;
156 m4 / (m2 * m2)
157 };
158
159 let w_sq = w_sum * w_sum;
160 let num_var = n_f * (n_f * n_f - 3.0 * n_f + 3.0) * s1 - n_f * s2_stat + 3.0 * w_sq;
161 let den_var = (n_f - 1.0) * (n_f - 2.0) * (n_f - 3.0) * w_sq;
162 let kur_term = k4 * ((n_f * n_f - n_f) * s1 - 2.0 * n_f * s2_stat + 6.0 * w_sq)
163 / ((n_f - 1.0) * (n_f - 2.0) * (n_f - 3.0) * w_sq);
164
165 let variance = (num_var / den_var - kur_term - expected * expected).max(1e-15);
166 let z_score = (statistic - expected) / variance.sqrt();
167 let p_value = two_tailed_p(z_score);
168
169 Ok(MoransResult {
170 statistic,
171 expected,
172 variance,
173 z_score,
174 p_value,
175 })
176 }
177
178 pub fn local(values: &[f64], weights: &Array2<f64>) -> StatsResult<LisaResult> {
182 let n = values.len();
183 if n < 3 {
184 return Err(StatsError::InvalidArgument(
185 "LISA requires at least 3 observations".to_string(),
186 ));
187 }
188 if weights.nrows() != n || weights.ncols() != n {
189 return Err(StatsError::InvalidArgument(format!(
190 "weights must be {n}×{n}"
191 )));
192 }
193
194 let mean = values.iter().sum::<f64>() / n as f64;
195 let z: Vec<f64> = values.iter().map(|&v| v - mean).collect();
196 let m2 = z.iter().map(|&zi| zi * zi).sum::<f64>() / n as f64;
197
198 if m2 == 0.0 {
199 return Err(StatsError::InvalidArgument(
200 "All values are identical; LISA is undefined".to_string(),
201 ));
202 }
203
204 let mut local_i = vec![0.0_f64; n];
205 let mut z_scores = vec![0.0_f64; n];
206 let mut p_values = vec![0.0_f64; n];
207
208 for i in 0..n {
209 let row_sum: f64 = (0..n).map(|j| weights[[i, j]]).sum();
211 let w_i: Vec<f64> = if row_sum > 0.0 {
212 (0..n).map(|j| weights[[i, j]] / row_sum).collect()
213 } else {
214 vec![0.0; n]
215 };
216
217 let lag_i: f64 = (0..n).map(|j| w_i[j] * z[j]).sum();
218 local_i[i] = (z[i] / m2) * lag_i;
219
220 let w_sq_sum: f64 = w_i.iter().map(|&w| w * w).sum();
222 let var_i = (w_sq_sum * (n as f64 / (n as f64 - 1.0))).max(1e-15);
223 z_scores[i] = local_i[i] / var_i.sqrt();
224 p_values[i] = two_tailed_p(z_scores[i]);
225 }
226
227 Ok(LisaResult {
228 local_i,
229 z_scores,
230 p_values,
231 })
232 }
233}
234
235pub struct GearyC;
241
242impl GearyC {
243 pub fn compute(values: &[f64], weights: &Array2<f64>) -> StatsResult<f64> {
255 let n = values.len();
256 if n < 3 {
257 return Err(StatsError::InvalidArgument(
258 "Geary's C requires at least 3 observations".to_string(),
259 ));
260 }
261 if weights.nrows() != n || weights.ncols() != n {
262 return Err(StatsError::InvalidArgument(format!(
263 "weights must be {n}×{n}"
264 )));
265 }
266
267 let mean = values.iter().sum::<f64>() / n as f64;
268 let ss: f64 = values.iter().map(|&v| (v - mean).powi(2)).sum();
269 if ss == 0.0 {
270 return Err(StatsError::InvalidArgument(
271 "All values are identical; Geary's C is undefined".to_string(),
272 ));
273 }
274
275 let w_sum: f64 = weights.iter().sum();
276 if w_sum == 0.0 {
277 return Err(StatsError::InvalidArgument(
278 "Sum of spatial weights is zero".to_string(),
279 ));
280 }
281
282 let mut cross_sum = 0.0_f64;
283 for i in 0..n {
284 for j in 0..n {
285 let diff = values[i] - values[j];
286 cross_sum += weights[[i, j]] * diff * diff;
287 }
288 }
289
290 Ok(((n as f64 - 1.0) * cross_sum) / (2.0 * w_sum * ss))
291 }
292}
293
294pub struct SpatialAutocorrelation {
300 pub global: MoransResult,
302 pub local: LisaResult,
304}
305
306impl SpatialAutocorrelation {
307 pub fn compute(values: &[f64], weights: &Array2<f64>) -> StatsResult<Self> {
309 let global = MoransI::compute(values, weights)?;
310 let local = MoransI::local(values, weights)?;
311 Ok(Self { global, local })
312 }
313}
314
315pub struct Ripley;
324
325impl Ripley {
326 pub fn k_function(
338 points: &[(f64, f64)],
339 distances: &[f64],
340 area: f64,
341 ) -> StatsResult<Vec<f64>> {
342 let n = points.len();
343 if n < 2 {
344 return Err(StatsError::InvalidArgument(
345 "Ripley's K requires at least 2 points".to_string(),
346 ));
347 }
348 if area <= 0.0 {
349 return Err(StatsError::InvalidArgument(
350 "Area must be positive".to_string(),
351 ));
352 }
353
354 let lambda = n as f64 / area;
355 let mut k_values = vec![0.0_f64; distances.len()];
356
357 for (d_idx, &d) in distances.iter().enumerate() {
358 let mut count = 0.0_f64;
359 for i in 0..n {
360 for j in 0..n {
361 if i == j {
362 continue;
363 }
364 let dx = points[i].0 - points[j].0;
365 let dy = points[i].1 - points[j].1;
366 let dist = (dx * dx + dy * dy).sqrt();
367 if dist <= d {
368 count += 1.0;
369 }
370 }
371 }
372 k_values[d_idx] = count / (lambda * n as f64);
373 }
374
375 Ok(k_values)
376 }
377
378 pub fn l_function(
381 points: &[(f64, f64)],
382 distances: &[f64],
383 area: f64,
384 ) -> StatsResult<Vec<f64>> {
385 let k = Self::k_function(points, distances, area)?;
386 let l = k
387 .iter()
388 .zip(distances.iter())
389 .map(|(&ki, &d)| (ki / std::f64::consts::PI).sqrt() - d)
390 .collect();
391 Ok(l)
392 }
393}
394
395#[derive(Debug, Clone)]
401pub struct VariogramBin {
402 pub distance: f64,
404 pub semivariance: f64,
406 pub count: usize,
408}
409
410pub fn variogram(points: &[(f64, f64, f64)], n_bins: usize) -> StatsResult<Vec<VariogramBin>> {
419 let n = points.len();
420 if n < 2 {
421 return Err(StatsError::InvalidArgument(
422 "variogram requires at least 2 points".to_string(),
423 ));
424 }
425 if n_bins == 0 {
426 return Err(StatsError::InvalidArgument(
427 "n_bins must be at least 1".to_string(),
428 ));
429 }
430
431 let mut pairs: Vec<(f64, f64)> = Vec::with_capacity(n * (n - 1) / 2);
433 let mut max_dist = 0.0_f64;
434
435 for i in 0..n {
436 for j in (i + 1)..n {
437 let dx = points[i].0 - points[j].0;
438 let dy = points[i].1 - points[j].1;
439 let dist = (dx * dx + dy * dy).sqrt();
440 let sq_diff = (points[i].2 - points[j].2).powi(2);
441 pairs.push((dist, sq_diff));
442 if dist > max_dist {
443 max_dist = dist;
444 }
445 }
446 }
447
448 if max_dist == 0.0 {
449 return Err(StatsError::InvalidArgument(
450 "All points are at the same location".to_string(),
451 ));
452 }
453
454 let bin_width = max_dist / n_bins as f64;
456 let mut bin_sum = vec![0.0_f64; n_bins];
457 let mut bin_cnt = vec![0_usize; n_bins];
458 let mut bin_dist = vec![0.0_f64; n_bins];
459
460 for (dist, sq_diff) in &pairs {
461 let idx = ((dist / max_dist) * n_bins as f64).floor() as usize;
462 let idx = idx.min(n_bins - 1);
463 bin_sum[idx] += sq_diff;
464 bin_cnt[idx] += 1;
465 bin_dist[idx] += dist;
466 }
467
468 let result = (0..n_bins)
469 .filter(|&i| bin_cnt[i] > 0)
470 .map(|i| {
471 let count = bin_cnt[i];
472 VariogramBin {
473 distance: bin_dist[i] / count as f64,
474 semivariance: bin_sum[i] / (2.0 * count as f64),
475 count,
476 }
477 })
478 .collect();
479
480 let _ = bin_width; Ok(result)
482}
483
484pub fn variogram_pairs(points: &[(f64, f64, f64)], n_bins: usize) -> StatsResult<Vec<(f64, f64)>> {
493 let bins = variogram(points, n_bins)?;
494 Ok(bins
495 .into_iter()
496 .map(|b| (b.distance, b.semivariance))
497 .collect())
498}
499
500#[cfg(test)]
505mod tests {
506 use super::*;
507 use scirs2_core::ndarray::Array2;
508
509 fn chain_weights(n: usize) -> Array2<f64> {
511 let mut w = Array2::zeros((n, n));
512 for i in 0..n - 1 {
513 w[[i, i + 1]] = 1.0;
514 w[[i + 1, i]] = 1.0;
515 }
516 w
517 }
518
519 fn full_weights(n: usize) -> Array2<f64> {
521 let mut w = Array2::ones((n, n));
522 for i in 0..n {
523 w[[i, i]] = 0.0;
524 }
525 w
526 }
527
528 #[test]
529 fn test_morans_i_positive_autocorrelation() {
530 let values = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
532 let w = chain_weights(6);
533 let result = MoransI::compute(&values, &w).expect("MoransI compute failed");
534 assert!(
536 result.statistic > 0.0,
537 "Expected positive I, got {}",
538 result.statistic
539 );
540 }
541
542 #[test]
543 fn test_morans_i_negative_autocorrelation() {
544 let values = vec![1.0, -1.0, 1.0, -1.0, 1.0, -1.0];
546 let w = chain_weights(6);
547 let result = MoransI::compute(&values, &w).expect("MoransI compute failed");
548 assert!(
549 result.statistic < 0.0,
550 "Expected negative I, got {}",
551 result.statistic
552 );
553 }
554
555 #[test]
556 fn test_morans_i_expected_value() {
557 let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
558 let w = chain_weights(5);
559 let result = MoransI::compute(&values, &w).expect("MoransI compute failed");
560 let expected = -1.0 / (5.0 - 1.0);
561 assert!(
562 (result.expected - expected).abs() < 1e-10,
563 "Expected E[I]={expected}, got {}",
564 result.expected
565 );
566 }
567
568 #[test]
569 fn test_morans_i_p_value_range() {
570 let values = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
571 let w = chain_weights(6);
572 let result = MoransI::compute(&values, &w).expect("MoransI compute failed");
573 assert!(
574 result.p_value >= 0.0 && result.p_value <= 1.0,
575 "p-value out of range: {}",
576 result.p_value
577 );
578 }
579
580 #[test]
581 fn test_morans_i_error_too_few_observations() {
582 let values = vec![1.0, 2.0];
583 let w = chain_weights(2);
584 assert!(MoransI::compute(&values, &w).is_err());
585 }
586
587 #[test]
588 fn test_morans_i_error_weight_dimension_mismatch() {
589 let values = vec![1.0, 2.0, 3.0];
590 let w = chain_weights(4);
591 assert!(MoransI::compute(&values, &w).is_err());
592 }
593
594 #[test]
595 fn test_geary_c_range() {
596 let values = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
597 let w = chain_weights(6);
598 let c = GearyC::compute(&values, &w).expect("GearyC compute failed");
599 assert!(c >= 0.0, "Geary's C should be ≥ 0, got {c}");
601 }
602
603 #[test]
604 fn test_geary_c_positive_autocorrelation() {
605 let values = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
607 let w = chain_weights(8);
608 let c = GearyC::compute(&values, &w).expect("GearyC compute failed");
609 assert!(
610 c < 1.0,
611 "Expected C < 1 for positive autocorrelation, got {c}"
612 );
613 }
614
615 #[test]
616 fn test_ripley_k_increasing() {
617 let points = vec![(0.0, 0.0), (1.0, 0.0), (2.0, 0.0), (0.0, 1.0), (1.0, 1.0)];
619 let distances = vec![0.5, 1.0, 1.5, 2.0];
620 let k = Ripley::k_function(&points, &distances, 9.0).expect("Ripley k_function failed");
621 for i in 1..k.len() {
622 assert!(
623 k[i] >= k[i - 1] - 1e-10,
624 "K should be non-decreasing: K[{}]={} < K[{}]={}",
625 i,
626 k[i],
627 i - 1,
628 k[i - 1]
629 );
630 }
631 }
632
633 #[test]
634 fn test_ripley_k_error_too_few_points() {
635 let points = vec![(0.0, 0.0)];
636 assert!(Ripley::k_function(&points, &[1.0], 4.0).is_err());
637 }
638
639 #[test]
640 fn test_variogram_bins_count() {
641 let pts: Vec<(f64, f64, f64)> = (0..5)
642 .flat_map(|i| (0..5).map(move |j| (i as f64, j as f64, (i + j) as f64)))
643 .collect();
644 let bins = variogram(&pts, 5).expect("variogram failed");
645 assert!(!bins.is_empty(), "Expected at least one non-empty bin");
646 assert!(bins.len() <= 5, "Expected at most 5 bins");
647 }
648
649 #[test]
650 fn test_variogram_semivariance_positive() {
651 let pts: Vec<(f64, f64, f64)> = (0..4)
652 .flat_map(|i| (0..4).map(move |j| (i as f64, j as f64, (i * j) as f64)))
653 .collect();
654 let bins = variogram(&pts, 4).expect("variogram failed");
655 for b in &bins {
656 assert!(
657 b.semivariance >= 0.0,
658 "semivariance should be non-negative, got {}",
659 b.semivariance
660 );
661 }
662 }
663
664 #[test]
665 fn test_variogram_pairs_wrapper() {
666 let pts: Vec<(f64, f64, f64)> = vec![
667 (0.0, 0.0, 1.0),
668 (1.0, 0.0, 2.0),
669 (2.0, 0.0, 1.0),
670 (3.0, 0.0, 3.0),
671 ];
672 let pairs = variogram_pairs(&pts, 3).expect("variogram_pairs failed");
673 for (d, sv) in &pairs {
674 assert!(*d > 0.0, "distance should be positive");
675 assert!(*sv >= 0.0, "semivariance should be non-negative");
676 }
677 }
678
679 #[test]
680 fn test_spatial_autocorrelation_wrapper() {
681 let values = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
682 let w = full_weights(6);
683 let sa = SpatialAutocorrelation::compute(&values, &w)
684 .expect("SpatialAutocorrelation compute failed");
685 assert!(
686 sa.global.p_value >= 0.0 && sa.global.p_value <= 1.0,
687 "p-value out of range"
688 );
689 assert_eq!(sa.local.local_i.len(), 6);
690 }
691
692 #[test]
693 fn test_lisa_local_i_count() {
694 let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
695 let w = chain_weights(5);
696 let lisa = MoransI::local(&values, &w).expect("LISA compute failed");
697 assert_eq!(lisa.local_i.len(), 5);
698 assert_eq!(lisa.z_scores.len(), 5);
699 assert_eq!(lisa.p_values.len(), 5);
700 }
701}