diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 10ec411d4..3ebc785c8 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2685,7 +2685,10 @@ class EpcPropertyDataMapper: return _clear_basement_flag_when_system_built( _with_renewable_heat_incentive( - _with_recorded_performance(mapped, data), data + _with_recorded_performance( + _drop_building_parts_without_age_band(mapped), data + ), + data, ) ) @@ -2987,6 +2990,35 @@ def _optional_float(value: Any) -> Optional[float]: return float(value) if value is not None else None +def _drop_building_parts_without_age_band( + epc: EpcPropertyData, +) -> EpcPropertyData: + """Drop building parts that carry no `construction_age_band`. + + Some gov-API certs lodge a leading building part with floor geometry but no + construction descriptors (no age band, identifier or wall/roof construction) + — a glazed-perimeter "conservatory shell" / lodging artifact, e.g. cert + 3235-4627-4400-0610-7292's part 0 `{floor_area, room_height, glazed_perimeter, + double_glazed}`. A part with no age band cannot have its RdSAP fabric U-values + derived (the engine reads `parts[0].construction_age_band` as the primary era), + and it violates the epc_building_part NOT-NULL column on persist. + + Drops such parts only when at least one banded part remains, so a fully + band-less cert (e.g. full SAP, measured-U) is left untouched. Returns the same + object when nothing changes.""" + parts = epc.sap_building_parts + banded = [ + p + for p in parts + # construction_age_band is typed str but the gov API genuinely omits it + # on these artifact parts, so the runtime None check is real. + if p.construction_age_band is not None # pyright: ignore[reportUnnecessaryComparison] + ] + if banded and len(banded) != len(parts): + return replace(epc, sap_building_parts=banded) + return epc + + def _clear_basement_flag_when_system_built( epc: EpcPropertyData, ) -> EpcPropertyData: diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index a2463eae2..91ac5afd9 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -191,6 +191,32 @@ class TestFromRdSapSchema20_0_0: schema = from_dict(RdSapSchema20_0_0, load("20_0_0.json")) return EpcPropertyDataMapper.from_rdsap_schema_20_0_0(schema) + def test_leading_building_part_without_age_band_is_dropped(self) -> None: + # Some gov-API certs lodge a leading building part with floor geometry but + # no construction descriptors (no age band / identifier / wall / roof) — a + # conservatory-shell / lodging artifact (e.g. cert 3235-4627-4400-0610-7292 + # part 0). It maps to construction_age_band=None, which violates the + # epc_building_part NOT-NULL column on persist (and the engine reads + # parts[0].construction_age_band as the primary era). It must be dropped, + # leaving the real banded parts. + data = load("20_0_0.json") + data["sap_building_parts"].insert( + 0, + { + "floor_area": 11.22, + "room_height": 1, + "double_glazed": "N", + "glazed_perimeter": 10.31, + }, + ) + + epc = EpcPropertyDataMapper.from_api_response(data) + + assert len(epc.sap_building_parts) == 1 + assert all( + bp.construction_age_band is not None for bp in epc.sap_building_parts + ) + def test_photovoltaic_supply_as_dict_list_is_mapped_not_crashed(self) -> None: # 20.0.0 types `photovoltaic_supply` as the wrapper only (not the 21.0.x # Union), so a cert lodging measured arrays as a LIST leaves the leaves as