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.