Computing Range Shift and Centroid Metrics from SDM Projections
Two suitability rasters — present and future — contain a report’s worth of numbers, and the ones people quote are almost always the same handful: how far the range moved, in which direction, how much it grew or shrank, and how much of it stayed put. Computing them takes a page of numpy; computing them in a way that survives scrutiny takes a projection choice and an extrapolation mask. This guide does both, inside SDM Projection and Transferability, part of Species Distribution Modeling with MaxEnt.
When to use each metric
| Metric | Units | Answers | Caveat |
|---|---|---|---|
| Centroid shift | km, bearing | Where is the range moving? | A single point summarising a possibly bimodal distribution |
| Suitable area change | ha or % | Is the range growing or shrinking? | Entirely dependent on the binary threshold |
| Range overlap (Jaccard/Schoener) | 0–1 | How much is the same ground? | Insensitive to where the disagreement is |
| Elevation shift | m | Is it moving upslope? | Needs a DTM on the same grid |
| Latitudinal shift | km | Poleward movement | Distinct from centroid shift when the range is not compact |
| Stable / gain / loss areas | ha | Which ground matters for management? | The most operationally useful, and least often reported |
Report the gain/loss/stable decomposition alongside any headline shift figure. A centroid that moved 240 km north is compatible with a range that shifted wholesale and with one that stayed put while gaining a distant northern patch, and only the decomposition distinguishes them.
The centroid, and the projection it depends on
A suitability-weighted centroid is the mean position weighted by suitability:
Computing it in a geographic CRS averages degrees, which weights a degree of longitude at 60° north the same as one at the equator, so the centroid drifts. Every metric on this page must be computed in an equal-area projection — the discipline set out in Choosing an Equal-Area CRS for Forest Area Statistics.
Minimal reproducible example
import numpy as np
import rasterio
def weighted_centroid(path: str, threshold: float | None = None,
mess_path: str | None = None) -> dict:
"""Suitability-weighted centroid in the raster's own (equal-area) CRS."""
with rasterio.open(path) as src:
assert not src.crs.is_geographic, "compute centroids in an equal-area CRS"
s = src.read(1, masked=True).astype("float64").filled(np.nan)
transform, crs = src.transform, src.crs
if mess_path: # drop extrapolated pixels
with rasterio.open(mess_path) as m:
s = np.where(m.read(1) >= 0, s, np.nan)
if threshold is not None:
s = np.where(s >= threshold, s, np.nan)
rows, cols = np.indices(s.shape)
ok = np.isfinite(s) & (s > 0)
if not ok.any():
return {"status": "no_suitable_area"}
w = s[ok]
xs, ys = rasterio.transform.xy(transform, rows[ok], cols[ok])
xs, ys = np.asarray(xs), np.asarray(ys)
return {
"x": float((w * xs).sum() / w.sum()),
"y": float((w * ys).sum() / w.sum()),
"crs": str(crs),
"n_px": int(ok.sum()),
"total_weight": float(w.sum()),
}
def shift_metrics(present: dict, future: dict) -> dict:
"""Distance and bearing between two centroids in a projected CRS."""
dx = future["x"] - present["x"]
dy = future["y"] - present["y"]
dist_km = float(np.hypot(dx, dy) / 1000.0)
bearing = float((np.degrees(np.arctan2(dx, dy)) + 360.0) % 360.0)
return {
"shift_km": round(dist_km, 1),
"bearing_deg": round(bearing, 1),
"northward_km": round(dy / 1000.0, 1),
"eastward_km": round(dx / 1000.0, 1),
}
Note the argument order in arctan2(dx, dy) — reversed from the usual mathematical convention, because a compass bearing is measured clockwise from north rather than counter-clockwise from east. Getting this wrong produces a bearing reflected about the diagonal, which for a northward shift reads as eastward and is easy to miss.
Area change and the decomposition
import numpy as np
import rasterio
def area_decomposition(present_path: str, future_path: str, threshold: float,
mess_path: str | None = None) -> dict:
"""Stable, gained and lost area between two binarised projections."""
with rasterio.open(present_path) as p, rasterio.open(future_path) as f:
assert p.transform == f.transform, "rasters must share a grid"
pres = p.read(1, masked=True).filled(np.nan)
fut = f.read(1, masked=True).filled(np.nan)
cell_ha = abs(p.transform.a) * abs(p.transform.e) / 10_000.0
valid = np.isfinite(pres) & np.isfinite(fut)
if mess_path:
with rasterio.open(mess_path) as m:
valid &= (m.read(1) >= 0)
a = valid & (pres >= threshold)
b = valid & (fut >= threshold)
stable, gained, lost = (a & b), (~a & b), (a & ~b)
union = (a | b).sum()
return {
"present_ha": round(float(a.sum() * cell_ha), 1),
"future_ha": round(float(b.sum() * cell_ha), 1),
"stable_ha": round(float(stable.sum() * cell_ha), 1),
"gained_ha": round(float(gained.sum() * cell_ha), 1),
"lost_ha": round(float(lost.sum() * cell_ha), 1),
"net_change_pct": round(float(100.0 * (b.sum() - a.sum()) / max(a.sum(), 1)), 1),
"jaccard": round(float(stable.sum() / max(union, 1)), 3),
}
Every one of those numbers depends on threshold, which is why it belongs in the output and why the threshold choice deserves its own treatment in Choosing a Binary Threshold for SDM Suitability Maps. Reporting “the range shrinks by 34%” without the threshold is reporting an opinion.
The elevation shift is the same weighted mean applied to a terrain raster, and in mountainous regions it is often the more ecologically meaningful number:
def elevation_shift(present_path: str, future_path: str, dtm_path: str,
threshold: float) -> dict:
"""Suitability-weighted mean elevation of the range, present and future."""
with rasterio.open(dtm_path) as d:
dtm = d.read(1, masked=True).filled(np.nan)
def weighted_mean_elev(path):
with rasterio.open(path) as src:
s = src.read(1, masked=True).filled(np.nan)
ok = np.isfinite(s) & np.isfinite(dtm) & (s >= threshold)
if not ok.any():
return float("nan")
return float((s[ok] * dtm[ok]).sum() / s[ok].sum())
e0, e1 = weighted_mean_elev(present_path), weighted_mean_elev(future_path)
return {"present_m": round(e0, 1), "future_m": round(e1, 1),
"upslope_shift_m": round(e1 - e0, 1)}
Parameter reference
| Parameter | Type | Typical | Notes |
|---|---|---|---|
| CRS | — | equal-area | Mandatory; degrees give a drifting centroid and meaningless areas |
threshold |
float | from a stated rule | Every area figure depends on it; report it |
mess_path |
str | supplied | Excluding extrapolated pixels usually shrinks the shift |
| weighting | — | continuous suitability | Binary weighting discards magnitude information |
| DTM grid | — | matched | Elevation shift needs the terrain on the same grid |
| ensemble handling | — | per member | Compute metrics per member, then report their spread |
That last row is the one most often skipped. Computing metrics on the ensemble median raster gives one number with no uncertainty; computing them per member and reporting the median and range gives the same central figure plus an honest interval.
Expected output and verification
The metrics are simple arithmetic, so verification is about catching the projection and orientation errors that make them wrong in ways that still look plausible.
import numpy as np
def verify_shift(present, future, decomposition) -> dict:
"""Internal consistency checks across the reported metrics."""
m = shift_metrics(present, future)
checks = {
"areas_sum": abs(
decomposition["stable_ha"] + decomposition["lost_ha"]
- decomposition["present_ha"]) < 1.0,
"future_sum": abs(
decomposition["stable_ha"] + decomposition["gained_ha"]
- decomposition["future_ha"]) < 1.0,
"bearing_in_range": 0.0 <= m["bearing_deg"] < 360.0,
"components_match": abs(
np.hypot(m["eastward_km"], m["northward_km"]) - m["shift_km"]) < 0.2,
}
return {**m, "checks": checks}
res = verify_shift(cent_present, cent_future, decomp)
assert all(res["checks"].values()), f"metric inconsistency: {res['checks']}"
Then feed it a case it must get right by construction — a synthetic range translated by a known distance:
import numpy as np
# A synthetic suitability field, and the same field shifted 100 km north.
ny, nx, cell = 400, 400, 1000.0 # 1 km cells
yy, xx = np.mgrid[0:ny, 0:nx]
base = np.exp(-(((xx - 200) ** 2 + (yy - 260) ** 2) / (2 * 40.0 ** 2)))
moved = np.exp(-(((xx - 200) ** 2 + (yy - 160) ** 2) / (2 * 40.0 ** 2)))
cy_base = (base * yy).sum() / base.sum()
cy_moved = (moved * yy).sum() / moved.sum()
shift_km = (cy_base - cy_moved) * cell / 1000.0
assert abs(shift_km - 100.0) < 1.0, f"known 100 km shift measured as {shift_km:.1f} km"
A test that recovers a shift you constructed is worth more than any amount of inspection, because it exercises the sign conventions and the cell-size arithmetic together — the two places where a range-shift figure goes wrong by a factor rather than by a little.
Common pitfalls
- Computing in a geographic CRS. Averaging degrees weights high latitudes wrongly and every area is meaningless.
arctan2arguments in mathematical order. Bearings run clockwise from north; the reversed convention silently reflects the direction.- Metrics from the ensemble median raster only. It yields a central estimate with no spread, which is exactly the information a projection most needs to carry.
- No extrapolation mask. A range that “expands” into novel climate is expanding into pixels the model knows nothing about, and the number is not evidence.
- A centroid summarising a bimodal range. When suitability splits into two disjoint blocks, the centroid can sit in unsuitable ground between them. Report the decomposition too.
Frequently Asked Questions
Should the centroid be weighted by suitability or computed on the binary range?
Weighted, in general — it uses all the information and is far less sensitive to the threshold. The binary centroid has one advantage worth knowing: it is directly comparable with published figures, most of which use binary ranges. Compute both when comparing to literature.
How do I report a range that fragments rather than moves?
With the decomposition and with a fragmentation statistic — patch count and mean patch size from a connected-components labelling. A fragmenting range can show a small centroid shift and a modest area change while becoming ecologically far less viable, and neither headline number captures that.
What if the projection domain does not cover the whole future range?
Then every metric is truncated at the domain edge and the shift is under-estimated. Check whether suitable pixels touch the boundary; if they do, extend the domain and recompute rather than reporting a figure that the study extent has capped.
Related
- SDM Projection and Transferability — the workflow these metrics conclude
- Detecting Extrapolation with MESS Maps in Python — the mask that keeps these numbers honest
- Choosing a Binary Threshold for SDM Suitability Maps — the choice every area figure rests on
- Choosing an Equal-Area CRS for Forest Area Statistics — the projection requirement behind every metric here
Up: SDM Projection & Transferability · Species Distribution Modeling with MaxEnt