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"