mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
slice S-B23: RdSAP 10 Table 6 "Filled cavity" row dispatch
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 <noreply@anthropic.com>
This commit is contained in:
parent
1c0cb9ac07
commit
9a509e4102
4 changed files with 149 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue