From 95a0da48285dc6deca1fd5799a0232a515d1210a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 10 Jul 2026 16:06:44 +0000 Subject: [PATCH] =?UTF-8?q?Recommend=20loft=20insulation=20for=20a=20loft?= =?UTF-8?q?=20below=20the=20building-regs=20depth=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lodged numeric loft depth below the 270mm building-regs compliance gate is now eligible (topped up to the 300mm install depth), regardless of whether the roof type is lodged, since a measured depth is a positive statement of a real loft. Sentinels keep their ADR-0047 resolution. Fixes property 724702 (50mm loft) which previously returned no recommendation. ADR-0063. Co-Authored-By: Claude Fable 5 --- .../generators/roof_recommendation.py | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/domain/modelling/generators/roof_recommendation.py b/domain/modelling/generators/roof_recommendation.py index 5b234a41d..d09fda0a9 100644 --- a/domain/modelling/generators/roof_recommendation.py +++ b/domain/modelling/generators/roof_recommendation.py @@ -30,6 +30,12 @@ _SLOPING_CEILING_MEASURE_TYPE = MeasureType.SLOPING_CEILING_INSULATION # measure at 300 mm; pinning the before→after cascade (000490/001431) requires # the overlay to match that depth exactly (see test_elmhurst_cascade_pins). _RECOMMENDED_LOFT_THICKNESS_MM = 300 +# Building Regulations Approved Document L loft/ceiling compliance gate (mm): a +# lodged loft shallower than this is sub-standard and eligible for a top-up to +# `_RECOMMENDED_LOFT_THICKNESS_MM` (ADR-0063). The gate (270, "is this at +# building regs?") and the install depth (300, "how deep do we lay it?") are +# deliberately different numbers. +_BUILDING_REGS_LOFT_DEPTH_MM: Final[int] = 270 # Recommended sloping-ceiling depth (mm); Elmhurst re-lodges 100 mm (ADR-0021). _RECOMMENDED_SLOPING_CEILING_THICKNESS_MM = 100 # Age bands whose as-built pitched roof is uninsulated (ADR-0047: A-D @@ -62,6 +68,23 @@ def _pitched_roof_is_uninsulated( and age_band.upper() in _AS_BUILT_UNINSULATED_AGE_BANDS ) return False + + +def _lodged_numeric_depth_mm(thickness: Union[str, int, None]) -> Optional[int]: + """The lodged loft depth as a whole number of millimetres, or None when the + value is a sentinel or unlodged (``ND`` / ``NI`` / ``"As Built"`` / None). + Parses a bare int, an ``"Nmm"`` string (``"50mm"``), and an ``"Nmm+"`` string + (``"400mm+"`` → 400). Non-numeric values return None so they fall through to + the sentinel resolution (`_pitched_roof_is_uninsulated`), preserving the + ADR-0047 split (ADR-0063).""" + if isinstance(thickness, int): + return thickness + if thickness is None: + return None + digits: str = thickness.strip().lower().removesuffix("+").removesuffix("mm").strip() + return int(digits) if digits.isdigit() else None + + _FLAT_ROOF_MEASURE_TYPE = MeasureType.FLAT_ROOF_INSULATION # Recommended flat-roof depth (mm); Elmhurst re-lodges 200 mm (ADR-0021). _RECOMMENDED_FLAT_ROOF_THICKNESS_MM = 200 @@ -134,15 +157,25 @@ def recommend_roof_insulation( # codes 6/7 ("(another dwelling above)" / insulated thatch) map the type # to None and real code-7 certs lodge "ND" — with zero roof heat loss # (RdSAP 10 Table 18), so only an explicit 0 may trigger it. + # A lodged NUMERIC depth is a positive statement of a real, accessible loft + # (a party ceiling never lodges a measured roof depth), so it is eligible + # whenever it is below the building-regs compliance gate — regardless of + # whether the roof type is lodged, and covering the explicit-0 uninsulated + # case. A SENTINEL (or nothing) keeps the ADR-0047 resolution: known-pitched + # resolves by age band, an unlodged type defers (party-ceiling guard). known_pitched: bool = "pitched" in roof_type or "thatch" in roof_type - uninsulated: bool = ( - _pitched_roof_is_uninsulated( + lodged_depth_mm: Optional[int] = _lodged_numeric_depth_mm( + main.roof_insulation_thickness + ) + if lodged_depth_mm is not None: + eligible: bool = lodged_depth_mm < _BUILDING_REGS_LOFT_DEPTH_MM + elif known_pitched: + eligible = _pitched_roof_is_uninsulated( main.roof_insulation_thickness, main.construction_age_band ) - if known_pitched - else main.roof_insulation_thickness == 0 - ) - if not uninsulated: + else: + eligible = False + if not eligible: return None return _roof_recommendation( epc,