diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 9f75847d..98feac78 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3960,6 +3960,39 @@ def _api_rir_detailed_surfaces( if length is not None and height is not None and length > 0 and height > 0: area = _round_half_up_2dp(float(length), float(height)) surfaces.append(SapRoomInRoofSurface(kind=gable_kind, area_m2=area)) + # Sloping ceiling + stud walls — up to two of each (RdSAP §3.9 Figure 4). + # Both route to the roof aggregate (line (30)) via the cascade's + # Detailed-RR branch (`u_rr_slope` / `u_rr_stud_wall`, Table 17 cols 1/3). + # insulation_type is left None so the cascade defers to the Table 17 + # column (a) mineral-wool default, mirroring the flat_ceiling branch. + slope_specs = ( + (details.slope_length_1, details.slope_height_1, + details.slope_insulation_thickness_1), + (details.slope_length_2, details.slope_height_2, + details.slope_insulation_thickness_2), + ) + stud_specs = ( + (details.stud_wall_length_1, details.stud_wall_height_1, + details.stud_wall_insulation_thickness_1), + (details.stud_wall_length_2, details.stud_wall_height_2, + details.stud_wall_insulation_thickness_2), + ) + for kind, specs in (("slope", slope_specs), ("stud_wall", stud_specs)): + for length, height, thickness_str in specs: + if ( + length is not None and height is not None + and length > 0 and height > 0 + ): + area = _round_half_up_2dp(float(length), float(height)) + surfaces.append( + SapRoomInRoofSurface( + kind=kind, + area_m2=area, + insulation_thickness_mm=( + _parse_rir_insulation_thickness_mm(thickness_str) + ), + ) + ) if ( details.flat_ceiling_length_1 is not None and details.flat_ceiling_height_1 is not None diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 04073801..5d29893a 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -2152,3 +2152,72 @@ class TestRdSap17_1ReducedFieldSynthesis: assert result.sap_heating.number_baths == expected_baths assert result.sap_heating.mixer_shower_count == expected_mixers + + +class TestRoomInRoofDetailedSlopeAndStudWall: + """RdSAP 10 §3.9 Detailed RR — the gov API lodges the sloping ceiling + and stud-wall surfaces under `room_in_roof_details.slope_*` / + `stud_wall_*`. These were undeclared on the schema, so `from_dict` + dropped them and the API mapper built ONLY the gable + flat-ceiling + surfaces — omitting the (large) sloping roof and vertical knee walls → + undercounted RR heat loss → a systematic ~+4 SAP over-rate across the + 15 detailed-RR corpus certs carrying `slope_height_1`.""" + + def test_slope_surface_survives_from_dict_round_trip(self) -> None: + # Arrange — a 21.0.1 detailed-RR block (cert 0390-2538 shape). + from datatypes.epc.schema.rdsap_schema_21_0_1 import RoomInRoofDetails + + raw = { + "slope_length_1": 7.0, + "slope_height_1": 1.4, + "slope_insulation_thickness_1": "100mm", + "stud_wall_length_1": 7.0, + "stud_wall_height_1": 1.03, + "stud_wall_insulation_thickness_1": "75mm", + } + + # Act + details = from_dict(RoomInRoofDetails, raw) + + # Assert — the fields are no longer silently dropped. + assert details.slope_height_1 == 1.4 + assert details.slope_insulation_thickness_1 == "100mm" + assert details.stud_wall_height_1 == 1.03 + + def test_from_api_response_builds_slope_and_stud_wall_surfaces(self) -> None: + # Arrange — drive the PUBLIC API path: take the 21.0.1 fixture's RR + # building part and replace its Simplified Type-1 block with a + # Detailed RR carrying two sloping ceilings (7 × 1.4) + two stud + # walls (7 × 1.03). cert 0390-2538 went +5.95 -> +3.56 SAP once these + # surfaces entered the roof aggregate. + cert = load("21_0_1.json") + rir = cert["sap_building_parts"][0]["sap_room_in_roof"] + rir.pop("room_in_roof_type_1", None) + rir["room_in_roof_details"] = { + "slope_length_1": 7.0, "slope_height_1": 1.4, + "slope_length_2": 7.0, "slope_height_2": 1.4, + "slope_insulation_thickness_1": "100mm", + "slope_insulation_thickness_2": "100mm", + "stud_wall_length_1": 7.0, "stud_wall_height_1": 1.03, + "stud_wall_length_2": 7.0, "stud_wall_height_2": 1.03, + "stud_wall_insulation_thickness_1": "75mm", + "stud_wall_insulation_thickness_2": "75mm", + } + + # Act + result = EpcPropertyDataMapper.from_api_response(cert) + + # Assert — both slopes + both stud walls reach the cascade, with the + # lodged thickness parsed and the L × H area to 2 d.p. + rir_part = result.sap_building_parts[0].sap_room_in_roof + assert rir_part is not None + surfaces = rir_part.detailed_surfaces + assert surfaces is not None + slopes = [s for s in surfaces if s.kind == "slope"] + studs = [s for s in surfaces if s.kind == "stud_wall"] + assert len(slopes) == 2 + assert len(studs) == 2 + assert abs(slopes[0].area_m2 - 9.8) <= 1e-9 + assert slopes[0].insulation_thickness_mm == 100 + assert abs(studs[0].area_m2 - 7.21) <= 1e-9 + assert studs[0].insulation_thickness_mm == 75 diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index d74cd3ca..fdb0d17c 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -209,6 +209,25 @@ class RoomInRoofDetails: flat_ceiling_height_1: Optional[float] = None flat_ceiling_insulation_type_1: Optional[int] = None flat_ceiling_insulation_thickness_1: Optional[str] = None + # Sloping-ceiling + stud-wall surfaces of a Detailed RR — see + # `rdsap_schema_21_0_1.RoomInRoofDetails`. Previously undeclared and + # dropped by `from_dict`. + slope_length_1: Optional[float] = None + slope_length_2: Optional[float] = None + slope_height_1: Optional[float] = None + slope_height_2: Optional[float] = None + slope_insulation_type_1: Optional[int] = None + slope_insulation_type_2: Optional[int] = None + slope_insulation_thickness_1: Optional[str] = None + slope_insulation_thickness_2: Optional[str] = None + stud_wall_length_1: Optional[float] = None + stud_wall_length_2: Optional[float] = None + stud_wall_height_1: Optional[float] = None + stud_wall_height_2: Optional[float] = None + stud_wall_insulation_type_1: Optional[int] = None + stud_wall_insulation_type_2: Optional[int] = None + stud_wall_insulation_thickness_1: Optional[str] = None + stud_wall_insulation_thickness_2: Optional[str] = None @dataclass diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 12628a7b..db6d4c1a 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -246,6 +246,27 @@ class RoomInRoofDetails: flat_ceiling_height_1: Optional[float] = None flat_ceiling_insulation_type_1: Optional[int] = None flat_ceiling_insulation_thickness_1: Optional[str] = None + # The sloping-ceiling and stud-wall surfaces of a Detailed RR. Up to two + # of each per spec Figure 4. Previously undeclared, so `from_dict` + # silently dropped them and the API mapper built ONLY the gable + flat- + # ceiling surfaces — omitting the (large) sloping roof and the vertical + # stud walls → undercounted RR heat loss → systematic over-rate. + slope_length_1: Optional[float] = None + slope_length_2: Optional[float] = None + slope_height_1: Optional[float] = None + slope_height_2: Optional[float] = None + slope_insulation_type_1: Optional[int] = None + slope_insulation_type_2: Optional[int] = None + slope_insulation_thickness_1: Optional[str] = None + slope_insulation_thickness_2: Optional[str] = None + stud_wall_length_1: Optional[float] = None + stud_wall_length_2: Optional[float] = None + stud_wall_height_1: Optional[float] = None + stud_wall_height_2: Optional[float] = None + stud_wall_insulation_type_1: Optional[int] = None + stud_wall_insulation_type_2: Optional[int] = None + stud_wall_insulation_thickness_1: Optional[str] = None + stud_wall_insulation_thickness_2: Optional[str] = None @dataclass