diff --git a/applications/modelling_e2e/handler.py b/applications/modelling_e2e/handler.py index 7ae15bef..d9b86c39 100644 --- a/applications/modelling_e2e/handler.py +++ b/applications/modelling_e2e/handler.py @@ -92,6 +92,7 @@ from repositories.comparable_properties.epc_comparable_properties_repository imp EpcComparablePropertiesRepository, SkippedCohortCert, ) +from repositories.epc.epc_postgres_repository import EpcPostgresRepository from repositories.geospatial.geospatial_s3_repository import ( GeospatialS3Repository, ParquetReader, @@ -507,6 +508,17 @@ def handler(body: dict[str, Any], context: Any, orchestrator: TaskOrchestrator, list(set(uprns.values())) ) ) + epc_repo = EpcPostgresRepository(read_session) + stored_lodged_epcs: dict[int, EpcPropertyData] = ( + epc_repo.get_for_properties(property_ids) + if not refetch_epc + else {} + ) + stored_predicted_epcs: dict[int, EpcPropertyData] = ( + epc_repo.get_predicted_for_properties(property_ids) + if not repredict_epc + else {} + ) read_session.close() # Each Property models in its own child SubTask (failures isolated here), @@ -531,7 +543,12 @@ def handler(body: dict[str, Any], context: Any, orchestrator: TaskOrchestrator, spatial.coordinates if spatial is not None else None ) - epc: Optional[EpcPropertyData] = epc_client.get_by_uprn(uprn) + stored_lodged = stored_lodged_epcs.get(pid) + if not refetch_epc and stored_lodged is not None: + logger.info(f"property={pid} using stored lodged EPC (refetch_epc=False)") + epc: Optional[EpcPropertyData] = stored_lodged + else: + epc = epc_client.get_by_uprn(uprn) overrides = overlays_from(overrides_reader.overrides_for(pid)) predicted_epc: Optional[EpcPropertyData] = None @@ -551,17 +568,24 @@ def handler(body: dict[str, Any], context: Any, orchestrator: TaskOrchestrator, logger.info( f"property={pid} no lodged EPC — attempting prediction" ) - predicted_epc = _predict_epc( - property_id=pid, - uprn=uprn, - postcode=postcode, - portfolio_id=portfolio_id, - attributes_reader=prediction_attrs_reader, - coordinates=coordinates, - cohort_for=_get_cohort, - broaden=_broaden, - predictor=predictor, - ) + stored_predicted = stored_predicted_epcs.get(pid) + if not repredict_epc and stored_predicted is not None: + logger.info( + f"property={pid} using stored predicted EPC (repredict_epc=False)" + ) + predicted_epc = stored_predicted + else: + predicted_epc = _predict_epc( + property_id=pid, + uprn=uprn, + postcode=postcode, + portfolio_id=portfolio_id, + attributes_reader=prediction_attrs_reader, + coordinates=coordinates, + cohort_for=_get_cohort, + broaden=_broaden, + predictor=predictor, + ) effective_epc = Property( identity=PropertyIdentity( portfolio_id=portfolio_id, diff --git a/tests/applications/modelling_e2e/test_handler.py b/tests/applications/modelling_e2e/test_handler.py index 6ee7fb13..1959cd1a 100644 --- a/tests/applications/modelling_e2e/test_handler.py +++ b/tests/applications/modelling_e2e/test_handler.py @@ -1107,6 +1107,192 @@ def test_cohort_cache_prevents_duplicate_candidates_for_calls() -> None: MockCandidates.return_value.candidates_for.assert_called_once_with(POSTCODE) +# --------------------------------------------------------------------------- +# refetch_epc flag +# --------------------------------------------------------------------------- + + +def test_refetch_epc_false_with_stored_epc_skips_api_call() -> None: + """refetch_epc=False + stored lodged EPC present: EpcClientService.get_by_uprn + is never called; the stored EPC is used and reaches run_modelling.""" + # Arrange + mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) + stored_epc = MagicMock() + mock_plan = _plan_mock() + mock_uow = MagicMock() + + with ExitStack() as stack: + stack.enter_context(patch("applications.modelling_e2e.handler.os.environ", _ENV)) + stack.enter_context( + patch("applications.modelling_e2e.handler._get_engine", return_value=mock_engine) + ) + mock_epc_client = stack.enter_context( + patch("applications.modelling_e2e.handler.EpcClientService") + ).return_value + mock_epc_client.get_by_uprn.return_value = MagicMock() # would be called if flag ignored + stack.enter_context(patch("applications.modelling_e2e.handler.GeospatialS3Repository")) + stack.enter_context(patch("applications.modelling_e2e.handler.GoogleSolarApiClient")) + stack.enter_context( + patch("applications.modelling_e2e.handler._spatial_for", return_value=None) + ) + stack.enter_context( + patch("applications.modelling_e2e.handler._solar_insights_for", return_value=None) + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.overlays_from", return_value=[]) + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.PropertyOverridesPostgresReader") + ).return_value.overrides_for_many.return_value = {} + stack.enter_context( + patch("applications.modelling_e2e.handler.ScenarioPostgresRepository") + ).return_value.get_many.return_value = [MagicMock()] + stack.enter_context( + patch("applications.modelling_e2e.handler.catalogue_snapshot_with_off_catalogue_overrides") + ) + stack.enter_context(patch("applications.modelling_e2e.handler.Session")) + mock_run_modelling = stack.enter_context( + patch("applications.modelling_e2e.handler.run_modelling", return_value=mock_plan) + ) + # Bulk-read of stored lodged EPCs returns the stored EPC for this property + stack.enter_context( + patch("applications.modelling_e2e.handler.EpcPostgresRepository") + ).return_value.get_for_properties.return_value = {PROPERTY_ID: stored_epc} + MockUoW = stack.enter_context( + patch("applications.modelling_e2e.handler.PostgresUnitOfWork") + ) + MockUoW.return_value.__enter__.return_value = mock_uow + MockUoW.return_value.__exit__.return_value = False + + # Act + _call_handler({**_BODY, "refetch_epc": False}) + + # Assert — API not called; stored EPC flows into run_modelling + mock_epc_client.get_by_uprn.assert_not_called() + mock_run_modelling.assert_called_once() + # Stored lodged EPC is persisted in the lodged slot + mock_uow.epc.save.assert_called_once_with( + stored_epc, property_id=PROPERTY_ID, portfolio_id=PORTFOLIO_ID + ) + + +def test_refetch_epc_false_without_stored_epc_falls_back_to_api() -> None: + """refetch_epc=False + no stored lodged EPC: handler falls back to the live + EPC API call rather than silently skipping the property.""" + # Arrange + mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) + live_epc = MagicMock() + mock_plan = _plan_mock() + mock_uow = MagicMock() + + with ExitStack() as stack: + stack.enter_context(patch("applications.modelling_e2e.handler.os.environ", _ENV)) + stack.enter_context( + patch("applications.modelling_e2e.handler._get_engine", return_value=mock_engine) + ) + mock_epc_client = stack.enter_context( + patch("applications.modelling_e2e.handler.EpcClientService") + ).return_value + mock_epc_client.get_by_uprn.return_value = live_epc + stack.enter_context(patch("applications.modelling_e2e.handler.GeospatialS3Repository")) + stack.enter_context(patch("applications.modelling_e2e.handler.GoogleSolarApiClient")) + stack.enter_context( + patch("applications.modelling_e2e.handler._spatial_for", return_value=None) + ) + stack.enter_context( + patch("applications.modelling_e2e.handler._solar_insights_for", return_value=None) + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.overlays_from", return_value=[]) + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.PropertyOverridesPostgresReader") + ).return_value.overrides_for_many.return_value = {} + stack.enter_context( + patch("applications.modelling_e2e.handler.ScenarioPostgresRepository") + ).return_value.get_many.return_value = [MagicMock()] + stack.enter_context( + patch("applications.modelling_e2e.handler.catalogue_snapshot_with_off_catalogue_overrides") + ) + stack.enter_context(patch("applications.modelling_e2e.handler.Session")) + stack.enter_context( + patch("applications.modelling_e2e.handler.run_modelling", return_value=mock_plan) + ) + # No stored lodged EPC for this property + stack.enter_context( + patch("applications.modelling_e2e.handler.EpcPostgresRepository") + ).return_value.get_for_properties.return_value = {} + MockUoW = stack.enter_context( + patch("applications.modelling_e2e.handler.PostgresUnitOfWork") + ) + MockUoW.return_value.__enter__.return_value = mock_uow + MockUoW.return_value.__exit__.return_value = False + + # Act + _call_handler({**_BODY, "refetch_epc": False}) + + # Assert — API was called as fallback; live EPC is persisted + mock_epc_client.get_by_uprn.assert_called_once_with(UPRN) + mock_uow.epc.save.assert_called_once_with( + live_epc, property_id=PROPERTY_ID, portfolio_id=PORTFOLIO_ID + ) + + +def test_refetch_epc_true_always_calls_api_even_if_stored_epc_exists() -> None: + """refetch_epc=True (default): EpcClientService.get_by_uprn is called even + when a stored lodged EPC exists — existing behaviour is preserved.""" + # Arrange + mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) + live_epc = MagicMock() + mock_plan = _plan_mock() + mock_uow = MagicMock() + + with ExitStack() as stack: + stack.enter_context(patch("applications.modelling_e2e.handler.os.environ", _ENV)) + stack.enter_context( + patch("applications.modelling_e2e.handler._get_engine", return_value=mock_engine) + ) + mock_epc_client = stack.enter_context( + patch("applications.modelling_e2e.handler.EpcClientService") + ).return_value + mock_epc_client.get_by_uprn.return_value = live_epc + stack.enter_context(patch("applications.modelling_e2e.handler.GeospatialS3Repository")) + stack.enter_context(patch("applications.modelling_e2e.handler.GoogleSolarApiClient")) + stack.enter_context( + patch("applications.modelling_e2e.handler._spatial_for", return_value=None) + ) + stack.enter_context( + patch("applications.modelling_e2e.handler._solar_insights_for", return_value=None) + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.overlays_from", return_value=[]) + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.PropertyOverridesPostgresReader") + ).return_value.overrides_for_many.return_value = {} + stack.enter_context( + patch("applications.modelling_e2e.handler.ScenarioPostgresRepository") + ).return_value.get_many.return_value = [MagicMock()] + stack.enter_context( + patch("applications.modelling_e2e.handler.catalogue_snapshot_with_off_catalogue_overrides") + ) + stack.enter_context(patch("applications.modelling_e2e.handler.Session")) + stack.enter_context( + patch("applications.modelling_e2e.handler.run_modelling", return_value=mock_plan) + ) + MockUoW = stack.enter_context( + patch("applications.modelling_e2e.handler.PostgresUnitOfWork") + ) + MockUoW.return_value.__enter__.return_value = mock_uow + MockUoW.return_value.__exit__.return_value = False + + # Act — default refetch_epc=True (not setting the flag at all) + _call_handler(_BODY) + + # Assert — API was called regardless + mock_epc_client.get_by_uprn.assert_called_once_with(UPRN) + + # --------------------------------------------------------------------------- # Dry-run # ---------------------------------------------------------------------------