Mapper synthesises SapFloorDimension for SAP-16.0 flats with empty floor dims 🟩

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-06-25 14:16:36 +00:00
parent c1e754057b
commit 8a909dc15b
3 changed files with 52 additions and 18 deletions

View file

@ -799,7 +799,13 @@ class EpcPropertyDataMapper:
# D1: derive heat-loss perimeter + party-wall length from the # D1: derive heat-loss perimeter + party-wall length from the
# measured wall areas (full SAP lodges neither directly). # measured wall areas (full SAP lodges neither directly).
sap_building_parts=[ 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) for i, bp in enumerate(schema.sap_building_parts)
], ],
# D6: full-SAP heating — translate the differing field names onto # D6: full-SAP heating — translate the differing field names onto
@ -2903,7 +2909,11 @@ def _with_recorded_performance(
def _sap_17_1_building_part( 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: ) -> SapBuildingPart:
"""D1: build one `SapBuildingPart`, deriving the heat-loss perimeter and """D1: build one `SapBuildingPart`, deriving the heat-loss perimeter and
party-wall length the engine needs from the measured wall areas full SAP 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 length, 5 internal discarded; any other code fails loud. Exposed/party
areas divide by Σ storey-heights so the per-storey perimeter, summed as areas divide by Σ storey-heights so the per-storey perimeter, summed as
Σ(perimeter × height) by the engine, reconstructs the measured area exactly. Σ(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 exposed_area = 0.0
party_area = 0.0 party_area = 0.0
@ -2930,17 +2946,28 @@ def _sap_17_1_building_part(
perimeter_per_storey = exposed_area / total_height perimeter_per_storey = exposed_area / total_height
party_length_per_storey = party_area / total_height party_length_per_storey = party_area / total_height
floor_dimensions = [ if not bp.sap_floor_dimensions and is_single_part and is_flat:
SapFloorDimension( floor_dimensions = [
room_height_m=fd.storey_height, SapFloorDimension(
total_floor_area_m2=fd.total_floor_area, room_height_m=1.0,
heat_loss_perimeter_m=perimeter_per_storey, total_floor_area_m2=total_floor_area,
party_wall_length_m=party_length_per_storey, heat_loss_perimeter_m=perimeter_per_storey,
floor=fd.storey, party_wall_length_m=party_length_per_storey,
floor_construction=fd.floor_type, floor=0,
) )
for fd in bp.sap_floor_dimensions ]
] 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: if index == 0:
identifier = BuildingPartIdentifier.MAIN identifier = BuildingPartIdentifier.MAIN

View file

@ -777,14 +777,14 @@ class TestFullSapSchema16xNoFloorDimensions:
assert epc.uprn == 10090783001 assert epc.uprn == 10090783001
assert epc.total_floor_area_m2 == 72.0 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 self, epc: EpcPropertyData
) -> None: ) -> None:
# The API lodges no sap_floor_dimensions for this cert family. # The API lodges no sap_floor_dimensions for this cert family, but the
# The mapper faithfully produces an empty list — the data gap that # mapper synthesises one from the cert-level total_floor_area so that
# causes roof_area() / gross_heat_loss_wall_area() to crash downstream. # roof_area() and gross_heat_loss_wall_area() do not crash downstream.
main_part = epc.sap_building_parts[0] 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( def test_total_floor_area_available_at_cert_level(
self, epc: EpcPropertyData self, epc: EpcPropertyData

View file

@ -171,6 +171,11 @@ class EnergyElement:
environmental_efficiency_rating: int environmental_efficiency_rating: int
@dataclass
class SapFlatDetails:
level: Optional[int] = None
@dataclass @dataclass
class SapSchema17_1: class SapSchema17_1:
uprn: int 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 # 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. # sap_heating; the mapper falls back to sap_heating.has_hot_water_cylinder.
has_hot_water_cylinder: Optional[str] = None has_hot_water_cylinder: Optional[str] = None
# Present for flat-type dwellings; absence means not a flat.
sap_flat_details: Optional[SapFlatDetails] = None