feat(mapper): map API gable_wall_type 2/3 (Sheltered/Connected) — clears 14 raises

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-06 11:59:50 +00:00
parent 7dfe3f2c99
commit cdf211393c
3 changed files with 56 additions and 2 deletions

View file

@ -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":

View file

@ -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

View file

@ -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