From cdf211393ce7fab4ccec1d78c13b32f0a87fd7fc Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 6 Jun 2026 11:59:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(mapper):=20map=20API=20gable=5Fwall=5Ftype?= =?UTF-8?q?=202/3=20(Sheltered/Connected)=20=E2=80=94=20clears=2014=20rais?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026 API sample raised UnmappedApiCode on `gable_wall_type` 2 (10 certs) and 3 (4 certs) — the two RR gable variants beyond Party(0)/Exposed(1). Sim case 21 (an Elmhurst replica of API cert 2818-3053-3203-2655-9204: gable_wall_type_1=2, gable_wall_type_2=3) lodges them as "Sheltered" and "Connected", confirming **2=Sheltered, 3=Connected**. - Mapper: `_API_TYPE_1_GABLE_TYPE_TO_KIND` gains 2 → `gable_wall_sheltered`, 3 → `connected_wall` (U=0, area deducts — already handled). - Calculator: new `gable_wall_sheltered` branch. The API path lodges no per-gable U, so the cascade DERIVES it as RdSAP 10 Table 4 (p.22) Sheltered = 1/(1/U_wall + 0.5) — back-solved + validated against case 21 (U_wall 1.10 → 0.71) and case 20 (1.70 → 0.92). A lodged U (Summary path) still rides through as an override. API sample: 14 raises clear → `computed` 882 → 896, `raise:ValueError` 16 → 2. Summary path unchanged (Sheltered stays `gable_wall_external` + lodged U, so cert 000487's hand-built fixture is untouched). 2861 pass (lone test_total_floor_area pre-existing); pyright strict net-zero (32=32 / 12=12). NOTE: the derived Sheltered U on cert 2818 lands at 0.92 not 0.71 because the cascade computes its 440 mm solid-brick wall U as 1.70 (the 220 mm default) — a SEPARATE wall-U-vs-thickness bug (next slice, validated by case 21's 1.10). Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 18 +++++++++++++-- .../worksheet/heat_transmission.py | 22 +++++++++++++++++++ .../rdsap/test_cert_to_inputs.py | 18 +++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 80b64774..bf29c169 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3017,11 +3017,20 @@ _RIR_TYPE_1_GABLE_HEIGHT_M: Final[float] = 2.45 # `SapRoomInRoofSurface.kind` the cascade's Detailed-RR branch routes by # U-value. Established from cert 6035's Summary (gable_wall_type_1=1 ↔ # "Exposed" U=0.29; gable_wall_type_2=0 ↔ "Party" U=0.25): -# 0 = Party → `gable_wall` (Table 4 p.22 row 2, U=0.25) -# 1 = Exposed → `gable_wall_external` (Table 4 p.22 row 1, "as common wall") +# 0 = Party → `gable_wall` (Table 4 p.22 row 2, U=0.25) +# 1 = Exposed → `gable_wall_external` (Table 4 p.22 row 1, "as common wall") +# 2 = Sheltered → `gable_wall_sheltered` (Table 4 p.22, U = 1/(1/U_wall + 0.5)) +# 3 = Connected → `connected_wall` (Table 4 p.22 row 4, U=0, area deducts) +# Codes 2/3 established from sim case 21 (a replica of API cert +# 2818-3053-3203-2655-9204: gable_wall_type_1=2 lodges "Sheltered", +# gable_wall_type_2=3 lodges "Connected"). The Summary path already routes +# the same string labels to these kinds; the cascade derives the Sheltered +# U from the wall (the API lodges no per-gable U-value). _API_TYPE_1_GABLE_TYPE_TO_KIND: Dict[int, str] = { 0: "gable_wall", 1: "gable_wall_external", + 2: "gable_wall_sheltered", + 3: "connected_wall", } @@ -3846,6 +3855,11 @@ def _map_elmhurst_rir_surface( return None u_value_override: Optional[float] = None if kind == "gable_wall" and surface.gable_type == "Sheltered": + # Summary lodges the Sheltered Default U-value directly (case 20 + # 0.92 / case 21 0.71), so route to gable_wall_external and carry the + # lodged U as the override — the cascade uses it as-is. (The API path + # lodges no per-gable U, so it routes code 2 to the discrete + # `gable_wall_sheltered` kind that DERIVES 1/(1/U_wall+0.5) instead.) kind = "gable_wall_external" u_value_override = surface.default_u_value elif kind == "gable_wall" and surface.gable_type == "Exposed": diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index a961d60f..c8c12c74 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -127,6 +127,11 @@ _WINDOW_CURTAIN_RESISTANCE_M2K_PER_W: Final[float] = 0.04 # rounding policy — applied to gross wall / roof / floor / party / window # / door / alt-wall / RR sub-area inputs to the §3 cascade. _AREA_ROUND_DP: Final[int] = 2 +# RdSAP 10 Table 4 (p.22) — a "Sheltered" room-in-roof gable adds this +# external surface resistance to the storey-below main wall: U_sheltered = +# 1/(1/U_wall + 0.5). Back-solved from Elmhurst Default U-values: sim case +# 21 (U_wall 1.10 → 0.71) and sim case 20 (U_wall 1.70 → 0.92). +_SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W: Final[float] = 0.5 # RdSAP 10 §3.8 "Roof area" — pitched-sloping-ceiling roofs use the # inclined surface area (floor area divided by cos(30°)) rather than # the horizontal projection. @@ -1081,6 +1086,23 @@ def heat_transmission_from_cert( rr_detailed_area += area walls += u_gable * area rr_walls_in_a_rr_area += area + elif kind == "gable_wall_sheltered": + # RdSAP 10 Table 4 (p.22) "Sheltered" gable: the storey- + # below main-wall U (`uw`) with an added R=0.5 m²K/W + # sheltered external resistance → U = 1/(1/uw + 0.5). + # The API path carries only the gable_wall_type=2 code + # (no lodged U) so the cascade derives it; the Summary + # path's lodged Default U-value rides through as a + # `surf.u_value` override. Validated against sim case 21 + # (uw=1.10 → 0.71) and sim case 20 (uw=1.70 → 0.92). + u_sheltered = ( + surf.u_value if surf.u_value is not None + else 1.0 / (1.0 / uw + _SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W) + ) + if area >= 0: + rr_detailed_area += area + walls += u_sheltered * area + rr_walls_in_a_rr_area += area elif kind == "common_wall": # RdSAP 10 §3.9.2 Simplified Type 2 + Table 4 p.22 # "Common wall": billed as external wall at the diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index f99afce9..07d322cf 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -2515,6 +2515,24 @@ def test_elmhurst_simplified_rir_drops_placeholder_roof_surfaces() -> None: assert kinds == ["gable_wall", "gable_wall_external"] +def test_api_type_1_gable_kind_maps_sheltered_and_connected_codes() -> None: + # Arrange — RdSAP 10 Table 4 (p.22) room-in-roof gable variants. Codes + # 2/3 established from sim case 21 (a replica of API cert 2818-3053-...: + # gable_wall_type_1=2 lodges "Sheltered", gable_wall_type_2=3 lodges + # "Connected"). Before this, codes 2/3 raised UnmappedApiCode (14 certs + # in the 2026 API sample). Sheltered routes to the discrete kind whose + # U the cascade derives (1/(1/U_wall+0.5)); Connected is U=0. + from datatypes.epc.domain.mapper import ( + _api_type_1_gable_kind, # pyright: ignore[reportPrivateUsage] + ) + + # Act / Assert + assert _api_type_1_gable_kind(0) == "gable_wall" + assert _api_type_1_gable_kind(1) == "gable_wall_external" + assert _api_type_1_gable_kind(2) == "gable_wall_sheltered" + assert _api_type_1_gable_kind(3) == "connected_wall" + + def test_elmhurst_detailed_rir_keeps_roof_surfaces() -> None: # Arrange — a Detailed (§3.10) assessment DOES measure slope / flat # ceiling, so they must be retained (regression guard so the