mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
The PropertyBaselineOrchestrator now reads no Lodged Performance off a predicted Property's neighbour-synthesised EPC: lodged is None when source_path is 'predicted', so no phantom lodged figure is manufactured. The Effective half is unchanged. Rebaseliner port + PropertyBaselinePerformance.lodged widen to Optional; the pristine-cert paths assert non-None (lodged is None only for a predicted Property, which is always physical_state_changed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
348 lines
12 KiB
Python
348 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Optional, 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: Optional[Performance],
|
|
*,
|
|
physical_state_changed: bool = False,
|
|
) -> RebaselineResult:
|
|
assert lodged is not None # this stub is only used on the real-cert path
|
|
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
|
|
|
|
|
|
class _PredictedRebaseliner(Rebaseliner):
|
|
"""Scores a predicted picture into a fixed Effective Performance, ignoring the
|
|
(absent) lodged half — a predicted Property always has ``physical_state_changed``,
|
|
so the calculator output IS the Effective Performance (ADR-0039)."""
|
|
|
|
def __init__(self, effective: Performance, result: SapResult) -> None:
|
|
self._effective = effective
|
|
self._result = result
|
|
|
|
def rebaseline(
|
|
self,
|
|
property_id: int,
|
|
effective_epc: EpcPropertyData,
|
|
lodged: Optional[Performance],
|
|
*,
|
|
physical_state_changed: bool = False,
|
|
) -> RebaselineResult:
|
|
return RebaselineResult(
|
|
effective=self._effective,
|
|
reason="physical_state_changed",
|
|
sap_result=self._result,
|
|
)
|
|
|
|
|
|
def _predicted_property() -> Property:
|
|
"""A predicted Property (ADR-0031): no lodged EPC, only a neighbour-synthesised
|
|
predicted EPC whose recorded performance fields are a *different dwelling's* —
|
|
the phantom Lodged Performance this fix must not propagate. Mirrors the #1361
|
|
exemplar (uprn 34003067 / property 742072), predicted-only."""
|
|
predicted = object.__new__(EpcPropertyData)
|
|
predicted.energy_rating_current = 14 # phantom: copied from a neighbour template
|
|
predicted.current_energy_efficiency_band = Epc.G
|
|
predicted.co2_emissions_current = 5.0
|
|
predicted.energy_consumption_current = 400
|
|
predicted.sap_version = 10.2
|
|
predicted.renewable_heat_incentive = None
|
|
return Property(
|
|
identity=PropertyIdentity(
|
|
portfolio_id=1, postcode="A0 0AA", address="1 Some Street", uprn=34003067
|
|
),
|
|
epc=None,
|
|
predicted_epc=predicted,
|
|
)
|
|
|
|
|
|
def test_run_persists_no_lodged_performance_for_a_predicted_property() -> None:
|
|
# Arrange — a predicted Property (no lodged cert); the rebaseliner scores its
|
|
# predicted picture into a known Effective Performance.
|
|
property_baseline_repo = FakePropertyBaselineRepo()
|
|
uow = FakeUnitOfWork(
|
|
property=FakePropertyRepo({742072: _predicted_property()}),
|
|
property_baseline=property_baseline_repo,
|
|
)
|
|
effective = Performance(
|
|
sap_score=57, epc_band=Epc.D, co2_emissions=1.5, primary_energy_intensity=160
|
|
)
|
|
orchestrator = PropertyBaselineOrchestrator(
|
|
unit_of_work=lambda: uow,
|
|
rebaseliner=_PredictedRebaseliner(
|
|
effective, _sap_result_with_heating(space_kwh=4200.0, water_kwh=1600.0)
|
|
),
|
|
fuel_rates=FuelRatesStaticFileRepository(),
|
|
)
|
|
|
|
# Act
|
|
orchestrator.run([742072])
|
|
|
|
# Assert — no lodged comparator was manufactured from the neighbour-synthesised
|
|
# EPC's recorded fields; the Effective half is still persisted.
|
|
(baseline, _) = property_baseline_repo.saved[0]
|
|
assert baseline.lodged is None
|
|
assert baseline.effective == effective
|
|
|
|
|
|
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
|