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>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-23 08:31:04 +00:00
parent 702150002f
commit 44fff76722
3 changed files with 53 additions and 6 deletions

View file

@ -61,3 +61,26 @@ class MissingMainFuelType(ValueError):
)
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

View file

@ -17,6 +17,7 @@ from __future__ import annotations
from typing import Final, Optional
from domain.sap10_calculator.exceptions import UnpricedFuelCode
from domain.sap10_calculator.tables.table_12a import Tariff
@ -184,8 +185,16 @@ STANDING_CHARGE_GBP_PER_YR: Final[dict[int, float]] = {
def unit_price_p_per_kwh(fuel_code: Optional[int]) -> float:
"""Unit price (p/kWh) for the given fuel code. Accepts either a
Table 32 code or a gov API `main_fuel_type` / `water_heating_fuel`
enum; translates the latter via `API_FUEL_TO_TABLE_32`. Unknown
mains gas (3.48 p/kWh)."""
enum; translates the latter via `API_FUEL_TO_TABLE_32`.
`None` (no fuel lodged) mains-gas default; callers resolve a
"no system" before pricing. A concrete but UNRECOGNISED code raises
`UnpricedFuelCode` rather than silently defaulting to the gas price
an unhandled fuel billed at 3.48 p/kWh mis-costs the dwelling (the
same failure mode as the dual-main wood-vs-electric over-cost). The
strict-raise surfaces the gap at the price boundary; 0 corpus certs
hit it today (every lodged fuel resolves), so the raise is a guard
against future / unmapped fuels, mirroring `MissingMainFuelType`."""
if fuel_code is None:
return _DEFAULT_P_PER_KWH
if fuel_code in UNIT_PRICE_P_PER_KWH:
@ -193,7 +202,7 @@ def unit_price_p_per_kwh(fuel_code: Optional[int]) -> float:
translated = API_FUEL_TO_TABLE_32.get(fuel_code)
if translated is not None and translated in UNIT_PRICE_P_PER_KWH:
return UNIT_PRICE_P_PER_KWH[translated]
return _DEFAULT_P_PER_KWH
raise UnpricedFuelCode(fuel_code)
def standing_charge_gbp(fuel_code: Optional[int]) -> float:

View file

@ -13,6 +13,7 @@ from __future__ import annotations
import pytest
from domain.sap10_calculator.exceptions import UnpricedFuelCode
from domain.sap10_calculator.tables.table_12a import Tariff
from domain.sap10_calculator.tables.table_32 import (
additional_standing_charges_gbp,
@ -143,9 +144,9 @@ def test_unit_price_translates_api_fuel_enum_via_api_fuel_to_table_32() -> None:
def test_unit_price_defaults_to_mains_gas_when_code_is_none() -> None:
"""Mirrors `table_12.unit_price_p_per_kwh` behaviour: unknown / missing
fuel codes fall back to mains gas. cert_to_inputs occasionally has to
resolve a price for a cert with a missing main_fuel_type."""
"""A `None` fuel code (no fuel lodged) falls back to mains gas — callers
resolve a "no system" before pricing, so None is not an error. A concrete
UNRECOGNISED code raises instead (see the next test)."""
# Arrange
fuel_code = None
@ -156,6 +157,20 @@ def test_unit_price_defaults_to_mains_gas_when_code_is_none() -> None:
assert price == 3.48
def test_unit_price_raises_on_unrecognised_fuel_code() -> None:
"""A concrete (non-None) fuel code that resolves to neither a Table 32
price nor a translatable gov-API enum raises `UnpricedFuelCode` rather
than silently defaulting to the mains-gas price an unhandled fuel
billed at 3.48 p/kWh mis-costs the dwelling (the dual-main wood-vs-
electric failure mode). Guards against future/unmapped fuels."""
# Arrange — 999 is not a Table 32 code nor a gov-API fuel enum.
fuel_code = 999
# Act / Assert
with pytest.raises(UnpricedFuelCode):
unit_price_p_per_kwh(fuel_code)
@pytest.mark.parametrize(
"fuel_code, expected_standing_gbp, fuel_name",
[