From 781efd75c0196239037b8900a9fa46d341dfffe6 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 11 Jun 2026 09:43:50 +0000 Subject: [PATCH] fix(heat-transmission): apply dry-lining Table 14 R=0.17 to the main wall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main-wall `u_wall(...)` call dropped the `dry_lined` kwarg, so the RdSAP 10 §5.7/§5.8 (PDF p.40-41) Table 14 dry-lining adjustment — U_adj = 1/(1/U₀ + 0.17) for a dry-lined (incl. lath-and-plaster) uninsulated wall — was never applied to any main wall, even when the cert lodged `wall_dry_lined=Y`. The ALTERNATIVE-wall path already passes `dry_lined` (line 1367); this one-sided omission billed every dry-lined main wall at the un-adjusted (too-high) U → wall heat loss too high → SAP under-rated. Per-cert: a solid-brick (construction 3) band-A 230 mm main wall computes U₀=1.70; dry-lined it is 1/(1/1.70+0.17)=1.32 — we were 22% too high. Across the API gov-EPC sample the dry-lined `wall_construction=3` (solid brick) sub-cohort sat at 10% within-0.5 / signed -1.33. Fix: pass `dry_lined=bool(part.wall_dry_lined)` to the main-wall `u_wall` call, mirroring the alt-wall path. `part.wall_dry_lined` is already plumbed (Optional[bool], None → False). The three dry-lining branches in `u_wall` (stone §5.6, solid-brick-by-thickness §5.7, generic uninsulated bucket §5.8) are all spec-correct and already worksheet-validated (the bucket-0 cavity case against cert 7700 age-C → 1.20). Worksheet harness UNAFFECTED (47/47, 0 divergers): the Elmhurst/Summary extractor only captures dry-lining for ALTERNATIVE walls (Summary §7), never the main wall, so `part.wall_dry_lined` stays None on that path — this is a pure API-path improvement. API gauge: within-0.5 60.1% -> 64.4% (mean|err| 1.163 -> 1.085, signed -0.097 -> +0.049). Both affected buckets improved with no overshoot: solid brick (wc=3) 50% -> 57% within-0.5; cavity (wc=4, dry-lined via the §5.8 bucket-0 path) 68% -> 72%. Co-Authored-By: Claude Opus 4.8 --- .../worksheet/heat_transmission.py | 6 +++ .../worksheet/test_heat_transmission.py | 44 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index ceb086ce..5af20455 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -812,6 +812,12 @@ def heat_transmission_from_cert( # code feeds the documentary-evidence R-value calc when a # measured wall thickness is also present (else ignored). wall_insulation_thermal_conductivity=part.wall_insulation_thermal_conductivity, + # RdSAP 10 §5.7/§5.8 (PDF p.40-41), Table 14 — a dry-lined + # (incl. lath-and-plaster) uninsulated wall adds R=0.17. + # The alt-wall path already passes this; the main wall must + # too, else every lodged `wall_dry_lined=Y` main wall is + # billed at the un-adjusted U. + dry_lined=bool(part.wall_dry_lined), ) # When the per-bp `roof_insulation_thickness` is explicitly lodged # as 0 (uninsulated — e.g. cert 001479 Ext2 PS sloping ceiling diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index c1632a39..b6a21d0a 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -1275,6 +1275,50 @@ def test_corridor_door_on_sheltered_alt_wall_uses_table26_u_1p4() -> None: assert with_corridor.fabric_heat_loss_w_per_k < no_corridor.fabric_heat_loss_w_per_k +def test_main_wall_dry_lining_applies_table_14_resistance() -> None: + # Arrange — RdSAP 10 §5.7/§5.8 (PDF p.40-41), Table 14: a dry-lined + # (including lath-and-plaster) uninsulated wall adds R=0.17 m²K/W: + # U_adj = 1/(1/U₀ + 0.17). A solid-brick (construction 3) age-A wall + # with a measured 230 mm thickness has U₀=1.70 (Table 13, 200-280 mm + # band) → dry-lined U=1/(1/1.70+0.17)=1.32 (2 d.p.). The alt-wall path + # already applies this; the MAIN wall dropped the `dry_lined` kwarg, so + # every lodged `wall_dry_lined=Y` main wall was billed at the un-adjusted + # U — under-rating solid-brick stock (API wall_construction=3 cohort: + # 48 dry-lined certs at 10% within-0.5, signed -1.33). + from dataclasses import replace + + base_part = make_building_part( + construction_age_band="A", + wall_construction=3, # solid brick + wall_insulation_type=0, # uninsulated (as-built) + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=50.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=28.0, floor=0, + ), + ], + ) + not_dry = replace(base_part, wall_thickness_mm=230, wall_dry_lined=False) + dry = replace(base_part, wall_thickness_mm=230, wall_dry_lined=True) + epc_not_dry = make_minimal_sap10_epc( + total_floor_area_m2=50.0, country_code="ENG", sap_building_parts=[not_dry], + ) + epc_dry = make_minimal_sap10_epc( + total_floor_area_m2=50.0, country_code="ENG", sap_building_parts=[dry], + ) + + # Act + ht_not_dry = heat_transmission_from_cert(epc_not_dry, door_count=0) + ht_dry = heat_transmission_from_cert(epc_dry, door_count=0) + + # Assert — same net wall area, so the W/K ratio is the U ratio: the + # dry-lined wall is 1.32/1.70 = 0.776× the as-built wall. + assert ht_not_dry.walls_w_per_k > 0.0 + expected_ratio = 1.32 / 1.70 + assert abs(ht_dry.walls_w_per_k / ht_not_dry.walls_w_per_k - expected_ratio) <= 0.005 + assert ht_dry.fabric_heat_loss_w_per_k < ht_not_dry.fabric_heat_loss_w_per_k + + def test_window_uses_effective_u_value_with_curtain_resistance_per_sap10_2_section_3_2() -> None: """SAP10.2 §3.2: the window U-value used for heat-transmission is the effective form `U_eff = 1/(1/U_raw + 0.04)` — the 0.04 m²K/W is the