This commit is contained in:
Andras Schmelczer 2026-07-03 18:28:56 +01:00
parent 909e241907
commit 1ee796b282
29 changed files with 250 additions and 126 deletions

View file

@ -1,8 +1,8 @@
"""Robust GEOS overlay helpers.
Overlay operations (union, difference, intersection) can raise a
``GEOSException`` most often ``TopologyException: side location conflict``,
``Ring edge missing``, or ``found non-noded intersection`` on geometries that
``GEOSException``, most often ``TopologyException: side location conflict``,
``Ring edge missing``, or ``found non-noded intersection``, on geometries that
contain near-coincident or near-degenerate edges, or that are individually
invalid. The robust remedy is a *fixed-precision* overlay: GEOS's OverlayNG
engine, handed a grid size, nodes every edge onto that grid and finishes where
@ -14,8 +14,8 @@ learned the hard way from a crash:
1. **Never precision-reduce with the default mode.** ``set_precision``'s default
``valid_output`` (and ``keep_collapsed``) mode runs its *own* noding pass that
re-raises the very ``side location conflict`` we are trying to escape. We push
the grid into the overlay via the ``grid_size`` argument instead where
OverlayNG nodes robustly and only ever call ``set_precision`` in
the grid into the overlay via the ``grid_size`` argument instead (where
OverlayNG nodes robustly) and only ever call ``set_precision`` in
``pointwise`` mode (pure coordinate rounding, which cannot raise).
2. **Validate first.** ``make_valid`` repairs the self-intersections (bow-ties,
pinches) that make GEOS choke, so the overlay starts from an OGC-valid shape.
@ -40,7 +40,7 @@ from shapely import GEOSException, make_valid, set_precision
from shapely.geometry import Polygon
from shapely.ops import unary_union
# 0.1 mm in metres well below MIN_GEOM_AREA (0.01 m^2) and survey resolution.
# 0.1 mm in metres: well below MIN_GEOM_AREA (0.01 m^2) and survey resolution.
_SNAP_GRID = 1e-4
_EMPTY = Polygon()

View file

@ -120,7 +120,7 @@ def _is_pointlike(geom_bng) -> bool:
def _rescue_footprint(geom_bng) -> dict | None:
"""Fatten a degenerate BNG geometry into a representable footprint and snap.
A POINTLIKE input (a point, or a near-zero-area/short-perimeter polygon the
A POINTLIKE input (a point, or a near-zero-area/short-perimeter polygon, the
signature of a tower-block postcode whose UPRNs all share one coordinate)
gets a building-scale buffer so it is not reduced to an invisible sub-metre
dot; thin slivers that still carry length keep the minimal buffer.
@ -263,7 +263,7 @@ def merge_fragments(
# Close tiny gaps between adjacent OA boundary edges (float mismatches).
# The closing can erode a tiny MultiPolygon (e.g. a postcode with only a
# sliver fragment) to nothing, which would leave the postcode with no
# geometry at all — keep the un-closed shape if that happens.
# geometry at all. Keep the un-closed shape if that happens.
if combined.geom_type == "MultiPolygon":
closed = combined.buffer(5.0).buffer(-5.0)
if not closed.is_valid:
@ -308,7 +308,7 @@ def _polygonal(geom):
return None
# Both callers run on WGS84-degree output geometry, so the robustness
# fallback snaps on the 1e-6° grid (~0.11 m), not geometry.py's metre
# default — a coarse metre grid would obliterate a degree-scale shape.
# default. A coarse metre grid would obliterate a degree-scale shape.
merged = safe_union(polys, grid=_OUTPUT_PRECISION_DEG)
return merged if not merged.is_empty else None
return None
@ -324,7 +324,7 @@ def _resolve_overlaps(
containment (a postcode fully enclosed by another). Each postcode is trimmed
by the union of its higher-priority overlapping neighbours, where **priority =
ascending area**: a smaller postcode wins contested ground. That single rule
handles both cases correctly an enclosed postcode is always smaller than its
handles both cases correctly: an enclosed postcode is always smaller than its
container, so it keeps its area while the container gets a hole (a `overlaps`
query alone would miss containment entirely). Run last, on the final output
geometries, so nothing re-introduces overlap afterwards. A postcode that would
@ -348,7 +348,7 @@ def _resolve_overlaps(
arr = np.array(geoms, dtype=object)
pairs: set[tuple[int, int]] = set()
# "overlaps" gives partial overlaps; "contains" gives containment (which
# "overlaps" excludes) — together they cover every 2-D overlap without the
# "overlaps" excludes). Together they cover every 2-D overlap without the
# edge-touch explosion a plain "intersects" query would add.
for predicate in ("overlaps", "contains"):
qsrc, qtgt = tree.query(arr, predicate=predicate)
@ -577,7 +577,7 @@ def _grid_footprint(geom):
pass can shave a small (e.g. co-located, non-geographic) postcode down to a
sub-grid sliver that disappears when snapped to output precision. Rather than
drop it, place a minimal valid footprint at its location. The tiny overlap
this re-creates with the neighbour that trimmed it is harmless the output
this re-creates with the neighbour that trimmed it is harmless: the output
partition is best-effort, a missing boundary is a hard validation failure.
"""
try:

View file

@ -11,7 +11,7 @@ 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
# 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
@ -77,7 +77,7 @@ def process_oa(
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
# 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.
@ -142,7 +142,7 @@ def _claim_inspire_parcels(
# UPRNs from a single postcode goes wholly to that postcode. A parcel shared
# by several postcodes (a block of flats spanning postcodes, or overlapping
# parcel data) is split between them via a sub-Voronoi over their own UPRNs
# clipped to the parcel so EVERY contained postcode keeps part of the
# clipped to the parcel, so EVERY contained postcode keeps part of the
# parcel. A bare majority vote would hand the whole parcel to one winner and
# leave the losers' UPRNs trapped inside claimed land, dropping them from
# both this claim and the `remaining` polygon handed to Voronoi downstream.
@ -312,7 +312,7 @@ def _extract_polygonal(geom) -> Polygon | MultiPolygon | None:
return polys[0]
# Union (not bare MultiPolygon construction): make_valid can emit
# overlapping polygonal parts, and a MultiPolygon of overlapping parts is
# invalid it double-counts area and makes the next `.difference()` raise
# invalid: it double-counts area and makes the next `.difference()` raise
# a TopologyException that aborts the OA (and, in parallel mode, the
# worker). safe_union merges them into a valid geometry.
merged = safe_union(polys)

View file

@ -82,7 +82,7 @@ def load_uprns(
# Remap terminated postcodes to their nearest active successor. The
# successor generally lives in a DIFFERENT OA (and at different grid
# coordinates), so the remapped point must adopt the successor's
# authoritative OA/coords — keeping the terminated postcode's original
# authoritative OA/coords. Keeping the terminated postcode's original
# OA would seed the successor into an OA it doesn't belong to, splitting
# its boundary across OAs. Genuine (non-remapped) UPRN rows keep their
# own OA, since a live postcode can legitimately span several OAs.
@ -127,7 +127,7 @@ def load_uprns(
uprns.sort("OA21CD").sink_parquet(tmp_path)
release_memory()
# Read the sorted data only one copy in memory (~2GB)
# Read the sorted data: only one copy in memory (~2GB)
df = pl.read_parquet(tmp_path)
tmp_path.unlink()
n = len(df)

View file

@ -4,7 +4,7 @@ Stratified by property type and postcode sector, with IRLS Huber regression,
hierarchical shrinkage (sector district area national hedonic),
and KD-tree spatial smoothing for sparse sectors.
Output: price_index.parquet sector x type_group x year -> log_index
Output: price_index.parquet (sector x type_group x year -> log_index)
"""
import argparse

View file

@ -23,7 +23,7 @@ ESTIMATE_COLUMNS = ["Estimated current price", "Est. price per sqm"]
# Natural join key from estimates back onto properties: postcode plus the
# coalesced register/EPC address. This is unique and non-null on the deduped
# dwelling universe (see property_base._dedupe_collapsed_properties), so it maps
# estimates 1:1 onto properties regardless of row order — estimates are computed
# estimates 1:1 onto properties regardless of row order. Estimates are computed
# from a separate price_inputs.parquet, so a positional key would not line up.
JOIN_ADDRESS = "_join_address"
JOIN_KEYS = ["Postcode", JOIN_ADDRESS]

View file

@ -315,7 +315,7 @@ def test_run_tenure_history_tracks_rent_owner_transitions(tmp_path: Path):
def test_run_tenure_history_empty_when_always_owner_occupied(tmp_path: Path):
# A property only ever observed as owner-occupied has no tenure change worth
# surfacing the timeline column is null (no events), not a noisy baseline.
# surfacing: the timeline column is null (no events), not a noisy baseline.
zip_path = tmp_path / "domestic-csv.zip"
_write_epc_zip(
zip_path,
@ -339,6 +339,37 @@ def test_run_tenure_history_empty_when_always_owner_occupied(tmp_path: Path):
assert df.get_column("tenure_history").to_list() == [None]
def test_run_latest_tenure_status_reflects_most_recent_certificate(tmp_path: Path):
# Two certificates for one dwelling: an older social-rented cert and a newer
# owner-occupied one. The published latest_tenure_status must carry the
# LATEST certificate's normalized tenure ("Owner-occupied"), while
# was_council_house stays "Yes" because the dwelling was social at some
# point. This also confirms latest_tenure_status reaches the epc_pp parquet.
zip_path = tmp_path / "domestic-csv.zip"
_write_epc_zip(
zip_path,
[
_row(inspection_date="2016-04-01", tenure="Rented (social)"),
_row(inspection_date="2024-04-01", tenure="owner-occupied"),
],
)
price_paid_path = tmp_path / "price-paid.parquet"
_price_paid_frame(prices=[250_000], dates=[date(2024, 2, 3)]).write_parquet(
price_paid_path
)
output_path = tmp_path / "epc-pp.parquet"
_run(zip_path, price_paid_path, output_path, tmp_path)
df = pl.read_parquet(output_path)
assert df.height == 1
assert df.select("latest_tenure_status", "was_council_house").to_dicts() == [
{"latest_tenure_status": "Owner-occupied", "was_council_house": "Yes"}
]
def test_run_dedup_prefers_valid_dated_cert_over_garbled_date(tmp_path: Path):
# Two certificates for the same property. The cert with the garbled,
# unparseable inspection_date must NOT be chosen as "latest": a string sort
@ -565,7 +596,7 @@ def test_run_new_build_keeps_early_first_transfer_when_sub_min_price(tmp_path: P
price_paid_path = tmp_path / "price-paid.parquet"
pl.DataFrame(
{
# 5_000 is below MIN_PRICE (10_000) a nominal/junk transfer that
# 5_000 is below MIN_PRICE (10_000), a nominal/junk transfer that
# must still anchor the construction year but stay out of the price
# aggregations.
"price": [5_000, 300_000],
@ -603,7 +634,7 @@ def test_run_caps_band_year_at_first_transfer_year(tmp_path: Path):
# lands AFTER its first Land Registry sale (1998). A dwelling cannot have
# been built after it was first sold, so the published build year must be
# capped at the first transfer year (1998), not the later band estimate.
# It stays flagged as an estimate (approximate=1) — it is still EPC-derived.
# It stays flagged as an estimate (approximate=1). It is still EPC-derived.
zip_path = tmp_path / "domestic-csv.zip"
_write_epc_zip(
zip_path, [_row(construction_age_band="England and Wales: 2003 onwards")]
@ -627,7 +658,7 @@ def test_run_caps_band_year_at_first_transfer_year(tmp_path: Path):
def test_run_keeps_band_year_when_earlier_than_first_transfer(tmp_path: Path):
# The common case: the EPC band (1950-1966 -> 1958) predates the first
# recorded sale (2020). The cap must NOT fire — the band estimate stands.
# recorded sale (2020). The cap must NOT fire. The band estimate stands.
zip_path = tmp_path / "domestic-csv.zip"
_write_epc_zip(zip_path)
@ -649,7 +680,7 @@ def test_run_keeps_band_year_when_earlier_than_first_transfer(tmp_path: Path):
def test_run_keeps_sale_above_lowered_min_price(tmp_path: Path):
# A genuine cheap sale of 30_000 sits between the OLD floor (50k) and the
# NEW floor (10k): it must now be RETAINED in the price aggregations. This
# pins the 50k->10k change it fails on the pre-fix 50k floor (where 30k was
# pins the 50k->10k change: it fails on the pre-fix 50k floor (where 30k was
# excluded, giving historical_prices length 1 / latest_price 250_000).
zip_path = tmp_path / "domestic-csv.zip"
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
@ -748,8 +779,8 @@ def test_run_collapses_duplicate_transactions(tmp_path: Path):
# The duplicated 250_000 sale collapses to one entry; two distinct sales.
assert df.get_column("historical_prices").to_list() == [
[
{"year": 2020, "month": 2, "price": 200_000},
{"year": 2024, "month": 2, "price": 250_000},
{"year": 2020, "month": 2, "price": 200_000, "is_new": False},
{"year": 2024, "month": 2, "price": 250_000, "is_new": False},
]
]
assert df.get_column("latest_price").to_list() == [250_000]
@ -777,7 +808,7 @@ def test_run_excludes_implausible_price_jump_but_keeps_property(tmp_path: Path):
assert df.height == 1
assert df.get_column("latest_price").to_list() == [140_000]
assert df.get_column("historical_prices").to_list() == [
[{"year": 2016, "month": 6, "price": 140_000}]
[{"year": 2016, "month": 6, "price": 140_000, "is_new": False}]
]

View file

@ -593,7 +593,7 @@ def test_transform_grocery_dedup_drops_only_grocery_aspect(tmp_path):
# The _write_transform_inputs fixture seeds 5 GEOLYTIX "Tesco" points at
# (51.52, -0.14). An OSM object colocated there carrying "Tesco" in its name
# is the same physical store, so its Convenience Store (Groceries) row is a
# duplicate and must be dropped but its NON-grocery aspect (a Post Office
# duplicate and must be dropped, but its NON-grocery aspect (a Post Office
# sharing the same OSM id) must survive. An independent shop away from the
# GEOLYTIX point keeps its grocery row.
raw = pl.DataFrame(

View file

@ -34,7 +34,7 @@ DROP_CATEGORIES = {
"emergency/water_tank",
"leisure/bleachers",
"leisure/schoolyard",
# Park "furniture" / incidental features not parks; they massively
# Park "furniture" / incidental features, not parks; they massively
# inflated the Park count (picnic_table ~15k, outdoor_seating ~5.8k).
"leisure/bandstand",
"leisure/bird_hide",
@ -222,7 +222,7 @@ DROP_CATEGORIES = {
"public_transport/entrance",
"public_transport/station",
"public_transport/stop_position",
# Education amenities — schools come from GIAS instead. OSM coverage for
# Education amenities. Schools come from GIAS instead. OSM coverage for
# tertiary education, tutoring, and childcare is too noisy/incomplete to be
# useful on a property-search map.
"amenity/school",
@ -398,7 +398,7 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [
"tourism/theme_park",
# bicycle_rental/boat_rental/marina/slipway used to live here and
# made up ~46% of the bucket (cycle-hire docks, boat ramps); they
# are infrastructure, not entertainment venues — see DROP_CATEGORIES.
# are infrastructure, not entertainment venues. See DROP_CATEGORIES.
"leisure/hackerspace",
"leisure/yes",
],
@ -722,7 +722,7 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [
[
"leisure/fitness_centre",
# leisure/fitness_station (outdoor pull-up bars / trim-trail
# apparatus, ~2.5k) is not a gym — see DROP_CATEGORIES.
# apparatus, ~2.5k) is not a gym. See DROP_CATEGORIES.
"amenity/dojo",
"amenity/dancing_school",
],
@ -849,8 +849,8 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [
"healthcare/pharmacy",
"shop/chemist",
# healthcare/alternative, shop/herbalist and shop/health (homeopaths,
# herbalists, generic "health" shops) are not dispensing pharmacies
# — see DROP_CATEGORIES.
# herbalists, generic "health" shops) are not dispensing pharmacies.
# See DROP_CATEGORIES.
],
),
# "Hospital & Clinic" used to be one bucket; an actual hospital and a small
@ -878,7 +878,7 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [
"healthcare/laboratory",
"healthcare/rehabilitation",
"healthcare/vaccination_centre",
# healthcare/yes (untyped junk rows) is dropped — see DROP_CATEGORIES.
# healthcare/yes (untyped junk rows) is dropped. See DROP_CATEGORIES.
],
),
(
@ -950,7 +950,7 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [
[
"tourism/gallery",
# tourism/artwork (statues, murals, village signs) was 93% of this
# bucket and is not a visitable gallery — see DROP_CATEGORIES.
# bucket and is not a visitable gallery. See DROP_CATEGORIES.
],
),
(
@ -1420,7 +1420,7 @@ def _school_icon_category_expr() -> pl.Expr:
# GIAS phase mixes casing ("Middle deemed Primary" vs "Middle deemed
# primary") so we normalise before matching.
phase = pl.col("phase").str.to_lowercase()
# gias._format_age_range emits three shapes: "<low><high>" (em-dash),
# gias._format_age_range emits three shapes: "<low><high>" (en-dash),
# "up to <high>" (high-only) and "<low>+" (low-only). Extract the leading
# integer as low and the trailing integer as high, then suppress the wrong
# end for the one-sided shapes so they don't collapse to a single bound.
@ -1477,7 +1477,7 @@ def _load_ofsted_ratings(ofsted_path: Path) -> pl.LazyFrame:
the conventional Ofsted labels; when there is no usable graded result
(null/"Not judged", e.g. schools last seen under the post-2024 ungraded
report-card framework) we fall back to "Ungraded inspection overall outcome"
so genuinely good/outstanding schools aren't dropped mirroring
so genuinely good/outstanding schools aren't dropped, mirroring
school_catchments.classify_good_plus_schools. Remaining nulls drop out."""
grade_col = pl.col("Latest OEIF overall effectiveness")
# See school_catchments: the ungraded outcome carries "School remains Good"/
@ -1682,7 +1682,7 @@ def osm_groceries_colocated_with_geolytix(
An OSM Groceries row is a duplicate when a GEOLYTIX point lies within
``radius_m`` metres AND that point's brand tokens (its ``category``, e.g.
"Tesco", "Co-op", "M&S") are all present in the OSM row's name i.e. the
"Tesco", "Co-op", "M&S") are all present in the OSM row's name, i.e. the
same physical branded store. Short brands like "M&S" match via
_GROCERY_SHORT_BRAND_TOKENS; brands that still tokenise to set() are kept.
@ -1703,7 +1703,7 @@ def osm_groceries_colocated_with_geolytix(
osm_ids = osm_groceries["id"].to_list()
osm_name_tokens = [_significant_tokens(n) for n in osm_groceries["name"].to_list()]
# Equirectangular projection to metres around the shared mean latitude — at
# Equirectangular projection to metres around the shared mean latitude. At
# England's scale this is accurate to well under the dedup radius.
mean_lat = float(np.mean(np.concatenate([glx_lat, osm_lat])))
cos_lat = float(np.cos(np.radians(mean_lat)))
@ -1860,7 +1860,7 @@ def transform(
)
# Scope the drop to the Groceries group: a single OSM object can also
# carry a non-grocery aspect (e.g. a convenience store that is also a
# Post Office), which must survive — only its duplicate grocery row goes.
# Post Office), which must survive. Only its duplicate grocery row goes.
lf = lf.filter(
~((pl.col("group") == "Groceries") & pl.col("id").is_in(duplicate_ids))
)

View file

@ -45,7 +45,7 @@ POSTCODE_DENSITY_PERCENTILE_COL = "Tree canopy density percentile within {radius
POSTCODE_AREA_COL = "Tree canopy area within {radius}m (sqm)"
POSTCODE_HEIGHT_COL = "Mean TOW height within {radius}m (m)"
# National Forest Inventory (NFI) woodland the geometric complement of TOW.
# National Forest Inventory (NFI) woodland: the geometric complement of TOW.
# NFI ships as a zipped shapefile of woodland parcels (>=0.5 ha) in EPSG:27700.
# Field names are from the NFI Woodland England 2022 release; re-check on bumps.
NFI_CATEGORY_COL = "CATEGORY"
@ -263,7 +263,7 @@ def _postcode_buffers(
return circles, shapely.STRtree(circles)
# 0.1 mm in the BNG working CRS (EPSG:27700) far below survey resolution; the
# 0.1 mm in the BNG working CRS (EPSG:27700), far below survey resolution; the
# same grid the postcode_boundaries overlay uses.
_OVERLAY_GRID_M = 1e-4
@ -274,13 +274,13 @@ def _robust_intersection_area(a: np.ndarray, b: np.ndarray) -> np.ndarray:
External Forest Research TOW/NFI polygons are occasionally invalid
(self-intersections), and a single bad polygon makes the batched
``shapely.intersection`` raise ``TopologyException: side location conflict``,
aborting the whole run. The fast path is the raw batched overlay unchanged,
full-speed, when the data is clean and only a failure triggers repair.
aborting the whole run. The fast path is the raw batched overlay (unchanged,
full-speed, when the data is clean), and only a failure triggers repair.
The repair deliberately uses a *plain* overlay rather than the fixed-precision
(``grid_size``) one: ``make_valid`` can emit a mixed-dimension
``GeometryCollection`` (a polygon plus a dangling line), which OverlayNG
rejects with ``Overlay input is mixed-dimension`` whereas a plain overlay
rejects with ``Overlay input is mixed-dimension``, whereas a plain overlay
accepts it, and its non-polygonal debris has zero area and is dropped by the
``clipped_area > 0`` filter downstream anyway. A final pointwise coordinate
snap (which never raises) collapses the near-coincident edges behind any
@ -496,7 +496,7 @@ def _finalize_metrics(
if over_count:
print(
f" note: {over_count:,} postcode(s) exceeded 100% raw canopy and were "
"capped — indicates overlapping TOW/NFI canopy within the buffer"
"capped, indicating overlapping TOW/NFI canopy within the buffer"
)
mean_height = np.divide(