Slice S0380.26: RdSAP10 §5.8 dry-lining adjustment on alt walls — closes cert 7700 -0.44 → +5e-5

Per RdSAP10 §5.8 final note + Table 14 page 41:

  "For drylining including laths and plaster use Rinsulation = 0.17 m²K/W."

Applied additively to the base U-value of an otherwise-uninsulated wall:

  U_adjusted = 1 / (1/U_base + 0.17)  — rounded to 2 d.p. half-up.

Closed form for the cohort fixture (cavity-as-built age C, U_base=1.5):

  1 / (1/1.5 + 0.17) = 1.19522... → 1.20 ✓ matches worksheet

Cert 7700-3362-0922-7022-3563 (Summary_000905.pdf / dr87-0001-000905.pdf)
is an End-Terrace house age C lodging:
  - Main wall: CavityWallDensePlasterDenseBlock, Filled Cavity, U=0.70
  - Alt wall 1: 14.44 m² Cavity As-Built, Dry-lining: Yes (worksheet
    `CavityWallPlasterOnDabsDenseBlock`, U=1.20)

Pre-slice the Elmhurst alt-wall mapper hard-coded `wall_dry_lined="N"`
and the cascade ignored the field everywhere — alt-wall U routed to the
cavity-as-built default (1.50), giving fabric (33) 148.72 W/K vs
worksheet 144.38 (Δ +4.33 W/K = ~+0.44 SAP). Worksheet "SAP value" line
lodges unrounded SAP 63.4425.

Implementation:
  1. `AlternativeWall.dry_lined: bool = False` on the Elmhurst surveys
     dataclass.
  2. Elmhurst extractor reads "Alternative Wall N Dry-lining: Yes/No"
     into the new field.
  3. `_map_elmhurst_alternative_wall` propagates `wall_dry_lined="Y"`
     instead of the hard-coded "N".
  4. `u_wall` gains a `dry_lined: bool = False` kwarg and a single
     §5.8 adjustment site at the as-built bucket (bucket=0). Insulated
     buckets already absorb the dry-lining R via Table 14.
  5. `_alt_wall_w_per_k` passes `dry_lined=alt_wall.wall_dry_lined == "Y"`.

Scope is the alt-wall path only — main BPs in the corpus all lodge
`wall_dry_lined="N"` (or the Summary PDF omits the field for the main
wall), so the main-wall call site is untouched. Conservative regression
posture per the user's strict cohort-pin convention.

Cohort-2 outcome (38 certs, Summary path):
  exact (<1e-4): 22 → **23**  (+1: cert 7700 -0.44 → +4.87e-05)
  0.07..0.5:      1 → **0**   (-1: cert 7700 closes out)
  0.5..1:         1 → 1       (cert 9796 unchanged — MIT precision floor)
  RAISES:         0 → 0

Cohort-1 ASHP cohort untouched: all certs lodge wall_dry_lined="N", so
the alt-wall call site short-circuits to the original cascade. Verified
no regressions across the 22 previously-exact cohort-2 certs either.

Pyright net-zero on all 8 touched files (183 → 183).

Tests: 704 → 708 pass (+4 new: u_wall §5.8 adjustment fires
correctly; cavity-as-built unchanged without flag; insulated bucket
unaffected by flag; heat_transmission alt-wall delta = 14.44 × 0.30
W/K; cert 7700 full chain hits worksheet 63.4425 at < 1e-4),
10 expected fails unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 10:56:11 +00:00
parent 73fedc0ecd
commit c144d444e2
8 changed files with 209 additions and 8 deletions

View file

@ -294,6 +294,13 @@ class ElmhurstSiteNotesExtractor:
u_value_known=self._local_bool(
lines, f"Alternative Wall {n} U-value Known"
),
# RdSAP10 §5.8 + Table 14: dry-lined uninsulated wall adds
# R = 0.17 m²K/W to base U. Cohort fixture: cert 7700
# Alt 1 "CavityWallPlasterOnDabs" lodges Dry-lining: Yes →
# U = 1/(1/1.5 + 0.17) ≈ 1.20.
dry_lined=self._local_bool(
lines, f"Alternative Wall {n} Dry-lining"
),
))
return result

View file

@ -340,6 +340,38 @@ def test_summary_2102_secondary_heating_routes_house_coal_for_open_fire() -> Non
assert epc.sap_heating.secondary_fuel_type == 11
def test_summary_7700_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
# Arrange — cohort-2 cert 7700-3362-0922-7022-3563 (Summary_000905.pdf
# / dr87-0001-000905.pdf) is the first cohort fixture to exercise
# the alt-wall dry-lining adjustment. End-Terrace house age C, main
# wall filled cavity (CavityWallDensePlasterDenseBlock, U=0.70),
# alt wall 14.44 m² Cavity As-Built, Dry-lining: Yes
# (CavityWallPlasterOnDabsDenseBlock, worksheet U=1.20).
#
# Per RdSAP10 §5.8 + Table 14 page 41: dry-lining adds R = 0.17
# m²K/W → U = 1/(1/1.5 + 0.17) = 1.19522... → 2 d.p. half-up = 1.20.
# Pre-slice the alt sub-area's `wall_dry_lined="N"` hard-code routed
# to the cavity-as-built default (U=1.50), giving fabric (33)
# 148.72 W/K vs worksheet 144.38 (Δ +4.33 W/K = ~+0.44 SAP). Worksheet
# "SAP value" line lodges unrounded SAP **63.4425**.
cert_dir = Path(
"sap worksheets/additional with api 2/7700-3362-0922-7022-3563"
)
summary_pdf = next(cert_dir.glob("Summary_*.pdf"))
pages = _summary_pdf_to_textract_style_pages(summary_pdf)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Act
result = calculate_sap_from_inputs(
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
)
# Assert
worksheet_unrounded_sap = 63.4425
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4
def test_summary_9501_flat_has_no_built_form_in_summary_pdf() -> None:
# Arrange — cert 9501 (Summary_000784.pdf) is a flat. The Elmhurst
# Summary's §1.0 "Property type" section lodges the built-form

View file

@ -2875,7 +2875,7 @@ def _map_elmhurst_alternative_wall(
case, matching the full-cert-text "TimberWallOneLayer" lodgement)."""
return SapAlternativeWall(
wall_area=a.area_m2,
wall_dry_lined="N",
wall_dry_lined="Y" if a.dry_lined else "N",
wall_construction=_elmhurst_wall_construction_int(a.wall_type) or 0,
wall_insulation_type=_elmhurst_wall_insulation_int(a.insulation) or 4,
wall_thickness_measured="Y" if not a.thickness_unknown else "N",

View file

@ -57,7 +57,12 @@ class AlternativeWall:
gross wall that has a different construction (e.g. a small 1.43
timber-frame panel on an otherwise cavity-walled extension). Up to
two alternative walls per bp; Elmhurst lodges them in §7's "1st/2nd
Extension" subsection under the "Alternative Wall N <field>" prefix."""
Extension" subsection under the "Alternative Wall N <field>" prefix.
`dry_lined` carries Summary §7 "Alternative Wall N Dry-lining: Yes/No".
RdSAP10 §5.8 + Table 14: a dry-lined uninsulated wall adds R = 0.17
m²K/W to the base U-value (cavity-as-built age C: U = 1/(1/1.5 + 0.17)
1.20). Cohort fixture: cert 7700 alt-wall (CavityWallPlasterOnDabs)."""
area_m2: float
wall_type: str # e.g. "TI Timber Frame"
@ -65,6 +70,7 @@ class AlternativeWall:
thickness_unknown: bool
thickness_mm: Optional[int]
u_value_known: bool
dry_lined: bool = False
@dataclass

View file

@ -921,5 +921,9 @@ def _alt_wall_w_per_k(
insulation_present=alt_insulation_present,
description=wall_description,
wall_insulation_type=alt_wall.wall_insulation_type,
# RdSAP10 §5.8 + Table 14: dry-lined alt-wall adds R = 0.17 m²K/W
# when no insulation thickness is lodged. Cohort fixture: cert
# 7700 Alt 1 (Cavity, As-Built, Dry-lined) → 1.50 → 1.20.
dry_lined=alt_wall.wall_dry_lined == "Y",
)
return alt_u * net_alt_area

View file

@ -1119,6 +1119,65 @@ def test_basement_alt_wall_uses_table_23_u_value_not_cascade() -> None:
)
def test_dry_lined_alt_wall_cavity_as_built_age_c_applies_rdsap_5_8_r_0_17_adjustment() -> None:
"""RdSAP10 §5.8 + Table 14 page 41: dry-lined uninsulated alt-wall
adds R = 0.17 m²K/W. Cohort fixture: cert 7700-3362-0922-7022-3563
Alt 1 lodges 14.44 Cavity, As-Built, Dry-lining: Yes, age C
worksheet `CavityWallPlasterOnDabsDenseBlock` row (29a) U=1.20,
A×U = 14.44 × 1.20 = 17.3280 W/K. Without dry-lining the cascade
would route to the cavity-as-built default (U=1.50, A×U=21.66).
Difference: 4.33 W/K ~+0.44 SAP the entire cert 7700 residual."""
from dataclasses import replace
# Arrange — age C single-bp dwelling, main wall filled-cavity (U=0.70,
# so the difference we isolate sits entirely on the alt sub-area).
main_age_c = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="C",
wall_construction=4,
wall_insulation_type=2, # filled cavity → main wall U=0.70
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=80.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0,
),
],
)
with_dry_lined_alt = replace(
main_age_c,
sap_alternative_wall_1=SapAlternativeWall(
wall_area=14.44, wall_dry_lined="Y",
wall_construction=4, wall_insulation_type=4,
wall_thickness_measured="N",
),
)
without_dry_lined_alt = replace(
main_age_c,
sap_alternative_wall_1=SapAlternativeWall(
wall_area=14.44, wall_dry_lined="N",
wall_construction=4, wall_insulation_type=4,
wall_thickness_measured="N",
),
)
# Act
epc_dry_lined = make_minimal_sap10_epc(
total_floor_area_m2=80.0, country_code="ENG",
sap_building_parts=[with_dry_lined_alt],
)
epc_plain = make_minimal_sap10_epc(
total_floor_area_m2=80.0, country_code="ENG",
sap_building_parts=[without_dry_lined_alt],
)
result_dry_lined = heat_transmission_from_cert(epc_dry_lined)
result_plain = heat_transmission_from_cert(epc_plain)
# Assert — the alt-wall A×U delta is exactly 14.44 × (1.50 - 1.20)
# = 4.3320 W/K. Closed form: 1/(1/1.5 + 0.17) = 1.19522... → 2 d.p. = 1.20.
delta = result_plain.walls_w_per_k - result_dry_lined.walls_w_per_k
assert abs(delta - (14.44 * (1.50 - 1.20))) <= 1e-9
def test_basement_floor_uses_table_23_u_value_for_whole_floor_when_basement_detected() -> None:
"""User-confirmed convention: when a part has a basement, the WHOLE
floor=0 is the basement floor. Table 23 F-column overrides the

View file

@ -137,6 +137,12 @@ WALL_INSULATION_CAVITY_PLUS_INTERNAL: Final[int] = 7
# (cavity + external/internal insulation).
_WALL_INSULATION_LAMBDA_W_PER_MK: Final[float] = 0.04
# RdSAP10 §5.8 final note + Table 14 page 41: "For drylining including
# laths and plaster use Rinsulation = 0.17 m²K/W." Applied additively to
# the base U-value of an otherwise-uninsulated wall when the cert lodges
# `wall_dry_lined = "Y"` — see `u_wall(dry_lined=True)`.
_DRY_LINING_RESISTANCE_M2K_PER_W: Final[float] = 0.17
_AGE_BANDS: Final[tuple[str, ...]] = tuple("ABCDEFGHIJKLM")
@ -329,6 +335,7 @@ def u_wall(
insulation_present: bool = False,
description: Optional[str] = None,
wall_insulation_type: Optional[int] = None,
dry_lined: bool = False,
) -> float:
"""RdSAP10 wall U-value in W/m^2K, never null.
@ -344,6 +351,16 @@ def u_wall(
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).
`dry_lined` triggers the RdSAP10 §5.8 + Table 14 adjustment:
U_adjusted = 1 / (1/U_base + R_dryline) with R_dryline = 0.17 m²K/W.
The adjustment is applied only when the base U comes from the
uninsulated bucket (no measured insulation thickness, no filled-cavity
branch, no surveyor "described as insulated" override) for those
branches the dry-lining R is already absorbed into the assumed
insulation stack. Cohort fixture: cert 7700 Alt 1 cavity-as-built
age C with Dry-lining: Yes base U=1.5 adjusted U=1.20 (2 d.p.,
matching worksheet `CavityWallPlasterOnDabsDenseBlock`).
"""
measured = _measured_u_from_description(description)
if measured is not None:
@ -394,12 +411,21 @@ def u_wall(
# Country override first.
overrides = _COUNTRY_KLM_OVERRIDES.get(ctry, {}).get((wall_type, bucket), {})
if band in overrides:
return overrides[band]
base = _ENG_WALL.get((wall_type, bucket))
if base is None:
return 1.5
return base[age_idx]
u_base = overrides[band]
else:
base = _ENG_WALL.get((wall_type, bucket))
u_base = base[age_idx] if base is not None else 1.5
# RdSAP10 §5.8 + Table 14 page 41 — dry-lining (including lath and
# plaster) adds R = 0.17 m²K/W to an otherwise-uninsulated wall:
# U_adjusted = 1 / (1/U_base + 0.17), rounded to 2 d.p. half-up.
# Only the as-built uninsulated bucket triggers the adjustment;
# insulated buckets already incorporate the dry-lining R via Table 14.
if dry_lined and bucket == 0:
u_unrounded = 1.0 / (1.0 / u_base + _DRY_LINING_RESISTANCE_M2K_PER_W)
return float(
Decimal(str(u_unrounded)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
)
return u_base
# ---------------------------------------------------------------------------

View file

@ -278,6 +278,73 @@ def test_u_wall_unfilled_cavity_england_age_band_e_unchanged_at_1_5() -> None:
assert result == pytest.approx(1.5, abs=0.001)
def test_u_wall_dry_lined_cavity_as_built_age_c_applies_rdsap_5_8_r_0_17_adjustment() -> None:
# Arrange — RdSAP10 §5.8 final note + Table 14 page 41: "For drylining
# including laths and plaster use Rinsulation = 0.17 m²K/W." Applied
# additively to the base U-value of an otherwise-uninsulated wall.
# Cohort fixture: cert 7700-3362-0922-7022-3563 Alt 1 lodges Cavity,
# As-Built, Dry-lining: Yes, age band C → worksheet
# `CavityWallPlasterOnDabsDenseBlock` U-value = 1.20 W/m²K.
# Closed form: 1 / (1/1.5 + 0.17) = 1.19522... → 2 d.p. half-up = 1.20.
# Act
result = u_wall(
country=Country.ENG,
age_band="C",
construction=WALL_CAVITY,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=4,
dry_lined=True,
)
# Assert — adjusted U is rounded to 2 d.p. matching the dr87 worksheet's
# `UValueFinal` column for this construction.
assert abs(result - 1.20) <= 1e-9
def test_u_wall_not_dry_lined_cavity_as_built_age_c_returns_unadjusted_1_5() -> None:
# Arrange — same age + construction as the dry-lined case above but
# without the dry-lining flag. Cascade must return the bare Table 6
# "Cavity as built" row value (no R = 0.17 added).
# Act
result = u_wall(
country=Country.ENG,
age_band="C",
construction=WALL_CAVITY,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=4,
dry_lined=False,
)
# Assert
assert abs(result - 1.50) <= 1e-9
def test_u_wall_dry_lined_with_measured_insulation_thickness_no_adjustment() -> None:
# Arrange — once a measured insulation thickness is lodged, Table 6's
# insulated buckets already incorporate the dry-lining R via Table 14.
# Applying R = 0.17 on top would double-count. Cavity + 100 mm
# insulation, age band E → Table 6 cavity-100mm row = 0.32 W/m²K
# regardless of the dry-lining flag.
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=WALL_CAVITY,
insulation_thickness_mm=100,
insulation_present=True,
wall_insulation_type=4,
dry_lined=True,
)
# Assert
assert abs(result - 0.32) <= 1e-9
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.