diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index af3448b9..fd3d3190 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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. diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 620deded..ceb086ce 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -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) diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 6dfbff44..c1632a39 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -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