diff --git a/conftest.py b/conftest.py index 2bc6eba00fe..9975824590e 100644 --- a/conftest.py +++ b/conftest.py @@ -10,62 +10,93 @@ import pytest _implicit_markers = {'default'} -_extended_implicit_markers = _implicit_markers.union({'solver'}) +_category_markers = {'solver', 'writer'} +_extended_implicit_markers = _implicit_markers.union(_category_markers) -def pytest_collection_modifyitems(items): - """ - This method will mark any unmarked tests with the implicit marker ('default') +def pytest_configure(config): + # If the user specified "--solver" or "--writer", then add that + # logic to the marker expression + markexpr = config.option.markexpr + for cat in _category_markers: + opt = config.getoption('--' + cat) + if opt: + if markexpr: + markexpr = f"({markexpr}) and " + markexpr += f"{cat}(id='{opt}')" + # If the user didn't specify a marker expression, then we will + # select all "default" tests. + if not markexpr: + markexpr = 'default' + config.option.markexpr = markexpr - """ - for item in items: - try: - next(item.iter_markers()) - except StopIteration: - for marker in _implicit_markers: - item.add_marker(getattr(pytest.mark, marker)) +def pytest_itemcollected(item): + """Standardize all Pyomo test markers. + + This callback ensures that all unmarked tests, along with all tests + that are only marked by category markers (e.g., "solver" or + "writer"), are also marked with the default (implicit) markers + (currently just "default"). + + About category markers + ---------------------- + + We have historically supported "category markers":: + + @pytest.mark.solver("highs") + + Unfortunately, pytest doesn't allow for building marker + expressions (e.g., for "-m") based on the marker.args. We will + map the positional argument (for pytest.mark.solver and + pytest.mark.writer) to the keyword argument "id". This will allow + querying against specific solver interfaces in marker expressions + with:: + + solver(id='highs') + + We will take this opportunity to also set a keyword argument for + the solver/writer "vendor" (defined as the id up to the first + underscore). This will allow running "all Gurobi tests" + (including, e.g., lp, direct, and persistent) with:: + + -m solver(vendor='gurobi') + + As with all pytest markers, these can be combined into more complex + "marker expressions" using ``and``, ``or``, ``not``, and ``()``. -def pytest_runtest_setup(item): - """ - This method overrides pytest's default behavior for marked tests. - - The logic below follows this flow: - 1) Did the user ask for a specific solver using the '--solver' flag? - If so: Add skip statements to any test NOT labeled with the - requested solver category. - 2) Did the user ask for a specific marker using the '-m' flag? - If so: Return to pytest's default behavior. - 3) If the user requested no specific solver or marker, look at each - test for the following: - a) If unmarked, run the test - b) If marked with implicit_markers, run the test - c) If marked "solver" and NOT any explicit marker, run the test - OTHERWISE: Skip the test. - In other words - we want to run unmarked, implicit, and solver tests as - the default mode; but if solver tests are also marked with an explicit - category (e.g., "expensive"), we will skip them. """ - solvernames = [mark.args[0] for mark in item.iter_markers(name="solver")] - solveroption = item.config.getoption("--solver") - markeroption = item.config.getoption("-m") - item_markers = set(mark.name for mark in item.iter_markers()) - if solveroption: - if solveroption not in solvernames: - pytest.skip("SKIPPED: Test not marked {!r}".format(solveroption)) - return - elif markeroption: + markers = list(item.iter_markers()) + if not markers: + # No markers; add the implicit (default) markers + for marker in _implicit_markers: + item.add_marker(getattr(pytest.mark, marker)) return - elif item_markers: - if not _implicit_markers.issubset(item_markers) and not item_markers.issubset( - _extended_implicit_markers - ): - pytest.skip('SKIPPED: Only running default, solver, and unmarked tests.') + + marker_set = {mark.name for mark in markers} + # If the item is only marked by extended implicit markers (e.g., + # solver and/or writer), then make sure it is also marked by all + # implicit markers (i.e., "default") + if marker_set.issubset(_extended_implicit_markers): + for marker in _implicit_markers - marker_set: + item.add_marker(getattr(pytest.mark, marker)) + + # Map any "category" markers (solver or writer) positional arguments + # to the id keyword, and ensure the 'vendor' keyword is populated + for mark in markers: + if mark.name not in _category_markers: + continue + if mark.args: + (_id,) = mark.args + mark.kwargs['id'] = _id + if 'vendor' not in mark.kwargs: + mark.kwargs['vendor'] = mark.kwargs['id'].split("_")[0] def pytest_addoption(parser): """ - Add another parser option to specify suite of solver tests to run + Add parser options as shorthand for running tests marked by specific + solvers or writers. """ parser.addoption( "--solver", @@ -73,13 +104,9 @@ def pytest_addoption(parser): metavar="SOLVER", help="Run tests matching the requested SOLVER.", ) - - -def pytest_configure(config): - """ - Register additional solver marker, as applicable. - This stops pytest from printing a warning about unregistered solver options. - """ - config.addinivalue_line( - "markers", "solver(name): mark test to run the named solver" + parser.addoption( + "--writer", + action="store", + metavar="WRITER", + help="Run tests matching the requested WRITER.", ) diff --git a/doc/OnlineDocs/contribution_guide.rst b/doc/OnlineDocs/contribution_guide.rst index 735e9b52cfd..ba69afea30a 100644 --- a/doc/OnlineDocs/contribution_guide.rst +++ b/doc/OnlineDocs/contribution_guide.rst @@ -98,8 +98,12 @@ Markers are declared in ``pyproject.toml``. Some commonly used markers are: - ``expensive``: tests that take a long time to run - ``mpi``: tests that require MPI -- ``solver(name)``: dynamic marker to label a test for a specific solver, - e.g., ``@pytest.mark.solver("gurobi")`` +- ``solver(id='name')``: tests for a specific solver, + e.g., ``@pytest.mark.solver("name")`` +- ``solver(vendor='name')``: tests for a set of solvers (matching up to the + first underscore), e.g., ``solver(vendor="gurobi")`` will run tests marked + with ``solver("gurobi")``, ``solver("gurobi_direct")``, and + ``solver("gurobi_persistent")`` More details about Pyomo-defined default test behavior can be found in the `conftest.py file `_. diff --git a/doc/OnlineDocs/getting_started/solvers.rst b/doc/OnlineDocs/getting_started/solvers.rst index e06b5c9c95a..a9f04a9b954 100644 --- a/doc/OnlineDocs/getting_started/solvers.rst +++ b/doc/OnlineDocs/getting_started/solvers.rst @@ -69,7 +69,7 @@ the license requirements for their desired solver. * - PyMUMPS - ``pip install pymumps`` - ``conda install ‑c conda‑forge pymumps`` - - `License `__ + - `License `__ `Docs `__ * - SCIP - N/A diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index 2971034351a..4d5e2e70c5c 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -119,6 +119,13 @@ def _restore_var_values(self) -> None: var.set_value(self._saved_var_values[id(var)]) StaleFlagManager.mark_all_as_stale() + def _warm_start(self) -> None: + variables = [] + for var in self._get_vars(): + if var.value is not None: + variables.append(var) + self._engine.set_initial_values(variables) + @abstractmethod def _presolve( self, model: BlockData, config: KnitroConfig, timer: HierarchicalTimer diff --git a/pyomo/contrib/solver/solvers/knitro/config.py b/pyomo/contrib/solver/solvers/knitro/config.py index b7fc4135a30..ce94d93bdcf 100644 --- a/pyomo/contrib/solver/solvers/knitro/config.py +++ b/pyomo/contrib/solver/solvers/knitro/config.py @@ -28,6 +28,18 @@ def __init__( visibility=visibility, ) + self.use_start: bool = self.declare( + "use_start", + ConfigValue( + domain=Bool, + default=False, + doc=( + "If True, KNITRO solver will use the the current values " + "of variables as starting points for the optimization." + ), + ), + ) + self.rebuild_model_on_remove_var: bool = self.declare( "rebuild_model_on_remove_var", ConfigValue( diff --git a/pyomo/contrib/solver/solvers/knitro/direct.py b/pyomo/contrib/solver/solvers/knitro/direct.py index 8df25d115ac..202bd506732 100644 --- a/pyomo/contrib/solver/solvers/knitro/direct.py +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -48,6 +48,11 @@ def _solve(self, config: KnitroConfig, timer: HierarchicalTimer) -> None: self._engine.set_options(**config.solver_options) timer.stop("load_options") + if config.use_start: + timer.start("warm_start") + self._warm_start() + timer.stop("warm_start") + timer.start("solve") self._engine.solve() timer.stop("solve") diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 83d8c19f134..8046c6dcea0 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -310,6 +310,11 @@ def get_idxs( idx_map = self.maps[item_type] return [idx_map[id(item)] for item in items] + def set_initial_values(self, variables: Iterable[VarData]) -> None: + values = [value(var) for var in variables] + idxs = self.get_idxs(VarData, variables) + self.execute(knitro.KN_set_var_primal_init_values, idxs, values) + def get_values( self, item_type: type[ItemType], diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py index dc498d68c9c..3f29d002183 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py @@ -39,6 +39,7 @@ @unittest.skipUnless(gurobi_direct.available(), "needs Gurobi Direct MINLP interface") +@unittest.pytest.mark.solver("gurobi_direct_minlp") class TestGurobiMINLP(unittest.TestCase): def test_gurobi_minlp_sincosexp(self): m = ConcreteModel(name="test") diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py index 15f9ffd19c9..19a8498f08e 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py @@ -64,6 +64,7 @@ def get_visitor(self): @unittest.skipUnless(gurobipy_available, "gurobipy is not available") +@unittest.pytest.mark.solver("gurobi_direct_minlp") class TestGurobiMINLPWalker(CommonTest): def _get_nl_expr_tree(self, visitor, expr): # This is a bit hacky, but the only way that I know to get the expression tree diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py index 4bb3321c53e..5b4f3cd33ed 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py @@ -74,6 +74,7 @@ def make_model(): @unittest.skipUnless(gurobipy_available, "Gurobipy 12 is not available") +@unittest.pytest.mark.solver("gurobi_direct_minlp") class TestGurobiMINLPWriter(CommonTest): def test_small_model(self): grb_model = gurobipy.Model() diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py index 0a9942a3e03..0b5d7eb00bd 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_persistent.py @@ -122,6 +122,7 @@ def rule3(model): return model +@unittest.pytest.mark.solver("gurobi_persistent") class TestGurobiPersistentSimpleLPUpdates(unittest.TestCase): def setUp(self): self.m = pyo.ConcreteModel() @@ -183,6 +184,7 @@ def test_lp(self): self.assertAlmostEqual(y, self.m.y.value) +@unittest.pytest.mark.solver("gurobi_persistent") class TestGurobiPersistent(unittest.TestCase): def test_nonconvex_qcp_objective_bound_1(self): # the goal of this test is to ensure we can get an objective bound @@ -493,6 +495,7 @@ def test_zero_time_limit(self): self.assertIsNone(res.incumbent_objective) +@unittest.pytest.mark.solver("gurobi_persistent") class TestManualMode(unittest.TestCase): def setUp(self): opt = GurobiPersistent() diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_warm_start.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_warm_start.py index d68bc3c88fe..cccd0b6b5cd 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_warm_start.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_warm_start.py @@ -67,6 +67,7 @@ def check_optimal_soln(self, m): self.assertEqual(value(m.x[i]), x[i]) @unittest.skipUnless(gurobi_direct.available(), "needs Gurobi Direct interface") + @unittest.pytest.mark.solver("gurobi_direct") def test_gurobi_direct_warm_start(self): m = self.make_model() @@ -82,6 +83,7 @@ def test_gurobi_direct_warm_start(self): @unittest.skipUnless( gurobi_direct_minlp.available(), "needs Gurobi Direct MINLP interface" ) + @unittest.pytest.mark.solver("gurobi_direct_minlp") def test_gurobi_minlp_warmstart(self): m = self.make_model() @@ -97,6 +99,7 @@ def test_gurobi_minlp_warmstart(self): @unittest.skipUnless( gurobi_persistent.available(), "needs Gurobi persistent interface" ) + @unittest.pytest.mark.solver("gurobi_persistent") def test_gurobi_persistent_warmstart(self): m = self.make_model() diff --git a/pyomo/contrib/solver/tests/solvers/test_highs.py b/pyomo/contrib/solver/tests/solvers/test_highs.py index 86b727ad221..2b29ce7dc10 100644 --- a/pyomo/contrib/solver/tests/solvers/test_highs.py +++ b/pyomo/contrib/solver/tests/solvers/test_highs.py @@ -17,6 +17,7 @@ raise unittest.SkipTest +@unittest.pytest.mark.solver("highs") class TestBugs(unittest.TestCase): def test_mutable_params_with_remove_cons(self): m = pyo.ConcreteModel() diff --git a/pyomo/contrib/solver/tests/solvers/test_ipopt.py b/pyomo/contrib/solver/tests/solvers/test_ipopt.py index e85a293fa0f..8e529928303 100644 --- a/pyomo/contrib/solver/tests/solvers/test_ipopt.py +++ b/pyomo/contrib/solver/tests/solvers/test_ipopt.py @@ -53,6 +53,7 @@ def windows_tee_buffer(size=1 << 20): tee._pipe_buffersize = old +@unittest.pytest.mark.solver("ipopt") class TestIpoptSolverConfig(unittest.TestCase): def test_default_instantiation(self): config = ipopt.IpoptConfig() @@ -87,6 +88,7 @@ def test_custom_instantiation(self): self.assertFalse(config.executable.available()) +@unittest.pytest.mark.solver("ipopt") class TestIpoptSolutionLoader(unittest.TestCase): def test_get_reduced_costs_error(self): loader = ipopt.IpoptSolutionLoader( @@ -105,6 +107,7 @@ def test_get_duals_error(self): loader.get_duals() +@unittest.pytest.mark.solver("ipopt") class TestIpoptInterface(unittest.TestCase): @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") def test_command_line_options(self): @@ -1877,6 +1880,7 @@ def test_bad_executable(self): @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +@unittest.pytest.mark.solver("ipopt") class TestIpopt(unittest.TestCase): def create_model(self): model = pyo.ConcreteModel() @@ -2091,6 +2095,7 @@ def test_load_suffixes_infeasible_model(self): @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +@unittest.pytest.mark.solver("ipopt") class TestLegacyIpopt(unittest.TestCase): def create_model(self): model = pyo.ConcreteModel() diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index 877a03a6af7..af99366acaa 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -23,6 +23,7 @@ @unittest.skipIf(not avail, "KNITRO solver is not available") +@unittest.pytest.mark.solver("knitro_direct") class TestKnitroDirectSolverConfig(unittest.TestCase): def test_default_instantiation(self): config = KnitroConfig() @@ -35,16 +36,20 @@ def test_default_instantiation(self): self.assertIsNone(config.timer) self.assertIsNone(config.threads) self.assertIsNone(config.time_limit) + self.assertFalse(config.use_start) def test_custom_instantiation(self): config = KnitroConfig(description="A description") config.tee = True + config.use_start = True self.assertTrue(config.tee) self.assertEqual(config._description, "A description") self.assertIsNone(config.time_limit) + self.assertTrue(config.use_start) @unittest.skipIf(not avail, "KNITRO solver is not available") +@unittest.pytest.mark.solver("knitro_direct") class TestKnitroSolverResultsExtraInfo(unittest.TestCase): def test_results_extra_info_mip(self): """Test that MIP-specific extra info is populated for MIP problems.""" @@ -98,6 +103,7 @@ def test_results_extra_info_no_mip(self): @unittest.skipIf(not avail, "KNITRO solver is not available") +@unittest.pytest.mark.solver("knitro_direct") class TestKnitroSolverObjectiveBound(unittest.TestCase): def test_objective_bound_mip(self): """Test that objective bound is retrieved for MIP problems.""" @@ -135,6 +141,7 @@ def test_objective_bound_no_mip(self): @unittest.skipIf(not avail, "KNITRO solver is not available") +@unittest.pytest.mark.solver("knitro_direct") class TestKnitroSolverIncumbentObjective(unittest.TestCase): def test_none_without_objective(self): """Test that incumbent objective is None when no objective is present.""" @@ -192,6 +199,7 @@ def test_value_when_optimal(self): @unittest.skipIf(not avail, "KNITRO solver is not available") +@unittest.pytest.mark.solver("knitro_direct") class TestKnitroSolverSolutionStatus(unittest.TestCase): def test_solution_status_mapping(self): """Test that solution status is correctly mapped from KNITRO status.""" @@ -246,6 +254,7 @@ def test_solution_status_mapping(self): @unittest.skipIf(not avail, "KNITRO solver is not available") +@unittest.pytest.mark.solver("knitro_direct") class TestKnitroSolverTerminationCondition(unittest.TestCase): def test_termination_condition_mapping(self): """Test that termination condition is correctly mapped from KNITRO status.""" @@ -337,6 +346,7 @@ def test_termination_condition_mapping(self): @unittest.skipIf(not avail, "KNITRO solver is not available") +@unittest.pytest.mark.solver("knitro_direct") class TestKnitroDirectSolverInterface(unittest.TestCase): def test_class_member_list(self): opt = KnitroDirectSolver() @@ -379,6 +389,7 @@ def test_available_cache(self): @unittest.skipIf(not avail, "KNITRO solver is not available") +@unittest.pytest.mark.solver("knitro_direct") class TestKnitroDirectSolver(unittest.TestCase): def setUp(self): self.opt = KnitroDirectSolver() @@ -478,3 +489,89 @@ def test_solve_HS071(self): self.assertAlmostEqual(pyo.value(m.x[2]), 4.743, 3) self.assertAlmostEqual(pyo.value(m.x[3]), 3.821, 3) self.assertAlmostEqual(pyo.value(m.x[4]), 1.379, 3) + + +@unittest.skipIf(not avail, "KNITRO solver is not available") +class TestKnitroWarmStart(unittest.TestCase): + """Test cases for KNITRO warm start (use_start) functionality.""" + + def setUp(self): + self.opt = KnitroDirectSolver() + + def test_warm_start_reduces_iterations(self): + """Test that providing a good starting point reduces the number of iterations.""" + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(-5, 5)) + m.y = pyo.Var(bounds=(-5, 5)) + m.obj = pyo.Objective( + expr=(1.0 - m.x) ** 2 + 100.0 * (m.y - m.x**2) ** 2, sense=pyo.minimize + ) + + m.x.set_value(None) + m.y.set_value(None) + res_no_start = self.opt.solve(m, use_start=False) + iters_no_start = res_no_start.extra_info.number_iters + + m.x.set_value(0.9) + m.y.set_value(0.9) + res_with_start = self.opt.solve(m, use_start=True) + iters_with_start = res_with_start.extra_info.number_iters + + self.assertAlmostEqual(pyo.value(m.x), 1.0, 3) + self.assertAlmostEqual(pyo.value(m.y), 1.0, 3) + + self.assertLessEqual(iters_with_start, iters_no_start) + + def test_warm_start_uses_initial_values(self): + """Test that warm start uses the current variable values.""" + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 10)) + m.y = pyo.Var(bounds=(0, 10)) + m.obj = pyo.Objective(expr=(m.x - 3) ** 2 + (m.y - 4) ** 2, sense=pyo.minimize) + m.x.set_value(3.0) + m.y.set_value(4.0) + res = self.opt.solve(m, use_start=True) + self.assertAlmostEqual(pyo.value(m.x), 3.0, 5) + self.assertAlmostEqual(pyo.value(m.y), 4.0, 5) + self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) + self.assertLessEqual(res.extra_info.number_iters, 1) + + def test_warm_start_with_subset_variables(self): + """Test warm start when only a subset of variables have initial values.""" + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 10)) + m.y = pyo.Var(bounds=(0, 10)) + m.obj = pyo.Objective(expr=(m.x - 3) ** 2 + (m.y - 4) ** 2, sense=pyo.minimize) + m.x.set_value(3.0) + m.y.set_value(None) + res = self.opt.solve(m, use_start=True) + self.assertAlmostEqual(pyo.value(m.x), 3.0, 5) + self.assertAlmostEqual(pyo.value(m.y), 4.0, 5) + self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) + + def test_warm_start_disabled(self): + """Test that use_start=False disables warm start.""" + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, 10)) + m.y = pyo.Var(bounds=(0, 10)) + m.obj = pyo.Objective(expr=(m.x - 3) ** 2 + (m.y - 4) ** 2, sense=pyo.minimize) + m.x.set_value(3.0) + m.y.set_value(4.0) + res = self.opt.solve(m, use_start=False) + self.assertAlmostEqual(pyo.value(m.x), 3.0, 5) + self.assertAlmostEqual(pyo.value(m.y), 4.0, 5) + self.assertAlmostEqual(res.incumbent_objective, 0.0, 5) + + def test_warm_start_with_constraints(self): + """Test warm start with constrained optimization.""" + m = pyo.ConcreteModel() + m.x = pyo.Var(bounds=(0, None)) + m.y = pyo.Var(bounds=(0, None)) + m.obj = pyo.Objective(expr=m.x + m.y, sense=pyo.minimize) + m.c1 = pyo.Constraint(expr=m.x + 2 * m.y >= 4) + m.c2 = pyo.Constraint(expr=2 * m.x + m.y >= 4) + m.x.set_value(1.3) + m.y.set_value(1.3) + self.opt.solve(m, use_start=True) + self.assertAlmostEqual(pyo.value(m.x), 4.0 / 3.0, 3) + self.assertAlmostEqual(pyo.value(m.y), 4.0 / 3.0, 3) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 967ea3cc6d8..9774e08d664 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -48,10 +48,24 @@ parameterized, param_available = attempt_import('parameterized') parameterized = parameterized.parameterized - if not param_available: raise unittest.SkipTest('Parameterized is not available.') + +class mark_parameterized(parameterized): + """Custom :class:`parameterized` that marks the generated tests + + This class will mark all generated tests as a 'solver' test, using + the first positional argument as the solver name. + + """ + + @classmethod + def param_as_standalone_func(cls, p, func, name): + newfunc = parameterized.param_as_standalone_func(p, func, name) + return unittest.pytest.mark.solver(p.args[0])(newfunc) + + all_solvers = [ ('gurobi_persistent', GurobiPersistent), ('gurobi_direct', GurobiDirect), @@ -112,7 +126,7 @@ def test_all_solvers_list(): class TestDualSignConvention(unittest.TestCase): - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -164,7 +178,7 @@ def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bo self.assertAlmostEqual(duals[m.c1], 0) self.assertAlmostEqual(duals[m.c2], -1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_inequality( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -226,7 +240,7 @@ def test_inequality( self.assertAlmostEqual(duals[m.c1], 0.5) self.assertAlmostEqual(duals[m.c2], 0.5) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_bounds(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -281,7 +295,7 @@ def test_bounds(self, name: str, opt_class: Type[SolverBase], use_presolve: bool rc = res.solution_loader.get_reduced_costs() self.assertAlmostEqual(rc[m.x], -1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_range(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -333,7 +347,7 @@ def test_range(self, name: str, opt_class: Type[SolverBase], use_presolve: bool) self.assertAlmostEqual(duals[m.c1], -0.5) self.assertAlmostEqual(duals[m.c2], -0.5) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_equality_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -387,7 +401,7 @@ def test_equality_max( self.assertAlmostEqual(duals[m.c1], 0) self.assertAlmostEqual(duals[m.c2], 1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_inequality_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -449,7 +463,7 @@ def test_inequality_max( self.assertAlmostEqual(duals[m.c1], -0.5) self.assertAlmostEqual(duals[m.c2], -0.5) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_bounds_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -506,7 +520,7 @@ def test_bounds_max( rc = res.solution_loader.get_reduced_costs() self.assertAlmostEqual(rc[m.x], 1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_range_max( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -563,11 +577,11 @@ def test_range_max( @unittest.skipUnless(numpy_available, 'numpy is not available') class TestSolvers(unittest.TestCase): - @parameterized.expand(input=all_solvers) + @mark_parameterized.expand(input=all_solvers) def test_config_overwrite(self, name: str, opt_class: Type[SolverBase]): self.assertIsNot(SolverBase.CONFIG, opt_class.CONFIG) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_results_object_populated( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -631,7 +645,7 @@ def test_results_object_populated( self.assertIsNotNone(res.solver_log) self.assertIsInstance(res.solver_log, str) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_remove_variable_and_objective( self, name: str, opt_class: Type[SolverBase], use_presolve ): @@ -659,7 +673,7 @@ def test_remove_variable_and_objective( self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, 2) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_stale_vars( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -707,7 +721,7 @@ def test_stale_vars( res.solution_loader.load_vars([m.y]) self.assertFalse(m.y.stale) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_range_constraint( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -735,7 +749,7 @@ def test_range_constraint( duals = res.solution_loader.get_duals() self.assertAlmostEqual(duals[m.c], 1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_reduced_costs( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -764,7 +778,7 @@ def test_reduced_costs( self.assertAlmostEqual(rc[m.x], -3) self.assertAlmostEqual(rc[m.y], -4) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_reduced_costs2( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -791,7 +805,7 @@ def test_reduced_costs2( rc = res.solution_loader.get_reduced_costs() self.assertAlmostEqual(rc[m.x], 1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_param_changes( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -834,7 +848,7 @@ def test_param_changes( self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_immutable_param( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -881,7 +895,7 @@ def test_immutable_param( self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -925,7 +939,7 @@ def test_equality(self, name: str, opt_class: Type[SolverBase], use_presolve: bo self.assertAlmostEqual(duals[m.c1], (1 + a1 / (a2 - a1))) self.assertAlmostEqual(duals[m.c2], -a1 / (a2 - a1)) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_linear_expression( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -970,7 +984,7 @@ def test_linear_expression( bound = res.objective_bound self.assertTrue(bound <= m.y.value) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_no_objective( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1011,7 +1025,7 @@ def test_no_objective( self.assertAlmostEqual(duals[m.c1], 0) self.assertAlmostEqual(duals[m.c2], 0) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_add_remove_cons( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1072,7 +1086,7 @@ def test_add_remove_cons( self.assertAlmostEqual(duals[m.c1], -(1 + a1 / (a2 - a1))) self.assertAlmostEqual(duals[m.c2], a1 / (a2 - a1)) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_results_infeasible( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1128,7 +1142,7 @@ def test_results_infeasible( ): res.solution_loader.get_reduced_costs() - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_duals(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -1156,7 +1170,7 @@ def test_duals(self, name: str, opt_class: Type[SolverBase], use_presolve: bool) self.assertAlmostEqual(duals[m.c1], 0.5) self.assertNotIn(m.c2, duals) - @parameterized.expand(input=_load_tests(qcp_solvers)) + @mark_parameterized.expand(input=_load_tests(qcp_solvers)) def test_mutable_quadratic_coefficient( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1185,7 +1199,7 @@ def test_mutable_quadratic_coefficient( self.assertAlmostEqual(m.x.value, 0.10256137418973625, 4) self.assertAlmostEqual(m.y.value, 0.0869525991355825, 4) - @parameterized.expand(input=_load_tests(qcp_solvers)) + @mark_parameterized.expand(input=_load_tests(qcp_solvers)) def test_mutable_quadratic_objective_qcp( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1217,7 +1231,7 @@ def test_mutable_quadratic_objective_qcp( self.assertAlmostEqual(m.x.value, 0.6962249634573562, 4) self.assertAlmostEqual(m.y.value, 0.09227926676152151, 4) - @parameterized.expand(input=_load_tests(qp_solvers)) + @mark_parameterized.expand(input=_load_tests(qp_solvers)) def test_mutable_quadratic_objective_qp( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1292,7 +1306,7 @@ def test_mutable_quadratic_objective_qp( if opt_class is Highs: self.assertIn(opt._pyomo_var_to_solver_var_map[id(m.x3)], {0, 1}) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_fixed_vars( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1335,7 +1349,7 @@ def test_fixed_vars( self.assertAlmostEqual(m.x.value, 0) self.assertAlmostEqual(m.y.value, 2) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_fixed_vars_2( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1378,7 +1392,7 @@ def test_fixed_vars_2( self.assertAlmostEqual(m.x.value, 0) self.assertAlmostEqual(m.y.value, 2) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_fixed_vars_3( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1400,7 +1414,7 @@ def test_fixed_vars_3( self.assertAlmostEqual(res.incumbent_objective, 3) self.assertAlmostEqual(m.x.value, 2) - @parameterized.expand(input=_load_tests(nlp_solvers)) + @mark_parameterized.expand(input=_load_tests(nlp_solvers)) def test_fixed_vars_4( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1414,7 +1428,7 @@ def test_fixed_vars_4( opt.config.writer_config.linear_presolve = False m = pyo.ConcreteModel() m.x = pyo.Var() - m.y = pyo.Var() + m.y = pyo.Var(bounds=(-1.4, None)) m.obj = pyo.Objective(expr=m.x**2 + m.y**2) m.c1 = pyo.Constraint(expr=m.x == 2 / m.y) m.y.fix(1) @@ -1422,10 +1436,12 @@ def test_fixed_vars_4( self.assertAlmostEqual(m.x.value, 2) m.y.unfix() res = opt.solve(m) + # The global minimum is +/-(2**.5, 2**.5) without bounds. + # Bounds force it to the positive side self.assertAlmostEqual(m.x.value, 2**0.5, delta=1e-3) self.assertAlmostEqual(m.y.value, 2**0.5, delta=1e-3) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_mutable_param_with_range( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1522,7 +1538,7 @@ def test_mutable_param_with_range( self.assertAlmostEqual(duals[m.con1], (1 + a1 / (a2 - a1)), 6) self.assertAlmostEqual(duals[m.con2], -a1 / (a2 - a1), 6) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_add_and_remove_vars( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1573,7 +1589,7 @@ def test_add_and_remove_vars( self.assertEqual(m.x.value, None) self.assertAlmostEqual(m.y.value, -1) - @parameterized.expand(input=_load_tests(nlp_solvers)) + @mark_parameterized.expand(input=_load_tests(nlp_solvers)) def test_exp(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt = opt_class() if not opt.available(): @@ -1592,7 +1608,7 @@ def test_exp(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): self.assertAlmostEqual(m.x.value, -0.42630274815985264, delta=1e-3) self.assertAlmostEqual(m.y.value, 0.6529186341994245, delta=1e-3) - @parameterized.expand(input=_load_tests(nlp_solvers)) + @mark_parameterized.expand(input=_load_tests(nlp_solvers)) def test_log(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt = opt_class() if not opt.available(): @@ -1611,7 +1627,7 @@ def test_log(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): self.assertAlmostEqual(m.x.value, 0.6529186341994245) self.assertAlmostEqual(m.y.value, -0.42630274815985264) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_with_numpy( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1642,7 +1658,7 @@ def test_with_numpy( self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2)) self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_bounds_with_params( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1681,7 +1697,7 @@ def test_bounds_with_params( res = opt.solve(m) self.assertAlmostEqual(m.y.value, 3) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_solution_loader( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1738,7 +1754,7 @@ def test_solution_loader( self.assertIn(m.c1, duals) self.assertAlmostEqual(duals[m.c1], 1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_time_limit( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1801,7 +1817,7 @@ def test_time_limit( {TerminationCondition.maxTimeLimit, TerminationCondition.iterationLimit}, ) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_objective_changes( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1876,7 +1892,7 @@ def test_objective_changes( res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 4) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_domain(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -1905,7 +1921,7 @@ def test_domain(self, name: str, opt_class: Type[SolverBase], use_presolve: bool res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 0) - @parameterized.expand(input=_load_tests(mip_solvers)) + @mark_parameterized.expand(input=_load_tests(mip_solvers)) def test_domain_with_integers( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1936,7 +1952,7 @@ def test_domain_with_integers( res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_fixed_binaries( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -1968,7 +1984,7 @@ def test_fixed_binaries( res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, 1) - @parameterized.expand(input=_load_tests(mip_solvers)) + @mark_parameterized.expand(input=_load_tests(mip_solvers)) def test_with_gdp(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -2004,7 +2020,7 @@ def test_with_gdp(self, name: str, opt_class: Type[SolverBase], use_presolve: bo self.assertAlmostEqual(m.x.value, 0) self.assertAlmostEqual(m.y.value, 1) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_variables_elsewhere( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -2038,7 +2054,7 @@ def test_variables_elsewhere( self.assertAlmostEqual(m.x.value, 0) self.assertAlmostEqual(m.y.value, 2) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_variables_elsewhere2( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -2080,7 +2096,7 @@ def test_variables_elsewhere2( self.assertIn(m.y, sol) self.assertNotIn(m.z, sol) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_bug_1(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -2108,7 +2124,7 @@ def test_bug_1(self, name: str, opt_class: Type[SolverBase], use_presolve: bool) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(res.incumbent_objective, 3) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_bug_2(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): """ This test is for a bug where an objective containing a fixed variable does @@ -2139,7 +2155,7 @@ def test_bug_2(self, name: str, opt_class: Type[SolverBase], use_presolve: bool) res = opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, -18, 5) - @parameterized.expand(input=_load_tests(nl_solvers)) + @mark_parameterized.expand(input=_load_tests(nl_solvers)) def test_presolve_with_zero_coef( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -2204,7 +2220,7 @@ def test_presolve_with_zero_coef( ) self.assertEqual(res.termination_condition, exp) - @parameterized.expand(input=_load_tests(all_solvers)) + @mark_parameterized.expand(input=_load_tests(all_solvers)) def test_scaling(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): opt: SolverBase = opt_class() if not opt.available(): @@ -2261,7 +2277,7 @@ def test_scaling(self, name: str, opt_class: Type[SolverBase], use_presolve: boo self.assertAlmostEqual(rc[m.x], 1) self.assertAlmostEqual(rc[m.y], 0) - @parameterized.expand(input=_load_tests([("highs", Highs)])) + @mark_parameterized.expand(input=_load_tests([("highs", Highs)])) def test_node_limit( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -2279,7 +2295,7 @@ def test_node_limit( ) assert res.termination_condition == TerminationCondition.iterationLimit - @parameterized.expand(input=_load_tests(nl_solvers)) + @mark_parameterized.expand(input=_load_tests(nl_solvers)) def test_external_function( self, name: str, opt_class: Type[SolverBase], use_presolve: bool ): @@ -2298,7 +2314,7 @@ def test_external_function( class TestLegacySolverInterface(unittest.TestCase): - @parameterized.expand(input=all_solvers) + @mark_parameterized.expand(input=all_solvers) def test_param_updates(self, name: str, opt_class: Type[SolverBase]): opt = pyo.SolverFactory(name + '_v2') if not opt.available(exception_flag=False): @@ -2328,7 +2344,7 @@ def test_param_updates(self, name: str, opt_class: Type[SolverBase]): self.assertAlmostEqual(m.dual[m.c1], (1 + a1 / (a2 - a1))) self.assertAlmostEqual(m.dual[m.c2], a1 / (a2 - a1)) - @parameterized.expand(input=all_solvers) + @mark_parameterized.expand(input=all_solvers) def test_load_solutions(self, name: str, opt_class: Type[SolverBase]): opt = pyo.SolverFactory(name + '_v2') if not opt.available(exception_flag=False): diff --git a/pyomo/solvers/plugins/solvers/xpress_direct.py b/pyomo/solvers/plugins/solvers/xpress_direct.py index 72b1447bb82..eacf67fd65f 100644 --- a/pyomo/solvers/plugins/solvers/xpress_direct.py +++ b/pyomo/solvers/plugins/solvers/xpress_direct.py @@ -58,24 +58,57 @@ def _print_message(xp_prob, _, msg, *args): def _finalize_xpress_import(xpress, avail): if not avail: return - XpressDirect._version = tuple(int(k) for k in xpress.getversion().split('.')) + xp = xpress + XpressDirect._version = tuple(int(k) for k in xp.getversion().split('.')) XpressDirect._name = "Xpress %s.%s.%s" % XpressDirect._version - # in versions prior to 34, xpress raised a RuntimeError, but - # in more recent versions it raises a - # xpress.ModelError. We'll cache the appropriate one here - if XpressDirect._version[0] < 34: - XpressDirect.XpressException = RuntimeError + # In (pypi) versions prior to 8.13.0, the 'xp.rng' keyword was + # 'xp.range' + if not hasattr(xp, 'rng'): + xp.rng = xp.range + + # Xpress 9.6 (45.1.1) renamed many of its enums, deprecating the old ones + if XpressDirect._version < (45,): + XpressDirect.LPStatus.UNSTARTED = xp.lp_unstarted + XpressDirect.LPStatus.OPTIMAL = xp.lp_optimal + XpressDirect.LPStatus.INFEAS = xp.lp_infeas + XpressDirect.LPStatus.CUTOFF = xp.lp_cutoff + XpressDirect.LPStatus.NONCONVEX = xp.lp_nonconvex + XpressDirect.LPStatus.UNFINISHED = xp.lp_unfinished + XpressDirect.LPStatus.UNBOUNDED = xp.lp_unbounded + XpressDirect.LPStatus.UNSOLVED = xp.lp_unsolved + XpressDirect.LPStatus.CUTOFF_IN_DUAL = xp.lp_cutoff_in_dual + XpressDirect.MIPStatus.INFEAS = xp.mip_infeas + XpressDirect.MIPStatus.LP_NOT_OPTIMAL = xp.mip_lp_not_optimal + XpressDirect.MIPStatus.LP_OPTIMAL = xp.mip_lp_optimal + XpressDirect.MIPStatus.NO_SOL_FOUND = xp.mip_no_sol_found + XpressDirect.MIPStatus.NOT_LOADED = xp.mip_not_loaded + XpressDirect.MIPStatus.OPTIMAL = xp.mip_optimal + XpressDirect.MIPStatus.SOLUTION = xp.mip_solution + XpressDirect.MIPStatus.UNBOUNDED = xp.mip_unbounded + XpressDirect.NLPStatus.OPTIMAL = xp.nlp_globally_optimal + XpressDirect.NLPStatus.INFEASIBLE = xp.nlp_infeasible + XpressDirect.NLPStatus.LOCALLY_INFEASIBLE = xp.nlp_locally_infeasible + XpressDirect.NLPStatus.LOCALLY_OPTIMAL = xp.nlp_locally_optimal + XpressDirect.NLPStatus.SOLUTION = xp.nlp_solution + XpressDirect.NLPStatus.UNBOUNDED = xp.nlp_unbounded + XpressDirect.NLPStatus.UNFINISHED = xp.nlp_unfinished + XpressDirect.NLPStatus.UNSTARTED = xp.nlp_unstarted else: - XpressDirect.XpressException = xpress.ModelError - # In (pypi) versions prior to 8.13.0, the 'xpress.rng' keyword was - # 'xpress.range' - if not hasattr(xpress, 'rng'): - xpress.rng = xpress.range + XpressDirect.LPStatus = xp.LPStatus + XpressDirect.MIPStatus = xp.MIPStatus + XpressDirect.NLPStatus.OPTIMAL = xp.constants.NLPSTATUS_OPTIMAL + XpressDirect.NLPStatus.INFEASIBLE = xp.constants.NLPSTATUS_INFEASIBLE + XpressDirect.NLPStatus.LOCALLY_OPTIMAL = xp.constants.NLPSTATUS_LOCALLY_OPTIMAL + XpressDirect.NLPStatus.SOLUTION = xp.constants.NLPSTATUS_SOLUTION + XpressDirect.NLPStatus.UNBOUNDED = xp.constants.NLPSTATUS_UNBOUNDED + XpressDirect.NLPStatus.UNFINISHED = xp.constants.NLPSTATUS_UNFINISHED + XpressDirect.NLPStatus.UNSTARTED = xp.constants.NLPSTATUS_UNSTARTED + XpressDirect.NLPStatus.LOCALLY_INFEASIBLE = ( + xp.constants.NLPSTATUS_LOCALLY_INFEASIBLE + ) - # # Xpress 9.5 (44.1.1) changed the Python API fairly significantly. # We will map between the two APIs based on the version. - # if XpressDirect._version < (44,): def _addConstraint( @@ -97,17 +130,17 @@ def _addConstraint( for field in ('constraint', 'body', 'lb', 'ub', 'rhs'): if locals()[field] is not None: args[field] = locals()[field] - con = xpress.constraint(**args) + con = xp.constraint(**args) prob.addConstraint(con) return con def _addVariable(self, prob, name, lb, ub, vartype): - var = xpress.var(name=name, lb=lb, ub=ub, vartype=vartype) + var = xp.var(name=name, lb=lb, ub=ub, vartype=vartype) prob.addVariable(var) return var def _addSOS(self, prob, indices, weights, type, name): - con = xpress.sos(indices, weights, type, name) + con = xp.sos(indices, weights, type, name) prob.addSOS(con) return con @@ -132,7 +165,7 @@ def _addConstraint( rhs=None, name='', ): - con = xpress.constraint( + con = xp.constraint( constraint=constraint, body=body, lb=lb, @@ -159,12 +192,107 @@ def _addConstraint( XpressDirect._getDuals = lambda self, prob, con: prob.getDuals(con) XpressDirect._getRedCosts = lambda self, prob, con: prob.getRedCosts(con) - # Note that as of 9.5, xpress.var raises an exception when + # Note that as of 9.5, xp.var raises an exception when # compared using '==' after it has been removed from the model. # This can foul up ComponentMaps in the persistent interface, # so we will hard-code the `var` as not being hashable (so the # ComponentMap will use the id() as the key) - ComponentMap.hasher.hashable(xpress.var, False) + ComponentMap.hasher.hashable(xp.var, False) + + # Xpress 9.8 (46) adopted camelcase function naming, deprecating the old names + if XpressDirect._version < (46,): + XpressDirect._setLogFile = lambda self, prob, *args, **kwargs: prob.setlogfile( + *args, **kwargs + ) + XpressDirect._lpOptimize = lambda self, prob, *args, **kwargs: prob.lpoptimize( + *args, **kwargs + ) + XpressDirect._mipOptimize = ( + lambda self, prob, *args, **kwargs: prob.mipoptimize(*args, **kwargs) + ) + XpressDirect._nlpOptimize = ( + lambda self, prob, *args, **kwargs: prob.nlpoptimize(*args, **kwargs) + ) + XpressDirect._postSolve = lambda self, prob, *args, **kwargs: prob.postsolve( + *args, **kwargs + ) + XpressDirect._chgBounds = lambda sef, prob, *args, **kwargs: prob.chgbounds( + *args, **kwargs + ) + XpressDirect._addMipSol = lambda self, prob, *args, **kwargs: prob.addmipsol( + *args, **kwargs + ) + XpressDirect._addCols = lambda self, prob, objx, mstart, mrwind, dmatval, bdl, bdu, names, types: prob.addcols( + objx, mstart, mrwind, dmatval, bdl, bdu, names, types + ) + XpressDirect._chgColType = lambda self, prob, *args, **kwargs: prob.chgcoltype( + *args, *kwargs + ) + XpressDirect._getIndex = ( + lambda self, prob, *args, **kwargs: prob.getIndexFromName(*args, **kwargs) + ) + XpressDirect._getObjIndex = lambda self, prob, obj: prob.getIndex(obj) + + def _getLB(self, prob, *args, **kwargs): + lb = [] + prob.getlb(lb, *args, *kwargs) + return lb + + def _getUB(self, prob, *args, **kwargs): + ub = [] + prob.getub(ub, *args, *kwargs) + return ub + + XpressDirect._getLB = _getLB + XpressDirect._getUB = _getUB + + else: + XpressDirect._setLogFile = lambda self, prob, *args, **kwargs: prob.setLogFile( + *args, **kwargs + ) + XpressDirect._lpOptimize = lambda self, prob, *args, **kwargs: prob.lpOptimize( + *args, **kwargs + ) + XpressDirect._mipOptimize = ( + lambda self, prob, *args, **kwargs: prob.mipOptimize(*args, **kwargs) + ) + XpressDirect._nlpOptimize = ( + lambda self, prob, *args, **kwargs: prob.nlpOptimize(*args, **kwargs) + ) + XpressDirect._postSolve = lambda self, prob, *args, **kwargs: prob.postSolve( + *args, **kwargs + ) + XpressDirect._chgBounds = lambda sef, prob, *args, **kwargs: prob.chgBounds( + *args, **kwargs + ) + XpressDirect._addMipSol = lambda self, prob, *args, **kwargs: prob.addMipSol( + *args, **kwargs + ) + XpressDirect._chgColType = lambda self, prob, *args, **kwargs: prob.chgColType( + *args, *kwargs + ) + XpressDirect._getIndex = lambda self, prob, *args, **kwargs: prob.getIndex( + *args, **kwargs + ) + XpressDirect._getObjIndex = lambda self, prob, obj: obj.index + XpressDirect._getLB = lambda self, prob, *args, **kwargs: prob.getLB( + *args, **kwargs + ) + XpressDirect._getUB = lambda self, prob, *args, **kwargs: prob.getUB( + *args, **kwargs + ) + + def _addCols(self, prob, objx, mstart, mrwind, dmatval, bdl, bdu, names, types): + first_col_ind = prob.attributes.cols + prob.addCols(objx, mstart, mrwind, dmatval, bdl, bdu) + last_col_ind = prob.attributes.cols - 1 + if names is not None: + prob.addNames(xp.Namespaces.COLUMN, names, first_col_ind, last_col_ind) + if types is not None: + col_indices = list(range(first_col_ind, last_col_ind + 1)) + prob.chgColType(col_indices, types) + + XpressDirect._addCols = _addCols class _xpress_importer_class: @@ -200,6 +328,21 @@ class XpressDirect(DirectSolver): _version = None XpressException = RuntimeError + class LPStatus: + """LP Status constants compatible across Xpress versions.""" + + pass + + class MIPStatus: + """MIP Status constants compatible across Xpress versions.""" + + pass + + class NLPStatus: + """NLP Status constants compatible across Xpress versions.""" + + pass + def __init__(self, **kwds): if 'type' not in kwds: kwds['type'] = 'xpress_direct' @@ -272,17 +415,10 @@ def _presolve(self, *args, **kwds): def _apply_solver(self): StaleFlagManager.mark_all_as_stale() - self._solver_model.setlogfile(self._log_file) + self._setLogFile(self._solver_model, self._log_file) if self._keepfiles: print("Solver log file: " + self._log_file) - # Setting a log file in xpress disables all output - # in xpress versions less than 36. - # This callback prints all messages to stdout - # when using those xpress versions. - if self._tee and XpressDirect._version[0] < 36: - self._solver_model.addcbmessage(_print_message, None, 0) - # set xpress options # if the user specifies a 'mipgap', set it, and # set xpress's related options to 0. @@ -299,7 +435,7 @@ def _apply_solver(self): continue try: self._solver_model.setControl(key, option) - except XpressDirect.XpressException: + except xpress.ModelError: # take another try, converting to its type # we'll wrap this in a function to raise the # xpress error @@ -315,20 +451,30 @@ def _apply_solver(self): # In xpress versions greater than or equal 36, # it seems difficult to completely suppress console # output without disabling logging altogether. - # As a work around, we capature all screen output + # As a work around, we capture all screen output # when tee is False. with capture_output() as OUT: self._solve_model() self._opt_time = time.time() - start_time - self._solver_model.setlogfile('') - if self._tee and XpressDirect._version[0] < 36: - self._solver_model.removecbmessage(_print_message, None) + self._setLogFile(self._solver_model, '') # FIXME: can we get a return code indicating if XPRESS had a # significant failure? return Bunch(rc=None, log=None) + def _get_lb(self, var): + """Return the upper bound associated to the pyomo variable object""" + xp_var = self._pyomo_var_to_solver_var_map[var] + var_idx = self._getObjIndex(self._solver_model, xp_var) + return self._getLB(self._solver_model, var_idx, var_idx)[0] + + def _get_ub(self, var): + """Return the upper bound associated to the pyomo variable object""" + xp_var = self._pyomo_var_to_solver_var_map[var] + var_idx = self._getObjIndex(self._solver_model, xp_var) + return self._getUB(self._solver_model, var_idx, var_idx)[0] + def _get_mip_results(self, results, soln): """Sets up `results` and `soln` and returns whether there is a solution to query. @@ -339,7 +485,7 @@ def _get_mip_results(self, results, soln): xprob_attrs = xprob.attributes status = xprob_attrs.mipstatus mip_sols = xprob_attrs.mipsols - if status == xp.mip_not_loaded: + if status == XpressDirect.MIPStatus.NOT_LOADED: results.solver.status = SolverStatus.aborted results.solver.termination_message = ( "Model is not loaded; no solution information is available." @@ -349,9 +495,9 @@ def _get_mip_results(self, results, soln): # no MIP solution, first LP did not solve, second LP did, # third search started but incomplete elif ( - status == xp.mip_lp_not_optimal - or status == xp.mip_lp_optimal - or status == xp.mip_no_sol_found + status == XpressDirect.MIPStatus.LP_NOT_OPTIMAL + or status == XpressDirect.MIPStatus.LP_OPTIMAL + or status == XpressDirect.MIPStatus.NO_SOL_FOUND ): results.solver.status = SolverStatus.aborted results.solver.termination_message = ( @@ -359,7 +505,7 @@ def _get_mip_results(self, results, soln): ) results.solver.termination_condition = TerminationCondition.error soln.status = SolutionStatus.unknown - elif status == xp.mip_solution: # some solution available + elif status == XpressDirect.MIPStatus.SOLUTION: # some solution available results.solver.status = SolverStatus.warning results.solver.termination_message = ( "Unable to satisfy optimality tolerances; a sub-optimal " @@ -367,12 +513,12 @@ def _get_mip_results(self, results, soln): ) results.solver.termination_condition = TerminationCondition.other soln.status = SolutionStatus.feasible - elif status == xp.mip_infeas: # MIP proven infeasible + elif status == XpressDirect.MIPStatus.INFEAS: # MIP proven infeasible results.solver.status = SolverStatus.warning results.solver.termination_message = "Model was proven to be infeasible" results.solver.termination_condition = TerminationCondition.infeasible soln.status = SolutionStatus.infeasible - elif status == xp.mip_optimal: # optimal + elif status == XpressDirect.MIPStatus.OPTIMAL: # optimal results.solver.status = SolverStatus.ok results.solver.termination_message = ( "Model was solved to optimality (subject to tolerances), " @@ -380,7 +526,7 @@ def _get_mip_results(self, results, soln): ) results.solver.termination_condition = TerminationCondition.optimal soln.status = SolutionStatus.optimal - elif status == xp.mip_unbounded and mip_sols > 0: + elif status == XpressDirect.MIPStatus.UNBOUNDED and mip_sols > 0: results.solver.status = SolverStatus.warning results.solver.termination_message = ( "LP relaxation was proven to be unbounded, " @@ -388,7 +534,7 @@ def _get_mip_results(self, results, soln): ) results.solver.termination_condition = TerminationCondition.unbounded soln.status = SolutionStatus.unbounded - elif status == xp.mip_unbounded and mip_sols <= 0: + elif status == XpressDirect.MIPStatus.UNBOUNDED and mip_sols <= 0: results.solver.status = SolverStatus.warning results.solver.termination_message = ( "LP relaxation was proven to be unbounded." @@ -408,20 +554,20 @@ def _get_mip_results(self, results, soln): if xprob_attrs.objsense == 1.0: # minimizing MIP try: results.problem.upper_bound = xprob_attrs.mipbestobjval - except (XpressDirect.XpressException, AttributeError): + except (xpress.ModelError, AttributeError): pass try: results.problem.lower_bound = xprob_attrs.bestbound - except (XpressDirect.XpressException, AttributeError): + except (xpress.ModelError, AttributeError): pass elif xprob_attrs.objsense == -1.0: # maximizing MIP try: results.problem.upper_bound = xprob_attrs.bestbound - except (XpressDirect.XpressException, AttributeError): + except (xpress.ModelError, AttributeError): pass try: results.problem.lower_bound = xprob_attrs.mipbestobjval - except (XpressDirect.XpressException, AttributeError): + except (xpress.ModelError, AttributeError): pass return mip_sols > 0 @@ -435,14 +581,14 @@ def _get_lp_results(self, results, soln): xp = xpress xprob_attrs = xprob.attributes status = xprob_attrs.lpstatus - if status == xp.lp_unstarted: + if status == XpressDirect.LPStatus.UNSTARTED: results.solver.status = SolverStatus.aborted results.solver.termination_message = ( "Model is not loaded; no solution information is available." ) results.solver.termination_condition = TerminationCondition.error soln.status = SolutionStatus.unknown - elif status == xp.lp_optimal: + elif status == XpressDirect.LPStatus.OPTIMAL: results.solver.status = SolverStatus.ok results.solver.termination_message = ( "Model was solved to optimality (subject to tolerances), " @@ -450,12 +596,12 @@ def _get_lp_results(self, results, soln): ) results.solver.termination_condition = TerminationCondition.optimal soln.status = SolutionStatus.optimal - elif status == xp.lp_infeas: + elif status == XpressDirect.LPStatus.INFEAS: results.solver.status = SolverStatus.warning results.solver.termination_message = "Model was proven to be infeasible" results.solver.termination_condition = TerminationCondition.infeasible soln.status = SolutionStatus.infeasible - elif status == xp.lp_cutoff: + elif status == XpressDirect.LPStatus.CUTOFF: results.solver.status = SolverStatus.ok results.solver.termination_message = ( "Optimal objective for model was proven to be worse than the " @@ -463,26 +609,26 @@ def _get_lp_results(self, results, soln): ) results.solver.termination_condition = TerminationCondition.minFunctionValue soln.status = SolutionStatus.optimal - elif status == xp.lp_unfinished: + elif status == XpressDirect.LPStatus.UNFINISHED: results.solver.status = SolverStatus.aborted results.solver.termination_message = ( "Optimization was terminated by the user." ) results.solver.termination_condition = TerminationCondition.error soln.status = SolutionStatus.error - elif status == xp.lp_unbounded: + elif status == XpressDirect.LPStatus.UNBOUNDED: results.solver.status = SolverStatus.warning results.solver.termination_message = "Model was proven to be unbounded." results.solver.termination_condition = TerminationCondition.unbounded soln.status = SolutionStatus.unbounded - elif status == xp.lp_cutoff_in_dual: + elif status == XpressDirect.LPStatus.CUTOFF_IN_DUAL: results.solver.status = SolverStatus.ok results.solver.termination_message = ( "Xpress reported the LP was cutoff in the dual." ) results.solver.termination_condition = TerminationCondition.minFunctionValue soln.status = SolutionStatus.optimal - elif status == xp.lp_unsolved: + elif status == XpressDirect.LPStatus.UNSOLVED: results.solver.status = SolverStatus.error results.solver.termination_message = ( "Optimization was terminated due to unrecoverable numerical " @@ -490,7 +636,7 @@ def _get_lp_results(self, results, soln): ) results.solver.termination_condition = TerminationCondition.error soln.status = SolutionStatus.error - elif status == xp.lp_nonconvex: + elif status == XpressDirect.LPStatus.NONCONVEX: results.solver.status = SolverStatus.error results.solver.termination_message = ( "Optimization was terminated because nonconvex quadratic data " @@ -511,16 +657,16 @@ def _get_lp_results(self, results, soln): try: results.problem.upper_bound = xprob_attrs.lpobjval results.problem.lower_bound = xprob_attrs.lpobjval - except (XpressDirect.XpressException, AttributeError): + except (xpress.ModelError, AttributeError): pass # Not all solution information will be available in all cases, it is # up to the caller/user to check the actual status and figure which # of x, slack, duals, reduced costs are valid. return xprob_attrs.lpstatus in [ - xp.lp_optimal, - xp.lp_cutoff, - xp.lp_cutoff_in_dual, + XpressDirect.LPStatus.OPTIMAL, + XpressDirect.LPStatus.CUTOFF, + XpressDirect.LPStatus.CUTOFF_IN_DUAL, ] def _get_nlp_results(self, results, soln): @@ -547,15 +693,15 @@ def _get_nlp_results(self, results, soln): solstatus = xprob_attrs.xslp_solstatus have_soln = False optimal = False # *globally* optimal? - if status == xp.nlp_unstarted: + if status == XpressDirect.NLPStatus.UNSTARTED: results.solver.status = SolverStatus.unknown results.solver.termination_message = ( "Non-convex model solve was not started" ) results.solver.termination_condition = TerminationCondition.unknown soln.status = SolutionStatus.unknown - elif status == xp.nlp_locally_optimal: - # This is either xp.nlp_locally_optimal or xp.nlp_solution + elif status == XpressDirect.NLPStatus.LOCALLY_OPTIMAL: + # This is either XpressDirect.NLPStatus.LOCALLY_OPTIMAL or XpressDirect.NLPStatus.SOLUTION # we must look at the solstatus to figure out which if solstatus in [2, 3]: results.solver.status = SolverStatus.ok @@ -574,7 +720,7 @@ def _get_nlp_results(self, results, soln): results.solver.termination_condition = TerminationCondition.feasible soln.status = SolutionStatus.feasible have_soln = True - elif status == xp.nlp_globally_optimal: + elif status == XpressDirect.NLPStatus.OPTIMAL: results.solver.status = SolverStatus.ok results.solver.termination_message = ( "Non-convex model was solved to global optimality" @@ -583,26 +729,26 @@ def _get_nlp_results(self, results, soln): soln.status = SolutionStatus.optimal have_soln = True optimal = True - elif status == xp.nlp_locally_infeasible: + elif status == XpressDirect.NLPStatus.LOCALLY_INFEASIBLE: results.solver.status = SolverStatus.ok results.solver.termination_message = ( "Non-convex model was proven to be locally infeasible" ) results.solver.termination_condition = TerminationCondition.noSolution soln.status = SolutionStatus.unknown - elif status == xp.nlp_infeasible: + elif status == XpressDirect.NLPStatus.INFEASIBLE: results.solver.status = SolverStatus.ok results.solver.termination_message = ( "Non-convex model was proven to be infeasible" ) results.solver.termination_condition = TerminationCondition.infeasible soln.status = SolutionStatus.infeasible - elif status == xp.nlp_unbounded: # locally unbounded! + elif status == XpressDirect.NLPStatus.UNBOUNDED: # locally unbounded! results.solver.status = SolverStatus.ok results.solver.termination_message = "Non-convex model is locally unbounded" results.solver.termination_condition = TerminationCondition.unbounded soln.status = SolutionStatus.unbounded - elif status == xp.nlp_unfinished: + elif status == XpressDirect.NLPStatus.UNFINISHED: results.solver.status = SolverStatus.ok results.solver.termination_message = ( "Non-convex solve not finished (numerical issues?)" @@ -625,7 +771,7 @@ def _get_nlp_results(self, results, soln): results.problem.upper_bound = xprob_attrs.xslp_objval if xprob_attrs.objsense < 0.0 or optimal: # maximizing results.problem.lower_bound = xprob_attrs.xslp_objval - except (XpressDirect.XpressException, AttributeError): + except (xpress.ModelError, AttributeError): pass return have_soln @@ -635,23 +781,23 @@ def _solve_model(self): self._warm_start() xprob = self._solver_model - is_mip = (xprob.attributes.mipents > 0) or (xprob.attributes.sets > 0) + # Check for quadratic objective or quadratic constraints. If there are # any then we call nlpoptimize since that can handle non-convex # quadratics as well. In case of convex quadratics it will call # mipoptimize under the hood. if (xprob.attributes.qelems > 0) or (xprob.attributes.qcelems > 0): - xprob.nlpoptimize("g" if is_mip else "") + self._nlpOptimize(xprob, "g" if is_mip else "") self._get_results = self._get_nlp_results elif is_mip: - xprob.mipoptimize() + self._mipOptimize(xprob) self._get_results = self._get_mip_results else: - xprob.lpoptimize() + self._lpOptimize(xprob) self._get_results = self._get_lp_results - self._solver_model.postsolve() + self._postSolve(xprob) def _get_expr_from_pyomo_repn(self, repn, max_degree=2): referenced_vars = ComponentSet() @@ -733,10 +879,10 @@ def _add_var(self, var): ## by the method above if vartype == xpress.binary: if lb == ub: - self._solver_model.chgbounds([xpress_var], ['B'], [lb]) + self._chgBounds(self._solver_model, [xpress_var], ['B'], [lb]) else: - self._solver_model.chgbounds( - [xpress_var, xpress_var], ['L', 'U'], [lb, ub] + self._chgBounds( + self._solver_model, [xpress_var, xpress_var], ['L', 'U'], [lb, ub] ) self._pyomo_var_to_solver_var_map[var] = xpress_var @@ -1112,7 +1258,8 @@ def _warm_start(self): if pyomo_var.value is not None: mipsolval.append(value(pyomo_var)) mipsolcol.append(xpress_var) - self._solver_model.addmipsol(mipsolval, mipsolcol) + if len(mipsolval) > 0: + self._addMipSol(self._solver_model, mipsolval, mipsolcol) def _load_vars(self, vars_to_load=None): var_map = self._pyomo_var_to_solver_var_map diff --git a/pyomo/solvers/plugins/solvers/xpress_persistent.py b/pyomo/solvers/plugins/solvers/xpress_persistent.py index cafa3eb522d..ea103901ab0 100644 --- a/pyomo/solvers/plugins/solvers/xpress_persistent.py +++ b/pyomo/solvers/plugins/solvers/xpress_persistent.py @@ -108,8 +108,10 @@ def update_var(self, var): qctype = self._xpress_chgcoltype_from_var(var) lb, ub = self._xpress_lb_ub_from_var(var) - self._solver_model.chgcoltype([xpress_var], [qctype]) - self._solver_model.chgbounds([xpress_var, xpress_var], ['L', 'U'], [lb, ub]) + XpressDirect._chgColType(self, self._solver_model, [xpress_var], [qctype]) + XpressDirect._chgBounds( + self, self._solver_model, [xpress_var, xpress_var], ['L', 'U'], [lb, ub] + ) def _add_column(self, var, obj_coef, constraints, coefficients): """Add a column to the solver's model @@ -133,7 +135,9 @@ def _add_column(self, var, obj_coef, constraints, coefficients): vartype = self._xpress_chgcoltype_from_var(var) lb, ub = self._xpress_lb_ub_from_var(var) - self._solver_model.addcols( + XpressDirect._addCols( + self, + self._solver_model, objx=[obj_coef], mstart=[0, len(coefficients)], mrwind=constraints, @@ -145,7 +149,7 @@ def _add_column(self, var, obj_coef, constraints, coefficients): ) xpress_var = self._solver_model.getVariable( - index=self._solver_model.getIndexFromName(type=2, name=varname) + index=XpressDirect._getIndex(self, self._solver_model, type=2, name=varname) ) self._pyomo_var_to_solver_var_map[var] = xpress_var diff --git a/pyomo/solvers/tests/checks/test_BARON.py b/pyomo/solvers/tests/checks/test_BARON.py index b9317e06ac8..68d8a10c3eb 100644 --- a/pyomo/solvers/tests/checks/test_BARON.py +++ b/pyomo/solvers/tests/checks/test_BARON.py @@ -24,6 +24,7 @@ @unittest.skipIf(not baron_available, "The 'BARON' solver is not available") +@unittest.pytest.mark.solver("baron") class BaronTest(unittest.TestCase): """Test the BARON interface.""" diff --git a/pyomo/solvers/tests/checks/test_CBCplugin.py b/pyomo/solvers/tests/checks/test_CBCplugin.py index f1988e579b6..8d2cbb85e43 100644 --- a/pyomo/solvers/tests/checks/test_CBCplugin.py +++ b/pyomo/solvers/tests/checks/test_CBCplugin.py @@ -35,6 +35,8 @@ data_dir = '{}/data'.format(dirname(abspath(__file__))) +@unittest.skipIf(not cbc_available, "The 'cbc' solver is not available") +@unittest.pytest.mark.solver("cbc") class TestCBC(unittest.TestCase): """ These tests are here to test the general functionality of the cbc solver when using the lp solverio, which will @@ -51,7 +53,6 @@ def setUp(self): def tearDown(self): sys.stderr = self.stderr - @unittest.skipIf(not cbc_available, "The 'cbc' solver is not available") def test_infeasible_lp(self): self.model.X = Var(within=Reals) self.model.C1 = Constraint(expr=self.model.X <= 1) @@ -69,7 +70,6 @@ def test_infeasible_lp(self): ) self.assertEqual(SolverStatus.warning, results.solver.status) - @unittest.skipIf(not cbc_available, "The 'cbc' solver is not available") def test_unbounded_lp(self): self.model.Idx = RangeSet(2) self.model.X = Var(self.model.Idx, within=Reals) @@ -88,7 +88,6 @@ def test_unbounded_lp(self): ) self.assertEqual(SolverStatus.warning, results.solver.status) - @unittest.skipIf(not cbc_available, "The 'cbc' solver is not available") def test_optimal_lp(self): self.model.X = Var(within=NonNegativeReals) self.model.Obj = Objective(expr=self.model.X, sense=minimize) @@ -107,7 +106,6 @@ def test_optimal_lp(self): ) self.assertEqual(SolverStatus.ok, results.solver.status) - @unittest.skipIf(not cbc_available, "The 'cbc' solver is not available") def test_infeasible_mip(self): self.model.X = Var(within=NonNegativeIntegers) self.model.C1 = Constraint(expr=self.model.X <= 1) @@ -125,7 +123,6 @@ def test_infeasible_mip(self): ) self.assertEqual(SolverStatus.warning, results.solver.status) - @unittest.skipIf(not cbc_available, "The 'cbc' solver is not available") def test_unbounded_mip(self): self.model.X = Var(within=Integers) self.model.Obj = Objective(expr=self.model.X, sense=minimize) @@ -141,7 +138,6 @@ def test_unbounded_mip(self): ) self.assertEqual(SolverStatus.warning, results.solver.status) - @unittest.skipIf(not cbc_available, "The 'cbc' solver is not available") def test_optimal_mip(self): self.model.Idx = RangeSet(2) self.model.X = Var(self.model.Idx, within=NonNegativeIntegers) diff --git a/pyomo/solvers/tests/checks/test_CPLEXDirect.py b/pyomo/solvers/tests/checks/test_CPLEXDirect.py index 3585477812f..b672f0c4bcd 100644 --- a/pyomo/solvers/tests/checks/test_CPLEXDirect.py +++ b/pyomo/solvers/tests/checks/test_CPLEXDirect.py @@ -43,6 +43,8 @@ diff_tol = 1e-4 +@unittest.skipIf(not cplexpy_available, "The 'cplex' python bindings are not available") +@unittest.pytest.mark.solver("cplex_direct") class CPLEXDirectTests(unittest.TestCase): def setUp(self): self.stderr = sys.stderr @@ -51,9 +53,6 @@ def setUp(self): def tearDown(self): sys.stderr = self.stderr - @unittest.skipIf( - not cplexpy_available, "The 'cplex' python bindings are not available" - ) def test_infeasible_lp(self): with SolverFactory("cplex", solver_io="python") as opt: model = ConcreteModel() @@ -68,9 +67,6 @@ def test_infeasible_lp(self): results.solver.termination_condition, TerminationCondition.infeasible ) - @unittest.skipIf( - not cplexpy_available, "The 'cplex' python bindings are not available" - ) def test_unbounded_lp(self): with SolverFactory("cplex", solver_io="python") as opt: model = ConcreteModel() @@ -87,9 +83,6 @@ def test_unbounded_lp(self): ), ) - @unittest.skipIf( - not cplexpy_available, "The 'cplex' python bindings are not available" - ) def test_optimal_lp(self): with SolverFactory("cplex", solver_io="python") as opt: model = ConcreteModel() @@ -100,9 +93,6 @@ def test_optimal_lp(self): self.assertEqual(results.solution.status, SolutionStatus.optimal) - @unittest.skipIf( - not cplexpy_available, "The 'cplex' python bindings are not available" - ) def test_get_duals_lp(self): with SolverFactory("cplex", solver_io="python") as opt: model = ConcreteModel() @@ -122,9 +112,6 @@ def test_get_duals_lp(self): self.assertAlmostEqual(model.dual[model.C1], 0.4) self.assertAlmostEqual(model.dual[model.C2], 0.2) - @unittest.skipIf( - not cplexpy_available, "The 'cplex' python bindings are not available" - ) def test_infeasible_mip(self): with SolverFactory("cplex", solver_io="python") as opt: model = ConcreteModel() @@ -139,9 +126,6 @@ def test_infeasible_mip(self): results.solver.termination_condition, TerminationCondition.infeasible ) - @unittest.skipIf( - not cplexpy_available, "The 'cplex' python bindings are not available" - ) def test_unbounded_mip(self): with SolverFactory("cplex", solver_io="python") as opt: model = AbstractModel() @@ -159,9 +143,6 @@ def test_unbounded_mip(self): ), ) - @unittest.skipIf( - not cplexpy_available, "The 'cplex' python bindings are not available" - ) def test_optimal_mip(self): with SolverFactory("cplex", solver_io="python") as opt: model = ConcreteModel() @@ -174,6 +155,7 @@ def test_optimal_mip(self): @unittest.skipIf(not cplexpy_available, "The 'cplex' python bindings are not available") +@unittest.pytest.mark.solver("cplex_persistent") class TestIsFixedCallCount(unittest.TestCase): """Tests for PR#1402 (669e7b2b)""" @@ -430,6 +412,7 @@ def test_add_block_containing_multiple_variables(self): @unittest.skipIf(not cplexpy_available, "The 'cplex' python bindings are not available") +@unittest.pytest.mark.solver("cplex_direct") class TestAddCon(unittest.TestCase): def test_add_single_constraint(self): model = ConcreteModel() @@ -536,6 +519,7 @@ def test_add_block_containing_multiple_constraints(self): @unittest.skipIf(not cplexpy_available, "The 'cplex' python bindings are not available") +@unittest.pytest.mark.solver("cplex_direct") class TestLoadVars(unittest.TestCase): def setUp(self): opt = SolverFactory("cplex", solver_io="python") diff --git a/pyomo/solvers/tests/checks/test_CPLEXPersistent.py b/pyomo/solvers/tests/checks/test_CPLEXPersistent.py index df4b2a2eaa3..cebb0984a91 100644 --- a/pyomo/solvers/tests/checks/test_CPLEXPersistent.py +++ b/pyomo/solvers/tests/checks/test_CPLEXPersistent.py @@ -22,6 +22,7 @@ @unittest.skipIf(not cplexpy_available, "The 'cplex' python bindings are not available") +@unittest.pytest.mark.solver("cplex_persistent") class TestQuadraticObjective(unittest.TestCase): def test_quadratic_objective_is_set(self): model = ConcreteModel() diff --git a/pyomo/solvers/tests/checks/test_GAMS.py b/pyomo/solvers/tests/checks/test_GAMS.py index fa0256b1108..e39d70a9239 100644 --- a/pyomo/solvers/tests/checks/test_GAMS.py +++ b/pyomo/solvers/tests/checks/test_GAMS.py @@ -38,6 +38,7 @@ gamsgms_available = opt_gms.available(exception_flag=False) +@unittest.pytest.mark.solver("gams") class GAMSTests(unittest.TestCase): @unittest.skipIf( not gamspy_available, "The 'gams' python bindings are not available" @@ -387,6 +388,7 @@ def _check_stdout(self, output_string, exists=True): @unittest.skipIf(not gamsgms_available, "The 'gams' executable is not available") +@unittest.pytest.mark.solver("gams") class GAMSLogfileGmsTests(GAMSLogfileTestBase): """Test class for testing permultations of tee and logfile options. @@ -450,6 +452,7 @@ def test_tee_and_logfile(self): @unittest.skipIf(not gamspy_available, "The 'gams' python bindings are not available") +@unittest.pytest.mark.solver("gams") class GAMSLogfilePyTests(GAMSLogfileTestBase): """Test class for testing permultations of tee and logfile options. diff --git a/pyomo/solvers/tests/checks/test_KNITROAMPL.py b/pyomo/solvers/tests/checks/test_KNITROAMPL.py index 829e115c5a7..d9ab6f0cdb5 100644 --- a/pyomo/solvers/tests/checks/test_KNITROAMPL.py +++ b/pyomo/solvers/tests/checks/test_KNITROAMPL.py @@ -23,10 +23,9 @@ knitroampl_available = SolverFactory('knitroampl').available(False) +@unittest.skipIf(not knitroampl_available, "The 'knitroampl' command is not available") +@unittest.pytest.mark.solver("knitroampl") class TestKNITROAMPLInterface(unittest.TestCase): - @unittest.skipIf( - not knitroampl_available, "The 'knitroampl' command is not available" - ) def test_infeasible_lp(self): with SolverFactory('knitroampl') as opt: model = ConcreteModel() @@ -41,9 +40,6 @@ def test_infeasible_lp(self): results.solver.termination_condition, TerminationCondition.infeasible ) - @unittest.skipIf( - not knitroampl_available, "The 'knitroampl' command is not available" - ) def test_unbounded_lp(self): with SolverFactory('knitroampl') as opt: model = ConcreteModel() @@ -60,9 +56,6 @@ def test_unbounded_lp(self): ), ) - @unittest.skipIf( - not knitroampl_available, "The 'knitroampl' command is not available" - ) def test_optimal_lp(self): with SolverFactory('knitroampl') as opt: model = ConcreteModel() @@ -77,9 +70,6 @@ def test_optimal_lp(self): ) self.assertAlmostEqual(value(model.X), 2.5) - @unittest.skipIf( - not knitroampl_available, "The 'knitroampl' command is not available" - ) def test_get_duals_lp(self): with SolverFactory('knitroampl') as opt: model = ConcreteModel() @@ -99,9 +89,6 @@ def test_get_duals_lp(self): self.assertAlmostEqual(model.dual[model.C1], 0.4) self.assertAlmostEqual(model.dual[model.C2], 0.2) - @unittest.skipIf( - not knitroampl_available, "The 'knitroampl' command is not available" - ) def test_infeasible_mip(self): with SolverFactory('knitroampl') as opt: model = ConcreteModel() @@ -116,9 +103,6 @@ def test_infeasible_mip(self): results.solver.termination_condition, TerminationCondition.infeasible ) - @unittest.skipIf( - not knitroampl_available, "The 'knitroampl' command is not available" - ) def test_optimal_mip(self): with SolverFactory('knitroampl') as opt: model = ConcreteModel() diff --git a/pyomo/solvers/tests/checks/test_MOSEKDirect.py b/pyomo/solvers/tests/checks/test_MOSEKDirect.py index 0791f693f53..e41d576676f 100644 --- a/pyomo/solvers/tests/checks/test_MOSEKDirect.py +++ b/pyomo/solvers/tests/checks/test_MOSEKDirect.py @@ -20,6 +20,7 @@ @unittest.skipIf(not mosek_available, "MOSEK's python bindings are not available") +@unittest.pytest.mark.solver("mosek_direct") class MOSEKDirectTests(unittest.TestCase): def setUp(self): self.stderr = sys.stderr diff --git a/pyomo/solvers/tests/checks/test_MOSEKPersistent.py b/pyomo/solvers/tests/checks/test_MOSEKPersistent.py index 39b4c5d2751..dcaa19b9e82 100644 --- a/pyomo/solvers/tests/checks/test_MOSEKPersistent.py +++ b/pyomo/solvers/tests/checks/test_MOSEKPersistent.py @@ -28,6 +28,7 @@ @unittest.skipIf(not mosek_available, "MOSEK's python bindings are missing.") +@unittest.pytest.mark.solver("mosek_persistent") class MOSEKPersistentTests(unittest.TestCase): def setUp(self): self.stderr = sys.stderr diff --git a/pyomo/solvers/tests/checks/test_SAS.py b/pyomo/solvers/tests/checks/test_SAS.py index cb900b1b2ce..46b7ebbbd7f 100644 --- a/pyomo/solvers/tests/checks/test_SAS.py +++ b/pyomo/solvers/tests/checks/test_SAS.py @@ -294,6 +294,7 @@ def test_solver_with_milp(self): @unittest.skipIf(not sas94_available, "The SAS94 solver interface is not available") +@unittest.pytest.mark.solver("sas") class SASTestLP94(SASTestLP, unittest.TestCase): @mock.patch( "pyomo.solvers.plugins.solvers.SAS.SAS94.sas_version", @@ -328,6 +329,7 @@ def test_solver_error(self, submit_mock, symget_mock): # @unittest.skipIf(not sascas_available, "The SAS solver is not available") @unittest.skip("Tests not yet configured for SAS Viya interface.") +@unittest.pytest.mark.solver("sas") class SASTestLPCAS(SASTestLP, unittest.TestCase): solver_io = "_sascas" session_options = CAS_OPTIONS @@ -339,6 +341,7 @@ def test_solver_large_file(self, os_stat): self.checkSolution() +@unittest.pytest.mark.solver("sas") class SASTestMILP(SASTestAbc): def setX(self): self.instance.X = Var([1, 2, 3], within=NonNegativeIntegers) @@ -529,12 +532,14 @@ def test_solver_warmstart_capable(self): # @unittest.skipIf(not sas94_available, "The SAS solver is not available") @unittest.skip("MILP94 tests disabled.") +@unittest.pytest.mark.solver("sas") class SASTestMILP94(SASTestMILP, unittest.TestCase): pass # @unittest.skipIf(not sascas_available, "The SAS solver is not available") @unittest.skip("Tests not yet configured for SAS Viya interface.") +@unittest.pytest.mark.solver("sas") class SASTestMILPCAS(SASTestMILP, unittest.TestCase): solver_io = "_sascas" session_options = CAS_OPTIONS diff --git a/pyomo/solvers/tests/checks/test_cbc.py b/pyomo/solvers/tests/checks/test_cbc.py index db6db9e1026..7e51d11ff80 100644 --- a/pyomo/solvers/tests/checks/test_cbc.py +++ b/pyomo/solvers/tests/checks/test_cbc.py @@ -27,6 +27,7 @@ cbc_available = opt_cbc.available(exception_flag=False) +@unittest.pytest.mark.solver("cbc") class CBCTests(unittest.TestCase): @unittest.skipIf(not cbc_available, "The CBC solver is not available") def test_warm_start(self): diff --git a/pyomo/solvers/tests/checks/test_cuopt_direct.py b/pyomo/solvers/tests/checks/test_cuopt_direct.py index 5ff50bf5805..aef0a924468 100644 --- a/pyomo/solvers/tests/checks/test_cuopt_direct.py +++ b/pyomo/solvers/tests/checks/test_cuopt_direct.py @@ -32,6 +32,7 @@ from pyomo.solvers.plugins.solvers.cuopt_direct import cuopt_available +@unittest.pytest.mark.solver("cuopt") class CUOPTTests(unittest.TestCase): @unittest.skipIf(not cuopt_available, "The CuOpt solver is not available") def test_values_and_rc(self): diff --git a/pyomo/solvers/tests/checks/test_gurobi.py b/pyomo/solvers/tests/checks/test_gurobi.py index f7fe8b9cbbf..8619a883762 100644 --- a/pyomo/solvers/tests/checks/test_gurobi.py +++ b/pyomo/solvers/tests/checks/test_gurobi.py @@ -23,6 +23,7 @@ @unittest.skipIf(not gurobipy_available, "gurobipy is not available") +@unittest.pytest.mark.solver("gurobi") class GurobiTest(unittest.TestCase): @unittest.skipIf(not has_worklimit, "gurobi < 9.5") @patch("pyomo.solvers.plugins.solvers.GUROBI_RUN.read") diff --git a/pyomo/solvers/tests/checks/test_gurobi_direct.py b/pyomo/solvers/tests/checks/test_gurobi_direct.py index 6d9095360b7..7e0196e279c 100644 --- a/pyomo/solvers/tests/checks/test_gurobi_direct.py +++ b/pyomo/solvers/tests/checks/test_gurobi_direct.py @@ -79,6 +79,7 @@ def tearDown(self): @unittest.skipIf(gurobipy_available, "gurobipy is installed, skip import test") +@unittest.pytest.mark.solver("gurobi_direct") class GurobiImportFailedTests(unittest.TestCase): def test_gurobipy_not_installed(self): # ApplicationError should be thrown if gurobipy is not available @@ -90,6 +91,7 @@ def test_gurobipy_not_installed(self): @unittest.skipIf(not gurobipy_available, "gurobipy is not available") @unittest.skipIf(not gurobi_available, "gurobi license is not valid") +@unittest.pytest.mark.solver("gurobi_direct") class GurobiParameterTests(GurobiBase): # Test parameter handling at the model and environment level @@ -170,6 +172,7 @@ def test_param_changes_4(self): @unittest.skipIf(not gurobipy_available, "gurobipy is not available") @unittest.skipIf(not gurobi_available, "gurobi license is not valid") +@unittest.pytest.mark.solver("gurobi_direct") class GurobiEnvironmentTests(GurobiBase): # Test handling of gurobi environments @@ -337,6 +340,7 @@ def test_nonmanaged_env(self): @unittest.skipIf(not gurobipy_available, "gurobipy is not available") @unittest.skipIf(not gurobi_available, "gurobi license is not valid") @unittest.skipIf(not single_use_license(), reason="test needs a single use license") +@unittest.pytest.mark.solver("gurobi_direct") class GurobiSingleUseTests(GurobiBase): # Integration tests for Gurobi single-use licenses (useful for checking all Gurobi # environments were correctly freed). These tests are not run in pyomo's CI. Each diff --git a/pyomo/solvers/tests/checks/test_gurobi_persistent.py b/pyomo/solvers/tests/checks/test_gurobi_persistent.py index 9cc876fa97e..816ca180d6d 100644 --- a/pyomo/solvers/tests/checks/test_gurobi_persistent.py +++ b/pyomo/solvers/tests/checks/test_gurobi_persistent.py @@ -22,8 +22,9 @@ gurobipy_available = False +@unittest.skipIf(not gurobipy_available, "gurobipy is not available") +@unittest.pytest.mark.solver("gurobi_persistent") class TestGurobiPersistent(unittest.TestCase): - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_basics(self): m = pyo.ConcreteModel() m.x = pyo.Var(bounds=(-10, 10)) @@ -107,7 +108,6 @@ def test_basics(self): del m.z self.assertEqual(opt.get_model_attr('NumVars'), 2) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_update1(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -129,7 +129,6 @@ def test_update1(self): opt.update() self.assertEqual(opt._solver_model.getAttr('NumQConstrs'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_update2(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -151,7 +150,6 @@ def test_update2(self): opt.update() self.assertEqual(opt._solver_model.getAttr('NumConstrs'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_update3(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -171,7 +169,6 @@ def test_update3(self): opt.update() self.assertEqual(opt._solver_model.getAttr('NumQConstrs'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_update4(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -191,7 +188,6 @@ def test_update4(self): opt.update() self.assertEqual(opt._solver_model.getAttr('NumConstrs'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_update5(self): m = pyo.ConcreteModel() m.a = pyo.Set(initialize=[1, 2, 3], ordered=True) @@ -213,7 +209,6 @@ def test_update5(self): opt.update() self.assertEqual(opt._solver_model.getAttr('NumSOS'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_update6(self): m = pyo.ConcreteModel() m.a = pyo.Set(initialize=[1, 2, 3], ordered=True) @@ -233,7 +228,6 @@ def test_update6(self): opt.update() self.assertEqual(opt._solver_model.getAttr('NumSOS'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_update7(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -259,7 +253,6 @@ def test_update7(self): opt.update() self.assertEqual(opt._solver_model.getAttr('NumVars'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_linear_constraint_attr(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -271,7 +264,6 @@ def test_linear_constraint_attr(self): opt.set_linear_constraint_attr(m.c, 'Lazy', 1) self.assertEqual(opt.get_linear_constraint_attr(m.c, 'Lazy'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_quadratic_constraint_attr(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -282,7 +274,6 @@ def test_quadratic_constraint_attr(self): opt.set_instance(m) self.assertEqual(opt.get_quadratic_constraint_attr(m.c, 'QCRHS'), 0) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_var_attr(self): m = pyo.ConcreteModel() m.x = pyo.Var(within=pyo.Binary) @@ -292,7 +283,6 @@ def test_var_attr(self): opt.set_var_attr(m.x, 'Start', 1) self.assertEqual(opt.get_var_attr(m.x, 'Start'), 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_callback(self): m = pyo.ConcreteModel() m.x = pyo.Var(bounds=(0, 4)) @@ -323,7 +313,6 @@ def _my_callback(cb_m, cb_opt, cb_where): self.assertAlmostEqual(m.x.value, 1) self.assertAlmostEqual(m.y.value, 1) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_add_column(self): m = pyo.ConcreteModel() m.x = pyo.Var(within=pyo.NonNegativeReals) @@ -343,7 +332,6 @@ def test_add_column(self): self.assertAlmostEqual(m.x.value, 0) self.assertAlmostEqual(m.y.value, 0.5) - @unittest.skipIf(not gurobipy_available, "gurobipy is not available") def test_add_column_exceptions(self): m = pyo.ConcreteModel() m.x = pyo.Var() diff --git a/pyomo/solvers/tests/checks/test_ipopt.py b/pyomo/solvers/tests/checks/test_ipopt.py index 82d6afdf9a8..686b1d2ac31 100644 --- a/pyomo/solvers/tests/checks/test_ipopt.py +++ b/pyomo/solvers/tests/checks/test_ipopt.py @@ -15,6 +15,7 @@ @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +@unittest.pytest.mark.solver("ipopt") class TestIpoptInterface(unittest.TestCase): def test_has_linear_solver(self): opt = IPOPT.IPOPT() diff --git a/pyomo/solvers/tests/checks/test_no_solution_behavior.py b/pyomo/solvers/tests/checks/test_no_solution_behavior.py index 61d86d689a9..f98072145c2 100644 --- a/pyomo/solvers/tests/checks/test_no_solution_behavior.py +++ b/pyomo/solvers/tests/checks/test_no_solution_behavior.py @@ -89,7 +89,7 @@ def return_test(self): def return_test(self): return failed_solve_test(self) - unittest.pytest.mark.solver(solver)(return_test) + unittest.pytest.mark.solver(solver)(unittest.pytest.mark.writer(io)(return_test)) return return_test diff --git a/pyomo/solvers/tests/checks/test_pickle.py b/pyomo/solvers/tests/checks/test_pickle.py index b06aff67aef..56ae581277a 100644 --- a/pyomo/solvers/tests/checks/test_pickle.py +++ b/pyomo/solvers/tests/checks/test_pickle.py @@ -138,7 +138,7 @@ def return_test(self): def return_test(self): return pickle_test(self) - unittest.pytest.mark.solver(solver)(return_test) + unittest.pytest.mark.solver(solver)(unittest.pytest.mark.writer(io)(return_test)) return return_test diff --git a/pyomo/solvers/tests/checks/test_writers.py b/pyomo/solvers/tests/checks/test_writers.py index 8f2bb4cea84..dd638a29c9a 100644 --- a/pyomo/solvers/tests/checks/test_writers.py +++ b/pyomo/solvers/tests/checks/test_writers.py @@ -158,7 +158,7 @@ def return_test(self): def return_test(self): return writer_test(self) - unittest.pytest.mark.solver(solver)(return_test) + unittest.pytest.mark.solver(solver)(unittest.pytest.mark.writer(io)(return_test)) return return_test diff --git a/pyomo/solvers/tests/checks/test_xpress_persistent.py b/pyomo/solvers/tests/checks/test_xpress_persistent.py index 3c07b0a225c..1553c6d6449 100644 --- a/pyomo/solvers/tests/checks/test_xpress_persistent.py +++ b/pyomo/solvers/tests/checks/test_xpress_persistent.py @@ -20,8 +20,9 @@ xpress_available = pyo.SolverFactory('xpress_persistent').available(False) +@unittest.skipIf(not xpress_available, "xpress is not available") +@unittest.pytest.mark.solver("xpress_persistent") class TestXpressPersistent(unittest.TestCase): - @unittest.skipIf(not xpress_available, "xpress is not available") def test_basics(self): m = pyo.ConcreteModel() m.x = pyo.Var(bounds=(-10, 10)) @@ -81,33 +82,24 @@ def test_basics(self): m.x.setlb(-5) m.x.setub(5) opt.update_var(m.x) - # a nice wrapper for xpress isn't implemented, - # so we'll do this directly - x_idx = opt._solver_model.getIndex(opt._pyomo_var_to_solver_var_map[m.x]) - lb = [] - opt._solver_model.getlb(lb, x_idx, x_idx) - ub = [] - opt._solver_model.getub(ub, x_idx, x_idx) - self.assertEqual(lb[0], -5) - self.assertEqual(ub[0], 5) + lb = opt._get_lb(m.x) + ub = opt._get_ub(m.x) + self.assertEqual(lb, -5) + self.assertEqual(ub, 5) m.x.fix(0) opt.update_var(m.x) - lb = [] - opt._solver_model.getlb(lb, x_idx, x_idx) - ub = [] - opt._solver_model.getub(ub, x_idx, x_idx) - self.assertEqual(lb[0], 0) - self.assertEqual(ub[0], 0) + lb = opt._get_lb(m.x) + ub = opt._get_ub(m.x) + self.assertEqual(lb, 0) + self.assertEqual(ub, 0) m.x.unfix() opt.update_var(m.x) - lb = [] - opt._solver_model.getlb(lb, x_idx, x_idx) - ub = [] - opt._solver_model.getub(ub, x_idx, x_idx) - self.assertEqual(lb[0], -5) - self.assertEqual(ub[0], 5) + lb = opt._get_lb(m.x) + ub = opt._get_ub(m.x) + self.assertEqual(lb, -5) + self.assertEqual(ub, 5) m.c2 = pyo.Constraint(expr=m.y >= m.x**2) opt.add_constraint(m.c2) @@ -126,7 +118,6 @@ def test_basics(self): del m.z self.assertEqual(opt.get_xpress_attribute('cols'), 2) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_vartype_change(self): # test for issue #3565 m = pyo.ConcreteModel() @@ -139,19 +130,15 @@ def test_vartype_change(self): m.x.fix(1) opt.update_var(m.x) - x_idx = opt._solver_model.getIndex(opt._pyomo_var_to_solver_var_map[m.x]) - lb = [] - opt._solver_model.getlb(lb, x_idx, x_idx) - self.assertEqual(lb[0], 1) + lb = opt._get_lb(m.x) + self.assertEqual(lb, 1) m.x.domain = pyo.Binary opt.update_var(m.x) - lb = [] - opt._solver_model.getlb(lb, x_idx, x_idx) - self.assertEqual(lb[0], 1) + lb = opt._get_lb(m.x) + self.assertEqual(lb, 1) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_remove_qconstraint(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -170,7 +157,6 @@ def test_add_remove_qconstraint(self): opt.add_constraint(m.c1) self.assertEqual(opt.get_xpress_attribute('rows'), 1) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_remove_lconstraint(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -189,7 +175,6 @@ def test_add_remove_lconstraint(self): opt.add_constraint(m.c2) self.assertEqual(opt.get_xpress_attribute('rows'), 1) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_remove_sosconstraint(self): m = pyo.ConcreteModel() m.a = pyo.Set(initialize=[1, 2, 3], ordered=True) @@ -208,7 +193,6 @@ def test_add_remove_sosconstraint(self): opt.add_sos_constraint(m.c1) self.assertEqual(opt.get_xpress_attribute('sets'), 1) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_remove_sosconstraint2(self): m = pyo.ConcreteModel() m.a = pyo.Set(initialize=[1, 2, 3], ordered=True) @@ -226,7 +210,6 @@ def test_add_remove_sosconstraint2(self): opt.remove_sos_constraint(m.c2) self.assertEqual(opt.get_xpress_attribute('sets'), 1) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_remove_var(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -247,7 +230,6 @@ def test_add_remove_var(self): opt.remove_var(m.x) self.assertEqual(opt.get_xpress_attribute('cols'), 1) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_column(self): m = pyo.ConcreteModel() m.x = pyo.Var(within=pyo.NonNegativeReals) @@ -267,7 +249,6 @@ def test_add_column(self): self.assertAlmostEqual(m.x.value, 0) self.assertAlmostEqual(m.y.value, 0.5) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_add_column_exceptions(self): m = pyo.ConcreteModel() m.x = pyo.Var() @@ -317,7 +298,6 @@ def test_add_column_exceptions(self): # var already in solver model self.assertRaises(RuntimeError, opt.add_column, m, m.y, -2, [m.c], [1]) - @unittest.skipIf(not xpress_available, "xpress is not available") @unittest.skipIf( xpd.xpress_available and xpd.xpress.__version__ == '9.8.0', "Xpress 9.8 always runs global optimizer", @@ -353,7 +333,6 @@ def test_nonconvexqp_locally_optimal(self): self.assertGreater(m.x2.value, 0.0) self.assertGreater(m.x3.value, 0.0) - @unittest.skipIf(not xpress_available, "xpress is not available") def test_nonconvexqp_infeasible(self): """Test non-convex QP which xpress_direct should prove infeasible.""" m = pyo.ConcreteModel() @@ -376,6 +355,9 @@ def test_nonconvexqp_infeasible(self): results.solver.termination_condition, TerminationCondition.infeasible ) + +@unittest.pytest.mark.solver("xpress_persistent") +class TestXpressPersistentMock(unittest.TestCase): def test_available(self): class mock_xpress: def __init__(self, importable, initable): diff --git a/pyomo/solvers/tests/testcases.py b/pyomo/solvers/tests/testcases.py index 2497c3ca4f0..a154139af8d 100644 --- a/pyomo/solvers/tests/testcases.py +++ b/pyomo/solvers/tests/testcases.py @@ -106,24 +106,24 @@ # 12.0.3 (for AMPL only) returns all zeros for suffixes MissingSuffixFailures['gurobi', 'nl', 'LP_duals_maximize'] = ( - lambda v: v[:3] == (12, 0, 3), + lambda v: v[:3] >= (12, 0, 3), {'dual': (False, {})}, - "AMPL Gurobi 12.0.3 fails to report duals for problems solved in presolve", + "AMPL Gurobi>=12.0.3 fails to report duals for problems solved in presolve", ) MissingSuffixFailures['gurobi', 'nl', 'LP_duals_minimize'] = ( - lambda v: v[:3] == (12, 0, 3), + lambda v: v[:3] >= (12, 0, 3), {'dual': (False, {})}, - "AMPL Gurobi 12.0.3 fails to report duals for problems solved in presolve", + "AMPL Gurobi>=12.0.3 fails to report duals for problems solved in presolve", ) MissingSuffixFailures['gurobi', 'nl', 'LP_inactive_index'] = ( - lambda v: v[:3] == (12, 0, 3), + lambda v: v[:3] >= (12, 0, 3), {'dual': (False, {})}, - "AMPL Gurobi 12.0.3 fails to report duals for problems solved in presolve", + "AMPL Gurobi>=12.0.3 fails to report duals for problems solved in presolve", ) MissingSuffixFailures['gurobi', 'nl', 'QP_simple'] = ( - lambda v: v[:3] == (12, 0, 3), + lambda v: v[:3] >= (12, 0, 3), {'dual': (False, {})}, - "AMPL Gurobi 12.0.3 fails to report duals for problems solved in presolve", + "AMPL Gurobi>=12.0.3 fails to report duals for problems solved in presolve", ) # diff --git a/pyproject.toml b/pyproject.toml index 60fc4aa8640..aad5dc28855 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,16 +76,10 @@ markers = [ "mpi: marks tests that require MPI", "neos: marks tests that require NEOS server connections", "importtest: marks tests that checks for warnings when importing modules", - "book: marks tests from the Pyomo book", "performance: marks performance tests", - "long: marks long performance tests", - "short: marks short performance tests", - "devel: marks developer-created performance tests", - "nl: marks nl tests", - "lp: marks lp tests", - "gams: marks gams tests", - "bar: marks bar tests", "builders: tests that should be run when testing custom (extension) builders", + "solver(name): mark test to run the named solver", + "writer(name): mark test to run the named problem writer", ] [tool.black]