From 81d5429b603519060aa889b92a725c103dd45080 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 24 Jun 2026 17:57:23 +0000 Subject: [PATCH] =?UTF-8?q?Source=20every=20end=20use's=20off-peak=20high-?= =?UTF-8?q?rate=20fraction=20from=20the=20cert=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the hot-water (Table 13 / HP-DHW), secondary (direct-acting), main-2 and ALL_OTHER_USES High-Rate Fractions on CalculatorInputs from the same Table 12a helpers the SAP cost path uses, so Bill Derivation's day/night split matches the rating's exactly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sap10_calculator/rdsap/cert_to_inputs.py | 118 ++++++++++++++++++ .../rdsap/test_cert_to_inputs.py | 12 ++ 2 files changed, 130 insertions(+) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index caff68c0..469223ab 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2819,6 +2819,76 @@ def _hot_water_fuel_cost_gbp_per_kwh( return _fuel_cost_gbp_per_kwh(main, prices) +def _hot_water_high_rate_fraction( + water_heating_fuel: Optional[int], + main: Optional[MainHeatingDetail], + tariff: Tariff, + *, + water_heating_code: Optional[int] = None, + inherit_main_for_community_heating: bool = False, + cylinder_volume_l: Optional[float] = None, + occupancy_n: Optional[float] = None, + immersion_single: Optional[bool] = None, +) -> float: + """ADR-0014 Bill Derivation — the hot-water High-Rate Fraction (the day/high- + rate share) on an Off-Peak Meter, mirroring `_hot_water_fuel_cost_gbp_per_kwh` + branch-for-branch so the bill's day/night HW split matches the rating's: + + - community-heating HW inheriting a (non-electric) main, non-electric HW, or + STANDARD tariff → 1.0 (single rate, no split); + - HP-DHW (the WHC inherits a PCDB Table 362 heat-pump main) → Table 12a Grid 1 + WH `ASHP_APP_N` fraction (0.70 at 7-/10-hour); + - electric immersion (WHC 903) with a known cylinder + occupancy → the Table + 13 dual-immersion fraction (§10.5 assumes a DUAL immersion on a dual meter); + - any other electric off-peak HW (e.g. heated by a storage main) → 0.0 (the + timer charges it wholly at the night/low rate).""" + if inherit_main_for_community_heating: + return 1.0 + if not _is_electric_water(water_heating_fuel) or tariff is Tariff.STANDARD: + return 1.0 + 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 + ): + return water_heating_high_rate_fraction(Table12aSystem.ASHP_APP_N, tariff) + if ( + water_heating_code == _WHC_ELECTRIC_IMMERSION + and cylinder_volume_l is not None + and occupancy_n is not None + ): + effective_single = ( + immersion_single if immersion_single is not None else False + ) + return electric_dhw_high_rate_fraction( + cylinder_volume_l=cylinder_volume_l, + occupancy_n=occupancy_n, + single_immersion=effective_single, + tariff=tariff, + ) + return 0.0 + + +def _secondary_high_rate_fraction(epc: EpcPropertyData, tariff: Tariff) -> float: + """ADR-0014 Bill Derivation — the secondary-heating High-Rate Fraction on an + Off-Peak Meter. Non-electric or standard-tariff secondary → 1.0. Electric + secondary heaters are portable/direct-acting (Table 4a room heaters), so they + take the Table 12a Grid 1 `OTHER_DIRECT_ACTING_ELECTRIC` row (1.0 at 7-hour, + 0.50 at 10-hour) — run on demand, mostly at the day/high rate. Tariffs Table + 12a omits (18-/24-hour) fall back to 1.0 (high).""" + if tariff is Tariff.STANDARD: + return 1.0 + if not is_electric_fuel_code(_secondary_fuel_code(epc)): + return 1.0 + try: + return space_heating_high_rate_fraction( + Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, tariff + ) + except NotImplementedError: + return 1.0 + + def _secondary_fraction( main: Optional[MainHeatingDetail], secondary_heating_type: object, @@ -3408,6 +3478,27 @@ def _other_fuel_cost_gbp_per_kwh( return blended * _PENCE_TO_GBP +def _other_uses_high_rate_fraction(tariff: Tariff) -> float: + """ADR-0014 Bill Derivation — the ALL_OTHER_USES High-Rate Fraction (the + day/high-rate share) for lighting / appliances / cooking / pumps on an + Off-Peak Meter, mirroring `_other_fuel_cost_gbp_per_kwh`'s tariff handling so + the bill's split matches the rating's: STANDARD → 1.0 (single rate); 7-/10- + hour → SAP 10.2 Table 12a Grid 2; 18-hour → 1.0 (all other uses bill at the + high rate per SAP 10.2 Appendix F2); 24-hour → 1.0 (a heating-only tariff — + the Fuel Rates snapshot carries no separate non-heating rate, so other uses + bill at the off-peak day rate, a documented approximation for a rare tariff). + + Pumps/fans reuse this fraction; the Table 12a Grid 2 MEV/MVHR `FANS_FOR_MECH_ + VENT` distinction the SAP cost path applies is a small second-order effect on + a small load and is deferred for the bill.""" + if tariff is Tariff.STANDARD: + return 1.0 + try: + return other_use_high_rate_fraction(OtherUse.ALL_OTHER_USES, tariff) + except NotImplementedError: + return 1.0 + + def _pumps_fans_fuel_cost_gbp_per_kwh( *, tariff: Tariff, @@ -8164,6 +8255,28 @@ def cert_to_inputs( _main_high_rate_fraction = _main_space_heating_high_rate_fraction( main, _billing_tariff ) + _main_2_detail = ( + epc.sap_heating.main_heating_details[1] + if epc.sap_heating and len(epc.sap_heating.main_heating_details) > 1 + else None + ) + _main_2_high_rate_fraction = _main_space_heating_high_rate_fraction( + _main_2_detail, _billing_tariff + ) + _secondary_high_rate_frac = _secondary_high_rate_fraction(epc, _billing_tariff) + _hw_high_rate_fraction = _hot_water_high_rate_fraction( + _water_heating_fuel_code(epc), + _water_heating_main(epc), + _billing_tariff, + water_heating_code=( + epc.sap_heating.water_heating_code if epc.sap_heating else None + ), + inherit_main_for_community_heating=_is_community_heating_hw_from_main(epc), + cylinder_volume_l=_hot_water_cylinder_volume_l(epc), + occupancy_n=wh_result.occupancy if wh_result is not None else None, + immersion_single=_immersion_is_single(epc), + ) + _other_uses_fraction = _other_uses_high_rate_fraction(_billing_tariff) return CalculatorInputs( dimensions=dim, @@ -8231,6 +8344,11 @@ def cert_to_inputs( hot_water_fuel_code=_water_heating_fuel_code(epc), is_off_peak_meter=_is_off_peak_meter, main_heating_high_rate_fraction=_main_high_rate_fraction, + main_2_heating_high_rate_fraction=_main_2_high_rate_fraction, + secondary_heating_high_rate_fraction=_secondary_high_rate_frac, + hot_water_high_rate_fraction=_hw_high_rate_fraction, + pumps_fans_high_rate_fraction=_other_uses_fraction, + other_electricity_high_rate_fraction=_other_uses_fraction, space_heating_fuel_cost_gbp_per_kwh=_space_heating_fuel_cost_gbp_per_kwh( main, _rdsap_tariff(epc), prices ), 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 1a0cb680..c6e53595 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -870,6 +870,10 @@ def test_off_peak_storage_cert_surfaces_billing_meter_flag_and_main_fraction() - # Assert — whole-meter off-peak, main heating high-rate fraction 0.20. assert inputs.is_off_peak_meter is True assert inputs.main_heating_high_rate_fraction == 0.20 + # Lighting / appliances / cooking + pumps bill at the 7-hour Table 12a + # Grid 2 ALL_OTHER_USES high-rate fraction (0.90), mostly at the day rate. + assert inputs.other_electricity_high_rate_fraction == 0.90 + assert inputs.pumps_fans_high_rate_fraction == 0.90 def test_off_peak_immersion_unlodged_type_defaults_to_dual_table13_blend() -> None: @@ -919,6 +923,14 @@ def test_off_peak_immersion_unlodged_type_defaults_to_dual_table13_blend() -> No rate = inputs.hot_water_fuel_cost_gbp_per_kwh assert rate > 0.0550 + 1e-6 # NOT the 100%-low-rate bug value (5.50 p/kWh) assert rate < 0.0900 # a small DUAL high-rate fraction, not single (~11 p) + # ADR-0014 Bill Derivation surfaces the SAME Table 13 fraction it bills the + # HW cost at: a small day-rate share (0 < frac < 0.3), not 0.0 (all-night + # fallback) nor 1.0 (single rate). Mirrors the cost-rate split exactly. + assert 0.0 < inputs.hot_water_high_rate_fraction < 0.3 + # Back out the fraction the cost rate used and confirm the surfaced one + # matches it: rate_p = frac×15.29 + (1−frac)×5.50. + _frac_from_cost = (rate * 100.0 - 5.50) / (15.29 - 5.50) + assert inputs.hot_water_high_rate_fraction == pytest.approx(_frac_from_cost) def test_non_integrated_storage_heater_bills_100_percent_low_rate() -> None: