diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index 44c4f0bfe..7f0f5b5c3 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -82,6 +82,15 @@ def objective_scale(objective) -> float: # those requests but no money, see the fallback in _solve_preferences. COST_BOUND_SLACK = 1e-5 +# ceiling for the slack ladder in _solve_preferences. CBC declares the pinned tie break LP +# infeasible on some production splits over a bound its own incumbent satisfies: the model's +# big M rows put CBC's solve-time perturbation orders above any hundredth-of-a-cent slack, and +# no algorithm switch helps (primal, dual, barrier and perturbation-off all agree). Measured on +# two captured requests, feasibility returns at 3e-4 and 1e-3. Each retry multiplies the slack +# by ten, so the ceiling is reached in three; it equals the default gap_abs, money the cost +# stage may already have left on the table, and the alternative is no tie break at all. +COST_BOUND_SLACK_CEILING = 1e-2 + # how far below the bound the check in _solve_preferences still accepts a solution. CBC treats # that row like any other, so it may miss it by its feasibility tolerance: measured at 1.2e-5 on # 024-attenuate-demand-peaks, where rejecting over it threw away a tie break worth four times the @@ -834,9 +843,20 @@ def keep(): # clock says and the strategies get something even when the search below never starts. pinned = self._pin_integers() try: + slack = COST_BOUND_SLACK with self._timed('tie_break'): self.problem.solve(self._solver(tmpdir, timeLimit=LP_PREFERENCE_TIME_LIMIT)) - stages.append('LP ' + pulp.LpStatus[self.problem.status] + ('' if keep() else ' unused')) + # CBC declares this LP infeasible on ~6% of production splits, over a bound the + # incumbent it just returned satisfies, see COST_BOUND_SLACK_CEILING. Walk the + # slack up until CBC can hold the row; keep() follows via budget. + while (pulp.LpStatus[self.problem.status] == 'Infeasible' + and slack < COST_BOUND_SLACK_CEILING): + slack *= 10 + budget = self.settings.preference_budget + slack + self.problem.constraints[COST_BOUND].constant = -(cost - budget) + self.problem.solve(self._solver(tmpdir, timeLimit=LP_PREFERENCE_TIME_LIMIT)) + label = 'LP' if slack == COST_BOUND_SLACK else f'LP (slack {slack:g})' + stages.append(label + ' ' + pulp.LpStatus[self.problem.status] + ('' if keep() else ' unused')) finally: self._unpin_integers(pinned) diff --git a/tests/test_objective_split.py b/tests/test_objective_split.py index de11adca6..3db9a098b 100644 --- a/tests/test_objective_split.py +++ b/tests/test_objective_split.py @@ -48,6 +48,29 @@ def solve_cost_only(optimizer): return optimizer +def test_the_lp_floor_relaxes_a_bound_cbc_reports_infeasible(monkeypatch): + # production: CBC declares the pinned LP infeasible over a cost bound the incumbent it just + # returned satisfies -- the model's big M rows put the solver's perturbation orders above the + # slack. The floor must walk COST_BOUND_SLACK up rather than give up the tie break. + optimizer = solve_cost_only(build('026-attenuate-grid-peaks')) + calls = {'n': 0} + real_solve = pulp.LpProblem.solve + + def infeasible_twice(self, solver): + result = real_solve(self, solver) + calls['n'] += 1 + if calls['n'] <= 2: + self.status = pulp.LpStatusInfeasible + return result + + monkeypatch.setattr(pulp.LpProblem, 'solve', infeasible_twice) + with TemporaryDirectory() as tmpdir: + optimizer._solve_preferences(tmpdir, None) + + # two refusals walk the slack from 1e-5 over 1e-4 to 1e-3, and the tie break still lands + assert 'LP (slack 0.001) Optimal' in optimizer.preference_stage + + @pytest.mark.parametrize('case', CASES) def test_objective_split_covers_the_whole_objective(case): # the split must stay exhaustive: cost plus preferences has to equal what a single solve would