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()).unwrap();
33//! let geary = gearys_c(&values.view(), &weights.view()).unwrap();
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()).unwrap();
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).unwrap();
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).unwrap() / 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()).unwrap();
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).unwrap();
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).unwrap() / 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()).unwrap();
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).unwrap();
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).unwrap();
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).unwrap();
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).unwrap();
348    let variance: T = values
349        .map(|&x| {
350            let diff = x - mean;
351            diff * diff
352        })
353        .sum()
354        / T::from(n).unwrap();
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).unwrap();
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/// ).unwrap();
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).unwrap();
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 =
566        nn_distances.iter().fold(T::zero(), |acc, &d| acc + d) / T::from(n).unwrap();
567
568    // Calculate expected mean nearest neighbor distance for random pattern
569    let density = T::from(n).unwrap() / study_area;
570    let expected_mean = T::one() / (T::from(2.0).unwrap() * density.sqrt());
571
572    // Clark-Evans index
573    let clark_evans = observed_mean / expected_mean;
574
575    Ok(clark_evans)
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use approx::assert_relative_eq;
582    use scirs2_core::ndarray::array;
583
584    #[test]
585    fn test_morans_i() {
586        // Test with a simple case where adjacent values are similar
587        let values = array![1.0, 1.0, 3.0, 3.0, 3.0];
588        let weights = array![
589            [0.0, 1.0, 0.0, 0.0, 0.0],
590            [1.0, 0.0, 1.0, 0.0, 0.0],
591            [0.0, 1.0, 0.0, 1.0, 0.0],
592            [0.0, 0.0, 1.0, 0.0, 1.0],
593            [0.0, 0.0, 0.0, 1.0, 0.0],
594        ];
595
596        let moran = morans_i(&values.view(), &weights.view()).unwrap();
597
598        // Should be positive due to spatial clustering
599        assert!(moran > 0.0);
600    }
601
602    #[test]
603    fn test_gearys_c() {
604        // Test with clustered data
605        let values = array![1.0, 1.0, 3.0, 3.0, 3.0];
606        let weights = array![
607            [0.0, 1.0, 0.0, 0.0, 0.0],
608            [1.0, 0.0, 1.0, 0.0, 0.0],
609            [0.0, 1.0, 0.0, 1.0, 0.0],
610            [0.0, 0.0, 1.0, 0.0, 1.0],
611            [0.0, 0.0, 0.0, 1.0, 0.0],
612        ];
613
614        let geary = gearys_c(&values.view(), &weights.view()).unwrap();
615
616        // Should be less than 1 due to positive spatial autocorrelation
617        assert!(geary < 1.0);
618    }
619
620    #[test]
621    fn test_local_morans_i() {
622        let values = array![1.0, 1.0, 3.0, 3.0, 3.0];
623        let weights = array![
624            [0.0, 1.0, 0.0, 0.0, 0.0],
625            [1.0, 0.0, 1.0, 0.0, 0.0],
626            [0.0, 1.0, 0.0, 1.0, 0.0],
627            [0.0, 0.0, 1.0, 0.0, 1.0],
628            [0.0, 0.0, 0.0, 1.0, 0.0],
629        ];
630
631        let local_i = local_morans_i(&values.view(), &weights.view()).unwrap();
632
633        // Should have 5 values (one for each location)
634        assert_eq!(local_i.len(), 5);
635    }
636
637    #[test]
638    fn test_distance_weights_matrix() {
639        let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [2.0, 2.0],];
640
641        let weights = distance_weights_matrix(&coords.view(), 1.5, "inverse", 1.0).unwrap();
642
643        // Check dimensions
644        assert_eq!(weights.shape(), &[4, 4]);
645
646        // Diagonal should be zero
647        for i in 0..4 {
648            assert_relative_eq!(weights[[i, i]], 0.0, epsilon = 1e-10);
649        }
650
651        // Points (0,0) and (1,0) should have positive weight (distance = 1)
652        assert!(weights[[0, 1]] > 0.0);
653        assert!(weights[[1, 0]] > 0.0);
654
655        // Points (0,0) and (2,2) should have zero weight (distance > max_distance)
656        assert_relative_eq!(weights[[0, 3]], 0.0, epsilon = 1e-10);
657    }
658
659    #[test]
660    fn test_clark_evans_index() {
661        // Perfect grid pattern should have R > 1 (regular)
662        let coords = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0],];
663
664        let ce_index = clark_evans_index(&coords.view(), 4.0).unwrap();
665
666        // Grid pattern should be regular (R > 1)
667        assert!(ce_index > 1.0);
668    }
669}