From ce0940c335d2011c2e744607e19c163b428baca1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 10 Jul 2026 15:59:51 +0000 Subject: [PATCH 1/4] Record below-regs loft eligibility as ADR-0063 The roof generator offered loft insulation only when genuinely uninsulated (ADR-0021), so a loft with a shallow lodged depth (e.g. 724702's 50mm) got nothing and never entered the optimiser pool. Grilled with Khalim: the loft branch becomes eligible when the lodged depth is a known numeric value below the 270mm building-regs compliance gate, topped up to the 300mm install depth. Numeric-lodged-depths only; sentinels keep their ADR-0047 resolution. Loft branch only. CONTEXT 'Roof Insulation Eligibility' updated to match. Co-Authored-By: Claude Fable 5 --- CONTEXT.md | 2 +- ...-eligible-below-building-regs-thickness.md | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0063-loft-eligible-below-building-regs-thickness.md diff --git a/CONTEXT.md b/CONTEXT.md index 34d380249..047087e97 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -353,7 +353,7 @@ _Avoid_: no-op filter (it gates generation, not scored output), U-value threshol _Avoid_: restricted measures (legacy collapsed conservation/listed/heritage into one boolean — they now gate different Options, so keep them distinct) **Roof Insulation Eligibility**: -The rule fixing which single roof Measure the main-roof **Recommendation** offers, by roof type. One Measure per roof — never a menu the Optimiser chooses between (ADR-0021): **pitched with an accessible loft** (incl. **thatch** — the covering doesn't block insulating the loft floor) → **loft insulation** (laid flat at the ceiling joists, 300 mm); **pitched with a sloping ceiling** → **sloping-ceiling insulation** (at the rafters, 100 mm); **flat roof** → **flat-roof insulation** (200 mm); **pitched, no access** → none (can't reach the void). A **room-in-roof** takes neither loft nor sloping-ceiling insulation — it is insulated at its own slopes/stud-walls (rafter-area, not floor-area, quantity) as a distinct **room-in-roof insulation** Measure, currently **deferred** (pending retrofit-specialist examples). A Measure is offered only when the roof is genuinely uninsulated ("As Built" / "None" / 0 mm). +The rule fixing which single roof Measure the main-roof **Recommendation** offers, by roof type. One Measure per roof — never a menu the Optimiser chooses between (ADR-0021): **pitched with an accessible loft** (incl. **thatch** — the covering doesn't block insulating the loft floor) → **loft insulation** (laid flat at the ceiling joists, 300 mm); **pitched with a sloping ceiling** → **sloping-ceiling insulation** (at the rafters, 100 mm); **flat roof** → **flat-roof insulation** (200 mm); **pitched, no access** → none (can't reach the void). A **room-in-roof** takes neither loft nor sloping-ceiling insulation — it is insulated at its own slopes/stud-walls (rafter-area, not floor-area, quantity) as a distinct **room-in-roof insulation** Measure, currently **deferred** (pending retrofit-specialist examples). Sloping-ceiling and flat-roof measures are offered only when the roof is genuinely uninsulated ("As Built" / "None" / 0 mm). The **loft** measure is additionally offered when the lodged depth is a known numeric value **below the 270 mm building-regs compliance gate** — a sub-standard loft is topped up to the 300 mm install depth (ADR-0063): the gate (compliance floor, 270 mm) and the target (install depth, 300 mm) are deliberately different numbers. The below-standard gate is **numeric-lodged-depths only**; sentinels keep their [[RdSAP String Sentinels]] resolution (`NI` stays not-eligible; `ND`/`None` resolve by age band). _Avoid_: "roof insulation" (name the specific Measure — loft / sloping-ceiling / flat-roof / room-in-roof); "joist insulation" (use **loft insulation**, the established Measure Type) **Glazing Eligibility**: diff --git a/docs/adr/0063-loft-eligible-below-building-regs-thickness.md b/docs/adr/0063-loft-eligible-below-building-regs-thickness.md new file mode 100644 index 000000000..003787ab8 --- /dev/null +++ b/docs/adr/0063-loft-eligible-below-building-regs-thickness.md @@ -0,0 +1,78 @@ +--- +status: accepted (amends ADR-0021; composes with ADR-0047) +--- + +# A loft is eligible for insulation when its lodged depth is below the building-regs standard, not only when uninsulated + +ADR-0021 fixed the roof generator to offer a Measure **only when the roof is +genuinely uninsulated** — for the loft branch, `roof_insulation_thickness ∈ +{0, None}` (or a sentinel resolving to as-built-uninsulated per ADR-0047). That +under-serves a large, common case: a loft with a shallow *lodged* depth. Property +724702 (portfolio 796) lodges `roof_insulation_thickness = "50mm"`; the generator +read 50mm as "already insulated → recommend nothing" and returned `None`, so loft +insulation never entered the optimiser's candidate pool — and neither the plain +nor the fabric-first package could select it, on scenario 1292 (Increasing EPC → +C, unlimited budget). The Option's own description already promises to "top up to +recommended depth"; the eligibility gate never let it. + +**270 mm is the compliance gate; 300 mm is the install depth — two numbers, two +questions.** Building Regulations Approved Document L pitches the retrofit +loft/ceiling standard at a roof U-value of ≈0.16 W/m²K, which is ≈**270 mm** of +mineral-wool-equivalent insulation: *"is this property at building regs?"*. The +depth we lay when we do the work is the best-practice **300 mm** (ADR-0021; +pinned to the Elmhurst before→after cascade, `test_elmhurst_cascade_pins`): +*"how deep do we install?"*. They are deliberately different values because they +answer different questions — a loft at 270 mm is compliant and left alone; a loft +below it is topped up past the bar, to 300 mm. This is not a 270-vs-300 +inconsistency to reconcile. + +We gate on **depth** as a practical proxy for the regs **U-value**, because depth +is what the cert lodges. That is exact for mineral-wool lofts and mildly +conservative for higher-performance materials (a thinner high-λ layer could be +compliant yet read as sub-270 mm) — an accepted simplification, not a silent one. + +Decided in a grilling session with Khalim, 2026-07-10. + +## Decision + +**The loft branch is eligible when the MAIN roof's lodged insulation depth is a +known numeric value below the 270 mm building-regs compliance gate**, in addition +to the existing genuinely-uninsulated triggers. The recommended top-up depth is +unchanged at **300 mm**, so the gate (compliance floor) is 270 mm and the target +(install depth) is 300 mm. + +- **Numeric lodged values only.** The rule parses an explicit numeric depth from + the lodged `roof_insulation_thickness` — a bare int (`50`), an `"Nmm"` string + (`"50mm"`), or an `"Nmm+"` string (`"400mm+"` → 400). A parsed depth `< 270` + is eligible; `>= 270` is not. +- **Sentinels keep their ADR-0047 resolution, unchanged.** `ND` / `None` / + `"As Built"` still resolve to the construction age band's as-built state + (A–D uninsulated → eligible), and `NI` ("insulation present, thickness + unknown") stays **not-eligible** to the generator even though the U-cascade + scores it as 50 mm. The deliberate eligibility-vs-scoring split (ADR-0047, + CONTEXT "RdSAP String Sentinels") is preserved: an unknown depth does not + auto-trigger works. This keeps the change surgical — it fixes the lodged-depth + case without reopening sentinel resolution. +- **Loft branch only.** Sloping-ceiling (100 mm at the rafters) and flat-roof + (200 mm) keep the uninsulated-only gate. Their U-targets and physical + constraints differ from a ceiling-level loft, so a below-standard gate for + them is a separate decision with its own recommended depths — deferred. + +## Consequences + +- Property 724702 and every like it (a lodged loft depth in `[1, 269]` mm) now + produce a loft Recommendation, priced at the roof area and topped up to 300 mm. + The 50 mm → 300 mm before→after is scored truthfully by the calculator's + existing U-cascade. +- **Unlimited-budget scenarios recommend low-value top-ups.** A loft already at + 200–250 mm is now eligible for a ~0.1-SAP top-up to 300 mm. Budget-capped + scenarios self-filter (the optimiser will not spend on a negligible gain), but + an unlimited-budget brief — such as the reporting scenario 1292 — will buy it + in fabric-first phase 1. This is the accepted cost of a clean building-regs + gate; if the noise proves material, a minimum-gain or lower-depth floor is the + follow-up lever. +- ADR-0021's dispatch table gains a loft trigger: *uninsulated, **or** a lodged + numeric depth < 270 mm*. The other branches' rows are unchanged. +- CONTEXT.md's "Roof Insulation Eligibility" entry is updated from "offered only + when the roof is genuinely uninsulated" to include the below-standard loft + case, so the ubiquitous language matches the generator. From 2fe5b79dc414967c92c63beea83c28a465c778d3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 10 Jul 2026 16:03:39 +0000 Subject: [PATCH 2/4] =?UTF-8?q?Recommend=20loft=20insulation=20for=20a=20l?= =?UTF-8?q?oft=20below=20the=20building-regs=20depth=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../modelling/test_roof_recommendation.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/domain/modelling/test_roof_recommendation.py b/tests/domain/modelling/test_roof_recommendation.py index 6759341ed..55b459da7 100644 --- a/tests/domain/modelling/test_roof_recommendation.py +++ b/tests/domain/modelling/test_roof_recommendation.py @@ -216,6 +216,32 @@ def test_unlodged_roof_type_with_nd_thickness_yields_no_recommendation() -> None assert recommendation is None +def test_loft_below_building_regs_depth_yields_loft_insulation() -> None: + # Arrange — property 724702's shape (ADR-0063): an unlodged roof type with a + # lodged 50 mm loft, below the 270 mm building-regs compliance gate. A + # measured depth is a positive statement of a real loft (unlike a sentinel on + # an unlodged type, which keeps deferring), so the below-regs top-up fires and + # reaches the 300 mm install depth. + baseline: EpcPropertyData = build_epc() + main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) + main.roof_construction_type = None + main.roof_insulation_thickness = "50mm" + + # Act + recommendation: Recommendation | None = recommend_roof_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + option = recommendation.options[0] + assert option.measure_type == "loft_insulation" + simulated: EpcPropertyData = apply_simulations(baseline, [option.overlay]) + assert ( + _part(simulated, BuildingPartIdentifier.MAIN).roof_insulation_thickness == 300 + ) + + def test_loft_option_carries_cost_from_roof_area_and_product() -> None: # Arrange baseline: EpcPropertyData = build_epc() # MAIN roof area 14.85 m^2 From 95a0da48285dc6deca1fd5799a0232a515d1210a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 10 Jul 2026 16:06:44 +0000 Subject: [PATCH 3/4] =?UTF-8?q?Recommend=20loft=20insulation=20for=20a=20l?= =?UTF-8?q?oft=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, From 3bbe293f68e1b7763e7c619e5bfe223ad8340e50 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 10 Jul 2026 16:07:48 +0000 Subject: [PATCH 4/4] =?UTF-8?q?Gate=20the=20loft=20top-up=20strictly=20bel?= =?UTF-8?q?ow=20the=20270mm=20building-regs=20depth=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks the gate edges — a lodged pitched loft below 270mm is recommended, a loft at 270mm is left alone, and the 'Nmm+' form ('300mm+') parses to its number and stays ineligible. These passed on arrival (they fell out of the numeric-parse gate added in the previous commit); pinned as regression guards. ADR-0063. Co-Authored-By: Claude Fable 5 --- .../modelling/test_roof_recommendation.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/domain/modelling/test_roof_recommendation.py b/tests/domain/modelling/test_roof_recommendation.py index 55b459da7..781814a8a 100644 --- a/tests/domain/modelling/test_roof_recommendation.py +++ b/tests/domain/modelling/test_roof_recommendation.py @@ -242,6 +242,55 @@ def test_loft_below_building_regs_depth_yields_loft_insulation() -> None: ) +def test_lodged_pitched_loft_below_regs_yields_loft_insulation() -> None: + # Arrange — the below-regs gate also fires on a KNOWN pitched loft with a + # partial lodged depth (150 mm), not only the unlodged shape: the numeric + # rule applies regardless of roof type (ADR-0063). + baseline: EpcPropertyData = build_epc() + main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) + main.roof_construction_type = "Pitched (slates/tiles), access to loft" + main.roof_insulation_thickness = "150mm" + + # Act + recommendation: Recommendation | None = recommend_roof_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + assert recommendation.options[0].measure_type == "loft_insulation" + + +def test_loft_at_the_building_regs_depth_yields_no_recommendation() -> None: + # Arrange — 270 mm is the compliance gate: a loft AT the standard is not + # sub-standard, so it is left alone (strict below-270, ADR-0063). + baseline: EpcPropertyData = build_epc() + _part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = "270mm" + + # Act + recommendation: Recommendation | None = recommend_roof_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_loft_with_a_plus_suffix_depth_above_regs_yields_no_recommendation() -> None: + # Arrange — the "Nmm+" lodged form ("300mm+") parses to its number (300), + # which is at/above the gate, so no top-up is offered (ADR-0063). + baseline: EpcPropertyData = build_epc() + _part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = "300mm+" + + # Act + recommendation: Recommendation | None = recommend_roof_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + def test_loft_option_carries_cost_from_roof_area_and_product() -> None: # Arrange baseline: EpcPropertyData = build_epc() # MAIN roof area 14.85 m^2