From ea4728c6f6ab94862f058df151d0739e4a2dbf39 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 10:50:21 +0000 Subject: [PATCH] Slice S0380.170: Community heating mapper unblock (Table 12 dispatch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the 5 community-heating variants in the heating-systems corpus (community heating 1/2/3/4/6 on property 001431). Pre-slice the mapper returned `MainHeatingDetail.main_fuel_type=''` for every community-heating cert because §14.0 lodges no Fuel Type — only EES 'COM' + a Table 4a heat-network SAP code (301/302/304). The cascade strict-raised `MissingMainFuelType` per S0380.132. The actual fuel that bills the cascade lives in the §14.1 Community Heating/Heat Network block, which the extractor was skipping entirely. SAP 10.2 Table 12 (PDF p.189) defines the heat-network fuel codes: Boilers + Mains Gas → 51 (heat from boilers — mains gas) Boilers + Mineral oil → 53 (heat from boilers — oil) Boilers + Coal → 54 (heat from boilers — coal) Boilers + Biomass → 43 (heat from boilers — biomass) Combined Heat and Power → 48 (heat from CHP; fuel-agnostic) Heat pump + Electricity → 41 (heat from electric heat pump) Per spec text the upstream fuel determines the boiler-side code; CHP is fuel-agnostic at the Table 12 cost / CO2 / PE level. Three layers wired: 1. Survey schema — new `CommunityHeating` dataclass alongside `MainHeating2` carrying the §14.1 fields (heating_type, community_heat_source, community_fuel_type, heating_controls_ees, heating_controls_sap, chp_fuel_factor). Mutually exclusive with `main_heating_2` at the §14.1 level. Attached as `MainHeating.community_heating: Optional[CommunityHeating] = None`. 2. Extractor — new `_extract_community_heating()` method bracketed by "14.1 Community Heating/Heat Network" / "14.2 Meters". Returns None on individually-heated dwellings (no Community Heat Source lodged). Wired into `_extract_main_heating()`. 3. Mapper — new `_resolve_community_heating_fuel_code(heat_source, fuel)` dispatch helper + `_ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12` constant for the boiler upstream-fuel split. Wired in `_map_elmhurst_sap_heating` after the EES-code-to-fuel dispatch and before the strict-raise on absent SAP code. Per the standard slice workflow + [[feedback-aaa-test-convention]]: - 5 new AAA tests in `test_community_heating_mapper_resolves_table_12_ fuel_code` parametrized over the 5 corpus variants, asserting the mapper resolves the expected Table 12 code per variant. - The existing parametrized residual-pin test in `test_heating_systems_corpus_residual_matches_pin` picks up the 5 community-heating variants with cascade-side residuals pinned as forcing functions for follow-up slices: variant dSAP dcost dCO2 dPE CH1 (Boilers/Gas) +0.59 -£14 -787 -3827 CH2 (CHP/Gas) +4.50 -£104 -1430 +1506 CH3 (HP/Elec) +0.59 -£14 +1614 +11879 CH4 (CHP/Oil) +4.50 -£104 -4397 +495 CH6 (CHP/Coal) -3.52 +£81 -2935 +7865 These reflect open cascade-side work (SAP 10.2 Appendix C CHP/ boiler heat-fraction split missing — cascade treats CHP+Boilers as 100% CHP; community-HP COP cascade missing — cascade doesn't divide delivered heat by COP for Table 12 code 41; heat-network overall CO2/PE blended-factor cascade missing — cascade doesn't compute worksheet rows (386)/(486)). Pinned per [[feedback-zero-error-strict]]; follow-up slices close gaps and re-pin smaller residuals. - `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` tuple now empty; the blocked-tier test pytest-skipped via `pytest.mark.skipif` with a reason naming this slice. Test baseline at HEAD: 921 pass + 1 skipped (was 916 + 0 at predecessor 7e08e7af). Pyright net-zero on affected files (elmhurst_site_notes.py, elmhurst_extractor.py, mapper.py, test_heating_systems_corpus.py): 32 → 32. Per [[feedback-spec-citation-in-commits]] the dispatch is grounded in SAP 10.2 Table 12 (PDF p.189). Per [[feedback-bigger-slices-for-uniform-work]] all 5 variants land in one slice — the work is uniform (single Elmhurst label dict + single dispatch helper) and the per-variant residuals surface together because of cascade-side gaps, not mapper-side variation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 --- .../documents_parser/elmhurst_extractor.py | 35 ++++++ .../tests/test_heating_systems_corpus.py | 108 +++++++++++++++++- datatypes/epc/domain/mapper.py | 60 ++++++++++ datatypes/epc/surveys/elmhurst_site_notes.py | 40 +++++++ 4 files changed, 237 insertions(+), 6 deletions(-) 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