Model/tests/orchestration/test_property_baseline_orchestrator.py
Jun-te Kim 6679d8c621 Fall back to computed kWh for predicted-EPC baselines (no lodged RHI)
16 modelling_e2e properties failed with "Effective EPC is missing
renewable_heat_incentive; cannot read baseline space-heating / hot-water kWh".

Baseline runs for predicted properties too (ADR-0031), reading space/water-
heating kWh off the EPC's lodged RHI block. Predicted EPCs deep-copy a neighbour
template that may carry no RHI, so `_require_rhi` hard-failed the whole subtask.

Fix: when the EPC has no RHI, fall back to the property's OWN computed figures
from the scored SapResult (space_heating_kwh_per_yr / hot_water_kwh_per_yr) —
more representative than a neighbour's lodged numbers. Only when there is also no
SapResult (the rebaseliner scored nothing) is there genuinely no demand to
record, and we still fail noisily. Lodged certs are unchanged (RHI still wins).

Regression tests: fallback-to-computed, and the no-RHI/no-result raise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:58:24 +00:00

268 lines
9.5 KiB
Python

from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from datatypes.epc.domain.epc import Epc
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
RenewableHeatIncentive,
)
from domain.fuel_rates.fuel import Fuel
from domain.billing.bill import BillSection
from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance
from domain.property_baseline.performance import Performance
from domain.property_baseline.rebaseliner import (
RebaselineNotImplemented,
RebaselineResult,
Rebaseliner,
StubRebaseliner,
)
from domain.property.property import Property, PropertyIdentity
from domain.sap10_calculator.calculator import SapResult
from orchestration.property_baseline_orchestrator import PropertyBaselineOrchestrator
from repositories.fuel_rates.fuel_rates_static_file_repository import (
FuelRatesStaticFileRepository,
)
from tests.orchestration.fakes import (
FakePropertyBaselineRepo,
FakePropertyRepo,
FakeUnitOfWork,
)
if TYPE_CHECKING:
from datatypes.epc.domain.epc_property_data import EpcPropertyData
def _property(*, sap_version: float) -> Property:
epc = object.__new__(EpcPropertyData)
epc.energy_rating_current = 72
epc.current_energy_efficiency_band = Epc.C
epc.co2_emissions_current = 1.8
epc.energy_consumption_current = 180
epc.sap_version = sap_version
epc.renewable_heat_incentive = RenewableHeatIncentive(
space_heating_kwh=5000.0, water_heating_kwh=2000.0
)
return Property(
identity=PropertyIdentity(
portfolio_id=1, postcode="A0 0AA", address="1 Some Street", uprn=123
),
epc=epc,
)
def test_run_establishes_persists_and_commits_the_batch_once() -> None:
# Arrange
property_baseline_repo = FakePropertyBaselineRepo()
uow = FakeUnitOfWork(
property=FakePropertyRepo({10: _property(sap_version=10.2)}),
property_baseline=property_baseline_repo,
)
orchestrator = PropertyBaselineOrchestrator(
unit_of_work=lambda: uow,
rebaseliner=StubRebaseliner(),
fuel_rates=FuelRatesStaticFileRepository(),
)
# Act
orchestrator.run([10])
# Assert — one Baseline Performance persisted (both halves equal, kWh off the
# RHI, no bill because the stub ran no calculator), and the batch committed
# exactly once.
lodged = Performance(
sap_score=72, epc_band=Epc.C, co2_emissions=1.8, primary_energy_intensity=180
)
assert property_baseline_repo.saved == [
(
PropertyBaselinePerformance(
lodged=lodged,
effective=lodged,
rebaseline_reason="none",
space_heating_kwh=5000.0,
water_heating_kwh=2000.0,
bill=None,
),
10,
)
]
assert uow.commits == 1
def test_run_raises_on_a_pre_sap10_property_and_does_not_commit() -> None:
# Arrange — a pre-SAP10 cert needs ML rebaselining, which is not wired yet.
property_baseline_repo = FakePropertyBaselineRepo()
uow = FakeUnitOfWork(
property=FakePropertyRepo({10: _property(sap_version=9.94)}),
property_baseline=property_baseline_repo,
)
orchestrator = PropertyBaselineOrchestrator(
unit_of_work=lambda: uow,
rebaseliner=StubRebaseliner(),
fuel_rates=FuelRatesStaticFileRepository(),
)
# Act / Assert — the raise propagates; the batch is neither persisted nor
# committed (all-or-nothing).
with pytest.raises(RebaselineNotImplemented):
orchestrator.run([10])
assert property_baseline_repo.saved == []
assert uow.commits == 0
_LIGHTING_KWH = 400.0
def _sap_result_with_lighting() -> SapResult:
"""A minimal scored picture carrying only lighting energy — enough for Bill
Derivation to produce one electric section. Mirrors the constructor shape in
tests/domain/property_baseline/test_energy_breakdown.py::_sap_result."""
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=0.0,
main_2_heating_fuel_kwh_per_yr=0.0,
secondary_heating_fuel_kwh_per_yr=0.0,
space_cooling_fuel_kwh_per_yr=0.0,
hot_water_kwh_per_yr=0.0,
pumps_fans_kwh_per_yr=0.0,
lighting_kwh_per_yr=_LIGHTING_KWH,
appliances_kwh_per_yr=0.0,
cooking_kwh_per_yr=0.0,
main_heating_fuel_code=None,
main_2_heating_fuel_code=None,
secondary_heating_fuel_code=None,
hot_water_fuel_code=None,
pv_exported_kwh_per_yr=0.0,
primary_energy_kwh_per_yr=0.0,
primary_energy_kwh_per_m2=0.0,
monthly=(),
intermediate={},
)
class _ScoringRebaseliner(Rebaseliner):
"""A rebaseliner that returns a fixed scored picture (a SapResult) so the
orchestrator's Bill Derivation wiring exercises (StubRebaseliner returns
sap_result=None, which never bills)."""
def __init__(self, result: SapResult) -> None:
self._result = result
def rebaseline(
self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance
) -> RebaselineResult:
return RebaselineResult(
effective=lodged, reason="none", sap_result=self._result
)
def _property_without_rhi(*, sap_version: float = 10.2) -> Property:
"""A predicted-style property whose EPC carries no lodged RHI block (ADR-0031
predicted EPCs deep-copy a template that may lack RHI)."""
epc = object.__new__(EpcPropertyData)
epc.energy_rating_current = 72
epc.current_energy_efficiency_band = Epc.C
epc.co2_emissions_current = 1.8
epc.energy_consumption_current = 180
epc.sap_version = sap_version
epc.renewable_heat_incentive = None
return Property(
identity=PropertyIdentity(
portfolio_id=1, postcode="A0 0AA", address="1 Some Street", uprn=123
),
epc=epc,
)
def _sap_result_with_heating(*, space_kwh: float, water_kwh: float) -> SapResult:
result = _sap_result_with_lighting()
object.__setattr__(result, "space_heating_kwh_per_yr", space_kwh)
object.__setattr__(result, "hot_water_kwh_per_yr", water_kwh)
# hot-water kWh must carry a fuel code for Bill Derivation (1 = mains gas).
object.__setattr__(result, "hot_water_fuel_code", 1)
return result
def test_run_falls_back_to_computed_kwh_when_epc_has_no_rhi() -> None:
# Arrange — a predicted property with no lodged RHI, scored by a rebaseliner
# that returns a SapResult carrying the property's own computed energy.
property_baseline_repo = FakePropertyBaselineRepo()
uow = FakeUnitOfWork(
property=FakePropertyRepo({10: _property_without_rhi()}),
property_baseline=property_baseline_repo,
)
orchestrator = PropertyBaselineOrchestrator(
unit_of_work=lambda: uow,
rebaseliner=_ScoringRebaseliner(
_sap_result_with_heating(space_kwh=4200.0, water_kwh=1600.0)
),
fuel_rates=FuelRatesStaticFileRepository(),
)
# Act
orchestrator.run([10])
# Assert — baseline kWh come from the computed SapResult, not a (missing) RHI.
(baseline, _) = property_baseline_repo.saved[0]
assert baseline.space_heating_kwh == 4200.0
assert baseline.water_heating_kwh == 1600.0
assert uow.commits == 1
def test_run_raises_when_no_rhi_and_no_scored_result() -> None:
# Arrange — no lodged RHI AND the stub rebaseliner scores nothing: there is
# genuinely no source for baseline kWh, so the batch must fail noisily.
property_baseline_repo = FakePropertyBaselineRepo()
uow = FakeUnitOfWork(
property=FakePropertyRepo({10: _property_without_rhi()}),
property_baseline=property_baseline_repo,
)
orchestrator = PropertyBaselineOrchestrator(
unit_of_work=lambda: uow,
rebaseliner=StubRebaseliner(),
fuel_rates=FuelRatesStaticFileRepository(),
)
# Act / Assert
with pytest.raises(ValueError, match="renewable_heat_incentive"):
orchestrator.run([10])
assert property_baseline_repo.saved == []
assert uow.commits == 0
def test_run_derives_and_persists_a_bill_when_the_rebaseliner_scores() -> None:
# Arrange — a rebaseliner that hands back a SapResult with lighting energy,
# so the orchestrator prices it into a Bill at the committed snapshot.
property_baseline_repo = FakePropertyBaselineRepo()
uow = FakeUnitOfWork(
property=FakePropertyRepo({10: _property(sap_version=10.2)}),
property_baseline=property_baseline_repo,
)
orchestrator = PropertyBaselineOrchestrator(
unit_of_work=lambda: uow,
rebaseliner=_ScoringRebaseliner(_sap_result_with_lighting()),
fuel_rates=FuelRatesStaticFileRepository(),
)
# Act
orchestrator.run([10])
# Assert — the persisted baseline carries a populated bill; the LIGHTING
# section is the lighting kWh priced at the snapshot's electricity rate
# (read from the snapshot, not hard-coded).
rates = FuelRatesStaticFileRepository().get_current()
expected_cost = _LIGHTING_KWH * rates.unit_rate_p_per_kwh(Fuel.ELECTRICITY) / 100.0
(baseline, _) = property_baseline_repo.saved[0]
assert baseline.bill is not None
lighting = baseline.bill.sections[BillSection.LIGHTING]
assert lighting.kwh == _LIGHTING_KWH
assert abs(lighting.cost_gbp - expected_cost) <= 1e-9