From 25d715a95a0e43f1ff12300235ad3f7299f34b9a Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:34:18 -0500 Subject: [PATCH 1/7] Add ability to change gurobi solution pool mode to 1, best effort --- .../contrib/alternative_solutions/solnpool.py | 17 +++++++++++++- .../tests/test_solnpool.py | 23 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index 5c75a6261c3..d8a1d873a3d 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -29,6 +29,7 @@ def gurobi_generate_solutions( abs_opt_gap=None, solver_options={}, tee=False, + pool_search_mode=2, ): """ Finds alternative optimal solutions for discrete variables using Gurobi's @@ -56,6 +57,10 @@ def gurobi_generate_solutions( Solver option-value pairs to be passed to the Gurobi solver. tee : boolean Boolean indicating that the solver output should be displayed. + pool_search_mode : 1 or 2 + The generation method for filling the pool. + This parameter maps to the PoolSearchMode in gurobi. + Method designed to work with value 2 as optimality ordered. Returns ------- @@ -69,10 +74,20 @@ 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!") + + assert pool_search_mode in [1, 2], "pool_search_mode must be 1 or 2" + if pool_search_mode == 1: + logger.warning( + "Running gurobi_solnpool with PoolSearchMode=1, best effort search may lead to unexpected behavior" + ) + opt.config.stream_solver = tee opt.config.load_solution = False opt.gurobi_options["PoolSolutions"] = num_solutions - opt.gurobi_options["PoolSearchMode"] = 2 + opt.gurobi_options["PoolSearchMode"] = pool_search_mode if rel_opt_gap is not None: opt.gurobi_options["PoolGap"] = rel_opt_gap if abs_opt_gap is not None: diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index 5fef32facc9..a2665ea801b 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -48,6 +48,29 @@ 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_invalid_search_mode(self): + """ + Confirm that an exception is thrown with pool_search_mode not in [1,2] + """ + m = tc.get_triangle_ip() + try: + gurobi_generate_solutions(m, pool_search_mode=0) + except AssertionError as e: + pass + + @unittest.skipIf(not numpy_available, "Numpy not installed") + 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() + results = gurobi_generate_solutions(m,pool_search_mode=1,num_solutions=8) + assert len(results) >= 1, 'Need to find some solutions' + + @unittest.skipIf(not numpy_available, "Numpy not installed") def test_ip_num_solutions(self): """ From a6786a9af1e99db3007e8b4464a2c67897cd2c88 Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:35:30 -0500 Subject: [PATCH 2/7] Enforce Black standard --- pyomo/contrib/alternative_solutions/tests/test_solnpool.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index a2665ea801b..1e5c9765337 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -63,13 +63,12 @@ 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() - results = gurobi_generate_solutions(m,pool_search_mode=1,num_solutions=8) + results = gurobi_generate_solutions(m, pool_search_mode=1, num_solutions=8) assert len(results) >= 1, 'Need to find some solutions' - @unittest.skipIf(not numpy_available, "Numpy not installed") def test_ip_num_solutions(self): From b65b5e0f4f77def2c7260bdfba952d6dcb6e214f Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Sat, 20 Sep 2025 12:41:21 -0500 Subject: [PATCH 3/7] Update gurobi solution pool tests Using assertRaisesRegex format now --- pyomo/contrib/alternative_solutions/tests/test_solnpool.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index 1e5c9765337..ec6b3bc5c93 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -53,10 +53,8 @@ def test_invalid_search_mode(self): Confirm that an exception is thrown with pool_search_mode not in [1,2] """ m = tc.get_triangle_ip() - try: + with self.assertRaisesRegex(AssertionError, "pool_search_mode must be 1 or 2"): gurobi_generate_solutions(m, pool_search_mode=0) - except AssertionError as e: - pass @unittest.skipIf(not numpy_available, "Numpy not installed") def test_ip_num_solutions_best_effort(self): From 3bf3284525ee40566556580b01f07c51b7d4ac33 Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:33:20 -0500 Subject: [PATCH 4/7] Updated handling of Gurobi PoolSearchMode in gurobi_generate_solutions Changed to the more general paradigm for handling solver options for best effort mode alternative solution generation in gurob_generate_solutions. Added comments explaining the functionality and how to use --- .../contrib/alternative_solutions/solnpool.py | 32 +++++++++++-------- .../tests/test_solnpool.py | 12 ++----- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index d8a1d873a3d..025d7771429 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -29,12 +29,17 @@ def gurobi_generate_solutions( abs_opt_gap=None, solver_options={}, tee=False, - pool_search_mode=2, ): """ 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 ---------- @@ -57,10 +62,6 @@ def gurobi_generate_solutions( Solver option-value pairs to be passed to the Gurobi solver. tee : boolean Boolean indicating that the solver output should be displayed. - pool_search_mode : 1 or 2 - The generation method for filling the pool. - This parameter maps to the PoolSearchMode in gurobi. - Method designed to work with value 2 as optimality ordered. Returns ------- @@ -78,22 +79,25 @@ def gurobi_generate_solutions( if num_solutions == 1: logger.warning("Running alternative_solutions method to find only 1 solution!") - assert pool_search_mode in [1, 2], "pool_search_mode must be 1 or 2" - if pool_search_mode == 1: - logger.warning( - "Running gurobi_solnpool with PoolSearchMode=1, best effort search may lead to unexpected behavior" - ) - opt.config.stream_solver = tee opt.config.load_solution = False opt.gurobi_options["PoolSolutions"] = num_solutions - opt.gurobi_options["PoolSearchMode"] = pool_search_mode 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" + ) + if opt.gurobi_options["PoolSearchMode"] == 0: + 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 ec6b3bc5c93..48013559666 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -48,14 +48,6 @@ 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_invalid_search_mode(self): - """ - Confirm that an exception is thrown with pool_search_mode not in [1,2] - """ - m = tc.get_triangle_ip() - with self.assertRaisesRegex(AssertionError, "pool_search_mode must be 1 or 2"): - gurobi_generate_solutions(m, pool_search_mode=0) - @unittest.skipIf(not numpy_available, "Numpy not installed") def test_ip_num_solutions_best_effort(self): """ @@ -65,7 +57,9 @@ def test_ip_num_solutions_best_effort(self): Check that the correct number of alternate solutions are found. """ m = tc.get_triangle_ip() - results = gurobi_generate_solutions(m, pool_search_mode=1, num_solutions=8) + results = gurobi_generate_solutions( + m, num_solutions=8, solver_options={"PoolSearchMode": 1} + ) assert len(results) >= 1, 'Need to find some solutions' @unittest.skipIf(not numpy_available, "Numpy not installed") From 43781d0545a8d519e49e8b9957c3c02947ef6086 Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Wed, 1 Oct 2025 19:25:33 -0500 Subject: [PATCH 5/7] Added proper logging tests for warnings, added assertation error check Added logging tests with LoggingIntercept. Added assertation check for num_solutions = 0 case Removed unneccessary skipIf numpy unavailable from tests that didn't use numpy --- .../contrib/alternative_solutions/solnpool.py | 2 +- .../tests/test_solnpool.py | 60 ++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/solnpool.py b/pyomo/contrib/alternative_solutions/solnpool.py index 025d7771429..d252fcd1a00 100644 --- a/pyomo/contrib/alternative_solutions/solnpool.py +++ b/pyomo/contrib/alternative_solutions/solnpool.py @@ -94,7 +94,7 @@ def gurobi_generate_solutions( logger.warning( "Running gurobi_solnpool with PoolSearchMode=0, this is single search mode and not the intended use case for gurobi_generate_solutions" ) - if opt.gurobi_options["PoolSearchMode"] == 0: + elif opt.gurobi_options["PoolSearchMode"] == 1: logger.warning( "Running gurobi_solnpool with PoolSearchMode=1, best effort search may lead to unexpected behavior" ) diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index 48013559666..5764d74a50d 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,7 +49,6 @@ 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) - @unittest.skipIf(not numpy_available, "Numpy not installed") def test_ip_num_solutions_best_effort(self): """ Enumerate solutions for an ip: triangle_ip. @@ -57,10 +57,64 @@ def test_ip_num_solutions_best_effort(self): Check that the correct number of alternate solutions are found. """ m = tc.get_triangle_ip() - results = gurobi_generate_solutions( - m, num_solutions=8, solver_options={"PoolSearchMode": 1} + 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 assertation 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): From 47fc717d5bf3975c72b5ece23238cc1414b0a722 Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Wed, 1 Oct 2025 19:29:40 -0500 Subject: [PATCH 6/7] Black fixes to test script --- .../tests/test_solnpool.py | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index 5764d74a50d..597406205bb 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -57,17 +57,16 @@ def test_ip_num_solutions_best_effort(self): Check that the correct number of alternate solutions are found. """ m = tc.get_triangle_ip() - with LoggingIntercept() as LOG: + 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() + 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. @@ -78,13 +77,13 @@ def test_ip_num_solutions_standard_single_solution_solve(self): This is a warning check. """ m = tc.get_triangle_ip() - with LoggingIntercept() as LOG: + 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() + LOG.getvalue(), ) assert len(results) == 1, 'Need to find only 1 solution' @@ -97,13 +96,11 @@ def test_ip_num_solutions_seeking_one(self): This is a warning check. """ m = tc.get_triangle_ip() - with LoggingIntercept() as LOG: - results = gurobi_generate_solutions( - m, num_solutions=1 - ) + 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() + LOG.getvalue(), ) assert len(results) == 1, 'Need to find only 1 solution' @@ -113,7 +110,9 @@ def test_ip_num_solutions_seeking_zero(self): Test case where zero solutions are asked for to check assertation error. """ m = tc.get_triangle_ip() - with self.assertRaisesRegex(AssertionError, "num_solutions must be positive integer"): + 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") From 1138a360ab19dceb9fecb3ce909503bec9285dba Mon Sep 17 00:00:00 2001 From: Matthew Viens <75225878+viens-code@users.noreply.github.com> Date: Wed, 1 Oct 2025 19:49:35 -0500 Subject: [PATCH 7/7] Spelling Change Pyomo Spell checking breaks if you use the word assertation anywhere. It apparently will tolerate assertion but not assertation. Now using assert instead of assertation --- pyomo/contrib/alternative_solutions/tests/test_solnpool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py index 597406205bb..590a5eee4f7 100644 --- a/pyomo/contrib/alternative_solutions/tests/test_solnpool.py +++ b/pyomo/contrib/alternative_solutions/tests/test_solnpool.py @@ -107,7 +107,7 @@ def test_ip_num_solutions_seeking_one(self): 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 assertation error. + Test case where zero solutions are asked for to check assert error. """ m = tc.get_triangle_ip() with self.assertRaisesRegex(