diff --git a/domain/sap10_calculator/exceptions.py b/domain/sap10_calculator/exceptions.py new file mode 100644 index 00000000..54ccffe3 --- /dev/null +++ b/domain/sap10_calculator/exceptions.py @@ -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 diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index dbdb4d01..4fefa7ce 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index 48dac2f9..fed55a29 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -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 diff --git a/domain/sap10_calculator/tables/table_12a.py b/domain/sap10_calculator/tables/table_12a.py index dccc555e..e4a07f1b 100644 --- a/domain/sap10_calculator/tables/table_12a.py +++ b/domain/sap10_calculator/tables/table_12a.py @@ -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