diff --git a/applications/ara_first_run/handler.py b/applications/ara_first_run/handler.py index 8aca4fea..e82da40f 100644 --- a/applications/ara_first_run/handler.py +++ b/applications/ara_first_run/handler.py @@ -10,8 +10,7 @@ from sqlmodel import Session from applications.ara_first_run.ara_first_run_trigger_body import ( AraFirstRunTriggerBody, ) -from domain.property_baseline.calculator_shadow import LoggingCalculatorShadow -from domain.property_baseline.rebaseliner import StubRebaseliner +from domain.property_baseline.calculator_rebaseliner import CalculatorRebaseliner from domain.sap10_calculator.calculator import Sap10Calculator from infrastructure.postgres.config import PostgresConfig from infrastructure.postgres.engine import make_engine @@ -82,10 +81,10 @@ def build_first_run_pipeline( ), baseline=PropertyBaselineOrchestrator( unit_of_work=unit_of_work, - rebaseliner=StubRebaseliner(), - # Shadow only: validates the calculator over the wild cohort without - # gating the load-bearing baseline write (ADR-0013). - calculator_shadow=LoggingCalculatorShadow(Sap10Calculator()), + # The calculator is load-bearing: effective=calculated for pre-10.2 + # certs, lodged + divergence-logged at/above 10.2; a raise aborts the + # batch (ADR-0013 amendment). + rebaseliner=CalculatorRebaseliner(Sap10Calculator()), ), modelling=ModellingOrchestrator( scenario_repo=ScenarioRepository(), diff --git a/domain/property_baseline/calculator_rebaseliner.py b/domain/property_baseline/calculator_rebaseliner.py new file mode 100644 index 00000000..b2443784 --- /dev/null +++ b/domain/property_baseline/calculator_rebaseliner.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Optional, Protocol + +from datatypes.epc.domain.epc import Epc +from domain.property_baseline.performance import Performance +from domain.property_baseline.rebaseliner import Rebaseliner, RebaselineReason + +if TYPE_CHECKING: + from datatypes.epc.domain.epc_property_data import EpcPropertyData + from domain.sap10_calculator.calculator import SapResult + +logger = logging.getLogger(__name__) + +# The calculator targets SAP 10.2 (14-03-2025). A cert lodged below this carries +# a superseded methodology and is rebaselined to the calculator's output; at or +# above it, the API's lodged figures are kept and the calculator only validates. +_SAP10_2_FLOOR = 10.2 +_SAP_ABS_TOL = 0.5 +_REL_TOL = 0.01 +_KG_PER_TONNE = 1000.0 + + +class Calculator(Protocol): + """The slice of `Sap10Calculator` the rebaseliner needs — `Sap10Calculator` + satisfies it structurally, so this module does not import the calculator.""" + + def calculate(self, epc: "EpcPropertyData") -> "SapResult": ... + + +def performance_from_sap_result(result: "SapResult") -> Performance: + """The four rated quantities, read off a `SapResult`: band derived from the + score, CO2 converted kg→tonnes, PEUI rounded to the lodged integer scale.""" + return Performance( + sap_score=result.sap_score, + epc_band=Epc.from_sap_score(result.sap_score), + co2_emissions=result.co2_kg_per_yr / _KG_PER_TONNE, + primary_energy_intensity=round(result.primary_energy_kwh_per_m2), + ) + + +def _relative_diff(calculated: float, lodged: float) -> float: + if lodged == 0: + return 0.0 if calculated == 0 else float("inf") + return abs(calculated - lodged) / abs(lodged) + + +class CalculatorRebaseliner(Rebaseliner): + """Produces Effective Performance from the deterministic `Sap10Calculator` + (ADR-0013 amendment — the calculator is load-bearing). + + Runs the calculator on every Property. For a cert lodged under a superseded + methodology (``sap_version < 10.2``) the calculator's output **is** Effective + Performance. At or above 10.2 the API's lodged figures are kept and the + calculator only **logs divergence** (a validation signal). A calculator + strict-raise propagates — the batch aborts (ADR-0012) and the un-mapped cert + is fixed immediately. + """ + + def __init__(self, calculator: Calculator) -> None: + self._calculator = calculator + + def rebaseline( + self, property_id: int, effective_epc: "EpcPropertyData", lodged: Performance + ) -> tuple[Performance, RebaselineReason]: + # A raise (UnmappedSapCode, etc.) propagates: the calculator is + # load-bearing, so the batch aborts and the cert is fixed at once. + result = self._calculator.calculate(effective_epc) + sap_version = effective_epc.sap_version + if sap_version is not None and sap_version < _SAP10_2_FLOOR: + return performance_from_sap_result(result), "pre_sap10" + self._log_divergence( + property_id=property_id, sap_version=sap_version, result=result, lodged=lodged + ) + return lodged, "none" + + def _log_divergence( + self, + *, + property_id: int, + sap_version: Optional[float], + result: "SapResult", + lodged: Performance, + ) -> None: + if abs(result.sap_score_continuous - lodged.sap_score) > _SAP_ABS_TOL: + self._warn(property_id, sap_version, "sap_score", lodged.sap_score, result.sap_score_continuous) + if _relative_diff(result.primary_energy_kwh_per_m2, lodged.primary_energy_intensity) > _REL_TOL: + self._warn( + property_id, sap_version, "primary_energy_intensity", + lodged.primary_energy_intensity, result.primary_energy_kwh_per_m2, + ) + calculated_co2_t = result.co2_kg_per_yr / _KG_PER_TONNE + if _relative_diff(calculated_co2_t, lodged.co2_emissions) > _REL_TOL: + self._warn(property_id, sap_version, "co2_emissions", lodged.co2_emissions, calculated_co2_t) + + def _warn( + self, + property_id: int, + sap_version: Optional[float], + quantity: str, + lodged: float, + calculated: float, + ) -> None: + logger.warning( + "SAP10 calculator divergence on %s for property_id=%s sap_version=%s: " + "lodged=%s calculated=%s", + quantity, + property_id, + sap_version, + lodged, + calculated, + ) diff --git a/domain/property_baseline/calculator_shadow.py b/domain/property_baseline/calculator_shadow.py deleted file mode 100644 index ba7927d8..00000000 --- a/domain/property_baseline/calculator_shadow.py +++ /dev/null @@ -1,141 +0,0 @@ -from __future__ import annotations - -import logging -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Optional, Protocol - -from domain.property_baseline.performance import Performance - -if TYPE_CHECKING: - from datatypes.epc.domain.epc_property_data import EpcPropertyData - from domain.sap10_calculator.calculator import SapResult - -logger = logging.getLogger(__name__) - -# A continuous SAP this far from the lodged integer would round to a different -# band-driving score; PEUI / CO2 scale with dwelling size so they use a relative -# tolerance (ADR-0013). Starting dials — tune against the wild-cohort logs. -_SAP_ABS_TOL = 0.5 -_REL_TOL = 0.01 -_KG_PER_TONNE = 1000.0 - - -class CalculatorShadow(ABC): - """Runs SAP10 Calculation in shadow beside the load-bearing baseline write - and reports divergence from Lodged Performance (ADR-0013). - - The calculator is not yet load-bearing — it is still being hardened, and a - large test cohort is about to flow through baselining. So an implementation - **must never raise**: a shadow failure may not abort the batch (ADR-0012's - all-or-nothing governs only the load-bearing Lodged/Effective write). It - observes, compares against Lodged, and logs; it does not feed Effective - Performance. The seam is retired when the calculator is promoted to the - Rebaseliner and its output *becomes* Effective Performance. - """ - - @abstractmethod - def observe( - self, - *, - property_id: int, - effective_epc: "EpcPropertyData", - lodged: Performance, - ) -> None: ... - - -def _relative_diff(calculated: float, lodged: float) -> float: - """|calculated − lodged| / |lodged|; a zero lodged value diverges iff - calculated is non-zero (avoids a divide-by-zero on degenerate certs).""" - if lodged == 0: - return 0.0 if calculated == 0 else float("inf") - return abs(calculated - lodged) / abs(lodged) - - -class Calculator(Protocol): - """The slice of `Sap10Calculator` the shadow needs: cert in, result out. - `Sap10Calculator` satisfies it structurally — no coupling to its module.""" - - def calculate(self, epc: "EpcPropertyData") -> "SapResult": ... - - -class LoggingCalculatorShadow(CalculatorShadow): - """Runs the calculator and logs, never persists, never raises (ADR-0013). - - A strict-raise (an un-mapped cert) is caught and logged at ``error`` so the - wild-cohort gap is greppable; a successful result whose SAP / PEUI / CO2 - diverges from Lodged beyond tolerance is logged at ``warning``. Every line - is tagged with ``property_id`` and the cert's ``sap_version`` so SAP-10.2 - divergence (a real calculator signal) is separable from older-spec drift. - """ - - def __init__(self, calculator: Calculator) -> None: - self._calculator = calculator - - def observe( - self, - *, - property_id: int, - effective_epc: "EpcPropertyData", - lodged: Performance, - ) -> None: - sap_version = effective_epc.sap_version - try: - # Broad by design: the point is to discover *what* breaks in the - # wild, and a shadow failure must never abort the batch (ADR-0013). - result = self._calculator.calculate(effective_epc) - except Exception as exc: - logger.error( - "SAP10 shadow calculation failed for property_id=%s " - "sap_version=%s: %r", - property_id, - sap_version, - exc, - ) - return - if abs(result.sap_score_continuous - lodged.sap_score) > _SAP_ABS_TOL: - self._warn_divergence( - quantity="sap_score", - property_id=property_id, - sap_version=sap_version, - lodged=lodged.sap_score, - calculated=result.sap_score_continuous, - ) - if _relative_diff( - result.primary_energy_kwh_per_m2, lodged.primary_energy_intensity - ) > _REL_TOL: - self._warn_divergence( - quantity="primary_energy_intensity", - property_id=property_id, - sap_version=sap_version, - lodged=lodged.primary_energy_intensity, - calculated=result.primary_energy_kwh_per_m2, - ) - # Lodged CO2 is tonnes/yr; the calculator emits kg/yr (ADR-0013). - calculated_co2_t = result.co2_kg_per_yr / _KG_PER_TONNE - if _relative_diff(calculated_co2_t, lodged.co2_emissions) > _REL_TOL: - self._warn_divergence( - quantity="co2_emissions", - property_id=property_id, - sap_version=sap_version, - lodged=lodged.co2_emissions, - calculated=calculated_co2_t, - ) - - def _warn_divergence( - self, - *, - quantity: str, - property_id: int, - sap_version: Optional[float], - lodged: float, - calculated: float, - ) -> None: - logger.warning( - "SAP10 shadow divergence on %s for property_id=%s sap_version=%s: " - "lodged=%s calculated=%s", - quantity, - property_id, - sap_version, - lodged, - calculated, - ) diff --git a/domain/property_baseline/rebaseliner.py b/domain/property_baseline/rebaseliner.py index a80552ea..2fd60df9 100644 --- a/domain/property_baseline/rebaseliner.py +++ b/domain/property_baseline/rebaseliner.py @@ -36,20 +36,22 @@ class Rebaseliner(ABC): @abstractmethod def rebaseline( - self, effective_epc: EpcPropertyData, lodged: Performance + self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance ) -> tuple[Performance, RebaselineReason]: ... class StubRebaseliner(Rebaseliner): - """The no-ML stub for the validation phase. + """A no-calculator stub for tests that don't want the real calculator. SAP10 certs pass through untouched — Effective Performance equals Lodged, - reason ``"none"``. A pre-SAP10 cert genuinely needs ML rebaselining, which is - not implemented yet (#1135), so it raises rather than fabricating a "none". + reason ``"none"``. A pre-SAP10 cert genuinely needs rebaselining, which this + stub does not do, so it raises rather than fabricating a "none". Production + uses ``CalculatorRebaseliner`` (the calculator is load-bearing — ADR-0013 + amendment); this stub stays for orchestrator/repo unit tests. """ def rebaseline( - self, effective_epc: EpcPropertyData, lodged: Performance + self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance ) -> tuple[Performance, RebaselineReason]: sap_version = effective_epc.sap_version if sap_version is not None and sap_version < _SAP10_FLOOR: diff --git a/orchestration/property_baseline_orchestrator.py b/orchestration/property_baseline_orchestrator.py index 119889bd..bf82a514 100644 --- a/orchestration/property_baseline_orchestrator.py +++ b/orchestration/property_baseline_orchestrator.py @@ -6,7 +6,6 @@ from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, RenewableHeatIncentive, ) -from domain.property_baseline.calculator_shadow import CalculatorShadow from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance from domain.property_baseline.performance import lodged_performance from domain.property_baseline.rebaseliner import Rebaseliner @@ -33,11 +32,9 @@ class PropertyBaselineOrchestrator: *, unit_of_work: Callable[[], UnitOfWork], rebaseliner: Rebaseliner, - calculator_shadow: CalculatorShadow, ) -> None: self._unit_of_work = unit_of_work self._rebaseliner = rebaseliner - self._calculator_shadow = calculator_shadow def run(self, property_ids: list[int]) -> None: with self._unit_of_work() as uow: @@ -46,7 +43,7 @@ class PropertyBaselineOrchestrator: effective_epc = prop.effective_epc lodged = lodged_performance(effective_epc) effective, reason = self._rebaseliner.rebaseline( - effective_epc, lodged + property_id, effective_epc, lodged ) rhi = _require_rhi(effective_epc) baseline = PropertyBaselinePerformance( @@ -57,14 +54,6 @@ class PropertyBaselineOrchestrator: water_heating_kwh=rhi.water_heating_kwh, ) uow.property_baseline.save(baseline, property_id) - # Shadow only: validate the calculator in the wild without - # gating the load-bearing write above (ADR-0013). `observe` - # never raises, so it cannot abort the batch. - self._calculator_shadow.observe( - property_id=property_id, - effective_epc=effective_epc, - lodged=lodged, - ) uow.commit() diff --git a/tests/domain/property_baseline/test_calculator_rebaseliner.py b/tests/domain/property_baseline/test_calculator_rebaseliner.py new file mode 100644 index 00000000..ea1230fc --- /dev/null +++ b/tests/domain/property_baseline/test_calculator_rebaseliner.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import logging +from typing import Optional + +import pytest + +from datatypes.epc.domain.epc import Epc +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.property_baseline.calculator_rebaseliner import CalculatorRebaseliner +from domain.property_baseline.performance import Performance +from domain.sap10_calculator.calculator import SapResult +from domain.sap10_calculator.exceptions import UnmappedSapCode + + +def _epc(*, sap_version: Optional[float]) -> EpcPropertyData: + epc = object.__new__(EpcPropertyData) + epc.sap_version = sap_version + return epc + + +def _lodged() -> Performance: + return Performance( + sap_score=72, epc_band=Epc.C, co2_emissions=1.8, primary_energy_intensity=180 + ) + + +def _sap_result( + *, + sap_score: int = 72, + co2_kg_per_yr: float = 1800.0, + primary_energy_kwh_per_m2: float = 180.0, +) -> SapResult: + return SapResult( + sap_score=sap_score, + sap_score_continuous=float(sap_score), + ecf=0.0, + total_fuel_cost_gbp=0.0, + co2_kg_per_yr=co2_kg_per_yr, + 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=0.0, + primary_energy_kwh_per_yr=0.0, + primary_energy_kwh_per_m2=primary_energy_kwh_per_m2, + monthly=(), + intermediate={}, + ) + + +class _StubCalculator: + def __init__(self, result: SapResult) -> None: + self._result = result + + def calculate(self, epc: EpcPropertyData) -> SapResult: + return self._result + + +def test_pre_10_2_cert_is_rebaselined_to_the_calculator_output() -> None: + # Arrange — a SAP 10.0 cert: lodged figures are a superseded methodology, so + # the calculator's output becomes Effective Performance (ADR-0013 amendment). + calculator = _StubCalculator( + _sap_result(sap_score=70, co2_kg_per_yr=1900.0, primary_energy_kwh_per_m2=185.4) + ) + rebaseliner = CalculatorRebaseliner(calculator) + epc = _epc(sap_version=10.0) + + # Act + effective, reason = rebaseliner.rebaseline( + property_id=10, effective_epc=epc, lodged=_lodged() + ) + + # Assert — calculated Performance: band from the score, CO2 kg->t, PEUI rounded. + assert effective == Performance( + sap_score=70, epc_band=Epc.C, co2_emissions=1.9, primary_energy_intensity=185 + ) + assert reason == "pre_sap10" + + +def test_a_10_2_cert_keeps_the_lodged_figures() -> None: + # Arrange — a SAP 10.2 cert: the API's lodged figures are on-target, so they + # stand; the calculator runs only to validate. + calculator = _StubCalculator(_sap_result(sap_score=72)) + rebaseliner = CalculatorRebaseliner(calculator) + epc = _epc(sap_version=10.2) + + # Act + effective, reason = rebaseliner.rebaseline( + property_id=10, effective_epc=epc, lodged=_lodged() + ) + + # Assert + assert effective == _lodged() + assert reason == "none" + + +def test_a_10_2_cert_logs_divergence_when_the_calculator_disagrees( + caplog: pytest.LogCaptureFixture, +) -> None: + # Arrange — calculated SAP 76 vs lodged 72 (> 0.5 out) on a 10.2 cert. + calculator = _StubCalculator(_sap_result(sap_score=76)) + rebaseliner = CalculatorRebaseliner(calculator) + epc = _epc(sap_version=10.2) + + # Act + with caplog.at_level(logging.WARNING): + rebaseliner.rebaseline(property_id=42, effective_epc=epc, lodged=_lodged()) + + # Assert — a divergence warning, tagged with property_id + sap_version. + assert len(caplog.records) == 1 + message = caplog.records[0].getMessage() + assert "sap_score" in message + assert "property_id=42" in message + assert "sap_version=10.2" in message + + +def test_a_calculator_raise_propagates_and_aborts() -> None: + # Arrange — the calculator is load-bearing, so a raise is not swallowed. + class _Raising: + def calculate(self, epc: EpcPropertyData) -> SapResult: + raise UnmappedSapCode("heat_emitter_type", 99) + + rebaseliner = CalculatorRebaseliner(_Raising()) + epc = _epc(sap_version=10.0) + + # Act / Assert + with pytest.raises(UnmappedSapCode): + rebaseliner.rebaseline(property_id=10, effective_epc=epc, lodged=_lodged()) diff --git a/tests/domain/property_baseline/test_calculator_shadow.py b/tests/domain/property_baseline/test_calculator_shadow.py deleted file mode 100644 index 81718b72..00000000 --- a/tests/domain/property_baseline/test_calculator_shadow.py +++ /dev/null @@ -1,166 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Optional - -import pytest - -from datatypes.epc.domain.epc import Epc -from datatypes.epc.domain.epc_property_data import EpcPropertyData -from domain.property_baseline.calculator_shadow import LoggingCalculatorShadow -from domain.property_baseline.performance import Performance -from domain.sap10_calculator.calculator import SapResult -from domain.sap10_calculator.exceptions import UnmappedSapCode - - -def _epc(*, sap_version: Optional[float]) -> EpcPropertyData: - epc = object.__new__(EpcPropertyData) - epc.sap_version = sap_version - return epc - - -def _lodged() -> Performance: - return Performance( - sap_score=72, epc_band=Epc.C, co2_emissions=1.8, primary_energy_intensity=180 - ) - - -def _sap_result( - *, - sap_score_continuous: float = 72.0, - primary_energy_kwh_per_m2: float = 180.0, - co2_kg_per_yr: float = 1800.0, -) -> SapResult: - """A `SapResult` whose three compared quantities default to *matching* - `_lodged()`; each test perturbs one axis.""" - return SapResult( - sap_score=round(sap_score_continuous), - sap_score_continuous=sap_score_continuous, - ecf=0.0, - total_fuel_cost_gbp=0.0, - co2_kg_per_yr=co2_kg_per_yr, - 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=0.0, - primary_energy_kwh_per_yr=0.0, - primary_energy_kwh_per_m2=primary_energy_kwh_per_m2, - monthly=(), - intermediate={}, - ) - - -class _RaisingCalculator: - def calculate(self, epc: EpcPropertyData) -> SapResult: - raise UnmappedSapCode("heat_emitter_type", 99) - - -class _StubCalculator: - def __init__(self, result: SapResult) -> None: - self._result = result - - def calculate(self, epc: EpcPropertyData) -> SapResult: - return self._result - - -def test_observe_swallows_a_calculator_raise_and_logs_error( - caplog: pytest.LogCaptureFixture, -) -> None: - # Arrange — the calculator strict-raises on a cert it cannot yet map. - shadow = LoggingCalculatorShadow(_RaisingCalculator()) - epc = _epc(sap_version=10.2) - - # Act — observe must not propagate the raise (ADR-0013: shadow is not - # load-bearing, so it cannot abort the batch). - with caplog.at_level(logging.ERROR): - shadow.observe(property_id=42, effective_epc=epc, lodged=_lodged()) - - # Assert — exactly one error record, tagged with property_id + sap_version - # and carrying the exception so the wild-cohort gap is greppable. - assert len(caplog.records) == 1 - message = caplog.records[0].getMessage() - assert caplog.records[0].levelno == logging.ERROR - assert "property_id=42" in message - assert "sap_version=10.2" in message - assert "heat_emitter_type" in message - - -def test_observe_warns_when_sap_diverges_beyond_half_a_point( - caplog: pytest.LogCaptureFixture, -) -> None: - # Arrange — calculated SAP 75.0 vs lodged 72 is 3.0 out (> 0.5). - shadow = LoggingCalculatorShadow( - _StubCalculator(_sap_result(sap_score_continuous=75.0)) - ) - epc = _epc(sap_version=10.2) - - # Act - with caplog.at_level(logging.WARNING): - shadow.observe(property_id=42, effective_epc=epc, lodged=_lodged()) - - # Assert — one warning, naming the diverging quantity + the tags. - assert len(caplog.records) == 1 - message = caplog.records[0].getMessage() - assert caplog.records[0].levelno == logging.WARNING - assert "sap_score" in message - assert "property_id=42" in message - assert "sap_version=10.2" in message - - -def test_observe_warns_when_peui_diverges_beyond_one_percent( - caplog: pytest.LogCaptureFixture, -) -> None: - # Arrange — calculated PEUI 200 vs lodged 180 is ~11% out (> 1%). - shadow = LoggingCalculatorShadow( - _StubCalculator(_sap_result(primary_energy_kwh_per_m2=200.0)) - ) - epc = _epc(sap_version=10.2) - - # Act - with caplog.at_level(logging.WARNING): - shadow.observe(property_id=42, effective_epc=epc, lodged=_lodged()) - - # Assert - assert len(caplog.records) == 1 - assert "primary_energy_intensity" in caplog.records[0].getMessage() - - -def test_observe_warns_when_co2_diverges_beyond_one_percent_after_kg_to_tonnes( - caplog: pytest.LogCaptureFixture, -) -> None: - # Arrange — calculator emits kg/yr; 2000 kg = 2.0 t vs lodged 1.8 t (~11%). - shadow = LoggingCalculatorShadow( - _StubCalculator(_sap_result(co2_kg_per_yr=2000.0)) - ) - epc = _epc(sap_version=10.2) - - # Act - with caplog.at_level(logging.WARNING): - shadow.observe(property_id=42, effective_epc=epc, lodged=_lodged()) - - # Assert — the kg→tonnes conversion is applied before comparison, so a - # matching 1800 kg would *not* fire (guarded by the silent-when-aligned test). - assert len(caplog.records) == 1 - assert "co2_emissions" in caplog.records[0].getMessage() - - -def test_observe_is_silent_when_the_calculator_agrees_with_lodged( - caplog: pytest.LogCaptureFixture, -) -> None: - # Arrange — all three quantities at the matching defaults (SAP 72, PEUI 180, - # 1800 kg ≡ 1.8 t): nothing should be logged. - shadow = LoggingCalculatorShadow(_StubCalculator(_sap_result())) - epc = _epc(sap_version=10.2) - - # Act - with caplog.at_level(logging.WARNING): - shadow.observe(property_id=42, effective_epc=epc, lodged=_lodged()) - - # Assert - assert caplog.records == [] diff --git a/tests/domain/property_baseline/test_rebaseliner.py b/tests/domain/property_baseline/test_rebaseliner.py index 8f669aed..f760dbf0 100644 --- a/tests/domain/property_baseline/test_rebaseliner.py +++ b/tests/domain/property_baseline/test_rebaseliner.py @@ -29,7 +29,7 @@ def test_sap10_epc_is_not_rebaselined_so_effective_equals_lodged() -> None: rebaseliner = StubRebaseliner() # Act - effective, reason = rebaseliner.rebaseline(epc, lodged) + effective, reason = rebaseliner.rebaseline(10, epc, lodged) # Assert — Effective Performance equals Lodged, reason "none". assert effective == lodged @@ -45,4 +45,4 @@ def test_pre_sap10_epc_raises_because_rebaselining_is_not_implemented() -> None: # Act / Assert with pytest.raises(RebaselineNotImplemented): - rebaseliner.rebaseline(epc, _lodged()) + rebaseliner.rebaseline(10, epc, _lodged()) diff --git a/tests/orchestration/fakes.py b/tests/orchestration/fakes.py index 23b1fc90..3e2feef0 100644 --- a/tests/orchestration/fakes.py +++ b/tests/orchestration/fakes.py @@ -10,8 +10,6 @@ from types import TracebackType from typing import Any, Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData -from domain.property_baseline.calculator_shadow import CalculatorShadow -from domain.property_baseline.performance import Performance from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance from domain.property.properties import Properties from domain.property.property import Property @@ -90,23 +88,6 @@ class FakePropertyBaselineRepo(PropertyBaselineRepository): raise NotImplementedError -class FakeCalculatorShadow(CalculatorShadow): - """Records each `observe` call so a test can assert the orchestrator runs - the shadow per property without dragging in the real calculator.""" - - def __init__(self) -> None: - self.observed: list[tuple[int, EpcPropertyData, Performance]] = [] - - def observe( - self, - *, - property_id: int, - effective_epc: EpcPropertyData, - lodged: Performance, - ) -> None: - self.observed.append((property_id, effective_epc, lodged)) - - class FakeUnitOfWork(UnitOfWork): """A unit that holds in-memory repos and counts commits.""" diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index 357ea7f2..e60ac716 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -36,7 +36,6 @@ from repositories.geospatial.geospatial_repository import GeospatialRepository from repositories.materials.materials_repository import MaterialsRepository from repositories.postgres_unit_of_work import PostgresUnitOfWork from repositories.scenario.scenario_repository import ScenarioRepository -from tests.orchestration.fakes import FakeCalculatorShadow _JSON_SAMPLES = Path(__file__).resolve().parents[2] / "backend/epc_api/json_samples" @@ -114,7 +113,6 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun( baseline=PropertyBaselineOrchestrator( unit_of_work=unit_of_work, rebaseliner=StubRebaseliner(), - calculator_shadow=FakeCalculatorShadow(), ), modelling=ModellingOrchestrator( scenario_repo=ScenarioRepository(), diff --git a/tests/orchestration/test_property_baseline_orchestrator.py b/tests/orchestration/test_property_baseline_orchestrator.py index b14574f0..12c3d660 100644 --- a/tests/orchestration/test_property_baseline_orchestrator.py +++ b/tests/orchestration/test_property_baseline_orchestrator.py @@ -13,7 +13,6 @@ from domain.property_baseline.rebaseliner import RebaselineNotImplemented, StubR from domain.property.property import Property, PropertyIdentity from orchestration.property_baseline_orchestrator import PropertyBaselineOrchestrator from tests.orchestration.fakes import ( - FakeCalculatorShadow, FakePropertyBaselineRepo, FakePropertyRepo, FakeUnitOfWork, @@ -38,34 +37,6 @@ def _property(*, sap_version: float) -> Property: ) -def test_run_invokes_the_calculator_shadow_per_property_and_still_persists() -> None: - # Arrange - property_baseline_repo = FakePropertyBaselineRepo() - shadow = FakeCalculatorShadow() - prop = _property(sap_version=10.2) - uow = FakeUnitOfWork( - property=FakePropertyRepo({10: prop}), - property_baseline=property_baseline_repo, - ) - orchestrator = PropertyBaselineOrchestrator( - unit_of_work=lambda: uow, - rebaseliner=StubRebaseliner(), - calculator_shadow=shadow, - ) - - # Act - orchestrator.run([10]) - - # Assert — the load-bearing write + single commit are unchanged, and the - # shadow observed the Effective EPC + Lodged Performance once (ADR-0013). - lodged = Performance( - sap_score=72, epc_band=Epc.C, co2_emissions=1.8, primary_energy_intensity=180 - ) - assert len(property_baseline_repo.saved) == 1 - assert uow.commits == 1 - assert shadow.observed == [(10, prop.effective_epc, lodged)] - - def test_run_establishes_persists_and_commits_the_batch_once() -> None: # Arrange property_baseline_repo = FakePropertyBaselineRepo() @@ -76,7 +47,6 @@ def test_run_establishes_persists_and_commits_the_batch_once() -> None: orchestrator = PropertyBaselineOrchestrator( unit_of_work=lambda: uow, rebaseliner=StubRebaseliner(), - calculator_shadow=FakeCalculatorShadow(), ) # Act @@ -112,7 +82,6 @@ def test_run_raises_on_a_pre_sap10_property_and_does_not_commit() -> None: orchestrator = PropertyBaselineOrchestrator( unit_of_work=lambda: uow, rebaseliner=StubRebaseliner(), - calculator_shadow=FakeCalculatorShadow(), ) # Act / Assert — the raise propagates; the batch is neither persisted nor