Merge pull request #1610 from Hestia-Homes/fix/1606-water-only-c6-half-standing-charge

[1592] Include the C6 half standing charge for water-only heat networks
This commit is contained in:
Daniel Roth 2026-07-15 15:21:23 +01:00 committed by GitHub
commit c54ee4cad7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 261 additions and 22 deletions

View file

@ -1995,16 +1995,15 @@ def _is_community_heating_hw_from_main(epc: EpcPropertyData) -> bool:
def _heat_network_standing_charge_gbp(
epc: EpcPropertyData, main: Optional[MainHeatingDetail]
) -> Optional[float]:
"""SAP 10.2 Table 12 note (l) + §C3.2 heat-network standing charge, or
None when the dwelling is not on a heat network (caller then falls back
to the fuel-based `additional_standing_charges_gbp`).
"""SAP 10.2 Table 12 note (l) + §C6 heat-network standing charge that
REPLACES the dwelling's fuel-based standing, or None when no replacement
applies (`_standing_charges_total_gbp` then composes the fuel-based
`additional_standing_charges_gbp` itself).
A heat network carries the Table 12 £120/yr standing charge regardless
of the network fuel full when the SPACE heating is on the network
(§C3.2 "the total standing charge is the normal heat network standing
charge"), halved to £60 when ONLY DHW is provided by the heat network
(note (l)). This REPLACES the fuel-based gas/off-peak standing for a
heat-network main, so it must not be added on top of
(§C6 p.55 "the total standing charge is the normal heat network standing
charge"). Being a TOTAL, it must not be added on top of
`additional_standing_charges_gbp` (which would double-count: a
Summary-path community-gas main lodges Table-32 code 1 and already
draws the £120 gas standing). Worksheet-validated: simulated case 14
@ -2013,6 +2012,17 @@ def _heat_network_standing_charge_gbp(
The API path under-counted this: an EPC community fuel (e.g. 20 = mains
gas community) is not a Table-32 gas code, so `_is_gas_code` returned
False and the standing came out £0 cert 9390 lost the whole £120.
The £60 branch is reachable ONLY for WHC 914 (`_water_heating_main`
returns Main 2 for 914 alone, and 901/902's Main 1 would already have
hit the branch above). Main 2 is a MAIN heating system, so it carries a
space-heating fraction meaning the space heating IS also a heat
network, which is precisely C6's "unless", pointing at the full £120.
#1608 tracks that; it is deliberately left as-is here.
NOTE: this is NOT the C6 half charge for water-only networks (WHC
950/951/952) that one is additive and lives in
`_standing_charges_total_gbp`.
"""
if is_heat_network_main(main):
return _HEAT_NETWORK_STANDING_CHARGE_GBP
@ -2021,6 +2031,49 @@ def _heat_network_standing_charge_gbp(
return None
def _standing_charges_total_gbp(
epc: EpcPropertyData,
main: Optional[MainHeatingDetail],
*,
main_fuel_code: Optional[int],
water_heating_fuel_code: Optional[int],
tariff: Tariff,
) -> float:
"""Total annual standing charge (£/yr) for worksheet (251) — the single
source of truth for how the heat-network and fuel-based standing charges
compose.
SAP 10.2 §C6 (PDF p.54-55) settles the composition, and phrases its two
cases deliberately differently:
- Space heating on a heat network "the TOTAL standing charge IS the
normal heat network standing charge" — a REPLACEMENT. The dwelling's own
fuel standing does not apply on top (see
`_heat_network_standing_charge_gbp`).
- DHW-only heat network "INCLUDE one-half of the normal heat network
standing charge IN the calculation of fuel costs unless the space heating
is also a heat network" — ADDITIVE. Only the full case is worded as a
total, so the half stacks on whatever Table 12 note (a) already charges
the dwelling for its own space-heating fuel (e.g. £24 off-peak electric
for a storage-heater main £84).
The DHW-only half keys off the WATER heating code (950 / 951 / 952), not
the space main: the space main is by definition NOT a network on this
branch, so gating on it (as the £120 branch does) can never fire here.
"""
network_standing = _heat_network_standing_charge_gbp(epc, main)
if network_standing is not None:
return network_standing
fuel_standing = additional_standing_charges_gbp(
main_fuel_code=main_fuel_code,
water_heating_fuel_code=water_heating_fuel_code,
tariff=tariff,
)
if epc.sap_heating.water_heating_code in _WATER_HEAT_NETWORK_ONLY_CODES:
return fuel_standing + _HEAT_NETWORK_STANDING_CHARGE_GBP / 2.0
return fuel_standing
def _main_heating_efficiency(epc: EpcPropertyData) -> float:
"""SAP 10.2 (206) main system 1 efficiency as a 0..1 fraction.
@ -7680,15 +7733,12 @@ def _fuel_cost(
table_32_unit_price_p_per_kwh(60) * _PENCE_TO_GBP
)
heat_network_standing = _heat_network_standing_charge_gbp(epc, main)
standing = (
heat_network_standing
if heat_network_standing is not None
else additional_standing_charges_gbp(
main_fuel_code=main_fuel_code,
water_heating_fuel_code=water_heating_fuel_code,
tariff=tariff,
)
standing = _standing_charges_total_gbp(
epc,
main,
main_fuel_code=main_fuel_code,
water_heating_fuel_code=water_heating_fuel_code,
tariff=tariff,
)
# Worksheet display convention: when a row's kWh is zero (no main 2, no
@ -8555,16 +8605,16 @@ def cert_to_inputs(
immersion_high_rate_fraction=_hw_immersion_high_frac,
)
_hw_extra_standing = 0.0
_heat_network_standing = _heat_network_standing_charge_gbp(epc, main)
standing_charges_total = (
_heat_network_standing
if _heat_network_standing is not None
else additional_standing_charges_gbp(
_standing_charges_total_gbp(
epc,
main,
main_fuel_code=_main_fuel_code(main),
water_heating_fuel_code=_water_heating_fuel_code(epc),
tariff=_rdsap_tariff(epc),
)
) + _hw_extra_standing
+ _hw_extra_standing
)
# SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution
# pumping electricity (worksheet (313)/(372)/(472)). None for

View file

@ -62,11 +62,13 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_heat_network_dlf, # pyright: ignore[reportPrivateUsage]
_heat_network_factor_fuel_code, # pyright: ignore[reportPrivateUsage]
_heat_network_standing_charge_gbp, # pyright: ignore[reportPrivateUsage]
_standing_charges_total_gbp, # pyright: ignore[reportPrivateUsage]
_main_fuel_code, # pyright: ignore[reportPrivateUsage]
_mev_decentralised_kwh_per_yr_from_cert, # pyright: ignore[reportPrivateUsage]
_mev_default_data_iuf, # pyright: ignore[reportPrivateUsage]
_TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S, # pyright: ignore[reportPrivateUsage]
dimensions_from_cert,
fuel_cost_section_from_cert,
_table_12_factor_fuel_code, # pyright: ignore[reportPrivateUsage]
_table_12a_system_for_main, # pyright: ignore[reportPrivateUsage]
_is_electric_main, # pyright: ignore[reportPrivateUsage]
@ -9104,6 +9106,174 @@ def test_non_heat_network_main_returns_none_so_caller_uses_fuel_standing() -> No
assert _heat_network_standing_charge_gbp(epc, main) is None
def test_water_only_heat_network_half_charge_adds_to_the_dwellings_fuel_standing() -> None:
# Arrange — SAP 10.2 §C6 (PDF p.54): "Include one-half of the normal heat
# network standing charge in the calculation of fuel costs unless the space
# heating is also a heat network." WHC 950 is a DHW-only heat network, and
# this dwelling's space heating is electric storage (SAP 401) — not a
# network — so the "unless" does not fire and the half charge applies.
#
# C6 says "INCLUDE [...] IN the calculation of fuel costs", in deliberate
# contrast to the full case's "the TOTAL standing charge IS the normal heat
# network standing charge" (next paragraph, p.55). Only the full case is
# phrased as a replacement, so the half is ADDITIVE to whatever Table 12
# note (a) already charges the dwelling for its own fuel. Note (a): "The
# standing charge for off-peak electricity is added to space and water
# heating costs where either main heating or hot water uses off-peak
# electricity" — a Dual meter + SAP 401 is §12 Rule 2 → 7-hour → Table 32
# code 32 = £24/yr.
#
# Half of the RdSAP10 Table 32 code-51 heat-network row (£120/yr) is £60.
# Total £84. Cert 22032926 (22 Gwydyr Mansions) is the corpus instance.
from dataclasses import replace
from domain.sap10_calculator.tables.table_12a import Tariff
base = _typical_semi_detached_epc()
storage_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29, # electricity
heat_emitter_type=0,
emitter_temperature=1,
main_heating_control=2401,
main_heating_category=7, # electric storage heaters
sap_main_heating_code=401,
)
epc = replace(
base,
sap_energy_source=replace(base.sap_energy_source, meter_type="1"), # Dual
sap_heating=make_sap_heating(
main_heating_details=[storage_main],
water_heating_code=950, # DHW-only heat network (boilers)
),
)
# Act
total = _standing_charges_total_gbp(
epc,
storage_main,
main_fuel_code=29,
water_heating_fuel_code=51, # Table 32 heat-network row
tariff=Tariff.SEVEN_HOUR,
)
# Assert — £24 off-peak electric (note (a)) + £60 half network (C6).
assert abs(total - 84.0) <= 1e-9
def test_water_only_code_on_a_heat_network_space_main_takes_the_full_charge_not_both() -> None:
# Arrange — SAP 10.2 §C6's "unless" clause. A water-only WHC (950) can sit
# on a cert whose SPACE main is ALSO a heat network (SAP 301). C6 gives the
# half "unless the space heating is also a heat network (see next
# paragraph)", and that next paragraph (p.55) says "the total standing
# charge is the normal heat network standing charge" — £120 flat.
#
# So the half must NOT stack onto the full charge here: £180 would be the
# double-count this branch exists to prevent, and £120 is not additive with
# the dwelling's fuel standing either ("the TOTAL standing charge IS").
from dataclasses import replace
from domain.sap10_calculator.tables.table_12a import Tariff
base = _typical_semi_detached_epc()
network_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=20, # EPC mains gas (community)
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2306,
main_heating_category=6,
sap_main_heating_code=301, # community boilers — a heat network
)
epc = replace(
base,
sap_heating=make_sap_heating(
main_heating_details=[network_main],
water_heating_code=950,
water_heating_fuel=20,
),
)
# Act
total = _standing_charges_total_gbp(
epc,
network_main,
main_fuel_code=20,
water_heating_fuel_code=20,
tariff=Tariff.STANDARD,
)
# Assert — the full £120, not £120 + £60.
assert abs(total - 120.0) <= 1e-9
def test_water_only_heat_network_half_charge_reaches_the_fuel_cost_worksheet() -> None:
# Arrange — the §C6 half charge must reach worksheet (251), not just the
# helper. A gas-boiler main (SAP 102, not a network) with WHC 950 keeps the
# dwelling on the STANDARD tariff, so `_fuel_cost` returns the real
# (240)..(255) result rather than the off-peak zero sentinel.
#
# Table 12 note (a): "The standing charge for gas fuels is added to space
# and water heating costs where the gas fuel is used for space heating" →
# Table 32 code 1 = £120. Plus the C6 half (£60) = £180.
from dataclasses import replace
base = _typical_semi_detached_epc()
epc = replace(
base,
sap_heating=make_sap_heating(
main_heating_details=[_gas_boiler_detail(sap_main_heating_code=102)],
water_heating_code=950, # DHW-only heat network (boilers)
),
)
# Act
fc = fuel_cost_section_from_cert(epc)
# Assert
assert fc is not None
assert abs(fc.additional_standing_charges_gbp - 180.0) <= 1e-9
def test_water_only_heat_network_half_charge_reaches_the_off_peak_standing_scalar() -> None:
# Arrange — cert 22032926 (22 Gwydyr Mansions) in shape: electric storage
# main (SAP 401) on a Dual meter + WHC 950. Off-peak certs take the zero
# `FuelCostResult` sentinel from `_fuel_cost` and cost via the scalar
# `standing_charges_gbp` instead, so the §C6 half must reach BOTH paths —
# this is the one the corpus cert actually bills through.
#
# The lodged `water_heating_fuel` 20 ("mains gas (community)") is NOT a
# Table 32 gas code, so note (a) draws no gas standing — the dwelling has
# no gas meter, only the Economy-7 electric one. Hence £24 today.
from dataclasses import replace
base = _typical_semi_detached_epc()
storage_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29, # electricity
heat_emitter_type=0,
emitter_temperature=1,
main_heating_control=2401,
main_heating_category=7, # electric storage heaters
sap_main_heating_code=401,
)
epc = replace(
base,
sap_energy_source=replace(base.sap_energy_source, meter_type="1"), # Dual
sap_heating=make_sap_heating(
main_heating_details=[storage_main],
water_heating_code=950, # DHW-only heat network (boilers)
water_heating_fuel=20, # EPC "mains gas (community)", as lodged
),
)
# Act
standing: float = cert_to_inputs(epc).standing_charges_gbp
# Assert — £24 off-peak electric (Table 12 note (a)) + £60 half (C6).
assert abs(standing - 84.0) <= 1e-9
def test_mev_default_data_iuf_is_table_329_system_type_10_value() -> None:
# Arrange / Act — SAP 10.2 Table 4g note 3 (PDF p.176) directs the
# default SFP to "the appropriate in-use factor for default data from

View file

@ -367,7 +367,26 @@ _MIN_WITHIN_HALF_SAP = 0.788
# 100031768368 SAP 59.12 -> 61.21 (lodged 61, now within 0.5); 217091901
# 60.82 -> 61.59 (lodged 62, now an exact match). 14 corpus certs lodge
# exactly 280mm solid brick. Unit-pinned in test_rdsap_uvalues.
_MAX_SAP_MAE = 0.628
# MAE 0.627 -> 0.625 (within-0.5 held at 78.8%) via the SAP 10.2 §C6
# water-only heat-network standing charge: "Include one-half of the normal
# heat network standing charge in the calculation of fuel costs unless the
# space heating is also a heat network" (p.54). The half was gated on the
# SPACE main being a network, which WHC 950/951/952 never are by
# definition, so it could not fire. C6 phrases the half as "INCLUDE [...]
# IN the calculation of fuel costs" against the full case's "the TOTAL
# standing charge IS", so it is ADDITIVE to the dwelling's own Table 12
# note (a) fuel standing rather than replacing it.
# Blast radius is 1 cert: of the three WHC-950 certs, only 22032926 (22
# Gwydyr Mansions, electric storage main 401) has a non-network space
# main. £24 Economy-7 -> £24 + £60 = £84; SAP 63.737 -> 60.328 (lodged 61),
# |err| 2.737 -> 0.672. The other two (mains 304 / 301) are on network
# space mains and correctly keep the full £120 via the first branch.
# NOTE: this lands on a spec argument, not an oracle — there is no
# accredited Elmhurst worksheet for a water-only network yet (#1592-C).
# The -0.672 overshoot is expected to move again when the C4 plant-
# efficiency and C3.2 pumping siblings land. Unit-pinned in
# test_cert_to_inputs.
_MAX_SAP_MAE = 0.626
_MAX_CO2_MAE_TONNES = 0.072 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 3.0 # kWh / m2 / yr vs energy_consumption_current