fix(cost): HP-DHW from PCDB heat pump bills Table 12a ASHP_APP_N WH split

When DHW is heated by the main heat pump (WHC 901/902/914 = "from main
system") and the main carries a PCDB Table 362 record,
`_hot_water_fuel_cost_gbp_per_kwh` billed the electric HW at 100% off-peak
low rate (its long-standing TODO). SAP 10.2 Table 12a Grid 1 WH column
(PDF p.191) puts HP-DHW on the ASHP/GSHP-from-database row: 0.70
high-rate fraction at 7-hour and 10-hour → 0.70×14.68 + 0.30×7.50 =
12.526 p/kWh (10-hour), not 7.50 p. The low-rate collapse over-credited
the cat-4 HP-DHW cluster.

Fix: pass the cert WHC into the helper and, for HP-DHW (WHC ∈ {901,902,
914} + PCDB-HP main), bill at the ASHP_APP_N WH blended rate. Electric
IMMERSION (WHC 903) is a different Table 12a row (off-peak immersion 0.17
/ Table 13) and stays on the 100%-low-rate fallback until that slice
lands.

cat-4 cluster (20 certs): mean|err| 2.43→2.11, mean signed +0.06→-0.52
(now per-cert scatter, no systematic bias); cert 9472 +6.4→+3.2, 2789
+6.8→+4.0, 4135 +2.7→within 0.5. Headline mean|err| 1.727→1.720.
Regression green (2447 pass incl. golden 6035 + ASHP cohort at 1e-4);
pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-06 19:54:01 +00:00
parent e41a0bc0d7
commit 2bc73fb08d
2 changed files with 67 additions and 2 deletions

View file

@ -103,6 +103,7 @@ from domain.sap10_calculator.tables.table_12a import (
rdsap_tariff_for_cert, rdsap_tariff_for_cert,
space_heating_high_rate_fraction, space_heating_high_rate_fraction,
tariff_from_meter_type, tariff_from_meter_type,
water_heating_high_rate_fraction,
) )
from domain.sap10_calculator.tables.table_32 import ( from domain.sap10_calculator.tables.table_32 import (
additional_standing_charges_gbp, additional_standing_charges_gbp,
@ -2231,6 +2232,7 @@ def _hot_water_fuel_cost_gbp_per_kwh(
tariff: Tariff, tariff: Tariff,
prices: PriceTable, prices: PriceTable,
*, *,
water_heating_code: Optional[int] = None,
inherit_main_for_community_heating: bool = False, inherit_main_for_community_heating: bool = False,
) -> float: ) -> float:
"""Hot water bills at the *water-heating* fuel's rate. When the """Hot water bills at the *water-heating* fuel's rate. When the
@ -2239,8 +2241,16 @@ def _hot_water_fuel_cost_gbp_per_kwh(
water fuel is a non-electric fuel (gas / oil / LPG), tariff is water fuel is a non-electric fuel (gas / oil / LPG), tariff is
not consulted those fuels are single-rate per Table 32. For not consulted those fuels are single-rate per Table 32. For
cert 000565 HW routes to gas combi via WHC 914 tariff branch cert 000565 HW routes to gas combi via WHC 914 tariff branch
not taken. TODO: Table 12a Grid 1 WH high-rate-fraction split for not taken.
electric WH on off-peak (currently uses 100% low rate).
HP-DHW exception: when DHW is heated by the main system (WHC
{901, 902, 914}) and that main is a PCDB Table 362 heat pump, the
HW bills per SAP 10.2 Table 12a Grid 1 WH column (PDF p.191) the
ASHP/GSHP-from-database row carries a 0.70 high-rate fraction at
7-hour and 10-hour, NOT 100% off-peak low rate. Electric IMMERSION
(WHC 903) is a different Table 12a row (off-peak immersion 0.17 /
Table 13) and stays on the 100%-low-rate fallback until that slice
lands.
`inherit_main_for_community_heating`: per S0380.173, when WHC `inherit_main_for_community_heating`: per S0380.173, when WHC
{901, 902, 914} AND main is a heat network, ignore the cert- {901, 902, 914} AND main is a heat network, ignore the cert-
@ -2253,6 +2263,18 @@ def _hot_water_fuel_cost_gbp_per_kwh(
return _fuel_cost_gbp_per_kwh(main, prices) return _fuel_cost_gbp_per_kwh(main, prices)
water_electric = _is_electric_water(water_heating_fuel) water_electric = _is_electric_water(water_heating_fuel)
if water_electric and tariff is not Tariff.STANDARD: if water_electric and tariff is not Tariff.STANDARD:
if (
water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES
and main is not None
and main.main_heating_index_number is not None
and heat_pump_record(main.main_heating_index_number) is not None
):
high_rate, low_rate = _tariff_high_low_rates_p_per_kwh(tariff)
high_frac = water_heating_high_rate_fraction(
Table12aSystem.ASHP_APP_N, tariff
)
blended = high_frac * high_rate + (1.0 - high_frac) * low_rate
return blended * _PENCE_TO_GBP
return _off_peak_low_rate_gbp_per_kwh(tariff) return _off_peak_low_rate_gbp_per_kwh(tariff)
if water_heating_fuel is not None: if water_heating_fuel is not None:
return prices.unit_price_p_per_kwh(water_heating_fuel) * _PENCE_TO_GBP return prices.unit_price_p_per_kwh(water_heating_fuel) * _PENCE_TO_GBP
@ -6963,6 +6985,7 @@ def cert_to_inputs(
_water_heating_main(epc), _water_heating_main(epc),
_rdsap_tariff(epc), _rdsap_tariff(epc),
prices, prices,
water_heating_code=epc.sap_heating.water_heating_code,
inherit_main_for_community_heating=_community_hw_inherit, inherit_main_for_community_heating=_community_hw_inherit,
) )
hw_co2_factor = _hot_water_co2_factor_kg_per_kwh( hw_co2_factor = _hot_water_co2_factor_kg_per_kwh(

View file

@ -3212,6 +3212,48 @@ def test_space_heating_electric_room_heater_off_peak_bills_at_direct_acting_high
assert abs(gas_rate - 0.0550) > 1e-6 assert abs(gas_rate - 0.0550) > 1e-6
def test_hot_water_from_pcdb_heat_pump_bills_at_app_n_wh_high_rate() -> None:
# Arrange — when DHW is heated by the main heat pump (WHC 901/902/914
# "from main system") and that main carries a PCDB Table 362 record,
# SAP 10.2 Table 12a Grid 1 WH column (PDF p.191) bills it on the
# ASHP/GSHP-from-database row: 0.70 high-rate fraction at 7-hour and
# 10-hour. `_hot_water_fuel_cost_gbp_per_kwh` previously billed any
# electric off-peak HW at 100% low rate (its TODO), over-crediting the
# HP-DHW cat-4 cluster. Electric IMMERSION (WHC 903) is a different
# Table 12a row (off-peak immersion 0.17 / Table 13) and must stay on
# the 100%-low-rate fallback here.
from domain.sap10_calculator.tables.table_12a import Tariff
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_hot_water_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
)
pcdb_heat_pump_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29, # electricity (heat pump), API enum
heat_emitter_type=1,
emitter_temperature=0,
main_heating_control=2210,
main_heating_category=4,
sap_main_heating_code=None,
main_heating_index_number=104351, # PCDB Table 362 heat pump
)
# Act — DHW from the main HP (WHC 901) vs a separate electric
# immersion (WHC 903), both on a 10-hour off-peak tariff.
rate_from_hp = _hot_water_fuel_cost_gbp_per_kwh(
29, pcdb_heat_pump_main, Tariff.TEN_HOUR, SAP_10_2_SPEC_PRICES,
water_heating_code=901,
)
rate_immersion = _hot_water_fuel_cost_gbp_per_kwh(
29, pcdb_heat_pump_main, Tariff.TEN_HOUR, SAP_10_2_SPEC_PRICES,
water_heating_code=903,
)
# Assert — HP-DHW: 0.70 × 14.68 p + 0.30 × 7.50 p = 12.526 p; immersion
# stays at the 10-hour low rate 7.50 p (£0.0750).
assert abs(rate_from_hp - 0.12526) <= 1e-6
assert abs(rate_immersion - 0.0750) <= 1e-6
def test_space_heating_pcdb_heat_pump_without_sap_code_bills_at_app_n_high_rate() -> None: def test_space_heating_pcdb_heat_pump_without_sap_code_bills_at_app_n_high_rate() -> None:
# Arrange — an API-path heat pump resolves via its PCDB Table 362 # Arrange — an API-path heat pump resolves via its PCDB Table 362
# index alone (data_source=1, no Table-4a SAP code lodged), so # index alone (data_source=1, no Table-4a SAP code lodged), so