From fb3973457aee3424f65b36f4cedcf661c40df9d7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 24 May 2026 14:14:25 +0000 Subject: [PATCH] Slice 40: room_in_roof_type_1 gable lengths flow through schema-21 to EpcPropertyData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema-21.0.0/0.1's SapRoomInRoof dataclass declared only floor_area and construction_age_band. Real certs lodge gable wall lengths under sap_room_in_roof.room_in_roof_type_1 (RdSAP §3.9.1 Simplified Type 1). from_dict silently dropped the whole block at deserialization, so the mapper never had a chance to surface the lengths on EpcPropertyData. Fix: add RoomInRoofType1 dataclass to both schema-21 variants; extend SapRoomInRoof with `room_in_roof_type_1: Optional[...]`; update the mapper to populate EpcPropertyData.SapRoomInRoof gable_1_length_m / gable_2_length_m from the new field. Calculator behaviour unchanged this slice: heat_transmission.py:243 requires BOTH length AND height to contribute gable area, and the cert lodges length only (RdSAP §3.9.1 uses a default 2.45 m storey height — not yet plumbed). Cert 0240's −12 SAP residual unchanged. Schema scope: both 21.0.0 and 21.0.1 schemas (identical SapBuildingPart mapper code, kept consistent). Older schemas (17/18/19/20) don't carry this RR shape on their dataclasses and are out of scope per the prior cohort scope decision. Unblocks the follow-up slices that close the RR cascade: default H_gable in calculator or mapper, parse "Roof room(s), insulated (assumed)" description for the U-value override, etc. 930/930 Elmhurst cascade green. 14/14 golden cohort green at pinned residuals (no shift, as expected). 76/76 mapper tests green. Pyright net-zero (32 errors before and after). Co-Authored-By: Claude Opus 4.7 --- datatypes/epc/domain/mapper.py | 26 +++++++++++++++++++ .../domain/tests/test_from_rdsap_schema.py | 19 ++++++++++++++ datatypes/epc/schema/rdsap_schema_21_0_0.py | 15 +++++++++++ datatypes/epc/schema/rdsap_schema_21_0_1.py | 15 +++++++++++ .../epc/schema/tests/fixtures/21_0_1.json | 11 +++++++- 5 files changed, 85 insertions(+), 1 deletion(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 708ad571..8068485f 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1233,6 +1233,19 @@ class EpcPropertyDataMapper: SapRoomInRoof( floor_area=_measurement_value(bp.sap_room_in_roof.floor_area), construction_age_band=bp.sap_room_in_roof.construction_age_band, + # RdSAP §3.9.1 Simplified Type 1: gable lengths + # only (no heights — the cascade applies the + # 2.45 m default storey height per §3.9.1). + gable_1_length_m=( + bp.sap_room_in_roof.room_in_roof_type_1.gable_wall_length_1 + if bp.sap_room_in_roof.room_in_roof_type_1 is not None + else None + ), + gable_2_length_m=( + bp.sap_room_in_roof.room_in_roof_type_1.gable_wall_length_2 + if bp.sap_room_in_roof.room_in_roof_type_1 is not None + else None + ), ) if bp.sap_room_in_roof else None @@ -1478,6 +1491,19 @@ class EpcPropertyDataMapper: SapRoomInRoof( floor_area=_measurement_value(bp.sap_room_in_roof.floor_area), construction_age_band=bp.sap_room_in_roof.construction_age_band, + # RdSAP §3.9.1 Simplified Type 1: gable lengths + # only (no heights — the cascade applies the + # 2.45 m default storey height per §3.9.1). + gable_1_length_m=( + bp.sap_room_in_roof.room_in_roof_type_1.gable_wall_length_1 + if bp.sap_room_in_roof.room_in_roof_type_1 is not None + else None + ), + gable_2_length_m=( + bp.sap_room_in_roof.room_in_roof_type_1.gable_wall_length_2 + if bp.sap_room_in_roof.room_in_roof_type_1 is not None + else None + ), ) if bp.sap_room_in_roof else None diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index f6551429..cebad47c 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -587,6 +587,25 @@ class TestFromRdSapSchema21_0_1: def test_party_wall_length(self, result: EpcPropertyData) -> None: assert result.sap_building_parts[0].sap_floor_dimensions[0].party_wall_length_m == 7.9 + # --- room-in-roof (sap_room_in_roof.room_in_roof_type_1) --- + + def test_sap_room_in_roof_gable_lengths_extracted_from_room_in_roof_type_1( + self, result: EpcPropertyData + ) -> None: + # Arrange — schema-21.0.1 lodges Simplified Type 1 gable lengths + # under sap_room_in_roof.room_in_roof_type_1. The cascade requires + # them on EpcPropertyData.SapRoomInRoof.gable_1_length_m / + # gable_2_length_m for the §3.9.2 area cascade. Without this the + # length data is silently dropped at deserialization. + + # Act + rir = result.sap_building_parts[0].sap_room_in_roof + + # Assert + assert rir is not None + assert rir.gable_1_length_m == 6.4 + assert rir.gable_2_length_m == 6.4 + # --- ventilation (sap_ventilation) --- def test_sap_ventilation_extract_fans_count_flows_through_to_calculator_input( diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index 54077b20..279c35b9 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -166,11 +166,26 @@ class SapFloorDimension: floor_construction: Optional[int] = None +@dataclass +class RoomInRoofType1: + """RdSAP §3.9.1 Simplified Type 1 RR — gable lengths only. + + `gable_wall_type_*` is the Table 4 gable variant (0 = external, etc.; + full enum not yet mapped). `gable_wall_length_*` is the run of the + external gable in metres. Heights are NOT lodged here — the cascade + applies the §3.9.1 default storey height (2.45 m).""" + gable_wall_type_1: Optional[int] = None + gable_wall_type_2: Optional[int] = None + gable_wall_length_1: Optional[float] = None + gable_wall_length_2: Optional[float] = None + + @dataclass class SapRoomInRoof: """Room-in-roof details. insulation and roof_room_connected removed in schema 21.0.0.""" floor_area: Union[int, float] construction_age_band: str + room_in_roof_type_1: Optional[RoomInRoofType1] = 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 f9c6125b..db89194b 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -176,10 +176,25 @@ class SapFloorDimension: floor_construction: Optional[int] = None +@dataclass +class RoomInRoofType1: + """RdSAP §3.9.1 Simplified Type 1 RR — gable lengths only. + + `gable_wall_type_*` is the Table 4 gable variant (0 = external, etc.; + full enum not yet mapped). `gable_wall_length_*` is the run of the + external gable in metres. Heights are NOT lodged here — the cascade + applies the §3.9.1 default storey height (2.45 m).""" + gable_wall_type_1: Optional[int] = None + gable_wall_type_2: Optional[int] = None + gable_wall_length_1: Optional[float] = None + gable_wall_length_2: Optional[float] = None + + @dataclass class SapRoomInRoof: floor_area: Union[int, float] construction_age_band: str + room_in_roof_type_1: Optional[RoomInRoofType1] = None @dataclass diff --git a/datatypes/epc/schema/tests/fixtures/21_0_1.json b/datatypes/epc/schema/tests/fixtures/21_0_1.json index ff332801..a8c8d645 100644 --- a/datatypes/epc/schema/tests/fixtures/21_0_1.json +++ b/datatypes/epc/schema/tests/fixtures/21_0_1.json @@ -126,7 +126,16 @@ "identifier": "Main Dwelling", "wall_dry_lined": "N", "floor_heat_loss": 7, - "sap_room_in_roof": {"floor_area": 100, "construction_age_band": "B"}, + "sap_room_in_roof": { + "floor_area": 100, + "construction_age_band": "B", + "room_in_roof_type_1": { + "gable_wall_type_1": 0, + "gable_wall_type_2": 0, + "gable_wall_length_1": 6.4, + "gable_wall_length_2": 6.4 + } + }, "roof_construction": 4, "wall_construction": 4, "building_part_number": 1,