Derive heat-loss perimeter and party-wall length from full-SAP measured wall areas 🟩

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-15 13:56:31 +00:00
parent 8746eabb70
commit af26688846
3 changed files with 162 additions and 2 deletions

View file

@ -47,6 +47,7 @@ from datatypes.epc.schema.sap_schema_17_1 import (
SapSchema17_1,
EnergyElement as EnergyElement_SAP_17_1,
SapOpeningType as SapOpeningType_SAP_17_1,
SapBuildingPart as SapBuildingPart_SAP_17_1,
)
# full-SAP opening-type codes: 1/2/3 = door, 4 = window, 5 = roof window.
@ -54,6 +55,17 @@ _SAP_OPENING_TYPE_WINDOW: Final[int] = 4
_SAP_OPENING_TYPE_ROOF_WINDOW: Final[int] = 5
_SAP_OPENING_TYPE_DOORS: Final[frozenset[int]] = frozenset({1, 2, 3})
_SAP_KNOWN_OPENING_TYPES: Final[frozenset[int]] = frozenset({1, 2, 3, 4, 5})
# full-SAP wall_type codes: 1/2/3 = external (exposed), 4 = party, 5 = internal.
_SAP_WALL_TYPES_EXPOSED: Final[frozenset[int]] = frozenset({1, 2, 3})
_SAP_WALL_TYPE_PARTY: Final[int] = 4
_SAP_WALL_TYPE_INTERNAL: Final[int] = 5
_SAP_KNOWN_WALL_TYPES: Final[frozenset[int]] = frozenset({1, 2, 3, 4, 5})
# D4 fallback: full-SAP 17.1 certs are new-build (LIG-17.0); when a measured U
# isn't parseable from a wall description, the engine derives from the newest
# RdSAP age band M. Used only for that fallback + secondary age-band logic.
_SAP_DEFAULT_AGE_BAND: Final[str] = "M"
# rdsap_uvalues WALL_CAVITY = 4 (D7 fallback; U comes from the description).
_SAP_DEFAULT_WALL_CONSTRUCTION: Final[int] = 4
# SAP-typical glazing solar transmittance when an opening-type omits it.
_SAP_DEFAULT_SOLAR_TRANSMITTANCE: Final[float] = 0.63
# SAP-typical window frame factor when an opening-type omits it.
@ -688,7 +700,12 @@ class EpcPropertyDataMapper:
# roof-window openings (opening-type 5) → sap_roof_windows.
sap_windows=EpcPropertyDataMapper._sap_17_1_windows(schema),
sap_roof_windows=EpcPropertyDataMapper._sap_17_1_roof_windows(schema),
sap_building_parts=[],
# 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)
for i, bp in enumerate(schema.sap_building_parts)
],
sap_heating=SapHeating(
instantaneous_wwhrs=InstantaneousWwhrs(),
main_heating_details=[],
@ -2438,6 +2455,68 @@ class EpcPropertyDataMapper:
# ---------------------------------------------------------------------------
def _sap_17_1_building_part(
bp: SapBuildingPart_SAP_17_1, index: int
) -> SapBuildingPart:
"""D1: build one `SapBuildingPart`, deriving the heat-loss perimeter and
party-wall length the engine needs from the measured wall areas full SAP
lodges (it carries neither directly).
Walls classify by `wall_type`: 1/2/3 exposed perimeter, 4 party party
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.
"""
exposed_area = 0.0
party_area = 0.0
for w in bp.sap_walls:
if w.wall_type in _SAP_WALL_TYPES_EXPOSED:
exposed_area += w.total_wall_area
elif w.wall_type == _SAP_WALL_TYPE_PARTY:
party_area += w.total_wall_area
elif w.wall_type == _SAP_WALL_TYPE_INTERNAL:
continue # internal partition — not part of the heat-loss envelope
else:
raise UnmappedApiCode("wall_type", w.wall_type)
total_height = sum(fd.storey_height for fd in bp.sap_floor_dimensions) or 1.0
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 index == 0:
identifier = BuildingPartIdentifier.MAIN
elif index - 1 < len(_EXTENSION_IDENTIFIERS):
identifier = _EXTENSION_IDENTIFIERS[index - 1]
else:
identifier = BuildingPartIdentifier.OTHER
return SapBuildingPart(
identifier=identifier,
# D4 fallback band (measured U normally comes from the wall description).
construction_age_band=_SAP_DEFAULT_AGE_BAND,
# D7 (low stakes — U comes from the description): default to cavity
# (rdsap_uvalues WALL_CAVITY = 4); wall_construction here mainly feeds
# the thermal-mass parameter.
wall_construction=_SAP_DEFAULT_WALL_CONSTRUCTION,
wall_insulation_type=0,
wall_thickness_measured=False,
sap_floor_dimensions=floor_dimensions,
building_part_number=bp.building_part_number,
)
def _sap_assert_known_opening_types(schema: SapSchema17_1) -> None:
"""D2: raise `UnmappedApiCode` if any placed opening joins to an
opening-type whose `type` code is outside the known taxonomy. Keeps a

View file

@ -207,3 +207,50 @@ class TestFromSapSchema17_1UnknownOpeningType:
# Act / Assert
with pytest.raises(UnmappedApiCode):
EpcPropertyDataMapper.from_sap_schema_17_1(schema)
class TestFromSapSchema17_1Perimeter:
"""Slice 5 (D1): full SAP lodges no heat-loss perimeter; derive it from the
measured exposed-wall areas (wall_type 1/2/3) ÷ Σ storey-heights, with party
walls (wall_type 4) routed to party_wall_length_m and internal partitions
(wall_type 5) discarded. Distributed uniformly per storey so the engine's
Σ(perimeter × height) reconstructs the measured exposed-wall area exactly."""
def _map(self, fixture: str) -> EpcPropertyData:
schema = from_dict(SapSchema17_1, load(fixture))
return EpcPropertyDataMapper.from_sap_schema_17_1(schema)
def test_sample_perimeter_from_exposed_walls(self) -> None:
result = self._map("sap_17_1.json")
fd = result.sap_building_parts[0].sap_floor_dimensions[0]
# exposed 91.54 m² ÷ storey-height 2.4 m = 38.1417 m
assert fd.heat_loss_perimeter_m == pytest.approx(38.1417, abs=1e-3)
def test_sample_has_no_party_wall(self) -> None:
result = self._map("sap_17_1.json")
fd = result.sap_building_parts[0].sap_floor_dimensions[0]
assert fd.party_wall_length_m == 0.0
def test_multistorey_house_reconstructs_exposed_area(self) -> None:
# 3-storey mid-terrace: Σ(perimeter_i × height_i) must equal the
# measured exposed-wall area 87.85 m² (uniform per-storey split).
result = self._map("sap_17_1_house.json")
fds = result.sap_building_parts[0].sap_floor_dimensions
gross = sum((fd.heat_loss_perimeter_m or 0.0) * (fd.room_height_m or 0.0) for fd in fds)
assert gross == pytest.approx(87.85, abs=1e-2)
def test_house_routes_party_walls_to_party_length(self) -> None:
# Σ(party_length_i × height_i) must equal the measured party-wall area.
result = self._map("sap_17_1_house.json")
fds = result.sap_building_parts[0].sap_floor_dimensions
party = sum((fd.party_wall_length_m or 0.0) * (fd.room_height_m or 0.0) for fd in fds)
assert party == pytest.approx(105.44, abs=1e-2)
def test_unknown_wall_type_fails_loud(self) -> None:
# A wall_type outside {1,2,3 exposed, 4 party, 5 internal} must raise
# rather than silently vanish from the envelope.
data = load("sap_17_1.json")
data["sap_building_parts"][0]["sap_walls"][0]["wall_type"] = 7
schema = from_dict(SapSchema17_1, data)
with pytest.raises(UnmappedApiCode):
EpcPropertyDataMapper.from_sap_schema_17_1(schema)

View file

@ -46,12 +46,46 @@ class SapOpening:
pitch: Optional[Union[int, float]] = None
@dataclass
class SapWall:
"""A measured wall surface. `wall_type`: 1/2/3 = external (exposed), 4 =
party, 5 = internal partition. `total_wall_area` is the measured area used
(with storey height) to back out the heat-loss perimeter / party length."""
wall_type: int
total_wall_area: float
name: Optional[Union[str, int]] = None
u_value: Optional[float] = None
kappa_value: Optional[float] = None
is_curtain_walling: Optional[str] = None
@dataclass
class SapFloorDim:
"""A per-storey floor dimension. `storey` is the 0-indexed level (0 =
ground). Full SAP lodges no heat-loss perimeter it's derived from the
part's wall areas and `storey_height`."""
storey: int
storey_height: float
total_floor_area: float
u_value: Optional[float] = None
floor_type: Optional[int] = None
kappa_value: Optional[float] = None
heat_loss_area: Optional[float] = None
@dataclass
class SapBuildingPart:
"""A building part. Modelled incrementally — slice 4 needs `sap_openings`;
slice 5 (perimeter) will add `sap_walls` / `sap_floor_dimensions`."""
slice 5 (perimeter) adds `sap_walls` / `sap_floor_dimensions`."""
sap_openings: List[SapOpening] = field(default_factory=list)
sap_walls: List[SapWall] = field(default_factory=list)
sap_floor_dimensions: List[SapFloorDim] = field(default_factory=list)
identifier: Optional[str] = None
construction_year: Optional[int] = None
building_part_number: Optional[int] = None
@dataclass