Zonal Statistics of Rainfall Rasters over Field Boundaries
One-sentence answer: convert the grid to millimetres of depth, subset it to the farm with the latitude slice in the product’s own order, and compute an exact area-weighted mean per boundary — then check the season total against a gauge before trusting any of it.
Context
Weather aggregation looks like the simplest step in a farm platform, and it produces more silently wrong numbers than any other. The reasons are mundane: precipitation stored as a flux rather than a depth, latitude stored descending in one product and ascending in the next, and centroid sampling that assigns a whole cell’s value to a field that only partly overlaps it. None of them raise an exception. All of them produce a plausible-looking daily series that is wrong by a factor of 86,400, or empty, or discontinuous.
This guide is the aggregation half of weather and agronomic data integration; its output is what computing growing degree days accumulates.
Prerequisites
Beyond the parent topic’s stack: exactextract 0.2.* (or rasterstats 0.19.* as a fallback), a gridded product opened with xarray, and field boundaries in EPSG:4326 from PostGIS schema design for farm data.
Step-by-step
1. Establish the units from the variable’s own attributes, and convert once.
2. Subset to the farm, checking the latitude order rather than assuming it.
3. Aggregate with exact area weights.
4. Fail on missing values — a null here becomes a null season total three steps later.
5. Compare with a gauge.
import geopandas as gpd
import numpy as np
import pandas as pd
import xarray as xr
from exactextract import exact_extract
SECONDS_PER_DAY = 86_400.0
def to_mm_per_day(da: xr.DataArray) -> xr.DataArray:
"""Normalise precipitation to millimetres per day from whatever the product stores."""
units = str(da.attrs.get("units", "")).strip().lower()
if units in ("mm", "mm/day", "mm d-1"):
out = da
elif units in ("m", "metre", "meter"):
out = da * 1000.0
elif units in ("kg m-2 s-1", "kg/m2/s", "kg m**-2 s**-1"):
out = da * SECONDS_PER_DAY # 1 kg m-2 == 1 mm of depth
elif units in ("kg m-2", "kg/m2"):
out = da
else:
raise ValueError(
f"unrecognised precipitation units {units!r} — add the conversion explicitly "
"rather than assuming millimetres")
out.attrs["units"] = "mm"
return out
def subset_to_farm(ds: xr.Dataset, fields: gpd.GeoDataFrame, pad_deg: float = 0.2) -> xr.Dataset:
"""Subset a lat/lon grid to the farm, handling either latitude ordering."""
minx, miny, maxx, maxy = fields.to_crs(4326).total_bounds
lat = ds["latitude"].values
descending = bool(lat[0] > lat[-1])
lat_slice = (slice(maxy + pad_deg, miny - pad_deg) if descending
else slice(miny - pad_deg, maxy + pad_deg))
out = ds.sel(latitude=lat_slice, longitude=slice(minx - pad_deg, maxx + pad_deg))
assert out.sizes["latitude"] > 0 and out.sizes["longitude"] > 0, (
"empty subset — latitude ordering or longitude convention (0–360 vs −180–180) is wrong")
return out
def field_daily(ds: xr.Dataset, fields: gpd.GeoDataFrame, var: str = "precip") -> pd.DataFrame:
"""Area-weighted daily mean of `var` per field. One row per field-day."""
da = to_mm_per_day(ds[var])
frames = []
for day in da["time"].values:
layer = da.sel(time=day).rio.write_crs("EPSG:4326")
stats = exact_extract(layer, fields, ["mean"], output="pandas")
frames.append(pd.DataFrame({
"field_id": fields["field_id"].to_numpy(),
"date": pd.Timestamp(day).normalize(),
"precip_mm": stats["mean"].to_numpy(),
}))
out = pd.concat(frames, ignore_index=True)
missing = out["precip_mm"].isna()
assert not missing.any(), (
f"{int(missing.sum())} field-day(s) with no value — "
f"fields {sorted(out.loc[missing, 'field_id'].unique())[:5]} fall outside the grid")
assert out["precip_mm"].between(0, 300).all(), (
f"daily rainfall outside 0–300 mm (max {out['precip_mm'].max():.1f}) — unit conversion")
return out
Inline verification — the gauge comparison, which is the only external evidence available:
season = field_daily(ds, fields).query("field_id == @gauge_field_id")
gridded_total = float(season["precip_mm"].sum())
gauge_total = float(gauge_records["precip_mm"].sum())
print(f"gridded {gridded_total:.0f} mm vs gauge {gauge_total:.0f} mm "
f"({(gridded_total - gauge_total) / gauge_total:+.1%})")
assert abs(gridded_total - gauge_total) < max(25.0, 0.15 * gauge_total), (
"gridded and gauge season totals disagree by more than 15% — check units, "
"the accumulation window, and whether the grid blends forecast with analysis")
Settings worth being deliberate about
| Setting | Default here | Why it matters |
|---|---|---|
| Aggregation | exact area weighting | Makes a field’s value a smooth function of its geometry. Centroid sampling jumps whenever a boundary edit moves the centroid across a cell line |
| Unit handling | read the attribute, convert once | Flux in kg m⁻² s⁻¹ becomes millimetres per day by ×86,400. Guessing here produces a season total near zero and a reported drought |
| Bounding-box pad | 0.2° | Enough that a field on the subset edge still has whole cells around it; larger pads read data no field touches |
| Plausible daily range | 0–300 mm | A cheap assertion that catches unit errors, sign errors and a corrupted variable in one line |
| Missing-value policy | fail, never fill | A NaN field-day propagates into a null season total three steps later, at which point its cause is unrecoverable |
| Gauge tolerance | 15% or 25 mm | Gridded and gauge season totals rarely agree exactly; a persistent divergence beyond this is a unit or window problem, not weather |
Temperature and rainfall deserve different confidence. The spatial gradient in daily temperature across a farm is far smaller than any grid’s own error, so gridded temperature is trustworthy; convective rainfall is not resolvable at 4 km and should be treated as an estimate to be checked.
Gotchas and edge cases
- Longitude conventions differ as much as latitude ordering. Products on a 0–360 grid return nothing for a farm at −93° until the longitudes are shifted. Detect it by checking whether the grid’s longitude maximum exceeds 180, and convert rather than assuming.
-
Convective rainfall is not resolvable at any usable grid resolution. A summer thunderstorm drops 30 mm on one half of a farm and nothing on the other; a 4 km cell reports the average of both. Use gridded rainfall for seasonal accounting and a gauge for anything where a single event matters.
-
Accumulation windows are not always midnight to midnight. Some products accumulate to the timestamp, others from it, and some are UTC on a farm eight hours away. A one-day systematic offset between rainfall and the operations it should explain is usually this.
-
Forecast blended with analysis changes the past. Products that splice a forecast onto the end of the analysis produce covariates that change retrospectively as the forecast is replaced. Read the product’s status flag and keep forecast rows in a separate column.
-
exactextractneeds a CRS on the raster. Anxarraylayer withoutrio.write_crssilently mismatches the polygons’ CRS and returns nothing or nonsense. Set it explicitly per layer, as above. -
Aggregating one day at a time is simple and slow. For a decade of history, aggregate the whole time axis in one pass with a chunked read instead — the per-call overhead dominates at 3,650 iterations.
-
The mean is the right statistic for rainfall and the wrong one for several other variables. Rainfall over a field is an area-weighted average by definition. Wind gusts, frost risk and heat stress are threshold questions where the field’s minimum or maximum cell matters more than its mean — a single frost-prone hollow decides whether a crop was damaged. Compute the statistic the decision needs rather than defaulting to the mean for every variable in the product.
This guide is part of Weather & Agronomic Data Integration for Field Models — see there for the water balance and the covariate table this feeds.
Related
- Computing Growing Degree Days from Gridded Weather Data — the accumulation that consumes this daily series
- Clipping Rasters to Field Boundaries with rasterio.mask — the same boundary-and-raster problem at field resolution
- PostGIS Schema Design for Farm Data — where the per-field-season-day rows are stored