diff --git a/orchestration/property_baseline_orchestrator.py b/orchestration/property_baseline_orchestrator.py index 6c749e36..4a290a5d 100644 --- a/orchestration/property_baseline_orchestrator.py +++ b/orchestration/property_baseline_orchestrator.py @@ -1,12 +1,11 @@ from __future__ import annotations from collections.abc import Callable +from typing import Optional -from datatypes.epc.domain.epc_property_data import ( - EpcPropertyData, - RenewableHeatIncentive, -) +from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.billing.bill import EnergyBreakdown +from domain.sap10_calculator.calculator import SapResult from domain.billing.bill_derivation import BillDerivation from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance from domain.property_baseline.performance import lodged_performance @@ -64,24 +63,40 @@ class PropertyBaselineOrchestrator: if rebaselined.sap_result is not None else None ) - rhi = _require_rhi(effective_epc) + space_heating_kwh, water_heating_kwh = _baseline_kwh( + effective_epc, rebaselined.sap_result + ) baseline = PropertyBaselinePerformance( lodged=lodged, effective=rebaselined.effective, rebaseline_reason=rebaselined.reason, - space_heating_kwh=rhi.space_heating_kwh, - water_heating_kwh=rhi.water_heating_kwh, + space_heating_kwh=space_heating_kwh, + water_heating_kwh=water_heating_kwh, bill=bill, ) uow.property_baseline.save(baseline, property_id) uow.commit() -def _require_rhi(epc: EpcPropertyData) -> RenewableHeatIncentive: +def _baseline_kwh( + epc: EpcPropertyData, sap_result: Optional[SapResult] +) -> tuple[float, float]: + """Baseline space- and water-heating kWh for the Property. + + Lodged certs carry these in the EPC's RHI block (the ADR-0007 ML target). + Predicted EPCs (ADR-0031) deep-copy a neighbour template that may lack an RHI + block, so fall back to the property's OWN computed figures from the scored + SapResult — a better baseline than a neighbour's lodged numbers anyway. When + neither source exists (no RHI and the rebaseliner scored nothing), there is + genuinely no demand to record, so fail noisily. + """ rhi = epc.renewable_heat_incentive - if rhi is None: - raise ValueError( - "Effective EPC is missing renewable_heat_incentive; cannot read " - "baseline space-heating / hot-water kWh" - ) - return rhi + if rhi is not None: + return rhi.space_heating_kwh, rhi.water_heating_kwh + if sap_result is not None: + return sap_result.space_heating_kwh_per_yr, sap_result.hot_water_kwh_per_yr + raise ValueError( + "Effective EPC has no renewable_heat_incentive and the rebaseliner " + "produced no SapResult; cannot establish baseline space-heating / " + "hot-water kWh" + ) diff --git a/tests/orchestration/test_property_baseline_orchestrator.py b/tests/orchestration/test_property_baseline_orchestrator.py index 9183a8b3..85930741 100644 --- a/tests/orchestration/test_property_baseline_orchestrator.py +++ b/tests/orchestration/test_property_baseline_orchestrator.py @@ -165,6 +165,80 @@ class _ScoringRebaseliner(Rebaseliner): ) +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.