diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index 5c75a6261c3..d252fcd1a00 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -32,8 +32,14 @@ def gurobi_generate_solutions( ): """ Finds alternative optimal solutions for discrete variables using Gurobi's - built-in Solution Pool capability. See the Gurobi Solution Pool - documentation for additional details. + built-in Solution Pool capability. + + This method defaults to the optimality-enforced discovery method with PoolSearchMode = 2. + There are two other options, standard single optimal solution (PoolSearchMode = 0) and + best-effort discovery with no guarantees (PoolSearchMode = 1). Please consult the Gurobi + documentation on PoolSearchMode for details on impact on Gurobi results. + Changes to this mode can be made by included PoolSearchMode set to the intended value + in solver_options. Parameters ---------- @@ -69,16 +75,29 @@ def gurobi_generate_solutions( if not opt.available(): raise ApplicationError("Solver (gurobi) not available") + assert num_solutions >= 1, "num_solutions must be positive integer" + if num_solutions == 1: + logger.warning("Running alternative_solutions method to find only 1 solution!") + opt.config.stream_solver = tee opt.config.load_solution = False opt.gurobi_options["PoolSolutions"] = num_solutions - opt.gurobi_options["PoolSearchMode"] = 2 if rel_opt_gap is not None: opt.gurobi_options["PoolGap"] = rel_opt_gap if abs_opt_gap is not None: opt.gurobi_options["PoolGapAbs"] = abs_opt_gap for parameter, value in solver_options.items(): opt.gurobi_options[parameter] = value + if "PoolSearchMode" not in opt.gurobi_options: + opt.gurobi_options["PoolSearchMode"] = 2 + elif opt.gurobi_options["PoolSearchMode"] == 0: + logger.warning( + "Running gurobi_solnpool with PoolSearchMode=0, this is single search mode and not the intended use case for gurobi_generate_solutions" + ) + elif opt.gurobi_options["PoolSearchMode"] == 1: + logger.warning( + "Running gurobi_solnpool with PoolSearchMode=1, best effort search may lead to unexpected behavior" + ) # # Run gurobi # diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index 5fef32facc9..590a5eee4f7 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -17,6 +17,7 @@ from pyomo.contrib.appsi.solvers import Gurobi import pyomo.contrib.alternative_solutions.tests.test_cases as tc +from pyomo.common.log import LoggingIntercept gurobipy_available = Gurobi().available() @@ -48,6 +49,72 @@ def test_ip_feasibility(self): unique_solns_by_obj = [val for val in Counter(objectives).values()] np.testing.assert_array_almost_equal(unique_solns_by_obj, actual_solns_by_obj) + def test_ip_num_solutions_best_effort(self): + """ + Enumerate solutions for an ip: triangle_ip. + Test best effort mode in solution pool. + + Check that the correct number of alternate solutions are found. + """ + m = tc.get_triangle_ip() + with LoggingIntercept() as LOG: + results = gurobi_generate_solutions( + m, num_solutions=8, solver_options={"PoolSearchMode": 1} + ) + self.assertRegex( + 'Running gurobi_solnpool with PoolSearchMode=1, best effort search may lead to unexpected behavior\n', + LOG.getvalue(), + ) + assert len(results) >= 1, 'Need to find some solutions' + + def test_ip_num_solutions_standard_single_solution_solve(self): + """ + Enumerate solutions for an ip: triangle_ip. + Test single solve mode in solution pool. + + Check that the correct number of solutions (1) are found. + This is not the intended use case for this method. + This is a warning check. + """ + m = tc.get_triangle_ip() + with LoggingIntercept() as LOG: + results = gurobi_generate_solutions( + m, num_solutions=8, solver_options={"PoolSearchMode": 0} + ) + self.assertRegex( + 'Running gurobi_solnpool with PoolSearchMode=0, this is single search mode and not the intended use case for gurobi_generate_solutions\n', + LOG.getvalue(), + ) + assert len(results) == 1, 'Need to find only 1 solution' + + def test_ip_num_solutions_seeking_one(self): + """ + Enumerate solutions for an ip: triangle_ip. + Test case where only one solution is asked for. + + This is not the intended use case for this code. + This is a warning check. + """ + m = tc.get_triangle_ip() + with LoggingIntercept() as LOG: + results = gurobi_generate_solutions(m, num_solutions=1) + self.assertRegex( + 'Running alternative_solutions method to find only 1 solution!\n', + LOG.getvalue(), + ) + assert len(results) == 1, 'Need to find only 1 solution' + + def test_ip_num_solutions_seeking_zero(self): + """ + Enumerate solutions for an ip: triangle_ip. + Test case where zero solutions are asked for to check assert error. + """ + m = tc.get_triangle_ip() + with self.assertRaisesRegex( + AssertionError, "num_solutions must be positive integer" + ): + gurobi_generate_solutions(m, num_solutions=0) + @unittest.skipIf(not numpy_available, "Numpy not installed") def test_ip_num_solutions(self): """