From 700f44cbbc59e004d307ff755068da8f10ee2496 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 24 Jun 2026 15:58:14 +0000 Subject: [PATCH] =?UTF-8?q?Map=20non-separated=20conservatory=20on=20the?= =?UTF-8?q?=2017.0/17.1/18.0/20.0.0=20gov=20paths=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the §6.1 conservatory split (declare the four glazed fields; exclude the glazed BP from the fabric loop; carry it as sap_conservatory) to the remaining gov mappers that lacked it. Fixes the modelling_e2e construction_age_band NOT-NULL failures: 5 type-4 conservatory certs on RdSAP-Schema-20.0.0 (e.g. property 725213) mapped the conservatory to a null-age fabric part that crashed on persist. 17.0/17.1/18.0 ported preventively (no-op for non-conservatory certs). 21.0.0 deferred — it never adopted the ADR-0027 all-Optional SapBuildingPart, so a sparse conservatory part needs that broader change first. Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 20 ++++ .../domain/tests/test_from_rdsap_schema.py | 103 ++++++++++++++++++ datatypes/epc/schema/rdsap_schema_17_0.py | 8 ++ datatypes/epc/schema/rdsap_schema_17_1.py | 8 ++ datatypes/epc/schema/rdsap_schema_18_0.py | 8 ++ datatypes/epc/schema/rdsap_schema_20_0_0.py | 15 ++- 6 files changed, 159 insertions(+), 3 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 55895ae1..a5ba3225 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -710,8 +710,13 @@ class EpcPropertyDataMapper: roof_insulation_thickness=bp.roof_insulation_thickness, sap_room_in_roof=None, ) + # RdSAP 10 §6.1 — exclude the glazed conservatory BP from the + # fabric loop; carried as `sap_conservatory` below. Mirrors the + # 19.0 / 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 @@ -1147,8 +1152,13 @@ class EpcPropertyDataMapper: roof_insulation_thickness=bp.roof_insulation_thickness, sap_room_in_roof=None, ) + # RdSAP 10 §6.1 — exclude the glazed conservatory BP from the + # fabric loop; carried as `sap_conservatory` below. Mirrors the + # 19.0 / 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 @@ -1370,8 +1380,13 @@ class EpcPropertyDataMapper: else None ), ) + # RdSAP 10 §6.1 — exclude the glazed conservatory BP from the + # fabric loop; carried as `sap_conservatory` below. Mirrors the + # 19.0 / 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 @@ -1825,8 +1840,13 @@ class EpcPropertyDataMapper: else None ), ) + # RdSAP 10 §6.1 — exclude the glazed conservatory BP from the + # fabric loop; carried as `sap_conservatory` below. Mirrors the + # 19.0 / 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 f52d1b61..5a2218d3 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -2533,3 +2533,106 @@ class TestNonSeparatedConservatoryApiMirror19_0: # Assert — §6.2: disregarded; no conservatory geometry. assert epc.sap_conservatory is None assert conservatory_geometry(epc) is None + + +class TestNonSeparatedConservatoryApiMirror20_0_0: + """RdSAP 10 §6.1 — the same glazed-BP conservatory, on RdSAP-Schema-20.0.0. + Surfaced by the modelling_e2e triage: 5 type-4 certs (e.g. property 725213) + failed `NotNullViolation: construction_age_band` because the 20.0.0 schema + declared none of the four glazed fields, so the conservatory mapped to a + null-age fabric part. Mirrors the 19.0 / 21.0.1 split.""" + + def test_from_api_response_splits_out_conservatory_building_part( + self, + ) -> None: + 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("20_0_0.json")).sap_building_parts + ) + + cert = load("20_0_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 + # (so it never hits the NOT-NULL construction_age_band on persist). + 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 + assert all( + bp.construction_age_band is not None for bp in epc.sap_building_parts + ) + assert conservatory_geometry(epc) is not None + + def test_separated_conservatory_lodges_no_glazed_building_part(self) -> None: + from domain.sap10_calculator.worksheet.conservatory import ( + conservatory_geometry, + ) + + cert = load("20_0_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 + + +@pytest.mark.parametrize("fixture", ["17_0.json", "17_1.json", "18_0.json"]) +def test_gov_mappers_split_non_separated_conservatory(fixture: str) -> None: + """RdSAP 10 §6.1 — every gov mapper splits a non-separated conservatory + (conservatory_type=4) glazed BP out of the fabric parts and into + `sap_conservatory`, so it never persists as a null-age fabric part (the + modelling_e2e `construction_age_band` NOT-NULL triage). 19.0 / 20.0.0 / + 21.0.1 are covered by their own classes; this pins the rest. + + 21.0.0 is intentionally excluded: unlike every other gov schema it never + adopted the ADR-0027 "every SapBuildingPart field is Optional" treatment, so + a sparse conservatory part fails `from_dict` on its required fields. Porting + it needs that broader strictness change — tracked as a follow-up; no 21.0.0 + conservatory cert surfaced in the triage.""" + from datatypes.epc.domain.epc_property_data import SapConservatory + + base_parts = len( + EpcPropertyDataMapper.from_api_response(load(fixture)).sap_building_parts + ) + cert = load(fixture) + 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 — split out, not a (null-age) 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) == base_parts + assert all(bp.construction_age_band is not None for bp in epc.sap_building_parts) diff --git a/datatypes/epc/schema/rdsap_schema_17_0.py b/datatypes/epc/schema/rdsap_schema_17_0.py index aa1255ca..d66f9c69 100644 --- a/datatypes/epc/schema/rdsap_schema_17_0.py +++ b/datatypes/epc/schema/rdsap_schema_17_0.py @@ -120,6 +120,14 @@ 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 — non-separated conservatory glazed-BP fields + # ({double_glazed, floor_area, glazed_perimeter, room_height}); declared so + # `from_dict` keeps them and the mapper splits the BP into + # `EpcPropertyData.sap_conservatory`. Mirrors the 19.0 / 21.0.1 schema. + 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 diff --git a/datatypes/epc/schema/rdsap_schema_17_1.py b/datatypes/epc/schema/rdsap_schema_17_1.py index f7f186e6..bbd04b79 100644 --- a/datatypes/epc/schema/rdsap_schema_17_1.py +++ b/datatypes/epc/schema/rdsap_schema_17_1.py @@ -106,6 +106,14 @@ class SapBuildingPart: # Can be a thickness string (e.g. "100mm") or 0 for uninsulated flat roofs roof_insulation_thickness: Optional[Union[str, int]] = None wall_insulation_thickness: Optional[str] = None + # RdSAP 10 §6.1 — non-separated conservatory glazed-BP fields + # ({double_glazed, floor_area, glazed_perimeter, room_height}); declared so + # `from_dict` keeps them and the mapper splits the BP into + # `EpcPropertyData.sap_conservatory`. Mirrors the 19.0 / 21.0.1 schema. + 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 diff --git a/datatypes/epc/schema/rdsap_schema_18_0.py b/datatypes/epc/schema/rdsap_schema_18_0.py index b9e98154..f35f0733 100644 --- a/datatypes/epc/schema/rdsap_schema_18_0.py +++ b/datatypes/epc/schema/rdsap_schema_18_0.py @@ -121,6 +121,14 @@ 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 — non-separated conservatory glazed-BP fields + # ({double_glazed, floor_area, glazed_perimeter, room_height}); declared so + # `from_dict` keeps them and the mapper splits the BP into + # `EpcPropertyData.sap_conservatory`. Mirrors the 19.0 / 21.0.1 schema. + 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 diff --git a/datatypes/epc/schema/rdsap_schema_20_0_0.py b/datatypes/epc/schema/rdsap_schema_20_0_0.py index 9a4b78f2..395fb9b9 100644 --- a/datatypes/epc/schema/rdsap_schema_20_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_20_0_0.py @@ -131,9 +131,12 @@ class SapBuildingPart: # 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. + # precedent every field is Optional. RdSAP 10 §6.1: the four glazed fields + # are declared so `from_dict` keeps them and the mapper can split this BP out + # into `EpcPropertyData.sap_conservatory` (a non-separated conservatory, + # `conservatory_type == 4`). Previously undeclared → dropped → the part + # mapped to an all-None fabric part that mis-scored AND violated the + # NOT-NULL `construction_age_band` column on persist (modelling_e2e triage). identifier: Optional[str] = None wall_dry_lined: Optional[str] = None floor_heat_loss: Optional[int] = None @@ -153,6 +156,12 @@ 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 — non-separated conservatory glazed-BP fields (see the + # class docstring). Mirrors the 19.0 / 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