"""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