Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/optimizer/optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
23 changes: 23 additions & 0 deletions tests/test_objective_split.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down