Merge pull request #1431 from Hestia-Homes/fix/refetch-solar-reuse-stored

Fix refetch_solar=False dropping solar from re-modelled plans (reuse stored Solar instead)
This commit is contained in:
Jun-te Kim 2026-07-02 15:27:44 +01:00 committed by GitHub
commit cf50c53cad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 115 additions and 13 deletions

View file

@ -512,12 +512,11 @@ def handler(
# held-open read Session. (The ``finally`` is the safety net.)
scenario = ScenarioPostgresRepository(read_session).get_many([scenario_id])[0]
products = catalogue_snapshot_with_off_catalogue_overrides(read_session)
# Stored Solar is read whatever refetch_solar says — the flag decides
# whether a UPRN with no stored row gets a live Google fetch, never
# whether solar is modelled at all.
stored_solar: dict[int, Optional[dict[str, Any]]] = (
{}
if not refetch_solar
else SolarPostgresRepository(read_session).get_many(
list(set(uprns.values()))
)
SolarPostgresRepository(read_session).get_many(list(set(uprns.values())))
)
epc_repo = EpcPostgresRepository(read_session)
stored_lodged_epcs: dict[int, EpcPropertyData] = (
@ -615,15 +614,16 @@ def handler(
landlord_overrides=overrides,
).effective_epc
solar_insights: Optional[dict[str, Any]]
# refetch_solar gates ONLY the paid Google call for UPRNs with no
# stored row; stored insights always feed the Modelling stage.
# (False here once meant "no solar at all" — the pre-rename
# `no_solar` semantics — which silently stripped solar from every
# plan in a re-model batch.)
solar_insights: Optional[dict[str, Any]] = stored_solar.get(uprn)
solar_was_fetched = False
if not refetch_solar:
solar_insights = None
else:
solar_insights = stored_solar.get(uprn)
if solar_insights is None:
solar_insights = _solar_insights_for(solar_client, spatial)
solar_was_fetched = solar_insights is not None
if solar_insights is None and refetch_solar:
solar_insights = _solar_insights_for(solar_client, spatial)
solar_was_fetched = solar_insights is not None
plan = run_modelling(
effective_epc,

View file

@ -124,6 +124,18 @@ def _baseline_orchestrator() -> Iterator[MagicMock]:
yield orchestrator
@pytest.fixture(autouse=True)
def _solar_repository() -> Iterator[MagicMock]:
"""Patch the stored-Solar repository for every test — the handler reads
stored insights through it whatever refetch_solar says. Default: no stored
rows; solar tests override get_many with real payloads."""
with patch(
"applications.modelling_e2e.handler.SolarPostgresRepository"
) as repository:
repository.return_value.get_many.return_value = {}
yield repository
# ---------------------------------------------------------------------------
# Solar throttle: per-container call gap resolved from env (quota/N safety)
# ---------------------------------------------------------------------------
@ -208,6 +220,96 @@ def test_trigger_body_new_flags_default_to_true() -> None:
assert result.refetch_solar is True
# ---------------------------------------------------------------------------
# refetch_solar semantics — False skips the LIVE fetch only, never the measure
# ---------------------------------------------------------------------------
def test_refetch_solar_false_models_with_stored_solar_and_never_calls_google(
_solar_repository: MagicMock,
) -> None:
"""refetch_solar=False means "don't pay for a Google call", NOT "model
without solar": the stored Solar insights are read from the DB and handed to
the Modelling stage, and the live Google client is never invoked.
Provenance: portfolio 814's 2026-07-02 re-model ran with refetch_solar=False
and every plan lost its solar candidates (pids 742071/742077/742397/742399
houses stopped short of goal B with bill-negative ASHP-only plans) even
though the `solar` table held healthy payloads for their UPRNs."""
# Arrange
stored_insights = {"solarPotential": {"maxArrayPanelsCount": 58}}
mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE])
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 = MagicMock()
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)
)
mock_live_fetch = 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=_plan_mock(),
)
)
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
_solar_repository.return_value.get_many.return_value = {
UPRN: stored_insights
}
# Act — _BODY carries refetch_solar=False
_call_handler(_BODY)
# Assert — the stored insights reach the Modelling stage unchanged, and the
# paid Google leg is never exercised.
assert (
mock_run_modelling.call_args.kwargs["solar_insights"] == stored_insights
)
mock_live_fetch.assert_not_called()
# ---------------------------------------------------------------------------
# Child SubTask creation
# ---------------------------------------------------------------------------