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 e10d1f32..d33a7810 100644 --- a/docs/adr/0014-bill-derivation-from-real-fuel-rates.md +++ b/docs/adr/0014-bill-derivation-from-real-fuel-rates.md @@ -109,10 +109,14 @@ the fuel each end use burns come from?* Resolved in a `/grill-with-docs` session - **Decision: per-end-use fuel is calculator output.** The calculator resolves the fuel for each billable end use (it already uses it to derive the delivered kWh and the rating cost), so it emits - the **resolved Table-32 fuel codes** on `SapResult` (main-1 / main-2 / secondary / hot water — the - electric end uses are electricity by construction), alongside `pv_exported_kwh` for the SEG credit. - `BillDerivation`'s adapter is then a **pure `SapResult → EnergyBreakdown` map** and can never price - the calculator's kWh at a fuel the calculator never used. Rejected: an adapter that re-reads raw + the **per-end-use fuel codes** on `SapResult` (main-1 / main-2 / secondary / hot water — the electric + end uses are electricity by construction), alongside `pv_exported_kwh` for the SEG credit. These are + the calculator's own fuel codes (which, per [ADR-0015](0015-mappers-own-cert-normalization.md), may + be raw API codes or already-Table-32 depending on the mapper), so `sap_fuel.sap_code_to_fuel` + **normalizes them through the calculator's own `table_32.to_table_32_code`** (T32-first, then + API-translate — the same normalization the calculator's pricing/classification uses) before the + Table-32 → `Fuel` dispatch. `BillDerivation`'s adapter is then a **pure `SapResult → EnergyBreakdown` + map** and can never price the calculator's kWh at a fuel the calculator never used. Rejected: an adapter that re-reads raw `EpcPropertyData` fuel fields and re-normalizes them — that duplicates `cert_to_inputs` (`_main_fuel_code`, `_water_heating_fuel_code`, HW→main default, CHP blend, the `MissingMainFuelType` strict-raise) and reopens divergence between the bill and the rating. diff --git a/domain/property_baseline/bill.py b/domain/property_baseline/bill.py index fcc49329..110aa237 100644 --- a/domain/property_baseline/bill.py +++ b/domain/property_baseline/bill.py @@ -3,8 +3,13 @@ from __future__ import annotations from collections.abc import Mapping, Sequence from dataclasses import dataclass from enum import Enum +from typing import Optional, TYPE_CHECKING from domain.fuel_rates.fuel import Fuel +from domain.property_baseline.sap_fuel import sap_code_to_fuel + +if TYPE_CHECKING: + from domain.sap10_calculator.calculator import SapResult class BillSection(Enum): @@ -17,6 +22,7 @@ class BillSection(Enum): APPLIANCES = "APPLIANCES" COOKING = "COOKING" PUMPS_FANS = "PUMPS_FANS" + COOLING = "COOLING" @dataclass(frozen=True) @@ -31,13 +37,78 @@ class EnergyLine: @dataclass(frozen=True) class EnergyBreakdown: - """A Property's delivered energy per end use, the input to Bill Derivation — - produced from SAP10 Calculation in a later slice. ``exported_kwh`` is PV - generation exported to the grid, credited at the SEG rate.""" + """A Property's delivered energy per end use, the input to Bill Derivation. + ``exported_kwh`` is PV generation exported to the grid, credited at the SEG + rate.""" lines: Sequence[EnergyLine] exported_kwh: float = 0.0 + @classmethod + def from_sap_result(cls, result: "SapResult") -> "EnergyBreakdown": + """Fold a calculator `SapResult`'s per-end-use delivered kWh into billable + `EnergyLine`s (ADR-0014). Heating (main / main-2 / secondary) and hot water + are billed at their resolved fuel (`sap_code_to_fuel`); lighting / pumps- + fans / appliances / cooking / cooling are electricity by construction. A + line is emitted only when its kWh is positive; PV export carries to + `exported_kwh` for the SEG credit. The `from_*` factory mirrors + `Performance.from_sap_result`; living on the target keeps the calculator + free of any `property_baseline` dependency.""" + candidates = [ + _fuelled_line( + BillSection.HEATING, + result.main_heating_fuel_code, + result.main_heating_fuel_kwh_per_yr, + ), + _fuelled_line( + BillSection.HEATING, + result.main_2_heating_fuel_code, + result.main_2_heating_fuel_kwh_per_yr, + ), + _fuelled_line( + BillSection.HEATING, + result.secondary_heating_fuel_code, + result.secondary_heating_fuel_kwh_per_yr, + ), + _fuelled_line( + BillSection.HOT_WATER, + result.hot_water_fuel_code, + result.hot_water_kwh_per_yr, + ), + _electric_line(BillSection.LIGHTING, result.lighting_kwh_per_yr), + _electric_line(BillSection.PUMPS_FANS, result.pumps_fans_kwh_per_yr), + _electric_line(BillSection.APPLIANCES, result.appliances_kwh_per_yr), + _electric_line(BillSection.COOKING, result.cooking_kwh_per_yr), + _electric_line(BillSection.COOLING, result.space_cooling_fuel_kwh_per_yr), + ] + return cls( + lines=[line for line in candidates if line is not None], + exported_kwh=result.pv_exported_kwh_per_yr, + ) + + +def _fuelled_line( + section: BillSection, fuel_code: Optional[int], kwh: float +) -> Optional[EnergyLine]: + """An `EnergyLine` for a fuelled end use, or None when it has no energy. A + positive kWh with no resolved fuel code is a data gap — raise rather than + bill it at a default (mirrors the calculator's strict-raise discipline).""" + if kwh <= 0: + return None + if fuel_code is None: + raise ValueError( + f"{section.value} has {kwh} kWh but no fuel code on the SapResult; " + "cannot attribute a billing fuel" + ) + return EnergyLine(section=section, fuel=sap_code_to_fuel(fuel_code), kwh=kwh) + + +def _electric_line(section: BillSection, kwh: float) -> Optional[EnergyLine]: + """An electricity `EnergyLine` for an electric end use, or None when zero.""" + if kwh <= 0: + return None + return EnergyLine(section=section, fuel=Fuel.ELECTRICITY, kwh=kwh) + @dataclass(frozen=True) class BillSectionCost: diff --git a/tests/domain/property_baseline/test_energy_breakdown.py b/tests/domain/property_baseline/test_energy_breakdown.py new file mode 100644 index 00000000..ffe7ffb0 --- /dev/null +++ b/tests/domain/property_baseline/test_energy_breakdown.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import pytest + +from domain.fuel_rates.fuel import Fuel +from domain.property_baseline.bill import BillSection, EnergyBreakdown +from domain.sap10_calculator.calculator import SapResult + + +def _sap_result( + *, + main_heating_fuel_kwh_per_yr: float = 0.0, + main_heating_fuel_code: int | None = None, + main_2_heating_fuel_kwh_per_yr: float = 0.0, + main_2_heating_fuel_code: int | None = None, + secondary_heating_fuel_kwh_per_yr: float = 0.0, + secondary_heating_fuel_code: int | None = None, + hot_water_kwh_per_yr: float = 0.0, + hot_water_fuel_code: int | None = None, + space_cooling_fuel_kwh_per_yr: float = 0.0, + pumps_fans_kwh_per_yr: float = 0.0, + lighting_kwh_per_yr: float = 0.0, + appliances_kwh_per_yr: float = 0.0, + cooking_kwh_per_yr: float = 0.0, + pv_exported_kwh_per_yr: float = 0.0, +) -> SapResult: + return SapResult( + sap_score=72, + sap_score_continuous=72.0, + ecf=0.0, + total_fuel_cost_gbp=0.0, + co2_kg_per_yr=0.0, + space_heating_kwh_per_yr=0.0, + space_cooling_kwh_per_yr=0.0, + fabric_energy_efficiency_kwh_per_m2_yr=0.0, + main_heating_fuel_kwh_per_yr=main_heating_fuel_kwh_per_yr, + main_2_heating_fuel_kwh_per_yr=main_2_heating_fuel_kwh_per_yr, + secondary_heating_fuel_kwh_per_yr=secondary_heating_fuel_kwh_per_yr, + space_cooling_fuel_kwh_per_yr=space_cooling_fuel_kwh_per_yr, + hot_water_kwh_per_yr=hot_water_kwh_per_yr, + pumps_fans_kwh_per_yr=pumps_fans_kwh_per_yr, + lighting_kwh_per_yr=lighting_kwh_per_yr, + appliances_kwh_per_yr=appliances_kwh_per_yr, + cooking_kwh_per_yr=cooking_kwh_per_yr, + main_heating_fuel_code=main_heating_fuel_code, + main_2_heating_fuel_code=main_2_heating_fuel_code, + secondary_heating_fuel_code=secondary_heating_fuel_code, + hot_water_fuel_code=hot_water_fuel_code, + pv_exported_kwh_per_yr=pv_exported_kwh_per_yr, + primary_energy_kwh_per_yr=0.0, + primary_energy_kwh_per_m2=0.0, + monthly=(), + intermediate={}, + ) + + +def test_each_positive_end_use_becomes_a_line_at_its_fuel() -> None: + # Arrange — a gas-boiler home with an electric secondary heater: HEATING + # carries two lines on different fuels; HW is gas; the rest are electricity. + result = _sap_result( + main_heating_fuel_kwh_per_yr=8000.0, + main_heating_fuel_code=1, # mains gas + secondary_heating_fuel_kwh_per_yr=300.0, + secondary_heating_fuel_code=30, # electricity + hot_water_kwh_per_yr=2500.0, + hot_water_fuel_code=1, # mains gas + lighting_kwh_per_yr=400.0, + appliances_kwh_per_yr=1900.0, + cooking_kwh_per_yr=300.0, + ) + + # Act + breakdown = EnergyBreakdown.from_sap_result(result) + + # Assert + lines = {(line.section, line.fuel): line.kwh for line in breakdown.lines} + assert lines == { + (BillSection.HEATING, Fuel.MAINS_GAS): 8000.0, + (BillSection.HEATING, Fuel.ELECTRICITY): 300.0, + (BillSection.HOT_WATER, Fuel.MAINS_GAS): 2500.0, + (BillSection.LIGHTING, Fuel.ELECTRICITY): 400.0, + (BillSection.APPLIANCES, Fuel.ELECTRICITY): 1900.0, + (BillSection.COOKING, Fuel.ELECTRICITY): 300.0, + } + + +def test_zero_kwh_end_uses_emit_no_line() -> None: + # Arrange — only lighting has energy; everything else is zero. + result = _sap_result(lighting_kwh_per_yr=350.0) + + # Act + breakdown = EnergyBreakdown.from_sap_result(result) + + # Assert — exactly one line, no empty HEATING / HOT_WATER / COOLING entries. + assert len(breakdown.lines) == 1 + assert breakdown.lines[0].section == BillSection.LIGHTING + + +def test_cooling_is_billed_as_electricity() -> None: + # Arrange — a home with fixed cooling. + result = _sap_result(space_cooling_fuel_kwh_per_yr=450.0) + + # Act + breakdown = EnergyBreakdown.from_sap_result(result) + + # Assert + assert len(breakdown.lines) == 1 + line = breakdown.lines[0] + assert (line.section, line.fuel, line.kwh) == ( + BillSection.COOLING, + Fuel.ELECTRICITY, + 450.0, + ) + + +def test_pv_export_carries_to_exported_kwh() -> None: + # Arrange + result = _sap_result(lighting_kwh_per_yr=400.0, pv_exported_kwh_per_yr=1200.0) + + # Act + breakdown = EnergyBreakdown.from_sap_result(result) + + # Assert + assert breakdown.exported_kwh == 1200.0 + + +def test_raw_api_fuel_code_is_normalized_to_its_billing_fuel() -> None: + # Arrange — the calculator can carry a raw gov-API fuel code (26 = mains gas). + result = _sap_result( + main_heating_fuel_kwh_per_yr=9000.0, main_heating_fuel_code=26 + ) + + # Act + breakdown = EnergyBreakdown.from_sap_result(result) + + # Assert + assert breakdown.lines[0].fuel == Fuel.MAINS_GAS + + +def test_positive_heating_kwh_with_no_fuel_code_raises() -> None: + # Arrange — energy with no resolvable fuel is a data gap, not a default. + result = _sap_result( + main_heating_fuel_kwh_per_yr=8000.0, main_heating_fuel_code=None + ) + + # Act / Assert + with pytest.raises(ValueError, match="no fuel code"): + EnergyBreakdown.from_sap_result(result)