Topology Rule Enforcement Jump to heading
A parcel layer is not a bag of polygons. It is a claim that a piece of the world has been partitioned exactly once — every square metre belongs to precisely one parcel, neighbouring parcels share their boundary rather than approximating it, and no strip of land is either owned twice or owned by nobody. Those are topological properties, and none of them can be observed by looking at a single feature. They emerge only from the relationships between features, which is why this gate is structurally different from every other one in Spatial Data Quality Validation & Geometry Integrity: its unit of evaluation is a pair, and its cost grows with the square of the layer unless the implementation is careful.
This page owns rules that hold between features of one or more layers: must-not-overlap, must-not-have-gaps, boundaries-must-be-coincident, must-be-covered-by, and must-not-have-dangles. It assumes every geometry reaching it has already cleared the geometry validity gate, because a spatial predicate evaluated against an invalid polygon returns an answer about an undefined interior. It stops short of fixing the breaches it finds: automatic geometry edits that move shared boundaries are the subject of snapping shared boundaries without moving survey corners, and this stage’s default is to detect, classify and route rather than to edit.
Declarative Configuration Manifest Jump to heading
Topology rules read almost like policy documents, which is precisely why they belong in a manifest rather than in code. A rule names the layers it relates, the predicate that must hold, and the tolerance below which a breach is noise rather than a fact.
# topology_rules.yaml — shapely >=2.0, pyyaml >=6.0
ruleset_version: "1.4.0" # MANDATORY
crs: "EPSG:25832" # MANDATORY: projected; all tolerances are in its units
tiling:
size: 2000 # OPTIONAL: tile edge in CRS units; default 2000
overlap: 25 # OPTIONAL: halo so cross-tile pairs are still seen
rules:
- name: parcels_must_not_overlap
kind: must_not_overlap # MANDATORY: rule vocabulary, not free text
layer: parcels # MANDATORY
area_tolerance: 0.05 # OPTIONAL: m2 below which an overlap is a sliver
shape_index_max: 0.02 # OPTIONAL: thinness test; below this it is a sliver
on_breach: reconcile # MANDATORY: reconcile | quarantine | fail_dataset
- name: parcels_must_not_have_gaps
kind: must_not_have_gaps
layer: parcels
area_tolerance: 0.05
boundary_layer: municipality_boundary # MANDATORY for gap rules: the outer limit
on_breach: reconcile
- name: parcels_share_boundaries_with_blocks
kind: boundaries_coincident
layer: parcels
other_layer: blocks
vertex_tolerance: 0.01 # metres; 1 cm
on_breach: quarantine
- name: buildings_within_parcels
kind: must_be_covered_by
layer: buildings
other_layer: parcels
allowance: 0.0 # no building may extend beyond its parcel
on_breach: quarantine
- name: road_centrelines_no_dangles
kind: must_not_have_dangles
layer: road_centrelines
node_tolerance: 0.05 # metres
on_breach: reconcile
| Field | Required | Meaning |
|---|---|---|
ruleset_version |
Mandatory | Stamped on every breach record; a rule change is a versioned event |
crs |
Mandatory | The evaluation CRS; the engine refuses input in any other |
tiling.size |
Optional | Tile edge length; controls memory, not correctness |
tiling.overlap |
Optional | Halo width; must exceed the largest tolerance or cross-tile pairs are missed |
kind |
Mandatory | Drawn from a closed vocabulary; an unknown kind fails the manifest load |
layer / other_layer |
Mandatory | Named datasets, resolved by the reader stage, never file paths |
area_tolerance |
Optional | Area in CRS units below which a breach is classified as a sliver |
shape_index_max |
Optional | Thinness measure; a long thin overlap is a digitizing artefact, a blocky one is a dispute |
on_breach |
Mandatory | reconcile queues for review, quarantine stops the features, fail_dataset stops the run |
The pairing of area_tolerance with shape_index_max is the part that earns its keep. Area alone is a poor classifier: a 0.04 m² overlap that is 40 m long and 1 mm wide is a digitizing artefact along a shared boundary, while a 0.04 m² overlap that is 20 cm square is a genuine, if tiny, conflicting claim. Using the shape index — the ratio of area to the area of a circle with the same perimeter — separates the two without a human looking at a map.
Preprocessing Requirements Jump to heading
Topology evaluation is the stage most often defeated by data shape rather than by data content, and the preparation below is what keeps it tractable.
One CRS, projected, for every layer in a rule. Comparing layers in different projections produces breaches that are artefacts of the comparison. The engine should load each layer through the multi-CRS harmonization path and refuse to evaluate a rule whose layers disagree.
A spatial index per layer, built once. The pairwise question — which features could possibly interact — must be answered by an index, not by iteration. Shapely 2’s STRtree over the layer’s geometries turns an O(n²) scan into an O(n log n) candidate query, and for a million-parcel county that is the difference between minutes and days.
Tiles with a halo wider than the largest tolerance. Memory, not CPU, is what stops a national coverage from being evaluated in one pass. Tiling solves it, but a naive tiling introduces false gaps at every tile edge. The halo — features from neighbouring tiles included in the evaluation but not reported from this tile — must be wider than any tolerance in the manifest, or a breach that straddles an edge is reported twice or not at all.
Deduplicated candidate pairs. With a halo, the pair (A, B) will be seen in more than one tile. Order the pair by feature identifier and hash it, so each pair is evaluated once and the breach count is a count of facts rather than a count of encounters.
Execution Engine & Precision Guards Jump to heading
# quality/topology.py — shapely >=2.0, geopandas >=0.14 — Python 3.10+
import logging
import math
from dataclasses import dataclass
import geopandas as gpd
from shapely import STRtree
from shapely.geometry.base import BaseGeometry
logger = logging.getLogger("quality.topology")
@dataclass(frozen=True)
class Breach:
rule: str
kind: str
left_id: str
right_id: str | None
measure: float # area in CRS units for overlaps and gaps
classification: str # sliver | overlap | gap | dangle
action: str # reconcile | quarantine | fail_dataset
def shape_index(geom: BaseGeometry) -> float:
"""Polsby-Popper compactness: 1.0 is a circle, ~0 is a hair-thin strip."""
perimeter = geom.length
if perimeter <= 0:
return 0.0
return (4.0 * math.pi * geom.area) / (perimeter * perimeter)
def evaluate_must_not_overlap(layer: gpd.GeoDataFrame, rule: dict) -> list[Breach]:
"""Indexed pairwise overlap test with sliver classification."""
if layer.crs is None or layer.crs.is_geographic:
raise ValueError(f"{rule['name']}: requires a projected CRS, got {layer.crs}")
geoms = layer.geometry.values
ids = layer["feature_id"].tolist()
tree = STRtree(geoms)
seen: set[tuple[str, str]] = set()
breaches: list[Breach] = []
# query the whole layer against itself: returns (input_index, tree_index) pairs
left_idx, right_idx = tree.query(geoms, predicate="intersects")
for i, j in zip(left_idx.tolist(), right_idx.tolist()):
if i == j:
continue
key = (ids[i], ids[j]) if ids[i] < ids[j] else (ids[j], ids[i])
if key in seen:
continue
seen.add(key)
shared = geoms[i].intersection(geoms[j])
if shared.is_empty or shared.area <= 0.0:
continue # a shared edge is contact, not overlap
thin = shape_index(shared) < rule.get("shape_index_max", 0.02)
small = shared.area <= rule.get("area_tolerance", 0.05)
classification = "sliver" if (thin and small) else "overlap"
action = "reconcile" if classification == "sliver" else rule["on_breach"]
logger.info("overlap rule=%s pair=%s area=%.6f class=%s",
rule["name"], key, shared.area, classification)
breaches.append(Breach(rule["name"], "must_not_overlap", key[0], key[1],
shared.area, classification, action))
return breaches
Three precision guards are doing quiet work here. Contact is not overlap: two parcels sharing a boundary intersect, but their intersection is a line of zero area, so the area test — not the predicate — decides. Pairs are keyed by sorted identifier, which makes the breach set identical regardless of the order features were read in, and therefore diffable between runs. A geographic CRS is a hard error, because every tolerance in the manifest is meaningless in degrees; the check costs nothing and prevents a class of results that look plausible and are nonsense.
Gap detection uses the complementary operation: union the layer, subtract it from the authoritative boundary layer, and explode the remainder into parts. Each part is a gap, classified by the same area-and-thinness pair of tests. The reason a boundary_layer is mandatory for gap rules is that without an outer limit, the “gap” outside the outermost parcel is the rest of the planet.
Failure Modes & Fallback Routing Jump to heading
| Failure | Typical cause | Deterministic action |
|---|---|---|
TOPO_SLIVER |
The same boundary digitized twice from different sources | Reconciliation queue; both features remain publishable while the strip is adjudicated |
TOPO_OVERLAP |
Genuine conflicting claims, or a parcel split that was never applied to its neighbour | Quarantine both features; publishing either alone would assert a boundary nobody agreed |
TOPO_GAP |
A parcel retired without its neighbours being extended; a coverage assembled from mismatched vintages | Reconciliation queue with the gap polygon retained as evidence |
TOPO_COVERAGE_OPEN |
Total gap area above the dataset budget — the coverage is not a partition | Fail the dataset; a partial partition is not a publishable statement |
TOPO_NOT_COINCIDENT |
Block boundaries redrawn without the parcel layer being updated | Quarantine the parcel side; the authoritative layer is declared in the rule, not inferred |
TOPO_DANGLE |
A road centreline that stops 4 cm short of the junction | Reconciliation queue; a dangle below the node tolerance is snapped only by the explicit snapping procedure |
TOPO_CRS_MISMATCH |
Two layers in a rule loaded from sources with different projections | Fail the run immediately; every breach the rule would produce is an artefact |
The distinction between reconcile and quarantine is a statement about who can decide. A sliver has an obvious correct answer — one of the two boundaries is the survey, the other is a trace of it — so it queues for a data steward with the evidence attached. A genuine overlap has no correct answer available inside the pipeline, so the features stop until someone with authority over the source resolves it.
Compliance Reporting Output Jump to heading
Topology breaches are dataset-level evidence, so their report is keyed by rule rather than by feature. Each row records what was evaluated, not merely what failed — a rule that produced zero breaches is a positive result worth publishing, and a rule that did not run at all must be distinguishable from one that found nothing.
# quality/topology_report.py — pyarrow >=14 — Python 3.10+
import pyarrow as pa
TOPOLOGY_REPORT_SCHEMA = pa.schema([
("dataset_id", pa.string()),
("run_id", pa.string()),
("ruleset_version", pa.string()),
("rule_name", pa.string()),
("kind", pa.string()), # must_not_overlap, must_not_have_gaps, ...
("pairs_evaluated", pa.int64()), # candidate pairs after index + dedup
("breaches", pa.int64()),
("slivers", pa.int64()), # breaches classified below the thinness bar
("breach_area_sum", pa.float64()), # CRS units; the coverage-closure measure
("result", pa.string()), # pass | fail | not_evaluated
("evidence_uri", pa.string()), # partition holding the breach geometries
])
breach_area_sum is the number that answers the coverage-closure question directly: if the total gap area across a partitioned coverage exceeds the dataset budget, the layer is not a partition, however few individual gaps there are. Storing the breach geometries under evidence_uri is what makes reconciliation possible weeks later — a steward opens the gap polygons in a desktop client rather than re-running the pipeline to reproduce them. These rows join the lineage manifest on run_id and feed the ISO 19115 lineage statement as a topological-consistency element.
CI Integration Jump to heading
Topology tests in CI must run on a fixture coverage small enough to be fast and pathological enough to be meaningful. A nine-parcel grid with one deliberate 0.03 m² sliver, one 20 cm overlap and one gap is enough to exercise every branch.
# tests/test_topology.py — pytest >=7, geopandas >=0.14
import geopandas as gpd
import pytest
from quality.topology import evaluate_must_not_overlap
from quality.manifest import load_topology_rules
RULES = load_topology_rules("topology_rules.yaml")
OVERLAP_RULE = RULES["parcels_must_not_overlap"]
@pytest.fixture(scope="module")
def fixture_parcels() -> gpd.GeoDataFrame:
return gpd.read_file("tests/fixtures/parcels_with_known_defects.gpkg")
def test_sliver_is_classified_not_quarantined(fixture_parcels):
breaches = evaluate_must_not_overlap(fixture_parcels, OVERLAP_RULE)
slivers = [b for b in breaches if b.classification == "sliver"]
assert len(slivers) == 1
assert slivers[0].action == "reconcile"
def test_genuine_overlap_is_quarantined(fixture_parcels):
breaches = evaluate_must_not_overlap(fixture_parcels, OVERLAP_RULE)
real = [b for b in breaches if b.classification == "overlap"]
assert len(real) == 1
assert real[0].action == "quarantine"
def test_geographic_crs_is_refused(fixture_parcels):
# Negative control: tolerances in metres against degrees must never run.
with pytest.raises(ValueError):
evaluate_must_not_overlap(fixture_parcels.to_crs("EPSG:4326"), OVERLAP_RULE)
The third test is the one that stays valuable longest. Every team eventually adds a layer that arrives in EPSG:4326, and the failure it prevents is not a crash but a silent flood of false breaches at 1e-7 degree tolerances, which teaches everyone to ignore the topology report.
Deeper Implementation Walkthroughs Jump to heading
The two procedures that carry the most operational risk have their own pages. Detecting gaps and overlaps in parcel coverages covers the union-and-subtract implementation, tiling at county scale, and how to report a gap that spans four tiles exactly once. Snapping shared boundaries without moving survey corners handles the repair side, where the constraint is legal rather than geometric: a monumented corner is evidence, and a snapping tolerance that moves it has damaged the record even when the resulting coverage is topologically perfect.
Frequently Asked Questions Jump to heading
Why not let PostGIS do this with a topology schema? PostGIS Topology is an excellent structure for maintaining a coverage that is edited in place, because it stores the shared edges once and every face references them. It is a poor fit for a validation gate over data you receive from elsewhere, where the input is a bag of independent polygons and the question is whether it could be a coverage. Use the gate to decide whether a delivery is publishable; use a topology schema when you own the editing lifecycle.
How large can a coverage get before tiling is mandatory? The practical limit is memory for the geometry array plus the index, and on commodity hardware that is comfortably a few million simple parcels. The more useful trigger is not size but shape: tiling becomes mandatory as soon as a single run cannot be re-run cheaply, because the value of the gate depends on people being willing to run it after every change.
Should slivers be closed automatically? Only where a declared authority exists. If the manifest states that the block layer is authoritative over the parcel layer, snapping parcel boundaries to block edges within a stated tolerance is a defensible, reproducible repair. Without such a declaration, closing a sliver means choosing which of two surveys is wrong, and the pipeline has no basis for that choice — so it queues the evidence instead.
What tolerance should a coverage-closure budget use? Express it as a fraction of total coverage area rather than an absolute, and set it from the digitizing accuracy of the source: a coverage assembled from 1:1000 mapping cannot be expected to close better than a few centimetres per boundary. Publish the number in the quality report so that consumers can judge the layer against it rather than against an assumption.
Related Jump to heading
- Spatial Data Quality Validation & Geometry Integrity — the parent section and the gate ordering that puts topology after validity
- Geometry Validity & Repair Rules — the prerequisite gate; predicates on invalid geometry are undefined
- Detecting Gaps and Overlaps in Parcel Coverages — the union-and-subtract procedure at county scale
- Snapping Shared Boundaries Without Moving Survey Corners — constrained repair with protected vertices
- Multi-CRS Dataset Harmonization — how layers arrive in one projected CRS before a rule can mean anything
- Merging UTM Zone Boundary Datasets Without Slivers — the projection-driven cause of the sliver class