u_floor: route age A,B unknowns to suspended-timber branch (Table 19 fn 1)

RdSAP10 §5.12 Table 19 footnote (1): when floor_construction is unknown,
age bands A and B default to suspended timber, not solid. Previously
u_floor always used the BS EN ISO 13370 solid-floor formula, which
under-counted ~14% on pre-1929 dwellings.

Elmhurst worksheet U985-0001-000490 Main Dwelling (A=14.85, P=7.42,
w=0.400, age B) records floor U=0.71 W/m²K — the suspended-floor formula
on §5.12 page 46 reproduces this exactly. The solid branch returned 0.66.

Description prefixes "Solid, ..." / "Suspended, ..." take precedence over
the age-band default since they're explicit assessor observations.

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

View file

@ -460,6 +460,54 @@ _FLOOR_INSULATION_DEFAULT_MM: Final[dict[str, int]] = {
"H": 0, "I": 25, "J": 75, "K": 100, "L": 100, "M": 140,
}
# Table 19 footnote (1): age bands whose default floor_construction is
# suspended timber (when unknown). All other bands default to solid.
_SUSPENDED_TIMBER_DEFAULT_BANDS: Final[frozenset[str]] = frozenset({"A", "B"})
def _floor_is_suspended_from_description(description: Optional[str]) -> Optional[bool]:
"""Parse the cert's floor description prefix ("Solid, ..." vs
"Suspended, ...") into a tri-state: True if explicitly suspended,
False if explicitly solid, None if the description carries no
construction signal. `EpcFloorDescriptions` in `datatypes.epc.floor`
enumerates the canonical prefixes."""
if description is None:
return None
desc = description.lower().lstrip()
if desc.startswith("suspended"):
return True
if desc.startswith("solid"):
return False
return None
def _u_floor_suspended(
*,
area_m2: float,
perimeter_m: float,
wall_thickness_mm: Optional[int],
insulation_thickness_mm: int,
) -> float:
"""Suspended ground-floor U-value per RdSAP10 §5.12 (page 46). Uses
BS EN ISO 13370 with the suspended-floor adjustments underfloor
ventilation Ux is added to the soil Ug term before inverting.
Parameter defaults are pinned by the spec: thermal resistance of an
uninsulated deck Rf=0.2 m²K/W (adds insulation R when present);
underfloor height h=0.3 m; mean wind speed v=5 m/s; wind shielding
fw=0.05; ventilation openings ε=0.003 /m; wall-to-underfloor U_w=1.5.
"""
w = (wall_thickness_mm or 300) / 1000.0
soil_g = 1.5
r_si = 0.17
r_se = 0.04
r_f = 0.2 + (insulation_thickness_mm / 1000.0) / 0.035
d_g = w + soil_g * (r_si + r_se)
b = 2.0 * area_m2 / perimeter_m
u_g = 2.0 * soil_g * log(pi * b / d_g + 1.0) / (pi * b + d_g)
u_x = (2.0 * 0.3 * 1.5 / b) + (1450.0 * 0.003 * 5.0 * 0.05 / b)
return 1.0 / (2.0 * r_si + r_f + 1.0 / (u_g + u_x))
def u_floor(
country: Optional[Country],
@ -512,6 +560,25 @@ def u_floor(
# Table 19 footnote (2): "use the greater of 50 mm and the
# thickness according to the age band".
ins_mm = max(50, age_default_mm)
# Table 19 footnote (1): if floor_construction is unknown, age bands
# A and B default to suspended timber (the rest default to solid).
# A description prefix of "Solid, ..." or "Suspended, ..." takes
# precedence over the age-band default since it's an explicit assessor
# observation about the construction.
band_upper = age_band.upper() if age_band else None
described_suspended = _floor_is_suspended_from_description(description)
use_suspended_branch = (
described_suspended
if described_suspended is not None
else (construction is None and band_upper in _SUSPENDED_TIMBER_DEFAULT_BANDS)
)
if use_suspended_branch:
return _u_floor_suspended(
area_m2=area_m2,
perimeter_m=perimeter_m,
wall_thickness_mm=wall_thickness_mm,
insulation_thickness_mm=ins_mm or 0,
)
r_f = ((ins_mm or 0) / 1000.0) / 0.035
d_t = w + soil_g * (r_si + r_f + r_se)
b = 2.0 * area_m2 / perimeter_m

View file

@ -742,6 +742,29 @@ def test_u_floor_solid_uninsulated_typical_geometry_returns_iso_13370_value() ->
assert result == pytest.approx(0.68, abs=0.05)
def test_u_floor_age_b_unknown_construction_uses_suspended_timber_per_table_19_footnote_1() -> None:
# Arrange — RdSAP10 §5.12 Table 19 footnote (1) routes age A, B with
# unknown floor_construction to the suspended-timber branch. Geometry
# is taken from Elmhurst worksheet U985-0001-000490 Main Dwelling
# (A=14.85, P=7.42, w=0.400) — the worksheet records U=0.71 W/m²K,
# confirming the suspended-floor formula on §5.12 (page 46) is the
# one Elmhurst applies for this fixture.
# Act
result = u_floor(
country=Country.ENG,
age_band="B",
construction=None,
insulation_thickness_mm=None,
area_m2=14.85,
perimeter_m=7.42,
wall_thickness_mm=400,
)
# Assert
assert result == pytest.approx(0.71, abs=0.01)
def test_u_floor_with_insulation_lowers_u_value() -> None:
# Arrange — same geometry but with 100mm insulation -> R_f = 0.1/0.035 = 2.857.