Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	tests/domain/sap10_calculator/worksheet/test_heat_transmission.py
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-22 12:03:52 +00:00
commit 21faf63c89
3 changed files with 118 additions and 3 deletions

View file

@ -1114,9 +1114,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
)
@ -1260,7 +1290,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

@ -2776,3 +2776,84 @@ def test_sheltered_added_resistance_defaults_to_the_corridor_value() -> None:
assert _sheltered_added_resistance(3) == 2.1
assert _sheltered_added_resistance(2) == 0.5
assert _sheltered_added_resistance(None) == 0.5
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)

View file

@ -411,7 +411,11 @@ _MIN_WITHIN_HALF_SAP = 0.803
# efficiency / interlock -5pp — SAP Table 4a immersion = 100%): observed
# within-0.5 78.9%, MAE 0.622. A handful of corpus certs with a gas-boiler
# space main + separate electric immersion moved closer to accredited.
_MAX_SAP_MAE = 0.585
# Ratcheted 0.585 -> 0.584 by the #1663 sheltered-stairwell Ru 2.1 + party-
# ceiling roof-code-3 slice stacking on main's #1662 floor-to-unheated-space
# fix: merged-corpus MAE measured 0.583761 (within-0.5 80.3%), rounded up to
# 3 d.p. so the `<=` assert holds.
_MAX_SAP_MAE = 0.584
_MAX_CO2_MAE_TONNES = 0.068 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 2.72 # kWh / m2 / yr vs energy_consumption_current