mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
8746eabb70
commit
af26688846
3 changed files with 162 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue