mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
316 lines
14 KiB
Python
316 lines
14 KiB
Python
"""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
|