diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 10ec411d..ed91551d 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1568,8 +1568,14 @@ class EpcPropertyDataMapper: else None ), ) + # RdSAP 10 §6.1 — exclude the glazed conservatory BP from the + # fabric loop; it is carried as `sap_conservatory` below and + # billed by the §6.1 cascade (window/rooflight/floor), not as + # a dwelling building part. Mirrors the 21.0.1 path. for bp in schema.sap_building_parts + if getattr(bp, "glazed_perimeter", None) is None ], + sap_conservatory=_api_sap_conservatory(schema.sap_building_parts), ) @staticmethod diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index a2463eae..070b100c 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -2433,3 +2433,76 @@ class TestNonSeparatedConservatoryApiMirror: # Assert — §6.2: disregarded; no conservatory geometry. assert epc.sap_conservatory is None assert conservatory_geometry(epc) is None + + +class TestNonSeparatedConservatoryApiMirror19_0: + """RdSAP 10 §6.1 — the same glazed-BP conservatory the 21.0.1 mapper + handles is also lodged on RdSAP-Schema-19.0 (repro cert 718138). The four + glazed fields were undeclared on the 19.0 `SapBuildingPart`, so `from_dict` + dropped them: the conservatory mapped to a fabric-less building part that + mis-scored and could not persist (NOT-NULL `construction_age_band`). The + 19.0 mapper now mirrors the 21.0.1 split. + + Unlike 21.0.1, the 19.0 mapper keeps the **lodged** `total_floor_area` + scalar (a real 19.0 cert already lodges the non-separated conservatory in + it — 718138: dwelling 140.23 + conservatory 9.71 = 149.94 → lodged 150), so + appending a conservatory does NOT change `total_floor_area_m2`. The §6.1 + floor-area fold reaches the score through the calculator's dimensions, not + through the scalar.""" + + def test_from_api_response_splits_out_conservatory_building_part( + self, + ) -> None: + # Arrange — the 19.0 dwelling plus a non-separated double-glazed + # conservatory glazed BP. + from datatypes.epc.domain.epc_property_data import SapConservatory + from domain.sap10_calculator.worksheet.conservatory import ( + conservatory_geometry, + ) + + dwelling_part_count = len( + EpcPropertyDataMapper.from_api_response(load("19_0.json")).sap_building_parts + ) + + cert = load("19_0.json") + cert["conservatory_type"] = 4 + cert["sap_building_parts"].append( + { + "floor_area": 12.0, + "room_height": 1, + "double_glazed": "Y", + "glazed_perimeter": 9.0, + } + ) + + # Act + epc = EpcPropertyDataMapper.from_api_response(cert) + + # Assert — conservatory split out; the glazed BP is NOT a fabric part. + assert epc.sap_conservatory == SapConservatory( + floor_area_m2=12.0, + glazed_perimeter_m=9.0, + double_glazed=True, + thermally_separated=False, + room_height_storeys=1.0, + ) + assert len(epc.sap_building_parts) == dwelling_part_count + # §6.1 fold is active (surfaces derived by the shared cascade). + assert conservatory_geometry(epc) is not None + + def test_separated_conservatory_lodges_no_glazed_building_part(self) -> None: + # Arrange — a separated conservatory (type 2/3) lodges NO glazed BP; + # the dwelling is unchanged. + from domain.sap10_calculator.worksheet.conservatory import ( + conservatory_geometry, + ) + + cert = load("19_0.json") + cert["conservatory_type"] = 2 + + # Act + epc = EpcPropertyDataMapper.from_api_response(cert) + + # Assert — §6.2: disregarded; no conservatory geometry. + assert epc.sap_conservatory is None + assert conservatory_geometry(epc) is None diff --git a/datatypes/epc/schema/rdsap_schema_19_0.py b/datatypes/epc/schema/rdsap_schema_19_0.py index b91dff35..2ba0f3cb 100644 --- a/datatypes/epc/schema/rdsap_schema_19_0.py +++ b/datatypes/epc/schema/rdsap_schema_19_0.py @@ -122,6 +122,16 @@ class SapBuildingPart: wall_insulation_thickness: Optional[str] = None floor_insulation_thickness: Optional[str] = None flat_roof_insulation_thickness: Optional[Union[str, int]] = None + # RdSAP 10 §6.1 — a NON-SEPARATED conservatory (`conservatory_type == 4`) is + # lodged by the gov API as a glazed "building part" carrying ONLY these four + # fields (no fabric, no floor dimensions). Previously undeclared → dropped by + # `from_dict`, so the conservatory was silently lost on the 19.0 path. The + # mapper splits this BP out into `EpcPropertyData.sap_conservatory`. Mirrors + # the 21.0.1 schema declaration. + floor_area: Optional[Union[Measurement, int, float]] = None + room_height: Optional[Union[Measurement, int, float]] = None + double_glazed: Optional[str] = None + glazed_perimeter: Optional[Union[Measurement, int, float]] = None @dataclass