diff --git a/domain/sap10_calculator/tables/table_12a.py b/domain/sap10_calculator/tables/table_12a.py index fe04aaaa..dccc555e 100644 --- a/domain/sap10_calculator/tables/table_12a.py +++ b/domain/sap10_calculator/tables/table_12a.py @@ -16,7 +16,7 @@ all consumption at the unit price. from __future__ import annotations from enum import Enum -from typing import Final +from typing import Final, Optional class Table12aSystem(Enum): @@ -191,7 +191,14 @@ def other_use_high_rate_fraction(use: OtherUse, tariff: Tariff) -> float: def tariff_from_meter_type(meter_type: object) -> Tariff: """Resolve the RdSAP cert `meter_type` field to a Table 12a tariff column. Unknown / missing → STANDARD (no off-peak split applied) - per the Q11b spec-faithful policy.""" + per the Q11b spec-faithful policy. + + 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. + """ if meter_type is None: return Tariff.STANDARD if isinstance(meter_type, int): @@ -202,3 +209,85 @@ def tariff_from_meter_type(meter_type: object) -> Tariff: return Tariff.STANDARD return _METER_INT_TO_TARIFF[code] return Tariff.STANDARD + + +# 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