Merge pull request #1663 from Hestia-Homes/fix/sheltered-stairwell-resistance

Sheltered stairwell Ru 2.1 + party-ceiling roof code 3
This commit is contained in:
KhalimCK 2026-07-22 13:11:02 +01:00 committed by GitHub
commit 1c42845450
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 210 additions and 4 deletions

View file

@ -4648,7 +4648,23 @@ _API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = {
# here too rather than left half-fixed.
_API_ROOF_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = {
1: "Flat",
3: "Pitched (slates/tiles), no access to loft",
# RdSAP 10 item 2-3(h) "another dwelling above" — U=0 (spec p.45: "There is
# no heat loss through the roof of a building part that has the same
# dwelling above or another dwelling above"). Previously mislabelled
# "Pitched (slates/tiles), no access to loft" (that is code 5), so the
# per-part party override at `heat_transmission.py:1172` never matched.
# 190 of the 200 corpus code-3 parts sit at index 0 and are already
# suppressed by `_cert_lodges_exposed_roof` off the `roof_insulation_location
# == "ND"` signal; the unmitigated set is the 10 parts at index > 0, where
# extensions default to exposed and were billed a phantom pitched roof.
# Also removes a latent PV defect: the bogus "Pitched" prefix satisfied
# `is_pitched` (cert_to_inputs.py:3559) and divided PV array area by
# cos(35°), inflating kWp. Corpus evidence (1000 certs, 200 code-3 parts):
# 200/200 lodge roof_insulation_location "ND", 186/200 lodge the roof
# description literally "(another dwelling above)", zero lodge a "no access
# to loft" description, and no top-floor dwelling ever lodges it. Codes 7
# and 9 were corrected by an earlier slice; 3 was left behind.
3: "(another dwelling above)",
4: "Pitched (slates/tiles), access to loft",
5: "Pitched (vaulted ceiling)",
6: None,

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
@ -1298,6 +1330,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
@ -1625,6 +1662,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
@ -1685,5 +1723,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

@ -0,0 +1,78 @@
"""Regression: gov-API `roof_construction = 3` is "another dwelling above"
(RdSAP 10 item 2-3(h), U=0), NOT a pitched roof.
RdSAP 10 item 2-3 "Above the building part" (spec PDF p.72, printed p.71) lists
the codes and their treatment; (g) "same dwelling above" and (h) "another
dwelling above" are both U=0, and p.45 states it plainly: "There is no heat loss
through the roof of a building part that has the same dwelling above or another
dwelling above."
The mapper labelled code 3 "Pitched (slates/tiles), no access to loft", so the
per-part party override in `heat_transmission.py` which matches on the literal
string "another dwelling above" in `roof_construction_type` never fired. The
building part then fell through to the dwelling-level `has_exposed_roof`
heuristic, which is keyed only on the `dwelling_type` LABEL. Where that label
says top-floor (or the part is an extension, billed exposed unconditionally), we
invented an entire pitched roof over a lodged party ceiling.
Evidence that code 3 is a party ceiling, from the committed RdSAP-21.0.1 corpus
(1000 certs, 200 code-3 building parts):
- 200/200 lodge `roof_insulation_location == "ND"` (not determined there is
no insulation to determine on a party ceiling);
- 186/200 lodge the cert's roof description as literally
"(another dwelling above)";
- dwelling types are exclusively non-top-floor (Mid-floor flat 104,
Ground-floor flat 86, Ground-floor maisonette 7, Mid-floor maisonette 1,
Mid-terrace house 2) no top-floor dwelling ever lodges it.
Codes 7 and 9 were corrected to the party string by an earlier slice; code 3 was
left behind.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
_FIXTURE = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/epc.json")
_PARTY_CEILING = "another dwelling above"
def _load_with_roof_construction(code: int) -> dict[str, Any]:
data: dict[str, Any] = json.loads(_FIXTURE.read_text())
for part in data["sap_building_parts"]:
part["roof_construction"] = code
return data
def test_roof_construction_3_maps_to_a_party_ceiling() -> None:
# Arrange — the lodged part has another dwelling above it (API code 3).
data = _load_with_roof_construction(3)
# Act
epc = EpcPropertyDataMapper.from_api_response(data)
# Assert — carries the party-ceiling marker the per-part override matches on,
# so the roof is billed at U=0 rather than as an invented pitched roof.
assert epc is not None
assert epc.sap_building_parts
for part in epc.sap_building_parts:
assert _PARTY_CEILING in (part.roof_construction_type or "").lower()
def test_roof_construction_4_remains_a_real_pitched_roof() -> None:
# Arrange — code 4 is 2-3(a) "pitched roof, access to loft", a REAL roof.
data = _load_with_roof_construction(4)
# Act
epc = EpcPropertyDataMapper.from_api_response(data)
# Assert — must not be swept up by the code-3 correction.
assert epc is not None
assert epc.sap_building_parts
for part in epc.sap_building_parts:
assert _PARTY_CEILING not in (part.roof_construction_type or "").lower()

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]
@ -2715,6 +2716,68 @@ def test_room_in_roof_detailed_gable_wall_excluded_from_line_31_external_area()
)
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
def test_floor_to_unheated_space_on_flat_carries_semi_exposed_loss() -> None:
# Arrange — a mid-/top-floor flat whose lowest floor is lodged "To unheated
# space" (API floor_heat_loss=2, e.g. an enclosed garage or undercroft

View file

@ -273,7 +273,14 @@ _CORPUS = Path(
# 000480/000516 lodge Party gables). Cert 100040550095 -10.13 -> +0.37; the
# 18-cert terraced-external-gable cohort mean -1.94 -> +0.11; end-terrace
# untouched. Unit-pinned in test_mapper_rir_gable_terraced.
_MIN_WITHIN_HALF_SAP = 0.795
# SHELTERED STAIRWELL + PARTY-CEILING ROOF-CODE-3 SLICE (stacks on the gable
# fix above): RdSAP 10 §5.9 (PDF p.43) bills a sheltered wall to an unheated
# STAIRWELL at Ru 2.1, not the corridor 0.5 (`SapFlatDetails.heat_loss_corridor`
# was read nowhere); and gov `roof_construction=3` is "another dwelling above"
# (U=0), previously mislabelled a pitched roof so the party override never fired.
# Combined with main's gable fix: within-0.5 79.5% -> 80.3%, MAE 0.599 -> 0.584,
# PE 2.71 -> 2.5. Ratcheted.
_MIN_WITHIN_HALF_SAP = 0.803
# 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,
@ -404,7 +411,11 @@ _MIN_WITHIN_HALF_SAP = 0.795
# efficiency / interlock -5pp — SAP Table 4a immersion = 100%): observed
# within-0.5 78.9%, MAE 0.622. A handful of corpus certs with a gas-boiler
# space main + separate electric immersion moved closer to accredited.
_MAX_SAP_MAE = 0.599
# Ratcheted 0.585 -> 0.584 by the #1663 sheltered-stairwell Ru 2.1 + party-
# ceiling roof-code-3 slice stacking on main's #1662 floor-to-unheated-space
# fix: merged-corpus MAE measured 0.583761 (within-0.5 80.3%), rounded up to
# 3 d.p. so the `<=` assert holds.
_MAX_SAP_MAE = 0.584
_MAX_CO2_MAE_TONNES = 0.068 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 2.72 # kWh / m2 / yr vs energy_consumption_current