From c51ca47467e3bca33f2ef2c140300075c86cc6f1 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 26 Jun 2026 10:12:21 +0000 Subject: [PATCH 1/9] =?UTF-8?q?Rename=20no=5Fsolar=20=E2=86=92=20refetch?= =?UTF-8?q?=5Fsolar=20and=20add=20refetch=5Fepc,=20repredict=5Fepc=20flags?= =?UTF-8?q?=20to=20TriggerBody=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- applications/modelling_e2e/handler.py | 11 ++++--- .../modelling_e2e_trigger_body.py | 4 ++- .../modelling_e2e/test_handler.py | 29 +++++++++++++++---- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/applications/modelling_e2e/handler.py b/applications/modelling_e2e/handler.py index 5b3db070..7ae15bef 100644 --- a/applications/modelling_e2e/handler.py +++ b/applications/modelling_e2e/handler.py @@ -412,12 +412,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,7 +502,7 @@ 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())) ) @@ -573,7 +576,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/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/tests/applications/modelling_e2e/test_handler.py b/tests/applications/modelling_e2e/test_handler.py index 7f11c7d7..6ee7fb13 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, } ) From a940c94b33c1552967f6b9e355cd92db447f5d51 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 26 Jun 2026 10:13:51 +0000 Subject: [PATCH 2/9] =?UTF-8?q?Handler=20pre-fetches=20stored=20EPCs=20and?= =?UTF-8?q?=20routes=20per-property=20via=20refetch=5Fepc=20flag=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- applications/modelling_e2e/handler.py | 48 +++-- .../modelling_e2e/test_handler.py | 186 ++++++++++++++++++ 2 files changed, 222 insertions(+), 12 deletions(-) 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 # --------------------------------------------------------------------------- From 100a5801190c5ad916b4be9d26bcdb9634a75652 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 26 Jun 2026 10:15:40 +0000 Subject: [PATCH 3/9] =?UTF-8?q?Add=20tests=20for=20repredict=5Fepc=20flag?= =?UTF-8?q?=20routing=20via=20stored=20predicted=20EPC=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../modelling_e2e/test_handler.py | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) diff --git a/tests/applications/modelling_e2e/test_handler.py b/tests/applications/modelling_e2e/test_handler.py index 1959cd1a..0f73bbc2 100644 --- a/tests/applications/modelling_e2e/test_handler.py +++ b/tests/applications/modelling_e2e/test_handler.py @@ -1298,6 +1298,181 @@ def test_refetch_epc_true_always_calls_api_even_if_stored_epc_exists() -> None: # --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# 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 +# --------------------------------------------------------------------------- + + def test_dry_run_skips_all_db_writes() -> None: """dry_run=True: run_modelling executes but PostgresUnitOfWork is never entered — no DB writes occur for any property in the batch.""" From 5c3bde0cf594bd34de7d83c7199668f63d734bff Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 26 Jun 2026 10:17:20 +0000 Subject: [PATCH 4/9] =?UTF-8?q?Update=20trigger=20script=20and=20local=20i?= =?UTF-8?q?nvoker=20to=20use=20refetch=5Fsolar,=20refetch=5Fepc,=20repredi?= =?UTF-8?q?ct=5Fepc=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../local_handler/invoke_local_lambda.py | 2 +- scripts/trigger_modelling_e2e_sqs.py | 23 ++++++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) 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/scripts/trigger_modelling_e2e_sqs.py b/scripts/trigger_modelling_e2e_sqs.py index e4b0dd4f..665dd18c 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 → Lambda re-uses stored lodged EPCs instead of calling the EPC API +# (falls back to live API call when no stored EPC exists for a property). +REFETCH_EPC: bool = True + +# False → Lambda re-uses stored predicted EPCs instead of re-running prediction +# (falls back to live prediction when no stored predicted EPC exists). +REPREDICT_EPC: bool = True # --------------------------------------------------------------------------- _REPO_ROOT = Path(__file__).resolve().parents[1] @@ -150,7 +158,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 +170,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, } ), From b1fd9d9368090f08718ea589feafad57809bf910 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 26 Jun 2026 10:20:29 +0000 Subject: [PATCH 5/9] =?UTF-8?q?Clarify=20REFETCH=5FEPC/REPREDICT=5FEPC=20c?= =?UTF-8?q?omments=20=E2=80=94=20both=20flags=20skip-if-stored,=20not=20ne?= =?UTF-8?q?ver-fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- scripts/trigger_modelling_e2e_sqs.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/trigger_modelling_e2e_sqs.py b/scripts/trigger_modelling_e2e_sqs.py index 665dd18c..35095430 100644 --- a/scripts/trigger_modelling_e2e_sqs.py +++ b/scripts/trigger_modelling_e2e_sqs.py @@ -45,12 +45,14 @@ DRY_RUN: bool = False # False → Lambda skips the Google Solar fetch (re-uses stored Solar data). REFETCH_SOLAR: bool = True -# False → Lambda re-uses stored lodged EPCs instead of calling the EPC API -# (falls back to live API call when no stored EPC exists for a property). +# False → skip the EPC API call for properties that already have a stored lodged +# EPC; the API is still called for any property that has no stored lodged EPC. REFETCH_EPC: bool = True -# False → Lambda re-uses stored predicted EPCs instead of re-running prediction -# (falls back to live prediction when no stored predicted EPC exists). +# False → skip live EPC prediction for properties that already have a stored +# predicted EPC; live prediction still runs for any property that reaches the +# prediction branch with no stored predicted EPC. Only relevant for properties +# without a lodged EPC (either stored or freshly fetched). REPREDICT_EPC: bool = True # --------------------------------------------------------------------------- From 17a9f0aafc45402a3b6fe43dfce56038f0c608ae Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 26 Jun 2026 10:24:27 +0000 Subject: [PATCH 6/9] refetch_epc=False skips API entirely; EPC-less properties go straight to prediction path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When refetch_epc=False and no stored lodged EPC exists, the handler no longer falls back to a live EPC API call — it treats the property as EPC-less and hands it to the prediction path. This keeps REFETCH_EPC (lodged path) and REPREDICT_EPC (prediction path) cleanly independent. Co-Authored-By: Claude Sonnet 4.6 --- applications/modelling_e2e/handler.py | 8 ++-- scripts/trigger_modelling_e2e_sqs.py | 10 ++-- .../modelling_e2e/test_handler.py | 46 +++++++++++++++---- 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/applications/modelling_e2e/handler.py b/applications/modelling_e2e/handler.py index d9b86c39..532d5fb4 100644 --- a/applications/modelling_e2e/handler.py +++ b/applications/modelling_e2e/handler.py @@ -544,11 +544,13 @@ def handler(body: dict[str, Any], context: Any, orchestrator: TaskOrchestrator, ) stored_lodged = stored_lodged_epcs.get(pid) - if not refetch_epc and stored_lodged is not None: + 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: Optional[EpcPropertyData] = stored_lodged + epc = stored_lodged else: - epc = epc_client.get_by_uprn(uprn) + epc = None # no stored lodged EPC; prediction path handles this property overrides = overlays_from(overrides_reader.overrides_for(pid)) predicted_epc: Optional[EpcPropertyData] = None diff --git a/scripts/trigger_modelling_e2e_sqs.py b/scripts/trigger_modelling_e2e_sqs.py index 35095430..7521b1ec 100644 --- a/scripts/trigger_modelling_e2e_sqs.py +++ b/scripts/trigger_modelling_e2e_sqs.py @@ -45,14 +45,12 @@ DRY_RUN: bool = False # False → Lambda skips the Google Solar fetch (re-uses stored Solar data). REFETCH_SOLAR: bool = True -# False → skip the EPC API call for properties that already have a stored lodged -# EPC; the API is still called for any property that has no stored lodged EPC. +# 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 → skip live EPC prediction for properties that already have a stored -# predicted EPC; live prediction still runs for any property that reaches the -# prediction branch with no stored predicted EPC. Only relevant for properties -# without a lodged EPC (either stored or freshly fetched). +# 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 # --------------------------------------------------------------------------- diff --git a/tests/applications/modelling_e2e/test_handler.py b/tests/applications/modelling_e2e/test_handler.py index 0f73bbc2..90b3e97c 100644 --- a/tests/applications/modelling_e2e/test_handler.py +++ b/tests/applications/modelling_e2e/test_handler.py @@ -1176,15 +1176,24 @@ def test_refetch_epc_false_with_stored_epc_skips_api_call() -> None: ) -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.""" +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]) - live_epc = MagicMock() 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( @@ -1193,7 +1202,7 @@ def test_refetch_epc_false_without_stored_epc_falls_back_to_api() -> None: mock_epc_client = stack.enter_context( patch("applications.modelling_e2e.handler.EpcClientService") ).return_value - mock_epc_client.get_by_uprn.return_value = live_epc + 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( @@ -1208,6 +1217,22 @@ def test_refetch_epc_false_without_stored_epc_falls_back_to_api() -> None: 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()] @@ -1218,7 +1243,7 @@ def test_refetch_epc_false_without_stored_epc_falls_back_to_api() -> None: stack.enter_context( patch("applications.modelling_e2e.handler.run_modelling", return_value=mock_plan) ) - # No stored lodged EPC for this property + # No stored lodged EPC stack.enter_context( patch("applications.modelling_e2e.handler.EpcPostgresRepository") ).return_value.get_for_properties.return_value = {} @@ -1231,10 +1256,13 @@ def test_refetch_epc_false_without_stored_epc_falls_back_to_api() -> None: # 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) + # 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( - live_epc, property_id=PROPERTY_ID, portfolio_id=PORTFOLIO_ID + mock_predicted_epc, + property_id=PROPERTY_ID, + portfolio_id=PORTFOLIO_ID, + source="predicted", ) From 632465f03f9f2ed20498a5c8abed5e39da29c13f Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 26 Jun 2026 11:24:26 +0000 Subject: [PATCH 7/9] allow for multiple scenarios for 1 portfolio without ignoring all properties in second scenario --- ...amptonshire_properties_by_postcode_812.txt | 15 ++++++++++ scripts/trigger_modelling_e2e_sqs.py | 30 +++++++------------ 2 files changed, 25 insertions(+), 20 deletions(-) create mode 100644 scripts/north_northamptonshire_properties_by_postcode_812.txt 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 7521b1ec..521359c4 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 = 796 -SCENARIO_ID: int = 1268 +PORTFOLIO_ID: int = 805 +SCENARIO_ID: int = 1267 SQS_QUEUE_NAME: str = "modelling_e2e-queue-dev" # Max number of properties to process this run (cost cap). @@ -42,16 +42,8 @@ COMPLETED_SINCE: datetime | None = datetime( # True → Lambda runs the full pipeline but skips all DB writes (safe for testing). DRY_RUN: 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 +# True → Lambda skips the Google Solar fetch. +NO_SOLAR: bool = False # --------------------------------------------------------------------------- _REPO_ROOT = Path(__file__).resolve().parents[1] @@ -82,8 +74,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: @@ -96,8 +88,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} @@ -158,8 +151,7 @@ def main() -> None: logger.info( f"sending {len(batches)} messages " f"(portfolio={PORTFOLIO_ID}, scenario={SCENARIO_ID}, " - f"dry_run={DRY_RUN}, refetch_solar={REFETCH_SOLAR}, " - f"refetch_epc={REFETCH_EPC}, repredict_epc={REPREDICT_EPC}) → {sqs_url}" + f"dry_run={DRY_RUN}, no_solar={NO_SOLAR}) → {sqs_url}" ) for batch in batches: @@ -170,9 +162,7 @@ def main() -> None: "property_ids": batch, "portfolio_id": PORTFOLIO_ID, "scenario_id": SCENARIO_ID, - "refetch_solar": REFETCH_SOLAR, - "refetch_epc": REFETCH_EPC, - "repredict_epc": REPREDICT_EPC, + "no_solar": NO_SOLAR, "dry_run": DRY_RUN, } ), From f811b39fa1d3026ca547e35cba676bb718ad7b0a Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 26 Jun 2026 11:26:28 +0000 Subject: [PATCH 8/9] correct previous comits --- scripts/trigger_modelling_e2e_sqs.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/scripts/trigger_modelling_e2e_sqs.py b/scripts/trigger_modelling_e2e_sqs.py index 521359c4..0733a941 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] @@ -151,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: @@ -162,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, } ), From 7f552edc0dba042b27dc8e4f49d97e3031624bf7 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 26 Jun 2026 11:32:17 +0000 Subject: [PATCH 9/9] pass scenario id to already processed check --- scripts/trigger_modelling_e2e_sqs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/trigger_modelling_e2e_sqs.py b/scripts/trigger_modelling_e2e_sqs.py index 0733a941..e0d060d9 100644 --- a/scripts/trigger_modelling_e2e_sqs.py +++ b/scripts/trigger_modelling_e2e_sqs.py @@ -108,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}" )