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