Bill a sheltered wall to a stairwell at Ru 2.1, not the corridor 0.5 🟩

RdSAP 10 §5.9 "U-values of sheltered walls" (PDF p.43): a wall between a
flat/maisonette and an unheated buffer takes SAP 10.2 §3.3's U = 1/(1/U0 + Ru),
and Ru depends on the BUFFER TYPE — "Use Ru = 0.5 m²K/W for corridors and
Ru = 2.1 m²K/W for stairwell."

The buffer type is lodged on `SapFlatDetails.heat_loss_corridor` (0-based per
item 1-11, PDF p.70: 0 none, 1 heated, 2 unheated corridor, 3 unheated
stairwell). That field was read NOWHERE under domain/ — corridor presence was
inferred only from a lodged sheltered alternative wall — so every sheltered wall
took the corridor 0.5 and every stairwell flat over-billed its sheltered wall,
under-rating the dwelling.

Distinct from `_SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W`, which is RdSAP 10
Table 4 (p.22) for a room-in-roof sheltered gable — same 0.5, different rule,
deliberately untouched.

Corpus gauge ratcheted UP: within-0.5 78.8% -> 79.8% (+10 certs), SAP MAE
0.626 -> 0.613, PE MAE 2.9 -> 2.8. Failure set and pyright count identical to
main. Portfolio 824 is stairwell-heavy (60 properties at code 3); property
749898 over-bills 4.44 W/K on a 48 m2 flat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-21 22:02:31 +00:00
parent ccba02b51a
commit 3f6129afc6
3 changed files with 111 additions and 3 deletions

View file

@ -207,6 +207,33 @@ _AREA_ROUND_DP: Final[int] = 2
# 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 §5.9 "U-values of sheltered walls" (PDF p.43) — a wall between a
# flat/maisonette and an unheated buffer takes SAP 10.2 §3.3's
# U = 1/(1/U0 + Ru), and Ru depends on the BUFFER TYPE: "Use Ru = 0.5 m²K/W
# for corridors and Ru = 2.1 m²K/W for stairwell." A stairwell is the better
# buffer, so billing one at the corridor value over-states its heat loss.
#
# Distinct from `_SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W` above, which is
# RdSAP 10 Table 4 (p.22) for a room-in-roof sheltered gable — same number,
# different rule, unaffected by the buffer type.
_SHELTERED_CORRIDOR_RU_M2K_W: Final[float] = 0.5
_SHELTERED_STAIRWELL_RU_M2K_W: Final[float] = 2.1
# `SapFlatDetails.heat_loss_corridor`, 0-based per RdSAP 10 item 1-11
# (PDF p.70): 0 no corridor/stairwell, 1 heated corridor/stairwell,
# 2 unheated CORRIDOR, 3 unheated STAIRWELL. Confirmed against the corpus and
# the DB: `unheated_corridor_length_m` is populated for exactly codes 2 and 3.
_HEAT_LOSS_CORRIDOR_UNHEATED_STAIRWELL: Final[int] = 3
def _sheltered_added_resistance(heat_loss_corridor: Optional[int]) -> float:
"""RdSAP 10 §5.9 (PDF p.43) Ru for a sheltered wall, by buffer type.
Only an unheated STAIRWELL takes 2.1; an unheated corridor and any
unlodged/absent value keep the 0.5 corridor default, so a cert with no
flat-details block bills exactly as before."""
if heat_loss_corridor == _HEAT_LOSS_CORRIDOR_UNHEATED_STAIRWELL:
return _SHELTERED_STAIRWELL_RU_M2K_W
return _SHELTERED_CORRIDOR_RU_M2K_W
# 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.
@ -883,6 +910,11 @@ def heat_transmission_from_cert(
window_total_area_m2, _AREA_ROUND_DP,
)
sheltered_ru = _sheltered_added_resistance(
epc.sap_flat_details.heat_loss_corridor
if epc.sap_flat_details is not None
else None
)
for i, part in enumerate(parts):
geom = _part_geometry(part)
age_band = part.construction_age_band
@ -1268,6 +1300,11 @@ def heat_transmission_from_cert(
age_band=age_band,
wall_description=wall_description,
opening_area_m2=alt_opening,
# RdSAP 10 §5.9 (PDF p.43) — Ru is 0.5 for a corridor but 2.1
# for a stairwell. The buffer type is lodged on
# `SapFlatDetails.heat_loss_corridor`; before this it was read
# nowhere under domain/ and every sheltered wall took 0.5.
sheltered_added_resistance_m2k_w=sheltered_ru,
)
# Main wall net adds back the alt-wall windows that were initially
# deducted from the BP's total gross — those openings should have
@ -1595,6 +1632,7 @@ def _alt_wall_w_per_k(
age_band: str,
wall_description: Optional[str],
opening_area_m2: float = 0.0,
sheltered_added_resistance_m2k_w: float = _SHELTERED_CORRIDOR_RU_M2K_W,
) -> float:
"""U × (gross openings) for one alternative-wall sub-area. RdSAP
§1.4.2: inherits the part's age band but carries its own construction
@ -1655,5 +1693,5 @@ def _alt_wall_w_per_k(
# 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)
alt_u = 1.0 / (1.0 / alt_u + sheltered_added_resistance_m2k_w)
return alt_u * net_alt_area

View file

@ -37,6 +37,7 @@ from domain.sap10_calculator.worksheet.heat_transmission import (
)
from domain.sap10_calculator.worksheet.heat_transmission import (
_alt_wall_w_per_k, # pyright: ignore[reportPrivateUsage]
_sheltered_added_resistance, # pyright: ignore[reportPrivateUsage]
_joined_main_roof_descriptions, # pyright: ignore[reportPrivateUsage]
_main_roof_descriptions_by_kind, # pyright: ignore[reportPrivateUsage]
_part_geometry, # pyright: ignore[reportPrivateUsage]
@ -2713,3 +2714,65 @@ def test_room_in_roof_detailed_gable_wall_excluded_from_line_31_external_area()
assert result.total_external_element_area_m2 == pytest.approx(
expected_line_31, abs=0.001
)
def test_sheltered_wall_to_unheated_stairwell_uses_ru_2p1_not_0p5() -> None:
# Arrange — RdSAP 10 §5.9 "U-values of sheltered walls" (PDF p.43) is
# explicit that the buffer resistance differs by buffer TYPE:
#
# "For sheltered walls of flats and maisonettes (between the dwelling and
# an unheated corridor or stairwell), the U-value for the applicable wall
# area is adjusted as described in SAP10.2 specification Section 3.3:
# U = 1 / (1/U0 + Ru) ... Use Ru = 0.5 m²K/W for corridors and
# Ru = 2.1 m²K/W for stairwell."
#
# A stairwell is a far better buffer than a corridor, so billing a stairwell
# at Ru=0.5 OVER-states its heat loss and under-rates the dwelling. The
# buffer type is lodged on SapFlatDetails.heat_loss_corridor (0-based:
# 0 none, 1 heated, 2 unheated CORRIDOR, 3 unheated STAIRWELL — confirmed by
# `unheated_corridor_length_m` being populated for exactly codes 2 and 3).
# That field was read nowhere under domain/, so every sheltered wall got the
# corridor constant.
#
# NOTE the sibling 0.5 at `_SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W` is a
# DIFFERENT rule — RdSAP 10 Table 4 (p.22), room-in-roof sheltered gable —
# and is unaffected.
from dataclasses import replace
from domain.sap10_ml.rdsap_uvalues import Country
area = 42.0
alt = 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=True,
)
# Act — same wall, billed against a corridor and against a stairwell.
corridor_wpk = _alt_wall_w_per_k(
alt_wall=alt, country=Country.ENG, age_band="A", wall_description=None,
sheltered_added_resistance_m2k_w=_sheltered_added_resistance(2),
)
stairwell_wpk = _alt_wall_w_per_k(
alt_wall=alt, country=Country.ENG, age_band="A", wall_description=None,
sheltered_added_resistance_m2k_w=_sheltered_added_resistance(3),
)
# Assert — the stairwell's Ru=2.1 yields a materially lower U than the
# corridor's Ru=0.5, per §5.9.
unsheltered_u = _alt_wall_w_per_k(
alt_wall=replace(alt, is_sheltered=False),
country=Country.ENG, age_band="A", wall_description=None,
) / area
assert abs(corridor_wpk - (1.0 / (1.0 / unsheltered_u + 0.5)) * area) <= 1e-9
assert abs(stairwell_wpk - (1.0 / (1.0 / unsheltered_u + 2.1)) * area) <= 1e-9
assert stairwell_wpk < corridor_wpk
def test_sheltered_added_resistance_defaults_to_the_corridor_value() -> None:
# Arrange / Act / Assert — only code 3 (unheated stairwell) takes Ru=2.1;
# the unheated corridor (2) and any unlodged/absent value keep Ru=0.5, so a
# cert with no flat-details block is billed exactly as before.
assert _sheltered_added_resistance(3) == 2.1
assert _sheltered_added_resistance(2) == 0.5
assert _sheltered_added_resistance(None) == 0.5

View file

@ -260,7 +260,14 @@ _CORPUS = Path(
# the plant efficiency C4 reserves for CO2/PE is a known follow-up. Spec-
# verified against the SAP 10.2 PDF, not the ticket prose; unit-pinned in
# test_cert_to_inputs (950/951/952 must bill identical DHW fuel).
_MIN_WITHIN_HALF_SAP = 0.788
# SHELTERED STAIRWELL SLICE (ratcheted 0.788 -> 0.798, MAE 0.626 -> 0.614):
# RdSAP 10 §5.9 (PDF p.43) sets the sheltered-wall buffer resistance by buffer
# TYPE — "Ru = 0.5 m²K/W for corridors and Ru = 2.1 m²K/W for stairwell". The
# buffer type is lodged on `SapFlatDetails.heat_loss_corridor` (0-based: 2 =
# unheated corridor, 3 = unheated stairwell), which was read NOWHERE under
# domain/, so every sheltered wall took the corridor 0.5 and every stairwell
# flat over-billed its sheltered wall. 23 corpus certs; within-0.5 +1.0pp.
_MIN_WITHIN_HALF_SAP = 0.798
# 0.793 -> 0.789 via the §12 Unknown-meter + dual-electric-immersion off-peak
# trigger (RdSAP 10 PDF p.62): Apartment 241 (main 691 + 903 dual immersion)
# -5.38 -> -1.05. Worksheet-validated on "simulated case 48" (Elmhurst SAP 57,
@ -386,7 +393,7 @@ _MIN_WITHIN_HALF_SAP = 0.788
# The -0.672 overshoot is expected to move again when the C4 plant-
# efficiency and C3.2 pumping siblings land. Unit-pinned in
# test_cert_to_inputs.
_MAX_SAP_MAE = 0.626
_MAX_SAP_MAE = 0.614
_MAX_CO2_MAE_TONNES = 0.072 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 3.0 # kWh / m2 / yr vs energy_consumption_current