diff --git a/.changeset/skip-converged-ph-iterations.md b/.changeset/skip-converged-ph-iterations.md new file mode 100644 index 000000000..f799d0d6d --- /dev/null +++ b/.changeset/skip-converged-ph-iterations.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Stop progressive hedging after its initial scenario solves when their decisions already meet the configured residual tolerance. This avoids a redundant solver iteration that could exhaust the time limit and discard a valid plan. diff --git a/optimizer/ftw_optimizer/progressive.py b/optimizer/ftw_optimizer/progressive.py index bd3664e04..2233fc471 100644 --- a/optimizer/ftw_optimizer/progressive.py +++ b/optimizer/ftw_optimizer/progressive.py @@ -113,6 +113,8 @@ def solve_progressive_hedging( iterations = 0 for iteration in range(1, max_iterations + 1): + if residual_w <= tolerance_w: + break iterations = iteration for si, subproblem in enumerate(subproblems): subproblem.consensus_kw.value = consensus[si] @@ -129,8 +131,6 @@ def solve_progressive_hedging( residual_w = _nonanticipativity_residual_w(prepared, decisions, consensus) for si, subproblem in enumerate(subproblems): dual[si] += subproblem.consensus_mask * (decisions[si] - consensus[si]) - if residual_w <= tolerance_w: - break solver_ms = (time.perf_counter() - solver_started) * 1000.0 if residual_w > tolerance_w: raise ProgressiveHedgingNotConverged( diff --git a/optimizer/tests/test_model.py b/optimizer/tests/test_model.py index e1def1015..a849ef867 100644 --- a/optimizer/tests/test_model.py +++ b/optimizer/tests/test_model.py @@ -1957,6 +1957,54 @@ def test_multistage_uses_progressive_hedging_only_for_eligible_large_convex_case assert response["solver"]["ph_residual_w"] <= 10 +def test_progressive_hedging_skips_iteration_for_converged_initial_solution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from ftw_optimizer import progressive + + request = base_request() + request["request_id"] = "ph-initial-consensus" + request["slots"] = request["slots"][:1] + request["settings"].update( + { + "scenario_policy": "multistage", + "formulation": "relaxed", + "decomposition_method": "progressive_hedging", + "ph_max_iterations": 4, + "ph_tolerance_w": 5, + } + ) + request["scenarios"] = [ + { + "id": "base", + "probability": 1, + "load_w": [500], + "pv_w": [0], + } + ] + + original_solve = progressive._solve_problem + solve_calls = 0 + + def solve_once(*args, **kwargs) -> None: + nonlocal solve_calls + solve_calls += 1 + if solve_calls > 1: + raise AssertionError("converged initial PH solution ran another solve") + original_solve(*args, **kwargs) + + monkeypatch.setattr(progressive, "_solve_problem", solve_once) + + response = handle(request) + + assert response["ok"], response + assert solve_calls == 1 + assert response["solver"]["status"] == "optimal-ph" + assert response["solver"]["ph_iterations"] == 0 + assert response["solver"]["ph_residual_w"] == pytest.approx(0) + assert_storage_replays(request, response) + + def test_progressive_hedging_refuses_discrete_mode() -> None: request = base_request() request["settings"].update(