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