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

View file

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

View file

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