From 3f6129afc6d47a074161fd4473eacd30c0ae31e1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 21 Jul 2026 22:02:31 +0000 Subject: [PATCH] =?UTF-8?q?Bill=20a=20sheltered=20wall=20to=20a=20stairwel?= =?UTF-8?q?l=20at=20Ru=202.1,=20not=20the=20corridor=200.5=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../worksheet/heat_transmission.py | 40 +++++++++++- .../worksheet/test_heat_transmission.py | 63 +++++++++++++++++++ .../epc_client/test_sap_accuracy_corpus.py | 11 +++- 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 789483e6c..7ea286e01 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -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 diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 2df247a2d..ba970d8ea 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -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 diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index a0a45a31e..56b826bbb 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -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