fix(uvalues): apply §5.8 insulation R to stone walls (RdSAP 10 p.41-42)

The §5.8 Table-14 added-insulation R-value adjustment was gated to
WALL_SOLID_BRICK, so a stone (granite/sandstone) wall lodging
wall_insulation_type 1/3 ("External"/"Internal") + a thickness fell
through the §5.6 thin-wall branch and was billed at its UNINSULATED U
(e.g. sandstone 520 mm + 100 mm internal: 1.64 instead of 0.30 → ~5×
the wall heat loss). Mirror the brick insulation branch into the stone
block, feeding the RAW §5.6 U₀ into the §5.8 chain per the same rule the
brick branch and the dry-lined granite pin 000565 already follow (the
Table-6 footnote (a) 1.7 cap does not apply on the insulated path).

Corpus cert 100052159386 (sandstone 520 mm + 100 mm internal): -26.20 ->
-4.08 SAP, walls 300 -> 55 W/K. RdSAP-21.0.1 corpus within-0.5 68.6% ->
68.8% (SAP MAE 0.942 -> 0.888; PE MAE 14.3 -> 13.9; CO2 0.27 -> 0.26);
floors/ceilings ratcheted. Unit-pinned in test_rdsap_uvalues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-17 00:26:25 +00:00
parent 0d24f5b13a
commit c5aa5620ca
3 changed files with 116 additions and 4 deletions

View file

@ -589,6 +589,35 @@ def u_wall(
):
u0 = _u_stone_thin_wall_age_a_to_e(construction, wall_thickness_mm)
if u0 is not None:
# RdSAP 10 §5.8 + Table 14 (PDF p.41-42) — added External/Internal
# insulation on a stone wall: U = 1/(1/U₀ + R_ins), with U₀ the
# RAW §5.6 stone result (the Table-6 footnote (a) 1.7 cap does NOT
# apply to the insulated path — same rule the brick branch below
# and the dry-lined granite pin 000565 follow). λ defaults to
# 0.04 W/m·K per §5.8 final note. Mirrors the WALL_SOLID_BRICK
# insulated branch; without it a stone wall lodging code 1/3 +
# a thickness was billed at its UNINSULATED U (e.g. sandstone
# 520 mm + 100 mm internal: 1.64 → 0.30), the dominant cause of
# the wall_insulation_type=3 corpus under-rate cluster.
if (
wall_insulation_type in (
_WALL_INSULATION_EXTERNAL, _WALL_INSULATION_INTERNAL,
)
and insulation_thickness_mm is not None
and insulation_thickness_mm > 0
):
r_ins = _r_insulation_table_14(
insulation_thickness_mm,
_resolve_wall_insulation_lambda_w_per_mk(
wall_insulation_thermal_conductivity
),
)
u_unrounded = 1.0 / (1.0 / u0 + r_ins)
return float(
Decimal(str(u_unrounded)).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
)
if dry_lined:
# Round to 2 d.p. — worksheet (29a) A×U product uses
# the 2-d.p.-displayed U (cf. 000565 Main alt_wall_1:

View file

@ -725,6 +725,80 @@ def test_u_wall_stone_sandstone_thin_wall_age_a_120mm_uses_5_6_sandstone_formula
assert abs(result - 3.7408) <= 1e-3
def test_u_wall_stone_sandstone_with_internal_insulation_applies_5_8_table_14_r_value() -> None:
# Arrange — RdSAP 10 §5.8 + Table 14 (PDF p.41-42): a stone wall lodging
# External/Internal insulation (wall_insulation_type 1/3) + a thickness
# gets the same R-value adjustment as solid brick, applied to the RAW §5.6
# U₀. Mirrors corpus cert 100052159386 (Sandstone, 520 mm, 100 mm internal):
# U₀ = 54.876 × 520^(-0.561) = 1.6433
# R = 0.025 × 100 + 0.25 = 2.75 (Table 14, λ = 0.04)
# U = 1 / (1/1.6433 + 2.75) = 0.2977 → 0.30 (2 d.p.)
# Before this branch the wall was billed at its UNINSULATED U (≈1.64),
# the dominant cause of the wall_insulation_type=3 corpus under-rate cluster.
# Act
result = u_wall(
country=Country.ENG,
age_band="A",
construction=WALL_STONE_SANDSTONE,
insulation_thickness_mm=100,
insulation_present=True,
wall_insulation_type=3,
dry_lined=False,
wall_thickness_mm=520,
)
# Assert
assert abs(result - 0.30) <= 1e-4
def test_u_wall_stone_sandstone_insulated_feeds_raw_u0_not_table_6_cap() -> None:
# Arrange — the Table-6 footnote (a) 1.7 cap applies ONLY to the as-built
# row; the insulated §5.8 path takes the RAW §5.6 U₀ (same rule the brick
# branch and the dry-lined granite pin 000565 follow). At W=120 mm the raw
# sandstone U₀ = 3.7408 (> 1.7), so the 100 mm internal result must be
# 1 / (1/3.7408 + 2.75) = 0.331 → 0.33 (raw),
# NOT the capped 1 / (1/1.7 + 2.75) = 0.30. The 0.33 vs 0.30 split proves
# the cap is bypassed on the insulated path.
# Act
result = u_wall(
country=Country.ENG,
age_band="A",
construction=WALL_STONE_SANDSTONE,
insulation_thickness_mm=100,
insulation_present=True,
wall_insulation_type=3,
dry_lined=False,
wall_thickness_mm=120,
)
# Assert
assert abs(result - 0.33) <= 1e-4
def test_u_wall_stone_granite_with_external_insulation_applies_5_8_table_14_r_value() -> None:
# Arrange — granite/whinstone §5.6 formula + §5.8 external insulation:
# U₀ = 45.315 × 120^(-0.513) = 3.8871
# R = 0.025 × 50 + 0.25 = 1.50 (Table 14, λ = 0.04)
# U = 1 / (1/3.8871 + 1.50) = 0.567 → 0.57 (2 d.p.)
# Act
result = u_wall(
country=Country.ENG,
age_band="A",
construction=WALL_STONE_GRANITE,
insulation_thickness_mm=50,
insulation_present=True,
wall_insulation_type=1,
dry_lined=False,
wall_thickness_mm=120,
)
# Assert
assert abs(result - 0.57) <= 1e-4
def test_u_wall_stone_granite_age_g_with_wall_thickness_ignores_5_6_formula_per_age_a_to_e_gate() -> None:
# Arrange — §5.6 (PDF p.40) heading explicitly scopes the formula
# to "age bands A to E". For age F onwards Table 6 gives literal

View file

@ -102,10 +102,19 @@ _CORPUS = Path(
# part → SapConservatory → §6.1 window/rooflight/floor cascade + TFA, MIRRORING
# the case-44 Summary path pinned to 1e-4) -> 68.6% (MAE 0.942). 5 type-4
# certs were over-rating (conservatory dropped → too little heat loss).
_MIN_WITHIN_HALF_SAP = 0.68
_MAX_SAP_MAE = 0.95
_MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current
# STONE WALL + INTERNAL/EXTERNAL INSULATION (RdSAP 10 §5.8 + Table 14, p.41-42):
# the §5.8 added-insulation R-value adjustment was applied ONLY to WALL_SOLID_
# BRICK; a stone (granite/sandstone) wall lodging wall_insulation_type 1/3 + a
# thickness fell through the §5.6 branch and was billed at its UNINSULATED U
# (e.g. sandstone 520 mm + 100 mm internal: 1.64 instead of 0.30 → 5× wall heat
# loss). Mirroring the brick branch into the stone block recovered the worst of
# the wall_insulation_type=3 under-rate cluster (cert 100052159386 -26.2 -> -4.1
# SAP, walls 300 -> 55 W/K). within-0.5 68.6% -> 68.8% (MAE 0.942 -> 0.888;
# PE MAE 14.3 -> 13.9; CO2 MAE 0.27 -> 0.26). Unit-pinned in test_rdsap_uvalues.
_MIN_WITHIN_HALF_SAP = 0.685
_MAX_SAP_MAE = 0.89
_MAX_CO2_MAE_TONNES = 0.30 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 14.5 # kWh / m2 / yr vs energy_consumption_current
def _load_corpus() -> list[dict[str, Any]]: