A synthetic boundary set has three levels — region, district, ward — and a spatial join between them returns wards that straddle two districts, districts whose area does not equal the sum of their wards, and a handful of wards belonging to no district at all.
Part of Polygon Tessellation Algorithms: a single tessellation has to partition its window, and a hierarchy has to do that at every level and have the levels agree with each other. The second requirement is the one that is usually discovered late.
The natural implementation generates each level separately — tessellate the extent into regions, tessellate it again into districts, again into wards — and then hopes the levels align. They never do, and the reason is arithmetic rather than tolerance.
Three independent tessellations of the same window produce three independent sets of edges. A district boundary and a ward boundary that ought to be the same line are two different lines, computed from different seeds, agreeing nowhere except by accident. Snapping them together afterwards is the sliver problem at every level boundary simultaneously, and it does not converge: snapping wards to districts moves ward edges, which breaks the ward-level partition, which needs re-snapping.
The resolution is to invert the construction. Generate the finest level only, and derive every coarser level by aggregating it. Then a district is by definition the union of its wards, its boundary is by definition made of ward edges, and the containment relationships hold by construction rather than by check.
Three construction strategies against the four invariants a hierarchy must satisfy — only bottom-up aggregation satisfies all of them by construction rather than by repair.
defcontainment(child_gdf, parent_gdf, child_key:str, parent_key:str)->list[str]:"""Every child must lie inside exactly one parent."""
joined = child_gdf.sjoin(parent_gdf, predicate="within", how="left")
orphans = joined[joined[parent_key].isna()][child_key].tolist()
straddlers =(joined.groupby(child_key)[parent_key].nunique().loc[lambda s: s >1].index.tolist())return[f"orphan: {o}"for o in orphans]+[f"straddles: {s}"for s in straddlers]defarea_conservation(child_gdf, parent_gdf, parent_key:str, tol=1e-9)->list[str]:"""A parent's area must equal the sum of its children's."""
summed = child_gdf.groupby(parent_key).geometry.apply(lambda g: g.area.sum())
out =[]for key, parent_area in parent_gdf.set_index(parent_key).geometry.area.items():
got = summed.get(key,0.0)ifabs(got - parent_area)/max(parent_area,1e-12)> tol:
out.append(f"{key}: children sum {got:.4f} vs parent {parent_area:.4f}")return out
Both of these should return empty on a correctly constructed hierarchy, and both are cheap. Writing them before the generator is worth doing, because they are also the specification: a construction that cannot satisfy them is a construction to abandon rather than to debug.
The tolerance in area_conservation is deliberately near zero. On a bottom-up hierarchy the parent is literally the union of the children, so the areas agree to floating-point precision; a tolerance loose enough to accommodate a top-down construction is a tolerance that hides real gaps.
from shapely import set_precision
GRID =1e-7deffinest_level(seeds, envelope, grid:float= GRID):"""One tessellation, snapped to the precision grid, covering the envelope exactly."""
cells = voronoi_clipped(seeds, envelope)return[set_precision(c, grid)for c in cells]
Everything above this level is derived, so this is the only place the partition invariants have to be established — and it is the only place they can be violated.
from shapely.ops import unary_union
defaggregate(children, assignment:dict, key_of)->dict:"""Union children by a declared parent key.
The assignment is data — a lookup from child key to parent key — not a spatial
predicate. A spatial join here would re-introduce the ambiguity the whole
construction exists to avoid.
"""
groups:dict={}for child in children:
groups.setdefault(assignment[key_of(child)],[]).append(child)return{parent: unary_union(members)for parent, members in groups.items()}
The distinction in that docstring is the one that matters. Deriving the parent by unioning declared members gives a boundary made of exactly the children’s edges. Deriving it by a spatial join against an independently generated parent geometry gives back every problem the construction was meant to remove.
Children have to be grouped into contiguous, plausibly-shaped parents rather than arbitrary sets. Region-growing over the adjacency graph does this and is straightforward:
python
defgrow_regions(adjacency:dict, keys:list, n_parents:int, target_size:int, rng)->dict:"""Grow contiguous groups from seeds until each reaches its target size."""
seeds =[keys[rng.integers(len(keys))]for _ inrange(n_parents)]
assignment ={s:f"P{i:03d}"for i, s inenumerate(seeds)}
frontier ={f"P{i:03d}":set(adjacency[s])for i, s inenumerate(seeds)}
sizes ={f"P{i:03d}":1for i inrange(n_parents)}whileany(f for f in frontier.values()):for parent insorted(frontier):if sizes[parent]>= target_size ornot frontier[parent]:continue
candidates =sorted(k for k in frontier[parent]if k notin assignment)ifnot candidates:
frontier[parent]=set()continue
pick = candidates[rng.integers(len(candidates))]
assignment[pick]= parent
sizes[parent]+=1
frontier[parent]|=set(adjacency[pick])-set(assignment)# anything unassigned joins the neighbouring parent it shares the most edge withfor k in keys:if k notin assignment:
assignment[k]= dominant_neighbour_parent(k, adjacency, assignment)return assignment
The sorted calls are not cosmetic. Iterating a set of candidate cells in arbitrary order makes the assignment depend on hash ordering, which makes the whole hierarchy non-reproducible — the same defect as everywhere else in this area, arriving through a different door.
The construction in one direction: cells are grown into contiguous groups, groups are unioned into parents, and the parents' edges are by definition cell edges.
deftest_each_level_partitions_the_envelope(levels, envelope, tol=1e-9):for name, gdf in levels.items():
union = gdf.geometry.union_all()assert union.symmetric_difference(envelope).area < tol,f"{name} is not a partition"deftest_every_child_nests_in_one_parent(levels, hierarchy):for child, parent in hierarchy:
issues = containment(levels[child], levels[parent],f"{child}_id",f"{parent}_id")assertnot issues,f"{child} → {parent}: {issues[:5]}"deftest_areas_conserve_up_the_hierarchy(levels, hierarchy):for child, parent in hierarchy:
issues = area_conservation(levels[child], levels[parent],f"{parent}_id")assertnot issues, issues[:5]deftest_parents_are_contiguous(levels, adjacency, hierarchy):"""A region-grown parent should be one piece; a disjoint one is a bug in the growth."""for _, parent in hierarchy:
multi = levels[parent][levels[parent].geometry.geom_type =="MultiPolygon"]assert multi.empty,f"{parent} has {len(multi)} disjoint parents"
Child area falling outside its assigned parent as the hierarchy gets finer: independent generation drifts steadily upward, aggregation stays at exactly zero.
The contiguity test is the one that catches a defect the others do not. A parent assembled from cells that are not adjacent is geometrically valid, nests correctly and conserves area — and it is a district in two pieces on opposite sides of the region, which no administrative geography has and which any consumer will notice.
Genuinely disjoint administrative units. They exist — enclaves, offshore islands, historical anomalies. If the release models them, the contiguity assertion needs an allow-list rather than a relaxation, because relaxing it hides the accidental cases too.
Levels that do not nest in reality. Postal geographies and administrative geographies famously cross. If the release contains both, they are two separate hierarchies that share a finest level, not one hierarchy with five levels — and modelling them as the latter guarantees failures that are not defects.
Population-weighted targets. Administrative units are usually sized by population rather than by area, so the growth target should be a population sum drawn from the covariate rather than a cell count. That is a one-line change to the growth loop and it makes the output far more plausible.
The finest level is too fine to aggregate quickly. Region growing over a million cells is slow. Growing over a coarser intermediate and then refining is faster and equivalent, provided the intermediate is itself derived from the finest level rather than generated independently — which is the same rule again.
Because everything is derived from it, the finest level is the only real design decision in the
construction, and it is worth making deliberately rather than defaulting to whatever the first
consumer asked for.
It has to be at least as fine as the finest level anyone will publish. Aggregation only goes
one way. A hierarchy built on wards can produce districts and regions, and can never produce
anything below a ward — so if a consumer later needs output areas, the whole set has to be
regenerated from a finer base, and every previously published boundary changes with it.
It should be finer than that, but not by much. A base an order of magnitude below the finest
published level gives room to add a level later without regenerating, and costs only storage. A
base three orders below multiplies the region-growing cost for no benefit anyone will ever
observe, and makes the adjacency graph large enough that the growth pass becomes the slowest step
in the pipeline.
Its cell count sets the granularity of every parent’s size. A parent grown from twenty cells
can only ever have twenty possible sizes, so a population target expressed to three significant
figures cannot be hit. If the release is calibrated to population totals, the base needs enough
cells per parent that the growth can land close to a target rather than stepping past it.
The practical shape of this is that the base level is a published artifact in its own right, not
an implementation detail, and it deserves the same version identity as the levels above it — a
consumer who wants to build their own aggregation needs it, and a producer who wants to add a
level in a year’s time needs to know exactly which base the existing levels came from.