This commit is contained in:
Andras Schmelczer 2026-02-10 22:21:15 +00:00
parent 1f68ca0512
commit 3599803589
43 changed files with 3578 additions and 262 deletions

View file

@ -9,7 +9,8 @@ import pytest
from shapely.geometry import MultiPolygon, Polygon, box
from .oa_boundaries import parse_gpkg_geometry
from .output import merge_fragments, to_wgs84_geojson
from .greenspace import subtract_greenspace
from .output import _fill_holes, merge_fragments, to_wgs84_geojson
from .process_oa import _extract_polygonal, process_oa
from .uprn import get_oa_uprns, load_uprns
from .voronoi import _equal_split_fallback, compute_voronoi_regions
@ -426,3 +427,143 @@ class TestParseGpkgGeometry:
blob = bytes([0x47, 0x50, 0x00, 0b00001010]) + b"\x00" * 100
with pytest.raises(ValueError, match="Unknown GeoPackage envelope type 5"):
parse_gpkg_geometry(blob)
# ---------------------------------------------------------------------------
# _fill_holes removes interior rings
# ---------------------------------------------------------------------------
class TestFillHoles:
"""_fill_holes must remove all interior holes from polygons."""
def test_polygon_with_hole(self):
"""A polygon with an interior ring should become a solid polygon."""
outer = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)]
hole = [(30, 30), (70, 30), (70, 70), (30, 70), (30, 30)]
poly_with_hole = Polygon(outer, [hole])
assert len(list(poly_with_hole.interiors)) == 1
result = _fill_holes(poly_with_hole)
assert result.geom_type == "Polygon"
assert len(list(result.interiors)) == 0
assert result.area == pytest.approx(Polygon(outer).area)
def test_multipolygon_with_holes(self):
"""A MultiPolygon where each part has holes should have all holes removed."""
outer1 = [(0, 0), (50, 0), (50, 50), (0, 50), (0, 0)]
hole1 = [(10, 10), (20, 10), (20, 20), (10, 20), (10, 10)]
outer2 = [(60, 60), (110, 60), (110, 110), (60, 110), (60, 60)]
hole2 = [(70, 70), (80, 70), (80, 80), (70, 80), (70, 70)]
mp = MultiPolygon(
[Polygon(outer1, [hole1]), Polygon(outer2, [hole2])]
)
result = _fill_holes(mp)
assert result.geom_type == "MultiPolygon"
for p in result.geoms:
assert len(list(p.interiors)) == 0
def test_polygon_without_hole_unchanged(self):
"""A polygon with no holes should pass through unchanged."""
poly = box(0, 0, 100, 100)
result = _fill_holes(poly)
assert result.area == pytest.approx(poly.area)
# ---------------------------------------------------------------------------
# Improved merge with 5m buffer closes 3m gaps
# ---------------------------------------------------------------------------
class TestMergeImprovedBuffer:
"""The 5m buffer should close gaps that the old 1m buffer could not."""
def test_3m_gap_merged(self):
"""Two fragments with a 3m gap should merge into a single polygon."""
left = box(0, 0, 50, 100)
right = box(53, 0, 100, 100) # 3m gap at x=50..53
result = merge_fragments([("AA1 1AA", left), ("AA1 1AA", right)])
assert "AA1 1AA" in result
geom = result["AA1 1AA"]
assert geom.geom_type == "Polygon", (
f"Expected single Polygon after merging 3m gap, got {geom.geom_type}"
)
def test_holes_removed_after_merge(self):
"""Interior holes created by merging should be filled."""
# Create a donut-like shape from fragments
outer = box(0, 0, 100, 100)
inner = box(30, 30, 70, 70)
ring = outer.difference(inner)
# Add the inner piece as a separate fragment
result = merge_fragments([("AA1 1AA", ring), ("AA1 1AA", inner)])
assert "AA1 1AA" in result
geom = result["AA1 1AA"]
assert len(list(geom.interiors)) == 0, "Merged polygon should have no holes"
# ---------------------------------------------------------------------------
# subtract_greenspace
# ---------------------------------------------------------------------------
class TestSubtractGreenspace:
"""subtract_greenspace must remove park/water area from postcode polygons."""
def test_park_subtracted(self):
"""A park overlapping a postcode should reduce its area."""
from shapely.strtree import STRtree
postcode = box(0, 0, 100, 100) # 10000 sqm
park = box(60, 0, 100, 100) # 4000 sqm overlap on the right
tree = STRtree([park])
geoms = [park]
result = subtract_greenspace(postcode, tree, geoms)
# Should have lost ~4000 sqm
assert result.area == pytest.approx(6000, rel=0.01)
def test_no_greenspace_unchanged(self):
"""With no overlapping greenspace, the geometry should be unchanged."""
from shapely.strtree import STRtree
postcode = box(0, 0, 100, 100)
park = box(200, 200, 300, 300) # far away
tree = STRtree([park])
geoms = [park]
result = subtract_greenspace(postcode, tree, geoms)
assert result.area == pytest.approx(postcode.area)
def test_full_overlap_preserves_postcode(self):
"""If greenspace covers the entire postcode, keep the original."""
from shapely.strtree import STRtree
postcode = box(0, 0, 100, 100)
park = box(-10, -10, 110, 110) # completely covers postcode
tree = STRtree([park])
geoms = [park]
result = subtract_greenspace(postcode, tree, geoms)
# Should keep original since subtraction would erase entirely
assert result.area == pytest.approx(postcode.area)
def test_over_90pct_removal_preserves_postcode(self):
"""If greenspace would remove >90% of area, keep the original."""
from shapely.strtree import STRtree
postcode = box(0, 0, 100, 100) # 10000 sqm
park = box(5, 0, 100, 100) # 9500 sqm overlap = 95% removal
tree = STRtree([park])
geoms = [park]
result = subtract_greenspace(postcode, tree, geoms)
# Should keep original since >90% would be removed
assert result.area == pytest.approx(postcode.area)
def test_under_90pct_removal_subtracts(self):
"""If greenspace removes <90%, subtraction should proceed."""
from shapely.strtree import STRtree
postcode = box(0, 0, 100, 100) # 10000 sqm
park = box(20, 0, 100, 100) # 8000 sqm overlap = 80% removal
tree = STRtree([park])
geoms = [park]
result = subtract_greenspace(postcode, tree, geoms)
# 80% < 90% cap, so subtraction should happen
assert result.area == pytest.approx(2000, rel=0.01)