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