From 2ee1b35dcab30a4ba5bbb1a1d55618dc4c596bd1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 23 Jun 2026 17:21:03 +0000 Subject: [PATCH] fix(modelling_e2e): persist Baseline Performance for lodged properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler wrote epc/spatial/solar/plan and marked the property modelled, but never established its Baseline Performance — so no row was created in property_baseline_performance for any property modelled through the Lambda (noticed on portfolio 796 / scenario 1268 / property 727218, a lodged property). Mirror the e2e runner: after the plan UoW commits (so the EPC is persisted for the orchestrator to re-hydrate), run PropertyBaselineOrchestrator for lodged properties. Predicted properties have no lodged figures and no persisted EPC, so they are skipped — consistent with the e2e runner and the ara_first_run Baseline stage. Verified 727218's baseline pipeline builds end-to-end in-memory (lodged_performance → CalculatorRebaseliner → bill → PropertyBaselinePerformance, reason pre_sap10). Tests: lodged path asserts the orchestrator runs once; prediction path asserts it does not. Co-Authored-By: Claude Opus 4.8 (1M context) --- applications/modelling_e2e/handler.py | 25 +++++++++++++++ .../modelling_e2e/test_handler.py | 31 +++++++++++++++---- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/applications/modelling_e2e/handler.py b/applications/modelling_e2e/handler.py index 2bb5e245..9a8c8e73 100644 --- a/applications/modelling_e2e/handler.py +++ b/applications/modelling_e2e/handler.py @@ -51,8 +51,13 @@ from domain.geospatial.coordinates import Coordinates from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.geospatial.spatial_reference import SpatialReference from domain.property.property import Property, PropertyIdentity +from domain.property_baseline.calculator_rebaseliner import CalculatorRebaseliner +from domain.sap10_calculator.calculator import Sap10Calculator from domain.tasks.tasks import Source from harness.console import run_modelling +from orchestration.property_baseline_orchestrator import ( + PropertyBaselineOrchestrator, +) from infrastructure.epc_client.epc_client_service import EpcClientService from infrastructure.postcodes_io.postcodes_io_client import PostcodesIoClient from infrastructure.postgres.config import PostgresConfig @@ -72,6 +77,9 @@ from repositories.geospatial.geospatial_s3_repository import ( GeospatialS3Repository, ParquetReader, ) +from repositories.fuel_rates.fuel_rates_static_file_repository import ( + FuelRatesStaticFileRepository, +) from repositories.postgres_unit_of_work import PostgresUnitOfWork from repositories.product.composite_product_repository import ( catalogue_with_off_catalogue_overrides, @@ -266,6 +274,16 @@ def handler(body: dict[str, Any], context: Any) -> Optional[dict[str, Any]]: ) return _nearby_cohort_cache[key] + # Re-establishes each lodged Property's Baseline Performance from the just- + # persisted EPC (one UoW per property, committed after the Plan's). Predicted + # Properties have no lodged figures, so they get no baseline (mirrors the e2e + # runner and the ara_first_run Baseline stage). + baseline_orchestrator = PropertyBaselineOrchestrator( + unit_of_work=lambda: PostgresUnitOfWork(lambda: Session(engine)), + rebaseliner=CalculatorRebaseliner(Sap10Calculator()), + fuel_rates=FuelRatesStaticFileRepository(), + ) + read_session = Session(engine) try: scenario = ScenarioPostgresRepository(read_session).get_many([scenario_id])[0] @@ -399,6 +417,13 @@ def handler(body: dict[str, Any], context: Any) -> Optional[dict[str, Any]]: uow.commit() logger.info(f"property={property_id} plan saved") + # Baseline Performance is re-established from the persisted EPC, so + # it runs after the Plan UoW commits. Only lodged Properties have + # the lodged figures the Baseline reads; predicted ones are skipped. + if epc is not None: + baseline_orchestrator.run([property_id]) + logger.info(f"property={property_id} baseline saved") + except Exception as error: # noqa: BLE001 logger.error( f"property={property_id}: {type(error).__name__}: {error}", diff --git a/tests/applications/modelling_e2e/test_handler.py b/tests/applications/modelling_e2e/test_handler.py index d9a93184..6929d13f 100644 --- a/tests/applications/modelling_e2e/test_handler.py +++ b/tests/applications/modelling_e2e/test_handler.py @@ -8,7 +8,7 @@ is needed. One test per distinct behaviour path. from __future__ import annotations from contextlib import ExitStack -from typing import Any +from typing import Any, Iterator from unittest.mock import MagicMock, call, patch import pytest @@ -84,6 +84,16 @@ def _clear_cohort_cache() -> None: h._nearby_cohort_cache.clear() +@pytest.fixture(autouse=True) +def _baseline_orchestrator() -> Iterator[MagicMock]: + """Patch the Baseline orchestrator for every test — construction stays cheap + and ``.run`` is a no-op mock the baseline tests assert against.""" + with patch( + "applications.modelling_e2e.handler.PropertyBaselineOrchestrator" + ) as orchestrator: + yield orchestrator + + # --------------------------------------------------------------------------- # Trigger body validation # --------------------------------------------------------------------------- @@ -117,9 +127,12 @@ def test_trigger_body_rejects_missing_property_ids() -> None: # --------------------------------------------------------------------------- -def test_lodged_epc_path_saves_epc_plan_and_marks_modelled() -> None: +def test_lodged_epc_path_saves_epc_plan_and_marks_modelled( + _baseline_orchestrator: MagicMock, +) -> None: """When get_by_uprn returns an EPC the handler saves it, saves the plan, - and marks the property as modelled — all inside one UoW per property.""" + marks the property as modelled — all inside one UoW per property — and + re-establishes its Baseline Performance after the plan commits.""" # Arrange mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) mock_epc = MagicMock() @@ -197,6 +210,8 @@ def test_lodged_epc_path_saves_epc_plan_and_marks_modelled() -> None: PROPERTY_ID, has_recommendations=False ) mock_uow.commit.assert_called_once() + # Baseline Performance is re-established for the lodged property. + _baseline_orchestrator.return_value.run.assert_called_once_with([PROPERTY_ID]) def test_skipped_cohort_certs_are_surfaced_in_the_outputs() -> None: @@ -297,9 +312,12 @@ def test_skipped_cohort_certs_are_surfaced_in_the_outputs() -> None: # --------------------------------------------------------------------------- -def test_prediction_path_saves_plan_without_epc_save() -> None: +def test_prediction_path_saves_plan_without_epc_save( + _baseline_orchestrator: MagicMock, +) -> None: """When get_by_uprn returns None the handler synthesises an EPC via - prediction and saves the plan — but never calls epc.save.""" + prediction and saves the plan — but never calls epc.save, and (having no + lodged figures) never establishes a Baseline.""" # Arrange mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) mock_plan = _plan_mock() @@ -399,10 +417,11 @@ def test_prediction_path_saves_plan_without_epc_save() -> None: # Act _call_handler(_BODY) - # Assert — epc.save NOT called (no lodged cert), plan IS saved + # Assert — epc.save NOT called (no lodged cert), plan IS saved, no baseline mock_uow.epc.save.assert_not_called() mock_uow.plan.save.assert_called_once() mock_uow.commit.assert_called_once() + _baseline_orchestrator.return_value.run.assert_not_called() # ---------------------------------------------------------------------------