Creating minimal parcel fixtures that reproduce topology bugs
GEOSException: TopologyException: found non-noded intersection between LINESTRING (571043.21 289114.87, 571051.44 289120.13) and LINESTRING (571051.44 289120.13, 571043.21 289114.87) at 571047.3 289117.5. The overlay ran for eleven minutes before raising, the input is a 41 000-parcel county layer, and the coordinates in the message belong to a linestring you cannot find. Debugging that directly is hopeless; the productive move is to shrink the input until the failure is small enough to look at. This guide does that mechanically — bisection on the feature set, then on the ring — producing a fixture of a handful of vertices that still fails, which then becomes a permanent regression test. It is the fixture-building practice for testing spatial data pipelines.
Diagnosis: making the failure deterministic before shrinking it jump to heading
Shrinking only works on a reproducible failure, and spatial failures are often nearly deterministic in ways that waste an afternoon.
Pin the library versions. GEOS changes its noding behaviour between releases, and a fixture that fails on GEOS 3.11 may pass on 3.12. Record the versions in the fixture directory or the shrink is not reproducible either.
Remove ordering dependence. Many overlay failures depend on the order features are processed, so a run that reads a GeoPackage in file order and a run that reads it after a spatial sort fail differently. Sort features by a stable key before shrinking, and keep that sort in the fixture.
Confirm the failure is in the geometry, not the volume. A MemoryError at 41 000 parcels is a different problem and will not shrink. Run the same operation on a random 5% sample: if it succeeds, you have a scale problem; if it fails, you have a geometry problem and shrinking will find it.
def is_reproducible(features, op, attempts=3) -> bool:
"""The same input must fail the same way every time before shrinking begins."""
errors = []
for _ in range(attempts):
try:
op(features)
return False # it passed: not reproducible
except Exception as exc:
errors.append(f"{type(exc).__name__}: {exc}")
return len(set(errors)) == 1 # identical failure each time
Step-by-step implementation jump to heading
1. Bisect the feature set jump to heading
def shrink_features(features, op):
"""Delta-debugging on the feature list: repeatedly try to drop half, keeping any
subset that still fails. Converges on a minimal failing set, which for overlay
bugs is usually two or three parcels."""
current = list(features)
granularity = 2
while len(current) >= 2:
chunk = max(1, len(current) // granularity)
reduced = False
for start in range(0, len(current), chunk):
candidate = current[:start] + current[start + chunk:]
if not candidate:
continue
if still_fails(candidate, op):
current = candidate
granularity = max(granularity - 1, 2)
reduced = True
break
if not reduced:
if granularity >= len(current):
break
granularity = min(granularity * 2, len(current))
return current
def still_fails(features, op) -> bool:
try:
op(features)
return False
except Exception:
return True
For a non-noded intersection this normally lands on two parcels — the pair whose shared edge is not noded — and that alone usually explains the bug.
2. Bisect the vertices within the surviving features jump to heading
Two parcels with 400 vertices each is still too much to read. The same technique applies to the rings, with one extra condition: the shrunken ring must remain a valid closed polygon, or you are debugging a different bug.
from shapely.geometry import Polygon
def shrink_ring(polygon, op_on_single, min_vertices=4):
"""Drop vertices while the failure survives and the ring stays a closed polygon."""
coords = list(polygon.exterior.coords)[:-1] # drop the closing duplicate
i = 0
while len(coords) > min_vertices and i < len(coords):
trial = coords[:i] + coords[i + 1:]
candidate = Polygon(trial + [trial[0]])
# A shrink that changes WHY it fails is not a shrink. Requiring the same
# exception type keeps the bisection honest.
if same_failure(op_on_single, candidate, polygon):
coords = trial
else:
i += 1
return Polygon(coords + [coords[0]])
def same_failure(op, candidate, original) -> bool:
def kind(geom):
try:
op(geom)
return None
except Exception as exc:
return type(exc).__name__
return kind(candidate) is not None and kind(candidate) == kind(original)
3. Round the coordinates so a human can read them jump to heading
A minimal fixture with coordinates like 571043.2100001 is minimal and still unreadable. Translate to a round origin and snap to a grid, checking at each step that the failure survives.
from shapely import affinity
def humanise(geoms, op, grid=0.01):
"""Translate to the origin and snap, keeping the failure. Purely cosmetic, and
it is what turns a fixture into something reviewable."""
minx = min(g.bounds[0] for g in geoms)
miny = min(g.bounds[1] for g in geoms)
moved = [affinity.translate(g, -minx, -miny) for g in geoms]
if not still_fails(moved, op):
return geoms # translation changed it: keep original
snapped = [Polygon([(round(x / grid) * grid, round(y / grid) * grid)
for x, y in g.exterior.coords]) for g in moved]
return snapped if still_fails(snapped, op) else moved
Translation can change a floating-point failure, which is itself informative: a bug that disappears when the coordinates move is a precision bug rather than a topology bug, and the fix is a precision model rather than a geometry repair.
4. Emit the fixture as code, with the finding written down jump to heading
def emit_fixture(geoms, path, error, versions):
lines = [
'"""Minimal reproduction, shrunk from a 41 000-parcel county layer.',
"",
f"Failure: {error}",
f"GEOS {versions['geos']}, shapely {versions['shapely']}.",
"",
"Shrunk from 41 216 features / 812 vertices to "
f"{len(geoms)} features / {sum(len(g.exterior.coords) - 1 for g in geoms)} vertices.",
'"""',
"from shapely.geometry import Polygon", "",
"FEATURES = [",
]
for g in geoms:
pts = ", ".join(f"({x:g}, {y:g})" for x, y in list(g.exterior.coords)[:-1])
lines.append(f" Polygon([{pts}]),")
lines.append("]")
path.write_text("\n".join(lines) + "\n")
Verification & testing jump to heading
def test_the_shrunken_fixture_still_fails():
"""The fixture's whole purpose. If this ever passes, the bug is fixed and the
test below should be the one asserting the fix."""
with pytest.raises(GEOSException, match="non-noded"):
overlay(FEATURES)
def test_make_valid_repairs_it():
"""The fix, asserted against the same minimal input."""
repaired = [make_valid(g) for g in FEATURES]
result = overlay(repaired)
assert result.is_valid
assert abs(result.area - sum(g.area for g in FEATURES)) < 0.01
def test_shrinking_preserves_the_failure_kind():
big = load_fixture("county_extract_subset.gpkg")
small = shrink_features(big, overlay)
assert len(small) < len(big)
assert still_fails(small, overlay)
with pytest.raises(GEOSException, match="non-noded"):
overlay(small)
def test_shrinker_terminates_on_a_passing_input():
ok = [box(0, 0, 10, 10), box(20, 20, 30, 30)]
assert not is_reproducible(ok, overlay)
Keeping both of the first two tests is the point: one asserts the bug reproduces on the minimal input, the other asserts the repair works on it. Together they document the bug and the fix in a form the next person can run in a second.
Failure recovery jump to heading
The failure disappears during shrinking. Usually order dependence or a precision effect. Re-run with the feature sort pinned; if it still vanishes only after translation, you have found a precision bug and the fixture should record the original coordinates with a note, because the absolute position is part of the reproduction.
Shrinking runs for hours. The bisection is quadratic in the worst case, and the overlay is the expensive part. Cap it: run the feature-set bisection to a few hundred iterations, take whatever it has, and shrink rings only within the surviving features. A fixture of five parcels is already a hundredfold improvement on 41 000.
The minimal fixture is one parcel. Then it is not an interaction bug and the geometry is self-intersecting on its own. That is a cheaper bug: validate on ingest and the layer never contains it, which is a schema-validation fix rather than an overlay fix.
Frequently asked questions jump to heading
Why not just fix the geometry and move on?
Because the same county will publish the same shape next month. Without a fixture the bug returns and costs another eleven-minute run plus an afternoon of bisection. The fixture converts that recurring cost into a one-second test, and it also documents which pathology the pipeline is contracted to survive.
Should the shrunken fixture keep its original coordinates?
Translate to a round origin if the failure survives, because readability is what makes the fixture reviewable in a year. If translating makes the failure vanish, keep the original coordinates and note why: the absolute position is then part of the reproduction, which tells you the bug is about floating-point precision rather than topology.
How small is small enough?
Small enough to read the coordinates and see the problem — typically two or three features and under twenty vertices. Stop shrinking when the shape stops being informative rather than when the algorithm stops making progress; a four-vertex bowtie that a person can immediately recognise is a better fixture than a mechanically minimal three-vertex degenerate case.
Does this work for failures that are not exceptions?
Yes, with a different predicate. Replace “raises” with the property you care about — area not preserved, row count wrong, a hole lost — and the same bisection finds a minimal input exhibiting it. Silent wrong answers are actually the better use of the technique, because they are the ones nobody can otherwise localise.
Related jump to heading
- Parent topic: Testing Spatial Data Pipelines
- Section overview: Municipal Zoning Data Architecture & Compliance Frameworks
- Building golden-file tests for geometry transformations — where a shrunken fixture usually ends up living
- Schema Validation & Data Quality Checks — catching a self-intersecting polygon on ingest instead
- Spatial Overlay Analysis — the operation that raises most of these exceptions