Skip to main content

oxigdal_analytics/hotspot/
mod.rs

1//! Hotspot Analysis Module
2//!
3//! This module provides spatial statistics for hotspot and cluster analysis:
4//! - Getis-Ord Gi* statistic
5//! - Moran's I spatial autocorrelation
6//! - Local Indicators of Spatial Association (LISA)
7
8pub mod getis_ord;
9pub mod moran;
10
11pub use getis_ord::{GetisOrdGiStar, GetisOrdResult};
12pub use moran::{LocalMoransI, LocalMoransIResult, MoransI, MoransIResult};
13
14use crate::error::{AnalyticsError, Result};
15use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
16
17/// Spatial weights matrix
18///
19/// Represents spatial relationships between observations
20#[derive(Debug, Clone)]
21pub struct SpatialWeights {
22    /// Weights matrix (n × n)
23    pub weights: Array2<f64>,
24    /// Whether weights are row-standardized
25    pub row_standardized: bool,
26}
27
28impl SpatialWeights {
29    /// Create spatial weights from adjacency matrix
30    ///
31    /// # Arguments
32    /// * `adjacency` - Binary adjacency matrix (1 if neighbors, 0 otherwise)
33    ///
34    /// # Errors
35    /// Returns error if matrix is not square
36    pub fn from_adjacency(adjacency: Array2<f64>) -> Result<Self> {
37        let (n, m) = adjacency.dim();
38        if n != m {
39            return Err(AnalyticsError::dimension_mismatch(
40                format!("{}x{}", n, n),
41                format!("{}x{}", n, m),
42            ));
43        }
44
45        Ok(Self {
46            weights: adjacency,
47            row_standardized: false,
48        })
49    }
50
51    /// Create spatial weights from distance matrix with threshold
52    ///
53    /// # Arguments
54    /// * `distances` - Distance matrix
55    /// * `threshold` - Maximum distance to be considered neighbors
56    ///
57    /// # Errors
58    /// Returns error if matrix is not square
59    pub fn from_distance_threshold(distances: &ArrayView2<f64>, threshold: f64) -> Result<Self> {
60        let (n, m) = distances.dim();
61        if n != m {
62            return Err(AnalyticsError::dimension_mismatch(
63                format!("{}x{}", n, n),
64                format!("{}x{}", n, m),
65            ));
66        }
67
68        let mut weights = Array2::zeros((n, n));
69        for i in 0..n {
70            for j in 0..n {
71                if i != j && distances[[i, j]] <= threshold {
72                    weights[[i, j]] = 1.0;
73                }
74            }
75        }
76
77        Ok(Self {
78            weights,
79            row_standardized: false,
80        })
81    }
82
83    /// Create spatial weights using inverse distance
84    ///
85    /// # Arguments
86    /// * `distances` - Distance matrix
87    /// * `power` - Power parameter for inverse distance (typically 1 or 2)
88    ///
89    /// # Errors
90    /// Returns error if matrix is not square or contains zero distances
91    pub fn from_inverse_distance(distances: &ArrayView2<f64>, power: f64) -> Result<Self> {
92        let (n, m) = distances.dim();
93        if n != m {
94            return Err(AnalyticsError::dimension_mismatch(
95                format!("{}x{}", n, n),
96                format!("{}x{}", n, m),
97            ));
98        }
99
100        let mut weights = Array2::zeros((n, n));
101        for i in 0..n {
102            for j in 0..n {
103                if i != j {
104                    let dist = distances[[i, j]];
105                    if dist < f64::EPSILON {
106                        return Err(AnalyticsError::invalid_input(
107                            "Distance matrix contains zero distances",
108                        ));
109                    }
110                    weights[[i, j]] = 1.0 / dist.powf(power);
111                }
112            }
113        }
114
115        Ok(Self {
116            weights,
117            row_standardized: false,
118        })
119    }
120
121    /// Row-standardize the weights matrix
122    ///
123    /// Each row sums to 1.0 (or 0.0 if the row was all zeros)
124    pub fn row_standardize(&mut self) {
125        let n = self.weights.nrows();
126        for i in 0..n {
127            let row_sum: f64 = self.weights.row(i).sum();
128            if row_sum > f64::EPSILON {
129                for j in 0..n {
130                    self.weights[[i, j]] /= row_sum;
131                }
132            }
133        }
134        self.row_standardized = true;
135    }
136
137    /// Get number of neighbors for each observation
138    #[must_use]
139    pub fn n_neighbors(&self) -> Array1<usize> {
140        let n = self.weights.nrows();
141        let mut neighbors = Array1::zeros(n);
142
143        for i in 0..n {
144            let count = self
145                .weights
146                .row(i)
147                .iter()
148                .filter(|&&w| w > f64::EPSILON)
149                .count();
150            neighbors[i] = count;
151        }
152
153        neighbors
154    }
155
156    /// Calculate spatial lag (weighted average of neighbors)
157    ///
158    /// # Arguments
159    /// * `values` - Values for each observation
160    ///
161    /// # Errors
162    /// Returns error if dimension mismatch
163    pub fn spatial_lag(&self, values: &ArrayView1<f64>) -> Result<Array1<f64>> {
164        let n = self.weights.nrows();
165        if values.len() != n {
166            return Err(AnalyticsError::dimension_mismatch(
167                format!("{}", n),
168                format!("{}", values.len()),
169            ));
170        }
171
172        let mut lag = Array1::zeros(n);
173        for i in 0..n {
174            let mut weighted_sum = 0.0;
175            for j in 0..n {
176                weighted_sum += self.weights[[i, j]] * values[j];
177            }
178            lag[i] = weighted_sum;
179        }
180
181        Ok(lag)
182    }
183}
184
185/// Calculate spatial lag for raw weights (utility function)
186///
187/// # Arguments
188/// * `values` - Values for each observation
189/// * `weights` - Spatial weights matrix
190///
191/// # Errors
192/// Returns error if dimensions don't match
193pub fn calculate_spatial_lag(
194    values: &ArrayView1<f64>,
195    weights: &ArrayView2<f64>,
196) -> Result<Array1<f64>> {
197    let n = weights.nrows();
198    if values.len() != n || weights.ncols() != n {
199        return Err(AnalyticsError::dimension_mismatch(
200            format!("{}", n),
201            format!("{}x{}", weights.nrows(), weights.ncols()),
202        ));
203    }
204
205    let mut lag = Array1::zeros(n);
206    for i in 0..n {
207        let mut sum = 0.0;
208        for j in 0..n {
209            sum += weights[[i, j]] * values[j];
210        }
211        lag[i] = sum;
212    }
213
214    Ok(lag)
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use approx::assert_abs_diff_eq;
221    use scirs2_core::ndarray::array;
222
223    #[test]
224    fn test_spatial_weights_from_adjacency() {
225        let adj = array![[0.0, 1.0, 0.0], [1.0, 0.0, 1.0], [0.0, 1.0, 0.0]];
226
227        let weights = SpatialWeights::from_adjacency(adj)
228            .expect("Failed to create spatial weights from adjacency matrix");
229        assert_eq!(weights.weights.nrows(), 3);
230        assert!(!weights.row_standardized);
231    }
232
233    #[test]
234    fn test_spatial_weights_distance_threshold() {
235        let distances = array![[0.0, 1.0, 5.0], [1.0, 0.0, 2.0], [5.0, 2.0, 0.0]];
236
237        let weights = SpatialWeights::from_distance_threshold(&distances.view(), 2.5)
238            .expect("Failed to create spatial weights from distance threshold");
239
240        assert_eq!(weights.weights[[0, 1]], 1.0);
241        assert_eq!(weights.weights[[1, 2]], 1.0);
242        assert_eq!(weights.weights[[0, 2]], 0.0);
243    }
244
245    #[test]
246    fn test_row_standardize() {
247        let adj = array![[0.0, 1.0, 1.0], [1.0, 0.0, 1.0], [1.0, 1.0, 0.0]];
248
249        let mut weights = SpatialWeights::from_adjacency(adj)
250            .expect("Failed to create spatial weights for row standardization test");
251        weights.row_standardize();
252
253        assert!(weights.row_standardized);
254        assert_abs_diff_eq!(weights.weights.row(0).sum(), 1.0, epsilon = 1e-10);
255        assert_abs_diff_eq!(weights.weights.row(1).sum(), 1.0, epsilon = 1e-10);
256    }
257
258    #[test]
259    fn test_spatial_lag() {
260        let adj = array![[0.0, 1.0, 0.0], [1.0, 0.0, 1.0], [0.0, 1.0, 0.0]];
261        let values = array![1.0, 2.0, 3.0];
262
263        let weights = SpatialWeights::from_adjacency(adj)
264            .expect("Failed to create spatial weights for spatial lag test");
265        let lag = weights
266            .spatial_lag(&values.view())
267            .expect("Failed to calculate spatial lag");
268
269        assert_abs_diff_eq!(lag[0], 2.0, epsilon = 1e-10); // Neighbor value
270        assert_abs_diff_eq!(lag[1], 4.0, epsilon = 1e-10); // Sum of neighbors
271        assert_abs_diff_eq!(lag[2], 2.0, epsilon = 1e-10); // Neighbor value
272    }
273
274    #[test]
275    fn test_n_neighbors() {
276        let adj = array![[0.0, 1.0, 1.0], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
277
278        let weights = SpatialWeights::from_adjacency(adj)
279            .expect("Failed to create spatial weights for n_neighbors test");
280        let neighbors = weights.n_neighbors();
281
282        assert_eq!(neighbors[0], 2);
283        assert_eq!(neighbors[1], 1);
284        assert_eq!(neighbors[2], 1);
285    }
286}