Fail unmodellable properties with a specific, debuggable error 🟩

_predict_epc returned None for three unrelated causes — unresolved
property_type, an empty same-type cohort, and a degenerate (no MAIN part)
prediction — which the handler collapsed into one generic "not predictable"
string. The SubTask output could not say which cause fired or which data to
fix.

Raise a specific PropertyNotModellableError subclass per cause, each carrying
the property's identity (property_id, uprn, postcode, portfolio_id) and
cause-specific context. The unresolved-property-type message points at the
likely missing/contradictory Landlord Override. All subclass ValueError, so the
per-property failure boundary keeps catching them and records str(exc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-24 14:13:28 +00:00
parent dd06b19c77
commit 04ee16488e
2 changed files with 41 additions and 14 deletions

View file

@ -67,6 +67,11 @@ from infrastructure.solar.google_solar_api_client import (
BuildingInsightsNotFoundError, BuildingInsightsNotFoundError,
GoogleSolarApiClient, GoogleSolarApiClient,
) )
from applications.modelling_e2e.errors import (
DegeneratePredictionError,
NoSameTypeComparablesError,
UnresolvedPropertyTypeError,
)
from applications.modelling_e2e.modelling_e2e_trigger_body import ( from applications.modelling_e2e.modelling_e2e_trigger_body import (
ModellingE2ETriggerBody, ModellingE2ETriggerBody,
) )
@ -181,16 +186,19 @@ def _predict_epc(
cohort_for: Callable[[str], list[ComparableProperty]], cohort_for: Callable[[str], list[ComparableProperty]],
broaden: Callable[[PredictionTarget], list[ComparableProperty]], broaden: Callable[[PredictionTarget], list[ComparableProperty]],
predictor: EpcPrediction, predictor: EpcPrediction,
) -> Optional[EpcPropertyData]: ) -> EpcPropertyData:
"""Synthesise an EpcPropertyData for an EPC-less property from its postcode """Synthesise an EpcPropertyData for an EPC-less property from its postcode
cohort (EPC Prediction Path 3, ADR-0031), or None when ineligible. cohort (EPC Prediction Path 3, ADR-0031).
When the property's own postcode holds no same-type comparables (a sparse When the property's own postcode holds no same-type comparables (a sparse
postcode e.g. the only flat among houses), the cohort is broadened to the postcode e.g. the only flat among houses), the cohort is broadened to the
real unit postcodes physically nearest it (``broaden``) before giving up. real unit postcodes physically nearest it (``broaden``) before giving up.
Returns None when property_type is unresolvable (hard cohort filter cannot Raises a specific ``PropertyNotModellableError`` subclass naming the cause
fire) or when even the broadened cohort is empty after filtering. and carrying the property's identity — when it cannot predict: property_type
unresolved, an empty same-type cohort, or a degenerate (no MAIN part)
prediction. The per-property handler records ``str(exc)`` in the SubTask
output, so the cause is debuggable from the output alone.
""" """
attributes = attributes_reader.attributes_for(property_id) attributes = attributes_reader.attributes_for(property_id)
identity = PropertyIdentity( identity = PropertyIdentity(
@ -198,18 +206,41 @@ def _predict_epc(
) )
target = build_prediction_target(identity, coordinates, attributes) target = build_prediction_target(identity, coordinates, attributes)
if target is None: if target is None:
return None raise UnresolvedPropertyTypeError(
property_id=property_id,
uprn=uprn,
postcode=postcode,
portfolio_id=portfolio_id,
property_type=attributes.property_type,
built_form=attributes.built_form,
)
comparables = select_comparables(target, cohort_for(target.postcode)) comparables = select_comparables(target, cohort_for(target.postcode))
broadened = False
if not comparables.members: if not comparables.members:
broadened = True
comparables = select_comparables(target, broaden(target)) comparables = select_comparables(target, broaden(target))
if not comparables.members: if not comparables.members:
return None raise NoSameTypeComparablesError(
property_id=property_id,
uprn=uprn,
postcode=postcode,
portfolio_id=portfolio_id,
property_type=target.property_type,
broadened=broadened,
)
predicted = predictor.predict(target, comparables) predicted = predictor.predict(target, comparables)
if not any( if not any(
part.identifier is BuildingPartIdentifier.MAIN part.identifier is BuildingPartIdentifier.MAIN
for part in predicted.sap_building_parts for part in predicted.sap_building_parts
): ):
return None raise DegeneratePredictionError(
property_id=property_id,
uprn=uprn,
postcode=postcode,
portfolio_id=portfolio_id,
property_type=target.property_type,
cohort_size=len(comparables.members),
)
return predicted return predicted
@ -344,12 +375,6 @@ def handler(body: dict[str, Any], context: Any, orchestrator: TaskOrchestrator,
broaden=_broaden, broaden=_broaden,
predictor=predictor, predictor=predictor,
) )
if predicted_epc is None:
raise ValueError(
f"no EPC for UPRN {uprn} and not predictable "
f"(unresolved property_type, or no same-type "
f"comparables in or near '{postcode}')"
)
effective_epc = Property( effective_epc = Property(
identity=PropertyIdentity( identity=PropertyIdentity(
portfolio_id=portfolio_id, portfolio_id=portfolio_id,

View file

@ -15,7 +15,9 @@ from applications.modelling_e2e.errors import (
NoSameTypeComparablesError, NoSameTypeComparablesError,
UnresolvedPropertyTypeError, UnresolvedPropertyTypeError,
) )
from applications.modelling_e2e.handler import _predict_epc from applications.modelling_e2e.handler import (
_predict_epc, # pyright: ignore[reportPrivateUsage]
)
from datatypes.epc.domain.epc_property_data import ( from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier, BuildingPartIdentifier,
EpcPropertyData, EpcPropertyData,