S0380.180: heat-network distribution pumping electricity (§C3.2) — closes CH1

SAP 10.2 Appendix C §C3.2 (PDF p.51), verbatim: "CO2 emissions and
Primary Energy associated with the electricity used for pumping water
through the distribution system are allowed for by adding electrical
energy equal to 1% of the energy required for space and water heating."

Worksheet line (313) = 0.01 × [(307)+(310)]; its CO2 (372) and PE (472)
bill on the Table 12d/12e monthly factors for fuel code 50 ("electricity
for pumping in distribution network"), weighted by the monthly heat
profile per worksheet footnote (a). (307)m/(310)m = (space_demand +
hw_output) / efficiency (the cascade models a heat network's generator
efficiency as 1/DLF).

This un-defers the (372)/(472) front the post-S0380.179 handover flagged
"don't guess until the factor source is identified": the source is
§C3.2 + Table 12d/12e code 50, NOT an empirical constant. The apparent
0.1994/0.2114 "factor" is an Elmhurst DISPLAY artifact — the worksheet
shows the (372) energy column as 0.01×(307) (space only) while computing
emissions on 0.01×(307+310) per the §C3.2 text. Verified EXACT line-by-
line against the CH2 corpus worksheet: (372)=23.6007 CO2 (rating),
(472)=208.2267 PE (demand).

New `_heat_network_distribution_electricity` helper (gated on
`_is_heat_network_main`) precomputes the energy + effective CO2/PE
factors; three new CalculatorInputs fields + calculator.py CO2/PE
summation terms (0.0/None → no-op for individually-heated certs).

Closures:
  CH1 (Boilers/Gas)  CO2 −23.60→−0.00, PE −208.23→+0.00  — FULLY EXACT
  CH3 (HP/Elec)      CO2 −98.92→−75.32, PE −457.54→−249.32 (distribution
                     component closed; code-304 community-HP COP remains)
  CH2/CH4/CH6        gain their (372)/(472) component (CO2 +23.6, PE
                     +208.2); dominant CHP displaced-electricity credit
                     residual (Table 12f + block 12b/13b) is next slice.

No regression on the other 36 corpus variants (helper returns None off
heat-network mains) + golden + U985 fixtures. 2223 pass + 1 skip + 0
fail; 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:04:16 +00:00
parent ba2d6e1cbb
commit 8452cf9e2d
4 changed files with 223 additions and 5 deletions

View file

@ -674,11 +674,27 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
# distribution" line — 118.38 kWh billed at electricity factors
# (CO2 0.1993, PE 1.760), not heat-network factors — the cascade
# doesn't currently meter this. Next follow-up slice.
_CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-23.6007, expected_pe_resid_kwh=-208.2267),
_CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-1435.0874, expected_pe_resid_kwh=+1123.0063),
_CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-98.9235, expected_pe_resid_kwh=-457.5428),
_CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-4401.8456, expected_pe_resid_kwh=+111.5798),
_CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-7.4942, expected_cost_resid_gbp=+172.6778, expected_co2_resid_kg=-2939.6683, expected_pe_resid_kwh=+7481.5658),
# Slice S0380.180 wired the SAP 10.2 Appendix C §C3.2 (PDF p.51)
# heat-network distribution pumping electricity (worksheet (313) =
# 0.01 × [(307)+(310)]; CO2 (372) / PE (472) on Table 12d/12e fuel-
# code-50 monthly factors weighted by the monthly heat profile).
# CH1 (Boilers/Gas) closes FULLY — the (372)/(472) line was its
# entire remaining residual (un-defers the front the predecessor
# handover flagged "don't guess"; the factor source is §C3.2 +
# Table 12f, not an empirical constant). CH3 (HP/Elec) closes its
# distribution component (CO2 98.92→75.32, PE 457.54→249.32);
# the remainder is the code-304 community-HP COP cascade (separate
# follow-up). CH2/CH4/CH6 gain their (372)/(472) component (CO2
# +23.6, PE +208.2/+208.2/+208.2); their dominant CHP displaced-
# electricity credit residual (Table 12f + block 12b/13b) remains
# for the next slice. Elmhurst DISPLAYS the (372) energy column as
# 0.01 × (307) (space only) but computes emissions on 0.01 ×
# (307+310) per the §C3.2 text — verified EXACT line-by-line.
_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.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-1411.4867, expected_pe_resid_kwh=+1331.2330),
_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 4', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-4378.2449, expected_pe_resid_kwh=+319.8065),
_CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-7.4942, expected_cost_resid_gbp=+172.6778, expected_co2_resid_kg=-2916.0676, expected_pe_resid_kwh=+7689.7925),
)

View file

@ -236,6 +236,17 @@ class CalculatorInputs:
pumps_fans_primary_factor: Optional[float] = None
lighting_primary_factor: Optional[float] = None
electric_shower_primary_factor: Optional[float] = None
# SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution
# pumping electricity. For community-heating mains the network pump
# energy = 1% of (space + water) heat generated (worksheet (313));
# its CO2 / PE (worksheet (372)/(472)) bill on Table 12d/12e monthly
# electricity factors (fuel code 50) weighted by the monthly heat
# profile. The energy + effective factors are precomputed in
# cert_to_inputs. 0.0 / None for individually-heated certs (no
# distribution loop) leaves the cascade unchanged.
heat_network_distribution_kwh_per_yr: float = 0.0
heat_network_distribution_co2_factor_kg_per_kwh: Optional[float] = None
heat_network_distribution_primary_factor: Optional[float] = None
# Generation offsets — applied as a cost credit against the ECF
# numerator. SAP 10.2 Appendix M: PV self-consumption + export
# collapse to a single credit at the export rate (Table 12 code 60).
@ -596,6 +607,13 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
electric_shower_co2 = (
inputs.electric_shower_kwh_per_yr * electric_shower_co2_factor
)
# SAP 10.2 Appendix C §C3.2 (PDF p.51) worksheet (372) — electricity
# for pumping water through a heat network's distribution system.
# Zero for individually-heated certs (factor None → 0.0).
heat_network_distribution_co2 = (
inputs.heat_network_distribution_kwh_per_yr
* (inputs.heat_network_distribution_co2_factor_kg_per_kwh or 0.0)
)
co2 = (
main_heating_co2
+ secondary_heating_co2
@ -603,6 +621,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
+ pumps_fans_co2
+ lighting_co2
+ electric_shower_co2
+ heat_network_distribution_co2
)
# SAP 10.2 Appendix M1 §7 — subtract PV CO2 credit. Onsite consumption
# offsets grid imports at the IMPORT CO2 factor (Table 12d weighted
@ -662,6 +681,12 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
+ inputs.lighting_kwh_per_yr * lighting_primary_factor
+ inputs.electric_shower_kwh_per_yr * electric_shower_primary_factor
)
# SAP 10.2 Appendix C §C3.2 (PDF p.51) worksheet (472) — heat-network
# distribution pumping electricity primary energy (CO2 sister above).
heat_network_distribution_primary_kwh = (
inputs.heat_network_distribution_kwh_per_yr
* (inputs.heat_network_distribution_primary_factor or 0.0)
)
# SAP 10.2 Appendix M1 §8: PV onsite consumption credits at IMPORT
# PEF (offsets grid imports); PV exports credit at the EXPORT PEF
# ("electricity sold to grid, PV" — Table 12 code 60 = 0.501). When
@ -696,6 +721,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
space_heating_primary_kwh
+ hot_water_primary_kwh
+ other_primary_kwh
+ heat_network_distribution_primary_kwh
- pv_primary_offset_kwh,
)
primary_energy_per_m2 = primary_energy_kwh / tfa if tfa > 0 else 0.0
@ -738,6 +764,8 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
"hot_water_co2_kg_per_yr": hot_water_co2,
"pumps_fans_co2_kg_per_yr": pumps_fans_co2,
"lighting_co2_kg_per_yr": lighting_co2,
"heat_network_distribution_co2_kg_per_yr": heat_network_distribution_co2,
"heat_network_distribution_pe_kwh_per_yr": heat_network_distribution_primary_kwh,
"space_heating_pe_kwh_per_m2": space_heating_primary_kwh / tfa if tfa > 0 else 0.0,
"hot_water_pe_kwh_per_m2": hot_water_primary_kwh / tfa if tfa > 0 else 0.0,
"other_pe_kwh_per_m2": other_primary_kwh / tfa if tfa > 0 else 0.0,

View file

@ -913,6 +913,77 @@ def _heat_network_dlf(age_band: Optional[str]) -> float:
raise UnmappedSapCode("heat_network_age_band", age_band)
# SAP 10.2 Table 12 fuel code 50 — "electricity for pumping in
# distribution network". Its CO2 / PE factors vary by month per Table
# 12d / 12e (= standard-electricity profile); worksheet (372)/(472)
# footnote (a) applies the monthly factors weighted by the heat profile.
_ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE: Final[int] = 50
# SAP 10.2 Appendix C §C3.2 (PDF p.51) — pumping energy = 1% of the
# energy required for space and water heating.
_HEAT_NETWORK_PUMPING_FRACTION_OF_HEAT: Final[float] = 0.01
def _heat_network_distribution_electricity(
main: Optional[MainHeatingDetail],
space_heating_monthly_kwh: tuple[float, ...],
hot_water_output_monthly_kwh: tuple[float, ...],
efficiency: float,
) -> Optional[tuple[float, float, float]]:
"""SAP 10.2 Appendix C §C3.2 (PDF p.51) — electricity for pumping
water through a heat network's distribution system.
Spec verbatim: "CO2 emissions and Primary Energy associated with the
electricity used for pumping water through the distribution system
are allowed for by adding electrical energy equal to 1% of the
energy required for space and water heating." Worksheet line (313) =
0.01 × [(307) + (310)]; its CO2 (372) and PE (472) bill on the
Table 12d / 12e monthly factors for fuel code 50 ("electricity for
pumping in distribution network"), weighted by the monthly heat
profile per worksheet footnote (a).
(307)m = space-heating fuel and (310)m = water-heating fuel: for a
heat network the cascade models the heat-generator efficiency as
1/DLF, so fuel = q_useful / efficiency = q_useful × DLF. The
monthly weighting of the Table 12d/12e factor is shape-only (the DLF
scalar cancels), and the energy carries the DLF.
Returns (energy_kwh, co2_factor, pe_factor) for heat-network mains
(Table 4a 301-304 / category 6); None otherwise so the default
0.0 / None fields leave individually-heated certs unchanged.
NB Elmhurst's worksheet DISPLAYS the (372) energy column as 0.01 ×
(307) (space only) but computes the EMISSIONS on 0.01 × (307+310)
per the §C3.2 text verified line-by-line against the community-
heating corpus worksheets. We mirror the spec text (space + water).
"""
if not _is_heat_network_main(main) or efficiency <= 0.0:
return None
distribution_monthly_kwh = tuple(
_HEAT_NETWORK_PUMPING_FRACTION_OF_HEAT * (sh + hw) / efficiency
for sh, hw in zip(
space_heating_monthly_kwh, hot_water_output_monthly_kwh
)
)
energy_kwh = sum(distribution_monthly_kwh)
if energy_kwh <= 0.0:
return None
co2_monthly = co2_monthly_factors_kg_per_kwh(
_ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE
)
pe_monthly = pe_monthly_factors_kwh_per_kwh(
_ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE
)
if co2_monthly is None or pe_monthly is None:
return None
co2_factor = sum(
kwh * f for kwh, f in zip(distribution_monthly_kwh, co2_monthly)
) / energy_kwh
pe_factor = sum(
kwh * f for kwh, f in zip(distribution_monthly_kwh, pe_monthly)
) / energy_kwh
return (energy_kwh, co2_factor, pe_factor)
@dataclass(frozen=True)
class PriceTable:
"""Seam between the spec-correct SAP 10.2/10.3 Table 12 prices and
@ -6125,6 +6196,16 @@ def cert_to_inputs(
tariff=_rdsap_tariff(epc),
) + _hw_extra_standing
# SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution
# pumping electricity (worksheet (313)/(372)/(472)). None for
# individually-heated certs.
heat_network_distribution = _heat_network_distribution_electricity(
main,
space_heating_result.total_space_heating_monthly_kwh,
hw_monthly_kwh_for_factors,
eff,
)
return CalculatorInputs(
dimensions=dim,
heat_transmission=ht,
@ -6214,6 +6295,21 @@ def cert_to_inputs(
epc, energy_requirements_result.secondary_fuel_monthly_kwh,
),
hot_water_co2_factor_kg_per_kwh=hw_co2_factor,
# SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution
# pumping electricity (worksheet (313)/(372)/(472)). 0.0 / None
# on individually-heated certs.
heat_network_distribution_kwh_per_yr=(
heat_network_distribution[0]
if heat_network_distribution is not None else 0.0
),
heat_network_distribution_co2_factor_kg_per_kwh=(
heat_network_distribution[1]
if heat_network_distribution is not None else None
),
heat_network_distribution_primary_factor=(
heat_network_distribution[2]
if heat_network_distribution is not None else None
),
# SAP 10.2 Table 12a Grid 2 (p.191) + Table 12d (p.194): pumps,
# lighting, and the electric-shower end-use all bill via the
# "All other uses" row → on off-peak tariffs blend the high /

View file

@ -41,6 +41,7 @@ from domain.sap10_calculator.exceptions import (
from domain.sap10_calculator.rdsap.cert_to_inputs import (
SAP_10_2_SPEC_PRICES,
_has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage]
_heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage]
_heat_network_dlf, # pyright: ignore[reportPrivateUsage]
_is_electric_main, # pyright: ignore[reportPrivateUsage]
_is_electric_water, # pyright: ignore[reportPrivateUsage]
@ -153,6 +154,83 @@ def test_heat_network_main_applies_table12c_dlf_to_main_heating_efficiency() ->
assert inputs.main_heating_efficiency == pytest.approx(1.0 / 1.41, abs=0.005)
def test_heat_network_distribution_electricity_per_sap_10_2_appendix_c_3_2() -> None:
# Arrange — heat-network main (Table 4a code 301 = community heating,
# category 6). SAP 10.2 Appendix C §C3.2 (PDF p.51): distribution
# pumping electricity = 1% of the (space + water) heat generated,
# i.e. 0.01 × [(307) + (310)] where (307)m/(310)m = (space_demand +
# hw_output) / efficiency. Its CO2 (372) / PE (472) bill on the
# Table 12d / 12e monthly factors for fuel code 50 ("electricity for
# pumping in distribution network"), weighted by the monthly heat
# profile per worksheet footnote (a).
from domain.sap10_calculator.tables.table_12 import (
co2_monthly_factors_kg_per_kwh,
pe_monthly_factors_kwh_per_kwh,
)
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=20,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2106,
main_heating_category=6,
sap_main_heating_code=301,
)
space = (1000.0, 800.0, 600.0, 400.0, 200.0, 0.0,
0.0, 0.0, 0.0, 300.0, 700.0, 1000.0)
hw = (200.0,) * 12
efficiency = 1.0 / 1.45 # heat network models efficiency as 1/DLF
# Act
result = _heat_network_distribution_electricity(main, space, hw, efficiency)
# Assert — energy = 0.01 × Σ((space + hw) / eff) = 0.01 × 7400 × 1.45;
# factors = code-50 monthly weighted by the (space + hw) heat profile.
assert result is not None
energy_kwh, co2_factor, pe_factor = result
distribution_monthly = tuple(
0.01 * (s + w) / efficiency for s, w in zip(space, hw)
)
co2_monthly = co2_monthly_factors_kg_per_kwh(50)
pe_monthly = pe_monthly_factors_kwh_per_kwh(50)
assert co2_monthly is not None
assert pe_monthly is not None
expected_energy = 0.01 * (5000.0 + 2400.0) / efficiency
expected_co2_factor = sum(
d * f for d, f in zip(distribution_monthly, co2_monthly)
) / expected_energy
expected_pe_factor = sum(
d * f for d, f in zip(distribution_monthly, pe_monthly)
) / expected_energy
assert abs(energy_kwh - expected_energy) <= 1e-9
assert abs(energy_kwh - 0.01 * 7400.0 * 1.45) <= 1e-9
assert abs(co2_factor - expected_co2_factor) <= 1e-9
assert abs(pe_factor - expected_pe_factor) <= 1e-9
def test_heat_network_distribution_electricity_none_for_individual_main() -> None:
# Arrange — an individually-heated gas-boiler main (category 2, no
# heat-network SAP code). §C3.2 pumping electricity applies only to
# heat networks, so no distribution line should be emitted.
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=26,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2106,
main_heating_category=2,
)
space = (1000.0,) * 12
hw = (200.0,) * 12
# Act
result = _heat_network_distribution_electricity(main, space, hw, 0.85)
# Assert
assert result is None
def test_heat_network_main_with_hw_from_main_dlf_scales_hot_water_kwh() -> None:
# Arrange — when main heating is a heat network AND water heating
# inherits from main (water_heating_code=901), the HW also incurs