Skip to main content

scirs2_spatial/
spatial_stats.rs

1//! Spatial statistics module for analyzing spatial patterns and relationships
2//!
3//! This module provides statistical measures commonly used in spatial analysis,
4//! including measures of spatial autocorrelation, clustering, and pattern analysis.
5//!
6//! # Features
7//!
8//! * **Spatial Autocorrelation**: Moran's I, Geary's C
9//! * **Local Indicators**: Local Moran's I (LISA)
10//! * **Distance-based Statistics**: Getis-Ord statistics
11//! * **Pattern Analysis**: Nearest neighbor analysis
12//!
13//! # Examples
14//!
15//! ```
16//! use scirs2_core::ndarray::array;
17//! use scirs2_spatial::spatial_stats::{morans_i, gearys_c};
18//!
19//! // Create spatial data (values at different locations)
20//! let values = array![1.0, 2.0, 1.5, 3.0, 2.5];
21//!
22//! // Define spatial weights matrix (adjacency-based)
23//! let weights = array![
24//!     [0.0, 1.0, 0.0, 0.0, 1.0],
25//!     [1.0, 0.0, 1.0, 0.0, 0.0],
26//!     [0.0, 1.0, 0.0, 1.0, 0.0],
27//!     [0.0, 0.0, 1.0, 0.0, 1.0],
28//!     [1.0, 0.0, 0.0, 1.0, 0.0],
29//! ];
30//!
31//! // Calculate spatial autocorrelation
32//! let moran = morans_i(&values.view(), &weights.view()).expect("Operation failed");
33//! let geary = gearys_c(&values.view(), &weights.view()).expect("Operation failed");
34//!
35//! println!("Moran's I: {:.3}", moran);
36//! println!("Geary's C: {:.3}", geary);
37//! ```
38
39use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
40use scirs2_core::numeric::Float;
41
42use crate::error::{SpatialError, SpatialResult};
43
44/// Calculate Moran's I statistic for spatial autocorrelation
45///
46/// Moran's I measures the degree of spatial autocorrelation in a dataset.
47/// Values range from -1 (perfect negative autocorrelation) to +1 (perfect positive autocorrelation).
48/// A value of 0 indicates no spatial autocorrelation (random spatial pattern).
49///
50/// # Arguments
51///
52/// * `values` - The observed values at each location
53/// * `weights` - Spatial weights matrix (typically binary adjacency or distance-based)
54///
55/// # Returns
56///
57/// * Moran's I statistic
58///
59/// # Examples
60///
61/// ```
62/// use scirs2_core::ndarray::array;
63/// use scirs2_spatial::spatial_stats::morans_i;
64///
65/// let values = array![1.0, 2.0, 1.5, 3.0, 2.5];
66/// let weights = array![
67///     [0.0, 1.0, 0.0, 0.0, 1.0],
68///     [1.0, 0.0, 1.0, 0.0, 0.0],
69///     [0.0, 1.0, 0.0, 1.0, 0.0],
70///     [0.0, 0.0, 1.0, 0.0, 1.0],
71///     [1.0, 0.0, 0.0, 1.0, 0.0],
72/// ];
73///
74/// let moran = morans_i(&values.view(), &weights.view()).expect("Operation failed");
75/// println!("Moran's I: {:.3}", moran);
76/// ```
77#[allow(dead_code)]
78pub fn morans_i<T: Float>(values: &ArrayView1<T>, weights: &ArrayView2<T>) -> SpatialResult<T> {
79    let n = values.len();
80
81    if weights.shape()[0] != n || weights.shape()[1] != n {
82        return Err(SpatialError::DimensionError(
83            "Weights matrix dimensions must match number of values".to_string(),
84        ));
85    }
86
87    // Calculate mean
88    let mean = values.sum() / T::from(n).expect("Operation failed");
89
90    // Calculate deviations from mean
91    let deviations: Array1<T> = values.map(|&x| x - mean);
92
93    // Calculate sum of weights
94    let w_sum = weights.sum();
95
96    if w_sum.is_zero() {
97        return Err(SpatialError::ValueError(
98            "Sum of weights cannot be zero".to_string(),
99        ));
100    }
101
102    // Calculate numerator: sum of (w_ij * (x_i - mean) * (x_j - mean))
103    let mut numerator = T::zero();
104    for i in 0..n {
105        for j in 0..n {
106            if i != j {
107                numerator = numerator + weights[[i, j]] * deviations[i] * deviations[j];
108            }
109        }
110    }
111
112    // Calculate denominator: sum of (x_i - mean)^2
113    let denominator: T = deviations.map(|&x| x * x).sum();
114
115    if denominator.is_zero() {
116        return Err(SpatialError::ValueError(
117            "Variance cannot be zero".to_string(),
118        ));
119    }
120
121    // Moran's I = (n / W) * (numerator / denominator)
122    let morans_i = (T::from(n).expect("Operation failed") / w_sum) * (numerator / denominator);
123
124    Ok(morans_i)
125}
126
127/// Calculate Geary's C statistic for spatial autocorrelation
128///
129/// Geary's C is another measure of spatial autocorrelation that ranges from 0 to 2.
130/// Values close to 1 indicate no spatial autocorrelation, values < 1 indicate positive
131/// autocorrelation, and values > 1 indicate negative autocorrelation.
132///
133/// # Arguments
134///
135/// * `values` - The observed values at each location
136/// * `weights` - Spatial weights matrix
137///
138/// # Returns
139///
140/// * Geary's C statistic
141///
142/// # Examples
143///
144/// ```
145/// use scirs2_core::ndarray::array;
146/// use scirs2_spatial::spatial_stats::gearys_c;
147///
148/// let values = array![1.0, 2.0, 1.5, 3.0, 2.5];
149/// let weights = array![
150///     [0.0, 1.0, 0.0, 0.0, 1.0],
151///     [1.0, 0.0, 1.0, 0.0, 0.0],
152///     [0.0, 1.0, 0.0, 1.0, 0.0],
153///     [0.0, 0.0, 1.0, 0.0, 1.0],
154///     [1.0, 0.0, 0.0, 1.0, 0.0],
155/// ];
156///
157/// let geary = gearys_c(&values.view(), &weights.view()).expect("Operation failed");
158/// println!("Geary's C: {:.3}", geary);
159/// ```
160#[allow(dead_code)]
161pub fn gearys_c<T: Float>(values: &ArrayView1<T>, weights: &ArrayView2<T>) -> SpatialResult<T> {
162    let n = values.len();
163
164    if weights.shape()[0] != n || weights.shape()[1] != n {
165        return Err(SpatialError::DimensionError(
166            "Weights matrix dimensions must match number of values".to_string(),
167        ));
168    }
169
170    // Calculate mean
171    let mean = values.sum() / T::from(n).expect("Operation failed");
172
173    // Calculate sum of weights
174    let w_sum = weights.sum();
175
176    if w_sum.is_zero() {
177        return Err(SpatialError::ValueError(
178            "Sum of weights cannot be zero".to_string(),
179        ));
180    }
181
182    // Calculate numerator: sum of (w_ij * (x_i - x_j)^2)
183    let mut numerator = T::zero();
184    for i in 0..n {
185        for j in 0..n {
186            if i != j {
187                let diff = values[i] - values[j];
188                numerator = numerator + weights[[i, j]] * diff * diff;
189            }
190        }
191    }
192
193    // Calculate denominator: 2 * W * sum of (x_i - mean)^2
194    let variance_sum: T = values
195        .map(|&x| {
196            let diff = x - mean;
197            diff * diff
198        })
199        .sum();
200
201    if variance_sum.is_zero() {
202        return Err(SpatialError::ValueError(
203            "Variance cannot be zero".to_string(),
204        ));
205    }
206
207    let denominator = (T::one() + T::one()) * w_sum * variance_sum;
208
209    // Geary's C = ((n-1) / 2W) * (numerator / variance_sum)
210    let gearys_c = (T::from((n - 1) as i32).expect("Operation failed") / denominator) * numerator;
211
212    Ok(gearys_c)
213}
214
215/// Calculate Local Indicators of Spatial Association (LISA) using Local Moran's I
216///
217/// Local Moran's I identifies clusters and outliers for each location individually.
218/// Positive values indicate that a location is part of a cluster of similar values,
219/// while negative values indicate spatial outliers.
220///
221/// # Arguments
222///
223/// * `values` - The observed values at each location
224/// * `weights` - Spatial weights matrix
225///
226/// # Returns
227///
228/// * Array of Local Moran's I values, one for each location
229///
230/// # Examples
231///
232/// ```
233/// use scirs2_core::ndarray::array;
234/// use scirs2_spatial::spatial_stats::local_morans_i;
235///
236/// let values = array![1.0, 2.0, 1.5, 3.0, 2.5];
237/// let weights = array![
238///     [0.0, 1.0, 0.0, 0.0, 1.0],
239///     [1.0, 0.0, 1.0, 0.0, 0.0],
240///     [0.0, 1.0, 0.0, 1.0, 0.0],
241///     [0.0, 0.0, 1.0, 0.0, 1.0],
242///     [1.0, 0.0, 0.0, 1.0, 0.0],
243/// ];
244///
245/// let local_i = local_morans_i(&values.view(), &weights.view()).expect("Operation failed");
246/// println!("Local Moran's I values: {:?}", local_i);
247/// ```
248#[allow(dead_code)]
249pub fn local_morans_i<T: Float>(
250    values: &ArrayView1<T>,
251    weights: &ArrayView2<T>,
252) -> SpatialResult<Array1<T>> {
253    let n = values.len();
254
255    if weights.shape()[0] != n || weights.shape()[1] != n {
256        return Err(SpatialError::DimensionError(
257            "Weights matrix dimensions must match number of values".to_string(),
258        ));
259    }
260
261    // Calculate global mean
262    let mean = values.sum() / T::from(n).expect("Operation failed");
263
264    // Calculate global variance
265    let variance: T = values
266        .map(|&x| {
267            let diff = x - mean;
268            diff * diff
269        })
270        .sum()
271        / T::from(n).expect("Operation failed");
272
273    if variance.is_zero() {
274        return Err(SpatialError::ValueError(
275            "Variance cannot be zero".to_string(),
276        ));
277    }
278
279    let mut local_i = Array1::zeros(n);
280
281    for i in 0..n {
282        let zi = (values[i] - mean) / variance.sqrt();
283
284        // Calculate weighted sum of neighboring deviations
285        let mut weighted_sum = T::zero();
286        for j in 0..n {
287            if i != j && weights[[i, j]] > T::zero() {
288                let zj = (values[j] - mean) / variance.sqrt();
289                weighted_sum = weighted_sum + weights[[i, j]] * zj;
290            }
291        }
292
293        local_i[i] = zi * weighted_sum;
294    }
295
296    Ok(local_i)
297}
298
299/// Calculate Getis-Ord Gi statistic for hotspot analysis
300///
301/// The Getis-Ord Gi statistic identifies statistically significant spatial
302/// clusters of high values (hotspots) and low values (coldspots).
303///
304/// # Arguments
305///
306/// * `values` - The observed values at each location
307/// * `weights` - Spatial weights matrix
308/// * `include_self` - Whether to include the focal location in the calculation
309///
310/// # Returns
311///
312/// * Array of Gi statistics, one for each location
313///
314/// # Examples
315///
316/// ```
317/// use scirs2_core::ndarray::array;
318/// use scirs2_spatial::spatial_stats::getis_ord_gi;
319///
320/// let values = array![1.0, 2.0, 1.5, 3.0, 2.5];
321/// let weights = array![
322///     [0.0, 1.0, 0.0, 0.0, 1.0],
323///     [1.0, 0.0, 1.0, 0.0, 0.0],
324///     [0.0, 1.0, 0.0, 1.0, 0.0],
325///     [0.0, 0.0, 1.0, 0.0, 1.0],
326///     [1.0, 0.0, 0.0, 1.0, 0.0],
327/// ];
328///
329/// let gi_stats = getis_ord_gi(&values.view(), &weights.view(), false).expect("Operation failed");
330/// println!("Gi statistics: {:?}", gi_stats);
331/// ```
332#[allow(dead_code)]
333pub fn getis_ord_gi<T: Float>(
334    values: &ArrayView1<T>,
335    weights: &ArrayView2<T>,
336    include_self: bool,
337) -> SpatialResult<Array1<T>> {
338    let n = values.len();
339
340    if weights.shape()[0] != n || weights.shape()[1] != n {
341        return Err(SpatialError::DimensionError(
342            "Weights matrix dimensions must match number of values".to_string(),
343        ));
344    }
345
346    // Calculate global mean and variance
347    let mean = values.sum() / T::from(n).expect("Operation failed");
348    let variance: T = values
349        .map(|&x| {
350            let diff = x - mean;
351            diff * diff
352        })
353        .sum()
354        / T::from(n).expect("Operation failed");
355
356    if variance.is_zero() {
357        return Err(SpatialError::ValueError(
358            "Variance cannot be zero".to_string(),
359        ));
360    }
361
362    let _std_dev = variance.sqrt();
363    let mut gi_stats = Array1::zeros(n);
364
365    for i in 0..n {
366        let mut weighted_sum = T::zero();
367        let mut weight_sum = T::zero();
368        let mut weight_sum_squared = T::zero();
369
370        for j in 0..n {
371            let use_weight = if include_self {
372                weights[[i, j]]
373            } else if i == j {
374                T::zero()
375            } else {
376                weights[[i, j]]
377            };
378
379            if use_weight > T::zero() {
380                weighted_sum = weighted_sum + use_weight * values[j];
381                weight_sum = weight_sum + use_weight;
382                weight_sum_squared = weight_sum_squared + use_weight * use_weight;
383            }
384        }
385
386        if weight_sum > T::zero() {
387            let n_f = T::from(n).expect("Operation failed");
388            let expected = weight_sum * mean;
389
390            // Calculate standard deviation of the sum
391            let variance_of_sum =
392                (n_f * weight_sum_squared - weight_sum * weight_sum) * variance / (n_f - T::one());
393
394            if variance_of_sum > T::zero() {
395                gi_stats[i] = (weighted_sum - expected) / variance_of_sum.sqrt();
396            }
397        }
398    }
399
400    Ok(gi_stats)
401}
402
403/// Calculate spatial weights matrix based on distance decay
404///
405/// Creates a spatial weights matrix where weights decay with distance according
406/// to a specified function (inverse distance, exponential decay, etc.).
407///
408/// # Arguments
409///
410/// * `coordinates` - Array of coordinate pairs [x, y] for each location
411/// * `max_distance` - Maximum distance for neighbors (beyond this, weight = 0)
412/// * `decay_function` - Function to apply distance decay ("inverse", "exponential", "gaussian")
413/// * `bandwidth` - Parameter controlling the rate of decay
414///
415/// # Returns
416///
417/// * Spatial weights matrix
418///
419/// # Examples
420///
421/// ```
422/// use scirs2_core::ndarray::array;
423/// use scirs2_spatial::spatial_stats::distance_weights_matrix;
424///
425/// let coords = array![
426///     [0.0, 0.0],
427///     [1.0, 0.0],
428///     [0.0, 1.0],
429///     [1.0, 1.0],
430/// ];
431///
432/// let weights = distance_weights_matrix(
433///     &coords.view(),
434///     2.0,
435///     "inverse",
436///     1.0
437/// ).expect("Operation failed");
438///
439/// println!("Distance-based weights matrix: {:?}", weights);
440/// ```
441#[allow(dead_code)]
442pub fn distance_weights_matrix<T: Float>(
443    coordinates: &ArrayView2<T>,
444    max_distance: T,
445    decay_function: &str,
446    bandwidth: T,
447) -> SpatialResult<Array2<T>> {
448    let n = coordinates.shape()[0];
449
450    if coordinates.shape()[1] != 2 {
451        return Err(SpatialError::DimensionError(
452            "Coordinates must be 2D (x, y)".to_string(),
453        ));
454    }
455
456    let mut weights = Array2::zeros((n, n));
457
458    for i in 0..n {
459        for j in 0..n {
460            if i != j {
461                // Calculate Euclidean _distance
462                let dx = coordinates[[i, 0]] - coordinates[[j, 0]];
463                let dy = coordinates[[i, 1]] - coordinates[[j, 1]];
464                let _distance = (dx * dx + dy * dy).sqrt();
465
466                if _distance <= max_distance {
467                    let weight = match decay_function {
468                        "inverse" => {
469                            if _distance > T::zero() {
470                                T::one() / (T::one() + _distance / bandwidth)
471                            } else {
472                                T::zero()
473                            }
474                        }
475                        "exponential" => (-_distance / bandwidth).exp(),
476                        "gaussian" => {
477                            let exponent = -(_distance * _distance) / (bandwidth * bandwidth);
478                            exponent.exp()
479                        }
480                        _ => {
481                            return Err(SpatialError::ValueError(
482                                "Unknown decay function. Use 'inverse', 'exponential', or 'gaussian'".to_string(),
483                            ));
484                        }
485                    };
486
487                    weights[[i, j]] = weight;
488                }
489            }
490        }
491    }
492
493    Ok(weights)
494}
495
496/// Calculate Clark-Evans nearest neighbor index
497///
498/// The Clark-Evans index compares the average nearest neighbor distance
499/// to the expected distance in a random point pattern. Values < 1 indicate
500/// clustering, values > 1 indicate regularity, and values ≈ 1 indicate randomness.
501///
502/// # Arguments
503///
504/// * `coordinates` - Array of coordinate pairs [x, y] for each point
505/// * `study_area` - Area of the study region
506///
507/// # Returns
508///
509/// * Clark-Evans index (R)
510///
511/// # Examples
512///
513/// ```
514/// use scirs2_core::ndarray::array;
515/// use scirs2_spatial::spatial_stats::clark_evans_index;
516///
517/// let coords = array![
518///     [0.0, 0.0],
519///     [1.0, 0.0],
520///     [0.0, 1.0],
521///     [1.0, 1.0],
522/// ];
523///
524/// let ce_index = clark_evans_index(&coords.view(), 4.0).expect("Operation failed");
525/// println!("Clark-Evans index: {:.3}", ce_index);
526/// ```
527#[allow(dead_code)]
528pub fn clark_evans_index<T: Float>(coordinates: &ArrayView2<T>, study_area: T) -> SpatialResult<T> {
529    let n = coordinates.shape()[0];
530
531    if coordinates.shape()[1] != 2 {
532        return Err(SpatialError::DimensionError(
533            "Coordinates must be 2D (x, y)".to_string(),
534        ));
535    }
536
537    if n < 2 {
538        return Err(SpatialError::ValueError(
539            "Need at least 2 points to calculate nearest neighbor distances".to_string(),
540        ));
541    }
542
543    // Calculate nearest neighbor distances
544    let mut nn_distances = Vec::with_capacity(n);
545
546    for i in 0..n {
547        let mut min_distance = T::infinity();
548
549        for j in 0..n {
550            if i != j {
551                let dx = coordinates[[i, 0]] - coordinates[[j, 0]];
552                let dy = coordinates[[i, 1]] - coordinates[[j, 1]];
553                let distance = (dx * dx + dy * dy).sqrt();
554
555                if distance < min_distance {
556                    min_distance = distance;
557                }
558            }
559        }
560
561        nn_distances.push(min_distance);
562    }
563
564    // Calculate observed mean nearest neighbor distance
565    let observed_mean = nn_distances.iter().fold(T::zero(), |acc, &d| acc + d)
566        / T::from(n).expect("Operation failed");
567
568    // Calculate expected mean nearest neighbor distance for random pattern
569    let density = T::from(n).expect("Operation failed") / study_area;
570    let expected_mean = T::one() / (T::from(2.0).expect("Operation failed") * density.sqrt());
571
572    // Clark-Evans index
573    let clark_evans = observed_mean / expected_mean;
574
575    Ok(clark_evans)
576}
577
578/// Build a contiguity-based spatial weights matrix from polygon adjacency
579///
580/// Given a list of polygon boundaries (each polygon is a list of vertex indices),
581/// two polygons are considered neighbors if they share at least `min_shared_vertices`
582/// vertices (rook contiguity uses edges = 2 shared vertices in 2D,
583/// queen contiguity uses any shared vertex = 1).
584///
585/// # Arguments
586///
587/// * `polygons` - A slice of polygons, each represented as a vector of vertex indices
588/// * `n` - Total number of spatial units
589/// * `min_shared_vertices` - Minimum number of shared vertices to be considered neighbors
590///   (1 for queen contiguity, 2 for rook contiguity)
591///
592/// # Returns
593///
594/// * Binary spatial weights matrix (n x n)
595pub fn contiguity_weights_matrix(
596    polygons: &[Vec<usize>],
597    n: usize,
598    min_shared_vertices: usize,
599) -> SpatialResult<Array2<f64>> {
600    if polygons.len() != n {
601        return Err(SpatialError::DimensionError(format!(
602            "Number of polygons ({}) must match n ({})",
603            polygons.len(),
604            n
605        )));
606    }
607
608    if min_shared_vertices == 0 {
609        return Err(SpatialError::ValueError(
610            "min_shared_vertices must be at least 1".to_string(),
611        ));
612    }
613
614    let mut weights = Array2::zeros((n, n));
615
616    for i in 0..n {
617        for j in (i + 1)..n {
618            // Count shared vertices
619            let shared = polygons[i]
620                .iter()
621                .filter(|v| polygons[j].contains(v))
622                .count();
623
624            if shared >= min_shared_vertices {
625                weights[[i, j]] = 1.0;
626                weights[[j, i]] = 1.0;
627            }
628        }
629    }
630
631    Ok(weights)
632}
633
634/// Build a k-nearest neighbors spatial weights matrix
635///
636/// For each location, the k nearest neighbors (by Euclidean distance) receive
637/// a weight of 1, and all other locations receive a weight of 0. The resulting
638/// matrix is typically asymmetric (i may be a neighbor of j, but j may not be
639/// a neighbor of i).
640///
641/// # Arguments
642///
643/// * `coordinates` - Array of coordinate pairs for each location (n x d)
644/// * `k` - Number of nearest neighbors
645///
646/// # Returns
647///
648/// * Binary spatial weights matrix (n x n), where `w[i,j]` = 1 if j is among
649///   the k nearest neighbors of i
650///
651/// # Examples
652///
653/// ```
654/// use scirs2_core::ndarray::array;
655/// use scirs2_spatial::spatial_stats::knn_weights_matrix;
656///
657/// let coords = array![
658///     [0.0, 0.0],
659///     [1.0, 0.0],
660///     [0.0, 1.0],
661///     [1.0, 1.0],
662/// ];
663///
664/// let weights = knn_weights_matrix(&coords.view(), 2).expect("Operation failed");
665/// // Each point has exactly 2 neighbors
666/// for i in 0..4 {
667///     let row_sum: f64 = (0..4).map(|j| weights[[i, j]]).sum();
668///     assert!((row_sum - 2.0).abs() < 1e-10);
669/// }
670/// ```
671pub fn knn_weights_matrix(coordinates: &ArrayView2<f64>, k: usize) -> SpatialResult<Array2<f64>> {
672    let n = coordinates.shape()[0];
673    let ndim = coordinates.shape()[1];
674
675    if k == 0 {
676        return Err(SpatialError::ValueError("k must be at least 1".to_string()));
677    }
678
679    if k >= n {
680        return Err(SpatialError::ValueError(format!(
681            "k ({}) must be less than number of points ({})",
682            k, n
683        )));
684    }
685
686    let mut weights = Array2::zeros((n, n));
687
688    for i in 0..n {
689        // Compute distances from point i to all other points
690        let mut distances: Vec<(usize, f64)> = Vec::with_capacity(n - 1);
691        for j in 0..n {
692            if i != j {
693                let mut dist_sq = 0.0;
694                for d in 0..ndim {
695                    let diff = coordinates[[i, d]] - coordinates[[j, d]];
696                    dist_sq += diff * diff;
697                }
698                distances.push((j, dist_sq));
699            }
700        }
701
702        // Sort by distance
703        distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
704
705        // Assign weight 1 to the k nearest neighbors
706        for &(j, _) in distances.iter().take(k) {
707            weights[[i, j]] = 1.0;
708        }
709    }
710
711    Ok(weights)
712}
713
714/// Compute Ripley's K-function for point pattern analysis
715///
716/// Ripley's K-function estimates the expected number of points within distance t
717/// of a typical point, divided by the intensity (point density). It is used to
718/// detect clustering or regularity at different spatial scales.
719///
720/// K(t) = (area / n^2) * sum_{i != j} I(d_{ij} <= t)
721///
722/// Where I is the indicator function. Under complete spatial randomness (CSR),
723/// K(t) = pi * t^2 in 2D.
724///
725/// # Arguments
726///
727/// * `coordinates` - Array of coordinate pairs [x, y] for each point (n x 2)
728/// * `study_area` - Area of the study region
729/// * `distances` - Array of distance thresholds at which to evaluate K
730///
731/// # Returns
732///
733/// * Array of K(t) values, one for each distance threshold
734///
735/// # Examples
736///
737/// ```
738/// use scirs2_core::ndarray::array;
739/// use scirs2_spatial::spatial_stats::ripleys_k;
740///
741/// let coords = array![
742///     [0.0, 0.0],
743///     [1.0, 0.0],
744///     [0.0, 1.0],
745///     [1.0, 1.0],
746///     [0.5, 0.5],
747/// ];
748///
749/// let distances = array![0.5, 1.0, 1.5, 2.0];
750/// let k_values = ripleys_k(&coords.view(), 4.0, &distances.view()).expect("Operation failed");
751/// assert_eq!(k_values.len(), 4);
752/// // K should be monotonically non-decreasing
753/// for i in 1..k_values.len() {
754///     assert!(k_values[i] >= k_values[i - 1]);
755/// }
756/// ```
757pub fn ripleys_k(
758    coordinates: &ArrayView2<f64>,
759    study_area: f64,
760    distances: &ArrayView1<f64>,
761) -> SpatialResult<Array1<f64>> {
762    let n = coordinates.shape()[0];
763
764    if coordinates.shape()[1] != 2 {
765        return Err(SpatialError::DimensionError(
766            "Coordinates must be 2D (x, y)".to_string(),
767        ));
768    }
769
770    if n < 2 {
771        return Err(SpatialError::ValueError(
772            "Need at least 2 points for Ripley's K".to_string(),
773        ));
774    }
775
776    if study_area <= 0.0 {
777        return Err(SpatialError::ValueError(
778            "Study area must be positive".to_string(),
779        ));
780    }
781
782    let n_dists = distances.len();
783    let mut k_values = Array1::zeros(n_dists);
784    let n_f = n as f64;
785    let intensity = n_f / study_area;
786
787    // Precompute all pairwise distances
788    let mut pairwise_dists: Vec<f64> = Vec::with_capacity(n * (n - 1) / 2);
789    for i in 0..n {
790        for j in (i + 1)..n {
791            let dx = coordinates[[i, 0]] - coordinates[[j, 0]];
792            let dy = coordinates[[i, 1]] - coordinates[[j, 1]];
793            pairwise_dists.push((dx * dx + dy * dy).sqrt());
794        }
795    }
796
797    // For each distance threshold, count pairs within that distance
798    for (t_idx, &t) in distances.iter().enumerate() {
799        let count: usize = pairwise_dists.iter().filter(|&&d| d <= t).count();
800        // Each pair (i,j) counted once, but K sums over ordered pairs i != j
801        // so multiply by 2
802        k_values[t_idx] = (study_area / (n_f * n_f)) * (2.0 * count as f64);
803    }
804
805    Ok(k_values)
806}
807
808/// Compute Ripley's L-function (variance-stabilized K-function)
809///
810/// L(t) = sqrt(K(t) / pi), which linearizes K under CSR so that L(t) = t
811/// for a complete spatial random process.
812///
813/// # Arguments
814///
815/// * `coordinates` - Array of coordinate pairs [x, y] for each point (n x 2)
816/// * `study_area` - Area of the study region
817/// * `distances` - Array of distance thresholds at which to evaluate L
818///
819/// # Returns
820///
821/// * Array of L(t) values, one for each distance threshold
822pub fn ripleys_l(
823    coordinates: &ArrayView2<f64>,
824    study_area: f64,
825    distances: &ArrayView1<f64>,
826) -> SpatialResult<Array1<f64>> {
827    let k_values = ripleys_k(coordinates, study_area, distances)?;
828
829    let l_values = k_values.mapv(|k| {
830        if k >= 0.0 {
831            (k / std::f64::consts::PI).sqrt()
832        } else {
833            0.0
834        }
835    });
836
837    Ok(l_values)
838}
839
840/// Calculate the average nearest neighbor distance statistic
841///
842/// Computes the mean distance from each point to its nearest neighbor,
843/// and returns the observed mean, the expected mean under CSR, the z-score,
844/// and the nearest-neighbor index (R = observed / expected).
845///
846/// # Arguments
847///
848/// * `coordinates` - Array of coordinate pairs [x, y] for each point (n x 2)
849/// * `study_area` - Area of the study region
850///
851/// # Returns
852///
853/// * `AnnResult` containing observed mean, expected mean, z-score, and R index
854///
855/// # Examples
856///
857/// ```
858/// use scirs2_core::ndarray::array;
859/// use scirs2_spatial::spatial_stats::average_nearest_neighbor;
860///
861/// let coords = array![
862///     [0.0, 0.0],
863///     [1.0, 0.0],
864///     [0.0, 1.0],
865///     [1.0, 1.0],
866/// ];
867///
868/// let result = average_nearest_neighbor(&coords.view(), 4.0).expect("Operation failed");
869/// assert!(result.observed_mean > 0.0);
870/// assert!(result.expected_mean > 0.0);
871/// // Regular grid should have R > 1
872/// assert!(result.r_index > 1.0);
873/// ```
874pub fn average_nearest_neighbor(
875    coordinates: &ArrayView2<f64>,
876    study_area: f64,
877) -> SpatialResult<AnnResult> {
878    let n = coordinates.shape()[0];
879    let ndim = coordinates.shape()[1];
880
881    if n < 2 {
882        return Err(SpatialError::ValueError(
883            "Need at least 2 points for average nearest neighbor".to_string(),
884        ));
885    }
886
887    if study_area <= 0.0 {
888        return Err(SpatialError::ValueError(
889            "Study area must be positive".to_string(),
890        ));
891    }
892
893    // Calculate nearest neighbor distances
894    let mut nn_distances = Vec::with_capacity(n);
895
896    for i in 0..n {
897        let mut min_distance = f64::INFINITY;
898
899        for j in 0..n {
900            if i != j {
901                let mut dist_sq = 0.0;
902                for d in 0..ndim {
903                    let diff = coordinates[[i, d]] - coordinates[[j, d]];
904                    dist_sq += diff * diff;
905                }
906                let dist = dist_sq.sqrt();
907                if dist < min_distance {
908                    min_distance = dist;
909                }
910            }
911        }
912
913        nn_distances.push(min_distance);
914    }
915
916    // Observed mean nearest neighbor distance
917    let observed_mean: f64 = nn_distances.iter().sum::<f64>() / n as f64;
918
919    // Expected mean under CSR: E(d) = 1 / (2 * sqrt(density))
920    let density = n as f64 / study_area;
921    let expected_mean = 1.0 / (2.0 * density.sqrt());
922
923    // Standard error of the mean under CSR
924    let se = 0.26136 / (n as f64 * density).sqrt();
925
926    // Z-score
927    let z_score = if se > 0.0 {
928        (observed_mean - expected_mean) / se
929    } else {
930        0.0
931    };
932
933    // Nearest neighbor index R
934    let r_index = if expected_mean > 0.0 {
935        observed_mean / expected_mean
936    } else {
937        0.0
938    };
939
940    Ok(AnnResult {
941        observed_mean,
942        expected_mean,
943        z_score,
944        r_index,
945        nn_distances,
946    })
947}
948
949/// Result of the average nearest neighbor analysis
950#[derive(Debug, Clone)]
951pub struct AnnResult {
952    /// Observed mean nearest neighbor distance
953    pub observed_mean: f64,
954    /// Expected mean nearest neighbor distance under complete spatial randomness
955    pub expected_mean: f64,
956    /// Z-score for significance testing
957    pub z_score: f64,
958    /// Nearest neighbor index (R = observed / expected)
959    /// R < 1 indicates clustering, R > 1 indicates dispersion, R ≈ 1 indicates randomness
960    pub r_index: f64,
961    /// Individual nearest neighbor distances for each point
962    pub nn_distances: Vec<f64>,
963}
964
965#[cfg(test)]
966mod tests {
967    use super::*;
968    use approx::assert_relative_eq;
969    use scirs2_core::ndarray::array;
970
971    #[test]
972    fn test_morans_i() {
973        // Test with a simple case where adjacent values are similar
974        let values = array![1.0, 1.0, 3.0, 3.0, 3.0];
975        let weights = array![
976            [0.0, 1.0, 0.0, 0.0, 0.0],
977            [1.0, 0.0, 1.0, 0.0, 0.0],
978            [0.0, 1.0, 0.0, 1.0, 0.0],
979            [0.0, 0.0, 1.0, 0.0, 1.0],
980            [0.0, 0.0, 0.0, 1.0, 0.0],
981        ];
982
983        let moran = morans_i(&values.view(), &weights.view()).expect("Operation failed");
984
985        // Should be positive due to spatial clustering
986        assert!(moran > 0.0);
987    }
988
989    #[test]
990    fn test_gearys_c() {
991        // Test with clustered data
992        let values = array![1.0, 1.0, 3.0, 3.0, 3.0];
993        let weights = array![
994            [0.0, 1.0, 0.0, 0.0, 0.0],
995            [1.0, 0.0, 1.0, 0.0, 0.0],
996            [0.0, 1.0, 0.0, 1.0, 0.0],
997            [0.0, 0.0, 1.0, 0.0, 1.0],
998            [0.0, 0.0, 0.0, 1.0, 0.0],
999        ];
1000
1001        let geary = gearys_c(&values.view(), &weights.view()).expect("Operation failed");
1002
1003        // Should be less than 1 due to positive spatial autocorrelation
1004        assert!(geary < 1.0);
1005    }
1006
1007    #[test]
1008    fn test_local_morans_i() {
1009        let values = array![1.0, 1.0, 3.0, 3.0, 3.0];
1010        let weights = array![
1011            [0.0, 1.0, 0.0, 0.0, 0.0],
1012            [1.0, 0.0, 1.0, 0.0, 0.0],
1013            [0.0, 1.0, 0.0, 1.0, 0.0],
1014            [0.0, 0.0, 1.0, 0.0, 1.0],
1015            [0.0, 0.0, 0.0, 1.0, 0.0],
1016        ];
1017
1018        let local_i = local_morans_i(&values.view(), &weights.view()).expect("Operation failed");
1019
1020        // Should have 5 values (one for each location)
1021        assert_eq!(local_i.len(), 5);
1022    }
1023
1024    #[test]
1025    fn test_distance_weights_matrix() {
1026        let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [2.0, 2.0],];
1027
1028        let weights =
1029            distance_weights_matrix(&coords.view(), 1.5, "inverse", 1.0).expect("Operation failed");
1030
1031        // Check dimensions
1032        assert_eq!(weights.shape(), &[4, 4]);
1033
1034        // Diagonal should be zero
1035        for i in 0..4 {
1036            assert_relative_eq!(weights[[i, i]], 0.0, epsilon = 1e-10);
1037        }
1038
1039        // Points (0,0) and (1,0) should have positive weight (distance = 1)
1040        assert!(weights[[0, 1]] > 0.0);
1041        assert!(weights[[1, 0]] > 0.0);
1042
1043        // Points (0,0) and (2,2) should have zero weight (distance > max_distance)
1044        assert_relative_eq!(weights[[0, 3]], 0.0, epsilon = 1e-10);
1045    }
1046
1047    #[test]
1048    fn test_clark_evans_index() {
1049        // Perfect grid pattern should have R > 1 (regular)
1050        let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0],];
1051
1052        let ce_index = clark_evans_index(&coords.view(), 4.0).expect("Operation failed");
1053
1054        // Grid pattern should be regular (R > 1)
1055        assert!(ce_index > 1.0);
1056    }
1057
1058    #[test]
1059    fn test_contiguity_weights_matrix_queen() {
1060        // 4 polygons sharing vertices (queen contiguity)
1061        // Polygon 0: vertices {0, 1, 2, 3}
1062        // Polygon 1: vertices {2, 3, 4, 5}
1063        // Polygon 2: vertices {3, 5, 6, 7}
1064        // Polygon 3: vertices {8, 9, 10, 11}
1065        let polygons = vec![
1066            vec![0, 1, 2, 3],
1067            vec![2, 3, 4, 5],
1068            vec![3, 5, 6, 7],
1069            vec![8, 9, 10, 11],
1070        ];
1071
1072        let weights = contiguity_weights_matrix(&polygons, 4, 1).expect("Operation failed");
1073
1074        assert_eq!(weights.shape(), &[4, 4]);
1075
1076        // Polygon 0 and 1 share vertices 2, 3
1077        assert_relative_eq!(weights[[0, 1]], 1.0);
1078        assert_relative_eq!(weights[[1, 0]], 1.0);
1079
1080        // Polygon 0 and 2 share vertex 3
1081        assert_relative_eq!(weights[[0, 2]], 1.0);
1082
1083        // Polygon 1 and 2 share vertices 3, 5
1084        assert_relative_eq!(weights[[1, 2]], 1.0);
1085
1086        // Polygon 3 is isolated
1087        assert_relative_eq!(weights[[0, 3]], 0.0);
1088        assert_relative_eq!(weights[[1, 3]], 0.0);
1089        assert_relative_eq!(weights[[2, 3]], 0.0);
1090    }
1091
1092    #[test]
1093    fn test_contiguity_weights_matrix_rook() {
1094        // With rook contiguity (min_shared = 2), only edges count
1095        let polygons = vec![
1096            vec![0, 1, 2, 3],
1097            vec![2, 3, 4, 5],
1098            vec![3, 5, 6, 7], // shares only vertex 3 with polygon 0
1099            vec![8, 9, 10, 11],
1100        ];
1101
1102        let weights = contiguity_weights_matrix(&polygons, 4, 2).expect("Operation failed");
1103
1104        // Polygon 0 and 1 share 2 vertices => neighbors
1105        assert_relative_eq!(weights[[0, 1]], 1.0);
1106
1107        // Polygon 0 and 2 share only vertex 3 => NOT neighbors with rook
1108        assert_relative_eq!(weights[[0, 2]], 0.0);
1109
1110        // Polygon 1 and 2 share vertices 3, 5 => neighbors
1111        assert_relative_eq!(weights[[1, 2]], 1.0);
1112    }
1113
1114    #[test]
1115    fn test_knn_weights_matrix() {
1116        let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0], [5.0, 5.0],];
1117
1118        let weights = knn_weights_matrix(&coords.view(), 2).expect("Operation failed");
1119
1120        assert_eq!(weights.shape(), &[5, 5]);
1121
1122        // Each point should have exactly 2 neighbors
1123        for i in 0..5 {
1124            let row_sum: f64 = (0..5).map(|j| weights[[i, j]]).sum();
1125            assert_relative_eq!(row_sum, 2.0, epsilon = 1e-10);
1126        }
1127
1128        // Diagonal should be zero
1129        for i in 0..5 {
1130            assert_relative_eq!(weights[[i, i]], 0.0);
1131        }
1132
1133        // Point (0,0) should have (1,0) and (0,1) as nearest neighbors
1134        assert_relative_eq!(weights[[0, 1]], 1.0);
1135        assert_relative_eq!(weights[[0, 2]], 1.0);
1136    }
1137
1138    #[test]
1139    fn test_knn_weights_errors() {
1140        let coords = array![[0.0, 0.0], [1.0, 0.0]];
1141
1142        // k = 0 should fail
1143        assert!(knn_weights_matrix(&coords.view(), 0).is_err());
1144
1145        // k >= n should fail
1146        assert!(knn_weights_matrix(&coords.view(), 2).is_err());
1147    }
1148
1149    #[test]
1150    fn test_ripleys_k() {
1151        let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0], [0.5, 0.5],];
1152
1153        let distances = array![0.5, 1.0, 1.5, 2.0];
1154        let k_values = ripleys_k(&coords.view(), 4.0, &distances.view()).expect("Operation failed");
1155
1156        assert_eq!(k_values.len(), 4);
1157
1158        // K should be monotonically non-decreasing
1159        for i in 1..k_values.len() {
1160            assert!(
1161                k_values[i] >= k_values[i - 1],
1162                "K should be non-decreasing: K[{}] = {} < K[{}] = {}",
1163                i,
1164                k_values[i],
1165                i - 1,
1166                k_values[i - 1]
1167            );
1168        }
1169
1170        // At distance 0.5, only (0.5,0.5) is within 0.5 of some points
1171        // K(0) should be 0 (no points at distance 0)
1172        // K at larger distances should be larger
1173        assert!(k_values[3] > k_values[0]);
1174    }
1175
1176    #[test]
1177    fn test_ripleys_l() {
1178        let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0], [0.5, 0.5],];
1179
1180        let distances = array![0.5, 1.0, 1.5];
1181        let l_values = ripleys_l(&coords.view(), 4.0, &distances.view()).expect("Operation failed");
1182
1183        assert_eq!(l_values.len(), 3);
1184
1185        // L should be non-negative
1186        for &l in l_values.iter() {
1187            assert!(l >= 0.0);
1188        }
1189    }
1190
1191    #[test]
1192    fn test_ripleys_k_errors() {
1193        let coords = array![[0.0, 0.0]]; // Only 1 point
1194        let distances = array![1.0];
1195
1196        assert!(ripleys_k(&coords.view(), 1.0, &distances.view()).is_err());
1197
1198        // Negative study area
1199        let coords2 = array![[0.0, 0.0], [1.0, 1.0]];
1200        assert!(ripleys_k(&coords2.view(), -1.0, &distances.view()).is_err());
1201    }
1202
1203    #[test]
1204    fn test_average_nearest_neighbor() {
1205        // Regular grid pattern
1206        let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0],];
1207
1208        let result = average_nearest_neighbor(&coords.view(), 4.0).expect("Operation failed");
1209
1210        assert!(result.observed_mean > 0.0);
1211        assert!(result.expected_mean > 0.0);
1212        assert_eq!(result.nn_distances.len(), 4);
1213
1214        // All nearest neighbor distances should be 1.0 for a unit grid
1215        for &d in &result.nn_distances {
1216            assert_relative_eq!(d, 1.0, epsilon = 1e-10);
1217        }
1218
1219        // Regular pattern should have R > 1
1220        assert!(result.r_index > 1.0);
1221    }
1222
1223    #[test]
1224    fn test_average_nearest_neighbor_clustered() {
1225        // Clustered pattern: points very close together
1226        let coords = array![[0.0, 0.0], [0.01, 0.0], [0.0, 0.01], [0.01, 0.01],];
1227
1228        let result = average_nearest_neighbor(&coords.view(), 100.0).expect("Operation failed");
1229
1230        // Clustered pattern should have R < 1
1231        assert!(
1232            result.r_index < 1.0,
1233            "Expected R < 1 for clustered pattern, got {}",
1234            result.r_index
1235        );
1236    }
1237
1238    #[test]
1239    fn test_average_nearest_neighbor_errors() {
1240        let coords = array![[0.0, 0.0]]; // Only 1 point
1241
1242        assert!(average_nearest_neighbor(&coords.view(), 1.0).is_err());
1243
1244        // Negative study area
1245        let coords2 = array![[0.0, 0.0], [1.0, 1.0]];
1246        assert!(average_nearest_neighbor(&coords2.view(), -1.0).is_err());
1247    }
1248}