Source every end use's off-peak high-rate fraction from the cert 🟩

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-24 17:57:23 +00:00
parent b58e27f46e
commit 81d5429b60
2 changed files with 130 additions and 0 deletions

View file

@ -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
),

View file

@ -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 + (1frac)×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: