slice S-B28: §5.11.4 — roof "NI" + insulated description → 50 mm joist row

346 corpus certs lodge roof_insulation_thickness="NI" (Not Indicated,
parsed to 0 by _parse_thickness_mm). When the description also signals
retrofit insulation ("Pitched, insulated (assumed)" / "Flat,
insulated" / "Roof room(s), insulated (assumed)"), our cascade
returned the uninsulated Table 16 row-0 value (U=2.30).

RdSAP 10 §5.11.4 (page 44, end of section): "If retrofit insulation
present of unknown thickness use 50 mm". That maps to Table 16 row
"Insulation at joists at ceiling level, 50 mm" = 0.68 W/m²K. The fix
is the analog of S-B27 for roofs: when insulation_thickness_mm==0
(the "NI" sentinel) and _described_as_insulated(description), return
0.68 instead of the row-0 lookup.

Per-cert delta: ΔU = 1.62 W/m²K on the affected slice; for typical
80 m² roof = 130 W/K HLC reduction ≈ 12 kWh/m² PEUI per cert.

Parity probe at 300 certs, seed=7:
  SAP MAE 4.72 → 4.69 (-0.03)  ← first SAP MAE drop in 3 slices
  PE MAE  44.19 → 43.32 (-0.87)
  PE bias 38.56 → 37.69 (-0.87)

Cumulative across S-B23 → S-B28:
  PE MAE  57.28 → 43.32 (-13.96)
  PE bias 51.56 → 37.69 (-13.87)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 21:49:44 +00:00
parent 1f49fa03cd
commit 25261d5c8b
3 changed files with 96 additions and 0 deletions

View file

@ -417,6 +417,14 @@ def u_roof(
as uninsulated.
3. Table 18 age-band default.
"""
if insulation_thickness_mm == 0 and _described_as_insulated(description):
# Spec §5.11.4 (page 44 footnote): "If retrofit insulation
# present of unknown thickness use 50 mm". The cert encodes
# "thickness unknown but retrofit insulation present" as the
# "NI" sentinel which `_parse_thickness_mm` parses to 0. Without
# this override the Table 16 row-0 lookup below returns the
# uninsulated 2.30 W/m²K.
return 0.68 # Table 16 row 50, "Insulation at joists at ceiling level"
if insulation_thickness_mm is not None:
# nearest tabulated thickness <= supplied
u = _ROOF_BY_THICKNESS[0][1]

View file

@ -466,6 +466,49 @@ def test_u_wall_uses_rdsap_unknown_thickness_default_of_50mm_when_insulated_but_
# ----- Roofs -----
def test_u_roof_ni_thickness_with_insulated_description_applies_50mm_per_section_5_11_4() -> None:
# Arrange — 346 corpus certs lodge roof_insulation_thickness="NI"
# (Not Indicated, parsed to 0 by _parse_thickness_mm). When the
# description also signals retrofit insulation ("Pitched, insulated
# (assumed)" / "Flat, insulated" / "Roof room(s), insulated
# (assumed)"), RdSAP 10 §5.11.4 (page 44) footnote applies:
# "If retrofit insulation present of unknown thickness use 50 mm".
# That maps to Table 16 row "50 mm at joists at ceiling level" = 0.68
# W/m²K — vs the current 2.30 we return when thickness=0 hits the
# Table 16 row-0 lookup.
# Act
result = u_roof(
country=Country.ENG,
age_band="C",
insulation_thickness_mm=0, # parsed from "NI"
description="Pitched, insulated (assumed)",
)
# Assert
assert result == pytest.approx(0.68, abs=0.01)
def test_u_roof_ni_thickness_with_no_insulation_description_stays_at_2_30() -> None:
# Arrange — 706 corpus certs lodge "Pitched, no insulation
# (assumed)" which can co-occur with thickness="NI". The
# description-based override for retrofit-insulated roofs must
# respect the "no insulation" negation: `_described_as_insulated`
# returns False on "no insulation" substring, so the Table 16
# row-0 lookup applies and U = 2.30 W/m²K stays.
# Act
result = u_roof(
country=Country.ENG,
age_band="C",
insulation_thickness_mm=0,
description="Pitched, no insulation (assumed)",
)
# Assert
assert result == pytest.approx(2.30, abs=0.01)
def test_u_roof_age_band_j_pitched_returns_table18_value() -> None:
# Arrange — Table 18, pitched insulation between joists, age J -> 0.16 W/m^2K.

View file

@ -31,6 +31,51 @@ from domain.sap.worksheet.heat_transmission import (
)
def test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4() -> None:
# Arrange — 346 corpus certs lodge roof_insulation_thickness="NI"
# with descriptions like "Pitched, insulated (assumed)". The
# retrofit-insulation signal in the description must flow from
# epc.roofs[0].description through heat_transmission_from_cert
# into u_roof so the 50 mm assumption fires per RdSAP 10 §5.11.4.
# Geometry: 100 m² ground floor → top floor area also 100 m² →
# roof_area = 100 m². roof_w_per_k expected = 0.68 × 100 = 68
# W/K (vs uninsulated 2.30 × 100 = 230 W/K).
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="C",
wall_construction=3,
wall_insulation_type=4,
party_wall_construction=1,
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0,
),
],
)
# roof_insulation_thickness "NI" mimics the cert field — parses to 0.
main.roof_insulation_thickness = "NI"
epc = make_minimal_sap10_epc(
total_floor_area_m2=100.0,
country_code="ENG",
sap_building_parts=[main],
)
epc.roofs = [
EnergyElement(
description="Pitched, insulated (assumed)",
energy_efficiency_rating=4,
environmental_efficiency_rating=4,
),
]
# Act
result = heat_transmission_from_cert(epc)
# Assert
assert result.roof_w_per_k == pytest.approx(68.0, abs=2.0)
def test_floor_insulated_assumed_with_ni_thickness_uses_50mm_per_table19_footnote() -> None:
# Arrange — 2 413 corpus certs lodge floors with thickness="NI" and
# description "Solid, insulated (assumed)". The retrofit-insulation