From 25261d5c8b6def8116e29b1f6a215999820b51c0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 18 May 2026 21:49:44 +0000 Subject: [PATCH] =?UTF-8?q?slice=20S-B28:=20=C2=A75.11.4=20=E2=80=94=20roo?= =?UTF-8?q?f=20"NI"=20+=20insulated=20description=20=E2=86=92=2050=20mm=20?= =?UTF-8?q?joist=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../domain/src/domain/ml/rdsap_uvalues.py | 8 ++++ .../src/domain/ml/tests/test_rdsap_uvalues.py | 43 ++++++++++++++++++ .../worksheet/tests/test_heat_transmission.py | 45 +++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/packages/domain/src/domain/ml/rdsap_uvalues.py b/packages/domain/src/domain/ml/rdsap_uvalues.py index a38d05b7..27069827 100644 --- a/packages/domain/src/domain/ml/rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/rdsap_uvalues.py @@ -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] diff --git a/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py b/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py index 90c13cce..afc96334 100644 --- a/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py @@ -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. diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py b/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py index cf5950f6..fdbed35a 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py @@ -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