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}