diff --git a/docs/adr/0014-bill-derivation-from-real-fuel-rates.md b/docs/adr/0014-bill-derivation-from-real-fuel-rates.md index 7c033085..cf01b02a 100644 --- a/docs/adr/0014-bill-derivation-from-real-fuel-rates.md +++ b/docs/adr/0014-bill-derivation-from-real-fuel-rates.md @@ -79,6 +79,20 @@ production migration is FE-owned (Drizzle); `docs/migrations/` updated. - The snapshot goes stale on the Ofgem-cap cadence (quarterly); the file records its period, and the ETL that automates refresh is the deferred follow-up. +## Deferred / TODO + +- **Appliances + cooking kWh** are computed inside `cert_to_inputs` (Appendix L L13-L20) but not + yet threaded onto `SapResult`. Until they are, the `SapResult` → `EnergyBreakdown` adapter + **stubs them at 0 kWh**, so the bill total currently understates by the unregulated electricity + load. Khalim is adding the fields to `SapResult` directly; the adapter wires the + `APPLIANCES`/`COOKING` sections in as soon as they land. +- **Off-peak (Economy 7) day/night split** — the snapshot carries the E7 day/night rates, but + `FuelRates` exposes single-rate fuels only; the day/night accessor + the calculator's Table 12a + high/low-rate split land in a later slice. +- **Heat-network rate model** — heat-network certs raise `UnpricedFuel` for now (the one common gap). +- **Regional rates + Ofgem-cap ETL** — national snapshot now; both are later refinements behind the + same `FuelRatesRepository` port. + ## Considered alternatives - **Bill from `RenewableHeatIncentive` heating+HW kWh only** (CONTEXT's original scope) — rejected: diff --git a/domain/property_baseline/sap_fuel.py b/domain/property_baseline/sap_fuel.py new file mode 100644 index 00000000..cd7c6efc --- /dev/null +++ b/domain/property_baseline/sap_fuel.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import Final + +from domain.fuel_rates.fuel import Fuel +from domain.sap10_calculator.exceptions import UnmappedSapCode + +# SAP 10.2 / Table 32 fuel code -> canonical billing Fuel (ADR-0014). Bounded to +# the ~47 Table 32 fuel codes (the keys of `table_12.UNIT_PRICE_P_PER_KWH`) — the +# carrier, NOT the PCDB product, so a thousand PCDB heat pumps all share one code. +# Input is a normalised Table 32 fuel code (the calculator sets `main_fuel_type` +# to Table 32 codes); 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), + **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 a SAP 10.2 / Table 32 fuel code to its canonical 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. + """ + fuel = _CODE_TO_FUEL.get(code) + if fuel is None: + raise UnmappedSapCode("fuel_code", code) + return fuel diff --git a/tests/domain/property_baseline/test_sap_fuel.py b/tests/domain/property_baseline/test_sap_fuel.py new file mode 100644 index 00000000..24dcf193 --- /dev/null +++ b/tests/domain/property_baseline/test_sap_fuel.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import pytest + +from domain.fuel_rates.fuel import Fuel +from domain.property_baseline.sap_fuel import sap_code_to_fuel +from domain.sap10_calculator.exceptions import UnmappedSapCode + + +def test_mains_gas_code_maps_to_mains_gas() -> None: + # Arrange / Act / Assert — Table 32 code 1 is mains gas. + assert sap_code_to_fuel(1) == Fuel.MAINS_GAS + + +@pytest.mark.parametrize( + ("code", "fuel"), + [ + (1, Fuel.MAINS_GAS), + (2, Fuel.LPG), + (4, Fuel.OIL), + (76, Fuel.OIL), # bioethanol — a liquid fuel row + (11, Fuel.COAL), # house coal + (15, Fuel.COAL), # anthracite + (12, Fuel.SMOKELESS), + (20, Fuel.WOOD_LOGS), + (23, Fuel.WOOD_PELLETS), + (30, Fuel.ELECTRICITY), # standard tariff + (32, Fuel.ELECTRICITY_OFF_PEAK), # 7-hour tariff + (41, Fuel.HEAT_NETWORK), # heat from electric heat pump (community) + (50, Fuel.HEAT_NETWORK), # electricity for distribution pumping + ], +) +def test_table_32_codes_map_to_their_billing_fuel(code: int, fuel: Fuel) -> None: + # Arrange / Act / Assert + assert sap_code_to_fuel(code) == fuel + + +def test_an_unmapped_code_raises_rather_than_guessing() -> None: + # Arrange — code 10 (dual fuel) has no single billing fuel. + # Act / Assert + with pytest.raises(UnmappedSapCode): + sap_code_to_fuel(10)