From 0ff814451f1e8fccbf9f14b06867e10cb79b64f6 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 22 May 2026 19:19:01 +0000 Subject: [PATCH] =?UTF-8?q?Cohort=20residual=20slice=2010:=20u=5Frr=5Fslop?= =?UTF-8?q?e=20/=20u=5Frr=5Fflat=5Fceiling=20/=20u=5Frr=5Fstud=5Fwall=20?= =?UTF-8?q?=E2=80=94=20RdSAP10=20Table=2017?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the three Table 17 lookups for rooms in roof where insulation thickness is known. Each column of Table 17 splits into (a) mineral wool / EPS slab vs (b) PUR or PIR rigid foam — pinned verbatim from spec page 44 across all 16 thickness rows (0, 12, 25, ..., >400). The three public functions share a single private `_u_rr_table_17` row picker indexed by (column-a, column-b) pair, so a `u_rr_slope`, `u_rr_flat_ceiling`, or `u_rr_stud_wall` call boils down to one row descent through the same tuple-of-tuples. Falls back to `u_rr_default_all_elements` (Table 18 col 4) when thickness is None — matches the spec text at §5.11.3 / §5.11.4 ("U-values in Table 18 are used when thickness of insulation cannot be determined"). Reference: RdSAP 10 (10-06-2025) Table 17 page 44; key on same page. Co-Authored-By: Claude Opus 4.7 --- .../domain/src/domain/ml/rdsap_uvalues.py | 125 ++++++++++++++++ .../src/domain/ml/tests/test_rdsap_uvalues.py | 135 ++++++++++++++++++ 2 files changed, 260 insertions(+) diff --git a/packages/domain/src/domain/ml/rdsap_uvalues.py b/packages/domain/src/domain/ml/rdsap_uvalues.py index 9b49cea4..3716a30b 100644 --- a/packages/domain/src/domain/ml/rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/rdsap_uvalues.py @@ -462,6 +462,131 @@ def u_roof( return _ROOF_BY_AGE.get(age_band.upper(), 0.4) +# RdSAP10 Table 17 — U-values for rooms in roof where insulation thickness +# is known. Each tuple row is (thickness_mm, col_1a, col_1b, col_2a, col_2b, +# col_3a, col_3b) per spec page 44 (mineral wool/EPS for "a", PUR/PIR for +# "b"). Row "none" represents thickness 0 mm. Row ">400" represents any +# thickness ≥ 400 mm. +_RR_TABLE_17_ROWS: Final[tuple[tuple[int, float, float, float, float, float, float], ...]] = ( + (0, 2.30, 2.30, 2.30, 2.30, 2.30, 2.30), + (12, 1.50, 1.25, 1.75, 1.50, 0.95, 0.85), + (25, 1.00, 0.80, 1.25, 1.00, 0.70, 0.60), + (50, 0.68, 0.52, 0.88, 0.69, 0.52, 0.45), + (75, 0.50, 0.38, 0.67, 0.51, 0.43, 0.35), + (100, 0.40, 0.30, 0.54, 0.41, 0.36, 0.29), + (125, 0.35, 0.25, 0.45, 0.34, 0.31, 0.24), + (150, 0.30, 0.21, 0.39, 0.29, 0.27, 0.21), + (175, 0.25, 0.17, 0.32, 0.23, 0.24, 0.19), + (200, 0.21, 0.15, 0.29, 0.20, 0.22, 0.17), + (225, 0.19, 0.13, 0.25, 0.18, 0.20, 0.15), + (250, 0.17, 0.11, 0.23, 0.15, 0.18, 0.14), + (270, 0.16, 0.10, 0.21, 0.14, 0.17, 0.13), + (300, 0.14, 0.09, 0.19, 0.13, 0.16, 0.12), + (350, 0.12, 0.08, 0.16, 0.11, 0.14, 0.11), + (400, 0.11, 0.07, 0.14, 0.09, 0.12, 0.10), +) + +# Aliases mapping (insulation_type, column) → tuple index above. The PDF +# splits each Table 17 column into "(a) mineral wool or EPS slab" vs "(b) +# PUR or PIR optional". Aliases collapse common synonyms. +_RR_RIGID_FOAM_INSULATION_TYPES: Final[frozenset[str]] = frozenset({"pur", "pir", "rigid"}) + + +def _is_rigid_foam(insulation_type: Optional[str]) -> bool: + """True if the insulation type names rigid foam (PUR/PIR). Falls back + to False (i.e. mineral wool / EPS slab column) on None or any other + string — same convention as `u_roof`'s mineral-wool default.""" + if insulation_type is None: + return False + return insulation_type.strip().lower() in _RR_RIGID_FOAM_INSULATION_TYPES + + +def _u_rr_table_17( + country: Optional[Country], + age_band: Optional[str], + insulation_thickness_mm: Optional[int], + insulation_type: Optional[str], + col_a_index: int, + col_b_index: int, +) -> float: + """Generic Table 17 row picker. Returns the U-value at the nearest + tabulated thickness ≤ supplied. Falls back to `u_rr_default_all_ + elements` (Table 18 col 4) when thickness is None — matches the + spec text at §5.11.3 / §5.11.4.""" + if insulation_thickness_mm is None: + return u_rr_default_all_elements(country=country, age_band=age_band) + col = col_b_index if _is_rigid_foam(insulation_type) else col_a_index + u = _RR_TABLE_17_ROWS[0][col] + for row in _RR_TABLE_17_ROWS: + if insulation_thickness_mm >= row[0]: + u = row[col] + return u + + +def u_rr_slope( + *, + country: Optional[Country], + age_band: Optional[str], + insulation_thickness_mm: Optional[int], + insulation_type: Optional[str] = None, +) -> float: + """RdSAP10 §5.11.3 + Table 17 column (1): U-value for an insulated + sloping ceiling section of a room-in-roof. Column (1a) is mineral + wool / EPS slab (default), (1b) is PUR/PIR rigid foam. + + Falls back to `u_rr_default_all_elements` (Table 18 col 4) when + thickness is unknown. + """ + return _u_rr_table_17( + country=country, + age_band=age_band, + insulation_thickness_mm=insulation_thickness_mm, + insulation_type=insulation_type, + col_a_index=1, + col_b_index=2, + ) + + +def u_rr_flat_ceiling( + *, + country: Optional[Country], + age_band: Optional[str], + insulation_thickness_mm: Optional[int], + insulation_type: Optional[str] = None, +) -> float: + """RdSAP10 §5.11.3 + Table 17 column (2): U-value for the flat ceiling + section of a room-in-roof (the "External roof" element in the U985 + worksheet vocabulary).""" + return _u_rr_table_17( + country=country, + age_band=age_band, + insulation_thickness_mm=insulation_thickness_mm, + insulation_type=insulation_type, + col_a_index=3, + col_b_index=4, + ) + + +def u_rr_stud_wall( + *, + country: Optional[Country], + age_band: Optional[str], + insulation_thickness_mm: Optional[int], + insulation_type: Optional[str] = None, +) -> float: + """RdSAP10 §5.11.3 + Table 17 column (3): U-value for a stud wall + inside a room-in-roof (typically the short vertical wall between + the RR floor and the slope).""" + return _u_rr_table_17( + country=country, + age_band=age_band, + insulation_thickness_mm=insulation_thickness_mm, + insulation_type=insulation_type, + col_a_index=5, + col_b_index=6, + ) + + def u_rr_default_all_elements( country: Optional[Country], age_band: Optional[str], 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 4cf8704e..af1b034c 100644 --- a/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py @@ -33,6 +33,9 @@ from domain.ml.rdsap_uvalues import ( u_party_wall, u_roof, u_rr_default_all_elements, + u_rr_flat_ceiling, + u_rr_slope, + u_rr_stud_wall, u_wall, u_window, ) @@ -1031,3 +1034,135 @@ def test_u_rr_default_all_elements_unknown_age_band_falls_back_to_mid_range() -> # Assert assert result == pytest.approx(0.50, abs=0.001) + + +# ----- Room-in-roof Table 17 lookups (insulation thickness known) ----- + + +def test_u_rr_slope_table17_col1a_mineral_wool_100mm_returns_0_40() -> None: + """RdSAP10 §5.11.3 + Table 17 column (1a): "Insulated slope - sloping + ceiling, mineral wool or EPS slab" 100 mm row → 0.40 W/m²K.""" + # Arrange / Act + result = u_rr_slope( + country=Country.ENG, + age_band="B", + insulation_thickness_mm=100, + insulation_type="mineral_wool", + ) + + # Assert + assert result == pytest.approx(0.40, abs=0.001) + + +def test_u_rr_slope_table17_col1b_pur_pir_100mm_returns_0_30() -> None: + """Table 17 column (1b): "Insulated slope - sloping ceiling, PUR or + PIR optional" 100 mm row → 0.30 W/m²K. The PUR/PIR rigid foam route + gives a tighter U than mineral wool at the same thickness.""" + # Arrange / Act + result = u_rr_slope( + country=Country.ENG, + age_band="B", + insulation_thickness_mm=100, + insulation_type="pir", + ) + + # Assert + assert result == pytest.approx(0.30, abs=0.001) + + +def test_u_rr_flat_ceiling_table17_col2a_mineral_wool_100mm_returns_0_54() -> None: + """Table 17 column (2a): "Insulated slope - flat ceiling, mineral wool + or EPS slab" 100 mm row → 0.54 W/m²K.""" + # Arrange / Act + result = u_rr_flat_ceiling( + country=Country.ENG, + age_band="B", + insulation_thickness_mm=100, + insulation_type="mineral_wool", + ) + + # Assert + assert result == pytest.approx(0.54, abs=0.001) + + +def test_u_rr_stud_wall_table17_col3a_mineral_wool_100mm_returns_0_36() -> None: + """Table 17 column (3a): "Stud wall u-value For Room in Roof, mineral + wool or EPS slab" 100 mm row → 0.36 W/m²K. (Used by the U985 worksheet + for 000477's RR stud walls.)""" + # Arrange / Act + result = u_rr_stud_wall( + country=Country.ENG, + age_band="B", + insulation_thickness_mm=100, + insulation_type="mineral_wool", + ) + + # Assert + assert result == pytest.approx(0.36, abs=0.001) + + +def test_u_rr_slope_table17_none_row_uninsulated_returns_2_30() -> None: + """Table 17 "none" row (every column collapses to 2.3 when no + insulation). Used by the U985 worksheet for 000477's RR slope panels + that lodge as uninsulated.""" + # Arrange / Act + result = u_rr_slope( + country=Country.ENG, + age_band="B", + insulation_thickness_mm=0, + insulation_type="mineral_wool", + ) + + # Assert + assert result == pytest.approx(2.30, abs=0.001) + + +def test_u_rr_flat_ceiling_table17_col2b_pir_over_400mm_returns_0_09() -> None: + """Table 17 row ">400 mm" column (2b) PUR/PIR → 0.09 W/m²K. The U985 + worksheet for 000477 lodges 0.14 for "External roof Main" which is + Table 17 col (2a) row >400 (mineral wool) — but this test uses the + PIR column for completeness.""" + # Arrange / Act + result = u_rr_flat_ceiling( + country=Country.ENG, + age_band="B", + insulation_thickness_mm=450, + insulation_type="pir", + ) + + # Assert + assert result == pytest.approx(0.09, abs=0.001) + + +def test_u_rr_slope_unknown_thickness_falls_back_to_table18_all_elements() -> None: + """When `insulation_thickness_mm is None`, Table 17 doesn't apply and + we cascade to Table 18 col (4) "Room-in-roof, all elements" by age + band — same fallback as the spec text at §5.11.3 / §5.11.4. + + For age band B, that's the 2.30 W/m²K uninsulated default.""" + # Arrange / Act + result = u_rr_slope( + country=Country.ENG, + age_band="B", + insulation_thickness_mm=None, + insulation_type="mineral_wool", + ) + + # Assert + assert result == pytest.approx(2.30, abs=0.001) + + +def test_u_rr_stud_wall_thickness_125mm_takes_nearest_tabulated_row_below() -> None: + """Table 17 row alignment: an arbitrary thickness picks the nearest + tabulated row ≤ supplied (the same convention `u_roof` uses against + Table 16). 125 mm matches the exact row → col (3a) = 0.31 W/m²K.""" + # Arrange / Act + result = u_rr_stud_wall( + country=Country.ENG, + age_band="B", + insulation_thickness_mm=125, + insulation_type="mineral_wool", + ) + + # Assert + assert result == pytest.approx(0.31, abs=0.001)