From 66e0adf6196f8bd56ec752ce1dfa505cb30246ec Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 14 Jul 2026 21:26:42 +0000 Subject: [PATCH] =?UTF-8?q?Lodge=20explicit=20zero=20roof=20insulation=20f?= =?UTF-8?q?or=20a=20surveyed=20"Insulation=20At:=20None"=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/domain/tests/test_from_site_notes.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index cc532b6e7..af90f142f 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -74,6 +74,75 @@ class TestRoofConstructionTypeCoding: assert extension_part.roof_construction_type == "Flat" +class TestRoofInsulationNoneCoding: + """A surveyed roof-space "Insulation At: None" is the assessor explicitly + recording ZERO loft insulation — `from_site_notes` must lodge an explicit + `roof_insulation_thickness=0`, not leave it `None` ("unknown"). With `None` + the calculator's `u_roof` falls through to the age-band "assumed insulated" + default (e.g. 0.16 W/m²K at band J/K) instead of the Table 16 uninsulated + row (2.30) — a ~14x understatement of roof heat loss (issue #1590 bug 2; + fixtures 507665533138 / 507670893756 over-rate by ~7 SAP each). + """ + + def test_insulation_at_none_lodges_explicit_zero_thickness(self) -> None: + # Arrange — the surveyor recorded "Insulation At: None" (no thickness + # line follows; there is no insulation to measure). + data = load("pashub_rdsap_site_notes_example1.json") + data["roof_space"]["main_building"]["insulation_at"] = "None" + data["roof_space"]["main_building"]["insulation_thickness_mm"] = None + survey = from_dict(PasHubRdSapSiteNotes, data) + + # Act + result = EpcPropertyDataMapper.from_site_notes(survey) + + # Assert — explicit zero, so u_roof takes the uninsulated branch. + assert result.sap_building_parts[0].roof_insulation_thickness == 0 + + def test_surveyed_thickness_still_passes_through(self) -> None: + # Arrange — the fixture's as-lodged "Joists" + 100mm. + survey = from_dict( + PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example1.json") + ) + + # Act + result = EpcPropertyDataMapper.from_site_notes(survey) + + # Assert + assert result.sap_building_parts[0].roof_insulation_thickness == 100 + + def test_blank_insulation_at_stays_unknown(self) -> None: + # Arrange — a blank lodging is "not surveyed", NOT "no insulation"; + # it must stay None so the age-band default still applies. + data = load("pashub_rdsap_site_notes_example1.json") + data["roof_space"]["main_building"]["insulation_at"] = "" + data["roof_space"]["main_building"]["insulation_thickness_mm"] = None + survey = from_dict(PasHubRdSapSiteNotes, data) + + # Act + result = EpcPropertyDataMapper.from_site_notes(survey) + + # Assert + assert result.sap_building_parts[0].roof_insulation_thickness is None + + def test_extension_insulation_at_none_lodges_explicit_zero(self) -> None: + # Arrange — same behaviour on the extension building part. + data = load("pashub_rdsap_site_notes_example2.json") + data["roof_space"]["extensions"][0]["insulation_at"] = "None" + data["roof_space"]["extensions"][0]["insulation_thickness_mm"] = None + survey = from_dict(PasHubRdSapSiteNotes, data) + + # Act + result = EpcPropertyDataMapper.from_site_notes(survey) + + # Assert + extension_part = next( + bp + for bp in result.sap_building_parts + if bp.identifier != BuildingPartIdentifier.MAIN + ) + assert extension_part.roof_insulation_thickness == 0 + + class TestCountryCode: """`from_site_notes` must set `country_code` so the cascade's England / Wales / Scotland / NI U-value tables (`u_floor`, `u_basement_floor`,