.
This commit is contained in:
parent
909e241907
commit
1ee796b282
29 changed files with 250 additions and 126 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue