mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
feat(baseline): SAP fuel-code -> Fuel mapping for billing (ADR-0014)
Slice 3 of Bill Derivation. sap_code_to_fuel(code) maps a SAP 10.2 / Table 32 fuel code to the canonical billing Fuel — bounded to the ~47 Table 32 codes (the carrier, orthogonal to the PCDB product index, so all PCDB heat pumps share one electricity code). Mains gas / LPG / oil+bioliquids / coal / smokeless / wood / electricity (standard + off-peak) / heat-network groupings; an unmapped code (dual fuel, grid-export) raises UnmappedSapCode rather than guessing. Also: ADR-0014 deferred/TODO section records the stubbed appliances+cooking (pending the SapResult fields), the off-peak day/night split, the heat-network rate gap, and regional rates / ETL. The SapResult -> EnergyBreakdown adapter (next slice) is gated on the appliances/cooking fields landing on SapResult. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c7ad26f07b
commit
bb1029c0d8
3 changed files with 97 additions and 0 deletions
|
|
@ -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
|
- 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.
|
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
|
## Considered alternatives
|
||||||
|
|
||||||
- **Bill from `RenewableHeatIncentive` heating+HW kWh only** (CONTEXT's original scope) — rejected:
|
- **Bill from `RenewableHeatIncentive` heating+HW kWh only** (CONTEXT's original scope) — rejected:
|
||||||
|
|
|
||||||
41
domain/property_baseline/sap_fuel.py
Normal file
41
domain/property_baseline/sap_fuel.py
Normal file
|
|
@ -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
|
||||||
42
tests/domain/property_baseline/test_sap_fuel.py
Normal file
42
tests/domain/property_baseline/test_sap_fuel.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Reference in a new issue