mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
commit
6cbfa5c0ad
6 changed files with 499 additions and 33 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ payload = {
|
|||
"property_ids": [727220, 727229],
|
||||
"portfolio_id": 796,
|
||||
"scenario_id": 1268,
|
||||
"no_solar": False,
|
||||
"refetch_solar": True,
|
||||
"dry_run": True,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue