Recommend loft insulation for a loft below the building-regs depth 🟩

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-10 16:06:44 +00:00
parent 2fe5b79dc4
commit 95a0da4828

View file

@ -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,