fix(u-value): solid brick as-built U by thickness — §5.7 Table 13

A 440 mm (>420 mm) solid brick AS-BUILT wall computed U = 1.70 (the
220 mm bucket default) instead of the RdSAP-correct 1.10. The §5.7
Table 13 thickness path only fired for *insulated* brick (external/
internal + thickness > 0); the as-built case fell through to the
Table 6 cavity/solid age-band default.

Spec: RdSAP 10 Specification (9th June 2025), §5.7 "U-values for
uninsulated brick walls, age bands A to E", Table 13 (PDF p.40):
  ≤200 mm → 2.5, 200–280 mm → 1.7, 280–420 mm → 1.4, >420 mm → 1.1.
Table 6 footnote (b) on the "Solid brick as built" row (PDF p.40):
"Or from 5.7 if wall thickness is other than 200mm to 280mm" — the
thickness table supersedes the flat 1.7 default whenever a documentary
wall thickness is lodged (200–280 mm gives 1.7 either way). The §5.8 /
Table 14 dry-lining R is added on top only when the wall is dry-lined,
per the §5.7 closing sentence.

Validated against the user-generated Elmhurst worksheet "simulated
case 21" (replica of API cert 2818-3053-3203-2655-9204: mid-terrace,
age band B, solid brick as-built 440 mm, room-in-roof). New §3 cascade
pin `test_section_3_wall_u_by_thickness_case21_match_pdf` routes the
Summary through the real extractor + mapper and pins:
  (31) 155.1000, (33) 175.6208, (36) 23.2650, (37) 198.8858 — all 1e-4.
External walls Main U → 1.1000; Sheltered RR gable → 1/(1/1.10+0.5) =
0.71 (was 0.92). Pinned on §3 only (case-6 precedent): its code-908
instantaneous multi-point gas water heater has a separate §4 (219) gap.

Cross-check: sim case 20 (220 mm) stays at 1.70 — unchanged.

API SAP accuracy (scripts/eval_api_sap_accuracy.py, 896 computed certs):
% |err| < 0.5 SAP vs lodged: 42.6% → 43.8%; mean |err| 2.045 → 2.010.

Regression: tests/domain/sap10_calculator/ (1861), backend/
documents_parser/tests/ (574), datatypes/epc/ + rdsap golden fixtures
all green (pre-existing test_total_floor_area excepted). pyright strict
net-zero. No solid-brick fixture pin shifted (200–280 mm unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-06 14:40:06 +00:00
parent cdf211393c
commit 27375d93a4
4 changed files with 203 additions and 0 deletions

Binary file not shown.

View file

@ -648,6 +648,35 @@ def u_wall(
return float(
Decimal(str(u_unrounded)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
)
# RdSAP 10 §5.7 Table 13 (PDF p.40) — uninsulated ("as built") solid
# brick wall U₀ by lodged wall thickness, age bands A-E. Table 6
# footnote (b) on the "Solid brick as built" row (PDF p.40):
# "Or from 5.7 if wall thickness is other than 200mm to 280mm" — the
# thickness table supersedes the flat 1.7 Table-6 default whenever a
# documentary wall thickness is lodged. 200-280 mm gives 1.7 either
# way, so the table is applied unconditionally here:
# ≤200 → 2.5, 200-280 → 1.7, 280-420 → 1.4, >420 → 1.1.
# The §5.8 + Table 14 dry-lining R is added on top only when the wall
# is dry-lined (§5.7 closing sentence: "Apply the adjustment according
# to Table 14 ... if wall is insulated or/and dry-lined including lath
# and plaster"). The insulated External/Internal case is handled by
# the branch above; this is the as-built (and dry-lined-only) path.
# Worksheet sim case 21: solid brick 440 mm (>420) as-built, Dry-lining
# No → U=1.10 (§3 (29a)). Cross-check sim case 20: 220 mm → 1.70.
if (
wall_type == WALL_SOLID_BRICK
and band in _STONE_AGE_A_TO_E
and wall_thickness_mm is not None
):
u0 = _u_brick_thin_wall_age_a_to_e(wall_thickness_mm)
if dry_lined:
u_unrounded = 1.0 / (1.0 / u0 + _DRY_LINING_RESISTANCE_M2K_PER_W)
return float(
Decimal(str(u_unrounded)).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
)
return u0
if wall_type == WALL_CAVITY and wall_insulation_type in (
WALL_INSULATION_CAVITY_PLUS_EXTERNAL,
WALL_INSULATION_CAVITY_PLUS_INTERNAL,

View file

@ -0,0 +1,129 @@
"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431
"simulated case 21" worksheet a replica of API cert
2818-3053-3203-2655-9204: a mid-terrace, age-band-B dwelling whose Main
wall is **solid brick, as built, 440 mm** (room-in-roof above).
Like 000565 / the _rr cases / case 20, this fixture does NOT hand-build
the EpcPropertyData: it routes the Summary PDF through
ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the SAP-result
pin grid exercises the WHOLE extractor + mapper + calculator pipeline.
This case validates the RdSAP 10 §5.7 Table 13 (PDF p.40) "uninsulated
brick wall by thickness" path for an **as-built** wall. A 440 mm solid
brick wall is >420 mm U = 1.10 (not the 220 mm bucket default 1.70).
Table 6 footnote (b) on the "Solid brick as built" row makes this
explicit: "Or from 5.7 if wall thickness is other than 200mm to 280mm".
The wall is lodged "Dry-lining No", so no §5.8 / Table 14 adjustment is
applied U is the raw Table 13 value.
The fix flows through to the Sheltered room-in-roof gable, which is
1/(1/1.10 + 0.5) = 0.71 (worksheet §3 Gable Wall 1), down from the
pre-fix 0.92 that a 1.70 wall U produced (case 20's 220 mm wall).
Source: user-simulated PDFs at `sap worksheets/golden fixture debugging/
simulated case 21/`. The Summary is mirrored into the tracked
`backend/documents_parser/tests/fixtures/Summary_001431_case21.pdf` so
the test runs without depending on the unstaged workspace.
Cert shape: Main mid-terrace, solid brick as-built 440 mm, age band B,
2 storeys + Detailed room-in-roof on the Main (Sheltered + Connected
gables), suspended uninsulated ground floor, mains-gas boiler (SAP code
119, 84% efficiency, control 2113), mains-gas multi-point instantaneous
water heater (code 908, 65% efficiency), Dual/E7 electricity meter, no
secondary heating, no PV.
This fixture is pinned on the **§3 heat-loss line refs only**
((31)/(33)/(36)/(37)) the values the wall-U-by-thickness fix directly
controls. Following the same rationale as simulated case 6 (see
`test_section_3_roof_windows_case6_match_pdf`), it is NOT added to the
full §1-§13 SAP cascade grid because its water heater code 908,
multi-point gas **instantaneous** serving several taps exposes a
separate, unrelated §4 water-heating gap (the cascade over-computes
(219) vs the worksheet's 1859.1534). That is its own cause / own slice;
folding it in here would force a tolerance widening this slice does not
own. The §3 pins below fully exercise the wall-U fix end-to-end through
the real extractor + mapper.
Worksheet §3 pin targets (P960-0001-001431 page 2, "3. Heat losses"):
- (31) Total net area of external elements = 155.1000
- (33) Fabric heat loss Σ(A×U) = 175.6208 W/K
- (36) Thermal bridges (0.150 × exposed) = 23.2650 W/K
- (37) Total fabric heat loss (33)+(36) = 198.8858 W/K
- §3 element refs: External walls Main U = 1.1000 (§5.7 Table 13, 440 mm
> 420 mm); Roof room Main Gable Wall 1 (Sheltered) = 0.71 =
1/(1/1.10 + 0.5); Common Walls = 1.10.
Per [[feedback-zero-error-strict]] + [[feedback-e2e-validation-
philosophy]]: pins are abs=1e-4 against the worksheet PDF.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import Final
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/,
# [4]=repo root.
_SUMMARY_PDF: Final[Path] = (
Path(__file__).resolve().parents[4]
/ "backend" / "documents_parser" / "tests" / "fixtures"
/ "Summary_001431_case21.pdf"
)
# §3 heat-loss line refs from the P960 worksheet (page 2, "3. Heat
# losses"). These are the dimensions the wall-U-by-thickness fix drives:
# a 440 mm (>420) solid brick as-built wall takes RdSAP 10 §5.7 Table 13
# U=1.10, lifting fabric heat loss to 175.6208 (pre-fix the 220 mm bucket
# default 1.70 over-stated it).
LINE_31_TOTAL_EXTERNAL_AREA_M2: Final[float] = 155.1000
LINE_33_FABRIC_HEAT_LOSS_W_PER_K: Final[float] = 175.6208
LINE_36_THERMAL_BRIDGING_W_PER_K: Final[float] = 23.2650
LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K: Final[float] = 198.8858
def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]:
"""Convert a Summary PDF into the per-page text format the
ElmhurstSiteNotesExtractor expects (label\\nvalue sequences). Mirror
of the helper in the other `_elmhurst_worksheet_*` fixtures.
"""
info = subprocess.run(
["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True,
).stdout
m = re.search(r"Pages:\s+(\d+)", info)
if m is None:
raise RuntimeError(f"Could not parse page count from {pdf_path}")
page_count = int(m.group(1))
pages: list[str] = []
for i in range(1, page_count + 1):
layout = subprocess.run(
[
"pdftotext", "-layout", "-f", str(i), "-l", str(i),
str(pdf_path), "-",
],
capture_output=True, text=True, check=True,
).stdout
tokens: list[str] = []
for line in layout.splitlines():
if not line.strip():
tokens.append("")
continue
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
tokens.extend(parts)
pages.append("\n".join(tokens))
return pages
def build_epc() -> EpcPropertyData:
"""Route the simulated case-21 Summary through extractor + mapper.
No hand-built EpcPropertyData the extractor and mapper are part of
the test target.
"""
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)

View file

@ -43,6 +43,7 @@ from tests.domain.sap10_calculator.worksheet import (
_elmhurst_worksheet_000490 as _w000490,
_elmhurst_worksheet_000516 as _w000516,
_elmhurst_worksheet_001431_case6 as _w001431_case6,
_elmhurst_worksheet_001431_case21 as _w001431_case21,
)
@ -283,6 +284,50 @@ def test_section_3_roof_windows_case6_match_pdf() -> None:
)
def test_section_3_wall_u_by_thickness_case21_match_pdf() -> None:
"""§3 heat-loss pins for simulated case 21 — a replica of API cert
2818 whose Main wall is solid brick, **as built, 440 mm**.
RdSAP 10 §5.7 Table 13 (PDF p.40) defaults an uninsulated brick wall
by thickness: >420 mm U = 1.10 (not the 220 mm bucket default 1.70).
Table 6 footnote (b) on the "Solid brick as built" row makes this
explicit: "Or from 5.7 if wall thickness is other than 200mm to
280mm". The lower wall U flows through (33) and the Sheltered
room-in-roof gable (1/(1/1.10 + 0.5) = 0.71).
Pinned on §3 line refs only (not added to `_FIXTURES`) the same
rationale as case 6: its instantaneous multi-point gas water heater
(code 908) exposes a separate §4 (219) gap, so the full §10/§12 SAP
cascade is non-comparable. See the fixture module docstring."""
# Arrange
epc = _w001431_case21.build_epc()
# Act
ht = heat_transmission_section_from_cert(epc)
# Assert
_pin(
ht.total_external_element_area_m2,
_w001431_case21.LINE_31_TOTAL_EXTERNAL_AREA_M2,
"§3 (31) case21",
)
_pin(
ht.fabric_heat_loss_w_per_k,
_w001431_case21.LINE_33_FABRIC_HEAT_LOSS_W_PER_K,
"§3 (33) case21",
)
_pin(
ht.thermal_bridging_w_per_k,
_w001431_case21.LINE_36_THERMAL_BRIDGING_W_PER_K,
"§3 (36) case21",
)
_pin(
ht.total_w_per_k,
_w001431_case21.LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K,
"§3 (37) case21",
)
def test_case6_main_2_emitter_and_control_extracted() -> None:
"""Simulated case 6's §14.1 Main Heating2 lodges its OWN emitter
("Underfloor Heating") and control ("SAP code 2110, ...") the two