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}