mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
Merge pull request #1542 from Hestia-Homes/fix/loft-topup-below-building-regs
Loft insulation eligible below the 270mm building-regs depth (ADR-0063)
This commit is contained in:
commit
e1c99edc3f
4 changed files with 193 additions and 7 deletions
|
|
@ -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**:
|
||||
|
|
|
|||
78
docs/adr/0063-loft-eligible-below-building-regs-thickness.md
Normal file
78
docs/adr/0063-loft-eligible-below-building-regs-thickness.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -216,6 +216,81 @@ 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_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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue