When a geoprocessing chain runs buffer, then clip, then dissolve, a single invalid geometry produced in step one silently poisons every step after it — and the only symptom is a wrong answer with no error. This guide adds assertions between steps — geometry validity, CRS agreement, and non-empty, plausibly-sized results — so a broken intermediate halts the chain at its source instead of propagating. It is a companion technique within LLM-assisted geoprocessing pipelines.
The failure this prevents is error propagation. A dissolve over a self-intersecting buffer output may not throw; it may return a subtly wrong multipolygon that the next stage happily consumes. By the time a human notices, the provenance is lost. A between-step gate converts a late, mysterious wrong answer into an early, precise failure with the exact step, input, and reason attached.
When to Use This Approach
Add inter-step validation to any chain of two or more geoprocessing operations, especially when the steps or their arguments came from a model rather than a fixed script. The check is cheap relative to the operations it guards, and the diagnostic value is highest exactly where a model can plan a plausible-but-wrong sequence.
| Check point | Catches | Cost |
|---|---|---|
| Input only | Bad user/model input | Cheapest, misses mid-chain corruption |
| Between every step (this page) | Propagated invalidity, CRS drift, empty results | Low, high diagnostic value |
| Final output only | Nothing about where it broke | Cheap, poor provenance |
Validate between every step when correctness matters more than a few milliseconds per stage. If a chain is short and every op is known-safe, an input-plus-output check may suffice. The between-step gate pairs naturally with a typed plan from decomposing natural language into geoprocessing steps, where each step is already named and its expected output known. For CRS handling specifically, lean on coordinate reference system normalization.
Implementation
The validator below runs after each step. It confirms the result is a non-empty GeoDataFrame, every geometry is valid, the CRS matches the declared working CRS, and the total area sits within an order-of-magnitude sanity band relative to the previous step. Any violation halts the chain and returns a deterministic fallback carrying the last known-good state.
import logging
from dataclasses import dataclass
from typing import Callable, Optional
import geopandas as gpd
log = logging.getLogger("interstep_validator")
class StepViolation(Exception):
def __init__(self, step: str, reason: str):
super().__init__(f"[{step}] {reason}")
self.step, self.reason = step, reason
@dataclass
class GateResult:
ok: bool
gdf: Optional[gpd.GeoDataFrame]
halted_at: Optional[str] = None
reason: Optional[str] = None
def check_step(
gdf: gpd.GeoDataFrame,
step_name: str,
working_crs: str,
prev_area: Optional[float],
area_ratio_limit: float = 100.0,
) -> float:
"""Assert one intermediate output; return its total area for the next comparison."""
if gdf is None or len(gdf) == 0:
raise StepViolation(step_name, "empty result set")
if gdf.crs is None:
raise StepViolation(step_name, "missing CRS")
if gdf.crs.to_string() != working_crs:
raise StepViolation(step_name, f"CRS {gdf.crs.to_string()} != {working_crs}")
invalid = gdf[~gdf.geometry.is_valid]
if len(invalid) > 0:
raise StepViolation(step_name, f"{len(invalid)} invalid geometries")
if gdf.geometry.is_empty.any():
raise StepViolation(step_name, "contains empty geometries")
area = float(gdf.geometry.area.sum())
if prev_area is not None and prev_area > 0:
ratio = max(area, 1e-9) / prev_area
if ratio > area_ratio_limit or ratio < 1.0 / area_ratio_limit:
raise StepViolation(
step_name, f"area changed {ratio:.1f}x vs prior step (limit {area_ratio_limit}x)"
)
return area
def run_validated_chain(
seed: gpd.GeoDataFrame,
steps: list[tuple[str, Callable[[gpd.GeoDataFrame], gpd.GeoDataFrame]]],
working_crs: str = "EPSG:3857",
) -> GateResult:
"""Execute steps in order, gating each output; halt to last-good state on violation."""
current = seed
last_good = seed
prev_area: Optional[float] = None
try:
prev_area = check_step(current, "seed", working_crs, None)
except StepViolation as v:
log.error("seed failed validation: %s", v)
return GateResult(ok=False, gdf=None, halted_at="seed", reason=v.reason)
for name, fn in steps:
try:
current = fn(last_good)
prev_area = check_step(current, name, working_crs, prev_area)
last_good = current
except StepViolation as v:
log.error("halting chain: %s", v)
# Deterministic fallback: return the last validated state, not the corrupt one.
return GateResult(ok=False, gdf=last_good, halted_at=v.step, reason=v.reason)
except Exception as exc: # operation itself threw
log.exception("step '%s' raised", name)
return GateResult(ok=False, gdf=last_good, halted_at=name, reason=str(exc))
return GateResult(ok=True, gdf=last_good)
The fallback returns last_good — the most recent validated GeoDataFrame — so a caller always receives a coherent geometry set plus the exact step and reason that stopped the chain. That payload is what an orchestrator feeds back for a re-plan or surfaces to an operator.
Validation & Testing
- Invalid geometry halts immediately. Insert a step that returns a bowtie (self-intersecting) polygon and assert
run_validated_chainreturnsok=Falsewithhalted_atequal to that step’s name. - CRS drift is caught. Have a step reproject to
EPSG:4326whileworking_crsisEPSG:3857; assert the gate halts with a CRS reason and thatgdfequals the pre-driftlast_good. - Area sanity band. Feed a step whose output area is 1000x the prior step and assert it halts on the ratio limit, while a 2x change passes — proving the band flags implausible blow-ups without over-triggering.
Gotchas & Edge Cases
- Legitimately empty results. Some queries correctly return nothing (no schools in the flood zone). Treating every empty set as a violation causes false halts; let the caller declare per-step whether empty is a valid terminal state.
- Area check across mixed geometry types. Point and line layers have zero area, so the ratio band is meaningless for them. Gate the area comparison on polygonal geometry types and fall back to feature-count sanity for others.
- Validity in a geographic CRS.
is_validandareacomputed inEPSG:4326are misleading — area is in square degrees and validity ignores the sphere. Run these checks in the projectedworking_crs, consistent with normalizing mixed CRS data before LLM ingestion. - Repairing instead of halting. Silently
make_valid-ing every intermediate hides the upstream bug that produced the invalidity. Halt and report first; repair only as an explicit, logged policy the caller opts into.
Operating This Step Over Time
Checks accumulate and slow the pipeline down, one reasonable addition at a time. The way to keep that in hand is to measure what each check costs against how often it has ever fired: a check that has never fired in a year and costs a tenth of the step it guards is a candidate for removal or for sampling rather than a permanent tax.
Thresholds are the other maintenance. A count-plausibility band set against last year’s data will fire constantly once the data has grown, and the response is usually to widen it until it stops firing — which removes the check without removing its cost. Deriving the band from the input count rather than from an absolute makes it move with the data.
Record which check caught each failure, not just that a failure occurred. Over a few months that turns into a ranking of which checks earn their place, which is the only real evidence for what to keep.
Frequently Asked Questions
Should a failed check stop the pipeline?
It should stop that branch and report which check failed on which step. Continuing past a failed validity check means every later step operates on geometry known to be broken, and the eventual error will name the last step rather than the first. Where a plan has independent branches, the others can continue — the point is not to discard work, it is not to build on a known-bad result.
Are empty results always failures?
No, and that is exactly why they need to be flagged rather than raised. "No hospitals within two kilometres" is a real and useful answer; an empty result caused by a projection mismatch looks identical. Recording the emptiness with the step that produced it lets the difference be worked out, whereas silently passing it along guarantees it will not be.
How expensive should the checks be?
Bounded by the step they guard — a check that costs a meaningful fraction of the operation is worth sampling rather than running on every feature. Count, emptiness and reference-system checks are effectively free. Full validity checking on a large result is not, and a sample of a few hundred features catches systematic breakage while missing only the isolated case.
Should the model see the check results?
It should see the classified outcome, not the raw check output. Telling a model that step three produced an empty result lets it consider whether the threshold was wrong; handing it a validation report invites it to reason about details it cannot verify. The summary is what changes the next decision.
What should a check record when it passes?
The values it measured, not the fact that it passed. A row that says the step went from twelve thousand features to eleven thousand nine hundred is useful weeks later when someone asks why an answer changed; a row that says the check passed is useful to nobody. The measurements are also what allow thresholds to be tuned from evidence rather than from the last complaint.