From c097f03d92a248835365823db52945ef05f3fe93 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 18 Mar 2024 12:37:06 -0600 Subject: [PATCH 01/19] Initial draft of a new numpy-based Gurobi Direct interface --- pyomo/contrib/solver/gurobi_direct.py | 349 ++++++++++++++++++++++++++ pyomo/contrib/solver/plugins.py | 6 + 2 files changed, 355 insertions(+) create mode 100644 pyomo/contrib/solver/gurobi_direct.py diff --git a/pyomo/contrib/solver/gurobi_direct.py b/pyomo/contrib/solver/gurobi_direct.py new file mode 100644 index 00000000000..56047b6c2c7 --- /dev/null +++ b/pyomo/contrib/solver/gurobi_direct.py @@ -0,0 +1,349 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2024 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import datetime +import io +import math + +from pyomo.common.config import ConfigValue +from pyomo.common.dependencies import attempt_import +from pyomo.common.shutdown import python_is_shutting_down +from pyomo.common.tee import capture_output, TeeStream +from pyomo.common.timing import HierarchicalTimer + +from pyomo.contrib.solver.base import SolverBase +from pyomo.contrib.solver.config import BranchAndBoundConfig +from pyomo.contrib.solver.results import Results, SolutionStatus, TerminationCondition +from pyomo.contrib.solver.solution import SolutionLoaderBase + +from pyomo.core.staleflag import StaleFlagManager + +from pyomo.repn.plugins.standard_form import LinearStandardFormCompiler + +gurobipy, gurobipy_available = attempt_import('gurobipy') + + +class GurobiConfig(BranchAndBoundConfig): + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + super(GurobiConfig, self).__init__( + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + self.use_mipstart: bool = self.declare( + 'use_mipstart', + ConfigValue( + default=False, + domain=bool, + description="If True, the values of the integer variables will be passed to Gurobi.", + ), + ) + + +class GurobiDirectSolutionLoader(SolutionLoaderBase): + def __init__(self, grb_model, grb_vars, pyo_vars): + self._grb_model = grb_model + self._grb_vars = grb_vars + self._pyo_vars = pyo_vars + GurobiDirect._num_instances += 1 + + def __del__(self): + if not python_is_shutting_down(): + GurobiDirect._num_instances -= 1 + if GurobiDirect._num_instances == 0: + GurobiDirect.release_license() + + def load_vars(self, vars_to_load=None, solution_number=0): + assert vars_to_load is None + assert solution_number == 0 + for p_var, g_var in zip(self._pyo_vars, self._grb_vars.x.tolist()): + p_var.set_value(g_var, skip_validation=True) + + def get_primals(self, vars_to_load=None): + assert vars_to_load is None + assert solution_number == 0 + return ComponentMap(zip(self._pyo_vars, self._grb_vars.x.tolist())) + + +class GurobiDirect(SolverBase): + CONFIG = GurobiConfig() + + _available = None + _num_instances = 0 + + def __init__(self, **kwds): + super().__init__(**kwds) + GurobiDirect._num_instances += 1 + + def available(self): + if not gurobipy_available: # this triggers the deferred import + return self.Availability.NotFound + elif self._available == self.Availability.BadVersion: + return self.Availability.BadVersion + else: + return self._check_license() + + def _check_license(self): + avail = False + try: + # Gurobipy writes out license file information when creating + # the environment + with capture_output(capture_fd=True): + m = gurobipy.Model() + avail = True + except gurobipy.GurobiError: + avail = False + + if avail: + if self._available is None: + self._available = GurobiDirect._check_full_license(m) + return self._available + else: + return self.Availability.BadLicense + + @classmethod + def _check_full_license(cls, model=None): + if model is None: + model = gurobipy.Model() + model.setParam('OutputFlag', 0) + try: + model.addVars(range(2001)) + model.optimize() + return cls.Availability.FullLicense + except gurobipy.GurobiError: + return cls.Availability.LimitedLicense + + def __del__(self): + if not python_is_shutting_down(): + GurobiDirect._num_instances -= 1 + if GurobiDirect._num_instances == 0: + self.release_license() + + @staticmethod + def release_license(): + if gurobipy_available: + with capture_output(capture_fd=True): + gurobipy.disposeDefaultEnv() + + def version(self): + version = ( + gurobipy.GRB.VERSION_MAJOR, + gurobipy.GRB.VERSION_MINOR, + gurobipy.GRB.VERSION_TECHNICAL, + ) + return version + + def solve(self, model, **kwds) -> Results: + start_timestamp = datetime.datetime.now(datetime.timezone.utc) + self._config = config = self.config(value=kwds, preserve_implicit=True) + StaleFlagManager.mark_all_as_stale() + if config.timer is None: + config.timer = HierarchicalTimer() + timer = config.timer + + timer.start('compile_model') + repn = LinearStandardFormCompiler().write(model, mixed_form=True) + timer.stop('compile_model') + + timer.start('prepare_matrices') + inf = float('inf') + ninf = -inf + lb = [] + ub = [] + for v in repn.columns: + _l, _u = v.bounds + if _l is None: + _l = ninf + if _u is None: + _u = inf + lb.append(_l) + ub.append(_u) + vtype = [ + ( + gurobipy.GRB.CONTINUOUS + if v.is_continuous() + else ( + gurobipy.GRB.BINARY + if v.is_binary() + else gurobipy.GRB.INTEGER if v.is_integer() else '?' + ) + ) + for v in repn.columns + ] + sense_type = '>=<' + sense = [sense_type[r[1] + 1] for r in repn.rows] + timer.stop('prepare_matrices') + + ostreams = [io.StringIO()] + config.tee + + try: + orig_cwd = os.getcwd() + if self._config.working_directory: + os.chdir(self._config.working_directory) + with TeeStream(*ostreams) as t, capture_output(t.STDOUT, capture_fd=False): + gurobi_model = gurobipy.Model() + + timer.start('transfer_model') + x = gurobi_model.addMVar( + len(repn.columns), + lb=lb, + ub=ub, + obj=repn.c.todense()[0], + vtype=vtype, + ) + A = gurobi_model.addMConstr(repn.A, x, sense, repn.rhs) + # gurobi_model.update() + timer.stop('transfer_model') + + options = config.solver_options + + gurobi_model.setParam('LogToConsole', 1) + + if config.threads is not None: + gurobi_model.setParam('Threads', config.threads) + if config.time_limit is not None: + gurobi_model.setParam('TimeLimit', config.time_limit) + if config.rel_gap is not None: + gurobi_model.setParam('MIPGap', config.rel_gap) + if config.abs_gap is not None: + gurobi_model.setParam('MIPGapAbs', config.abs_gap) + + if config.use_mipstart: + raise MouseTrap("MIPSTART not yet supported") + + for key, option in options.items(): + gurobi_model.setParam(key, option) + + timer.start('optimize') + gurobi_model.optimize() + timer.stop('optimize') + finally: + os.chdir(orig_cwd) + + res = self._postsolve( + timer, GurobiDirectSolutionLoader(gurobi_model, x, repn.columns) + ) + res.solver_configuration = config + res.solver_name = 'Gurobi' + res.solver_version = self.version() + res.solver_log = ostreams[0].getvalue() + + end_timestamp = datetime.datetime.now(datetime.timezone.utc) + res.timing_info.start_timestamp = start_timestamp + res.timing_info.wall_time = (end_timestamp - start_timestamp).total_seconds() + res.timing_info.timer = timer + return res + + def _postsolve(self, timer: HierarchicalTimer, loader): + config = self._config + + gprob = loader._grb_model + grb = gurobipy.GRB + status = gprob.Status + + results = Results() + results.solution_loader = loader + results.timing_info.gurobi_time = gprob.Runtime + + if gprob.SolCount > 0: + if status == grb.OPTIMAL: + results.solution_status = SolutionStatus.optimal + else: + results.solution_status = SolutionStatus.feasible + else: + results.solution_status = SolutionStatus.noSolution + + if status == grb.LOADED: # problem is loaded, but no solution + results.termination_condition = TerminationCondition.unknown + elif status == grb.OPTIMAL: # optimal + results.termination_condition = ( + TerminationCondition.convergenceCriteriaSatisfied + ) + elif status == grb.INFEASIBLE: + results.termination_condition = TerminationCondition.provenInfeasible + elif status == grb.INF_OR_UNBD: + results.termination_condition = TerminationCondition.infeasibleOrUnbounded + elif status == grb.UNBOUNDED: + results.termination_condition = TerminationCondition.unbounded + elif status == grb.CUTOFF: + results.termination_condition = TerminationCondition.objectiveLimit + elif status == grb.ITERATION_LIMIT: + results.termination_condition = TerminationCondition.iterationLimit + elif status == grb.NODE_LIMIT: + results.termination_condition = TerminationCondition.iterationLimit + elif status == grb.TIME_LIMIT: + results.termination_condition = TerminationCondition.maxTimeLimit + elif status == grb.SOLUTION_LIMIT: + results.termination_condition = TerminationCondition.unknown + elif status == grb.INTERRUPTED: + results.termination_condition = TerminationCondition.interrupted + elif status == grb.NUMERIC: + results.termination_condition = TerminationCondition.unknown + elif status == grb.SUBOPTIMAL: + results.termination_condition = TerminationCondition.unknown + elif status == grb.USER_OBJ_LIMIT: + results.termination_condition = TerminationCondition.objectiveLimit + else: + results.termination_condition = TerminationCondition.unknown + + if ( + results.termination_condition + != TerminationCondition.convergenceCriteriaSatisfied + and config.raise_exception_on_nonoptimal_result + ): + raise RuntimeError( + 'Solver did not find the optimal solution. Set opt.config.raise_exception_on_nonoptimal_result = False to bypass this error.' + ) + + results.incumbent_objective = None + results.objective_bound = None + try: + results.incumbent_objective = gprob.ObjVal + except (gurobipy.GurobiError, AttributeError): + results.incumbent_objective = None + try: + results.objective_bound = gprob.ObjBound + except (gurobipy.GurobiError, AttributeError): + if self._objective.sense == minimize: + results.objective_bound = -math.inf + else: + results.objective_bound = math.inf + + if results.incumbent_objective is not None and not math.isfinite( + results.incumbent_objective + ): + results.incumbent_objective = None + + results.iteration_count = gprob.getAttr('IterCount') + + timer.start('load solution') + if config.load_solutions: + if gprob.SolCount > 0: + results.solution_loader.load_vars() + else: + raise RuntimeError( + 'A feasible solution was not found, so no solution can be loaded.' + 'Please set opt.config.load_solutions=False and check ' + 'results.solution_status and ' + 'results.incumbent_objective before loading a solution.' + ) + timer.stop('load solution') + + return results diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index c7da41463a2..b0beef185de 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -13,6 +13,7 @@ from .factory import SolverFactory from .ipopt import Ipopt from .gurobi import Gurobi +from .gurobi_direct import GurobiDirect def load(): @@ -22,3 +23,8 @@ def load(): SolverFactory.register( name='gurobi', legacy_name='gurobi_v2', doc='New interface to Gurobi' )(Gurobi) + SolverFactory.register( + name='gurobi_direct', + legacy_name='gurobi_direct_v2', + doc='Direct (scipy-based) interface to Gurobi', + )(GurobiDirect) From f10ef5654828975d532354178f5fa7f96ac037d8 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 18 Mar 2024 15:22:32 -0600 Subject: [PATCH 02/19] Adding missing import --- pyomo/contrib/solver/gurobi_direct.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyomo/contrib/solver/gurobi_direct.py b/pyomo/contrib/solver/gurobi_direct.py index 56047b6c2c7..be06c17b63b 100644 --- a/pyomo/contrib/solver/gurobi_direct.py +++ b/pyomo/contrib/solver/gurobi_direct.py @@ -12,6 +12,7 @@ import datetime import io import math +import os from pyomo.common.config import ConfigValue from pyomo.common.dependencies import attempt_import From 0465c89d94b58223694fed84334ce603c9d66f15 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 18 Mar 2024 16:09:37 -0600 Subject: [PATCH 03/19] bugfix: correct option name --- pyomo/contrib/solver/gurobi_direct.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/gurobi_direct.py b/pyomo/contrib/solver/gurobi_direct.py index be06c17b63b..7b5ec6ed904 100644 --- a/pyomo/contrib/solver/gurobi_direct.py +++ b/pyomo/contrib/solver/gurobi_direct.py @@ -196,8 +196,8 @@ def solve(self, model, **kwds) -> Results: try: orig_cwd = os.getcwd() - if self._config.working_directory: - os.chdir(self._config.working_directory) + if self._config.working_dir: + os.chdir(self._config.working_dir) with TeeStream(*ostreams) as t, capture_output(t.STDOUT, capture_fd=False): gurobi_model = gurobipy.Model() From 812a1b7fd80be976c5cba7ff93c5bc68d9f795da Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sun, 31 Mar 2024 18:52:18 -0600 Subject: [PATCH 04/19] Remove giant status if tree --- pyomo/contrib/solver/gurobi_direct.py | 65 +++++++++++++-------------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/pyomo/contrib/solver/gurobi_direct.py b/pyomo/contrib/solver/gurobi_direct.py index 7b5ec6ed904..f5f1bca7184 100644 --- a/pyomo/contrib/solver/gurobi_direct.py +++ b/pyomo/contrib/solver/gurobi_direct.py @@ -88,6 +88,7 @@ class GurobiDirect(SolverBase): _available = None _num_instances = 0 + _tc_map = None def __init__(self, **kwds): super().__init__(**kwds) @@ -256,7 +257,6 @@ def _postsolve(self, timer: HierarchicalTimer, loader): config = self._config gprob = loader._grb_model - grb = gurobipy.GRB status = gprob.Status results = Results() @@ -264,45 +264,16 @@ def _postsolve(self, timer: HierarchicalTimer, loader): results.timing_info.gurobi_time = gprob.Runtime if gprob.SolCount > 0: - if status == grb.OPTIMAL: + if status == gurobipy.GRB.OPTIMAL: results.solution_status = SolutionStatus.optimal else: results.solution_status = SolutionStatus.feasible else: results.solution_status = SolutionStatus.noSolution - if status == grb.LOADED: # problem is loaded, but no solution - results.termination_condition = TerminationCondition.unknown - elif status == grb.OPTIMAL: # optimal - results.termination_condition = ( - TerminationCondition.convergenceCriteriaSatisfied - ) - elif status == grb.INFEASIBLE: - results.termination_condition = TerminationCondition.provenInfeasible - elif status == grb.INF_OR_UNBD: - results.termination_condition = TerminationCondition.infeasibleOrUnbounded - elif status == grb.UNBOUNDED: - results.termination_condition = TerminationCondition.unbounded - elif status == grb.CUTOFF: - results.termination_condition = TerminationCondition.objectiveLimit - elif status == grb.ITERATION_LIMIT: - results.termination_condition = TerminationCondition.iterationLimit - elif status == grb.NODE_LIMIT: - results.termination_condition = TerminationCondition.iterationLimit - elif status == grb.TIME_LIMIT: - results.termination_condition = TerminationCondition.maxTimeLimit - elif status == grb.SOLUTION_LIMIT: - results.termination_condition = TerminationCondition.unknown - elif status == grb.INTERRUPTED: - results.termination_condition = TerminationCondition.interrupted - elif status == grb.NUMERIC: - results.termination_condition = TerminationCondition.unknown - elif status == grb.SUBOPTIMAL: - results.termination_condition = TerminationCondition.unknown - elif status == grb.USER_OBJ_LIMIT: - results.termination_condition = TerminationCondition.objectiveLimit - else: - results.termination_condition = TerminationCondition.unknown + results.termination_condition = self._get_tc_map().get( + status, TerminationCondition.unknown + ) if ( results.termination_condition @@ -310,7 +281,9 @@ def _postsolve(self, timer: HierarchicalTimer, loader): and config.raise_exception_on_nonoptimal_result ): raise RuntimeError( - 'Solver did not find the optimal solution. Set opt.config.raise_exception_on_nonoptimal_result = False to bypass this error.' + 'Solver did not find the optimal solution. Set ' + 'opt.config.raise_exception_on_nonoptimal_result=False ' + 'to bypass this error.' ) results.incumbent_objective = None @@ -348,3 +321,25 @@ def _postsolve(self, timer: HierarchicalTimer, loader): timer.stop('load solution') return results + + def _get_tc_map(self): + if GurobiDirect._tc_map is None: + grb = gurobipy.GRB + tc = TerminationCondition + GurobiDirect._tc_map = { + grb.LOADED: tc.unknown, # problem is loaded, but no solution + grb.OPTIMAL: tc.convergenceCriteriaSatisfied, + grb.INFEASIBLE: tc.provenInfeasible, + grb.INF_OR_UNBD: tc.infeasibleOrUnbounded, + grb.UNBOUNDED: tc.unbounded, + grb.CUTOFF: tc.objectiveLimit, + grb.ITERATION_LIMIT: tc.iterationLimit, + grb.NODE_LIMIT: tc.iterationLimit, + grb.TIME_LIMIT: tc.maxTimeLimit, + grb.SOLUTION_LIMIT: tc.unknown, + grb.INTERRUPTED: tc.interrupted, + grb.NUMERIC: tc.unknown, + grb.SUBOPTIMAL: tc.unknown, + grb.USER_OBJ_LIMIT: tc.objectiveLimit, + } + return GurobiDirect._tc_map From 711fafe517ce8f4d21a89c462e9083a40dfb55ca Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sun, 31 Mar 2024 19:42:37 -0600 Subject: [PATCH 05/19] NFC: apply black --- pyomo/contrib/solver/gurobi_direct.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/gurobi_direct.py b/pyomo/contrib/solver/gurobi_direct.py index f5f1bca7184..1164686f0f1 100644 --- a/pyomo/contrib/solver/gurobi_direct.py +++ b/pyomo/contrib/solver/gurobi_direct.py @@ -328,7 +328,7 @@ def _get_tc_map(self): tc = TerminationCondition GurobiDirect._tc_map = { grb.LOADED: tc.unknown, # problem is loaded, but no solution - grb.OPTIMAL: tc.convergenceCriteriaSatisfied, + grb.OPTIMAL: tc.convergenceCriteriaSatisfied, grb.INFEASIBLE: tc.provenInfeasible, grb.INF_OR_UNBD: tc.infeasibleOrUnbounded, grb.UNBOUNDED: tc.unbounded, From 347b5950ba5e8515541783e6480e79c50d05ec88 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 4 Apr 2024 10:24:33 -0600 Subject: [PATCH 06/19] standard_form: return objective list, offsets --- pyomo/repn/plugins/standard_form.py | 39 ++++++++++++++++++----------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index ea7b6a6a9e6..d09537e4eee 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -61,12 +61,16 @@ class LinearStandardFormInfo(object): Attributes ---------- - c : scipy.sparse.csr_array + c : scipy.sparse.csc_array The objective coefficients. Note that this is a sparse array and may contain multiple rows (for multiobjective problems). The objectives may be calculated by "c @ x" + c_offset : numpy.ndarray + + The list of objective constant offsets + A : scipy.sparse.csc_array The constraint coefficients. The constraint bodies may be @@ -89,6 +93,10 @@ class LinearStandardFormInfo(object): The list of Pyomo variable objects corresponding to columns in the `A` and `c` matrices. + objectives : List[_ObjectiveData] + + The list of Pyomo objective objects correcponding to the active objectives + eliminated_vars: List[Tuple[_VarData, NumericExpression]] The list of variables from the original model that do not appear @@ -101,12 +109,14 @@ class LinearStandardFormInfo(object): """ - def __init__(self, c, A, rhs, rows, columns, eliminated_vars): + def __init__(self, c, c_offset, A, rhs, rows, columns, objectives, eliminated_vars): self.c = c + self.c_offset = c_offset self.A = A self.rhs = rhs self.rows = rows self.columns = columns + self.objectives = objectives self.eliminated_vars = eliminated_vars @property @@ -305,21 +315,18 @@ def write(self, model): # # Process objective # - if not component_map[Objective]: - objectives = [Objective(expr=1)] - objectives[0].construct() - else: - objectives = [] - for blk in component_map[Objective]: - objectives.extend( - blk.component_data_objects( - Objective, active=True, descend_into=False, sort=sorter - ) + objectives = [] + for blk in component_map[Objective]: + objectives.extend( + blk.component_data_objects( + Objective, active=True, descend_into=False, sort=sorter ) + ) + obj_offset = [] obj_data = [] obj_index = [] obj_index_ptr = [0] - for i, obj in enumerate(objectives): + for obj in objectives: repn = visitor.walk_expression(obj.expr) if repn.nonlinear is not None: raise ValueError( @@ -328,8 +335,10 @@ def write(self, model): ) N = len(repn.linear) obj_data.append(np.fromiter(repn.linear.values(), float, N)) + obj_offset.append(repn.constant) if obj.sense == maximize: obj_data[-1] *= -1 + obj_offset[-1] *= -1 obj_index.append( np.fromiter(map(var_order.__getitem__, repn.linear), float, N) ) @@ -495,7 +504,9 @@ def write(self, model): else: eliminated_vars = [] - info = LinearStandardFormInfo(c, A, rhs, rows, columns, eliminated_vars) + info = LinearStandardFormInfo( + c, np.array(obj_offset), A, rhs, rows, columns, objectives, eliminated_vars + ) timer.toc("Generated linear standard form representation", delta=False) return info From 89556c3da721c10ad7390cc2be0a0cc07e1124db Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 4 Apr 2024 10:25:19 -0600 Subject: [PATCH 07/19] standard_form: allow empty objectives, constraints --- pyomo/repn/plugins/standard_form.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index d09537e4eee..0211ba44387 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -465,13 +465,17 @@ def write(self, model): # Get the variable list columns = list(var_map.values()) # Convert the compiled data to scipy sparse matrices + if obj_data: + obj_data = np.concatenate(obj_data) + obj_index = np.concatenate(obj_index) c = scipy.sparse.csr_array( - (np.concatenate(obj_data), np.concatenate(obj_index), obj_index_ptr), - [len(obj_index_ptr) - 1, len(columns)], + (obj_data, obj_index, obj_index_ptr), [len(obj_index_ptr) - 1, len(columns)] ).tocsc() + if rows: + con_data = np.concatenate(con_data) + con_index = np.concatenate(con_index) A = scipy.sparse.csr_array( - (np.concatenate(con_data), np.concatenate(con_index), con_index_ptr), - [len(rows), len(columns)], + (con_data, con_index, con_index_ptr), [len(rows), len(columns)] ).tocsc() # Some variables in the var_map may not actually appear in the From e60c0dea7749100c94da6a697395d143e720f3c5 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 4 Apr 2024 10:31:24 -0600 Subject: [PATCH 08/19] gurobi_direct: support (partial) loading duals, reduced costs --- pyomo/contrib/solver/gurobi_direct.py | 77 ++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 7 deletions(-) diff --git a/pyomo/contrib/solver/gurobi_direct.py b/pyomo/contrib/solver/gurobi_direct.py index 1164686f0f1..f10cc8f619f 100644 --- a/pyomo/contrib/solver/gurobi_direct.py +++ b/pyomo/contrib/solver/gurobi_direct.py @@ -15,6 +15,7 @@ import os from pyomo.common.config import ConfigValue +from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.common.dependencies import attempt_import from pyomo.common.shutdown import python_is_shutting_down from pyomo.common.tee import capture_output, TeeStream @@ -59,10 +60,13 @@ def __init__( class GurobiDirectSolutionLoader(SolutionLoaderBase): - def __init__(self, grb_model, grb_vars, pyo_vars): + def __init__(self, grb_model, grb_cons, grb_vars, pyo_cons, pyo_vars, pyo_obj): self._grb_model = grb_model + self._grb_cons = grb_cons self._grb_vars = grb_vars + self._pyo_cons = pyo_cons self._pyo_vars = pyo_vars + self._pyo_obj = pyo_obj GurobiDirect._num_instances += 1 def __del__(self): @@ -72,15 +76,70 @@ def __del__(self): GurobiDirect.release_license() def load_vars(self, vars_to_load=None, solution_number=0): - assert vars_to_load is None assert solution_number == 0 - for p_var, g_var in zip(self._pyo_vars, self._grb_vars.x.tolist()): + if self._grb_model.SolCount == 0: + raise RuntimeError( + 'Solver does not currently have a valid solution. Please ' + 'check the termination condition.' + ) + + iterator = zip(self._pyo_vars, self._grb_vars.x.tolist()) + if vars_to_load: + vars_to_load = ComponentSet(vars_to_load) + iterator = filter(lambda var_val: var_val[0] in vars_to_load, iterator) + for p_var, g_var in iterator: p_var.set_value(g_var, skip_validation=True) + StaleFlagManager.mark_all_as_stale(delayed=True) - def get_primals(self, vars_to_load=None): - assert vars_to_load is None + def get_primals(self, vars_to_load=None, solution_number=0): assert solution_number == 0 - return ComponentMap(zip(self._pyo_vars, self._grb_vars.x.tolist())) + if self._grb_model.SolCount == 0: + raise RuntimeError( + 'Solver does not currently have a valid solution. Please ' + 'check the termination condition.' + ) + + iterator = zip(self._pyo_vars, self._grb_vars.x.tolist()) + if vars_to_load: + vars_to_load = ComponentSet(vars_to_load) + iterator = filter(lambda var_val: var_val[0] in vars_to_load, iterator) + return ComponentMap(iterator) + + def get_duals(self, cons_to_load=None): + if self._grb_model.Status != gurobipy.GRB.OPTIMAL: + raise RuntimeError( + 'Solver does not currently have valid duals. Please ' + 'check the termination condition.' + ) + + def dedup(_iter): + last = None + for con_info_dual in _iter: + if not con_info_dual[1] and con_info_dual[0][0] is last: + continue + last = con_info_dual[0][0] + yield con_info_dual + + iterator = dedup(zip(self._pyo_cons, self._grb_cons.getAttr('Pi').tolist())) + if cons_to_load: + cons_to_load = set(cons_to_load) + iterator = filter( + lambda con_info_dual: con_info_dual[0][0] in cons_to_load, iterator + ) + return {con_info[0]: dual for con_info, dual in iterator} + + def get_reduced_costs(self, vars_to_load=None): + if self._grb_model.Status != gurobipy.GRB.OPTIMAL: + raise RuntimeError( + 'Solver does not currently have valid reduced costs. Please ' + 'check the termination condition.' + ) + + iterator = zip(self._pyo_vars, self._grb_vars.getAttr('Rc').tolist()) + if vars_to_load: + vars_to_load = ComponentSet(vars_to_load) + iterator = filter(lambda var_rc: var_rc[0] in vars_to_load, iterator) + return ComponentMap(iterator) class GurobiDirect(SolverBase): @@ -240,7 +299,11 @@ def solve(self, model, **kwds) -> Results: os.chdir(orig_cwd) res = self._postsolve( - timer, GurobiDirectSolutionLoader(gurobi_model, x, repn.columns) + timer, + config, + GurobiDirectSolutionLoader( + gurobi_model, A, x, repn.rows, repn.columns, repn.objectives + ), ) res.solver_configuration = config res.solver_name = 'Gurobi' From dbefc5cab0a824283b8ac00d92950e569a10760f Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 4 Apr 2024 10:39:44 -0600 Subject: [PATCH 09/19] gurobi_direct: do not store ephemeral config on instance; rename gprob --- pyomo/contrib/solver/gurobi_direct.py | 38 +++++++++++++-------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/pyomo/contrib/solver/gurobi_direct.py b/pyomo/contrib/solver/gurobi_direct.py index f10cc8f619f..1d4b1871654 100644 --- a/pyomo/contrib/solver/gurobi_direct.py +++ b/pyomo/contrib/solver/gurobi_direct.py @@ -213,12 +213,13 @@ def version(self): def solve(self, model, **kwds) -> Results: start_timestamp = datetime.datetime.now(datetime.timezone.utc) - self._config = config = self.config(value=kwds, preserve_implicit=True) - StaleFlagManager.mark_all_as_stale() + config = self.config(value=kwds, preserve_implicit=True) if config.timer is None: config.timer = HierarchicalTimer() timer = config.timer + StaleFlagManager.mark_all_as_stale() + timer.start('compile_model') repn = LinearStandardFormCompiler().write(model, mixed_form=True) timer.stop('compile_model') @@ -256,8 +257,8 @@ def solve(self, model, **kwds) -> Results: try: orig_cwd = os.getcwd() - if self._config.working_dir: - os.chdir(self._config.working_dir) + if config.working_dir: + os.chdir(config.working_dir) with TeeStream(*ostreams) as t, capture_output(t.STDOUT, capture_fd=False): gurobi_model = gurobipy.Model() @@ -316,17 +317,15 @@ def solve(self, model, **kwds) -> Results: res.timing_info.timer = timer return res - def _postsolve(self, timer: HierarchicalTimer, loader): - config = self._config - - gprob = loader._grb_model - status = gprob.Status + def _postsolve(self, timer: HierarchicalTimer, config, loader): + grb_model = loader._grb_model + status = grb_model.Status results = Results() results.solution_loader = loader - results.timing_info.gurobi_time = gprob.Runtime + results.timing_info.gurobi_time = grb_model.Runtime - if gprob.SolCount > 0: + if grb_model.SolCount > 0: if status == gurobipy.GRB.OPTIMAL: results.solution_status = SolutionStatus.optimal else: @@ -349,30 +348,31 @@ def _postsolve(self, timer: HierarchicalTimer, loader): 'to bypass this error.' ) - results.incumbent_objective = None - results.objective_bound = None try: - results.incumbent_objective = gprob.ObjVal + if math.isfinite(grb_model.ObjVal): + results.incumbent_objective = grb_model.ObjVal + else: + results.incumbent_objective = None except (gurobipy.GurobiError, AttributeError): results.incumbent_objective = None try: - results.objective_bound = gprob.ObjBound + results.objective_bound = grb_model.ObjBound except (gurobipy.GurobiError, AttributeError): - if self._objective.sense == minimize: + if grb_model.ModelSense == OptimizationSense.minimize: results.objective_bound = -math.inf else: results.objective_bound = math.inf - if results.incumbent_objective is not None and not math.isfinite( results.incumbent_objective ): results.incumbent_objective = None + results.objective_bound = None - results.iteration_count = gprob.getAttr('IterCount') + results.iteration_count = grb_model.getAttr('IterCount') timer.start('load solution') if config.load_solutions: - if gprob.SolCount > 0: + if grb_model.SolCount > 0: results.solution_loader.load_vars() else: raise RuntimeError( From e11fd66b7c8abeff4aa7c9b728de252ec20741d9 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 4 Apr 2024 10:41:10 -0600 Subject: [PATCH 10/19] gurobi_direct: make var processing more efficient --- pyomo/contrib/solver/gurobi_direct.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/pyomo/contrib/solver/gurobi_direct.py b/pyomo/contrib/solver/gurobi_direct.py index 1d4b1871654..54ef2c5306e 100644 --- a/pyomo/contrib/solver/gurobi_direct.py +++ b/pyomo/contrib/solver/gurobi_direct.py @@ -237,20 +237,19 @@ def solve(self, model, **kwds) -> Results: _u = inf lb.append(_l) ub.append(_u) + CON = gurobipy.GRB.CONTINUOUS + BIN = gurobipy.GRB.BINARY + INT = gurobipy.GRB.INTEGER vtype = [ ( - gurobipy.GRB.CONTINUOUS + CON if v.is_continuous() - else ( - gurobipy.GRB.BINARY - if v.is_binary() - else gurobipy.GRB.INTEGER if v.is_integer() else '?' - ) + else (BIN if v.is_binary() else INT if v.is_integer() else '?') ) for v in repn.columns ] - sense_type = '>=<' - sense = [sense_type[r[1] + 1] for r in repn.rows] + sense_type = '=<>' # Note: ordering matches 0, 1, -1 + sense = [sense_type[r[1]] for r in repn.rows] timer.stop('prepare_matrices') ostreams = [io.StringIO()] + config.tee From 99a7efc2cbc93456c2a0e69850593444613b4a24 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 4 Apr 2024 10:41:57 -0600 Subject: [PATCH 11/19] gurobi_direct: support models with no objectives --- pyomo/contrib/solver/gurobi_direct.py | 36 ++++++++++++++------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/pyomo/contrib/solver/gurobi_direct.py b/pyomo/contrib/solver/gurobi_direct.py index 54ef2c5306e..82491e88a42 100644 --- a/pyomo/contrib/solver/gurobi_direct.py +++ b/pyomo/contrib/solver/gurobi_direct.py @@ -266,10 +266,13 @@ def solve(self, model, **kwds) -> Results: len(repn.columns), lb=lb, ub=ub, - obj=repn.c.todense()[0], + obj=repn.c.todense()[0] if repn.c.shape[0] else 0, vtype=vtype, ) A = gurobi_model.addMConstr(repn.A, x, sense, repn.rhs) + if repn.c.shape[0]: + gurobi_model.setAttr('ObjCon', repn.c_offset[0]) + gurobi_model.setAttr('ModelSense', int(repn.objectives[0].sense)) # gurobi_model.update() timer.stop('transfer_model') @@ -347,23 +350,22 @@ def _postsolve(self, timer: HierarchicalTimer, config, loader): 'to bypass this error.' ) - try: - if math.isfinite(grb_model.ObjVal): - results.incumbent_objective = grb_model.ObjVal - else: + if loader._pyo_obj: + try: + if math.isfinite(grb_model.ObjVal): + results.incumbent_objective = grb_model.ObjVal + else: + results.incumbent_objective = None + except (gurobipy.GurobiError, AttributeError): results.incumbent_objective = None - except (gurobipy.GurobiError, AttributeError): - results.incumbent_objective = None - try: - results.objective_bound = grb_model.ObjBound - except (gurobipy.GurobiError, AttributeError): - if grb_model.ModelSense == OptimizationSense.minimize: - results.objective_bound = -math.inf - else: - results.objective_bound = math.inf - if results.incumbent_objective is not None and not math.isfinite( - results.incumbent_objective - ): + try: + results.objective_bound = grb_model.ObjBound + except (gurobipy.GurobiError, AttributeError): + if grb_model.ModelSense == OptimizationSense.minimize: + results.objective_bound = -math.inf + else: + results.objective_bound = math.inf + else: results.incumbent_objective = None results.objective_bound = None From 7e4938f8e4fe32eef50c1d4816c378d0e28b6413 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 4 Apr 2024 10:42:19 -0600 Subject: [PATCH 12/19] NFC: wrap long line --- pyomo/contrib/solver/gurobi_direct.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/gurobi_direct.py b/pyomo/contrib/solver/gurobi_direct.py index 82491e88a42..ab5a07bc5f5 100644 --- a/pyomo/contrib/solver/gurobi_direct.py +++ b/pyomo/contrib/solver/gurobi_direct.py @@ -54,7 +54,8 @@ def __init__( ConfigValue( default=False, domain=bool, - description="If True, the values of the integer variables will be passed to Gurobi.", + description="If True, the current values of the integer variables " + "will be passed to Gurobi.", ), ) From 93590c6eabbb1c2f0b06592f6687d2feefb0243e Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 4 Apr 2024 10:42:45 -0600 Subject: [PATCH 13/19] gurobi_direct: add error checking for MO problems --- pyomo/contrib/solver/gurobi_direct.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyomo/contrib/solver/gurobi_direct.py b/pyomo/contrib/solver/gurobi_direct.py index ab5a07bc5f5..e0e0d7d32b0 100644 --- a/pyomo/contrib/solver/gurobi_direct.py +++ b/pyomo/contrib/solver/gurobi_direct.py @@ -225,6 +225,12 @@ def solve(self, model, **kwds) -> Results: repn = LinearStandardFormCompiler().write(model, mixed_form=True) timer.stop('compile_model') + if len(repn.objectives) > 1: + raise ValueError( + f"The {self.__class__.__name__} solver only supports models " + f"with zero or one objectives (received {len(repn.objectives)})." + ) + timer.start('prepare_matrices') inf = float('inf') ninf = -inf From 6fa6196a3e66c42b91a19e69ef9275c6289c493c Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 4 Apr 2024 10:48:34 -0600 Subject: [PATCH 14/19] standard_form: add option to control the final optimization sense --- pyomo/contrib/solver/gurobi_direct.py | 5 ++++- pyomo/repn/plugins/standard_form.py | 12 +++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/gurobi_direct.py b/pyomo/contrib/solver/gurobi_direct.py index e0e0d7d32b0..36a783cba02 100644 --- a/pyomo/contrib/solver/gurobi_direct.py +++ b/pyomo/contrib/solver/gurobi_direct.py @@ -17,6 +17,7 @@ from pyomo.common.config import ConfigValue from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.common.dependencies import attempt_import +from pyomo.common.enums import OptimizationSense from pyomo.common.shutdown import python_is_shutting_down from pyomo.common.tee import capture_output, TeeStream from pyomo.common.timing import HierarchicalTimer @@ -222,7 +223,9 @@ def solve(self, model, **kwds) -> Results: StaleFlagManager.mark_all_as_stale() timer.start('compile_model') - repn = LinearStandardFormCompiler().write(model, mixed_form=True) + repn = LinearStandardFormCompiler().write( + model, mixed_form=True, set_sense=None + ) timer.stop('compile_model') if len(repn.objectives) > 1: diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index 0211ba44387..566d0d8d932 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -20,6 +20,7 @@ document_kwargs_from_configdict, ) from pyomo.common.dependencies import scipy, numpy as np +from pyomo.common.enums import OptimizationSense from pyomo.common.gc_manager import PauseGC from pyomo.common.timing import TicTocTimer @@ -158,6 +159,14 @@ class LinearStandardFormCompiler(object): 'mix of <=, ==, and >=)', ), ) + CONFIG.declare( + 'set_sense', + ConfigValue( + default=OptimizationSense.minimize, + domain=InEnum(OptimizationSense), + description='If not None, map all objectives to the specified sense.', + ), + ) CONFIG.declare( 'show_section_timing', ConfigValue( @@ -315,6 +324,7 @@ def write(self, model): # # Process objective # + set_sense = self.config.set_sense objectives = [] for blk in component_map[Objective]: objectives.extend( @@ -336,7 +346,7 @@ def write(self, model): N = len(repn.linear) obj_data.append(np.fromiter(repn.linear.values(), float, N)) obj_offset.append(repn.constant) - if obj.sense == maximize: + if set_sense is not None and set_sense != obj.sense: obj_data[-1] *= -1 obj_offset[-1] *= -1 obj_index.append( From d546a248493a34623e61e46f002f3d85b8ef2b66 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 4 Apr 2024 10:49:00 -0600 Subject: [PATCH 15/19] Add gurobi_direcct to the solver test suite --- pyomo/contrib/solver/tests/solvers/test_solvers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index a4f4a3bc389..f91de2287b7 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -21,6 +21,7 @@ from pyomo.contrib.solver.base import SolverBase from pyomo.contrib.solver.ipopt import Ipopt from pyomo.contrib.solver.gurobi import Gurobi +from pyomo.contrib.solver.gurobi_direct import GurobiDirect from pyomo.core.expr.numeric_expr import LinearExpression @@ -32,8 +33,8 @@ if not param_available: raise unittest.SkipTest('Parameterized is not available.') -all_solvers = [('gurobi', Gurobi), ('ipopt', Ipopt)] -mip_solvers = [('gurobi', Gurobi)] +all_solvers = [('gurobi', Gurobi), ('gurobi_direct', GurobiDirect), ('ipopt', Ipopt)] +mip_solvers = [('gurobi', Gurobi), ('gurobi_direct', GurobiDirect)] nlp_solvers = [('ipopt', Ipopt)] qcp_solvers = [('gurobi', Gurobi), ('ipopt', Ipopt)] miqcqp_solvers = [('gurobi', Gurobi)] From 30490d24bcdf58763330f3216cbc30c93e559089 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 4 Apr 2024 10:58:14 -0600 Subject: [PATCH 16/19] Correct import of ObjectiveSense enum --- pyomo/contrib/solver/gurobi_direct.py | 4 ++-- pyomo/repn/plugins/standard_form.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/solver/gurobi_direct.py b/pyomo/contrib/solver/gurobi_direct.py index 36a783cba02..7b80651ccae 100644 --- a/pyomo/contrib/solver/gurobi_direct.py +++ b/pyomo/contrib/solver/gurobi_direct.py @@ -17,7 +17,7 @@ from pyomo.common.config import ConfigValue from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.common.dependencies import attempt_import -from pyomo.common.enums import OptimizationSense +from pyomo.common.enums import ObjectiveSense from pyomo.common.shutdown import python_is_shutting_down from pyomo.common.tee import capture_output, TeeStream from pyomo.common.timing import HierarchicalTimer @@ -371,7 +371,7 @@ def _postsolve(self, timer: HierarchicalTimer, config, loader): try: results.objective_bound = grb_model.ObjBound except (gurobipy.GurobiError, AttributeError): - if grb_model.ModelSense == OptimizationSense.minimize: + if grb_model.ModelSense == ObjectiveSense.minimize: results.objective_bound = -math.inf else: results.objective_bound = math.inf diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index 566d0d8d932..a5aaece8531 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -20,7 +20,7 @@ document_kwargs_from_configdict, ) from pyomo.common.dependencies import scipy, numpy as np -from pyomo.common.enums import OptimizationSense +from pyomo.common.enums import ObjectiveSense from pyomo.common.gc_manager import PauseGC from pyomo.common.timing import TicTocTimer @@ -162,8 +162,8 @@ class LinearStandardFormCompiler(object): CONFIG.declare( 'set_sense', ConfigValue( - default=OptimizationSense.minimize, - domain=InEnum(OptimizationSense), + default=ObjectiveSense.minimize, + domain=InEnum(ObjectiveSense), description='If not None, map all objectives to the specified sense.', ), ) From b57adf1bc3aaa5c9c93e1988e31da94eb6ad81d7 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 4 Apr 2024 13:17:29 -0600 Subject: [PATCH 17/19] Add gurobi_direct to the docs --- doc/OnlineDocs/developer_reference/solvers.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/doc/OnlineDocs/developer_reference/solvers.rst b/doc/OnlineDocs/developer_reference/solvers.rst index 94fb684236f..9e3281246f4 100644 --- a/doc/OnlineDocs/developer_reference/solvers.rst +++ b/doc/OnlineDocs/developer_reference/solvers.rst @@ -45,9 +45,12 @@ with existing interfaces). * - Ipopt - ``ipopt`` - ``ipopt_v2`` - * - Gurobi + * - Gurobi (persistent) - ``gurobi`` - ``gurobi_v2`` + * - Gurobi (direct) + - ``gurobi_direct`` + - ``gurobi_direct_v2`` Using the new interfaces through the legacy interface ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From 9790e080a7f3412841125e7c38ada2506e583ea2 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Thu, 4 Apr 2024 13:55:08 -0600 Subject: [PATCH 18/19] NFC: fix typo --- pyomo/repn/plugins/standard_form.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index a5aaece8531..110e95c3c6d 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -96,7 +96,7 @@ class LinearStandardFormInfo(object): objectives : List[_ObjectiveData] - The list of Pyomo objective objects correcponding to the active objectives + The list of Pyomo objective objects corresponding to the active objectives eliminated_vars: List[Tuple[_VarData, NumericExpression]] From fc58199106b62604946d9c20dde95f2b0362e70f Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 7 May 2024 06:54:32 -0600 Subject: [PATCH 19/19] Clarify comment --- pyomo/contrib/solver/gurobi_direct.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/gurobi_direct.py b/pyomo/contrib/solver/gurobi_direct.py index 7b80651ccae..edca7018f92 100644 --- a/pyomo/contrib/solver/gurobi_direct.py +++ b/pyomo/contrib/solver/gurobi_direct.py @@ -283,7 +283,8 @@ def solve(self, model, **kwds) -> Results: if repn.c.shape[0]: gurobi_model.setAttr('ObjCon', repn.c_offset[0]) gurobi_model.setAttr('ModelSense', int(repn.objectives[0].sense)) - # gurobi_model.update() + # Note: calling gurobi_model.update() here is not + # necessary (it will happen as part of optimize()) timer.stop('transfer_model') options = config.solver_options