Computing Distance to the Nearest Hazard Site at Scale
This guide, part of Hazard Proximity & Drinking Water Metrics, is about the engineering of a deceptively simple query — how far is each residence from the nearest hazardous site — when there are two million residences, forty thousand sites and several site classes.
Problem Context & Constraints
The naive implementation computes a full distance matrix. Two million residences against forty thousand sites is eighty billion distances, which is neither necessary nor feasible. A spatial index reduces it to a logarithmic search per point, and the whole job to a few minutes.
Three correctness constraints shape the implementation more than the performance one.
Distance must be metric and planar, or genuinely geodesic. Computing Euclidean distance on longitude and latitude gives a number in degrees that varies in meaning with latitude, and it is wrong by tens of per cent across a large study area. Either project to an equal-distance or local metric system and use planar distance, or use a geodesic library function. Mixing the two — projecting and then calling a geodesic function, or vice versa — produces silent nonsense.
Distance to a polygon is not distance to its centroid. A landfill is an area. The distance that matters is to its nearest edge, and for a large site the centroid can be a kilometre further away. Where the inventory gives polygons, use them; where it gives points for large facilities, note the resulting overstatement.
Per-class distances are usually what the study needs. “Nearest hazardous site” mixes a solvent recycler with a municipal landfill. Computing the nearest distance separately for each site class costs almost nothing extra with an index per class and produces a far more usable table.
Prerequisites
python3.11geopandas0.14.4,shapely2.0.4,numpy1.26.4,pandas2.2.2,scipy1.13.1- A hazard inventory with a site class attribute and geometry — polygons where available
- Residence points with a study identifier
- Both layers in one projected metric coordinate reference system covering the study extent
- The inventory extended beyond the study boundary by more than the largest distance of interest
Step-by-Step Solution
Build one index per site class, query nearest edge distance for all residences in a vectorised call, and screen the result for coordinates that behave like mailing addresses.
# Nearest-distance to hazard sites by class, for millions of residences.
# Pinned: geopandas==0.14.4, shapely==2.0.4, numpy==1.26.4, pandas==2.2.2
import logging
import numpy as np
import pandas as pd
import geopandas as gpd
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("hazard.distance")
SUSPECT_SHARED_COORD = 25 # sites sharing a coordinate above this are suspect
def _require_planar(*frames: gpd.GeoDataFrame) -> None:
crs = {f.crs for f in frames}
if len(crs) != 1 or next(iter(crs)) is None:
raise ValueError("layers must share exactly one CRS")
if next(iter(crs)).is_geographic:
raise ValueError("project to a metric CRS: planar distance in degrees is "
"not a distance")
def nearest_by_class(residences: gpd.GeoDataFrame, sites: gpd.GeoDataFrame,
class_col: str = "site_class") -> pd.DataFrame:
"""Nearest EDGE distance to each site class, one index per class."""
_require_planar(residences, sites)
residences = residences.sort_values("study_id").reset_index(drop=True)
out = pd.DataFrame({"study_id": residences["study_id"].to_numpy()})
for cls, grp in sites.groupby(class_col):
grp = grp.reset_index(drop=True)
# sjoin_nearest measures to the GEOMETRY, so polygons give edge distance.
near = gpd.sjoin_nearest(residences[["study_id", "geometry"]],
grp[["site_id", "geometry"]],
how="left", distance_col="d")
# A residence equidistant from two sites yields two rows; take the
# smallest distance and break ties on site_id for reproducibility.
near = (near.sort_values(["study_id", "d", "site_id"])
.groupby("study_id", as_index=False).first())
out[f"dist_{cls}_m"] = near["d"].to_numpy()
out[f"nearest_{cls}_id"] = near["site_id"].to_numpy()
log.info("class %-18s sites=%5d median nearest=%8.0f m",
cls, len(grp), float(np.nanmedian(near["d"])))
dist_cols = [c for c in out.columns if c.startswith("dist_")]
out["dist_any_m"] = out[dist_cols].min(axis=1)
log.info("nearest-distance complete: %d residences, %d classes",
len(out), len(dist_cols))
return out
def screen_site_coordinates(sites: gpd.GeoDataFrame,
residences: gpd.GeoDataFrame,
class_col: str = "site_class") -> pd.DataFrame:
"""Flag site coordinates that behave like administrative addresses."""
_require_planar(sites, residences)
xy = np.c_[sites.geometry.centroid.x.round(1), sites.geometry.centroid.y.round(1)]
key = pd.Series([f"{a}_{b}" for a, b in xy])
shared = key.value_counts()
suspect_keys = set(shared[shared >= SUSPECT_SHARED_COORD].index)
flags = pd.DataFrame({"site_id": sites["site_id"].to_numpy(),
class_col: sites[class_col].to_numpy(),
"shared_coordinate": key.isin(suspect_keys).to_numpy(),
"is_point": sites.geometry.geom_type.eq("Point").to_numpy()})
# A "site" that many residences sit almost on top of is usually a centroid
# fallback from the geocoder, not a facility.
n_flag = int(flags["shared_coordinate"].sum())
if n_flag:
log.warning("%d site records share a coordinate with %d or more others — "
"likely administrative geocodes, review before use",
n_flag, SUSPECT_SHARED_COORD)
return flags
Using sjoin_nearest against polygon geometry rather than centroids is what makes the distance an edge distance. It is the single line that separates a defensible metric from one that misclassifies every residence near a large facility, and it costs nothing.
The coordinate screen exists because inventories are full of administrative geocodes. When forty sites share a coordinate to within a tenth of a metre, that coordinate is a county centroid or a state fallback, not forty facilities. Those records inflate the site count near the fallback point and deflate distances there, and the pattern is easy to detect and invisible if you do not look.
Validation & Edge Cases
Ties. A residence exactly between two sites yields two rows from a nearest join. Sorting on distance then site identifier makes the choice deterministic; leaving it to the join order does not.
Sites outside the study extent. If the inventory is clipped to the study boundary, every residence near the edge gets an inflated nearest distance. Extend the inventory, and verify by checking that the maximum nearest distance is not suspiciously close to a boundary-driven value.
Multipart site geometries. A facility with disjoint parcels is one record with several polygons. Edge distance handles it correctly; a centroid would fall between the parts, possibly outside the site entirely.
Zero distances. A residence inside a site polygon returns zero. That is sometimes real — housing on a former industrial parcel — and sometimes a boundary error. Count them and inspect a sample.
Class labels that are not stable. Inventory class vocabularies change between vintages. Map them to a study-defined set explicitly rather than grouping on the raw field, and record the mapping.
Distance used without direction. For an airborne hazard, undirected distance attenuates the estimate, as the parent guide describes. Where wind data exist, compute a directional variant alongside and report both.
INFO hazard.distance class landfill sites= 1842 median nearest= 8140 m
INFO hazard.distance class solvent_recycler sites= 614 median nearest= 19330 m
WARNING hazard.distance 312 site records share a coordinate with 25 or more others — likely administrative geocodes, review before use
INFO hazard.distance nearest-distance complete: 2118400 residences, 6 classes
The class mapping in that list is what makes per-class distances comparable across inventory vintages, and it deserves to be an explicit, versioned table:
Compliance Notes
Record the inventory identifier and extract date, the class mapping applied, whether distances were measured to polygon edges or point coordinates and for which classes, the coordinate reference system, the study-area padding, the tie-breaking rule, and the results of the coordinate screen including how many sites were flagged and what was done about them.
The computation itself consumes residence coordinates and therefore runs inside the protected environment, with an output keyed by study identifier. The site inventory and the screening results are not protected and should be published alongside any analysis: a resident or a journalist checking whether a particular facility was included in a study needs the list, and the flagged-coordinate report is exactly the kind of data-quality caveat that is better disclosed than discovered.
Related Topics
- Hazard Proximity & Drinking Water Metrics — the parent guide on what these distances can and cannot support.
- Choosing a Buffer Radius for Proximity Exposure — turning a continuous distance into a classification defensibly.
- Caching OSRM Route Matrices for Large Batches — the same scale problem where distance must follow a network rather than a straight line.