diff --git a/pyomo/contrib/gdpopt/branch_and_bound.py b/pyomo/contrib/gdpopt/branch_and_bound.py index afabdc39123..2ce38b794df 100644 --- a/pyomo/contrib/gdpopt/branch_and_bound.py +++ b/pyomo/contrib/gdpopt/branch_and_bound.py @@ -12,7 +12,11 @@ import traceback from pyomo.common.collections import ComponentMap -from pyomo.common.config import document_kwargs_from_configdict +from pyomo.common.config import ( + ConfigBlock, + ConfigValue, + document_kwargs_from_configdict, +) from pyomo.common.errors import InfeasibleConstraintException from pyomo.contrib.fbbt.fbbt import fbbt from pyomo.contrib.gdpopt.algorithm_base_class import _GDPoptAlgorithm @@ -32,6 +36,7 @@ _add_nlp_solve_configs, ) from pyomo.contrib.gdpopt.nlp_initialization import restore_vars_to_original_values +from pyomo.contrib.gdpopt.solve_subproblem import detect_unfixed_discrete_vars from pyomo.contrib.gdpopt.util import ( copy_var_list_values, SuppressInfeasibleWarning, @@ -76,6 +81,26 @@ class GDP_LBB_Solver(_GDPoptAlgorithm): CONFIG = _GDPoptAlgorithm.CONFIG() _add_mip_solver_configs(CONFIG) _add_nlp_solver_configs(CONFIG, default_solver='ipopt') + CONFIG.declare( + "relaxed_nlp_solver", + ConfigValue( + default=None, + description=""" + Continuous nonlinear solver to use for transformed LBB node + subproblems with no unfixed discrete variables. If unset, LBB uses + the relevant mixed-integer node solver to preserve existing global + bounding behavior.""", + ), + ) + CONFIG.declare( + "relaxed_nlp_solver_args", + ConfigBlock( + description=""" + Keyword arguments to send to the relaxed NLP subsolver solve() + invocation.""", + implicit=True, + ), + ) _add_nlp_solve_configs( CONFIG, default_nlp_init_method=restore_vars_to_original_values ) @@ -413,6 +438,40 @@ def _evaluate_node(self, node_data, node_model, config): ) return new_node_data + def _get_rnGDP_subproblem_solver(self, subproblem, config, discrete_solver_name): + unfixed_discrete_vars = detect_unfixed_discrete_vars(subproblem) + discrete_solver = getattr(config, discrete_solver_name) + discrete_solver_args_name = discrete_solver_name + "_args" + discrete_solver_args = dict(getattr(config, discrete_solver_args_name)) + if len(unfixed_discrete_vars) == 0: + if config.relaxed_nlp_solver is not None: + config.logger.debug( + "Transformed node subproblem has no unfixed discrete variables. " + "Solving with relaxed NLP solver %s." % config.relaxed_nlp_solver + ) + return config.relaxed_nlp_solver, dict(config.relaxed_nlp_solver_args) + else: + config.logger.debug( + "Transformed node subproblem has no unfixed discrete variables, " + "but relaxed_nlp_solver is not specified. Solving with " + "mixed-integer solver %s." % discrete_solver + ) + return discrete_solver, discrete_solver_args + else: + config.logger.debug( + "Transformed node subproblem has unfixed discrete variables: %s. " + "Solving with mixed-integer solver %s." + % (", ".join(v.name for v in unfixed_discrete_vars), discrete_solver) + ) + return discrete_solver, discrete_solver_args + + def _apply_rnGDP_subproblem_time_limit(self, solver_name, solver_args, config): + if config.time_limit is not None and solver_name == 'gams': + elapsed = get_main_elapsed_time(self.timing) + remaining = max(config.time_limit - elapsed, 1) + solver_args['add_options'] = solver_args.get('add_options', []) + solver_args['add_options'].append('option reslim=%s;' % remaining) + def _solve_rnGDP_subproblem(self, model, config): subproblem = TransformationFactory('gdp.bigm').create_using(model) obj_sense_correction = self.objective_sense != minimize @@ -432,15 +491,13 @@ def _solve_rnGDP_subproblem(self, model, config): ignore_integrality=True, ) return float('inf'), float('inf') - minlp_args = dict(config.minlp_solver_args) - if config.time_limit is not None and config.minlp_solver == 'gams': - elapsed = get_main_elapsed_time(self.timing) - remaining = max(config.time_limit - elapsed, 1) - minlp_args['add_options'] = minlp_args.get('add_options', []) - minlp_args['add_options'].append('option reslim=%s;' % remaining) - result = SolverFactory(config.minlp_solver).solve( - subproblem, **minlp_args + solver_name, solver_args = self._get_rnGDP_subproblem_solver( + subproblem, config, 'minlp_solver' + ) + self._apply_rnGDP_subproblem_time_limit( + solver_name, solver_args, config ) + result = SolverFactory(solver_name).solve(subproblem, **solver_args) except RuntimeError as e: config.logger.warning( "Solver encountered RuntimeError. Treating as infeasible. " @@ -533,9 +590,13 @@ def _solve_local_rnGDP_subproblem(self, model, config): try: with SuppressInfeasibleWarning(): - result = SolverFactory(config.local_minlp_solver).solve( - subproblem, **config.local_minlp_solver_args + solver_name, solver_args = self._get_rnGDP_subproblem_solver( + subproblem, config, 'local_minlp_solver' + ) + self._apply_rnGDP_subproblem_time_limit( + solver_name, solver_args, config ) + result = SolverFactory(solver_name).solve(subproblem, **solver_args) except RuntimeError as e: config.logger.warning( "Solver encountered RuntimeError. Treating as infeasible. " diff --git a/pyomo/contrib/gdpopt/tests/test_LBB.py b/pyomo/contrib/gdpopt/tests/test_LBB.py index 871b79ecc31..b2141b6b09b 100644 --- a/pyomo/contrib/gdpopt/tests/test_LBB.py +++ b/pyomo/contrib/gdpopt/tests/test_LBB.py @@ -18,11 +18,26 @@ from pyomo.common.fileutils import import_file from pyomo.common.log import LoggingIntercept +import pyomo.contrib.gdpopt.branch_and_bound as bb import pyomo.contrib.gdpopt.tests.common_tests as ct +from pyomo.contrib.gdpopt.branch_and_bound import GDP_LBB_Solver +from pyomo.contrib.gdpopt.create_oa_subproblems import ( + add_algebraic_variable_list, + add_util_block, +) from pyomo.contrib.satsolver.satsolver import z3_available -from pyomo.environ import SolverFactory, value, ConcreteModel, Var, Objective, maximize +from pyomo.environ import ( + Binary, + SolverFactory, + value, + ConcreteModel, + Var, + Objective, + maximize, + minimize, +) from pyomo.gdp import Disjunction -from pyomo.opt import TerminationCondition +from pyomo.opt import SolverResults, SolverStatus, TerminationCondition currdir = dirname(abspath(__file__)) exdir = normpath(join(currdir, '..', '..', '..', '..', 'examples', 'gdp')) @@ -35,6 +50,120 @@ ) +class _RecordingSolver: + def __init__(self, name, calls): + self.name = name + self.calls = calls + + def solve(self, model, **kwds): + has_unfixed_discrete = any( + (v.is_binary() or v.is_integer()) and not v.fixed + for v in model.component_data_objects(Var, descend_into=True) + ) + self.calls.append((self.name, has_unfixed_discrete, kwds)) + + results = SolverResults() + results.solver.status = SolverStatus.ok + results.solver.termination_condition = TerminationCondition.optimal + results.problem.lower_bound = 0 + results.problem.upper_bound = 0 + return results + + +class TestGDPoptLBBNodeSolverDispatch(unittest.TestCase): + def _make_lbb_solver(self, model): + solver = GDP_LBB_Solver() + solver.pyomo_results = SolverResults() + solver.pyomo_results.problem.sense = minimize + solver.original_util_block = add_util_block(model) + add_algebraic_variable_list(solver.original_util_block) + return solver + + def _make_config(self, solver, relaxed_nlp_solver=None): + config = solver.CONFIG() + config.minlp_solver = 'sentinel_minlp' + config.nlp_solver = 'sentinel_nlp' + config.local_minlp_solver = 'sentinel_local_minlp' + config.minlp_solver_args['role'] = 'minlp' + config.nlp_solver_args['role'] = 'nlp' + config.local_minlp_solver_args['role'] = 'local_minlp' + config.relaxed_nlp_solver = relaxed_nlp_solver + if relaxed_nlp_solver is not None: + config.relaxed_nlp_solver_args['role'] = 'relaxed_nlp' + config.integer_tolerance = 1e-5 + config.time_limit = None + return config + + def _record_solver_calls(self, method_name, model, relaxed_nlp_solver=None): + calls = [] + original_solver_factory = bb.SolverFactory + bb.SolverFactory = lambda name: _RecordingSolver(name, calls) + try: + solver = self._make_lbb_solver(model) + config = self._make_config(solver, relaxed_nlp_solver) + getattr(solver, method_name)(model, config) + finally: + bb.SolverFactory = original_solver_factory + return calls + + def test_continuous_node_subproblem_uses_relaxed_nlp_solver(self): + m = ConcreteModel() + m.x = Var(bounds=(0, 1)) + m.obj = Objective(expr=m.x) + + calls = self._record_solver_calls( + '_solve_rnGDP_subproblem', m, relaxed_nlp_solver='sentinel_relaxed_nlp' + ) + + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0][0], 'sentinel_relaxed_nlp') + self.assertFalse(calls[0][1]) + self.assertEqual(calls[0][2]['role'], 'relaxed_nlp') + + def test_continuous_node_subproblem_defaults_to_minlp_solver(self): + m = ConcreteModel() + m.x = Var(bounds=(0, 1)) + m.obj = Objective(expr=m.x) + + calls = self._record_solver_calls('_solve_rnGDP_subproblem', m) + + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0][0], 'sentinel_minlp') + self.assertFalse(calls[0][1]) + self.assertEqual(calls[0][2]['role'], 'minlp') + + def test_mixed_integer_node_subproblem_uses_minlp_solver(self): + m = ConcreteModel() + m.x = Var(bounds=(0, 1)) + m.y = Var(domain=Binary) + m.obj = Objective(expr=m.x + m.y) + + calls = self._record_solver_calls( + '_solve_rnGDP_subproblem', m, relaxed_nlp_solver='sentinel_relaxed_nlp' + ) + + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0][0], 'sentinel_minlp') + self.assertTrue(calls[0][1]) + self.assertEqual(calls[0][2]['role'], 'minlp') + + def test_continuous_local_node_subproblem_uses_relaxed_nlp_solver(self): + m = ConcreteModel() + m.x = Var(bounds=(0, 1)) + m.obj = Objective(expr=m.x) + + calls = self._record_solver_calls( + '_solve_local_rnGDP_subproblem', + m, + relaxed_nlp_solver='sentinel_relaxed_nlp', + ) + + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0][0], 'sentinel_relaxed_nlp') + self.assertFalse(calls[0][1]) + self.assertEqual(calls[0][2]['role'], 'relaxed_nlp') + + @unittest.skipUnless( solver_available, "Required subsolver %s is not available" % (minlp_solver,) ) diff --git a/pyomo/contrib/mindtpy/algorithm_base_class.py b/pyomo/contrib/mindtpy/algorithm_base_class.py index 11b34e47d8e..eada9ea7317 100644 --- a/pyomo/contrib/mindtpy/algorithm_base_class.py +++ b/pyomo/contrib/mindtpy/algorithm_base_class.py @@ -298,7 +298,7 @@ def model_is_valid(self): This method performs a structural check on the working model. It determines if the problem is a true Mixed-Integer program. - If no discrete variables are present, it serves as a short-circuit. + If no unfixed discrete variables are present, it serves as a short-circuit. In short-circuit cases, the problem is solved immediately with the configured MIP or NLP subsolver. @@ -314,11 +314,12 @@ def model_is_valid(self): 1. Discrete Variable Presence The method first inspects ``MindtPy.discrete_variable_list``. - If this list is not empty, the function implicitly returns True. + If this list contains any unfixed variables, the function implicitly + returns True. This indicates the model is a valid MINLP for decomposition. 2. Continuous Model Handling (The "False" cases) - If the discrete variable list is empty, the model is "invalid" for + If there are no unfixed discrete variables, the model is "invalid" for MINLP. The method then classifies the continuous model as LP, QP, QCP, or NLP and routes it directly to the configured MIP or NLP subsolver. @@ -342,8 +343,8 @@ def model_is_valid(self): # Handle purely continuous models by short-circuiting to a direct solve prob = self.results.problem - if len(MindtPy.discrete_variable_list) == 0: - config.logger.info('Problem has no discrete decisions.') + if not any(not v.fixed for v in MindtPy.discrete_variable_list): + config.logger.info('Problem has no unfixed discrete decisions.') original_obj = next( self.original_model.component_data_objects(ctype=Objective, active=True) diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py index 1ba46b08d75..72aa97ea561 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py @@ -505,10 +505,14 @@ def _make_algorithm( nlp_opt=None, mip_constraint_polynomial_degree=None, mip_objective_polynomial_degree=None, + algorithm_class=None, ): - from pyomo.contrib.mindtpy.algorithm_base_class import _MindtPyAlgorithm + if algorithm_class is None: + from pyomo.contrib.mindtpy.algorithm_base_class import _MindtPyAlgorithm + + algorithm_class = _MindtPyAlgorithm - algo = _MindtPyAlgorithm() + algo = algorithm_class() algo.config = _SimpleNamespace( logger=MagicMock(), mip_solver=mip_solver_name, @@ -577,6 +581,15 @@ def _make_nlp_model(self): m.obj = Objective(expr=m.x**3, sense=minimize) return m + def _make_fixed_binary_nlp_model(self): + m = ConcreteModel() + m.x = Var(bounds=(0, 10)) + m.y = Var(domain=Binary, initialize=1) + m.y.fix(1) + m.c = Constraint(expr=m.x >= m.y) + m.obj = Objective(expr=m.x**3 + m.y, sense=minimize) + return m + def test_short_circuit_lp_routes_to_mip(self): algo = self._make_algorithm( self._make_lp_model(), @@ -688,6 +701,29 @@ def test_short_circuit_nlp_uses_nlp_even_with_quadratic_capable_mip_solver(self) algo.mip_opt.solve.assert_not_called() algo.nlp_opt.solve.assert_called_once() + def test_goa_short_circuit_fixed_discrete_nlp_uses_nlp_solver(self): + from pyomo.contrib.mindtpy.global_outer_approximation import MindtPy_GOA_Solver + + algo = self._make_algorithm( + self._make_fixed_binary_nlp_model(), + mip_solver_name='gurobi', + mip_opt=_FakeLegacyMIPSolver( + quadratic_objective=True, quadratic_constraint=True + ), + algorithm_class=MindtPy_GOA_Solver, + ) + + self.assertEqual( + len(algo.working_model.MindtPy_utils.discrete_variable_list), 0 + ) + with patch( + 'pyomo.contrib.mindtpy.algorithm_base_class.update_solver_timelimit' + ): + self.assertFalse(algo.model_is_valid()) + + algo.mip_opt.solve.assert_not_called() + algo.nlp_opt.solve.assert_called_once() + def test_short_circuit_mip_failure_does_not_fallback_to_nlp(self): algo = self._make_algorithm( self._make_qp_model(),