fix(wall-U): apply RdSAP Table 4 "Sheltered" R=0.5 to alternative walls

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-08 22:49:42 +00:00
parent e1adc8d3d5
commit 3e05c95e65
6 changed files with 61 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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