From a33707f851262b1b71a8724d8023054c5bbb372b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 07:51:33 +0000 Subject: [PATCH] fix(elmhurst): read main-wall dry-lining + fix last-RR-row U over-read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../documents_parser/elmhurst_extractor.py | 19 +++++++++++++++++++ datatypes/epc/domain/epc_property_data.py | 2 +- datatypes/epc/domain/mapper.py | 6 ++++++ datatypes/epc/surveys/elmhurst_site_notes.py | 6 ++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 11e94fba..a8c9e596 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -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: diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 1472963d..baf1db00 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -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 diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index d09a46e6..b3fc944f 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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 ), diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index eded346f..4614d33c 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -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