Model/domain/sap10_calculator/tables/table_12a.py
Khalim Conn-Kowlessar 4d1a58b828 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>
2026-06-06 19:02:34 +00:00

357 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""SAP 10.2 Table 12a — high-rate fractions for off-peak tariffs.
Sourced verbatim from `domain/sap10_calculator/docs/specs/sap-10-2-full-specification-2025-
03-14.pdf`, page 191 (Table 12a). RdSAP10 §19.1 cross-references this
table from RdSAP10 §10a/§10b — the table is not duplicated in the
RdSAP10 PDF.
Two grids:
- Grid 1: space + water heating systems × tariff → (SH_frac, WH_frac)
- Grid 2: other electricity uses × tariff → fraction
For STANDARD tariff (no off-peak split) every lookup returns 1.0 —
all consumption at the unit price.
"""
from __future__ import annotations
from enum import Enum
from typing import Final, Optional
class Table12aSystem(Enum):
"""Table 12a row label (System column) for the space + water heating
fractions grid. Each member maps to a row of PDF page 191. Three
rows that require external sources (Electric CPSU → Appendix F;
Immersion / HP-DHW-only → Table 13) are reachable via lookup but
raise `NotImplementedError` until a fixture exercises them."""
INTEGRATED_STORAGE_DIRECT = "integrated_storage_direct"
OTHER_STORAGE_HEATERS = "other_storage_heaters"
ELECTRIC_DRY_CORE_OR_WATER_STORAGE = "electric_dry_core_or_water_storage"
DIRECT_ACTING_ELECTRIC_BOILER = "direct_acting_electric_boiler"
ELECTRIC_CPSU = "electric_cpsu"
UNDERFLOOR_HEATING = "underfloor_heating"
GSHP_APP_N = "gshp_app_n"
GSHP_OTHER = "gshp_other"
GSHP_OTHER_OFF_PEAK_IMMERSION = "gshp_other_off_peak_immersion"
GSHP_OTHER_NO_IMMERSION = "gshp_other_no_immersion"
ASHP_APP_N = "ashp_app_n"
ASHP_OTHER = "ashp_other"
ASHP_OTHER_OFF_PEAK_IMMERSION = "ashp_other_off_peak_immersion"
ASHP_OTHER_NO_IMMERSION = "ashp_other_no_immersion"
OTHER_DIRECT_ACTING_ELECTRIC = "other_direct_acting_electric"
IMMERSION_OR_HP_DHW_ONLY = "immersion_or_hp_dhw_only"
class OtherUse(Enum):
"""Table 12a Grid 2 row label — "Other electricity uses" sub-table.
Maps end-uses (pumps/fans/lighting/PV-credit) to their off-peak
high-rate fraction. Pumps + lighting + locally-generated electricity
use ALL_OTHER_USES; mechanical-ventilation fans use the dedicated
FANS_FOR_MECH_VENT row."""
FANS_FOR_MECH_VENT = "fans_for_mech_vent"
ALL_OTHER_USES = "all_other_uses"
class Tariff(Enum):
"""Electricity tariff column in Table 12a. TEN_HOUR is in the spec
but unreachable from RdSAP10 cert flow (meter_type enum 1..5 has no
10-hour code) — kept for worksheet-shape fidelity."""
STANDARD = "standard"
SEVEN_HOUR = "7-hour"
TEN_HOUR = "10-hour"
EIGHTEEN_HOUR = "18-hour"
TWENTY_FOUR_HOUR = "24-hour"
# RdSAP cert `meter_type` integer enum → Table 12a tariff column.
# String forms accepted by lower-casing + stripping.
_METER_INT_TO_TARIFF: Final[dict[int, Tariff]] = {
1: Tariff.SEVEN_HOUR, # Dual
2: Tariff.STANDARD, # Single
3: Tariff.STANDARD, # Unknown (per Q11b — spec-faithful)
4: Tariff.TWENTY_FOUR_HOUR, # Dual (24 hour)
5: Tariff.EIGHTEEN_HOUR, # Off-peak 18 hour
}
_METER_STR_TO_INT: Final[dict[str, int]] = {
"single": 2,
"standard": 2,
"dual": 1,
"dual (24 hour)": 4,
"off-peak 18 hour": 5,
# RdSAP 10 §17 page 85 row 10-2 lodging form: "18-hour" (Elmhurst
# Summary §14.2 surfaces this as the bare "18 Hour"). Per §12
# page 62: "if the meter is dual 18-hour/24-hour it is 18-hour/
# 24-hour tariff" → enum 5 (EIGHTEEN_HOUR).
"18 hour": 5,
"unknown": 3,
"": 3,
}
# Table 12a Grid 1 SH column — high-rate fraction by (system, tariff).
# Only spec-listed (system, tariff) pairs appear; combos not in the
# table raise NotImplementedError at lookup time. Sourced verbatim from
# SAP10.2 PDF page 191.
_SH_HIGH_RATE_FRACTION: Final[dict[tuple[Table12aSystem, Tariff], float]] = {
(Table12aSystem.INTEGRATED_STORAGE_DIRECT, Tariff.SEVEN_HOUR): 0.20,
(Table12aSystem.OTHER_STORAGE_HEATERS, Tariff.SEVEN_HOUR): 0.00,
(Table12aSystem.OTHER_STORAGE_HEATERS, Tariff.TWENTY_FOUR_HOUR): 0.00,
(Table12aSystem.ELECTRIC_DRY_CORE_OR_WATER_STORAGE, Tariff.SEVEN_HOUR): 0.00,
(Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER, Tariff.SEVEN_HOUR): 0.90,
(Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER, Tariff.TEN_HOUR): 0.50,
(Table12aSystem.UNDERFLOOR_HEATING, Tariff.SEVEN_HOUR): 0.90,
(Table12aSystem.UNDERFLOOR_HEATING, Tariff.TEN_HOUR): 0.50,
(Table12aSystem.GSHP_APP_N, Tariff.SEVEN_HOUR): 0.80,
(Table12aSystem.GSHP_APP_N, Tariff.TEN_HOUR): 0.80,
(Table12aSystem.GSHP_OTHER, Tariff.SEVEN_HOUR): 0.70,
(Table12aSystem.GSHP_OTHER, Tariff.TEN_HOUR): 0.60,
(Table12aSystem.ASHP_APP_N, Tariff.SEVEN_HOUR): 0.80,
(Table12aSystem.ASHP_APP_N, Tariff.TEN_HOUR): 0.80,
(Table12aSystem.ASHP_OTHER, Tariff.SEVEN_HOUR): 0.90,
(Table12aSystem.ASHP_OTHER, Tariff.TEN_HOUR): 0.60,
(Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, Tariff.SEVEN_HOUR): 1.00,
(Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, Tariff.TEN_HOUR): 0.50,
}
# Table 12a Grid 1 WH column. Only heat-pump WH rows carry off-peak
# fractions in scope A; Electric CPSU (Appendix F) and immersion /
# HP-DHW (Table 13) raise on lookup until those slices land.
_WH_HIGH_RATE_FRACTION: Final[dict[tuple[Table12aSystem, Tariff], float]] = {
(Table12aSystem.GSHP_APP_N, Tariff.SEVEN_HOUR): 0.70,
(Table12aSystem.GSHP_APP_N, Tariff.TEN_HOUR): 0.70,
(Table12aSystem.GSHP_OTHER_OFF_PEAK_IMMERSION, Tariff.SEVEN_HOUR): 0.17,
(Table12aSystem.GSHP_OTHER_OFF_PEAK_IMMERSION, Tariff.TEN_HOUR): 0.17,
(Table12aSystem.GSHP_OTHER_NO_IMMERSION, Tariff.SEVEN_HOUR): 0.70,
(Table12aSystem.GSHP_OTHER_NO_IMMERSION, Tariff.TEN_HOUR): 0.70,
(Table12aSystem.ASHP_APP_N, Tariff.SEVEN_HOUR): 0.70,
(Table12aSystem.ASHP_APP_N, Tariff.TEN_HOUR): 0.70,
(Table12aSystem.ASHP_OTHER_OFF_PEAK_IMMERSION, Tariff.SEVEN_HOUR): 0.17,
(Table12aSystem.ASHP_OTHER_OFF_PEAK_IMMERSION, Tariff.TEN_HOUR): 0.17,
(Table12aSystem.ASHP_OTHER_NO_IMMERSION, Tariff.SEVEN_HOUR): 0.70,
(Table12aSystem.ASHP_OTHER_NO_IMMERSION, Tariff.TEN_HOUR): 0.70,
}
def water_heating_high_rate_fraction(
system: Table12aSystem, tariff: Tariff
) -> float:
"""Table 12a Grid 1 WH column lookup. Returns the fraction of water-
heating consumption billed at the high rate. STANDARD tariff → 1.0
(passthrough). Heat-pump WH rows return spec fractions. Immersion /
HP-DHW-only (Table 13) and Electric CPSU (Appendix F) raise."""
if tariff is Tariff.STANDARD:
return 1.0
fraction = _WH_HIGH_RATE_FRACTION.get((system, tariff))
if fraction is None:
raise NotImplementedError((system, tariff))
return fraction
def space_heating_high_rate_fraction(
system: Table12aSystem, tariff: Tariff
) -> float:
"""Table 12a Grid 1 SH column lookup. Returns the fraction of space-
heating consumption billed at the high rate. STANDARD tariff has no
off-peak split, so every system returns 1.0 (passthrough). Spec-
listed off-peak (system, tariff) pairs return the published
fraction; unlisted pairs (incl. Electric CPSU → Appendix F and
immersion / HP-DHW → Table 13) raise."""
if tariff is Tariff.STANDARD:
return 1.0
fraction = _SH_HIGH_RATE_FRACTION.get((system, tariff))
if fraction is None:
raise NotImplementedError((system, tariff))
return fraction
# Table 12a Grid 2 — "Other electricity uses" sub-table.
_OTHER_USE_HIGH_RATE_FRACTION: Final[dict[tuple[OtherUse, Tariff], float]] = {
(OtherUse.FANS_FOR_MECH_VENT, Tariff.SEVEN_HOUR): 0.71,
(OtherUse.FANS_FOR_MECH_VENT, Tariff.TEN_HOUR): 0.58,
(OtherUse.ALL_OTHER_USES, Tariff.SEVEN_HOUR): 0.90,
(OtherUse.ALL_OTHER_USES, Tariff.TEN_HOUR): 0.80,
}
def other_use_high_rate_fraction(use: OtherUse, tariff: Tariff) -> float:
"""Table 12a Grid 2 lookup — fraction of an "other electricity use"
consumption billed at the high rate. STANDARD → 1.0. 18-hour /
24-hour tariffs aren't in Grid 2; the spec implicitly applies the
same logic via Grid 1 for those tariffs, so this lookup raises for
them."""
if tariff is Tariff.STANDARD:
return 1.0
fraction = _OTHER_USE_HIGH_RATE_FRACTION.get((use, tariff))
if fraction is None:
raise NotImplementedError((use, tariff))
return fraction
def tariff_from_meter_type(meter_type: object) -> Tariff:
"""Resolve the RdSAP cert `meter_type` field to a Table 12a tariff
column. Absent (None / "") → STANDARD (no off-peak split applied)
per the Q11b spec-faithful policy.
Strict-dispatch per [[reference-unmapped-sap-code]]: lodging present
but unmapped (integer outside enum 1..5, or string not in the
accepted set) raises `UnmappedSapCode`. Empty string maps to
"unknown" code 3 → STANDARD (the explicit absent-sentinel).
NOTE: for a Dual meter the §12 dispatch (Rules 1-4 page 62)
requires the main heating SAP codes to choose between 7-hour and
10-hour. This helper returns the SEVEN_HOUR default for Dual —
callers that have access to the main heating codes should use
`rdsap_tariff_for_cert` instead.
"""
from domain.sap10_calculator.exceptions import UnmappedSapCode
if meter_type is None:
return Tariff.STANDARD
if isinstance(meter_type, int):
if meter_type in _METER_INT_TO_TARIFF:
return _METER_INT_TO_TARIFF[meter_type]
raise UnmappedSapCode("meter_type", meter_type)
if isinstance(meter_type, str):
key = meter_type.strip().lower()
# Digit-string forms (e.g. '2') route via int-cast first; the
# str dict only carries the enum word aliases ('single', 'dual',
# 'unknown', ...). The empty-string alias maps to "unknown"
# (code 3) per the dict — that's the explicit absent sentinel.
if key in _METER_STR_TO_INT:
return _METER_INT_TO_TARIFF[_METER_STR_TO_INT[key]]
if key.isdigit():
digit_code = int(key)
if digit_code in _METER_INT_TO_TARIFF:
return _METER_INT_TO_TARIFF[digit_code]
raise UnmappedSapCode("meter_type", meter_type)
raise UnmappedSapCode("meter_type", meter_type)
raise UnmappedSapCode("meter_type", meter_type)
# RdSAP 10 §12 page 62 — SAP main heating code sets for the Dual-meter
# tariff dispatch. Each rule's set is taken verbatim from §12 Rules 1-3.
# Rule 1: Electric CPSU → 10-hour
_RULE_1_CPSU_CODES: Final[frozenset[int]] = frozenset({192})
# Rule 2: storage-based electric → 7-hour. The (421, 422) underfloor
# subset is explicit per §12 ("421 or 422, but not 424") — 423 / 425
# fall through to Rule 4 default unless a later spec amendment adds
# them.
_RULE_2_STORAGE_CODES: Final[frozenset[int]] = frozenset(
list(range(401, 410)) # electric storage heaters 401-409
+ [193, 195] # electric dry-core / water-storage boiler
+ [421, 422] # electric underfloor heating (424 excluded)
)
# Rule 3: direct-acting electric + heat pumps + electric room heaters
# → 10-hour. §12 lists "heat pump (211 to 224, 521 to 524, or
# database)" — the "database" branch fires when the cert lodges a
# PCDB Table 362 heat-pump index regardless of SAP code. §12 also
# names "electric room heaters" verbatim (RdSAP 10 PDF p.62) — Table 4a
# electric room-heater codes 691 (panel/convector/radiant), 692 (fan),
# 693 (portable), 694 (water-/oil-filled), 699 (assumed). Without these
# a Dual-meter room-heater cert fell through to Rule 4 (7-hour default).
_RULE_3_TEN_HOUR_CODES: Final[frozenset[int]] = frozenset(
[191] # direct-acting electric boiler
+ list(range(211, 225)) # heat pumps 211-224
+ list(range(521, 525)) # warm-air heat pumps 521-524
+ [691, 692, 693, 694, 699] # electric room heaters (Table 4a)
)
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,
*,
main_1_sap_code: Optional[int] = None,
main_2_sap_code: Optional[int] = None,
main_1_is_heat_pump_database: bool = False,
main_2_is_heat_pump_database: bool = False,
) -> Tariff:
"""RdSAP 10 §12 page 62 — full meter+heating tariff dispatch.
Single meter → STANDARD. Dual 18-hour / Dual 24-hour map straight
to their respective tariffs. Otherwise applies §12 Rules 1-4
where each rule considers BOTH main heating systems on multi-
main certs ("the main system or either main system if there are
two"):
Rule 1 Electric CPSU (192) → 10-hour
Rule 2 Storage / storage boiler / underfloor → 7-hour
(401-409, 193, 195, 421, 422)
Rule 3 Direct-acting electric boiler (191), → 10-hour
heat pump (211-224, 521-524, database),
electric room heaters
Rule 4 None of the above → 7-hour
(default for Dual + non-electric main)
`main_1_is_heat_pump_database` / `main_2_is_heat_pump_database`
signal the "or database" Rule 3 branch — the cert lodges a PCDB
Table 362 heat-pump record. Callers compute this via
`heat_pump_record(main_heating_index_number) is not None`.
Cert 000565 (Main 1 SAP code 224 ASHP + Dual meter) → Rule 3 →
TEN_HOUR, matching the worksheet's "10 Hour Off Peak" lodging.
"""
base = tariff_from_meter_type(meter_type)
main_codes = {
c for c in (main_1_sap_code, main_2_sap_code) if c is not None
}
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