Source code: Watershed delineation in Python with pysheds: the River Derwent above Ladybower

Runnable Python scripts, requirements and console output for the tutorial “Watershed delineation in Python with pysheds: the River Derwent above…

Last updated

These are the exact scripts used to produce every figure and number in Watershed delineation in Python with pysheds: the River Derwent above Ladybower. Download them, install the requirements into a fresh virtual environment and run them from this folder.

Run it
python -m venv .venv
.venv\Scripts\activate # macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
python watershed_delineation.py

watershed_delineation.py

watershed_delineation.py
"""Watershed (catchment) delineation and stream networks from a DEM in Python with pysheds.
Delineates the catchment of the River Derwent above Yorkshire Bridge (NRFA gauging
station 28001, just below Ladybower Reservoir in the Peak District) from the
Copernicus GLO-30 DEM: fill pits and depressions, D8 flow direction, flow
accumulation, snap the pour point to the stream, catchment polygon and area,
stream extraction and Strahler order. The result is checked against the official
NRFA catchment boundary and the Environment Agency (EA) Water Framework Directive
(WFD) catchments.
Data: Copernicus DEM GLO-30 from the Microsoft Planetary Computer (no account needed),
NRFA station metadata and catchment boundary (UKCEH), EA Catchment Data Explorer.
Run with: python watershed_delineation.py
"""
import json
import platform
from importlib.metadata import version
from pathlib import Path
import numpy as np
# pysheds 0.5 still calls np.in1d, which NumPy 2.4 removed. np.isin is the drop-in replacement.
if not hasattr(np, "in1d"):
np.in1d = np.isin
import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import pandas as pd
import planetary_computer
import pystac_client
import rasterio
import requests
import shapely
from matplotlib.colors import BoundaryNorm, LightSource, ListedColormap, LogNorm
from matplotlib.lines import Line2D
from matplotlib.patches import Patch
from pyproj import Transformer
from pysheds.grid import Grid
from pysheds.sview import Raster, ViewFinder
from rasterio import features
from rasterio.transform import from_origin
from rasterio.warp import Resampling, reproject
from shapely.geometry import LineString, shape
HERE = Path(__file__).parent
OUT = HERE / "output"
DATA = HERE / "data"
OUT.mkdir(exist_ok=True)
DATA.mkdir(exist_ok=True)
plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
SPECTRAL = plt.get_cmap("Spectral_r")
print("=== versions ===")
print(f"python {platform.python_version()} | pysheds {version('pysheds')} | numpy {np.__version__} | "
f"rasterio {rasterio.__version__} | geopandas {gpd.__version__} | shapely {shapely.__version__}")
# ------------------------------------------------------ 1. the gauge and the official answer
STATION = 28001 # River Derwent at Yorkshire Bridge, below Ladybower dam
NRFA = "https://nrfaapps.ceh.ac.uk/nrfa/ws/station-info"
info_path = DATA / f"nrfa_{STATION}.json"
if not info_path.exists():
r = requests.get(NRFA, params={"station": STATION, "format": "json-object", "fields": "all"}, timeout=60)
r.raise_for_status()
info_path.write_text(json.dumps(r.json()["data"][0], indent=1))
info = json.loads(info_path.read_text())
gx, gy = info["easting"], info["northing"]
print(f"\n=== NRFA station {STATION}: {info['name']} ===")
print(f"gauge at British National Grid {gx:.0f} E, {gy:.0f} N | catchment area {info['catchment-area']} km² "
f"(IHDTM {info['ihdtm-catchment-area']} km²) | altitude {info['minimum-altitude']}–"
f"{info['maximum-altitude']} m | drainage density {info['draindens']} km/km²")
bnd_path = DATA / f"nrfa_{STATION}_catchment.geojson"
if not bnd_path.exists():
r = requests.get("https://hydrologygeoserver.ceh.ac.uk/geoserver/ows",
params={"service": "WFS", "version": "2.0.0", "request": "GetFeature",
"typename": "NRFA:NRFA_catchments", "CQL_FILTER": f"ID={STATION}",
"outputformat": "application/json", "srsname": "EPSG:27700"}, timeout=60)
r.raise_for_status()
bnd_path.write_text(r.text)
nrfa = gpd.read_file(bnd_path).set_crs(27700, allow_override=True)
print(f"NRFA boundary polygon: {nrfa.area.sum() / 1e6:.2f} km²")
# ------------------------------------------------------------------- 2. the DEM
RES = 30.0 # metres, close to GLO-30's native 1 arc-second
X0, Y0, X1, Y1 = 402_000, 381_000, 427_000, 402_000 # 25 km x 21 km of the Dark Peak in BNG
W, H = int((X1 - X0) / RES), int((Y1 - Y0) / RES)
dem_path = DATA / "glo30_derwent_bng.tif"
if not dem_path.exists():
lon0, lat0, lon1, lat1 = Transformer.from_crs(27700, 4326, always_xy=True).transform_bounds(X0, Y0, X1, Y1)
cat = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1",
modifier=planetary_computer.sign_inplace)
items = list(cat.search(collections=["cop-dem-glo-30"], bbox=[lon0, lat0, lon1, lat1]).items())
print(f"\n=== Copernicus GLO-30 tiles: {', '.join(i.id for i in items)} ===")
dem = np.full((H, W), np.nan, dtype="float32")
for item in items:
with rasterio.open(item.assets["data"].href) as src:
part = np.full((H, W), np.nan, dtype="float32")
reproject(rasterio.band(src, 1), part, dst_transform=from_origin(X0, Y1, RES, RES),
dst_crs="EPSG:27700", dst_nodata=np.nan, resampling=Resampling.bilinear)
dem = np.where(np.isnan(dem), part, dem)
with rasterio.open(dem_path, "w", driver="GTiff", width=W, height=H, count=1, dtype="float32",
crs="EPSG:27700", transform=from_origin(X0, Y1, RES, RES), nodata=-9999,
compress="deflate") as dst:
dst.write(np.nan_to_num(dem, nan=-9999), 1)
grid = Grid.from_raster(str(dem_path))
dem = grid.read_raster(str(dem_path))
z = np.where(dem == -9999, np.nan, dem.astype("float64"))
print(f"\n=== DEM ===\n{W} x {H} cells of {RES:.0f} m (EPSG:27700) | elevation {np.nanmin(z):.1f}–{np.nanmax(z):.1f} m "
f"| missing cells {np.isnan(z).sum()}")
# ------------------------------------------------------- 3. condition the DEM
print("\n=== condition the DEM ===")
pit_filled = grid.fill_pits(dem)
pits = np.asarray(pit_filled) > np.asarray(dem) # copy now: the next step can modify in place
flooded = grid.fill_depressions(pit_filled)
inflated = grid.resolve_flats(flooded)
fill = np.asarray(flooded) - np.asarray(dem)
dep = (fill > 0) & ~pits
print(f"single-cell pits raised: {pits.sum():,}")
print(f"cells raised by depression filling (beyond the pits): {dep.sum():,} ({dep.mean():.1%}) | "
f"max fill {fill.max():.1f} m | volume {fill.sum() * RES ** 2 / 1e6:.2f} million m³")
r_, c_ = np.unravel_index(np.argmax(fill), fill.shape)
print(f"deepest fill at ({X0 + (c_ + 0.5) * RES:.0f}, {Y1 - (r_ + 0.5) * RES:.0f}), "
f"DEM {z[r_, c_]:.1f} m raised to {np.asarray(flooded)[r_, c_]:.1f} m")
flats = grid.detect_flats(flooded)
print(f"flat cells after filling: {np.asarray(flats).sum():,}; resolve_flats adds a tiny gradient so they drain")
# ------------------------------------------------- 4. flow direction and accumulation
print("\n=== D8 flow direction and accumulation ===")
DIRMAP = (64, 128, 1, 2, 4, 8, 16, 32) # N, NE, E, SE, S, SW, W, NW (ESRI convention)
fdir = grid.flowdir(inflated, dirmap=DIRMAP)
acc = grid.accumulation(fdir, dirmap=DIRMAP)
acc_km2 = np.asarray(acc) * RES ** 2 / 1e6
print(f"largest upstream area in the grid: {acc_km2.max():.1f} km²")
codes, counts = np.unique(np.asarray(fdir), return_counts=True)
names = dict(zip(DIRMAP, ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]))
print("flow directions: " + ", ".join(f"{names[c]} {n / counts.sum():.1%}" for c, n in zip(codes, counts) if c in names))
# ------------------------------------------------------------ 5. snap the pour point
print("\n=== pour point ===")
col_g, row_g = int((gx - X0) // RES), int((Y1 - gy) // RES)
print(f"gauge cell drains {acc_km2[row_g, col_g]:.2f} km² before snapping")
SNAP_KM2 = 50 # snap to a cell that drains at least 50 km²
sx, sy = grid.snap_to_mask(acc > SNAP_KM2 * 1e6 / RES ** 2, (gx, gy))
col_s, row_s = int((sx - X0) // RES), int((sy - Y1) // -RES)
print(f"snapped to ({sx:.0f}, {sy:.0f}), {np.hypot(sx - gx, sy - gy):.0f} m from the gauge; "
f"that cell drains {acc_km2[row_s, col_s]:.1f} km²")
# --------------------------------------------------------------- 6. the catchment
print("\n=== catchment ===")
catch = grid.catchment(x=sx, y=sy, fdir=fdir, dirmap=DIRMAP, xytype="coordinate")
cmask = np.asarray(catch).astype(bool)
area = cmask.sum() * RES ** 2 / 1e6
polys = [shape(g) for g, v in features.shapes(cmask.astype("uint8"), mask=cmask,
transform=grid.affine, connectivity=8)]
ours = gpd.GeoDataFrame({"name": ["Derwent at Yorkshire Bridge (GLO-30)"], "area_km2": [round(area, 2)]},
geometry=[shapely.union_all(polys)], crs=27700)
ref = nrfa.geometry.iloc[0]
inter, union = ours.geometry.iloc[0].intersection(ref).area, ours.geometry.iloc[0].union(ref).area
print(f"delineated area {area:.2f} km² vs NRFA {info['catchment-area']} km² "
f"({(area / info['catchment-area'] - 1):+.1%}); vs NRFA boundary polygon {ref.area / 1e6:.2f} km²")
print(f"IoU with the NRFA boundary {inter / union:.3f} | only in ours {(ours.area.iloc[0] - inter) / 1e6:.2f} km² "
f"| only in NRFA {(ref.area - inter) / 1e6:.2f} km²")
zc = z[cmask]
qs = np.percentile(zc, [0, 10, 50, 90, 100])
nrfa_q = [info["minimum-altitude"], info["10-percentile-altitude"], info["50-percentile-altitude"],
info["90-percentile-altitude"], info["maximum-altitude"]]
print(pd.DataFrame({"GLO-30 (m)": qs.round(1), "NRFA (m)": nrfa_q},
index=["min", "10%", "median", "90%", "max"]).T.to_string())
print(f"mean elevation {zc.mean():.0f} m (NRFA ALTBAR {info['altbar']} m)")
cell_len = np.where(np.isin(np.asarray(fdir), [128, 2, 8, 32]), RES * np.sqrt(2), RES)
# distance_to_outlet counts cells unless you weight each step by its length (diagonals are 42.4 m)
dist_m = grid.distance_to_outlet(x=sx, y=sy, fdir=fdir, dirmap=DIRMAP, xytype="coordinate",
weights=Raster(cell_len, viewfinder=grid.viewfinder))
dist_m = np.where(cmask, np.asarray(dist_m), np.nan)
print(f"longest flow path {np.nanmax(dist_m) / 1000:.1f} km; mean flow path {np.nanmean(dist_m) / 1000:.2f} km "
f"(NRFA DPLBAR {info['dplbar']} km)")
# ---------------------------------------------------- 7. streams and Strahler order
print("\n=== stream network ===")
STREAM_KM2 = 0.5 # a channel starts where 0.5 km² drains to a cell
BOOL_VF = ViewFinder(affine=grid.affine, shape=grid.shape, crs=grid.crs, nodata=False) # for mask rasters
streams = Raster((acc_km2 > STREAM_KM2) & cmask, viewfinder=BOOL_VF)
# pysheds also labels the cell just past the outlet (order + 1), so keep only stream cells
order = np.where(np.asarray(streams), np.asarray(grid.stream_order(fdir, streams, dirmap=DIRMAP)), 0)
branches = grid.extract_river_network(fdir, streams, dirmap=DIRMAP)
lines, orders = [], []
for feat in branches["features"]:
coords = feat["geometry"]["coordinates"]
if len(coords) < 2:
continue
xy = np.asarray(coords[:-1]) # the last vertex is the junction with the next segment
o = order[((Y1 - xy[:, 1]) // RES).astype(int), ((xy[:, 0] - X0) // RES).astype(int)]
lines.append(LineString(coords))
orders.append(int(np.bincount(o.astype(int)).argmax()))
net = gpd.GeoDataFrame({"strahler": orders}, geometry=lines, crs=27700)
net["length_km"] = net.length / 1000
by_order = net.groupby("strahler").agg(segments=("length_km", "size"), length_km=("length_km", "sum"))
by_order["length_km"] = by_order["length_km"].round(1)
print(f"channel heads at {STREAM_KM2} km²: {len(net)} segments, {net['length_km'].sum():.1f} km, "
f"highest Strahler order {net['strahler'].max()}")
print(by_order.to_string())
print(f"drainage density {net['length_km'].sum() / area:.3f} km/km² (NRFA DRAINDENS {info['draindens']})")
rows = []
for t in [0.1, 0.25, 0.5, 1, 2, 5]:
s = (acc_km2 > t) & cmask
s_order = grid.stream_order(fdir, Raster(s, viewfinder=BOOL_VF), dirmap=DIRMAP)
# length of the stream cells, counting diagonal steps as RES * sqrt(2)
rows.append((t, (s * cell_len).sum() / 1000 / area, int(np.asarray(s_order)[s].max())))
sens = pd.DataFrame(rows, columns=["threshold_km2", "drainage_density", "max_strahler"])
print("\nthreshold sensitivity:")
print(sens.round(3).to_string(index=False))
# ------------------------------------------------------- 8. EA WFD catchments
print("\n=== EA Water Framework Directive catchments ===")
WFD = {"GB104028057960": "river", "GB104028057950": "river", "GB104028057930": "river",
"GB104028057940": "river", "GB104028057910": "river", "GB104028057900": "river",
"GB104028057880": "river", "GB30432299": "lake", "GB30432359": "lake", "GB30432459": "lake"}
wfd_path = DATA / "ea_wfd_waterbodies.gpkg"
if not wfd_path.exists():
parts = []
for wb in WFD:
r = requests.get(f"https://environment.data.gov.uk/catchment-planning/WaterBody/{wb}.geojson", timeout=60)
r.raise_for_status()
g = gpd.GeoDataFrame.from_features(r.json()["features"], crs=4326)
g = g[g.geometry.type.isin(["Polygon", "MultiPolygon"])]
parts.append(gpd.GeoDataFrame({"id": [wb], "name": [g["name"].iloc[0]]}, geometry=[g.union_all()], crs=4326))
pd.concat(parts).to_crs(27700).to_file(wfd_path)
wfd = gpd.read_file(wfd_path)
wfd["kind"] = wfd["id"].map(WFD)
wfd["area_km2"] = (wfd.area / 1e6).round(2)
wfd["share_inside"] = (wfd.intersection(ours.geometry.iloc[0]).area / wfd.area).round(3)
print(wfd[["id", "name", "kind", "area_km2", "share_inside"]].to_string(index=False))
up = wfd[(wfd["kind"] == "river") & (wfd["share_inside"] > 0.5)]
up_u = up.union_all()
print(f"river water bodies more than half inside: {len(up)}, {up_u.area / 1e6:.2f} km² together; "
f"IoU with our catchment {up_u.intersection(ours.geometry.iloc[0]).area / up_u.union(ours.geometry.iloc[0]).area:.3f}")
# ------------------------------------------------------------------- 9. export
gpkg = OUT / "derwent_catchment.gpkg"
gpkg.unlink(missing_ok=True)
ours.to_file(gpkg, layer="catchment")
net.to_file(gpkg, layer="streams")
nrfa.drop(columns=["id"], errors="ignore").to_file(gpkg, layer="nrfa_catchment")
wfd.to_file(gpkg, layer="ea_wfd_waterbodies")
gpd.GeoDataFrame({"name": ["gauge", "snapped pour point"]},
geometry=gpd.points_from_xy([gx, sx], [gy, sy]), crs=27700).to_file(gpkg, layer="pour_point")
with rasterio.open(OUT / "flow_accumulation_km2.tif", "w", driver="GTiff", width=W, height=H, count=1,
dtype="float32", crs="EPSG:27700", transform=grid.affine, compress="deflate") as dst:
dst.write(acc_km2.astype("float32"), 1)
print(f"\n=== export ===\nwrote {gpkg.name} (layers: {', '.join(gpd.list_layers(gpkg)['name'])}) "
f"and flow_accumulation_km2.tif")
# ------------------------------------------------------------------- 10. figures
ext = (X0, X1, Y0, Y1)
hs = LightSource(azdeg=315, altdeg=40).hillshade(np.nan_to_num(z, nan=np.nanmin(z)), vert_exag=2, dx=RES, dy=RES)
lakes = wfd[wfd["kind"] == "lake"]
def frame(ax, title, extent=ext):
ax.set_title(title, loc="left")
ax.set_xticks([]), ax.set_yticks([])
ax.set_xlim(extent[0], extent[1]), ax.set_ylim(extent[2], extent[3])
def scalebar(ax, x0, y0, length=5000):
ax.plot([x0, x0 + length], [y0, y0], color="black", lw=3, solid_capstyle="butt")
ax.text(x0 + length / 2, y0 + 250, f"{length // 1000} km", ha="center", va="bottom", fontsize=8,
bbox=dict(boxstyle="round,pad=0.15", fc="white", ec="none", alpha=0.8))
# 01: DEM, reservoirs and gauge
fig, ax = plt.subplots(figsize=(8.5, 6.6), layout="constrained")
ax.imshow(hs, cmap="gray", extent=ext, vmin=0, vmax=1)
im = ax.imshow(z, cmap="terrain", extent=ext, alpha=0.55, vmin=100, vmax=650)
lakes.plot(ax=ax, color="#2b83ba", edgecolor="none")
nrfa.boundary.plot(ax=ax, color="black", lw=1.2, ls="--")
ax.plot(gx, gy, marker="v", ms=9, mfc="#d7191c", mec="white")
ax.annotate("NRFA 28001\nYorkshire Bridge", (gx, gy), (gx + 1200, gy - 1300), fontsize=8,
bbox=dict(boxstyle="round,pad=0.25", fc="white", ec="none", alpha=0.85))
for _, r in lakes.iterrows():
p = r.geometry.representative_point()
ax.annotate(r["name"].replace(" Reservoir", ""), (p.x, p.y), (p.x + 1500, p.y), fontsize=7.5, color="#08306b",
arrowprops=dict(arrowstyle="-", color="#08306b", lw=0.6),
bbox=dict(boxstyle="round,pad=0.2", fc="white", ec="none", alpha=0.8))
ax.legend(handles=[Line2D([], [], color="black", ls="--", label="NRFA catchment boundary"),
Patch(color="#2b83ba", label="reservoirs (EA WFD lakes)")], loc="upper right", fontsize=8)
fig.colorbar(im, ax=ax, shrink=0.75, label="elevation (m, Copernicus GLO-30)")
frame(ax, "Upper Derwent valley, Peak District: GLO-30 DEM at 30 m")
scalebar(ax, X0 + 1000, Y0 + 1000)
fig.savefig(OUT / "01-dem-pour-point.png")
plt.close(fig)
# 02: what depression filling changed
fig, axes = plt.subplots(1, 2, figsize=(11, 4.6), gridspec_kw={"width_ratios": [1.3, 1]}, layout="constrained")
ax = axes[0]
ax.imshow(hs, cmap="gray", extent=ext, vmin=0, vmax=1)
im = ax.imshow(np.ma.masked_less_equal(fill, 0), cmap="magma_r", norm=LogNorm(0.001, max(fill.max(), 1)),
extent=ext, interpolation="nearest")
lakes.boundary.plot(ax=ax, color="#2b83ba", lw=0.8)
fig.colorbar(im, ax=ax, shrink=0.8, label="raised by filling (m, log scale)")
frame(ax, "Depression filling: cells raised so every cell can drain")
ax = axes[1]
ax.hist(fill[fill > 0], bins=np.logspace(-3, np.log10(max(fill.max(), 1)), 30), color="#f46d43", edgecolor="white")
ax.set_xscale("log")
ax.set_xlabel("fill depth (m)"), ax.set_ylabel("cells")
ax.set_title(f"{(fill > 0).sum():,} cells raised, median {np.median(fill[fill > 0]):.2f} m", loc="left")
fig.savefig(OUT / "02-depression-filling.png")
plt.close(fig)
# 03: flow direction and accumulation, zoomed on the gauge, plus the whole area
zoom = (sx - 2400, sx + 2400, sy - 1600, sy + 2000)
fig, axes = plt.subplots(1, 2, figsize=(11.5, 5), gridspec_kw={"width_ratios": [1, 1.25]}, layout="constrained")
ax = axes[0]
r0, r1 = int((Y1 - zoom[3]) / RES), int((Y1 - zoom[2]) / RES)
c0, c1 = int((zoom[0] - X0) / RES), int((zoom[1] - X0) / RES)
fd = np.asarray(fdir)[r0:r1, c0:c1]
idx = np.searchsorted(np.array(sorted(DIRMAP)), fd).clip(0, 7)
cmap8 = ListedColormap(plt.get_cmap("Spectral")(np.linspace(0, 1, 8)))
ax.imshow(idx, cmap=cmap8, extent=zoom, vmin=-0.5, vmax=7.5, interpolation="nearest")
step = 4
yy, xx = np.mgrid[r0:r1:step, c0:c1:step]
vec = {64: (0, 1), 128: (1, 1), 1: (1, 0), 2: (1, -1), 4: (0, -1), 8: (-1, -1), 16: (-1, 0), 32: (-1, 1)}
u = np.vectorize(lambda c: vec.get(c, (0, 0))[0])(np.asarray(fdir)[yy, xx])
v = np.vectorize(lambda c: vec.get(c, (0, 0))[1])(np.asarray(fdir)[yy, xx])
ax.quiver(X0 + (xx + 0.5) * RES, Y1 - (yy + 0.5) * RES, u, v, scale=40, width=0.003, color="black")
ax.plot(gx, gy, marker="v", ms=9, mfc="white", mec="black")
ax.plot(sx, sy, marker="o", ms=7, mfc="none", mec="black", mew=1.5)
ax.legend(handles=[Patch(color=cmap8(i), label=names[d]) for i, d in enumerate(sorted(DIRMAP))],
loc="lower left", ncol=4, fontsize=7, title="D8 direction", title_fontsize=7, framealpha=0.9)
frame(ax, "D8 flow direction near the gauge", zoom)
ax = axes[1]
ax.imshow(hs, cmap="gray", extent=ext, vmin=0, vmax=1)
im = ax.imshow(np.ma.masked_less(acc_km2, 0.05), cmap="viridis_r", norm=LogNorm(0.05, acc_km2.max()), extent=ext,
interpolation="nearest")
ax.add_patch(plt.Rectangle((zoom[0], zoom[2]), zoom[1] - zoom[0], zoom[3] - zoom[2], fill=False, ec="#d7191c", lw=1.2))
fig.colorbar(im, ax=ax, shrink=0.8, label="upstream area (km², log scale)")
frame(ax, "Flow accumulation: area draining through each cell")
fig.savefig(OUT / "03-flow-direction-accumulation.png")
plt.close(fig)
# 04: our catchment vs the NRFA boundary and the EA WFD catchments
ref_r = features.rasterize([(ref, 1)], out_shape=(H, W), transform=grid.affine, dtype="uint8").astype(bool)
comp = np.zeros((H, W), dtype="uint8")
comp[cmask & ref_r], comp[cmask & ~ref_r], comp[~cmask & ref_r] = 1, 2, 3
cb = ours.total_bounds
cext = (cb[0] - 1500, cb[2] + 1500, cb[1] - 1500, cb[3] + 1500)
fig, axes = plt.subplots(1, 2, figsize=(11.5, 5.2), layout="constrained")
ax = axes[0]
ax.imshow(hs, cmap="gray", extent=ext, vmin=0, vmax=1)
ax.imshow(comp, cmap=ListedColormap(["#00000000", "#abdda4", "#d7191c", "#2b83ba"]), extent=ext,
vmin=-0.5, vmax=3.5, interpolation="nearest", alpha=0.85)
ax.plot(sx, sy, marker="o", ms=6, mfc="white", mec="black")
ax.legend(handles=[Patch(color="#abdda4", label="both"), Patch(color="#d7191c", label="GLO-30 only"),
Patch(color="#2b83ba", label="NRFA only")], loc="upper right", fontsize=8, framealpha=0.9)
frame(ax, f"GLO-30 catchment {area:.1f} km² vs NRFA {info['catchment-area']} km² (IoU {inter / union:.3f})", cext)
scalebar(ax, cext[0] + 800, cext[2] + 800)
ax = axes[1]
ax.imshow(hs, cmap="gray", extent=ext, vmin=0, vmax=1)
rivers = wfd[wfd["kind"] == "river"].reset_index(drop=True)
rivers.plot(ax=ax, column="name", cmap="Spectral", alpha=0.45, edgecolor="#333333", lw=0.6)
lakes.plot(ax=ax, color="#2b83ba")
ours.boundary.plot(ax=ax, color="black", lw=1.6)
for _, r in rivers.iterrows():
p = r.geometry.representative_point()
ax.annotate(f"{r['name'].split(' (')[0]}\n{r['share_inside']:.0%} inside", (p.x, p.y), fontsize=6.5,
ha="center", va="center", bbox=dict(boxstyle="round,pad=0.15", fc="white", ec="none", alpha=0.8))
frame(ax, "EA WFD river water-body catchments and our boundary", cext)
fig.savefig(OUT / "04-catchment-vs-nrfa-wfd.png")
plt.close(fig)
# 05: Strahler order
fig, ax = plt.subplots(figsize=(8, 6.6), layout="constrained")
ax.imshow(hs, cmap="gray", extent=ext, vmin=0, vmax=1)
ours.boundary.plot(ax=ax, color="black", lw=1)
lakes.plot(ax=ax, color="#9ecae1")
omax = int(net["strahler"].max())
norm_o = BoundaryNorm(np.arange(0.5, omax + 1), SPECTRAL.N)
for o in range(1, omax + 1):
net[net["strahler"] == o].plot(ax=ax, color=SPECTRAL(norm_o(o)), lw=0.5 + 0.6 * o)
ax.legend(handles=[Line2D([], [], color=SPECTRAL(norm_o(o)), lw=0.5 + 0.6 * o,
label=f"order {o}: {by_order.loc[o, 'length_km']} km") for o in range(1, omax + 1)],
loc="lower left", fontsize=7.5, title=f"Strahler order (channels from {STREAM_KM2} km²)",
title_fontsize=7.5, framealpha=0.9)
frame(ax, f"Stream network: {net['length_km'].sum():.0f} km of channel, Strahler order up to {omax}", cext)
scalebar(ax, cext[1] - 6000, cext[2] + 800)
fig.savefig(OUT / "05-strahler-order.png")
plt.close(fig)
# 06: threshold sensitivity and hypsometry
fig, axes = plt.subplots(1, 2, figsize=(11, 4), layout="constrained")
ax = axes[0]
ax.plot(sens["threshold_km2"], sens["drainage_density"], marker="o", color="#3288bd")
ax.axhline(info["draindens"], color="#d53e4f", ls="--", lw=1)
ax.text(sens["threshold_km2"].max(), info["draindens"] * 1.06, f"NRFA DRAINDENS {info['draindens']}", ha="right",
color="#d53e4f", fontsize=8)
for _, r in sens.iterrows():
ax.annotate(f"order {int(r['max_strahler'])}", (r["threshold_km2"], r["drainage_density"]),
(5, 5), textcoords="offset points", fontsize=7)
ax.set_xscale("log")
ax.set_xlabel("channel initiation threshold (km² upstream)"), ax.set_ylabel("drainage density (km/km²)")
ax.set_title("Your stream network is a choice of threshold", loc="left")
ax = axes[1]
zs = np.sort(zc)[::-1]
ax.plot(np.arange(1, len(zs) + 1) / len(zs) * 100, zs, color="#3288bd", label="GLO-30 catchment")
ax.scatter([100, 90, 50, 10, 0], nrfa_q, color="#d53e4f", zorder=3, label="NRFA altitude percentiles")
ax.set_xlabel("% of catchment above this elevation"), ax.set_ylabel("elevation (m)")
ax.set_title("Hypsometric curve", loc="left")
ax.legend(fontsize=8)
fig.savefig(OUT / "06-threshold-hypsometry.png")
plt.close(fig)
print("figures written")