From d97b8e87a44884dffbe29469c837bbae3da4093e Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 24 Jun 2026 10:07:57 +0000 Subject: [PATCH] Add dual-fuel (mineral+wood) billing carrier (fix UnmappedSapCode 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- domain/billing/sap_fuel.py | 3 +++ domain/fuel_rates/fuel.py | 4 ++++ repositories/fuel_rates/data/fuel_rates_2026_q2.json | 3 +++ tests/domain/billing/test_sap_fuel.py | 6 ++++-- .../test_fuel_rates_static_file_repository.py | 11 +++++++++++ 5 files changed, 25 insertions(+), 2 deletions(-) diff --git a/domain/billing/sap_fuel.py b/domain/billing/sap_fuel.py index b0523a2f..0c78ddae 100644 --- a/domain/billing/sap_fuel.py +++ b/domain/billing/sap_fuel.py @@ -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. diff --git a/domain/fuel_rates/fuel.py b/domain/fuel_rates/fuel.py index fff51f57..f84a9b27 100644 --- a/domain/fuel_rates/fuel.py +++ b/domain/fuel_rates/fuel.py @@ -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" diff --git a/repositories/fuel_rates/data/fuel_rates_2026_q2.json b/repositories/fuel_rates/data/fuel_rates_2026_q2.json index c92af0dc..397efa3c 100644 --- a/repositories/fuel_rates/data/fuel_rates_2026_q2.json +++ b/repositories/fuel_rates/data/fuel_rates_2026_q2.json @@ -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)" } } diff --git a/tests/domain/billing/test_sap_fuel.py b/tests/domain/billing/test_sap_fuel.py index ae9dd28f..98bc5c23 100644 --- a/tests/domain/billing/test_sap_fuel.py +++ b/tests/domain/billing/test_sap_fuel.py @@ -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) diff --git a/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py b/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py index 6b177296..1bce0362 100644 --- a/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py +++ b/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py @@ -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()