Skip to content
Closed
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
83 changes: 72 additions & 11 deletions pyomo/contrib/gdpopt/branch_and_bound.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand All @@ -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. "
Expand Down Expand Up @@ -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. "
Expand Down
133 changes: 131 additions & 2 deletions pyomo/contrib/gdpopt/tests/test_LBB.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand All @@ -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,)
)
Expand Down
11 changes: 6 additions & 5 deletions pyomo/contrib/mindtpy/algorithm_base_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand All @@ -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)
Expand Down
40 changes: 38 additions & 2 deletions pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
Loading