u_exposed_floor: Table 20 lookup for exposed/semi-exposed upper floors

RdSAP10 §5.13 Table 20 (page 47) gives U-values for upper floors that
sit over outside air (exposed) or enclosed unheated space (semi-exposed) —
e.g. an extension hanging off the main from the first storey upward.
The spec collapses both into the same lookup: keyed on age band ×
insulation thickness, no geometry needed.

Elmhurst worksheet U985-0001-000490 Extension 1 records U=1.20 W/m²K
for its exposed timber floor (age B, no insulation). Table 20 row
"A to G, insulation unknown or as built" returns 1.20 exactly.

Caller wiring (heat_transmission_from_cert routing on a floor_position
discriminator) lands in the next slice.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 13:19:46 +00:00
parent 344a9c9d5e
commit e2c37300ec
2 changed files with 65 additions and 0 deletions

View file

@ -587,6 +587,54 @@ def u_floor(
return soil_g / (0.457 * b + d_t)
# ---------------------------------------------------------------------------
# Exposed / semi-exposed upper-floor U-values (Table 20, §5.13)
# ---------------------------------------------------------------------------
#
# Table 20 (page 47): the spec collapses exposed (to outside air) and
# semi-exposed (to enclosed unheated space) into the same lookup. Keyed
# on age band × insulation thickness — no geometry input. This is the
# floor of e.g. a single-storey extension that hangs off the main from
# the first storey upward (000490 Extension 1 is exactly this shape).
#
# Country footnotes:
# (1) Use the 50 mm row if known to be insulated but thickness unknown.
# (2) Band L → 0.18 W/m²K in Scotland.
# (3) Band M → 0.15 W/m²K in Scotland AND Wales.
# These are England-and-Wales values; country overrides land later.
_EXPOSED_FLOOR_BY_AGE_AND_INS: Final[dict[str, tuple[float, float, float, float]]] = {
# (unknown/as-built, 50mm, 100mm, 150mm)
"A": (1.20, 0.50, 0.30, 0.22), "B": (1.20, 0.50, 0.30, 0.22),
"C": (1.20, 0.50, 0.30, 0.22), "D": (1.20, 0.50, 0.30, 0.22),
"E": (1.20, 0.50, 0.30, 0.22), "F": (1.20, 0.50, 0.30, 0.22),
"G": (1.20, 0.50, 0.30, 0.22),
"H": (0.51, 0.50, 0.30, 0.22), "I": (0.51, 0.50, 0.30, 0.22),
"J": (0.25, 0.25, 0.25, 0.22),
"K": (0.22, 0.22, 0.22, 0.22),
"L": (0.22, 0.22, 0.22, 0.22),
"M": (0.18, 0.18, 0.18, 0.18),
}
def u_exposed_floor(
age_band: Optional[str], insulation_thickness_mm: Optional[int]
) -> float:
"""RdSAP10 Table 20 exposed/semi-exposed upper-floor U-value in W/m²K.
Used when the part's floor is open to outside air or sits over an
unheated space (e.g. an over-passageway extension) rather than over
soil. No geometry input the lookup is age × insulation only."""
band = (age_band or "A").upper()
row = _EXPOSED_FLOOR_BY_AGE_AND_INS.get(band, _EXPOSED_FLOOR_BY_AGE_AND_INS["A"])
if insulation_thickness_mm is None or insulation_thickness_mm < 25:
return row[0]
if insulation_thickness_mm < 75:
return row[1]
if insulation_thickness_mm < 125:
return row[2]
return row[3]
# ---------------------------------------------------------------------------
# Window U-values (Table 24)
# ---------------------------------------------------------------------------

View file

@ -28,6 +28,7 @@ from domain.ml.rdsap_uvalues import (
WALL_TIMBER_FRAME,
thermal_bridging_y,
u_door,
u_exposed_floor,
u_floor,
u_party_wall,
u_roof,
@ -783,6 +784,22 @@ def test_u_floor_with_insulation_lowers_u_value() -> None:
assert insulated < 0.3
def test_u_exposed_floor_age_b_unknown_insulation_uses_table_20_row_a_to_g() -> None:
# Arrange — RdSAP10 §5.13 Table 20 (page 47) gives U-values for
# exposed and semi-exposed upper floors keyed on age band +
# insulation thickness. The "Insulation unknown or as built"
# column at age band A-G = 1.20 W/m²K. Elmhurst worksheet
# U985-0001-000490 Extension 1 records U=1.20 for its exposed
# timber floor (1900-1929, no insulation lodged) — this lookup
# reproduces that exact value without any geometry input.
# Act
result = u_exposed_floor(age_band="B", insulation_thickness_mm=None)
# Assert
assert result == pytest.approx(1.20, abs=0.001)
def test_u_floor_falls_back_to_mid_range_when_geometry_unknown() -> None:
# Arrange — geometry missing.