From 12ff15e55b22fb022fb980021c7ef9db7aa09479 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 10 Jun 2026 15:21:00 +0000 Subject: [PATCH] =?UTF-8?q?Parse=2020.0.0=20conservatory=20building=20part?= =?UTF-8?q?s=20so=20all=201000=20certs=20map=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../domain/tests/test_from_rdsap_schema.py | 34 +++++++++++++++++++ datatypes/epc/schema/rdsap_schema_20_0_0.py | 30 +++++++++------- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index f536cbb7..fb4cce54 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -1365,3 +1365,37 @@ class TestRdSap20_0_0ReducedFieldSynthesis: # Assert assert result.sap_heating.number_baths == expected_baths assert result.sap_heating.mixer_shower_count == expected_mixers + + def test_conservatory_building_part_maps_without_missing_required_field( + self, + ) -> None: + # Arrange — ADR-0027: 17/1000 certs lodge a conservatory-shaped + # sap_building_part carrying only {double_glazed, floor_area, + # glazed_perimeter, room_height} — NOT the wall/roof/floor construction + # fields. The placeholder schema declared identifier (and the + # construction fields) required, so all 17 failed to parse. Following + # the 21.0.1 precedent, every SapBuildingPart field is Optional and the + # conservatory's effect is carried separately by conservatory_type, so + # the all-None part flows through harmlessly. + corpus = _load_20_0_0_corpus() + if not corpus: + pytest.skip("no RdSAP-Schema-20.0.0 corpus harvested") + cert = next( + ( + c + for c in corpus + if any( + "identifier" not in part + for part in c.get("sap_building_parts", []) + ) + ), + None, + ) + if cert is None: + pytest.skip("no corpus cert with a conservatory building part") + + # Act + result = EpcPropertyDataMapper.from_api_response(cert) + + # Assert + assert isinstance(result, EpcPropertyData) diff --git a/datatypes/epc/schema/rdsap_schema_20_0_0.py b/datatypes/epc/schema/rdsap_schema_20_0_0.py index 2c959924..dbd5feef 100644 --- a/datatypes/epc/schema/rdsap_schema_20_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_20_0_0.py @@ -128,18 +128,24 @@ class SapAlternativeWall: @dataclass class SapBuildingPart: - identifier: str - wall_dry_lined: str - floor_heat_loss: int - roof_construction: int - wall_construction: int - building_part_number: int - sap_floor_dimensions: List[SapFloorDimension] - wall_insulation_type: int - construction_age_band: str - party_wall_construction: Union[int, str] - wall_thickness_measured: str - roof_insulation_location: Union[int, str] + # ADR-0027: 17/1000 certs lodge a CONSERVATORY-shaped building part carrying + # only {double_glazed, floor_area, glazed_perimeter, room_height} — none of + # the wall/roof/floor construction fields below. Following the 21.0.1 + # precedent every field is Optional, so a conservatory part parses to an + # all-None SapBuildingPart; its thermal effect is carried separately by the + # cert-level conservatory_type, so the empty part flows through harmlessly. + identifier: Optional[str] = None + wall_dry_lined: Optional[str] = None + floor_heat_loss: Optional[int] = None + roof_construction: Optional[int] = None + wall_construction: Optional[int] = None + building_part_number: Optional[int] = None + sap_floor_dimensions: Optional[List[SapFloorDimension]] = None + wall_insulation_type: Optional[int] = None + construction_age_band: Optional[str] = None + party_wall_construction: Optional[Union[int, str]] = None + wall_thickness_measured: Optional[str] = None + roof_insulation_location: Optional[Union[int, str]] = None # ADR-0027: absent on 254/1506 building parts (flat-roof / no-loft) → optional. roof_insulation_thickness: Optional[Union[str, int]] = None sap_room_in_roof: Optional[SapRoomInRoof] = None