From ae81a81e799fd1ea1d3bc41fa2950a125f00280f Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 24 Jun 2026 11:30:23 +0000 Subject: [PATCH] Drop lodged building parts that carry no construction age band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2 modelling_e2e properties (LODGED, e.g. uprn 100061733327 cert 3235-4627-4400-0610-7292 / uprn 100020397707 cert 8081-7022-7669-5038-0996) failed with NotNullViolation on epc_building_part.construction_age_band. The certs lodge a leading building part with floor geometry but no construction descriptors — {floor_area, room_height, double_glazed, glazed_perimeter}, no age band / identifier / wall / roof. A glazed-perimeter conservatory-shell / lodging artifact. It maps to construction_age_band=None → violates the NOT-NULL column, and the engine reads parts[0].construction_age_band as the primary era (so the junk part also poisoned the calc's primary age band). Fix: from_api_response drops building parts with no construction_age_band, but only when banded parts remain (a fully band-less cert is left untouched). The real Main + Extension parts survive and parts[0] becomes the real Main Dwelling. Verified both certs now map with only their banded parts. Regression test + mapper-corpus / accuracy gates green. Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 34 ++++++++++++++++++- .../domain/tests/test_from_rdsap_schema.py | 26 ++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) 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