diff --git a/packages/domain/src/domain/ml/rdsap_uvalues.py b/packages/domain/src/domain/ml/rdsap_uvalues.py index 8ffece99..1dd5659a 100644 --- a/packages/domain/src/domain/ml/rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/rdsap_uvalues.py @@ -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) # --------------------------------------------------------------------------- diff --git a/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py b/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py index 86377466..d4af358c 100644 --- a/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py @@ -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.