From aeff0ff96d50c4acf508889fc0d12031e5cb32da Mon Sep 17 00:00:00 2001 From: Daniel Junglas Date: Fri, 2 Dec 2022 08:01:26 +0100 Subject: [PATCH 01/10] Add support for callbacks in Python. --- .../solvers/plugins/solvers/xpress_direct.py | 476 +++++++++++++++++- .../tests/checks/test_xpress_persistent.py | 375 ++++++++++++++ 2 files changed, 849 insertions(+), 2 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/xpress_direct.py b/pyomo/solvers/plugins/solvers/xpress_direct.py index 6ed7f58e993..dbc4ccbd44f 100644 --- a/pyomo/solvers/plugins/solvers/xpress_direct.py +++ b/pyomo/solvers/plugins/solvers/xpress_direct.py @@ -32,6 +32,7 @@ from pyomo.opt.results.solver import TerminationCondition, SolverStatus from pyomo.opt.base import SolverFactory from pyomo.core.base.suffix import Suffix +from pyomo.core.base.constraint import Constraint import pyomo.core.base.var @@ -110,10 +111,476 @@ def __call__(self): callback=_finalize_xpress_import, ) +class CallbackContext(object): + """Base class for the contexts that are passed to Xpress callback functions. + + Any callback function supported in Pyomo receives a single argument: + an instance of a subclass of `CallbackContext`. This instance carries + callback-specific information and also allows callback-specific actions. + """ + def __init__(self, prob, solver, var2idx): + super(CallbackContext, self).__init__() + self._prob = prob + self._solver = solver + self._lpsol = None + self._mipsol = None + self._var2idx = var2idx + @property + def solver(self): + """The Pyomo solver object.""" + return self._solver + @property + def prob(self): + """The callback local Xpress problem instance.""" + return self._prob + @property + def attributes(self): + """The attributes of the callback local Xpress problem instance.""" + return self._prob.attributes + @property + def controls(self): + """The controls of the callback local Xpress problem instance.""" + return self._prob.controls + def _make_value_map(self, values, comp2idx, component_map): + """Create a `ComponentMap` that maps the keys in `component_map` to + data from `values`. + `values`: Array of values the indices of which correspond to + Xpress variable indices. + `comp2idx`: Maps Xpress variables to their respective index. + `component_map`: Maps Pyomo variables (or other stuff) to + Xpress variables (or other stuff) + """ + m = ComponentMap() + for p in component_map: + m[p] = values[comp2idx[component_map[p]]] + return m + def getlpsol(self): + """Get LP solution for callback. + + The meaning of the returned vector depends on the actual callback + context. + Returns a `ComponentMap` that maps Pyomo variables to values. + """ + if self._lpsol is None: + x = [0.0] * self._prob.attributes.originalcols + self._prob.getlpsol(x) + self._lpsol = self._make_value_map(x, self._var2idx, + self._solver._pyomo_var_to_solver_var_map) + return self._lpsol + + def getmipsol(self): + """Get MIP solution for callback. + + The meaning of the returned vector depends on the actual callback + context. + Returns a `ComponentMap` that maps Pyomo variables to values. + """ + if self._mipsol is None: + x = [0.0] * self._prob.attributes.originalcols + self._prob.getmipsol(x) + self._mipsol = self._make_value_map(x, self._var2idx, + self._solver._pyomo_var_to_solver_var_map) + return self._mipsol + + def addmipsol(self, sol): + """Inject a feasible solution. + + - `sol`: A map from Pyomo variables to solution values. + May be incomplete. If multiple values are specified for the + same variable then it is unspecified which of them will be + used. + """ + var2idx = self._var2idx + component_map = self._solver._pyomo_var_to_solver_var_map + data = dict() + for x in sol: + data[var2idx[component_map[x]]] = sol[x] + self._prob.addmipsol([data[x] for x in sorted(data)], sorted(data)) + + def addcut(self, cut, cuttype=0): + """Add a (local) cut to the current node. + + - `cut`: The cut to add. Must be a linear non-range constraint. + - `cuttype`: An integer chosen by the user. The Xpress optimizer does + not use the actual value. + """ + + if not isinstance(cut, Constraint): + cut = Constraint(expr=cut) + cut.construct() + + if cut.has_lb(): + if cut.has_ub(): + if cut.lower != cut.upper: + raise ValueError("Cannot add ranged cut {0} ".format(cut)) + sense = 'E' + origrhs = cut.lower + else: + sense = 'G' + origrhs = cut.lower + elif cut.has_ub(): + sense = 'L' + origrhs = cut.upper + else: + raise ValueError("Cannot add free cut {0} ".format(cut)) + + if cut._linear_canonical_form: + lhs = cut.canonical_form() + else: + lhs = generate_standard_repn(cut.body, quadratic=False) + + degree = lhs.polynomial_degree() + if (degree is None) or (degree > 1): + raise ValueError("Cannot add non-linear cut {0} ".format(cut)) + + + origrowcoef = lhs.linear_coefs + origcolind = [self._var2idx[self._solver._pyomo_var_to_solver_var_map[x]] for x in lhs.linear_vars] + maxcoefs = self._prob.attributes.cols + colind = [] + rowcoef = [] + redrhs, status = self._prob.presolverow(sense, origcolind, + origrowcoef, origrhs, + maxcoefs, colind, rowcoef) + if status != 0: + raise RuntimeError('Cut cannot be presolved: %d' % status) + self._prob.addcuts([cuttype], [sense], [redrhs], [0, len(colind)], + colind, rowcoef) + +class MessageCallbackContext(CallbackContext): + """Data passed to message callbacks. + + In addition to the super class, this class has the following properties: + - `msg` [read]: The message that is sent (may be `None`). + - `msgtype` [read]: The type of message sent (info, warning, error). + """ + def __init__(self, prob, solver, var2idx, msg, msgtype): + super(MessageCallbackContext, self).__init__(prob, solver, var2idx) + self._msg = msg + self._msgtype = msgtype + @property + def msg(self): + """Message sent by this callback.""" + return self._msg + @property + def msgtype(self): + """Type of message sent.""" + return self._msgtype + @property + def is_info(self): + """`True` if this is an info message.""" + return self._msgtype == 1 + @property + def is_warning(self): + """`True` if this is a warning message.""" + return self._msgtype == 3 + @property + def is_error(self): + """`True` if this is an error message.""" + return self._msgtype == 4 + @property + def is_flush(self): + """`True` if this message is a request to flush output.""" + return self._msgtype < 0 + +class OptNodeCallbackContext(CallbackContext): + """Data passed to optnode callbacks. + + In addition to the super class, this class has two properties: + - `infeas` [write]: Wether the node is considered infeasible. + """ + def __init__(self, prob, solver, var2idx): + super(OptNodeCallbackContext, self).__init__(prob, solver, var2idx) + self._infeas = False + @property + def infeas(self): + """Message sent by this callback.""" + return self._infeas + @infeas.setter + def infeas(self, value): + self._infeas = value + +class PreIntSolCallbackContext(CallbackContext): + """Data passed to preintsol callbacks. + + In addition to the super class, this class has the following properties: + - `soltype` [read]: Solution type (heuristic, optimal node, ...) + - `cutoff` [read/write]: New cutoff value in case solution is not rejected + - `reject` [write]: Whether solution should be rejected + - `candidate_sol` [read]: The candidate solution. + - `candidate_obj` [read]: The objective value for `candidate_sol` + """ + def __init__(self, prob, solver, var2idx, soltype, cutoff): + super(PreIntSolCallbackContext, self).__init__(prob, solver, var2idx) + self._soltype = soltype + self._cutoff = cutoff + self._reject = False + @property + def soltype(self): + """Solution type.""" + return self._soltype + @property + def cutoff(self): + """Cutoff if solution if accepted.""" + return self._cutoff + @cutoff.setter + def cutoff(self, value): + """Cutoff if solution if accepted.""" + self._cutoff = value + @property + def reject(self): + """Should solution be rejected?""" + return self._reject + @reject.setter + def reject(self, value): + """Should solution be rejected?""" + self._reject = value + @property + def candidate_sol(self): + """Returns the candidate solution as a map from Pyomo variables to + values. + """ + return self.getlpsol() + @property + def candidate_obj(self): + """Get the objective value for `candidate_sol`.""" + return self.attributes.lpobjval + +class IntSolCallbackContext(CallbackContext): + """Data passed to intsol callbacks. + + In addition to the super class, this class has the following properties: + - `solution` [read]: The solution being reported. + - `objval` [read]: The objective value of `solution`. + """ + def __init__(self, prob, solver, var2idx): + super(IntSolCallbackContext, self).__init__(prob, solver, var2idx) + @property + def solution(self): + """Returns the solution as a map from Pyomo variables to values.""" + return self.getmipsol() + @property + def objval(self): + """Get the objective value for `solution`.""" + return self.attributes.mipobjval + +class ChgBranchObjectCallbackContext(CallbackContext): + """Data passed to chgbranchobject callbacks. + + In addition to the super class, this class has the following properties: + - `orig_branchobject` [read]: The branching that Xpress suggests + - `branchobject' [read/write]: The branching that will be executed. + - `new_object()`: Create a new branching object. + """ + def __init__(self, prob, solver, var2idx, obranch): + super(ChgBranchObjectCallbackContext, self).__init__(prob, solver, var2idx) + self._obranch = obranch + self._branch = obranch + @property + def orig_branchobject(self): + """The branching suggested by Xpress.""" + return self._obranch + @property + def branchobject(self): + """The branching that will be carried out. + + If you set this to `None` then Xpress will carry out the branching + it originally intended to do. + """ + return self._branch + @branchobject.setter + def branchobject(self, value): + self._branch = value + def new_object(self, nbranch): + """Create a new branch object that branches in the original space.""" + return xpress.branchobj(prob, nbranch, isoriginal=True) + def map_pyomo_var(self, var): + """Map a Pyomo variable to an Xpress variable.""" + return self._solver._pyomo_var_to_solver_var_map[var] + +class CallbackInterface(object): + """This is an internal class that is used for callback handling. + + Callbacks are stored internally and are only applied to an Xpress + problem instance when `_apply` is called. The usage pattern is this: + with callback_interface._apply(prob): + prob.solve() + """ + + class EmptyManager: + """Context manager used in `_apply` in case there are not callbacks.""" + def __enter__(self): + return self + def __exit__(self, type, value, traceback): + pass + + def __init__(self): + super(CallbackInterface, self).__init__() + self._callbacks = { + 'message': [], + 'optnode': [], + 'preintsol': [], + 'intsol': [], + 'chgbranchobject': [] + } + self._numcbs = 0 + self._nocb = CallbackInterface.EmptyManager() + def _add_callback(self, name, cb, priority): + self._callbacks[name].append((cb, priority)) + self._numcbs += 1 + def _del_callback(self, name, cb): + self._numcbs -= len(self._callbacks[name]) + if cb is None: + # Delete all + self._callbacks[name] = [] + else: + try: + self._callbacks[name].remove(cb) + except ValueError: + # It is ok if the callback was not even registered + pass + self._numcbs += len(self._callbacks[name]) + def add_message(self, messagecb, priority = 0): + """Add a message callback. + `messagecb` must be a callable that takes exactly one argument. + It will be invoked with an instance of `MessageCallbackContext` + as argument. + """ + self._add_callback('message', messagecb, priority) + def del_message(self, messagecb = None): + """Remove a specific or all (`messagecb` is `None`) message callbacks.""" + self._del_callback('message', messagecb) + def _message_wrapper(self, solver, var2idx): + return lambda prob, cb, msg, msgtype: cb(MessageCallbackContext(prob, solver, var2idx, msg, msgtype)) + + def add_optnode(self, optnodecb, priority = 0): + """Add an optnode callback. + `optnodecb` must be a callable that takes exactly one argument. + It will be invoked with an instance of `OptNodeCallbackContext` + as argument. + """ + self._add_callback('optnode', optnodecb, priority) + def del_optnode(self, optnodecb = None): + """Remove a specific or all (`optnodecb` is `None`) optnode callbacks.""" + self._del_callback('optnode', optnodecb) + def _optnode_wrapper(self, solver, var2idx): + def wrapper(prob, cb): + data = OptNodeCallbackContext(prob, solver, var2idx) + cb(data) + return data.infeas + return wrapper + + def add_preintsol(self, preintsolcb, priority = 0): + """Add a preintsol callback. + `preintsolcb` must be a callable that takes exactly one argument. + It will be invoked with an instance of `PreIntSolCallbackContext` + as argument. + """ + self._add_callback('preintsol', preintsolcb, priority) + def del_preintsol(self, preintsolcb = None): + """Remove a specific or all (`preintsolcb` is `None`) preintsol callbacks.""" + self._del_callback('preintsol', preintsolcb) + def _preintsol_wrapper(self, solver, var2idx): + def wrapper(prob, cb, soltype, cutoff): + data = PreIntSolCallbackContext(prob, solver, var2idx, soltype, cutoff) + cb(data) + return (data.reject, data.cutoff) + return wrapper + + def add_intsol(self, intsolcb, priority = 0): + """Add an intsol callback. + `intsolcb` must be a callable that takes exactly one argument. + It will be invoked with an instance of `IntSolCallbackContext` + as argument. + """ + self._add_callback('intsol', intsolcb, priority) + def del_intsol(self, intsolcb = None): + """Remove a specific or all (`intsolcb` is `None`) intsol callbacks.""" + self._del_callback('intsol', preintsolcb) + def _intsol_wrapper(self, solver, var2idx): + return lambda prob, cb: cb(IntSolCallbackContext(prob, solver, var2idx)) + + def add_chgbranchobject(self, chgbranchobjectcb, priority = 0): + """Add an chgbranchobject callback.""" + self._add_callback('chgbranchobject', chgbranchobjectcb, priority) + def del_chgbranchobject(self, chgbranchobjectcb = None): + """Remove a specific or all (`chgbranchobjectcb` is `None`) chgbranchobject callbacks.""" + self._del_callback('chgbranchobject', prechgbranchobjectcb) + def _chgbranchobject_wrapper(self, solver, var2idx): + def wrapper(prob, cb, obranch): + data = ChgBranchObjectCallbackContext(prob, solver, var2idx, obranch) + cb(data) + return data.branchobject + return wrapper + + def _apply(self, prob, solver): + """Apply callback settings. + + Installs all registered callbacks into `prob`. + `solver` is the solver object that will be available as "solver" + property in the objects that are passed to the callback functions. + Returns a context manager that will uninstall callbacks in `__exit__`. + """ + if self._numcbs == 0: + return self._nocb + + # This is required in the callbacks to map Pyomo variables to the + # respective variable indices in the solver. + var2idx = { v: i for i, v in enumerate(prob.getVariable()) } + + class Manager(object): + def __init__(self, prob): + self._prob = prob + self._cbs = [] + def __enter__(self): + return self + def __exit__(self, type, value, traceback): + self._prob.removecbmessage(None, None) + self._prob.removecboptnode(None, None) + self._prob.removecbpreintsol(None, None) + self._prob.removecbintsol(None, None) + self._prob.removecbchgbranchobject(None, None) + m = Manager(prob) + try: + for cb, prio in self._callbacks['message']: + prob.addcbmessage(self._message_wrapper(solver, var2idx), cb, prio) + for cb, prio in self._callbacks['optnode']: + prob.addcboptnode(self._optnode_wrapper(solver, var2idx), cb, prio) + for cb, prio in self._callbacks['preintsol']: + prob.addcbpreintsol(self._preintsol_wrapper(solver, var2idx), cb, prio) + for cb, prio in self._callbacks['intsol']: + prob.addcbintsol(self._intsol_wrapper(solver, var2idx), cb, prio) + for cb, prio in self._callbacks['chgbranchobject']: + prob.addcbchgbranchobject(self._chgbranchobject_wrapper(solver, var2idx), cb, prio) + return m + except Exception: + t, v, b = sys.exc_info() + m.__exit__(t, v, b) + raise @SolverFactory.register('xpress_direct', doc='Direct python interface to XPRESS') class XpressDirect(DirectSolver): - + """ + Interface to the Xpress solver. + + Note that this solver also supports interaction with the solution process + via callbacks. The callbacks allow notification about new solutions, + give opportunities to reject solution, dynamically inject cuts and + constraints, etc. + + Callbacks are handled by the `callbacks` property which is an instance of + `CallbackInterface`. + Callbacks are registered via + solver.callbacks.add_preintsol(callback_function) + solver.callbacks.add_intsol(callback_function) + solver.callbacks.add_optnode(callback_function) + etc. + + See the documentation of `CallbackInterface` for further information and + the Xpress specific test cases in Pyomo for some examples how to use those. + Also see the general Xpress documentation to understand in which contexts + callbacks are invoked and what you can do with them. + """ _name = None _version = None XpressException = RuntimeError @@ -130,6 +597,7 @@ def __init__(self, **kwds): self._range_constraints = set() self._python_api_exists = xpress_available + self._callbacks = CallbackInterface() # TODO: this isn't a limit of XPRESS, which implements an SLP # method for NLPs. But it is a limit of *this* interface @@ -163,6 +631,9 @@ def available(self, exception_flag=True): "No Python bindings available for %s solver plugin" % (type(self),)) return bool(xpress_available) + @property + def callbacks(self): + return self._callbacks def _apply_solver(self): StaleFlagManager.mark_all_as_stale() @@ -224,7 +695,8 @@ def _apply_solver(self): return Bunch(rc=None, log=None) def _solve_model(self): - self._solver_model.solve() + with self._callbacks._apply(self._solver_model, self): + self._solver_model.solve() self._solver_model.postsolve() def _get_expr_from_pyomo_repn(self, repn, max_degree=2): diff --git a/pyomo/solvers/tests/checks/test_xpress_persistent.py b/pyomo/solvers/tests/checks/test_xpress_persistent.py index 96b9cc196af..7d797c0cb0f 100644 --- a/pyomo/solvers/tests/checks/test_xpress_persistent.py +++ b/pyomo/solvers/tests/checks/test_xpress_persistent.py @@ -3,6 +3,209 @@ from pyomo.core.expr.taylor_series import taylor_series_expansion from pyomo.solvers.plugins.solvers.xpress_direct import xpress_available +from pyomo.environ import value +from pyomo.opt import SolverFactory +from pyomo.common.collections import ComponentMap +import random +import math + +# This serves as example as well as test case. It illustrates how to use +# callbacks with Pyomo and the Xpress solver. Of course, this is very +# solver specific since callbacks are inherently solver specific +class TSP: + """ + Solve a MIP using cuts/constraints that are lazily separated. + + We take a random instance of the symmetric TSP and solve that using + lazily separated constraints. + + The model is based on a graph G = (V,E). + We have one binary variable x[e] for each edge e in E. That variable + is set to 1 if edge e is selected in the optimal tour and 0 otherwise. + + The model contains only these explicit constraint: + for each v in V: sum(u in V : u != v) x[uv] == 1 + for each v in V: sum(u in V : u != v) x[vu] == 1 + + This states that each node must be entered and exited exactly once in the + optimal tour. + + The above constraints ensures that the selected edges form tours. However, + it allows multiple tours, also known as subtours. So we need a constraint + that requires that there is only one tour (which then necessarily hits + all the nodes). For this we just create no-good constraints for subtours: + If S is a set of edges that forms a subtour, then we add constraint + sum(e in S) x[e] <= |S|-1 + + Since there are exponentially many subtours in a graph, this constraint + is not stated explicitly. Instead we check for any solution that the + optimizer finds, whether it satisfies the subtour elimination constraint. + If it does then we accept the solution. Otherwise we reject the solution + and augment the model by the violated subtour eliminiation constraint. + + This lazy addition of constraints is implemented using two callbacks: + - a preintsol callback that rejects any solution that violates a + subtour elimination constraint, + - an optnode callback that injects any violated subtour elimination + constraints. + + An important thing to note about this strategy is that dual reductions + have to be disabled. Since the optimizer does not see the whole model + (subtour elimination constraints are only generated on the fly), dual + reductions may cut off the optimal solution. + """ + def __init__(self, nodes, seed=0): + """Construct a new random instance with the given seed.""" + self._nodes = nodes # Number of nodes/cities in the instance. + self._nodex = [0.0] * nodes # X coordinate of nodes. + self._nodey = [0.0] * nodes # Y coordinate of nodes. + + random.seed(seed) + for i in range(nodes): + self._nodex[i] = 4.0 * random.random() + self._nodey[i] = 4.0 * random.random() + + def distance(self, u, v): + """Get the distance between two nodes.""" + return math.sqrt((self._nodex[u] - self._nodex[v]) ** 2 + + (self._nodey[u] - self._nodey[v]) ** 2) + + def find_tour(self, sol, prev=None): + """Find the tour rooted at the first city in a solution. + """ + if prev is None: + prev = dict() + tour = set() + x = self._model.x + + u = 1 + used = 0 + print('1', end='') + while True: + for v in self._model.cities: + if u == v: + continue # no self-loops + elif v in prev: + continue # node already on tour + elif sol[x[u, v]] < 0.5: + continue # edge not selected in solution + elif (u, v) in tour: + continue # edge already on tour + else: + print(' -> %d' % v, end='') + tour.add((u, v)) + prev[v] = u + used += 1; + u = v; + break + if u == 1: + break + print() + return used + + def preintsol(self, data): + """Integer solution check callback.""" + print("Checking feasible solution ...") + + # Get current solution and check whether it is feasible + used = self.find_tour(data.candidate_sol) + print("Solution is ", end='') + if used < len(self._model.cities): + print('infeasible (%d edges)' % used) + data.reject = True + else: + print('feasible with length %f' % data.candidate_obj) + + def optnode(self, data): + """Optimal node callback. + This callback is invoked after the LP relaxation of a node is solved. + This is where we can inject additional constraints as cuts. + """ + # Only separate constraints on nodes that are integer feasible. + if data.attributes.mipinfeas != 0: + return + + # Get the current solution + sol = data.getlpsol() + + # Get the tour starting at the first city and check whether it covers + # all nodes. If it does not then it is infeasible and we must + # generate a subtour elimination constraint. + prev = dict() + used = self.find_tour(sol, prev) + if used < len(self._model.cities): + # The tour is too short. Get the edges on the tour and add a + # subtour elimination constraint + lhs = sum(self._model.x[u, prev[u]] for u in self._model.cities if u in prev) + data.addcut(lhs <= (used - 1)) + data.infeas = True + + def create_initial_tour(self): + """Create a feasible tour and add this as initial MIP solution.""" + for u in self._model.cities: + for v in self._model.cities: + # A starting solution in Pyomo is set by assigning the values + # to the variables and then passing `warmstart=True` to the + # `solve()` call. + if v == u + 1 or (u == self._nodes and v == 1): + self._model.x[u, v] = 1.0 + else: + self._model.x[u, v] = 0.0 + + def solve(self): + """Solve the TSP represented by this instance.""" + self._model = pe.ConcreteModel() + self._model.cities = pe.RangeSet(self._nodes) + # Create variables. We create one variable for each edge in + # the complete directed graph. x[u,v] is set to 1 if the tour goes + # from u to v, otherwise it is set to 0. + # All variables are binary. + self._model.x = pe.Var(self._model.cities * self._model.cities, + within=pe.Binary) + self._model.cons = pe.ConstraintList() + + # Do not allow self loops. + # We could have skipped creating the variables but fixing them to + # 0 here is slightly easier. + for u in self._model.cities: + self._model.cons.add(self._model.x[u, u] <= 0.0) + + # Objective function. + obj = sum(self._model.x[u, v] * self.distance(u-1, v-1) for u in self._model.cities for v in self._model.cities) + self._model.obj = pe.Objective(expr=obj) + + # Constraint: Each node must be exited and entered exactly once. + for u in self._model.cities: + self._model.cons.add(sum(self._model.x[u, v] for v in self._model.cities if v != u) == 1) + self._model.cons.add(sum(self._model.x[v, u] for v in self._model.cities if v != u) == 1) + + # Create a starting solution. + # This is optional but having a feasible solution available right + # from the beginning can improve optimizer performance. + self.create_initial_tour() + + # We don't have all constraints explicitly in the matrix, hence + # we must disable dual reductions. Otherwise MIP presolve may + # cut off the optimal solution. + opt = SolverFactory('xpress_direct') + opt.options['mipdualreductions'] = 0 + + # Add a callback that rejects solutions that do not satisfy + # the subtour constraints. + opt.callbacks.add_preintsol(self.preintsol) + + # Add a callback that separates subtour elimination constraints + opt.callbacks.add_optnode(self.optnode) + + opt.solve(self._model, tee=True, warmstart=True) + + # Print the optimal tour. + print("Tour with length %f:" % value(self._model.obj)) + self.find_tour(ComponentMap([(self._model.x[e], self._model.x[e].value) for e in self._model.x])) + + self._model = None # cleanup + + class TestXpressPersistent(unittest.TestCase): @unittest.skipIf(not xpress_available, "xpress is not available") def test_basics(self): @@ -261,3 +464,175 @@ def test_add_column_exceptions(self): opt.add_var(m.y) # var already in solver model self.assertRaises(RuntimeError, opt.add_column, m, m.y, -2, [m.c], [1]) + + def _markshare(self): + """Create a model that is non-trivial to solve. + The returned model has two variables: `x` and `s`. It also has an + objective function that is stored in `obj`. + """ + model = pe.ConcreteModel() + model.X = pe.RangeSet(50) + model.S = pe.RangeSet(6) + model.x = pe.Var(model.X, within=pe.Binary) + x = model.x + model.s = pe.Var(model.S, bounds = (0, None)) + s = model.s + model.obj = pe.Objective(expr=s[1] + s[2] + s[3] + s[4] + s[5] + s[6]) + model.cons = pe.ConstraintList() + + model.cons.add(s[1] + 25*x[1] + 35*x[2] + 14*x[3] + 76*x[4] + 58*x[5] + 10*x[6] + 20*x[7] + + 51*x[8] + 58*x[9] + x[10] + 35*x[11] + 40*x[12] + 65*x[13] + 59*x[14] + 24*x[15] + + 44*x[16] + x[17] + 93*x[18] + 24*x[19] + 68*x[20] + 38*x[21] + 64*x[22] + 93*x[23] + + 14*x[24] + 83*x[25] + 6*x[26] + 58*x[27] + 14*x[28] + 71*x[29] + 17*x[30] + + 18*x[31] + 8*x[32] + 57*x[33] + 48*x[34] + 35*x[35] + 13*x[36] + 47*x[37] + + 46*x[38] + 8*x[39] + 82*x[40] + 51*x[41] + 49*x[42] + 85*x[43] + 66*x[44] + + 45*x[45] + 99*x[46] + 21*x[47] + 75*x[48] + 78*x[49] + 43*x[50] == 1116) + model.cons.add(s[2] + 97*x[1] + 64*x[2] + 24*x[3] + 63*x[4] + 58*x[5] + 45*x[6] + 20*x[7] + + 71*x[8] + 32*x[9] + 7*x[10] + 28*x[11] + 77*x[12] + 95*x[13] + 96*x[14] + + 70*x[15] + 22*x[16] + 93*x[17] + 32*x[18] + 17*x[19] + 56*x[20] + 74*x[21] + + 62*x[22] + 94*x[23] + 9*x[24] + 92*x[25] + 90*x[26] + 40*x[27] + 45*x[28] + + 84*x[29] + 62*x[30] + 62*x[31] + 34*x[32] + 21*x[33] + 2*x[34] + 75*x[35] + + 42*x[36] + 75*x[37] + 29*x[38] + 4*x[39] + 64*x[40] + 80*x[41] + 17*x[42] + + 55*x[43] + 73*x[44] + 23*x[45] + 13*x[46] + 91*x[47] + 70*x[48] + 73*x[49] + + 28*x[50] == 1325) + model.cons.add(s[3] + 95*x[1] + 71*x[2] + 19*x[3] + 15*x[4] + 66*x[5] + 76*x[6] + 4*x[7] + + 50*x[8] + 50*x[9] + 97*x[10] + 83*x[11] + 14*x[12] + 27*x[13] + 14*x[14] + + 34*x[15] + 9*x[16] + 99*x[17] + 62*x[18] + 92*x[19] + 39*x[20] + 56*x[21] + + 53*x[22] + 91*x[23] + 81*x[24] + 46*x[25] + 94*x[26] + 76*x[27] + 53*x[28] + + 58*x[29] + 23*x[30] + 15*x[31] + 63*x[32] + 2*x[33] + 31*x[34] + 55*x[35] + + 71*x[36] + 97*x[37] + 71*x[38] + 55*x[39] + 8*x[40] + 57*x[41] + 14*x[42] + + 76*x[43] + x[44] + 46*x[45] + 87*x[46] + 22*x[47] + 97*x[48] + 99*x[49] + 92*x[50] + == 1353) + model.cons.add(s[4] + x[1] + 27*x[2] + 46*x[3] + 48*x[4] + 66*x[5] + 58*x[6] + 52*x[7] + 6*x[8] + + 14*x[9] + 26*x[10] + 55*x[11] + 61*x[12] + 60*x[13] + 3*x[14] + 33*x[15] + + 99*x[16] + 36*x[17] + 55*x[18] + 70*x[19] + 73*x[20] + 70*x[21] + 38*x[22] + + 66*x[23] + 39*x[24] + 43*x[25] + 63*x[26] + 88*x[27] + 47*x[28] + 18*x[29] + + 73*x[30] + 40*x[31] + 91*x[32] + 96*x[33] + 49*x[34] + 13*x[35] + 27*x[36] + + 22*x[37] + 71*x[38] + 99*x[39] + 66*x[40] + 57*x[41] + x[42] + 54*x[43] + 35*x[44] + + 52*x[45] + 66*x[46] + 26*x[47] + x[48] + 26*x[49] + 12*x[50] == 1169) + model.cons.add(s[5] + 3*x[1] + 94*x[2] + 51*x[3] + 4*x[4] + 25*x[5] + 46*x[6] + 30*x[7] + + 2*x[8] + 89*x[9] + 65*x[10] + 28*x[11] + 46*x[12] + 36*x[13] + 53*x[14] + + 30*x[15] + 73*x[16] + 37*x[17] + 60*x[18] + 21*x[19] + 41*x[20] + 2*x[21] + + 21*x[22] + 93*x[23] + 82*x[24] + 16*x[25] + 97*x[26] + 75*x[27] + 50*x[28] + + 13*x[29] + 43*x[30] + 45*x[31] + 64*x[32] + 78*x[33] + 78*x[34] + 6*x[35] + + 35*x[36] + 72*x[37] + 31*x[38] + 28*x[39] + 56*x[40] + 60*x[41] + 23*x[42] + + 70*x[43] + 46*x[44] + 88*x[45] + 20*x[46] + 69*x[47] + 13*x[48] + 40*x[49] + + 73*x[50] == 1160) + model.cons.add(s[6] + 69*x[1] + 72*x[2] + 94*x[3] + 56*x[4] + 90*x[5] + 20*x[6] + 56*x[7] + + 50*x[8] + 79*x[9] + 59*x[10] + 36*x[11] + 24*x[12] + 42*x[13] + 9*x[14] + + 29*x[15] + 68*x[16] + 10*x[17] + x[18] + 44*x[19] + 74*x[20] + 61*x[21] + 37*x[22] + + 71*x[23] + 63*x[24] + 44*x[25] + 77*x[26] + 57*x[27] + 46*x[28] + 51*x[29] + + 43*x[30] + 4*x[31] + 85*x[32] + 59*x[33] + 7*x[34] + 25*x[35] + 46*x[36] + 25*x[37] + + 70*x[38] + 78*x[39] + 88*x[40] + 20*x[41] + 40*x[42] + 40*x[43] + 16*x[44] + + 3*x[45] + 3*x[46] + 5*x[47] + 77*x[48] + 88*x[49] + 16*x[50] == 1163) + + return model + + + @unittest.skipIf(not xpress_available, "xpress is not available") + def test_callbacks_01(self): + """Simple callback test. + + Tests that optnode, preintsol, intsol callbacks are invoked. + Also tests that information between preintsol an intsol callbacks + is consistent. + """ + model = self._markshare() + opt = pe.SolverFactory('xpress_direct') + opt.options['MAXNODE'] = 5 + opt.options['THREADS'] = 1 # for interaction between preintsol and intsol + test = self + + lastnode = [0] + noptnode = [0] + def optnode(data): + try: + noptnode[0] += 1 + node = data.attributes.nodes + test.assertGreaterEqual(node, lastnode[0]) + lastnode[0] = node + except Exception as ex: + print('optnode:', ex) + raise ex + opt.callbacks.add_optnode(optnode) + + announced = [None] + npreintsol = [0] + def preintsol(data): + try: + npreintsol[0] += 1 + test.assertIsNone(announced[0]) + test.assertGreater(data.attributes.mipobjval, + data.candidate_obj) + announced[0] = (data.candidate_obj, data.candidate_sol) + except Exception as ex: + print('preintsol:', ex) + raise ex + opt.callbacks.add_preintsol(preintsol) + + nintsol = [0] + def intsol(data): + try: + nintsol[0] += 1 + self.assertIsNotNone(announced[0]) + obj, x = announced[0] + announced[0] = None + self.assertEqual(data.objval, obj) + sol = data.solution + for p in sol: + self.assertEqual(sol[p], x[p]) + except Exception as ex: + print('intsol:', ex) + raise ex + opt.callbacks.add_intsol(intsol) + + opt.solve(model, tee=True) + + self.assertGreater(noptnode[0], 0) + self.assertGreater(npreintsol[0], 0) + self.assertGreater(nintsol[0], 0) + self.assertGreaterEqual(lastnode[0], opt.options['MAXNODE']) + + @unittest.skipIf(not xpress_available, "xpress is not available") + def test_callbacks_02(self): + """Test branching callback by doing most fractional branching on markshare.""" + + model = self._markshare() + opt = pe.SolverFactory('xpress_direct') + opt.options['MAXNODE'] = 5 + test = self + + called = [0] + def chgbranchobject(data): + try: + test.assertIsNotNone(data.branchobject) + test.assertEqual(data.branchobject, data.orig_branchobject) + called[0] += 1 + sol = data.getlpsol() + maxfrac = 0.0 + maxvar = None + for var in sol: + if var.domain == pe.Binary: + frac = abs(round(sol[var]) - sol.var) + if frac > maxfrac: + maxfrac = frac + maxvar = var + test.assertIsNotNone(maxvar) + if maxvar is not None: + b = data.new_object(2) + b.addbounds(0, ['U'], [data.map_pyomo_var(maxvar)], [0]) + b.addbounds(1, ['L'], [data.map_pyomo_var(maxvar)], [1]) + data.branchobject = b + except Exception as ex: + print('chgbranchobject:', ex) + raise ex + opt.callbacks.add_chgbranchobject(chgbranchobject) + + opt.solve(model, tee=True) + + self.assertGreater(called[0], 0) + + @unittest.skipIf(not xpress_available, "xpress is not available") + def test_callbacks_03(self): + """Test the TSP example.""" + TSP(10).solve() From ee6832e7e89eae3dbc883f4a39ef01c9b7ad5954 Mon Sep 17 00:00:00 2001 From: Daniel Junglas Date: Tue, 10 Jan 2023 09:51:48 +0100 Subject: [PATCH 02/10] Remove invalid assertion. Nodes may be cutoff and thus the last call to the `optnode` callback may not be for the last node processed. --- pyomo/solvers/tests/checks/test_xpress_persistent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyomo/solvers/tests/checks/test_xpress_persistent.py b/pyomo/solvers/tests/checks/test_xpress_persistent.py index f388d12c618..533668b1ba7 100644 --- a/pyomo/solvers/tests/checks/test_xpress_persistent.py +++ b/pyomo/solvers/tests/checks/test_xpress_persistent.py @@ -592,7 +592,6 @@ def intsol(data): self.assertGreater(noptnode[0], 0) self.assertGreater(npreintsol[0], 0) self.assertGreater(nintsol[0], 0) - self.assertGreaterEqual(lastnode[0], opt.options['MAXNODE']) @unittest.skipIf(not xpress_available, "xpress is not available") def test_callbacks_02(self): From e2cb7ca973c7f3a1019c45d0f920254c6a9292df Mon Sep 17 00:00:00 2001 From: Daniel Junglas Date: Tue, 17 Jan 2023 09:47:23 +0100 Subject: [PATCH 03/10] Name the public facing field `problem` rather than `prob`. --- .../solvers/plugins/solvers/xpress_direct.py | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/xpress_direct.py b/pyomo/solvers/plugins/solvers/xpress_direct.py index cb42a780f77..12760c91bbb 100644 --- a/pyomo/solvers/plugins/solvers/xpress_direct.py +++ b/pyomo/solvers/plugins/solvers/xpress_direct.py @@ -118,9 +118,9 @@ class CallbackContext(object): an instance of a subclass of `CallbackContext`. This instance carries callback-specific information and also allows callback-specific actions. """ - def __init__(self, prob, solver, var2idx): + def __init__(self, problem, solver, var2idx): super(CallbackContext, self).__init__() - self._prob = prob + self._problem = problem self._solver = solver self._lpsol = None self._mipsol = None @@ -130,17 +130,17 @@ def solver(self): """The Pyomo solver object.""" return self._solver @property - def prob(self): + def problem(self): """The callback local Xpress problem instance.""" - return self._prob + return self._problem @property def attributes(self): """The attributes of the callback local Xpress problem instance.""" - return self._prob.attributes + return self._problem.attributes @property def controls(self): """The controls of the callback local Xpress problem instance.""" - return self._prob.controls + return self._problem.controls def _make_value_map(self, values, comp2idx, component_map): """Create a `ComponentMap` that maps the keys in `component_map` to data from `values`. @@ -162,8 +162,8 @@ def getlpsol(self): Returns a `ComponentMap` that maps Pyomo variables to values. """ if self._lpsol is None: - x = [0.0] * self._prob.attributes.originalcols - self._prob.getlpsol(x) + x = [0.0] * self._problem.attributes.originalcols + self._problem.getlpsol(x) self._lpsol = self._make_value_map(x, self._var2idx, self._solver._pyomo_var_to_solver_var_map) return self._lpsol @@ -176,8 +176,8 @@ def getmipsol(self): Returns a `ComponentMap` that maps Pyomo variables to values. """ if self._mipsol is None: - x = [0.0] * self._prob.attributes.originalcols - self._prob.getmipsol(x) + x = [0.0] * self._problem.attributes.originalcols + self._problem.getmipsol(x) self._mipsol = self._make_value_map(x, self._var2idx, self._solver._pyomo_var_to_solver_var_map) return self._mipsol @@ -195,7 +195,7 @@ def addmipsol(self, sol): data = dict() for x in sol: data[var2idx[component_map[x]]] = sol[x] - self._prob.addmipsol([data[x] for x in sorted(data)], sorted(data)) + self._problem.addmipsol([data[x] for x in sorted(data)], sorted(data)) def addcut(self, cut, cuttype=0): """Add a (local) cut to the current node. @@ -236,16 +236,16 @@ def addcut(self, cut, cuttype=0): origrowcoef = lhs.linear_coefs origcolind = [self._var2idx[self._solver._pyomo_var_to_solver_var_map[x]] for x in lhs.linear_vars] - maxcoefs = self._prob.attributes.cols + maxcoefs = self._problem.attributes.cols colind = [] rowcoef = [] - redrhs, status = self._prob.presolverow(sense, origcolind, - origrowcoef, origrhs, - maxcoefs, colind, rowcoef) + redrhs, status = self._problem.presolverow(sense, origcolind, + origrowcoef, origrhs, + maxcoefs, colind, rowcoef) if status != 0: raise RuntimeError('Cut cannot be presolved: %d' % status) - self._prob.addcuts([cuttype], [sense], [redrhs], [0, len(colind)], - colind, rowcoef) + self._problem.addcuts([cuttype], [sense], [redrhs], [0, len(colind)], + colind, rowcoef) class MessageCallbackContext(CallbackContext): """Data passed to message callbacks. @@ -254,8 +254,8 @@ class MessageCallbackContext(CallbackContext): - `msg` [read]: The message that is sent (may be `None`). - `msgtype` [read]: The type of message sent (info, warning, error). """ - def __init__(self, prob, solver, var2idx, msg, msgtype): - super(MessageCallbackContext, self).__init__(prob, solver, var2idx) + def __init__(self, problem, solver, var2idx, msg, msgtype): + super(MessageCallbackContext, self).__init__(problem, solver, var2idx) self._msg = msg self._msgtype = msgtype @property @@ -289,8 +289,8 @@ class OptNodeCallbackContext(CallbackContext): In addition to the super class, this class has two properties: - `infeas` [write]: Wether the node is considered infeasible. """ - def __init__(self, prob, solver, var2idx): - super(OptNodeCallbackContext, self).__init__(prob, solver, var2idx) + def __init__(self, problem, solver, var2idx): + super(OptNodeCallbackContext, self).__init__(problem, solver, var2idx) self._infeas = False @property def infeas(self): @@ -310,8 +310,8 @@ class PreIntSolCallbackContext(CallbackContext): - `candidate_sol` [read]: The candidate solution. - `candidate_obj` [read]: The objective value for `candidate_sol` """ - def __init__(self, prob, solver, var2idx, soltype, cutoff): - super(PreIntSolCallbackContext, self).__init__(prob, solver, var2idx) + def __init__(self, problem, solver, var2idx, soltype, cutoff): + super(PreIntSolCallbackContext, self).__init__(problem, solver, var2idx) self._soltype = soltype self._cutoff = cutoff self._reject = False @@ -353,8 +353,8 @@ class IntSolCallbackContext(CallbackContext): - `solution` [read]: The solution being reported. - `objval` [read]: The objective value of `solution`. """ - def __init__(self, prob, solver, var2idx): - super(IntSolCallbackContext, self).__init__(prob, solver, var2idx) + def __init__(self, problem, solver, var2idx): + super(IntSolCallbackContext, self).__init__(problem, solver, var2idx) @property def solution(self): """Returns the solution as a map from Pyomo variables to values.""" @@ -372,8 +372,8 @@ class ChgBranchObjectCallbackContext(CallbackContext): - `branchobject' [read/write]: The branching that will be executed. - `new_object()`: Create a new branching object. """ - def __init__(self, prob, solver, var2idx, obranch): - super(ChgBranchObjectCallbackContext, self).__init__(prob, solver, var2idx) + def __init__(self, problem, solver, var2idx, obranch): + super(ChgBranchObjectCallbackContext, self).__init__(problem, solver, var2idx) self._obranch = obranch self._branch = obranch @property @@ -393,7 +393,7 @@ def branchobject(self, value): self._branch = value def new_object(self, nbranch): """Create a new branch object that branches in the original space.""" - return xpress.branchobj(prob, nbranch, isoriginal=True) + return xpress.branchobj(self.problem, nbranch, isoriginal=True) def map_pyomo_var(self, var): """Map a Pyomo variable to an Xpress variable.""" return self._solver._pyomo_var_to_solver_var_map[var] From 6f3b049a6ec56290e93c1beda21782842c0bd8ab Mon Sep 17 00:00:00 2001 From: Daniel Junglas Date: Tue, 17 Jan 2023 09:51:19 +0100 Subject: [PATCH 04/10] Use snake case instead of long variable names. --- .../solvers/plugins/solvers/xpress_direct.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/xpress_direct.py b/pyomo/solvers/plugins/solvers/xpress_direct.py index 12760c91bbb..8bee46e9c22 100644 --- a/pyomo/solvers/plugins/solvers/xpress_direct.py +++ b/pyomo/solvers/plugins/solvers/xpress_direct.py @@ -214,13 +214,13 @@ def addcut(self, cut, cuttype=0): if cut.lower != cut.upper: raise ValueError("Cannot add ranged cut {0} ".format(cut)) sense = 'E' - origrhs = cut.lower + orig_rhs = cut.lower else: sense = 'G' - origrhs = cut.lower + orig_rhs = cut.lower elif cut.has_ub(): sense = 'L' - origrhs = cut.upper + orig_rhs = cut.upper else: raise ValueError("Cannot add free cut {0} ".format(cut)) @@ -234,18 +234,18 @@ def addcut(self, cut, cuttype=0): raise ValueError("Cannot add non-linear cut {0} ".format(cut)) - origrowcoef = lhs.linear_coefs - origcolind = [self._var2idx[self._solver._pyomo_var_to_solver_var_map[x]] for x in lhs.linear_vars] - maxcoefs = self._problem.attributes.cols - colind = [] - rowcoef = [] - redrhs, status = self._problem.presolverow(sense, origcolind, - origrowcoef, origrhs, - maxcoefs, colind, rowcoef) + orig_row_coeff = lhs.linear_coefs + orig_col_ind = [self._var2idx[self._solver._pyomo_var_to_solver_var_map[x]] for x in lhs.linear_vars] + max_coefs = self._problem.attributes.cols + col_ind = [] + row_coef = [] + red_rhs, status = self._problem.presolverow(sense, orig_col_ind, + orig_row_coeff, orig_rhs, + max_coefs, col_ind, row_coef) if status != 0: raise RuntimeError('Cut cannot be presolved: %d' % status) - self._problem.addcuts([cuttype], [sense], [redrhs], [0, len(colind)], - colind, rowcoef) + self._problem.addcuts([cuttype], [sense], [red_rhs], [0, len(col_ind)], + col_ind, row_coef) class MessageCallbackContext(CallbackContext): """Data passed to message callbacks. From 917ccb09a6ef9e440417362c47c34867369418a2 Mon Sep 17 00:00:00 2001 From: Daniel Junglas Date: Tue, 17 Jan 2023 09:51:46 +0100 Subject: [PATCH 05/10] Fix typo. --- pyomo/solvers/plugins/solvers/xpress_direct.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/solvers/plugins/solvers/xpress_direct.py b/pyomo/solvers/plugins/solvers/xpress_direct.py index 8bee46e9c22..5fec2c5a269 100644 --- a/pyomo/solvers/plugins/solvers/xpress_direct.py +++ b/pyomo/solvers/plugins/solvers/xpress_direct.py @@ -287,7 +287,7 @@ class OptNodeCallbackContext(CallbackContext): """Data passed to optnode callbacks. In addition to the super class, this class has two properties: - - `infeas` [write]: Wether the node is considered infeasible. + - `infeas` [write]: Whether the node is considered infeasible. """ def __init__(self, problem, solver, var2idx): super(OptNodeCallbackContext, self).__init__(problem, solver, var2idx) From bc623a281e25291133523a082542536de375b8f0 Mon Sep 17 00:00:00 2001 From: Daniel Junglas Date: Tue, 17 Jan 2023 09:52:51 +0100 Subject: [PATCH 06/10] Fix comment. --- pyomo/solvers/plugins/solvers/xpress_direct.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/xpress_direct.py b/pyomo/solvers/plugins/solvers/xpress_direct.py index 5fec2c5a269..41b9acd6572 100644 --- a/pyomo/solvers/plugins/solvers/xpress_direct.py +++ b/pyomo/solvers/plugins/solvers/xpress_direct.py @@ -286,7 +286,7 @@ def is_flush(self): class OptNodeCallbackContext(CallbackContext): """Data passed to optnode callbacks. - In addition to the super class, this class has two properties: + In addition to the super class, this class has one property: - `infeas` [write]: Whether the node is considered infeasible. """ def __init__(self, problem, solver, var2idx): @@ -294,7 +294,7 @@ def __init__(self, problem, solver, var2idx): self._infeas = False @property def infeas(self): - """Message sent by this callback.""" + """Whether the node is considered infeasible.""" return self._infeas @infeas.setter def infeas(self, value): From 206f323f016c78a1002d79a6ae84c38236f78a96 Mon Sep 17 00:00:00 2001 From: Daniel Junglas Date: Tue, 17 Jan 2023 09:54:52 +0100 Subject: [PATCH 07/10] Spell out the name of the callback context. Previous name was the Xpress C style name, now it looks more like Pyomo. --- pyomo/solvers/plugins/solvers/xpress_direct.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/xpress_direct.py b/pyomo/solvers/plugins/solvers/xpress_direct.py index 41b9acd6572..54b608defc3 100644 --- a/pyomo/solvers/plugins/solvers/xpress_direct.py +++ b/pyomo/solvers/plugins/solvers/xpress_direct.py @@ -364,7 +364,7 @@ def objval(self): """Get the objective value for `solution`.""" return self.attributes.mipobjval -class ChgBranchObjectCallbackContext(CallbackContext): +class ChangeBranchObjectCallbackContext(CallbackContext): """Data passed to chgbranchobject callbacks. In addition to the super class, this class has the following properties: @@ -373,7 +373,7 @@ class ChgBranchObjectCallbackContext(CallbackContext): - `new_object()`: Create a new branching object. """ def __init__(self, problem, solver, var2idx, obranch): - super(ChgBranchObjectCallbackContext, self).__init__(problem, solver, var2idx) + super(ChangeBranchObjectCallbackContext, self).__init__(problem, solver, var2idx) self._obranch = obranch self._branch = obranch @property @@ -508,7 +508,7 @@ def del_chgbranchobject(self, chgbranchobjectcb = None): self._del_callback('chgbranchobject', prechgbranchobjectcb) def _chgbranchobject_wrapper(self, solver, var2idx): def wrapper(prob, cb, obranch): - data = ChgBranchObjectCallbackContext(prob, solver, var2idx, obranch) + data = ChangeBranchObjectCallbackContext(prob, solver, var2idx, obranch) cb(data) return data.branchobject return wrapper From 9e4fc239b44ded7a22ac5d46ef3c925a35f79f2b Mon Sep 17 00:00:00 2001 From: Daniel Junglas Date: Tue, 17 Jan 2023 09:56:21 +0100 Subject: [PATCH 08/10] Use more verbose variable name. --- pyomo/solvers/plugins/solvers/xpress_direct.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/xpress_direct.py b/pyomo/solvers/plugins/solvers/xpress_direct.py index 54b608defc3..1af1084ce51 100644 --- a/pyomo/solvers/plugins/solvers/xpress_direct.py +++ b/pyomo/solvers/plugins/solvers/xpress_direct.py @@ -372,14 +372,14 @@ class ChangeBranchObjectCallbackContext(CallbackContext): - `branchobject' [read/write]: The branching that will be executed. - `new_object()`: Create a new branching object. """ - def __init__(self, problem, solver, var2idx, obranch): + def __init__(self, problem, solver, var2idx, orig_branch): super(ChangeBranchObjectCallbackContext, self).__init__(problem, solver, var2idx) - self._obranch = obranch - self._branch = obranch + self._orig_branch = orig_branch + self._branch = orig_branch @property def orig_branchobject(self): """The branching suggested by Xpress.""" - return self._obranch + return self._orig_branch @property def branchobject(self): """The branching that will be carried out. @@ -507,8 +507,8 @@ def del_chgbranchobject(self, chgbranchobjectcb = None): """Remove a specific or all (`chgbranchobjectcb` is `None`) chgbranchobject callbacks.""" self._del_callback('chgbranchobject', prechgbranchobjectcb) def _chgbranchobject_wrapper(self, solver, var2idx): - def wrapper(prob, cb, obranch): - data = ChangeBranchObjectCallbackContext(prob, solver, var2idx, obranch) + def wrapper(prob, cb, orig_branch): + data = ChangeBranchObjectCallbackContext(prob, solver, var2idx, orig_branch) cb(data) return data.branchobject return wrapper From ebde2605c46ade3c94f7bd0d7047750ed9b72242 Mon Sep 17 00:00:00 2001 From: Daniel Junglas Date: Tue, 17 Jan 2023 09:56:42 +0100 Subject: [PATCH 09/10] Fix typo. --- pyomo/solvers/tests/checks/test_xpress_persistent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/solvers/tests/checks/test_xpress_persistent.py b/pyomo/solvers/tests/checks/test_xpress_persistent.py index 533668b1ba7..91372bda380 100644 --- a/pyomo/solvers/tests/checks/test_xpress_persistent.py +++ b/pyomo/solvers/tests/checks/test_xpress_persistent.py @@ -42,7 +42,7 @@ class TSP: is not stated explicitly. Instead we check for any solution that the optimizer finds, whether it satisfies the subtour elimination constraint. If it does then we accept the solution. Otherwise we reject the solution - and augment the model by the violated subtour eliminiation constraint. + and augment the model by the violated subtour elimination constraint. This lazy addition of constraints is implemented using two callbacks: - a preintsol callback that rejects any solution that violates a From 863ff863be8e425deba48484f3bd0eac1085edf2 Mon Sep 17 00:00:00 2001 From: Daniel Junglas Date: Tue, 17 Jan 2023 09:57:05 +0100 Subject: [PATCH 10/10] Fix typo. --- pyomo/solvers/tests/checks/test_xpress_persistent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/solvers/tests/checks/test_xpress_persistent.py b/pyomo/solvers/tests/checks/test_xpress_persistent.py index 91372bda380..611d06c9362 100644 --- a/pyomo/solvers/tests/checks/test_xpress_persistent.py +++ b/pyomo/solvers/tests/checks/test_xpress_persistent.py @@ -535,7 +535,7 @@ def test_callbacks_01(self): """Simple callback test. Tests that optnode, preintsol, intsol callbacks are invoked. - Also tests that information between preintsol an intsol callbacks + Also tests that information between preintsol and intsol callbacks is consistent. """ model = self._markshare()