Model/applications/modelling_e2e/errors.py
Jun-te Kim bb5746c9b5 Recognise "complete" as a MAIN building part, and anchor lone parts 🟪
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>
2026-07-28 15:12:01 +00:00

152 lines
5.7 KiB
Python

"""Specific, debuggable failures for the modelling_e2e prediction path.
When an EPC-less Property cannot be modelled the per-property failure handler
records ``str(exc)`` in the child SubTask's outputs (see ``handler.run_subtask``).
A single generic "not predictable" string conflated three unrelated causes —
unresolved property_type, an empty same-type cohort, and a degenerate
prediction — so the output could not tell an operator *which* one fired or which
data to fix. Each cause is its own exception carrying the Property's identity
plus the cause-specific context needed to debug it from the output alone.
All subclass ``ValueError`` so the existing ``except Exception`` per-property
boundary (and any ``except ValueError`` callers) keep catching them.
"""
from __future__ import annotations
from typing import Optional
class PropertyNotModellableError(ValueError):
"""Base: an EPC-less Property could not be predicted. Subclasses name the
specific cause. Carries the Property's identity so the failed SubTask output
identifies exactly which Property (and portfolio) to investigate."""
def __init__(
self,
*,
property_id: int,
uprn: int,
postcode: str,
portfolio_id: int,
cause: str,
) -> None:
self.property_id = property_id
self.uprn = uprn
self.postcode = postcode
self.portfolio_id = portfolio_id
self.cause = cause
super().__init__(
f"cannot model property_id={property_id} uprn={uprn} "
f"postcode={postcode!r} portfolio_id={portfolio_id}: {cause}"
)
class UnresolvedPropertyTypeError(PropertyNotModellableError):
"""No lodged EPC and the Property's ``property_type`` could not be resolved,
so the hard same-type cohort filter cannot fire. Almost always a missing or
contradictory Landlord Override (e.g. a House/Bungalow lodged with an
"another dwelling above" roof is skipped by the override build), or a
``property_type`` value with no gov-EPC code."""
def __init__(
self,
*,
property_id: int,
uprn: int,
postcode: str,
portfolio_id: int,
property_type: Optional[str],
built_form: Optional[str],
) -> None:
self.property_type = property_type
self.built_form = built_form
super().__init__(
property_id=property_id,
uprn=uprn,
postcode=postcode,
portfolio_id=portfolio_id,
cause=(
"no lodged EPC and property_type could not be resolved "
f"(property_type={property_type!r}, built_form={built_form!r}), "
"so no same-type cohort can be selected. Usually a missing or "
"contradictory Landlord Override (e.g. a House/Bungalow with an "
"'another dwelling above' roof is skipped by the override build). "
"Resolve this Property's property_type override to model it."
),
)
class NoSameTypeComparablesError(PropertyNotModellableError):
"""``property_type`` resolved, but no same-type comparable was found in the
Property's own postcode or — after broadening — the nearby-postcode cohort,
so it cannot be sized from a mixed-type cohort (ADR-0031)."""
def __init__(
self,
*,
property_id: int,
uprn: int,
postcode: str,
portfolio_id: int,
property_type: str,
broadened: bool,
) -> None:
self.property_type = property_type
self.broadened = broadened
scope = (
"its own postcode or the broadened nearby-postcode cohort"
if broadened
else "its own postcode"
)
super().__init__(
property_id=property_id,
uprn=uprn,
postcode=postcode,
portfolio_id=portfolio_id,
cause=(
f"no lodged EPC; property_type={property_type!r} resolved but no "
f"same-type comparable was found in {scope}. Cannot size the "
"Property from a mixed-type cohort."
),
)
class DegeneratePredictionError(PropertyNotModellableError):
"""A same-type cohort was found, but the synthesised EPC carried no MAIN
building part — no comparable seeding the structure classified one — so the
SAP calculation has no main dwelling to anchor on.
The message deliberately does NOT name a cause. It used to assert the
template was "lodged with a null part identifier", which was never checked
against a real failing cert and turned out to be false: the certs behind
task 4d006fab lodged `identifier: "complete"`, a perfectly good value the
mapper did not recognise. That invented detail sent the investigation the
wrong way, so this now reports only what it actually observes. Diagnosing a
live case means reading the cohort certs' raw identifiers — see
`datatypes/epc/domain/tests/test_lone_building_part_anchoring.py`.
"""
def __init__(
self,
*,
property_id: int,
uprn: int,
postcode: str,
portfolio_id: int,
property_type: str,
cohort_size: int,
) -> None:
self.property_type = property_type
self.cohort_size = cohort_size
super().__init__(
property_id=property_id,
uprn=uprn,
postcode=postcode,
portfolio_id=portfolio_id,
cause=(
f"no lodged EPC; predicted from a {cohort_size}-member "
f"property_type={property_type!r} cohort, but the synthesised EPC "
"had no MAIN building part. Cannot anchor the SAP calculation."
),
)