From f3bdf0e88415af0c0cb6cf2d25623f8ceea21fb3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 2 Jul 2026 11:01:43 +0000 Subject: [PATCH] =?UTF-8?q?Vaulted=20ND=20roof=20on=20a=20pre-1950=20dwell?= =?UTF-8?q?ing=20gets=20sloping-ceiling=20insulation=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../generators/roof_recommendation.py | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/domain/modelling/generators/roof_recommendation.py b/domain/modelling/generators/roof_recommendation.py index 2595e3f93..3bdebd1b9 100644 --- a/domain/modelling/generators/roof_recommendation.py +++ b/domain/modelling/generators/roof_recommendation.py @@ -10,7 +10,7 @@ Flat-roof and room-in-roof branches land in later slices. No scoring, no persistence — impact is produced later by scoring (ADR-0016). """ -from typing import Optional +from typing import Final, Optional, Union from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, @@ -33,6 +33,36 @@ _ROOF_UNINSULATED_MM = 0 _RECOMMENDED_LOFT_THICKNESS_MM = 300 # 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 +# uninsulated, E-F limited insulation, G+ insulated) — the resolution for a +# cert lodging the "ND" (Not Defined) sentinel or no thickness at all. +_AS_BUILT_UNINSULATED_AGE_BANDS: Final[frozenset[str]] = frozenset( + {"A", "B", "C", "D"} +) + + +def _pitched_roof_is_uninsulated( + thickness: Union[str, int, None], age_band: Optional[str] +) -> bool: + """The pitched-roof uninsulated trigger, sentinel-aware (ADR-0047). + + `roof_insulation_thickness` lodges numerics AND string sentinels; the + sentinels are data, not noise, and must never make an undefined cert MORE + eligible than an explicitly-lodged one. An explicit 0 is uninsulated; the + "ND" (Not Defined) sentinel — or nothing lodged — resolves to the age + band's as-built state (only A-D built without pitched-roof insulation). + Anything else ("NI" = insulation present at unknown thickness per RdSAP 10 + §5.11.4, or a measured depth) means insulation is there. Eligibility only + — the calculator resolves the same sentinels separately for the U-cascade + (deliberate two-home split, ADR-0047).""" + if isinstance(thickness, int): + return thickness == 0 + if thickness is None or thickness == "ND": + return ( + age_band is not None + and age_band.upper() in _AS_BUILT_UNINSULATED_AGE_BANDS + ) + return False _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 @@ -68,8 +98,13 @@ def recommend_roof_insulation( # plain pitched loft, a thatched roof (the covering doesn't block insulating # the loft floor), and an unlodged roof type (the modal UK case), matching # the pre-dispatcher behaviour of firing on `roof_insulation_thickness == 0`. - if "sloping ceiling" in roof_type: - if main.roof_insulation_thickness != _ROOF_UNINSULATED_MM: + # A vaulted ceiling follows the slope like a sloping ceiling (no loft + # void) — same disjunction the calculator's `is_sloping_ceiling` uses + # (heat_transmission.py), so generator and scorer read one story (ADR-0047). + if "sloping ceiling" in roof_type or "vaulted" in roof_type: + if not _pitched_roof_is_uninsulated( + main.roof_insulation_thickness, main.construction_age_band + ): return None return _roof_recommendation( epc,