diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index f95c052..e6e04e1 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -81,6 +81,191 @@ class GurobiSolver(InternalSolver): self.gp.GRB.Callback.MIPNODE, ] + @overrides + def add_constraint(self, constr: Constraint, name: str) -> None: + assert self.model is not None + lhs = self.gp.quicksum( + self._varname_to_var[varname] * coeff + for (varname, coeff) in constr.lhs.items() + ) + if constr.sense == "=": + self.model.addConstr(lhs == constr.rhs, name=name) + elif constr.sense == "<": + self.model.addConstr(lhs <= constr.rhs, name=name) + else: + self.model.addConstr(lhs >= constr.rhs, name=name) + self._dirty = True + self._has_lp_solution = False + self._has_mip_solution = False + + @overrides + def are_callbacks_supported(self) -> bool: + return True + + @overrides + def build_test_instance_infeasible(self) -> Instance: + return GurobiTestInstanceInfeasible() + + @overrides + def build_test_instance_knapsack(self) -> Instance: + return GurobiTestInstanceKnapsack( + weights=[23.0, 26.0, 20.0, 18.0], + prices=[505.0, 352.0, 458.0, 220.0], + capacity=67.0, + ) + + @overrides + def build_test_instance_redundancy(self) -> Instance: + return GurobiTestInstanceRedundancy() + + @overrides + def clone(self) -> "GurobiSolver": + return GurobiSolver( + params=self.params, + lazy_cb_frequency=self.lazy_cb_frequency, + ) + + @overrides + def fix(self, solution: Solution) -> None: + self._raise_if_callback() + for (varname, value) in solution.items(): + if value is None: + continue + var = self._varname_to_var[varname] + var.vtype = self.gp.GRB.CONTINUOUS + var.lb = value + var.ub = value + + @overrides + def get_dual(self, cid: str) -> float: + assert self.model is not None + c = self.model.getConstrByName(cid) + if self.is_infeasible(): + return c.farkasDual + else: + return c.pi + + @overrides + def get_constraint_attrs(self) -> List[str]: + return [ + "basis_status", + "category", + "dual_value", + "lazy", + "lhs", + "rhs", + "sa_rhs_down", + "sa_rhs_up", + "sense", + "slack", + "user_features", + ] + + @overrides + def get_constraints(self) -> Dict[str, Constraint]: + assert self.model is not None + self._raise_if_callback() + if self._dirty: + self.model.update() + self._dirty = False + constraints: Dict[str, Constraint] = {} + for c in self.model.getConstrs(): + constr = self._parse_gurobi_constraint(c) + assert c.constrName not in constraints + constraints[c.constrName] = constr + return constraints + + @overrides + def get_sense(self) -> str: + assert self.model is not None + if self.model.modelSense == 1: + return "min" + else: + return "max" + + @overrides + def get_solution(self) -> Optional[Solution]: + assert self.model is not None + if self.cb_where is not None: + if self.cb_where == self.gp.GRB.Callback.MIPNODE: + return { + v.varName: self.model.cbGetNodeRel(v) for v in self.model.getVars() + } + elif self.cb_where == self.gp.GRB.Callback.MIPSOL: + return { + v.varName: self.model.cbGetSolution(v) for v in self.model.getVars() + } + else: + raise Exception( + f"get_solution can only be called from a callback " + f"when cb_where is either MIPNODE or MIPSOL" + ) + if self.model.solCount == 0: + return None + return {v.varName: v.x for v in self.model.getVars()} + + @overrides + def get_variable_attrs(self) -> List[str]: + return [ + "basis_status", + "category", + "lower_bound", + "obj_coeff", + "reduced_cost", + "sa_lb_down", + "sa_lb_up", + "sa_obj_down", + "sa_obj_up", + "sa_ub_down", + "sa_ub_up", + "type", + "upper_bound", + "user_features", + "value", + ] + + @overrides + def get_variable_names(self) -> List[VariableName]: + self._raise_if_callback() + assert self.model is not None + return [v.varName for v in self.model.getVars()] + + @overrides + def get_variables(self) -> Dict[str, Variable]: + assert self.model is not None + variables = {} + for gp_var in self.model.getVars(): + name = gp_var.varName + assert len(name) > 0, f"empty variable name detected" + assert name not in variables, f"duplicated variable name detected: {name}" + var = self._parse_gurobi_var(gp_var) + variables[name] = var + return variables + + @overrides + def is_constraint_satisfied(self, constr: Constraint, tol: float = 1e-6) -> bool: + lhs = 0.0 + for (varname, coeff) in constr.lhs.items(): + var = self._varname_to_var[varname] + lhs += self._get_value(var) * coeff + if constr.sense == "<": + return lhs <= constr.rhs + tol + elif constr.sense == ">": + return lhs >= constr.rhs - tol + else: + return abs(constr.rhs - lhs) < abs(tol) + + @overrides + def is_infeasible(self) -> bool: + assert self.model is not None + return self.model.status in [self.gp.GRB.INFEASIBLE, self.gp.GRB.INF_OR_UNBD] + + @overrides + def remove_constraint(self, name: str) -> None: + assert self.model is not None + constr = self.model.getConstrByName(name) + self.model.remove(constr) + @overrides def set_instance( self, @@ -96,66 +281,14 @@ class GurobiSolver(InternalSolver): self.model.update() self._update_vars() - def _raise_if_callback(self) -> None: - if self.cb_where is not None: - raise Exception("method cannot be called from a callback") - - def _update_vars(self) -> None: - assert self.model is not None - self._varname_to_var.clear() - self._original_vtype = {} - self._bin_vars.clear() - for var in self.model.getVars(): - assert var.varName not in self._varname_to_var, ( - f"Duplicated variable name detected: {var.varName}. " - f"Unique variable names are currently required." - ) - self._varname_to_var[var.varName] = var - assert var.vtype in ["B", "C"], ( - "Only binary and continuous variables are currently supported. " - "Variable {var.varName} has type {var.vtype}." - ) - self._original_vtype[var] = var.vtype - if var.vtype == "B": - self._bin_vars.append(var) - - def _apply_params(self, streams: List[Any]) -> None: - assert self.model is not None - with _RedirectOutput(streams): - for (name, value) in self.params.items(): - self.model.setParam(name, value) - @overrides - def solve_lp( - self, - tee: bool = False, - ) -> LPSolveStats: + def set_warm_start(self, solution: Solution) -> None: self._raise_if_callback() - streams: List[Any] = [StringIO()] - if tee: - streams += [sys.stdout] - self._apply_params(streams) - assert self.model is not None - for var in self._bin_vars: - var.vtype = self.gp.GRB.CONTINUOUS - var.lb = 0.0 - var.ub = 1.0 - with _RedirectOutput(streams): - self.model.optimize() - self._dirty = False - for var in self._bin_vars: - var.vtype = self.gp.GRB.BINARY - log = streams[0].getvalue() - self._has_lp_solution = self.model.solCount > 0 - self._has_mip_solution = False - opt_value = None - if not self.is_infeasible(): - opt_value = self.model.objVal - return LPSolveStats( - lp_value=opt_value, - lp_log=log, - lp_wallclock_time=self.model.runtime, - ) + self._clear_warm_start() + for (var_name, value) in solution.items(): + var = self._varname_to_var[var_name] + if value is not None: + var.start = value @overrides def solve( @@ -235,126 +368,36 @@ class GurobiSolver(InternalSolver): ) @overrides - def get_solution(self) -> Optional[Solution]: - assert self.model is not None - if self.cb_where is not None: - if self.cb_where == self.gp.GRB.Callback.MIPNODE: - return { - v.varName: self.model.cbGetNodeRel(v) for v in self.model.getVars() - } - elif self.cb_where == self.gp.GRB.Callback.MIPSOL: - return { - v.varName: self.model.cbGetSolution(v) for v in self.model.getVars() - } - else: - raise Exception( - f"get_solution can only be called from a callback " - f"when cb_where is either MIPNODE or MIPSOL" - ) - if self.model.solCount == 0: - return None - return {v.varName: v.x for v in self.model.getVars()} - - @overrides - def get_variable_names(self) -> List[VariableName]: - self._raise_if_callback() - assert self.model is not None - return [v.varName for v in self.model.getVars()] - - @overrides - def set_warm_start(self, solution: Solution) -> None: + def solve_lp( + self, + tee: bool = False, + ) -> LPSolveStats: self._raise_if_callback() - self._clear_warm_start() - for (var_name, value) in solution.items(): - var = self._varname_to_var[var_name] - if value is not None: - var.start = value - - @overrides - def get_sense(self) -> str: - assert self.model is not None - if self.model.modelSense == 1: - return "min" - else: - return "max" - - @overrides - def is_infeasible(self) -> bool: - assert self.model is not None - return self.model.status in [self.gp.GRB.INFEASIBLE, self.gp.GRB.INF_OR_UNBD] - - @overrides - def get_dual(self, cid: str) -> float: - assert self.model is not None - c = self.model.getConstrByName(cid) - if self.is_infeasible(): - return c.farkasDual - else: - return c.pi - - def _get_value(self, var: Any) -> float: - assert self.model is not None - if self.cb_where == self.gp.GRB.Callback.MIPSOL: - return self.model.cbGetSolution(var) - elif self.cb_where == self.gp.GRB.Callback.MIPNODE: - return self.model.cbGetNodeRel(var) - elif self.cb_where is None: - return var.x - else: - raise Exception( - "get_value cannot be called from cb_where=%s" % self.cb_where - ) - - @overrides - def add_constraint(self, constr: Constraint, name: str) -> None: - assert self.model is not None - lhs = self.gp.quicksum( - self._varname_to_var[varname] * coeff - for (varname, coeff) in constr.lhs.items() - ) - if constr.sense == "=": - self.model.addConstr(lhs == constr.rhs, name=name) - elif constr.sense == "<": - self.model.addConstr(lhs <= constr.rhs, name=name) - else: - self.model.addConstr(lhs >= constr.rhs, name=name) - self._dirty = True - self._has_lp_solution = False - self._has_mip_solution = False - - @overrides - def remove_constraint(self, name: str) -> None: + streams: List[Any] = [StringIO()] + if tee: + streams += [sys.stdout] + self._apply_params(streams) assert self.model is not None - constr = self.model.getConstrByName(name) - self.model.remove(constr) - - @overrides - def is_constraint_satisfied(self, constr: Constraint, tol: float = 1e-6) -> bool: - lhs = 0.0 - for (varname, coeff) in constr.lhs.items(): - var = self._varname_to_var[varname] - lhs += self._get_value(var) * coeff - if constr.sense == "<": - return lhs <= constr.rhs + tol - elif constr.sense == ">": - return lhs >= constr.rhs - tol - else: - return abs(constr.rhs - lhs) < abs(tol) - - def _clear_warm_start(self) -> None: - for var in self._varname_to_var.values(): - var.start = self.gp.GRB.UNDEFINED - - @overrides - def fix(self, solution: Solution) -> None: - self._raise_if_callback() - for (varname, value) in solution.items(): - if value is None: - continue - var = self._varname_to_var[varname] + for var in self._bin_vars: var.vtype = self.gp.GRB.CONTINUOUS - var.lb = value - var.ub = value + var.lb = 0.0 + var.ub = 1.0 + with _RedirectOutput(streams): + self.model.optimize() + self._dirty = False + for var in self._bin_vars: + var.vtype = self.gp.GRB.BINARY + log = streams[0].getvalue() + self._has_lp_solution = self.model.solCount > 0 + self._has_mip_solution = False + opt_value = None + if not self.is_infeasible(): + opt_value = self.model.objVal + return LPSolveStats( + lp_value=opt_value, + lp_log=log, + lp_wallclock_time=self.model.runtime, + ) @overrides def relax(self) -> None: @@ -363,14 +406,18 @@ class GurobiSolver(InternalSolver): self.model = self.model.relax() self._update_vars() - def _extract_warm_start_value(self, log: str) -> Optional[float]: - ws = self.__extract(log, "MIP start with objective ([0-9.e+-]*)") - if ws is None: - return None - return float(ws) + def _apply_params(self, streams: List[Any]) -> None: + assert self.model is not None + with _RedirectOutput(streams): + for (name, value) in self.params.items(): + self.model.setParam(name, value) + + def _clear_warm_start(self) -> None: + for var in self._varname_to_var.values(): + var.start = self.gp.GRB.UNDEFINED @staticmethod - def __extract( + def _extract( log: str, regexp: str, default: Optional[str] = None, @@ -383,53 +430,24 @@ class GurobiSolver(InternalSolver): value = matches[0] return value - def __getstate__(self) -> Dict: - return { - "params": self.params, - "lazy_cb_where": self.lazy_cb_where, - } - - def __setstate__(self, state: Dict) -> None: - self.params = state["params"] - self.lazy_cb_where = state["lazy_cb_where"] - self.instance = None - self.model = None - self.cb_where = None - - @overrides - def clone(self) -> "GurobiSolver": - return GurobiSolver( - params=self.params, - lazy_cb_frequency=self.lazy_cb_frequency, - ) - - @overrides - def build_test_instance_infeasible(self) -> Instance: - return GurobiTestInstanceInfeasible() - - @overrides - def build_test_instance_redundancy(self) -> Instance: - return GurobiTestInstanceRedundancy() - - @overrides - def build_test_instance_knapsack(self) -> Instance: - return GurobiTestInstanceKnapsack( - weights=[23.0, 26.0, 20.0, 18.0], - prices=[505.0, 352.0, 458.0, 220.0], - capacity=67.0, - ) + def _extract_warm_start_value(self, log: str) -> Optional[float]: + ws = self._extract(log, "MIP start with objective ([0-9.e+-]*)") + if ws is None: + return None + return float(ws) - @overrides - def get_variables(self) -> Dict[str, Variable]: + def _get_value(self, var: Any) -> float: assert self.model is not None - variables = {} - for gp_var in self.model.getVars(): - name = gp_var.varName - assert len(name) > 0, f"empty variable name detected" - assert name not in variables, f"duplicated variable name detected: {name}" - var = self._parse_gurobi_var(gp_var) - variables[name] = var - return variables + if self.cb_where == self.gp.GRB.Callback.MIPSOL: + return self.model.cbGetSolution(var) + elif self.cb_where == self.gp.GRB.Callback.MIPNODE: + return self.model.cbGetNodeRel(var) + elif self.cb_where is None: + return var.x + else: + raise Exception( + "get_value cannot be called from cb_where=%s" % self.cb_where + ) def _parse_gurobi_var(self, gp_var: Any) -> Variable: assert self.model is not None @@ -462,20 +480,6 @@ class GurobiSolver(InternalSolver): var.value = gp_var.x return var - @overrides - def get_constraints(self) -> Dict[str, Constraint]: - assert self.model is not None - self._raise_if_callback() - if self._dirty: - self.model.update() - self._dirty = False - constraints: Dict[str, Constraint] = {} - for c in self.model.getConstrs(): - constr = self._parse_gurobi_constraint(c) - assert c.constrName not in constraints - constraints[c.constrName] = constr - return constraints - def _parse_gurobi_constraint(self, gp_constr: Any) -> Constraint: assert self.model is not None expr = self.model.getRow(gp_constr) @@ -501,45 +505,41 @@ class GurobiSolver(InternalSolver): constr.slack = gp_constr.slack return constr - @overrides - def are_callbacks_supported(self) -> bool: - return True + def _raise_if_callback(self) -> None: + if self.cb_where is not None: + raise Exception("method cannot be called from a callback") - @overrides - def get_constraint_attrs(self) -> List[str]: - return [ - "basis_status", - "category", - "dual_value", - "lazy", - "lhs", - "rhs", - "sa_rhs_down", - "sa_rhs_up", - "sense", - "slack", - "user_features", - ] + def _update_vars(self) -> None: + assert self.model is not None + self._varname_to_var.clear() + self._original_vtype = {} + self._bin_vars.clear() + for var in self.model.getVars(): + assert var.varName not in self._varname_to_var, ( + f"Duplicated variable name detected: {var.varName}. " + f"Unique variable names are currently required." + ) + self._varname_to_var[var.varName] = var + assert var.vtype in ["B", "C"], ( + "Only binary and continuous variables are currently supported. " + "Variable {var.varName} has type {var.vtype}." + ) + self._original_vtype[var] = var.vtype + if var.vtype == "B": + self._bin_vars.append(var) - @overrides - def get_variable_attrs(self) -> List[str]: - return [ - "basis_status", - "category", - "lower_bound", - "obj_coeff", - "reduced_cost", - "sa_lb_down", - "sa_lb_up", - "sa_obj_down", - "sa_obj_up", - "sa_ub_down", - "sa_ub_up", - "type", - "upper_bound", - "user_features", - "value", - ] + def __getstate__(self) -> Dict: + return { + "params": self.params, + "lazy_cb_where": self.lazy_cb_where, + } + + def __setstate__(self, state: Dict) -> None: + self.params = state["params"] + self.lazy_cb_where = state["lazy_cb_where"] + self.instance = None + self.model = None + self.cb_where = None class GurobiTestInstanceInfeasible(Instance): diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py index 06e5a2d..86d0909 100644 --- a/miplearn/solvers/pyomo/base.py +++ b/miplearn/solvers/pyomo/base.py @@ -70,82 +70,95 @@ class BasePyomoSolver(InternalSolver): self._pyomo_solver.options[key] = value @overrides - def solve_lp( + def add_constraint( self, - tee: bool = False, - ) -> LPSolveStats: - self.relax() - streams: List[Any] = [StringIO()] - if tee: - streams += [sys.stdout] - with _RedirectOutput(streams): - results = self._pyomo_solver.solve(tee=True) - self._termination_condition = results["Solver"][0]["Termination condition"] - self._restore_integrality() - opt_value = None + constr: Any, + name: str, + ) -> None: + assert self.model is not None + if isinstance(constr, Constraint): + lhs = 0.0 + for (varname, coeff) in constr.lhs.items(): + var = self._varname_to_var[varname] + lhs += var * coeff + if constr.sense == "=": + expr = lhs == constr.rhs + elif constr.sense == "<": + expr = lhs <= constr.rhs + else: + expr = lhs >= constr.rhs + cl = pe.Constraint(expr=expr, name=name) + self.model.add_component(name, cl) + self._pyomo_solver.add_constraint(cl) + self._cname_to_constr[name] = cl + else: + self._pyomo_solver.add_constraint(constr) + self._termination_condition = "" self._has_lp_solution = False self._has_mip_solution = False - if not self.is_infeasible(): - opt_value = results["Problem"][0]["Lower bound"] - self._has_lp_solution = True - return LPSolveStats( - lp_value=opt_value, - lp_log=streams[0].getvalue(), - lp_wallclock_time=results["Solver"][0]["Wallclock time"], + + @overrides + def are_callbacks_supported(self) -> bool: + return False + + @overrides + def build_test_instance_infeasible(self) -> Instance: + return PyomoTestInstanceInfeasible() + + @overrides + def build_test_instance_redundancy(self) -> Instance: + return PyomoTestInstanceRedundancy() + + @overrides + def build_test_instance_knapsack(self) -> Instance: + return PyomoTestInstanceKnapsack( + weights=[23.0, 26.0, 20.0, 18.0], + prices=[505.0, 352.0, 458.0, 220.0], + capacity=67.0, ) - def _restore_integrality(self) -> None: - for var in self._bin_vars: - var.domain = pyomo.core.base.set_types.Binary + @overrides + def fix(self, solution: Solution) -> None: + for (varname, value) in solution.items(): + if value is None: + continue + var = self._varname_to_var[varname] + var.fix(value) self._pyomo_solver.update_var(var) @overrides - def solve( - self, - tee: bool = False, - iteration_cb: Optional[IterationCallback] = None, - lazy_cb: Optional[LazyCallback] = None, - user_cut_cb: Optional[UserCutCallback] = None, - ) -> MIPSolveStats: - assert lazy_cb is None, "callbacks are not currently supported" - assert user_cut_cb is None, "callbacks are not currently supported" - total_wallclock_time = 0 - streams: List[Any] = [StringIO()] - if tee: - streams += [sys.stdout] - if iteration_cb is None: - iteration_cb = lambda: False - while True: - logger.debug("Solving MIP...") - with _RedirectOutput(streams): - results = self._pyomo_solver.solve( - tee=True, - warmstart=self._is_warm_start_available, - ) - total_wallclock_time += results["Solver"][0]["Wallclock time"] - should_repeat = iteration_cb() - if not should_repeat: - break - log = streams[0].getvalue() - node_count = self._extract_node_count(log) - ws_value = self._extract_warm_start_value(log) - self._termination_condition = results["Solver"][0]["Termination condition"] - lb, ub = None, None - self._has_mip_solution = False - self._has_lp_solution = False - if not self.is_infeasible(): - self._has_mip_solution = True - lb = results["Problem"][0]["Lower bound"] - ub = results["Problem"][0]["Upper bound"] - return MIPSolveStats( - mip_lower_bound=lb, - mip_upper_bound=ub, - mip_wallclock_time=total_wallclock_time, - mip_sense=self._obj_sense, - mip_log=log, - mip_nodes=node_count, - mip_warm_start_value=ws_value, - ) + def get_constraints(self) -> Dict[str, Constraint]: + assert self.model is not None + + constraints = {} + for constr in self.model.component_objects(pyomo.core.Constraint): + if isinstance(constr, pe.ConstraintList): + for idx in constr: + name = f"{constr.name}[{idx}]" + assert name not in constraints + constraints[name] = self._parse_pyomo_constraint(constr[idx]) + else: + name = constr.name + assert name not in constraints + constraints[name] = self._parse_pyomo_constraint(constr) + + return constraints + + @overrides + def get_constraint_attrs(self) -> List[str]: + return [ + "dual_value", + "lazy", + "lhs", + "rhs", + "sense", + "slack", + ] + + @overrides + def get_dual(self, cid: str) -> float: + constr = self._cname_to_constr[cid] + return self._pyomo_solver.dual[constr] @overrides def get_solution(self) -> Optional[Solution]: @@ -172,17 +185,63 @@ class BasePyomoSolver(InternalSolver): return variables @overrides - def set_warm_start(self, solution: Solution) -> None: - self._clear_warm_start() - count_fixed = 0 - for (var_name, value) in solution.items(): - if value is None: - continue - var = self._varname_to_var[var_name] - var.value = solution[var_name] - count_fixed += 1 - if count_fixed > 0: - self._is_warm_start_available = True + def get_sense(self) -> str: + return self._obj_sense + + @overrides + def get_variables(self) -> Dict[str, Variable]: + assert self.model is not None + variables = {} + for var in self.model.component_objects(pyomo.core.Var): + for idx in var: + varname = f"{var}[{idx}]" + if idx is None: + varname = str(var) + variables[varname] = self._parse_pyomo_variable(var[idx]) + return variables + + @overrides + def get_variable_attrs(self) -> List[str]: + return [ + # "basis_status", + "lower_bound", + "obj_coeff", + "reduced_cost", + # "sa_lb_down", + # "sa_lb_up", + # "sa_obj_down", + # "sa_obj_up", + # "sa_ub_down", + # "sa_ub_up", + "type", + "upper_bound", + "value", + ] + + @overrides + def is_constraint_satisfied(self, constr: Constraint, tol: float = 1e-6) -> bool: + lhs = 0.0 + for (varname, coeff) in constr.lhs.items(): + var = self._varname_to_var[varname] + lhs += var.value * coeff + if constr.sense == "<": + return lhs <= constr.rhs + tol + elif constr.sense == ">": + return lhs >= constr.rhs - tol + else: + return abs(constr.rhs - lhs) < abs(tol) + + @overrides + def is_infeasible(self) -> bool: + return self._termination_condition == TerminationCondition.infeasible + + @overrides + def remove_constraint(self, name: str) -> None: + assert self.model is not None + constr = self._cname_to_constr[name] + del self._cname_to_constr[name] + self.model.del_component(constr) + self._pyomo_solver.remove_constraint(constr) @overrides def set_instance( @@ -204,102 +263,109 @@ class BasePyomoSolver(InternalSolver): self._update_vars() self._update_constrs() - def _clear_warm_start(self) -> None: - for var in self._all_vars: - if not var.fixed: - var.value = None - self._is_warm_start_available = False - - def _update_obj(self) -> None: - self._obj_sense = "max" - if self._pyomo_solver._objective.sense == pyomo.core.kernel.objective.minimize: - self._obj_sense = "min" - - def _update_vars(self) -> None: - assert self.model is not None - self._all_vars = [] - self._bin_vars = [] - self._varname_to_var = {} - for var in self.model.component_objects(Var): - for idx in var: - self._varname_to_var[f"{var.name}[{idx}]"] = var[idx] - self._all_vars += [var[idx]] - if var[idx].domain == pyomo.core.base.set_types.Binary: - self._bin_vars += [var[idx]] - for obj in self.model.component_objects(Objective): - self._obj = self._parse_pyomo_expr(obj.expr) - break - - def _update_constrs(self) -> None: - assert self.model is not None - self._cname_to_constr.clear() - for constr in self.model.component_objects(pyomo.core.Constraint): - if isinstance(constr, pe.ConstraintList): - for idx in constr: - self._cname_to_constr[f"{constr.name}[{idx}]"] = constr[idx] - else: - self._cname_to_constr[constr.name] = constr - @overrides - def fix(self, solution: Solution) -> None: - for (varname, value) in solution.items(): + def set_warm_start(self, solution: Solution) -> None: + self._clear_warm_start() + count_fixed = 0 + for (var_name, value) in solution.items(): if value is None: continue - var = self._varname_to_var[varname] - var.fix(value) - self._pyomo_solver.update_var(var) + var = self._varname_to_var[var_name] + var.value = solution[var_name] + count_fixed += 1 + if count_fixed > 0: + self._is_warm_start_available = True + + @overrides + def solve( + self, + tee: bool = False, + iteration_cb: Optional[IterationCallback] = None, + lazy_cb: Optional[LazyCallback] = None, + user_cut_cb: Optional[UserCutCallback] = None, + ) -> MIPSolveStats: + assert lazy_cb is None, "callbacks are not currently supported" + assert user_cut_cb is None, "callbacks are not currently supported" + total_wallclock_time = 0 + streams: List[Any] = [StringIO()] + if tee: + streams += [sys.stdout] + if iteration_cb is None: + iteration_cb = lambda: False + while True: + logger.debug("Solving MIP...") + with _RedirectOutput(streams): + results = self._pyomo_solver.solve( + tee=True, + warmstart=self._is_warm_start_available, + ) + total_wallclock_time += results["Solver"][0]["Wallclock time"] + should_repeat = iteration_cb() + if not should_repeat: + break + log = streams[0].getvalue() + node_count = self._extract_node_count(log) + ws_value = self._extract_warm_start_value(log) + self._termination_condition = results["Solver"][0]["Termination condition"] + lb, ub = None, None + self._has_mip_solution = False + self._has_lp_solution = False + if not self.is_infeasible(): + self._has_mip_solution = True + lb = results["Problem"][0]["Lower bound"] + ub = results["Problem"][0]["Upper bound"] + return MIPSolveStats( + mip_lower_bound=lb, + mip_upper_bound=ub, + mip_wallclock_time=total_wallclock_time, + mip_sense=self._obj_sense, + mip_log=log, + mip_nodes=node_count, + mip_warm_start_value=ws_value, + ) @overrides - def add_constraint( + def solve_lp( self, - constr: Any, - name: str, - ) -> None: - assert self.model is not None - if isinstance(constr, Constraint): - lhs = 0.0 - for (varname, coeff) in constr.lhs.items(): - var = self._varname_to_var[varname] - lhs += var * coeff - if constr.sense == "=": - expr = lhs == constr.rhs - elif constr.sense == "<": - expr = lhs <= constr.rhs - else: - expr = lhs >= constr.rhs - cl = pe.Constraint(expr=expr, name=name) - self.model.add_component(name, cl) - self._pyomo_solver.add_constraint(cl) - self._cname_to_constr[name] = cl - else: - self._pyomo_solver.add_constraint(constr) - self._termination_condition = "" + tee: bool = False, + ) -> LPSolveStats: + self.relax() + streams: List[Any] = [StringIO()] + if tee: + streams += [sys.stdout] + with _RedirectOutput(streams): + results = self._pyomo_solver.solve(tee=True) + self._termination_condition = results["Solver"][0]["Termination condition"] + self._restore_integrality() + opt_value = None self._has_lp_solution = False self._has_mip_solution = False + if not self.is_infeasible(): + opt_value = results["Problem"][0]["Lower bound"] + self._has_lp_solution = True + return LPSolveStats( + lp_value=opt_value, + lp_log=streams[0].getvalue(), + lp_wallclock_time=results["Solver"][0]["Wallclock time"], + ) @overrides - def remove_constraint(self, name: str) -> None: - assert self.model is not None - constr = self._cname_to_constr[name] - del self._cname_to_constr[name] - self.model.del_component(constr) - self._pyomo_solver.remove_constraint(constr) + def relax(self) -> None: + for var in self._bin_vars: + lb, ub = var.bounds + var.setlb(lb) + var.setub(ub) + var.domain = pyomo.core.base.set_types.Reals + self._pyomo_solver.update_var(var) - @overrides - def is_constraint_satisfied(self, constr: Constraint, tol: float = 1e-6) -> bool: - lhs = 0.0 - for (varname, coeff) in constr.lhs.items(): - var = self._varname_to_var[varname] - lhs += var.value * coeff - if constr.sense == "<": - return lhs <= constr.rhs + tol - elif constr.sense == ">": - return lhs >= constr.rhs - tol - else: - return abs(constr.rhs - lhs) < abs(tol) + def _clear_warm_start(self) -> None: + for var in self._all_vars: + if not var.fixed: + var.value = None + self._is_warm_start_available = False @staticmethod - def __extract( + def _extract( log: str, regexp: Optional[str], default: Optional[str] = None, @@ -314,73 +380,23 @@ class BasePyomoSolver(InternalSolver): value = matches[0] return value - def _extract_warm_start_value(self, log: str) -> Optional[float]: - value = self.__extract(log, self._get_warm_start_regexp()) - if value is None: - return None - return float(value) - def _extract_node_count(self, log: str) -> Optional[int]: - value = self.__extract(log, self._get_node_count_regexp()) + value = self._extract(log, self._get_node_count_regexp()) if value is None: return None return int(value) - def _get_warm_start_regexp(self) -> Optional[str]: - return None + def _extract_warm_start_value(self, log: str) -> Optional[float]: + value = self._extract(log, self._get_warm_start_regexp()) + if value is None: + return None + return float(value) def _get_node_count_regexp(self) -> Optional[str]: return None - @overrides - def relax(self) -> None: - for var in self._bin_vars: - lb, ub = var.bounds - var.setlb(lb) - var.setub(ub) - var.domain = pyomo.core.base.set_types.Reals - self._pyomo_solver.update_var(var) - - @overrides - def is_infeasible(self) -> bool: - return self._termination_condition == TerminationCondition.infeasible - - @overrides - def get_dual(self, cid: str) -> float: - constr = self._cname_to_constr[cid] - return self._pyomo_solver.dual[constr] - - @overrides - def get_sense(self) -> str: - return self._obj_sense - - @overrides - def build_test_instance_infeasible(self) -> Instance: - return PyomoTestInstanceInfeasible() - - @overrides - def build_test_instance_redundancy(self) -> Instance: - return PyomoTestInstanceRedundancy() - - @overrides - def build_test_instance_knapsack(self) -> Instance: - return PyomoTestInstanceKnapsack( - weights=[23.0, 26.0, 20.0, 18.0], - prices=[505.0, 352.0, 458.0, 220.0], - capacity=67.0, - ) - - @overrides - def get_variables(self) -> Dict[str, Variable]: - assert self.model is not None - variables = {} - for var in self.model.component_objects(pyomo.core.Var): - for idx in var: - varname = f"{var}[{idx}]" - if idx is None: - varname = str(var) - variables[varname] = self._parse_pyomo_variable(var[idx]) - return variables + def _get_warm_start_regexp(self) -> Optional[str]: + return None def _parse_pyomo_variable(self, var: pyomo.core.Var) -> Variable: # Variable type @@ -420,24 +436,6 @@ class BasePyomoSolver(InternalSolver): reduced_cost=rc, ) - @overrides - def get_constraints(self) -> Dict[str, Constraint]: - assert self.model is not None - - constraints = {} - for constr in self.model.component_objects(pyomo.core.Constraint): - if isinstance(constr, pe.ConstraintList): - for idx in constr: - name = f"{constr.name}[{idx}]" - assert name not in constraints - constraints[name] = self._parse_pyomo_constraint(constr[idx]) - else: - name = constr.name - assert name not in constraints - constraints[name] = self._parse_pyomo_constraint(constr) - - return constraints - def _parse_pyomo_constraint( self, pyomo_constr: pyomo.core.Constraint, @@ -490,38 +488,40 @@ class BasePyomoSolver(InternalSolver): raise Exception(f"Unknown expression type: {expr.__class__.__name__}") return lhs - @overrides - def are_callbacks_supported(self) -> bool: - return False + def _restore_integrality(self) -> None: + for var in self._bin_vars: + var.domain = pyomo.core.base.set_types.Binary + self._pyomo_solver.update_var(var) - @overrides - def get_constraint_attrs(self) -> List[str]: - return [ - "dual_value", - "lazy", - "lhs", - "rhs", - "sense", - "slack", - ] + def _update_obj(self) -> None: + self._obj_sense = "max" + if self._pyomo_solver._objective.sense == pyomo.core.kernel.objective.minimize: + self._obj_sense = "min" - @overrides - def get_variable_attrs(self) -> List[str]: - return [ - # "basis_status", - "lower_bound", - "obj_coeff", - "reduced_cost", - # "sa_lb_down", - # "sa_lb_up", - # "sa_obj_down", - # "sa_obj_up", - # "sa_ub_down", - # "sa_ub_up", - "type", - "upper_bound", - "value", - ] + def _update_vars(self) -> None: + assert self.model is not None + self._all_vars = [] + self._bin_vars = [] + self._varname_to_var = {} + for var in self.model.component_objects(Var): + for idx in var: + self._varname_to_var[f"{var.name}[{idx}]"] = var[idx] + self._all_vars += [var[idx]] + if var[idx].domain == pyomo.core.base.set_types.Binary: + self._bin_vars += [var[idx]] + for obj in self.model.component_objects(Objective): + self._obj = self._parse_pyomo_expr(obj.expr) + break + + def _update_constrs(self) -> None: + assert self.model is not None + self._cname_to_constr.clear() + for constr in self.model.component_objects(pyomo.core.Constraint): + if isinstance(constr, pe.ConstraintList): + for idx in constr: + self._cname_to_constr[f"{constr.name}[{idx}]"] = constr[idx] + else: + self._cname_to_constr[constr.name] = constr class PyomoTestInstanceInfeasible(Instance):