From 5c2158e6c459d2dd3e05f34c42d5cbcaf66a2f51 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 11:21:01 +0000 Subject: [PATCH] Slice S0380.171: CHP heat-fraction split for community heating cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the +£104 cost / +4.5 SAP gap on CH2/CH4 (community heating with CHP-fed mains-gas / oil boilers) by implementing the RdSAP 10 §C / SAP 10.2 Appendix C (PDF p.58) default heat-fraction split: "If CHP (waste heat or geothermal treat as CHP): - fraction of heat from CHP = 0.35 - CHP overall efficiency 75% - heat to power ratio = 2.0 - boiler efficiency 80%" Verified against the corpus block 9b lodgement: CH2 worksheet (303a) = 0.3500 + (303b) = 0.6500 + (305) = 1.00 + (306) DLF = 1.45. The worksheet block 10b cost cascade applies (340a) = (307a) × CHP_price (Table 12 code 48 = 2.97 p/kWh) + (340b) = (307b) × boiler_price (Table 12 codes 51-58 = 4.24 p/kWh) with (307a) = 0.35 × (307), (307b) = 0.65 × (307). Pre-slice the cascade dispatched single-fuel code 48 (CHP) for every CHP variant and billed 100% of heat at 2.97 p/kWh, under-charging by ~£104/yr versus the worksheet's 35% × 2.97 + 65% × 4.24 = 3.7945 p/kWh blended rate. Three layers wired: 1. Datatype — new fields on `MainHeatingDetail`: - `community_heating_chp_fraction: Optional[float]` - `community_heating_boiler_fuel_type: Optional[int]` None on individually-heated dwellings + non-CHP heat networks (Boilers-only + Heat-pump networks bill at a single Table 12 code via main_fuel_type, unchanged path). 2. Mapper — new `_elmhurst_community_chp_split(community)` helper + `_RDSAP_COMMUNITY_CHP_FRACTION_DEFAULT = 0.35` constant. When the §14.1 Community Heat Source is "Combined Heat and Power": returns (0.35, boiler_fuel_code) where boiler_fuel_code is resolved from the §14.1 Community Fuel Type via the existing `_ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12` dispatch (gas → 51, oil → 53, coal → 54). 3. Cascade — `_fuel_cost_gbp_per_kwh` now returns `chp_frac × CHP_price + (1 - chp_frac) × boiler_price` when both new fields are set on Main 1. Per [[feedback-spec- citation-in-commits]] the implementation cites RdSAP 10 §C verbatim. Non-CHP heat networks + individually-heated certs route through the existing single-fuel-code branch unchanged. 5 new AAA tests parametrized over the 5 CH corpus variants in `test_community_heating_mapper_populates_chp_split_fields` assert the per-variant (chp_fraction, boiler_fuel_code) populates correctly. Closures vs pre-S0380.171 residuals (heating-systems corpus block 11b): variant ΔSAP Δcost status CH1 (Boilers/Gas) +0.5915 -£13.63 unchanged (no CHP split) CH2 (CHP/Gas) +4.50→-0.0076 -£104→+£0.17 ✓ CLOSED CH3 (HP/Elec) +0.5915 -£13.63 unchanged (no CHP split) CH4 (CHP/Oil) +4.50→-0.0076 -£104→+£0.17 ✓ CLOSED CH6 (CHP/Coal) -3.52→-8.03 +£81→+£185 REGRESSED The CH6 regression is exposed (not caused) by the spec-correct split: pre-slice CH6 sat at -3.52 SAP / +£81 by coincidence — the cascade's CHP-only pricing (2.97 p/kWh) cancelled with cascade DLF=1.45 (Table 12c age G default) against the CH6 worksheet's lodged DLF=1.0. Per [[feedback-software-no-special-handling]] apply the spec-correct fix uniformly; the pre-fix near-zero was an offsetting-bugs artifact, not a deliberate non-spec rule. The CH6 worksheet (306) DLF=1.0 is a cert-side quirk not currently surfaced through the Summary PDF: CH4 and CH6 §14 lodgements are IDENTICAL except for Community Fuel Type ("Mineral oil or biodiesel" vs "Coal"), yet CH6's worksheet (306) = 1.0000 while CH4's = 1.4500. The Elmhurst engine appears to override DLF for the coal-CHP combo via a path not visible in the Summary; a follow-up slice will need to either (a) add a §17 assessor-lodged DLF extractor or (b) extend the mapper's age-band → DLF dispatch with a community-fuel-specific override. CO2 / PE residuals on all 5 CH variants are unchanged — this slice touches cost only. The CO2 / PE cascade still needs: (1) the CHP electricity-credit line (worksheet (464)/(466)/(364)/(366) per SAP 10.2 §13b spec — displaced-electricity reduction), (2) community-HP COP cascade for CH3 (Table 12 code 41 PE/CO2 isn't divided by COP), and (3) heat-network overall blended-factor (486)/(386) calc. Test baseline at HEAD: 926 pass + 1 skipped (was 921 + 1 at predecessor 9f0d23ad). Pyright net-zero on affected files (epc_property_data.py, mapper.py, cert_to_inputs.py, test_heating_systems_corpus.py + elmhurst_site_notes.py): 65 → 65. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 88 ++++++++++++++++++- datatypes/epc/domain/epc_property_data.py | 9 ++ datatypes/epc/domain/mapper.py | 47 ++++++++++ .../sap10_calculator/rdsap/cert_to_inputs.py | 24 ++++- 4 files changed, 164 insertions(+), 4 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 8e17ae52..b3b298ed 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -46,6 +46,7 @@ import re import subprocess from dataclasses import dataclass from pathlib import Path +from typing import Optional import pytest @@ -515,11 +516,41 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # 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. + # + # Slice S0380.171 closed the CHP heat-fraction split for CH2 / CH4 + # via RdSAP 10 §C / SAP 10.2 Appendix C (PDF p.58 default 35% CHP / + # 65% boilers when no PCDB record). New MainHeatingDetail fields + # `community_heating_chp_fraction` + `community_heating_boiler_ + # fuel_type` populated by the Elmhurst mapper from §14.1 Community + # Heat Source + Community Fuel Type; cascade `_fuel_cost_gbp_per_ + # kwh` blends 0.35 × CHP_price + 0.65 × boiler_price when the + # fields are set. CH2 / CH4 cost gap −£104 → +£0.17 (~1e-3 of + # worksheet); SAP +4.50 → −0.008. + # + # CH6 regression (-3.52 SAP / +£81 → -8.03 / +£185) is exposed by + # the spec-correct split. Pre-slice the CHP-only pricing (2.97 p/ + # kWh) cancelled with cascade DLF=1.45 (Table 12c age G default) + # vs the CH6 worksheet's lodged DLF=1.0 — the offset-bugs + # cancellation hid the gap. Post-slice the blended price (3.79 + # p/kWh) shows the true magnitude of the DLF mismatch. CH6 + # Summary §14.1 is otherwise IDENTICAL to CH4 (only the Community + # Fuel Type "Coal" vs "Mineral oil or biodiesel" differs), but + # CH6's worksheet (306) = 1.0000 while CH4's = 1.4500 — a cert- + # side quirk not currently surfaced through the Summary PDF. Per + # [[feedback-software-no-special-handling]] apply spec-correct + # fix uniformly; CH6 closure needs a separate slice for the + # assessor-lodged DLF override. + # + # CO2 / PE residuals on the 5 CH variants are unchanged (CHP-split + # touches cost only; CO2 / PE need (1) CHP electricity-credit line + # (worksheet (464)/(466)/(364)/(366) per SAP 10.2 §13b spec) + + # (2) community-HP COP cascade for CH3 + (3) heat-network overall + # factor (486)/(386) calc — separate follow-up slices). _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 2', block='11b', expected_sap_resid=-0.0076, expected_cost_resid_gbp=+0.1744, 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), + _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=-0.0076, expected_cost_resid_gbp=+0.1744, expected_co2_resid_kg=-4397.0794, expected_pe_resid_kwh=+494.6090), + _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-8.0295, expected_cost_resid_gbp=+185.0120, expected_co2_resid_kg=-2934.9021, expected_pe_resid_kwh=+7864.5950), ) @@ -799,3 +830,54 @@ def test_community_heating_mapper_resolves_table_12_fuel_code( 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 + + +# S0380.171 — Community heating CHP-split mapper coverage tests. +# +# Per RdSAP 10 §C / SAP 10.2 Appendix C (PDF p.58): for CHP+boilers +# heat networks without a PCDB record, the heat split defaults to 35% +# CHP + 65% boilers. The mapper populates both fields on Main 1 so the +# cascade's `_fuel_cost_gbp_per_kwh` returns a blended price weighted +# by the heat fractions. Non-CHP heat networks leave both fields None +# (single-fuel-code path stays unchanged). +_COMMUNITY_HEATING_EXPECTED_CHP_SPLIT: tuple[ + tuple[str, Optional[float], Optional[int]], ... +] = ( + # (variant, chp_fraction, boiler_fuel_code) + ('community heating 1', None, None), # Boilers only — no split + ('community heating 2', 0.35, 51), # CHP + Mains Gas boilers + ('community heating 3', None, None), # Heat pump only — no split + ('community heating 4', 0.35, 53), # CHP + Oil boilers + ('community heating 6', 0.35, 54), # CHP + Coal boilers +) + + +@pytest.mark.parametrize( + ("variant", "expected_chp_fraction", "expected_boiler_fuel_code"), + _COMMUNITY_HEATING_EXPECTED_CHP_SPLIT, + ids=lambda v: v if isinstance(v, str) else str(v), +) +def test_community_heating_mapper_populates_chp_split_fields( + variant: str, + expected_chp_fraction: Optional[float], + expected_boiler_fuel_code: Optional[int], +) -> None: + # Arrange — CHP+boilers heat networks lodge "Combined Heat and + # Power" in §14.1 Community Heat Source. Per RdSAP 10 §C the + # mapper sets chp_fraction = 0.35 + resolves the boiler fuel code + # from the §14.1 Community Fuel Type (Mains Gas → 51, Mineral oil + # → 53, Coal → 54). Boilers-only and Heat-pump networks leave both + # fields None — the single main_fuel_type code handles them. + 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_heating_details = epc.sap_heating.main_heating_details + assert main_heating_details is not None and len(main_heating_details) >= 1 + main_1 = main_heating_details[0] + assert main_1.community_heating_chp_fraction == expected_chp_fraction + assert main_1.community_heating_boiler_fuel_type == expected_boiler_fuel_code diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index a8980174..1048bed2 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -109,6 +109,15 @@ class MainHeatingDetail: main_heating_data_source: Optional[int] = None condensing: Optional[bool] = None weather_compensator: Optional[bool] = None + # Community-heating CHP split (RdSAP 10 §C / SAP 10.2 Appendix C): + # when the heat network combines CHP + back-up boilers, the worksheet + # splits heat 35% CHP / 65% boilers and prices each share at its own + # Table 12 fuel-code rate. Populated by the Elmhurst mapper for SAP + # code 302 ("Community heating with CHP") when the §14.1 Community + # Heat Source is "Combined Heat and Power"; None for non-CHP heat + # networks and individually-heated dwellings. + community_heating_chp_fraction: Optional[float] = None + community_heating_boiler_fuel_type: Optional[int] = None @dataclass diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 37bc348c..db35e1c2 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -65,6 +65,7 @@ from domain.sap10_calculator.tables.pcdb import heat_pump_record from datatypes.epc.surveys.elmhurst_site_notes import ( AlternativeWall as ElmhurstAlternativeWall, BuildingPartDimensions as ElmhurstBuildingPartDimensions, + CommunityHeating, ElmhurstSiteNotes, FloorDetails as ElmhurstFloorDetails, MainHeating as ElmhurstMainHeating, @@ -4270,6 +4271,41 @@ def _resolve_community_heating_fuel_code( return None +# RdSAP 10 §C / SAP 10.2 Appendix C default CHP heat fraction (PDF p.58). +# Spec text verbatim: "If CHP (waste heat or geothermal treat as CHP): +# fraction of heat from CHP = 0.35; CHP overall efficiency 75%; heat to +# power ratio = 2.0; boiler efficiency 80%." Applied when no PCDB +# record overrides — the modal case for non-PCDB community-heated certs. +_RDSAP_COMMUNITY_CHP_FRACTION_DEFAULT: Final[float] = 0.35 + + +def _elmhurst_community_chp_split( + community: Optional[CommunityHeating], +) -> tuple[Optional[float], Optional[int]]: + """Return the (chp_fraction, boiler_fuel_code) pair for the cascade + to use when computing CHP+boilers heat-network cost / CO2 / PE. + + Returns (None, None) when: + - the §14.1 block is absent (individually-heated dwelling); + - the §14.1 Heat Source is not CHP (Boilers-only or Heat-pump + networks bill at a single Table 12 code via the main fuel). + Returns (0.35, boiler_fuel_code) for CHP+boilers configurations. + The boiler fuel code is resolved from the §14.1 Community Fuel + Type via `_ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12`; per Table + 12 PDF p.189 all heat-network-boiler codes 51-58 carry the same + cost rate (4.24 p/kWh) but distinct CO2 / PE factors keyed on the + upstream fuel. + """ + if community is None: + return None, None + if community.community_heat_source != "Combined Heat and Power": + return None, None + boiler_code = _ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12.get( + community.community_fuel_type, + ) + return _RDSAP_COMMUNITY_CHP_FRACTION_DEFAULT, boiler_code + + 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. @@ -4781,6 +4817,15 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating: 1 for s in survey.baths_and_showers.showers if s.outlet_type != "Electric shower" ) + # Community heating CHP-split: RdSAP 10 §C / SAP 10.2 Appendix C + # default for heat networks combining CHP and back-up boilers + # (SAP code 302 "Community heating with CHP" + §14.1 Community Heat + # Source = "Combined Heat and Power"). Per RdSAP 10 PDF p.58: 35% + # heat from CHP, 65% from boilers (default when no PCDB record). + # The cascade prices each share at its own Table 12 fuel-code rate. + chp_fraction, chp_boiler_fuel_int = _elmhurst_community_chp_split( + mh.community_heating, + ) main_1_detail = MainHeatingDetail( has_fghrs=survey.renewables.flue_gas_heat_recovery_present, # Prefer SAP integer codes when the Elmhurst string maps @@ -4808,6 +4853,8 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating: # 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, + community_heating_chp_fraction=chp_fraction, + community_heating_boiler_fuel_type=chp_boiler_fuel_int, ) # §14.1 Main Heating2 — second main system, when lodged. Typically # services DHW via `Water Heating SapCode 914` ("from second main diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 09b06e2d..79b370d0 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1509,7 +1509,29 @@ def _fuel_cost_gbp_per_kwh( main: Optional[MainHeatingDetail], prices: PriceTable ) -> float: """Convert main-fuel unit price → £/kWh using the supplied price - table. Unknown fuel falls back to mains gas per the table's default.""" + table. Unknown fuel falls back to mains gas per the table's default. + + For CHP+boilers community heating (RdSAP 10 §C / SAP 10.2 Appendix + C — PDF p.58 default 35% CHP / 65% boilers when no PCDB record), + returns the heat-fraction-weighted blended price of the CHP fuel + code + the upstream boiler fuel code. The Elmhurst worksheet block + 10b verifies this exactly: (340) = (307a) × CHP_price + (307b) × + boiler_price = (307) × [chp_frac × CHP_price + (1 - chp_frac) × + boiler_price]. Per [[feedback-spec-citation-in-commits]] the rule + is RdSAP 10 §C verbatim. + """ + if ( + main is not None + and main.community_heating_chp_fraction is not None + and main.community_heating_boiler_fuel_type is not None + ): + chp_frac = main.community_heating_chp_fraction + chp_price = prices.unit_price_p_per_kwh(_main_fuel_code(main)) + boiler_price = prices.unit_price_p_per_kwh( + main.community_heating_boiler_fuel_type, + ) + blended_p = chp_frac * chp_price + (1.0 - chp_frac) * boiler_price + return blended_p * _PENCE_TO_GBP return prices.unit_price_p_per_kwh(_main_fuel_code(main)) * _PENCE_TO_GBP