mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
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:
parent
ccba02b51a
commit
3f6129afc6
3 changed files with 111 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue