From 22fe4f41b8c752c96cf692a4d5ce1472520307fc Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 23 Jun 2026 09:58:02 +0000 Subject: [PATCH] =?UTF-8?q?fix(tariff):=20Unknown=20meter=20+=20dual=20ele?= =?UTF-8?q?ctric=20immersion=20=E2=86=92=20off-peak=20per=20=C2=A712=20(Rd?= =?UTF-8?q?SAP=2010=20PDF=20p.62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../sap10_calculator/rdsap/cert_to_inputs.py | 12 ++++ domain/sap10_calculator/tables/table_12a.py | 65 +++++++++++-------- .../domain/sap10_calculator/test_table_12a.py | 45 +++++++++++-- 3 files changed, 88 insertions(+), 34 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 281d87b5..3f1294e5 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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, ) diff --git a/domain/sap10_calculator/tables/table_12a.py b/domain/sap10_calculator/tables/table_12a.py index b41819b0..d79d9fbe 100644 --- a/domain/sap10_calculator/tables/table_12a.py +++ b/domain/sap10_calculator/tables/table_12a.py @@ -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 diff --git a/tests/domain/sap10_calculator/test_table_12a.py b/tests/domain/sap10_calculator/test_table_12a.py index a43ef5e2..8ce42b09 100644 --- a/tests/domain/sap10_calculator/test_table_12a.py +++ b/tests/domain/sap10_calculator/test_table_12a.py @@ -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