mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
22cb47a280
commit
6679d8c621
2 changed files with 103 additions and 14 deletions
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue