Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Gurobipy Environment Handling #588

Merged
merged 14 commits into from
Sep 24, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions pulp/apis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,13 @@ def listSolvers(onlyAvailable=False):
:return: list of solver names
:rtype: list
"""
solvers = [s() for s in _all_solvers]
if onlyAvailable:
return [solver.name for solver in solvers if solver.available()]
return [solver.name for solver in solvers]
result = []
for s in _all_solvers:
solver = s()
if (not onlyAvailable) or solver.available():
result.append(solver.name)
del solver
return result


# DEPRECATED aliases:
Expand Down
156 changes: 104 additions & 52 deletions pulp/apis/gurobi_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
import warnings

# to import the gurobipy name into the module scope
gurobipy = None
gp = None


class GUROBI(LpSolver):
Expand All @@ -46,12 +46,13 @@ class GUROBI(LpSolver):
"""

name = "GUROBI"
env = None

try:
sys.path.append(gurobi_path)
# to import the name into the module scope
global gurobipy
import gurobipy
global gp
import gurobipy as gp
except: # FIXME: Bug because gurobi returns
# a gurobi exception on failed imports
def available(self):
Expand All @@ -73,7 +74,10 @@ def __init__(
gapRel=None,
warmStart=False,
logPath=None,
**solverParams
env=None,
envOptions=None,
manageEnv=False,
**solverParams,
):
"""
:param bool mip: if False, assume LP even if integer variables
Expand All @@ -83,13 +87,26 @@ def __init__(
:param bool warmStart: if True, the solver will use the current value of variables as a start
:param str logPath: path to the log file
:param float epgap: deprecated for gapRel
:param gp.Env env: gurobipyEnv. Default None.
:param dict envOptions: environment options
:param bool manageEnv: if False, assume gp.Env's is handled
internally and outside of context manager
"""
self.env = env
self.env_options = envOptions if envOptions else {}
self.manage_env = False if self.env is not None else manageEnv
self.solver_params = solverParams

self.model = None
self.init_gurobi = False # whether env and model have been initialised

if epgap is not None:
warnings.warn("Parameter epgap is being depreciated for gapRel")
if gapRel is not None:
warnings.warn("Parameter gapRel and epgap passed, using gapRel")
else:
gapRel = epgap

LpSolver.__init__(
self,
mip=mip,
Expand All @@ -99,18 +116,28 @@ def __init__(
logPath=logPath,
warmStart=warmStart,
)

# set the output of gurobi
if not self.msg:
gurobipy.setParam("OutputFlag", 0)
if self.manage_env:
self.env_options["OutputFlag"] = 0
else:
self.solver_params["OutputFlag"] = 0

def __del__(self):
self.close()

# set the gurobi parameter values
for key, value in solverParams.items():
gurobipy.setParam(key, value)
def close(self):
if not self.init_gurobi:
return
if self.manage_env:
self.env.dispose()
self.model.dispose()

def findSolutionValues(self, lp):
model = lp.solverModel
solutionStatus = model.Status
GRB = gurobipy.GRB
GRB = gp.GRB
# TODO: check status for Integer Feasible
gurobiLpStatus = {
GRB.OPTIMAL: constants.LpStatusOptimal,
Expand All @@ -131,46 +158,66 @@ def findSolutionValues(self, lp):
var.isModified = False
status = gurobiLpStatus.get(solutionStatus, constants.LpStatusUndefined)
lp.assignStatus(status)
if status != constants.LpStatusOptimal:
return status

# populate pulp solution values
for var, value in zip(
lp._variables, model.getAttr(GRB.Attr.X, model.getVars())
):
var.varValue = value

# populate pulp constraints slack
for constr, value in zip(
lp.constraints.values(),
model.getAttr(GRB.Attr.Slack, model.getConstrs()),
):
constr.slack = value

if not model.getAttr(GRB.Attr.IsMIP):
for var, value in zip(
lp._variables, model.getAttr(GRB.Attr.RC, model.getVars())
):
var.dj = value

# put pi and slack variables against the constraints
for constr, value in zip(
lp.constraints.values(),
model.getAttr(GRB.Attr.Pi, model.getConstrs()),
):
constr.pi = value

if status in [constants.LpStatusOptimal, constants.LpStatusNotSolved]:
torressa marked this conversation as resolved.
Show resolved Hide resolved
try:
# populate pulp solution values
for var, value in zip(
lp._variables, model.getAttr(GRB.Attr.X, model.getVars())
):
var.varValue = value

# populate pulp constraints slack
for constr, value in zip(
lp.constraints.values(),
model.getAttr(GRB.Attr.Slack, model.getConstrs()),
):
constr.slack = value

if not model.getAttr(GRB.Attr.IsMIP):
for var, value in zip(
lp._variables, model.getAttr(GRB.Attr.RC, model.getVars())
):
var.dj = value

# put pi and slack variables against the constraints
for constr, value in zip(
lp.constraints.values(),
model.getAttr(GRB.Attr.Pi, model.getConstrs()),
):
constr.pi = value
except gp.GurobiError as e:
raise e
return status

def available(self):
"""True if the solver is available"""
try:
gurobipy.setParam("_test", 0)
with gp.Env():
pass
except gurobipy.GurobiError as e:
warnings.warn("GUROBI error: {}.".format(e))
return False
return True

def initGurobi(self):
if self.init_gurobi:
return
else:
self.init_gurobi = True
try:
if self.manage_env:
self.env = gp.Env(params=self.env_options)
self.model = gp.Model(env=self.env)
# Environment handled by user or default Env
else:
self.model = gp.Model(env=self.env)
# Set solver parameters
for param, value in self.solver_params.items():
self.model.setParam(param, value)
# for param, value in self.env_options:
except gp.GurobiError as e:
raise e

def callSolver(self, lp, callback=None):
"""Solves the problem with gurobi"""
# solve the problem
Expand All @@ -183,7 +230,9 @@ def buildSolverModel(self, lp):
Takes the pulp lp model and translates it into a gurobi model
"""
log.debug("create the gurobi model")
lp.solverModel = gurobipy.Model(lp.name)
self.initGurobi()
self.model.ModelName = lp.name
lp.solverModel = self.model
log.debug("set the sense of the problem")
if lp.sense == constants.LpMaximize:
lp.solverModel.setAttr("ModelSense", -1)
Expand All @@ -200,14 +249,14 @@ def buildSolverModel(self, lp):
for var in lp.variables():
lowBound = var.lowBound
if lowBound is None:
lowBound = -gurobipy.GRB.INFINITY
lowBound = -gp.GRB.INFINITY
upBound = var.upBound
if upBound is None:
upBound = gurobipy.GRB.INFINITY
upBound = gp.GRB.INFINITY
obj = lp.objective.get(var, 0.0)
varType = gurobipy.GRB.CONTINUOUS
varType = gp.GRB.CONTINUOUS
if var.cat == constants.LpInteger and self.mip:
varType = gurobipy.GRB.INTEGER
varType = gp.GRB.INTEGER
var.solverVar = lp.solverModel.addVar(
lowBound, upBound, vtype=varType, obj=obj, name=var.name
)
Expand All @@ -222,20 +271,23 @@ def buildSolverModel(self, lp):
log.debug("add the Constraints to the problem")
for name, constraint in lp.constraints.items():
# build the expression
expr = gurobipy.LinExpr(
expr = gp.LinExpr(
list(constraint.values()), [v.solverVar for v in constraint.keys()]
)
if constraint.sense == constants.LpConstraintLE:
relation = gurobipy.GRB.LESS_EQUAL
constraint.solverConstraint = lp.solverModel.addConstr(
expr <= -constraint.constant, name=name
)
elif constraint.sense == constants.LpConstraintGE:
relation = gurobipy.GRB.GREATER_EQUAL
constraint.solverConstraint = lp.solverModel.addConstr(
expr >= -constraint.constant, name=name
)
elif constraint.sense == constants.LpConstraintEQ:
relation = gurobipy.GRB.EQUAL
constraint.solverConstraint = lp.solverModel.addConstr(
expr == -constraint.constant, name=name
)
else:
raise PulpSolverError("Detected an invalid constraint type")
constraint.solverConstraint = lp.solverModel.addConstr(
expr, relation, -constraint.constant, name
)
lp.solverModel.update()

def actualSolve(self, lp, callback=None):
Expand Down Expand Up @@ -267,7 +319,7 @@ def actualResolve(self, lp, callback=None):
for constraint in lp.constraints.values():
if constraint.modified:
constraint.solverConstraint.setAttr(
gurobipy.GRB.Attr.RHS, -constraint.constant
gp.GRB.Attr.RHS, -constraint.constant
)
lp.solverModel.update()
self.callSolver(lp, callback=callback)
Expand Down
108 changes: 108 additions & 0 deletions pulp/tests/test_gurobipy_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import unittest

import gurobipy as gp


from pulp import GUROBI, GUROBI_CMD, LpProblem, LpVariable, const
torressa marked this conversation as resolved.
Show resolved Hide resolved


def check_dummy_env():
with gp.Env():
pass


def generate_lp() -> LpProblem:
prob = LpProblem("test", const.LpMaximize)
x = LpVariable("x", 0, 1)
y = LpVariable("y", 0, 1)
z = LpVariable("z", 0, 1)
prob += x + y + z, "obj"
prob += x + y + z <= 1, "c1"
return prob


class GurobiEnvTests(unittest.TestCase):
def setUp(self):
self.options = {"OutputFlag": 1}
self.env_options = {"MemLimit": 1}

def testGpEnv(self):
# Using gp.Env within a context manager
with gp.Env(params=self.env_options) as env:
prob = generate_lp()
solver = GUROBI(msg=True, env=env, **self.options)
prob.solve(solver)
solver.close()
check_dummy_env()

def testMultipleGpEnv(self):
# Using the same env multiple times
with gp.Env() as env:
solver = GUROBI(msg=True, env=env)
prob = generate_lp()
prob.solve(solver)
solver.close()

solver2 = GUROBI(msg=True, env=env)
prob2 = generate_lp()
prob2.solve(solver2)
solver2.close()

check_dummy_env()

@unittest.SkipTest
def testBackwardCompatibility(self):
"""
Backward compatibility check as previously the environment was not being
freed. On a single-use license this passes (fails to initialise a dummy
env).
"""
solver = GUROBI(msg=True, **self.options)
prob = generate_lp()
prob.solve(solver)

self.assertRaises(gp.GurobiError, check_dummy_env)
gp.disposeDefaultEnv()
solver.close()

def testManageEnvTrue(self):
solver = GUROBI(msg=True, manageEnv=True, **self.options)
prob = generate_lp()
prob.solve(solver)

solver.close()
check_dummy_env()

def testMultipleSolves(self):
solver = GUROBI(msg=True, manageEnv=True, **self.options)
prob = generate_lp()
prob.solve(solver)

solver.close()
check_dummy_env()

solver2 = GUROBI(msg=True, manageEnv=True, **self.options)
prob.solve(solver2)

solver2.close()
check_dummy_env()

@unittest.SkipTest
def testLeak(self):
"""
Check that we cannot initialise environments after a memory leak. On a
single-use license this passes (fails to initialise a dummy env with a
memory leak).
"""
solver = GUROBI(msg=True, **self.options)
prob = generate_lp()
prob.solve(solver)

tmp = solver.model
solver.close()

solver2 = GUROBI(msg=True, **self.options)

prob2 = generate_lp()
prob2.solve(solver2)
self.assertRaises(gp.GurobiError, check_dummy_env)
Loading