diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 789483e6c..0bc92b9c4 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -1082,9 +1082,39 @@ def heat_transmission_from_cert( is_above_partial = bool( ground_fd is not None and ground_fd.is_above_partially_heated_space ) + # RdSAP 10 §3.12 (PDF p.26) — the FLATS AND MAISONETTES floor rules: + # "There is no heat loss through the floor if there is another flat + # below. Otherwise the floor area of the flat, or the lower floor of + # the maisonette, is: ... a semi-exposed floor if there are unheated + # premises below it (e.g. an enclosed garage) ... Semi-exposed + # (sheltered) floors are treated as if they were fully exposed". + # §5.13 (PDF p.48) confirms the U-value identity: "no distinction is + # made ... between an exposed floor ... and a semi-exposed floor ... + # and the U-values in Table 20 are used". + # + # API floor_heat_loss=2 -> floor_type "To unheated space". It set NO + # exposure signal, so on a mid-/top-floor flat (where + # `_dwelling_exposure_from_type` defaults has_exposed_floor=False, + # assuming a heated dwelling below) nothing overrode the suppression + # and the entire floor was billed at 0 W/K. + # + # SCOPED TO THE FLAT CASE ON PURPOSE. §3.12 is the flats/maisonettes + # section, and the accredited Elmhurst worksheet disagrees with Table 20 + # for a HOUSE: golden cert 7536-3827-0600-0600-0276 (detached) lodges + # floor_heat_loss=2 on its Main part and the worksheet bills that floor + # at the BS EN ISO 13370 ground-floor U 0.97, NOT Table 20's 1.20 + # (worksheet-validated by simulated case 15). A house's floor stays on + # the ground-floor cascade; only a flat whose floor would otherwise be + # suppressed to zero takes the §3.12 semi-exposed route. + part_floor_is_unheated_space = ( + "to unheated space" in (part.floor_type or "").lower() + ) + semi_exposed_flat_floor = ( + part_floor_is_unheated_space and not exposure.has_exposed_floor + ) if part.has_basement: uf = u_basement_floor(age_band) - elif is_exposed_floor: + elif is_exposed_floor or semi_exposed_flat_floor: uf = u_exposed_floor( age_band=age_band, insulation_thickness_mm=floor_ins_thickness ) @@ -1228,7 +1258,7 @@ def heat_transmission_from_cert( # the "another dwelling below" party signal overrides it downward. part_has_exposed_floor = ( exposure.has_exposed_floor or is_exposed_floor or is_above_partial - or part_floor_is_ground + or part_floor_is_ground or semi_exposed_flat_floor ) and not part_floor_is_party floor_area_total = _round_half_up( geom["ground_floor_area_m2"] if part_has_exposed_floor else 0.0, diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 2df247a2d..cf6fa0bca 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -2713,3 +2713,84 @@ def test_room_in_roof_detailed_gable_wall_excluded_from_line_31_external_area() assert result.total_external_element_area_m2 == pytest.approx( expected_line_31, abs=0.001 ) + + +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 + # below). RdSAP 10 §3.12 (PDF p.26) — the flats/maisonettes section — makes + # this a SEMI-exposed floor: "There is no heat loss through the floor if + # there is another flat below. Otherwise the floor area of the flat ... is + # ... a semi-exposed floor if there are unheated premises below it (e.g. an + # enclosed garage) ... Semi-exposed (sheltered) floors are treated as if + # they were fully exposed". §5.13 (PDF p.48) confirms the U-value identity: + # "no distinction is made ... between an exposed floor ... and a + # semi-exposed floor ... and the U-values in Table 20 are used". + # + # Code 2 previously set NO exposure signal, so the dwelling-level flat + # heuristic (has_exposed_floor=False, assuming a heated dwelling below) went + # unopposed and the whole floor was billed at 0 W/K. + main = make_building_part( + construction_age_band="B", + wall_construction=4, wall_insulation_type=4, + party_wall_construction=1, roof_construction=4, + floor_type="To unheated space", + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=18.0, room_height_m=2.88, + party_wall_length_m=0.0, heat_loss_perimeter_m=8.68, floor=0, + ), + ], + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=18.0, country_code="ENG", sap_building_parts=[main], + ) + + # Act — dwelling-level exposure suppresses the floor (flat label). + result = heat_transmission_from_cert( + epc, exposure=DwellingExposure(has_exposed_floor=False, has_exposed_roof=True), + ) + + # Assert — Table 20 semi-exposed loss (1.20 W/m²K × 18 m² = 21.6 W/K), + # not the suppressed 0.0. + assert result.floor_w_per_k == pytest.approx(21.6, abs=0.1) + + +def test_floor_to_unheated_space_on_house_stays_on_the_ground_floor_cascade() -> None: + # Arrange — the SAME lodgement on a dwelling whose floor is already counted + # (a house: has_exposed_floor=True). §3.12's semi-exposed rule is scoped to + # flats and maisonettes, and the accredited Elmhurst worksheet agrees for a + # house: golden cert 7536-3827-0600-0600-0276 (detached) lodges + # floor_heat_loss=2 on its Main part and the worksheet bills that floor on + # the BS EN ISO 13370 ground-floor cascade at U 0.97 — NOT Table 20's 1.20 + # (worksheet-validated by simulated case 15). + # + # So a house lodging "To unheated space" must bill exactly as if it lodged a + # ground floor. This pins the scope of the flat fix above. + def _house(floor_type: str) -> float: + part = make_building_part( + construction_age_band="B", + wall_construction=4, wall_insulation_type=4, + party_wall_construction=1, roof_construction=4, + floor_type=floor_type, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=18.0, room_height_m=2.88, + party_wall_length_m=0.0, heat_loss_perimeter_m=8.68, floor=0, + ), + ], + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=18.0, country_code="ENG", sap_building_parts=[part], + ) + return heat_transmission_from_cert( + epc, + exposure=DwellingExposure(has_exposed_floor=True, has_exposed_roof=True), + ).floor_w_per_k + + # Act + unheated_space = _house("To unheated space") + + # Assert — identical to the ground-floor lodgement, i.e. untouched by the + # §3.12 flat rule (and in particular NOT Table 20's 1.20 × 18 = 21.6). + assert unheated_space == pytest.approx(_house("Ground floor"), abs=1e-9)