From f3dcd7b43e150e937766aaed76a9cbe3f6b907a5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 11 Jun 2026 07:23:56 +0000 Subject: [PATCH] fix(elmhurst-mapper): single-storey flat with exposed roof is Top-floor, not Ground-floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Elmhurst dwelling-type classifier keyed "Top-floor flat" on a "dwelling below" floor lodgement. A single-storey flat exposed BOTH top (a real external roof) AND bottom (floor over partially-heated space, no dwelling below) therefore fell through to "Ground-floor flat" — which the cascade's _dwelling_exposure maps to has_exposed_roof=False, dropping the external roof entirely. Surfaced by simulated case 34 (cert 001431 reconfigured as a slimline electric-storage flat): the worksheet bills (30) External roof = 39.98 m² x U=2.30 = 91.95 W/K — the dominant heat-loss element — but the cascade dropped it, under-stating space-heating demand by 42% (6550 vs 11357 kWh/yr) and over-predicting SAP by +21.76 (57.07 vs worksheet 35.31). Fix: an exposed (non-party) roof puts the flat on the top storey regardless of what is below it. Classify as "Top-floor flat" whenever the roof is exposed; the flat's exposed floor is recovered downstream by the existing per-BP is_above_partially_heated_space / is_exposed_floor override in heat_transmission (§3). Party-roof flats ("another dwelling above") are unaffected and stay Ground-/Mid-floor. This is an Elmhurst-mapper (dwelling_type) bug, NOT a calculator bug: the calculator correctly trusts dwelling_type, and the gov-API path supplies the position directly (cert 0036 — a genuine ground-floor flat whose API data lodges a "Pitched, no access" roof construction under another dwelling — stays party, 2.51 W/K). API SAP gauge unchanged (57.6% within 0.5); worksheet harness 47/47 unaffected; case 34 roof now exact (residual -1.61 is a separate flat-corridor wall-U thread). Regression gate green (3 pre-existing fails unrelated). Co-Authored-By: Claude Opus 4.8 --- .../tests/test_summary_pdf_mapper_chain.py | 58 +++++++++++++++++++ datatypes/epc/domain/mapper.py | 11 +++- 2 files changed, 68 insertions(+), 1 deletion(-) 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 a495addd..25c0b63f 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -42,9 +42,14 @@ from datatypes.epc.domain.mapper import ( EpcPropertyDataMapper, UnmappedApiCode, UnmappedElmhurstLabel, + _elmhurst_dwelling_type, # pyright: ignore[reportPrivateUsage] _elmhurst_glazing_type_code, # pyright: ignore[reportPrivateUsage] _elmhurst_immersion_type_code, # pyright: ignore[reportPrivateUsage] ) +from datatypes.epc.surveys.elmhurst_site_notes import ( + FloorDetails as ElmhurstFloorDetails, + RoofDetails as ElmhurstRoofDetails, +) from domain.sap10_calculator.calculator import calculate_sap_from_inputs from domain.sap10_calculator.rdsap.cert_to_inputs import ( SAP_10_2_SPEC_PRICES, @@ -1540,6 +1545,59 @@ def test_summary_mapper_raises_on_unmapped_cylinder_insulation_label() -> None: assert excinfo.value.value == "Polyester wool" +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, + # not another dwelling) — simulated case 34 (cert 001431). Pre-fix the + # absence of a "dwelling below" routed it to "Ground-floor flat", which + # the cascade's `_dwelling_exposure` maps to has_exposed_roof=False, + # dropping the 91.95 W/K external roof (+21.76 SAP over-prediction). An + # exposed (non-party) roof means the flat is on the top storey → + # "Top-floor flat"; its exposed floor is recovered downstream by the + # per-BP is_above_partially_heated_space override. + roof = ElmhurstRoofDetails( + roof_type="PA Pitched (slates/tiles), access to loft", + insulation="N None", u_value_known=False, + ) + floor = ElmhurstFloorDetails( + location="P Above partially heated space", + floor_type="", insulation="A As built", u_value_known=False, + ) + + # Act + result = _elmhurst_dwelling_type( + built_form="Mid-Terrace", property_type="Flat", + floor=floor, roof=roof, room_in_roof=None, + ) + + # Assert + assert result == "Top-floor flat" + + +def test_elmhurst_dwelling_type_party_roof_flat_stays_ground_floor() -> None: + # Arrange — a genuine ground-floor flat with a dwelling ABOVE lodges a + # party roof ("A Another dwelling above") + a real ground floor. It must + # stay "Ground-floor flat" (roof party, floor exposed) — the fix must + # not over-promote party-roof flats to top-floor. + roof = ElmhurstRoofDetails( + roof_type="A Another dwelling above", + insulation="N None", u_value_known=False, + ) + floor = ElmhurstFloorDetails( + location="G Ground floor", + floor_type="", insulation="A As built", u_value_known=False, + ) + + # Act + result = _elmhurst_dwelling_type( + built_form="Mid-Terrace", property_type="Flat", + floor=floor, roof=roof, room_in_roof=None, + ) + + # Assert + assert result == "Ground-floor flat" + + def test_elmhurst_glazing_type_code_strips_interleaved_alternative_wall() -> None: # Arrange — when a property lodges an Alternative Wall (cert 001431 # storage-heater variants, "simulated case 34"), pdftotext interleaves diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index d6573aac..985760ab 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2368,7 +2368,16 @@ def _elmhurst_dwelling_type( floor_loc = (floor.location if floor is not None else "") or "" has_dwelling_below = "dwelling below" in floor_loc.lower() has_exposed_roof = room_in_roof is not None or _elmhurst_roof_is_exposed(roof) - if has_dwelling_below and has_exposed_roof: + # An exposed (non-party) roof puts the flat on the TOP storey, whether + # or not a dwelling sits below it. A single-storey flat exposed both top + # (external roof) and bottom (floor over partially-heated space, no + # dwelling below) is still top-floor for roof purposes — its exposed + # floor is recovered by the per-BP is_above_partially_heated_space / + # is_exposed_floor override in §3. Keying "Top-floor" on + # has_dwelling_below dropped that roof, routing such flats to + # "Ground-floor flat" → has_exposed_roof=False → no roof heat loss + # (simulated case 34, cert 001431: 91.95 W/K roof dropped, +21.76 SAP). + if has_exposed_roof: position = "Top-floor" elif has_dwelling_below: position = "Mid-floor"