fix(cost): PCDB heat pump without SAP code bills Table 12a ASHP_APP_N split

A heat pump that resolves via its PCDB Table 362 index alone (API path,
data_source=1, no Table-4a SAP code) had sap_main_heating_code=None, so
`_table_12a_system_for_main` fell through the 211-227/521-524 code-range
gate to None → the "100% off-peak low-rate" fallback. On a Dual meter
(RdSAP §12 Rule 3 routes heat pumps to the 10-hour tariff) this billed
space heating at 7.50 p/kWh instead of the SAP 10.2 Table 12a Grid 1
(PDF p.191) ASHP/GSHP-from-database row: 0.80 high-rate fraction →
0.80×14.68 + 0.20×7.50 = 13.244 p/kWh. The collapse over-credited the
whole cat-4 heat-pump cluster.

Fix: route any main with a PCDB heat-pump record to ASHP_APP_N regardless
of SAP code (a Table 362 record IS an Appendix-N heat pump by
definition). ASHP_APP_N and GSHP_APP_N share the 0.80 SH fraction at
7h/10h, so ASHP_APP_N is the canonical Appendix-N row for the SH split.

cat-4 cluster (20 certs): within-0.5 45%→50%, mean signed +1.43→+0.06,
mean|err| 3.81→2.43; cert 9472 +15.0→+6.4, 2789 +13.4→+6.8. Headline
45.0%→45.1%, mean|err| 1.757→1.727. Regression green (only the
pre-existing test_total_floor_area fails); 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:48:37 +00:00
parent fb350036b1
commit e41a0bc0d7
2 changed files with 51 additions and 1 deletions

View file

@ -2140,12 +2140,27 @@ def _table_12a_system_for_main(
# all callers already pre-gate on electric, this is belt-and-braces.
if main.main_heating_category == 10 and _is_electric_main(main):
return Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC
# A PCDB Table 362 record IS a heat pump by definition (the Appendix-N
# efficiency cascade keys off it), whether or not a Table-4a SAP code
# (211-227 / 521-524) was ALSO lodged. API-path heat pumps resolve via
# the PCDB index alone (data_source=1, sap_main_heating_code None), so
# the code-range gate below misses them and they fell through to None
# → the "100% off-peak low-rate" fallback, OVER-crediting the cat-4
# cluster on Dual meters (cert 9472 +15.0 SAP). Route any PCDB heat
# pump to ASHP_APP_N: SAP 10.2 Table 12a Grid 1 (PDF p.191) gives the
# ASHP/GSHP Appendix-N rows the same 0.80 SH high-rate fraction at
# 7-hour and 10-hour, so ASHP_APP_N is the canonical Appendix-N row
# for the space-heating cost split.
if has_pcdb_hp:
return Table12aSystem.ASHP_APP_N
# ASHP — Table 4a rows 211-217 (earlier generations) + 221-227
# (2013+) cover the air-source space. Warm-air ASHPs are 521-524.
# Reached only when no PCDB record is present (handled above), so the
# "from database" variant never applies here → ASHP_OTHER.
if code is not None and (
211 <= code <= 217 or 221 <= code <= 227 or 521 <= code <= 524
):
return Table12aSystem.ASHP_APP_N if has_pcdb_hp else Table12aSystem.ASHP_OTHER
return Table12aSystem.ASHP_OTHER
return None

View file

@ -3212,6 +3212,41 @@ def test_space_heating_electric_room_heater_off_peak_bills_at_direct_acting_high
assert abs(gas_rate - 0.0550) > 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
# `sap_main_heating_code` is None. SAP 10.2 Table 12a Grid 1 (PDF
# p.191) puts an Appendix-N heat pump on the ASHP/GSHP "from database"
# row: SH high-rate fraction 0.80 at both 7-hour and 10-hour. The
# code-range gate in `_table_12a_system_for_main` (211-227 / 521-524)
# missed the PCDB-only heat pump, so it fell through to the "100%
# low-rate" fallback (10-hour low 7.50 p, £0.0750), under-charging
# space heating by ~5.74 p/kWh and OVER-rating the cat-4 heat-pump
# cluster (1,000-cert API sample: 20 certs, mean signed +1.43; cert
# 9472 +15.0). The fix routes any main with a PCDB heat-pump record
# to ASHP_APP_N regardless of SAP code. Mirror of the cat-10 room-
# heater fix above.
from domain.sap10_calculator.tables.table_12a import Tariff
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, # heat pump
sap_main_heating_code=None, # API path: PCDB index only, no SAP code
main_heating_index_number=104351, # Vaillant aroTHERM, PCDB Table 362
)
# Act — 10-hour off-peak tariff (RdSAP §12 Rule 3 routes heat pumps here).
rate_ten_hour = _space_heating_fuel_cost_gbp_per_kwh(
pcdb_heat_pump_main, Tariff.TEN_HOUR, prices=SAP_10_2_SPEC_PRICES,
)
# Assert — ASHP_APP_N 10-hour: 0.80 × 14.68 p + 0.20 × 7.50 p = 13.244 p.
assert abs(rate_ten_hour - 0.13244) <= 1e-6
def test_heat_network_dlf_full_table_12c_age_band_coverage() -> None:
# Arrange — SAP 10.2 Table 12c (page 193) heat-network Distribution
# Loss Factor by dwelling age band A..M. None → K-or-newer