From 9a509e410266bf463f7e9604d4e9824f24dce6e4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 18 May 2026 20:15:41 +0000 Subject: [PATCH] slice S-B23: RdSAP 10 Table 6 "Filled cavity" row dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cert encodes filled-cavity walls as (wall_construction=4 cavity, wall_insulation_type=2 filled, wall_insulation_thickness="NI"). The previous cascade parsed "NI"→0 and ran the thickness-bucketed table, returning U=1.5 (the "Cavity as built" row) — treating retrofit-filled cavities as if they were uninsulated. Spec (RdSAP 10 Table 6, page 33) has a dedicated "Filled cavity" row at U=0.7 for bands A-E, 0.40 at F, 0.35 at G-H, and "as built" from band I onward. Adds: - WALL_INSULATION_FILLED_CAVITY constant (code 2 per RdSAP schema, confirmed empirically on 8 000 corpus certs against walls.description) - _CAVITY_FILLED_ENG row in domain.ml.rdsap_uvalues - dispatcher in u_wall when (construction=cavity, insulation_type=2) - wall_insulation_type plumbing through heat_transmission_from_cert Parity probe (300 certs, seed=7) before → after: - PE MAE 57.28 → 48.99 (-8.3) - PE bias 51.56 → 42.07 (-9.5) - Band C bias +65.3 → +47.8 (-17.5) - Band D bias +67.9 → +45.7 (-22.2) - Band E bias +77.0 → +58.8 (-18.2) - Band F bias +43.8 → +25.4 (-18.4) - Band K-L bias unchanged (filled-cavity row falls back to as-built from band I onward per spec footnote; correct no-op) Future slices already lit up by the same enumeration: - type=1 external / type=3 internal insulation rows (~440 certs) - type=6 filled + external / type=7 filled + internal (~22 certs) - type=None "Average thermal transmittance X W/m²K" string parse (1 358 certs — biggest follow-up) Co-Authored-By: Claude Opus 4.7 --- .../domain/src/domain/ml/rdsap_uvalues.py | 41 +++++++++++ .../src/domain/ml/tests/test_rdsap_uvalues.py | 73 +++++++++++++++++++ .../domain/sap/worksheet/heat_transmission.py | 1 + .../worksheet/tests/test_heat_transmission.py | 34 +++++++++ 4 files changed, 149 insertions(+) diff --git a/packages/domain/src/domain/ml/rdsap_uvalues.py b/packages/domain/src/domain/ml/rdsap_uvalues.py index f9e8864e..9ebdec9f 100644 --- a/packages/domain/src/domain/ml/rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/rdsap_uvalues.py @@ -66,6 +66,20 @@ WALL_CURTAIN: Final[int] = 9 WALL_UNKNOWN: Final[int] = 10 +# RdSAP schema `wall_insulation_type` codes (empirically confirmed across +# 8 000 corpus certs against walls[0].description): +# 1 = external wall insulation +# 2 = filled cavity ("Cavity wall, filled cavity") +# 3 = internal wall insulation +# 4 = as-built / assumed (default cascade) +# 5 = none specified (rare) +# 6 = filled cavity + external insulation +# 7 = filled cavity + internal insulation +# Only the filled-cavity dispatch is wired here; the other codes will be +# handled in subsequent slices. +WALL_INSULATION_FILLED_CAVITY: Final[int] = 2 + + _AGE_BANDS: Final[tuple[str, ...]] = tuple("ABCDEFGHIJKLM") @@ -154,6 +168,20 @@ _ENG_WALL: Final[dict[tuple[int, int], list[float]]] = { (WALL_SYSTEM_BUILT, 200): [0.18, 0.18, 0.18, 0.18, 0.18, 0.17, 0.15, 0.15, 0.14, 0.13, 0.12, 0.12, 0.11], } +# RdSAP 10 Table 6 (England) row "Filled cavity" — 13 values A..M. The +# cert records this case as (wall_construction=4 cavity, +# wall_insulation_type=2 filled, wall_insulation_thickness="NI"). It's a +# distinct row from "Cavity as built" + bucketed retrofit insulation +# because filled-cavity isn't an added-insulation thickness; it's the +# original cavity-fill state. Bands I-M carry the "†" footnote in the +# spec ("assumed as built") — post-1996 cavities are filled as-built per +# Building Regs, so the row collapses to the Cavity-as-built values from +# band I onward. +_CAVITY_FILLED_ENG: Final[list[float]] = [ + 0.7, 0.7, 0.7, 0.7, 0.7, 0.40, 0.35, 0.35, 0.45, 0.35, 0.30, 0.28, 0.26, +] + + # Country-specific K-M overrides (Tables 7-9). Tables share most A-J values # with England; the divergence sits at the newer age bands. IsleOfMan = ENG. # Format: country -> (wall_type, ins_bucket) -> {age_band: u_value} for the @@ -235,6 +263,7 @@ def u_wall( *, insulation_present: bool = False, description: Optional[str] = None, + wall_insulation_type: Optional[int] = None, ) -> float: """RdSAP10 wall U-value in W/m^2K, never null. @@ -243,6 +272,13 @@ def u_wall( parsed for material keywords ("sandstone", "granite", "solid brick", ...) so the cascade picks the right table instead of falling through to the cavity-by-age default. Explicit construction codes always win. + + `wall_insulation_type` is the RdSAP-coded insulation kind on the cert's + `sap_building_parts[i].wall_insulation_type` field. When it indicates + a filled cavity (code 2) on a cavity-wall construction, the spec's + dedicated "Filled cavity" row is used in preference to the + thickness-bucketed cascade — the two encode different things (filled- + cavity is a construction state, not an added-insulation thickness). """ if country is None and age_band is None and construction is None and insulation_thickness_mm is None and not insulation_present: return 1.5 @@ -257,6 +293,11 @@ def u_wall( wall_type = construction else: wall_type = _wall_type_from_description(description) or _DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY) + if ( + wall_type == WALL_CAVITY + and wall_insulation_type == WALL_INSULATION_FILLED_CAVITY + ): + return _CAVITY_FILLED_ENG[age_idx] bucket = _insulation_bucket(insulation_thickness_mm, insulation_present) # Country override first. 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 418c83c4..96fcf460 100644 --- a/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py @@ -21,6 +21,7 @@ import pytest from domain.ml.rdsap_uvalues import ( Country, WALL_CAVITY, + WALL_INSULATION_FILLED_CAVITY, WALL_SOLID_BRICK, WALL_STONE_GRANITE, WALL_SYSTEM_BUILT, @@ -38,6 +39,78 @@ from domain.ml.rdsap_uvalues import ( # ----- Walls ----- +def test_u_wall_filled_cavity_england_age_band_e_returns_table6_value() -> None: + # Arrange — RdSAP 10 Table 6 (England) row "Filled cavity", age band E + # (1967-1975) -> 0.7 W/m^2K. The cert records this as the triple + # (wall_construction=4 cavity, wall_insulation_type=2 filled, + # wall_insulation_thickness="NI"). Spec: docs/sap-spec/rdsap-10- + # specification-2025-06-10.pdf page 33. + + # Act + result = u_wall( + country=Country.ENG, + age_band="E", + construction=WALL_CAVITY, + insulation_thickness_mm=0, + insulation_present=True, + wall_insulation_type=WALL_INSULATION_FILLED_CAVITY, + ) + + # Assert + assert result == pytest.approx(0.7, abs=0.001) + + +@pytest.mark.parametrize( + "age_band,expected_u", + [ + # RdSAP 10 Table 6 (England) "Filled cavity" row sampled at three bands: + # A (pre-1900) = 0.7 — early cavity dwellings, retro-filled. + # F (1976-1982) = 0.40 — first cavity-insulation era. + # K (2007+) = 0.30 — "assumed as built" (†) — matches Cavity-as-built K. + ("A", 0.7), + ("F", 0.40), + ("K", 0.30), + ], +) +def test_u_wall_filled_cavity_england_row_matches_table6_across_age_bands( + age_band: str, expected_u: float +) -> None: + # Arrange — the dispatcher must return the right cell of the + # "Filled cavity" row, not just the band-E value used by the tracer. + + # Act + result = u_wall( + country=Country.ENG, + age_band=age_band, + construction=WALL_CAVITY, + insulation_thickness_mm=0, + insulation_present=True, + wall_insulation_type=WALL_INSULATION_FILLED_CAVITY, + ) + + # Assert + assert result == pytest.approx(expected_u, abs=0.001) + + +def test_u_wall_unfilled_cavity_england_age_band_e_unchanged_at_1_5() -> None: + # Arrange — adding the filled-cavity dispatcher must not regress the + # existing as-built path. Band E + cavity construction + no insulation + # type set -> the "Cavity as built" row of Table 6, U = 1.5 W/m^2K. + + # Act + result = u_wall( + country=Country.ENG, + age_band="E", + construction=WALL_CAVITY, + insulation_thickness_mm=0, + insulation_present=False, + wall_insulation_type=None, + ) + + # Assert + assert result == pytest.approx(1.5, abs=0.001) + + def test_u_wall_cavity_as_built_england_age_band_g_returns_table6_value() -> None: # Arrange — Table 6, England, Cavity as built, age band G -> 0.60 W/m^2K. diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py index 21302886..d4d49f40 100644 --- a/packages/domain/src/domain/sap/worksheet/heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -216,6 +216,7 @@ def heat_transmission_from_cert( insulation_thickness_mm=wall_ins_thickness, insulation_present=wall_ins_present, description=wall_description, + wall_insulation_type=wall_ins_type, ) ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=roof_description) uf = u_floor( diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py b/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py index ae7d6b2d..2222b1ba 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py @@ -29,6 +29,40 @@ from domain.sap.worksheet.heat_transmission import ( ) +def test_band_e_filled_cavity_uses_table6_filled_row_in_walls_w_per_k() -> None: + # Arrange — RdSAP 10 Table 6 (England) "Filled cavity" row at band E + # (1967-1975) = 0.7 W/m^2K. Cert encodes this as + # (wall_construction=4 cavity, wall_insulation_type=2 filled). + # 100 m² ground floor, 40 m perimeter, 2.5 m height, single storey → + # gross_wall = 100 m². With no windows/doors, net_wall = 100 m². + # walls_w_per_k expected = 0.7 × 100 = 70 W/K. + main = make_building_part( + identifier="Main Dwelling", + construction_age_band="E", + wall_construction=4, # cavity + wall_insulation_type=2, # filled cavity + party_wall_construction=1, + roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=100.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0, + ), + ], + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=100.0, + country_code="ENG", + sap_building_parts=[main], + ) + + # Act + result = heat_transmission_from_cert(epc) + + # Assert + assert result.walls_w_per_k == pytest.approx(70.0, abs=1.0) + + def test_single_storey_age_g_cavity_returns_per_element_breakdown() -> None: # Arrange — Mid-terrace, age G cavity-as-built, 100 m² floor area, 40 m # heat-loss perimeter, 5 m party wall, 2.5 m room height, single storey.