From 4d1a58b8280eb3934827ec0ff4cef48283548b50 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 6 Jun 2026 19:02:34 +0000 Subject: [PATCH] =?UTF-8?q?fix(tariff):=20Unknown=20meter=20+=20storage/CP?= =?UTF-8?q?SU=20main=20=E2=86=92=20off-peak=20(=C2=A712)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 10 ++- domain/sap10_calculator/tables/table_12a.py | 72 ++++++++++++++----- .../domain/sap10_calculator/test_table_12a.py | 47 ++++++++++++ 3 files changed, 110 insertions(+), 19 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index d9f587b4..bd01f16c 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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_ diff --git a/domain/sap10_calculator/tables/table_12a.py b/domain/sap10_calculator/tables/table_12a.py index 2ee7c331..ab5394f1 100644 --- a/domain/sap10_calculator/tables/table_12a.py +++ b/domain/sap10_calculator/tables/table_12a.py @@ -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 diff --git a/tests/domain/sap10_calculator/test_table_12a.py b/tests/domain/sap10_calculator/test_table_12a.py index a15cb2ed..a43ef5e2 100644 --- a/tests/domain/sap10_calculator/test_table_12a.py +++ b/tests/domain/sap10_calculator/test_table_12a.py @@ -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