From 3b5c3da9af4d2bd6c848f8001a909221f6295fb4 Mon Sep 17 00:00:00 2001 From: Luke Marshall <52978038+mathgeekcoder@users.noreply.github.com> Date: Tue, 23 Jul 2024 12:07:28 -0700 Subject: [PATCH] Refactor highspy for enhanced usability Refactor highspy for enhanced usability This commit significantly improves the `Highs` class within `highs.py`, focusing on enhancing usability, efficiency, and robustness. Key changes include: - Added comprehensive docstrings. - Improved methods for adding, deleting, and retrieving multiple variables and constraints, for a more flexible and efficient API. - Standardized some API conventions. Note, this is a breaking change for the constraint value/dual methods. - Updated tests and examples. --- examples/chip.py | 36 +-- examples/distillation.py | 48 ++- examples/minimal.py | 37 ++- examples/network_flow.py | 37 +++ examples/nqueens.py | 29 ++ src/highspy/highs.py | 631 +++++++++++++++++++++++++++++---------- tests/test_highspy.py | 324 +++++++++++++------- 7 files changed, 824 insertions(+), 318 deletions(-) create mode 100644 examples/network_flow.py create mode 100644 examples/nqueens.py diff --git a/examples/chip.py b/examples/chip.py index dae469cd7d..3391b78954 100644 --- a/examples/chip.py +++ b/examples/chip.py @@ -9,23 +9,12 @@ h = highspy.Highs() h.silent() -varNames = list() -varNames.append('Tables') -varNames.append('Sets of chairs') +items = ['Tables', 'Sets of chairs'] +x = h.addVariables(items, obj = [10, 20], name = items) -x1 = h.addVariable(obj = 10, name = varNames[0]) -x2 = h.addVariable(obj = 25, name = varNames[1]) - -vars = list() -vars.append(x1) -vars.append(x2) - -constrNames = list() -constrNames.append('Assembly') -constrNames.append('Finishing') - -h.addConstr(x1 + 2*x2 <= 80, name = constrNames[0]) -h.addConstr(x1 + 4*x2 <= 120, name = constrNames[1]) +constrNames = ['Assembly', 'Finishing'] +cons = h.addConstrs(x['Tables'] + 2*x['Sets of chairs'] <= 80, + x['Tables'] + 4*x['Sets of chairs'] <= 120, name = constrNames) h.setMaximize() @@ -36,16 +25,17 @@ h.solve() -for var in vars: + +for n, var in x.items(): print('Make', h.variableValue(var), h.variableName(var), ': Reduced cost', h.variableDual(var)) -print('Make', h.variableValues(vars), 'of', h.variableNames(vars)) + +print('Make', h.variableValues(x.values()), 'of', h.variableNames(x.values())) print('Make', h.allVariableValues(), 'of', h.allVariableNames()) -for name in constrNames: - print('Constraint', name, 'has value', h.constrValue(name), 'and dual', h.constrDual(name)) - -print('Constraints have values', h.constrValues(constrNames), 'and duals', h.constrDuals(constrNames)) - +for c in cons: + print('Constraint', c.name, 'has value', h.constrValue(c), 'and dual', h.constrDual(c)) + +print('Constraints have values', h.constrValues(cons), 'and duals', h.constrDuals(cons)) print('Constraints have values', h.allConstrValues(), 'and duals', h.allConstrDuals()) print('Optimal objective value is', h.getObjectiveValue()) diff --git a/examples/distillation.py b/examples/distillation.py index 5e375ab496..ab85c7b08b 100644 --- a/examples/distillation.py +++ b/examples/distillation.py @@ -15,25 +15,13 @@ h = highspy.Highs() h.silent() -variableNames = list() -variableNames.append('TypeA') -variableNames.append('TypeB') +variableNames = ['TypeA', 'TypeB'] +x = h.addVariables(variableNames, obj = [8, 10], name = variableNames[0]) -useTypeA = h.addVariable(obj = 8, name = variableNames[0]) -useTypeB = h.addVariable(obj = 10, name = variableNames[1]) - -vars = list() -vars.append(useTypeA) -vars.append(useTypeB) - -constrNames = list() -constrNames.append('Product1') -constrNames.append('Product2') -constrNames.append('Product3') - -h.addConstr(2*useTypeA + 2*useTypeB >= 7, name = constrNames[0]) -h.addConstr(3*useTypeA + 4*useTypeB >= 12, name = constrNames[1]) -h.addConstr(2*useTypeA + useTypeB >= 6, name = constrNames[2]) +constrNames = ['Product1', 'Product2', 'Product3'] +cons = h.addConstrs(2*x['TypeA'] + 2*x['TypeB'] >= 7, + 3*x['TypeA'] + 4*x['TypeB'] >= 12, + 2*x['TypeA'] + x['TypeB'] >= 6, name = constrNames) h.setMinimize() @@ -47,31 +35,31 @@ h.solve() -for var in vars: +for name, var in x.items(): print('Use {0:.1f} of {1:s}: reduced cost {2:.6f}'.format(h.variableValue(var), h.variableName(var), h.variableDual(var))) -print('Use', h.variableValues(vars), 'of', h.variableNames(vars)) + +print('Use', h.variableValues(x.values()), 'of', h.variableNames(x.values())) print('Use', h.allVariableValues(), 'of', h.allVariableNames()) -for name in constrNames: - print(f"Constraint {name} has value {h.constrValue(name):{width}.{precision}} and dual {h.constrDual(name):{width}.{precision}}") - -print('Constraints have values', h.constrValues(constrNames), 'and duals', h.constrDuals(constrNames)) +for c in cons: + print(f"Constraint {c.name} has value {h.constrValue(c):{width}.{precision}} and dual {h.constrDual(c):{width}.{precision}}") +print('Constraints have values', h.constrValues(cons), 'and duals', h.constrDuals(cons)) print('Constraints have values', h.allConstrValues(), 'and duals', h.allConstrDuals()) -for var in vars: +for var in x.values(): print(f"Use {h.variableValue(var):{width}.{precision}} of {h.variableName(var)}") print(f"Optimal objective value is {h.getObjectiveValue():{width}.{precision}}") print() print('Solve as MIP') -for var in vars: +for var in x.values(): h.setInteger(var) h.solve() -for var in vars: +for var in x.values(): print(f"Use {h.variableValue(var):{width}.{precision}} of {h.variableName(var)}") print(f"Optimal objective value is {h.getObjectiveValue():{width}.{precision}}") @@ -79,14 +67,14 @@ print('Solve as LP with Gomory cut') # Make the variables continuous -for var in vars: +for var in x.values(): h.setContinuous(var) # Add Gomory cut -h.addConstr(useTypeA + useTypeB >= 4, name = "Gomory") +h.addConstr(x['TypeA'] + x['TypeB'] >= 4, name = "Gomory") h.solve() -for var in vars: +for var in x.values(): print(f"Use {h.variableValue(var):{width}.{precision}} of {h.variableName(var)}") print(f"Optimal objective value is {h.getObjectiveValue():{width}.{precision}}") diff --git a/examples/minimal.py b/examples/minimal.py index 943e231105..48cf840107 100644 --- a/examples/minimal.py +++ b/examples/minimal.py @@ -1,11 +1,40 @@ import highspy +import time +import networkx as nx +from random import randint h = highspy.Highs() -x1 = h.addVariable(lb = -h.inf) -x2 = h.addVariable(lb = -h.inf) +(x1, x2) = h.addVariables(2, lb = -h.inf) -h.addConstr(x2 - x1 >= 2) -h.addConstr(x1 + x2 >= 0) +h.addConstrs(x2 - x1 >= 2, + x1 + x2 >= 0) h.minimize(x2) + + + +h = highspy.Highs() + + +G = nx.circular_ladder_graph(5).to_directed() +nx.set_edge_attributes(G, {e: {'weight': randint(1, 9)} for e in G.edges}) + +d = h.addBinaries(G.edges, obj=nx.get_edge_attributes(G, 'weight')) + +h.addConstrs(sum(d[e] for e in G.in_edges(i)) - sum(d[e] for e in G.out_edges(i)) == 0 for i in G.nodes) + +h = highspy.Highs() + +ts = time.time() +perf1 = [h.addBinary() for _ in range(1000000)] +t1 = time.time() - ts +print(t1) + +h = highspy.Highs() + +ts = time.time() +perf2 = h.addVariables(1000000) +t2 = time.time() - ts +print(t2) + diff --git a/examples/network_flow.py b/examples/network_flow.py new file mode 100644 index 0000000000..2d01827423 --- /dev/null +++ b/examples/network_flow.py @@ -0,0 +1,37 @@ +# Example of a shortest path network flow in a graph +# Shows integration of highspy with networkx + +import highspy +import networkx as nx + +orig, dest = ('A', 'D') + +# create directed graph with edge weights (distances) +G = nx.DiGraph() +G.add_weighted_edges_from([('A', 'B', 2.0), ('B', 'C', 3.0), ('A', 'C', 1.5), ('B', 'D', 2.5), ('C', 'D', 1.0)]) + +h = highspy.Highs() +h.silent() + +x = h.addBinaries(G.edges, obj=nx.get_edge_attributes(G, 'weight')) + +# add flow conservation constraints +# { 1 if n = orig +# sum(out) - sum(in) = { -1 if n = dest +# { 0 otherwise +rhs = lambda n: 1 if n == orig else -1 if n == dest else 0 +flow = lambda E: sum((x[e] for e in E)) + +h.addConstrs(flow(G.out_edges(n)) - flow(G.in_edges(n)) == rhs(n) for n in G.nodes) +h.minimize() + +# Print the solution +print('Shortest path from', orig, 'to', dest, 'is: ', end = '') +sol = h.vals(x) + +n = orig +while n != dest: + print(n, end=' ') + n = next(e[1] for e in G.out_edges(n) if sol[e] > 0.5) + +print(dest) diff --git a/examples/nqueens.py b/examples/nqueens.py new file mode 100644 index 0000000000..84e07bb626 --- /dev/null +++ b/examples/nqueens.py @@ -0,0 +1,29 @@ +# This is an example of the N-Queens problem, which is a classic combinatorial problem. +# The problem is to place N queens on an N x N chessboard so that no two queens attack each other. +# +# We show how to model the problem as a MIP and solve it using highspy. +# Using numpy can simplify the construction of the constraints (i.e., diagonal). + +import highspy +import numpy as np + +N = 8 +h = highspy.Highs() +h.silent() + +x = np.reshape(h.addBinaries(N*N), (N, N)) + +h.addConstrs(sum(x[i,:]) == 1 for i in range(N)) # each row has exactly one queen +h.addConstrs(sum(x[:,j]) == 1 for j in range(N)) # each col has exactly one queen + +y = np.fliplr(x) +h.addConstrs(x.diagonal(k).sum() <= 1 for k in range(-N + 1, N)) # each diagonal has at most one queen +h.addConstrs(y.diagonal(k).sum() <= 1 for k in range(-N + 1, N)) # each 'reverse' diagonal has at most one queen + +h.solve() +sol = np.array(h.vals(x)) + +print('Queens:') + +for i in range(N): + print(''.join('Q' if sol[i, j] > 0.5 else '*' for j in range(N))) \ No newline at end of file diff --git a/src/highspy/highs.py b/src/highspy/highs.py index 9abf20ea26..4d4f4e05c2 100644 --- a/src/highspy/highs.py +++ b/src/highspy/highs.py @@ -32,32 +32,48 @@ kHighsIInf, ) - -from itertools import groupby +from collections.abc import Mapping +from itertools import groupby, product from operator import itemgetter from decimal import Decimal class Highs(_Highs): """HiGHS solver interface""" - __slots__ = ['_batch', '_vars', '_cons'] def __init__(self): super().__init__() - - self._batch = highs_batch(self) - self._vars = [] - self._cons = [] # Silence logging def silent(self): + """Disables solver output to the console.""" super().setOptionValue("output_flag", False) # solve def solve(self): + """Runs the solver on the current problem. + + Returns: + A HighsStatus object containing the solve status. + """ + return super().run() + + def optimize(self): + """Alias for the solve method.""" return super().run() # reset the objective and sense, then solve def minimize(self, obj=None): + """Solves a minimization of the objective and optionally updates the costs. + + Args: + obj: An optional highs_linear_expression representing the new objective function. + + Raises: + Exception: If obj is an inequality or not a highs_linear_expression. + + Returns: + A HighsStatus object containing the solve status after minimization. + """ if obj != None: # if we have a single variable, wrap it in a linear expression if isinstance(obj, highs_var) == True: @@ -67,7 +83,6 @@ def minimize(self, obj=None): raise Exception('Objective cannot be an inequality') # reset objective - self.update() super().changeColsCost(self.numVariables, range(self.numVariables), [0]*self.numVariables) # if we have duplicate variables, add the vals @@ -80,6 +95,17 @@ def minimize(self, obj=None): # reset the objective and sense, then solve def maximize(self, obj=None): + """Solves a maximization of the objective and optionally updates the costs. + + Args: + obj: An optional highs_linear_expression representing the new objective function. + + Raises: + Exception: If obj is an inequality or not a highs_linear_expression. + + Returns: + A HighsStatus object containing the solve status after maximization. + """ if obj != None: # if we have a single variable, wrap it in a linear expression if isinstance(obj, highs_var) == True: @@ -89,7 +115,6 @@ def maximize(self, obj=None): raise Exception('Objective cannot be an inequality') # reset objective - self.update() super().changeColsCost(self.numVariables, range(self.numVariables), [0]*self.numVariables) # if we have duplicate variables, add the vals @@ -100,31 +125,52 @@ def maximize(self, obj=None): super().changeObjectiveSense(ObjSense.kMaximize) return super().run() - - # update variables - def update(self): - current_batch_size = len(self._batch.obj) - if current_batch_size > 0: - names = [self._batch.name[i] for i in range(current_batch_size)] - super().addVars(int(current_batch_size), self._batch.lb, self._batch.ub) - super().changeColsCost(current_batch_size, self._batch.idx, self._batch.obj) - - # only set integrality if we have non-continuous variables - if any([t != HighsVarType.kContinuous for t in self._batch.type]): - super().changeColsIntegrality(current_batch_size, self._batch.idx, self._batch.type) - - for i in range(current_batch_size): - super().passColName(int(self._batch.idx[i]), str(names[i])) - self._batch = highs_batch(self) + def internal_get_value(self, var_index_collection, col_value): + """Internal method to get the value of a variable in the solution. Could be value or dual.""" + if isinstance(var_index_collection, int): + return col_value[var_index_collection] + elif isinstance(var_index_collection, highs_var): + return col_value[var_index_collection.index] + elif isinstance(var_index_collection, Mapping): + return {k: col_value[v.index] for k,v in var_index_collection.items()} + else: + return [col_value[v.index] for v in var_index_collection] def val(self, var): + """Gets the value of a variable in the solution. + + Args: + var: A highs_var object representing the variable. + + Returns: + The value of the variable in the solution. + """ return super().getSolution().col_value[var.index] def vals(self, vars): - sol = super().getSolution() - return [sol.col_value[v.index] for v in vars] + """Gets the values of multiple variables in the solution. + + Args: + vars: A collection of highs_var objects representing the variables. Can be a Mapping (e.g., dict) where keys are variable names and values are highs_var objects, or an iterable of highs_var objects. + + Returns: + If vars is a Mapping, returns a dict where keys are the same keys from the input vars and values are the solution values of the corresponding variables. If vars is an iterable, returns a list of solution values for the variables. + """ + col_value = super().getSolution().col_value + return {k: self.internal_get_value(v, col_value) for k,v in vars.items()} if isinstance(vars, Mapping) else [self.internal_get_value(v, col_value) for v in vars] def variableName(self, var): + """Retrieves the name of a specific variable. + + Args: + var: A highs_var object representing the variable. + + Raises: + Exception: If the variable name cannot be found. + + Returns: + The name of the specified variable. + """ [status, name] = super().getColName(var.index) failed = status != HighsStatus.kOk if failed: @@ -132,168 +178,474 @@ def variableName(self, var): return name def variableNames(self, vars): - names = list() - for v in vars: - [status, name] = super().getColName(v.index) - failed = status != HighsStatus.kOk - if failed: - raise Exception('Variable name not found') - names.append(name) - return names + """Retrieves the names of multiple variables. + + Args: + vars: An iterable of highs_var objects or a mapping where keys are identifiers and values are highs_var objects. + + Raises: + Exception: If any variable name cannot be found. + + Returns: + If vars is a mapping, returns a dict where keys are the same keys from the input vars and values are the names of the corresponding variables. + If vars is an iterable, returns a list of names for the specified variables. + """ + if isinstance(vars, Mapping): + return { key: v.name for key, v in vars.items() } + else: + return [v.name for v in vars] def allVariableNames(self): + """Retrieves the names of all variables in the model. + + Returns: + A list of strings representing the names of all variables. + """ return super().getLp().col_names_ def variableValue(self, var): + """Retrieves the value of a specific variable in the solution. + + Args: + var: A highs_var object representing the variable. + + Returns: + The value of the specified variable in the solution. + """ return super().getSolution().col_value[var.index] def variableValues(self, vars): - col_value = super().getSolution().col_value - return [col_value[v.index] for v in vars] + """Retrieves the values of multiple variables in the solution. + + Args: + vars: A collection of highs_var objects representing the variables. Can be a Mapping (e.g., dict) where keys are variable names and values are highs_var objects, or an iterable of highs_var objects. + + Returns: + If vars is a Mapping, returns a dict where keys are the same keys from the input vars and values are the solution values of the corresponding variables. If vars is an iterable, returns a list of solution values for the variables. + """ + return self.vals(vars) + def allVariableValues(self): + """Retrieves the values of all variables in the solution. + + Returns: + A list of values for all variables in the solution. + """ return super().getSolution().col_value def variableDual(self, var): + """Retrieves the dual value of a specific variable in the solution. + + Args: + var: A highs_var object representing the variable. + + Returns: + The dual value of the specified variable in the solution. + """ return super().getSolution().col_dual[var.index] def variableDuals(self, vars): + """Retrieves the dual values of multiple variables in the solution. + + Args: + vars: A collection of highs_var objects representing the variables. Can be a Mapping (e.g., dict) where keys are variable names and values are highs_var objects, or an iterable of highs_var objects. + + Returns: + If vars is a Mapping, returns a dict where keys are the same keys from the input vars and values are the dual values of the corresponding variables. If vars is an iterable, returns a list of dual values for the variables. + """ col_dual = super().getSolution() - return [col_dual[v.index] for v in vars] + return {k: self.internal_get_value(v, col_dual) for k,v in vars.items()} if isinstance(vars, Mapping) else [self.internal_get_value(v, col_dual) for v in vars] + def allVariableDuals(self): + """Retrieves the dual values of all variables in the solution. + + Returns: + A list of dual values for all variables in the solution. + """ return super().getSolution().col_dual - def constrValue(self, constr_name): - status_index = super().getRowByName(constr_name) - failed = status_index[0] != HighsStatus.kOk - if failed: - raise Exception('Constraint name not found') - return super().getSolution().row_value[status_index[1]] + def constrValue(self, con): + """Retrieves the value of a specific constraint in the solution. + + Args: + con: A highs_con object representing the constraint. + + Returns: + The value of the specified constraint in the solution. + """ + return super().getSolution().row_value[con.index] - def constrValues(self, constr_names): + def constrValues(self, cons): + """Retrieves the values of multiple constraints in the solution. + + Args: + cons: A collection of highs_con objects representing the constraints. Can be a Mapping (e.g., dict) where keys are constraint names and values are highs_con objects, or an iterable of highs_con objects. + + Returns: + If cons is a Mapping, returns a dict where keys are the same keys from the input cons and values are the solution values of the corresponding constraints. If cons is an iterable, returns a list of solution values for the constraints. + """ row_value = super().getSolution().row_value - index = list() - for name in constr_names: - status_index = super().getRowByName(name) - failed = status_index[0] != HighsStatus.kOk - if failed: - raise Exception('Constraint name not found') - index.append(status_index[1]) - return [row_value[index[v]] for v in range(len(index))] + return {k: row_value[c.index] for k,c in cons.items()} if isinstance(cons, Mapping) else [row_value[c.index] for c in cons] + def allConstrValues(self): + """Retrieves the values of all constraints in the solution. + + Returns: + A list of values for all constraints in the solution. + """ return super().getSolution().row_value + + def constrDual(self, con): + """Retrieves the dual value of a specific constraint in the solution. - def constrDual(self, constr_name): - status_index = super().getRowByName(constr_name) - failed = status_index[0] != HighsStatus.kOk - if failed: - raise Exception('Constraint name not found') - return super().getSolution().row_dual[status_index[1]] + Args: + con: A highs_con object representing the constraint. + + Returns: + The dual value of the specified constraint in the solution. + """ + return super().getSolution().row_dual[con.index] + + def constrDuals(self, cons): + """Retrieves the dual values of multiple constraints in the solution. - def constrDuals(self, constr_names): + Args: + cons: A collection of highs_con objects representing the constraints. Can be a Mapping (e.g., dict) where keys are constraint names and values are highs_con objects, or an iterable of highs_con objects. + + Returns: + If cons is a Mapping, returns a dict where keys are the same keys from the input cons and values are the dual values of the corresponding constraints. If cons is an iterable, returns a list of dual values for the constraints. + """ row_dual = super().getSolution().row_dual - index = list() - for name in constr_names: - status_index = super().getRowByName(name) - failed = status_index[0] != HighsStatus.kOk - if failed: - raise Exception('Constraint name not found') - index.append(status_index[1]) - return [row_dual[index[v]] for v in range(len(index))] + return {k: row_dual[c.index] for k,c in cons.items()} if isinstance(cons, Mapping) else [row_dual[c.index] for c in cons] def allConstrDuals(self): + """Retrieves the dual values of all constraints in the solution. + + Returns: + A list of dual values for all constraints in the solution. + """ return super().getSolution().row_dual - # - # add variable & useful constants - # - # Change the name of addVar to addVariable to prevent shadowing of - # highspy binding to Highs::addVar def addVariable(self, lb = 0, ub = kHighsInf, obj = 0, type=HighsVarType.kContinuous, name = None): - var = self._batch.add(obj, lb, ub, type, name, self) - self._vars.append(var) - # No longer acumulate a batch of variables so that addVariable - # behaves like Highs::addVar and highspy bindings modifying - # column data and adding rows can be used - self.update() + """Adds a variable to the model. + + Args: + lb: Lower bound of the variable (default is 0). + ub: Upper bound of the variable (default is infinity). + obj: Objective coefficient of the variable (default is 0). + type: Type of the variable (continuous, integer; default is continuous). + name: Optional name for the variable. + + Returns: + A highs_var object representing the added variable. + """ + status = super().addCol(obj, lb, ub, 0, [], []) + + if status != HighsStatus.kOk: + raise Exception("Failed to add variable to the model.") + + var = highs_var(self.numVariables - 1, self) + + if type != HighsVarType.kContinuous: + super().changeColIntegrality(var.index, type) + + if name != None: + super().passColName(var.index, name) + return var + def addVariables(self, *nvars, **kwargs): + """Adds multiple variables to the model. + + Args: + *args: A sequence of variables to be added. Can be a collection of scalars or indices (or mix). + + **kwargs: Optional keyword arguments. Can be scalars, arrays, or mappings. + lb: Lower bound of the variables (default is 0). + ub: Upper bound of the variables (default is infinity). + obj: Objective coefficient of the variables (default is 0). + type: Type of the variables (continuous, integer; default is continuous). + name: A collection of names for the variables (list or mapping). + name_prefix: Prefix for the variable names. Constructed name will be name_prefix + index. + out_array: Return an array of highs_var objects instead of a dictionary. + + Returns: + A highs_var collection (array or dictionary) representing the added variables. + """ + if len(nvars) == 0: + return None + + lb = kwargs.get('lb', 0) + ub = kwargs.get('ub', kHighsInf) + obj = kwargs.get('obj', 0) + vartype = kwargs.get('type', HighsVarType.kContinuous) + name_prefix = kwargs.get('name_prefix', None) + name = kwargs.get('name', None) + out_array = kwargs.get('out_array', len(nvars) == 1 and isinstance(nvars[0], int)) + + nvars = [range(n) if isinstance(n, int) else n for n in nvars] + indices = list(nvars[0] if len(nvars) == 1 else product(*nvars)) # unpack tuple if needed + N = len(indices) + + # parameter can be scalar, array, or mapping lookup (i.e., dictionary, custom class, etc.) + # scalar: repeat for all N, array: use as is, lookup: convert to array using indices + R = lambda x: [x[i] for i in indices] if isinstance(x, Mapping) else (x if hasattr(x, "__getitem__") else [x] * N) + + start_idx = self.numVariables + idx = range(start_idx, start_idx + N) + status = super().addCols(N, R(obj), R(lb), R(ub), 0, [], [], []) + + if status != HighsStatus.kOk: + raise Exception("Failed to add columns to the model.") + + # only set integrality if we have non-continuous variables + if vartype != HighsVarType.kContinuous: + super().changeColsIntegrality(N, idx, R(vartype)) + + if name or name_prefix: + names = name or [f"{name_prefix}{i}" for i in indices] + + for i,n in zip(idx, names): + super().passColName(int(i), str(n)) + + return [highs_var(i, self) for i in idx] if out_array == True else {index: highs_var(i, self) for index,i in zip(indices, idx)} + + def addIntegrals(self, *nvars, **kwargs): + """Alias for the addVariables method, for integer variables.""" + kwargs.setdefault('type', HighsVarType.kInteger) + return self.addVariables(*nvars, **kwargs) + + def addBinaries(self, *nvars, **kwargs): + """Alias for the addVariables method, for binary variables.""" + kwargs.setdefault('lb', 0) + kwargs.setdefault('ub', 1) + kwargs.setdefault('type', HighsVarType.kInteger) + + return self.addVariables(*nvars, **kwargs) + def addIntegral(self, lb = 0, ub = kHighsInf, obj = 0, name = None): + """Alias for the addVariable method, for integer variables.""" return self.addVariable(lb, ub, obj, HighsVarType.kInteger, name) def addBinary(self, obj = 0, name = None): + """Alias for the addVariable method, for binary variables.""" return self.addVariable(0, 1, obj, HighsVarType.kInteger, name) - # Change the name of removeVar to deleteVariable - def deleteVariable(self, var): - for i in self._vars[var.index+1:]: - i.index -= 1 - - del self._vars[var.index] - - # only delete from model if it exists - if var.index < self.numVariables: - super().deleteVars(1, [var.index]) + def deleteVariable(self, var_or_index, *args): + """Deletes a variable from the model and updates the indices of subsequent variables in provided collections. + + Args: + var_or_index: A highs_var object or an index representing the variable to be deleted. + *args: Optional collections (lists, dicts, etc.) of highs_var objects whose indices need to be updated. + """ + # Determine the index of the variable to delete + index = var_or_index.index if isinstance(var_or_index, highs_var) else var_or_index + + # Delete the variable from the model if it exists + if index < self.numVariables: + super().deleteVars(1, [index]) + + # Update the indices of variables in the provided collections + for collection in args: + if isinstance(collection, dict): + # Update indices in a dictionary of variables + for key, var in collection.items(): + if var.index > index: + var.index -= 1 + elif hasattr(collection, '__iter__'): + # Update indices in an iterable of variables + for var in collection: + if var.index > index: + var.index -= 1 + # If the collection is a single highs_var object, check and update if necessary + elif isinstance(collection, highs_var) and collection.index > index: + collection.index -= 1 - # Change the name of getVars to getVariables def getVariables(self): - return self._vars + """Retrieves all variables in the model. + + Returns: + A list of highs_var objects, each representing a variable in the model. + """ + return [highs_var(i, self) for i in range(self.numVariables)] @property def inf(self): + """Represents infinity in the context of the solver. + + Returns: + The value used to represent infinity. + """ return kHighsInf @property def numVariables(self): + """Gets the number of variables in the model. + + Returns: + The number of variables. + """ return super().getNumCol() @property def numConstrs(self): + """Gets the number of constraints in the model. + + Returns: + The number of constraints. + """ return super().getNumRow() # # add constraints # def addConstr(self, cons, name=None): - self.update() + """Adds a constraint to the model. + + Args: + cons: A highs_linear_expression to be added. + name: Optional name of the constraint. + Returns: + A highs_con object representing the added constraint. + """ # if we have duplicate variables, add the vals vars,vals = zip(*[(var, sum(v[1] for v in Vals)) for var, Vals in groupby(sorted(zip(cons.vars, cons.vals)), key=itemgetter(0))]) super().addRow(cons.LHS - cons.constant, cons.RHS - cons.constant, len(vars), vars, vals) + con = highs_cons(self.numConstrs - 1, self) + + if name != None: + super().passRowName(con.index, name) + + return con + + def addConstrs(self, *args, **kwargs): + """Adds multiple constraints to the model. + + Args: + *args: A sequence of highs_linear_expression to be added. + + **kwargs: Optional keyword arguments. + name_prefix: Prefix for the constraint names. Constructed name will be name_prefix + index. + name: A collection of names for the constraints (list or mapping). + + Returns: + A highs_con collection array representing the added constraints. + """ + name_prefix = kwargs.get('name_prefix', None) + name = kwargs.get('name', None) + generator = args + + # unpack generator if needed + if len(args) == 1 and hasattr(args[0], "__iter__") == True: + generator = args[0] + + lower = [] + upper = [] + starts = [0] + indices = [] + values = [] + nnz = 0; + + for cons in generator: + # if we have duplicate variables, add the vals together + vars,vals = zip(*[(var, sum(v[1] for v in Vals)) for var, Vals in groupby(sorted(zip(cons.vars, cons.vals)), key=itemgetter(0))]) + + indices.extend(vars) + values.extend(vals) + nnz += len(vars) + + lower.append(cons.LHS - cons.constant) + upper.append(cons.RHS - cons.constant) + starts.append(nnz) + + new_rows = len(lower) + super().addRows(new_rows, lower, upper, nnz, starts, indices, values); + cons = [highs_cons(self.numConstrs - new_rows + n, self) for n in range(new_rows)] + + if name or name_prefix: + names = name or [f"{name_prefix}{n}" for n in range(new_rows)] + + for c,n in zip(cons, names): + super().passRowName(int(c.index), str(n)) - cons = highs_cons(self.numConstrs - 1, self, name) - self._cons.append(cons) return cons + def chgCoeff(self, cons, var, val): + """Changes the coefficient of a variable in a constraint. + + Args: + cons: A highs_con object representing the constraint. + var: A highs_var object representing the variable. + val: The new coefficient value for the variable in the constraint. + """ super().changeCoeff(cons.index, var.index, val) def getConstrs(self): - return self._cons - - def removeConstr(self, cons): - for i in self._cons[cons.index+1:]: - i.index -= 1 + """Retrieves all constraints in the model. + + Returns: + A list of highs_cons objects, each representing a constraint in the model. + """ + return [highs_cons(i, self) for i in range(self.numConstrs)] + + def removeConstr(self, cons_or_index, *args): + """Removes a constraint from the model and updates the indices of subsequent constraints in provided collections. + + Args: + cons_or_index: A highs_cons object or an index representing the constraint to be removed. + *args: Optional collections (lists, dicts, etc.) of highs_cons objects whose indices need to be updated after the removal. + """ + # Determine the index of the constraint to delete + index = cons_or_index.index if isinstance(cons_or_index, highs_cons) else cons_or_index + + # Delete the variable from the model if it exists + if index < self.numConstrs: + super().deleteRows(1, [index]) + + # Update the indices of constraints in the provided collections + for collection in args: + if isinstance(collection, dict): + # Update indices in a dictionary of constraints + for key, con in collection.items(): + if con.index > index: + con.index -= 1 + elif hasattr(collection, '__iter__'): + # Update indices in an iterable of constraints + for con in collection: + if con.index > index: + con.index -= 1 + # If the collection is a single highs_cons object, check and update if necessary + elif isinstance(collection, highs_cons) and collection.index > index: + collection.index -= 1 - del self._cons[cons.index] - super().deleteRows(1, [cons.index]) - # set to minimization def setMinimize(self): + """Sets the objective sense of the model to minimization.""" super().changeObjectiveSense(ObjSense.kMinimize) - # set to maximization def setMaximize(self): + """Sets the objective sense of the model to maximization.""" super().changeObjectiveSense(ObjSense.kMaximize) - # Set to integer def setInteger(self, var): + """Sets a variable's type to integer. + + Args: + var: A highs_var object representing the variable to be set as integer. + """ super().changeColIntegrality(var.index, HighsVarType.kInteger) - # Set to continuous def setContinuous(self, var): + """Sets a variable's type to continuous. + + Args: + var: A highs_var object representing the variable to be set as continuous. + """ super().changeColIntegrality(var.index, HighsVarType.kContinuous) ## The following classes keep track of variables @@ -302,31 +654,26 @@ def setContinuous(self, var): # highs variable class highs_var(object): """Basic constraint builder for HiGHS""" - __slots__ = ['index', '_variableName', 'highs'] + __slots__ = ['index', 'highs'] - def __init__(self, i, highs, name=None): + def __init__(self, i, highs): self.index = i self.highs = highs - self.name = f"__v{i}" if name == None else name def __repr__(self): - return f"{self.name}" + return f"highs_var({self.index})" @property def name(self): - if self.index < self.highs.numVariables and self.highs.numVariables > 0: - return self.highs.getLp().col_names_[self.index] - else: - return self._variableName + status, name = self.highs.getColName(self.index) + + if status != HighsStatus.kOk: + raise Exception("Error retrieving variable name.") + return name @name.setter def name(self, value): - if value == None or len(value) == 0: - raise Exception('Name cannot be empty') - - self._variableName = value - if self.index < self.highs.numVariables and self.highs.numVariables > 0: - self.highs.passColName(self.index, self._variableName) + self.highs.passColName(self.index, value) def __hash__(self): return self.index @@ -364,27 +711,26 @@ def __sub__(self, other): # highs constraint class highs_cons(object): """Basic constraint for HiGHS""" - __slots__ = ['index', '_constrName', 'highs'] + __slots__ = ['index', 'highs'] - def __init__(self, i, highs, name): + def __init__(self, i, highs): self.index = i self.highs = highs - self.name = f"__c{i}" if name == None else name def __repr__(self): - return f"{self.name}" + return f"highs_cons({self.index})" @property def name(self): - return self._constrName + status, name = self.highs.getRowName(self.index) + + if status != HighsStatus.kOk: + raise Exception("Error retrieving constraint name.") + return name @name.setter def name(self, value): - if value == None or len(value) == 0: - raise Exception('Name cannot be empty') - - self._constrName = value - self.highs.passRowName(self.index, self._constrName) + self.highs.passRowName(self.index, value) # highs constraint builder @@ -467,7 +813,6 @@ def __eq__(self, other): # (other.LHS <= other <= other.RHS) <= (LHS <= self <= RHS) def __ge__(self, other): - if isinstance(other, highs_linear_expression): return other <= self @@ -533,29 +878,3 @@ def __sub__(self, other): return self + (-1.0 * other) else: return NotImplemented - -# used to batch add new variables -class highs_batch(object): - """Batch constraint builder for HiGHS""" - __slots__ = ['obj', 'lb', 'ub', 'type', 'name', 'highs', 'idx'] - - def __init__(self, highs): - self.highs = highs - - self.obj = [] - self.lb = [] - self.ub = [] - self.type = [] - self.idx = [] - self.name = [] - - def add(self, obj, lb, ub, type, name, solver): - self.obj.append(obj) - self.lb.append(lb) - self.ub.append(ub) - self.type.append(type) - self.name.append(name) - - newIndex = self.highs.numVariables + len(self.obj)-1 - self.idx.append(newIndex) - return highs_var(newIndex, solver, name) diff --git a/tests/test_highspy.py b/tests/test_highspy.py index 7991f91fd6..8d8a27174f 100644 --- a/tests/test_highspy.py +++ b/tests/test_highspy.py @@ -5,7 +5,6 @@ from io import StringIO from sys import platform - class TestHighsPy(unittest.TestCase): def get_basic_model(self): """ @@ -54,33 +53,33 @@ def get_example_model(self): h.passModel(lp) return h - # def test_example_model_builder(self): - # """ - # minimize f = x0 + x1 - # subject to x1 <= 7 - # 5 <= x0 + 2x1 <= 15 - # 6 <= 3x0 + 2x1 - # 0 <= x0 <= 4; 1 <= x1 - # """ - # h = highspy.Highs() - - # x0 = h.addVariable(lb=0, ub=4, obj=1) - # x1 = h.addVariable(lb=1, ub=7, obj=1) - - # h.addConstr(5 <= x0 + 2*x1 <= 15) - # h.addConstr(6 <= 3*x0 + 2*x1) - - # lp = h.getLp() - - # self.assertEqual(lp.num_col_, 2) - # self.assertEqual(lp.num_row_, 2) - # self.assertAlmostEqual(lp.col_cost_[0], 1) - # self.assertAlmostEqual(lp.col_lower_[0], 0) - # self.assertAlmostEqual(lp.col_upper_[0], 4) - # self.assertAlmostEqual(lp.row_lower_[0], 5) - # self.assertAlmostEqual(lp.row_upper_[0], 15) - # self.assertAlmostEqual(lp.row_lower_[1], 6) - # self.assertAlmostEqual(lp.row_upper_[1], highspy.kHighsInf) + def test_example_model_builder(self): + """ + minimize f = x0 + x1 + subject to x1 <= 7 + 5 <= x0 + 2x1 <= 15 + 6 <= 3x0 + 2x1 + 0 <= x0 <= 4; 1 <= x1 + """ + h = highspy.Highs() + + x0 = h.addVariable(lb=0, ub=4, obj=1) + x1 = h.addVariable(lb=1, ub=7, obj=1) + + h.addConstr(5 <= x0 + 2*x1 <= 15) + h.addConstr(6 <= 3*x0 + 2*x1) + + lp = h.getLp() + + self.assertEqual(lp.num_col_, 2) + self.assertEqual(lp.num_row_, 2) + self.assertAlmostEqual(lp.col_cost_[0], 1) + self.assertAlmostEqual(lp.col_lower_[0], 0) + self.assertAlmostEqual(lp.col_upper_[0], 4) + self.assertAlmostEqual(lp.row_lower_[0], 5) + self.assertAlmostEqual(lp.row_upper_[0], 15) + self.assertAlmostEqual(lp.row_lower_[1], 6) + self.assertAlmostEqual(lp.row_upper_[1], highspy.kHighsInf) def get_infeasible_model(self): inf = highspy.kHighsInf @@ -462,91 +461,88 @@ def test_infeasible_model(self): status = h.getModelStatus() self.assertEqual(status, highspy.HighsModelStatus.kInfeasible) - # failing? - - # def test_basics_builder(self): - # h = highspy.Highs() - # h.setOptionValue('output_flag', False) - - # x = h.addVariable(lb=highspy.kHighsInf) - # y = h.addVariable(lb=highspy.kHighsInf) - - # c1 = h.addConstr(-x + y >= 2) - # c2 = h.addConstr(x + y >= 0) - - # h.minimize(y) - - # self.assertAlmostEqual(h.val(x), -1) - # self.assertAlmostEqual(h.val(y), 1) - - # """ - # min y - # s.t. - # -x + y >= 3 - # x + y >= 0 - # """ - # h.changeRowBounds(0, 3, highspy.kHighsInf) - # h.run() - - # self.assertAlmostEqual(h.val(x), -1.5) - # self.assertAlmostEqual(h.val(y), 1.5) - - # # now make y integer - # h.changeColsIntegrality(1, np.array([1]), np.array([highspy.HighsVarType.kInteger])) - # h.run() - # sol = h.getSolution() - # self.assertAlmostEqual(sol.col_value[0], -1) - # self.assertAlmostEqual(sol.col_value[1], 2) - - # """ - # now delete the first constraint and add a new one + def test_basics_builder(self): + h = highspy.Highs() + h.setOptionValue('output_flag', False) + + x = h.addVariable(lb=-highspy.kHighsInf) + y = h.addVariable(lb=-highspy.kHighsInf) + + c1 = h.addConstr(-x + y >= 2) + c2 = h.addConstr(x + y >= 0) + + h.minimize(y) + + self.assertAlmostEqual(h.val(x), -1) + self.assertAlmostEqual(h.val(y), 1) + + """ + min y + s.t. + -x + y >= 3 + x + y >= 0 + """ + h.changeRowBounds(0, 3, highspy.kHighsInf) + h.run() + + self.assertAlmostEqual(h.val(x), -1.5) + self.assertAlmostEqual(h.val(y), 1.5) + + # now make y integer + h.changeColsIntegrality(1, np.array([1]), np.array([highspy.HighsVarType.kInteger])) + h.run() + sol = h.getSolution() + self.assertAlmostEqual(sol.col_value[0], -1) + self.assertAlmostEqual(sol.col_value[1], 2) + + """ + now delete the first constraint and add a new one - # min y - # s.t. - # x + y >= 0 - # -x + y >= 0 - # """ - # h.removeConstr(c1) + min y + s.t. + x + y >= 0 + -x + y >= 0 + """ + h.removeConstr(c1) - # c1 = h.addConstr(-x + y >= 0) + c1 = h.addConstr(-x + y >= 0) - # h.run() + h.run() - # self.assertAlmostEqual(h.val(x), 0) - # self.assertAlmostEqual(h.val(y), 0) + self.assertAlmostEqual(h.val(x), 0) + self.assertAlmostEqual(h.val(y), 0) - # # change the upper bound of x to -5 - # h.changeColsBounds(1, np.array([0]), np.array([-highspy.kHighsInf], dtype=np.double), - # np.array([-5], dtype=np.double)) - # h.run() - # self.assertAlmostEqual(h.val(x), -5) - # self.assertAlmostEqual(h.val(y), 5) + # change the upper bound of x to -5 + h.changeColsBounds(1, np.array([0]), np.array([-highspy.kHighsInf], dtype=np.double), + np.array([-5], dtype=np.double)) + h.run() + self.assertAlmostEqual(h.val(x), -5) + self.assertAlmostEqual(h.val(y), 5) - # # now maximize - # h.changeRowBounds(1, -highspy.kHighsInf, 0) - # h.changeRowBounds(0, -highspy.kHighsInf, 0) - # h.minimize(-y) + # now maximize + h.changeRowBounds(1, -highspy.kHighsInf, 0) + h.changeRowBounds(0, -highspy.kHighsInf, 0) + h.minimize(-y) - # self.assertAlmostEqual(h.val(x), -5) - # self.assertAlmostEqual(h.val(y), -5) + self.assertAlmostEqual(h.val(x), -5) + self.assertAlmostEqual(h.val(y), -5) - # self.assertEqual(h.getObjectiveSense()[1], highspy.ObjSense.kMinimize) - # h.maximize(y) - # self.assertEqual(h.getObjectiveSense()[1], highspy.ObjSense.kMaximize) + self.assertEqual(h.getObjectiveSense()[1], highspy.ObjSense.kMinimize) + h.maximize(y) + self.assertEqual(h.getObjectiveSense()[1], highspy.ObjSense.kMaximize) - # self.assertAlmostEqual(h.val(x), -5) - # self.assertAlmostEqual(h.val(y), -5) + self.assertAlmostEqual(h.val(x), -5) + self.assertAlmostEqual(h.val(y), -5) - # self.assertAlmostEqual(h.getObjectiveValue(), -5) + self.assertAlmostEqual(h.getObjectiveValue(), -5) - # h.maximize(y + 1) - # self.assertAlmostEqual(h.getObjectiveOffset()[1], 1) - # self.assertAlmostEqual(h.getObjectiveValue(), -4) + h.maximize(y + 1) + self.assertAlmostEqual(h.getObjectiveOffset()[1], 1) + self.assertAlmostEqual(h.getObjectiveValue(), -4) def test_addVariable(self): h = highspy.Highs() h.addVariable() - h.update() self.assertEqual(h.numVariables, 1) def test_addConstr(self): @@ -606,7 +602,6 @@ def test_var_name(self): self.assertEqual(x.name, 'y') # add to the model - h.update() self.assertEqual(h.numVariables, 1) self.assertEqual(h.getLp().col_names_[0], 'y') @@ -618,13 +613,7 @@ def test_var_name(self): h.passColName(0, 'a') self.assertEqual(h.getLp().col_names_[0], 'a') self.assertEqual(x.name, 'a') - - # change name to none or empty string - def change_name(n): - x.name = n - self.assertRaises(Exception, change_name, None) - self.assertRaises(Exception, change_name, '') def test_binary(self): h = highspy.Highs() @@ -756,6 +745,131 @@ def test_constraint_builder(self): h.addConstr(c1) self.assertAlmostEqual((h.getLp().row_lower_[0], h.getLp().row_upper_[0]), (4.5, 4.5)) + def test_add_multiple_variables(self): + # test basic functionality + h = highspy.Highs() + x = h.addVariables(2) + self.assertEqual(h.numVariables, 2) + + # test multiple dimensions + h = highspy.Highs() + x = h.addVariables(2,3,4) + self.assertEqual(h.numVariables, 2*3*4) + self.assertEqual(isinstance(x, dict), True) + + # test multiple dimensions array + h = highspy.Highs() + x = h.addVariables(2,3,4, out_array=True) + self.assertEqual(h.numVariables, 2*3*4) + self.assertEqual(isinstance(x, list), True) + + # test binary variables with objective and names + h = highspy.Highs() + x = h.addBinaries(20, obj=range(20), name_prefix='t_') + self.assertEqual(h.numVariables, 20) + self.assertEqual(h.getLp().col_names_[0], 't_0') + self.assertEqual(h.getLp().col_names_[19], 't_19') + self.assertEqual(h.getLp().col_cost_[0], 0) + self.assertEqual(h.getLp().col_cost_[19], 19) + + # test prefix item with indices, not variable offset + h = highspy.Highs() + x = h.addVariables('a','b','c', name_prefix='t_') # ('a', 'b', 'c') + self.assertEqual(h.numVariables, 1) + self.assertEqual(x['a','b','c'].name, "t_('a', 'b', 'c')") + + # Testing different ways of adding variables + # Some are unlikely to be used, but this is expected behaviour + N = 0 + h = highspy.Highs() + x1 = h.addVariables(('a','b','c'), obj={ 'b': 20, 'c': 10, 'a': 50 }) + N += 3 + self.assertEqual(h.numVariables, N) + lp = h.getLp() + self.assertEqual(lp.col_cost_[0], 50) + self.assertEqual(lp.col_cost_[1], 20) + self.assertEqual(lp.col_cost_[2], 10) + + x2 = h.addVariables(['a','b','c','d']) # 'a', 'b', 'c', 'd' + N += 4 + self.assertEqual(h.numVariables, N) + + x3 = h.addVariables('abc') # 'a', 'b', 'c' + N += 3 + self.assertEqual(h.numVariables, N) + + x4 = h.addVariables(('ab','b','c','d')) # 'ab', 'b', 'c', 'd' + N += 4 + self.assertEqual(h.numVariables, N) + + x5 = h.addVariables('ab','b','c','d') # ('a', 'b', 'c', 'd'), ('b', 'b', 'c', 'd') + N += 2 + self.assertEqual(h.numVariables, N) + self.assertTrue(('a', 'b', 'c', 'd') in x5.keys()) + self.assertTrue(('b', 'b', 'c', 'd') in x5.keys()) + + x6 = h.addVariables(5, 'a', 2, 'b', 'c') # range(5), 'a', range(2), 'b', 'c' + N += 5*2 + self.assertEqual(h.numVariables, N) + + x7 = h.addVariables([5, 'a', 2, 'b', 'c']) # 5, 'a', 2, 'b', 'c' + N += 5 + self.assertEqual(h.numVariables, N) + + x8 = h.addVariables([(20, 1), (1,2), (2,6)], ub=[3,2,1], name_prefix='t') # (20, 1), (1,2), (2,6) + N += 3 + self.assertEqual(h.numVariables, N) + + x9 = h.addBinaries((20, 1), (1,2), (2,6)) # product((20, 1), (1,2), (2,6))) = (20, 1, 2), ..., (1, 2, 6) + N += 8 + self.assertEqual(h.numVariables, N) + + def test_add_single_constraints(self): + h = highspy.Highs() + (x,y) = h.addVariables(2) + added_constraints = h.addConstrs([2*x + 3*y <= 5]) + self.assertEqual(len(added_constraints), 1) + self.assertEqual(h.numConstrs, 1) + + def test_add_multiple_constraints(self): + # test manual constraints + h = highspy.Highs() + (x,y,z) = h.addVariables(3) + added_constraints = h.addConstrs([ + x + y <= 5, + 2*x + 3*y >= 10, + x - z == 2]) + self.assertEqual(len(added_constraints), 3) + self.assertEqual(h.numConstrs, 3) + + # test list comprehension constraints + h = highspy.Highs() + x = h.addVariables(5) + h.addConstr(sum(x) == 1) + self.assertEqual(h.numConstrs, 1) + + h.addConstrs(x[i] + x[j] <= 1 for i in range(5) for j in range(5)) + self.assertEqual(h.numConstrs, 26) + + # test names and sequence types (list, tuple, nothing) + h = highspy.Highs() + (x1, x2, x3) = h.addVariables(3) + + h.addConstrs((x2 - x1 >= 2), name_prefix='a') + self.assertEqual(h.numConstrs, 1) + h.addConstrs((x2 - x1 >= 2)) + self.assertEqual(h.numConstrs, 2) + + h.addConstrs(x2 - x1 >= 2, name_prefix='b') + self.assertEqual(h.numConstrs, 3) + h.addConstrs(x2 - x1 >= 2) + self.assertEqual(h.numConstrs, 4) + + h.addConstrs([x2 - x1 >= 2], name_prefix='c') + self.assertEqual(h.numConstrs, 5) + h.addConstrs([x2 - x1 >= 2]) + self.assertEqual(h.numConstrs, 6) + # r/w basis tests below works on unix but not windows? def test_write_basis_before_running(self):