mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-19 08:53:17 +00:00
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>
268 lines
9.5 KiB
Python
268 lines
9.5 KiB
Python
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
|
|
) -> 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
|