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>
This commit is contained in:
Jun-te Kim 2026-06-24 10:07:57 +00:00
parent 22cb47a280
commit d97b8e87a4
5 changed files with 25 additions and 2 deletions

View file

@ -19,6 +19,9 @@ _CODE_TO_FUEL: Final[dict[int, Fuel]] = {
**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.

View file

@ -23,6 +23,10 @@ class Fuel(Enum):
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"

View file

@ -16,11 +16,13 @@
"SMOKELESS": { "unit_rate_p_per_kwh": 8.69, "standing_charge_p_per_day": 0.0 },
"WOOD_LOGS": { "unit_rate_p_per_kwh": 8.25, "standing_charge_p_per_day": 0.0 },
"WOOD_PELLETS": { "unit_rate_p_per_kwh": 7.38, "standing_charge_p_per_day": 0.0, "_note": "bagged pellets (NEP Apr 2026 / DUKES GCV); blown bulk is lower" },
"DUAL_FUEL_MINERAL_AND_WOOD": { "unit_rate_p_per_kwh": 7.69, "standing_charge_p_per_day": 0.0, "derived": true, "_note": "DERIVED, not a market rate. SAP 10.2 dual-fuel appliance (mineral + wood) has no single retail price. Midpoint of the COAL proxy (7.13) and WOOD_LOGS (8.25) = 7.69, mirroring SAP Table-32 dual fuel (3.99) ~= midpoint of house coal (3.67) and wood logs (4.23)." },
"COAL": { "unit_rate_p_per_kwh": 7.13, "standing_charge_p_per_day": 0.0, "proxy": true, "_note": "PROXY, not a market rate. No current GB retail house-coal price (NEP Apr 2026 blank; domestic house-coal sale restricted since 2021). NEP Nov 2025 coal 48.50p/kg uprated by smokeless movement -> 52.39p/kg / DUKES house-coal GCV 7.3502 kWh/kg = 7.13 p/kWh." },
"HEAT_NETWORK": { "unit_rate_p_per_kwh": 16.0, "standing_charge_p_per_day": 69.4, "indicative": true, "_note": "INDICATIVE, not a regulated rate. Delivered-heat charge; no national tariff/cap. Insite Energy Nov 2024 operator sample avg 16.03 p/kWh + 69.42 p/day; schemes vary widely (~8-30 p/kWh per CMA/Heat Trust)." }
},
"_gaps": {
"COAL": "PROXY rate (see _note): no current national retail price; sense-check estimate so coal-heated certs model rather than erroring.",
"DUAL_FUEL_MINERAL_AND_WOOD": "DERIVED rate (see _note): mineral+wood blend has no retail price; midpoint of the COAL proxy and WOOD_LOGS so dual-fuel certs model rather than erroring.",
"HEAT_NETWORK": "INDICATIVE rate (see _note): scheme-specific, no national tariff/cap; treat the bill as indicative.",
"ELECTRICITY_OFF_PEAK": "day/night split; priced once the off-peak slice adds the day/night accessor"
},
@ -32,6 +34,7 @@
"WOOD_LOGS": "NEP Apr 2026 kiln-dried 37.26 p/kg / DUKES wood 4.5156 kWh/kg = 8.25 p/kWh",
"WOOD_PELLETS": "NEP Apr 2026 bagged 38.33 p/kg / DUKES pellet 5.1928 kWh/kg = 7.38 p/kWh",
"COAL": "NEP Nov 2025 48.50 p/kg uprated x(71.42/66.12) = 52.39 p/kg / DUKES house-coal 7.3502 kWh/kg = 7.13 p/kWh",
"DUAL_FUEL_MINERAL_AND_WOOD": "(COAL 7.13 + WOOD_LOGS 8.25) / 2 = 7.69 p/kWh; mirrors SAP Table-32 dual fuel = midpoint of house coal + wood logs",
"HEAT_NETWORK": "Insite Energy Nov 2024 operator sample avg 16.03 p/kWh + 69.42 p/day (delivered heat)"
}
}

View file

@ -24,6 +24,7 @@ def test_mains_gas_code_maps_to_mains_gas() -> None:
(12, Fuel.SMOKELESS),
(20, Fuel.WOOD_LOGS),
(23, Fuel.WOOD_PELLETS),
(10, Fuel.DUAL_FUEL_MINERAL_AND_WOOD), # dual fuel (mineral + wood)
(30, Fuel.ELECTRICITY), # standard tariff
(32, Fuel.ELECTRICITY_OFF_PEAK), # 7-hour tariff
(41, Fuel.HEAT_NETWORK), # heat from electric heat pump (community)
@ -52,7 +53,8 @@ def test_raw_api_fuel_codes_normalize_before_mapping(api_code: int, fuel: Fuel)
def test_an_unmapped_code_raises_rather_than_guessing() -> None:
# Arrange — code 10 (dual fuel) has no single billing fuel.
# Arrange — grid-export code 60 is not an end use's input fuel, so it has no
# billing carrier (code 10 dual fuel is now mapped — see the parametrized test).
# Act / Assert
with pytest.raises(UnmappedSapCode):
sap_code_to_fuel(10)
sap_code_to_fuel(60)

View file

@ -44,6 +44,17 @@ def test_coal_and_heat_network_carry_proxy_rates() -> None:
assert rates.standing_charge_p_per_day(Fuel.HEAT_NETWORK) == 69.4
def test_dual_fuel_carries_a_derived_midpoint_rate() -> None:
# Arrange — dual fuel (mineral + wood) has no retail blend price, so the
# snapshot prices it as the COAL-proxy/WOOD_LOGS midpoint (see the JSON
# _note) — mirroring SAP Table-32's own dual-fuel construction.
rates = FuelRatesStaticFileRepository().get_current()
# Act / Assert — (7.13 + 8.25) / 2 = 7.69
assert rates.unit_rate_p_per_kwh(Fuel.DUAL_FUEL_MINERAL_AND_WOOD) == 7.69
assert rates.standing_charge_p_per_day(Fuel.DUAL_FUEL_MINERAL_AND_WOOD) == 0.0
def test_off_peak_remains_unpriced_pending_the_day_night_accessor() -> None:
# Arrange — off-peak still needs the day/night split a later slice adds (ADR-0014).
rates = FuelRatesStaticFileRepository().get_current()