S0380.213: heat-network standing charge (£120) — fixes 9390 cost under-count

Cert 9390 (community mains-gas boiler, API main_fuel_type=20) drew £0
standing charge → fuel cost under-counted → SAP read +4 high (71 vs 67).

Root cause: the standing-charge logic (`additional_standing_charges_gbp`)
only knows the GAS branch (`_is_gas_code`) and the off-peak-electric branch.
A heat-network community fuel is not a Table-32 gas code — EPC 20 = "mains
gas (community)" normalises to Table-32 code 20 (biomass), so
`_is_gas_code(20)` is False and the standing came out £0. The Summary path
masks this because it lodges community gas as Table-32 code 1 (ordinary
mains gas), which IS gas-recognised and already draws the £120 gas standing
— so the CH1-6 corpus was unaffected while the API path lost the charge.

Spec basis (verified against SAP 10.2 spec PDF):
- Table 12 (p.191) "Heat networks" row standing charge = £120/yr, note (k).
- Note (l): "Include half this value if only DHW is provided by a heat
  network."
- §C3.2 (p.58): the full charge applies when the space heating is also a
  heat network.
Worksheet-validated: simulated case 14 (community boilers + mains gas,
space + water) → worksheet (351) Additional standing charges = £120.

Fix: new `_heat_network_standing_charge_gbp(epc, main)` returns the
heat-network standing (£120 full when the space main is a heat network;
£60 when only DHW is on the network) or None otherwise. Applied at both
fuel-cost call sites, REPLACING the fuel-based `additional_standing_charges
_gbp` for heat-network mains (NOT additive) so a Summary-path community-gas
main — already £120 via the gas branch — is not double-counted to £240. The
CH1-6 community corpus stays exactly £120 (59 corpus tests pass).

9390 SAP +4 → -2 (cont 65.39 vs lodged 67): the spec-correct £120 standing
EXPOSES a separate ~7% demand over-count (also visible as PE 220 vs lodged
205) — a heat-source-efficiency-default / fabric residual, follow-up scope.
9390 is unpinned (retired P2.2 per ADR-0010 §10); helper locked by 2 unit
tests. Full suite 2386 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 23:45:24 +00:00
parent f658f7ce71
commit ee484d9f4a
2 changed files with 100 additions and 8 deletions

View file

@ -883,6 +883,12 @@ _HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY: Final[dict[int, float]] = {
304: 3.00,
}
# SAP 10.2 Table 12 (PDF p.191) "Heat networks" standing charge row =
# £120/yr (note (k)). Note (l): "Include half this value if only DHW is
# provided by a heat network." §C3.2 (PDF p.58): the full charge applies
# when the space heating is also on the heat network.
_HEAT_NETWORK_STANDING_CHARGE_GBP: Final[float] = 120.0
def _is_heat_network_main(main: Optional[MainHeatingDetail]) -> bool:
"""True when the cert's main heating is a heat network — either by
@ -1592,6 +1598,35 @@ 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`).
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
`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
(community boilers + mains gas, space + water) (351) = £120.
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.
"""
if _is_heat_network_main(main):
return _HEAT_NETWORK_STANDING_CHARGE_GBP
if _is_community_heating_hw_from_main(epc):
return _HEAT_NETWORK_STANDING_CHARGE_GBP / 2.0
return None
def _main_heating_efficiency(epc: EpcPropertyData) -> float:
"""SAP 10.2 (206) main system 1 efficiency as a 0..1 fraction.
@ -5885,10 +5920,15 @@ def _fuel_cost(
table_32_unit_price_p_per_kwh(60) * _PENCE_TO_GBP
)
standing = additional_standing_charges_gbp(
main_fuel_code=main_fuel_code,
water_heating_fuel_code=water_heating_fuel_code,
tariff=tariff,
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,
)
)
# Worksheet display convention: when a row's kWh is zero (no main 2, no
@ -6610,10 +6650,15 @@ def cert_to_inputs(
epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc),
)
_hw_extra_standing = 0.0
standing_charges_total = additional_standing_charges_gbp(
main_fuel_code=_main_fuel_code(main),
water_heating_fuel_code=_water_heating_fuel_code(epc),
tariff=_rdsap_tariff(epc),
_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(
main_fuel_code=_main_fuel_code(main),
water_heating_fuel_code=_water_heating_fuel_code(epc),
tariff=_rdsap_tariff(epc),
)
) + _hw_extra_standing
# SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution

View file

@ -53,6 +53,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage]
_heat_network_dlf, # pyright: ignore[reportPrivateUsage]
_heat_network_factor_fuel_code, # pyright: ignore[reportPrivateUsage]
_heat_network_standing_charge_gbp, # pyright: ignore[reportPrivateUsage]
_is_electric_main, # pyright: ignore[reportPrivateUsage]
_is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage]
_is_electric_water, # pyright: ignore[reportPrivateUsage]
@ -5873,3 +5874,49 @@ def test_non_heat_network_biomass_fuel_not_translated() -> None:
# Assert — unchanged (raw code, biomass factor preserved).
assert code == 20
def test_heat_network_space_and_water_standing_charge_is_full_120() -> None:
# Arrange — a heat-network SPACE main (SAP code 301) carries the full
# Table 12 (PDF p.191) heat-network standing charge of £120/yr per
# §C3.2 ("the total standing charge is the normal heat network standing
# charge" when space heating is on the network). Worksheet-validated:
# case 14 (community boilers + mains gas, space + water) → (351) £120.
# The epc is not consulted on this branch (heat-network space main wins
# first), so a minimal epc suffices.
epc = _typical_semi_detached_epc()
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,
)
# Act
standing = _heat_network_standing_charge_gbp(epc, main)
# Assert
assert standing is not None
assert abs(standing - 120.0) <= 1e-9
def test_non_heat_network_main_returns_none_so_caller_uses_fuel_standing() -> None:
# Arrange — a non-heat-network gas-boiler main must NOT draw the
# heat-network standing branch; the helper returns None so the caller
# falls back to the fuel-based `additional_standing_charges_gbp`.
epc = _typical_semi_detached_epc()
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=26, # mains gas (not community)
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2106,
main_heating_category=2,
sap_main_heating_code=102,
)
# Act / Assert
assert _heat_network_standing_charge_gbp(epc, main) is None