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 406729ad..c05f1437 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -180,6 +180,45 @@ def test_summary_001431_topfloor_extracts_main_property_age_band() -> None: assert survey.construction_age_band == "C 1930-1949" +def test_summary_001431_topfloor_flat_classified_as_top_floor() -> None: + # Arrange — the recommendation "after" Summary lodges §6.0 "Position + # of flat in block of flats: Top Floor": floor "A Another dwelling + # below" (party) + roof "PS Pitched, sloping ceiling" (an exposed + # external roof, NOT a room-in-roof). The mapper must classify it + # Top-floor (roof exposed) — not Mid-floor — so the cascade charges + # the roof heat-loss term. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_TOPFLOOR_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Assert + assert epc.dwelling_type == "Top-floor flat" + + +def test_summary_001431_topfloor_full_chain_sap_matches_worksheet_pdf() -> None: + # Arrange — gas-boiler-upgrade-with-cylinder recommendation "after" + # worksheet (P960-0001-001431). Top-floor flat, PS sloping roof at + # U=2.3 (age C, uninsulated) → (30) roof 241.68 W/K, (33) fabric + # 320.06, (37) HLC 348.76. Worksheet §11a lodges unrounded SAP + # 56.3649. Exercises both upstream fixes: the Date-Built age band + # (roof U 2.3 not 0.4) and the top-floor flat classification (roof + # not dropped). + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_TOPFLOOR_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Act + result = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) + + # Assert + worksheet_unrounded_sap = 56.3649 + assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4 + + def test_summary_000474_mapper_produces_three_building_parts() -> None: # Arrange — cert U985-0001-000474 is a mid-terrace with 3 building # parts (Main + 2 extensions) per the hand-built worksheet fixture diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 9c2587b9..01512ddf 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -296,6 +296,7 @@ class EpcPropertyDataMapper: built_form=built_form, property_type=property_type, floor=survey.floor, + roof=survey.roof, room_in_roof=survey.room_in_roof, ) @@ -2311,26 +2312,53 @@ _ELMHURST_INSULATION_CODE_TO_SAP10: Dict[str, int] = { } +# Elmhurst roof codes that denote a party ceiling (another/same dwelling +# or non-residential space ABOVE), so the flat's roof is NOT a heat-loss +# surface: S (Same dwelling above), A (Another dwelling above), NR +# (Non-residential space above). Every other roof code (F / PN / PA / PS) +# is an exposed external roof — the dwelling is on the top storey. +_ELMHURST_PARTY_ROOF_CODES: frozenset[str] = frozenset({"S", "A", "NR"}) + + +def _elmhurst_roof_is_exposed(roof: Optional[ElmhurstRoofDetails]) -> bool: + """Whether a flat's roof is an exposed (heat-loss) external roof. + + The dual of the floor's "Another dwelling below" signal: the roof is + a party ceiling only when its Elmhurst code is S / A / NR (a dwelling + or non-residential space above). A plain external roof — including a + "PS Pitched, sloping ceiling" with no room-in-roof — is exposed, so + the flat sits on the top storey.""" + if roof is None: + return False + return _leading_code(roof.roof_type) not in _ELMHURST_PARTY_ROOF_CODES + + def _elmhurst_dwelling_type( *, built_form: str, property_type: str, floor: Optional[ElmhurstFloorDetails], + roof: Optional[ElmhurstRoofDetails], room_in_roof: Optional[ElmhurstRoomInRoof], ) -> str: """Compose `EpcPropertyData.dwelling_type` from the Elmhurst Summary's - property-type + attachment + floor-location + RR presence. + property-type + attachment + floor-location + roof-type + RR presence. For HOUSES: returns `f"{built_form} {property_type.lower()}"` — the historical contract ("Mid-Terrace house", "Detached house"). For FLATS: derives the floor-position prefix ("Top-floor", - "Mid-floor", "Ground-floor") from `floor.location` + RR presence: - - floor lodges "dwelling below" → roof exposed (RR present or - external roof) → Top-floor; roof party (no RR/external) → - Mid-floor; + "Mid-floor", "Ground-floor") from `floor.location` + roof exposure: + - floor lodges "dwelling below" → roof exposed (RR present OR an + external roof type, per `_elmhurst_roof_is_exposed`) → Top-floor; + roof party (dwelling above, no RR) → Mid-floor; - floor not over another dwelling → Ground-floor. + Reading the roof TYPE (not just room-in-roof presence) is the dual of + reading the floor location: a top-floor flat can have a plain external + sloping ceiling and no room-in-roof, which the RR-only test wrongly + routed to Mid-floor (dropping the roof heat-loss term). + The cascade's `_dwelling_exposure` (cert_to_inputs.py) is prefix- matched on the lowercase result; correct flat-prefix detection is the gate for floor / roof party-surface routing (RdSAP 10 §5). @@ -2339,7 +2367,7 @@ def _elmhurst_dwelling_type( return f"{built_form} {property_type.lower()}".strip() 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 + has_exposed_roof = room_in_roof is not None or _elmhurst_roof_is_exposed(roof) if has_dwelling_below and has_exposed_roof: position = "Top-floor" elif has_dwelling_below: