From ed8fdc6ae3498e9accce95e1834b1cafe3f60f1e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 29 May 2026 23:00:31 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.83:=20Extractor=20+=20mapper=20re?= =?UTF-8?q?cognise=20Exposed=20/=20Connected=20gable=5Ftype=20per=20RdSAP?= =?UTF-8?q?=2010=20=C2=A73.10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Elmhurst Summary PDF §8.1 "Room(s) in Roof" per-surface table publishes the gable-wall environment column with one of four values: Party → §8.1 party-wall row Sheltered → §8.1 sheltered external row Exposed → §8.1 exposed external row Connected (to heated space) → §8.1 internal partition Per RdSAP 10 §3.10 (PDF p.30-35) "Detailed Room-in-Roof" + Table 4 (p.22) "Heat-loss surface variants": - Exposed gable wall → external wall at the lodged U-value - Sheltered gable wall → external wall at the lodged U-value - Party gable wall → party wall at U=0.25 (Table 4 row 2) - Connected gable wall → internal partition to heated space, NOT a heat-loss surface The extractor was only capturing `gable_type ∈ {"Party", "Sheltered", "Connected to heated space"}` — neither `"Exposed"` (every external gable on cert 000565) nor the plain `"Connected"` string (the actual PDF lodging value, vs the verbose "Connected to heated space" form used on other Summary schemas) was recognised. Both fell through with `gable_type=None`, masking the downstream cascade gap (cert 000565 BP[0] Main Gable Wall 1 is lodged "Exposed" at U=0.35 but extracted as untyped → mapper routes to `gable_wall` party at U=0.25, vs the worksheet's "Roof room Main Gable Wall 1" at U=0.35). This slice closes the extractor side only: backend/documents_parser/elmhurst_extractor.py:_parse_rir_surface_row expands its `gable_type` lookup set to include "Exposed" and the plain "Connected" lodging value. Mapper-side: `_map_elmhurst_rir_surface` (datatypes/epc/domain/mapper.py) preserves cert 9501's behaviour — its flat-RR elif previously hinged on `surface.gable_type is None and is_flat`; now extends to `surface.gable_type in (None, "Exposed") and is_flat` so the same flat-RR routing fires whichever lodging shape the Summary PDF uses. Net cascade impact: zero. Cert 9501 (top-floor flat) retains its RR-gables-as-external routing. Cert 000565 (house) keeps falling through to the default `gable_wall` (party at U=0.25) routing for "Exposed" + "Connected" gables — the next slice in the block reroutes those to external walls + drops Connected surfaces per RdSAP 10 Table 4. This commit is pure data-extraction completion; pin movement lands when S0380.84 wires the mapper through. Test baseline: 555 pass + 8 expected `test_sap_result_pin[000565-*]` fails (was 554 + 8 at S0380.82; one new test pins the spec rule). Pyright net-zero on touched files (45 errors, matches baseline). Co-Authored-By: Claude Opus 4.7 --- .../documents_parser/elmhurst_extractor.py | 5 +- .../tests/test_summary_pdf_mapper_chain.py | 73 +++++++++++++++++++ datatypes/epc/domain/mapper.py | 9 ++- 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 12f4d3de..ed35bef1 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -523,7 +523,10 @@ class ElmhurstSiteNotesExtractor: insulation = t elif t in ("Mineral or EPS", "PUR", "PIR"): insulation_type = t - elif t in ("Party", "Sheltered", "Connected to heated space"): + elif t in ( + "Party", "Sheltered", "Exposed", + "Connected", "Connected to heated space", + ): gable_type = t return RoomInRoofSurface( name=name, 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 f4408fa3..2f371559 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -509,6 +509,79 @@ def test_summary_9501_rr_gable_walls_route_to_external_walls_hlc() -> None: assert abs(ht.walls_w_per_k - worksheet_walls_w_per_k) <= 1e-2 +def test_summary_000565_extractor_recognises_exposed_and_connected_gable_types() -> None: + """Summary PDF §8.1 Room(s) in Roof per-surface table lists the + gable-wall environment column with one of four published values: + + Party → §8.1 party-wall row + Sheltered → §8.1 sheltered external row + Exposed → §8.1 exposed external row + Connected (to heated space) → §8.1 internal partition + + Per RdSAP 10 §3.10 (PDF p.30-35) "Detailed Room-in-Roof" + Table 4 + (p.22) "Heat-loss surface variants": + + - Exposed gable wall → external wall at the lodged U-value (or + the BP main-wall U when the lodged value is the default) + - Sheltered gable wall → external wall at the lodged U-value + - Party gable wall → party wall at U=0.25 (Table 4 row 2) + - Connected gable wall → internal partition to heated space, + NOT a heat-loss surface (drops from external + party totals) + + The extractor was only capturing `gable_type ∈ {"Party", + "Sheltered", "Connected to heated space"}` — neither `"Exposed"` + (every external gable on cert 000565) nor the plain `"Connected"` + string (the actual lodging used in Summary PDFs vs the verbose + "Connected to heated space") was recognised. Both fell through + with `gable_type=None`, masking the downstream cascade gap (cert + 000565 BP[0] Main Gable Wall 1 is lodged "Exposed" at U=0.35 but + extracted as untyped → mapper routes to `gable_wall` (party at + U=0.25) — see worksheet "Roof room Main Gable Wall 1" line at + U=0.35). + + This pin asserts the extractor surfaces the lodged environment + column verbatim. The downstream mapper + cascade behaviour stays + unchanged until follow-up slices use the new field — this is a + pure extractor data-completion step (no test pins move). + """ + # Arrange + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act — Main BP gables; Ext1/Ext2 gables expose both "Connected" + # and "Exposed" values from the cert lodging. + rir_main = site_notes.room_in_roof + main_surfaces = {s.name: s for s in (rir_main.surfaces if rir_main else [])} + rir_ext1 = ( + site_notes.extensions[0].room_in_roof + if site_notes.extensions and len(site_notes.extensions) > 0 + else None + ) + ext1_surfaces = {s.name: s for s in (rir_ext1.surfaces if rir_ext1 else [])} + + # Assert + # Main BP[0]: Gable Wall 1 lodged "Exposed" (default U 0.35); Gable + # Wall 2 lodged "Sheltered" (default U 0.30). + assert main_surfaces["Gable Wall 1"].gable_type == "Exposed", ( + f"Main Gable Wall 1 gable_type = " + f"{main_surfaces['Gable Wall 1'].gable_type!r}; expected 'Exposed'" + ) + assert main_surfaces["Gable Wall 2"].gable_type == "Sheltered", ( + f"Main Gable Wall 2 gable_type = " + f"{main_surfaces['Gable Wall 2'].gable_type!r}; expected 'Sheltered'" + ) + # Ext1 BP[1]: Gable Wall 1 lodged "Connected" (internal partition); + # Gable Wall 2 lodged "Exposed" (default U 1.70). + assert ext1_surfaces["Gable Wall 1"].gable_type == "Connected", ( + f"Ext1 Gable Wall 1 gable_type = " + f"{ext1_surfaces['Gable Wall 1'].gable_type!r}; expected 'Connected'" + ) + assert ext1_surfaces["Gable Wall 2"].gable_type == "Exposed", ( + f"Ext1 Gable Wall 2 gable_type = " + f"{ext1_surfaces['Gable Wall 2'].gable_type!r}; expected 'Exposed'" + ) + + def test_summary_9501_pv_array_surfaced_from_elmhurst_section_19() -> None: # Arrange — cert 9501's Elmhurst §19.0 PV section lodges measured # array detail (2.36 kWp, South-West orientation, 45° elevation, diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 82a6450b..d0fedb63 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3277,12 +3277,19 @@ def _map_elmhurst_rir_surface( if kind == "gable_wall" and surface.gable_type == "Sheltered": kind = "gable_wall_external" u_value_override = surface.default_u_value - elif kind == "gable_wall" and surface.gable_type is None and is_flat: + elif ( + kind == "gable_wall" + and surface.gable_type in (None, "Exposed") + and is_flat + ): # Flat with RR: gables are external by default (top of block, # no neighbour above). Lodge as gable_wall_external with no # u_value override so the cascade falls through to the main- # wall U (`uw` in heat_transmission.py:674) — matches cert # 9501's worksheet treatment of both gable walls at U=1.7. + # Per Summary PDF schema the gable env column reads "Exposed" + # for the same case the legacy heuristic detected via None; + # both lodging shapes route here. kind = "gable_wall_external" area_m2 = _round_half_up_2dp(surface.length_m, surface.height_m) if kind in ("gable_wall", "gable_wall_external"):