This commit is contained in:
Andras Schmelczer 2026-07-03 18:39:34 +01:00
parent 1ee796b282
commit ab688243d7
36 changed files with 307 additions and 135 deletions

View file

@ -5,7 +5,7 @@ The right pane shows each crime metric next to its area context: the mean
average-annual count (``"X (/yr, 7y)"``) across the selection's postcode sector (e.g.
``"E14 2"``), its outcode (e.g. ``"E14"``), and the nation. Crime is constant
within a postcode (the merge keys it on the postcode), so each postcode
contributes its single value weighted by how many properties sit in it keeping
contributes its single value weighted by how many properties sit in it, keeping
every scope on the same property-weighted basis as the per-selection mean, so the
four numbers (this selection / sector / outcode / nation) are directly
comparable. The national figure here is an EXACT property-weighted mean, which is
@ -18,7 +18,7 @@ crime values from ``postcode.parquet`` and the per-postcode property weights fro
``properties.parquet`` mirrors exactly the two inputs the server loads, so the
result matches what the server used to compute (minus its u16 quantization loss).
Output schema one row per area:
Output schema, one row per area:
scope : ``"national"`` | ``"outcode"`` | ``"sector"``
area : the outcode (``"E14"``) / sector (``"E14 2"``);
@ -43,7 +43,7 @@ SCOPE_NATIONAL = "national"
SCOPE_OUTCODE = "outcode"
SCOPE_SECTOR = "sector"
# Area label on the national row — it spans the whole country, so it has no code.
# Area label on the national row. It spans the whole country, so it has no code.
NATIONAL_AREA = ""
# Both merge outputs key on the canonical NSPL `pcds` postcode (spaced, e.g.
@ -71,7 +71,7 @@ def _weighted_mean(column: str) -> pl.Expr:
A null crime value is a genuine gap (the postcode's police force published no
usable data), not zero crime, so it must dilute neither the numerator nor the
denominator exactly as the server's former estimator skipped NaN values.
denominator, exactly as the server's former estimator skipped NaN values.
Yields null when no postcode in the group has data for this type.
"""
weight = pl.col(_WEIGHT_COLUMN)

View file

@ -126,7 +126,7 @@ def transform_crime(
# Sum per-incident weights directly: a 2021 LSOA can receive incidents
# carrying different `_weight`s in the same month (split 2011 parent at
# 1/N alongside an unsplit one at 1), so `_weight.first() * len` would
# apply one row's weight to all of them and nondeterministically so,
# apply one row's weight to all of them, and nondeterministically so,
# since `first` after a join has no ordering guarantee.
filtered.group_by("LSOA code", "year", "Crime type")
.agg(pl.col("_weight").sum().alias("count"))
@ -194,7 +194,7 @@ def _write_crime_by_year(
)
yearly_per_type = (
# Per-incident weight sum, not `_weight.first() * len` — see the
# Per-incident weight sum, not `_weight.first() * len`. See the
# matching comment in transform_crime.
filtered.group_by("LSOA code", "Crime type", "year")
.agg(pl.col("_weight").sum().alias("count"))

View file

@ -92,7 +92,7 @@ STREET_CSV_NAME_RE = re.compile(r"^(\d{4}-\d{2})-(.+)-street\.csv$")
# Trailing-window definitions, in (label, years) form. Each window's average is
# pooled over the force's covered months inside the window; one average-annual-
# count column (`(/yr, <label>)`) — the filterable crime feature — is emitted per
# count column (`(/yr, <label>)`), the filterable crime feature, is emitted per
# window.
WINDOWS: tuple[tuple[str, int], ...] = (("7y", 7), ("2y", 2))

View file

@ -22,20 +22,20 @@ pl.Config.set_tbl_cols(-1)
RATING_RANK = {"A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6, "G": 7}
# Value-quality floor for price aggregations. A flat nominal floor is a blunt
# tool against a deflating threshold £50k was completely normal for a 1990s
# tool against a deflating threshold: £50k was completely normal for a 1990s
# house, so a 50k floor wrongly discarded ~a third of legitimate 1990s
# open-market sales (and deleted properties whose only sales were old/cheap),
# biasing early-year price history upward. 10k recovers the large [10k,50k)
# band of genuine cheaper sales while still excluding the nominal/junk transfers
# (£1 etc.). A small tail of real sub-10k sales is still dropped a deliberate
# (£1 etc.). A small tail of real sub-10k sales is still dropped, a deliberate
# conservative tradeoff to keep clearly-implausible transfers out.
MIN_PRICE = 10_000
# Time-aware consecutive-sale jump guard. Price-paid contains keyed-in price
# errors that pass the MIN_PRICE/category filters — e.g. 13 QUICKSETTS HR2 7PP,
# a 93 m² terrace, sold £140,000 in 2016 then "£207,500,000" in 2026 (clearly
# £207,500 with extra digits, lodged as category A) — and would otherwise
# become latest_price. A quality sale is flagged when it exceeds its
# errors that pass the MIN_PRICE/category filters and would otherwise become
# latest_price. For example, 13 QUICKSETTS HR2 7PP, a 93 m² terrace, sold
# £140,000 in 2016 then "£207,500,000" in 2026 (clearly £207,500 with extra
# digits, lodged as category A). A quality sale is flagged when it exceeds its
# neighbouring sale by more than JUMP_TOLERANCE * JUMP_GROWTH_PER_YEAR ** years
# between the two sales. Calibration: genuine extreme appreciation (prime
# London 1995->2026 is roughly x50 over 31 years) stays comfortably under
@ -174,8 +174,8 @@ def _clean_number(column: str, dtype: pl.DataType) -> pl.Expr:
def _join_address_parts(*columns: str) -> pl.Expr:
"""Join address components into one display address, single-spaced.
Price-paid SAON/PAON/STREET are EMPTY STRINGS (not null) when absent
saon is "" on ~88% of rows and ``concat_str(..., ignore_nulls=True)``
Price-paid SAON/PAON/STREET are EMPTY STRINGS (not null) when absent
(saon is "" on ~88% of rows), and ``concat_str(..., ignore_nulls=True)``
skips only nulls, so empty components still contributed their separator
(``' 10 PALACE GREEN'``, doubled spaces when a middle part was empty).
Convert ``''``null per component so ignore_nulls works as intended, then
@ -444,6 +444,13 @@ def _run(epc_path: Path, price_paid_path: Path, output_path: Path, temp_dir: Pat
epc_base.sort("inspection_date", descending=True, nulls_last=True)
.group_by("_epc_match_address", "_epc_match_postcode")
.first()
# The deduped row carries the most-recent certificate (sorted newest
# first, .first() keeps it), so normalising its raw tenure here yields
# the LATEST certificate's coarse tenure. Kept past the .drop("tenure")
# so downstream (merge) can tell a still-social dwelling from one that
# was social once but whose latest cert is no longer social. Computed
# before .drop("tenure") because it reads that column.
.with_columns(tenure_status(pl.col("tenure")).alias("latest_tenure_status"))
.drop("tenure")
)
@ -520,7 +527,7 @@ def _run(epc_path: Path, price_paid_path: Path, output_path: Path, temp_dir: Pat
#
# Emission rule (walking certificates oldest-first, ignoring unknown-tenure
# ones so they neither appear nor break the chain):
# - the first known status is emitted only when it is a rental an
# - the first known status is emitted only when it is a rental: an
# owner-occupied baseline is the unremarkable default for a property
# that has changed hands and would only clutter the timeline;
# - every later certificate is emitted when its status differs from the
@ -658,8 +665,8 @@ def _run(epc_path: Path, price_paid_path: Path, output_path: Path, temp_dir: Pat
)
.filter(pl.col("pp_address").is_not_null())
# Price-paid carries ~72k duplicate (address, postcode, date, price)
# transaction groups with DISTINCT transaction ids the same completed
# sale lodged twice which double-counted sales in historical_prices.
# transaction groups with DISTINCT transaction ids (the same completed
# sale lodged twice), which double-counted sales in historical_prices.
# Collapse each to one row. ppd_category stays in the subset so an
# A/B-categorised pair of the same sale survives as two rows; only the
# A row feeds the price aggregations (quality_ok), which is intentional.
@ -714,6 +721,10 @@ def _run(epc_path: Path, price_paid_path: Path, output_path: Path, temp_dir: Pat
pl.col("date_of_transfer").dt.year().alias("year"),
pl.col("date_of_transfer").dt.month().cast(pl.UInt8).alias("month"),
"price",
# Per-sale new-build flag straight from the PPD "old_new" field
# (Y = newly built on first transfer, N = established). Kept on
# each transaction so the timeline can mark the new-build sale.
(pl.col("old_new") == "Y").alias("is_new"),
)
.filter(quality_ok)
.alias("historical_prices"),

View file

@ -83,7 +83,7 @@ _AREA_COLUMNS = [
"% Mixed",
"% White",
"% Other",
# Crime average annual recorded incident count (incidents/yr), 7-year and
# Crime: average annual recorded incident count (incidents/yr), 7-year and
# 2-year windows. These are the filterable crime features; the per-incident
# records live in a separate side table the server loads directly (it bypasses
# the merge).
@ -145,11 +145,18 @@ _AREA_COLUMNS = [
"% A-levels",
"% Degree or higher",
"% Other qualifications",
# Tenure (Census 2021 TS054, % of households by tenure) — unlike ethnicity &
# Tenure (Census 2021 TS054, % of households by tenure). Unlike ethnicity &
# education these three percentages are user-filterable, not display-only.
"% Owner occupied",
"% Social rent",
"% Private rent",
# Council housing (EPC-derived, NOT Census): share of dwellings in the
# postcode ever recorded as social housing per EPC, and the ever-social
# subset whose latest EPC certificate is no longer social rented (sold off).
# Aggregated from the per-property "was_council_house" / "latest_tenure_status"
# flags in _epc_council_by_postcode and joined onto the AREA frame only.
"% Council housing",
"% Ex-council",
# Politics
"Voter turnout (%)",
"% Labour",
@ -254,7 +261,7 @@ def _subset_numbers_compatible(left: str, right: str) -> bool:
Subset (not equality) is correct ONLY for listed-building name matching: a
list entry like "10-12 HIGH STREET" should flag "10 HIGH STREET". Address-
to-address matching must use the canonical `fuzzy_join._numbers_compatible`
instead (set equality over ``\\d+[A-Z]?`` tokens) subset semantics there
instead (set equality over ``\\d+[A-Z]?`` tokens): subset semantics there
let a single flat absorb its whole building (see fuzzy_join docstring).
"""
left_nums = set(_NUMBER_RE.findall(left))
@ -574,7 +581,7 @@ def _is_current_planning_record(end_date: object) -> bool:
"""A planning record is current when it has no end-date OR its end-date is
still in the future. The planning.data.gov.uk `end-date` field marks when a
designation is RETIRED, so a future date (e.g. 2029-12-31) is a still-current
area and must NOT be dropped the previous "any non-empty date = ended"
area and must NOT be dropped. The previous "any non-empty date = ended"
logic wrongly excluded those (e.g. 22 current Gateshead conservation areas)."""
if end_date is None:
return True
@ -864,7 +871,7 @@ def _join_area_side_tables(
) -> pl.LazyFrame:
base = base.join(iod, left_on="lsoa21", right_on="LSOA code (2021)", how="left")
# Ethnicity is Census 2021 TS021 at LSOA (~33,755 areas), joined on the same
# `lsoa21` key as median age and IoD a ~100x granularity gain over the old
# `lsoa21` key as median age and IoD, a ~100x granularity gain over the old
# Local-Authority broadcast, with no change to the 6-bucket output schema.
base = base.join(ethnicity, on="lsoa21", how="left")
# Education (Census 2021 TS067 "highest level of qualification") is sourced at
@ -1140,9 +1147,9 @@ def _address_score(query: str, candidate: str | None, *, allow_token_set: bool)
if not candidate:
return 0
# token_set_ratio returns 100 whenever the shorter token set is a subset of
# the longer. For a NUMBER-LESS query that is unsafe a single locality
# the longer. For a NUMBER-LESS query that is unsafe: a single locality
# token (e.g. "KINGSWOOD") subsets to 100 against any long address that
# merely contains it so number-less queries score with token_sort_ratio
# merely contains it, so number-less queries score with token_sort_ratio
# only, matching the canonical fuzzy_join._score_bucket. For a NUMBERED
# query the unconditional fuzzy_join._numbers_compatible gate has already
# guaranteed the candidate carries identical house numbers, so token_set
@ -1241,14 +1248,14 @@ def _best_listing_match(
``uprn_index`` (postcode-independent, so it is robust even when the
listing's postcode is slightly off); (2) failing that, the highest
fuzzy street-address similarity within the listing's own postcode bucket.
No property-attribute heuristics are used `fuzzy_join._numbers_compatible`
No property-attribute heuristics are used. `fuzzy_join._numbers_compatible`
gates every fuzzy match unconditionally (so a number-less listing can never
match a numbered property, and vice versa), as in the canonical
`fuzzy_join._score_bucket`. A house number additionally lowers the score
threshold and (via `_address_score`) permits token_set scoring; a number-less
address scores on token_sort only and must match the street almost exactly.
The direct-EPC path layers a street-level fallback on top of this strict
matcher see `_best_street_epc_fallback`.
matcher. See `_best_street_epc_fallback`.
``addressed_fields`` names the candidate columns to fuzzy-match against (a
candidate may carry both a register and an EPC address). Returns
@ -1301,7 +1308,7 @@ def _best_listing_match(
# the listing's own postcode unit is the nearest segment of the street, and a
# certificate sharing a house-number token with the listing (e.g. listing
# "751 753 Cranbrook Road" vs certificate "751 Cranbrook Road", which fails the
# strict set-equality gate) is almost certainly the right property — both
# strict set-equality gate) is almost certainly the right property. Both
# should beat a bare attribute-agreement win.
_STREET_FALLBACK_SAME_POSTCODE_BONUS = 3.0
_STREET_FALLBACK_NUMBER_OVERLAP_BONUS = 8.0
@ -1309,8 +1316,8 @@ _STREET_FALLBACK_NUMBER_OVERLAP_BONUS = 8.0
# is era-homogeneous (a single development). When the same-street certificates
# span more than this many years between their oldest and newest build, the
# street mixes construction eras (a Victorian terrace with modern infill, say),
# so no single year represents the unidentified number-less listing property
# the fallback then publishes a null construction year rather than an arbitrary,
# so no single year represents the unidentified number-less listing property.
# The fallback then publishes a null construction year rather than an arbitrary,
# often wrong-by-a-century guess. Two adjacent EPC age bands (one development
# straddling a band boundary) span at most ~26 representative years, so this
# threshold keeps genuinely-uniform streets while rejecting mixed ones.
@ -1328,7 +1335,7 @@ def _street_match_with_reliable_construction_year(
``_STREET_FALLBACK_CONSTRUCTION_SPAN_YEARS`` between their oldest and newest
build, the street mixes construction eras and the matched certificate's year
(e.g. an 1890 Victorian house on a street with 2007 infill) would otherwise
be presented as the listing property's own so the year and its
be presented as the listing property's own, so the year and its
approximate-date flag are nulled, leaving an honest "unknown" rather than a
wrong-by-a-century value. Other street-representative EPC facts (energy
rating, floor area) are inherently per-property approximations the fallback
@ -1357,7 +1364,7 @@ def _best_street_epc_fallback(
"""Street-level direct-EPC fallback for listings the strict matcher missed.
~90% of scraped listings publish a street-level address only ("Oldstead
Road, Bromley" Rightmove never exposes the house number or UPRN), so the
Road, Bromley", since Rightmove never exposes the house number or UPRN), so the
strict matcher in `_best_listing_match` can never match them against the
virtually-always-numbered EPC register and their EPC-derived fields
(energy rating, interior height, former-council-house flag, construction
@ -1371,7 +1378,7 @@ def _best_street_epc_fallback(
same-postcode-unit preference and a house-number-overlap bonus (a
numbered listing that failed the strict set-equality gate, e.g. a
"751 753" range vs "751", still lands on the right property). The result
is street-representative rather than property-exact hence the distinct
is street-representative rather than property-exact, hence the distinct
"street" method label so downstream consumers can tell the two confidence
levels apart. The matched certificate's construction year is kept only when
the street is era-homogeneous (see
@ -1439,7 +1446,7 @@ def _best_street_epc_fallback(
# bathrooms (the upstream storage.py defect noted in
# `_finalize_listings`). It systematically over-counts, so comparing
# it to the EPC habitable-room count biases selection toward larger,
# typically older certificates — the opposite of a useful signal —
# typically older certificates (the opposite of a useful signal),
# and there is no clean listing-side habitable-room count to use.
if (
listing_postcode
@ -1472,9 +1479,9 @@ def _load_listings_for_merge(listings_path: Path, arcgis_path: Path) -> pl.DataF
"""Read the listings parquet and prepare it for the wide-frame merge.
Output is keyed by `_listing_idx` and carries:
* `postcode` canonical (NSPL `pcds`) form, with terminated postcodes
* `postcode`: canonical (NSPL `pcds`) form, with terminated postcodes
remapped to their nearest active successor;
* `pp_address` the listing's raw register address (used as the
* `pp_address`: the listing's raw register address (used as the
address half of the fuzzy match);
* one `_actual_*` overlay column per `_LISTING_OVERLAY_SOURCES` entry.
"""
@ -1684,7 +1691,7 @@ def _listing_match_frame(listings: pl.DataFrame) -> pl.DataFrame:
Listings are matched to EPC certificates and properties by UPRN and by
fuzzy street address within their (now accurate, detail-page-sourced)
postcode never by coordinate proximity so no projected easting/northing
postcode (never by coordinate proximity), so no projected easting/northing
is computed here. `_listing_uprn` flows through from the loaded listings.
"""
return listings.with_columns(
@ -1763,8 +1770,8 @@ def _index_candidates(
The EPC register's UPRN is NOT unique: a single building/parent UPRN fans
across many distinct flats (up to 58 distinct (address, postcode) rows in
the 2026-06 data; ~9k UPRNs collide, touching ~20k epc_pp rows). Such a
UPRN cannot serve as a 1:1 exact-match key it would mis-link a listing to
one arbitrary flat so any UPRN that resolves to more than one distinct
UPRN cannot serve as a 1:1 exact-match key (it would mis-link a listing to
one arbitrary flat), so any UPRN that resolves to more than one distinct
``(postcode_key, address_key)`` identity is dropped from ``uprn_index``;
those listings fall back to the fuzzy street-address matcher, which
disambiguates the specific flat. A UPRN repeated for the SAME identity
@ -1878,7 +1885,7 @@ def _index_epc_streets(
maps outcode -> the tokens appearing in at least a quarter of that
outcode's street keys. Those are locality suffixes (LONDON, SURREY, the
town name) rather than street names, and a fallback match must be anchored
by at least one token that is NOT one of them otherwise a town-only
by at least one token that is NOT one of them. Otherwise a town-only
listing address ("COULSDON SURREY") token_set-inflates to 100 against any
street key carrying the same locality suffix and matches an arbitrary
street in the outcode.
@ -2260,7 +2267,7 @@ def _finalize_listings(df: pl.DataFrame) -> pl.DataFrame:
@dataclass
class _BuildResult:
"""Outputs of `_build` exactly one of the two slot pairs is populated."""
"""Outputs of `_build`: exactly one of the two slot pairs is populated."""
postcode: pl.DataFrame | None = None
properties: pl.DataFrame | None = None
@ -2286,6 +2293,38 @@ def _fill_property_level_no_defaults(frame: pl.LazyFrame) -> pl.LazyFrame:
)
def _epc_council_by_postcode(wide: pl.LazyFrame) -> pl.LazyFrame:
"""Aggregate the per-property EPC social-tenure flags to POSTCODE percentages.
Two EPC-derived AREA columns, each a share of *all* deduped dwellings in the
postcode (one row per dwelling in ``wide``). This denominator is the dwelling
universe, NOT Census households: a dwelling with no EPC is ``was_council_house
== "No"`` and so counts against the share, making these EPC-coverage-limited
lower bounds that are not directly comparable to the Census ``% Social rent``
(which stays at LSOA grain). A postcode holds relatively few dwellings, so
these shares are coarse and noisier than an LSOA average.
* ``% Council housing``: dwellings ever recorded as council/social housing
per EPC (``was_council_house == "Yes"``).
* ``% Ex-council``: ever-council dwellings whose LATEST EPC certificate is no
longer social rented (sold off / no longer social):
``was_council_house == "Yes" AND latest_tenure_status != "Rented (social)"``.
A null ``latest_tenure_status`` counts as not-currently-social.
``was_council_house`` is already "Yes"/"No" filled for every row (see
``_fill_property_level_no_defaults``), so the means are over the full postcode.
Returns a postcode-keyed LazyFrame to left-join onto the AREA frame only.
"""
currently_social = (pl.col("latest_tenure_status") == "Rented (social)").fill_null(
False
)
ever_social = pl.col("was_council_house") == "Yes"
return wide.group_by("postcode").agg(
(ever_social.mean() * 100).round(1).alias("% Council housing"),
((ever_social & ~currently_social).mean() * 100).round(1).alias("% Ex-council"),
)
def _build(
epc_pp_path: Path,
arcgis_path: Path,
@ -2311,9 +2350,9 @@ def _build(
"""Build postcode/properties dataframes (or enriched listings) from epc_pp + auxiliary data.
Modes:
* `normal` produces (postcode_df, properties_df) as before. Ignores
* `normal`: produces (postcode_df, properties_df) as before. Ignores
`actual_listings_path` if supplied.
* `listings` requires `actual_listings_path`; produces a single
* `listings`: requires `actual_listings_path`; produces a single
enriched-listings DataFrame and skips the postcode/properties outputs.
Listings flow through the same enrichment joins as historical rows,
so postcode-scoped features (tree density, crime, deprivation, ) end
@ -2331,8 +2370,8 @@ def _build(
)
_validate_lad_source_coverage(iod_path, rental_prices_path)
# The dwelling universe floor filter, terminated-postcode remap,
# collapse-dedupe, restrict to active English postcodes is shared with
# The dwelling universe (floor filter, terminated-postcode remap,
# collapse-dedupe, restrict to active English postcodes) is shared with
# price estimation so estimates line up 1:1 with these rows. See
# pipeline.transform.property_base.
wide = build_property_base(epc_pp_path, arcgis_path)
@ -2460,6 +2499,17 @@ def _build(
wide = _join_area_side_tables(wide, **area_side_tables)
postcode_area = _join_area_side_tables(postcode_area, **area_side_tables)
# EPC-derived council/ex-council shares: aggregate the per-property social
# tenure flags to POSTCODE percentages and attach to the AREA frame only
# (these are area columns, like the Census tenure block, not per-property).
# Built before dropping latest_tenure_status, which is its only consumer.
epc_council_by_postcode = _epc_council_by_postcode(wide)
postcode_area = postcode_area.join(epc_council_by_postcode, on="postcode", how="left")
# latest_tenure_status is property-grain and not in _AREA_COLUMNS, so the
# split would otherwise leak it into properties.parquet. It has served its
# purpose (the postcode aggregate above), so drop it from the property frame.
wide = wide.drop("latest_tenure_status", strict=False)
# Derive bedroom count: habitable rooms - 1 (assuming 1 reception room), clipped to 0..4
wide = wide.with_columns(
(pl.col("number_habitable_rooms") - 1)
@ -2649,7 +2699,7 @@ def main():
required=False,
help=(
"Optional scraped-listings parquet. When provided, listings flow "
"through the same merge pipeline as historical properties — set "
"through the same merge pipeline as historical properties. Set "
"--output-listings to write the enriched-listings file instead "
"of the postcode/properties files."
),

View file

@ -43,7 +43,7 @@ GROCERY_STATIC_EXCLUDED_CATEGORIES = {
# Scope: "Public Park Or Garden" is the core park function. "Playing Field"
# (open public recreation grounds) is borderline but kept: outside big cities
# the local rec ground is the de facto park. "Play Space" (playgrounds) is
# excluded a playground is not a park, and "Playground" is already its own
# excluded: a playground is not a park, and "Playground" is already its own
# OSM-derived category. The remaining functions (Religious Grounds, Golf
# Course, Cemetery, Allotments, Bowling Green, Tennis Court, Other Sports
# Facility) are clearly not parks.
@ -76,7 +76,7 @@ def _groceries_categories(pois: pl.DataFrame) -> list[str]:
with group "Groceries"; it never emits the literal "Supermarket". Collecting
every Groceries category captures both the OSM strings and the brand names.
Speciality food retail (bakeries, butchers, delis, off-licences) is
excluded see GROCERY_STATIC_EXCLUDED_CATEGORIES.
excluded. See GROCERY_STATIC_EXCLUDED_CATEGORIES.
"""
if "group" not in pois.columns:
raise ValueError("POI dataframe must include a 'group' column")
@ -138,7 +138,7 @@ def _greenspace_count_frame(greenspace: pl.DataFrame) -> pl.DataFrame:
os_greenspace.parquet is one row per ACCESS POINT (park gate), which is the
right grain for nearest-distance (the nearest gate is what matters) but
wildly over-counts "Number of amenities (Park) within Xkm" a large park
wildly over-counts "Number of amenities (Park) within Xkm": a large park
with 30 gates counted as 30 parks. Counting uses one row per site at the
site centroid (falling back to the first access point when no centroid is
available). Degrades gracefully: a legacy parquet without `site_id` is