diff --git a/applications/modelling_e2e/handler.py b/applications/modelling_e2e/handler.py index 5b3db070..532d5fb4 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, @@ -412,12 +413,15 @@ def handler(body: dict[str, Any], context: Any, orchestrator: TaskOrchestrator, property_ids = trigger.property_ids portfolio_id = trigger.portfolio_id scenario_id = trigger.scenario_id - no_solar = trigger.no_solar + refetch_solar = trigger.refetch_solar + refetch_epc = trigger.refetch_epc + repredict_epc = trigger.repredict_epc dry_run = trigger.dry_run logger.info( f"start property_ids={property_ids} portfolio={portfolio_id} " - f"scenario={scenario_id} no_solar={no_solar} dry_run={dry_run}" + f"scenario={scenario_id} refetch_solar={refetch_solar} " + f"refetch_epc={refetch_epc} repredict_epc={repredict_epc} dry_run={dry_run}" ) engine = _get_engine() @@ -499,11 +503,22 @@ def handler(body: dict[str, Any], context: Any, orchestrator: TaskOrchestrator, products = catalogue_snapshot_with_off_catalogue_overrides(read_session) stored_solar: dict[int, Optional[dict[str, Any]]] = ( {} - if no_solar + if not refetch_solar else SolarPostgresRepository(read_session).get_many( 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), @@ -528,7 +543,14 @@ 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 refetch_epc: + epc: Optional[EpcPropertyData] = epc_client.get_by_uprn(uprn) + elif stored_lodged is not None: + logger.info(f"property={pid} using stored lodged EPC (refetch_epc=False)") + epc = stored_lodged + else: + epc = None # no stored lodged EPC; prediction path handles this property overrides = overlays_from(overrides_reader.overrides_for(pid)) predicted_epc: Optional[EpcPropertyData] = None @@ -548,17 +570,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, @@ -573,7 +602,7 @@ def handler(body: dict[str, Any], context: Any, orchestrator: TaskOrchestrator, solar_insights: Optional[dict[str, Any]] solar_was_fetched = False - if no_solar: + if not refetch_solar: solar_insights = None else: solar_insights = stored_solar.get(uprn) diff --git a/applications/modelling_e2e/local_handler/invoke_local_lambda.py b/applications/modelling_e2e/local_handler/invoke_local_lambda.py index 9ed2fc02..991d181d 100644 --- a/applications/modelling_e2e/local_handler/invoke_local_lambda.py +++ b/applications/modelling_e2e/local_handler/invoke_local_lambda.py @@ -15,7 +15,7 @@ payload = { "property_ids": [727220, 727229], "portfolio_id": 796, "scenario_id": 1268, - "no_solar": False, + "refetch_solar": True, "dry_run": True, } ) diff --git a/applications/modelling_e2e/modelling_e2e_trigger_body.py b/applications/modelling_e2e/modelling_e2e_trigger_body.py index cb83118a..f9c92513 100644 --- a/applications/modelling_e2e/modelling_e2e_trigger_body.py +++ b/applications/modelling_e2e/modelling_e2e_trigger_body.py @@ -7,5 +7,7 @@ class ModellingE2ETriggerBody(BaseModel): property_ids: list[int] portfolio_id: int scenario_id: int - no_solar: bool = False + refetch_solar: bool = True + refetch_epc: bool = True + repredict_epc: bool = True dry_run: bool = False diff --git a/scripts/north_northamptonshire_properties_by_postcode_812.txt b/scripts/north_northamptonshire_properties_by_postcode_812.txt new file mode 100644 index 00000000..6aad7962 --- /dev/null +++ b/scripts/north_northamptonshire_properties_by_postcode_812.txt @@ -0,0 +1,15 @@ +'NN14 1JZ': [742012] +'NN15 6TD': [741992] +'NN14 1JS': [742051, 742052] +'LE16 8HG': [741987, 741988, 741989] +'LE16 8PP': [742044, 742045, 742046] +'NN14 1PY': [742000, 742001, 742002, 742003] +'LE16 8HF': [741983, 741984, 741985, 741986, 741990, 741991] +'LE16 8HT': [742057, 742058, 742059, 742060, 742061, 742062] +'LE16 8LD': [741993, 741994, 741995, 741996, 741997, 741998, 741999] +'NN14 1JP': [742047, 742048, 742049, 742050, 742053, 742054, 742055, 742056] +'NN14 1LA': [742013, 742014, 742015, 742016, 742017, 742018, 742019, 742020] +'NN14 1PT': [742004, 742005, 742006, 742007, 742008, 742009, 742010, 742011] +'NN14 1EL': [742021, 742022, 742023, 742024, 742025, 742026, 742027, 742028, 742029, 742030, 742031, 742032, 742033, 742034, 742035, 742036, 742037, 742038, 742039, 742040, 742041, 742042, 742043] + +Total postcodes: 13, total properties: 80 \ No newline at end of file diff --git a/scripts/trigger_modelling_e2e_sqs.py b/scripts/trigger_modelling_e2e_sqs.py index e4b0dd4f..e0d060d9 100644 --- a/scripts/trigger_modelling_e2e_sqs.py +++ b/scripts/trigger_modelling_e2e_sqs.py @@ -23,8 +23,8 @@ from utilities.logger import setup_logger # --------------------------------------------------------------------------- # CONFIG — edit these before running # --------------------------------------------------------------------------- -PORTFOLIO_ID: int = 805 -SCENARIO_ID: int = 1267 +PORTFOLIO_ID: int = 796 +SCENARIO_ID: int = 1268 SQS_QUEUE_NAME: str = "modelling_e2e-queue-dev" # Max number of properties to process this run (cost cap). @@ -42,8 +42,16 @@ COMPLETED_SINCE: datetime | None = datetime( # True → Lambda runs the full pipeline but skips all DB writes (safe for testing). DRY_RUN: bool = False -# True → Lambda skips the Google Solar fetch. -NO_SOLAR: bool = False +# False → Lambda skips the Google Solar fetch (re-uses stored Solar data). +REFETCH_SOLAR: bool = True + +# False → use stored lodged EPC for properties that have one; properties with no +# stored lodged EPC are treated as EPC-less and routed to prediction (no API call). +REFETCH_EPC: bool = True + +# False → use stored predicted EPC for EPC-less properties that have one; live +# prediction still runs when no stored predicted EPC exists for the property. +REPREDICT_EPC: bool = True # --------------------------------------------------------------------------- _REPO_ROOT = Path(__file__).resolve().parents[1] @@ -74,8 +82,8 @@ def _load_postcode_map() -> dict[str, list[int]]: return result -def _completed_property_ids(since: datetime) -> set[int]: - """Return property IDs with a completed modelling_e2e sub_task on or after *since*.""" +def _completed_property_ids(since: datetime, scenario_id: int) -> set[int]: + """Return property IDs with a completed modelling_e2e sub_task for *scenario_id* on or after *since*.""" load_env(ENV_PATH) engine = build_engine() with engine.connect() as conn: @@ -88,8 +96,9 @@ def _completed_property_ids(since: datetime) -> set[int]: AND st.status = 'complete' AND st.job_completed >= :since AND (st.inputs::jsonb) ? 'property_id' + AND ((st.inputs::jsonb)->>'scenario_id')::int = :scenario_id """), - {"since": since}, + {"since": since, "scenario_id": scenario_id}, ).fetchall() return {int(r[0]) for r in rows} @@ -99,7 +108,7 @@ def main() -> None: completed: set[int] = set() if COMPLETED_SINCE is not None: - completed = _completed_property_ids(COMPLETED_SINCE) + completed = _completed_property_ids(COMPLETED_SINCE, SCENARIO_ID) logger.info( f"skipping {len(completed)} properties already completed since {COMPLETED_SINCE}" ) @@ -150,7 +159,8 @@ def main() -> None: logger.info( f"sending {len(batches)} messages " f"(portfolio={PORTFOLIO_ID}, scenario={SCENARIO_ID}, " - f"dry_run={DRY_RUN}, no_solar={NO_SOLAR}) → {sqs_url}" + f"dry_run={DRY_RUN}, refetch_solar={REFETCH_SOLAR}, " + f"refetch_epc={REFETCH_EPC}, repredict_epc={REPREDICT_EPC}) → {sqs_url}" ) for batch in batches: @@ -161,7 +171,9 @@ def main() -> None: "property_ids": batch, "portfolio_id": PORTFOLIO_ID, "scenario_id": SCENARIO_ID, - "no_solar": NO_SOLAR, + "refetch_solar": REFETCH_SOLAR, + "refetch_epc": REFETCH_EPC, + "repredict_epc": REPREDICT_EPC, "dry_run": DRY_RUN, } ), diff --git a/tests/applications/modelling_e2e/test_handler.py b/tests/applications/modelling_e2e/test_handler.py index 7f11c7d7..90b3e97c 100644 --- a/tests/applications/modelling_e2e/test_handler.py +++ b/tests/applications/modelling_e2e/test_handler.py @@ -36,7 +36,7 @@ _BODY = { "property_ids": [PROPERTY_ID], "portfolio_id": PORTFOLIO_ID, "scenario_id": SCENARIO_ID, - "no_solar": True, + "refetch_solar": False, "dry_run": False, } @@ -188,6 +188,25 @@ def test_trigger_body_rejects_missing_property_ids() -> None: ) +def test_trigger_body_new_flags_default_to_true() -> None: + """refetch_epc, repredict_epc, and refetch_solar all default True so that + existing callers that omit them get current behaviour (live fetches).""" + # Arrange + body = { + "property_ids": [PROPERTY_ID], + "portfolio_id": PORTFOLIO_ID, + "scenario_id": SCENARIO_ID, + } + + # Act + result = ModellingE2ETriggerBody.model_validate(body) + + # Assert + assert result.refetch_epc is True + assert result.repredict_epc is True + assert result.refetch_solar is True + + # --------------------------------------------------------------------------- # Child SubTask creation # --------------------------------------------------------------------------- @@ -243,7 +262,7 @@ def test_handler_creates_one_child_subtask_per_property_id() -> None: from applications.modelling_e2e.handler import handler handler.__wrapped__( # type: ignore[attr-defined] {"property_ids": [pid1, pid2, pid3], "portfolio_id": PORTFOLIO_ID, - "scenario_id": SCENARIO_ID, "no_solar": True, "dry_run": False}, + "scenario_id": SCENARIO_ID, "refetch_solar": False, "dry_run": False}, None, mock_orch, task_id, ) @@ -896,7 +915,7 @@ def test_per_property_failure_fails_child_subtask_and_siblings_continue() -> Non # Act — must not raise even though pid2 fails _call_handler( {"property_ids": [pid1, pid2], "portfolio_id": PORTFOLIO_ID, - "scenario_id": SCENARIO_ID, "no_solar": True, "dry_run": False}, + "scenario_id": SCENARIO_ID, "refetch_solar": False, "dry_run": False}, orchestrator=mock_orch, ) @@ -962,7 +981,7 @@ def test_batch_persists_in_one_transaction_and_one_baseline_run( # Act _call_handler( {"property_ids": [pid1, pid2, pid3], "portfolio_id": PORTFOLIO_ID, - "scenario_id": SCENARIO_ID, "no_solar": True, "dry_run": False} + "scenario_id": SCENARIO_ID, "refetch_solar": False, "dry_run": False} ) # Assert — all three Plans saved, but a single shared transaction: @@ -1079,7 +1098,7 @@ def test_cohort_cache_prevents_duplicate_candidates_for_calls() -> None: "property_ids": [pid1, pid2], "portfolio_id": PORTFOLIO_ID, "scenario_id": SCENARIO_ID, - "no_solar": True, + "refetch_solar": False, "dry_run": False, } ) @@ -1088,6 +1107,395 @@ 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_skips_api_and_goes_to_prediction() -> None: + """refetch_epc=False + no stored lodged EPC: the EPC API is not called; + the property is treated as EPC-less and prediction runs instead.""" + # Arrange + mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) + mock_plan = _plan_mock() + mock_uow = MagicMock() + + mock_predicted_epc = MagicMock() + from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier + + mock_part = MagicMock() + mock_part.identifier = BuildingPartIdentifier.MAIN + mock_predicted_epc.sap_building_parts = [mock_part] + + mock_comparables = MagicMock() + mock_comparables.members = [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 = {} + from domain.epc_prediction.prediction_target import PredictionTargetAttributes + + stack.enter_context( + patch("applications.modelling_e2e.handler.OverrideBackedPredictionAttributesReader") + ).return_value.attributes_for.return_value = PredictionTargetAttributes( + property_type="2" + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.select_comparables") + ).return_value = mock_comparables + stack.enter_context( + patch("applications.modelling_e2e.handler.EpcPrediction") + ).return_value.predict.return_value = mock_predicted_epc + stack.enter_context( + patch("applications.modelling_e2e.handler.EpcComparablePropertiesRepository") + ).return_value.candidates_for.return_value = [MagicMock()] + 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 + 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 NOT called; prediction ran and its output was persisted + mock_epc_client.get_by_uprn.assert_not_called() + mock_uow.epc.save.assert_called_once_with( + mock_predicted_epc, + property_id=PROPERTY_ID, + portfolio_id=PORTFOLIO_ID, + source="predicted", + ) + + +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 +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# repredict_epc flag +# --------------------------------------------------------------------------- + + +def test_repredict_epc_false_with_stored_predicted_epc_skips_prediction() -> None: + """repredict_epc=False + stored predicted EPC present: EpcPrediction.predict + is not called; the stored predicted EPC reaches run_modelling.""" + # Arrange + mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) + stored_predicted = MagicMock() + from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier + + mock_part = MagicMock() + mock_part.identifier = BuildingPartIdentifier.MAIN + stored_predicted.sap_building_parts = [mock_part] + + 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) + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.EpcClientService") + ).return_value.get_by_uprn.return_value = None # no lodged EPC → prediction path + 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) + ) + mock_predictor = stack.enter_context( + patch("applications.modelling_e2e.handler.EpcPrediction") + ).return_value + # Stored predicted EPC is present + stack.enter_context( + patch("applications.modelling_e2e.handler.EpcPostgresRepository") + ).return_value.get_predicted_for_properties.return_value = { + PROPERTY_ID: stored_predicted + } + 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, "repredict_epc": False}) + + # Assert — EpcPrediction.predict never called; stored EPC persisted in predicted slot + mock_predictor.predict.assert_not_called() + mock_uow.epc.save.assert_called_once_with( + stored_predicted, + property_id=PROPERTY_ID, + portfolio_id=PORTFOLIO_ID, + source="predicted", + ) + + +def test_repredict_epc_false_without_stored_predicted_epc_falls_back_to_live_prediction() -> None: + """repredict_epc=False + no stored predicted EPC: handler falls back to live + prediction so the property is not silently skipped.""" + # Arrange + mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) + mock_plan = _plan_mock() + mock_uow = MagicMock() + + mock_predicted_epc = MagicMock() + from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier + + mock_part = MagicMock() + mock_part.identifier = BuildingPartIdentifier.MAIN + mock_predicted_epc.sap_building_parts = [mock_part] + + mock_comparables = MagicMock() + mock_comparables.members = [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) + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.EpcClientService") + ).return_value.get_by_uprn.return_value = None # no lodged 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 = {} + from domain.epc_prediction.prediction_target import PredictionTargetAttributes + + stack.enter_context( + patch("applications.modelling_e2e.handler.OverrideBackedPredictionAttributesReader") + ).return_value.attributes_for.return_value = PredictionTargetAttributes( + property_type="2" + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.select_comparables") + ).return_value = mock_comparables + mock_predictor = stack.enter_context( + patch("applications.modelling_e2e.handler.EpcPrediction") + ).return_value + mock_predictor.predict.return_value = mock_predicted_epc + stack.enter_context( + patch("applications.modelling_e2e.handler.EpcComparablePropertiesRepository") + ).return_value.candidates_for.return_value = [MagicMock()] + 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 predicted EPC + stack.enter_context( + patch("applications.modelling_e2e.handler.EpcPostgresRepository") + ).return_value.get_predicted_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, "repredict_epc": False}) + + # Assert — live prediction was used as fallback + mock_predictor.predict.assert_called_once() + mock_uow.epc.save.assert_called_once_with( + mock_predicted_epc, + property_id=PROPERTY_ID, + portfolio_id=PORTFOLIO_ID, + source="predicted", + ) + + # --------------------------------------------------------------------------- # Dry-run # ---------------------------------------------------------------------------