From 48b36d3d7ee62ebae6aaf52a3b98364c89a0be5e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 11 Jun 2026 07:35:46 +0000 Subject: [PATCH] =?UTF-8?q?fix(elmhurst-mapper):=20carry=20=C2=A77=20alter?= =?UTF-8?q?native-wall=20"Sheltered=20Wall"=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Elmhurst Summary §7 lodges "Alternative Wall N Sheltered Wall: Yes" for a sub-area adjacent to an unheated buffer (e.g. a flat's corridor wall), but the extractor dropped it and _map_elmhurst_alternative_wall never set SapAlternativeWall.is_sheltered — so the cascade billed the sub-area at its full exposed U instead of the RdSAP 10 Table 4 (p.22) sheltered U = 1/(1/U + 0.5). The calculator already applies is_sheltered (_alt_wall_w_per_k) and the gov-API path already wires sheltered_wall=="Y"; this brings the Elmhurst front-end to parity. Three-part change: AlternativeWall.sheltered field + _alternative_walls_from_lines parse ("Alternative Wall N Sheltered Wall") + _map_elmhurst_alternative_wall is_sheltered=a.sheltered. Surfaced by simulated case 34 (cert 001431 electric-storage flat): the 6.02 m² corridor wall billed at full U=1.50 (9.03 W/K) instead of the sheltered 0.86 (5.18 W/K) — +3.85 W/K, -1.61 SAP. Post-fix the alt wall matches the worksheet's (29a) 5.177 and case 34 closes from -1.61 to -0.30 (remaining residual is a separate window/wall area-allocation thread). Elmhurst-mapper only: API SAP gauge unchanged (57.6% within 0.5); worksheet harness 47/47 unaffected; regression gate green (3 pre-existing fails unrelated); pyright net-zero. Co-Authored-By: Claude Opus 4.8 --- .../documents_parser/elmhurst_extractor.py | 6 ++++ .../tests/test_summary_pdf_mapper_chain.py | 31 +++++++++++++++++++ datatypes/epc/domain/mapper.py | 5 +++ datatypes/epc/surveys/elmhurst_site_notes.py | 5 +++ 4 files changed, 47 insertions(+) diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 87075f59..fa5dadc0 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -340,6 +340,12 @@ class ElmhurstSiteNotesExtractor: dry_lined=self._local_bool( lines, f"Alternative Wall {n} Dry-lining" ), + # RdSAP 10 Table 4 (p.22): a sheltered alt sub-area + # (adjacent to an unheated buffer, e.g. a flat corridor + # wall) adds R=0.5 m²K/W → U = 1/(1/U + 0.5). + sheltered=self._local_bool( + lines, f"Alternative Wall {n} Sheltered Wall" + ), )) return result diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index 25c0b63f..fbc1c6f9 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -45,8 +45,10 @@ from datatypes.epc.domain.mapper import ( _elmhurst_dwelling_type, # pyright: ignore[reportPrivateUsage] _elmhurst_glazing_type_code, # pyright: ignore[reportPrivateUsage] _elmhurst_immersion_type_code, # pyright: ignore[reportPrivateUsage] + _map_elmhurst_alternative_wall, # pyright: ignore[reportPrivateUsage] ) from datatypes.epc.surveys.elmhurst_site_notes import ( + AlternativeWall as ElmhurstAlternativeWall, FloorDetails as ElmhurstFloorDetails, RoofDetails as ElmhurstRoofDetails, ) @@ -1545,6 +1547,35 @@ def test_summary_mapper_raises_on_unmapped_cylinder_insulation_label() -> None: assert excinfo.value.value == "Polyester wool" +def test_map_elmhurst_alternative_wall_carries_sheltered_flag() -> None: + # Arrange — Elmhurst Summary §7 lodges "Alternative Wall N Sheltered + # Wall: Yes" for a sub-area adjacent to an unheated buffer (e.g. a flat's + # corridor wall). RdSAP 10 Table 4 (p.22) gives a sheltered wall an added + # R=0.5 m²K/W → U=1/(1/U+0.5). The cascade applies this via + # SapAlternativeWall.is_sheltered (the API path already wires it); the + # Elmhurst path must surface it too. Surfaced by simulated case 34 (cert + # 001431 flat: 6.02 m² corridor wall billed at full U=1.50 instead of + # the sheltered 0.86 → +3.85 W/K, -1.61 SAP). + sheltered = ElmhurstAlternativeWall( + area_m2=12.5, wall_type="CA Cavity", insulation="A As Built", + thickness_unknown=False, thickness_mm=250, u_value_known=False, + dry_lined=False, sheltered=True, + ) + plain = ElmhurstAlternativeWall( + area_m2=12.5, wall_type="CA Cavity", insulation="A As Built", + thickness_unknown=False, thickness_mm=250, u_value_known=False, + dry_lined=False, sheltered=False, + ) + + # Act + mapped_sheltered = _map_elmhurst_alternative_wall(sheltered) + mapped_plain = _map_elmhurst_alternative_wall(plain) + + # Assert + assert mapped_sheltered.is_sheltered is True + assert mapped_plain.is_sheltered is False + + def test_elmhurst_dwelling_type_single_storey_flat_with_exposed_roof_is_top_floor() -> None: # Arrange — a single-storey flat exposed BOTH top (external pitched # roof, access to loft) AND bottom (floor over partially-heated space, diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 985760ab..b47db127 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3864,6 +3864,11 @@ def _map_elmhurst_alternative_wall( wall_insulation_thickness=None, wall_thickness_mm=measured_thickness_mm, is_basement=_elmhurst_wall_is_basement(a.wall_type), + # Summary §7 "Alternative Wall N Sheltered Wall: Yes" → RdSAP 10 + # Table 4 (p.22) sheltered U = 1/(1/U + 0.5), applied by the + # cascade's `_alt_wall_w_per_k`. Mirror of the API path's + # `sheltered_wall == "Y"` wiring. + is_sheltered=a.sheltered, ) diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index 16464766..3d5b2b21 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -71,6 +71,11 @@ class AlternativeWall: thickness_mm: Optional[int] u_value_known: bool dry_lined: bool = False + # Summary §7 "Alternative Wall N Sheltered Wall: Yes/No". RdSAP 10 + # Table 4 (p.22): a sheltered sub-area (adjacent to an unheated buffer + # such as a flat's corridor) carries an added R=0.5 m²K/W → U = + # 1/(1/U + 0.5). Drives SapAlternativeWall.is_sheltered. + sheltered: bool = False @dataclass