mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-08-03 05:18:22 +00:00
Six properties failed task 4d006fab (portfolio 854 / scenario 1334) with
DegeneratePredictionError. None has a lodged EPC, so each was predicted from a
neighbouring-cert cohort — and every cohort cert lodges its single building part
as `identifier: "complete"`. from_api_string accepted only "Main Dwelling" /
"Main building", so "complete" fell to OTHER, _has_main_part returned False, and
prediction refused to proceed.
"complete" is what SAP 9.91/9.92-era software writes for an unextended dwelling:
semantically identical to "Main Dwelling". Casing varies within a single postcode
(cert 8824-7422-1180-6934-1902 lodges "complete", 199 Highfield Road "Complete"),
so matching is now case-folded.
The API's `identifier` is free text with no schema enum — api.yml declares the
whole cert body `additionalProperties: true` and documents no field of it, and
`identifier` is absent from the 17 vocabularies at /api/codes. So the recognised
set can only grow by discovery, and each miss has cost an incident ("Main
building" → task a40e71c4; "complete" → task 4d006fab). Hence three changes, not
one:
- "complete" → MAIN, case-insensitively.
- Bare "Extension" → EXTENSION_1. The regex required a digit, so 668 corpus
occurrences silently dropped a real extension from the structure. This reverses
a prior deliberate pin whose stated rationale (RdSAP10 §1.2's 4-extension cap)
does not apply — there is no out-of-range number in a bare "Extension".
- _anchor_lone_building_part: a cert lodging exactly ONE part describes the whole
dwelling whatever it is called, so an unclassified lone part becomes MAIN.
Multi-part certs are untouched — there the identifier carries real information
and guessing would invent structure the cert does not state.
Also removes a false claim from DegeneratePredictionError's message: it asserted
the template was "lodged with a null part identifier", which was hardcoded, never
checked against a real cert, and wrong. It misdirected this investigation.
Verified against the live gov API: all 13 cohort certs across BL1 8EB, BL3 6XJ
and BL4 0RA now resolve a MAIN part. The full modelling e2e was NOT run (no local
AWS creds for the geospatial lookup) — this clears the blocking error, it does not
prove the predictions are good.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
150 lines
5.1 KiB
Python
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, i.e. no comparable seeding it classified one.
|
|
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)
|