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, rasterstats or exactextract, 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.

Cell Centres Versus Area Weights A catchment polygon spans parts of nine grid cells. The centre-in-polygon rule counts three cells at full weight and ignores six. Area weighting assigns fractions from 0.08 to 0.94 across all nine, giving a basin mean 11 percent different. centre-in-polygon: 4 of 9 cells counted 0.34 0.61 0.08 0.72 0.94 0.41 0.29 0.58 0.12 area weights: all 9 cells, fractionally On this basin the two methods differ by 11 % in basin-mean depth.

Annotated Code Example

python
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.

The Bias Collapses Once the Basin Covers Enough Cells The spread of the cell-centre averaging error against basin area on a 4 kilometre grid. At 12 square kilometres the error band spans plus or minus 60 percent; at 90 it is plus or minus 15; at 500 plus or minus 4; and above 4000 square kilometres it is under 1 percent. 10 90 500 1 500 4 000 basin area (km², log scale) on a 4 km precipitation grid +60 % 0 −60 % the product cannot resolve a basin this small weighting worth doing either method is fine Weighting fixes a sampling bias; it cannot add resolution the grid never had.

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.

The Same Grid, Two Versions, Different Depths Six events compared between the provisional and revised releases of the same gridded product. Differences range from 1 percent to 14 percent, with the largest on the most intense event, which is where a calibration is most sensitive. +2 % +1 % +9 % +7 % +4 % +14 % left bar provisional, right bar revised The largest revision is on the most intense event, which is the one calibration leans on.

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=False on 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.