Model/domain/fuel_rates/fuel.py
Jun-te Kim d97b8e87a4 Add dual-fuel (mineral+wood) billing carrier (fix UnmappedSapCode 10)
10 modelling_e2e properties failed with "unmapped SAP code in fuel_code: 10":
the billing layer (`sap_code_to_fuel`) had no carrier for Table-32 code 10
(dual fuel, mineral + wood) and raised rather than guess one.

SAP 10.2 treats dual fuel as its OWN fuel (its own Table-12 factors), so model
it as its own billing carrier rather than collapsing onto wood or coal:

- New `Fuel.DUAL_FUEL_MINERAL_AND_WOOD`.
- `_CODE_TO_FUEL[10]` -> that carrier.
- Fuel Rates snapshot prices it at 7.69 p/kWh — the midpoint of the COAL proxy
  (7.13) and WOOD_LOGS (8.25). This mirrors SAP's own construction: Table-32
  dual fuel (3.99) ~= midpoint of house coal (3.67) and wood logs (4.23).
  Marked `derived` with a documented _note/_gap/_assumption (like the COAL and
  HEAT_NETWORK proxies), since there is no retail blend price.

A dedicated carrier + rate (vs a one-line map to an existing carrier) keeps the
fuel identity faithful to SAP and avoids mispricing dual fuel as pure wood/coal.

Tests: code 10 -> DUAL_FUEL carrier; snapshot prices it at 7.69; grid-export
codes (36/60) still raise (the genuine no-carrier case).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:07:57 +00:00

47 lines
1.8 KiB
Python

from __future__ import annotations
from enum import Enum
class Fuel(Enum):
"""A canonical billing fuel — the join key between the calculator's
per-end-use fuel (mapped from SAP fuel codes) and the Fuel Rates snapshot
(ADR-0014). Member names match the snapshot's keys.
``COAL`` (traditional house coal) and ``HEAT_NETWORK`` are carried as
members so a cert lodging them maps to a Fuel, but they have no national
rate — pricing them raises ``UnpricedFuel`` (house coal's domestic sale is
illegal in England; heat networks are scheme-specific).
"""
MAINS_GAS = "MAINS_GAS"
ELECTRICITY = "ELECTRICITY"
ELECTRICITY_OFF_PEAK = "ELECTRICITY_OFF_PEAK"
OIL = "OIL"
LPG = "LPG"
COAL = "COAL"
SMOKELESS = "SMOKELESS"
WOOD_LOGS = "WOOD_LOGS"
WOOD_PELLETS = "WOOD_PELLETS"
# SAP 10.2 dual-fuel appliance (mineral + wood) — its own SAP fuel, so kept
# as its own billing carrier rather than collapsed onto wood or coal. Priced
# as the mineral+wood midpoint (see the Fuel Rates snapshot note).
DUAL_FUEL_MINERAL_AND_WOOD = "DUAL_FUEL_MINERAL_AND_WOOD"
HEAT_NETWORK = "HEAT_NETWORK"
class UnpricedFuel(ValueError):
"""Bill Derivation was asked for a rate on a fuel the current Fuel Rates
snapshot does not price (ADR-0014).
Raised rather than billing at a wrong default so the gap surfaces
immediately — house coal and heat networks have no national rate, and
off-peak electricity needs the day/night split that a later slice adds.
"""
def __init__(self, fuel: Fuel) -> None:
super().__init__(
f"no rate for fuel {fuel.name} in the current Fuel Rates snapshot; "
f"add it to the snapshot or map this end use to a priced fuel"
)
self.fuel = fuel