lgtm
This commit is contained in:
parent
982e0cc89c
commit
cfaf58dfba
44 changed files with 793 additions and 201 deletions
|
|
@ -81,7 +81,7 @@ class TestFirstOADropped:
|
|||
"""E00000001 is the first OA after sorting. It must appear in offsets."""
|
||||
df, offsets = load_uprns(uprn_parquet)
|
||||
assert "E00000001" in offsets, (
|
||||
"First OA (E00000001) missing from offsets — shift(1) null comparison bug"
|
||||
"First OA (E00000001) missing from offsets: shift(1) null comparison bug"
|
||||
)
|
||||
|
||||
def test_all_oas_present(self, uprn_parquet):
|
||||
|
|
@ -221,7 +221,7 @@ class TestWhitespacePostcodes:
|
|||
|
||||
# The remapped point must be grouped under the successor's OA, not the
|
||||
# terminated postcode's OA.
|
||||
assert "E00000002" in offsets, "Successor OA missing — remap kept old OA"
|
||||
assert "E00000002" in offsets, "Successor OA missing: remap kept old OA"
|
||||
assert "E00000001" not in offsets, (
|
||||
"Remapped point still lives in the terminated postcode's OA"
|
||||
)
|
||||
|
|
@ -304,10 +304,10 @@ class TestVoronoiDeduplication:
|
|||
# Plus postcode A has one at a different location
|
||||
points = np.array(
|
||||
[
|
||||
[500020, 180050], # postcode A — unique location
|
||||
[500050, 180050], # postcode A — shared location
|
||||
[500050, 180050], # postcode B — shared location (same coords)
|
||||
[500080, 180050], # postcode B — unique location
|
||||
[500020, 180050], # postcode A: unique location
|
||||
[500050, 180050], # postcode A: shared location
|
||||
[500050, 180050], # postcode B: shared location (same coords)
|
||||
[500080, 180050], # postcode B: unique location
|
||||
]
|
||||
)
|
||||
postcodes = ["A", "A", "B", "B"]
|
||||
|
|
@ -321,7 +321,7 @@ class TestVoronoiDeduplication:
|
|||
points = np.array(
|
||||
[
|
||||
[500050, 180050], # postcode A
|
||||
[500050, 180050], # postcode B — identical coords
|
||||
[500050, 180050], # postcode B: identical coords
|
||||
]
|
||||
)
|
||||
postcodes = ["A", "B"]
|
||||
|
|
@ -362,11 +362,11 @@ class TestVoronoiCoincidentClusterNotCrushed:
|
|||
boundary = box(0, 0, 1000, 1000)
|
||||
points = np.array(
|
||||
[
|
||||
[500, 500], # A — coincident
|
||||
[500, 500], # B — coincident
|
||||
[500, 500], # C — coincident
|
||||
[500, 500], # D — coincident
|
||||
[100, 100], # OUT — elsewhere in the OA
|
||||
[500, 500], # A: coincident
|
||||
[500, 500], # B: coincident
|
||||
[500, 500], # C: coincident
|
||||
[500, 500], # D: coincident
|
||||
[100, 100], # OUT: elsewhere in the OA
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
|
@ -412,7 +412,7 @@ class TestVoronoiCollinear:
|
|||
"""Collinear points (handled by dummy corners) must distribute area fairly."""
|
||||
|
||||
def test_collinear_points_all_postcodes_get_area(self, square_boundary):
|
||||
"""Points along a line — every postcode must get area."""
|
||||
"""Points along a line: every postcode must get area."""
|
||||
points = np.array(
|
||||
[
|
||||
[500020, 180050],
|
||||
|
|
@ -483,14 +483,14 @@ class TestProcessOAGeometryTypes:
|
|||
def test_overlapping_inspire_no_postcode_overlap(self):
|
||||
"""Overlapping INSPIRE parcels assigned to different postcodes must not overlap."""
|
||||
oa_geom = box(500000, 180000, 500100, 180100)
|
||||
# Two overlapping parcels — left half and a wider middle section
|
||||
# Two overlapping parcels: left half and a wider middle section
|
||||
parcel_left = box(500000, 180000, 500060, 180100)
|
||||
parcel_right = box(500040, 180000, 500100, 180100) # overlaps left by 20m
|
||||
# UPRN in left parcel → postcode A, UPRN in right parcel → postcode B
|
||||
points = np.array(
|
||||
[
|
||||
[500020, 180050], # postcode A — inside left parcel
|
||||
[500080, 180050], # postcode B — inside right parcel
|
||||
[500020, 180050], # postcode A: inside left parcel
|
||||
[500080, 180050], # postcode B: inside right parcel
|
||||
]
|
||||
)
|
||||
postcodes = ["A", "B"]
|
||||
|
|
@ -744,7 +744,7 @@ class TestProcessOAInspireParcelAssignment:
|
|||
[
|
||||
[20, 50], # postcode A
|
||||
[30, 50], # postcode A (majority)
|
||||
[80, 50], # postcode B (minority — would be dropped pre-fix)
|
||||
[80, 50], # postcode B (minority, would be dropped pre-fix)
|
||||
]
|
||||
)
|
||||
postcodes = ["A", "A", "B"]
|
||||
|
|
@ -762,7 +762,7 @@ class TestProcessOAInspireParcelAssignment:
|
|||
|
||||
class TestProcessOASeedFootprintGuarantee:
|
||||
"""Every postcode with a UPRN seed in a multi-postcode OA must produce a
|
||||
fragment, even when its partition cell collapses below MIN_GEOM_AREA — an
|
||||
fragment, even when its partition cell collapses below MIN_GEOM_AREA. An
|
||||
active postcode must never be dropped (validate_outputs is zero-tolerance)."""
|
||||
|
||||
def test_collapsed_voronoi_cells_rescued_as_footprints(self):
|
||||
|
|
@ -935,7 +935,7 @@ class TestToWgs84Geojson:
|
|||
to_bng = pyproj.Transformer.from_crs(
|
||||
"EPSG:4326", "EPSG:27700", always_xy=True
|
||||
)
|
||||
# 0.9m x 0.9m square: area 0.81 m², perimeter 3.6 m — pointlike, yet
|
||||
# 0.9m x 0.9m square: area 0.81 m², perimeter 3.6 m. Pointlike, yet
|
||||
# large enough (~8 output-grid cells) to survive the 1e-6 deg snap.
|
||||
tiny = box(530000, 180000, 530000.9, 180000.9)
|
||||
from .output import _snap_to_wgs84_geojson
|
||||
|
|
@ -969,7 +969,7 @@ class TestToWgs84Geojson:
|
|||
|
||||
def test_thin_sliver_keeps_minimal_buffer(self):
|
||||
"""A genuine elongated sliver still carries length, so it is NOT inflated
|
||||
to building scale — only truly pointlike inputs are."""
|
||||
to building scale. Only truly pointlike inputs are."""
|
||||
import pyproj
|
||||
from shapely.geometry import LineString, shape
|
||||
from shapely.ops import transform as transform_geometry
|
||||
|
|
@ -1052,7 +1052,7 @@ class TestFillHoles:
|
|||
assert result.area == pytest.approx(Polygon(outer).area)
|
||||
|
||||
def test_large_hole_kept(self):
|
||||
"""A large (>=1000 m²) hole is likely a real enclosed postcode — keep it."""
|
||||
"""A large (>=1000 m²) hole is likely a real enclosed postcode, so keep it."""
|
||||
outer = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)]
|
||||
hole = [(20, 20), (80, 20), (80, 80), (20, 80), (20, 20)] # 60x60 = 3600 m²
|
||||
poly_with_hole = Polygon(outer, [hole])
|
||||
|
|
@ -1260,7 +1260,7 @@ class TestKeepDetachedParts:
|
|||
instead of dropping all but the largest, which left ~1.8% uncovered gaps."""
|
||||
|
||||
def test_far_apart_parts_both_kept(self):
|
||||
# Two 50x50m blocks 30m apart — wider than the 10m merge buffer.
|
||||
# Two 50x50m blocks 30m apart, wider than the 10m merge buffer.
|
||||
a = box(0, 0, 50, 50) # 2500 m²
|
||||
b = box(80, 0, 130, 50) # 2500 m², 30m gap
|
||||
geom = merge_fragments([("AA1 1AA", a), ("AA1 1AA", b)])["AA1 1AA"]
|
||||
|
|
@ -1421,7 +1421,7 @@ class TestGeojsonGeometrySliverValidity:
|
|||
class TestColocatedPostcodesAllRetained:
|
||||
"""Co-located non-geographic postcodes (e.g. AL1 9xx) have heavily-overlapping
|
||||
tiny footprints; the de-overlap pass trims most to sub-grid slivers. None may
|
||||
be dropped — every active postcode must keep a (valid, non-empty) boundary."""
|
||||
be dropped: every active postcode must keep a (valid, non-empty) boundary."""
|
||||
|
||||
def test_overlapping_tiny_footprints_none_dropped(self, tmp_path):
|
||||
from shapely.geometry import Point, shape
|
||||
|
|
@ -1447,7 +1447,7 @@ class TestSafeOverlayHelpers:
|
|||
"""The robust overlay helpers retry on a fixed-precision grid after a
|
||||
GEOSException (e.g. ``side location conflict`` from near-coincident OA-seam
|
||||
edges). The grid is a caller-supplied parameter: metres for the BNG stages,
|
||||
1e-6 degrees for the WGS84 output stage — so the same helper serves both
|
||||
1e-6 degrees for the WGS84 output stage, so the same helper serves both
|
||||
without crushing degree-scale shapes on the metre default."""
|
||||
|
||||
def test_grid_param_honored_on_clean_inputs(self):
|
||||
|
|
@ -1497,7 +1497,7 @@ class TestSafeOverlayHelpers:
|
|||
|
||||
# A self-intersecting bow-tie: invalid. set_precision()'s DEFAULT
|
||||
# ('valid_output') mode runs its own noding pass that re-raises
|
||||
# 'side location conflict' on this — which is exactly how the production
|
||||
# 'side location conflict' on this, which is exactly how the production
|
||||
# crash happened (the fallback re-raised the error it was meant to absorb).
|
||||
_BOWTIE = Polygon([(0, 0), (1e-5, 1e-5), (1e-5, 0), (0, 1e-5), (0, 0)])
|
||||
# make_valid() of this spiky ring returns a mixed-dimension
|
||||
|
|
@ -1831,7 +1831,7 @@ class TestFragmentsCache:
|
|||
def test_missing_input_is_ignored(self, tmp_path):
|
||||
cache = tmp_path / "fragments_cache.parquet"
|
||||
cache.write_text("c")
|
||||
# arcgis is optional/absent — it cannot have invalidated the cache.
|
||||
# arcgis is optional/absent, so it cannot have invalidated the cache.
|
||||
assert fragments_cache_is_fresh(cache, [tmp_path / "absent.parquet"]) is True
|
||||
|
||||
|
||||
|
|
@ -1885,7 +1885,7 @@ class TestEliminateSmallDetachedParts:
|
|||
assert after == pytest.approx(before, rel=1e-6), "coverage must be conserved"
|
||||
|
||||
def test_genuine_large_split_is_kept(self):
|
||||
# Two substantial parts (both 40000 m²) far apart — a real bisection, not
|
||||
# Two substantial parts (both 40000 m²) far apart: a real bisection, not
|
||||
# scatter. Neither is below the absolute/fraction thresholds, so both stay.
|
||||
a = MultiPolygon([_mbox(0, 0, 200, 200), _mbox(400, 0, 600, 200)])
|
||||
out = _as_dict(_eliminate_small_detached_parts([("A", a)]))
|
||||
|
|
@ -1909,8 +1909,8 @@ class TestEliminateSmallDetachedParts:
|
|||
assert out["A"].geom_type == "Polygon"
|
||||
|
||||
def test_largest_part_always_retained_no_postcode_dropped(self):
|
||||
# Even a postcode that is ENTIRELY a tiny sliver keeps its (largest) part —
|
||||
# active postcodes must never be dropped (validate_outputs is zero-tolerance).
|
||||
# Even a postcode that is ENTIRELY a tiny sliver keeps its (largest) part.
|
||||
# Active postcodes must never be dropped (validate_outputs is zero-tolerance).
|
||||
tiny = _mbox(500, 500, 505, 505) # 25 m², the whole postcode
|
||||
big = _mbox(0, 0, 300, 300)
|
||||
out = _as_dict(
|
||||
|
|
@ -1961,7 +1961,7 @@ class TestEliminateSmallDetachedParts:
|
|||
assert out["A"].geom_type == "Polygon"
|
||||
|
||||
def test_gapped_sliver_within_nearest_radius_is_reassigned_not_dropped(self):
|
||||
# A 300 m² sliver 1.2 m from its only neighbour N — beyond the border probe
|
||||
# A 300 m² sliver 1.2 m from its only neighbour N: beyond the border probe
|
||||
# but inside the nearest-fallback radius. Before the gather buffer was
|
||||
# widened to the accept radius, the old (smaller) gather buffer never
|
||||
# returned N, so the sliver fell through to the drop branch. Now it is
|
||||
|
|
@ -2017,7 +2017,7 @@ class TestGreenspaceReconnect:
|
|||
from shapely.strtree import STRtree
|
||||
|
||||
postcode = box(0, 0, 200, 100)
|
||||
barrier = box(60, 0, 130, 100) # 70 m wide — beyond 2x the bridge width
|
||||
barrier = box(60, 0, 130, 100) # 70 m wide, beyond 2x the bridge width
|
||||
result = subtract_greenspace(postcode, STRtree([barrier]), [barrier])
|
||||
assert result.geom_type == "MultiPolygon", "wide barrier should stay split"
|
||||
assert len(result.geoms) == 2
|
||||
|
|
@ -2035,7 +2035,7 @@ class TestGreenspaceReconnect:
|
|||
|
||||
def test_result_is_always_polygonal(self):
|
||||
# Regression: _reconnect_split must never return a GeometryCollection
|
||||
# (line/point debris) — downstream to_wgs84_geojson_multi would truncate a
|
||||
# (line/point debris). Downstream to_wgs84_geojson_multi would truncate a
|
||||
# GC to a single part, silently dropping substantial pieces. Sweep many
|
||||
# strip widths/offsets (incl. coincident-edge-prone integer geometries).
|
||||
from shapely.strtree import STRtree
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue