From a146f5201c4c31d5d7cb76a46f08204b5c1fdb3a Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Thu, 11 Jan 2024 13:53:49 +0000 Subject: [PATCH 01/14] addPiecewiseLinearCons method and test --- src/pyscipopt/scip.pxi | 31 ++++++++++++++++++++++++++++++- tests/test_cons.py | 12 ++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 0b2332d88..833f23182 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -2602,7 +2602,7 @@ cdef class Model: PY_SCIP_CALL(SCIPaddVarSOS2(self._scip, scip_cons, var.scip_var, weights[i])) PY_SCIP_CALL(SCIPaddCons(self._scip, scip_cons)) - return Constraint.create(scip_cons) + return Constraint.create(scip_cons) def addConsAnd(self, vars, resvar, name="ANDcons", initial=True, separate=True, enforce=True, check=True, @@ -2834,6 +2834,35 @@ cdef class Model: PY_SCIP_CALL(SCIPreleaseCons(self._scip, &scip_cons)) return pyCons + + def addPiecewiseLinearCons(self, X, Y, a, b): + """add piecewise relation with multiple selection formulation + + :param a: array with x-coordinates of the points in the piecewise linear relation + :param b: array with y-coordinate of the points in the piecewise linear relation + """ + K = len(a)-1 + w,z = {},{} + for k in range(K): + w[k] = self.addVar(lb=-self.infinity()) + z[k] = self.addVar(vtype="B") + + #X = self.addVar(lb=a[0], ub=a[K]) + #Y = self.addVar(lb=-self.infinity()) + + for k in range(K): + self.addCons(w[k] >= a[k]*z[k]) + self.addCons(w[k] <= a[k+1]*z[k]) + + self.addConsSOS1([z[k] for k in range(K)]) + self.addCons(X == quicksum(w[k] for k in range(K))) + + c = [float(b[k+1]-b[k])/(a[k+1]-a[k]) for k in range(K)] + d = [b[k]-c[k]*a[k] for k in range(K)] + + new_cons = self.addCons(Y == quicksum(d[k]*z[k] + c[k]*w[k] for k in range(K))) + + return new_cons def getSlackVarIndicator(self, Constraint cons): """Get slack variable of an indicator constraint. diff --git a/tests/test_cons.py b/tests/test_cons.py index c3f1f1978..00d5d1ae8 100644 --- a/tests/test_cons.py +++ b/tests/test_cons.py @@ -153,6 +153,18 @@ def test_printCons(): m.printCons(c) +def test_addPiecewiseLinearCons(): + m = Model() + + xpoints = [1,3,5] + ypoints = [1,2,4] + x = m.addVar(lb=xpoints[0], ub=xpoints[-1], obj=2) + y = m.addVar(lb=-m.infinity(), obj=-3) + m.addPiecewiseLinearCons(x,y,xpoints,ypoints) + + m.optimize() + assert m.isEQ(m.getObjVal(), -2) + @pytest.mark.skip(reason="TODO: test getValsLinear()") def test_getValsLinear(): assert True From 8eae73af02715af92f109546a54480e33dc18af2 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Thu, 11 Jan 2024 13:54:20 +0000 Subject: [PATCH 02/14] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e408517d..423a8977a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased ### Added +- Added method for adding piecewise linear constraints - Added methods for getting the names of the current stage and of an event ### Fixed - Fixed outdated time.clock call in gcp.py From 1a32614154e737980cca7bd080adfe5a2c353173 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Mon, 15 Jan 2024 11:50:09 +0000 Subject: [PATCH 03/14] Fix recommendations --- src/pyscipopt/scip.pxi | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 833f23182..dd5b33962 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -2836,29 +2836,34 @@ cdef class Model: return pyCons def addPiecewiseLinearCons(self, X, Y, a, b): - """add piecewise relation with multiple selection formulation + """add constraint of the form y = f(x), where f is a piecewise linear function + :param X: x variable + :param Y: y variable :param a: array with x-coordinates of the points in the piecewise linear relation :param b: array with y-coordinate of the points in the piecewise linear relation + + Disclaimer: For the moment, can only model 2d piecewise linear functions + Adapted from https://github.com/scipopt/PySCIPOpt/blob/master/examples/finished/piecewise.py """ + assert len(a) == len(b), "Must have the same number of x and y-coordinates" + K = len(a)-1 w,z = {},{} for k in range(K): w[k] = self.addVar(lb=-self.infinity()) z[k] = self.addVar(vtype="B") - #X = self.addVar(lb=a[0], ub=a[K]) - #Y = self.addVar(lb=-self.infinity()) - for k in range(K): self.addCons(w[k] >= a[k]*z[k]) self.addCons(w[k] <= a[k+1]*z[k]) - self.addConsSOS1([z[k] for k in range(K)]) + self.addCons(quicksum(z[k] for k in range(K)) == 1) + self.addCons(X == quicksum(w[k] for k in range(K))) - c = [float(b[k+1]-b[k])/(a[k+1]-a[k]) for k in range(K)] - d = [b[k]-c[k]*a[k] for k in range(K)] + c = [float(b[k+1]-b[k]) / (a[k+1]-a[k]) for k in range(K)] + d = [b[k] - c[k]*a[k] for k in range(K)] new_cons = self.addCons(Y == quicksum(d[k]*z[k] + c[k]*w[k] for k in range(K))) From d3f5ba8b68a59b2ea0476324f7068d42498c1c21 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Wed, 24 Jan 2024 16:11:33 +0000 Subject: [PATCH 04/14] Add possibility for nonlinear objective --- src/pyscipopt/scip.pxi | 45 ++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index dd5b33962..07aa62f0c 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -1322,12 +1322,8 @@ cdef class Model: # turn the constant value into an Expr instance for further processing if not isinstance(coeffs, Expr): - assert(_is_number(coeffs)), "given coefficients are neither Expr or number but %s" % coeffs.__class__.__name__ coeffs = Expr() + coeffs - if coeffs.degree() > 1: - raise ValueError("Nonlinear objective functions are not supported!") - if clear: # clear existing objective function self.addObjoffset(-self.getObjoffset()) @@ -1336,22 +1332,33 @@ cdef class Model: for i in range(_nvars): PY_SCIP_CALL(SCIPchgVarObj(self._scip, _vars[i], 0.0)) - if coeffs[CONST] != 0.0: - self.addObjoffset(coeffs[CONST]) - - for term, coef in coeffs.terms.items(): - # avoid CONST term of Expr - if term != CONST: - assert len(term) == 1 - var = term[0] - PY_SCIP_CALL(SCIPchgVarObj(self._scip, var.scip_var, coef)) - - if sense == "minimize": - self.setMinimize() - elif sense == "maximize": - self.setMaximize() + if coeffs.degree() > 1: + new_obj = self.addVar(lb=-float("inf"),obj=1) + if sense == "minimize": + self.addCons(coeffs <= new_obj) + self.setMinimize() + elif sense == "maximize": + self.addCons(coeffs >= new_obj) + self.setMaximize() + else: + raise Warning("unrecognized optimization sense: %s" % sense) else: - raise Warning("unrecognized optimization sense: %s" % sense) + if coeffs[CONST] != 0.0: + self.addObjoffset(coeffs[CONST]) + + for term, coef in coeffs.terms.items(): + # avoid CONST term of Expr + if term != CONST: + assert len(term) == 1 + var = term[0] + PY_SCIP_CALL(SCIPchgVarObj(self._scip, var.scip_var, coef)) + + if sense == "minimize": + self.setMinimize() + elif sense == "maximize": + self.setMaximize() + else: + raise Warning("unrecognized optimization sense: %s" % sense) def getObjective(self): """Retrieve objective function as Expr""" From 0f3f4c380819edd7aa7598b42ade2744a4b4feb9 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Wed, 24 Jan 2024 16:12:21 +0000 Subject: [PATCH 05/14] Add nonlinear objective test --- tests/test_nonlinear.py | 52 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/tests/test_nonlinear.py b/tests/test_nonlinear.py index 528ed383c..c0e02067f 100644 --- a/tests/test_nonlinear.py +++ b/tests/test_nonlinear.py @@ -1,6 +1,7 @@ import pytest +import random -from pyscipopt import Model, quicksum, sqrt +from pyscipopt import Model, quicksum, sqrt, exp, log, sin # test string with polynomial formulation (uses only Expr) def test_string_poly(): @@ -284,4 +285,51 @@ def test_quad_coeffs(): assert quadterms[0][1] == 0.5 assert linterms[0][0].name == z.name - assert linterms[0][1] == 4 \ No newline at end of file + assert linterms[0][1] == 4 + +def test_nonlinear_objective(): + m = Model() + + x = {} + for i in range(random.randint(1,20)): + x[i] = m.addVar(lb = -float("inf")) + + obj = 0 + for var in x: + rand = random.random() + if rand <= 0.2: + obj += 20*random.random()*var + elif rand <= 0.4: + obj += exp(random.random()*var) + elif rand <= 0.6: + obj += log(max(random.random()*var,0.1)) + elif rand <= 0.8: + obj += sqrt(random.random()*var) + else: + obj += sin(random.random()*var) + + if random.random() <= 0.5: + sense = "minimize" + else: + sense = "maximize" + + m.setObjective(obj, sense=sense) + m.optimize() + assert m.getNSols() > 0 + result_1 = m.getObjVal() + + m = Model() + aux = m.addVar(lb=-float("inf"), obj = 1) + if sense == "minimize": + m.addCons(obj <= aux) + m.setMinimize() + else: + m.addCons(obj >= aux) + m.setMaximize() + m.optimize() + assert m.getNSols() > 0 + result_2 = m.getObjVal() + + assert result_1 == result_2 + + From 995796bc418b5cc0a100e568f514272ecb612aed Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Wed, 24 Jan 2024 16:12:42 +0000 Subject: [PATCH 06/14] Modify other tests --- tests/test_linexpr.py | 7 +------ tests/test_model.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/test_linexpr.py b/tests/test_linexpr.py index 1160aff8c..f7eb54281 100644 --- a/tests/test_linexpr.py +++ b/tests/test_linexpr.py @@ -215,9 +215,4 @@ def test_objective(model): # setting affine objective m.setObjective(x + y + 1) - assert m.getObjoffset() == 1 - - # setting nonlinear objective - with pytest.raises(ValueError): - m.setObjective(x ** 2 - y * z) - + assert m.getObjoffset() == 1 \ No newline at end of file diff --git a/tests/test_model.py b/tests/test_model.py index d5016799e..4b4002385 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -286,3 +286,16 @@ def test_getStage(): print(m.getStage()) assert m.getStage() == SCIP_STAGE.SOLVED assert m.getStageName() == "SOLVED" + +@pytest.mark.xskip(reason="getObjective doesn't behave as expected with nonlinear objectives") +def test_getObjective(): + m = Model() + x = m.addVar(obj=2) + y = m.addVar(obj=3) + + assert str(m.getObjective()) == "Expr({Term(x1): 2.0, Term(x2): 3.0})" + + m.setObjective(x**2 + y**(1/3)) + m.getObjective() + + \ No newline at end of file From 0bc5b27f9641087d5cababb866c43786f14d8523 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Wed, 24 Jan 2024 16:19:51 +0000 Subject: [PATCH 07/14] Update CHANGELOG --- CHANGELOG.md | 2 +- src/pyscipopt/scip.pxi | 34 ---------------------------------- tests/test_cons.py | 13 ------------- 3 files changed, 1 insertion(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 423a8977a..28ad67d59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased ### Added -- Added method for adding piecewise linear constraints +- Added possibility for nonlinear objective functions - Added methods for getting the names of the current stage and of an event ### Fixed - Fixed outdated time.clock call in gcp.py diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 07aa62f0c..51566c0bd 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -2841,40 +2841,6 @@ cdef class Model: PY_SCIP_CALL(SCIPreleaseCons(self._scip, &scip_cons)) return pyCons - - def addPiecewiseLinearCons(self, X, Y, a, b): - """add constraint of the form y = f(x), where f is a piecewise linear function - - :param X: x variable - :param Y: y variable - :param a: array with x-coordinates of the points in the piecewise linear relation - :param b: array with y-coordinate of the points in the piecewise linear relation - - Disclaimer: For the moment, can only model 2d piecewise linear functions - Adapted from https://github.com/scipopt/PySCIPOpt/blob/master/examples/finished/piecewise.py - """ - assert len(a) == len(b), "Must have the same number of x and y-coordinates" - - K = len(a)-1 - w,z = {},{} - for k in range(K): - w[k] = self.addVar(lb=-self.infinity()) - z[k] = self.addVar(vtype="B") - - for k in range(K): - self.addCons(w[k] >= a[k]*z[k]) - self.addCons(w[k] <= a[k+1]*z[k]) - - self.addCons(quicksum(z[k] for k in range(K)) == 1) - - self.addCons(X == quicksum(w[k] for k in range(K))) - - c = [float(b[k+1]-b[k]) / (a[k+1]-a[k]) for k in range(K)] - d = [b[k] - c[k]*a[k] for k in range(K)] - - new_cons = self.addCons(Y == quicksum(d[k]*z[k] + c[k]*w[k] for k in range(K))) - - return new_cons def getSlackVarIndicator(self, Constraint cons): """Get slack variable of an indicator constraint. diff --git a/tests/test_cons.py b/tests/test_cons.py index 00d5d1ae8..bb113a0ee 100644 --- a/tests/test_cons.py +++ b/tests/test_cons.py @@ -152,19 +152,6 @@ def test_printCons(): m.printCons(c) - -def test_addPiecewiseLinearCons(): - m = Model() - - xpoints = [1,3,5] - ypoints = [1,2,4] - x = m.addVar(lb=xpoints[0], ub=xpoints[-1], obj=2) - y = m.addVar(lb=-m.infinity(), obj=-3) - m.addPiecewiseLinearCons(x,y,xpoints,ypoints) - - m.optimize() - assert m.isEQ(m.getObjVal(), -2) - @pytest.mark.skip(reason="TODO: test getValsLinear()") def test_getValsLinear(): assert True From 0b9adcb1753a327a8f43ed7cf0c974d5bdde2c9e Mon Sep 17 00:00:00 2001 From: Mark Turner Date: Fri, 15 Mar 2024 16:39:00 +0100 Subject: [PATCH 08/14] Add untested changes to fix test and assert --- src/pyscipopt/scip.pxi | 9 ++++--- tests/test_nonlinear.py | 56 +++++++++++++---------------------------- 2 files changed, 23 insertions(+), 42 deletions(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 0b5572958..e176bb632 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -1313,7 +1313,7 @@ cdef class Model: :param objlimit: limit on the objective function """ - PY_SCIP_CALL(SCIPsetObjlimit(self._scip, objlimit)) + PY_SCIP_CALL(SCIPlimit(self._scip, objlimit)) def getObjlimit(self): """returns current limit on objective function.""" @@ -1322,7 +1322,7 @@ cdef class Model: def setObjective(self, coeffs, sense = 'minimize', clear = 'true'): """Establish the objective function as a linear expression. - :param coeffs: the coefficients + :param coeffs: the objective function SCIP Expr, or constant value :param sense: the objective sense (Default value = 'minimize') :param clear: set all other variables objective coefficient to zero (Default value = 'true') @@ -1332,9 +1332,10 @@ cdef class Model: # turn the constant value into an Expr instance for further processing if not isinstance(coeffs, Expr): + if not isinstance(coeffs, GenExpr) and not issubclass(type(coeffs), GenExpr): + assert(_is_number(coeffs)), "given coefficients are neither Expr / GenExpr or number but %s" % coeffs.__class__.__name__ coeffs = Expr() + coeffs - cdef int i if clear: # clear existing objective function self.addObjoffset(-self.getObjoffset()) @@ -1345,7 +1346,7 @@ cdef class Model: PY_SCIP_CALL(SCIPchgVarObj(self._scip, _vars[i], 0.0)) if coeffs.degree() > 1: - new_obj = self.addVar(lb=-float("inf"),obj=1) + new_obj = self.addVar(lb=-SCIPinfinity,obj=1) if sense == "minimize": self.addCons(coeffs <= new_obj) self.setMinimize() diff --git a/tests/test_nonlinear.py b/tests/test_nonlinear.py index c0e02067f..54a8e4652 100644 --- a/tests/test_nonlinear.py +++ b/tests/test_nonlinear.py @@ -288,48 +288,28 @@ def test_quad_coeffs(): assert linterms[0][1] == 4 def test_nonlinear_objective(): - m = Model() + scip = Model() - x = {} - for i in range(random.randint(1,20)): - x[i] = m.addVar(lb = -float("inf")) + v = scip.addVar() + w = scip.addVar() + x = scip.addVar() + y = scip.addVar() + z = scip.addVar() obj = 0 - for var in x: - rand = random.random() - if rand <= 0.2: - obj += 20*random.random()*var - elif rand <= 0.4: - obj += exp(random.random()*var) - elif rand <= 0.6: - obj += log(max(random.random()*var,0.1)) - elif rand <= 0.8: - obj += sqrt(random.random()*var) - else: - obj += sin(random.random()*var) - - if random.random() <= 0.5: - sense = "minimize" - else: - sense = "maximize" - - m.setObjective(obj, sense=sense) - m.optimize() - assert m.getNSols() > 0 - result_1 = m.getObjVal() + obj += exp(v) + obj += log(w) + obj += sqrt(x) + obj += sin(y) + obj += z**3 * y - m = Model() - aux = m.addVar(lb=-float("inf"), obj = 1) - if sense == "minimize": - m.addCons(obj <= aux) - m.setMinimize() - else: - m.addCons(obj >= aux) - m.setMaximize() - m.optimize() - assert m.getNSols() > 0 - result_2 = m.getObjVal() + scip.addCons(v + w + x + y + z <= 1) + + scip.setObjective(obj, sense='maximize') + + obj_expr = scip.getObjective() - assert result_1 == result_2 + assert len(obj_expr) == 1 + assert obj_expr.degree == 1 From c8939c7fcfb96244fa840796d70ba9ad7868ed59 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Mon, 1 Apr 2024 12:05:05 +0100 Subject: [PATCH 09/14] Move to recipes folder --- src/pyscipopt/recipes/README.md | 3 ++ src/pyscipopt/recipes/nonlinear.py | 18 +++++++ src/pyscipopt/recipes/piecewise.py | 36 ++++++++++++++ src/pyscipopt/scip.pxi | 79 ++++++++++++++++++------------ tests/recipes/test_nonlinear.py | 34 +++++++++++++ tests/recipes/test_piecewise.py | 30 ++++++++++++ 6 files changed, 169 insertions(+), 31 deletions(-) create mode 100644 src/pyscipopt/recipes/README.md create mode 100644 src/pyscipopt/recipes/nonlinear.py create mode 100644 src/pyscipopt/recipes/piecewise.py create mode 100644 tests/recipes/test_nonlinear.py create mode 100644 tests/recipes/test_piecewise.py diff --git a/src/pyscipopt/recipes/README.md b/src/pyscipopt/recipes/README.md new file mode 100644 index 000000000..9942db0c3 --- /dev/null +++ b/src/pyscipopt/recipes/README.md @@ -0,0 +1,3 @@ +# Recipes sub-package + +This sub-package provides a set of functions for common usecases for pyscipopt. This sub-package is for all functions that don't necessarily reflect the core functionality of SCIP, but are useful for working with the solver. The functions implemented in this sub-package might not be the most efficient way to solve/formulate a problem but would provide a good starting point. diff --git a/src/pyscipopt/recipes/nonlinear.py b/src/pyscipopt/recipes/nonlinear.py new file mode 100644 index 000000000..f804e68a8 --- /dev/null +++ b/src/pyscipopt/recipes/nonlinear.py @@ -0,0 +1,18 @@ +from pyscipopt import Model + +def set_nonlinear_objective(model: Model, expr, sense="minimize"): + """ + Takes a nonlinear expression and performs an epigraph reformulation. + """ + + assert expr.degree() > 1, "For linear objectives, please use the setObjective method." + new_obj = model.addVar(lb=-float("inf"),obj=1) + if sense == "minimize": + model.addCons(expr <= new_obj) + model.setMinimize() + elif sense == "maximize": + model.addCons(expr >= new_obj) + model.setMaximize() + else: + raise Warning("unrecognized optimization sense: %s" % sense) + \ No newline at end of file diff --git a/src/pyscipopt/recipes/piecewise.py b/src/pyscipopt/recipes/piecewise.py new file mode 100644 index 000000000..7540414da --- /dev/null +++ b/src/pyscipopt/recipes/piecewise.py @@ -0,0 +1,36 @@ + +from pyscipopt import Model, quicksum, Variable, Constraint + +def add_piecewise_linear_cons(model: Model, X: Variable, Y: Variable, a: list[float], b: list[float]) -> Constraint: + """add constraint of the form y = f(x), where f is a piecewise linear function + + :param X: x variable + :param Y: y variable + :param a: array with x-coordinates of the points in the piecewise linear relation + :param b: array with y-coordinate of the points in the piecewise linear relation + + Disclaimer: For the moment, can only model 2d piecewise linear functions + Adapted from https://github.com/scipopt/PySCIPOpt/blob/master/examples/finished/piecewise.py + """ + assert len(a) == len(b), "Must have the same number of x and y-coordinates" + + K = len(a)-1 + w,z = {},{} + for k in range(K): + w[k] = model.addVar(lb=-model.infinity()) + z[k] = model.addVar(vtype="B") + + for k in range(K): + model.addCons(w[k] >= a[k]*z[k]) + model.addCons(w[k] <= a[k+1]*z[k]) + + model.addCons(quicksum(z[k] for k in range(K)) == 1) + + model.addCons(X == quicksum(w[k] for k in range(K))) + + c = [float(b[k+1]-b[k]) / (a[k+1]-a[k]) for k in range(K)] + d = [b[k] - c[k]*a[k] for k in range(K)] + + new_cons = model.addCons(Y == quicksum(d[k]*z[k] + c[k]*w[k] for k in range(K))) + + return new_cons diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 51566c0bd..1702e8fdc 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -35,7 +35,7 @@ include "relax.pxi" include "nodesel.pxi" # recommended SCIP version; major version is required -MAJOR = 8 +MAJOR = 9 MINOR = 0 PATCH = 0 @@ -975,10 +975,10 @@ cdef class Constraint: and self.scip_cons == (other).scip_cons) -cdef void relayMessage(SCIP_MESSAGEHDLR *messagehdlr, FILE *file, const char *msg): +cdef void relayMessage(SCIP_MESSAGEHDLR *messagehdlr, FILE *file, const char *msg) noexcept: sys.stdout.write(msg.decode('UTF-8')) -cdef void relayErrorMessage(void *messagehdlr, FILE *file, const char *msg): +cdef void relayErrorMessage(void *messagehdlr, FILE *file, const char *msg) noexcept: sys.stderr.write(msg.decode('UTF-8')) # - remove create(), includeDefaultPlugins(), createProbBasic() methods @@ -1322,8 +1322,12 @@ cdef class Model: # turn the constant value into an Expr instance for further processing if not isinstance(coeffs, Expr): + assert(_is_number(coeffs)), "given coefficients are neither Expr or number but %s" % coeffs.__class__.__name__ coeffs = Expr() + coeffs + if coeffs.degree() > 1: + raise ValueError("SCIP does not support nonlinear objectives. Please refer to the set_nonlinear_objective function in the recipe sub-package.") + if clear: # clear existing objective function self.addObjoffset(-self.getObjoffset()) @@ -1332,33 +1336,23 @@ cdef class Model: for i in range(_nvars): PY_SCIP_CALL(SCIPchgVarObj(self._scip, _vars[i], 0.0)) - if coeffs.degree() > 1: - new_obj = self.addVar(lb=-float("inf"),obj=1) - if sense == "minimize": - self.addCons(coeffs <= new_obj) - self.setMinimize() - elif sense == "maximize": - self.addCons(coeffs >= new_obj) - self.setMaximize() - else: - raise Warning("unrecognized optimization sense: %s" % sense) + if coeffs[CONST] != 0.0: + self.addObjoffset(coeffs[CONST]) + + for term, coef in coeffs.terms.items(): + # avoid CONST term of Expr + if term != CONST: + assert len(term) == 1 + var = term[0] + PY_SCIP_CALL(SCIPchgVarObj(self._scip, var.scip_var, coef)) + + if sense == "minimize": + self.setMinimize() + elif sense == "maximize": + self.setMaximize() else: - if coeffs[CONST] != 0.0: - self.addObjoffset(coeffs[CONST]) - - for term, coef in coeffs.terms.items(): - # avoid CONST term of Expr - if term != CONST: - assert len(term) == 1 - var = term[0] - PY_SCIP_CALL(SCIPchgVarObj(self._scip, var.scip_var, coef)) - - if sense == "minimize": - self.setMinimize() - elif sense == "maximize": - self.setMaximize() - else: - raise Warning("unrecognized optimization sense: %s" % sense) + raise Warning("unrecognized optimization sense: %s" % sense) + def getObjective(self): """Retrieve objective function as Expr""" @@ -2372,6 +2366,7 @@ cdef class Model: free(monomials) free(termcoefs) return PyCons + def _addGenNonlinearCons(self, ExprCons cons, **kwargs): cdef SCIP_EXPR** childrenexpr @@ -2496,6 +2491,24 @@ cdef class Model: return PyCons + # TODO Find a better way to retrieve a scip expression from a python expression. Consider making GenExpr include Expr, to avoid using Union. See PR #760. + from typing import Union + def addExprNonlinear(self, Constraint cons, expr: Union[Expr,GenExpr], float coef): + """ + Add coef*expr to nonlinear constraint. + """ + assert self.getStage() == 1, "addExprNonlinear cannot be called in stage %i." % self.getStage() + assert cons.isNonlinear(), "addExprNonlinear can only be called with nonlinear constraints." + + cdef Constraint temp_cons + cdef SCIP_EXPR* scip_expr + + temp_cons = self.addCons(expr <= 0) + scip_expr = SCIPgetExprNonlinear(temp_cons.scip_cons) + + PY_SCIP_CALL(SCIPaddExprNonlinear(self._scip, cons.scip_cons, scip_expr, coef)) + self.delCons(temp_cons) + def addConsCoeff(self, Constraint cons, Variable var, coeff): """Add coefficient to the linear constraint (if non-zero). @@ -2841,7 +2854,7 @@ cdef class Model: PY_SCIP_CALL(SCIPreleaseCons(self._scip, &scip_cons)) return pyCons - + def getSlackVarIndicator(self, Constraint cons): """Get slack variable of an indicator constraint. @@ -3768,7 +3781,7 @@ cdef class Model: PyConsInitsol, PyConsExitsol, PyConsDelete, PyConsTrans, PyConsInitlp, PyConsSepalp, PyConsSepasol, PyConsEnfolp, PyConsEnforelax, PyConsEnfops, PyConsCheck, PyConsProp, PyConsPresol, PyConsResprop, PyConsLock, PyConsActive, PyConsDeactive, PyConsEnable, PyConsDisable, PyConsDelvars, PyConsPrint, PyConsCopy, - PyConsParse, PyConsGetvars, PyConsGetnvars, PyConsGetdivebdchgs, + PyConsParse, PyConsGetvars, PyConsGetnvars, PyConsGetdivebdchgs, PyConsGetPermSymGraph, PyConsGetSignedPermSymGraph, conshdlr)) conshdlr.model = weakref.proxy(self) conshdlr.name = name @@ -5183,6 +5196,10 @@ cdef class Model: assert isinstance(var, Variable), "The given variable is not a pyvar, but %s" % var.__class__.__name__ PY_SCIP_CALL(SCIPchgVarBranchPriority(self._scip, var.scip_var, priority)) + def getTreesizeEstimation(self): + """Get an estimation of the final tree size """ + return SCIPgetTreesizeEstimation(self._scip) + # debugging memory management def is_memory_freed(): return BMSgetMemoryUsed() == 0 diff --git a/tests/recipes/test_nonlinear.py b/tests/recipes/test_nonlinear.py new file mode 100644 index 000000000..c284638d3 --- /dev/null +++ b/tests/recipes/test_nonlinear.py @@ -0,0 +1,34 @@ +from pyscipopt import Model, exp, log, sqrt, sin +from pyscipopt.recipes.nonlinear import set_nonlinear_objective + +def test_nonlinear_objective(): + model = Model() + + v = model.addVar() + w = model.addVar() + x = model.addVar() + y = model.addVar() + z = model.addVar() + + obj = 0 + obj += exp(v) + obj += log(w) + obj += sqrt(x) + obj += sin(y) + obj += z**3 * y + + model.addCons(v + w + x + y + z <= 1) + model2 = Model(sourceModel=model) + + set_nonlinear_objective(model, obj, sense='maximize') + t = model2.addVar("objective") + model2.addCons(t <= obj) + model2.setObjective(t, "maximize") + + obj_expr = model.getObjective() + assert obj_expr.degree() == 1 + + model.optimize() + model2.optimize() + + assert model.isEQ(model.getObjVal(), model2.getObjVal()) \ No newline at end of file diff --git a/tests/recipes/test_piecewise.py b/tests/recipes/test_piecewise.py new file mode 100644 index 000000000..b2aa11be4 --- /dev/null +++ b/tests/recipes/test_piecewise.py @@ -0,0 +1,30 @@ + +from pyscipopt import Model +from pyscipopt.recipes.piecewise import add_piecewise_linear_cons + +def test_add_piecewise_linear_cons(): + m = Model() + + xpoints = [1, 3, 5] + ypoints = [1, 2, 4] + x = m.addVar(lb=xpoints[0], ub=xpoints[-1], obj=2) + y = m.addVar(lb=-m.infinity(), obj=-3) + add_piecewise_linear_cons(m, x, y, xpoints, ypoints) + + m.optimize() + assert m.isEQ(m.getObjVal(), -2) + + +def test_add_piecewise_linear_cons2(): + m = Model() + + xpoints = [1, 3, 5] + ypoints = [1, 2, 4] + x = m.addVar(lb=xpoints[0], ub=xpoints[-1], obj=2) + y = m.addVar(lb=-m.infinity(), obj=-3) + add_piecewise_linear_cons(m, x, y, xpoints, ypoints) + + m.setMaximize() + + m.optimize() + assert m.isEQ(m.getObjVal(), 0) From 970c5a9d6dd53501c1b3c7eaa4f1a0cd0377692d Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Mon, 1 Apr 2024 12:05:22 +0100 Subject: [PATCH 10/14] Update CHANGELOG --- CHANGELOG.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28ad67d59..23339f9c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,25 @@ ## Unreleased ### Added -- Added possibility for nonlinear objective functions +- Added recipe for nonlinear objective functions +- Added method for adding piecewise linear constraints +- Add SCIP function SCIPgetTreesizeEstimation and wrapper getTreesizeEstimation +- Add recipes sub-package +### Fixed +### Changed +### Removed + +## 5.0.0 - 2024-03-05 +### Added +- Added SCIP function addExprNonlinear +- Add support for Cython 3 - Added methods for getting the names of the current stage and of an event +- Add support for SCIP symmetry graph callbacks in constraint handlers ### Fixed +- Fixed README links - Fixed outdated time.clock call in gcp.py ### Changed -### Removed +- Changed default installation option via pypi to package pre-build SCIP ## 4.4.0 - 2023-12-04 ### Added From a82bfd59e5813743e8a50f89295b574f9a0fa268 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Mon, 1 Apr 2024 13:17:07 +0100 Subject: [PATCH 11/14] Fix segfault --- tests/test_recipe_nonlinear.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/tests/test_recipe_nonlinear.py b/tests/test_recipe_nonlinear.py index 1542f3089..5354c320d 100644 --- a/tests/test_recipe_nonlinear.py +++ b/tests/test_recipe_nonlinear.py @@ -18,16 +18,35 @@ def test_nonlinear_objective(): obj += z**3 * y model.addCons(v + w + x + y + z <= 1) - model2 = Model(sourceModel=model) - set_nonlinear_objective(model, obj, sense='maximize') - t = model2.addVar("objective") - model2.addCons(t <= obj) - model2.setObjective(t, "maximize") + + model2 = Model() + + a = model2.addVar() + b = model2.addVar() + c = model2.addVar() + d = model2.addVar() + e = model2.addVar() + + obj2 = 0 + obj2 += exp(a) + obj2 += log(b) + obj2 += sqrt(c) + obj2 += sin(d) + obj2 += e**3 * d + + model2.addCons(a + b + c + d + e <= 1) + + t = model2.addVar(lb=-float("inf"),obj=1) + model2.addCons(t <= obj2) + model2.setMaximize() obj_expr = model.getObjective() assert obj_expr.degree() == 1 + model.setParam("numerics/epsilon", 10**(-5)) # bigger eps due to nonlinearities + model2.setParam("numerics/epsilon", 10**(-5)) + model.optimize() model2.optimize() - #assert model.isEQ(model.getObjVal(), model2.getObjVal()) \ No newline at end of file + assert model.isEQ(model.getObjVal(), model2.getObjVal()) \ No newline at end of file From d2f9999b92904b9bc9040d98339c9e867b1e563d Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Mon, 1 Apr 2024 13:18:29 +0100 Subject: [PATCH 12/14] Reset locale to standard --- tests/test_model.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_model.py b/tests/test_model.py index cfe157cb4..847fae1bc 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -360,3 +360,5 @@ def test_locale(): with open("model.cip") as file: assert "1,1" not in file.read() + + locale.setlocale(locale.LC_NUMERIC,"") \ No newline at end of file From 37d4f7a4dc8b1c55049dc87fbd20671b1b7ba65e Mon Sep 17 00:00:00 2001 From: Mohammed Ghannam Date: Mon, 1 Apr 2024 14:33:03 +0200 Subject: [PATCH 13/14] Fix test --- tests/test_model.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/test_model.py b/tests/test_model.py index 847fae1bc..49055cc35 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -290,14 +290,11 @@ def test_getStage(): def test_getObjective(): m = Model() - x = m.addVar(obj=2) - y = m.addVar(obj=3) + m.addVar(obj=2, name="x1") + m.addVar(obj=3, name="x2") assert str(m.getObjective()) == "Expr({Term(x1): 2.0, Term(x2): 3.0})" - m.setObjective(x**2 + y**(1/3)) - m.getObjective() - def test_getTreesizeEstimation(): m = Model() From ed3de60f42e24ee28123984c873acb7998d3c56b Mon Sep 17 00:00:00 2001 From: Mohammed Ghannam Date: Mon, 1 Apr 2024 14:37:37 +0200 Subject: [PATCH 14/14] Small refactoring --- src/pyscipopt/scip.pxi | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 5f40dfab0..cb48195d1 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -1315,10 +1315,10 @@ cdef class Model: """returns current limit on objective function.""" return SCIPgetObjlimit(self._scip) - def setObjective(self, coeffs, sense = 'minimize', clear = 'true'): + def setObjective(self, expr, sense = 'minimize', clear = 'true'): """Establish the objective function as a linear expression. - :param coeffs: the objective function SCIP Expr, or constant value + :param expr: the objective function SCIP Expr, or constant value :param sense: the objective sense (Default value = 'minimize') :param clear: set all other variables objective coefficient to zero (Default value = 'true') @@ -1328,12 +1328,13 @@ cdef class Model: cdef int _nvars # turn the constant value into an Expr instance for further processing - if not isinstance(coeffs, Expr): - assert(_is_number(coeffs)), "given coefficients are neither Expr or number but %s" % coeffs.__class__.__name__ - coeffs = Expr() + coeffs + if not isinstance(expr, Expr): + print(expr) + assert(_is_number(expr)), "given coefficients are neither Expr or number but %s" % expr.__class__.__name__ + expr = Expr() + expr - if coeffs.degree() > 1: - raise ValueError("SCIP does not support nonlinear objective functions. Consider using set_nonlinear_objective in the recipes folder.") + if expr.degree() > 1: + raise ValueError("SCIP does not support nonlinear objective functions. Consider using set_nonlinear_objective in the pyscipopt.recipe.nonlinear") if clear: # clear existing objective function @@ -1343,10 +1344,10 @@ cdef class Model: for i in range(_nvars): PY_SCIP_CALL(SCIPchgVarObj(self._scip, _vars[i], 0.0)) - if coeffs[CONST] != 0.0: - self.addObjoffset(coeffs[CONST]) + if expr[CONST] != 0.0: + self.addObjoffset(expr[CONST]) - for term, coef in coeffs.terms.items(): + for term, coef in expr.terms.items(): # avoid CONST term of Expr if term != CONST: assert len(term) == 1