diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index fd3d3190..0ef51687 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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, diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index b5497ec8..8a055253 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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