Model/tests/applications/modelling_e2e/test_predict_epc_historic.py
Jun-te Kim efdb8e816c feat(modelling_e2e): condition prediction on the expired Historic EPC (ADR-0054)
ADR-0054 gives a prediction conditioned by an expired Historic EPC the source
"expired", so reporting can tell "no EPC at all" apart from "only an expired
one". It was implemented in IngestionOrchestrator — but the pipeline that
actually runs is modelling_e2e, which predicts through its own _predict_epc and
never adopted it. Result: zero `expired` rows have ever been written, and
`epc_property.source` holds only 'lodged' and 'predicted'.

Two things kept the flavour unreachable here, and both had to go:

  - _predict_epc never looked at the historic backup at all, so no prediction
    was ever conditioned.
  - _flush_writes passed the literal source="predicted", and _PropertyWrite had
    no field to carry a flavour, so one would have been dropped before the write
    even if computed.

_predict_epc now mirrors IngestionOrchestrator._predict: the expired cert's
stable attributes fill the gaps Landlord Overrides left (overrides still win
where both speak) and condition the cohort, and it returns the source alongside
the EPC. _PropertyWrite carries it; _flush_writes persists it. save_batch
already groups deletes by source family, so a mixed predicted/expired batch
clears the shared slot correctly.

Ships dark: the reader is built only when HISTORIC_EPC_S3_ROOT is set, so
without it every prediction stays plain "predicted", exactly as today.
DEFAULT_S3_ROOT names the dev bucket, so defaulting to it would have a prod
lambda silently reading dev data — Terraform sets the var per environment.

Note this also rescues properties that currently fail to model outright: an
expired cert can supply the property_type no override resolved, where today
that raises UnresolvedPropertyTypeError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:15:39 +00:00

188 lines
6.8 KiB
Python

"""``_predict_epc`` conditions its prediction on an expired Historic EPC and
labels the result "expired" (ADR-0054).
The gov EPC API only serves certificates registered since 2012, so a dwelling
whose only certificate predates that is EPC-less to Ingestion and gets a
predicted picture. Persisting that picture as plain "predicted" loses the fact
that a real — if stale — certificate exists, which is exactly the distinction
portfolio reporting draws between "no EPC at all" and "only an expired one".
`IngestionOrchestrator` already does this; `modelling_e2e` is the pipeline that
actually runs, and predicts through its own `_predict_epc`. These tests pin the
source label there.
"""
from __future__ import annotations
import dataclasses
import json
from datetime import date
from pathlib import Path
from typing import Any, Callable, Optional, cast
from applications.modelling_e2e.handler import (
_predict_epc, # pyright: ignore[reportPrivateUsage]
)
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.historic_epc import HistoricEpc
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.epc_prediction.comparable_properties import ComparableProperty
from domain.epc_prediction.epc_prediction import EpcPrediction
from domain.epc_prediction.prediction_target import (
PredictionTarget,
PredictionTargetAttributes,
)
from repositories.property.override_backed_prediction_attributes_reader import (
OverrideBackedPredictionAttributesReader,
)
_JSON_SAMPLES = Path(__file__).resolve().parents[3] / "backend/epc_api/json_samples"
def _epc() -> EpcPropertyData:
raw: dict[str, Any] = json.loads(
(_JSON_SAMPLES / "RdSAP-Schema-21.0.0" / "epc.json").read_text()
)
return EpcPropertyDataMapper.from_api_response(raw)
def _cohort() -> list[ComparableProperty]:
# Three same-type neighbours (property_type "0"), distinct addresses so the
# dedupe keeps all three.
return [
ComparableProperty(
epc=_epc(),
certificate_number=f"CERT-{i}",
address=f"{i} Some Street",
registration_date=date(2023, 1, i + 1),
)
for i in range(3)
]
def _historic(**overrides: str) -> HistoricEpc:
"""A pre-2012 certificate. Every field defaults to "" — unset fields resolve
to None in `conditioning_from_historic`, so a test conditions on exactly the
attributes it names and nothing else."""
fields = {f.name: "" for f in dataclasses.fields(HistoricEpc)}
fields.update(overrides)
return HistoricEpc(**fields)
class _HistoricReaderReturning:
"""The historic-EPC backup, holding one record — or none, on a miss."""
def __init__(self, record: Optional[HistoricEpc]) -> None:
self._record = record
self.lookups: list[tuple[str, str]] = []
def record_for_uprn(self, uprn: str, postcode: str) -> Optional[HistoricEpc]:
self.lookups.append((uprn, postcode))
return self._record
class _FixedAttributesReader:
def __init__(self, attributes: PredictionTargetAttributes) -> None:
self._attributes = attributes
def attributes_for(self, property_id: int) -> PredictionTargetAttributes:
return self._attributes
def _reader(
attributes: PredictionTargetAttributes,
) -> OverrideBackedPredictionAttributesReader:
return cast(
OverrideBackedPredictionAttributesReader, _FixedAttributesReader(attributes)
)
def _no_broaden() -> Callable[[PredictionTarget], list[ComparableProperty]]:
return lambda _target: []
def _predict(
*,
attributes: PredictionTargetAttributes,
historic: Optional[_HistoricReaderReturning],
) -> tuple[EpcPropertyData, str]:
return _predict_epc(
property_id=733290,
uprn=100021987320,
postcode="SE4 1DX",
portfolio_id=796,
attributes_reader=_reader(attributes),
historic_epc_reader=historic,
coordinates=None,
cohort_for=lambda _postcode: _cohort(),
broaden=_no_broaden(),
predictor=EpcPrediction(),
)
def test_prediction_conditioned_by_expired_historic_epc_is_sourced_expired() -> None:
# Arrange — an override resolves the type, and the backup also holds this
# UPRN's expired pre-2012 certificate.
historic = _HistoricReaderReturning(_historic(property_type="House"))
# Act
_predicted, source = _predict(
attributes=PredictionTargetAttributes(property_type="0"), historic=historic
)
# Assert — the expired certificate is recorded in the source, not discarded,
# and it was looked up by this Property's UPRN and postcode.
assert source == "expired"
assert historic.lookups == [("100021987320", "SE4 1DX")]
def test_prediction_without_a_historic_record_is_sourced_predicted() -> None:
# Arrange — the backup is wired, but holds nothing for this UPRN.
# Act
_predicted, source = _predict(
attributes=PredictionTargetAttributes(property_type="0"),
historic=_HistoricReaderReturning(None),
)
# Assert — a blind gap-fill stays plain "predicted".
assert source == "predicted"
def test_unwired_historic_reader_leaves_every_prediction_predicted() -> None:
# Arrange — the feature is off (no HISTORIC_EPC_S3_ROOT in the environment).
# Act
_predicted, source = _predict(
attributes=PredictionTargetAttributes(property_type="0"), historic=None
)
# Assert — behaviour is exactly as before the ADR-0054 wiring.
assert source == "predicted"
def test_historic_epc_supplies_the_property_type_when_no_override_resolved() -> None:
# Arrange — no Landlord Override resolves a property_type, which on its own
# gates the Property out of prediction entirely (UnresolvedPropertyTypeError).
# The expired certificate observed one, so the Property becomes predictable.
# Act
_predicted, source = _predict(
attributes=PredictionTargetAttributes(property_type=None),
historic=_HistoricReaderReturning(_historic(property_type="House")),
)
# Assert — rescued by the historic observation rather than failing to model.
assert source == "expired"
def test_landlord_override_wins_over_the_expired_observation() -> None:
# Arrange — the override and the expired cert disagree on property type. The
# override speaks to *current* state, so it wins; the historic cert only
# fills gaps (ADR-0054). "0" (House) is the cohort's type — were the historic
# "Flat" to win, no same-type comparable would survive and this would raise.
# Act
_predicted, source = _predict(
attributes=PredictionTargetAttributes(property_type="0"),
historic=_HistoricReaderReturning(_historic(property_type="Flat")),
)
# Assert — predicted against the override's cohort, still labelled expired.
assert source == "expired"