mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
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:
commit
1c42845450
5 changed files with 210 additions and 4 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
78
tests/datatypes/epc/domain/test_mapper_roof_party_ceiling.py
Normal file
78
tests/datatypes/epc/domain/test_mapper_roof_party_ceiling.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue