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 7438fb00..3abbd7f5 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -236,6 +236,44 @@ def test_summary_001479_mapper_extensions_count_matches_extension_bps() -> None: assert len(epc.sap_building_parts) == 3 +def test_summary_001431_electric_1_underfloor_heating_resolves_to_in_screed_per_rdsap_10_section_10_11() -> None: + # Arrange — Heating-systems corpus fixture 001431 / "electric 1" lodges + # §14.0 "Heat Emitter: Underfloor Heating" (bare form, no subtype + # qualifier). Per RdSAP 10 Specification §10.11 Table 29 page 56 + # ("Heating and hot water parameters"): + # + # "Underfloor heating: If dwelling has a ground floor, then + # according to the floor construction (see Table 19 if unknown): + # - solid, main property age band A to E: concrete slab + # - solid, main property age band F to M: in screed + # - suspended timber: in timber floor + # - suspended, not timber: in screed" + # + # Property 001431 lodges §9.0 Floors as "Type: S Solid" + §3.0 Date + # Built "G 1983-1990" (age band G ∈ F-M), so the spec rule resolves + # to "in screed" → SAP10.2 Table 4d emitter enum 2 (R=0.75). + # + # Pre-slice the Elmhurst mapper passed the raw "Underfloor Heating" + # string through `_elmhurst_heat_emitter_int`'s `dict.get` (which + # returned None for the bare lodging) and then through to the + # MainHeatingDetail's `heat_emitter_type` field, which made the + # cascade strict-raise at `_responsiveness` for any of the 2 + # corpus variants lodging this form (`electric 1` + `oil 6`). + summary_pdf = ( + Path(__file__).parents[3] + / "sap worksheets/heating systems examples/electric 1/Summary_001431.pdf" + ) + pages = _summary_pdf_to_textract_style_pages(summary_pdf) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Assert + main_1 = epc.sap_heating.main_heating_details[0] + assert main_1.heat_emitter_type == 2 + + def test_summary_001479_main_party_wall_construction_is_cavity_unfilled() -> None: # Arrange — cert 001479 Main §7 Walls lodges "Party Wall Type: CU # Cavity masonry unfilled". The Elmhurst leading-code map previously diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index efe3a7fd..2ce63201 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3836,8 +3836,78 @@ def _elmhurst_main_fuel_int(fuel_type: str) -> Optional[int]: return _ELMHURST_MAIN_FUEL_TO_SAP10.get(fuel_type) -def _elmhurst_heat_emitter_int(emitter: str) -> Optional[int]: - return _ELMHURST_HEAT_EMITTER_TO_SAP10.get(emitter) +def _resolve_elmhurst_underfloor_subtype( + main_floor: ElmhurstFloorDetails, + main_age_band: str, +) -> int: + """RdSAP 10 Specification §10.11 Table 29 page 56 — derive the + underfloor-heating subtype from the main BP's floor construction + + age band when the Elmhurst Summary §14.0 lodges the bare + "Underfloor Heating" lodging form (no subtype qualifier). + + Spec rule verbatim: + + "Underfloor heating: If dwelling has a ground floor, then according + to the floor construction (see Table 19 if unknown): + - solid, main property age band A to E: concrete slab + - solid, main property age band F to M: in screed + - suspended timber: in timber floor + - suspended, not timber: in screed + Otherwise (i.e. upper floor flats), take floor as suspended" + + Returns the SAP10.2 Table 4d emitter int code: + 2 = Underfloor (in screed above insulation) → R=0.75 + 3 = Underfloor (timber floor) → R=1.0 + + The "concrete slab" branch (R=0.25 per Table 4d) has no cert-side + enum entry yet — strict-raise per [[reference-unmapped-sap-code]] + so a future age A-E + solid + underfloor fixture surfaces the gap + loudly instead of silently routing through `in screed`. + """ + floor_type = main_floor.floor_type + age_letter = main_age_band[0] if main_age_band else "" + is_solid = floor_type.startswith("S Solid") or floor_type == "Solid" + is_suspended_timber = ( + floor_type.startswith("T Suspended timber") + or floor_type == "Suspended timber" + ) + is_suspended_not_timber = ( + floor_type.startswith("N Suspended, not timber") + or floor_type == "Suspended, not timber" + ) + if is_solid: + if age_letter in {"A", "B", "C", "D", "E"}: + raise UnmappedElmhurstLabel( + "main_heating.heat_emitter", + ( + f"Underfloor heating on solid floor + age band " + f"{main_age_band!r} resolves to 'concrete slab' per " + f"RdSAP 10 §10.11 Table 29 (p.56) — SAP10.2 Table 4d " + f"R=0.25 — but no cert-side enum entry exists yet" + ), + ) + return 2 # solid + F-M → in screed + if is_suspended_timber: + return 3 # suspended timber → in timber floor + if is_suspended_not_timber: + return 2 # suspended not timber → in screed + # Upper-floor flat (no ground floor) → "take floor as suspended" + # → in screed per spec (defaults to suspended-not-timber side). + return 2 + + +def _elmhurst_heat_emitter_int( + emitter: str, + main_floor: Optional[ElmhurstFloorDetails] = None, + main_age_band: Optional[str] = None, +) -> Optional[int]: + if emitter in _ELMHURST_HEAT_EMITTER_TO_SAP10: + return _ELMHURST_HEAT_EMITTER_TO_SAP10[emitter] + # RdSAP 10 §10.11 Table 29 (p.56): bare "Underfloor heating" lodging + # derives subtype from main floor construction + age band. + if emitter == "Underfloor Heating" and main_floor is not None and main_age_band is not None: + return _resolve_elmhurst_underfloor_subtype(main_floor, main_age_band) + return None # Elmhurst boiler flue-type strings → SAP10 integer codes. Codes mirror @@ -4294,7 +4364,11 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating: and mh.main_heating_sap_code in _ELECTRIC_SAP_MAIN_HEATING_CODES ): main_fuel_int = _STANDARD_ELECTRICITY_FUEL_CODE - heat_emitter_int = _elmhurst_heat_emitter_int(mh.heat_emitter) + heat_emitter_int = _elmhurst_heat_emitter_int( + mh.heat_emitter, + main_floor=survey.floor, + main_age_band=survey.construction_age_band, + ) sap_control_int = _elmhurst_sap_control_code(sap_control) main_heating_category = _elmhurst_main_heating_category(mh, pcdb_index) # Strict-raise mirror of [[unmapped-api-code]] — when Main 1 has