Writing Geometry Validity Assertions for a Pipeline
This guide, part of Spatial Data Quality Monitoring, implements the validity family of checks — the assertions that stop structurally broken geometry before it reaches an algorithm that will either crash on it or, worse, quietly compute something slightly wrong.
Problem Context & Constraints
Invalid geometry is common in public health reference data. Boundary files edited by hand acquire self-intersections where two edits met; polygons converted between formats lose ring orientation; tiny slivers appear where two jurisdictions’ boundaries were digitised separately; multipart features arrive where single parts were expected.
The downstream consequences split into two groups. Loud failures — an overlay library raising a topology exception — are annoying and safe, because the run stops. Silent failures are the dangerous ones: a spatial weights build that treats a bow-tie polygon as two disconnected parts and reports an island that is not one; an area computation that returns a negative value for a reversed ring and quietly subtracts from a sum; a point-in-polygon test that gives inconsistent answers near a self-intersection.
Three constraints shape the assertions. Validity is necessary and not sufficient — a valid geometry of the wrong type, or outside the jurisdiction, or overlapping its neighbour, passes a validity check and still breaks the analysis. Repair must be explicit — automatic repair functions change geometry, sometimes substantially, and a pipeline that repairs silently has replaced the problem with an unrecorded modification. And the check must run where geometry enters, including reference layers, not only on case points.
Prerequisites
python3.11geopandas0.14.4,shapely2.0.4,pandas2.2.2,numpy1.26.4- The layer to be checked, in the pipeline’s projected metric coordinate reference system
- The jurisdiction polygon for containment
- A declared expected geometry type per layer and, for areal layers, whether they are meant to tessellate
Step-by-Step Solution
Assert in layers — validity, type, emptiness, containment, then topology for areal layers — and repair only by rules that record what they changed.
# Layered geometry assertions with explicit, logged repair.
# Pinned: geopandas==0.14.4, shapely==2.0.4, pandas==2.2.2, numpy==1.26.4
import logging
import numpy as np
import pandas as pd
import geopandas as gpd
import shapely
from shapely.validation import explain_validity, make_valid
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("dq.geometry")
SLIVER_M2 = 25.0 # overlaps or gaps smaller than this are digitising noise
MAX_REPAIR_AREA_CHANGE = 0.01
def assert_geometry(gdf: gpd.GeoDataFrame, expected_types: set[str],
jurisdiction: gpd.GeoDataFrame | None = None,
id_col: str = "geoid") -> dict:
"""Run the layered checks and return counts per defect class."""
if gdf.crs is None or gdf.crs.is_geographic:
raise ValueError("geometry checks require a projected metric CRS")
out = {"n": int(len(gdf))}
out["null"] = int(gdf.geometry.isna().sum())
out["empty"] = int(gdf.geometry.is_empty.sum())
invalid_mask = ~gdf.geometry.is_valid
out["invalid"] = int(invalid_mask.sum())
if out["invalid"]:
reasons = gdf.loc[invalid_mask, "geometry"].map(explain_validity)
out["invalid_reasons"] = (reasons.str.extract(r"^([A-Za-z ]+)")[0]
.value_counts().to_dict())
types = set(gdf.geometry.geom_type.dropna().unique())
out["unexpected_types"] = sorted(types - expected_types)
if jurisdiction is not None:
j = jurisdiction.union_all().buffer(1.0) # one metre border tolerance
out["outside_jurisdiction"] = int((~gdf.geometry.within(j)).sum())
if types & {"Polygon", "MultiPolygon"}:
# Orientation: shapely normalises most operations, but some writers do not.
ext = gdf.geometry.map(lambda g: g if g.geom_type == "Polygon"
else (max(g.geoms, key=lambda p: p.area) if not g.is_empty else g))
out["clockwise_exterior"] = int(sum(
1 for g in ext if g is not None and not g.is_empty
and g.geom_type == "Polygon" and not g.exterior.is_ccw))
for k, v in out.items():
if k not in ("n",) and v not in (0, [], {}):
log.warning("geometry check %-22s %s", k, v)
return out
def assert_tessellation(gdf: gpd.GeoDataFrame, id_col: str = "geoid") -> dict:
"""For layers meant to tile the plane: find overlaps and gaps above noise."""
g = gdf[[id_col, "geometry"]].reset_index(drop=True)
pairs = gpd.sjoin(g, g, predicate="intersects", how="inner")
pairs = pairs[pairs[f"{id_col}_left"] < pairs[f"{id_col}_right"]]
overlap_area = []
for r in pairs.itertuples():
a = g.geometry.iloc[r.Index]
b = g.geometry.iloc[r.index_right]
overlap_area.append(a.intersection(b).area)
overlap_area = np.asarray(overlap_area)
n_overlaps = int((overlap_area > SLIVER_M2).sum())
union = g.geometry.union_all()
hull = shapely.concave_hull(union, ratio=0.02) if not union.is_empty else union
gaps = hull.difference(union)
gap_parts = [p for p in getattr(gaps, "geoms", [gaps]) if p.area > SLIVER_M2]
result = {"overlaps_above_noise": n_overlaps,
"max_overlap_m2": float(overlap_area.max()) if overlap_area.size else 0.0,
"gaps_above_noise": len(gap_parts)}
log.info("tessellation: %s", result)
return result
def repair(gdf: gpd.GeoDataFrame, id_col: str = "geoid") -> tuple[gpd.GeoDataFrame, pd.DataFrame]:
"""Repair invalid geometry and REPORT how much each repair changed it."""
g = gdf.copy()
bad = ~g.geometry.is_valid
before = g.loc[bad, "geometry"].area
g.loc[bad, "geometry"] = g.loc[bad, "geometry"].map(make_valid)
# make_valid can return a collection; keep only the areal parts.
g.loc[bad, "geometry"] = g.loc[bad, "geometry"].map(
lambda x: shapely.union_all([p for p in getattr(x, "geoms", [x])
if p.geom_type in ("Polygon", "MultiPolygon")]))
after = g.loc[bad, "geometry"].area
change = ((after - before).abs() / before.replace(0, np.nan)).fillna(0)
report = pd.DataFrame({id_col: g.loc[bad, id_col], "area_change_frac": change})
big = report[report["area_change_frac"] > MAX_REPAIR_AREA_CHANGE]
log.info("repaired %d geometries; %d changed area by more than %.0f%%",
int(bad.sum()), len(big), 100 * MAX_REPAIR_AREA_CHANGE)
if len(big):
log.error("repairs that materially changed geometry need review before use")
return g, report
The repair report is the part that turns a convenience function into a controlled operation. A validity repair on a bow-tie polygon can drop half its area, and a pipeline that repairs and continues has published counts for an area that no longer exists in the shape anybody expects. Reporting the fractional area change per repaired feature, and refusing to proceed when any repair is material, keeps the modification visible.
The tessellation check applies only to layers intended to tile the study area — tracts, counties, service areas — and it catches the defects validity cannot: overlaps that double-count and gaps that break contiguity. The sliver threshold filters digitising noise so the check reports defects that matter rather than every floating-point disagreement along a shared edge.
Validation & Edge Cases
Checking case points but not boundaries. Point layers rarely fail validity; boundary layers regularly do. Run the full suite whenever a reference vintage is registered, not only on nightly case data.
Precision-induced invalidity. Reprojecting or rounding coordinates can make a valid polygon invalid by collapsing a narrow neck. Check validity after every transformation that changes coordinates, not only at ingestion.
Multipart features where single parts were expected. A tract that arrives as a MultiPolygon because of an offshore island is legitimate; one that arrives as two parts because of an editing error is not. The type check flags both, and the resolution is a human decision recorded against the identifier.
A jurisdiction polygon with its own defects. Containment against an invalid jurisdiction polygon produces nonsense. Validate the reference before using it to validate anything else.
Tolerance that hides a real problem. A sliver threshold large enough to suppress every warning also suppresses real overlaps. Derive it from the digitising precision of the source, and review the largest suppressed overlap periodically.
WARNING dq.geometry geometry check invalid 3
WARNING dq.geometry geometry check invalid_reasons {'Self-intersection': 2, 'Ring Self-intersection': 1}
INFO dq.geometry tessellation: {'overlaps_above_noise': 1, 'max_overlap_m2': 18422.6, 'gaps_above_noise': 0}
INFO dq.geometry repaired 3 geometries; 1 changed area by more than 1%
ERROR dq.geometry repairs that materially changed geometry need review before use
Precision-induced invalidity is the failure that appears after a step that should have been harmless, and a single narrow neck shows why:
Compliance Notes
Record, per layer and per vintage: the counts from each check, the invalidity reasons, the tessellation results with the sliver threshold used, every repair with its fractional area change, and the review decision for any material repair.
Repaired geometry is a modified version of an authoritative source. When a repaired boundary is used to publish counts, the product’s metadata should state that the source geometry was repaired, by what method, and how many features were affected. An interagency recipient comparing your tract counts with their own will otherwise see small discrepancies at exactly the features you repaired and have no way to explain them.
Related Topics
- Spatial Data Quality Monitoring — the parent guide and the other check families.
- Handling Island Polygons in Spatial Weights — where a bow-tie polygon’s false island does its damage.
- Validating and Repairing Invalid Polygon Geometry — the repair methods in more depth, outside a pipeline context.