Skip to main content

scirs2_stats/
spatial_stats.rs

1//! Spatial Statistics
2//!
3//! This module provides spatial analysis functions:
4//!
5//! - **Moran's I**: Global and local (LISA) spatial autocorrelation
6//! - **Geary's C**: Alternative spatial autocorrelation measure
7//! - **Ripley's K**: Point pattern analysis
8//! - **Variogram**: Experimental semi-variogram estimation
9//!
10//! # References
11//! - Moran, P.A.P. (1950). Notes on Continuous Stochastic Phenomena.
12//! - Geary, R.C. (1954). The Contiguity Ratio and Statistical Mapping.
13//! - Ripley, B.D. (1976). The Second-Order Analysis of Stationary Point Processes.
14//! - Matheron, G. (1963). Principles of Geostatistics.
15
16use crate::error::{StatsError, StatsResult};
17use scirs2_core::ndarray::{Array1, Array2};
18
19// ---------------------------------------------------------------------------
20// Helper: z-score and normal CDF
21// ---------------------------------------------------------------------------
22
23/// Abramowitz & Stegun rational approximation of erf(x), max error ~1.5e-7.
24fn 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
39/// Two-tailed p-value from standard normal z-score.
40fn two_tailed_p(z: f64) -> f64 {
41    (2.0 * norm_cdf(-z.abs())).clamp(0.0, 1.0)
42}
43
44// ---------------------------------------------------------------------------
45// Moran's I
46// ---------------------------------------------------------------------------
47
48/// Result of Moran's I spatial autocorrelation test.
49#[derive(Debug, Clone)]
50pub struct MoransResult {
51    /// The Moran's I statistic (range approximately [-1, 1]).
52    pub statistic: f64,
53    /// Expected value of I under no spatial autocorrelation.
54    pub expected: f64,
55    /// Variance of I under randomisation assumption.
56    pub variance: f64,
57    /// Standardised z-score.
58    pub z_score: f64,
59    /// Two-tailed p-value (normal approximation).
60    pub p_value: f64,
61}
62
63/// Local Moran's I (LISA) result for a single location.
64#[derive(Debug, Clone)]
65pub struct LisaResult {
66    /// Local Moran's I value for each observation.
67    pub local_i: Vec<f64>,
68    /// Z-score for each observation.
69    pub z_scores: Vec<f64>,
70    /// Two-tailed p-value for each observation.
71    pub p_values: Vec<f64>,
72}
73
74/// Spatial autocorrelation analysis using Moran's I.
75pub struct MoransI;
76
77impl MoransI {
78    /// Compute global Moran's I.
79    ///
80    /// # Arguments
81    /// * `values` - Observed values at each location.
82    /// * `weights` - Row-standardised (or raw) spatial weights matrix (n×n).
83    ///
84    /// # Returns
85    /// `MoransResult` with the statistic, expected value, variance, z-score and p-value.
86    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        // Sum of all weights
105        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        // Numerator: Σ_i Σ_j w_ij (z_i)(z_j)
113        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        // Denominator: Σ_i z_i²
121        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        // Expected value under randomisation: E[I] = -1/(n-1)
131        let expected = -1.0 / (n as f64 - 1.0);
132
133        // Variance (randomisation assumption)
134        // S1 = 0.5 * Σ_i Σ_j (w_ij + w_ji)²
135        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        // S2 = Σ_i (Σ_j w_ij + Σ_j w_ji)²
145        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    /// Compute Local Indicators of Spatial Association (LISA) — Local Moran's I.
179    ///
180    /// Returns the local I value for each observation along with z-scores and p-values.
181    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            // Row-standardise weights for location i
210            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            // Variance of local I (analytical approximation)
221            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
235// ---------------------------------------------------------------------------
236// Geary's C
237// ---------------------------------------------------------------------------
238
239/// Spatial autocorrelation using Geary's C statistic.
240pub struct GearyC;
241
242impl GearyC {
243    /// Compute Geary's C statistic.
244    ///
245    /// C ≈ 0 → strong positive autocorrelation; C ≈ 1 → no autocorrelation;
246    /// C > 1 → negative autocorrelation.
247    ///
248    /// # Arguments
249    /// * `values` - Observed values at each location.
250    /// * `weights` - Spatial weights matrix (n×n).
251    ///
252    /// # Returns
253    /// Geary's C value.
254    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
294// ---------------------------------------------------------------------------
295// Spatial Autocorrelation (convenience wrapper)
296// ---------------------------------------------------------------------------
297
298/// Convenience wrapper providing Moran's I and LISA together.
299pub struct SpatialAutocorrelation {
300    /// Global Moran's I result.
301    pub global: MoransResult,
302    /// Local LISA result.
303    pub local: LisaResult,
304}
305
306impl SpatialAutocorrelation {
307    /// Compute both global Moran's I and local LISA statistics.
308    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
315// ---------------------------------------------------------------------------
316// Ripley's K function
317// ---------------------------------------------------------------------------
318
319/// Ripley's K function for point pattern analysis.
320///
321/// K(d) estimates the expected number of additional events within distance d
322/// of a randomly chosen event, divided by the overall intensity λ.
323pub struct Ripley;
324
325impl Ripley {
326    /// Compute Ripley's K function at each specified distance.
327    ///
328    /// Uses a simple toroidal edge-correction (Ripley, 1976).
329    ///
330    /// # Arguments
331    /// * `points` - Point locations as `(x, y)` pairs.
332    /// * `distances` - Distances at which to evaluate K.
333    /// * `area` - Area of the study region.
334    ///
335    /// # Returns
336    /// `Vec<f64>` with K(d) for each distance d.
337    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    /// Compute L(d) = sqrt(K(d)/π) - d, a normalised version with expected value 0
379    /// under complete spatial randomness.
380    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// ---------------------------------------------------------------------------
396// Experimental variogram
397// ---------------------------------------------------------------------------
398
399/// A single bin of the empirical variogram.
400#[derive(Debug, Clone)]
401pub struct VariogramBin {
402    /// Mean distance in this lag bin.
403    pub distance: f64,
404    /// Estimated semi-variance γ(h).
405    pub semivariance: f64,
406    /// Number of point pairs in this bin.
407    pub count: usize,
408}
409
410/// Compute the experimental (empirical) semi-variogram from spatial observations.
411///
412/// # Arguments
413/// * `points` - `(x, y, value)` triples.
414/// * `n_bins` - Number of lag bins.
415///
416/// # Returns
417/// `Vec<VariogramBin>` sorted by distance.
418pub 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    // Collect all pairwise distances and squared differences
432    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    // Bin pairs
455    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; // used implicitly via max_dist / n_bins
481    Ok(result)
482}
483
484// ---------------------------------------------------------------------------
485// Convenience tuple-returning wrapper (matches task spec signature)
486// ---------------------------------------------------------------------------
487
488/// Compute the experimental variogram returning `(distance, semivariance)` pairs.
489///
490/// This is a convenience wrapper around [`variogram`] that returns the result
491/// in a simple `Vec<(f64, f64)>` format.
492pub 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// ---------------------------------------------------------------------------
501// Tests
502// ---------------------------------------------------------------------------
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use scirs2_core::ndarray::Array2;
508
509    /// Build a simple contiguity weight matrix for a 1-D chain of n nodes.
510    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    /// Build a fully-connected (excluding diagonal) weight matrix.
520    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        // Values that increase monotonically → positive autocorrelation
531        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        // With monotone values and chain weights, I should be positive
535        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        // Checkerboard pattern → negative autocorrelation
545        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        // Geary's C should be non-negative
600        assert!(c >= 0.0, "Geary's C should be ≥ 0, got {c}");
601    }
602
603    #[test]
604    fn test_geary_c_positive_autocorrelation() {
605        // Smooth monotone data → C < 1 (positive autocorrelation)
606        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        // K(d) should be non-decreasing in d
618        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}