Model/domain/sap10_calculator/tables/table_12a.py
Khalim Conn-Kowlessar d8cdee4e53 Slice S0380.125: map Elmhurst Summary "18 Hour" meter_type to EIGHTEEN_HOUR
The Elmhurst Summary §14.2 Meters section lodges the electricity meter
type as the bare RdSAP enum form "18 Hour", but `_METER_STR_TO_INT`
only carried the legacy "off-peak 18 hour" alias. All 41 P960-format
heating-system fixtures at `sap worksheets/heating systems examples/`
lodge meter_type "18 Hour", so `cert_to_inputs` strict-raised on every
one of them before this slice.

Per RdSAP 10 Specification §17 page 85 (Electricity meter row 10-2):

  > "Electricity meter: Dual/single/10-hour/18-hour/24-hour/unknown"

Per RdSAP 10 §12 page 62:

  > "if the meter is dual 18-hour/24-hour it is 18-hour/24-hour tariff"

So the bare "18 Hour" lodging routes directly to enum 5 (Off-peak 18
hour) → `Tariff.EIGHTEEN_HOUR`, bypassing the §12 Rules 1-4 dispatch
(which only fires for Dual meters that aren't 18-hour or 24-hour).

After this slice the heating-system corpus probe (`/tmp/probe_*.py`
across 41 variants of the same property × different heating systems)
shifts from "32 raises + 7 mapper gaps + 2 emitter gaps" to
"32 cascade-OK + 7 community-heating + 2 underfloor-emitter + 1
cylinder-size 'No Access'". The 32 newly-OK variants surface a
positive ΔSAP cluster (cascade SAP_c > worksheet SAP_c by +0.87..+30
across boiler types) — that residual layer is queued for the next
slice.

Extended handover suite at HEAD post-slice: **829 pass, 0 fail**
(baseline 775 + test_table_12a.py's 54 incl. the new "18 Hour" entry).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 23:51:47 +00:00

316 lines
14 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.
_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
# TODO: electric room heater codes (SAP Table 4a row 6xx for
# electric panel / radiant heaters) when a fixture surfaces them.
)
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)
# 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