fix(heat-transmission): apply dry-lining Table 14 R=0.17 to the main wall

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-11 09:43:50 +00:00
parent b7d283cd3a
commit 781efd75c0
2 changed files with 50 additions and 0 deletions

View file

@ -812,6 +812,12 @@ def heat_transmission_from_cert(
# code feeds the documentary-evidence R-value calc when a # code feeds the documentary-evidence R-value calc when a
# measured wall thickness is also present (else ignored). # measured wall thickness is also present (else ignored).
wall_insulation_thermal_conductivity=part.wall_insulation_thermal_conductivity, 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 # When the per-bp `roof_insulation_thickness` is explicitly lodged
# as 0 (uninsulated — e.g. cert 001479 Ext2 PS sloping ceiling # as 0 (uninsulated — e.g. cert 001479 Ext2 PS sloping ceiling

View file

@ -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 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: 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 """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 effective form `U_eff = 1/(1/U_raw + 0.04)` the 0.04 m²K/W is the