heat_transmission: route exposed/semi-exposed floors through Table 20

SapFloorDimension gains an is_exposed_floor flag (default False) signalling
that the floor sits over outside air or unheated space rather than soil —
typical for an extension that hangs off the main from the first storey
upward (Elmhurst 000490 Extension 1 is exactly this shape).

heat_transmission_from_cert now consults the flag on the part's ground
SapFloorDimension and dispatches to u_exposed_floor (Table 20) instead
of the BS EN ISO 13370 / Table 19 cascade. Basement floor still wins
priority (Table 23 § 5.17 overrides everything else for that part).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 13:22:44 +00:00
parent e2c37300ec
commit 6b99ad0a55
3 changed files with 58 additions and 3 deletions

View file

@ -250,6 +250,12 @@ class SapFloorDimension:
floor: Optional[int] = None
floor_insulation: Optional[int] = None
floor_construction: Optional[int] = None
# RdSAP10 §5.13 Table 20: True when this floor is open to outside air
# (exposed) or sits over enclosed unheated space (semi-exposed) — e.g.
# the lowest floor of an extension that hangs off the main from the
# first storey upward. False means a ground floor (on soil), the
# default path through the BS EN ISO 13370 / Table 19 cascade.
is_exposed_floor: bool = False
@dataclass

View file

@ -52,6 +52,7 @@ from domain.ml.rdsap_uvalues import (
u_basement_floor,
u_basement_wall,
u_door,
u_exposed_floor,
u_floor,
u_party_wall,
u_roof,
@ -273,11 +274,20 @@ def heat_transmission_from_cert(
wall_insulation_type=wall_ins_type,
)
ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=roof_description)
# When the part carries a basement, the WHOLE floor=0 is the
# basement floor (per user-confirmed convention). Table 23 F-column
# overrides the regular floor U-value cascade.
# Floor U-value routing (in priority order):
# 1. Basement floor — Table 23 F-column override (whole floor=0).
# 2. Exposed/semi-exposed upper floor — Table 20 lookup; no
# geometry input. Set on the ground SapFloorDimension when
# the part hangs off the main from the first storey upward
# (e.g. 000490 Extension 1).
# 3. Ground floor — BS EN ISO 13370 / Table 19 cascade.
is_exposed_floor = bool(ground_fd is not None and ground_fd.is_exposed_floor)
if part.has_basement:
uf = u_basement_floor(age_band)
elif is_exposed_floor:
uf = u_exposed_floor(
age_band=age_band, insulation_thickness_mm=floor_ins_thickness
)
else:
uf = u_floor(
country=country, age_band=age_band, construction=floor_construction,

View file

@ -80,6 +80,45 @@ def test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4()
assert result.roof_w_per_k == pytest.approx(68.0, abs=2.0)
def test_exposed_timber_floor_age_b_uses_table_20_u_120_not_iso_13370() -> None:
# Arrange — RdSAP10 §5.13 Table 20: a part whose lowest floor sits
# over outside air (or unheated space) rather than soil takes its
# U-value from Table 20, not the BS EN ISO 13370 ground-floor branch.
# Elmhurst worksheet 000490 Extension 1 is exactly this shape — the
# extension hangs off the main from the first storey upward, so its
# floor=0 is "exposed timber" at U=1.20 W/m²K. Without the routing,
# the cascade would treat the same dimensions as a small ground-floor
# rectangle and return a much lower U via ISO 13370.
# Geometry: 18 m² × 8.68 m perimeter at age B, no insulation lodged.
# floor_w_per_k expected = 1.20 × 18 = 21.6 W/K.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="B",
wall_construction=4,
wall_insulation_type=4,
party_wall_construction=1,
roof_construction=4,
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
result = heat_transmission_from_cert(epc)
# Assert
assert result.floor_w_per_k == pytest.approx(21.6, abs=0.1)
def test_floor_insulated_assumed_with_ni_thickness_uses_50mm_per_table19_footnote() -> None:
# Arrange — 2 413 corpus certs lodge floors with thickness="NI" and
# description "Solid, insulated (assumed)". The retrofit-insulation