feat(heat-transmission): door to unheated corridor uses Table 26 U=1.4 on the sheltered wall

A door opening to an unheated corridor/stairwell takes U=1.4 W/m²K (RdSAP 10
Table 26, p.51 — any age band) instead of the 3.0 external-door default, and
its area deducts from the SHELTERED wall, not the main wall (RdSAP §3.7,
p.18: "the door of a flat/maisonette to an unheated stairwell or corridor
... is deducted from the sheltered wall area"). The cascade previously
billed every door at the external U on the main wall.

Signal: a SHELTERED alternative wall (`is_sheltered`, the RdSAP §5.9
wall-to-unheated-corridor surface, already modelled) is the evidence that
the dwelling is accessed via an unheated corridor, so one lodged door opens
to it. `_corridor_door_count` returns 1 when a sheltered alt wall is present
and >=1 door is lodged, else 0 — so the door channel is unchanged for every
non-corridor dwelling (houses, exposed-gable flats). `heat_transmission_
from_cert` gains a `corridor_door_count` param (default 0): it splits the
door area into external (main wall, age-default U) + corridor (sheltered
alt wall, U=1.4), threading the corridor door's area into that wall's
opening deduction and billing it at 1.4.

Validated on TWO faithful worksheets: simulated case 34 (cert 001431
storage flat — doors 8.14 exact, fabric 207.47 ≈ ws 207.48) and the
long-standing worksheet-harness diverger cert 2474 (−0.87 → −0.32, the
"space-demand thread" was the dropped corridor door). The worksheet harness
is now 47/47 with ZERO divergers.

API SAP gauge: 57.6% → 60.0% within 0.5; mean|err| 1.185 → 1.167; signed
−0.165 → −0.115 — ~22 sheltered-corridor flats were a systematic gap.
Regression gate green (3 pre-existing fails unrelated); pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-11 08:03:06 +00:00
parent 06989d6b0f
commit c10881ae7a
3 changed files with 103 additions and 2 deletions

View file

@ -4011,9 +4011,31 @@ def heat_transmission_section_from_cert(epc: EpcPropertyData) -> HeatTransmissio
insulated_door_count=epc.insulated_door_count,
insulated_door_u_value=epc.insulated_door_u_value,
exposure=exposure,
corridor_door_count=_corridor_door_count(epc),
)
def _corridor_door_count(epc: EpcPropertyData) -> int:
"""RdSAP §3.7 + Table 26 — number of doors opening to an unheated
corridor/stairwell (each billed at U=1.4 on the sheltered wall).
The presence of a SHELTERED alternative wall (`is_sheltered`, the
RdSAP §5.9 wall-to-unheated-corridor surface) is the evidence that the
dwelling is accessed via an unheated corridor, so its entrance door
opens to that corridor. RdSAP convention assumes one such access door
when the sheltered wall is present and the cert lodges at least one
door; the remainder are external. Returns 0 when no sheltered alt wall
is lodged (houses, exposed-gable flats) so the door channel is
unchanged for every non-corridor dwelling.
"""
has_sheltered_alt = any(
(bp.sap_alternative_wall_1 is not None and bp.sap_alternative_wall_1.is_sheltered)
or (bp.sap_alternative_wall_2 is not None and bp.sap_alternative_wall_2.is_sheltered)
for bp in (epc.sap_building_parts or [])
)
return 1 if has_sheltered_alt and epc.door_count > 0 else 0
def _rooflight_total_area_m2_from_cert(epc: EpcPropertyData) -> float:
"""Σ area of `epc.sap_roof_windows` for §5 daylight-factor L2a +
§6 horizontal solar gain. Returns 0.0 when none are lodged.

View file

@ -117,6 +117,11 @@ def _decimal_round_half_up_product(a: float, b: float, dp: int) -> float:
_WALL_INSULATION_NONE: Final[int] = 4
_DEFAULT_DOOR_AREA_M2: Final[float] = 1.85
# RdSAP 10 Table 26 (PDF p.51): a door opening to an unheated corridor or
# stairwell takes U=1.4 W/m²K for any age band (vs 3.0 for an external door
# A-J). The door sits on the sheltered wall (RdSAP §3.7 p.18) and its area
# deducts from that wall, not the main wall.
_CORRIDOR_DOOR_U_W_PER_M2K: Final[float] = 1.4
_DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5
# SAP10.2 §3.2 curtain/blind thermal resistance applied to windows (and
# roof windows) — turns raw window U into the worksheet's (27) effective U.
@ -569,6 +574,7 @@ def heat_transmission_from_cert(
insulated_door_count: int = 0,
insulated_door_u_value: Optional[float] = None,
exposure: Optional[DwellingExposure] = None,
corridor_door_count: int = 0,
) -> HeatTransmission:
"""Conduction HLC + thermal-bridging contribution, summed across every
sap_building_part in the cert. Windows and doors are apportioned to the
@ -590,7 +596,18 @@ def heat_transmission_from_cert(
floor_description = _joined_descriptions(epc.floors)
# RdSAP10 §15 — door area rounds to 2 d.p. before entering the calc.
door_area = _round_half_up(max(0, door_count) * _DEFAULT_DOOR_AREA_M2, _AREA_ROUND_DP)
# A door to an unheated corridor (RdSAP Table 26 / §3.7) is billed at
# U=1.4 and its area deducts from the sheltered wall, not the main wall.
# Only the remaining EXTERNAL doors stay on the main wall at the
# age-default U; the corridor door area is tracked separately and
# assigned to the first sheltered alt wall in the BP loop below.
corridor_door_count = max(0, min(corridor_door_count, door_count))
external_door_count = max(0, door_count - corridor_door_count)
door_area = _round_half_up(external_door_count * _DEFAULT_DOOR_AREA_M2, _AREA_ROUND_DP)
corridor_door_area = _round_half_up(
corridor_door_count * _DEFAULT_DOOR_AREA_M2, _AREA_ROUND_DP,
)
corridor_door_area_remaining = corridor_door_area
# SAP10.2 §3.2: effective window U includes the 0.04 m²K/W curtain
# resistance — `(27)` worksheet column applies it per-window. When
# sap_windows have per-window U lodgements (mixed glazing types in
@ -1009,12 +1026,20 @@ def heat_transmission_from_cert(
continue
# RdSAP10 §15 — alt wall area rounded to 2 d.p.
alt_walls_total_area += _round_half_up(alt_wall.wall_area, _AREA_ROUND_DP)
alt_opening = alt_window_area if idx == 0 else 0.0
# RdSAP §3.7 (p.18): the door to an unheated corridor deducts
# from the SHELTERED wall area. Attach the corridor door to the
# first sheltered alt wall (its U=1.4 contribution is billed in
# the door channel below, not here).
if alt_wall.is_sheltered and corridor_door_area_remaining > 0.0:
alt_opening += corridor_door_area_remaining
corridor_door_area_remaining = 0.0
alt_walls_contribution += _alt_wall_w_per_k(
alt_wall=alt_wall,
country=country,
age_band=age_band,
wall_description=wall_description,
opening_area_m2=alt_window_area if idx == 0 else 0.0,
opening_area_m2=alt_opening,
)
# Main wall net adds back the alt-wall windows that were initially
# deducted from the BP's total gross — those openings should have
@ -1263,6 +1288,13 @@ def heat_transmission_from_cert(
total_external_area += part_external_area
bridging += y * part_external_area
# RdSAP Table 26 — the unheated-corridor door's heat loss (U=1.4). Its
# area was deducted from the sheltered alt wall above, so the alt wall
# net (and hence its W/K) already excludes it; bill it here at the
# corridor U. (31) is unaffected: the door area stays counted in the
# alt-wall gross contribution, equivalent to the worksheet's separate
# door line.
doors += _CORRIDOR_DOOR_U_W_PER_M2K * corridor_door_area
roof_windows_w_per_k = roof_windows_w_per_k_total
fabric_heat_loss = (
walls + roof + floor + party + windows + roof_windows_w_per_k + doors # (33)

View file

@ -1228,6 +1228,53 @@ def test_sheltered_alternative_wall_applies_table4_0p5_resistance() -> None:
assert sheltered_wpk < exposed_wpk
def test_corridor_door_on_sheltered_alt_wall_uses_table26_u_1p4() -> None:
# Arrange — RdSAP 10 Table 26 (PDF p.51): a door opening to an unheated
# corridor/stairwell has U=1.4 (any age), versus 3.0 for an external
# door (age A-J). RdSAP §3.7 (p.18): "the door of a flat/maisonette to
# an unheated stairwell or corridor ... is deducted from the sheltered
# wall area" — i.e. the corridor door sits on the sheltered alt wall,
# not the main wall. Simulated case 34: a flat with a sheltered alt
# (corridor) wall + 2 doors → 1 corridor door (U=1.4 on the alt wall) +
# 1 external door (U=3.0 on the main wall).
from dataclasses import replace
main = replace(
make_building_part(
construction_age_band="B",
wall_construction=4, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=50.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=28.0, floor=0,
),
],
),
sap_alternative_wall_1=SapAlternativeWall(
wall_area=12.5, wall_dry_lined="N", wall_construction=4,
wall_insulation_type=4, wall_thickness_measured="Y",
wall_insulation_thickness="NI", is_sheltered=True,
),
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=50.0, country_code="ENG", sap_building_parts=[main],
)
# Act
no_corridor = heat_transmission_from_cert(epc, door_count=2, corridor_door_count=0)
with_corridor = heat_transmission_from_cert(epc, door_count=2, corridor_door_count=1)
# Assert — no-corridor: both doors external at the age-default U.
door_u = no_corridor.doors_w_per_k / (2 * 1.85)
# with-corridor: 1 external @door_u + 1 corridor @1.4 (both 1.85 m²).
assert abs(with_corridor.doors_w_per_k - (1.85 * door_u + 1.85 * 1.4)) <= 0.02
# The corridor door (U=1.4) is cheaper than an external door (U≈3.0) and
# its area moves off the main wall onto the sheltered alt wall, so the
# net fabric heat loss drops.
assert with_corridor.fabric_heat_loss_w_per_k < no_corridor.fabric_heat_loss_w_per_k
def test_window_uses_effective_u_value_with_curtain_resistance_per_sap10_2_section_3_2() -> None:
"""SAP10.2 §3.2: the window U-value used for heat-transmission is the
effective form `U_eff = 1/(1/U_raw + 0.04)` the 0.04 m²K/W is the