Slice S0380.173: Community heating HW path routes through main fuel

Closes CH1 (boilers) + CH3 (HP) HW CO2 / PE residuals by routing
the HW cost / CO2 / PE factor lookups through the heat-network main
when WHC ∈ {901, 902, 914} ("HW from main heating system"). Pre-
slice the cascade honoured Elmhurst Summary §15.0's
`water_heating_fuel_type = "Mains gas"` placeholder on community-
heated certs, mis-routing HW through Table 12 code 1 (mains gas,
3.48 p/kWh / 0.21 CO2 / 1.13 PE) instead of the heat-network code
(4.24 p/kWh + Table 12 code 41 / 51 / 53 / 54 with Table 4a heat-
source-eff scaling per S0380.172).

Per SAP 10.2 §C1 + RdSAP 10 §C (PDF p.49 + p.58) the HW heat
delivered by a heat-network main is supplied through the same
network as SH: spec block 10b (342a)/(342b) computes HW cost as
`(310a) × CHP_price + (310b) × boiler_price`, mirroring SH's
(340a)/(340b) split. Block 12b (365)/(366) and 13a (465)/(466)
likewise apply the heat-source-eff division on HW.

Three layers wired:

1. New `_is_community_heating_hw_from_main(epc)` predicate. Gates
   on WHC ∈ {901, 902, 914} + heat-network main + SAP code in
   `_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` table (S0380.172 — only
   301 boilers + 304 HP). SAP 302 (CHP+boilers) is excluded
   because the 35%/65% split needs the displaced-electricity
   credit cascade per spec block 13b (464)/(466) on BOTH SH and HW
   paths — both converge in a single follow-up slice.

2. `_hot_water_fuel_cost_gbp_per_kwh` gains a keyword-only
   `inherit_main_for_community_heating: bool = False` parameter.
   When True, returns `_fuel_cost_gbp_per_kwh(main, prices)` —
   same helper that already applies the S0380.171 CHP blend +
   heat-network rate. The orchestrator passes
   `inherit_main_for_community_heating=_is_community_heating_hw_
   from_main(epc)` at the cost-rate construction site.

3. `_hot_water_co2_factor_kg_per_kwh` and `_hot_water_primary_
   factor` get top-level branches: when the predicate fires, return
   `Table_12_factor × _heat_network_heat_source_efficiency_scaling
   (main)` — same scaled-factor return as the SH path in S0380.172.

Closures (heating-systems corpus block 11b):

  CH1 (Boilers/Gas) ΔPE   −967 → −9    (essentially closed)
  CH1               ΔCO2 −126 → +52    (shifted across worksheet)
  CH3 (HP/Elec)     ΔPE  +1749 → −387  (~78% closure)
  CH3               ΔCO2 +473 → −86    (~82% closure)

Cost / SAP signs flip on CH1 / CH3 (was −£14 / +0.59 SAP, now
+£12 / −0.53 SAP) — HW cost now matches the worksheet's (342) line
exactly, exposing a +£12 lighting / standing overage that was
previously masked by the HW under-charge. Per [[feedback-software-
no-special-handling]] the pre-slice near-zero on CH1 / CH3 cost was
an offsetting-bugs artifact; the spec-correct fix surfaces the real
lighting / standing gap as the next forcing function.

CH2 / CH4 / CH6 (SAP 302) unchanged from S0380.171 / S0380.172 pins
— gated out per the heat-source-eff-table membership check.

Test baseline at HEAD: 926 pass + 1 skipped (was 926 + 1 at
predecessor 36d4bf87). Pyright net-zero on affected files
(cert_to_inputs.py, test_heating_systems_corpus.py): 32 → 32.

Per [[feedback-spec-citation-in-commits]] the rule cites SAP 10.2
§C1 verbatim ("heat from CHP + back-up boilers, via a heat main")
and RdSAP 10 §C defaults (PDF p.58).

🤖 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 12:01:09 +00:00 committed by Jun-te Kim
parent a5eda92a90
commit c17330b319
2 changed files with 97 additions and 3 deletions

View file

@ -562,9 +562,38 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
# the WHC=901 HW path (cascade reads cert-lodged "Mains gas" as
# HW fuel; should fall through to main fuel for community heating)
# + the Elmhurst 0.8523 multiplier on heat-network energy column.
_CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.5915, expected_cost_resid_gbp=-13.6289, expected_co2_resid_kg=-126.4571, expected_pe_resid_kwh=-967.3648),
#
# Slice S0380.173 routed CH1 + CH3 HW cost / CO2 / PE through the
# main heat-network fuel + Table 4a heat-source-eff scaling via a
# new `_is_community_heating_hw_from_main(epc)` predicate (WHC ∈
# {901, 902, 914} + heat-network main + SAP code in
# `_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` table from S0380.172).
# Pre-slice the cascade honoured Elmhurst's §15.0 placeholder
# `water_heating_fuel_type = "Mains gas"` for community-heated
# certs, mis-routing HW through the Mains-gas Table 12 code
# (3.48 p/kWh / 0.21 CO2 / 1.13 PE) instead of the heat-network
# code (4.24 p/kWh + scaled factors). Closures:
#
# CH1 (Boilers/Gas) ΔPE 967 → 9 (essentially closed)
# CH1 ΔCO2 126 → +52 (shift)
# CH3 (HP/Elec) ΔPE +1749 → 387 (~78% closed)
# CH3 ΔCO2 +473 → 86 (~82% closed)
#
# Cost / SAP signs flip on CH1 / CH3 (was £14 / +0.59 SAP, now
# +£12 / 0.53 SAP) — HW cost now matches the worksheet exactly,
# exposing a +£12 lighting / standing overage that was previously
# masked by the HW under-charge. The exposed lighting / standing
# gap is the next closure front (likely the £120 heat-network
# standing charge being applied to lighting kWh instead).
#
# SAP 302 (CHP+boilers) gated out per `_is_community_heating_hw_
# from_main`'s `_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` check — the
# 35%/65% split + displaced-electricity credit must converge on
# both SH and HW in a single follow-up slice. CH2 / CH4 / CH6
# residuals unchanged from S0380.172 / S0380.171 pins.
_CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=-0.5273, expected_cost_resid_gbp=+12.1495, expected_co2_resid_kg=+51.6176, expected_pe_resid_kwh=-9.1529),
_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=+472.5996, expected_pe_resid_kwh=+1748.7395),
_CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=-0.5273, expected_cost_resid_gbp=+12.1495, expected_co2_resid_kg=-85.9334, expected_pe_resid_kwh=-387.0272),
_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),
)

View file

@ -1298,6 +1298,36 @@ def _water_heating_fuel_code(epc: EpcPropertyData) -> Optional[int]:
return _main_fuel_code(_water_heating_main(epc))
def _is_community_heating_hw_from_main(epc: EpcPropertyData) -> bool:
"""True iff the cert's WHC routes HW from the main heating system
(codes 901 / 902 / 914) AND the main is a single-source heat
network with a registered heat-source efficiency
(`_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` currently SAP code 301
boilers and 304 HP).
Elmhurst Summary §15.0 lodges `water_heating_fuel_type = "Mains gas"`
on community-heating certs regardless of the actual heat-network
source without this guard the HW cost / CO2 / PE bills via the
Mains-gas Table 12 code (3.48 p/kWh / 0.21 / 1.13) instead of the
heat-network code (4.24 p/kWh / Table 12 code 41 / 51).
SAP code 302 (CHP+boilers) is excluded because the 35%/65% split
requires the displaced-electricity credit line per spec block 13b
(464)/(466) on the HW side same constraint as `_main_heating_
co2_factor_kg_per_kwh` (S0380.172). Routing HW through main for
SAP 302 without the credit cascade would regress CO2 / PE; both
the SH and HW paths converge in a single follow-up slice that
wires the CHP credit + boiler-side factor split.
"""
if epc.sap_heating.water_heating_code not in _WATER_INHERIT_FROM_MAIN_CODES:
return False
main = _water_heating_main(epc)
if not _is_heat_network_main(main):
return False
code = main.sap_main_heating_code if main is not None else None
return isinstance(code, int) and code in _HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY
def _main_heating_efficiency(epc: EpcPropertyData) -> float:
"""SAP 10.2 (206) main system 1 efficiency as a 0..1 fraction.
@ -1793,6 +1823,8 @@ def _hot_water_fuel_cost_gbp_per_kwh(
main: Optional[MainHeatingDetail],
tariff: Tariff,
prices: PriceTable,
*,
inherit_main_for_community_heating: bool = False,
) -> float:
"""Hot water bills at the *water-heating* fuel's rate. When the
water-heating fuel is electric AND tariff is off-peak, bill at the
@ -1801,7 +1833,17 @@ def _hot_water_fuel_cost_gbp_per_kwh(
not consulted those fuels are single-rate per Table 32. For
cert 000565 HW routes to gas combi via WHC 914 tariff branch
not taken. TODO: Table 12a Grid 1 WH high-rate-fraction split for
electric WH on off-peak (currently uses 100% low rate)."""
electric WH on off-peak (currently uses 100% low rate).
`inherit_main_for_community_heating`: per S0380.173, when WHC
{901, 902, 914} AND main is a heat network, ignore the cert-
lodged HW fuel (which Elmhurst defaults to "Mains gas") and route
HW cost through `_fuel_cost_gbp_per_kwh(main, prices)` same
helper that applies the .171 CHP heat-fraction blend for SAP 302
+ heat-network rate for code 41 / 51 / 53 / 54.
"""
if inherit_main_for_community_heating:
return _fuel_cost_gbp_per_kwh(main, prices)
water_electric = _is_electric_water(water_heating_fuel)
if water_electric and tariff is not Tariff.STANDARD:
return _off_peak_low_rate_gbp_per_kwh(tariff)
@ -2782,6 +2824,17 @@ def _hot_water_co2_factor_kg_per_kwh(
monthly HW fuel kWh the calculator uses an annual-flat HW
efficiency so the SHAPE of fuel monthly is identical to demand
monthly, and `_effective_monthly_co2_factor` is shape-only)."""
# Community heating + WHC ∈ {901, 902, 914}: HW heat is delivered
# through the heat-network main, so HW CO2 must read the same
# Table 12 heat-network code factor as SH, scaled by 1/heat_source_
# eff per spec block 12b (363)/(367). Cert-lodged HW fuel "Mains
# gas" is an Elmhurst placeholder that mis-routes the lookup.
if _is_community_heating_hw_from_main(epc):
main = _water_heating_main(epc)
return (
_co2_factor_kg_per_kwh(main)
* _heat_network_heat_source_efficiency_scaling(main)
)
fuel = _water_heating_fuel_code(epc)
if fuel is None:
return _DEFAULT_CO2_KG_PER_KWH
@ -2819,6 +2872,16 @@ def _hot_water_primary_factor(
exactly to match the Elmhurst worksheet's (278) annual factor.
The 41-variant heating-systems corpus closes its HW PE residual
+25/+48 0 with this gate."""
# Mirror of `_hot_water_co2_factor_kg_per_kwh` community-heating
# branch (S0380.173): WHC ∈ {901, 902, 914} on a heat-network main
# routes HW PE through the same Table 12 heat-network code as SH,
# scaled by 1/heat_source_eff per spec block 13a (463)/(467).
if _is_community_heating_hw_from_main(epc):
main = _water_heating_main(epc)
return (
primary_energy_factor(_main_fuel_code(main))
* _heat_network_heat_source_efficiency_scaling(main)
)
fuel = _water_heating_fuel_code(epc)
if fuel is None:
return _DEFAULT_PEF
@ -5818,11 +5881,13 @@ def cert_to_inputs(
hw_co2_factor = _hw_co2_factor
hw_pe_factor = _hw_pe_factor
else:
_community_hw_inherit = _is_community_heating_hw_from_main(epc)
hw_cost_rate = _hot_water_fuel_cost_gbp_per_kwh(
_water_heating_fuel_code(epc),
_water_heating_main(epc),
_rdsap_tariff(epc),
prices,
inherit_main_for_community_heating=_community_hw_inherit,
)
hw_co2_factor = _hot_water_co2_factor_kg_per_kwh(
epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc),