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