diff --git a/domain/epc/property_overrides/roof_sloping_ceiling_guard.py b/domain/epc/property_overrides/roof_sloping_ceiling_guard.py index e9de671cf..6496603e6 100644 --- a/domain/epc/property_overrides/roof_sloping_ceiling_guard.py +++ b/domain/epc/property_overrides/roof_sloping_ceiling_guard.py @@ -11,19 +11,65 @@ from domain.epc.property_overrides.roof_type import RoofType # family, never the loft ladder (ADR-0066). _SLOPING_CEILING_TOKEN = "pitchedwithslopingceiling" +# The measured-depth ladder (Table 17 col (1a) tabulated thicknesses), ascending. +# A lodged depth snaps to the nearest tabulated depth ≤ N — mirroring the +# calculator's `_ROOF_BY_THICKNESS` picker — so an off-ladder value never leaks +# back to the loft LLM route. `400+` / any depth > 400 is the top member. +_DEPTH_LADDER: list[tuple[int, RoofType]] = [ + (12, RoofType.PITCHED_SLOPING_CEILING_12MM), + (25, RoofType.PITCHED_SLOPING_CEILING_25MM), + (50, RoofType.PITCHED_SLOPING_CEILING_50MM), + (75, RoofType.PITCHED_SLOPING_CEILING_75MM), + (100, RoofType.PITCHED_SLOPING_CEILING_100MM), + (125, RoofType.PITCHED_SLOPING_CEILING_125MM), + (150, RoofType.PITCHED_SLOPING_CEILING_150MM), + (175, RoofType.PITCHED_SLOPING_CEILING_175MM), + (200, RoofType.PITCHED_SLOPING_CEILING_200MM), + (225, RoofType.PITCHED_SLOPING_CEILING_225MM), + (250, RoofType.PITCHED_SLOPING_CEILING_250MM), + (270, RoofType.PITCHED_SLOPING_CEILING_270MM), + (300, RoofType.PITCHED_SLOPING_CEILING_300MM), + (350, RoofType.PITCHED_SLOPING_CEILING_350MM), + (400, RoofType.PITCHED_SLOPING_CEILING_400MM), +] +_TOP_TABULATED_MM = 400 + def _normalise_roof_token(description: str) -> str: token = description.split(":", 1)[0] return re.sub(r"[^a-z0-9]", "", token.lower()) +def _member_for_depth(insulation: str) -> Optional[RoofType]: + """The ladder member for a lodged insulation suffix, or None when it carries + no measured depth (`As Built` / `Unknown` / empty) so the caller folds it to + `as built`. An explicit `+` or any depth above 400 mm → the 400+ member; an + off-ladder depth snaps to the nearest tabulated depth ≤ N; a depth below the + ladder (< 12 mm) has no member and also folds to `as built`.""" + digits = re.search(r"\d+", insulation) + if digits is None: + return None + depth_mm = int(digits.group()) + if "+" in insulation or depth_mm > _TOP_TABULATED_MM: + return RoofType.PITCHED_SLOPING_CEILING_400_PLUS + member: Optional[RoofType] = None + for tabulated_mm, ladder_member in _DEPTH_LADDER: + if depth_mm >= tabulated_mm: + member = ladder_member + return member + + def roof_sloping_ceiling_guard(description: str) -> Optional[RoofType]: """Deterministically resolve a `PitchedWithSlopingCeiling` roof description to its `Pitched, sloping ceiling, …` RoofType member (ADR-0066). - Returns None for anything that is not a sloping-ceiling marker, so the + A measured depth resolves to its ladder member (nearest tabulated depth ≤ N; + `400+` / above-400 → the 400+ member); `As Built` / `Unknown` / a sub-ladder + depth / no suffix fold to `as built`, which defers the U-value to the age + band. Returns None for anything that is not a sloping-ceiling marker, so the party-ceiling guard and the LLM classifier still handle the rest. """ if _normalise_roof_token(description) != _SLOPING_CEILING_TOKEN: return None - return RoofType.PITCHED_SLOPING_CEILING_AS_BUILT + _, _, insulation = description.partition(":") + return _member_for_depth(insulation) or RoofType.PITCHED_SLOPING_CEILING_AS_BUILT