Cohort residual slice 10: u_rr_slope / u_rr_flat_ceiling / u_rr_stud_wall — RdSAP10 Table 17

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-22 19:19:01 +00:00
parent 82627ebbfa
commit 0ff814451f
2 changed files with 260 additions and 0 deletions

View file

@ -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],

View file

@ -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)