diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index fa33cf10..70bf7d61 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -711,8 +711,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 @@ -1148,8 +1153,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 @@ -1371,8 +1381,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 @@ -1826,8 +1841,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