fix(tariff): Unknown meter + storage/CPSU main → off-peak (§12)

Electric storage heaters (and CPSU) charge overnight and cannot run
economically on a single rate, so their presence is physical evidence the
dwelling is on an off-peak tariff. RdSAP 10 §12 (PDF p.62) applied Rules
1-4 only for a Dual meter; an "Unknown" (code 3) meter returned STANDARD
without consulting the heating type, so a cat-7 storage main billed its
overnight charge at the standard 13.19 p/kWh instead of the 7-hour low
rate (5.50 p/kWh) — ~2.4x too high → large under-rate.

Two coupled fixes:
- `rdsap_tariff_for_cert`: for an Unknown meter, infer the off-peak tariff
  from a Rule-1 CPSU (→10-hour) or Rule-2 storage (→7-hour) main; keep
  STANDARD otherwise. Direct-acting/room heaters/heat pumps (Rule 3) are
  NOT off-peak evidence (run on demand, exist on single-rate meters) so
  they stay STANDARD — billing them 100% at the low rate over-credits.
- `_fuel_cost` now resolves its tariff via the §12-aware `_rdsap_tariff`
  (not the raw `tariff_from_meter_type`), so the off-peak branch fires for
  these storage certs and the legacy scalar fields bill the low rate.

Mirrors `_is_off_peak_meter`'s existing Unknown+electric heuristic (which
already routes HW/secondary off-peak), closing the main-space-heating gap.
Meter-3 electric cluster: mean |err| 11.18 → 6.52, within-1.0 3 → 5 (cert
7336 -26.1 → -0.16, 0380 -19.9 → +1.0). Eval headline 44.9% → 45.0%, mean
|err| 1.82 → 1.76, mean signed -0.08 → +0.02. A few storage certs overshoot
(other residuals the standard rate was masking).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-06 19:02:34 +00:00
parent 678aa7affd
commit 4d1a58b828
3 changed files with 110 additions and 19 deletions

View file

@ -6114,8 +6114,14 @@ def _fuel_cost(
is the natural extension point for the Table 12a `_SH_HIGH_RATE_
FRACTION` lookup + `Table12aSystem` mapping (deferred per slice 3
docs `Q11` follow-ups)."""
meter_type = epc.sap_energy_source.meter_type
tariff = tariff_from_meter_type(meter_type)
# Use the §12-Rules-aware tariff (not the raw meter→tariff): it routes
# an "Unknown" (code 3) meter with an electric storage / heat-pump /
# room-heater main to its off-peak tariff (storage heaters can't run on
# a single rate), so the off-peak branch below fires and the legacy
# scalar fields bill the overnight charge at the low rate instead of
# the standard 13.19 p/kWh. A non-electric Unknown-meter dwelling still
# resolves STANDARD here, keeping the full §10a precompute.
tariff = _rdsap_tariff(epc)
if tariff is not Tariff.STANDARD:
# Off-peak path defers to the legacy scalar fuel-cost fields on
# CalculatorInputs (the pre-§10a `_space_heating_fuel_cost_gbp_

View file

@ -263,6 +263,21 @@ _RULE_3_TEN_HOUR_CODES: Final[frozenset[int]] = frozenset(
)
def _meter_is_unknown(meter_type: object) -> bool:
"""True when the meter is the RdSAP "Unknown" sentinel (code 3 / the
"unknown" / "" / "3" string aliases) the assessor did not record the
tariff. Distinct from Single (code 2), an explicit single-rate
lodgement. Mirrors `_is_off_peak_meter`'s code extraction so the main-
heating tariff inference stays consistent with the HW/secondary path."""
if isinstance(meter_type, bool):
return False
if isinstance(meter_type, int):
return meter_type == 3
if isinstance(meter_type, str):
return meter_type.strip().lower() in {"unknown", "3", ""}
return False
def rdsap_tariff_for_cert(
meter_type: object,
*,
@ -297,23 +312,46 @@ def rdsap_tariff_for_cert(
TEN_HOUR, matching the worksheet's "10 Hour Off Peak" lodging.
"""
base = tariff_from_meter_type(meter_type)
# Non-Dual meters resolve straight from the meter type.
if base is not Tariff.SEVEN_HOUR:
return base
main_codes = {
c for c in (main_1_sap_code, main_2_sap_code) if c is not None
}
# Rule 1
if main_codes & _RULE_1_CPSU_CODES:
return Tariff.TEN_HOUR
# Rule 2 — checked BEFORE rule 3 per §12 ordering (storage takes
# precedence over the broader Rule 3 electric set).
if main_codes & _RULE_2_STORAGE_CODES:
return Tariff.SEVEN_HOUR
# Rule 3
if main_codes & _RULE_3_TEN_HOUR_CODES:
return Tariff.TEN_HOUR
if main_1_is_heat_pump_database or main_2_is_heat_pump_database:
return Tariff.TEN_HOUR
# Rule 4 — default
return Tariff.SEVEN_HOUR
def _rules_1_to_3() -> Optional[Tariff]:
"""§12 Rules 1-3 — the explicit electric-system tariff matches.
Returns None when no electric storage / CPSU / heat-pump / room-
heater main is present (i.e. Rule 4 territory)."""
# Rule 1
if main_codes & _RULE_1_CPSU_CODES:
return Tariff.TEN_HOUR
# Rule 2 — checked BEFORE rule 3 per §12 ordering (storage takes
# precedence over the broader Rule 3 electric set).
if main_codes & _RULE_2_STORAGE_CODES:
return Tariff.SEVEN_HOUR
# Rule 3
if main_codes & _RULE_3_TEN_HOUR_CODES:
return Tariff.TEN_HOUR
if main_1_is_heat_pump_database or main_2_is_heat_pump_database:
return Tariff.TEN_HOUR
return None
# Dual meter — §12 Rules 1-4, where Rule 4 is the 7-hour default.
if base is Tariff.SEVEN_HOUR:
return _rules_1_to_3() or Tariff.SEVEN_HOUR
# "Unknown" meter (code 3): the assessor didn't record the tariff, but
# an electric CPSU (Rule 1) or STORAGE (Rule 2) main is physical
# evidence the dwelling is on an off-peak tariff — these charge
# overnight at the low rate and cannot run economically on a single
# rate, so the tariff is implied. Direct-acting electric / room heaters
# / heat pumps (Rule 3) are NOT off-peak evidence (they run on demand
# and exist on single-rate meters too), so they keep STANDARD here
# rather than being mis-billed 100% at the off-peak low rate. A
# non-electric main also keeps STANDARD (no Rule 4 default — Unknown
# must not force off-peak on a gas dwelling).
if _meter_is_unknown(meter_type):
if main_codes & _RULE_1_CPSU_CODES:
return Tariff.TEN_HOUR
if main_codes & _RULE_2_STORAGE_CODES:
return Tariff.SEVEN_HOUR
return Tariff.STANDARD
# Single (code 2) or any other explicit non-off-peak meter.
return base

View file

@ -46,6 +46,53 @@ def test_dual_meter_electric_room_heater_resolves_to_ten_hour_tariff() -> None:
assert rdsap_tariff_for_cert(1, main_1_sap_code=601) is Tariff.SEVEN_HOUR
def test_unknown_meter_infers_off_peak_from_electric_storage_main() -> None:
# Arrange — RdSAP 10 §12 (PDF p.62). An "Unknown" meter (code 3) was
# not recorded by the assessor, but an electric STORAGE main (SAP
# 401-409, Rule 2) or CPSU (192, Rule 1) is physical evidence the
# dwelling is on an off-peak tariff — these charge overnight at the low
# rate and cannot run economically on a single rate. So infer the §12
# off-peak tariff rather than billing the overnight charge at the
# standard rate. Certs 7336/2080 (cat-7 storage, meter 3) under-rated
# ~25 SAP from standard-rate space heating.
# Act / Assert — storage (Rule 2) → 7-hour; CPSU (Rule 1) → 10-hour.
assert rdsap_tariff_for_cert(3, main_1_sap_code=402) is Tariff.SEVEN_HOUR
assert rdsap_tariff_for_cert(3, main_1_sap_code=192) is Tariff.TEN_HOUR
def test_unknown_meter_does_not_infer_off_peak_for_room_heater_or_heat_pump() -> None:
# Arrange — direct-acting electric room heaters (Rule 3, SAP 691) and
# heat pumps run ON DEMAND and exist on single-rate meters too, so they
# are NOT evidence of an off-peak tariff. On an Unknown meter they keep
# STANDARD — billing them 100% at the off-peak low rate would
# over-credit (room heaters draw mostly at the high rate).
# Act / Assert
assert rdsap_tariff_for_cert(3, main_1_sap_code=691) is Tariff.STANDARD
assert rdsap_tariff_for_cert(3, main_1_is_heat_pump_database=True) is Tariff.STANDARD
def test_unknown_meter_with_non_electric_main_stays_standard() -> None:
# Arrange — an "Unknown" meter on a GAS-heated dwelling (SAP 102) has
# no off-peak evidence, so it must NOT pick up the Rule-4 Dual default
# (7-hour); it stays STANDARD. (The off-peak inference fires only when
# a Rule 1/2 storage/CPSU system is present.)
# Act / Assert
assert rdsap_tariff_for_cert(3, main_1_sap_code=102) is Tariff.STANDARD
assert rdsap_tariff_for_cert(3, main_1_sap_code=None) is Tariff.STANDARD
def test_single_meter_with_storage_stays_standard() -> None:
# Arrange — code 2 (Single) is an EXPLICIT single-rate lodgement, not
# "unknown", so it is NOT overridden even with a storage main: the
# off-peak inference is only for the Unknown (code 3) sentinel.
# Act / Assert
assert rdsap_tariff_for_cert(2, main_1_sap_code=402) is Tariff.STANDARD
def test_tariff_enum_has_five_members() -> None:
"""Table 12a columns: standard (no off-peak split), 7-hour, 10-hour,
18-hour, 24-hour. Worksheet-shape fidelity: TEN_HOUR is included for