Slice S0380.90: 6 strict-raise dispatches + UnmappedSapCode promoted to shared module

Bundled slice closing the next 6 silent-fallback dispatch sites flagged
by the post-S0380.89 audit per [[reference-unmapped-sap-code]]:

  1. PV pitch (RdSAP 10 §11.1 — codes 1..5 → 0/30/45/60/90°)
  2. PV overshading (SAP 10.2 Table M1 — codes 1..4 → 1.0/0.8/0.5/0.35)
  3. Meter type (RdSAP cert enum 1..5 → Tariff enum)
  4. Tariff → (high, low) rate (RdSAP 10 Table 32 — 4 of 5 Tariffs)
  5. Heat-network DLF by age band (SAP 10.2 Table 12c — A..M)
  6. Secondary heating fraction by main_heating_category (SAP Table 11)

Each dispatch follows the established strict / total split:
  - Absent lodging (None / 0 / "") → cascade's modal-default value
  - Lodging present but unmapped → `UnmappedSapCode(field, value)`

`UnmappedSapCode` promoted from `cert_to_inputs.py` to new module
`domain/sap10_calculator/exceptions.py` so `tables/table_12a.py` can
raise it too (the meter-type dispatch lives there). `cert_to_inputs`
re-exports it for backward compat with existing test imports.

Corpus audit at HEAD 6d02d205 (full JSON sweep):

  PV pitch codes:           {2, 3}        — covered
  PV overshading codes:     {1, 2}        — covered
  meter_type codes:         {1, 2, 3}     — covered (incl. digit-string '2')
  main_heating_category:    {2, 4, 6, 7, 10} — covered

All corpus codes already in dispatch dicts — no production regression
expected.

**One silent runtime fix surfaced by the strict-raise rollout**: the
GOV.UK API lodges `meter_type` as a digit-string (e.g. `'2'`) on many
certs, but the original `_METER_STR_TO_INT` dict only had word aliases
("single", "dual", "unknown"). Pre-S0380.90 the digit-string fell
through to the silent `return Tariff.STANDARD` default. Adding a
`key.isdigit() → int(key)` short-circuit routes these through the int
enum correctly. Confirmed 125 golden cert fixtures previously running
on this silent default — all now passing with explicit STANDARD via
the int dispatch path (not via the silent fallback).

Tests (6 new, AAA-structure):

  - `test_pv_pitch_deg_full_table_coverage_per_rdsap_10_section_11_1`
  - `test_pv_overshading_factor_full_table_m1_coverage`
  - `test_meter_type_dispatch_full_table_12a_coverage` (incl. digit-string)
  - `test_tariff_high_low_rates_full_dispatch_coverage`
  - `test_heat_network_dlf_full_table_12c_age_band_coverage`
  - `test_secondary_heating_fraction_for_category_full_table_11_coverage`

Each test pins: spec-correct codes → expected dispatch result; absent
lodging → modal default; lodging present but unmapped → `UnmappedSapCode`
with field + value attached.

Test baseline: 574 pass (was 568 + 6 new) + 9 expected
`test_sap_result_pin[000565-*]` fails unchanged. Cohort + golden +
cert 9501 unaffected. Pyright net-zero per touched file.

Open silent-fallback inventory now empty per
[[reference-unmapped-sap-code]] — the cascade dispatch boundary is
now fully strict-raise-gated for code translations. Cascade VALUE
defaults (u_wall, u_floor, etc.) remain total per RdSAP §6.2.3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 09:46:55 +00:00 committed by Jun-te Kim
parent 089e6ac9da
commit c79f574e99
4 changed files with 280 additions and 53 deletions

View file

@ -0,0 +1,36 @@
"""Calculator-side strict-raise exception types.
Shared across `domain/sap10_calculator/` modules so any cascade-dispatch
helper can raise a consistent exception when it encounters a SAP/Table
code outside its dispatch dict. Mirrors the mapper-side
`UnmappedApiCode` / `UnmappedElmhurstLabel` pattern at
`datatypes/epc/domain/mapper.py`.
"""
from __future__ import annotations
class UnmappedSapCode(ValueError):
"""A SAP/Table integer code lodged on the cert that the calculator
does not yet know how to translate to a dispatch result.
Raised by strict cascade-dispatch helpers (Table 4e control codes,
Table 4d emitter codes, Appendix M PV pitch / overshading, meter
tariff, Table 12c heat-network DLF age band, Table 11 secondary
heating fraction by category, etc.) to surface spec-coverage gaps
at the cascade boundary instead of silently defaulting to a
fallback value.
Distinguish "lodging absent" (code is None / 0 / "" cascade
default OK, spec "assume as-built" applies) from "lodging present
but unmapped" (raise — fixture exposes a dispatch-dict gap that
needs an entry).
"""
def __init__(self, field: str, value: object) -> None:
super().__init__(
f"unmapped SAP code in {field}: {value!r}; "
f"add an entry to the corresponding cascade dispatch dict"
)
self.field = field
self.value = value

View file

@ -298,6 +298,17 @@ _PV_PITCH_DEG_BY_CODE: Final[dict[int, float]] = {
}
_PV_PITCH_DEG_DEFAULT: Final[float] = 30.0 # RdSAP10 §11.1 default
def _pv_pitch_deg(pitch_code: Optional[int]) -> float:
"""RdSAP 10 §11.1 PV pitch enum → degrees from horizontal. Strict-
dispatch per [[reference-unmapped-sap-code]]: absent (None / 0)
returns the spec default 30°; present-but-unmapped raises."""
if not pitch_code:
return _PV_PITCH_DEG_DEFAULT
if pitch_code in _PV_PITCH_DEG_BY_CODE:
return _PV_PITCH_DEG_BY_CODE[pitch_code]
raise UnmappedSapCode("pv_pitch_code", pitch_code)
# SAP 10.2 Appendix U3.3 equation (U4) constant: converts (W/m² × days)
# to (kWh/m²/yr) via 24 h/day ÷ 1000 W/kW = 0.024.
_HOURS_PER_DAY_OVER_1000: Final[float] = 0.024
@ -318,7 +329,7 @@ def _pv_annual_s_kwh_per_m2(
orientation = ORIENTATION_BY_SAP10_CODE.get(orientation_code)
if orientation is None:
return 0.0
pitch_deg = _PV_PITCH_DEG_BY_CODE.get(pitch_code, _PV_PITCH_DEG_DEFAULT)
pitch_deg = _pv_pitch_deg(pitch_code)
total = 0.0
for month_idx, days in enumerate(_DAYS_PER_MONTH):
s_m = surface_solar_flux_w_per_m2(
@ -342,6 +353,19 @@ _PV_OVERSHADING_FACTOR: Final[dict[int, float]] = {
3: 0.5,
4: 0.35,
}
_PV_OVERSHADING_FACTOR_DEFAULT: Final[float] = 1.0 # no shading
def _pv_overshading_factor(overshading_code: Optional[int]) -> float:
"""SAP 10.2 Table M1 PV overshading factor ZPV (RdSAP10 4-bucket
collapse). Strict-dispatch per [[reference-unmapped-sap-code]]:
absent (None / 0) returns the modal "no shading" default 1.0;
present-but-unmapped raises."""
if not overshading_code:
return _PV_OVERSHADING_FACTOR_DEFAULT
if overshading_code in _PV_OVERSHADING_FACTOR:
return _PV_OVERSHADING_FACTOR[overshading_code]
raise UnmappedSapCode("pv_overshading_code", overshading_code)
# SAP 10.2 Table 11 — fraction of space heating supplied by a secondary
@ -427,12 +451,17 @@ def _is_heat_network_main(main: Optional[MainHeatingDetail]) -> bool:
def _heat_network_dlf(age_band: Optional[str]) -> float:
"""RdSAP 10 §10.11 + SAP 10.2 Table 12c distribution loss factor by
age band. Defaults to the K-or-newer value (1.50) when band missing."""
if age_band is None:
age band. Defaults to the K-or-newer value (1.50) when band missing.
Strict-dispatch per [[reference-unmapped-sap-code]]: absent
(None / "") returns the spec default; present-but-unmapped (e.g.
"X" or "Z") raises so the spec-coverage gap surfaces."""
if not age_band:
return _HEAT_NETWORK_DLF_DEFAULT
return _HEAT_NETWORK_DLF_BY_AGE.get(
age_band.upper(), _HEAT_NETWORK_DLF_DEFAULT
)
band = age_band.upper()
if band in _HEAT_NETWORK_DLF_BY_AGE:
return _HEAT_NETWORK_DLF_BY_AGE[band]
raise UnmappedSapCode("heat_network_age_band", age_band)
@dataclass(frozen=True)
@ -560,29 +589,7 @@ _CONTROL_TYPE_BY_CODE: Final[dict[int, int]] = {
}
class UnmappedSapCode(ValueError):
"""A SAP/Table integer code lodged on the cert that the calculator
does not yet know how to translate to a dispatch result.
Raised by strict cascade-dispatch helpers (Table 4e control codes,
future Table 4d emitter codes, etc.) to surface spec-coverage gaps
at the cascade boundary instead of silently defaulting to a
fallback value. Mirrors `UnmappedApiCode` and `UnmappedElmhurstLabel`
on the mapper side same forcing-function philosophy for the
calculator side.
Distinguish "lodging absent" (code is None cascade default OK,
spec "assume as-built" applies) from "lodging present but unmapped"
(raise fixture exposes a dispatch-dict gap that needs an entry).
"""
def __init__(self, field: str, value: object) -> None:
super().__init__(
f"unmapped SAP code in {field}: {value!r}; "
f"add an entry to the corresponding cascade dispatch dict"
)
self.field = field
self.value = value
from domain.sap10_calculator.exceptions import UnmappedSapCode
@ -989,6 +996,17 @@ _TARIFF_HIGH_LOW_RATES_P_PER_KWH: Final[dict[Tariff, tuple[float, float]]] = {
}
def _tariff_high_low_rates_p_per_kwh(tariff: Tariff) -> tuple[float, float]:
"""RdSAP 10 Table 32 (page 95) per-tariff (high, low) rate tuples.
STANDARD has no split (callers must early-return before this fires);
the remaining 4 tariffs all have spec rates. Strict-dispatch per
[[reference-unmapped-sap-code]]: any future Tariff enum addition
must add an entry this raise enforces."""
if tariff in _TARIFF_HIGH_LOW_RATES_P_PER_KWH:
return _TARIFF_HIGH_LOW_RATES_P_PER_KWH[tariff]
raise UnmappedSapCode("tariff_high_low_rates", tariff)
# Tariff → (high_rate_fuel_code, low_rate_fuel_code) for the SAP 10.2
# Table 12d (CO2) / Table 12e (PE) monthly factors. Mirror of the
# Table 32 cost-rates dict above: 7-hour and 10-hour tariffs split into
@ -1059,10 +1077,7 @@ def _space_heating_fuel_cost_gbp_per_kwh(
high_frac = space_heating_high_rate_fraction(system, tariff)
except NotImplementedError:
return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP
rates = _TARIFF_HIGH_LOW_RATES_P_PER_KWH.get(tariff)
if rates is None:
return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP
high_rate, low_rate = rates
high_rate, low_rate = _tariff_high_low_rates_p_per_kwh(tariff)
blended = high_frac * high_rate + (1.0 - high_frac) * low_rate
return blended * _PENCE_TO_GBP
@ -1114,12 +1129,21 @@ def _secondary_fraction(
force = code is not None and code in _FORCE_SECONDARY_FOR_MAIN_CODES
if not has_lodged_secondary and not force:
return 0.0
cat = main.main_heating_category
if cat is None:
return _secondary_heating_fraction_for_category(main.main_heating_category)
def _secondary_heating_fraction_for_category(
main_heating_category: Optional[int],
) -> float:
"""SAP 10.2 Table 11 secondary-heating fraction by main heating
category. Strict-dispatch per [[reference-unmapped-sap-code]]:
absent (None) returns the modal default 0.10; present-but-unmapped
raises."""
if main_heating_category is None:
return _SECONDARY_HEATING_FRACTION_DEFAULT
return _SECONDARY_HEATING_FRACTION_BY_CATEGORY.get(
cat, _SECONDARY_HEATING_FRACTION_DEFAULT
)
if main_heating_category in _SECONDARY_HEATING_FRACTION_BY_CATEGORY:
return _SECONDARY_HEATING_FRACTION_BY_CATEGORY[main_heating_category]
raise UnmappedSapCode("main_heating_category", main_heating_category)
def _secondary_efficiency(
@ -1171,7 +1195,7 @@ def _pv_array_generation_kwh_per_yr(
if array.peak_power is None:
return 0.0
s = _pv_annual_s_kwh_per_m2(array.orientation, array.pitch, climate)
z = _PV_OVERSHADING_FACTOR.get(array.overshading, 1.0)
z = _pv_overshading_factor(array.overshading)
return _PV_MODULE_EFFICIENCY_FACTOR * array.peak_power * s * z
@ -1212,8 +1236,8 @@ def _pv_array_monthly_generation_kwh(
orientation = ORIENTATION_BY_SAP10_CODE.get(array.orientation)
if orientation is None:
return (0.0,) * 12
pitch_deg = _PV_PITCH_DEG_BY_CODE.get(array.pitch, _PV_PITCH_DEG_DEFAULT)
z = _PV_OVERSHADING_FACTOR.get(array.overshading, 1.0)
pitch_deg = _pv_pitch_deg(array.pitch)
z = _pv_overshading_factor(array.overshading)
monthly: list[float] = []
for month_idx, days in enumerate(_DAYS_PER_MONTH):
s_m_w_per_m2 = surface_solar_flux_w_per_m2(
@ -1453,10 +1477,7 @@ def _other_fuel_cost_gbp_per_kwh(
)
except NotImplementedError:
return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
rates = _TARIFF_HIGH_LOW_RATES_P_PER_KWH.get(tariff)
if rates is None:
return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
high_rate, low_rate = rates
high_rate, low_rate = _tariff_high_low_rates_p_per_kwh(tariff)
blended = high_frac * high_rate + (1.0 - high_frac) * low_rate
return blended * _PENCE_TO_GBP

View file

@ -32,12 +32,17 @@ from domain.sap10_ml.tests._fixtures import (
make_window,
)
from domain.sap10_calculator.calculator import Sap10Calculator, SapResult
from domain.sap10_calculator.exceptions import UnmappedSapCode
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage]
_heat_network_dlf, # pyright: ignore[reportPrivateUsage]
_main_floor_u_value, # pyright: ignore[reportPrivateUsage]
_pv_overshading_factor, # pyright: ignore[reportPrivateUsage]
_pv_pitch_deg, # pyright: ignore[reportPrivateUsage]
_responsiveness, # pyright: ignore[reportPrivateUsage]
_secondary_heating_fraction_for_category, # pyright: ignore[reportPrivateUsage]
_tariff_high_low_rates_p_per_kwh, # pyright: ignore[reportPrivateUsage]
_water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage]
UnmappedSapCode,
cert_to_demand_inputs,
cert_to_inputs,
pcdb_combi_loss_override,
@ -992,6 +997,153 @@ def test_responsiveness_raises_unmapped_sap_code_on_unknown_emitter() -> None:
assert excinfo.value.value == 99
def test_pv_pitch_deg_full_table_coverage_per_rdsap_10_section_11_1() -> None:
# Arrange — RdSAP 10 §11.1 fixes the PV pitch enum to 5 values:
# 1 = horizontal (0°)
# 2 = 30°
# 3 = 45°
# 4 = 60°
# 5 = vertical (90°)
# Strict-dispatch per [[reference-unmapped-sap-code]]: absent
# lodging (None / 0) returns the spec default (30°); lodging
# present but unmapped raises `UnmappedSapCode`.
# Act / Assert — spec-correct codes
assert _pv_pitch_deg(1) == 0.0
assert _pv_pitch_deg(2) == 30.0
assert _pv_pitch_deg(3) == 45.0
assert _pv_pitch_deg(4) == 60.0
assert _pv_pitch_deg(5) == 90.0
# Absent lodging — RdSAP §11.1 default (30°)
assert _pv_pitch_deg(None) == 30.0
assert _pv_pitch_deg(0) == 30.0
# Lodging present but unmapped → raise
with pytest.raises(UnmappedSapCode) as excinfo:
_pv_pitch_deg(99)
assert excinfo.value.field == "pv_pitch_code"
assert excinfo.value.value == 99
def test_pv_overshading_factor_full_table_m1_coverage() -> None:
# Arrange — SAP 10.2 Table M1 PV overshading factor ZPV (with the
# RdSAP10 collapse to 4 buckets, omitting "Severe"):
# 1 = very little / none → 1.0
# 2 = modest → 0.8
# 3 = significant → 0.5
# 4 = heavy → 0.35
# Act / Assert
assert _pv_overshading_factor(1) == 1.0
assert _pv_overshading_factor(2) == 0.8
assert _pv_overshading_factor(3) == 0.5
assert _pv_overshading_factor(4) == 0.35
# Absent lodging → modal default (no shading = 1.0)
assert _pv_overshading_factor(None) == 1.0
assert _pv_overshading_factor(0) == 1.0
# Lodging present but unmapped → raise
with pytest.raises(UnmappedSapCode) as excinfo:
_pv_overshading_factor(99)
assert excinfo.value.field == "pv_overshading_code"
assert excinfo.value.value == 99
def test_meter_type_dispatch_full_table_12a_coverage() -> None:
# Arrange — RdSAP cert meter_type enum (1..5) per Q11b spec mapping
# to Tariff enum. Absent (None / "") returns STANDARD; lodging
# present but unmapped raises.
from domain.sap10_calculator.tables.table_12a import (
Tariff, tariff_from_meter_type,
)
# Act / Assert
assert tariff_from_meter_type(1) is Tariff.SEVEN_HOUR
assert tariff_from_meter_type(2) is Tariff.STANDARD
assert tariff_from_meter_type(3) is Tariff.STANDARD
assert tariff_from_meter_type(4) is Tariff.TWENTY_FOUR_HOUR
assert tariff_from_meter_type(5) is Tariff.EIGHTEEN_HOUR
# Str-aliased codes accepted
assert tariff_from_meter_type("dual") is Tariff.SEVEN_HOUR
assert tariff_from_meter_type("standard") is Tariff.STANDARD
# Digit-string forms ('1', '2', ...) accepted via int-cast — the
# GOV.UK API lodges meter_type as a string of digits on many certs.
assert tariff_from_meter_type("1") is Tariff.SEVEN_HOUR
assert tariff_from_meter_type("2") is Tariff.STANDARD
assert tariff_from_meter_type("5") is Tariff.EIGHTEEN_HOUR
# Absent
assert tariff_from_meter_type(None) is Tariff.STANDARD
assert tariff_from_meter_type("") is Tariff.STANDARD
# Lodging present but unmapped → raise
with pytest.raises(UnmappedSapCode) as excinfo:
tariff_from_meter_type(99)
assert excinfo.value.field == "meter_type"
assert excinfo.value.value == 99
with pytest.raises(UnmappedSapCode) as excinfo2:
tariff_from_meter_type("rocket fuel")
assert excinfo2.value.field == "meter_type"
def test_tariff_high_low_rates_full_dispatch_coverage() -> None:
# Arrange — RdSAP 10 Table 32 (page 95) per-tariff (high, low) rate
# tuples. STANDARD tariff has no high/low split (early-returned by
# callers before this dispatch fires); the remaining 4 tariffs all
# have spec rates. Any future Tariff enum addition must add an
# entry here — strict-raise enforces.
from domain.sap10_calculator.tables.table_12a import Tariff
# Act / Assert
assert _tariff_high_low_rates_p_per_kwh(Tariff.SEVEN_HOUR) == (15.29, 5.50)
assert _tariff_high_low_rates_p_per_kwh(Tariff.TEN_HOUR) == (14.68, 7.50)
assert _tariff_high_low_rates_p_per_kwh(Tariff.EIGHTEEN_HOUR) == (13.67, 7.41)
assert _tariff_high_low_rates_p_per_kwh(Tariff.TWENTY_FOUR_HOUR) == (6.61, 6.61)
# STANDARD raises — caller must early-return before invoking this
with pytest.raises(UnmappedSapCode) as excinfo:
_tariff_high_low_rates_p_per_kwh(Tariff.STANDARD)
assert excinfo.value.field == "tariff_high_low_rates"
def test_heat_network_dlf_full_table_12c_age_band_coverage() -> None:
# Arrange — SAP 10.2 Table 12c (page 193) heat-network Distribution
# Loss Factor by dwelling age band A..M. None → K-or-newer
# default (1.50). Lodging present but unmapped (e.g. "X") raises.
# Act / Assert spec age bands
assert _heat_network_dlf("A") == 1.20
assert _heat_network_dlf("K") == 1.50
assert _heat_network_dlf("M") == 1.50
# Case-insensitive
assert _heat_network_dlf("a") == 1.20
# Absent
assert _heat_network_dlf(None) == 1.50
# Lodging present but unmapped → raise
with pytest.raises(UnmappedSapCode) as excinfo:
_heat_network_dlf("X")
assert excinfo.value.field == "heat_network_age_band"
assert excinfo.value.value == "X"
def test_secondary_heating_fraction_for_category_full_table_11_coverage() -> None:
# Arrange — SAP 10.2 Table 11 secondary-heating fraction by main
# heating category. Category None → default 0.10 (absent); category
# lodged but unmapped → raise.
# Act / Assert
assert _secondary_heating_fraction_for_category(1) == 0.10
assert _secondary_heating_fraction_for_category(2) == 0.10
assert _secondary_heating_fraction_for_category(3) == 0.10
assert _secondary_heating_fraction_for_category(4) == 0.00
assert _secondary_heating_fraction_for_category(5) == 0.10
assert _secondary_heating_fraction_for_category(6) == 0.10
assert _secondary_heating_fraction_for_category(7) == 0.15
assert _secondary_heating_fraction_for_category(10) == 0.20
# Absent
assert _secondary_heating_fraction_for_category(None) == 0.10
# Lodging present but unmapped → raise
with pytest.raises(UnmappedSapCode) as excinfo:
_secondary_heating_fraction_for_category(99)
assert excinfo.value.field == "main_heating_category"
assert excinfo.value.value == 99
def test_responsiveness_default_1p0_when_emitter_lodging_absent() -> None:
# Arrange — emitter lodging absent (None / 0 / "") returns modal
# default R=1.0 (radiators). Corpus has 4 certs lodging emitter=0

View file

@ -190,25 +190,43 @@ 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)
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):
return _METER_INT_TO_TARIFF.get(meter_type, Tariff.STANDARD)
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):
code = _METER_STR_TO_INT.get(meter_type.strip().lower())
if code is None:
return Tariff.STANDARD
return _METER_INT_TO_TARIFF[code]
return Tariff.STANDARD
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