diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 061d972f..67e87ff8 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -12,6 +12,7 @@ from datatypes.epc.surveys.elmhurst_site_notes import ( FloorDimension, Lighting, MainHeating, + MainHeating2, Meters, PropertyDetails, Renewables, @@ -1071,6 +1072,7 @@ class ElmhurstSiteNotesExtractor: and int(secondary_raw) > 0 else None ) + main_heating_2 = self._extract_main_heating_2() return MainHeating( heat_emitter=self._local_str(lines, "Heat Emitter"), fuel_type=self._local_str(lines, "Fuel Type"), @@ -1084,6 +1086,55 @@ class ElmhurstSiteNotesExtractor: heat_pump_age=self._local_val(lines, "Heat pump age"), main_heating_sap_code=main_heating_sap_code, secondary_heating_sap_code=secondary_code, + main_heating_2=main_heating_2, + ) + + def _extract_main_heating_2(self) -> Optional[MainHeating2]: + """§14.1 Main Heating2 block — returns None when the block is + either absent or lodges only placeholder zeros (the PCDB-only + convention for "no Main 2"). Otherwise builds a populated + `MainHeating2` from the lodged §14.1 fields. + + Identifier signal: Main 2 is "present" when the §14.1 block + lodges either a non-zero PCDB boiler reference (e.g. cert 000565 + Main 2 PCDB 15100 Vaillant Ecotec plus 415) OR a non-zero SAP + code. PCDB-only certs lodge `PCDF boiler Reference = 0` + + `Main Heating SAP Code = 0` for an absent Main 2 (per the two + JSON fixtures at `elmhurst_site_notes_{1,2}_text.json`). + """ + lines = self._section_lines( + "14.1 Main Heating2", "14.1 Community Heating", + ) + pcdf_raw = self._local_val(lines, "PCDF boiler Reference") + pcdf_first = ( + pcdf_raw.split()[0] if pcdf_raw and pcdf_raw.split() else "" + ) + has_pcdb_ref = pcdf_first.isdigit() and int(pcdf_first) > 0 + sap_code_raw = self._local_val(lines, "Main Heating SAP Code") + main_heating_sap_code: Optional[int] = None + if sap_code_raw is not None: + head = sap_code_raw.split()[0] if sap_code_raw.split() else "" + if head.isdigit(): + v = int(head) + main_heating_sap_code = v if v > 0 else None + if not has_pcdb_ref and main_heating_sap_code is None: + return None + # §14.1's "Percentage of Heat" lodges either "0 %" (with space) + # or "0%" (no space). Strip the '%' before int() rather than + # split() so both forms parse. + pct_raw = self._local_val(lines, "Percentage of Heat") + pct = ( + int(pct_raw.rstrip("%").strip().split()[0]) + if pct_raw and pct_raw.rstrip("%").strip() + else 0 + ) + return MainHeating2( + pcdf_boiler_reference=pcdf_raw, + fuel_type=self._local_str(lines, "Fuel Type"), + flue_type=self._local_str(lines, "Flue Type"), + fan_assisted_flue=self._local_bool(lines, "Fan Assisted Flue"), + percentage_of_heat=pct, + main_heating_sap_code=main_heating_sap_code, ) def _extract_meters(self) -> Meters: diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 963f5abc..7f268796 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -68,6 +68,7 @@ from datatypes.epc.surveys.elmhurst_site_notes import ( ElmhurstSiteNotes, FloorDetails as ElmhurstFloorDetails, MainHeating as ElmhurstMainHeating, + MainHeating2 as ElmhurstMainHeating2, Renewables as ElmhurstRenewables, RoofDetails as ElmhurstRoofDetails, RoomInRoof as ElmhurstRoomInRoof, @@ -3804,6 +3805,64 @@ def _elmhurst_main_heating_category( return None +def _map_elmhurst_main_heating_2( + mh2: Optional[ElmhurstMainHeating2], +) -> Optional[MainHeatingDetail]: + """Build a `MainHeatingDetail` from the Elmhurst §14.1 Main Heating2 + block. Returns None when no Main 2 is lodged (extractor convention: + placeholder zeros → None). + + Same identifier strict-raise as Main 1 — if Main 2 lodges fields + but neither the PCDB index nor the SAP code identifies the heat + source, surface the gap here rather than as a silent cascade + fall-through to gas-boiler default. + + The category derivation mirrors `_elmhurst_main_heating_category` + for Main 1: PCDB Table 362 membership → category 4 (heat pump); + PCDB-referenced boiler on a gas fuel → category 2. + """ + if mh2 is None: + return None + pcdb_index = _elmhurst_pcdb_boiler_index(mh2.pcdf_boiler_reference) + if mh2.main_heating_sap_code is None and pcdb_index is None: + raise UnmappedElmhurstLabel( + "main_heating_2", + ( + f"§14.1 Main Heating2 has neither PCDF boiler reference " + f"({mh2.pcdf_boiler_reference!r}) nor SAP code " + f"({mh2.main_heating_sap_code!r}); cannot identify the " + f"heat source" + ), + ) + main_fuel_int = _elmhurst_main_fuel_int(mh2.fuel_type) + category: Optional[int] = None + if pcdb_index is not None and heat_pump_record(pcdb_index) is not None: + 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 + 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=main_fuel_int if main_fuel_int is not None else mh2.fuel_type, + # §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="", + emitter_temperature="", + fan_flue_present=mh2.fan_assisted_flue, + boiler_flue_type=_elmhurst_flue_type_int(mh2.flue_type), + main_heating_control="", + main_heating_category=category, + main_heating_number=2, + main_heating_fraction=mh2.percentage_of_heat, + main_heating_index_number=pcdb_index, + main_heating_data_source=1 if pcdb_index is not None else None, + sap_main_heating_code=mh2.main_heating_sap_code, + ) + + def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating: mh = survey.main_heating sap_control = mh.heating_controls_sap @@ -3868,38 +3927,47 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating: water_heating_fuel = _elmhurst_main_fuel_int( survey.water_heating.water_heating_fuel_type, ) + main_1_detail = MainHeatingDetail( + has_fghrs=survey.renewables.flue_gas_heat_recovery_present, + # Prefer SAP integer codes when the Elmhurst string maps + # cleanly — the cascade only reads ints for fuel-cost, + # PE-factor, and CO2-factor lookups; strings fall through + # to defaults that drop the standing-charge component. + main_fuel_type=main_fuel_int if main_fuel_int is not None else mh.fuel_type, + heat_emitter_type=heat_emitter_int if heat_emitter_int is not None else mh.heat_emitter, + emitter_temperature=_elmhurst_emitter_temperature_int(mh.design_flow_temperature), + fan_flue_present=mh.fan_assisted_flue, + boiler_flue_type=_elmhurst_flue_type_int(mh.flue_type), + main_heating_control=sap_control_int if sap_control_int is not None else control, + central_heating_pump_age=_elmhurst_pump_age_int(mh.heat_pump_age), + central_heating_pump_age_str=mh.heat_pump_age, + main_heating_category=main_heating_category, + main_heating_number=1, + # Per RdSAP, a PCDB-listed boiler is data source 1 + # (manufacturer measured efficiency); the integer index + # number drives PCDB lookup in the cascade. + main_heating_index_number=pcdb_index, + main_heating_data_source=1 if pcdb_index is not None else None, + # §14.0 "Main Heating SAP Code" — Table 4a integer + # identifying Main 1 when no PCDB boiler reference is + # lodged (e.g. heat pump SAP code 224 on cert 000565). + # The cascade's `seasonal_efficiency` reads this when + # there is no PCDB Table 105/362 record to override. + sap_main_heating_code=mh.main_heating_sap_code, + ) + # §14.1 Main Heating2 — second main system, when lodged. Typically + # services DHW via `Water Heating SapCode 914` ("from second main + # 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) + main_heating_details = ( + [main_1_detail, main_2_detail] + if main_2_detail is not None + else [main_1_detail] + ) return SapHeating( instantaneous_wwhrs=InstantaneousWwhrs(), - main_heating_details=[ - MainHeatingDetail( - has_fghrs=survey.renewables.flue_gas_heat_recovery_present, - # Prefer SAP integer codes when the Elmhurst string maps - # cleanly — the cascade only reads ints for fuel-cost, - # PE-factor, and CO2-factor lookups; strings fall through - # to defaults that drop the standing-charge component. - main_fuel_type=main_fuel_int if main_fuel_int is not None else mh.fuel_type, - heat_emitter_type=heat_emitter_int if heat_emitter_int is not None else mh.heat_emitter, - emitter_temperature=_elmhurst_emitter_temperature_int(mh.design_flow_temperature), - fan_flue_present=mh.fan_assisted_flue, - boiler_flue_type=_elmhurst_flue_type_int(mh.flue_type), - main_heating_control=sap_control_int if sap_control_int is not None else control, - central_heating_pump_age=_elmhurst_pump_age_int(mh.heat_pump_age), - central_heating_pump_age_str=mh.heat_pump_age, - main_heating_category=main_heating_category, - main_heating_number=1, - # Per RdSAP, a PCDB-listed boiler is data source 1 - # (manufacturer measured efficiency); the integer index - # number drives PCDB lookup in the cascade. - main_heating_index_number=pcdb_index, - main_heating_data_source=1 if pcdb_index is not None else None, - # §14.0 "Main Heating SAP Code" — Table 4a integer - # identifying Main 1 when no PCDB boiler reference is - # lodged (e.g. heat pump SAP code 224 on cert 000565). - # The cascade's `seasonal_efficiency` reads this when - # there is no PCDB Table 105/362 record to override. - sap_main_heating_code=mh.main_heating_sap_code, - ) - ], + main_heating_details=main_heating_details, has_fixed_air_conditioning=survey.ventilation.fixed_space_cooling, shower_outlets=shower_outlets, cylinder_size=_elmhurst_cylinder_size_code( diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index 7c5396a0..cb96c682 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -188,6 +188,29 @@ class Lighting: low_energy_count: int = 0 +@dataclass +class MainHeating2: + """Elmhurst §14.1 "Main Heating2" block. Lodged when a cert carries a + second main heating system — typically to service DHW via + `Water Heating SapCode 914` ("from second main system") while Main 1 + handles space heat. Cert 000565 is the canonical example: Main 1 is + a heat pump (§14.0 SAP code 224, 100% space heat); Main 2 is a gas + combi (§14.1 PCDB 15100 Vaillant Ecotec plus 415, 0% space heat) + + WHC 914 routes DHW to Main 2. + + PCDB-only certs use §14.1 to lodge "0 / 0" placeholder lines for an + absent Main 2 — the extractor returns None in that case so the + mapper can distinguish "no Main 2" from "Main 2 present". + """ + + pcdf_boiler_reference: Optional[str] = None + fuel_type: str = "" + flue_type: str = "" + fan_assisted_flue: bool = False + percentage_of_heat: int = 0 + main_heating_sap_code: Optional[int] = None + + @dataclass class MainHeating: heat_emitter: str # e.g. "Radiators" @@ -217,6 +240,10 @@ class MainHeating: # `SapHeating.secondary_heating_type` to apply the Table 11 # secondary-fraction split; None when no secondary is lodged. secondary_heating_sap_code: Optional[int] = None + # §14.1 "Main Heating2" block — Optional Main 2 system. None when + # the §14.1 block is absent OR lodges only placeholder zeros (PCDB- + # only certs). See `MainHeating2` docstring above. + main_heating_2: Optional[MainHeating2] = None @dataclass