From 04ee16488ee95baea533d85cd1675eac9e8bc95d Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 24 Jun 2026 14:13:28 +0000 Subject: [PATCH] =?UTF-8?q?Fail=20unmodellable=20properties=20with=20a=20s?= =?UTF-8?q?pecific,=20debuggable=20error=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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) --- applications/modelling_e2e/handler.py | 51 ++++++++++++++----- .../modelling_e2e/test_predict_epc_errors.py | 4 +- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/applications/modelling_e2e/handler.py b/applications/modelling_e2e/handler.py index 32c76d7d..c3477bc1 100644 --- a/applications/modelling_e2e/handler.py +++ b/applications/modelling_e2e/handler.py @@ -67,6 +67,11 @@ from infrastructure.solar.google_solar_api_client import ( BuildingInsightsNotFoundError, GoogleSolarApiClient, ) +from applications.modelling_e2e.errors import ( + DegeneratePredictionError, + NoSameTypeComparablesError, + UnresolvedPropertyTypeError, +) from applications.modelling_e2e.modelling_e2e_trigger_body import ( ModellingE2ETriggerBody, ) @@ -181,16 +186,19 @@ def _predict_epc( cohort_for: Callable[[str], list[ComparableProperty]], broaden: Callable[[PredictionTarget], list[ComparableProperty]], predictor: EpcPrediction, -) -> Optional[EpcPropertyData]: +) -> EpcPropertyData: """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 postcode — e.g. the only flat among houses), the cohort is broadened to the real unit postcodes physically nearest it (``broaden``) before giving up. - Returns None when property_type is unresolvable (hard cohort filter cannot - fire) or when even the broadened cohort is empty after filtering. + Raises a specific ``PropertyNotModellableError`` subclass — naming the cause + 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) identity = PropertyIdentity( @@ -198,18 +206,41 @@ def _predict_epc( ) target = build_prediction_target(identity, coordinates, attributes) 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)) + broadened = False if not comparables.members: + broadened = True comparables = select_comparables(target, broaden(target)) 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) if not any( part.identifier is BuildingPartIdentifier.MAIN 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 @@ -344,12 +375,6 @@ def handler(body: dict[str, Any], context: Any, orchestrator: TaskOrchestrator, broaden=_broaden, 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( identity=PropertyIdentity( portfolio_id=portfolio_id, diff --git a/tests/applications/modelling_e2e/test_predict_epc_errors.py b/tests/applications/modelling_e2e/test_predict_epc_errors.py index 6984941c..715da621 100644 --- a/tests/applications/modelling_e2e/test_predict_epc_errors.py +++ b/tests/applications/modelling_e2e/test_predict_epc_errors.py @@ -15,7 +15,9 @@ from applications.modelling_e2e.errors import ( NoSameTypeComparablesError, 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 ( BuildingPartIdentifier, EpcPropertyData,