Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .github/workflows/core.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Rust core

# The connectivity-core crate on its own: no Python, its minimum supported Rust version, and a
# crates.io packaging dry run.
# Core releases are tagged connectivity-core-vX.Y.Z. Those tags never reach the PyPI
# workflow (publish.yml, vX.Y.Z tags), and this workflow publishes nothing: after a core tag
# passes, the crate is published manually with `cargo publish -p connectivity-core`.
on:
push:
branches:
- main
tags:
- "connectivity-core-v*"
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
core:
name: Core without Python
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Drop local CPU tuning
run: rm -f .cargo/config.toml
- name: Check the core does not depend on Python
run: |
if cargo tree -p connectivity-core -e normal,build,dev --locked | grep -Ei 'pyo3|numpy'; then
echo "connectivity-core must not depend on Python" >&2
exit 1
fi
# Unit, API and doc tests; core tags only run this workflow
- name: Test the core
run: cargo test -p connectivity-core --locked
- name: Run the synthetic example
run: cargo run -p connectivity-core --release --example synthetic --locked
- name: Check the tag matches the crate version
if: startsWith(github.ref, 'refs/tags/connectivity-core-v')
env:
TAG: ${{ github.ref_name }}
run: |
version=$(cargo metadata --no-deps --format-version 1 --locked \
| jq -r '.packages[] | select(.name == "connectivity-core") | .version')
test "$TAG" = "connectivity-core-v$version"
- name: Validate crates.io packaging
run: cargo publish --dry-run -p connectivity-core --locked

msrv:
name: Core on its minimum Rust version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Drop local CPU tuning
run: rm -f .cargo/config.toml
# The version comes from rust-version in crates/connectivity-core/Cargo.toml
- name: Install the minimum Rust version
run: |
msrv=$(cargo metadata --no-deps --format-version 1 --locked \
| jq -r '.packages[] | select(.name == "connectivity-core") | .rust_version')
rustup toolchain install "$msrv" --profile minimal
echo "MSRV=$msrv" >> "$GITHUB_ENV"
- name: Build the core, its tests and example
run: cargo +"$MSRV" check -p connectivity-core --all-targets --locked
12 changes: 9 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ on:
branches:
- main
tags:
- "v*"
# Python releases (eco-connectivity); connectivity-core uses connectivity-core-v* tags
- "v[0-9]*"
pull_request:
workflow_dispatch:

Expand Down Expand Up @@ -60,8 +61,8 @@ jobs:
- uses: actions/setup-python@v7
with:
python-version: "3.13"
- name: Run unit tests
run: cargo test --locked
- name: Run workspace tests
run: cargo test --workspace --locked

publish:
name: Publish to PyPI
Expand All @@ -83,6 +84,11 @@ jobs:
pattern: cibw-*
path: dist
merge-multiple: true
- name: Check the tag matches the package version
# Only a vX.Y.Z tag naming the eco-connectivity version may publish
env:
TAG: ${{ github.ref_name }}
run: ls "dist/eco_connectivity-${TAG#v}.tar.gz" dist/eco_connectivity-"${TAG#v}"-*.whl
- uses: actions/attest-build-provenance@v4
with:
subject-path: dist/*
Expand Down
15 changes: 13 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 19 additions & 9 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,40 @@ name = "rust_conn"
version = "2.4.0"
edition = "2021"
license = "MIT"
# Released as the eco-connectivity Python wheels; only connectivity-core goes to crates.io
publish = false

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "rust_conn"
crate-type = ["cdylib"]

[dependencies]
connectivity-core = { path = "crates/connectivity-core", version = "0.1.0" }
pyo3 = {version = "0.28", default-features = false, features = ["macros", "abi3-py38"]}
pyo3-built = "0.5"
numpy = "0.28"
ndarray = { version = "0.16", features = ["rayon"] }
ndarray = { workspace = true }
ordered-float = "4.2.0"
rayon = "1.11"
num_cpus = "1.16"
anyhow = "1.0.100"
rustc-hash = "2.1"

[dev-dependencies]
# Reference Dijkstra for routing tests
pathfinding = "4.14.0"
rayon = { workspace = true }
num_cpus = { workspace = true }
anyhow = { workspace = true }

[build-dependencies]
pyo3-build-config = { version = "0.28", features = ["resolve-config"] }

[workspace]
members = ["crates/connectivity-core"]
# Plain `cargo test` and `cargo build` at the root also cover the core
default-members = [".", "crates/connectivity-core"]

# Versions shared by the extension and the core
[workspace.dependencies]
anyhow = "1.0.100"
ndarray = { version = "0.16", features = ["rayon"] }
num_cpus = "1.16"
rayon = "1.11"

[profile.release]
# Stripped builds fail to load on recent macOS (dyld: "mis-aligned LINKEDIT string pool")
strip = false
Expand Down
1 change: 1 addition & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Batched BERI similarity calculations across all scenarios of each cell, preserving similarity values.
- Fixed each node's outgoing edge order: same-level queen neighbours in the existing order, then fringe links in `get_edge_neighbours` order. Shortest-path costs are unchanged, but integer-cost ties can select different parents and adjusted path sums, and indicator accumulation follows a different destination order. On the bundled example 2-16% of cells change, by up to about 1e-4 (2.4e-4 relative); grids where most edges round to zero integer cost can change more. Repeated runs and different thread counts remain bit-identical on the same build and platform.
- Improved Rust `connectivity()` validation: missing transforms are checked before processing, and skipped powers of two produce a clear panic message instead of an index error. Both previously surfaced when a focal cell first reached the affected level. Python `connectedness()` and `beri()` already validate both.
- Moved the Rust numerical engine (overviews, window templates, graph building, routing, indicator metrics and pixel coverage) into a separate crate, `connectivity-core` 0.1.0 in `crates/connectivity-core`, with a documented array-based Rust API and no Python, GDAL or file I/O dependencies. The `rust_conn` extension is now a thin PyO3 wrapper around it: Python function signatures, outputs, exceptions and threading are unchanged. The core is versioned and tagged separately (`connectivity-core-vX.Y.Z`); only `vX.Y.Z` tags publish the Python package to PyPI.

# Version 2.3.0
## Changed
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- [Installation ](#installation-)
- [Install from PyPI ](#install-from-pypi-)
- [Install from Source ](#install-from-source-)
- [Rust Core Library ](#rust-core-library-)
- [Connectivity Analysis ](#connectivity-analysis-)
- [Connected Habitat (Connectedness) ](#connected-habitat-connectedness-)
- [Optional resistance surface](#optional-resistance-surface)
Expand Down Expand Up @@ -45,6 +46,10 @@ cd connectivity
pip install .
```

### Rust Core Library <a name="rust-core"></a>

The numerical engine is a separate Rust crate, [`connectivity-core`](https://github.com/csiro/connectivity/tree/main/crates/connectivity-core), which works on in-memory arrays without Python, GDAL or file I/O. Rust programs can use it directly; see its [README](https://github.com/csiro/connectivity/blob/main/crates/connectivity-core/README.md) and crate documentation. `cargo test --workspace` runs every Rust test, and `cargo test -p connectivity-core` needs no Python.

## Connectivity Analysis <a name="analysis"></a>
Resolve the bundled example paths once so they can be reused throughout the
analysis:
Expand Down
25 changes: 25 additions & 0 deletions crates/connectivity-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[package]
name = "connectivity-core"
version = "0.1.0"
edition = "2021"
# f64::next_up and next_down (distance tables)
rust-version = "1.86"
license = "MIT"
description = "Multi-resolution landscape connectivity, PARC connectedness and BERI on in-memory raster arrays"
authors = ["Roozbeh Valavi <roozbeh.valavi@csiro.au>"]
repository = "https://github.com/csiro/connectivity"
readme = "README.md"
keywords = ["connectivity", "landscape-ecology", "biodiversity", "raster", "least-cost-path"]
categories = ["science::geo", "algorithms"]

[dependencies]
anyhow = { workspace = true }
ndarray = { workspace = true }
rayon = { workspace = true }

[dev-dependencies]
num_cpus = { workspace = true }
# Reference Dijkstra for routing tests
pathfinding = "4.14.0"
# Hash maps of the frozen reference implementation
rustc-hash = "2.1"
21 changes: 21 additions & 0 deletions crates/connectivity-core/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Commonwealth Scientific and Industrial Research Organisation (CSIRO)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
63 changes: 63 additions & 0 deletions crates/connectivity-core/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# connectivity-core

Multi-resolution landscape connectivity on in-memory raster arrays: habitat and PARC
connectedness, the Bioclimatic Ecosystem Resilience Index (BERI), and fractional pixel
coverage of polygons. This crate is the numerical engine of the
[`eco-connectivity`](https://pypi.org/project/eco-connectivity/) Python package; see
[Valavi et al. (2026)](https://doi.org/10.32942/X2S68V) for the method.

It has no Python, GDAL or file I/O dependencies. Callers read rasters, pass `ndarray` arrays
of one tile, and handle file formats, tiling and post-processing such as Connected Habitat.

```toml
[dependencies]
connectivity-core = "0.1"
```

It requires Rust 1.86 or later.

```rust
use connectivity_core::ndarray::Array2;
use connectivity_core::{connectivity, level_transforms, Config, Inputs};

let condition = Array2::from_shape_fn((24, 24), |(i, j)| ((i * 7 + j * 3) % 10) as f32 / 10.0);
let mask = condition.mapv(f32::is_nan);
let levels = [1, 2, 4];
// rasterio/affine (a, b, c, d, e, f) order; projected grids in metres
let transforms = level_transforms((100.0, 0.0, 500_000.0, 0.0, -100.0, 6_000_000.0), &levels, (0, 0));
let inputs = Inputs { condition: &condition, mask: &mask, traversal: None, transgrids: None, transforms: &transforms };
let config = Config { levels: &levels, outer_window: 5, ..Config::default() };
let connectedness = connectivity(&inputs, &config)?;
```

## Public API

- `connectivity(&Inputs, &Config)`: connectedness, or BERI when `Inputs::transgrids` holds the
current climate followed by future scenarios (each `(rows, cols, bands)`). Returns an array
with the condition array's shape, NaN where the mask is `true`.
- `Inputs`: borrowed condition, mask, optional traversal and transgrids, and level transforms.
- `Config`: levels, lambdas (km), `is_geo`, `max_cost`, window sizes, global tile offsets,
window mode and PARC `pa_to_pa` gating. `Config::default()` matches the Python defaults.
- `pixel_coverage(&[Polygon], &Affine, nrows, ncols)`: fraction of each pixel covered by
polygons with holes.
- `Affine` and `level_transforms`: (row, col) to world transforms and the per-level transforms
of a tile, anchored to the full raster's grid.
- `WindowMode`: circular or square annuli.

The crate documentation covers array shapes, mask semantics, BERI scenario and band order,
coordinate units, tile offsets, the preparation and post-processing left to callers, and the
error and panic conditions. Work runs on the current Rayon thread pool; results are
bit-identical for any number of threads on the same build and platform.

`cargo run --release -p connectivity-core --example synthetic` runs every indicator on
synthetic arrays.

## Versioning and releases

The crate is versioned independently of the Python package (`eco-connectivity` 2.4.0 uses
`connectivity-core` 0.1.0). Core releases are tagged `connectivity-core-vX.Y.Z`; Python
releases keep `vX.Y.Z` tags, and only those publish to PyPI.

## License

MIT
69 changes: 69 additions & 0 deletions crates/connectivity-core/examples/synthetic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//! Connectedness, PARC, BERI and pixel coverage on synthetic arrays, with no file I/O.
//!
//! `cargo run --release -p connectivity-core --example synthetic`
use connectivity_core::ndarray::{Array2, Array3, Zip};
use connectivity_core::{connectivity, level_transforms, pixel_coverage, Config, Inputs};
use std::error::Error;

fn summary(name: &str, values: &Array2<f32>) {
let finite: Vec<f32> = values.iter().copied().filter(|v| v.is_finite()).collect();
let mean = finite.iter().map(|&v| v as f64).sum::<f64>() / finite.len().max(1) as f64;
let max = finite.iter().copied().fold(f32::NEG_INFINITY, f32::max);
println!("{name:24} {} cells evaluated, mean {mean:.4}, max {max:.4}", finite.len());
}

fn main() -> Result<(), Box<dyn Error>> {
// A 96 x 128 tile whose top-left pixel is row 40, column 24 of a projected raster with
// 100 m pixels; overview cells and transforms are anchored to the full raster's grid
let (rows, cols, offsets) = (96, 128, (40, 24));
let base = (100.0, 0.0, 400_000.0, 0.0, -100.0, 6_100_000.0);
let levels = [1, 2, 4, 8];
let transforms = level_transforms(base, &levels, offsets);

// Habitat patches in [0, 1] around a lake of missing data; masked cells are skipped
let condition = Array2::from_shape_fn((rows, cols), |(i, j)| {
let (y, x) = (i as f32 - 48.0, j as f32 - 64.0);
if x * x + y * y < 120.0 {
f32::NAN
} else {
(0.5 + 0.5 * (i as f32 / 9.0).sin() * (j as f32 / 13.0).cos()).clamp(0.0, 1.0)
}
});
let mask = condition.mapv(f32::is_nan);
let inputs = Inputs { condition: &condition, mask: &mask, traversal: None, transgrids: None, transforms: &transforms };
let config = Config { levels: &levels, lambdas: &[2.0, 20.0], outer_window: 5, offsets, ..Config::default() };

let connectedness = connectivity(&inputs, &config)?;
summary("connectedness", &connectedness);
// Connected Habitat is caller post-processing; Python's default is option 3
let mut habitat = connectedness.clone();
Zip::from(&mut habitat).and(&condition).for_each(|h, &c| *h = (c * *h).sqrt());
summary("connected habitat", &habitat);

// A road two rows wide that is hard to cross (traversal = 1 - resistance) on otherwise
// condition-based traversal
let traversal = Array2::from_shape_fn((rows, cols), |(i, j)| if i == 70 || i == 71 { 0.05 } else { condition[[i, j]] });
let with_road = connectivity(&Inputs { traversal: Some(&traversal), ..inputs }, &config)?;
summary("connectedness, road", &with_road);

// PARC: protected-area proportions from a reserve polygon with a hole, in world coordinates
let tile = transforms[&1];
let (x0, y0) = (tile.x_origin, tile.y_origin);
let square = |a: f64, b: f64, c: f64, d: f64| vec![(x0 + a, y0 - b), (x0 + c, y0 - b), (x0 + c, y0 - d), (x0 + a, y0 - d)];
let reserve = vec![(square(500.0, 300.0, 4_050.0, 3_520.0), vec![square(1_500.0, 1_000.0, 2_500.0, 2_000.0)])];
let pa = pixel_coverage(&reserve, &tile, rows, cols);
// As numpy.maximum in Python: missing condition stays missing
let parc_condition = Zip::from(&condition).and(&pa).map_collect(|&c, &p| if c.is_nan() { c } else { c.max(p) });
let parc_mask = Zip::from(&parc_condition).and(&pa).map_collect(|&c, &p| !(p > 0.0 && c.is_finite()));
let parc_inputs = Inputs { condition: &parc_condition, mask: &parc_mask, ..inputs };
summary("PARC", &connectivity(&parc_inputs, &config)?);
summary("PARC, pa_to_pa", &connectivity(&parc_inputs, &Config { pa_to_pa: true, ..config })?);

// BERI: the current climate first, then two warmer scenarios, with three bands each
let climate = |shift: f32| {
Array3::from_shape_fn((rows, cols, 3), |(i, j, b)| (i as f32 / 40.0 + j as f32 / 60.0) * (b + 1) as f32 * 0.1 + shift)
};
let scenarios = [climate(0.0), climate(0.05), climate(0.15)];
summary("BERI", &connectivity(&Inputs { transgrids: Some(&scenarios), ..inputs }, &config)?);
Ok(())
}
Loading
Loading