don't crash
This commit is contained in:
parent
aab85fe32e
commit
d6d20ccd37
13 changed files with 2630 additions and 3924 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue