From c1e754057bd04ac75592e18cfcf27e20871179ae Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 25 Jun 2026 14:14:03 +0000 Subject: [PATCH 1/6] =?UTF-8?q?Mapper=20synthesises=20SapFloorDimension=20?= =?UTF-8?q?for=20SAP-16.0=20flats=20with=20empty=20floor=20dims=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../epc/domain/tests/test_from_sap_schema.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index 25acf566..42bb1867 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -751,3 +751,52 @@ class TestFullSapSchema16xRouting: # Assert assert not any("broken schema_type" in r.message for r in caplog.records) + + +class TestFullSapSchema16xNoFloorDimensions: + """SAP-Schema-16.0 certs with assessment_type=SAP that lodge no + sap_floor_dimensions inside sap_building_parts (uprn 10090783001, + cert lodged 88). The API only carries total_floor_area at the top level; + the building part has sap_walls but no per-storey dimension data. + + This is the root cause of the `max() arg is an empty sequence` crash in + building_geometry.roof_area() — documented in + scripts/handover_max_empty_sequence_design_question.md. + """ + + @pytest.fixture + def epc(self) -> EpcPropertyData: + return EpcPropertyDataMapper.from_api_response( + load("sap_16_0_full_no_floor_dims.json") + ) + + def test_maps_successfully(self, epc: EpcPropertyData) -> None: + assert isinstance(epc, EpcPropertyData) + + def test_uprn_and_total_floor_area(self, epc: EpcPropertyData) -> None: + assert epc.uprn == 10090783001 + assert epc.total_floor_area_m2 == 72.0 + + def test_building_part_has_empty_floor_dimensions( + self, epc: EpcPropertyData + ) -> None: + # The API lodges no sap_floor_dimensions for this cert family. + # The mapper faithfully produces an empty list — the data gap that + # causes roof_area() / gross_heat_loss_wall_area() to crash downstream. + main_part = epc.sap_building_parts[0] + assert main_part.sap_floor_dimensions == [] + + def test_total_floor_area_available_at_cert_level( + self, epc: EpcPropertyData + ) -> None: + # total_floor_area_m2 IS present at the EpcPropertyData level (72 m²). + # This is the candidate fallback value for roof_area() — accurate for + # single-storey single-part certs, an overestimate for multi-storey. + assert epc.total_floor_area_m2 == 72.0 + + def test_synthesises_single_floor_dimension(self, epc: EpcPropertyData) -> None: + # Arrange + main_part = epc.sap_building_parts[0] + + # Act / Assert + assert len(main_part.sap_floor_dimensions) == 1 From 8a909dc15b5d1d11a0a23ec31763bb8fe04e82df Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 25 Jun 2026 14:16:36 +0000 Subject: [PATCH 2/6] =?UTF-8?q?Mapper=20synthesises=20SapFloorDimension=20?= =?UTF-8?q?for=20SAP-16.0=20flats=20with=20empty=20floor=20dims=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- datatypes/epc/domain/mapper.py | 53 ++++++++++++++----- .../epc/domain/tests/test_from_sap_schema.py | 10 ++-- datatypes/epc/schema/sap_schema_17_1.py | 7 +++ 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 0f1b8bad..1f11895a 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -799,7 +799,13 @@ class EpcPropertyDataMapper: # D1: derive heat-loss perimeter + party-wall length from the # measured wall areas (full SAP lodges neither directly). sap_building_parts=[ - _sap_17_1_building_part(bp, i) + _sap_17_1_building_part( + bp, + i, + total_floor_area=float(schema.total_floor_area), + is_single_part=len(schema.sap_building_parts) == 1, + is_flat=schema.sap_flat_details is not None, + ) for i, bp in enumerate(schema.sap_building_parts) ], # D6: full-SAP heating — translate the differing field names onto @@ -2903,7 +2909,11 @@ def _with_recorded_performance( def _sap_17_1_building_part( - bp: SapBuildingPart_SAP_17_1, index: int + bp: SapBuildingPart_SAP_17_1, + index: int, + total_floor_area: float, + is_single_part: bool, + is_flat: bool, ) -> SapBuildingPart: """D1: build one `SapBuildingPart`, deriving the heat-loss perimeter and party-wall length the engine needs from the measured wall areas full SAP @@ -2913,6 +2923,12 @@ def _sap_17_1_building_part( length, 5 internal → discarded; any other code fails loud. Exposed/party areas divide by Σ storey-heights so the per-storey perimeter, summed as Σ(perimeter × height) by the engine, reconstructs the measured area exactly. + + When `bp.sap_floor_dimensions` is absent (SAP-16.0 flat certs) and the cert + is confirmed single-part and flat-type, synthesises one SapFloorDimension + from the cert-level `total_floor_area`. Only safe for single-storey + single-part flats; multi-part or multi-storey cases are left empty so + downstream failures stay diagnosable. """ exposed_area = 0.0 party_area = 0.0 @@ -2930,17 +2946,28 @@ def _sap_17_1_building_part( perimeter_per_storey = exposed_area / total_height party_length_per_storey = party_area / total_height - floor_dimensions = [ - SapFloorDimension( - room_height_m=fd.storey_height, - total_floor_area_m2=fd.total_floor_area, - heat_loss_perimeter_m=perimeter_per_storey, - party_wall_length_m=party_length_per_storey, - floor=fd.storey, - floor_construction=fd.floor_type, - ) - for fd in bp.sap_floor_dimensions - ] + if not bp.sap_floor_dimensions and is_single_part and is_flat: + floor_dimensions = [ + SapFloorDimension( + room_height_m=1.0, + total_floor_area_m2=total_floor_area, + heat_loss_perimeter_m=perimeter_per_storey, + party_wall_length_m=party_length_per_storey, + floor=0, + ) + ] + else: + floor_dimensions = [ + SapFloorDimension( + room_height_m=fd.storey_height, + total_floor_area_m2=fd.total_floor_area, + heat_loss_perimeter_m=perimeter_per_storey, + party_wall_length_m=party_length_per_storey, + floor=fd.storey, + floor_construction=fd.floor_type, + ) + for fd in bp.sap_floor_dimensions + ] if index == 0: identifier = BuildingPartIdentifier.MAIN diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index 42bb1867..2153b3f5 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -777,14 +777,14 @@ class TestFullSapSchema16xNoFloorDimensions: assert epc.uprn == 10090783001 assert epc.total_floor_area_m2 == 72.0 - def test_building_part_has_empty_floor_dimensions( + def test_building_part_floor_dimensions_synthesised( self, epc: EpcPropertyData ) -> None: - # The API lodges no sap_floor_dimensions for this cert family. - # The mapper faithfully produces an empty list — the data gap that - # causes roof_area() / gross_heat_loss_wall_area() to crash downstream. + # The API lodges no sap_floor_dimensions for this cert family, but the + # mapper synthesises one from the cert-level total_floor_area so that + # roof_area() and gross_heat_loss_wall_area() do not crash downstream. main_part = epc.sap_building_parts[0] - assert main_part.sap_floor_dimensions == [] + assert len(main_part.sap_floor_dimensions) == 1 def test_total_floor_area_available_at_cert_level( self, epc: EpcPropertyData diff --git a/datatypes/epc/schema/sap_schema_17_1.py b/datatypes/epc/schema/sap_schema_17_1.py index b84d3270..03d79f2f 100644 --- a/datatypes/epc/schema/sap_schema_17_1.py +++ b/datatypes/epc/schema/sap_schema_17_1.py @@ -171,6 +171,11 @@ class EnergyElement: environmental_efficiency_rating: int +@dataclass +class SapFlatDetails: + level: Optional[int] = None + + @dataclass class SapSchema17_1: uprn: int @@ -199,3 +204,5 @@ class SapSchema17_1: # Some 17.0 full-SAP certs omit the top-level flag and lodge it only under # sap_heating; the mapper falls back to sap_heating.has_hot_water_cylinder. has_hot_water_cylinder: Optional[str] = None + # Present for flat-type dwellings; absence means not a flat. + sap_flat_details: Optional[SapFlatDetails] = None From 76d07dba39e39a7b560b3a01e0b9bd467da0ecac Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 25 Jun 2026 14:18:27 +0000 Subject: [PATCH 3/6] =?UTF-8?q?Pin=20synthesised=20SapFloorDimension=20tot?= =?UTF-8?q?al=20area=20and=20heat-loss=20perimeter=20as=20regression=20tes?= =?UTF-8?q?ts=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../epc/domain/tests/test_from_sap_schema.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index 2153b3f5..47b6cc8c 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -800,3 +800,20 @@ class TestFullSapSchema16xNoFloorDimensions: # Act / Assert assert len(main_part.sap_floor_dimensions) == 1 + + def test_synthesised_floor_dimension_total_area(self, epc: EpcPropertyData) -> None: + # Arrange + fd = epc.sap_building_parts[0].sap_floor_dimensions[0] + + # Act / Assert + assert fd.total_floor_area_m2 == 72.0 + + def test_synthesised_floor_dimension_heat_loss_perimeter( + self, epc: EpcPropertyData + ) -> None: + # Arrange: both sap_walls are wall_type=2 (exposed): 43.2 + 3.88 = 47.08 m². + # total_height falls back to 1.0 (no storey heights lodged), so perimeter = 47.08 m. + fd = epc.sap_building_parts[0].sap_floor_dimensions[0] + + # Act / Assert + assert fd.heat_loss_perimeter_m == pytest.approx(47.08) From e62e5fbfe42220b99208f90a913ed60fa98e8989 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 25 Jun 2026 14:24:59 +0000 Subject: [PATCH 4/6] Add comment explaining floor-dim synthesis guard and bungalow coverage gap Co-Authored-By: Claude Sonnet 4.6 --- datatypes/epc/domain/mapper.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 1f11895a..72bd667b 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2946,6 +2946,17 @@ def _sap_17_1_building_part( perimeter_per_storey = exposed_area / total_height party_length_per_storey = party_area / total_height + # Guard: synthesise only when floor dims are absent AND we can confirm + # single-storey single-part. `is_flat` (sap_flat_details present) is the + # current signal — flats are always single-storey within the dwelling. + # Bungalows are equally single-storey but carry no equivalent structural + # field; `dwelling_type` string matching is the only handle, and has not + # been confirmed necessary (no bungalows with empty floor dims were found + # in the failing batch). An alternative worth considering: drop `is_flat` + # entirely and trigger on `not bp.sap_floor_dimensions and is_single_part` + # alone — if the cert lodged no dims at all, there is no per-storey data to + # misattribute regardless of dwelling type. Requires corpus evidence that + # multi-storey single-part certs never omit floor dims before widening. if not bp.sap_floor_dimensions and is_single_part and is_flat: floor_dimensions = [ SapFloorDimension( From 8024065d04d5ef3d00a34c796b56b7b69660dfd1 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 25 Jun 2026 15:03:03 +0000 Subject: [PATCH 5/6] =?UTF-8?q?Raise=20ValueError=20when=20floor=20dims=20?= =?UTF-8?q?absent=20and=20synthesis=20guards=20cannot=20fire=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- datatypes/epc/domain/mapper.py | 6 ++++++ datatypes/epc/domain/tests/test_from_sap_schema.py | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 72bd667b..1c34d060 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2968,6 +2968,12 @@ def _sap_17_1_building_part( ) ] else: + if not bp.sap_floor_dimensions: + raise ValueError( + f"building_part {bp.building_part_number!r}: sap_floor_dimensions " + f"is empty and cannot be synthesised (is_single_part={is_single_part}, " + f"is_flat={is_flat})" + ) floor_dimensions = [ SapFloorDimension( room_height_m=fd.storey_height, diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index 47b6cc8c..f55bbb8f 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -817,3 +817,15 @@ class TestFullSapSchema16xNoFloorDimensions: # Act / Assert assert fd.heat_loss_perimeter_m == pytest.approx(47.08) + + def test_raises_when_floor_dims_absent_and_synthesis_not_possible( + self, + ) -> None: + # Arrange: strip sap_flat_details so is_flat=False — synthesis guard + # cannot fire, empty floor dims must raise rather than silently produce []. + data = load("sap_16_0_full_no_floor_dims.json") + data.pop("sap_flat_details", None) + + # Act / Assert + with pytest.raises(ValueError, match="sap_floor_dimensions"): + EpcPropertyDataMapper.from_api_response(data) From 5a4285e1b759ba31f9c945f37568578400e63439 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 25 Jun 2026 15:07:07 +0000 Subject: [PATCH 6/6] Include address and postcode in floor-dims synthesis ValueError for triage Co-Authored-By: Claude Sonnet 4.6 --- datatypes/epc/domain/mapper.py | 11 ++++++++--- datatypes/epc/domain/tests/test_from_sap_schema.py | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 1c34d060..d5cb1708 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -805,6 +805,8 @@ class EpcPropertyDataMapper: total_floor_area=float(schema.total_floor_area), is_single_part=len(schema.sap_building_parts) == 1, is_flat=schema.sap_flat_details is not None, + address_line_1=schema.address_line_1, + postcode=schema.postcode, ) for i, bp in enumerate(schema.sap_building_parts) ], @@ -2914,6 +2916,8 @@ def _sap_17_1_building_part( total_floor_area: float, is_single_part: bool, is_flat: bool, + address_line_1: str, + postcode: str, ) -> SapBuildingPart: """D1: build one `SapBuildingPart`, deriving the heat-loss perimeter and party-wall length the engine needs from the measured wall areas full SAP @@ -2970,9 +2974,10 @@ def _sap_17_1_building_part( else: if not bp.sap_floor_dimensions: raise ValueError( - f"building_part {bp.building_part_number!r}: sap_floor_dimensions " - f"is empty and cannot be synthesised (is_single_part={is_single_part}, " - f"is_flat={is_flat})" + f"{address_line_1!r}, {postcode!r}, " + f"building_part {bp.building_part_number!r}: " + f"sap_floor_dimensions is empty and cannot be synthesised " + f"(is_single_part={is_single_part}, is_flat={is_flat})" ) floor_dimensions = [ SapFloorDimension( diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index f55bbb8f..d05b7d78 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -827,5 +827,5 @@ class TestFullSapSchema16xNoFloorDimensions: data.pop("sap_flat_details", None) # Act / Assert - with pytest.raises(ValueError, match="sap_floor_dimensions"): + with pytest.raises(ValueError, match="Wardalls Grove.*SE14 5FB.*sap_floor_dimensions"): EpcPropertyDataMapper.from_api_response(data)