Model/tests/orchestration/test_property_baseline_orchestrator.py
Khalim Conn-Kowlessar 899532a6d6 feat(baseline): CalculatorRebaseliner — calculator goes load-bearing (ADR-0013 amend)
Slice 5a: the promotion. Replaces StubRebaseliner in production and collapses the
shadow runner into the rebaseliner (ADR-0013 amendment).

- CalculatorRebaseliner runs Sap10Calculator on every Property:
  * sap_version < 10.2 -> Effective Performance IS the calculator output
    (band via Epc.from_sap_score, CO2 kg->t, PEUI rounded), reason "pre_sap10".
  * sap_version >= 10.2 -> Effective = lodged (API figures on-target), and the
    calculator only logs divergence (SAP>0.5, PEUI/CO2 1%) as a validation signal.
  * a calculator raise propagates -> batch aborts (ADR-0012); fix the cert at once.
- Rebaseliner.rebaseline gains property_id (for the divergence log).
- LoggingCalculatorShadow / the calculator_shadow seam removed from the
  orchestrator; its divergence-comparison logic now lives in the rebaseliner.
- StubRebaseliner kept (signature updated) for orchestrator/repo unit tests.

The SapResult->EnergyBreakdown adapter + BillDerivation wiring (to populate the
bill block) follow once the appliances/cooking SapResult fields land.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:40:26 +00:00

194 lines
6.7 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 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