fix(tariff): Unknown meter + dual electric immersion → off-peak per §12 (RdSAP 10 PDF p.62)

Supersedes the previous "verified non-fix" doc (3548f1f3): the spec DOES make
this a fix — Khalim was right that the Unknown-meter branch is driven by the
heating/water system, not a blanket STANDARD.

RdSAP 10 §12 (PDF p.62): "If the electricity meter is unknown, treat as single
meter EXCEPT where main heating OR WATER HEATING are intended to run off an
off-peak tariff (per systems listed in the text box above) ... If that results
in a dual meter, assign tariff per rules 1 to 4." The text-box off-peak systems
include DUAL ELECTRIC IMMERSION. Our `rdsap_tariff_for_cert` only triggered the
Unknown→off-peak exception on a storage/CPSU MAIN — it ignored the
dual-electric-immersion WATER-heating trigger, so an Unknown-meter dwelling
with a non-storage main (e.g. room heaters) + dual immersion was billed
STANDARD (13.19p flat) when it should be dual → Rules 1-4 on the main.

Fix: thread `water_is_off_peak_dual_immersion` (whc 903 + immersion lodged dual
via `_immersion_is_single is False`) into the Unknown-meter branch; when any
text-box trigger is present, resolve via the same Rules 1-4 dispatch (room
heaters → Rule 3 → 10-hour). Single-immersion / instantaneous (whc 909) certs
correctly stay STANDARD (no text-box system).

Worksheet-validated on "simulated case 48" (main 691 + Unknown meter + 903 dual
immersion): Elmhurst 10-Hour Off Peak, SAP 57; ours 45 → 55 (7-hour gives 45,
confirming 10-hour). Flips exactly ONE corpus cert — Apartment 241 (the genuine
-5.38 under-rater, main 691 + dual immersion) -5.38 → -1.05; every other
Unknown+dual-immersion cert already has a storage main (Rule 2). Corpus
within-0.5 holds 72.5%, MAE 0.793 → 0.789 (improved). CO2/PE unchanged.

GSHP/WSHP-main trigger (the other §12 Unknown exception bullet) is a separate
follow-up. Gates green: corpus 72.5%/0.789, batch worksheet 0 raised/0 diverge,
000565 e2e 11/11, suite 2987 passed (2 known pre-existing fails). pyright not
installed in this container — strict type gate not run locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-23 09:58:02 +00:00
parent 3548f1f31a
commit 22fe4f41b8
3 changed files with 88 additions and 34 deletions

View file

@ -1741,12 +1741,24 @@ def _rdsap_tariff(epc: EpcPropertyData) -> Tariff:
and heat_pump_record(detail.main_heating_index_number) is not None
)
# §12 Unknown-meter exception: "water heating ... intended to run off an
# off-peak tariff" via the text-box "dual electric immersion" system —
# whc 903 (HW from a separate electric immersion) lodged as dual
# (`_immersion_is_single` is False). This makes an Unknown-meter dwelling
# "dual" even when the main is a single-rate-capable system (e.g. room
# heaters), per RdSAP 10 §12 (PDF p.62).
water_dual_immersion = (
_int_or_none(epc.sap_heating.water_heating_code) == _WHC_ELECTRIC_IMMERSION
and _immersion_is_single(epc) is False
)
return rdsap_tariff_for_cert(
epc.sap_energy_source.meter_type,
main_1_sap_code=main_1.sap_main_heating_code if main_1 else None,
main_2_sap_code=main_2.sap_main_heating_code if main_2 else None,
main_1_is_heat_pump_database=_hp_db(main_1),
main_2_is_heat_pump_database=_hp_db(main_2),
water_is_off_peak_dual_immersion=water_dual_immersion,
)

View file

@ -285,6 +285,7 @@ def rdsap_tariff_for_cert(
main_2_sap_code: Optional[int] = None,
main_1_is_heat_pump_database: bool = False,
main_2_is_heat_pump_database: bool = False,
water_is_off_peak_dual_immersion: bool = False,
) -> Tariff:
"""RdSAP 10 §12 page 62 — full meter+heating tariff dispatch.
@ -308,6 +309,12 @@ def rdsap_tariff_for_cert(
Table 362 heat-pump record. Callers compute this via
`heat_pump_record(main_heating_index_number) is not None`.
`water_is_off_peak_dual_immersion` signals the §12 Unknown-meter
exception "water heating ... intended to run off an off-peak tariff"
via the text-box "dual electric immersion" system (whc 903 + dual
immersion). On an Unknown meter this is enough to make the dwelling
"dual"; the 7-/10-hour choice then follows Rules 1-4 on the main.
Cert 000565 (Main 1 SAP code 224 ASHP + Dual meter) Rule 3
TEN_HOUR, matching the worksheet's "10 Hour Off Peak" lodging.
"""
@ -337,36 +344,38 @@ def rdsap_tariff_for_cert(
# 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) keep STANDARD here. A non-electric main also
# keeps STANDARD (no Rule 4 default — Unknown must not force off-peak
# on a gas dwelling).
# "Unknown" meter (code 3, inaccessible): §12 (PDF p.62) — "treat as
# single meter EXCEPT where: main heating OR WATER HEATING are intended to
# run off an off-peak tariff (per systems listed in the text box above) or
# main heating is ground source or water source heat pump. If that results
# in a dual meter, assign tariff per rules 1 to 4." The text-box off-peak
# systems are electric storage heaters (401-409), underfloor (421/422),
# dry-core/water-storage boiler (193/195), CPSU (192), and DUAL ELECTRIC
# IMMERSION. So the off-peak trigger is NOT "any electric main" — a
# direct-acting / room-heater main on its own keeps the dwelling on a
# single meter (STANDARD); it only goes off-peak when one of the text-box
# systems is present. Once triggered, the meter becomes "dual" and the
# 7-hour/10-hour choice is made by the SAME Rules 1-4 on the main heating
# (so e.g. room heaters + dual immersion → Rule 3 → 10-hour).
#
# VERIFIED NON-FIX (2026-06-23, "simulated case 48" Elmhurst worksheet):
# Elmhurst DOES resolve an Unknown meter + room-heater main (SAP 691) to
# 10-Hour Off Peak (its Rule 3) — a hand-built case-48 Summary (main 691,
# Unknown meter, 903 dual electric immersion) scores Elmhurst SAP 57; our
# STANDARD gives 45, routing Unknown+Rule3 to off-peak gives 55 (7-hour
# gives 45 — confirms Elmhurst uses 10-hour). So the "Rule 3 is not off-
# peak evidence" intuition is WRONG about Elmhurst's behaviour. BUT
# adopting it REGRESSES the lodged-register corpus (72.5%->71.8%, MAE
# 0.793->0.827): of 11 Unknown+Rule3 corpus certs only ONE improves
# (Apartment 241 -5.38->-1.05); the other 10 overshoot +2.7..+9.1 (Flat 2
# +9.11). The register's meter_type=3 certs were lodged with STANDARD-
# tariff costing (their low ratings only reconcile at 13.19p flat) — the
# gov-API "Unknown" is lossy and does NOT mean off-peak was used. Since
# our north star is reproducing the lodged register (not Elmhurst's
# deliberate-Unknown worksheet path), KEEP STANDARD. Same "Elmhurst != the
# noisy register" family as roof-windows/shutters. Do NOT re-litigate.
# Worksheet-validated (2026-06-23, Khalim's "simulated case 48": main 691
# room heaters + Unknown meter + 903 DUAL electric immersion): Elmhurst
# SAP 57; ours 45 when this stayed STANDARD, 55 once the dual-immersion
# trigger routes it through Rule 3 → 10-hour (7-hour gives 45 — confirms
# 10-hour). The dual-immersion trigger flips exactly ONE corpus cert
# (Apartment 241, the genuine -5.38 under-rater, main 691 + 903 dual
# immersion); every other Unknown+dual-immersion cert already has a
# storage main (Rule 2). Single-immersion 691 certs (Flat 7, Flat 2) and
# whc-909 instantaneous certs correctly STAY standard — they carry no
# text-box off-peak system. (GSHP/WSHP-main trigger: separate follow-up.)
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
off_peak_evidence = (
bool(main_codes & _RULE_1_CPSU_CODES)
or bool(main_codes & _RULE_2_STORAGE_CODES)
or water_is_off_peak_dual_immersion
)
if off_peak_evidence:
return _rules_1_to_3() or Tariff.SEVEN_HOUR
return Tariff.STANDARD
# Single (code 2) or any other explicit non-off-peak meter.
return base

View file

@ -62,17 +62,50 @@ def test_unknown_meter_infers_off_peak_from_electric_storage_main() -> None:
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).
# Arrange — RdSAP 10 §12 (PDF p.62): on an Unknown meter the off-peak
# exception fires only when a text-box off-peak SYSTEM is present (storage
# / underfloor / dry-core / CPSU main, OR dual electric immersion water
# heating, OR GSHP/WSHP main). A direct-acting room-heater (Rule 3, SAP
# 691) or heat-pump main is NOT by itself such a system — absent a
# dual-immersion (or GSHP/WSHP) trigger the dwelling stays on a single
# meter (STANDARD). The 7-/10-hour choice in Rules 1-4 only applies ONCE
# the meter is already established as dual.
# Act / Assert
# Act / Assert — room heater / heat pump with no dual-immersion trigger.
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_dual_electric_immersion_triggers_off_peak_via_rules() -> None:
# Arrange — RdSAP 10 §12 (PDF p.62): "If the electricity meter is unknown,
# treat as single meter EXCEPT where ... water heating [is] intended to
# run off an off-peak tariff (per systems listed in the text box above)"
# — the text box lists DUAL ELECTRIC IMMERSION. "If that results in a dual
# meter, assign tariff per rules 1 to 4." So an Unknown meter + dual
# electric immersion makes the dwelling dual; the 7-/10-hour choice then
# follows Rules 1-4 on the MAIN heating. Worksheet-validated on Khalim's
# "simulated case 48" (main 691 room heaters + Unknown meter + 903 dual
# immersion → Elmhurst 10-Hour Off Peak, SAP 57; ours 45→55). Corpus cert
# "Apartment 241" (main 691 + dual immersion) moved -5.38 → -1.05.
# Act / Assert — dual immersion + room-heater main → Rule 3 → 10-hour;
# + storage main → Rule 2 → 7-hour (already off-peak); + gas main → Rule 4
# default → 7-hour. WITHOUT the dual-immersion flag, the room-heater main
# stays STANDARD (single electric immersion is not a trigger).
assert rdsap_tariff_for_cert(
3, main_1_sap_code=691, water_is_off_peak_dual_immersion=True
) is Tariff.TEN_HOUR
assert rdsap_tariff_for_cert(
3, main_1_sap_code=402, water_is_off_peak_dual_immersion=True
) is Tariff.SEVEN_HOUR
assert rdsap_tariff_for_cert(
3, main_1_sap_code=102, water_is_off_peak_dual_immersion=True
) is Tariff.SEVEN_HOUR
assert rdsap_tariff_for_cert(
3, main_1_sap_code=691, water_is_off_peak_dual_immersion=False
) 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