Drop lodged building parts that carry no construction age band

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) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-24 11:30:23 +00:00
parent f7f74ea72b
commit ae81a81e79
2 changed files with 59 additions and 1 deletions

View file

@ -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:

View file

@ -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