diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index a8b1893f..2523acb0 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -1346,6 +1346,8 @@ class ElmhurstSiteNotesExtractor: fan_assisted_flue=self._local_bool(lines, "Fan Assisted Flue"), percentage_of_heat=pct, main_heating_sap_code=main_heating_sap_code, + heat_emitter=self._local_str(lines, "Heat Emitter"), + heating_controls_sap=self._local_str(lines, "Main Heating Controls Sap"), ) def _extract_community_heating(self) -> Optional[CommunityHeating]: diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 3d85d19a..d7cb95b2 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4863,6 +4863,8 @@ def _map_elmhurst_main_heating_2( mh2: Optional[ElmhurstMainHeating2], *, fallback_fuel_type: Union[int, str, None] = None, + main_floor: Optional[ElmhurstFloorDetails] = None, + main_age_band: Optional[str] = None, ) -> Optional[MainHeatingDetail]: """Build a `MainHeatingDetail` from the Elmhurst §14.1 Main Heating2 block. Returns None when no Main 2 is lodged (extractor convention: @@ -4917,20 +4919,32 @@ def _map_elmhurst_main_heating_2( category = _ELMHURST_HEATING_CATEGORY_HEAT_PUMP elif pcdb_index is not None and mh2.fuel_type in _ELMHURST_GAS_BOILER_FUEL_TYPES: category = _ELMHURST_HEATING_CATEGORY_GAS_BOILER + # §14.1 lodges Main 2's own "Heat Emitter" + "Main Heating Controls + # Sap" when the two systems heat different parts of the dwelling + # (simulated case 6: Main 1 radiators / 2106, Main 2 underfloor / + # 2110). Map them through the same helpers as Main 1 so the SAP 10.2 + # p.186 two-systems-different-parts MIT can read system 2's + # responsiveness (underfloor → emitter 2 → R=0.75) + control type. + # Empty-string sentinels preserved for the legacy DHW-only Main 2 + # (cert 000565: §14.1 omits emitter/control → consumers key off + # Main 1). + emitter_int = _elmhurst_heat_emitter_int( + mh2.heat_emitter, main_floor=main_floor, main_age_band=main_age_band + ) + control_int = _elmhurst_sap_control_code(mh2.heating_controls_sap) return MainHeatingDetail( # Main 2 doesn't carry its own FGHRS lodgement in §14.1; the # cert-level renewables block is the single source of truth and # is already wired into Main 1. has_fghrs=False, main_fuel_type=resolved_fuel, - # §14.1 doesn't lodge a heat emitter (the emitter is Main 1's - # radiator/UFH); leave as empty-string sentinel for cascade - # consumers that key off Main 1's emitter. - heat_emitter_type="", + heat_emitter_type=( + emitter_int if emitter_int is not None else mh2.heat_emitter + ), emitter_temperature="", fan_flue_present=mh2.fan_assisted_flue, boiler_flue_type=_elmhurst_flue_type_int(mh2.flue_type), - main_heating_control="", + main_heating_control=control_int if control_int is not None else "", main_heating_category=category, main_heating_number=2, main_heating_fraction=mh2.percentage_of_heat, @@ -5127,7 +5141,10 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating: # system") while Main 1 handles space heat. None when the §14.1 # block is absent or lodges only placeholder zeros. main_2_detail = _map_elmhurst_main_heating_2( - mh.main_heating_2, fallback_fuel_type=main_1_detail.main_fuel_type + mh.main_heating_2, + fallback_fuel_type=main_1_detail.main_fuel_type, + main_floor=survey.floor, + main_age_band=survey.construction_age_band, ) main_heating_details = ( [main_1_detail, main_2_detail] diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index eb2b8885..f524ac79 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -244,6 +244,15 @@ class MainHeating2: fan_assisted_flue: bool = False percentage_of_heat: int = 0 main_heating_sap_code: Optional[int] = None + # §14.1 "Heat Emitter" (e.g. "Underfloor Heating") + "Main Heating + # Controls Sap" (e.g. "SAP code 2110, ..."). Lodged when the two main + # systems serve different parts of the dwelling with their own + # emitter + control (simulated case 6: Main 1 radiators / control + # 2106, Main 2 underfloor / control 2110). Needed for the SAP 10.2 + # p.186 two-systems-different-parts MIT (weighted responsiveness + + # elsewhere two-control blend). + heat_emitter: str = "" + heating_controls_sap: str = "" @dataclass diff --git a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index a6952cb4..57937a58 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -283,6 +283,24 @@ def test_section_3_roof_windows_case6_match_pdf() -> None: ) +def test_case6_main_2_emitter_and_control_extracted() -> None: + """Simulated case 6's §14.1 Main Heating2 lodges its OWN emitter + ("Underfloor Heating") and control ("SAP code 2110, ...") — the two + main systems heat different parts (Main 1 radiators/2106 living, Main + 2 underfloor/2110 elsewhere). Pre-S0380.204 the extractor + mapper + dropped both (emitter='' / control=''), so the SAP 10.2 p.186 two- + systems-different-parts MIT could not read system 2's responsiveness + (underfloor → emitter 2 → R=0.75) or control type (2110 → type 3).""" + # Arrange / Act + epc = _w001431_case6.build_epc() + main_2 = epc.sap_heating.main_heating_details[1] + + # Assert — emitter 2 (underfloor in screed → Table 4d R=0.75) + + # control 2110 (Table 4e type 3 zone control). + assert main_2.heat_emitter_type == 2 + assert main_2.main_heating_control == 2110 + + def test_section_4f_pumps_fans_case6_match_pdf() -> None: """(231) pumps/fans pin for simulated case 6 — a DUAL-oil-boiler detached dwelling. Worksheet (231) = 356 = (230c) central heating