Bill a flat's floor to an unheated space as semi-exposed 🟩

RdSAP 10 §3.12 (PDF p.26), the flats/maisonettes floor rules: "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 (p.48) confirms the U-value identity: Table 20
applies to exposed and semi-exposed alike.

API floor_heat_loss=2 -> floor_type "To unheated space" set no exposure signal,
so on a mid-/top-floor flat the dwelling-level heuristic (has_exposed_floor=False,
assuming a heated dwelling below) went unopposed and the ENTIRE floor was billed
at 0 W/K. 11 properties across portfolios 796+824, mean +4.02 SAP, 10 of 11
over-rating — we judged dwellings more efficient than the accredited assessment
and would withhold retrofit measures they need.

Exemplars: 750018 space heating -53.2% -> -8.1% vs the cert's own RHI figure,
SAP +8.22 -> +1.16; 749898 -43.0% -> +0.9%, SAP +6.29 -> -1.11.

SCOPED TO FLATS 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 it on the BS EN ISO 13370 ground-floor cascade at U 0.97, not
Table 20's 1.20. An unscoped mapper-level fix broke that pin and the
uprn_100050881708 accuracy pin; the scoped fix leaves both untouched.

No regressions: identical failure set and pyright count vs main; the RdSAP-21.0.1
accuracy corpus gauge is unchanged at 78.8% within-0.5 / MAE 0.625 (correctly —
all 7 corpus certs lodging code 2 are houses).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-21 21:24:25 +00:00
parent ccba02b51a
commit 0ffc25506e
2 changed files with 113 additions and 2 deletions

View file

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

View file

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