Merge pull request #1335 from Hestia-Homes/feature/e2e-modelling-trigger-optional-refetch

Configure whether lodged EPCs are re-fetched and predicted EPCs are re-predicted during e2e modelling
This commit is contained in:
Jun-te Kim 2026-06-26 13:29:55 +01:00 committed by GitHub
commit 6cbfa5c0ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 499 additions and 33 deletions

View file

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

View file

@ -15,7 +15,7 @@ payload = {
"property_ids": [727220, 727229],
"portfolio_id": 796,
"scenario_id": 1268,
"no_solar": False,
"refetch_solar": True,
"dry_run": True,
}
)

View file

@ -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

View file

@ -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

View file

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

View file

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