.
This commit is contained in:
parent
909e241907
commit
1ee796b282
29 changed files with 250 additions and 126 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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}]
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue