vibes
This commit is contained in:
parent
39ef5c6646
commit
c995f12f8b
78 changed files with 4830 additions and 1619 deletions
|
|
@ -1,12 +1,15 @@
|
|||
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 .voronoi import compute_voronoi_regions
|
||||
|
||||
MIN_GEOM_AREA = 0.01
|
||||
|
||||
|
||||
def process_oa(
|
||||
oa_geom: Polygon | MultiPolygon,
|
||||
|
|
@ -19,76 +22,31 @@ def process_oa(
|
|||
if len(unique_pcs) == 1:
|
||||
return [(next(iter(unique_pcs)), oa_geom)]
|
||||
|
||||
# Try INSPIRE-based assignment
|
||||
claimed: dict[str, Polygon | MultiPolygon] = {}
|
||||
if len(points) == 0:
|
||||
return []
|
||||
|
||||
valid_oa = _clean_polygonal(oa_geom)
|
||||
if valid_oa is None:
|
||||
return []
|
||||
|
||||
if inspire_candidates:
|
||||
cand_tree = STRtree(inspire_candidates)
|
||||
|
||||
from shapely import points as shp_points
|
||||
|
||||
uprn_pts = shp_points(points)
|
||||
pt_idx, cand_idx = cand_tree.query(uprn_pts, predicate="intersects")
|
||||
|
||||
# Majority vote per candidate polygon
|
||||
cand_postcodes: dict[int, list[str]] = defaultdict(list)
|
||||
for pi, ci in zip(pt_idx, cand_idx):
|
||||
cand_postcodes[ci].append(postcodes[pi])
|
||||
|
||||
pc_inspire_polys: dict[str, list[Polygon]] = defaultdict(list)
|
||||
for ci, pc_list in cand_postcodes.items():
|
||||
winner = Counter(pc_list).most_common(1)[0][0]
|
||||
pc_inspire_polys[winner].append(inspire_candidates[ci])
|
||||
|
||||
for pc, polys in pc_inspire_polys.items():
|
||||
merged = unary_union(polys)
|
||||
if not merged.is_valid:
|
||||
merged = make_valid(merged)
|
||||
valid_oa = oa_geom if oa_geom.is_valid else make_valid(oa_geom)
|
||||
clipped = merged.intersection(valid_oa)
|
||||
if not clipped.is_empty:
|
||||
if not clipped.is_valid:
|
||||
clipped = make_valid(clipped)
|
||||
clipped = _extract_polygonal(clipped)
|
||||
if clipped is not None:
|
||||
claimed[pc] = clipped
|
||||
|
||||
# Resolve overlaps: INSPIRE parcels can overlap geographically, so two
|
||||
# postcodes may claim the same area. Give contested area to whichever
|
||||
# postcode claimed it first (most UPRNs → first in insertion order).
|
||||
if len(claimed) > 1:
|
||||
resolved: dict[str, Polygon | MultiPolygon] = {}
|
||||
used = None
|
||||
for pc, geom in claimed.items():
|
||||
if used is not None:
|
||||
if not geom.is_valid:
|
||||
geom = make_valid(geom)
|
||||
if not used.is_valid:
|
||||
used = make_valid(used)
|
||||
geom = geom.difference(used)
|
||||
if geom.is_empty:
|
||||
continue
|
||||
geom = _extract_polygonal(geom)
|
||||
if geom is None:
|
||||
continue
|
||||
resolved[pc] = geom
|
||||
used = geom if used is None else unary_union([used, geom])
|
||||
claimed = resolved
|
||||
claimed = _claim_inspire_parcels(valid_oa, points, postcodes, inspire_candidates)
|
||||
else:
|
||||
claimed = {}
|
||||
|
||||
# Compute remaining area
|
||||
if claimed:
|
||||
all_claimed = unary_union(list(claimed.values()))
|
||||
if not all_claimed.is_valid:
|
||||
all_claimed = make_valid(all_claimed)
|
||||
valid_oa = oa_geom if oa_geom.is_valid else make_valid(oa_geom)
|
||||
remaining = valid_oa.difference(all_claimed)
|
||||
if not remaining.is_valid:
|
||||
remaining = make_valid(remaining)
|
||||
all_claimed = _clean_polygonal(all_claimed)
|
||||
remaining = (
|
||||
valid_oa.difference(all_claimed) if all_claimed is not None else valid_oa
|
||||
)
|
||||
remaining = _clean_polygonal(remaining)
|
||||
else:
|
||||
remaining = oa_geom if oa_geom.is_valid else make_valid(oa_geom)
|
||||
remaining = valid_oa
|
||||
|
||||
# Distribute remaining area via Voronoi
|
||||
if not remaining.is_empty and remaining.area > 0.01:
|
||||
# Distribute non-parcel land via Voronoi
|
||||
if remaining is not None and not remaining.is_empty and remaining.area > MIN_GEOM_AREA:
|
||||
voronoi_result = compute_voronoi_regions(points, postcodes, remaining)
|
||||
else:
|
||||
voronoi_result = {}
|
||||
|
|
@ -102,17 +60,167 @@ def process_oa(
|
|||
|
||||
fragments = []
|
||||
for pc, parts in result.items():
|
||||
merged = unary_union(parts)
|
||||
if not merged.is_empty:
|
||||
if not merged.is_valid:
|
||||
merged = make_valid(merged)
|
||||
merged = _extract_polygonal(merged)
|
||||
if merged is not None:
|
||||
fragments.append((pc, merged))
|
||||
merged = _clean_polygonal(unary_union(parts))
|
||||
if merged is not None:
|
||||
fragments.append((pc, merged))
|
||||
|
||||
return fragments
|
||||
|
||||
|
||||
def _claim_inspire_parcels(
|
||||
valid_oa: Polygon | MultiPolygon,
|
||||
points: np.ndarray,
|
||||
postcodes: list[str],
|
||||
inspire_candidates: list[Polygon],
|
||||
) -> dict[str, Polygon | MultiPolygon]:
|
||||
"""Assign INSPIRE parcels to postcodes before Voronoi fills non-parcel land."""
|
||||
parcels = _prepare_inspire_parcels(valid_oa, inspire_candidates)
|
||||
if not parcels:
|
||||
return {}
|
||||
|
||||
cand_tree = STRtree(parcels)
|
||||
|
||||
from shapely import points as shp_points
|
||||
|
||||
uprn_pts = shp_points(points)
|
||||
pt_idx, cand_idx = cand_tree.query(uprn_pts, predicate="within")
|
||||
|
||||
# First priority: parcels that physically contain UPRNs. Majority vote
|
||||
# resolves blocks of flats or overlapping parcel data.
|
||||
cand_postcodes: dict[int, list[str]] = defaultdict(list)
|
||||
for pi, ci in zip(pt_idx, cand_idx):
|
||||
cand_postcodes[ci].append(postcodes[pi])
|
||||
|
||||
contained_parts: dict[str, list] = defaultdict(list)
|
||||
contained_scores: Counter[str] = Counter()
|
||||
for ci, pc_list in cand_postcodes.items():
|
||||
pc_counts = Counter(pc_list)
|
||||
winner, votes = pc_counts.most_common(1)[0]
|
||||
contained_parts[winner].append(parcels[ci])
|
||||
contained_scores[winner] += votes
|
||||
|
||||
contained_claimed = _merge_parts_by_postcode(contained_parts)
|
||||
contained_claims = sorted(
|
||||
contained_claimed.items(),
|
||||
key=lambda item: (-contained_scores[item[0]], -item[1].area, item[0]),
|
||||
)
|
||||
|
||||
# Second priority: remaining INSPIRE parcels with no contained UPRN. Assign
|
||||
# each to the nearest UPRN/postcode so parcel boundaries carry more of the
|
||||
# visible postcode shape; Voronoi is then limited to roads, parks, water, and
|
||||
# any other non-parcel gaps.
|
||||
points_f64 = points.astype(np.float64, copy=False)
|
||||
contained_union = _union_claims(contained_claims)
|
||||
nearest_tree = cKDTree(points_f64)
|
||||
nearest_parts: dict[str, list] = defaultdict(list)
|
||||
for i, parcel in enumerate(parcels):
|
||||
if i in cand_postcodes:
|
||||
continue
|
||||
|
||||
assignable = parcel
|
||||
if contained_union is not None:
|
||||
assignable = assignable.difference(contained_union)
|
||||
for part in _polygon_parts(assignable):
|
||||
part = _clean_polygonal(part)
|
||||
if part is None:
|
||||
continue
|
||||
pc = _nearest_postcode(part, nearest_tree, postcodes)
|
||||
nearest_parts[pc].append(part)
|
||||
|
||||
nearest_claimed = _merge_parts_by_postcode(nearest_parts)
|
||||
nearest_claims = sorted(
|
||||
nearest_claimed.items(),
|
||||
key=lambda item: (-item[1].area, item[0]),
|
||||
)
|
||||
|
||||
return _resolve_ordered_claims(contained_claims + nearest_claims)
|
||||
|
||||
|
||||
def _prepare_inspire_parcels(
|
||||
valid_oa: Polygon | MultiPolygon,
|
||||
inspire_candidates: list[Polygon],
|
||||
) -> list[Polygon | MultiPolygon]:
|
||||
parcels: list[Polygon | MultiPolygon] = []
|
||||
for candidate in inspire_candidates:
|
||||
geom = _clean_polygonal(candidate)
|
||||
if geom is None:
|
||||
continue
|
||||
if not geom.intersects(valid_oa):
|
||||
continue
|
||||
clipped = _clean_polygonal(geom.intersection(valid_oa))
|
||||
if clipped is not None:
|
||||
parcels.append(clipped)
|
||||
return parcels
|
||||
|
||||
|
||||
def _nearest_postcode(
|
||||
geom: Polygon | MultiPolygon,
|
||||
tree: cKDTree,
|
||||
postcodes: list[str],
|
||||
) -> str:
|
||||
point = geom.representative_point()
|
||||
_, idx = tree.query([point.x, point.y])
|
||||
return postcodes[idx]
|
||||
|
||||
|
||||
def _polygon_parts(geom) -> list[Polygon]:
|
||||
geom = _clean_polygonal(geom)
|
||||
if geom is None:
|
||||
return []
|
||||
if geom.geom_type == "Polygon":
|
||||
return [geom]
|
||||
return list(geom.geoms)
|
||||
|
||||
|
||||
def _merge_parts_by_postcode(
|
||||
parts_by_postcode: dict[str, list],
|
||||
) -> dict[str, Polygon | MultiPolygon]:
|
||||
merged: dict[str, Polygon | MultiPolygon] = {}
|
||||
for pc, parts in parts_by_postcode.items():
|
||||
geom = _clean_polygonal(unary_union(parts))
|
||||
if geom is not None:
|
||||
merged[pc] = geom
|
||||
return merged
|
||||
|
||||
|
||||
def _union_claims(
|
||||
claims: list[tuple[str, Polygon | MultiPolygon]],
|
||||
) -> Polygon | MultiPolygon | None:
|
||||
if not claims:
|
||||
return None
|
||||
return _clean_polygonal(unary_union([geom for _, geom in claims]))
|
||||
|
||||
|
||||
def _resolve_ordered_claims(
|
||||
claims: list[tuple[str, Polygon | MultiPolygon]],
|
||||
) -> dict[str, Polygon | MultiPolygon]:
|
||||
"""Resolve overlapping parcel claims in priority order."""
|
||||
resolved_parts: dict[str, list] = defaultdict(list)
|
||||
used = None
|
||||
for pc, geom in claims:
|
||||
geom = _clean_polygonal(geom)
|
||||
if geom is None:
|
||||
continue
|
||||
if used is not None:
|
||||
geom = _clean_polygonal(geom.difference(used))
|
||||
if geom is None:
|
||||
continue
|
||||
resolved_parts[pc].append(geom)
|
||||
used = _clean_polygonal(geom if used is None else unary_union([used, geom]))
|
||||
return _merge_parts_by_postcode(resolved_parts)
|
||||
|
||||
|
||||
def _clean_polygonal(geom) -> Polygon | MultiPolygon | None:
|
||||
if geom is None or geom.is_empty:
|
||||
return None
|
||||
if not geom.is_valid:
|
||||
geom = make_valid(geom)
|
||||
geom = _extract_polygonal(geom)
|
||||
if geom is None or geom.is_empty or geom.area <= MIN_GEOM_AREA:
|
||||
return None
|
||||
return geom
|
||||
|
||||
|
||||
def _extract_polygonal(geom) -> Polygon | MultiPolygon | None:
|
||||
"""Extract only Polygon/MultiPolygon parts from a geometry.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue