Hydrology — catchment water balance from ERA5¶
Build a monthly water balance for a small catchment by pulling the
three drivers from CDS in one call: precipitation in, evaporation out,
and runoff out. ERA5 reports all three as flux variables (per-step
accumulations), so op="auto" resolves to sum and the per-month
GeoTIFF carries the actual monthly total in metres of water
equivalent.
Domain context. The catchment-scale water balance is
$$ P = ET + R + \frac{dS}{dt} $$
where $P$ is precipitation, $ET$ is actual evapotranspiration, $R$ is runoff (surface + subsurface), and $dS/dt$ is change in storage. For long enough periods the storage term averages near zero and the residual $P - ET - R$ should be small. We'll check that for one calendar year.
Setup¶
Pull in everything the notebook needs up front: numpy / pandas for the
time series, matplotlib for the plot, pyramids' Dataset to read the
per-month GeoTIFFs, and the earthlens entry points (EarthLens +
AggregationConfig).
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from pyramids.dataset import Dataset
from earthlens import AggregationConfig, EarthLens
Step 1 — verify the variables exist in the catalog¶
All three live on reanalysis-era5-single-levels, with types: flux
set so the auto-routing picks sum.
from earthlens.ecmwf import Catalog
cat = Catalog()
for code in ("total-precipitation", "evaporation", "surface-runoff"):
spec = cat.get_variable("reanalysis-era5-single-levels", code)
print(
f"{code:25s} nc={spec.nc_variable:5s} units={spec.units:25s} is_flux={spec.is_flux}"
)
Step 2 — retrieve a year of monthly totals¶
One small catchment-sized box (~1° around Coello, Colombia), all three
variables, twelve months. We pass cadence="monthly" so the retrieve uses
the -monthly-means dataset internally and the request body skips the day
field. The aggregation runs at 1MS (month-start) frequency.
Pick an output directory¶
The per-month GeoTIFFs and their aggregated/ subfolder are written under
data/era5-water-balance.
OUT = Path("data/era5-water-balance")
OUT.mkdir(parents=True, exist_ok=True)
Build the request¶
Construct the EarthLens request on its own — source, monthly cadence, the
twelve-month window, the three flux variables, and the Coello bounding box.
earthlens = EarthLens(
data_source="ecmwf",
cadence="monthly",
start="2022-01-01",
end="2022-12-01",
dataset="reanalysis-era5-single-levels-monthly-means",
variables=[
"total-precipitation",
"evaporation",
"surface-runoff",
],
aoi=[-75.0, 4.0, -74.0, 5.0],
path=str(OUT),
)
Download with op="auto" aggregation¶
Run the retrieve in a separate step. Chaining the AggregationConfig into
download() writes one GeoTIFF per (variable, month), summing the per-step
flux accumulations into a true monthly total.
earthlens.download(aggregate=AggregationConfig(freq="1MS", op="auto"))
agg_dir = OUT / "aggregated"
def stack_monthly(cds_variable: str) -> np.ndarray:
"""Stack the 12 monthly GeoTIFFs for one variable into a (12, lat, lon) cube."""
paths = sorted(agg_dir.glob(f"{cds_variable}_1MS_*.tif"))
return np.stack([Dataset.read_file(str(p)).read_array() for p in paths])
Stack each variable¶
Build a cube for precipitation, evaporation, and runoff from their per-month GeoTIFFs.
precip = stack_monthly("total_precipitation")
et = stack_monthly("evaporation")
runoff = stack_monthly("surface_runoff")
Reduce to a catchment-mean time series¶
Average each cube over space to get one number per (variable, month), then
assemble the three series into a monthly DataFrame (shown in millimetres).
precip_mean = np.nanmean(precip, axis=(1, 2))
et_mean = np.nanmean(et, axis=(1, 2))
runoff_mean = np.nanmean(runoff, axis=(1, 2))
months = pd.date_range("2022-01-01", periods=12, freq="MS")
df = pd.DataFrame(
{"P": precip_mean, "ET": et_mean, "R": runoff_mean},
index=months,
)
df * 1000 # m -> mm
Step 4 — plot the monthly water-balance terms¶
ERA5 reports evaporation as negative when the surface loses water to the
atmosphere, so we invert the sign to read the magnitude. Same convention for
surface_runoff (positive away from the surface).
Convert to mm and fix the sign¶
Scale each series from metres to millimetres and flip the evaporation sign.
precip_mm = precip_mean * 1000
et_mm = -et_mean * 1000 # ERA5 evaporation is negative for sfc->atm flux
runoff_mm = runoff_mean * 1000
Plot the three terms¶
Draw precipitation, evapotranspiration magnitude, and runoff on one axis to see the seasonal cycle of the catchment-mean water balance.
fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(months, precip_mm, marker="o", label="Precipitation (P)")
ax.plot(months, et_mm, marker="s", label="Evapotranspiration (|ET|)")
ax.plot(months, runoff_mm, marker="^", label="Surface runoff (R)")
ax.set_ylabel("mm / month (catchment mean)")
ax.set_title("ERA5 monthly water-balance terms — Coello bbox, 2022")
ax.legend()
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()
Step 5 — annual closure¶
Sum each term over the year and check the residual $P - |ET| - R \approx \Delta S$. For a 12-month period in a tropical catchment we expect a small residual relative to total fluxes — single-percent of $P$ for a closed system, larger when the catchment has substantial groundwater export or interannual storage swings.
annual = pd.DataFrame(
{
"P (mm/yr)": [precip_mm.sum()],
"|ET| (mm/yr)": [et_mm.sum()],
"R (mm/yr)": [runoff_mm.sum()],
"Residual P - |ET| - R": [precip_mm.sum() - et_mm.sum() - runoff_mm.sum()],
"Residual / P (%)": [
100
* (precip_mm.sum() - et_mm.sum() - runoff_mm.sum())
/ max(precip_mm.sum(), 1e-9)
],
}
)
annual.round(1)
Notes¶
op="auto"is critical here. All three variables are fluxes; the catalog'stypes: fluxfield tells the reducer to sum per-step accumulations instead of averaging them. Ameanwould give numbers ~30× too small (one slot's accumulation rather than a month's).- ERA5-Land for higher-resolution catchment work. The single-levels
product is 0.25° native; ERA5-Land is 0.1°. Switch the dataset key to
"reanalysis-era5-land"and addevaporation-from-bare-soil,evaporation-from-vegetation-transpiration,sub-surface-runofffor a more detailed land-surface budget. - Storage term. ERA5 also exposes
volumetric_soil_water_layer_1..4on ERA5-Land for shallow soil moisture; differencing month-end values closes the residual.