mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
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:
commit
c54ee4cad7
3 changed files with 261 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue