This commit is contained in:
Andras Schmelczer 2026-07-03 18:28:56 +01:00
parent 909e241907
commit 1ee796b282
29 changed files with 250 additions and 126 deletions

View file

@ -1,8 +1,8 @@
"""Robust GEOS overlay helpers.
Overlay operations (union, difference, intersection) can raise a
``GEOSException`` most often ``TopologyException: side location conflict``,
``Ring edge missing``, or ``found non-noded intersection`` on geometries that
``GEOSException``, most often ``TopologyException: side location conflict``,
``Ring edge missing``, or ``found non-noded intersection``, on geometries that
contain near-coincident or near-degenerate edges, or that are individually
invalid. The robust remedy is a *fixed-precision* overlay: GEOS's OverlayNG
engine, handed a grid size, nodes every edge onto that grid and finishes where
@ -14,8 +14,8 @@ learned the hard way from a crash:
1. **Never precision-reduce with the default mode.** ``set_precision``'s default
``valid_output`` (and ``keep_collapsed``) mode runs its *own* noding pass that
re-raises the very ``side location conflict`` we are trying to escape. We push
the grid into the overlay via the ``grid_size`` argument instead where
OverlayNG nodes robustly and only ever call ``set_precision`` in
the grid into the overlay via the ``grid_size`` argument instead (where
OverlayNG nodes robustly) and only ever call ``set_precision`` in
``pointwise`` mode (pure coordinate rounding, which cannot raise).
2. **Validate first.** ``make_valid`` repairs the self-intersections (bow-ties,
pinches) that make GEOS choke, so the overlay starts from an OGC-valid shape.
@ -40,7 +40,7 @@ from shapely import GEOSException, make_valid, set_precision
from shapely.geometry import Polygon
from shapely.ops import unary_union
# 0.1 mm in metres well below MIN_GEOM_AREA (0.01 m^2) and survey resolution.
# 0.1 mm in metres: well below MIN_GEOM_AREA (0.01 m^2) and survey resolution.
_SNAP_GRID = 1e-4
_EMPTY = Polygon()

View file

@ -120,7 +120,7 @@ def _is_pointlike(geom_bng) -> bool:
def _rescue_footprint(geom_bng) -> dict | None:
"""Fatten a degenerate BNG geometry into a representable footprint and snap.
A POINTLIKE input (a point, or a near-zero-area/short-perimeter polygon the
A POINTLIKE input (a point, or a near-zero-area/short-perimeter polygon, the
signature of a tower-block postcode whose UPRNs all share one coordinate)
gets a building-scale buffer so it is not reduced to an invisible sub-metre
dot; thin slivers that still carry length keep the minimal buffer.
@ -263,7 +263,7 @@ def merge_fragments(
# Close tiny gaps between adjacent OA boundary edges (float mismatches).
# The closing can erode a tiny MultiPolygon (e.g. a postcode with only a
# sliver fragment) to nothing, which would leave the postcode with no
# geometry at all — keep the un-closed shape if that happens.
# geometry at all. Keep the un-closed shape if that happens.
if combined.geom_type == "MultiPolygon":
closed = combined.buffer(5.0).buffer(-5.0)
if not closed.is_valid:
@ -308,7 +308,7 @@ def _polygonal(geom):
return None
# Both callers run on WGS84-degree output geometry, so the robustness
# fallback snaps on the 1e-6° grid (~0.11 m), not geometry.py's metre
# default — a coarse metre grid would obliterate a degree-scale shape.
# default. A coarse metre grid would obliterate a degree-scale shape.
merged = safe_union(polys, grid=_OUTPUT_PRECISION_DEG)
return merged if not merged.is_empty else None
return None
@ -324,7 +324,7 @@ def _resolve_overlaps(
containment (a postcode fully enclosed by another). Each postcode is trimmed
by the union of its higher-priority overlapping neighbours, where **priority =
ascending area**: a smaller postcode wins contested ground. That single rule
handles both cases correctly an enclosed postcode is always smaller than its
handles both cases correctly: an enclosed postcode is always smaller than its
container, so it keeps its area while the container gets a hole (a `overlaps`
query alone would miss containment entirely). Run last, on the final output
geometries, so nothing re-introduces overlap afterwards. A postcode that would
@ -348,7 +348,7 @@ def _resolve_overlaps(
arr = np.array(geoms, dtype=object)
pairs: set[tuple[int, int]] = set()
# "overlaps" gives partial overlaps; "contains" gives containment (which
# "overlaps" excludes) — together they cover every 2-D overlap without the
# "overlaps" excludes). Together they cover every 2-D overlap without the
# edge-touch explosion a plain "intersects" query would add.
for predicate in ("overlaps", "contains"):
qsrc, qtgt = tree.query(arr, predicate=predicate)
@ -577,7 +577,7 @@ def _grid_footprint(geom):
pass can shave a small (e.g. co-located, non-geographic) postcode down to a
sub-grid sliver that disappears when snapped to output precision. Rather than
drop it, place a minimal valid footprint at its location. The tiny overlap
this re-creates with the neighbour that trimmed it is harmless the output
this re-creates with the neighbour that trimmed it is harmless: the output
partition is best-effort, a missing boundary is a hard validation failure.
"""
try:

View file

@ -11,7 +11,7 @@ from .voronoi import compute_voronoi_regions
MIN_GEOM_AREA = 0.01
# Minimal footprint (BNG metres) for a postcode whose UPRN seed wins no area in a
# crowded multi-postcode OA its Voronoi cell ∩ remaining collapses below
# crowded multi-postcode OA: its Voronoi cell ∩ remaining collapses below
# MIN_GEOM_AREA, or its seed sits inside an INSPIRE parcel wholly claimed by a
# co-located postcode. Every *active* postcode must keep a boundary
# (validate_outputs is zero-tolerance), so it gets a small disc at its true seed
@ -77,7 +77,7 @@ def process_oa(
fragments.append((pc, merged))
# Every postcode with a UPRN seed in this OA must keep at least a minimal
# footprint in a dense OA (a block of flats with hundreds of distinct
# footprint: in a dense OA (a block of flats with hundreds of distinct
# postcodes) a single-seed postcode's cell can collapse below MIN_GEOM_AREA or
# be fully absorbed by a co-located postcode's INSPIRE parcel, producing no
# fragment, and an active postcode must never be dropped.
@ -142,7 +142,7 @@ def _claim_inspire_parcels(
# UPRNs from a single postcode goes wholly to that postcode. A parcel shared
# by several postcodes (a block of flats spanning postcodes, or overlapping
# parcel data) is split between them via a sub-Voronoi over their own UPRNs
# clipped to the parcel so EVERY contained postcode keeps part of the
# clipped to the parcel, so EVERY contained postcode keeps part of the
# parcel. A bare majority vote would hand the whole parcel to one winner and
# leave the losers' UPRNs trapped inside claimed land, dropping them from
# both this claim and the `remaining` polygon handed to Voronoi downstream.
@ -312,7 +312,7 @@ def _extract_polygonal(geom) -> Polygon | MultiPolygon | None:
return polys[0]
# Union (not bare MultiPolygon construction): make_valid can emit
# overlapping polygonal parts, and a MultiPolygon of overlapping parts is
# invalid it double-counts area and makes the next `.difference()` raise
# invalid: it double-counts area and makes the next `.difference()` raise
# a TopologyException that aborts the OA (and, in parallel mode, the
# worker). safe_union merges them into a valid geometry.
merged = safe_union(polys)

View file

@ -82,7 +82,7 @@ def load_uprns(
# Remap terminated postcodes to their nearest active successor. The
# successor generally lives in a DIFFERENT OA (and at different grid
# coordinates), so the remapped point must adopt the successor's
# authoritative OA/coords — keeping the terminated postcode's original
# authoritative OA/coords. Keeping the terminated postcode's original
# OA would seed the successor into an OA it doesn't belong to, splitting
# its boundary across OAs. Genuine (non-remapped) UPRN rows keep their
# own OA, since a live postcode can legitimately span several OAs.
@ -127,7 +127,7 @@ def load_uprns(
uprns.sort("OA21CD").sink_parquet(tmp_path)
release_memory()
# Read the sorted data only one copy in memory (~2GB)
# Read the sorted data: only one copy in memory (~2GB)
df = pl.read_parquet(tmp_path)
tmp_path.unlink()
n = len(df)