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..a92cdd7ed8f 100644 --- a/pyomo/contrib/gdpopt/tests/test_LBB.py +++ b/pyomo/contrib/gdpopt/tests/test_LBB.py @@ -19,10 +19,25 @@ from pyomo.common.fileutils import import_file from pyomo.common.log import LoggingIntercept 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, + Constraint, + SolverFactory, + value, + ConcreteModel, + Var, + Objective, + maximize, + minimize, +) from pyomo.gdp import Disjunction -from pyomo.opt import TerminationCondition +from pyomo.opt import SolverResults, TerminationCondition currdir = dirname(abspath(__file__)) exdir = normpath(join(currdir, '..', '..', '..', '..', 'examples', 'gdp')) @@ -35,6 +50,76 @@ ) +@unittest.skipUnless( + SolverFactory('glpk').available(exception_flag=False) + and SolverFactory('ipopt').available(exception_flag=False), + "The LBB node dispatch tests require GLPK and Ipopt", +) +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 = 'glpk' + config.nlp_solver = 'sentinel_nlp' + config.local_minlp_solver = 'glpk' + config.relaxed_nlp_solver = relaxed_nlp_solver + config.integer_tolerance = 1e-5 + config.time_limit = None + return config + + def test_continuous_node_subproblem_uses_relaxed_nlp_solver(self): + m = ConcreteModel() + m.x = Var(bounds=(0, 2), initialize=1.5) + m.obj = Objective(expr=(m.x - 1) ** 2) + + solver = self._make_lbb_solver(m) + config = self._make_config(solver, relaxed_nlp_solver='ipopt') + solver._solve_rnGDP_subproblem(m, config) + + self.assertAlmostEqual(value(m.x), 1.0, places=6) + + def test_continuous_node_subproblem_defaults_to_minlp_solver(self): + m = ConcreteModel() + m.x = Var(bounds=(1, 2)) + m.obj = Objective(expr=m.x) + + solver = self._make_lbb_solver(m) + config = self._make_config(solver) + solver._solve_rnGDP_subproblem(m, config) + + self.assertAlmostEqual(value(m.x), 1.0) + + def test_mixed_integer_node_subproblem_uses_minlp_solver(self): + m = ConcreteModel() + m.y = Var(domain=Binary) + m.c = Constraint(expr=m.y >= 0.5) + m.obj = Objective(expr=m.y) + + solver = self._make_lbb_solver(m) + config = self._make_config(solver, relaxed_nlp_solver='ipopt') + solver._solve_rnGDP_subproblem(m, config) + + self.assertAlmostEqual(value(m.y), 1.0) + + def test_continuous_local_node_subproblem_uses_relaxed_nlp_solver(self): + m = ConcreteModel() + m.x = Var(bounds=(0, 2), initialize=1.5) + m.obj = Objective(expr=(m.x - 1) ** 2) + + solver = self._make_lbb_solver(m) + config = self._make_config(solver, relaxed_nlp_solver='ipopt') + solver._solve_local_rnGDP_subproblem(m, config) + + self.assertAlmostEqual(value(m.x), 1.0, places=6) + + @unittest.skipUnless( solver_available, "Required subsolver %s is not available" % (minlp_solver,) )