From 1b2d68b9d20eff7b4d642dcaaa1fcf0feee8bc2d Mon Sep 17 00:00:00 2001 From: ZedongPeng Date: Mon, 7 Sep 2026 19:04:24 -0400 Subject: [PATCH 1/2] MindtPy: maintain a valid dual bound in each iteration when no-good cuts are used --- pyomo/contrib/mindtpy/algorithm_base_class.py | 111 +++--------------- .../contrib/mindtpy/extended_cutting_plane.py | 1 - .../mindtpy/global_outer_approximation.py | 31 ----- pyomo/contrib/mindtpy/outer_approximation.py | 8 -- pyomo/contrib/mindtpy/single_tree.py | 4 - 5 files changed, 17 insertions(+), 138 deletions(-) diff --git a/pyomo/contrib/mindtpy/algorithm_base_class.py b/pyomo/contrib/mindtpy/algorithm_base_class.py index 11b34e47d8e..94538e20808 100644 --- a/pyomo/contrib/mindtpy/algorithm_base_class.py +++ b/pyomo/contrib/mindtpy/algorithm_base_class.py @@ -143,9 +143,6 @@ def __init__(self, **kwds): self.best_solution_found = None self.best_solution_found_time = None - self.stored_bound = {} - self.num_no_good_cuts_added = {} - self.last_iter_cuts = False # Store the OA cuts generated in the mip_start_process. self.mip_start_lazy_oa_cuts = [] # Whether to load solutions in solve() function @@ -800,6 +797,20 @@ def update_dual_bound(self, bound_value): """ if math.isnan(bound_value): return + # The no-good cuts (or tabu list) only exclude the integer combinations that + # have already been explored, i.e., whose fixed NLP subproblems have been + # solved to (global) optimality or shown to be infeasible. Therefore, although + # the main problem with no-good cuts is no longer a relaxation of the original + # MINLP, its optimal objective value remains a valid dual bound for all the + # unexplored integer combinations, while the incumbent (primal bound) is the + # exact optimum over the explored ones. Hence, in each iteration, a valid dual + # bound for the original problem is the worse of the primal bound and the bound + # obtained from the main problem with the no-good cuts. + if self.config.add_no_good_cuts or self.config.use_tabu_list: + if self.objective_sense == minimize: + bound_value = min(self.primal_bound, bound_value) + else: + bound_value = max(self.primal_bound, bound_value) if self.objective_sense == minimize: self.dual_bound = max(bound_value, self.dual_bound) self.dual_bound_improved = self.dual_bound > self.dual_bound_progress[-1] @@ -1781,82 +1792,6 @@ def algorithm_should_terminate(self, check_cycling): or (check_cycling and self.iteration_cycling()) ) - def fix_dual_bound(self, last_iter_cuts): - """Fix the dual bound when no-good cuts or tabu list is activated. - - Parameters - ---------- - last_iter_cuts : bool - Whether the cuts in the last iteration have been added. - """ - # If no-good cuts or tabu list is activated, the dual bound is not valid for the final optimal solution. - # Therefore, we need to correct it at the end. - # In singletree implementation, the dual bound at one iteration before the optimal solution, is valid for the optimal solution. - # So we will set the dual bound to it. - config = self.config - if config.single_tree: - config.logger.info( - 'Fix the bound to the value of one iteration before optimal solution is found.' - ) - try: - self.dual_bound = self.stored_bound[self.primal_bound] - except KeyError as e: - config.logger.error(e, exc_info=True) - config.logger.error('No stored bound found. Bound fix failed.') - else: - config.logger.info( - 'Solve the main problem without the last no_good cut to fix the bound.' - 'zero_tolerance is set to 1E-4' - ) - config.zero_tolerance = 1e-4 - # Solve NLP subproblem - # The constraint linearization happens in the handlers - if not last_iter_cuts: - fixed_nlp, fixed_nlp_result = self.solve_subproblem() - self.handle_nlp_subproblem_tc(fixed_nlp, fixed_nlp_result) - - MindtPy = self.mip.MindtPy_utils - # Deactivate the integer cuts generated after the best solution was found. - self.deactivate_no_good_cuts_when_fixing_bound(MindtPy.cuts.no_good_cuts) - if ( - config.add_regularization is not None - and MindtPy.component('mip_obj') is None - ): - MindtPy.objective_list[-1].activate() - # determine if persistent solver is called. - if isinstance(self.mip_opt, PersistentSolver): - self.mip_opt.set_instance(self.mip, symbolic_solver_labels=True) - mip_args = dict(config.mip_solver_args) - update_solver_timelimit( - self.mip_opt, config.mip_solver, self.timing, config - ) - main_mip_results = self.mip_opt.solve( - self.mip, - tee=config.mip_solver_tee, - load_solutions=self.mip_load_solutions, - **mip_args, - ) - if len(main_mip_results.solution) > 0: - self.mip.solutions.load_from(main_mip_results) - - if main_mip_results.solver.termination_condition is tc.infeasible: - config.logger.info( - 'Bound fix failed. The bound fix problem is infeasible' - ) - else: - self.update_suboptimal_dual_bound(main_mip_results) - config.logger.info( - 'Fixed bound values: Primal Bound: {} Dual Bound: {}'.format( - self.primal_bound, self.dual_bound - ) - ) - # Check bound convergence - if ( - abs(self.primal_bound - self.dual_bound) - <= config.absolute_bound_tolerance - ): - self.results.solver.termination_condition = tc.optimal - def set_up_tabulist_callback(self): """Sets up the tabulist using IncumbentCallback. Currently only support CPLEX. @@ -1952,7 +1887,9 @@ def solve_main(self): self.mip_opt._pyomo_var_to_solver_var_map ) if main_mip_results.solver.termination_condition is tc.optimal: - if config.single_tree and not config.add_no_good_cuts: + if config.single_tree: + # With no-good cuts or tabu list, the bound of the B&B tree is + # clamped to the primal bound in update_dual_bound. self.update_suboptimal_dual_bound(main_mip_results) elif main_mip_results.solver.termination_condition is tc.infeasibleOrUnbounded: # Linear solvers will sometimes tell me that it's infeasible or @@ -3131,7 +3068,6 @@ def handle_main_mip_termination(self, main_mip, main_mip_results): self.handle_main_optimal(main_mip) elif main_mip_results.solver.termination_condition is tc.infeasible: self.handle_main_infeasible() - self.last_iter_cuts = True should_terminate = True elif main_mip_results.solver.termination_condition is tc.unbounded: temp_results = self.handle_main_unbounded(main_mip) @@ -3236,7 +3172,6 @@ def MindtPy_iteration_loop(self): self.handle_nlp_subproblem_tc(fixed_nlp, fixed_nlp_result) if self.algorithm_should_terminate(check_cycling=True): - self.last_iter_cuts = False break if not config.single_tree: # if we don't use lazy callback, i.e. LP_NLP @@ -3255,7 +3190,6 @@ def MindtPy_iteration_loop(self): config.call_after_subproblem_solve(fixed_nlp) if self.algorithm_should_terminate(check_cycling=False): - self.last_iter_cuts = True break else: solution_name_obj = self.get_solution_name_obj(main_mip_results) @@ -3292,19 +3226,8 @@ def MindtPy_iteration_loop(self): config.call_after_subproblem_solve(fixed_nlp) if self.algorithm_should_terminate(check_cycling=False): - self.last_iter_cuts = True break # TODO: break two loops. - # if add_no_good_cuts is True, the bound obtained in the last iteration is no reliable. - # we correct it after the iteration. - # There is no need to fix the dual bound if no feasible solution has been found. - if ( - (config.add_no_good_cuts or config.use_tabu_list) - and not self.should_terminate - and config.add_regularization is None - and self.best_solution_found is not None - ): - self.fix_dual_bound(self.last_iter_cuts) config.logger.info( ' ===============================================================================================' ) diff --git a/pyomo/contrib/mindtpy/extended_cutting_plane.py b/pyomo/contrib/mindtpy/extended_cutting_plane.py index a209125fcd0..e42dc00c2eb 100644 --- a/pyomo/contrib/mindtpy/extended_cutting_plane.py +++ b/pyomo/contrib/mindtpy/extended_cutting_plane.py @@ -59,7 +59,6 @@ def MindtPy_iteration_loop(self): self.config.call_after_main_solve(main_mip) if self.algorithm_should_terminate(): - self.last_iter_cuts = False break add_ecp_cuts(self.mip, self.jacobians, self.config, self.timing) diff --git a/pyomo/contrib/mindtpy/global_outer_approximation.py b/pyomo/contrib/mindtpy/global_outer_approximation.py index 9f3f70bde10..13b604ba48e 100644 --- a/pyomo/contrib/mindtpy/global_outer_approximation.py +++ b/pyomo/contrib/mindtpy/global_outer_approximation.py @@ -10,7 +10,6 @@ # ____________________________________________________________________________________ -from pyomo.contrib.gdpopt.util import get_main_elapsed_time from pyomo.core import ConstraintList from pyomo.opt import SolverFactory from pyomo.contrib.mindtpy.config_options import _get_MindtPy_GOA_config @@ -69,24 +68,6 @@ def initialize_mip_problem(self): super().initialize_mip_problem() self.mip.MindtPy_utils.cuts.aff_cuts = ConstraintList(doc='Affine cuts') - def update_primal_bound(self, bound_value): - """Update the primal bound. - - Call after solve fixed NLP subproblem. - Use the optimal primal bound of the relaxed problem to update the dual bound. - - Parameters - ---------- - bound_value : float - The input value used to update the primal bound. - """ - super().update_primal_bound(bound_value) - self.primal_bound_progress_time.append(get_main_elapsed_time(self.timing)) - if self.primal_bound_improved: - self.num_no_good_cuts_added.update( - {self.primal_bound: len(self.mip.MindtPy_utils.cuts.no_good_cuts)} - ) - def add_cuts( self, dual_values=None, @@ -96,15 +77,3 @@ def add_cuts( nlp=None, ): add_affine_cuts(self.mip, self.config, self.timing) - - def deactivate_no_good_cuts_when_fixing_bound(self, no_good_cuts): - try: - valid_no_good_cuts_num = self.num_no_good_cuts_added[self.primal_bound] - if self.config.add_no_good_cuts: - for i in range(valid_no_good_cuts_num + 1, len(no_good_cuts) + 1): - no_good_cuts[i].deactivate() - if self.config.use_tabu_list: - self.integer_list = self.integer_list[:valid_no_good_cuts_num] - except KeyError as e: - self.config.logger.error(e, exc_info=True) - self.config.logger.error('Deactivating no-good cuts failed.') diff --git a/pyomo/contrib/mindtpy/outer_approximation.py b/pyomo/contrib/mindtpy/outer_approximation.py index cdd54b4a1f5..b33efe721ff 100644 --- a/pyomo/contrib/mindtpy/outer_approximation.py +++ b/pyomo/contrib/mindtpy/outer_approximation.py @@ -128,14 +128,6 @@ def add_cuts( self.mip, nlp, self.config, self.objective_sense, self.mip_iter, cb_opt ) - def deactivate_no_good_cuts_when_fixing_bound(self, no_good_cuts): - # Only deactivate the last OA cuts may not be correct. - # Since integer solution may also be cut off by OA cuts due to calculation approximation. - if self.config.add_no_good_cuts: - no_good_cuts[len(no_good_cuts)].deactivate() - if self.config.use_tabu_list: - self.integer_list = self.integer_list[:-1] - def objective_reformulation(self): # In the process_objective function, as long as the objective function is nonlinear, it will be reformulated and the variable/constraint/objective lists will be updated. # For OA/GOA/LP-NLP algorithm, if the objective function is linear, it will not be reformulated as epigraph constraint. diff --git a/pyomo/contrib/mindtpy/single_tree.py b/pyomo/contrib/mindtpy/single_tree.py index d42d947baa5..b8a06567e0c 100644 --- a/pyomo/contrib/mindtpy/single_tree.py +++ b/pyomo/contrib/mindtpy/single_tree.py @@ -506,10 +506,6 @@ def handle_lazy_subproblem_optimal(self, fixed_nlp, mindtpy_solver, config, opt) mindtpy_solver.best_solution_found_time = get_main_elapsed_time( mindtpy_solver.timing ) - if config.add_no_good_cuts or config.use_tabu_list: - mindtpy_solver.stored_bound.update( - {mindtpy_solver.primal_bound: mindtpy_solver.dual_bound} - ) config.logger.info( mindtpy_solver.fixed_nlp_log_formatter.format( '*' if mindtpy_solver.primal_bound_improved else ' ', From a7a91b1fd98e450bbdc9957e0fae2c48bedcd1f3 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Mon, 7 Sep 2026 22:49:33 -0400 Subject: [PATCH 2/2] Preserve MindtPy bounds for exhausted and unresolved assignments Close the bound gap when certified integer exclusions exhaust the main problem, including single-tree solves. Retain the preceding dual bound after an unresolved fixed NLP so exclusions cannot certify false optimality or infeasibility. Add solver-backed regressions for both objective senses and document the assumptions behind the bound calculation. --- .../explanation/solvers/mindtpy.rst | 4 +- pyomo/contrib/mindtpy/algorithm_base_class.py | 66 +++++--- .../mindtpy/tests/test_mindtpy_bounds.py | 142 ++++++++++++++++++ 3 files changed, 194 insertions(+), 18 deletions(-) create mode 100644 pyomo/contrib/mindtpy/tests/test_mindtpy_bounds.py diff --git a/doc/OnlineDocs/explanation/solvers/mindtpy.rst b/doc/OnlineDocs/explanation/solvers/mindtpy.rst index 99fc2b403c0..55fa97d2886 100644 --- a/doc/OnlineDocs/explanation/solvers/mindtpy.rst +++ b/doc/OnlineDocs/explanation/solvers/mindtpy.rst @@ -270,7 +270,9 @@ MindtPy provides two ways to guarantee the finite convergence of the algorithm. Bound Calculation """"""""""""""""" -Since no-good cuts or tabu list is applied in the Global Outer-Approximation (GOA) method, the MILP master problem cannot provide a valid bound for the original problem. After the GOA method has converged, MindtPy will remove the no-good cuts or the tabu integer combinations added when and after the optimal solution has been found. Solving this problem will give us a valid bound for the original problem. +With no-good cuts or a tabu list, the MILP master problem bounds only the remaining integer assignments. MindtPy combines that bound with the incumbent objective: it takes the minimum for minimization, or the maximum for maximization. If no feasible assignments remain in the master problem, the incumbent gives both bounds and MindtPy terminates as optimal. No additional master solve is required. + +This calculation assumes that every excluded fixed NLP was solved to optimality or proven infeasible, and that the master problem provides a valid relaxation. OA requires model convexity; GOA requires global NLP solves. A local optimum is sufficient under the convex algorithms' assumptions, but not for GOA. If a fixed NLP instead reports a merely feasible solution, no solution, or a solver limit, MindtPy retains the dual bound obtained before that assignment was excluded. Exhausting the remaining assignments then reports a feasible solution (or no solution if there is no incumbent), without claiming optimality or infeasibility from the exhausted master alone. Using OA as a heuristic on a nonconvex model does not guarantee valid bounds. The GOA method also has a single-tree implementation with ``cplex_persistent`` and ``gurobi_persistent``. Notice that this method is more computationally expensive than the other strategies implemented for convex MINLP like OA and ECP, which can be used as heuristics for nonconvex MINLP problems. diff --git a/pyomo/contrib/mindtpy/algorithm_base_class.py b/pyomo/contrib/mindtpy/algorithm_base_class.py index 94538e20808..1343a25add8 100644 --- a/pyomo/contrib/mindtpy/algorithm_base_class.py +++ b/pyomo/contrib/mindtpy/algorithm_base_class.py @@ -105,6 +105,7 @@ def __init__(self, **kwds): self.curr_int_sol = [] self.should_terminate = False self.integer_list = [] + self._integer_exclusion_bound_valid = True # Dictionary {integer solution (tuple): [cuts begin index, cuts end index] (list)} self.integer_solution_to_cuts_index = dict() @@ -797,16 +798,16 @@ def update_dual_bound(self, bound_value): """ if math.isnan(bound_value): return - # The no-good cuts (or tabu list) only exclude the integer combinations that - # have already been explored, i.e., whose fixed NLP subproblems have been - # solved to (global) optimality or shown to be infeasible. Therefore, although - # the main problem with no-good cuts is no longer a relaxation of the original - # MINLP, its optimal objective value remains a valid dual bound for all the - # unexplored integer combinations, while the incumbent (primal bound) is the - # exact optimum over the explored ones. Hence, in each iteration, a valid dual - # bound for the original problem is the worse of the primal bound and the bound - # obtained from the main problem with the no-good cuts. + # Under the algorithm's assumptions (convexity for OA, globally solved + # subproblems for GOA), the incumbent bounds the optimum over excluded + # assignments only if their NLPs were solved to optimality or infeasibility. + # The main problem bounds the remaining assignments, so combine its bound + # with the incumbent. If an excluded NLP was unresolved, retain the last + # bound obtained before that exclusion instead. if self.config.add_no_good_cuts or self.config.use_tabu_list: + if not self._integer_exclusion_bound_valid: + self.dual_bound_improved = False + return if self.objective_sense == minimize: bound_value = min(self.primal_bound, bound_value) else: @@ -995,6 +996,7 @@ def set_up_solve_data(self, model): The original model to be solved in MindtPy. """ config = self.config + self._integer_exclusion_bound_valid = True self.original_model = model obj, objective_count, dummy_name = self._get_main_objective( model, logger=config.logger @@ -1385,6 +1387,22 @@ def solve_subproblem(self): TransformationFactory('contrib.deactivate_trivial_constraints').revert( self.fixed_nlp ) + # Both the ordinary loop and the single-tree callbacks solve fixed NLPs + # here. Record unresolved subproblems before either can exclude an integer + # assignment with a no-good cut or the tabu list. Local optimality suffices + # for the convex algorithms, but not for GOA. + if config.add_no_good_cuts or config.use_tabu_list: + termination = results.solver.termination_condition + if termination not in {tc.optimal, tc.infeasible} and not ( + termination is tc.locallyOptimal and config.strategy != 'GOA' + ): + if self._integer_exclusion_bound_valid: + config.logger.info( + 'Fixed NLP subproblem terminated with %s. Retaining the ' + 'last dual bound before excluding unresolved assignments.', + termination, + ) + self._integer_exclusion_bound_valid = False return self.fixed_nlp, results def handle_nlp_subproblem_tc(self, fixed_nlp, result, cb_opt=None): @@ -2084,19 +2102,33 @@ def handle_main_infeasible(self): self.config.logger.warning( 'MindtPy initialization may have generated poor quality cuts.' ) - # TODO no-good cuts for single tree case - # set optimistic bound to infinity self.config.logger.info( 'MindtPy exiting due to MILP main problem infeasibility.' ) if self.results.solver.termination_condition is None: + has_integer_exclusions = ( + self.config.add_no_good_cuts or self.config.use_tabu_list + ) + if ( + has_integer_exclusions + and self._integer_exclusion_bound_valid + and math.isfinite(self.primal_bound) + ): + # The remaining assignments are infeasible. The incumbent is + # therefore optimal over all assignments, including the excluded + # ones, without another main problem solve. + self.update_dual_bound(self.primal_bound) + if self.bounds_converged(): + return if ( self.primal_bound == float('inf') and self.objective_sense == minimize ) or ( self.primal_bound == float('-inf') and self.objective_sense == maximize ): - # if self.mip_iter == 0: - self.results.solver.termination_condition = tc.infeasible + if has_integer_exclusions and not self._integer_exclusion_bound_valid: + self.results.solver.termination_condition = tc.noSolution + else: + self.results.solver.termination_condition = tc.infeasible else: self.results.solver.termination_condition = tc.feasible @@ -3063,12 +3095,12 @@ def objective_reformulation(self): def handle_main_mip_termination(self, main_mip, main_mip_results): should_terminate = False if main_mip_results is not None: - if not self.config.single_tree: + if main_mip_results.solver.termination_condition is tc.infeasible: + self.handle_main_infeasible() + should_terminate = True + elif not self.config.single_tree: if main_mip_results.solver.termination_condition is tc.optimal: self.handle_main_optimal(main_mip) - elif main_mip_results.solver.termination_condition is tc.infeasible: - self.handle_main_infeasible() - should_terminate = True elif main_mip_results.solver.termination_condition is tc.unbounded: temp_results = self.handle_main_unbounded(main_mip) elif ( diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_bounds.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_bounds.py new file mode 100644 index 00000000000..b663853602e --- /dev/null +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_bounds.py @@ -0,0 +1,142 @@ +# ____________________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and Engineering +# Solutions of Sandia, LLC, the U.S. Government retains certain rights in this +# software. This software is distributed under the 3-clause BSD License. +# ____________________________________________________________________________________ + +"""Check the result when a no-good cut exhausts feasible integer assignments.""" + +import pyomo.common.unittest as unittest +import pyomo.environ as pyo + + +@unittest.skipUnless( + all( + pyo.SolverFactory(solver).available(exception_flag=False) + for solver in ('glpk', 'ipopt') + ), + 'GLPK and IPOPT are required', +) +class TestExhaustedAssignments(unittest.TestCase): + def _solve(self, sense, mip_solver='glpk', single_tree=False, use_tabu_list=False): + # Both models are convex optimization problems with one feasible binary + # assignment and a known optimum of +1 (minimize) or -1 (maximize). + model = pyo.ConcreteModel() + model.y = pyo.Var(domain=pyo.Binary, initialize=0) + model.x = pyo.Var(bounds=(1, 2), initialize=1.5) + model.only_assignment = pyo.Constraint(expr=model.y <= 0) + model.objective = pyo.Objective( + expr=int(sense) * (model.x**2 + model.y), sense=sense + ) + results = pyo.SolverFactory('mindtpy').solve( + model, + strategy='OA', + mip_solver=mip_solver, + nlp_solver='ipopt', + init_strategy='initial_binary', + add_no_good_cuts=not use_tabu_list, + use_tabu_list=use_tabu_list, + single_tree=single_tree, + use_fbbt=False, + ) + self.assertAlmostEqual(pyo.value(model.objective), int(sense), places=6) + self.assertEqual( + results.solver.termination_condition, pyo.TerminationCondition.optimal + ) + self.assertAlmostEqual(results.problem.lower_bound, int(sense), places=6) + self.assertAlmostEqual(results.problem.upper_bound, int(sense), places=6) + + def test_minimize(self): + self._solve(pyo.minimize) + + def test_maximize(self): + self._solve(pyo.maximize) + + @unittest.skipUnless( + pyo.SolverFactory('cplex_persistent').available(exception_flag=False), + 'CPLEX is required', + ) + def test_cplex_exclusions(self): + for sense in (pyo.minimize, pyo.maximize): + for single_tree in (False, True): + for use_tabu_list in (False, True): + with self.subTest( + sense=sense, + single_tree=single_tree, + use_tabu_list=use_tabu_list, + ): + self._solve( + sense, + mip_solver='cplex_persistent', + single_tree=single_tree, + use_tabu_list=use_tabu_list, + ) + + def test_unsolved_assignment_is_not_infeasible(self): + model = pyo.ConcreteModel() + model.y = pyo.Var(domain=pyo.Binary, initialize=0) + model.x = pyo.Var(bounds=(1, 2), initialize=1.5) + model.only_assignment = pyo.Constraint(expr=model.y <= 0) + model.objective = pyo.Objective(expr=model.x**2 + model.y) + results = pyo.SolverFactory('mindtpy').solve( + model, + mip_solver='glpk', + nlp_solver='ipopt', + nlp_solver_args={'options': {'max_iter': 0}}, + init_strategy='initial_binary', + add_no_good_cuts=True, + use_fbbt=False, + ) + self.assertEqual( + results.solver.termination_condition, pyo.TerminationCondition.noSolution + ) + self.assertEqual(results.problem.lower_bound, -float('inf')) + self.assertEqual(results.problem.upper_bound, float('inf')) + + def test_unsolved_assignment_does_not_close_gap(self): + for sense in (pyo.minimize, pyo.maximize): + with self.subTest(sense=sense), pyo.SolverFactory('mindtpy.oa') as opt: + model = pyo.ConcreteModel() + model.y = pyo.Var(domain=pyo.Binary, initialize=1) + model.z = pyo.Var(domain=pyo.Binary, initialize=0) + model.x = pyo.Var(bounds=(1, 2), initialize=1.5) + model.assignments = pyo.Constraint(expr=model.y + model.z <= 1) + model.objective = pyo.Objective( + expr=int(sense) * (model.x**2 + 2 * model.y + model.z), sense=sense + ) + limited_assignments = [] + + def limit_best_assignment(fixed_nlp): + # Interrupt the NLP for the true best assignment (0, 0). + # The other two assignments can still produce incumbents. + best_assignment = ( + pyo.value(fixed_nlp.y) + pyo.value(fixed_nlp.z) < 0.5 + ) + opt.nlp_opt.options['max_iter'] = 0 if best_assignment else 1000 + if best_assignment: + limited_assignments.append((0, 0)) + + results = opt.solve( + model, + mip_solver='glpk', + nlp_solver='ipopt', + init_strategy='initial_binary', + add_no_good_cuts=True, + use_fbbt=False, + call_before_subproblem_solve=limit_best_assignment, + ) + self.assertEqual(limited_assignments, [(0, 0)]) + self.assertAlmostEqual( + pyo.value(model.objective), 2 * int(sense), places=6 + ) + self.assertEqual( + results.solver.termination_condition, + pyo.TerminationCondition.feasible, + ) + # The reported interval must still contain the true optimum, + # even though that assignment was excluded after the NLP limit. + self.assertLessEqual(results.problem.lower_bound, int(sense) + 1e-6) + self.assertGreaterEqual(results.problem.upper_bound, int(sense) - 1e-6)