diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 4b45e598..6649ade1 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -440,6 +440,14 @@ class SapAlternativeWall: # Mirrors `SapBuildingPart.wall_thickness_mm` per the # [[feedback-no-misleading-insulation-type]] convention. wall_thickness_mm: Optional[int] = None + # RdSAP 10 Table 4 (p.22) "Sheltered" wall: a sub-area adjacent to + # an unheated buffer space (stair core, adjoining structure) carries + # an added external surface resistance of R=0.5 m²K/W, so its U is + # reduced to U_sheltered = 1/(1/U + 0.5) — the same adjustment the + # main wall applies for `gable_wall_type=2`. The gov-EPC API lodges + # this per alt-wall as `sheltered_wall="Y"`; the Summary/Elmhurst path + # leaves it False (sheltering rides through the lodged U-value there). + is_sheltered: bool = False # Explicit basement determination. RdSAP10 `wall_construction == 6` is # canonically SYSTEM-BUILT (`WALL_SYSTEM_BUILT`) — the basement # heuristic hijacked it because Elmhurst lodges both "SY System build" diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 04f39ba6..978711c5 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1469,6 +1469,7 @@ class EpcPropertyDataMapper: wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness, + is_sheltered=bp.sap_alternative_wall_1.sheltered_wall == "Y", ) if bp.sap_alternative_wall_1 else None @@ -1481,6 +1482,7 @@ class EpcPropertyDataMapper: wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness, + is_sheltered=bp.sap_alternative_wall_2.sheltered_wall == "Y", ) if bp.sap_alternative_wall_2 else None @@ -1745,6 +1747,7 @@ class EpcPropertyDataMapper: wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness, + is_sheltered=bp.sap_alternative_wall_1.sheltered_wall == "Y", ) if bp.sap_alternative_wall_1 else None @@ -1757,6 +1760,7 @@ class EpcPropertyDataMapper: wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness, + is_sheltered=bp.sap_alternative_wall_2.sheltered_wall == "Y", ) if bp.sap_alternative_wall_2 else None diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index da2125be..8fceb878 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -220,6 +220,7 @@ class SapAlternativeWall: wall_insulation_type: int wall_thickness_measured: str wall_insulation_thickness: Optional[str] = None + sheltered_wall: Optional[str] = None @dataclass diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index c5f456de..c12bf31c 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -258,6 +258,7 @@ class SapAlternativeWall: wall_insulation_type: int wall_thickness_measured: str wall_insulation_thickness: Optional[str] = None + sheltered_wall: Optional[str] = None @dataclass diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 3e4c9a4e..620deded 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -1339,4 +1339,16 @@ def _alt_wall_w_per_k( # dry-lined) → U=2.34 via §5.6 + §5.8 chain. wall_thickness_mm=alt_wall.wall_thickness_mm, ) + if alt_wall.is_sheltered: + # RdSAP 10 Table 4 (p.22) "Sheltered" wall: an alt sub-area + # adjacent to an unheated buffer (stair core, adjoining + # structure) carries an added external surface resistance of + # R=0.5 m²K/W → U_sheltered = 1/(1/U + 0.5). Mirrors the main + # wall's `gable_wall_sheltered` branch + # (`_SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W`). The gov-EPC API + # lodges this per alt-wall as `sheltered_wall="Y"`; without it a + # sheltered timber/cavity alt sub-area billed its full exposed U + # (cert 0340-2976: a 42 m² sheltered timber-frame alt at U=2.5 + # over-stated the wall channel by ~58 W/K → -5 SAP under-rate). + alt_u = 1.0 / (1.0 / alt_u + _SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W) return alt_u * net_alt_area diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 92967423..6dfbff44 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -36,6 +36,7 @@ from domain.sap10_calculator.worksheet.heat_transmission import ( heat_transmission_from_cert, ) from domain.sap10_calculator.worksheet.heat_transmission import ( + _alt_wall_w_per_k, # pyright: ignore[reportPrivateUsage] _joined_main_roof_descriptions, # pyright: ignore[reportPrivateUsage] _part_geometry, # pyright: ignore[reportPrivateUsage] _round_half_up, # pyright: ignore[reportPrivateUsage] @@ -1193,6 +1194,40 @@ def test_alternative_wall_uses_own_construction_and_deducts_from_main_wall_area( ) +def test_sheltered_alternative_wall_applies_table4_0p5_resistance() -> None: + # Arrange — RdSAP 10 Table 4 (PDF p.22) "Sheltered" wall: a sub-area + # adjacent to an unheated buffer carries an added external surface + # resistance R=0.5 m²K/W, so its U reduces to 1/(1/U + 0.5). The gov + # EPC API lodges this per alt-wall as `sheltered_wall="Y"` (mapped to + # `is_sheltered`). Two identical timber-frame as-built alt sub-areas + # (cavity-band-A → uninsulated cascade U), one exposed, one sheltered. + from dataclasses import replace + + from domain.sap10_ml.rdsap_uvalues import Country + + area = 42.0 + exposed = SapAlternativeWall( + wall_area=area, wall_dry_lined="N", wall_construction=5, + wall_insulation_type=4, wall_thickness_measured="Y", + wall_insulation_thickness="NI", is_sheltered=False, + ) + sheltered = replace(exposed, is_sheltered=True) + + # Act + exposed_wpk = _alt_wall_w_per_k( + alt_wall=exposed, country=Country.ENG, age_band="A", wall_description=None, + ) + sheltered_wpk = _alt_wall_w_per_k( + alt_wall=sheltered, country=Country.ENG, age_band="A", wall_description=None, + ) + + # Assert — the sheltered U is the exposed U with R=0.5 added. + exposed_u = exposed_wpk / area + expected_sheltered_u = 1.0 / (1.0 / exposed_u + 0.5) + assert abs(sheltered_wpk - expected_sheltered_u * area) <= 1e-9 + assert sheltered_wpk < exposed_wpk + + def test_window_uses_effective_u_value_with_curtain_resistance_per_sap10_2_section_3_2() -> None: """SAP10.2 §3.2: the window U-value used for heat-transmission is the effective form `U_eff = 1/(1/U_raw + 0.04)` — the 0.04 m²K/W is the