fix(ventilation): corridor flat assumes a draught lobby, zeroing §2 (13)

A flat accessed via an unheated corridor/stairwell assumes a draught lobby
is present, so SAP 10.2 §2 line (13) = 0.0 rather than the 0.05 no-lobby
infiltration penalty. Per RdSAP 10 Specification (10-06-2025, p.30, "Draught
lobby"): "add infiltration 0.05 if draught lobby is not present, or use 0.0
if present. ... Flat or maisonette: Assume draught lobby if entrance door is
facing corridor (heated or unheated) or stairwell."

Signal: a SHELTERED alternative wall (the RdSAP §5.9 wall-to-unheated-corridor
surface) is the evidence that the flat's entrance faces a corridor — the same
evidence the corridor door (Table 26 U=1.4) rides on. New helper
`_has_sheltered_corridor_wall` factors that check out of `_corridor_door_count`
and gates `_has_draught_lobby`. Houses and exposed-gable flats (no sheltered
alt wall) keep the lodged value / "assume no lobby if cannot be determined"
default, so the §2 cascade is unchanged for every non-corridor dwelling.

The cascade previously added the 0.05 penalty unconditionally, over-counting
(16)/(18)/(21) by 0.05 ACH. On simulated case 34 (cert 001431 storage flat)
this lifted effective air change (25)m from the worksheet's monthly 0.572-0.638
to 0.574-0.668, over-counting space-heating demand (98) by +46.3 kWh/yr
(+0.41%) -> SAP -0.18. Closing it lands (25)m exactly on the worksheet (avg
0.6024) and (98) at 11356.3 vs ws 11357.2:

  case 34 SAP 35.1325 -> 35.3130 vs ws 35.3094  (Δ -0.1769 -> +0.0036)

Guard-rails held (both improved): worksheet harness 47/47, 0 divergers (the
other corridor flat, cert 2474, -0.32 -> -0.02); API gauge 60.0% -> 60.1%
within 0.5, mean|err| 1.167 -> 1.163.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-11 09:00:54 +00:00
parent c10881ae7a
commit 450e33e15d
2 changed files with 90 additions and 14 deletions

View file

@ -4015,25 +4015,54 @@ def heat_transmission_section_from_cert(epc: EpcPropertyData) -> HeatTransmissio
)
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).
def _has_sheltered_corridor_wall(epc: EpcPropertyData) -> bool:
"""Whether the dwelling is accessed via an unheated corridor/stairwell.
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.
A SHELTERED alternative wall (`is_sheltered`, the RdSAP §5.9
wall-to-unheated-corridor surface) is the evidence that the dwelling's
entrance faces an unheated corridor or stairwell. False for houses and
exposed-gable flats (no sheltered alt wall lodged).
"""
has_sheltered_alt = any(
return 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 _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).
A sheltered alternative wall (`_has_sheltered_corridor_wall`) 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.
"""
return 1 if _has_sheltered_corridor_wall(epc) and epc.door_count > 0 else 0
def _has_draught_lobby(epc: EpcPropertyData, sv: Optional[SapVentilation]) -> bool:
"""RdSAP 10 §2 (13) — presence of a draught lobby.
Spec (RdSAP 10 Specification 10-06-2025, p.30, "Draught lobby"):
"add infiltration 0.05 if draught lobby is not present, or use 0.0 if
present. ... Flat or maisonette: Assume draught lobby if entrance door
is facing corridor (heated or unheated) or stairwell."
A sheltered corridor wall (`_has_sheltered_corridor_wall`) is exactly
that evidence: the flat's entrance faces an unheated corridor/stairwell,
so a draught lobby is assumed present regardless of the lodged value.
Otherwise fall back to the lodged value which, when undetermined, is
the RdSAP "assume no draught lobby if cannot be determined" default for
houses.
"""
if _has_sheltered_corridor_wall(epc):
return True
return bool(sv.has_draught_lobby) if sv is not None and sv.has_draught_lobby is not None else False
def _rooflight_total_area_m2_from_cert(epc: EpcPropertyData) -> float:
@ -4829,7 +4858,7 @@ def ventilation_from_cert(
flueless_gas_fires=vc.flueless_gas_fires,
has_suspended_timber_floor=eff_has_susp,
suspended_timber_floor_sealed=eff_sealed,
has_draught_lobby=bool(sv.has_draught_lobby) if sv is not None and sv.has_draught_lobby is not None else False,
has_draught_lobby=_has_draught_lobby(epc, sv),
window_pct_draught_proofed=float(epc.percent_draughtproofed or 0),
sheltered_sides=int(sv.sheltered_sides) if sv is not None and sv.sheltered_sides is not None else 2,
air_permeability_ap4=ap4,

View file

@ -30,6 +30,7 @@ from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
MainHeatingDetail,
PhotovoltaicArray,
SapAlternativeWall,
SapFloorDimension,
SapVentilation,
)
@ -1253,6 +1254,52 @@ def test_ventilation_from_cert_routes_mev_decentralised_to_extract_or_piv_outsid
assert abs(w25 - expected) <= 1e-4
def test_corridor_flat_assumes_draught_lobby_present_zeroing_line_13() -> None:
# Arrange — RdSAP 10 Specification (10-06-2025) p.30, "Draught lobby":
# "add infiltration 0.05 if draught lobby is not present, or use 0.0 if
# present. ... Flat or maisonette: Assume draught lobby if entrance door
# is facing corridor (heated or unheated) or stairwell." A SHELTERED
# alternative wall is the RdSAP §5.9 wall-to-unheated-corridor surface —
# the same evidence the corridor door rides on — so the flat's entrance
# faces a corridor and a draught lobby is assumed present, zeroing line
# (13). Simulated case 34 (cert 001431 storage flat): the cascade
# previously added the 0.05 no-lobby penalty, over-counting (16)/(18) by
# 0.05 ACH → +46 kWh/yr space demand → SAP 0.18.
from dataclasses import replace
corridor_part = replace(
make_building_part(construction_age_band="G"),
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,
),
)
exposed_part = make_building_part(construction_age_band="G")
corridor_flat = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2, country_code="ENG",
sap_building_parts=[corridor_part],
)
exposed_flat = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2, country_code="ENG",
sap_building_parts=[exposed_part],
)
# Act
v_corridor = ventilation_from_cert(corridor_flat)
v_exposed = ventilation_from_cert(exposed_flat)
# Assert — the corridor flat zeroes (13); a flat with no sheltered
# corridor wall keeps the 0.05 no-lobby penalty (cannot be determined).
assert abs(v_corridor.draught_lobby_ach - 0.0) <= 1e-9
assert abs(v_exposed.draught_lobby_ach - 0.05) <= 1e-9
# The lobby removes 0.05 ACH from (16); shelter (21) drops proportionally.
assert v_corridor.infiltration_rate_ach < v_exposed.infiltration_rate_ach
assert abs(
(v_exposed.infiltration_rate_ach - v_corridor.infiltration_rate_ach) - 0.05
) <= 1e-9
def test_index_less_mev_uses_table_4g_default_sfp_for_fan_electricity() -> None:
# Arrange — an MEV system with NO PCDB record (index absent / not in
# Table 322). SAP 10.2 §2.6.3 / Table 4g note 1 prescribes a default