fix(elmhurst): read main-wall dry-lining + fix last-RR-row U over-read

Two compensating Summary-extractor bugs surfaced by simulated case 43 (a
2-BP mid-terrace with a detailed room-in-roof + a dry-lined extension wall).
Their fabric errors nearly cancelled (walls net −0.76 W/K), hiding both
behind a deceptively small +0.05 SAP delta.

Bug 1 — main/extension wall dry-lining never read. The §7 "Dry-lining:
Yes/No" line was parsed only for ALTERNATIVE walls; the main/extension
WallDetails dropped it, so a dry-lined solid wall was billed at its
un-adjusted base U. RdSAP 10 §5.8 + Table 14: a dry-lined uninsulated wall
adds R=0.17 → U = 1/(1/U_base + 0.17). Case 43 Ext1: solid brick 1.70 →
1.32. Added `WallDetails.dry_lined`, read it in the extractor (both the
main-wall builder and the As-Main copy), threaded it to the domain
`wall_dry_lined` (emit None when undried — cascade-equivalent to False,
keeps the field absent for the non-dry-lined majority).

Bug 2 — the LAST room-in-roof surface row's U over-read. The per-row token
scan stops at the next RIR-row name; the final surface (no successor) over-
read into the following section, shifting the trailing-token slotting and
silently zeroing its `default_u` (case 43 Common Wall 2: 1.90 → 0.00 → the
2.4 m² common wall billed at U=0 instead of the main-wall 1.90). Stop the
scan at the row's natural end — the "Yes"/"No" u_value_known flag plus the
trailing u_value numeric.

Case 43 now reproduces the P960 EXACTLY: (29a) walls 74.5800, (33) fabric
172.7844, continuous SAP 73.2332 = (258), CO2 3518.30 = (272), all <1e-4
(was SAP +0.0455 / CO2 −8.04). Harness 47/47 0 raised; regression = the 3
pre-existing fails; pyright net-zero (51=51).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-16 07:51:33 +00:00
parent 8a70d22278
commit a33707f851
4 changed files with 32 additions and 1 deletions

View file

@ -290,6 +290,10 @@ class ElmhurstSiteNotesExtractor:
party_wall_type=self._local_str(lines, "Party Wall Type"),
thickness_mm=thickness_mm,
insulation_thickness_mm=insulation_thickness_mm,
# Summary §7 "Dry-lining: Yes/No" on the main/extension wall.
# RdSAP 10 §5.8 + Table 14 dry-lining R=0.17 adjustment. The
# alt-wall path reads its own "Alternative Wall N Dry-lining".
dry_lined=self._local_bool(lines, "Dry-lining"),
alternative_walls=self._alternative_walls_from_lines(lines),
# Summary §7 lodges the per-BP "Curtain Wall Age" line only
# when `Type: CW Curtain Wall`. Per RdSAP 10 §5.18 (PDF
@ -548,6 +552,20 @@ class ElmhurstSiteNotesExtractor:
if self._is_next_rir_row(lines[j]):
break
tokens.append(lines[j])
# Every RIR row ends with [default_u, "Yes"/"No", u_value]; the
# "Yes"/"No" is the unique u_value_known marker (gable types and
# insulation cells never take that value). Stop once we've
# appended that flag plus the trailing u_value numeric so the
# LAST surface row (no next-row name to bound it) does not
# over-read into the following section and shift the trailing
# token slotting — which silently zeroed Common Wall 2's
# default_u (case 43: 1.90 -> 0.00).
if (
len(tokens) >= 2
and tokens[-2] in ("Yes", "No")
and self._RIR_NUMERIC_RE.match(tokens[-1])
):
break
# First two numerics = length, height
length = float(tokens[0]) if tokens and self._RIR_NUMERIC_RE.match(tokens[0]) else 0.0
height = float(tokens[1]) if len(tokens) > 1 and self._RIR_NUMERIC_RE.match(tokens[1]) else 0.0
@ -698,6 +716,7 @@ class ElmhurstSiteNotesExtractor:
party_wall_type=ext_party_wall_type,
thickness_mm=main_walls.thickness_mm,
insulation_thickness_mm=main_walls.insulation_thickness_mm,
dry_lined=main_walls.dry_lined,
alternative_walls=self._alternative_walls_from_lines(wall_lines),
)
else:

View file

@ -505,7 +505,7 @@ class SapBuildingPart:
building_part_number: Optional[int] = (
None # Not sure how we get this from site notes
)
wall_dry_lined: Optional[bool] = None # Don't think we have this in site notes
wall_dry_lined: Optional[bool] = None # Summary §7 "Dry-lining: Yes/No"
wall_thickness_mm: Optional[int] = None
# Union[str, int]: a numeric mm value when the API lodges
# `wall_insulation_thickness == "measured"` (resolved from the

View file

@ -4436,6 +4436,12 @@ def _map_elmhurst_building_part(
wall_is_basement=_elmhurst_wall_is_basement(walls.wall_type),
wall_insulation_type=_elmhurst_wall_insulation_int(walls.insulation),
wall_thickness_measured=not walls.thickness_unknown,
# Summary §7 "Dry-lining: Yes" → RdSAP 10 §5.8 Table 14 R=0.17
# adjustment in the cascade (`dry_lined=bool(part.wall_dry_lined)`).
# Emit None (not False) when undried so the field stays absent for
# the non-dry-lined majority (cascade-equivalent: bool(None) == False);
# only a lodged "Yes" populates it.
wall_dry_lined=walls.dry_lined or None,
party_wall_construction=_elmhurst_party_wall_construction_int(
walls.party_wall_type
),

View file

@ -94,6 +94,12 @@ class WallDetails:
# "Insulation Thickness" / "100 mm" line pair when a composite or
# retrofit insulation is recorded. None when the PDF omits the line.
insulation_thickness_mm: Optional[int] = None
# Summary §7 "Dry-lining: Yes/No" on the main/extension wall (distinct
# from the per-alt-wall `AlternativeWall.dry_lined`). Per RdSAP 10
# §5.8 + Table 14 a dry-lined uninsulated wall adds R=0.17 m²K/W →
# U = 1/(1/U_base + 0.17). Previously unread, so dry-lined solid/
# cavity walls were billed at the un-adjusted (higher) base U.
dry_lined: bool = False
# Per-BP curtain-wall installation age, lodged in Summary §7 as
# "Curtain Wall Age" when `wall_type` is "CW Curtain Wall". Per
# RdSAP 10 §5.18 (PDF p.48) the curtain-wall U-value keys on this