diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 4a3dc895..12c33830 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -6,6 +6,7 @@ from datatypes.epc.surveys.elmhurst_site_notes import ( AlternativeWall, BathsAndShowers, BuildingPartDimensions, + CommunityHeating, ElmhurstSiteNotes, ExtensionPart, FloorDetails, @@ -1239,6 +1240,7 @@ class ElmhurstSiteNotesExtractor: else None ) main_heating_2 = self._extract_main_heating_2() + community_heating = self._extract_community_heating() return MainHeating( heat_emitter=self._local_str(lines, "Heat Emitter"), fuel_type=self._local_str(lines, "Fuel Type"), @@ -1254,6 +1256,7 @@ class ElmhurstSiteNotesExtractor: main_heating_ees=self._local_str(lines, "Main Heating EES Code"), secondary_heating_sap_code=secondary_code, main_heating_2=main_heating_2, + community_heating=community_heating, ) def _extract_main_heating_2(self) -> Optional[MainHeating2]: @@ -1304,6 +1307,38 @@ class ElmhurstSiteNotesExtractor: main_heating_sap_code=main_heating_sap_code, ) + def _extract_community_heating(self) -> Optional[CommunityHeating]: + """§14.1 Community Heating/Heat Network block. Lodged in place of + §14.1 Main Heating2 when the §14.0 Main Heating SAP code names a + heat-network row (Table 4a 301/302/304). Returns None when no + §14.1 Community Heating block is present on the cert. + + The block carries the Community Heat Source (Boilers / CHP / + Heat pump) + Community Fuel Type (Mains Gas / Electricity / + Mineral oil or biodiesel / Coal) — together these resolve the + Table 12 heat-network fuel code that bills the cascade. See + `_resolve_community_heating_fuel_code` in the mapper. + """ + lines = self._section_lines( + "14.1 Community Heating/Heat Network", "14.2 Meters", + ) + # Absence of the §14.1 Community Heating block: no marker found + # → `_section_lines` returns []. Lodgement convention also + # leaves Community Heat Source empty on individually-heated + # dwellings; treat both as "no community heating present". + heat_source = self._local_str(lines, "Community Heat Source") + if not lines or not heat_source: + return None + return CommunityHeating( + heating_type=self._local_str(lines, "Heating Type"), + pcdf_boiler_reference=self._local_val(lines, "PCDF Boiler Reference"), + community_heat_source=heat_source, + community_fuel_type=self._local_str(lines, "Community Fuel Type"), + heating_controls_ees=self._local_str(lines, "Heating Controls EES"), + heating_controls_sap=self._local_str(lines, "Heating Controls SAP"), + chp_fuel_factor=self._local_val(lines, "CHP Fuel Factor"), + ) + def _extract_meters(self) -> Meters: return Meters( electricity_meter_type=self._str_val("Electricity meter type"), diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index f569b4c7..8e17ae52 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -476,6 +476,50 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # cascade-side §A.2.2 efficiency or tariff-routing gap; pinned as # forcing function for follow-up. _CorpusExpectation(variant='no system', block='11a', expected_sap_resid=+1.1783, expected_cost_resid_gbp=-27.1485, expected_co2_resid_kg=-49.8272, expected_pe_resid_kwh=-562.4367), + # Slice S0380.170 unblocked the 5 community-heating variants. Per + # SAP 10.2 Table 12 (PDF p.189) the heat-network fuel code comes + # from the §14.1 Community Heat Source × Community Fuel Type pair: + # `Boilers × Mains Gas` → 51, `CHP × *` → 48 (fuel-agnostic), + # `Heat pump × Electricity` → 41. New CommunityHeating dataclass + # on `ElmhurstSiteNotes.main_heating` + extractor `_extract_ + # community_heating()` + mapper `_resolve_community_heating_fuel_ + # code(heat_source, fuel)` + dispatch wired before the strict-raise. + # + # CH1 (301 / Boilers / Mains Gas / code 51): cascade lands ~£14 + # under-cost; the gap is the missing electricity-for-heat- + # distribution kWh stream not propagating to (340)/(342) at the + # heat-network rate. CO2/PE residuals reflect the heat-network + # overall CO2 / PE factor calc not yet matching Elmhurst's (386)/ + # (486) blended-factor cascade. + # + # CH2/CH4 (302 / CHP / fuel / code 48): cascade overshoots SAP by + # +4.5 because it treats CHP+Boilers as 100% CHP at 2.97 p/kWh, + # missing the SAP 10.2 Appendix C 35% CHP / 65% boiler heat- + # fraction split for "Existing CHP (2015+), flexible operation". + # The boiler-side fuel-code dispatch + CHP-credit emissions for + # exported electricity (worksheet rows (464)/(466)) are the next + # cascade-side work. + # + # CH3 (304 / Heat pump / Electricity / code 41): cascade SAP +0.59 + # (same as CH1 — both worksheet SAP=64.2427 with identical Block + # 10b shapes). CO2/PE residuals are large because the cascade + # doesn't yet divide by the community-HP COP — Table 12 code 41 + # carries electricity factors but the worksheet divides delivered + # heat by COP first. + # + # CH6 (302 / CHP / Coal / code 48): same CHP split gap as CH2/CH4 + # but with upstream coal — cascade under-CO2 by ~2935 kg and + # over-PE by ~7865 kWh because the boiler-side code-54 coal CO2/PE + # factors are not applied. + # + # All 5 pinned as forcing functions for follow-up cascade work + # (CHP heat-fraction split, community-HP COP cascade, heat-network + # overall factor calc). Mapper-side closure complete. + _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.5915, expected_cost_resid_gbp=-13.6289, expected_co2_resid_kg=-787.2531, expected_pe_resid_kwh=-3827.1887), + _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=+4.5018, expected_cost_resid_gbp=-103.7279, expected_co2_resid_kg=-1430.3212, expected_pe_resid_kwh=+1506.0355), + _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.5915, expected_cost_resid_gbp=-13.6289, expected_co2_resid_kg=+1613.7837, expected_pe_resid_kwh=+11878.7588), + _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=+4.5018, expected_cost_resid_gbp=-103.7279, expected_co2_resid_kg=-4397.0794, expected_pe_resid_kwh=+494.6090), + _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-3.5201, expected_cost_resid_gbp=+81.1097, expected_co2_resid_kg=-2934.9021, expected_pe_resid_kwh=+7864.5950), ) @@ -495,16 +539,15 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # - Solid-fuel boilers (Table 4a 150-160, 600-636) ×10 # - PCDB-lodged "Bulk LPG" mapper-dict gap ×1 _BLOCKED_BY_MISSING_MAIN_FUEL_TYPE: tuple[str, ...] = ( - 'community heating 1', - 'community heating 2', - 'community heating 3', - 'community heating 4', - 'community heating 6', # Slice S0380.133 unblocked all 10 solid-fuel variants via the # §14.0 EES-code-driven fuel derivation; they now appear in # `_EXPECTATIONS` above with their post-derivation residual pins. # Slice S0380.166 unblocked `pcdb 3` via `"Bulk LPG": 27` in the # Elmhurst label dict; it now lives in `_EXPECTATIONS` at ±0.0000. + # Slice S0380.170 unblocked all 5 community-heating variants via + # the new CommunityHeating extractor field + the §14.1 Heat + # Source × Fuel Type → Table 12 fuel-code dispatch. They now + # appear in `_EXPECTATIONS` with pinned cascade-side residuals. ) @@ -674,9 +717,13 @@ def test_heating_systems_corpus_residual_matches_pin( ) +@pytest.mark.skipif( + not _BLOCKED_BY_MISSING_MAIN_FUEL_TYPE, + reason="all blocked variants have been unblocked (latest: S0380.170)", +) @pytest.mark.parametrize( "variant", - _BLOCKED_BY_MISSING_MAIN_FUEL_TYPE, + _BLOCKED_BY_MISSING_MAIN_FUEL_TYPE or ("__placeholder__",), ids=lambda v: v, ) def test_heating_systems_corpus_blocked_variant_raises_missing_main_fuel_type( @@ -703,3 +750,52 @@ def test_heating_systems_corpus_blocked_variant_raises_missing_main_fuel_type( # Act / Assert with pytest.raises(MissingMainFuelType): cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + + +# S0380.170 — Community heating mapper dispatch coverage tests. +# +# These focused tests document the per-variant resolution path +# independently of the cascade. The parametrized `_EXPECTATIONS` test +# above is the load-bearing assertion that the cascade lands at the +# pinned residual; these unit tests assert the mapper's `main_fuel_type` +# resolves to the correct Table 12 heat-network code per +# `_resolve_community_heating_fuel_code(heat_source, fuel)`. +_COMMUNITY_HEATING_EXPECTED_FUEL_CODES: tuple[tuple[str, int], ...] = ( + # (variant, SAP 10.2 Table 12 fuel code) + ('community heating 1', 51), # Boilers + Mains Gas + ('community heating 2', 48), # CHP + Mains Gas + ('community heating 3', 41), # Heat pump + Electricity + ('community heating 4', 48), # CHP + Mineral oil or biodiesel + ('community heating 6', 48), # CHP + Coal +) + + +@pytest.mark.parametrize( + ("variant", "expected_table_12_code"), + _COMMUNITY_HEATING_EXPECTED_FUEL_CODES, + ids=lambda v: v if isinstance(v, str) else str(v), +) +def test_community_heating_mapper_resolves_table_12_fuel_code( + variant: str, expected_table_12_code: int, +) -> None: + # Arrange — community-heating Summary lodges §14.0 EES='COM' + a + # Table 4a heat-network SAP code, with §14.0 Fuel Type empty. The + # §14.1 Community Heating/Heat Network block carries the upstream + # Heat Source + Fuel Type pair, which the mapper's + # `_resolve_community_heating_fuel_code` translates to a SAP 10.2 + # Table 12 (PDF p.189) heat-network code per the dispatch: + # Boilers + Mains Gas → 51 + # Combined Heat and Power → 48 (fuel-agnostic) + # Heat pump + Electricity → 41 + summary_pdf, _ = _variant_paths(variant) + 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 picks up the Table 12 fuel code derived from the + # §14.1 Community Heat Source + Community Fuel Type pair. + main_heating_details = epc.sap_heating.main_heating_details + assert main_heating_details is not None and len(main_heating_details) >= 1 + assert main_heating_details[0].main_fuel_type == expected_table_12_code diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 8347ce52..37bc348c 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4226,6 +4226,50 @@ _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE: Final[dict[str, int]] = { } +# Elmhurst §14.1 "Community Fuel Type" labels mapped to the SAP 10.2 +# Table 12 heat-network boiler fuel code (PDF p.189). Used when +# `community_heat_source == "Boilers"` — the upstream fuel determines +# which 51-58 row applies. CHP is fuel-agnostic at the Table 12 cost / +# CO2 / PE level (code 48 carries the same factors irrespective of +# upstream fuel); Heat-pump networks always route to code 41. +# +# Spec-correct codes from SAP 10.2 Table 12: +# 51 = heat from boilers — mains gas +# 52 = heat from boilers — LPG +# 53 = heat from boilers — oil +# 54 = heat from boilers — coal +# 43 = heat from boilers — biomass +_ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12: Final[dict[str, int]] = { + "Mains Gas": 51, + "Mineral oil or biodiesel": 53, + "Coal": 54, + "Biomass": 43, +} + + +def _resolve_community_heating_fuel_code( + heat_source: str, community_fuel: str, +) -> Optional[int]: + """Resolve the SAP 10.2 Table 12 (PDF p.189) heat-network fuel code + from the §14.1 "Community Heat Source" + "Community Fuel Type" + pair. Returns None when the heat-source string isn't recognised + (mapper-coverage gap for a future fixture). + + Dispatch table (verified against corpus block 10b/11b/12b/13b): + - "Combined Heat and Power" → 48 (heat from CHP; fuel-agnostic) + - "Heat pump" → 41 (heat from electric heat pump) + - "Boilers" + upstream fuel → 51/52/53/54/43 per + `_ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12` + """ + if heat_source == "Combined Heat and Power": + return 48 + if heat_source == "Heat pump": + return 41 + if heat_source == "Boilers": + return _ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12.get(community_fuel) + return None + + class UnmappedElmhurstLabel(ValueError): """An Elmhurst Summary lodged a finite-enum label that the mapper does not yet know how to translate to the SAP10 cascade enum. @@ -4679,6 +4723,22 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating: and mh.main_heating_ees in _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE ): main_fuel_int = _ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE[mh.main_heating_ees] + # Community heating: §14.0 lodges EES='COM' + a Table 4a heat-network + # SAP code (301/302/304) but no §14.0 Fuel Type. The §14.1 Community + # Heating/Heat Network block carries the actual heat source (Boilers + # / CHP / Heat pump) + upstream fuel (Mains Gas / Electricity / + # Mineral oil or biodiesel / Coal) which together resolve the + # Table 12 heat-network fuel code (PDF p.189, codes 41/43/48/51-58). + # Cascade routes through `_is_heat_network_main` (which keys on the + # SAP code) for the DLF and seasonal-efficiency overrides. + if ( + main_fuel_int is None + and mh.community_heating is not None + ): + main_fuel_int = _resolve_community_heating_fuel_code( + mh.community_heating.community_heat_source, + mh.community_heating.community_fuel_type, + ) heat_emitter_int = _elmhurst_heat_emitter_int( mh.heat_emitter, main_floor=survey.floor, diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index 15464adc..eb2b8885 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -246,6 +246,41 @@ class MainHeating2: main_heating_sap_code: Optional[int] = None +@dataclass +class CommunityHeating: + """Elmhurst §14.1 "Community Heating/Heat Network" block. Lodged + when the §14.0 Main Heating SAP code identifies a heat-network row + (Table 4a 301-304). Mutually exclusive with `MainHeating2` at the + §14.1 level (the extractor closes §14.0 at whichever §14.1 form + appears first). + + The §14.0 "Main Heating SAP Code" identifies the Table 4a category + (301 = community boilers, 302 = CHP + boilers, 304 = community heat + pump), but the fuel that ultimately bills the cascade comes from + the Community Fuel Type field combined with the Community Heat + Source. See SAP 10.2 Table 12 (PDF p.189) heat-network fuel codes: + + - Boilers + Mains Gas → code 51 + - Boilers + Mineral oil → code 53 + - Boilers + Coal → code 54 + - Boilers + Biomass → code 43 + - Combined Heat and Power → code 48 (fuel-agnostic) + - Heat pump + Electricity → code 41 + """ + + heating_type: str = "" # "Space and Water Heating" + pcdf_boiler_reference: Optional[str] = None + community_heat_source: str = "" # "Boilers" / "Combined Heat and Power" / "Heat pump" + community_fuel_type: str = "" # "Mains Gas" / "Electricity" / "Mineral oil or biodiesel" / "Coal" + heating_controls_ees: str = "" + heating_controls_sap: str = "" + # SAP 10.2 Appendix C — CHP Fuel Factor lookup label. Drives the + # CHP-vs-boiler heat-fraction split when `community_heat_source == + # "Combined Heat and Power"`. Absent on non-CHP networks (e.g. + # CH1 boilers-only / CH3 heat-pump only). + chp_fuel_factor: Optional[str] = None + + @dataclass class MainHeating: heat_emitter: str # e.g. "Radiators" @@ -289,6 +324,11 @@ class MainHeating: # the §14.1 block is absent OR lodges only placeholder zeros (PCDB- # only certs). See `MainHeating2` docstring above. main_heating_2: Optional[MainHeating2] = None + # §14.1 "Community Heating/Heat Network" block — Optional, lodged + # in place of Main Heating2 when the §14.0 SAP code identifies a + # heat-network row (Table 4a 301/302/304). Mutually exclusive with + # `main_heating_2`. None on individually-heated dwellings. + community_heating: Optional[CommunityHeating] = None @dataclass