Slice S0380.171: CHP heat-fraction split for community heating cost

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 11:21:01 +00:00 committed by Jun-te Kim
parent ea4728c6f6
commit 5c2158e6c4
4 changed files with 164 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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