149 lines
5.7 KiB
Python
149 lines
5.7 KiB
Python
"""Join the slim price estimates back onto properties.parquet.
|
|
|
|
Price estimation runs on ``price_inputs.parquet`` (built by ``property_base``
|
|
straight from epc_pp + arcgis, independently of merge's area features) and emits
|
|
``price_estimates.parquet``: the natural key (Postcode + coalesced address) plus
|
|
``Estimated current price`` / ``Est. price per sqm``. This step joins those two
|
|
columns onto properties.parquet to produce the file the server consumes.
|
|
|
|
Why the natural key
|
|
-------------------
|
|
Estimates and properties are built by separate runs, so a positional row index
|
|
would not line up. Instead both derive the key ``(Postcode, coalesce(register
|
|
address, EPC address))``, which is unique and non-null on the deduped dwelling
|
|
universe (see ``property_base._dedupe_collapsed_properties``) and identical on
|
|
both sides because both start from that same universe. So estimates map onto
|
|
properties 1:1 regardless of row order.
|
|
|
|
Re-running is safe: any pre-existing estimate columns are dropped first, and the
|
|
join is keyed (not positional), so a second run reproduces the same result. The
|
|
join refuses if any property has no estimate (the dwelling universes diverged,
|
|
e.g. a stale price_inputs vs a newer epc_pp) rather than silently leaving prices
|
|
null. Output is written to a temp file and atomically renamed.
|
|
"""
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
import polars as pl
|
|
|
|
from pipeline.transform.price_estimation.utils import (
|
|
ESTIMATE_COLUMNS,
|
|
JOIN_ADDRESS,
|
|
JOIN_KEYS,
|
|
join_address_expr,
|
|
)
|
|
|
|
|
|
def join_estimates(properties: Path, estimates_path: Path) -> int:
|
|
"""Augment ``properties`` in place with the estimate columns; return rows.
|
|
|
|
Joins the slim estimates onto properties by the natural key and atomically
|
|
replaces properties.parquet. Idempotent: any estimate columns already on the
|
|
file are dropped first.
|
|
"""
|
|
estimates = pl.scan_parquet(estimates_path)
|
|
est_cols = estimates.collect_schema().names()
|
|
missing = [c for c in (*JOIN_KEYS, *ESTIMATE_COLUMNS) if c not in est_cols]
|
|
if missing:
|
|
raise ValueError(f"{estimates_path}: missing columns {missing}")
|
|
|
|
stats = estimates.select(
|
|
n=pl.len(), unique=pl.struct(JOIN_KEYS).n_unique()
|
|
).collect(engine="streaming")
|
|
n_estimates, n_unique = stats["n"][0], stats["unique"][0]
|
|
if n_unique != n_estimates:
|
|
raise ValueError(
|
|
f"{estimates_path}: natural key {JOIN_KEYS} is not unique "
|
|
f"({n_estimates - n_unique:,} duplicate rows)"
|
|
)
|
|
|
|
n_properties = pl.scan_parquet(properties).select(pl.len()).collect().item()
|
|
|
|
# Drop any estimate columns already present (idempotent re-run) and attach the
|
|
# coalesced-address half of the natural key.
|
|
properties_keyed = (
|
|
pl.scan_parquet(properties)
|
|
.drop(ESTIMATE_COLUMNS, strict=False)
|
|
.with_columns(join_address_expr())
|
|
)
|
|
|
|
# Every property must have an estimate: estimates and properties come from the
|
|
# same dwelling universe, so a gap means a stale/foreign price_inputs (e.g.
|
|
# built from a different epc_pp). Fail loudly instead of nulling prices.
|
|
#
|
|
# This assumes properties.parquet contains ONLY epc_pp-derived dwellings, which
|
|
# is true for the production merge output. Running merge with --actual-listings
|
|
# appends listing seed rows whose (Postcode, address) keys are absent from
|
|
# price_inputs (built straight from epc_pp), which would trip the guard below.
|
|
# Enabling listing integration on the primary output therefore requires
|
|
# price_inputs to include those seed rows too.
|
|
unmatched = (
|
|
properties_keyed.select(JOIN_KEYS)
|
|
.join(estimates.select(JOIN_KEYS), on=JOIN_KEYS, how="anti")
|
|
.select(pl.len())
|
|
.collect(engine="streaming")
|
|
.item()
|
|
)
|
|
if unmatched:
|
|
raise ValueError(
|
|
f"{properties}: {unmatched:,} of {n_properties:,} properties have no "
|
|
"matching estimate; the price_inputs and properties dwelling universes "
|
|
"differ (regenerate price_inputs.parquet from the current epc_pp)."
|
|
)
|
|
|
|
# maintain_order="left" keeps properties in merge's row order; the unique key
|
|
# cannot fan the join out, so the row count is preserved.
|
|
result = properties_keyed.join(
|
|
estimates, on=JOIN_KEYS, how="left", maintain_order="left"
|
|
).drop(JOIN_ADDRESS)
|
|
|
|
tmp = properties.with_name(properties.name + ".tmp")
|
|
result.sink_parquet(tmp)
|
|
|
|
written = pl.scan_parquet(tmp).select(pl.len()).collect().item()
|
|
if written != n_properties:
|
|
tmp.unlink(missing_ok=True)
|
|
raise ValueError(
|
|
f"{properties}: join changed the row count "
|
|
f"({n_properties:,} -> {written:,})"
|
|
)
|
|
|
|
tmp.replace(properties)
|
|
return written
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Join price_estimates.parquet onto properties.parquet"
|
|
)
|
|
parser.add_argument(
|
|
"--properties",
|
|
type=Path,
|
|
required=True,
|
|
help="properties.parquet (read, then overwritten with the estimate "
|
|
"columns joined in)",
|
|
)
|
|
parser.add_argument(
|
|
"--estimates",
|
|
type=Path,
|
|
required=True,
|
|
help="Slim price_estimates.parquet from price_estimation.estimate",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
written = join_estimates(args.properties, args.estimates)
|
|
size_mb = args.properties.stat().st_size / (1024 * 1024)
|
|
n_priced = (
|
|
pl.scan_parquet(args.properties)
|
|
.filter(pl.col("Estimated current price").is_not_null())
|
|
.select(pl.len())
|
|
.collect()
|
|
.item()
|
|
)
|
|
print(f"Wrote {args.properties} ({size_mb:.1f} MB)")
|
|
print(f" {written:,} rows, {n_priced:,} with an estimated current price")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|