Model/tests/domain/sap10_calculator/test_table_12a.py
Khalim Conn-Kowlessar 22fe4f41b8 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>
2026-06-23 09:58:02 +00:00

363 lines
16 KiB
Python

"""SAP 10.2 Table 12a — high-rate fractions for off-peak tariffs.
Locks the `Tariff` enum, the `tariff_from_meter_type` cert resolver,
and the per-system / per-use high-rate-fraction lookups against the
published SAP10.2 specification at
`domain/sap10_calculator/docs/specs/sap-10-2-full-specification-2025-03-14.pdf`, page 191.
RdSAP10 §19.1 cross-references Table 12a in SAP10.2 for off-peak
splitting — the table itself is not duplicated in the RdSAP10 PDF.
"""
from __future__ import annotations
import pytest
from domain.sap10_calculator.tables.table_12a import (
OtherUse,
Table12aSystem,
Tariff,
other_use_high_rate_fraction,
rdsap_tariff_for_cert,
space_heating_high_rate_fraction,
tariff_from_meter_type,
water_heating_high_rate_fraction,
)
def test_dual_meter_electric_room_heater_resolves_to_ten_hour_tariff() -> None:
# Arrange — RdSAP 10 §12 (PDF p.62) Dual-meter tariff dispatch: for a
# Dual meter the choice between 7-hour and 10-hour is made by the main
# heating type. Rule 3 verbatim: "if the main system ... is a direct-
# acting electric boiler (191), or electric room heaters ... it is
# 10-hour tariff." The electric room-heater codes are Table 4a 691
# (panel/convector/radiant), 692 (fan), 693 (portable), 694 (water-/
# oil-filled), 699 (assumed). Pre-slice these fell through to Rule 4
# (7-hour default), so a Dual-meter room-heater cert was billed at the
# 7-hour rates with Table 12a high-rate fraction 1.00 instead of the
# 10-hour rates with fraction 0.50 — over-charging direct-acting heat
# once S0380.230 routed it to OTHER_DIRECT_ACTING_ELECTRIC.
# Act / Assert — every electric room-heater code on a Dual meter → 10-hour.
for code in (691, 692, 693, 694, 699):
assert rdsap_tariff_for_cert(1, main_1_sap_code=code) is Tariff.TEN_HOUR
# Storage heaters (Rule 2) stay 7-hour; a gas room heater (non-Rule-3
# code) keeps the Rule 4 Dual default of 7-hour.
assert rdsap_tariff_for_cert(1, main_1_sap_code=401) is Tariff.SEVEN_HOUR
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 — 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 — 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
# (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
spec completeness even though RdSAP10 meter_type enum (1..5) doesn't
route to it — see ADR-0010 §3 unreachable-branch policy."""
# Arrange
# Act
members = set(Tariff)
# Assert
assert members == {
Tariff.STANDARD,
Tariff.SEVEN_HOUR,
Tariff.TEN_HOUR,
Tariff.EIGHTEEN_HOUR,
Tariff.TWENTY_FOUR_HOUR,
}
@pytest.mark.parametrize(
"meter_type, expected",
[
# RdSAP cert meter_type string forms
("Single", Tariff.STANDARD),
("Standard", Tariff.STANDARD),
("Dual", Tariff.SEVEN_HOUR),
("Dual (24 hour)", Tariff.TWENTY_FOUR_HOUR),
("Off-peak 18 hour", Tariff.EIGHTEEN_HOUR),
# RdSAP 10 §17 page 85 (Electricity meter row 10-2):
# "Dual/single/10-hour/18-hour/24-hour/unknown". The Elmhurst
# Summary §14.2 lodges the bare form "18 Hour" (not the
# "Off-peak 18 hour" alias above). Per §12 page 62: "if the
# meter is dual 18-hour/24-hour it is 18-hour/24-hour tariff",
# so the bare lodging routes directly to EIGHTEEN_HOUR.
("18 Hour", Tariff.EIGHTEEN_HOUR),
# Per Q11b: "Unknown" maps to STANDARD (no off-peak heuristic).
("Unknown", Tariff.STANDARD),
("", Tariff.STANDARD),
# Numeric forms (cert sometimes lodges integers per S-B9 finding)
(2, Tariff.STANDARD),
(1, Tariff.SEVEN_HOUR),
(4, Tariff.TWENTY_FOUR_HOUR),
(5, Tariff.EIGHTEEN_HOUR),
(3, Tariff.STANDARD),
# None / missing → STANDARD
(None, Tariff.STANDARD),
],
)
def test_tariff_from_meter_type_maps_cert_codes(
meter_type: object, expected: Tariff
) -> None:
"""RdSAP cert `meter_type` field carries either a string or an int
enum (1..5). Per Q11b grilling: "Unknown" (code 3) maps to STANDARD
rather than the legacy off-peak heuristic — spec-faithful since
RdSAP10 has no rule for unresolved tariffs."""
# Arrange
# Act
tariff = tariff_from_meter_type(meter_type)
# Assert
assert tariff is expected
@pytest.mark.parametrize(
"system, tariff, expected_fraction, label",
[
# Integrated storage+direct (storage heaters 408, underfloor 422/423)
(Table12aSystem.INTEGRATED_STORAGE_DIRECT, Tariff.SEVEN_HOUR, 0.20, "integrated 408/422/423 7-hr"),
# Other storage heaters
(Table12aSystem.OTHER_STORAGE_HEATERS, Tariff.SEVEN_HOUR, 0.00, "other storage 7-hr"),
(Table12aSystem.OTHER_STORAGE_HEATERS, Tariff.TWENTY_FOUR_HOUR, 0.00, "other storage 24-hr"),
# Electric dry core / water storage boiler / Electricaire
(Table12aSystem.ELECTRIC_DRY_CORE_OR_WATER_STORAGE, Tariff.SEVEN_HOUR, 0.00, "electric dry core 7-hr"),
# Direct-acting electric boiler
(Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER, Tariff.SEVEN_HOUR, 0.90, "direct-acting boiler 7-hr"),
(Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER, Tariff.TEN_HOUR, 0.50, "direct-acting boiler 10-hr"),
# Underfloor heating (above insulation / timber / below floor)
(Table12aSystem.UNDERFLOOR_HEATING, Tariff.SEVEN_HOUR, 0.90, "underfloor 7-hr"),
(Table12aSystem.UNDERFLOOR_HEATING, Tariff.TEN_HOUR, 0.50, "underfloor 10-hr"),
# Ground/water source heat pump — Appendix N calculated
(Table12aSystem.GSHP_APP_N, Tariff.SEVEN_HOUR, 0.80, "GSHP App N 7-hr"),
(Table12aSystem.GSHP_APP_N, Tariff.TEN_HOUR, 0.80, "GSHP App N 10-hr"),
# GSHP otherwise
(Table12aSystem.GSHP_OTHER, Tariff.SEVEN_HOUR, 0.70, "GSHP otherwise 7-hr"),
(Table12aSystem.GSHP_OTHER, Tariff.TEN_HOUR, 0.60, "GSHP otherwise 10-hr"),
# Air source heat pump — Appendix N
(Table12aSystem.ASHP_APP_N, Tariff.SEVEN_HOUR, 0.80, "ASHP App N 7-hr"),
(Table12aSystem.ASHP_APP_N, Tariff.TEN_HOUR, 0.80, "ASHP App N 10-hr"),
# ASHP otherwise
(Table12aSystem.ASHP_OTHER, Tariff.SEVEN_HOUR, 0.90, "ASHP otherwise 7-hr"),
(Table12aSystem.ASHP_OTHER, Tariff.TEN_HOUR, 0.60, "ASHP otherwise 10-hr"),
# Other direct-acting electric (incl secondary)
(Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, Tariff.SEVEN_HOUR, 1.00, "other direct-acting 7-hr"),
(Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, Tariff.TEN_HOUR, 0.50, "other direct-acting 10-hr"),
],
)
def test_space_heating_high_rate_fraction_matches_table_12a_grid_1(
system: Table12aSystem, tariff: Tariff, expected_fraction: float, label: str
) -> None:
"""Table 12a Grid 1 SH column, verbatim from SAP10.2 PDF page 191.
Each (system, tariff) pair pinned to its published high-rate
fraction. Tariff columns not listed for a row (e.g. integrated
storage at 10-hr) are out-of-domain and raise — covered separately."""
# Arrange
# Act
fraction = space_heating_high_rate_fraction(system, tariff)
# Assert
assert fraction == expected_fraction, (
f"{label}: expected high-rate fraction {expected_fraction}, got {fraction}"
)
def test_space_heating_high_rate_fraction_returns_one_for_standard_tariff() -> None:
"""STANDARD tariff = no off-peak split. Every system bills 100% at
the (single) unit price, so high-rate fraction collapses to 1.0.
This is the passthrough path every gas-heated fixture in scope A
will exercise."""
# Arrange
# System choice is irrelevant on STANDARD — pick a representative one.
system = Table12aSystem.OTHER_STORAGE_HEATERS
# Act
fraction = space_heating_high_rate_fraction(system, Tariff.STANDARD)
# Assert
assert fraction == 1.0
@pytest.mark.parametrize(
"system, tariff, expected_fraction, label",
[
# Heat-pump WH (App N + otherwise) — same fractions for 7-hr / 10-hr
(Table12aSystem.GSHP_APP_N, Tariff.SEVEN_HOUR, 0.70, "GSHP App N WH 7-hr"),
(Table12aSystem.GSHP_APP_N, Tariff.TEN_HOUR, 0.70, "GSHP App N WH 10-hr"),
(Table12aSystem.GSHP_OTHER_OFF_PEAK_IMMERSION, Tariff.SEVEN_HOUR, 0.17, "GSHP other off-peak immersion 7-hr"),
(Table12aSystem.GSHP_OTHER_OFF_PEAK_IMMERSION, Tariff.TEN_HOUR, 0.17, "GSHP other off-peak immersion 10-hr"),
(Table12aSystem.GSHP_OTHER_NO_IMMERSION, Tariff.SEVEN_HOUR, 0.70, "GSHP other no immersion 7-hr"),
(Table12aSystem.GSHP_OTHER_NO_IMMERSION, Tariff.TEN_HOUR, 0.70, "GSHP other no immersion 10-hr"),
(Table12aSystem.ASHP_APP_N, Tariff.SEVEN_HOUR, 0.70, "ASHP App N WH 7-hr"),
(Table12aSystem.ASHP_APP_N, Tariff.TEN_HOUR, 0.70, "ASHP App N WH 10-hr"),
(Table12aSystem.ASHP_OTHER_OFF_PEAK_IMMERSION, Tariff.SEVEN_HOUR, 0.17, "ASHP other off-peak immersion 7-hr"),
(Table12aSystem.ASHP_OTHER_OFF_PEAK_IMMERSION, Tariff.TEN_HOUR, 0.17, "ASHP other off-peak immersion 10-hr"),
(Table12aSystem.ASHP_OTHER_NO_IMMERSION, Tariff.SEVEN_HOUR, 0.70, "ASHP other no immersion 7-hr"),
(Table12aSystem.ASHP_OTHER_NO_IMMERSION, Tariff.TEN_HOUR, 0.70, "ASHP other no immersion 10-hr"),
],
)
def test_water_heating_high_rate_fraction_matches_table_12a_grid_1(
system: Table12aSystem, tariff: Tariff, expected_fraction: float, label: str
) -> None:
"""Table 12a Grid 1 WH column, verbatim from SAP10.2 PDF page 191.
Heat-pump WH carries 0.70 high-rate by default (or 0.17 when paired
with off-peak immersion). Immersion / HP-DHW-only WH (Table 13) and
Electric CPSU (Appendix F) are out-of-scope until a fixture lands."""
# Arrange
# Act
fraction = water_heating_high_rate_fraction(system, tariff)
# Assert
assert fraction == expected_fraction, (
f"{label}: expected high-rate fraction {expected_fraction}, got {fraction}"
)
def test_water_heating_high_rate_fraction_returns_one_for_standard_tariff() -> None:
"""STANDARD-tariff passthrough — water heating bills 100% at the
single rate."""
# Arrange
system = Table12aSystem.ASHP_OTHER_NO_IMMERSION
# Act
fraction = water_heating_high_rate_fraction(system, Tariff.STANDARD)
# Assert
assert fraction == 1.0
def test_water_heating_high_rate_fraction_for_immersion_raises() -> None:
"""`IMMERSION_OR_HP_DHW_ONLY` sources its fraction from Table 13,
which lives in a separate spec section. Defer until first immersion
fixture lands (per Q5 deferred list)."""
# Arrange
system = Table12aSystem.IMMERSION_OR_HP_DHW_ONLY
# Act / Assert
with pytest.raises(NotImplementedError):
water_heating_high_rate_fraction(system, Tariff.SEVEN_HOUR)
def test_water_heating_high_rate_fraction_for_electric_cpsu_raises() -> None:
"""`ELECTRIC_CPSU` sources its fraction from Appendix F. Defer until
first CPSU fixture lands."""
# Arrange
system = Table12aSystem.ELECTRIC_CPSU
# Act / Assert
with pytest.raises(NotImplementedError):
water_heating_high_rate_fraction(system, Tariff.TEN_HOUR)
@pytest.mark.parametrize(
"use, tariff, expected_fraction, label",
[
(OtherUse.FANS_FOR_MECH_VENT, Tariff.SEVEN_HOUR, 0.71, "fans 7-hr"),
(OtherUse.FANS_FOR_MECH_VENT, Tariff.TEN_HOUR, 0.58, "fans 10-hr"),
(OtherUse.ALL_OTHER_USES, Tariff.SEVEN_HOUR, 0.90, "all other 7-hr"),
(OtherUse.ALL_OTHER_USES, Tariff.TEN_HOUR, 0.80, "all other 10-hr"),
],
)
def test_other_use_high_rate_fraction_matches_table_12a_grid_2(
use: OtherUse, tariff: Tariff, expected_fraction: float, label: str
) -> None:
"""Table 12a Grid 2 (PDF page 191) — "Other electricity uses" sub-
table for fans/MV vs all-other-uses-and-locally-generated. Lighting
+ pumps + locally-generated PV credit all bill via ALL_OTHER_USES."""
# Arrange
# Act
fraction = other_use_high_rate_fraction(use, tariff)
# Assert
assert fraction == expected_fraction, (
f"{label}: expected high-rate fraction {expected_fraction}, got {fraction}"
)
def test_other_use_high_rate_fraction_returns_one_for_standard_tariff() -> None:
"""STANDARD passthrough."""
# Arrange
use = OtherUse.ALL_OTHER_USES
# Act
fraction = other_use_high_rate_fraction(use, Tariff.STANDARD)
# Assert
assert fraction == 1.0