mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
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>
188 lines
6.8 KiB
Python
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"
|