mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
089e6ac9da
commit
c79f574e99
4 changed files with 280 additions and 53 deletions
36
domain/sap10_calculator/exceptions.py
Normal file
36
domain/sap10_calculator/exceptions.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue