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, *, physical_state_changed: bool = False, ) -> 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