diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index b2e76b8f..33b9741c 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -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: diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 8d4e3612..38efc93c 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -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 diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 1cddd87f..8944b576 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -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]]: