Skip to main content

connectivity_core/
lib.rs

1//! Multi-resolution landscape connectivity on in-memory raster arrays.
2//!
3//! This is the numerical engine of the [`eco-connectivity`](https://pypi.org/project/eco-connectivity/)
4//! Python package. For every unmasked focal cell of a raster tile, [`connectivity`] computes
5//! - habitat or PARC **connectedness**, when no transgrids are given, or
6//! - the Bioclimatic Ecosystem Resilience Index (**BERI**), when transgrids are given,
7//!
8//! and [`pixel_coverage`] computes the fraction of each pixel covered by polygons, e.g. to
9//! build protected-area proportion rasters. The crate has no Python, GDAL or file I/O
10//! dependencies: callers read rasters and pass plain [`ndarray`] arrays.
11//!
12//! # Method
13//! Each level `L` of [`Config::levels`] aggregates `L x L` base pixels into overview cells.
14//! Around a focal pixel, every level contributes the overview cells of an annulus (see
15//! [`Config::window_size`]), so detail is kept near the cell and distant areas are coarse.
16//! These cells form a graph with queen-neighbour edges within a level and links from the rim
17//! of each level to the next coarser one. Two integer Dijkstra searches from the focal cell
18//! give, for every reached cell `v`, the condition-adjusted path length `a_v` and the intact
19//! path length `g_v` in kilometres: an edge into `v` of length `d` costs `w_v * d` and `d`,
20//! with `w_v = (1 - max_cost) * t_v + max_cost` for traversal value `t_v`, rounded to integer
21//! units of 10 m for routing. With `q_v` the condition, `A_v` the habitat area weight and
22//! `p_v` the protected fraction (1 unless [`Config::pa_to_pa`]), connectedness for one lambda
23//! is
24//!
25//! `sum_v exp(-a_v / lambda) q_v p_v A_v / sum_v exp(-g_v / lambda) A_v`.
26//!
27//! BERI uses Gaussian kernels `exp(-(x / lambda)^2 / 283.465)` instead, without `p_v`, and
28//! weights each scenario's numerator by that scenario's climate similarity and the
29//! denominator by the current climate's. A similarity is `exp(-L1 distance)` between the
30//! focal cell's current-climate bands and the cell's scenario bands, and scenario
31//! numerators are aggregated as `0.5 * (mean + min)` over all scenarios, current climate
32//! included. The output is the mean over [`Config::lambdas`].
33//!
34//! # Inputs
35//! All 2-D arrays are `(rows, cols)` grids of one tile with the same shape, indexed
36//! `[[row, col]]` from the tile's top-left pixel; NaN marks missing values. Transgrids add a
37//! trailing band axis. See [`Inputs`] for each array.
38//!
39//! **Coordinates and units.** [`Affine`] maps (row, col) pixel centres to world coordinates.
40//! Projected grids (`is_geo = false`) must be in metres; geographic grids (`is_geo = true`)
41//! are longitude/latitude in degrees, with great-circle distances on a 6371 km sphere and
42//! habitat area weighted by cos(latitude). Distances and lambdas are in kilometres.
43//! Transform tuples follow the rasterio/`affine` order `(a, b, c, d, e, f)`, i.e.
44//! `x = a * col + b * row + c` and `y = d * col + e * row + f`, not GDAL's geotransform order.
45//!
46//! **Tiles and offsets.** [`Config::offsets`] is the global `(row0, col0)` of the tile's
47//! top-left pixel in the full raster. Overview cells are anchored to the full raster's grid:
48//! level-`L` cell `(R, C)` covers global base rows `R * L .. (R + 1) * L` and columns
49//! `C * L .. (C + 1) * L`, and a tile's level-`L` grid covers every such cell that intersects
50//! the tile, averaging only the tile's pixels in cells cut by its edge. The level-`L`
51//! transform must map that grid ([`level_transforms`] builds it from the full raster's base
52//! transform), so tiles evaluated separately with enough context around their cores join
53//! without seams.
54//!
55//! # Caller responsibilities
56//! The core aggregates overviews from base-resolution arrays: averages of condition,
57//! traversal and transgrids, habitat area weights (valid-cell counts, or cos-latitude areas)
58//! and protected fractions. Callers, such as the Python package, remain responsible for:
59//! - reading rasters, detecting geographic CRSs, converting nodata to NaN and scaling
60//!   condition, protected-area (PA) and resistance values to [0, 1];
61//! - planning tiles, their context padding and read windows, and passing matching offsets
62//!   and level transforms;
63//! - building the mask from polygons, nodata and, for PARC, non-protected cells;
64//! - PARC preparation: `condition = max(condition, pa)` and
65//!   `mask = !(pa > 0 && condition is finite)`;
66//! - resistance surfaces: `traversal = clip(1 - resistance, 0, 1)`, falling back to condition
67//!   where resistance is missing and NaN where condition is missing;
68//! - BERI inputs: current-climate transgrid first, then future scenarios, bands last;
69//! - normalising levels (add 1, round to powers of two, require a continuous sequence) and
70//!   `outer_window >= window_size`, which the Python wrappers check before calling;
71//! - post-processing: Connected Habitat for habitat (not PARC) connectedness with Python's
72//!   `option` 1 (connectedness), 2 (condition x connectedness) or 3 (the default,
73//!   sqrt(condition x connectedness)); cropping tiles back to their polygons; writing rasters.
74//!
75//! # Threads and memory
76//! Work runs on the current [Rayon](https://docs.rs/rayon) thread pool: the global pool, or
77//! the pool of an enclosing `ThreadPool::install`, which is how the Python binding applies
78//! `n_threads`. Outputs are bit-identical for any thread count on the same build and
79//! platform. Besides the overviews and the output, a call keeps a shared cache of window
80//! templates and distance tables of at most 128 MiB and one scratch buffer per pool thread;
81//! focal cells reuse those buffers.
82//!
83//! # Example
84//! ```
85//! use connectivity_core::ndarray::Array2;
86//! use connectivity_core::{connectivity, level_transforms, Config, Inputs};
87//!
88//! // A 24 x 24 tile at the top-left of a projected raster with 100 m pixels
89//! let condition = Array2::from_shape_fn((24, 24), |(i, j)| ((i * 7 + j * 3) % 10) as f32 / 10.0);
90//! let mask = condition.mapv(f32::is_nan);
91//! let levels = [1, 2, 4];
92//! let base = (100.0, 0.0, 500_000.0, 0.0, -100.0, 6_000_000.0);
93//! let transforms = level_transforms(base, &levels, (0, 0));
94//! let inputs = Inputs {
95//!     condition: &condition,
96//!     mask: &mask,
97//!     traversal: None,
98//!     transgrids: None,
99//!     transforms: &transforms,
100//! };
101//! let config = Config { levels: &levels, outer_window: 5, ..Config::default() };
102//! let out = connectivity(&inputs, &config).unwrap();
103//! assert_eq!(out.dim(), (24, 24));
104//! assert!(out.iter().all(|v| (0.0..=1.0).contains(v)));
105//! ```
106mod affine;
107mod builder;
108mod core;
109mod coverage;
110mod distances;
111mod graph;
112#[cfg(test)]
113mod legacy;
114mod metrics;
115mod overview;
116mod routing;
117mod tables;
118mod template;
119#[cfg(test)]
120mod testkit;
121mod utils;
122#[cfg(test)]
123mod verify;
124mod window;
125
126pub use affine::{level_transforms, Affine};
127pub use coverage::{pixel_coverage, Polygon};
128/// The `ndarray` version of this crate's array types.
129pub use ndarray;
130pub use window::WindowMode;
131
132use ndarray::{Array2, Array3};
133use std::collections::HashMap;
134
135/// Borrowed arrays of one tile for [`connectivity`]. 2-D arrays share the `(rows, cols)`
136/// shape of `condition`.
137#[derive(Clone, Copy, Debug)]
138pub struct Inputs<'a> {
139    /// Habitat condition, expected in [0, 1] with higher values for better condition. It sets
140    /// each destination's value and habitat area, and the path weights unless `traversal` is
141    /// given. NaN cells are left out of habitat area and of the graph; coarser overview cells
142    /// average the finite cells they contain.
143    pub condition: &'a Array2<f32>,
144    /// `true` skips a focal cell, whose output stays NaN. Masked cells with finite condition
145    /// still serve as destinations and path cells. Under [`Config::pa_to_pa`], `false` also
146    /// marks protected cells. Unmasked cells with NaN condition reach nothing and score 0, so
147    /// callers normally mask them.
148    pub mask: &'a Array2<bool>,
149    /// Optional traversal values in [0, 1], high for easy passage (e.g. `1 - resistance`),
150    /// used only for path weights. `None` uses condition; cells whose traversal overview is
151    /// NaN fall back to condition.
152    pub traversal: Option<&'a Array2<f32>>,
153    /// BERI scenarios: the current climate first, then future scenarios. Each array is
154    /// `(rows, cols, bands)` with condition's rows and columns and the same bands in the same
155    /// order; any memory layout works, e.g. a band-major view. A cell whose bands are not all
156    /// finite has similarity 0. `None` or an empty slice computes connectedness instead.
157    pub transgrids: Option<&'a [Array3<f32>]>,
158    /// Transform of each level's grid, keyed by level; see [`level_transforms`].
159    pub transforms: &'a HashMap<i32, Affine>,
160}
161
162/// Settings of one [`connectivity`] call. `Config::default()` gives the Python defaults,
163/// with level 1 included as the core requires.
164#[derive(Clone, Copy, Debug)]
165pub struct Config<'a> {
166    /// Overview levels in base pixels per overview cell: powers of two forming a continuous
167    /// sequence that includes 1, such as `[1, 2, 4, 8, 16, 32]`, in any order.
168    pub levels: &'a [usize],
169    /// Dispersal kernel bandwidths in kilometres; the output is the mean of their scores. An
170    /// empty slice scores every evaluated cell 0.
171    pub lambdas: &'a [f32],
172    /// Whether transforms are geographic (degrees) rather than projected (metres).
173    pub is_geo: bool,
174    /// Relative cost of crossing a fully degraded cell: a cell with traversal value `t` has
175    /// path weight `(1 - max_cost) * t + max_cost`, so `t = 1` costs 1 and `t = 0` costs
176    /// `max_cost`.
177    pub max_cost: f32,
178    /// Odd window size of every level but the coarsest. The level-`L` annulus spans
179    /// `L * window_size / 2` to `L * window_size` base pixels from the focal pixel centre, and
180    /// level 1 is the disc of radius `window_size`; in square mode these are half-widths.
181    pub window_size: i32,
182    /// Odd window size of the coarsest level `L`, whose annulus reaches `L * outer_window`
183    /// base pixels. Values below `window_size` use `window_size`.
184    pub outer_window: i32,
185    /// Global `(row0, col0)` of the tile's top-left pixel in the full raster, in base pixels;
186    /// `(0, 0)` for a whole raster.
187    pub offsets: (usize, usize),
188    /// Circular or square annuli.
189    pub window_mode: WindowMode,
190    /// PARC target gating: only protected destinations, weighted by each overview cell's
191    /// protected fraction (unmasked share of its valid pixels), count in the connectedness
192    /// numerator. Paths and the denominator are unchanged, and a focal cell that reaches no
193    /// protected destination scores 0. For BERI it only makes focal cells without neighbours
194    /// score 0; Python's `beri()` never sets it.
195    pub pa_to_pa: bool,
196}
197
198impl Default for Config<'_> {
199    fn default() -> Self {
200        Self {
201            levels: &[1, 2, 4, 8, 16, 32],
202            lambdas: &[2.0, 20.0, 200.0],
203            is_geo: false,
204            max_cost: 2.0,
205            window_size: 3,
206            outer_window: 9,
207            offsets: (0, 0),
208            window_mode: WindowMode::Circular,
209            pa_to_pa: false,
210        }
211    }
212}
213
214/// Connectedness, or BERI when [`Inputs::transgrids`] is non-empty, of every unmasked cell.
215///
216/// Returns an array with the shape of [`Inputs::condition`]: NaN where the mask is `true`,
217/// otherwise the mean score over [`Config::lambdas`], normally in [0, 1] and 0 for a cell that
218/// reaches no destination.
219///
220/// # Errors
221/// Returns an error when [`Inputs::traversal`] differs in shape from the condition array
222/// (`"Dimension mismatch for key <level>: <traversal shape> vs <condition shape>"`). The
223/// Python binding raises errors as `RuntimeError("Connectivity failed: <message>")`.
224///
225/// # Panics
226/// Invalid settings panic, with messages the Python binding raises as
227/// `pyo3_runtime.PanicException`; panics on worker threads reach the caller through Rayon.
228/// - A level that is not a power of two (`"Failed average resampling.: Level 3 is not a power
229///   of 2"`), or no level-1 transform (`"Missing base-level transform (key=1)."`).
230/// - When at least one cell is unmasked: no level 1 (`"Base condition level not found!"`),
231///   an even `window_size` (`"Window size must be odd-numbered"`) or an even
232///   `outer_window >= window_size` (`"Outer window size must be odd-numbered"`), a level
233///   without a transform (`"Missing level in Affine set."`), or levels that skip a power of
234///   two once a level past the gap has window cells (`"levels must form a continuous
235///   power-of-two sequence"`).
236/// - An integer path cost beyond `u32::MAX`, e.g. from a very large `max_cost` or unscaled
237///   traversal values (`"Path cost overflow; check max_cost and resistance scaling!"`).
238/// - A mask or transgrid smaller than the condition array can panic on indexing.
239pub fn connectivity(inputs: &Inputs, config: &Config) -> anyhow::Result<Array2<f32>> {
240    core::conn(inputs, config)
241}