diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index bd01f16c..d4185d59 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 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 07d322cf..627cc2d9 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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