Model/domain/sap10_calculator/exceptions.py
Khalim Conn-Kowlessar 44fff76722 fix(fuel): raise UnpricedFuelCode for unrecognised fuels instead of silently defaulting to mains gas
`table_32.unit_price_p_per_kwh` silently returned the mains-gas default
(3.48 p/kWh) for any fuel code it could not resolve to a Table 32 price or a
translatable gov-API enum. An unhandled fuel billed at the gas rate mis-costs
the dwelling (same failure mode as the dual-main wood-vs-electric over-cost).
Raise `UnpricedFuelCode` (new, mirrors MissingMainFuelType / UnmappedSapCode)
so the gap surfaces at the price boundary. `None` (no fuel lodged) still
defaults — callers resolve "no system" upstream.

0 corpus impact: all 1000 certs compute (every lodged fuel resolves), so this
is a forward guard against future/unmapped fuels. Unit pin added; existing
None-default test docstring tightened. pyright not installed locally — strict
type gate not run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:31:04 +00:00

86 lines
3.7 KiB
Python

"""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
class MissingMainFuelType(ValueError):
"""The cascade was asked to resolve `MainHeatingDetail.main_fuel_type`
but the mapper produced no usable SAP fuel code (None / empty string
/ unmapped string label).
Unlike the Table 4d/4e dispatch sites where "absent" maps to a spec-
blessed "assume as-built" default, heating fuel has no defensible
default: silently routing to mains gas produces a misleading cascade
output where cost may happen to be close but CO2 / PE / efficiency
are completely wrong for the actual heating system. The fix is
upstream in the mapper — extract the fuel from the appropriate
Summary / EPC field, or derive it from `sap_main_heating_code`
via SAP 10.2 Table 4a/4b/4f.
"""
def __init__(self, value: object, sap_main_heating_code: object) -> None:
super().__init__(
f"MainHeatingDetail.main_fuel_type is not resolvable to a SAP "
f"fuel code (got {value!r}); sap_main_heating_code="
f"{sap_main_heating_code!r}. Fix the mapper to populate "
f"main_fuel_type as an int via Summary / EPC fields or via "
f"SAP 10.2 Table 4a/4b/4f derivation from the SAP code."
)
self.value = value
self.sap_main_heating_code = sap_main_heating_code
class UnpricedFuelCode(ValueError):
"""A concrete (non-None) fuel code reached the Table 32 unit-price
lookup but resolves to neither a Table 32 price code nor a gov-API
enum that translates to one.
Raised instead of silently falling back to the mains-gas price
(3.48 p/kWh): an unrecognised fuel billed at the gas rate produces a
misleading cost (e.g. the dual-main wood-vs-electric over-cost on
cert 10032957680). Surface the gap at the price boundary so the fuel
is either added to `UNIT_PRICE_P_PER_KWH` / `API_FUEL_TO_TABLE_32`
or canonicalised upstream. A `None` code (no fuel lodged) is NOT an
error — callers resolve "no system" before pricing.
"""
def __init__(self, fuel_code: object) -> None:
super().__init__(
f"no Table 32 unit price for fuel code {fuel_code!r}; add it to "
f"UNIT_PRICE_P_PER_KWH or API_FUEL_TO_TABLE_32, or canonicalise "
f"the code upstream before pricing"
)
self.fuel_code = fuel_code