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