From 82627ebbfaf9d029749f2ba956d069cdee0818cd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 22 May 2026 19:16:15 +0000 Subject: [PATCH] =?UTF-8?q?Cohort=20residual=20slice=209:=20u=5Frr=5Fdefau?= =?UTF-8?q?lt=5Fall=5Felements=20=E2=80=94=20RdSAP10=20Table=2018=20col=20?= =?UTF-8?q?(4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the "Room-in-roof, all elements" U-value lookup keyed by age band, with Scotland override for age K per Table 18 footnote (2). This is the fallback U-value for the §3.9 Simplified RR cascade when no detailed per-surface lodgement is available (the "as built / unknown" path per footnote (1)). Tests cover the spec table verbatim: - A-D 2.30, E 1.50, F 0.80, G 0.50, H 0.35, I 0.35, J 0.30, - K 0.25 (England) / 0.20 (Scotland), L 0.18, M 0.15. Mid-range fallback 0.50 (matching age G) when neither age band nor country lodged — robustness contract identical to u_roof. Reference: RdSAP 10 (10-06-2025) Table 18 page 45. Co-Authored-By: Claude Opus 4.7 --- .../domain/src/domain/ml/rdsap_uvalues.py | 37 +++++++++++++ .../src/domain/ml/tests/test_rdsap_uvalues.py | 54 +++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/packages/domain/src/domain/ml/rdsap_uvalues.py b/packages/domain/src/domain/ml/rdsap_uvalues.py index 1dd5659a..9b49cea4 100644 --- a/packages/domain/src/domain/ml/rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/rdsap_uvalues.py @@ -390,6 +390,19 @@ _ROOF_BY_AGE: Final[dict[str, float]] = { "K": 0.16, "L": 0.16, "M": 0.15, } +# Table 18 column (4): "Room-in-roof, all elements" default U by age band +# when no detailed RR lodgement is available. Footnote (1) on each entry +# confirms "value from the table applies for unknown and as built". +# Scotland override per footnote (2): age band K → 0.20 W/m²K (other bands +# unchanged). +_RR_ALL_ELEMENTS_BY_AGE: Final[dict[str, float]] = { + "A": 2.30, "B": 2.30, "C": 2.30, "D": 2.30, + "E": 1.50, "F": 0.80, "G": 0.50, "H": 0.35, + "I": 0.35, "J": 0.30, "K": 0.25, "L": 0.18, "M": 0.15, +} +_RR_ALL_ELEMENTS_SCOTLAND_OVERRIDES: Final[dict[str, float]] = {"K": 0.20} +_RR_ALL_ELEMENTS_MID_RANGE_FALLBACK: Final[float] = 0.50 + _ROOF_NO_INSULATION_MARKERS: Final[tuple[str, ...]] = ( "no insulation", @@ -449,6 +462,30 @@ def u_roof( return _ROOF_BY_AGE.get(age_band.upper(), 0.4) +def u_rr_default_all_elements( + country: Optional[Country], + age_band: Optional[str], +) -> float: + """RdSAP10 Table 18 column (4) — "Room-in-roof, all elements" default + U-value when no detailed RR lodgement is available. + + Used as the catch-all fallback for the §3.9 Simplified RR cascade + when assessor didn't measure individual surfaces. The spec footnote + (1) on the table confirms: "value from the table applies for unknown + and as built". Footnote (2) overrides age K to 0.20 W/m²K in Scotland. + + The mid-range fallback (0.50, matching age G) fires when the cert + lodges neither age band nor country — same robustness contract as + `u_roof` and the other cascade helpers (never raise on missing input). + """ + if age_band is None: + return _RR_ALL_ELEMENTS_MID_RANGE_FALLBACK + band = age_band.upper() + if country is Country.SCT and band in _RR_ALL_ELEMENTS_SCOTLAND_OVERRIDES: + return _RR_ALL_ELEMENTS_SCOTLAND_OVERRIDES[band] + return _RR_ALL_ELEMENTS_BY_AGE.get(band, _RR_ALL_ELEMENTS_MID_RANGE_FALLBACK) + + # --------------------------------------------------------------------------- # Floor U-value (BS EN ISO 13370 + Table 19 defaults) # --------------------------------------------------------------------------- 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 d4af358c..4cf8704e 100644 --- a/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py @@ -32,6 +32,7 @@ from domain.ml.rdsap_uvalues import ( u_floor, u_party_wall, u_roof, + u_rr_default_all_elements, u_wall, u_window, ) @@ -977,3 +978,56 @@ def test_country_from_code_recognises_known_codes() -> None: assert Country.from_code("SCT") is Country.SCT assert Country.from_code("NIR") is Country.NIR assert Country.from_code("EAW") is Country.ENG # England-and-Wales aggregate maps to ENG + + +def test_u_rr_default_all_elements_age_band_b_returns_table18_col4_value() -> None: + """RdSAP10 §5.11.4 + Table 18 column (4) — "Room-in-roof, all elements" + as-built / unknown default. Age band B (1900-1929) → 2.30 W/m²K (the + uninsulated row carries footnote (1): "value from the table applies + for unknown and as built").""" + # Arrange / Act + result = u_rr_default_all_elements(country=Country.ENG, age_band="B") + + # Assert + assert result == pytest.approx(2.30, abs=0.001) + + +def test_u_rr_default_all_elements_table18_col4_matches_spec_across_age_bands() -> None: + """Table 18 column (4) per RdSAP10 spec page 45: + A-D 2.30, E 1.50, F 0.80, G 0.50, H 0.35, I 0.35, J 0.30, + K 0.25, L 0.18, M 0.15. + """ + # Arrange — expected RR-all-elements U-values for England. + expected = { + "A": 2.30, "B": 2.30, "C": 2.30, "D": 2.30, + "E": 1.50, "F": 0.80, "G": 0.50, "H": 0.35, + "I": 0.35, "J": 0.30, "K": 0.25, "L": 0.18, "M": 0.15, + } + + # Act / Assert + for age_band, want in expected.items(): + got = u_rr_default_all_elements(country=Country.ENG, age_band=age_band) + assert got == pytest.approx(want, abs=0.001), ( + f"age={age_band}: got {got}, want {want}" + ) + + +def test_u_rr_default_all_elements_scotland_age_band_k_returns_0_20_per_footnote() -> None: + """Table 18 footnote (2): "0.20 W/m²K in Scotland" applies to the + age band K row of column (4). Other age bands unchanged.""" + # Arrange / Act + result = u_rr_default_all_elements(country=Country.SCT, age_band="K") + + # Assert + assert result == pytest.approx(0.20, abs=0.001) + + +def test_u_rr_default_all_elements_unknown_age_band_falls_back_to_mid_range() -> None: + """Robustness: no age band → return the mid-range default rather than + raising. Picks the column (4) value at age G (0.50) as a sensible + middle estimate, matching the cascade convention used by `u_roof`.""" + # Arrange / Act + result = u_rr_default_all_elements(country=None, age_band=None) + + # Assert + assert result == pytest.approx(0.50, abs=0.001)