Model/tests/domain/billing/test_energy_breakdown.py
Khalim Conn-Kowlessar ced6287baa refactor(billing): relocate Bill Derivation to domain/billing/ (cross-stage)
Bill / EnergyBreakdown / BillDerivation / sap_fuel were under
domain/property_baseline/ only because Baseline was built first. The Modelling
stage now needs them too, so move them (and their tests) to a neutral
domain/billing/ — Fuel/FuelRates already live in the shared domain/fuel_rates/.
Avoids a modelling -> property_baseline cross-stage import and a package name
that wrongly implies ownership (ADR-0011, ADR-0014 amendment). Pure git mv +
import rewrite across 10 files; 40 billing/baseline/repo tests pass, pyright
strict clean. CONTEXT.md Bill Derivation location updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:19:23 +00:00

148 lines
5.1 KiB
Python

from __future__ import annotations
import pytest
from domain.fuel_rates.fuel import Fuel
from domain.billing.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)