diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 417a9e28..92158613 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2262,6 +2262,35 @@ def _secondary_efficiency( return seasonal_efficiency(code, None, None) +def _secondary_off_peak_rate_gbp_per_kwh(meter_type: object) -> float: + """SAP 10.2 Table 12a Grid 1 (PDF p.191) blended rate for an electric + secondary heater on an off-peak tariff. The secondary is a direct- + acting electric room heater (RdSAP 10 §A.2.2 default), so it sits on + the "Other systems including direct-acting electric" row — high-rate + fraction 1.00 for 7-hour, 0.50 for 10-hour. NOT the 100%-low-rate of + storage-charging: a room heater runs on demand, mostly at the high + rate. Worksheet evidence — simulated case 19 (242): "Space heating - + secondary (1.00*15.29 + 0.00*5.50)" → all at the 7-hour HIGH rate. + + Mirrors `_space_heating_fuel_cost_gbp_per_kwh`: the meter resolves to + a tariff (the `_is_off_peak_meter` Unknown-code-3 heuristic falls + through to 7-hour, as in `_off_peak_low_rate_gbp_per_kwh_via_meter_ + heuristic`); 18-/24-hour tariffs (absent from the Grid 1 direct-acting + row) fall back to the tariff's Table 32 low rate.""" + tariff = tariff_from_meter_type(meter_type) + if tariff is Tariff.STANDARD: + tariff = Tariff.SEVEN_HOUR + try: + high_frac = space_heating_high_rate_fraction( + Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, tariff, + ) + except NotImplementedError: + return _off_peak_low_rate_gbp_per_kwh(tariff) + high_rate, low_rate = _tariff_high_low_rates_p_per_kwh(tariff) + blended = high_frac * high_rate + (1.0 - high_frac) * low_rate + return blended * _PENCE_TO_GBP + + def _secondary_fuel_cost_gbp_per_kwh( sap_heating, main: Optional[MainHeatingDetail], @@ -2277,13 +2306,13 @@ def _secondary_fuel_cost_gbp_per_kwh( # Default to electricity since the default secondary system is # portable electric heaters (code 693). if _is_off_peak_meter(meter_type, fuel_is_electric=True): - return _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type) + return _secondary_off_peak_rate_gbp_per_kwh(meter_type) return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP # When secondary_fuel_type is electricity, apply off-peak if applicable. if _is_electric_water(sec_fuel) and _is_off_peak_meter( meter_type, fuel_is_electric=True ): - return _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type) + return _secondary_off_peak_rate_gbp_per_kwh(meter_type) return prices.unit_price_p_per_kwh(sec_fuel) * _PENCE_TO_GBP 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 c0e8df0a..69afd8dc 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -64,6 +64,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _pv_overshading_factor, # pyright: ignore[reportPrivateUsage] _pv_pitch_deg, # pyright: ignore[reportPrivateUsage] _responsiveness, # pyright: ignore[reportPrivateUsage] + _secondary_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] _secondary_heating_fraction_for_category, # pyright: ignore[reportPrivateUsage] _section_12_4_4_summer_immersion_applies, # pyright: ignore[reportPrivateUsage] _separately_timed_dhw, # pyright: ignore[reportPrivateUsage] @@ -1912,6 +1913,49 @@ def test_separately_timed_dhw_excludes_dedicated_water_heater_per_table_2b_note_ assert separately_timed is False +def test_secondary_electric_off_peak_bills_at_table_12a_direct_acting_high_rate() -> None: + # Arrange — SAP 10.2 Table 12a Grid 1 (PDF p.191): secondary heating + # is a direct-acting electric room heater (RdSAP 10 §A.2.2 default), + # which sits on the "Other systems including direct-acting electric" + # row. For a 7-hour (Economy-7) tariff that row's high-rate fraction + # is 1.00 — ALL secondary consumption bills at the high rate, NOT the + # off-peak low rate that storage-heater charging earns. Simulated + # case 19's worksheet (242) is the evidence: "Space heating - + # secondary (1.00*15.29 + 0.00*5.50)" → 15.29 p/kWh = £0.1529. Pre- + # slice `_secondary_fuel_cost_gbp_per_kwh` returned the 7-hour low + # rate 5.50 p (£0.0550) for every off-peak electric secondary, + # under-charging by 9.79 p/kWh × the secondary kWh (~£340 on case 19). + storage_heater_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, # electricity + heat_emitter_type="", + emitter_temperature=1, + main_heating_control=2402, + main_heating_category=None, + sap_main_heating_code=402, # electric storage heaters + ) + dual_meter_off_peak_epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_heating=make_sap_heating( + main_heating_details=[storage_heater_main], + # secondary_fuel_type omitted → §A.2.2 portable electric default + ), + ) + + # Act + secondary_rate_gbp_per_kwh = _secondary_fuel_cost_gbp_per_kwh( + dual_meter_off_peak_epc.sap_heating, + storage_heater_main, + 1, # Dual meter → 7-hour off-peak tariff + SAP_10_2_SPEC_PRICES, + ) + + # Assert — 1.00 × 15.29 p + 0.00 × 5.50 p = 15.29 p/kWh = £0.1529. + assert abs(secondary_rate_gbp_per_kwh - 0.1529) <= 1e-6 + + def test_water_efficiency_uses_table_4a_water_column_for_heat_pumps_per_sap_10_2() -> None: # Arrange — SAP 10.2 Table 4a (PDF p.163-164) gives heat pumps two # efficiency columns: "space" and "water". For low-temperature