Model/tests/applications/modelling_e2e/test_predict_epc_errors.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

150 lines
5.1 KiB
Python

"""Behaviour of ``_predict_epc``'s failure modes: an EPC-less Property that
cannot be predicted must fail loudly with a *specific*, debuggable exception
(carrying the Property's identity + the cause), not a single generic
"not predictable" string. The per-property handler records ``str(exc)`` in the
SubTask output, so these messages are the operator's only debugging signal.
"""
from typing import Callable, Optional, cast
from unittest.mock import MagicMock
import pytest
from applications.modelling_e2e.errors import (
DegeneratePredictionError,
NoSameTypeComparablesError,
UnresolvedPropertyTypeError,
)
from applications.modelling_e2e.handler import (
_predict_epc, # pyright: ignore[reportPrivateUsage]
)
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
SapBuildingPart,
)
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,
)
class _FixedAttributesReader:
"""An attributes reader that returns one fixed PredictionTargetAttributes."""
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_cohort() -> Callable[[str], list[ComparableProperty]]:
return lambda _postcode: []
def _no_broaden() -> Callable[[PredictionTarget], list[ComparableProperty]]:
return lambda _target: []
def _epc(property_type: Optional[str]) -> EpcPropertyData:
epc: EpcPropertyData = object.__new__(EpcPropertyData)
epc.property_type = property_type
return epc
def test_unresolved_property_type_raises_with_property_identity() -> None:
# Arrange — no resolvable property_type (the contradictory/missing-override
# case): build_prediction_target gates the Property out before any cohort.
reader = _reader(PredictionTargetAttributes(property_type=None))
# Act / Assert
with pytest.raises(UnresolvedPropertyTypeError) as excinfo:
_predict_epc(
property_id=733290,
uprn=100021987320,
postcode="SE4 1DX",
portfolio_id=796,
attributes_reader=reader,
historic_epc_reader=None,
coordinates=None,
cohort_for=_no_cohort(),
broaden=_no_broaden(),
predictor=EpcPrediction(),
)
message = str(excinfo.value)
assert "733290" in message
assert "100021987320" in message
assert "SE4 1DX" in message
assert "796" in message
def test_resolved_type_but_empty_cohort_raises_no_same_type_comparables() -> None:
# Arrange — property_type resolves, but neither the own postcode nor the
# broadened cohort yields a same-type comparable.
reader = _reader(PredictionTargetAttributes(property_type="2"))
# Act / Assert
with pytest.raises(NoSameTypeComparablesError) as excinfo:
_predict_epc(
property_id=723681,
uprn=100021935228,
postcode="SE13 5RT",
portfolio_id=796,
attributes_reader=reader,
historic_epc_reader=None,
coordinates=None,
cohort_for=_no_cohort(),
broaden=_no_broaden(),
predictor=EpcPrediction(),
)
assert excinfo.value.property_type == "2"
assert excinfo.value.broadened is True
assert "SE13 5RT" in str(excinfo.value)
def test_cohort_found_but_prediction_has_no_main_part_raises_degenerate() -> None:
# Arrange — a same-type cohort exists, but the synthesised EPC carries no
# MAIN building part (the template was lodged with a null part identifier).
reader = _reader(PredictionTargetAttributes(property_type="2"))
cohort = [ComparableProperty(epc=_epc("2"), certificate_number="c1")]
no_main: EpcPropertyData = object.__new__(EpcPropertyData)
part: SapBuildingPart = object.__new__(SapBuildingPart)
part.identifier = BuildingPartIdentifier.OTHER
no_main.sap_building_parts = [part]
predictor = MagicMock()
predictor.predict.return_value = no_main
# Act / Assert
with pytest.raises(DegeneratePredictionError) as excinfo:
_predict_epc(
property_id=712977,
uprn=100061743824,
postcode="LS6 1AA",
portfolio_id=796,
attributes_reader=reader,
historic_epc_reader=None,
coordinates=None,
cohort_for=lambda _postcode: cohort,
broaden=_no_broaden(),
predictor=cast(EpcPrediction, predictor),
)
assert excinfo.value.cohort_size == 1
assert "100061743824" in str(excinfo.value)