don't crash

This commit is contained in:
Andras Schmelczer 2026-06-04 20:40:42 +01:00
parent aab85fe32e
commit d6d20ccd37
13 changed files with 2630 additions and 3924 deletions

View file

@ -3,13 +3,23 @@ from collections import Counter, defaultdict
import numpy as np
from scipy.spatial import cKDTree
from shapely import STRtree, make_valid
from shapely.geometry import MultiPolygon, Polygon
from shapely.ops import unary_union
from shapely.geometry import MultiPoint, MultiPolygon, Point, Polygon
from .geometry import safe_difference, safe_intersection, safe_union
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
# 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
# location. ~28 m² clears MIN_GEOM_AREA and output snapping; the overlap it
# creates with the area's winner is resolved at the output stage, where the
# smaller postcode wins the contested ground (see output._resolve_overlaps).
_MIN_SEED_FOOTPRINT_M = 3.0
def process_oa(
oa_geom: Polygon | MultiPolygon,
@ -36,10 +46,12 @@ def process_oa(
# Compute remaining area
if claimed:
all_claimed = unary_union(list(claimed.values()))
all_claimed = safe_union(list(claimed.values()))
all_claimed = _clean_polygonal(all_claimed)
remaining = (
valid_oa.difference(all_claimed) if all_claimed is not None else valid_oa
safe_difference(valid_oa, all_claimed)
if all_claimed is not None
else valid_oa
)
remaining = _clean_polygonal(remaining)
else:
@ -60,13 +72,54 @@ def process_oa(
fragments = []
for pc, parts in result.items():
merged = _clean_polygonal(unary_union(parts))
merged = _clean_polygonal(safe_union(parts))
if merged is not None:
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
# 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.
orphans = unique_pcs - {pc for pc, _ in fragments}
if orphans:
fragments.extend(_seed_footprints(orphans, points, postcodes, valid_oa))
return fragments
def _seed_footprints(
orphans: set[str],
points: np.ndarray,
postcodes: list[str],
valid_oa: Polygon | MultiPolygon,
) -> list[tuple[str, Polygon | MultiPolygon]]:
"""Give each orphan postcode a minimal disc footprint at its UPRN seed(s).
Orphans are postcodes with a UPRN in this OA that nonetheless won no area in
the INSPIRE/Voronoi partition. Each keeps a small disc at its true location,
clipped to the OA; the overlap with the area's winner is resolved at output.
"""
by_pc: dict[str, list] = defaultdict(list)
for i, pc in enumerate(postcodes):
if pc in orphans:
by_pc[pc].append(points[i])
out: list[tuple[str, Polygon | MultiPolygon]] = []
for pc, pts in by_pc.items():
arr = np.asarray(pts, dtype=np.float64)
seed = Point(arr[0]) if len(arr) == 1 else MultiPoint(arr)
disc = seed.buffer(_MIN_SEED_FOOTPRINT_M)
clipped = _clean_polygonal(safe_intersection(disc, valid_oa))
if clipped is None:
# Seed on/near the OA edge: keep the unclipped disc so the postcode
# still gets a footprint at its location rather than no boundary.
clipped = _clean_polygonal(disc)
if clipped is not None:
out.append((pc, clipped))
return out
def _claim_inspire_parcels(
valid_oa: Polygon | MultiPolygon,
points: np.ndarray,
@ -141,7 +194,7 @@ def _claim_inspire_parcels(
assignable = parcel
if contained_union is not None:
assignable = assignable.difference(contained_union)
assignable = safe_difference(assignable, contained_union)
for part in _polygon_parts(assignable):
part = _clean_polygonal(part)
if part is None:
@ -169,7 +222,7 @@ def _prepare_inspire_parcels(
continue
if not geom.intersects(valid_oa):
continue
clipped = _clean_polygonal(geom.intersection(valid_oa))
clipped = _clean_polygonal(safe_intersection(geom, valid_oa))
if clipped is not None:
parcels.append(clipped)
return parcels
@ -199,7 +252,7 @@ def _merge_parts_by_postcode(
) -> dict[str, Polygon | MultiPolygon]:
merged: dict[str, Polygon | MultiPolygon] = {}
for pc, parts in parts_by_postcode.items():
geom = _clean_polygonal(unary_union(parts))
geom = _clean_polygonal(safe_union(parts))
if geom is not None:
merged[pc] = geom
return merged
@ -210,7 +263,7 @@ def _union_claims(
) -> Polygon | MultiPolygon | None:
if not claims:
return None
return _clean_polygonal(unary_union([geom for _, geom in claims]))
return _clean_polygonal(safe_union([geom for _, geom in claims]))
def _resolve_ordered_claims(
@ -224,11 +277,11 @@ def _resolve_ordered_claims(
if geom is None:
continue
if used is not None:
geom = _clean_polygonal(geom.difference(used))
geom = _clean_polygonal(safe_difference(geom, used))
if geom is None:
continue
resolved_parts[pc].append(geom)
used = _clean_polygonal(geom if used is None else unary_union([used, geom]))
used = _clean_polygonal(geom if used is None else safe_union([used, geom]))
return _merge_parts_by_postcode(resolved_parts)
@ -261,7 +314,7 @@ def _extract_polygonal(geom) -> Polygon | MultiPolygon | None:
# overlapping polygonal parts, and a MultiPolygon of overlapping parts is
# invalid — it double-counts area and makes the next `.difference()` raise
# a TopologyException that aborts the OA (and, in parallel mode, the
# worker). unary_union merges them into a valid geometry.
merged = unary_union(polys)
# worker). safe_union merges them into a valid geometry.
merged = safe_union(polys)
return merged if not merged.is_empty else None
return None