mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
feat(baseline): EnergyBreakdown.from_sap_result + COOLING section
The SapResult -> EnergyBreakdown adapter (ADR-0014), a classmethod on the target mirroring Performance.from_sap_result. Folds each positive per-end-use delivered kWh into a billable EnergyLine: main/main-2/secondary heating and hot water at their resolved fuel (sap_code_to_fuel); lighting/pumps-fans/ appliances/cooking/cooling as electricity. PV export carries to exported_kwh for the SEG credit. Zero-kWh end uses emit no line; a positive kWh with no fuel code raises rather than billing at a default (strict, mirrors the calculator). Adds BillSection.COOLING (electricity, from space_cooling_fuel_kwh_per_yr). BillDerivation already prices any section it is given, so no change there. Also corrects the ADR-0014 amendment: SapResult carries the calculator's own fuel codes (raw API or Table-32 per mapper, ADR-0015); sap_fuel normalizes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2cb4dd5833
commit
5e75fb474c
3 changed files with 230 additions and 7 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
148
tests/domain/property_baseline/test_energy_breakdown.py
Normal file
148
tests/domain/property_baseline/test_energy_breakdown.py
Normal file
|
|
@ -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)
|
||||
Loading…
Add table
Reference in a new issue