S0380.184: community electric-HP network CO2/PE uses monthly Table 12d/12e — closes CH3

SAP 10.2 worksheet block 12b/13b (367)/(467) for a community heating
electric heat pump (Table 4a code 304 → Table 12 fuel 41 "heat from
electric heat pump"). The HP meters grid electricity, so per Table 12
note (s)/(t) + block 12b/13b footnote (a) its emission/PE factor is the
MONTHLY Table 12d/12e cascade (fuel 41 = standard-electricity profile),
weighted by the network heat profile, then × 1/heat-source-eff (1/COP):

  (367)/(467) = [(307)+(310)] / COP × Σ((307+310)_m × factor_m)/Σ(...)

Per-line walk of CH3 (the displayed (367) 0.1535 / (467) 1.5717 are PDF
artifacts; the (373)/(473) totals reconcile only with):
  CO2 factor = 0.15040 (monthly Table 12d wtd) vs cascade annual 0.136
  PE  factor = 1.55692 (monthly Table 12e wtd) vs cascade annual 1.501

Pre-slice the cascade routed code 304 through the non-electric branch
(`_co2_factor_kg_per_kwh(main) × 1/COP` = annual × scaling). New
`_is_heat_network_electric_main` (heat-network main whose fuel has a
Table 12d monthly set — i.e. fuel 41) routes all four factor helpers
(main + HW, CO2 + PE) through the monthly cascade × 1/COP. Non-electric
heat networks (gas 51 / oil 53 / coal 54) have no monthly set → annual
path unchanged (CH1, CH6 untouched).

Closure (CH3 was already SAP+cost EXACT):
  CH3 (HP/Elec)  CO2 −75.32→+0.0000 (= [(307+310)/3]×(0.1504−0.136)),
                 PE −249.32→−0.0000 (× (1.5569−1.501))  — FULLY EXACT

Corpus now 40/41 EXACT on all four metrics. Only CH6 remains: its
worksheet lodges a manual DLF=1.0 ("two adjoining dwellings") absent
from the Summary PDF (byte-identical to CH4 bar fuel type) — an
architectural limit, not a cascade gap. 2226 pass + 1 skip + 0 fail
(tolerances 1e-4 all metrics); pyright net-zero 43→43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 18:43:16 +00:00
parent 803da062a2
commit 82f7315f8d
3 changed files with 108 additions and 17 deletions

View file

@ -728,9 +728,21 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
# on all four metrics. CH6 SAP 7.49→8.02 / cost +£172.68→+£184.84
# (its HW now also bills the blend, compounding the DLF=1.0 quirk —
# same root, still the separate CH6 DLF front).
#
# Slice S0380.184 closed CH3 (HP/Elec, code 304) CO2 + PE: an
# electric-HP heat network meters grid electricity, so per SAP 10.2
# Table 12 note (s)/(t) + block 12b/13b footnote (a) its (367)/(467)
# factor is the MONTHLY Table 12d/12e (fuel code 41) weighted by the
# network heat profile, then × 1/COP — not the annual 0.136/1.501.
# New `_is_heat_network_electric_main` routes the four factor helpers
# through the monthly cascade for code 304 (fuel 41). CH3 was
# SAP/cost EXACT; CO2 75.32→+0.0000 (= (307+310)/3 × (0.15040.136))
# and PE 249.32→0.0000 (× (1.55691.501)) now EXACT. Non-electric
# heat networks (CH1 gas 51, CH6 coal 54) have no monthly factor set
# → unchanged.
_CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-75.3228, expected_pe_resid_kwh=-249.3161),
_CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000),
_CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=-0.0000),
_CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-8.0219, expected_cost_resid_gbp=+184.8376, expected_co2_resid_kg=+2411.5399, expected_pe_resid_kwh=+5023.4766),
)

View file

@ -898,6 +898,21 @@ def _heat_network_heat_source_efficiency_scaling(
return 1.0 / eff
def _is_heat_network_electric_main(main: Optional[MainHeatingDetail]) -> bool:
"""True when the main is a heat network whose generator runs on grid
electricity (Table 4a code 304 Table 12 fuel code 41 "heat from
electric heat pump"). Such networks meter electricity, so SAP 10.2
Table 12 note (s)/(t) + worksheet block 12b/13b footnote (a) require
the MONTHLY Table 12d/12e factors (not the annual average), weighted
by the network heat profile, before the 1/heat-source-eff (1/COP)
scaling. Non-electric heat networks (gas/oil/coal boilers, codes
51/53/54) have no monthly factor set and keep the annual Table 12
value."""
if not _is_heat_network_main(main):
return False
return co2_monthly_factors_kg_per_kwh(_main_fuel_code(main)) is not None
def _heat_network_dlf(age_band: Optional[str]) -> float:
"""RdSAP 10 §10.11 + SAP 10.2 Table 12c distribution loss factor by
age band. Defaults to the K-or-newer value (1.50) when band missing.
@ -2716,10 +2731,18 @@ def _main_heating_co2_factor_kg_per_kwh(
# heat_source_eff × Table 12 CO2 factor. The cascade meters
# network_input directly so scale the factor by 1/eff to land at
# the spec's fuel-input × factor.
return (
_co2_factor_kg_per_kwh(main)
* _heat_network_heat_source_efficiency_scaling(main)
)
scaling = _heat_network_heat_source_efficiency_scaling(main)
hn_fuel = _main_fuel_code(main)
if _is_heat_network_electric_main(main) and hn_fuel is not None:
# Electric-HP heat network (code 304 / fuel 41): the HP runs
# on grid electricity → MONTHLY Table 12d factors weighted by
# the network heat profile, then × 1/COP (S0380.184).
monthly = _effective_monthly_co2_factor(
main_fuel_monthly_kwh, hn_fuel,
)
if monthly is not None:
return monthly * scaling
return _co2_factor_kg_per_kwh(main) * scaling
if tariff is Tariff.STANDARD:
monthly = _effective_monthly_co2_factor(
main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
@ -2788,10 +2811,17 @@ def _main_heating_primary_factor(
# (467) = network_input × 100 / heat_source_eff × Table 12 PE
# factor; cascade meters network_input directly so scale by
# 1/eff at lookup time.
return (
primary_energy_factor(fuel)
* _heat_network_heat_source_efficiency_scaling(main)
)
scaling = _heat_network_heat_source_efficiency_scaling(main)
if _is_heat_network_electric_main(main) and fuel is not None:
# Electric-HP heat network (code 304 / fuel 41): MONTHLY
# Table 12e factors weighted by the network heat profile,
# then × 1/COP (S0380.184).
monthly = _effective_monthly_pe_factor(
main_fuel_monthly_kwh, fuel,
)
if monthly is not None:
return monthly * scaling
return primary_energy_factor(fuel) * scaling
if tariff is Tariff.STANDARD:
monthly = _effective_monthly_pe_factor(
main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
@ -3056,10 +3086,18 @@ def _hot_water_co2_factor_kg_per_kwh(
# 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)
)
scaling = _heat_network_heat_source_efficiency_scaling(main)
hn_fuel = _main_fuel_code(main)
if _is_heat_network_electric_main(main) and hn_fuel is not None:
# Electric-HP heat network HW (code 304 / fuel 41): MONTHLY
# Table 12d factors weighted by the HW profile, × 1/COP
# (S0380.184) — mirror of the SH branch.
monthly = _effective_monthly_co2_factor(
hw_monthly_kwh, hn_fuel,
)
if monthly is not None:
return monthly * scaling
return _co2_factor_kg_per_kwh(main) * scaling
fuel = _water_heating_fuel_code(epc)
if fuel is None:
return _DEFAULT_CO2_KG_PER_KWH
@ -3112,10 +3150,18 @@ def _hot_water_primary_factor(
# 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)
)
scaling = _heat_network_heat_source_efficiency_scaling(main)
hn_fuel = _main_fuel_code(main)
if _is_heat_network_electric_main(main) and hn_fuel is not None:
# Electric-HP heat network HW (code 304 / fuel 41): MONTHLY
# Table 12e factors weighted by the HW profile, × 1/COP
# (S0380.184) — mirror of the SH branch.
monthly = _effective_monthly_pe_factor(
hw_monthly_kwh, hn_fuel,
)
if monthly is not None:
return monthly * scaling
return primary_energy_factor(_main_fuel_code(main)) * scaling
fuel = _water_heating_fuel_code(epc)
if fuel is None:
return _DEFAULT_PEF

View file

@ -45,6 +45,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage]
_heat_network_dlf, # pyright: ignore[reportPrivateUsage]
_is_electric_main, # pyright: ignore[reportPrivateUsage]
_is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage]
_is_electric_water, # pyright: ignore[reportPrivateUsage]
_is_off_peak_meter, # pyright: ignore[reportPrivateUsage]
_main_floor_u_value, # pyright: ignore[reportPrivateUsage]
@ -244,6 +245,38 @@ def test_heat_network_code_302_chp_effective_factor_per_sap_10_2_block_12b_13b()
assert abs(pe - 1.29455) <= 1e-9
def test_is_heat_network_electric_main_true_only_for_electric_hp_network() -> None:
# Arrange — code 304 community heat pump (Table 12 fuel 41 = "heat
# from electric heat pump", which HAS monthly Table 12d/12e factors)
# vs code 301 community gas boilers (fuel 51, annual-only). SAP 10.2
# Table 12 note (s)/(t): grid-electricity factors vary monthly, so
# the HP network must use Table 12d/12e; the gas-boiler network keeps
# the annual factor.
hp_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=41, # Table 12 fuel 41 = heat from electric HP
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2306,
main_heating_category=6,
sap_main_heating_code=304,
)
gas_boiler_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=51, # Table 12 fuel 51 = heat from gas boilers
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2306,
main_heating_category=6,
sap_main_heating_code=301,
)
# Act / Assert
assert _is_heat_network_electric_main(hp_main) is True
assert _is_heat_network_electric_main(gas_boiler_main) is False
assert _is_heat_network_electric_main(None) is False
def test_heat_network_code_302_effective_factor_none_for_non_302_main() -> None:
# Arrange — a code-301 heat-network boiler main (no CHP split). The
# §12b/13b CHP+boilers blend applies only to code 302; code 301