From ee484d9f4aab076bd5f23a2791896ecc7fe31fc5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 23:45:24 +0000 Subject: [PATCH] =?UTF-8?q?S0380.213:=20heat-network=20standing=20charge?= =?UTF-8?q?=20(=C2=A3120)=20=E2=80=94=20fixes=209390=20cost=20under-count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 61 ++++++++++++++++--- .../rdsap/test_cert_to_inputs.py | 47 ++++++++++++++ 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 4fa6fde3..9e189860 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 932552d2..e58f9713 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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