From 3e05c95e65bcf66668f8931e63e9101db350b70b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 8 Jun 2026 22:49:42 +0000 Subject: [PATCH] fix(wall-U): apply RdSAP Table 4 "Sheltered" R=0.5 to alternative walls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of the API-SAP error (53% within 0.5) localised the systematic under-rate to ELECTRIC FLATS (houses sit at 60-66% within 0.5; electric flats 13-19%). Decomposing the flat error showed it tracks space-heating demand per m² — the worst certs reach 130-289 kWh/m² (accurate certs sit at 14-110), i.e. a grossly over-stated fabric heat loss, amplified ~4x by the electricity unit price and the steep low-band SAP log curve. Root cause: the gov-EPC API lodges `sheltered_wall="Y"` on alternative wall sub-areas (a sub-area adjacent to an unheated buffer — stair core, adjoining structure), but the field was dropped by the schema + domain dataclasses and the calculator billed the alt sub-area at its full exposed U. RdSAP 10 Table 4 (PDF p.22) "Sheltered": such a wall carries an added external surface resistance R=0.5 m²K/W → U_sheltered = 1/(1/U + 0.5) — the SAME adjustment the main wall already applies for `gable_wall_type=2` (`gable_wall_sheltered`, `_SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W`). Cert 0340-2976 (band-A flat, 42 m² sheltered timber-frame alt) over-stated its wall channel by ~58 W/K → walls 128 -> 70 W/K. Threads the field end-to-end: schema dataclasses (21.0.0/21.0.1) + domain `SapAlternativeWall.is_sheltered` (default False — the Summary/ Elmhurst path leaves it False, sheltering rides through its lodged U-value there, so goldens are untouched) + `from_api_response` mapping `"Y"->True` + `_alt_wall_w_per_k` applying the 0.5 resistance on the cascade path (lodged-U and basement alt-walls return before it). 140 certs (15% of the corpus) carry a sheltered alt-wall; they under- rated at median -0.82 / mean signed -1.33 / 23% within 0.5. Eval: 102 improved, 38 regressed (offsetting-error cases — fix is spec-uniform per [[feedback_software_no_special_handling]]); within-0.5 53.14% -> 54.24%, within-1.0 67.99% -> 69.64%, within-2.0 81.85% -> 83.50%, mean|err| 1.312 -> 1.248, 909 computed / 0 raises. Goldens (6035, 000565) and full calc/epc/parser regression green; pyright net-zero. Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/epc_property_data.py | 8 +++++ datatypes/epc/domain/mapper.py | 4 +++ datatypes/epc/schema/rdsap_schema_21_0_0.py | 1 + datatypes/epc/schema/rdsap_schema_21_0_1.py | 1 + .../worksheet/heat_transmission.py | 12 +++++++ .../worksheet/test_heat_transmission.py | 35 +++++++++++++++++++ 6 files changed, 61 insertions(+) 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