diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index f90593ec..91783cbf 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -972,7 +972,17 @@ def heat_transmission_from_cert( # lodgement is authoritative. Mirrors the roof's "another dwelling # above" override above. Cert 2115-4121-4711-9361-3686. part_floor_is_party = "another dwelling below" in (part.floor_type or "").lower() - part_has_exposed_floor = exposure.has_exposed_floor and not part_floor_is_party + # A floor lodged as an *exposed* floor (API floor_heat_loss=1 → + # `is_exposed_floor`, "an exposed floor if there is an open space + # below" per RdSAP 10 §3.12, PDF p.25) carries heat loss even when + # the dwelling-level flat heuristic (`_dwelling_exposure`) defaults + # a mid-/top-floor flat to has_exposed_floor=False on the assumption + # its floor sits over another *heated* dwelling. The per-BP lodgement + # is authoritative: it overrides the suppression upward, mirroring + # how the "another dwelling below" party signal overrides it down. + part_has_exposed_floor = ( + exposure.has_exposed_floor or is_exposed_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, _AREA_ROUND_DP, diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 71dd4197..2137ee9d 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -996,6 +996,43 @@ def test_floor_over_another_dwelling_below_zeroes_floor_despite_exposed_flag() - assert result.walls_w_per_k > 0 +def test_exposed_floor_on_flat_carries_heat_loss_despite_unexposed_flag() -> None: + # Arrange — a top-/mid-floor flat whose lowest floor is lodged as an + # exposed floor (API floor_heat_loss=1, "an exposed floor if there is + # an open space below" per RdSAP 10 §3.12, PDF p.25 — e.g. a flat + # cantilevered over a passageway) IS a heat-loss floor on Table 20. + # The dwelling-level exposure heuristic, keyed only on the flat label, + # defaults has_exposed_floor=False on the assumption the floor sits over + # another heated dwelling; the per-BP `is_exposed_floor` lodgement is + # authoritative and must override that suppression upward, mirroring the + # "another dwelling below" party override (which suppresses downward). + 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 external air", + 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, + ), + ], + ) + main.sap_floor_dimensions[0].is_exposed_floor = True + epc = make_minimal_sap10_epc( + total_floor_area_m2=18.0, country_code="ENG", sap_building_parts=[main], + ) + + # Act — dwelling-level exposure flags the floor as NOT exposed (flat). + result = heat_transmission_from_cert( + epc, exposure=DwellingExposure(has_exposed_floor=False, has_exposed_roof=True), + ) + + # Assert — the per-BP exposed-floor lodgement wins → Table 20 floor 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_ground_floor_flat_extension_with_flat_roof_exposes_extension_roof_only() -> None: """Per-BP roof exposure: an extension on a ground-floor flat can have its own external (e.g. single-storey) roof even though the dwelling-