From 44fff767224508ce7c473b4c794707cba8ddeeeb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 23 Jun 2026 08:31:04 +0000 Subject: [PATCH] fix(fuel): raise UnpricedFuelCode for unrecognised fuels instead of silently defaulting to mains gas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- domain/sap10_calculator/exceptions.py | 23 +++++++++++++++++++ domain/sap10_calculator/tables/table_32.py | 15 +++++++++--- .../domain/sap10_calculator/test_table_32.py | 21 ++++++++++++++--- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/domain/sap10_calculator/exceptions.py b/domain/sap10_calculator/exceptions.py index a91bd9cd..374f6545 100644 --- a/domain/sap10_calculator/exceptions.py +++ b/domain/sap10_calculator/exceptions.py @@ -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 diff --git a/domain/sap10_calculator/tables/table_32.py b/domain/sap10_calculator/tables/table_32.py index 14544aea..da22b5ce 100644 --- a/domain/sap10_calculator/tables/table_32.py +++ b/domain/sap10_calculator/tables/table_32.py @@ -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: diff --git a/tests/domain/sap10_calculator/test_table_32.py b/tests/domain/sap10_calculator/test_table_32.py index b297a78d..59e2c93c 100644 --- a/tests/domain/sap10_calculator/test_table_32.py +++ b/tests/domain/sap10_calculator/test_table_32.py @@ -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", [