new demo mode & tenure
This commit is contained in:
parent
7656f24544
commit
4a0f00f2a4
64 changed files with 2875 additions and 338 deletions
|
|
@ -266,11 +266,79 @@ def test_run_joins_domestic_zip_with_price_paid(tmp_path: Path):
|
|||
]
|
||||
assert df.get_column("renovation_history").list.len().to_list() == [1]
|
||||
assert df.get_column("historical_prices").list.len().to_list() == [2]
|
||||
# Tenure timeline: the social baseline (a rental, so emitted) followed by the
|
||||
# switch to owner-occupied that closes the rented period.
|
||||
assert df.get_column("tenure_history").to_list() == [
|
||||
[
|
||||
{"year": 2023, "status": "Rented (social)"},
|
||||
{"year": 2024, "status": "Owner-occupied"},
|
||||
]
|
||||
]
|
||||
# Audit trail: the accepted fuzzy match's score is published (100 = exact
|
||||
# post-normalisation address match).
|
||||
assert df.get_column("epc_match_score").to_list() == [100]
|
||||
|
||||
|
||||
def test_run_tenure_history_tracks_rent_owner_transitions(tmp_path: Path):
|
||||
# owner-occupied (2016) -> privately rented (2019) -> owner-occupied (2023).
|
||||
# The owner-occupied baseline is suppressed (it is the unremarkable default);
|
||||
# the let-out and the return to owner-occupation are both surfaced so the
|
||||
# rented period [2019, 2023) is visible on the timeline.
|
||||
zip_path = tmp_path / "domestic-csv.zip"
|
||||
_write_epc_zip(
|
||||
zip_path,
|
||||
[
|
||||
_row(inspection_date="2016-04-01", tenure="owner-occupied"),
|
||||
_row(inspection_date="2019-04-01", tenure="Rented (private)"),
|
||||
_row(inspection_date="2023-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.get_column("tenure_history").to_list() == [
|
||||
[
|
||||
{"year": 2019, "status": "Rented (private)"},
|
||||
{"year": 2023, "status": "Owner-occupied"},
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
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.
|
||||
zip_path = tmp_path / "domestic-csv.zip"
|
||||
_write_epc_zip(
|
||||
zip_path,
|
||||
[
|
||||
_row(inspection_date="2016-04-01", tenure="owner-occupied"),
|
||||
_row(inspection_date="2023-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.get_column("tenure_history").to_list() == [None]
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -397,8 +465,9 @@ def test_run_does_not_attach_epc_facts_to_low_score_address_match(tmp_path: Path
|
|||
|
||||
df = pl.read_parquet(output_path)
|
||||
|
||||
assert df.height == 1
|
||||
assert df.select(
|
||||
# The low-score EPC must NOT attach its facts to the price-paid property.
|
||||
sold = df.filter(pl.col("pp_address").is_not_null())
|
||||
assert sold.select(
|
||||
"pp_address",
|
||||
"epc_address",
|
||||
"total_floor_area",
|
||||
|
|
@ -415,6 +484,29 @@ def test_run_does_not_attach_epc_facts_to_low_score_address_match(tmp_path: Path
|
|||
}
|
||||
]
|
||||
|
||||
# Instead the unmatched EPC enters the universe as its own EPC-only row: a
|
||||
# dwelling we hold a certificate for but that has no Land Registry sale, so
|
||||
# its EPC facts are present while the price columns stay null.
|
||||
epc_only = df.filter(pl.col("pp_address").is_null())
|
||||
assert epc_only.select(
|
||||
"epc_address",
|
||||
"postcode",
|
||||
"total_floor_area",
|
||||
"current_energy_rating",
|
||||
"latest_price",
|
||||
"epc_match_score",
|
||||
).to_dicts() == [
|
||||
{
|
||||
"epc_address": "1 Totally Different Road",
|
||||
"postcode": "AA1 1AA",
|
||||
"total_floor_area": 84.5,
|
||||
"current_energy_rating": "C",
|
||||
"latest_price": None,
|
||||
"epc_match_score": None,
|
||||
}
|
||||
]
|
||||
assert df.height == 2
|
||||
|
||||
|
||||
def test_run_excludes_category_b_sales_from_price_aggregations(tmp_path: Path):
|
||||
# Category B entries (repossessions, bulk/portfolio, power-of-sale) must not
|
||||
|
|
@ -506,6 +598,54 @@ def test_run_new_build_keeps_early_first_transfer_when_sub_min_price(tmp_path: P
|
|||
assert df.get_column("historical_prices").list.len().to_list() == [1]
|
||||
|
||||
|
||||
def test_run_caps_band_year_at_first_transfer_year(tmp_path: Path):
|
||||
# A non-new-build (old_new "N") whose EPC age band ("2003 onwards" -> 2003)
|
||||
# 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.
|
||||
zip_path = tmp_path / "domestic-csv.zip"
|
||||
_write_epc_zip(
|
||||
zip_path, [_row(construction_age_band="England and Wales: 2003 onwards")]
|
||||
)
|
||||
|
||||
price_paid_path = tmp_path / "price-paid.parquet"
|
||||
_price_paid_frame(prices=[250_000], dates=[date(1998, 5, 1)]).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
|
||||
# Capped at the 1998 first sale, not the 2003 band midpoint.
|
||||
assert df.get_column("construction_age_band").to_list() == [1998]
|
||||
assert df.get_column("is_construction_date_approximate").to_list() == [1]
|
||||
|
||||
|
||||
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.
|
||||
zip_path = tmp_path / "domestic-csv.zip"
|
||||
_write_epc_zip(zip_path)
|
||||
|
||||
price_paid_path = tmp_path / "price-paid.parquet"
|
||||
_price_paid_frame(prices=[250_000], dates=[date(2020, 5, 1)]).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.get_column("construction_age_band").to_list() == [1958]
|
||||
assert df.get_column("is_construction_date_approximate").to_list() == [1]
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -548,13 +688,16 @@ def test_run_keeps_sale_above_lowered_min_price(tmp_path: Path):
|
|||
assert df.get_column("latest_price").to_list() == [30_000]
|
||||
|
||||
|
||||
def _write_epc_zip(zip_path: Path) -> None:
|
||||
"""Write a minimal domestic zip with the default certificate row."""
|
||||
def _write_epc_zip(zip_path: Path, rows: list[dict[str, str]] | None = None) -> None:
|
||||
"""Write a minimal domestic zip with the given certificate rows.
|
||||
|
||||
Defaults to a single default certificate row when ``rows`` is omitted.
|
||||
"""
|
||||
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.DictWriter(csv_buffer, fieldnames=EPC_SOURCE_COLUMNS)
|
||||
writer.writeheader()
|
||||
writer.writerow(_row())
|
||||
writer.writerows(rows if rows is not None else [_row()])
|
||||
archive.writestr("certificates-2024.csv", csv_buffer.getvalue())
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue