Retrieving and Gridding PRISM Precipitation in Python
Gridded precipitation products answer the question a gauge cannot: what fell across the whole basin, not at one point in it. Converting a grid to basin-average forcing is a zonal statistic, and the two things that make it correct are partial-cell weighting and getting the time convention right. This guide covers both, as part of the precipitation forcing and design storms topic within rainfall-runoff modeling and hydrologic simulation.
Prerequisites
- A catchment polygon, ideally the delineated one rather than a published boundary, so the forcing matches the modelled area.
rasterio,rasterstatsorexactextract,xarray,pandas.- The product’s documented time convention. This is not inferable from the data.
Core Technique: Area-Weighted Zonal Statistics
The naive basin average takes every grid cell whose centre lies inside the polygon and means them. On a basin large relative to the grid that is close to right; on a small basin it is not.
Annotated Code Example
import logging
from pathlib import Path
import geopandas as gpd
import numpy as np
import pandas as pd
import rasterio
from rasterio.mask import mask as rio_mask
log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
def basin_mean_series(
grid_dir: str,
catchment_path: str,
pattern: str = "*.tif",
date_from_name=None,
stamp: str = "end",
tz_shift_hours: float = 0.0,
) -> pd.Series:
"""
Area-weighted basin-mean precipitation from a directory of grid files.
Parameters
----------
grid_dir : Directory of single-timestep precipitation rasters.
catchment_path : Catchment polygon, any CRS.
pattern : Glob for the raster files.
date_from_name : Callable mapping a filename to a timestamp.
stamp : "end" if a file's timestamp marks the END of its
accumulation interval, "start" otherwise.
tz_shift_hours : Hours to add to convert the product's clock to the
model's clock.
Returns
-------
Series of basin-mean depth indexed by the START of each interval.
"""
files = sorted(Path(grid_dir).glob(pattern))
if not files:
raise FileNotFoundError(f"no rasters matching {pattern} in {grid_dir}")
catchment = gpd.read_file(catchment_path)
records = []
step_hours = None
for i, path in enumerate(files):
with rasterio.open(path) as src:
geoms = catchment.to_crs(src.crs).geometry
# --- all_touched=True brings in every partially covered cell;
# the weights below then scale each by its true overlap, which is
# what a centre-in-polygon rule cannot do. ---
data, _ = rio_mask(src, geoms, crop=True, all_touched=True,
filled=True, nodata=np.nan)
weights, _ = rio_mask(
src, geoms, crop=True, all_touched=True, filled=True,
nodata=0, indexes=1,
)
arr = data[0].astype("float64")
# Weight is the fraction of each cell inside the polygon. Computing
# it exactly needs a coverage rasteriser; a high-resolution
# supersample is a good approximation and needs no extra dependency.
frac = _coverage_fraction(src, geoms, arr.shape, supersample=8)
valid = np.isfinite(arr) & (frac > 0)
if not valid.any():
log.warning("%s: no overlapping cells", path.name)
continue
mean = float(np.average(arr[valid], weights=frac[valid]))
naive = float(np.nanmean(arr[valid]))
ts = date_from_name(path.name) if date_from_name else pd.Timestamp(i, unit="h")
records.append({"stamp": ts, "depth_mm": mean, "naive_mm": naive})
df = pd.DataFrame(records).sort_values("stamp").reset_index(drop=True)
if len(df) > 1:
step_hours = (df["stamp"].iloc[1] - df["stamp"].iloc[0]).total_seconds() / 3600.0
# --- Normalise to interval-START stamping. Reading an end-stamped product
# as start-stamped shifts the whole hyetograph by one step, which shows up
# in calibration as a timing error nobody attributes to the input. ---
shift = pd.Timedelta(hours=tz_shift_hours)
if stamp == "end" and step_hours:
shift -= pd.Timedelta(hours=step_hours)
log.info("Product is end-stamped at a %.1f h step — shifting the index "
"back to interval starts", step_hours)
df["start"] = df["stamp"] + shift
bias = 100.0 * (df["naive_mm"].sum() - df["depth_mm"].sum()) / max(
df["depth_mm"].sum(), 1e-9)
log.info("%d intervals, total %.1f mm basin-mean; a cell-centre average "
"would differ by %+.1f %%", len(df), df["depth_mm"].sum(), bias)
out = df.set_index("start")["depth_mm"]
out.index.name = "interval_start"
return out
def _coverage_fraction(src, geoms, shape, supersample: int = 8) -> np.ndarray:
"""Approximate the fraction of each cell inside the polygon."""
from rasterio.features import rasterize
from rasterio.transform import from_origin
# Rasterize at N× resolution inside the same window, then block-average.
win_transform = src.window_transform(
rasterio.windows.from_bounds(*src.bounds, transform=src.transform))
fine_transform = from_origin(
win_transform.c, win_transform.f,
abs(win_transform.a) / supersample, abs(win_transform.e) / supersample)
fine = rasterize(
[(g, 1) for g in geoms], fill=0, dtype="uint8",
out_shape=(shape[0] * supersample, shape[1] * supersample),
transform=fine_transform,
)
return fine.reshape(shape[0], supersample, shape[1], supersample).mean(axis=(1, 3))
# --- Example usage ---
# import re
# def name_to_ts(name):
# m = re.search(r"(\d{8})", name)
# return pd.Timestamp(m.group(1))
# series = basin_mean_series("prism_daily/", "catchment.gpkg",
# date_from_name=name_to_ts, stamp="end")
# print(series.head())
Parameter Reference
| Parameter | Recommendation | Why |
|---|---|---|
all_touched |
True, with weights |
Brings in partially covered cells so weighting can scale them |
| Weighting | Area fraction | Removes the inclusion/exclusion bias on small basins |
stamp |
From the product docs | Wrong by one step is a systematic timing error |
tz_shift_hours |
From the product docs | UTC-stamped forcing on a local-time model shifts every event |
| Supersample factor | 8 | Approximates coverage to about 1 % without an extra dependency |
Worked Example: When the Grid Is Too Coarse
Basin size relative to the grid decides whether the weighting matters at all.
| Basin area | Cells covered (4 km grid) | Centre-average bias | Verdict |
|---|---|---|---|
| 12 km² | ~1 | up to ±60 % | Do not use a 4 km grid |
| 90 km² | ~6 | ±15 % | Weight, and expect noise |
| 500 km² | ~31 | ±4 % | Weighting is worthwhile |
| 4 000 km² | ~250 | under 1 % | Either method is fine |
The top row is the important one. A 12 km² basin on a 4 km grid is covered by roughly one cell, and no weighting scheme recovers information the product never had. That basin needs a finer product, a gauge, or an explicit acknowledgement that the forcing is a regional average.
Provisional and revised versions of the same gridded product differ enough to change a calibration. Pinning the version is part of making a run reproducible.
Gotchas and Edge Cases
- Daily grids driving event models. The peak intensity is an order of magnitude low. Use daily products for water balance and antecedent conditions, not for design or event peaks.
- Interval stamp assumed. One step of shift, applied consistently, becomes a timing bias that calibration hides by distorting the transform.
- Time zone ignored. A UTC-stamped product on a local-time model shifts every event by several hours, and the shift changes with daylight saving.
all_touched=Falseon a small basin. Excludes every partially covered cell, and on a one-cell basin can exclude all of them.- Provisional versus final products. Many gridded precipitation datasets publish a provisional version within days and a revised one months later. A calibration run against provisional data is not reproducible from the final archive.
- Snow reported as liquid equivalent. Correct for water balance, wrong for an event model that has no snowpack routine: the water arrives in the model on the day it fell rather than at melt.
- Basin boundary from a different source. Forcing averaged over a published boundary and runoff modelled over a delineated one introduces a mismatch proportional to the difference — see validating hydro-conditioning against NHD flowlines.
Related Topics
- Precipitation Forcing and Design Storms — the parent topic: product families, areal reduction and validation
- Converting NOAA Atlas 14 IDF Data into Model Forcing — the design-storm counterpart to this observed forcing
- Model Calibration & Objective Functions — where a forcing bias reappears as a parameter that will not settle
- Computing Nash-Sutcliffe Efficiency and KGE in Python — the metrics that a one-step timing shift quietly degrades