Merge pull request #1286 from Hestia-Homes/fix/predicted-baseline-rhi-fallback

Fall back to computed kWh for predicted-EPC baselines (fix 16 e2e failures)
This commit is contained in:
Jun-te Kim 2026-06-24 11:26:33 +01:00 committed by GitHub
commit eecea8301e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 103 additions and 14 deletions

View file

@ -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"
)

View file

@ -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.