This commit is contained in:
Andras Schmelczer 2026-07-03 18:28:56 +01:00
parent 909e241907
commit 1ee796b282
29 changed files with 250 additions and 126 deletions

View file

@ -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}]
]