| name | hydrology-pysheds |
| description | Use this Skill for DEM-based hydrological analysis: watershed delineation, flow direction/accumulation, stream networks, and runoff estimation with pysheds.
|
| tags | ["earth-science","hydrology","pysheds","dem","watershed"] |
| version | 1.0.0 |
| authors | [{"name":"Rosetta Skills Contributors","github":"@xjtulyc"}] |
| license | MIT |
| platforms | ["claude-code","codex","gemini-cli","cursor"] |
| dependencies | {"python":["pysheds>=0.3","rasterio>=1.3","geopandas>=0.14","matplotlib>=3.7","numpy>=1.24","scipy>=1.11"]} |
| last_updated | 2026-03-17 |
| status | stable |
Hydrological Analysis with pysheds
One-line summary: Derive watersheds, stream networks, and flow accumulation from Digital Elevation Models (DEMs) using pysheds, rasterio, and geopandas.
When to Use This Skill
- When delineating watershed boundaries from a DEM for a pour point
- When extracting stream networks from topographic data
- When computing flow direction and accumulation grids
- When estimating runoff and drainage basin area
- When conditioning DEMs to remove pits and flat areas
- When analyzing upstream contributing area for flood modeling
Trigger keywords: watershed delineation, DEM, flow direction, flow accumulation, stream network, pysheds, hydrological analysis, basin area, pour point
Background & Key Concepts
DEM Processing Pipeline
$$
\text{Raw DEM} \xrightarrow{\text{pit-fill}} \text{Conditioned DEM} \xrightarrow{\text{flow direction}} \text{D8 grid} \xrightarrow{\text{accumulation}} \text{Flow acc.} \xrightarrow{\text{threshold}} \text{Stream network}
$$
D8 Flow Direction
Each cell flows to one of 8 neighbors based on steepest descent. Flow direction encoding: N=64, NE=128, E=1, SE=2, S=4, SW=8, W=16, NW=32.
Flow Accumulation
$A_{ij}$ = number of upstream cells draining through cell $(i,j)$. Cells with high accumulation are stream channels.
Watershed Delineation
Starting from a pour point (outlet), trace upstream through the flow direction grid to find all contributing cells.
SCS Curve Number Method
For runoff estimation:
$$
Q = \frac{(P - I_a)^2}{P - I_a + S}, \quad S = \frac{25400}{CN} - 254, \quad I_a = 0.2S
$$
where $P$ is precipitation depth (mm), $CN$ is the SCS curve number.
Environment Setup
Install Dependencies
pip install pysheds>=0.3 rasterio>=1.3 geopandas>=0.14 \
matplotlib>=3.7 numpy>=1.24 scipy>=1.11
Download a Test DEM
pip install elevation
eio clip -o dem_test.tif --bounds -105.5 39.5 -104.5 40.5
Verify Installation
from pysheds.grid import Grid
import numpy as np
import matplotlib.pyplot as plt
print("pysheds installation OK")
dem_arr = np.random.rand(50, 50) * 100
print(f"Test DEM shape: {dem_arr.shape}")
Core Workflow
Step 1: DEM Loading and Conditioning
from pysheds.grid import Grid
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.colors as colors
def create_synthetic_dem(nrows=200, ncols=200, seed=42):
"""
Create a synthetic DEM with a valley and drainage divide.
Replace with: grid = Grid.from_raster('your_dem.tif')
"""
rng = np.random.default_rng(seed)
x = np.linspace(0, 2*np.pi, ncols)
y = np.linspace(0, 2*np.pi, nrows)
X, Y = np.meshgrid(x, y)
dem = 1000 - 200 * Y/Y.max() + 100 * np.sin(X) * np.cos(Y/2)
dem += 5 * rng.standard_normal((nrows, ncols))
return dem
def load_and_condition_dem(dem_path=None):
"""
Load a DEM and condition it for hydrological analysis.
Parameters
----------
dem_path : str or None
Path to GeoTIFF DEM. If None, uses synthetic DEM.
Returns
-------
grid : pysheds Grid
dem : ndarray
flooded_dem : ndarray (pit-filled)
"""
if dem_path:
grid = Grid.from_raster(dem_path)
dem = grid.read_raster(dem_path)
else:
dem_arr = create_synthetic_dem()
print("Using synthetic DEM (replace with real DEM path)")
return None, dem_arr, None
pit_filled = grid.fill_pits(dem)
depressions_filled = grid.fill_depressions(pit_filled)
inflated = grid.resolve_flats(depressions_filled)
print(f"DEM shape: {dem.shape}")
print(f"Elevation range: {dem.min():.1f} – {dem.max():.1f} m")
print(f"Pit-filled cells: {(inflated != dem).sum():,}")
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
im0 = axes[0].imshow(dem, cmap='terrain')
plt.colorbar(im0, ax=axes[0], label='Elevation (m)')
axes[0].set_title("Original DEM")
im1 = axes[1].imshow(inflated - dem, cmap='Blues')
plt.colorbar(im1, ax=axes[1], label='Δ Elevation (m)')
axes[1].set_title("Filled Depressions (difference)")
plt.tight_layout()
plt.savefig("dem_conditioning.png", dpi=150)
plt.show()
return grid, dem, inflated
_, dem_raw, _ = load_and_condition_dem()
print(f"Synthetic DEM stats: mean={dem_raw.mean():.1f}, std={dem_raw.std():.1f}")
Step 2: Flow Direction and Accumulation
from pysheds.grid import Grid
import numpy as np
import matplotlib.pyplot as plt
print("Flow direction and accumulation workflow:")
print("1. grid.flowdir(conditioned_dem) → D8 direction grid")
print("2. grid.accumulation(fdir) → upstream area in cells")
print("3. acc > threshold → binary stream network")
print("4. grid.snap_to_mask(streams, xy) → snap pour point to stream")
np.random.seed(42)
n = 50
acc_synthetic = np.zeros((n, n))
for i in range(n):
for j in range(n):
acc_synthetic[i, j] = (i + 1) * (j + 1)
fig, ax = plt.subplots(figsize=(6, 5))
im = ax.imshow(np.log1p(acc_synthetic), cmap='Blues')
plt.colorbar(im, ax=ax, label='log(accumulation + 1)')
ax.set_title("Synthetic Flow Accumulation")
plt.tight_layout()
plt.savefig("flow_accumulation.png", dpi=150)
plt.show()
Step 3: Watershed Delineation
from pysheds.grid import Grid
import geopandas as gpd
from shapely.geometry import shape
import numpy as np
import matplotlib.pyplot as plt
print("Watershed delineation workflow complete")
print("Key outputs:")
print(" - watershed polygon (GeoDataFrame)")
print(" - drainage area in km²")
print(" - stream network as LineString features")
def scs_runoff(P_mm, CN):
"""
Estimate direct runoff depth using SCS Curve Number method.
Parameters
----------
P_mm : float or ndarray
Total precipitation depth (mm)
CN : float
SCS Curve Number (0-100)
Returns
-------
Q_mm : float or ndarray
Direct runoff depth (mm)
"""
S = (25400 / CN) - 254
Ia = 0.2 * S
P = np.asarray(P_mm)
Q = np.where(P > Ia, (P - Ia)**2 / (P - Ia + S), 0.0)
return Q
storm_mm = 25.0
land_covers = {
"Row crops (poor)": 86,
"Row crops (good)": 75,
"Meadow (good)": 58,
"Forest (good)": 55,
"Impervious": 98,
}
print("\nSCS Runoff Estimation (25mm storm):")
print(f"{'Land Cover':<25} {'CN':>4} {'Runoff (mm)':>12} {'Runoff/P (%)':>13}")
print("-" * 56)
for lc, cn in land_covers.items():
Q = scs_runoff(storm_mm, cn)
print(f"{lc:<25} {cn:>4} {Q:>12.1f} {100*Q/storm_mm:>13.1f}")
Advanced Usage
Automated Multi-Watershed Analysis
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
def estimate_flood_frequency(area_km2, mean_annual_precip_mm, CN):
"""
Simplified regional flood frequency using SCS method.
Returns peak discharge estimates (m³/s) for return periods.
"""
return_periods = [2, 5, 10, 25, 50, 100]
rp_factors = {2: 1.0, 5: 1.4, 10: 1.7, 25: 2.1, 50: 2.4, 100: 2.8}
base_storm = mean_annual_precip_mm / 12
results = []
for rp in return_periods:
P = base_storm * rp_factors[rp]
Q_mm = scs_runoff(P, CN)
runoff_vol_m3 = Q_mm / 1000 * area_km2 * 1e6
Tc_h = 0.0078 * (area_km2**0.5 / 0.3)**0.77
Q_peak = runoff_vol_m3 / (Tc_h * 3600)
results.append({"return_period": rp, "Q_peak_m3s": Q_peak})
return pd.DataFrame(results)
flood_table = estimate_flood_frequency(area_km2=150, mean_annual_precip_mm=600, CN=72)
print(flood_table.round(2))
fig, ax = plt.subplots(figsize=(8, 5))
ax.semilogy(flood_table["return_period"], flood_table["Q_peak_m3s"], 'bs-', linewidth=2)
ax.set_xlabel("Return Period (years)")
ax.set_ylabel("Peak Discharge (m³/s)")
ax.set_title("Flood Frequency Curve")
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("flood_frequency.png", dpi=150)
plt.show()
Troubleshooting
Error: ValueError: No data in raster
Cause: DEM has NoData values throughout or incorrect file path.
Fix:
import rasterio
with rasterio.open("dem.tif") as src:
print(f"Nodata value: {src.nodata}")
print(f"Data range: {src.read(1).min()} – {src.read(1).max()}")
print(f"CRS: {src.crs}")
Issue: All cells flow in same direction (flat DEM)
Cause: DEM has no topographic relief (e.g., coastal plain or incorrect data).
Fix:
inflated = grid.resolve_flats(depressions)
fdir = grid.flowdir(inflated)
Version Compatibility
| Package | Tested versions | Known issues |
|---|
| pysheds | 0.3.5 | API changed in 0.3 (Grid class) |
| rasterio | 1.3, 1.4 | None |
External Resources
Official Documentation
Key Papers
- Bartos, M. et al. (2021). pysheds: An open-source Python library for watershed delineation. JOSS.
Examples
Example 1: Extract Stream Network from SRTM DEM
try:
from pysheds.grid import Grid
import numpy as np
import matplotlib.pyplot as plt
import geopandas as gpd
grid = Grid.from_raster('dem.tif')
dem = grid.read_raster('dem.tif')
print(f"DEM loaded: {dem.shape}, range {dem.min():.0f}–{dem.max():.0f} m")
pit_filled = grid.fill_pits(dem)
dep_filled = grid.fill_depressions(pit_filled)
inflated = grid.resolve_flats(dep_filled)
fdir = grid.flowdir(inflated)
acc = grid.accumulation(fdir)
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
for ax, thresh_pct in zip(axes, [0.1, 0.5, 2.0]):
thresh = int(acc.max() * thresh_pct / 100)
streams = acc > thresh
ax.imshow(np.log1p(acc), cmap='Blues', alpha=0.5)
ax.imshow(np.ma.masked_where(~streams, streams), cmap='Reds', alpha=0.8)
ax.set_title(f"Threshold: {thresh_pct}% ({thresh:,} cells)")
ax.axis('off')
plt.suptitle("Stream Networks at Different Thresholds")
plt.tight_layout()
plt.savefig("stream_networks.png", dpi=150)
plt.show()
except FileNotFoundError:
print("DEM file not found. Download from USGS Earth Explorer (https://earthexplorer.usgs.gov/)")
print("Then run: eio clip -o dem.tif --bounds <lon_min> <lat_min> <lon_max> <lat_max>")
Interpreting these results: Lower thresholds yield denser stream networks; higher thresholds show only major rivers. Use Strahler stream order to classify streams.
Last updated: 2026-03-17 | Maintainer: @xjtulyc
Issues: GitHub Issues