mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
22cb47a280
commit
d97b8e87a4
5 changed files with 25 additions and 2 deletions
|
|
@ -19,6 +19,9 @@ _CODE_TO_FUEL: Final[dict[int, Fuel]] = {
|
||||||
**dict.fromkeys([12], Fuel.SMOKELESS),
|
**dict.fromkeys([12], Fuel.SMOKELESS),
|
||||||
**dict.fromkeys([20, 21], Fuel.WOOD_LOGS), # logs, chips
|
**dict.fromkeys([20, 21], Fuel.WOOD_LOGS), # logs, chips
|
||||||
**dict.fromkeys([22, 23], Fuel.WOOD_PELLETS),
|
**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
|
**dict.fromkeys([30], Fuel.ELECTRICITY), # standard tariff
|
||||||
# 7/10/18-hour off-peak tariffs + 24-hour heating tariff — priced once the
|
# 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.
|
# off-peak day/night slice lands; ELECTRICITY_OFF_PEAK is unpriced until then.
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ class Fuel(Enum):
|
||||||
SMOKELESS = "SMOKELESS"
|
SMOKELESS = "SMOKELESS"
|
||||||
WOOD_LOGS = "WOOD_LOGS"
|
WOOD_LOGS = "WOOD_LOGS"
|
||||||
WOOD_PELLETS = "WOOD_PELLETS"
|
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"
|
HEAT_NETWORK = "HEAT_NETWORK"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,13 @@
|
||||||
"SMOKELESS": { "unit_rate_p_per_kwh": 8.69, "standing_charge_p_per_day": 0.0 },
|
"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_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" },
|
"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." },
|
"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)." }
|
"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": {
|
"_gaps": {
|
||||||
"COAL": "PROXY rate (see _note): no current national retail price; sense-check estimate so coal-heated certs model rather than erroring.",
|
"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.",
|
"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"
|
"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_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",
|
"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",
|
"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)"
|
"HEAT_NETWORK": "Insite Energy Nov 2024 operator sample avg 16.03 p/kWh + 69.42 p/day (delivered heat)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ def test_mains_gas_code_maps_to_mains_gas() -> None:
|
||||||
(12, Fuel.SMOKELESS),
|
(12, Fuel.SMOKELESS),
|
||||||
(20, Fuel.WOOD_LOGS),
|
(20, Fuel.WOOD_LOGS),
|
||||||
(23, Fuel.WOOD_PELLETS),
|
(23, Fuel.WOOD_PELLETS),
|
||||||
|
(10, Fuel.DUAL_FUEL_MINERAL_AND_WOOD), # dual fuel (mineral + wood)
|
||||||
(30, Fuel.ELECTRICITY), # standard tariff
|
(30, Fuel.ELECTRICITY), # standard tariff
|
||||||
(32, Fuel.ELECTRICITY_OFF_PEAK), # 7-hour tariff
|
(32, Fuel.ELECTRICITY_OFF_PEAK), # 7-hour tariff
|
||||||
(41, Fuel.HEAT_NETWORK), # heat from electric heat pump (community)
|
(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:
|
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
|
# Act / Assert
|
||||||
with pytest.raises(UnmappedSapCode):
|
with pytest.raises(UnmappedSapCode):
|
||||||
sap_code_to_fuel(10)
|
sap_code_to_fuel(60)
|
||||||
|
|
|
||||||
|
|
@ -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
|
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:
|
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).
|
# Arrange — off-peak still needs the day/night split a later slice adds (ADR-0014).
|
||||||
rates = FuelRatesStaticFileRepository().get_current()
|
rates = FuelRatesStaticFileRepository().get_current()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue