Zonal Statistics for Raster Exposure at Address Points
This guide, part of Assigning Exposure to Residential Locations, covers the mechanics of getting values out of an exposure raster at scale — the step that looks trivial, runs for six hours, and quietly returns a half-cell offset if the alignment is not checked.
Problem Context & Constraints
The obvious implementation opens the raster, loops over addresses, and reads a window per record. For a cohort of a million it performs a million seeks, and if the raster is remote it performs a million range requests. The same work done as a vectorised read of the whole array, or a blocked read of the tiles that actually contain addresses, finishes in minutes.
Performance, though, is the least dangerous part. Three correctness issues cost more.
Coordinate reference system mismatch. Reprojecting the points to the raster’s system is right; reprojecting the raster to the points’ system resamples the data and changes the values. The direction matters, and doing it the wrong way round is a common and invisible error.
Cell-centre versus cell-corner indexing. A geotransform maps a coordinate to a cell index, and an implementation that treats the transform origin as a cell centre rather than a corner shifts every value by half a cell. On a 1 km grid that is 500 m of systematic error applied to the whole cohort, in one direction.
Nodata that is not declared. A raster carrying −9999 as a fill value without declaring it in the header returns −9999 as an exposure. It will not raise; it will produce a mean that is obviously wrong only if somebody looks.
The constraint that ties these together is that the sampling code must assert what it assumes rather than trusting the file’s metadata, because exposure rasters are frequently assembled by hand from sources that disagree with one another.
Prerequisites
python3.11rasterio1.3.10,numpy1.26.4,geopandas0.14.4,pandas2.2.2- An exposure raster with a declared coordinate reference system and, ideally, a declared nodata value
- Geocoded address points with a study identifier and geocode match type
- Enough memory for one raster band, or a tiled access pattern where there is not
Step-by-Step Solution
Reproject points to the raster, convert coordinates to indices in one vectorised operation, and read the array once. Verify the transform before anything else.
# Vectorised raster sampling at address points, with alignment and nodata assertions.
# Pinned: rasterio==1.3.10, numpy==1.26.4, geopandas==0.14.4, pandas==2.2.2
import logging
import numpy as np
import pandas as pd
import geopandas as gpd
import rasterio
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("exposure.zonal")
def verify_raster(path: str, known_point: tuple[float, float] | None = None,
known_value: float | None = None) -> dict:
"""Assert what the sampling code is about to assume about this raster."""
with rasterio.open(path) as src:
t = src.transform
meta = {"crs": src.crs.to_string() if src.crs else None,
"res_x": abs(t.a), "res_y": abs(t.e),
"nodata": src.nodata, "dtype": src.dtypes[0],
"width": src.width, "height": src.height}
if meta["crs"] is None:
raise ValueError("raster has no CRS — refusing to sample")
if meta["nodata"] is None:
log.warning("raster declares no nodata value; sentinel fills will be "
"returned as real exposures")
if abs(t.b) > 1e-12 or abs(t.d) > 1e-12:
raise ValueError("rotated geotransform — reproject the raster first")
if known_point is not None:
# Ground-truth the indexing: one cell whose value you verified by hand.
row, col = src.index(*known_point)
got = float(src.read(1, window=((row, row + 1), (col, col + 1)))[0, 0])
if known_value is not None and not np.isclose(got, known_value):
raise AssertionError(
f"indexing check failed: expected {known_value} at "
f"{known_point}, read {got} — likely a half-cell offset")
log.info("indexing check passed at row=%d col=%d value=%.4f", row, col, got)
log.info("raster verified: %s", meta)
return meta
def sample_points(path: str, points: gpd.GeoDataFrame, band: int = 1) -> pd.DataFrame:
"""Sample a raster at every point in one vectorised pass."""
points = points.sort_values("study_id").reset_index(drop=True)
with rasterio.open(path) as src:
pts = points.to_crs(src.crs) if points.crs != src.crs else points
xs = pts.geometry.x.to_numpy()
ys = pts.geometry.y.to_numpy()
rows, cols = rasterio.transform.rowcol(src.transform, xs, ys)
rows = np.asarray(rows)
cols = np.asarray(cols)
inside = (rows >= 0) & (rows < src.height) & (cols >= 0) & (cols < src.width)
arr = src.read(band, masked=True)
vals = np.full(len(pts), np.nan)
vals[inside] = arr[rows[inside], cols[inside]].filled(np.nan)
nod = src.nodata
out = pd.DataFrame({"study_id": points["study_id"].to_numpy(), "exposure": vals})
if nod is not None:
out.loc[np.isclose(out["exposure"], nod), "exposure"] = np.nan
# Sentinel sweep: values far outside a plausible range are fills, declared or not.
suspicious = out["exposure"].abs() > 1e5
if suspicious.any():
log.error("%d sampled values look like undeclared fills — inspect before use",
int(suspicious.sum()))
out.loc[suspicious, "exposure"] = np.nan
log.info("sampled %d points: outside_extent=%d missing=%d",
len(out), int((~inside).sum()), int(out["exposure"].isna().sum()))
return out
The verify_raster call is the part worth insisting on. It takes one known coordinate with a value you checked by hand in a desktop tool, and it fails loudly if the indexing does not agree. Every other check in the function — rotation, declared coordinate system, nodata — protects against a class of file that arrives in exposure work regularly, assembled from mosaics and reprojected by somebody else.
For buffer means rather than point lookups, the equivalent vectorised approach is to precompute a cumulative sum over the array and read each buffer’s rectangular approximation in constant time. The approximation is a square rather than a circle, which is acceptable when the buffer is small relative to the surface’s spatial correlation and unacceptable when it is not — a decision to record, not to make silently.
Validation & Edge Cases
Points outside the raster extent. These return no value and must be counted, not silently nulled. A block of them usually means a coordinate reference system problem rather than genuinely distant addresses.
Masked arrays and the fill value. Reading with masked=True and then calling filled without an explicit fill returns the array’s default fill, which is a large number. Always pass the fill explicitly.
Integer rasters carrying scaled values. Land surface temperature is often stored as scaled integers. Applying the scale factor and offset is the caller’s job in most libraries, and skipping it produces exposures in the tens of thousands.
Multi-band rasters with band order assumptions. Prediction in band 1 and variance in band 2 is a convention, not a guarantee. Read the band descriptions and assert them.
Duplicate coordinates. Apartment buildings give many records the same value, which is correct. But a sudden spike of identical coordinates that is not a residential building is a geocoder fallback, and its sampled exposure is meaningless.
INFO exposure.zonal indexing check passed at row=2184 col=917 value=11.4200
INFO exposure.zonal raster verified: {'crs': 'EPSG:5070', 'res_x': 1000.0, 'res_y': 1000.0, 'nodata': -9999.0, 'dtype': 'float32', 'width': 4881, 'height': 3109}
INFO exposure.zonal sampled 1042118 points: outside_extent=214 missing=1908
The coordinate reference system direction in that list is the error with the least obvious symptom, so it helps to see both paths:
Compliance Notes
Record the raster identifier, vintage, band descriptions, coordinate reference system, resolution, declared nodata and the result of the indexing verification including the coordinate and value used. Record the access pattern and, where an integral-image approximation was used for buffers, the fact that circular buffers were approximated by squares and at what radius.
The sampling step runs inside the protected environment because it consumes address coordinates. The log must carry counts and raster metadata only — never the coordinate that failed, never the study identifier of a record outside the extent. Where a verification coordinate is hard-coded for the indexing check, use a public landmark rather than a case address.
Related Topics
- Assigning Exposure to Residential Locations — the parent guide and the method-by-match-type rules this sampling serves.
- Writing Cloud-Optimized GeoTIFFs for Exposure Rasters — producing rasters whose access pattern suits this sampling.
- Building Buffer Predictors for a Land-Use Regression Model — the same windowed-summary machinery on the predictor side.