diff --git a/pyomo/contrib/doe/doe.py b/pyomo/contrib/doe/doe.py index a13e6785bf4..d4a28c436d9 100644 --- a/pyomo/contrib/doe/doe.py +++ b/pyomo/contrib/doe/doe.py @@ -452,7 +452,21 @@ def run_doe(self, model=None, results_file=None): # Solve the full model, which has now been initialized with the square solve if self.use_grey_box: - res = self.grey_box_solver.solve(model, tee=self.grey_box_tee) + grey_box_solver_options = None + if hasattr(self.grey_box_solver, 'config') and hasattr( + self.grey_box_solver.config, 'options' + ): + grey_box_solver_options = dict( + self.grey_box_solver.config.options.items() + ) + if grey_box_solver_options: + res = self.grey_box_solver.solve( + model, + tee=self.grey_box_tee, + solver_options=grey_box_solver_options, + ) + else: + res = self.grey_box_solver.solve(model, tee=self.grey_box_tee) else: res = self.solver.solve(model, tee=self.tee) diff --git a/pyomo/contrib/mindtpy/algorithm_base_class.py b/pyomo/contrib/mindtpy/algorithm_base_class.py index c223bc51666..66044e2d8fd 100644 --- a/pyomo/contrib/mindtpy/algorithm_base_class.py +++ b/pyomo/contrib/mindtpy/algorithm_base_class.py @@ -275,13 +275,18 @@ def model_is_valid(self): 'Your model is a NLP (nonlinear program). ' 'Using NLP solver %s to solve.' % config.nlp_solver ) + nlp_args = dict(config.nlp_solver_args) update_solver_timelimit( - self.nlp_opt, config.nlp_solver, self.timing, config + self.nlp_opt, + config.nlp_solver, + self.timing, + config, + solve_args=nlp_args, ) self.nlp_opt.solve( self.original_model, tee=config.nlp_solver_tee, - **config.nlp_solver_args, + **nlp_args, ) return False else: @@ -834,7 +839,13 @@ def init_rNLP(self, add_oa_cuts=True): MindtPy = self.rnlp.MindtPy_utils TransformationFactory('core.relax_integer_vars').apply_to(self.rnlp) nlp_args = dict(config.nlp_solver_args) - update_solver_timelimit(self.nlp_opt, config.nlp_solver, self.timing, config) + update_solver_timelimit( + self.nlp_opt, + config.nlp_solver, + self.timing, + config, + solve_args=nlp_args, + ) with SuppressInfeasibleWarning(): results = self.nlp_opt.solve( self.rnlp, @@ -1109,7 +1120,13 @@ def solve_subproblem(self): return self.fixed_nlp, results # Solve the NLP nlp_args = dict(config.nlp_solver_args) - update_solver_timelimit(self.nlp_opt, config.nlp_solver, self.timing, config) + update_solver_timelimit( + self.nlp_opt, + config.nlp_solver, + self.timing, + config, + solve_args=nlp_args, + ) with SuppressInfeasibleWarning(): with time_code(self.timing, 'fixed subproblem'): results = self.nlp_opt.solve( @@ -1381,7 +1398,11 @@ def solve_feasibility_subproblem(self): MindtPy.feas_obj.activate() nlp_args = dict(config.nlp_solver_args) update_solver_timelimit( - self.feasibility_nlp_opt, config.nlp_solver, self.timing, config + self.feasibility_nlp_opt, + config.nlp_solver, + self.timing, + config, + solve_args=nlp_args, ) try: TransformationFactory('contrib.deactivate_trivial_constraints').apply_to( @@ -2395,7 +2416,13 @@ def solve_fp_subproblem(self): return fp_nlp, results # Solve the NLP nlp_args = dict(config.nlp_solver_args) - update_solver_timelimit(self.nlp_opt, config.nlp_solver, self.timing, config) + update_solver_timelimit( + self.nlp_opt, + config.nlp_solver, + self.timing, + config, + solve_args=nlp_args, + ) with SuppressInfeasibleWarning(): with time_code(self.timing, 'fp subproblem'): results = self.nlp_opt.solve( @@ -2662,10 +2689,21 @@ def initialize_subsolvers(self): set_solver_mipgap(self.mip_opt, config.mip_solver, config) set_solver_constraint_violation_tolerance( - self.nlp_opt, config.nlp_solver, config + self.nlp_opt, + config.nlp_solver, + config, + solve_args=( + config.nlp_solver_args if config.nlp_solver == 'cyipopt' else None + ), ) set_solver_constraint_violation_tolerance( - self.feasibility_nlp_opt, config.nlp_solver, config, warm_start=False + self.feasibility_nlp_opt, + config.nlp_solver, + config, + warm_start=False, + solve_args=( + config.nlp_solver_args if config.nlp_solver == 'cyipopt' else None + ), ) self.set_appsi_solver_update_config() diff --git a/pyomo/contrib/mindtpy/tests/test_cyipopt_options.py b/pyomo/contrib/mindtpy/tests/test_cyipopt_options.py new file mode 100644 index 00000000000..23911bb1768 --- /dev/null +++ b/pyomo/contrib/mindtpy/tests/test_cyipopt_options.py @@ -0,0 +1,45 @@ +from types import SimpleNamespace +from unittest import mock + +import pyomo.common.unittest as unittest + +from pyomo.contrib.mindtpy import util + + +class TestCyIpoptSolveOptions(unittest.TestCase): + def test_update_solver_timelimit_uses_solve_time_solver_options(self): + solver = SimpleNamespace(config=SimpleNamespace(options={}), options={}) + config = SimpleNamespace(time_limit=10) + solve_args = {'solver_options': {'print_level': 0}} + + with mock.patch.object(util, 'get_main_elapsed_time', return_value=3.2): + util.update_solver_timelimit( + solver, 'cyipopt', timing=object(), config=config, solve_args=solve_args + ) + + self.assertEqual( + solve_args['solver_options'], + {'print_level': 0, 'max_cpu_time': 7.0}, + ) + self.assertEqual(solver.config.options, {}) + + def test_constraint_tolerance_uses_existing_legacy_options_keyword(self): + solver = SimpleNamespace(config=SimpleNamespace(options={}), options={}) + config = SimpleNamespace(zero_tolerance=1e-7) + solve_args = {'options': {'max_iter': 5}} + + util.set_solver_constraint_violation_tolerance( + solver, 'cyipopt', config, solve_args=solve_args + ) + + self.assertEqual( + solve_args['options'], + {'max_iter': 5, 'constr_viol_tol': 1e-7}, + ) + self.assertEqual(solver.config.options, {}) + + def test_solver_option_merge_rejects_mixed_option_keywords(self): + with self.assertRaisesRegex(ValueError, "Both 'options' and 'solver_options'"): + util._update_solve_solver_options( + {'options': {}, 'solver_options': {}}, {'max_cpu_time': 1.0} + ) diff --git a/pyomo/contrib/mindtpy/util.py b/pyomo/contrib/mindtpy/util.py index 368f261eb50..7a0ba232392 100644 --- a/pyomo/contrib/mindtpy/util.py +++ b/pyomo/contrib/mindtpy/util.py @@ -39,6 +39,24 @@ numpy = attempt_import('numpy')[0] +def _update_solve_solver_options(solve_args, new_options): + if 'options' in solve_args and 'solver_options' in solve_args: + raise ValueError( + "Both 'options' and 'solver_options' were requested. " + "Please use one or the other, not both." + ) + + if 'solver_options' in solve_args: + option_kwd = 'solver_options' + elif 'options' in solve_args: + option_kwd = 'options' + else: + option_kwd = 'solver_options' + + solve_args[option_kwd] = dict(solve_args.get(option_kwd, {})) + solve_args[option_kwd].update(new_options) + + def calc_jacobians(constraint_list, differentiate_mode): """Generates a map of jacobians for the variables in the model. @@ -496,7 +514,7 @@ def generate_norm1_norm_constraint(model, setpoint_model, config, discrete_only= ) -def update_solver_timelimit(opt, solver_name, timing, config): +def update_solver_timelimit(opt, solver_name, timing, config, solve_args=None): """Updates the time limit for subsolvers. Parameters @@ -524,7 +542,12 @@ def update_solver_timelimit(opt, solver_name, timing, config): elif solver_name == 'appsi_highs': opt.config.time_limit = remaining elif solver_name == 'cyipopt': - opt.config.options['max_cpu_time'] = float(remaining) + if solve_args is None: + opt.config.options['max_cpu_time'] = float(remaining) + else: + _update_solve_solver_options( + solve_args, {'max_cpu_time': float(remaining)} + ) elif solver_name == 'glpk': opt.options['tmlim'] = remaining elif solver_name == 'baron': @@ -565,7 +588,7 @@ def set_solver_mipgap(opt, solver_name, config): def set_solver_constraint_violation_tolerance( - opt, solver_name, config, warm_start=True + opt, solver_name, config, warm_start=True, solve_args=None ): """Set constraint violation tolerance for solvers. @@ -583,7 +606,12 @@ def set_solver_constraint_violation_tolerance( elif solver_name in {'ipopt', 'appsi_ipopt'}: opt.options['constr_viol_tol'] = config.zero_tolerance elif solver_name == 'cyipopt': - opt.config.options['constr_viol_tol'] = config.zero_tolerance + if solve_args is None: + opt.config.options['constr_viol_tol'] = config.zero_tolerance + else: + _update_solve_solver_options( + solve_args, {'constr_viol_tol': config.zero_tolerance} + ) elif solver_name == 'gams': if config.nlp_solver_args['solver'] in { 'ipopt', diff --git a/pyomo/contrib/pynumero/algorithms/solvers/cyipopt_solver.py b/pyomo/contrib/pynumero/algorithms/solvers/cyipopt_solver.py index fb2b7fc9e91..ab3b77b054e 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/cyipopt_solver.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/cyipopt_solver.py @@ -68,6 +68,7 @@ from pyomo.opt.results.solution import Solution logger = logging.getLogger(__name__) +_options_not_set = object() # This maps the cyipopt STATUS_MESSAGES back to string representations # of the Ipopt ApplicationReturnStatus enum @@ -311,8 +312,30 @@ def _int(x): return tuple(_int(_) for _ in cyipopt_interface.cyipopt.__version__.split(".")) + def _get_solve_options( + self, options=_options_not_set, solver_options=_options_not_set + ): + if options is not _options_not_set and solver_options is not _options_not_set: + raise ValueError( + "Both 'options' and 'solver_options' were requested. " + "Please use one or the other, not both." + ) + + solve_options = dict(self.config.options.items()) + if options is not _options_not_set: + solve_options.update(options) + elif solver_options is not _options_not_set: + solve_options.update(solver_options) + + return solve_options + def solve(self, model, **kwds): + options = kwds.pop('options', _options_not_set) + solver_options = kwds.pop('solver_options', _options_not_set) config = self.config(kwds, preserve_implicit=True) + solve_options = self._get_solve_options( + options=options, solver_options=solver_options + ) if not isinstance(model, Block): raise ValueError( @@ -377,7 +400,7 @@ def solve(self, model, **kwds): except AttributeError: # Fall back to pre-1.0.0 API add_option = cyipopt_solver.addOption - for k, v in config.options.items(): + for k, v in solve_options.items(): add_option(k, v) timer = TicTocTimer() diff --git a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_pyomo_cyipopt_solver_interface.py b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_pyomo_cyipopt_solver_interface.py new file mode 100644 index 00000000000..0322484fcf0 --- /dev/null +++ b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_pyomo_cyipopt_solver_interface.py @@ -0,0 +1,151 @@ +from unittest import mock + +import pyomo.common.unittest as unittest +import pyomo.environ as pyo + +from pyomo.contrib.pynumero.algorithms.solvers import cyipopt_solver + + +class _FakeNLP: + def __init__(self, model): + self.model = model + + def g_lb(self): + return [] + + def x_lb(self): + return [0.0] + + def scaling_factors(self): + return None, None, None + + def x_init(self): + return [1.0] + + def set_primals(self, x): + self.model.x.set_value(x[0]) + + def set_duals(self, y): + pass + + def load_state_into_pyomo(self, bound_multipliers=None): + pass + + +class _FakeCyIpoptProblem: + instances = [] + + def __init__(self, nlp, intermediate_callback=None, halt_on_evaluation_error=None): + self._nlp = nlp + self.options = {} + type(self).instances.append(self) + + def g_lb(self): + return self._nlp.g_lb() + + def x_lb(self): + return self._nlp.x_lb() + + def scaling_factors(self): + return self._nlp.scaling_factors() + + def add_option(self, key, val): + self.options[key] = val + + def solve(self, xstart): + return [2.0], { + 'status': 0, + 'status_msg': ( + b"Algorithm terminated successfully at a locally " + b"optimal point, satisfying the convergence tolerances " + b"(can be specified by options)." + ), + 'obj_val': 2.0, + 'mult_g': [], + 'mult_x_L': [0.0], + 'mult_x_U': [0.0], + } + + def close(self): + pass + + +class TestPyomoCyIpoptSolverInterface(unittest.TestCase): + def setUp(self): + _FakeCyIpoptProblem.instances.clear() + self._patchers = [ + mock.patch.object( + cyipopt_solver.pyomo_nlp, 'PyomoNLP', side_effect=_FakeNLP + ), + mock.patch.object( + cyipopt_solver.cyipopt_interface, + 'CyIpoptNLP', + side_effect=_FakeCyIpoptProblem, + ), + ] + for patcher in self._patchers: + patcher.start() + + def tearDown(self): + for patcher in reversed(self._patchers): + patcher.stop() + + def _make_model(self): + m = pyo.ConcreteModel() + m.x = pyo.Var(initialize=1.0) + m.obj = pyo.Objective(expr=m.x) + return m + + def test_default_options_are_applied(self): + solver = pyo.SolverFactory('cyipopt') + solver.config.options['tol'] = 1e-6 + + solver.solve(self._make_model()) + + self.assertEqual(_FakeCyIpoptProblem.instances[-1].options, {'tol': 1e-6}) + + def test_solve_solver_options_override_defaults_without_mutation(self): + solver = pyo.SolverFactory('cyipopt') + solver.config.options['tol'] = 1e-6 + solver.config.options['max_iter'] = 10 + + solver.solve( + self._make_model(), + solver_options={'max_iter': 1, 'print_level': 0}, + ) + + self.assertEqual( + _FakeCyIpoptProblem.instances[-1].options, + {'tol': 1e-6, 'max_iter': 1, 'print_level': 0}, + ) + self.assertEqual( + dict(solver.config.options.items()), {'tol': 1e-6, 'max_iter': 10} + ) + + solver.solve(self._make_model()) + self.assertEqual( + _FakeCyIpoptProblem.instances[-1].options, + {'tol': 1e-6, 'max_iter': 10}, + ) + + def test_legacy_options_keyword_is_supported(self): + solver = pyo.SolverFactory('cyipopt') + solver.config.options['tol'] = 1e-6 + + solver.solve(self._make_model(), options={'max_iter': 3}) + + self.assertEqual( + _FakeCyIpoptProblem.instances[-1].options, + {'tol': 1e-6, 'max_iter': 3}, + ) + self.assertEqual(dict(solver.config.options.items()), {'tol': 1e-6}) + + def test_options_and_solver_options_cannot_both_be_passed(self): + solver = pyo.SolverFactory('cyipopt') + + with self.assertRaisesRegex(ValueError, "Both 'options' and 'solver_options'"): + solver.solve( + self._make_model(), + options={'max_iter': 3}, + solver_options={'max_iter': 1}, + )