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,
space_heating_high_rate_fraction,
tariff_from_meter_type,
water_heating_high_rate_fraction,
)
from domain.sap10_calculator.tables.table_32 import (
additional_standing_charges_gbp,
@ -2231,6 +2232,7 @@ def _hot_water_fuel_cost_gbp_per_kwh(
tariff: Tariff,
prices: PriceTable,
*,
water_heating_code: Optional[int] = None,
inherit_main_for_community_heating: bool = False,
) -> float:
"""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
not consulted those fuels are single-rate per Table 32. For
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
electric WH on off-peak (currently uses 100% low rate).
not taken.
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
{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)
water_electric = _is_electric_water(water_heating_fuel)
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)
if water_heating_fuel is not None:
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),
_rdsap_tariff(epc),
prices,
water_heating_code=epc.sap_heating.water_heating_code,
inherit_main_for_community_heating=_community_hw_inherit,
)
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
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:
# Arrange — an API-path heat pump resolves via its PCDB Table 362
# index alone (data_source=1, no Table-4a SAP code lodged), so