vibes
This commit is contained in:
parent
39ef5c6646
commit
c995f12f8b
78 changed files with 4830 additions and 1619 deletions
|
|
@ -7,6 +7,7 @@ import numpy as np
|
|||
import polars as pl
|
||||
import pytest
|
||||
from shapely.geometry import MultiPolygon, Polygon, box
|
||||
from shapely.ops import unary_union
|
||||
|
||||
from .oa_boundaries import parse_gpkg_geometry
|
||||
from .greenspace import subtract_greenspace
|
||||
|
|
@ -215,6 +216,20 @@ class TestVoronoiCollinear:
|
|||
assert ratio > 0.3, f"Area split too unfair: {area_a:.0f} vs {area_b:.0f}"
|
||||
|
||||
|
||||
class TestVoronoiCoverage:
|
||||
"""Voronoi fallback should cover large OAs even when UPRNs are clustered."""
|
||||
|
||||
def test_clustered_points_cover_large_boundary(self):
|
||||
boundary = box(0, 0, 5000, 100)
|
||||
points = np.array([[10, 50], [20, 50]])
|
||||
result = compute_voronoi_regions(points, ["A", "B"], boundary)
|
||||
|
||||
covered = unary_union(list(result.values()))
|
||||
|
||||
assert covered.area == pytest.approx(boundary.area)
|
||||
assert boundary.difference(covered).area < 0.01
|
||||
|
||||
|
||||
class TestEqualSplitFallback:
|
||||
"""_equal_split_fallback must give every postcode some area."""
|
||||
|
||||
|
|
@ -306,6 +321,186 @@ class TestProcessOAGeometryTypes:
|
|||
)
|
||||
|
||||
|
||||
class TestProcessOAInspireParcelAssignment:
|
||||
"""INSPIRE parcels without UPRNs should still shape postcode boundaries."""
|
||||
|
||||
def test_unoccupied_inspire_parcel_goes_to_nearest_postcode(self):
|
||||
"""A parcel with no contained UPRN should not be split by Voronoi."""
|
||||
oa_geom = box(0, 0, 100, 100)
|
||||
parcel = box(20, 40, 65, 60) # crosses the x=50 Voronoi split
|
||||
points = np.array(
|
||||
[
|
||||
[10, 50], # postcode A
|
||||
[90, 50], # postcode B
|
||||
]
|
||||
)
|
||||
postcodes = ["A", "B"]
|
||||
|
||||
fragments = process_oa(oa_geom, points, postcodes, inspire_candidates=[parcel])
|
||||
frag_dict = dict(fragments)
|
||||
|
||||
assert "A" in frag_dict and "B" in frag_dict
|
||||
assert parcel.difference(frag_dict["A"]).area < 0.01
|
||||
assert frag_dict["B"].intersection(parcel).area < 0.01
|
||||
|
||||
def test_contained_uprn_claim_wins_over_overlapping_nearest_parcel(self):
|
||||
"""Contained-UPRN parcel claims should keep priority over nearest claims."""
|
||||
oa_geom = box(0, 0, 100, 100)
|
||||
contained_a = box(0, 0, 60, 100)
|
||||
unoccupied_nearer_b = box(50, 0, 80, 100)
|
||||
points = np.array(
|
||||
[
|
||||
[20, 50], # postcode A, inside contained_a
|
||||
[90, 50], # postcode B, outside unoccupied_nearer_b
|
||||
]
|
||||
)
|
||||
postcodes = ["A", "B"]
|
||||
|
||||
fragments = process_oa(
|
||||
oa_geom,
|
||||
points,
|
||||
postcodes,
|
||||
inspire_candidates=[contained_a, unoccupied_nearer_b],
|
||||
)
|
||||
frag_dict = dict(fragments)
|
||||
|
||||
assert "A" in frag_dict and "B" in frag_dict
|
||||
assert contained_a.difference(frag_dict["A"]).area < 0.01
|
||||
assert frag_dict["A"].intersection(frag_dict["B"]).area < 0.01
|
||||
assert frag_dict["B"].intersection(box(60, 0, 80, 100)).area > 0
|
||||
|
||||
def test_nearest_uses_assignable_fragment_after_contained_subtraction(self):
|
||||
"""Nearest assignment should use the part left after priority subtraction."""
|
||||
oa_geom = box(0, 0, 100, 100)
|
||||
contained_a = box(0, 0, 60, 100)
|
||||
unoccupied = box(25, 0, 80, 100)
|
||||
points = np.array(
|
||||
[
|
||||
[20, 50], # postcode A, inside contained_a
|
||||
[90, 50], # postcode B, nearest to unoccupied remainder
|
||||
]
|
||||
)
|
||||
postcodes = ["A", "B"]
|
||||
|
||||
fragments = process_oa(
|
||||
oa_geom,
|
||||
points,
|
||||
postcodes,
|
||||
inspire_candidates=[contained_a, unoccupied],
|
||||
)
|
||||
frag_dict = dict(fragments)
|
||||
|
||||
assert contained_a.difference(frag_dict["A"]).area < 0.01
|
||||
assert box(60, 0, 80, 100).difference(frag_dict["B"]).area < 0.01
|
||||
|
||||
def test_boundary_uprn_does_not_claim_adjacent_parcel(self):
|
||||
"""A UPRN on a parcel edge should not count inside both parcels."""
|
||||
oa_geom = box(0, 0, 100, 100)
|
||||
left = box(0, 0, 50, 100)
|
||||
right = box(50, 0, 100, 100)
|
||||
points = np.array(
|
||||
[
|
||||
[50, 50], # postcode A, exactly on shared parcel boundary
|
||||
[75, 50], # postcode B, strictly inside right parcel
|
||||
]
|
||||
)
|
||||
postcodes = ["A", "B"]
|
||||
|
||||
fragments = process_oa(oa_geom, points, postcodes, inspire_candidates=[left, right])
|
||||
frag_dict = dict(fragments)
|
||||
|
||||
assert "A" in frag_dict and "B" in frag_dict
|
||||
assert right.difference(frag_dict["B"]).area < 0.01
|
||||
|
||||
def test_disconnected_nearest_fragments_can_go_to_different_postcodes(self):
|
||||
"""A split unoccupied parcel should be assigned component by component."""
|
||||
oa_geom = box(0, 0, 100, 100)
|
||||
contained_b = box(40, 0, 60, 100)
|
||||
unoccupied = box(0, 40, 100, 60)
|
||||
points = np.array(
|
||||
[
|
||||
[10, 20], # postcode A, nearest to left split fragment
|
||||
[50, 20], # postcode B, inside contained_b but outside unoccupied
|
||||
[90, 20], # postcode C, nearest to right split fragment
|
||||
]
|
||||
)
|
||||
postcodes = ["A", "B", "C"]
|
||||
|
||||
fragments = process_oa(
|
||||
oa_geom,
|
||||
points,
|
||||
postcodes,
|
||||
inspire_candidates=[contained_b, unoccupied],
|
||||
)
|
||||
frag_dict = dict(fragments)
|
||||
|
||||
assert box(0, 40, 40, 60).difference(frag_dict["A"]).area < 0.01
|
||||
assert box(60, 40, 100, 60).difference(frag_dict["C"]).area < 0.01
|
||||
|
||||
def test_overlapping_nearest_parcels_do_not_overlap_in_output(self):
|
||||
"""Two unoccupied nearest-assigned parcels should be resolved cleanly."""
|
||||
oa_geom = box(0, 0, 100, 100)
|
||||
left = box(0, 0, 70, 100)
|
||||
right = box(30, 0, 100, 100)
|
||||
points = np.array(
|
||||
[
|
||||
[10, 50], # postcode A, nearest to left parcel
|
||||
[90, 50], # postcode B, nearest to right parcel
|
||||
]
|
||||
)
|
||||
postcodes = ["A", "B"]
|
||||
|
||||
fragments = process_oa(oa_geom, points, postcodes, inspire_candidates=[left, right])
|
||||
frag_dict = dict(fragments)
|
||||
|
||||
assert "A" in frag_dict and "B" in frag_dict
|
||||
assert frag_dict["A"].intersection(frag_dict["B"]).area < 0.01
|
||||
|
||||
def test_mixed_inspire_and_voronoi_covers_oa_without_overlap(self):
|
||||
"""Parcel claims plus Voronoi fallback should cover the whole OA."""
|
||||
oa_geom = box(0, 0, 100, 100)
|
||||
contained_a = box(0, 0, 30, 100)
|
||||
unoccupied = box(70, 0, 90, 100)
|
||||
points = np.array(
|
||||
[
|
||||
[10, 50],
|
||||
[90, 50],
|
||||
]
|
||||
)
|
||||
postcodes = ["A", "B"]
|
||||
|
||||
fragments = process_oa(
|
||||
oa_geom,
|
||||
points,
|
||||
postcodes,
|
||||
inspire_candidates=[contained_a, unoccupied],
|
||||
)
|
||||
geoms = [geom for _, geom in fragments]
|
||||
covered = unary_union(geoms)
|
||||
overlap = sum(geom.area for geom in geoms) - covered.area
|
||||
|
||||
assert covered.area == pytest.approx(oa_geom.area)
|
||||
assert oa_geom.difference(covered).area < 0.01
|
||||
assert overlap < 0.01
|
||||
|
||||
def test_inspire_parcel_straddling_oa_is_clipped(self):
|
||||
"""INSPIRE parcels crossing the OA boundary should not leak outside it."""
|
||||
oa_geom = box(0, 0, 100, 100)
|
||||
straddling = box(80, 0, 140, 100)
|
||||
points = np.array(
|
||||
[
|
||||
[10, 50],
|
||||
[90, 50],
|
||||
]
|
||||
)
|
||||
postcodes = ["A", "B"]
|
||||
|
||||
fragments = process_oa(oa_geom, points, postcodes, inspire_candidates=[straddling])
|
||||
|
||||
for _, geom in fragments:
|
||||
assert geom.difference(oa_geom).area < 0.01
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_polygonal helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue