mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
ea4728c6f6
commit
5c2158e6c4
4 changed files with 164 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue