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>
This commit is contained in:
Jun-te Kim 2026-06-24 08:58:24 +00:00
parent 22cb47a280
commit 6679d8c621
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.