diff --git a/packages/domain/src/domain/ml/rdsap_uvalues.py b/packages/domain/src/domain/ml/rdsap_uvalues.py index 9bffc329..8ffece99 100644 --- a/packages/domain/src/domain/ml/rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/rdsap_uvalues.py @@ -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²/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 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 2bf0804d..86377466 100644 --- a/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py @@ -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.