Model/domain/billing/sap_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

57 lines
3 KiB
Python

from __future__ import annotations
from typing import Final
from domain.fuel_rates.fuel import Fuel
from domain.sap10_calculator.exceptions import UnmappedSapCode
from domain.sap10_calculator.tables.table_32 import to_table_32_code
# Table 32 fuel code -> canonical billing Fuel (ADR-0014). Bounded to the ~47
# Table 32 fuel codes (the keys of `UNIT_PRICE_P_PER_KWH`) — the carrier, NOT the
# PCDB product, so a thousand PCDB heat pumps all share one code. An unmapped code
# raises `UnmappedSapCode` rather than guessing — a bounded, self-surfacing
# backlog [[reference-unmapped-sap-code]].
_CODE_TO_FUEL: Final[dict[int, Fuel]] = {
**dict.fromkeys([1, 7], Fuel.MAINS_GAS), # mains gas, grid biogas
**dict.fromkeys([2, 3, 5, 9], Fuel.LPG),
**dict.fromkeys([4, 71, 73, 75, 76], Fuel.OIL), # heating oil + bio-liquids
**dict.fromkeys([11, 15], Fuel.COAL), # house coal, anthracite
**dict.fromkeys([12], Fuel.SMOKELESS),
**dict.fromkeys([20, 21], Fuel.WOOD_LOGS), # logs, chips
**dict.fromkeys([22, 23], Fuel.WOOD_PELLETS),
# Dual fuel (mineral + wood) — SAP 10.2 keeps it as its own fuel, so bill it
# as its own carrier (priced as the mineral+wood midpoint in the snapshot).
**dict.fromkeys([10], Fuel.DUAL_FUEL_MINERAL_AND_WOOD),
**dict.fromkeys([30], Fuel.ELECTRICITY), # standard tariff
# 7/10/18-hour off-peak tariffs + 24-hour heating tariff — priced once the
# off-peak day/night slice lands; ELECTRICITY_OFF_PEAK is unpriced until then.
**dict.fromkeys([31, 32, 33, 34, 35, 38, 40], Fuel.ELECTRICITY_OFF_PEAK),
# "heat from ..." community/heat-network + distribution codes (41-58).
**dict.fromkeys(range(41, 59), Fuel.HEAT_NETWORK),
}
def sap_code_to_fuel(code: int) -> Fuel:
"""Map one of the calculator's per-end-use fuel codes to its billing Fuel.
The code may be a raw gov-API `main_fuel_type` enum or an already-Table-32
code depending on the source mapper (until [[adr-0015]] normalizes the cert),
so it is first run through the calculator's own ``to_table_32_code`` —
T32-first, then API-translate — the **same** normalization the calculator's
pricing/CO2 helpers use, so the bill's carrier matches what the calculator
billed. The normalized Table-32 code is then dispatched to a billing Fuel.
Raises ``UnmappedSapCode`` on a code with no single billing carrier — e.g.
dual fuel (10) or the grid-export codes (36/60), which are not an end use's
input fuel.
"""
# Normalize to a Table-32 code; fall back to the raw code for billing fuels
# the price table does not carry (the 41-58 heat-network range — `to_table_32_
# code` returns None there, but they still resolve to HEAT_NETWORK and so to
# UnpricedFuel, which is stricter — and correct — than the calculator's
# lossy default-to-mains-gas for an unpriced code).
normalized = to_table_32_code(code)
fuel = _CODE_TO_FUEL.get(normalized if normalized is not None else code)
if fuel is None:
raise UnmappedSapCode("fuel_code", code)
return fuel