perfect-postcode/pipeline/transform/property_base.py
2026-07-12 15:03:33 +01:00

217 lines
8.5 KiB
Python

"""Shared property base: the dwelling universe before any area enrichment.
This is the single source of truth for *which* dwellings exist and their
intrinsic, source-level attributes (price, floor area, type, addresses). Both
``merge`` (which enriches it with postcode/LSOA-keyed area features to build
properties.parquet) and price estimation (which only needs the intrinsic
columns) start from exactly these rows, so estimates computed here line up 1:1
with the final properties by the natural key ``(Postcode, coalesced address)``.
Living in its own module is what lets price estimation be *cached* across
merge changes: ``price_inputs.parquet`` depends only on epc_pp + arcgis + this
file, so adding an area column to merge.py does not invalidate it and the
expensive index/kNN steps are skipped.
"""
import argparse
from pathlib import Path
import polars as pl
from pipeline.utils.postcode_mapping import build_postcode_mapping
MIN_FLOOR_AREA_M2 = 10
# Columns price estimation reads, with the final (properties.parquet) names so
# index.py/estimate.py and the join all speak the same schema. The two address
# columns form the natural join key (Postcode + their coalesce).
PRICE_INPUT_SELECT = [
pl.col("postcode").alias("Postcode"),
pl.col("total_floor_area").alias("Total floor area (sqm)"),
pl.col("latest_price").alias("Last known price"),
pl.col("date_of_transfer").alias("Date of last transaction"),
"historical_prices",
pl.col("pp_address").alias("Address per Property Register"),
pl.col("epc_address").alias("Address per EPC"),
]
def _active_english_postcode_area(arcgis_raw: pl.LazyFrame) -> pl.LazyFrame:
"""Return the supported postcode universe with geography join keys."""
return (
arcgis_raw.filter(pl.col("ctry25cd") == "E92000001")
.filter(pl.col("doterm").is_null())
.select(
pl.col("pcds").alias("postcode"),
"lat",
pl.col("long").alias("lon"),
"ctry25cd",
pl.col("lsoa21cd").alias("lsoa21"),
pl.col("oa21cd").alias("oa21"),
pl.col("pcon24cd").alias("pcon"),
)
.drop_nulls(["postcode"])
.unique(["postcode"])
)
def _remap_terminated_postcodes(
wide: pl.LazyFrame, postcode_mapping: pl.LazyFrame
) -> pl.LazyFrame:
return (
wide.join(
postcode_mapping,
left_on="postcode",
right_on="old_postcode",
how="left",
)
.with_columns(
pl.coalesce("new_postcode", "postcode").alias("postcode"),
)
.drop("new_postcode")
)
def _dedupe_collapsed_properties(wide: pl.LazyFrame) -> pl.LazyFrame:
"""Keep one row per (postcode, address): the most-recent transaction.
The terminated-postcode remap can map two distinct postcodes onto one active
successor, collapsing the same physical address onto a single
(postcode, address) key with conflicting sale records. Keep the row with the
latest date_of_transfer so the headline price/date reflect the most recent
transaction; genuinely distinct addresses are untouched.
The dedup key coalesces the price-paid address with the EPC address: EPC-only
dwellings (never sold) have a null pp_address, so keying on pp_address alone
would collapse EVERY EPC-only dwelling in a postcode onto one
(postcode, null) key and silently drop all but one. Each dwelling's coalesced
address is unique within its postcode (the EPC frame is deduped on
address+postcode upstream), so the coalesced key keeps them distinct while
leaving sold-property dedup unchanged, since pp_address wins the coalesce whenever
a sale exists.
"""
return (
wide.with_columns(
pl.coalesce("pp_address", "epc_address").alias("_dedupe_address")
)
.sort("date_of_transfer", descending=True, nulls_last=True)
.unique(
subset=["postcode", "_dedupe_address"], keep="first", maintain_order=True
)
.drop("_dedupe_address")
)
def _filter_to_active_english_postcodes(
wide: pl.LazyFrame, active_postcodes: pl.LazyFrame
) -> pl.LazyFrame:
return wide.join(active_postcodes, on="postcode", how="semi")
def property_type_expr() -> pl.Expr:
"""Unaliased property-type expression: prefer EPC, fall back to price-paid.
For Houses, use built_form (e.g. Semi-Detached, Mid-Terrace) for finer
detail. Depends only on intrinsic base columns (epc_property_type,
pp_property_type, built_form), so merge and price_inputs derive the same
value. Callers alias it ("property_type" in merge, "Property type" in
price_inputs).
"""
bad_built_form = pl.col("built_form").is_null() | pl.col("built_form").is_in(
["NO DATA!", "Not Recorded"]
)
has_epc = pl.col("epc_property_type").is_not_null()
is_house = pl.col("epc_property_type") == "House"
return (
pl.when(has_epc & is_house & ~bad_built_form)
.then(pl.col("built_form"))
.when(has_epc & is_house)
.then(pl.col("pp_property_type"))
.when(has_epc)
.then(pl.col("epc_property_type"))
.otherwise(pl.col("pp_property_type"))
# Unify EPC's "Flat"/"Maisonette" with price-paid's "Flats/Maisonettes",
# collapse terrace sub-types, and fold rare types into "Other"
.replace(
{
"Flat": "Flats/Maisonettes",
"Maisonette": "Flats/Maisonettes",
"End-Terrace": "Terraced",
"Mid-Terrace": "Terraced",
"Enclosed End-Terrace": "Terraced",
"Enclosed Mid-Terrace": "Terraced",
"Bungalow": "Other",
"Park home": "Other",
}
)
)
def build_postcode_centroids(arcgis_path: Path) -> pl.LazyFrame:
"""One row per active-English postcode with its lat/lon, from arcgis.
This is the lat/lon source price estimation needs (index sector centroids,
kNN). It is the same per-postcode lat/lon merge writes into postcode.parquet
(both come from arcgis), but built straight from arcgis so the index/estimate
steps do not depend on the merge output. Adding an area column to merge
therefore does not invalidate the expensive price index/kNN.
"""
return _active_english_postcode_area(pl.scan_parquet(arcgis_path)).select(
pl.col("postcode").alias("Postcode"), "lat", "lon"
)
def build_property_base(epc_pp_path: Path, arcgis_path: Path) -> pl.LazyFrame:
"""The deduped, active-English dwelling universe from epc_pp + arcgis.
Floor filter -> terminated-postcode remap -> collapse-dedupe -> restrict to
the active English postcode universe. Returns a LazyFrame with the original
epc_pp column names; merge enriches it, the CLI projects it to price_inputs.
"""
wide = pl.scan_parquet(epc_pp_path).filter(
pl.col("total_floor_area").is_null()
| (pl.col("total_floor_area") > MIN_FLOOR_AREA_M2)
)
postcode_mapping = build_postcode_mapping(arcgis_path)
wide = _remap_terminated_postcodes(wide, postcode_mapping.lazy())
wide = _dedupe_collapsed_properties(wide)
active_postcodes = (
_active_english_postcode_area(pl.scan_parquet(arcgis_path))
.select("postcode")
.unique()
)
return _filter_to_active_english_postcodes(wide, active_postcodes)
def main():
parser = argparse.ArgumentParser(
description="Write the slim price-estimation inputs from epc_pp + arcgis"
)
parser.add_argument("--epc-pp", type=Path, required=True)
parser.add_argument("--arcgis", type=Path, required=True)
parser.add_argument(
"--output", type=Path, required=True, help="price_inputs.parquet output"
)
parser.add_argument(
"--centroids",
type=Path,
required=True,
help="postcode_centroids.parquet output (Postcode, lat, lon)",
)
args = parser.parse_args()
base = build_property_base(args.epc_pp, args.arcgis)
price_inputs = base.with_columns(
property_type_expr().alias("Property type")
).select(*PRICE_INPUT_SELECT, "Property type")
price_inputs.sink_parquet(args.output)
n = pl.scan_parquet(args.output).select(pl.len()).collect().item()
print(f"Wrote {args.output} ({args.output.stat().st_size / 1e6:.1f} MB), {n:,} dwellings")
build_postcode_centroids(args.arcgis).sink_parquet(args.centroids)
n_pc = pl.scan_parquet(args.centroids).select(pl.len()).collect().item()
print(f"Wrote {args.centroids} ({args.centroids.stat().st_size / 1e6:.1f} MB), {n_pc:,} postcodes")
if __name__ == "__main__":
main()