217 lines
8.5 KiB
Python
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()
|