diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index a1a8af635..1fc568e03 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 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 fb76456dc..578cb67de 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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 diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 6a74fdc4d..a0a45a31e 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -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