fix(floor): exposed floor on a flat carries heat loss (RdSAP §3.12)

A mid-/top-floor flat whose lowest floor is lodged as an exposed floor
(API floor_heat_loss=1) had its floor area zeroed by the dwelling-level
exposure heuristic, which keys only on the flat label and defaults
has_exposed_floor=False (assuming the floor sits over another *heated*
dwelling). RdSAP 10 §3.12 (PDF p.25) is explicit:

  "Otherwise the floor area of the flat ... is:
     - an exposed floor if there is an open space below"

i.e. a flat cantilevered over a passageway IS a heat-loss floor on
Table 20. The per-BP `is_exposed_floor` lodgement is authoritative and
now overrides the dwelling-level suppression upward, mirroring the
existing "another dwelling below" party override (which suppresses
downward). The code-1↔"E To external air" enum is confirmed by the
paired API+Summary worksheet certs (0350, 3800).

Eval: 45.1% → 45.3% within 0.5 (909 computed); cert 3836 +6.79 → +0.77,
5717 +1.31 → -0.07 and 0997 +0.76 → +0.05 cross into <0.5. Two
already-failing under-rated certs (7636, 2241) shift further — both are
dominated by independent cost-side over-counts the exposed floor merely
unmasks (7636 walls = 8.98 W/K for 33.87 m² is the real defect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-07 21:47:52 +00:00
parent ae34ca4d74
commit b40e0f67b8
2 changed files with 48 additions and 1 deletions

View file

@ -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,

View file

@ -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-