Model/domain/sap10_calculator/tables/table_12a.py
Khalim Conn-Kowlessar f6ec96fbcd Merge remote-tracking branch 'origin/main' into feature/e2e-runs
# Conflicts:
#	domain/sap10_calculator/tables/table_12a.py
2026-06-24 11:07:42 +00:00

415 lines
20 KiB
Python
Raw Permalink 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)
)
# §12 Unknown-meter exception: "main heating is ground source or water source
# heat pump" makes the dwelling dual even though the same heat pump on a Single
# meter stays standard. This is the GROUND/WATER-source subset of Table 4a
# (SAP 10.2 PDF p.176-177): ground 211/215/221/225 + warm-air 521/525, water
# 213/216/223/226 + warm-air 523/526. AIR-source (214/217/224/227, 524/527) is
# DELIBERATELY EXCLUDED — the spec names only ground/water source. A heat pump
# lodged via a PCDB database index without one of these SAP codes can't have
# its source type read from the code alone (coverage gap: rare, 0 corpus certs).
_GROUND_OR_WATER_SOURCE_HEAT_PUMP_CODES: Final[frozenset[int]] = frozenset(
{211, 215, 221, 225, 521, 525} # ground source (radiator + warm-air)
| {213, 216, 223, 226, 523, 526} # water source (radiator + warm-air)
)
# The heating codes whose *presence* implies an off-peak (dual) meter: electric
# CPSU (Rule 1) and storage-based electric (Rule 2). These charge overnight and
# cannot run economically on a single rate, so the §12 dispatch already infers
# off-peak for them when the meter is Unknown (see `tariff_dispatch`). Exposed so
# *synthesis* (Landlord-Override / EPC-Prediction) can pair a coherent off-peak
# meter with such a system from the SAP code alone — the single source of "which
# systems are off-peak". Rule 3 (direct-acting electric, heat pumps, room
# heaters) is deliberately NOT here: those run on demand and live on single-rate
# meters too. A "Dual" meter on any of these lets the §12 dispatch resolve the
# specific tariff (CPSU → 10-hour, storage → 7-hour).
OFF_PEAK_IMPLYING_HEATING_CODES: Final[frozenset[int]] = (
_RULE_1_CPSU_CODES | _RULE_2_STORAGE_CODES
)
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,
water_is_off_peak_dual_immersion: 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`.
`water_is_off_peak_dual_immersion` signals the §12 Unknown-meter
exception "water heating ... intended to run off an off-peak tariff"
via the text-box "dual electric immersion" system (whc 903 + dual
immersion). On an Unknown meter this is enough to make the dwelling
"dual"; the 7-/10-hour choice then follows Rules 1-4 on the main.
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, inaccessible): §12 (PDF p.62) — "treat as
# single meter EXCEPT where: main heating OR WATER HEATING are intended to
# run off an off-peak tariff (per systems listed in the text box above) or
# main heating is ground source or water source heat pump. If that results
# in a dual meter, assign tariff per rules 1 to 4." The text-box off-peak
# systems are electric storage heaters (401-409), underfloor (421/422),
# dry-core/water-storage boiler (193/195), CPSU (192), and DUAL ELECTRIC
# IMMERSION. So the off-peak trigger is NOT "any electric main" — a
# direct-acting / room-heater main on its own keeps the dwelling on a
# single meter (STANDARD); it only goes off-peak when one of the text-box
# systems is present. Once triggered, the meter becomes "dual" and the
# 7-hour/10-hour choice is made by the SAME Rules 1-4 on the main heating
# (so e.g. room heaters + dual immersion → Rule 3 → 10-hour).
#
# Worksheet-validated (2026-06-23, Khalim's "simulated case 48": main 691
# room heaters + Unknown meter + 903 DUAL electric immersion): Elmhurst
# SAP 57; ours 45 when this stayed STANDARD, 55 once the dual-immersion
# trigger routes it through Rule 3 → 10-hour (7-hour gives 45 — confirms
# 10-hour). The dual-immersion trigger flips exactly ONE corpus cert
# (Apartment 241, the genuine -5.38 under-rater, main 691 + 903 dual
# immersion); every other Unknown+dual-immersion cert already has a
# storage main (Rule 2). Single-immersion 691 certs (Flat 7, Flat 2) and
# whc-909 instantaneous certs correctly STAY standard — they carry no
# text-box off-peak system. The §12 third exception bullet ("main heating
# is ground source or water source heat pump") is the
# `_GROUND_OR_WATER_SOURCE_HEAT_PUMP_CODES` check — AIR-source heat pumps
# (214/224 etc.) are NOT a trigger and stay standard (e.g. corpus certs
# "3/10 Bedford House", main 214 ASHP on Unknown meters — verified 2026-06-
# 23 they keep STANDARD). No corpus cert carries a GSHP/WSHP on an Unknown
# meter, so this trigger is a spec-completeness forward guard (0 impact).
if _meter_is_unknown(meter_type):
off_peak_evidence = (
bool(main_codes & _RULE_1_CPSU_CODES)
or bool(main_codes & _RULE_2_STORAGE_CODES)
or bool(main_codes & _GROUND_OR_WATER_SOURCE_HEAT_PUMP_CODES)
or water_is_off_peak_dual_immersion
)
if off_peak_evidence:
return _rules_1_to_3() or Tariff.SEVEN_HOUR
return Tariff.STANDARD
# Single (code 2) or any other explicit non-off-peak meter.
return base