mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
f658f7ce71
commit
ee484d9f4a
2 changed files with 100 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue