mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
415 lines
20 KiB
Python
415 lines
20 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. §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
|