From af26688846cbe3032144ae5c28069159d760b211 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 15 Jun 2026 13:56:31 +0000 Subject: [PATCH] =?UTF-8?q?Derive=20heat-loss=20perimeter=20and=20party-wa?= =?UTF-8?q?ll=20length=20from=20full-SAP=20measured=20wall=20areas=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 81 ++++++++++++++++++- .../epc/domain/tests/test_from_sap_schema.py | 47 +++++++++++ datatypes/epc/schema/sap_schema_17_1.py | 36 ++++++++- 3 files changed, 162 insertions(+), 2 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 712cccb9..5b127006 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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 diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index 51332a39..a5413a2f 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -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) diff --git a/datatypes/epc/schema/sap_schema_17_1.py b/datatypes/epc/schema/sap_schema_17_1.py index a2090a1e..e26de635 100644 --- a/datatypes/epc/schema/sap_schema_17_1.py +++ b/datatypes/epc/schema/sap_schema_17_1.py @@ -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