fix(modelling_e2e): persist Baseline Performance for lodged properties

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-23 17:21:03 +00:00
parent 4f4ec32e51
commit 2ee1b35dca
2 changed files with 50 additions and 6 deletions

View file

@ -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}",

View file

@ -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()
# ---------------------------------------------------------------------------